# Frontend Three-tab SPA at `inspector/frontend/src/`. TypeScript (strict) + Vite + Svelte 5 runes. Built to `frontend/dist/`, served by Flask (`FRONTEND_DIST` in `app.py`). ## Stack | Concern | Choice | |---|---| | Language | TypeScript strict (`strict`, `noUncheckedIndexedAccess`, `noImplicitAny`, `strictNullChecks`) | | Bundler | Vite 5, `target: es2022` | | UI | Svelte 5 (`@sveltejs/vite-plugin-svelte` ^4, mounted via `mount()` in `main.ts`) | | Charts | Chart.js 4 + chartjs-plugin-annotation; split into a `charts` chunk via `rollupOptions.output.manualChunks` | | CSS | Plain CSS in `src/styles/`, imported from `main.ts` (`tokens.css` first — defines the dark `:root` vars; `theme-light.css` last — the `[data-theme="light"]` override). Token-driven light/dark; canvas colours bridge via `lib/utils/canvas-theme.ts`. → [`theming.md`](theming.md) | | Audio | Web Audio API; per-tab `AudioPort` transport (`lib/playback/`) | | Unit tests | Vitest 3 + `@testing-library/svelte` 5 + `happy-dom` | | E2E | Playwright (`npm run test:e2e`) | | Typecheck/lint | `tsc --noEmit`, `svelte-check` (`npm run check`), eslint flat config | `vite.config.ts` proxies `/api` and `/audio` to Flask `:5000` during dev (`:5173`). Two-stack mode via `INSPECTOR_VITE_PORT` / `INSPECTOR_BACKEND_PORT`. Sourcemaps dev-only. ## Svelte version convention New code is **Svelte 5 runes**: `$state` / `$derived` / `$effect` / `$props` / `$bindable`, callback props. Legacy Svelte 4 syntax (`export let`, `$:`, `createEventDispatcher`, `on:` directives, ``) still compiles via the compat shim and is migrated opportunistically (plan: `docs/planning/svelte-migration.md`). A file is fully one mode or the other — never mixed. **Currently still legacy** (notable): `App.svelte`, `AccordionGuideModal.svelte`, and the imperative canvas/audio components. **Deliberately exempt (stay legacy indefinitely):** `TimestampsWaveform.svelte` and the canvas/audio-imperative components (`WaveformCanvas.svelte`, `SegmentWaveformCanvas.svelte`, and the per-frame overlay drivers). ## Layered diagram ``` main.ts (mount App, import global styles, installAudioWarmup) └── App.svelte (header/tab-bar, auth controls, lazy tab mount, global popover/modal/toast/bookmarks) ├── tabs/dashboard/DashboardTab.svelte → views/{CatalogList,ReciterDetail} + BottomPlayer ├── tabs/timestamps/TimestampsTab.svelte → {components,stores,utils,services}/ └── tabs/segments/SegmentsTab.svelte → {components,stores,utils,domain,types,guides}/ ``` Tabs are lazy-mounted (`{#if mountedTabs.has(tab)}`); once visited they stay in the DOM `hidden`, preserving state across switches. `AudioPort`s are paused on tab-leave by `applyTabSideEffects` — `segPort` (Segments) and the shared `dashPort` (Dashboard **and** Timestamps, via `BottomPlayer`); `tsPort` is defined but vestigial. `lib/` is strictly cross-tab; `lib/` never imports `tabs/`, and no tab imports another tab's dir. Audio playback internals (AudioPort, kill-switch, peaks) live in the **`inspector-audio` skill**, not here. The **Dashboard tab** is the entry view: public reciter browse/search/filter/play for all visitors, plus admin (maintainer/owner) controls — activity rail, claim/state actions, request review. It is the default landing tab (legacy `insp_active_tab='audio'` redirects here; the old Audio tab is removed — `App.svelte::cleanupLegacyAudioKeys` sweeps `insp_aud_*` localStorage keys). ## `lib/` — cross-tab only ### `lib/actions/` | File | Role | |---|---| | `editGate.ts` | Svelte action gating any edit-trigger click on `editingMode`; passes through for `editor`/`maintainer`/`owner` (or admin-only with `{require:'admin'}`), else swallows click + surfaces `EditAffordancePopover` or sign-in modal. Capture-phase. | | `click-outside.ts` | Action: callback on pointerdown outside the node (popover/dropup dismissal). | ### `lib/api/` | File | Role | |---|---| | `index.ts` | Single fetch boundary — `fetchJson` / `fetchJsonOrNull` / `fetchArrayBuffer` typed helpers | | `auth-client.ts` | `signIn` (→ `/api/auth/login`) / `signOut` (→ `/api/auth/logout` + reset `currentUser`) | | `claims-client.ts` | Claim POSTs; maps 200/401/403/409 → row resolve / sign-in modal / toast | | `dev-role.ts` | `POST /api/dev/role` (dev-mode only; 404s on Space) | | `reciter-task.ts` | `readable` store polling `/api/reciter-task/` @30 s while subscribed | | `public-reciters.ts` | Read-only `/api/public/*` reciter list fetchers (no auth, `max-age=30`) | | `public-reciter-detail.ts` | Single-reciter detail; `null` on 404 | | `public-activity.ts` | Public activity feed (`/api/public/activity`, six-event allowlist) | | `public-activity-admin.ts` | Owner-only `DELETE /api/public/activity/` — global tombstone for a public-feed card. (The admin notifications rail was retired; admin awareness lives in the Admin dashboard tabs now.) | | `requests.ts` | Request submit (contributor+) / admin review-reject / undiscard (owner) | ### `lib/playback/` | File | Role | |---|---| | `audio-port.ts` | `AudioPort` — single transport chokepoint; owns `