hetchyy's picture
deploy: inspector prod @ b17d0ba
e52f662 verified
|
Raw
History Blame Contribute Delete
46.4 kB

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
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, <slot>) 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. AudioPorts are paused on tab-leave by applyTabSideEffectssegPort (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/<slug> @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/<audit_id> — 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 <audio>, CBR-chapter-vs-VBR-clip src swap, file-absolute ms via offsetMs
audio-graph.ts Web Audio kill-switch — sample-accurate gain ramp (cutAudio/uncutAudio) to flush OS-queued tail
audio-range.ts AudioRange — one-rAF-loop [startMs,endMs] playback with pluggable boundary policy (stop/loop/advance)
dash-port.ts Dashboard-tab AudioPort instance (dashPort)
shadow-audio.ts Hidden <audio> for cross-chapter prewarm (validation accordion) without disturbing segPort
constants.ts Playback constants (coverage padding, etc.) shared across port/range consumers

lib/components/

Path Role
WaveformCanvas.svelte Base canvas peaks renderer; exposes canvas for imperative overlays (legacy/exempt)
AudioElement.svelte Thin <audio> wrapper (safePlay, events)
AudioPlayer.svelte Full audio player UI
SearchableSelect.svelte Dropdown with fuzzy search + grouped options
SpeedControl.svelte Playback speed selector
Modal.svelte Generic modal shell — focus trap, Esc/backdrop close, body scroll lock. Its backdrop portals to document.body (lib/actions/portal.ts) so a deeply-mounted modal escapes ancestor stacking contexts (e.g. the sticky dashboard rail) and paints above fixed app chrome like BottomPlayer.
SignInModal.svelte / ToastHost.svelte Root-mounted sign-in modal + toast host
EditAffordancePopover.svelte Single global popover surfaced by editGate
DevRoleSwitcher.svelte Dev-mode role switcher (gated on $currentUser.dev_mode)
BookmarksPanel.svelte Quran.Foundation bookmarks sidebar
ClaimButton.svelte, StatePill.svelte, CoveragePill.svelte, FilterPill.svelte, ReciterChip.svelte, ReciterRow.svelte, SearchInput.svelte, ExternalLinks.svelte Catalog/dashboard chips, pills, rows
picker/ CombinationPicker + PickerFilterRail / PickerFooter / PickerStateTabs / PickerReviewStatus. For callers holding reviews.view, the picker lazily fetches /api/admin/reviews/list on open and decorates under_review rows with the claimer + a marked-ready badge (PickerReviewStatus) — same data as the Admin → Reviews tab. Non-capable callers fire no admin fetch.
player/ BottomPlayer, PlayerControls, PlayerProgress, PlayerMetaChip, SpeedPopover, SurahPopover

Shared player rule: deliberate navigation resumes playback even when the audio was paused. This includes ayah/surah steps, surah or delivery changes, progress/filmstrip/line seeks, Timestamps keyboard navigation, validation jumps, and manual shuffle/random-reciter triggers. Passive inspection (popovers, hover previews, zoom/pan, speed/download/bookmark/display toggles) must stay paused.

Recitation-animation (lib/recitation-animation/, mounted by NowReciting above the player in both Dashboard + Timestamps tabs): the AyahFilmstrip ayah scrubber and the LineAnimation teleprompter are recitation-driven, not clock-driven. Both map playback time → the recited word via the shared findActiveAt (recitation-active.ts): a flat per-occurrence interval timeline that returns null during silence gaps and travels backward on loopbacks (the raw, non-deduped occurrences the shard retains "for the filmstrip" — see timestamps-job.md §1a). The filmstrip's cursor crosses each verse cell by recited time over the verse's CURRENT take (takeFrac in AyahFilmstrip.svelte): the cell is traversed at one constant velocity (aw ÷ that take's recited seconds), so a re-recited verse conforms to its OWN duration and the speed never lurches word-by-word within a cell. The take is the maximal run of consecutive timeline intervals that stay in the cell and advance in reading order (a loopback or another verse breaks it; a within-verse pause does not — its silence isn't summed); for a forward first take this is identical to the first-occurrence word-fraction crossing. A within-verse word loopback rewinds the cursor and a cross-verse loopback scrolls the strip back (same glide in both snap/hybrid modes). The bottom PlayerProgress scrubber stays time-linear — only the cell bar is recitation-adaptive.

Strip scroll motion is unified in one controller — createStripScroller (filmstrip-scroll.svelte.ts) — shared by every motion model (snap/hybrid/tuner) and every play/pause path, with one curve and one set of constants, owning its own rAF so a glide animates whether or not audio is playing. Continuous progression — forward playback, live drag/scrub, and the between-verse gap-scroll below — tracks the live position instantly (snap/follow), so it moves at the ruler velocity (filmstripPxPerSec). Every discontinuity (within-verse rewind, multi-verse loopback, click, key, snap-center, drag-release, paused refresh) instead runs one distance-proportional Hermite glide (glide, or the follow catch-up), independent of the ruler velocity — a jump is smoothed, not replayed in time. A catch-up chasing a moving target re-plans each frame carrying current velocity, so it never lurches and never inherits a stale cutoff. A natural loopback (the reciter re-reciting — a backward recitation jump with no audio-time seek) runs no separate motion path: each verse cell is simply crossed over its OWN take's recited seconds (the takeFrac rule above), so a multi-verse re-take replays every verse at that verse's own second-take pace — each conforming to its own duration, never collapsed onto the loopback verse's. The backward landing is one Hermite glide (the isJump discontinuity), and the loopback's pause-back is scrolled by scrollThroughGap (below), so by the replay's first word the strip is already at the landing. AyahFilmstrip keeps only the recitation-discontinuity detection (isJump / SEEK_JUMP_MS) and the one-shot fill-bar glide (which shares the controller's glideDur).

Silence handling. A "pause" is just a positive timing gap between consecutive units (contiguous units share an ms-quantized boundary, so a gap means the aligner inserted sil/sp — a detected silence). Each surface surfaces it:

  • Filmstripsnap mode freezes during silence (active cell de-highlights, no needle). The scrolling modes (hybrid/tuner) no longer freeze: the needle stays visible but greys (--text-muted) and scrolls continuously across the inter-cell gap for a between-verse pause in either direction — forward, and the backward gap of a loopback re-take (scrollThroughGap interpolates offset by elapsed-silence, anchored to the real interval the silence follows; nextIntervalAfter resolves the upcoming cell, the guard is nextIdx !== frozenIdx). Within-verse / leading / trailing silence holds. The strip is a fixed-scale time-ruler at one global velocity (config.filmstripPxPerSec, default 12 px/s): cell width = max(filmstripMinCellPx, canonDurSec × pxPerSec) and inter-cell gap = VerseCell.nextGapSec × pxPerSec (between-verse silence, measured from the verse's real canonEndSec so within-verse pauses never leak in). The cursor therefore travels at this one velocity everywhere — within cells and across silences — and a given silence renders to the same px in every surah and for every reciter (no per-chapter normalization; the prior maxCellPx / maxDur anchor scaled silences to the longest verse, so they flattened on long surahs). Geometry is identical across motion modes (toggling never shifts the strip — only the cursor's motion differs). A too-short verse is floored to filmstripMinCellPx (18) so its ayah number stays legible; to keep the floor from speeding the cursor up, each cell carries a time-true span aw = canonDurSec × pxPerSec and the cursor crosses only that centered span at the ruler velocity (offsetForReci), the surplus w − aw absorbed into the adjacent gap (where the greyed needle already scrolls the silence). The per-cell progress fill bar reads the cursor's position within its cell ((offset − cumBefore)/w on the cell the cursor sits in, fillIdx), so the bar's leading edge always sits under the needle — on floored cells and through a loopback re-take alike. An unrecited placeholder, which has no recited time, gets a fixed visible width. A near-zero silence floors to filmstripGapPx (2). No max cap — a long verse is just a wide cell scrolled through at constant velocity.
  • Analysis view (UnifiedDisplay) — each inter-word silence renders a small pause bridge between the two word columns (mirroring the crossword-bridge slot). While its silence plays it lights and the rest of the row dims to 70% (.in-pause); waveform-hover over the gap previews it. If the paused-on word carries a surfaced waqf (stop) mark it is lifted out of the word box into the bridge; otherwise the bridge shows the neutral two-bar pause icon.

Pure model (UnifiedDisplay). The view's data-derivation half is state-free and lives in three sibling utils modules the component reacts on: utils/cell-model.ts (cellGroupsFor + the cell/group helpers → RenderedGroup[]), utils/phoneme-columns.ts (splitPhone / _buildColumns / heavy-phone display), and utils/rendered-blocks.ts (buildRendered / groupUnitsRenderedBlock[] + bridges). UnifiedDisplay.svelte keeps only the reactive glue, the imperative per-frame highlight, the DOM handlers and the template; the cells/bridge unit tests drive this model through the mounted component.

Column alignment (UnifiedDisplay). The analysis letter row and phoneme row align per-grapheme: each .cell-group is its own 2-row CSS grid (repeat(--gcols, max-content), row 1 graphemes, row 2 phoneme clusters), and .mega-grid lays the groups out in one RTL flex row. _buildColumns(groups, phonemes) turns each RenderedGroup (a base consonant + its pinned ḥaraka/tanwīn, or a long-vowel [diacritic, carrier] pair / implicit madd) into ordered grapheme cols, then walks the phonemes in reading order and assigns each to the column of the grapheme that sounds it (via the cells' phonemeIndices) as a .phoneme-cluster placed by inline grid-column. So a consonant sits under its base letter and a vowel sits under its OWN diacritic — not centred over the carrier. A render-only phone that no cell indexes — the qalqala echo Q — rides the preceding column so it stays beside its source (synthetic-base test fixtures have no mapping, so their phones fall to the first group, order preserved). Each column is max-content → it reserves the WIDER of its glyph or its sound, so a multi-sound mark (tanwīn = 2 phonemes) never overlaps the neighbour in any display mode, including letters-hidden (columns then size to the clusters alone). To keep a diacritic flush against its base while still reserving that width, row-1 cells seam-justify via colJustify(ci, n) (ci 0end, last→start, single→center): the narrow mark is pinned to the base edge and the extra column width opens on the OUTER side, under the sound; phoneme clusters stay justify-self: center, so a cluster that is the widest item fills its column and its edge sound lands under the seam-pinned mark. A silent grapheme reserves an empty slot (no cluster — the column keeps the alignment); a truly-dropped diacritic at waqf (and any cell with no own phoneme) likewise adds nothing — except the carried-vowel cases (madd_iwad, allah_dagger_alef), which co-light on their carrier's phoneme. A cross-word idgham receiver (shafawi / noon) instead owns no phoneme and co-lights through the merger share-union, with the following vowel kept on its own haraka (own interval, no group) — the receiving meem and its haraka no longer share a vowel index. A dropped pronoun-haa ṣilah at waqf (هُۥ / هِۦ → both the ḍamma/kasra and the mini-waw/yaa silent) folds the two into one silent vowel group, ḥaraka leading the carrier (orthographic order); unlike a sounding و/ى waqf carrier — whose own dropped fatḥa trails it — a fully-silent vowel group (g.full.every(silent)) puts its ḥaraka(s) first, so the ḍamma sits in the vowel unit rather than gluing onto the haa. Cross-word mergers (idgham / iltiqaa-kasra) belong to neither word's columns and are lifted to the between-word crossword-bridge tile. Hovering any cell tints its whole column (.group-hover, matched by data-group-index); the resting cue is proximity + alignment alone.

Row layout (UnifiedDisplay). Wrapped word rows are centered with one shared, capped column gap so a multi-row verse reads as an aligned block: .unified-display is display: flex; flex-wrap: wrap; justify-content: center. The horizontal gap is --mega-row-gap, sized in JS by recomputeRowGap: it measures the wrapped rows and sets ONE gap = the value that flushes the densest row (the row with the least slack), clamped to [16px, 40px]. So dense rows fill the container width, sparser rows still center but with a smaller edge, and the gap can never blow out (justify-content: center only ever leaves a small inset). It re-measures on content/tier change (a reactive tick() on units + the tier stores), on container resize and web-font swap (a ResizeObserver + document.fonts.ready); the vertical row-gap stays fixed at --mega-line-gap. The layout atoms are .word-units (inline-flex) built by groupUnits(rendered): a block.bridge (idgham / iltiqaa) and a block.pauseBridge (inter-word silence) are both connectors that pair a block with its predecessor into ONE unit with the connector tile between them — so a bridged pair (and consecutive-idgham chains) and a word+stop-cell+word triple each stay on one row, never splitting. A pause cell therefore sits between its two paired words (a symmetric nestle via its own margin-inline), not trailing one.

Cell interactions (UnifiedDisplay). Word/letter/phoneme cells seek + publish tsHoveredElement (the waveform hover band) as before; diacritic cells — the haraka/tanwīn small cells and the implicit-madd full cells (.dia-seekable, when timed) — are now also click-to-seek (to their cellStart), but deliberately do NOT publish tsHoveredElement, so the waveform cursors are unchanged. Every cell (word, letter, diacritic, phoneme, and the pause/stop bridge) raises a duration tooltip on hover (.cell-tip) showing its recited span in ms rounded to the nearest 10. The tooltip has a warmup/cooldown delay owned by the component: the first (cold) hover shows after TS_TIP_WARMUP_MS (0.5s); once warm, moving to another cell shows near-instantly; warm decays back to cold TS_TIP_COOLDOWN_MS (2s) after the pointer leaves a cell with no re-entry.

Tajweed underlines + settings (UnifiedDisplay). Each cell carries an ordered badge stack tjBadges (≤2 bars: one base rule + tafkheem on top) composed into a per-cell style:box-shadow by tjShadowFor, filtered live by the per-rule enable toggles; colours are --tj-* custom props, overridable at runtime. The rule registry utils/tajweed-rules.ts is the single source for colour, legend label, tooltip name, default-on state and stack layer (badgesForTags keeps the cell's own base rule over a propagated one, tafkheem last). The legend + settings drop-up is the footer tajweed icon — itself a real analysis cell reading a big top-pinned TJW over one of its own underline bars cycling three --tj-* hues (ControlIcon's one non-currentColor glyph) — opening TajweedSettingsPanel: three equal columns (Noon / Meem, Madd, Other rules; LEGEND groups), each rule a colour chip (tint + bar preview; click → native picker, hover reveals a dropper) + a mini enable toggle, cached in stores/tajweed-settings (load-from-LS + persist; colour overrides written to --tj-* on the document root). Qalqala is two coupled rows (Qalqala Sughra / Qalqala Kubra) sharing the qalqala legendKey, so one colour + one toggle drive both; the kubrā chip previews the side-wrap. The Other column closes with two non-interactive keys: a dashed alef + a fatḥa/kasra dia column (positioned by the shared haraka-render calibration + .pin-top/.pin-bottom, mirroring the real grapheme row — "Pronounced but unwritten/transformed") and a greyed hamzat-waṣl ("Written but silent"). The duration tooltip gains a second line naming the cell's enabled rules; silent letters hover for their rule name with no ms (data-tj-rules) — lām shamsiyyah / hamzat-waṣl / iltiqaa / the iqlab silent nūn ("Iqlab"), plus three context-derived names: a trailing dropped ḥaraka at a stop → "Waqf", a dropped fatḥatan whose ʿiwaḍ madd moved onto the next ʾalif → "Madd 'Iwad", and a silent ʿiwaḍ alef right after a tanwīn (waṣl) → "Madd 'Iwad Wasl" (the .mega-letter.silent[data-tj-rules] rule re-enables hover so these surface). A heavy ikhfāʾ nasal (the FE-detected ŋ→ŋˤ before an istiʿlāʾ letter) stacks tafkhīm above its ikhfāʾ bar. qalqala underlines the render-only Q echo phoneme (not the consonant phoneme, which keeps only its own tafkheem); kubrā's bottom bar is identical to ṣughrā (the box-shadow inset, hugging the cell's rounded corners) and .tj-kubra::after adds only the short side-wraps that curl it up the edges (--tj-kubra), contained within the cell. The 4 consonant idghams render on a single contract: the source letter, the receiver letter, and the receiver phoneme/bridge all underline + name the rule. The receiver always carries the idgham as a secondary_tag (a merge-layer bar stacking ABOVE its own base rule, e.g. ghunnah on the receiving mīm of ٱرْكَب مَّعَنَا) — uniformly for all four, cross-word (_link_cross_word) and within-word (_tag_within_word_idgham, covering mutaqāribayn ق+ك نَخْلُقكُّم AND nāqiṣ ط+ت بَسَطتَ). Un-greying the source is a SEPARATE axis driven by its share group, NOT the underline: mutamāthilayn (+ noon/tanwīn/shafawi) co-light → source un-greyed; mutaqāribayn / mutajānisayn keep the source greyed/silent. Tafkhīm rides a cell's own heaviness, never the idgham (so مٌقارباين ق+ك → the light ك gets none, the heavy silent ق keeps its own). A sounding within-word source (nāqiṣ ط) badges its OWN phoneme too — the phonemeBadges skip is isBridgeTag(c.tag) && (!c.phonemeIndices.length || bridgeRules.has(c.tag)), so only a dropped source or one whose merger is a separate bridge phone is skipped. The badge stack is ≤3 bars (base < merge < tafkheem top). The dropped madd-ʿiwaḍ fatḥatan is detected structurally (next cell tag==='madd_iwad') and rendered as a dashed fatḥa co-lit with the ʾalif; both name "Madd 'Iwad". Muqattaat colour each phoneme by its own phonemeRuleTags; the FE stacks tafkhīm on a heavy long vowel (aˤ: → its madd + tafkhīm) and a heavy ikhfāʾ nasal (ŋ before an istiʿlāʾ letter — سٓ's ŋ before قٓ, عٓ's before صٓ), while a heavy consonant (istiʿlāʾ + the heavy rāʾ ) carries tafkhīm on its own prt slot (phonemizer-side). The 2-count names (ط ه ي ح ر) render the letter as a BASE cell — their madd lives only on the vowel phoneme, no letter bar — and a heavy name (ص ق ط / rāʾ) underlines its glyph via a secondary_tags tafkhīm; a buried heavy ikhfāʾ does NOT tafkhīm-bar a muqattaat letter (cellBadges's heavy-ikhfāʾ detection is gated to non-muqattaat cells). Iẓhar is FE-synthesized only for a sakin meem/noon; a meem/noon carrying a tanwīn is voweled (its vowel rides a tanween cell sharing the letter's source index), so _voweledSrcSet counts tanwīn cells to keep بُكْمٌ's meem from falsely badging izhar-shafawi (the tanwīn keeps its own izhar-ḥalqī). Defaults: every rule on except iẓhar and madd ṭabīʿī.

  • Teleprompter (LineAnimation) — stop marks are split out of the reveal (build-structure.tsAnimWord.clean/waqf) so they never join the per-letter/word highlight, and rendered as a separate .ra-waqf-mark overlay. A combining mark is painted in the colour of whatever run it shapes into (true for HTML and SVG <tspan fill>), so to get the mark's natural font-anchored position and an independent colour/opacity, the overlay is a second copy of the full word in one run, clip-path: inset(0 0 70% 0)-clipped to the high waqf register so only the mark paints (the letters are clipped away → no extra ink, opacity is fully independent, no bleed into the per-letter reveal). The reveal sweep (sweepWaqf) adds .revealed (opacity→1) when the mark's own last letter is reached, and .waqf-active (highlight) only while a pause holds on its word — it never gets the reveal highlight.

Stop-sign detection is one shared util — lib/utils/waqf.ts (splitWaqf/hasWaqf), the surfaced marks being U+06D6–U+06DB excluding U+06D9 (لا, "do not stop"); U+06DC (saktah) is also not surfaced.

Coverage markers. load-chapter.ts derives a per-chapter coverage (incomplete + fully-missing verses) entirely client-side — diffing the recited words against the reference word counts from the qpc verse index (ts-source.loadQpcVerseIndex, one memoized scan of the qpc the animation already loads; recitation-data/coverage.ts, mirroring the backend select_complete_verses gate). buildFilmstripModel(units, weighting, coverage?) then tags present cells none/words and inserts a full placeholder cell for each unrecited verse (no units/geometry) so the gap stays visible. In AyahFilmstrip, a words cell rings red (--state-missing-*, still clickable + plays), a full cell is a dimmed red placeholder that playback + all user-nav (click/arrow/drag, both motion modes) skip (non-clickable). The active cell's status is reported via onActiveCell, and NowReciting shows a contextual "missing words" pill in its handle row only while the selected verse is itself incomplete.

Cross-verse waṣl. A reciter continuing from verse N into N+1 without a stop is stored as a per-occurrence wasl flag on the bridging take's last segment (schema v10; ~22 reciters in prod). It is static-only: a boundary that bridges in any take renders permanently merged (the 2 dynamic corpus boundaries are folded in). occasions.ts back-stamps ChapterOccasion.bridgesOutTo; recitation-data/wasl.ts turns occasions into junctions + waslGroupOf(occasions, focusIdx) (the maximal bridgesOutTo run + its span). Naming guard: "bridge" is reserved for cross-word idgham — everything cross-verse is wasl*. Surfaces:

  • Filmstrip (AyahFilmstrip) — a bridged run renders as a gapless mega-cell: full-bordered sub-cells keeping their own numbers, tied by one accent rail under the row (waslGroups); VerseCell.waslNext (set in filmstrip-model._assemble from TimeSpan.waslTo) pins the gap to 0 and dissolves the min-width floor so the group crosses at one velocity.
  • Teleprompter (LineAnimation) — wasl-chains.ts precomputes the chains from units; the page extends to the whole chain (ayahEndIdx) and clears only on a chain change, so N+1's words flow onto the same line and the ۝ marker sits inline. The marker silence-colours (.marker-pause) on a real waqf stop at a non-bridging verse end.
  • Analysis + waveform (Timestamps) — when the focus verse is in a group, TimestampsTab precomputes focusWaslGroup (assembleWaslGroup → one merged TsVerseData over the chain, 0-anchored to the group start). UnifiedDisplay renders from it, non-focus verses dimmed + non-loopable (.context); highlights / click / loop key off displayOffsetSec() (group offset), and the loop seek-back (TimestampsTab) uses the group offset via loopAnchor.waslGroup. TimestampsWaveform spans the window over the group with peaks fetched over the span (dispWindow), and the shuffle prewarmer warms the group span. The end-of-verse waqf reuses the existing .in-pause dim. by_surah only; v9 shards + by_ayah no-op.
    • Junction merge effect (idgham) — the offline tagger phonemizes each verse-segment alone, so a cross-verse merger phone carries no bridge tag (only within-verse mergers do); the shard's source cell (the last word's trailing tanwīn/noon/meem) does carry the idgham tag. buildRendered (rendered-blocks.ts) detects each verse-change boundary in the merged words and, when the previous word's trailing cell isBridgeTag, synthesizes the junction bridge — lifting the next verse's nasalized head phone into the gold tile and suppressing the source's badge, mirroring the within-verse path. Fires only across a verse change, so a standalone single-verse render is untouched.

lib/catalog/, lib/refs/, lib/icons/

Path Role
catalog/schema-descriptor.ts Schema descriptor for the dashboard secondary facet rail (operates on combinations/PublicDelivery)
refs/quran-refs.ts dk_words + verse_word_counts behind one content-hashed endpoint; fetched once per browser, shared via store
icons/Icon.svelte + *.svg Inline SVG icons via {@html} (currentColor cascade)

lib/types/

File Role
generated/schemas.ts Codegen'd FE data contractsscripts/codegen/regen_fe_types.py over qua_shared/schemas/fe_types.py (Pydantic → JSON Schema → TS). Never hand-edit; CI schema-codegen-check gates it. The bucket/ artefact shapes (Segment, EditOp, PeakBucket, HistoryBatch, PhonemeInterval, …) and the wire/ seg/ts/public/audio request/response shapes are now modeled there and codegen'd.
view-models.ts FE-only view-models + derived reads with no single wire producer: the editor's working Segment superset, the EditOp/EditOpPatch/HistoryBatch history views, GenerationBoundary/HistorySummary rollups, Actor, the Ref/VerseRef string aliases, and the derived /api/seg/* reads (SegEditHistoryResponse, SegStatsResponse, …).
peaks-transport.ts FE-only waveform transport — PeakBucket/AudioPeaks/SegmentPeaks, including the flag-gated Int8Array drawer branch (no wire model).
ts-client.ts FE-only Timestamps-tab client types — the TsVerseData verse model, slim TsCatalog* projection, positional shard reads (TsShardWord/SegmentEntry/TsShardResponse), SurahInfo*, and the deprecated legacy Ts*Response shapes.
public-bucket.ts FE-only public-bucket display vocabulary — PublicBucket/AdminBucket, the PUBLIC_BUCKET_LABELS/PUBLIC_BUCKETS/BUCKET_PRIORITY tables, bucketRank, and CoverageKind.
ui.ts Shared UI types for components

lib/stores/ (cross-tab)

File Role
current-user.ts currentUser writable (loaded from /api/me); isSignedIn(); derived isAdmin (maintainer|owner), isOwner; loadCurrentUser() / resetCurrentUser(). Dashboard-level gating.
editing-mode.ts editingMode writable (view/editor/maintainer/owner + viewReason); pure syncEditingMode(user,task); derived editingDisabled, isAdmin. Reciter-scoped gating — see editGate.
edit-popover.ts State for the editGate-surfaced popover (showEditPopover)
sign-in-modal.ts openSignInModal state
toast.ts Toast queue
bookmarks.ts Bookmarks panel toggle
player-context.ts Cross-tab bottom-player context

lib/utils/

File Role
active-tab.ts Active-tab store + get/set
constants.ts LS_KEYS, TAB_NAMES
animation.ts createAnimationLoop() — rAF loop with start/stop
chart.ts Chart.js bootstrap (registers plugins)
audio.ts safePlay() (swallows AbortError), audioSrcMatches
audio-warmup.ts First-gesture audio decoder/output warm-up
preconnect.ts DNS preconnect helper
peaks-fetch.ts Cross-tab /api/seg/segment-peaks fetch
peaks-view.ts Peaks-shape adapter (legacy nested [min,max][] vs int8)
waveform-cache.ts Normalized-URL → peaks Map (non-reactive)
waveform-draw.ts Peaks → canvas draw (drawWaveformPeaks)
arabic-text.ts stripTashkeel, combining-mark helpers
waqf.ts Quranic waqf (stop) marks — splitWaqf/hasWaqf (U+06D6–U+06DB, excludes لا U+06D9 + saktah U+06DC). Shared by the analysis pause bridge + teleprompter stop-sign handling
fuzzy-match.ts Arabic-normalizing search: memoized normalizeArabic, match, and filterByFields (the shared multi-field filter behind every reciter/option search bar)
list-virtualization.ts Pure prefix-sum windowing helpers for variable-height virtual lists (CatalogTable + SegmentsList)
surah-info.ts surahInfo data + ready promise + surahOptionText
grouped-reciters.ts Grouped reciter dropdown options
keyboard-guard.ts shouldHandleKey(e, tab)
derived-eq.ts Deep-equality derived store helper
svg-arrow-geometry.ts computeArrowLayout() for history diff arrows
word-boundary.ts Word-boundary helpers
speed-control.ts Speed option list
formatting.ts _formatBytes
facets.ts Faceted-count helper (history filters, dashboard rail, picker)
visible-poll.ts Page-Visibility-aware polling
relative-time.ts "3 hours ago" labels for activity/detail
axis-labels.ts Resolve schema axis/tag keys → human labels
delivery-label.ts Catalog vocab slug → display name
delivery-sort.ts Canonical combination sort order used by reciter detail, player switcher, and dashboard row play defaults
countries.ts ISO-3166-1 alpha-2 list (build-time embedded)

Tabs

tabs/dashboard/

Entry view: public catalog browse/search/filter/play + admin controls.

Path Role
DashboardTab.svelte Root — CatalogList + ReciterDetail modal + BottomPlayer
views/CatalogList.svelte List view; filters on combinations, grouped back by reciter
views/ReciterDetail.svelte Reciter detail modal (flat combination table, status-priority sort)
components/CatalogTable.svelte Virtualized reciter list (own scroll container; windowing via lib/utils/list-virtualization.ts)
components/DetailHeader.svelte, FactsList.svelte, StateTimeline.svelte Detail/table pieces
components/ActivityRail.svelte Public activity feed (owner-visible delete affordance on each card)
components/RequestForm.svelte, submit/{SubmitWizard,StepReciter,StepSource,StepDetails}.svelte Request/submit flow
stores/catalog-data.ts Full public reciter roster (paginated) + stats, cached in-memory; visibility-aware poll gates the multi-page refetch on the /api/public/version (db_seq) probe — an idle catalog costs one tiny request per tick, not a full roster page-through
stores/dashboard-state.ts Filter/sort/search + detail-modal flag (mounted at root, survives modal)
stores/submit-wizard.ts Submit-recitation wizard FE state (3-step)

tabs/timestamps/

Waveform + phoneme display for published reciters (plus owner preview of generated-but-unreleased shards — the read-path honors the timestamps.view_unreleased capability; see timestamps-job.md). Shards are temporal segment-array .json.gz (every recited segment raw, recitation order; served as a byte pass-through). ts-source.ts splits segments into occasions (shardOccasions — maximal contiguous same-verse runs) and assembleOccasion builds each occasion's clip keeping every recited word (no FE dedup), so the waveform + analysis reflect the full recitation including leading/trailing repeats and full-verse loopbacks. The tab focuses one occasion at a time, following the playhead into re-takes.

Path Role
TimestampsTab.svelte Shell — reciter/chapter/verse cascade, config→CSS vars, view toggle, keyboard, waveform animation (imperative)
components/ UnifiedDisplay, AnimationDisplay, TimestampsWaveform (exempt), TimestampsControls, TimestampsAudio, TimestampsKeyboard, TimestampsViewControls, TimestampsShortcutsGuide, TranslationLangSelect, WordTranslation, TsValidationPanel (owner low-confidence accordion)
components/ (footer) TimestampsFooterLeft (reciter picker + shuffle), TimestampsFooterReport (report drop-up), TimestampsFooterAnalysis (loop / letters / phonemes / tajweed / help), TimestampsFooterOpenSegments (→ Segments editor redirect) — filled into BottomPlayer's meta / loc-lead / center-trail / download-lead slots by App.svelte, only while the Timestamps tab is active
services/ts_client.ts Shard-fetch data layer
utils/ constants.ts, loop-target.ts, zoom.ts, range-spec.ts, audio-load.ts

Public reports (Phase 2 — UI pending). The categorized Timestamps report backend (/api/ts/<slug>/reports*, gated by timestamps.report, anon-eligible) is built (see docs/reference/database.md ts_reports + capabilities.md); the report-creation UI and target-picking from the analysis grid are the deferred Phase-2 work. Owners get a ts_report.created notification on a new report and the reporter a ts_report.resolved on resolution; both rail redirects use gotoTimestamps(slug, verseKey) (lib/utils/goto-timestamps.ts) → the shared pendingTsNavigation channel → jumpToTarget.

"Open in Segments" redirect. TimestampsFooterOpenSegments (the ↗ next to the footer download button) deep-links the focused reciter + verse into the Segments editor via gotoSegments(slug, { focusVerse: { slug, chapter, verse } }) (lib/utils/goto-segments.ts). SegmentsTab consumes the focusVerse intent once the target reciter's corpus is resident (segAllData !== null, the clearPerReciterState null being the fresh-corpus barrier) — switching to the verse's chapter, loading it, then scrolling the verse's first segment row into view via targetSegmentIndex (the same path Go-To / verse-jump use). The slug guard + a post-tick() re-validation reject a stale corpus mid-switch.

Stores: verse.ts (reciter/chapter/verse selection + loadedVerse + validationData), display.ts (view mode, granularity, show-tashkeel/phonemes; localStorage-persisted), playback.ts (auto-play, currentTime; defines tsPort but the tab plays through the shared dashPort), zoom.ts (slice-relative visible window).

tabs/segments/

Full WIP editor — trim/split/merge/re-reference, auto-validation on save.

Path Role
SegmentsTab.svelte + ShortcutsGuide.svelte Shell — dropdowns, filter bar, nav banner, list, CSS-var config, keyboard
components/list/ SegmentsList, SegmentWaveformCanvas (exempt), Navigation, TimeEdit, TimeRange (windowing math in shared lib/utils/list-virtualization.ts)
components/filters/ FiltersBar, FilterCondition
components/edit/ EditOverlay, MergePanel, DeletePanel
components/history/ HistoryBatch, HistoryOp, HistoryArrows, EditChainRow
components/save/ SavePreview
components/stats/ StatsPanel, StatsChart, ChartFullscreen
components/validation/ ErrorCard, MissingWordsCard, MissingVersesCard, AccordionGuideModal (legacy)
components/footer/ tab footer
domain/ identity.ts, inverse-patch.ts (paired with backend domain/), registry.ts (issue registry — paired with backend), sorting.ts (accordion sort abstraction)
types/ segments.ts, stats.ts
guides/ Accordion help-modal guide system — see accordion-guides.md

Stores: segments.ts (normalized state by uid, IS-7), edit.ts (bundled 8-field edit state + activeTrimBoundary + splitPreviewSelection), history.ts (edit-history view), validation.ts (SegValidateResponse), validation-sort.ts (per-accordion sort prefs, localStorage insp_seg_val_sorts), save.ts (save-preview), autosave.ts (localStorage pref), active-actions.ts (focused-row action bundle for keyboard dispatch), chapter.ts, chapter-meta.ts, config.ts, dirty.ts, filters.ts, navigation.ts, playback.ts (segPort), stats.ts, accordion-pin.ts, merge-redirect.ts, undo-pending.ts.

utils/ (grouped): accordion-nav.ts (DOM-read card sequence for ↑/↓ + autoplay inside an open accordion), data/ (selection/filter/reciter/chapter actions, config loader, references, per-reciter state clear), edit/ (trim/split/merge/delete/ignore/reference/setIsWasl/auto-fix/enter/common), history/ (loader/actions/chains/items/render), playback/ (playback.ts playhead overlay, play-range.ts, range-spec.ts, preview.ts, resolvers.ts, source.ts, warmup.ts, row-registry.ts), save/ (payload/preview/execute/undo/actions), validation/ (classified-issues, conf-class, card-lead-seg, missing-verse-context, refresh, resolve-issue, split-group, stale), waveform/ (draw-seg.ts, split-draw.ts, trim-draw.ts, op-peaks.ts, peaks-cache.ts, utils.ts).

Keyboard shortcuts (shortcuts/ + utils/keyboard.ts): shortcuts/defaults.ts is the catalogue (id · label · context · default key · rebindable); shortcuts/store.svelte.ts holds user overrides (localStorage insp_seg_shortcuts, runes $state) and the resolve(token, context) reverse lookup. handleSegmentsKey picks a context — edit (trim/split: ←/→ stepper, Tab cycles cursor/region, Enter/Escape), accordion (a validation accordion is open: ↑/↓ + autoplay walk the accordion-nav sequence, G/C/L/F act on the focused card), or default — then dispatches the resolved action. Row/card edit actions (A/S/E/G/L/F/C) route through the active-actions registry, which the primary SegmentRow publishes; accordion cards forward their ignore/auto-fill/toggle-context callbacks via SegmentRow's onCard* props. The footer ShortcutsGuide.svelte (left of the speed control) is the reference + inline rebinder; the old reference accordion in GuidesGateModal is gone.

Validation accordion sorts (domain/sorting.ts + stores/validation-sort.ts): a declarative, FE-only sort abstraction. Each registry row declares the sort options it offers via IssueDefinition.sorts (e.g. Low Confidence → confidence (default) + quran_order; Missing Words → quran_order + word_count; Cross-verse → quran_order + verse_count; Repetitions → quran_order + rep_split_count); Missing Verses and the non-registry Flagged accordion offer none. sorting.ts owns the key extractors (quran_order reads (chapter, seg_index), falling back to verse_key/ref; counts read the relevant field; rep_split_count reads refs.length from the auto-split map) and sortItems, which orders by the active (kind, dir) and breaks ties with the other offered option ascending. ValidationPanel renders one pill per option in the accordion header — click to select, re-click the active pill to flip ▲/▼ — and applies the sort in projectVisible. Choice + direction persist globally (not per-reciter) in valSortPrefs; resolveSort falls back to the registry default and rejects a stored kind a category no longer offers. The sorts field is deliberately absent from the Python registry and its parity snapshot.

Hybrid imperative canvas pattern

Canvas waveform components expose the raw <canvas> so overlay code draws directly each animation frame, avoiding reactive re-renders at 60 fps. Drivers:

  • tabs/segments/utils/playback/playback.ts (playhead overlay)
  • tabs/segments/utils/waveform/{split-draw,trim-draw}.ts (edit overlays)
  • TimestampsTab.svelte / TimestampsWaveform.svelte (waveform animation)

These components stay Svelte-4 legacy by design (docs/planning/svelte-migration.md exempt list). lib/utils/animation.ts::createAnimationLoop() wraps the rAF lifecycle.

Client caching

Cache Location Invalidation
Segment peaks tabs/segments/utils/waveform/peaks-cache.ts (lazy via IntersectionObserver) reciter/chapter change
Audio peaks (cross-tab) lib/utils/waveform-cache.ts (Map keyed by normalized URL) manual
Quran refs bundle lib/refs/quran-refs.ts (one content-hashed fetch/browser) content hash (never per-session)
Catalog list + stats tabs/dashboard/stores/catalog-data.ts (in-memory snapshot) first read only
Audio transport/prewarm per-tab AudioPort + lib/playback/shadow-audio.ts tab-switch / chapter change

Build outputs

frontend/dist/ is gitignored. npm run build = tsc --noEmit && vite build → hashed JS + CSS, Chart.js in a separate charts chunk. Run before launching Flask in production mode.