Spaces:
Running
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 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/<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:
- Filmstrip —
snapmode 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 (scrollThroughGapinterpolates offset by elapsed-silence, anchored to the real interval the silence follows;nextIntervalAfterresolves the upcoming cell, the guard isnextIdx !== 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 realcanonEndSecso 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 priormaxCellPx / maxDuranchor 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 tofilmstripMinCellPx(18) so its ayah number stays legible; to keep the floor from speeding the cursor up, each cell carries a time-true spanaw = canonDurSec × pxPerSecand the cursor crosses only that centered span at the ruler velocity (offsetForReci), the surplusw − awabsorbed 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)/won 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 tofilmstripGapPx(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 thecrossword-bridgeslot). 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 / groupUnits → RenderedBlock[] + 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 0→end, 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āʾ 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.ts→AnimWord.clean/waqf) so they never join the per-letter/word highlight, and rendered as a separate.ra-waqf-markoverlay. 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 infilmstrip-model._assemblefromTimeSpan.waslTo) pins the gap to 0 and dissolves the min-width floor so the group crosses at one velocity. - Teleprompter (
LineAnimation) —wasl-chains.tsprecomputes the chains fromunits; 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 themarker 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,
TimestampsTabprecomputesfocusWaslGroup(assembleWaslGroup→ one mergedTsVerseDataover the chain, 0-anchored to the group start).UnifiedDisplayrenders from it, non-focus verses dimmed + non-loopable (.context); highlights / click / loop key offdisplayOffsetSec()(group offset), and the loop seek-back (TimestampsTab) uses the group offset vialoopAnchor.waslGroup.TimestampsWaveformspans 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-pausedim. 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
bridgetag (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 mergedwordsand, when the previous word's trailing cellisBridgeTag, 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.
- Junction merge effect (idgham) — the offline tagger phonemizes each verse-segment alone, so a cross-verse merger phone carries no
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 contracts — scripts/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.