diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md
new file mode 100644
index 00000000..6781f132
--- /dev/null
+++ b/.claude/memory/MEMORY.md
@@ -0,0 +1,10 @@
+# Memory index
+
+- [Hifth app identity](hifth-app-identity.md) — Quran navigation app for huffaz, renamed from "Linker", loops plan in docs/PLAN.md
+- [Artifacts in English by default](artifacts-english-by-default.md) — mocks/walkthroughs use English chrome, only scripture stays Arabic; watch published artifacts for feedback
+- [Desktop edge-grab page turn](desktop-edge-grab-turn.md) — desktop spread turns by grabbing the outer fore-edge (hand cursor); mid-page drags never turn
+- [Juz-jump leaf alignment](juz-jump-leaf-alignment.md) — a hop revealed the incoming leaf before centring it (1-frame ~9px flash); fixed by centerCurrent() in navigateTo; rebuild dist before e2e
+- [Desktop zoom StrictMode orphan](desktop-zoom-strictmode-orphan.md) — "zoom broken on spread" was a dev-only StrictMode orphan host; teardown now removes hosts; prod was never affected
+- [SPA hash nav is not a reload](spa-hash-nav-no-reload.md) — a #-only navigate keeps the old bundle on 5173; force location.reload() before calling a change "not rendered"
+- [Playwright WebKit missing](playwright-webkit-missing.md) — iphone e2e project cannot launch locally; run the Chromium projects, ask before installing WebKit
+- [Designs public on the site](designs-public-on-site.md) — tenet since 2026-09-01: docs/ pages are served from blog.bytesofpurpose.com/hifth/docs/ by the build; never publish a decision page to claude.ai as its address
diff --git a/.claude/memory/artifacts-english-by-default.md b/.claude/memory/artifacts-english-by-default.md
new file mode 100644
index 00000000..190e5fb8
--- /dev/null
+++ b/.claude/memory/artifacts-english-by-default.md
@@ -0,0 +1,25 @@
+---
+name: artifacts-english-by-default
+description: Build design artifacts/mocks with English UI chrome by default; keep only scripture Arabic; watch them for comment feedback
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: e1a37078-db9f-4e9e-a2a4-0d297cd099ff
+ modified: 2026-08-29T19:28:22.343Z
+---
+
+When building a visual artifact / mock / walkthrough for review (e.g. the confusion-points
+"Where You Slip" page), the **app chrome must default to English** — button labels, menu
+items, warnings, list rows, legends. The only Arabic that stays is the **Qur'an text itself**
+(ayah words, surah names as they appear on the mus'haf) — that is scripture, not chrome.
+
+Also: **publish these design artifacts in English mode and set a comment watch** so feedback
+can be captured for future sessions.
+
+**Why:** the owner reviews and leaves feedback in English; Arabic-only chrome in the mocks
+makes the experience unreadable to a reviewer and to anyone they show it to, and undercuts the
+CLAUDE.md tenet that a decision page be followable by a stranger.
+
+**How to apply:** default new mock UI labels to English; render ayah text in Arabic; keep a
+short glossary for domain words; after publishing, establish/keep a comment watch. See
+[[hifth-app-identity]].
diff --git a/.claude/memory/designs-public-on-site.md b/.claude/memory/designs-public-on-site.md
new file mode 100644
index 00000000..1ec5cf58
--- /dev/null
+++ b/.claude/memory/designs-public-on-site.md
@@ -0,0 +1,26 @@
+---
+name: designs-public-on-site
+description: "Tenet since 2026-09-01 — every design page under docs/ is public on the app's own site at the same path; never publish decision pages to claude.ai as their address"
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: c8c77742-fa0c-48ea-9c25-4e720245832a
+ modified: 2026-09-02T02:13:50.680Z
+---
+
+The owner wants every design and decision page public-facing, for transparency, and made it a
+tenet of the repo (CLAUDE.md "Every design is public"). The web build stages `docs/**/*.html`
+onto the site at the same path, so `docs/design/x.html` is
+`https://blog.bytesofpurpose.com/hifth/docs/design/x.html`, with a front door at `/docs/` linked
+from the colophon. A decision's `artifact` is derived from its `page` and the gate refuses any
+other address.
+
+**Why:** "i want all the designs to be public facing for transparency" / "this should be a tenant
+for our repo". Earlier, nine pages went to claude.ai and the tree named five; a link on another
+host dies with the host.
+
+**How to apply:** publishing a design = merging it. Do not publish pages to claude.ai as the
+decision's address; a conversational copy is allowed but goes in `docs/artifacts.json` as a copy.
+Pages reference vendored art as `../../apps/web/public/assets/…` and the staging step rewrites
+it; any other dangling relative link fails the build. See [[hifth-app-identity]] and
+[[artifacts-english-by-default]].
diff --git a/.claude/memory/desktop-edge-grab-turn.md b/.claude/memory/desktop-edge-grab-turn.md
new file mode 100644
index 00000000..5631ded4
--- /dev/null
+++ b/.claude/memory/desktop-edge-grab-turn.md
@@ -0,0 +1,45 @@
+---
+name: desktop-edge-grab-turn
+description: "On the desktop spread the page turns only by grabbing its outer fore-edge, not by dragging across the middle"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: c8c77742-fa0c-48ea-9c25-4e720245832a
+ modified: 2026-09-01T17:31:44.144Z
+---
+
+On the desktop two-page spread, turning a page is an **edge grab**, not a swipe
+across the page. The user was explicit: hovering the outer sides shows a hand
+cursor, grabbing a fore-edge and dragging turns/flips the leaf, and **any drag
+that does not start at a page edge must not turn the page** — the middle is left
+free to pan and select. Grab zone shape: a strip down each leaf's outer edge that
+**widens at the top/bottom corners** (pinched to a sliver at the vertical middle);
+the centre fold/gutter is never grabbable.
+
+**Why:** a hafiz turns a physical mus'haf by its fore-edge; the old
+drag-anywhere swipe-to-turn fought selecting and panning on a big screen.
+
+**How to apply:** the rails live on the book (one outer edge belongs to the
+*facing* leaf, which the live stage feels no pointer on) and drive the live
+stage's exposed turn verbs; left edge pulls forward, right edge pulls back
+(drag-right = forward, matching the swipe convention). Desktop swipe-to-turn is
+off (the live stage gets `dragToTurn={!desktop}`); wheel and arrow keys still
+turn. The phone is untouched — it keeps swipe-to-turn and has no rails. See
+[[hifth-app-identity]].
+
+**The grab is a trigger, not a tracked band (as of the peel decision).** Reusing
+the phone's finger-locked band on desktop drew a thin fore-edge strip creeping
+1:1 over a spread whose two pages had not changed yet — the reader saw "a
+vertical bar on the same page," not a turn. So the desktop edge verbs now draw
+nothing while the hand moves; on release the commit rule decides, and a
+committed grab is handed to the *ordinary* animated turn (the same flip a wheel
+or arrow plays — grab, wheel and arrows all funnel through `onTurn → turnTo →
+runTurn`). A faithful drag-to-**peel** that reveals the destination *opening*
+under a lifting corner is the larger job tracked as the desktop page-turn
+animation (#11): it must drive **both** leaves at once (the leaf that lifts and
+the destination pages sit on opposite sides of the gutter, and which leaf lifts
+depends on turn direction), respect "no 170KB SVG glyph moves" (reveal by
+masking a corner off the current page over the already-mounted destination
+beneath — never a transform on the page), and wants a live browser to tune.
+Because every trigger shares `runTurn`, building the peel there gives scroll and
+arrows the peel for free — keep that coupling.
diff --git a/.claude/memory/desktop-zoom-strictmode-orphan.md b/.claude/memory/desktop-zoom-strictmode-orphan.md
new file mode 100644
index 00000000..d1a1bb8c
--- /dev/null
+++ b/.claude/memory/desktop-zoom-strictmode-orphan.md
@@ -0,0 +1,21 @@
+---
+name: desktop-zoom-strictmode-orphan
+description: "Zoom broken on desktop spread" (one leaf grows, one stays) was a dev-only React StrictMode orphan host, not a prod bug
+metadata:
+ type: project
+---
+
+The desktop-spread "zooming is broken / don't see full pages on zoom" report reproduced
+**only in the dev server** (`pnpm dev`, port 5173), never in the production build (`vite
+preview`, 4173). Cause: React StrictMode's dev double-invoke mounts → tears down → remounts
+against the *same* layer. `PageStage`'s teardown effect cleared the page Map but did **not**
+`host.remove()`, so the first mount's host lingered as an orphan `
` outside the Map. Zoom
+transforms only the Map's host (`applyTransform` reads `pagesRef.get(currentPage)`), so the
+orphan twin sat un-magnified under the one that grew — reading as "one leaf zooms, one doesn't."
+
+Fix (in `apps/web/src/components/PageStage.tsx`): teardown now removes each host from the
+layer; `mountPage` re-reads the Map after its last await and yields to a rival that already
+landed the page. Guarded by a StrictMode regression test in `PageStage.budget.test.tsx`
+(confirmed it fails `-2/+1` on original code). Production was never affected — StrictMode is a
+dev-only wrapper — but the dev server is what a developer previews in. See also the cold-link
+blank-leaf reveal fix in the same file (showPage/navigateTo reveal unconditionally).
diff --git a/.claude/memory/hifth-app-identity.md b/.claude/memory/hifth-app-identity.md
new file mode 100644
index 00000000..d92ac8b2
--- /dev/null
+++ b/.claude/memory/hifth-app-identity.md
@@ -0,0 +1,21 @@
+---
+name: hifth-app-identity
+description: "What the hifth project is — Quran navigation app for huffaz, renamed from design-phase \"Linker\", built in loops per docs/PLAN.md"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 0da21e77-a946-4228-877a-aee0949749f7
+ modified: 2026-07-20T09:33:16.009Z
+---
+
+Hifth (حفظ) is a pure navigation web app for huffaz: tap/highlight an ayah on real
+mushaf SVG pages → hop to similar verses (mutashabihat), shared roots, etc. Knowledge
+graph = static adjacency JSON; no backend. Designed in a claude.ai conversation
+(shared: https://claude.ai/share/9d43fe10-4c1a-4880-86eb-4adcadb3dfbb) under the
+codename "Linker · رابط"; user renamed it **Hifth** on 2026-07-20.
+
+Key user decisions (2026-07-20): web app first with mobile support (PWA, touch-first);
+implement iteratively in loops (each loop = vertical slice demoed on a phone); proper
+component architecture is a requirement, not an afterthought. Spec of record:
+`docs/reference/linker-spec.md`; plan: `docs/PLAN.md`; interactive mock and architecture
+diagram also in `docs/reference/`.
diff --git a/.claude/memory/juz-jump-leaf-alignment.md b/.claude/memory/juz-jump-leaf-alignment.md
new file mode 100644
index 00000000..1c7602d0
--- /dev/null
+++ b/.claude/memory/juz-jump-leaf-alignment.md
@@ -0,0 +1,36 @@
+---
+name: juz-jump-leaf-alignment
+description: "Why a juz jump could flash the two desktop leaves at different heights, and the one-line fix"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: c8c77742-fa0c-48ea-9c25-4e720245832a
+ modified: 2026-09-01T17:53:16.966Z
+---
+
+On the desktop two-page spread, a jump to another juz (or any non-adjacent
+relocation) could flash the two leaves at **different heights** for one frame —
+the reported "sometimes things aren't aligned after a juz jump."
+
+**Root cause.** A juz jump is a *hop*, not a page turn: it runs the live stage's
+`navigateTo`, which reveals the incoming page (`setCurrentPage` → `display:block`)
+and only *frames* it afterwards, inside an `await`ed tween. A freshly mounted host
+wears no transform, so for the one paint before the tween's first frame the
+incoming page sat at its leaf's top-left — one centring offset (~9px) above the
+facing leaf beside it. On a fast machine that is a single frame; on a real one the
+incoming ~170KB SVG's parse stalls the correcting frame long enough to see and
+screenshot. (The page-*turn* path, `crossFade`, never had this — it applies the
+transform synchronously and forces a reflow *before* revealing: the "arrive
+already wearing your transform" rule.)
+
+**Fix.** One line in `navigateTo`: call `centerCurrent()` right after
+`setCurrentPage`, so the incoming leaf arrives centred before the tween can yield.
+A zoom-1 hop then simply rests there; a closer hop (an ayah hop) tweens out from
+fit, which is the correct motion anyway.
+
+**Guard.** `desktop.spec.ts` → "a juz jump keeps the two leaves level through
+every frame" polls both leaves' top edges every frame across the settle and fails
+if any frame caught them >1.5px apart (proven to bite without the fix). Note the
+e2e gotcha: `vite preview` serves the pre-built `dist/`, so **rebuild (`pnpm
+build`) after every source edit** or the test runs stale bytes. See
+[[desktop-edge-grab-turn]], [[hifth-app-identity]].
diff --git a/.claude/memory/playwright-webkit-missing.md b/.claude/memory/playwright-webkit-missing.md
new file mode 100644
index 00000000..d5cd3d77
--- /dev/null
+++ b/.claude/memory/playwright-webkit-missing.md
@@ -0,0 +1,20 @@
+---
+name: playwright-webkit-missing
+description: "The e2e \"iphone\" project runs on WebKit, which is not installed on this laptop — every iphone test fails at browser launch, not in the app; run desktop/android/golden (Chromium) and ask before installing"
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: c8c77742-fa0c-48ea-9c25-4e720245832a
+ modified: 2026-09-02T00:04:00.172Z
+---
+
+On 2026-09-01 the full `npx playwright test` run failed every `[iphone]` test with
+`browserType.launch: Executable doesn't exist at ~/Library/Caches/ms-playwright/webkit-2311/pw_run.sh`.
+The `desktop`, `android` (Pixel 7) and `golden` projects are Chromium and run fine.
+
+**Why:** WebKit was never installed via `npx playwright install webkit` on this machine. Installing it
+is a download from Playwright's CDN, which needs the user's explicit go-ahead first.
+
+**How to apply:** Verify a change with `--project desktop --project android --project golden`. Report
+the iphone project as *not run*, never as failing. Offer the install as a one-line ask rather than
+starting it. See [[spa-hash-nav-no-reload]] for the rest of the verification recipe.
diff --git a/.claude/memory/spa-hash-nav-no-reload.md b/.claude/memory/spa-hash-nav-no-reload.md
new file mode 100644
index 00000000..da208296
--- /dev/null
+++ b/.claude/memory/spa-hash-nav-no-reload.md
@@ -0,0 +1,26 @@
+---
+name: spa-hash-nav-no-reload
+description: "Navigating the dev app to a hash-only URL does not reload the SPA — the DOM keeps the old bundle even after a Vite restart; force location.reload() before concluding a change \"did not render\""
+metadata:
+ node_type: memory
+ type: feedback
+ originSessionId: c8c77742-fa0c-48ea-9c25-4e720245832a
+ modified: 2026-09-01T23:47:36.435Z
+---
+
+When verifying a UI change on `localhost:5173`, navigating the tab to `http://localhost:5173/#/...`
+(a hash-only change) does **not** reload the document. The React tree keeps running the old
+module graph. Restarting Vite and clearing `node_modules/.vite` does not help either, because
+the browser never re-fetches the entry. The served source can be verified new (fetch the module
+and grep it) while the DOM is still old.
+
+**Why:** In this session the finished page-bar redesign (30 juz detents, page-icon handle) read as
+"not rendering" for several turns — the DOM had zero detents while the served `PageSlider.tsx`
+already contained them. One `location.reload()` showed everything. See also
+[[desktop-zoom-strictmode-orphan]] for the sibling trap: a fault that shows only under the dev
+server's StrictMode double-mount (5173) and not in the built app (4173).
+
+**How to apply:** After editing, force a real reload (JS `location.reload()` or navigate to a
+different path and back) before reading the DOM. If something reproduces on 5173 and not 4173,
+say it is dev-only. Rebuild `dist` before e2e and free port 4173 first. The `run-app` skill
+carries the recipe.
diff --git a/.claude/plans/shimmering-wandering-lobster.md b/.claude/plans/shimmering-wandering-lobster.md
index 5c8cc469..30f55c36 100644
--- a/.claude/plans/shimmering-wandering-lobster.md
+++ b/.claude/plans/shimmering-wandering-lobster.md
@@ -1,155 +1,103 @@
-# The wheel navigates, buttons magnify
-
-## Context
-
-**The reported bug:** on the desktop spread, the two leaves get out of sync in zoom when
-crossing between one-page and two-page modes. Reproduced in Chrome at 1440×900 against a
-local build, it is **three** distinct defects, not one:
-
-1. **The facing leaf zooms on its own.** `ctrl`+wheel over it took it to `scale(1.549)`
- while the live leaf sat at `0.8` and the book stayed open. The comment at
- `App.tsx:1189` justifying why the facing stage gets no `onFitChange` — *"the facing leaf
- … never receives a hop or a gesture that could change its own scale"* — is **false**: the
- facing leaf mounts a complete `PageStage` with its own wheel listener and its own pinch
- handler. That untrue comment is why this went unnoticed.
-2. **The mode survives a breakpoint crossing; the zoom does not.** Zoom in at 1440 → resize
- to 800 (the live stage remounts, view resets to `scale(1)`) → resize back to 1440 leaves
- `data-solo="true"` with the host at `scale(1)`: a book closed onto one leaf at fit, with
- no zoom to explain it. Recovery requires zooming in and back out.
-3. **Zooming *out* counts as "at fit".** `atFit = z <= 1 + 1e-3` and `MIN_ZOOM = 0.8`, so at
- `0.8` the book reopens with the live host at 266 px beside the facing leaf's 332 px.
-
-All three are symptoms of one thing: **`soloLeaf` is derived from zoom**, and zoom lives on
-a gesture. The user's call is to cut the derivation rather than patch its three leaks —
-
-> i don't want zoom to be driven by scrolling … id rather a button to toggle between two
-> page and 1 pages mode / settings driven zoom
-
-— which also composes with the earlier request to make ctrl+scrolling flip juz.
-
-**Outcome:** the wheel becomes navigation-only; magnification becomes an explicit control;
-one-page/two-page becomes an explicit toggle with no derived state behind it.
-
-## The three answers
-
-| Fork | Answer |
-|---|---|
-| What does `ctrl`+wheel do? | **Nothing.** Swallowed (`preventDefault`, no action). Juz-flipping moves to **`Shift`+wheel** |
-| Where does zoom live? | A **− / 100% / +** stepper in the desktop chrome. Session state, not persisted |
-| Does zoom still auto-close the book? | **No.** The toggle is the sole source of truth for page mode |
-
-**Why `ctrl` cannot carry the juz jump**, even though it was asked for: a macOS trackpad
-pinch *is* a `ctrl`+wheel — the OS synthesises the modifier, and the browser cannot tell it
-from a real `ctrl`+scroll (`PageStage.tsx:1940-1943`). Binding juz to it means every
-two-finger pinch on a laptop teleports the reader ~20 pages. `Shift`+wheel is unclaimed
-here and costs nothing.
-
-**What this does not cost:** touch pinch is a separate path — `onPinch` via `@use-gesture`
-with `pinchOnWheel: false` (`PageStage.tsx:1896`, `1924`). Phones and tablets are untouched
-by every change below.
-
-## Work
-
-### A. `packages/core` — one lookup, no new arithmetic
-
-`juzPageIndex(pages)` → 30 entries, the first page of each juz, built on the existing
-`juzOf` (the repo already forbids a second membership implementation — `packs.ts:107-109`).
-Computed once and memoised at the App level, so a wheel flick is an array index rather than
-a 604-page scan. `nextWheelTurn` (`gestures.ts:443`) is reused **unchanged** for the juz
-axis with its own `WheelTurnState` ref — the state machine is already exactly right.
-
-### B. `PageStage.tsx` — the wheel loses zoom, the handle gains it
-
-- **Wheel handler** (`1930-2004`): the `ctrl`/`meta` branch stops zooming and returns having
- only `preventDefault`ed. Swallowing rather than passing through to the browser's own page
- zoom is deliberate — "I don't want zoom driven by scrolling" covers browser zoom too, and
- letting it through would bounce the desktop breakpoint as CSS px change. One line to
- reverse if that reads wrong in the hand.
-- **New `Shift` branch → `onJuzTurn?: (step: 1 | -1) => void`.** Hazard to handle: several
- browsers deliver `Shift`+wheel as **`deltaX`**, so this branch reads `deltaY || deltaX`.
- §6's "only `deltaY` is bound" rule keeps its reason (the horizontal swipe is the browser's
- back/forward) and gains this stated exception. Wired on **both** leaves, for the reason
- §6 already gives `onTurn`: a wheel over the facing leaf that did nothing reads as a dead
- half of the page.
-- **Handle gains `setZoom(z): number`** — clamps to `MIN_ZOOM…MAX_ZOOM`, anchors at the
- stage centre through the existing `zoomAbout` (`639-653`), returns what it applied. No
- second copy of the anchor arithmetic; §7 ⑨'s fix stays the only one.
-- **`onFitChange` and `atFitRef` are deleted.** The `ResizeObserver` added alongside them
- **stays** — §8 ② records an independent reason (a window resize was one gesture behind
- the truth).
-
-### C. `App.tsx` — explicit state, one write path
-
-- `soloLeaf` → `pageMode: "one" | "two"` (default `"two"`), `solo={desktop && pageMode === "one"}`.
-- `zoom` state holds the **requested** level; the stepper calls `stageRef.current.setZoom`
- and stores what it returns. No per-frame callback — §8 ② refused one for a good reason
- (`view` is a ref precisely so a pan does not re-render a 170 KB SVG's parent).
-- **Every landing resets `zoom` to 1** in the same place it calls `navigateTo`/`showPage`/
- `turnTo` — App is the sole caller of all three, so there is one place to keep in step.
- Crossing the breakpoint resets it too, which is defect ② closed by construction.
-- Remove `onFitChange` from the live stage.
-
-### D. `DesktopChrome.tsx` — two controls beside the language switch
-
-- A **page-mode `radiogroup`** («صفحة واحدة» / «صفحتان»), mirroring the existing `langRow`
- at line 57 — same markup, same keyboard behaviour, no new pattern.
-- A **zoom stepper**: `−` · readout · `+`, stepping by `1.2×` so it keeps the wheel's old
- multiplicative feel (the same proportion at 0.8× as at 5×).
-- **The stepper is disabled in two-page mode**, with the toggle beside it as the way out.
- §8 ② rendered two magnified leaves and found they lose their edges and read as one
- continuous column; and §3's finding is that a leaf is height-bound at ~398 px in a spread,
- so zoom there buys nothing anyway. The toggle is the gateway to magnification.
-- New strings in `messages/ar.json` + `en.json`, `.gen.ts` regenerated; `gate:i18n` already
- enforces parity.
-
-### E. Tests and registers
-
-- **`e2e/desktop.spec.ts`** — the "`ctrl`+wheel zooms by a step" test (`709`) and the whole
- *"the book closes above fit"* describe (`751-806`) assert behaviour that is being removed;
- they are **rewritten, not deleted**: `ctrl`+wheel now turns nothing *and* zooms nothing,
- the toggle closes and opens the book, the stepper zooms the live leaf only, `Shift`+wheel
- lands on the next juz's first page. Plus a new row for defect ②: zoom at 1440 → 800 →
- 1440, and assert the two leaves agree.
-- **Unit** — `juzPageIndex` in core; `PageSpread.test.tsx` unchanged in substance (`solo`
- was always a prop); `DesktopChrome.test.tsx` gains the two controls.
-- **Docs** — `desktop.md` §8 ② is `fixed`, so it gets a superseding note rather than an
- edit-in-place (its *outcome* survives; its *mechanism* is replaced); §6's wheel bullets
- rewritten; §5 gains the two controls. `page-turning.md` §7 ③'s `ctrl`+wheel half is
- superseded and says so. A row in `decisions/desktop-vs-mobile.md`, a new
- `docs/decisions/` doc, `docs/issues.json` (+ `pnpm issues:doc`), `docs/use-cases.json`
- (+ `make use-cases-doc`), and `docs/map.json` **hand-edited, never generated**.
-
-### Files
-
-| File | Change |
-|---|---|
-| `packages/core/src/packs.ts` (+ test) | `juzPageIndex` |
-| `apps/web/src/components/PageStage.tsx` | wheel: −zoom, +`Shift`→juz; handle `setZoom`; −`onFitChange` |
-| `apps/web/src/App.tsx` | `pageMode` + `zoom` state, landings reset zoom, juz wiring |
-| `apps/web/src/components/DesktopChrome.tsx` (+ css, test) | mode radiogroup + zoom stepper |
-| `apps/web/src/messages/{ar,en}.json` + `.gen.ts` | the new strings |
-| `apps/web/e2e/desktop.spec.ts` | four rows rewritten, one added |
-| `docs/design/desktop.md`, `docs/design/page-turning.md` | §8 ② superseded, §6 and §7 ③ rewritten |
-| `docs/decisions/desktop-vs-mobile.md` + a new decision doc | the row and the record |
-| `docs/issues.json`, `docs/use-cases.json`, `docs/map.json` | the registers |
-
-## Verification
-
-1. `pnpm issues:doc` && `make use-cases-doc`, then `git add -A` && `make ci` green.
-2. `make e2e` — the rewritten desktop rows.
-3. **Manual, Chrome at 1440×900**, re-running the three original reproductions and asserting
- each is gone: `ctrl`+wheel over the facing leaf changes nothing; zoom → resize to 800 →
- resize back leaves both leaves agreeing; the stepper at its floor no longer flips the
- book. Then `Shift`+wheel lands on a juz boundary and announces it.
-4. Commit code and docs separately.
-
-## Not doing
-
-- **The per-page "touch bar" strip.** Separate feature, and it has a hard blocker worth
- stating: its colour-coded "self-reported mistakes" need a signal that does not exist —
- `revision.ts` stores *looks* only, and its doc comment explicitly forbids quietly
- absorbing other meanings into a `RevisionEvent`. It needs its own design pass.
-- **Persisting zoom across reloads** — a preference surface is a new axis; the stepper is
- session state until someone asks otherwise.
-- **Any mobile change**, and **any shared `View` across the two leaves** (§8 ② rendered it
- and rejected it).
+# Both plans this file has held are finished
+
+This file has carried two plans. **Neither is outstanding.** It is kept as the record of
+what they were and how each was checked, because the second one was audited on
+2026-08-17 against the tree rather than against its own account of itself, and that
+audit is the useful thing here.
+
+What is left in both cases is **reader work, not code** — sittings that have to be sat, a
+verdict that has to be given on a device. Those live in `docs/validation/ledger.json`
+and `docs/issues.json`, which are where somebody should look, not here.
+
+---
+
+## Plan one — the repo's most-repeated claim was false
+
+**Landed** across `87b4241`, `5676128`, `a97fa98`, `a053bbd`, with two gaps closed later
+in `2ec9f4d`.
+
+Twenty-two times across twenty files this project asserted some version of *there is no
+Quran text here*, and two files held running scripture — one of which shipped in the
+bundle. Both are gone. The claim is now scoped to what is vendored and shipped, and a
+gate refuses the next one.
+
+| item | state |
+| --- | --- |
+| ① draw the comparison from the page artwork, delete the typed table | done — `verse-diff.ts` replaces `verse-text.ts`, panel redrawn |
+| ② the second site, a pipeline test holding a phrase | done |
+| ③ a gate that refuses the next one | done — wired into the composite, the Makefile and CI |
+| ④ make the licence map see the whole assets folder | done |
+| ⑤ say where the transliteration table came from | done |
+| ⑥ the nine prose sites stating the rule unscoped | done |
+| registers — issues, map, the licensing design doc | done; the second site is tracked on its own row |
+
+**Two things the plan promised and nobody had run, since checked and passing:** none of
+the deleted table's 36 distinct token strings is byte-present in the built bundle
+(strings recovered from git, never typed); and adding an undeclared tree under the
+shipped assets folder now fails the licence map by name — tested by creating one,
+confirming the failure, removing it.
+
+**Two gaps the plan did not know it had.** The comparison panel had no row on the code
+map — not after the rewrite and not before it, the only trace being a note on the popover
+saying it showed "the diff against the current ayah" and pointing at nothing. The map
+gate missed it because it only checks pointers in *staged* files, so deleting a mapped
+file and adding its replacement passes in silence. Both files have rows now, and the note
+on the arithmetic carries *why* the table went, because that is the part a later reader
+would otherwise undo: a small text table genuinely does look simpler, and it would bring
+back all three defects at once. Separately, a skill heading still asserted the unscoped
+claim three lines above quoting the scoped rule that replaced it.
+
+**One measurement worth not re-taking.** Six of fourteen shipped edge notes carry Arabic,
+up to five words in one — more than the plan assumed — but the longest fully-vowelled run
+across all of them is **0**. Specimens, not passages, so within the rule as the gate
+measures it.
+
+---
+
+## Plan two — the instrument that asks the question has to be trustworthy first
+
+**Recovered from `git show 83041f3:.claude/plans/shimmering-wandering-lobster.md` and
+audited item by item on 2026-08-17. All of it landed.** It should not be restored as a
+plan; this table is what was worth keeping.
+
+Sixteen sittings covering all 1,877 marks the machine could not place from ink were about
+to be sat, and an audit of the page doing the asking found three faults that did not make
+the sitting slower but made its answers **mean something other than what they say**. The
+worst ran in the direction that looks like success: in dark mode the rectangles were drawn
+in near-invisible colours on paper that is deliberately never re-themed, and a reader who
+cannot see the box affirms it.
+
+| item | state in the tree |
+| --- | --- |
+| ① parse the page once per card, not once per pointer frame | done — `mount()`/`paint()` split, `non-scaling-stroke` |
+| ② keep the rectangles legible on white paper in both themes | done — four never-re-themed tokens, plus a dash pattern so the distinction survives colour-blindness |
+| ③a pin the buttons to the bottom | done — a sticky dock, `viewport-fit=cover`, safe-area padding |
+| ③b get the destructive control out of the thumb corner | done |
+| ④ make the affirm button look pressed | done |
+| ⑤ get the lede off the fold without charging a tap for any answer | done — brief/full toggle keyed off a stored flag |
+| ⑥ say which mark, properly | done — the containing word is kept, and the mark's name reaches all three branches |
+| ⑦ 44px on the four undersized controls | done |
+| ⑧ the scorer was medianing increments and printing zero | done — one row per mark, and the reader's hand and the against-what-ships figures printed as two numbers under two sentences |
+| the hazard guard — the whole page is one template literal | done — two assertions that the emitted HTML contains no backtick and no interpolation |
+| registers — map rows, the ledger, the issues rows | done |
+
+**The item the plan called *owed and still unrecorded* is recorded**, and says more than
+the plan expected it to. The check stays `pending` on purpose: only 160 of 1,877 fallback
+marks have been sat. It reads the two populations apart — sixty marks placed from their
+own ink, every one affirmed, which bounds visible error at about 5% rather than at zero
+and carries its own caveat that a gross error was structurally impossible on those cards;
+against 160 fallback marks of which 158 carry a complaint. Two further instrument defects
+were found *while reading that sitting* and fixed.
+
+**Checked, not assumed:** the ETL suite is 303 tests across 10 files, all passing.
+
+### What is actually left, and it is not code
+
+**1,717 of the 1,877 marks have not been sat.** That is the work. The instrument is
+trustworthy now, which was the whole point of the plan — it was never the point that the
+instrument existed.
+
+Two verification steps from that plan need a device and a person, and neither can be done
+from here: opening a part on the phone over the tailnet in dark mode to confirm by eye
+that both boxes are visible on white paper, the buttons do not move between cards, and the
+largest card does not stutter under a drag.
diff --git a/.claude/plans/sitting-instrument.md b/.claude/plans/sitting-instrument.md
new file mode 100644
index 00000000..e87121e7
--- /dev/null
+++ b/.claude/plans/sitting-instrument.md
@@ -0,0 +1,286 @@
+# The instrument that asks the question has to be trustworthy first
+
+## Context
+
+Sixteen sittings are about to be sat. Between them they cover all **1,877** marks the machine
+could not place from ink — every one seen, not sampled — at roughly an hour each. That is
+sixteen hours of the scarcest thing this project has.
+
+Before spending it, the page that does the asking was audited against the frontend-design
+brief, and three of its findings do not make the sitting *slower*. They make its answers
+**mean something other than what they say**:
+
+1. **In dark mode the rectangles are drawn in near-invisible colours.** The crop's paper is
+ deliberately never re-themed — a mus'haf page stays on white — but the two rectangle stroke
+ colours *are* re-themed, and then drawn on that white. Measured: our box 5.05:1 → **2.49:1**,
+ the reader's box 4.89:1 → **1.70:1**, at a constant 1.64px stroke. A reader who cannot see the
+ box affirms it. This finding runs in exactly the direction that looks like success.
+2. **A destructive control lands where the reader's thumb already is.** The answer list sits
+ directly above Back/Next, each answer adds a ~28px row, and the "take it back" button in that
+ row is right-aligned — into the corner Next occupied a moment earlier. Two taps of Next can
+ retract the answer just given.
+3. **Every pointer frame re-parses the page.** The drag handler rebuilds the stage from
+ `c.svg` — 2.0 / 9.2 / **23.2 KB** of path data (min/median/max) — on every `pointermove`. A
+ correction that stutters is a correction the reader gives up on and affirms instead.
+
+Below those, four findings cost time and accuracy rather than truth: the affirm button looks
+the same pressed as unpressed; it sits at y≈842 on a 393px phone, below the fold, behind a
+~275px lede that never goes away; 91 of 115 cards carry the mark's containing word and throw it
+away, while the sentence naming the mark omits the mark's own name and is the dimmest text on
+the card; and four **destructive or load-bearing** controls are under 44px.
+
+**Decided with the reader, 2026-08-14:** the buttons get pinned to the bottom of the screen and
+the picture keeps its own shape (nothing ever shrinks); the fixes land **before** part 1 is sat,
+which costs nothing because the sixteen parts were re-dealt and **0 of 1,877** have been
+answered on the new deal; scope is the three data-corrupting findings plus the four
+time-and-accuracy ones.
+
+**Outcome:** all 1,877 marks are answered on one instrument, and the transcript means what it
+says.
+
+### What the audit explicitly warns against, and why
+
+- **Do not put the fault buttons behind a disclosure.** Adding a tap to reporting a fault while
+ affirming stays free biases the exact ratio the sitting exists to measure. Change weight, not
+ cost.
+- **Do not highlight the ligature's own ink in the crop.** Where the ink is, is the unknown
+ being measured. Also ruled out and not to be revisited here: centring the crop on the mark's
+ own ink; SVG resize handles on the rectangle.
+
+---
+
+## Also in scope: the scorer is reporting the wrong number
+
+`score-mark-report.mjs` (~377-387) medians the `by` field across every placement **event**. Two
+things are wrong with that, and they were verified against the banked transcript, not argued:
+
+**It medians increments, and increments cancel.** `by` is always an increment — both the nudge
+pad (`flush`) and the drag (`pointerup`) send the burst, and `to` is the running total. Of 179
+consecutive pairs on the same mark, **133** chain exactly; the other 46 are marks where the
+reader pressed *put it back where it was*, and a banked transcript has retractions already
+applied, so the superseded placements are gone and the chain legitimately restarts. Nothing is
+wrong with the field. What is wrong is medianing it: opposite-signed ±0.1 increments cancel, and
+one mark with 44 events outvotes 25 marks with one each. The scorer prints **0.000 / 0.000**.
+
+**And `to` is not the reader's hand.** `to = at + total − box`, so it is measured from the
+**shipped** box and already contains the correction the pipeline applied. Three different
+numbers, all real, currently collapsed into one wrong one:
+
+| question | across | down |
+|---|---|---|
+| what the scorer prints today | 0.000 | 0.000 |
+| where the reader put it, against what ships | −3.569 | −3.134 |
+| **how far the reader's own hand moved it** | **−2.468** | **−2.010** |
+
+The middle row is what corroborates the ink measurement: the corpus sets its text lower and
+further across than the ornament fit predicts, `dty` negative on **599 of 600** pages. Two
+instruments, same direction, same rough size. That is worth printing correctly.
+
+---
+
+## The work
+
+Fixes are ordered so each lands in one place rather than in a string another fix is about to
+rewrite. **Do them in this order.**
+
+### ① Parse the paths once per card — `build-mark-report.mjs`, `paint()` ~1084-1122
+
+Split `paint()` into `mount(c)` and `paint()`.
+
+- Module-level `let drawnFor = null, hitEl = null, boxEl = null, mineEl = null;`
+- `mount(c)` writes `stage.innerHTML` **once per card**: `c.svg` plus the `.hit`, `.grab` and a
+ hidden reader's rect; caches the three nodes; sets `drawnFor = c.id`.
+- `paint()` calls `mount(c)` only when `drawnFor !== c.id`, then `setAttribute`s x/y/width/height
+ on the cached nodes and toggles the reader's rect's `visibility`. The `viewBox` write stays.
+- The `.hit` slack `t = vb[2]/30` stays per-frame — it is unit-based and framing-dependent.
+- **Drop `sw = vb[2] / 220`** and use `vector-effect="non-scaling-stroke"` with `stroke-width` in
+ CSS pixels. That constant is 1.64px *only because* the stage is `width: 100%`; leaving it in
+ makes stroke width a function of each card's aspect the moment anything sizes the stage
+ differently. One fewer per-frame attribute write, too.
+
+This is what `crop()`'s two-viewBox design was always for — the framing toggle becomes one
+attribute write instead of a 23 KB reparse — and it has never actually delivered it.
+
+**Correction to the audit:** pointer capture is on the **stage** (`stage.setPointerCapture`,
+~1158), gated by `onRect(p)` (~1141) — not on a rect node. `docs/map.json:752` describes the
+gate. So rebuilding mid-gesture never risked capture; do this fix for the parse cost alone.
+
+### ② Keep the rectangles legible on white paper, in both themes — CSS ~514-525
+
+The rule to establish and then never break: **nothing drawn on the paper is themed.**
+
+- Add to bare `:root` **only**: `--ours-line`, `--ours-wash`, `--yours-line`, `--yours-wash`
+ (suggested `#c2410c` / `#c2410c1f` and `#15803d` / `#15803d24`).
+- Leave `--ours` / `--yours` / `--ours-fill` / `--yours-fill` exactly as they are and keep them
+ re-themed — they are **chrome** (affirm button, pressed state, focus ring, the answer list, the
+ bank panel) and sit on `--field`, so they must stay themed.
+- `mount()` draws with the four new tokens. Add one line of comment saying they join `--paper`
+ and `--ink` in the never-re-themed set, and *why*.
+- Carry the distinction independently of hue at the same time:
+ `stroke-dasharray="0.6 0.4"` on the reader's rect. Two rectangles that differ in dash as well
+ as colour survive deuteranopia, and survive anyone re-theming the palette later.
+
+Light theme comes out byte-identical. Dark theme returns to 5.05:1 and 4.89:1.
+
+### ③ Pin the buttons to the bottom — markup ~679-697, CSS
+
+Two independent halves; **③b must land whichever layout is chosen.**
+
+**③a — the dock.** Move the affirm row and the Back/Next row into one `
` with
+`position: sticky; bottom: 0; background: var(--field); border-top: 1px solid var(--edge);
+padding: .5rem 0 calc(.5rem + env(safe-area-inset-bottom));` and bump the main column's bottom
+padding. **Sticky, not fixed** — it keeps document flow and does not fight the iOS URL bar.
+Requires `viewport-fit=cover` on the viewport meta (~511) or `env()` resolves to zero; that is
+also the whole of the safe-area finding.
+
+The picture keeps its natural per-card shape (295–410px judging, 266–529px identifying, measured
+across all 1,877 marks). Nothing shrinks, `ptIn()` is untouched, and on the tallest cards the
+reader scrolls the picture behind a dock that never moves. **Explicitly not doing:** normalising
+the two viewBoxes to a constant aspect inside `crop()` — it either shrinks the mark or makes
+every card as tall as the tallest, and the ring clip would have to be re-ordered after the
+normalisation or the new margin comes back empty of the ink that belongs in it.
+
+**③b — get the destructive control out of the thumb corner.** In the answer list row, move "take
+it back" to the **start** of the row (button, then the kind, then the detail) so nothing tappable
+sits bottom-right, and give the list at least 44px of bottom margin. **This must ship before ⑦
+enlarges that button to 44px**, or the mis-tap target doubles.
+
+### ④ Make the affirm button look pressed — CSS ~596
+
+`button.affirm[aria-pressed="true"]` gets a solid fill: background `var(--ours)`, text
+`var(--field)`, matching border. Inverts legibly against both themes and reads as *on* without a
+second glance. Add a check glyph via `::before` on the pressed state only. **Do not change the
+label text** — that would move the fold ⑤ just fixed.
+
+*(The audit's specificity claim was off by one rule: `button.affirm` and the generic pressed rule
+are both (0,1,1) and settled by source order; the actual out-specifier is
+`button.affirm[aria-pressed="true"]` at (0,2,1). Conclusion unchanged.)*
+
+### ⑤ Get the lede off the fold without touching the fault buttons — markup ~640-646
+
+Split the lede into the full text and a one-line brief, with a quiet toggle between them. The
+brief shows once the reader has demonstrably read it: `seen > 0`, persisted through a
+`keepRead`/`keptRead` pair written **exactly** like the existing `keepSeen`/`keptSeen`
+(~733-736) — try/catch on every call, because a `file:` origin throws.
+
+Roughly 230px comes off the fold. **No answer costs a tap more than it does today**, so the
+affirm/fault ratio the sitting measures is untouched. That constraint is the whole design.
+
+### ⑥ Say which mark, properly — `identify()` ~1240-1264
+
+- The card already carries the containing word and throws it away for the 91 cards that have
+ both it and a single letter. Keep the big letters as they are, and append the word after them
+ at ~1rem, dim, `lang="ar" dir="rtl"`.
+- Put the mark's **name** into all three branches. It reaches only the `of === 1` branch today.
+- The sentence naming the mark is the question, not a footnote: `--dim` → `--text`,
+ .82rem → .9rem.
+
+### ⑦ 44px on the four — CSS
+
+All four are omissions from a convention this file already keeps elsewhere (the note pad and the
+chips are already 44/48):
+
+| control | today | why it matters |
+|---|---|---|
+| take it back | ~24px | destructive — **land ③b first** |
+| the two view toggles | ~31px | pressed constantly, both framings |
+| hand over what I have said | ~29px | the reader's safety net |
+| put it back where it was | 34px | destructive; also give it `margin-left: auto` so it is not shoulder-to-shoulder with "Finer steps" |
+
+Land **after** ⑤ and ③, since each of those moves the fold.
+
+### ⑧ The scorer — `score-mark-report.mjs` ~377-387
+
+Do 1-4 **before** 5. Steps 1-4 work on the two transcripts already on disk, which cannot be
+re-recorded; once the headline reads `to`, the rename stops being load-bearing.
+
+1. Collapse to **one row per mark** — walk in order, keep the last placement carrying a `to`.
+2. Print the reader's **hand**: `to` minus the displacement already shipped
+ (`drawnAt(r) − r.box`, both already available). Median/p90/worst of the magnitude, plus signed
+ per-axis medians. **−2.468 / −2.010** on the banked sitting.
+3. Print, under a **separately worded** sentence, where they landed **against what ships** —
+ median final `to`, **−3.569 / −3.134** — and say in the prose that this one includes the
+ correction the pipeline already applied, so no reader ever differences the two.
+4. Say *n marks*, and say how many events those marks took. 26 marks / 205 events is itself a
+ finding about the nudge pad.
+5. ~~Rename the drag's field.~~ **Dropped.** Both paths genuinely send an increment and `to` is
+ the running total, so one name is right, and a rename would only make the two transcripts
+ already on disk unreadable. (The drag path does not need `flush()`'s save-and-restore around
+ `dropVague` either: it captures the total at ~1190, before, and reassigns at ~1199, after.)
+
+---
+
+## The hazard, and the guard for it
+
+**The whole page — CSS, markup and JS — lives inside a template literal.** Backticks are
+forbidden anywhere in the emitted region *including comments*, and `${` must not appear except
+as a deliberate interpolation. This has broken the file three times.
+
+Add two assertions to `build-mark-report.test.mjs` that say so out loud: the emitted HTML
+contains no backtick and no `${`. Two lines, and they document the trap.
+
+## Tests
+
+The suite today parses the head and the cards out of the emitted HTML and string-matches the
+emitted script. Both styles extend cleanly; there is no visual coverage and none is being
+invented here.
+
+| fix | what a test would actually assert |
+|---|---|
+| ② **highest value** | the dark block re-themes **none** of the paper, the ink, or the four new rectangle tokens; and both rectangle line colours clear 3.0:1 against the paper. Twelve lines, no browser, and it is precisely the invariant that broke. |
+| ① | the paint function contains no `innerHTML`, and `c.svg` appears exactly once in the emitted script, inside `mount`. Coarse, in the same spirit as the existing replay test, and it catches the regression that matters. |
+| ⑥ | data, not layout: a single-letter card always carries a longer containing word — this is what makes the fix possible and what would silently stop being true. Plus that the mark's name reaches all three branches. |
+| ⑤ | the brief lede exists and the collapse is keyed off the stored flag; a regression deletes the second copy. |
+| ⑧ | in `score-mark-report.test.mjs`, which already builds synthetic transcripts and asserts on stdout: one mark nudged +0.5, −0.4, +0.4 (final `to` 0.5) and a second moved once by 0.5 — assert the printed median is **0.5, not 0.4**, and that the line says **2 marks**, not 4 events. That fixture would have caught this. |
+| ③④⑦ | not testable here beyond CSS-text presence. Fold position, per-frame cost and thumb geometry need a browser, and there is no in-repo harness for one. |
+
+## Registers
+
+- **`docs/map.json:752`** — the `build-mark-report.mjs` note is the canonical home and already
+ records *why* each control sits where it does, including two prior interaction regressions.
+ Fixes ①②③ belong there. Hand-edited.
+- **`docs/map.json`** — `serve-sittings.mjs` has **no row anywhere in `docs/`**. It needs one;
+ it is the thing that makes an answer survive the browser losing it.
+- **`docs/validation/ledger.json` → `placement-what-kind-of-wrong`** — its 13 runbook steps
+ **are** the reader's on-screen instructions. ⑤ and ⑥ change what the reader sees; the steps
+ change with them. Then `pnpm guide`.
+- **`docs/issues.json`** — only for the findings that **distorted a measurement**, which is this
+ repo's convention for a review tool (precedent: the "wrong instrument" paragraph inside
+ `nobody-has-looked-at-the-placement-verdicts`). That is finding ② and the scorer ⑧ — not the
+ ergonomics. Then `pnpm issues:doc && pnpm gate:issues`.
+- **A design doc is not warranted yet.** `docs/design/encoding-inspector.md` is the template if
+ it ever is.
+
+### Owed from the sitting already completed, and still unrecorded
+
+`make record CHECK=placement-what-kind-of-wrong RESULT='…'` — sixty marks from the placed set,
+every one explicitly affirmed, no faults. Bounds visible placement error at about **5%, not at
+zero**. The caveat that must survive into the wording: the placed population is defined by a
+match of 0.55 or better and a displacement under 3 units on a mark 5.6 × 3.6, so **gross errors
+were structurally impossible on those cards**. The check also owes its `tunes` step — a manual
+result must tighten something automated, and the gate fails if it tunes nothing.
+
+## Verification
+
+1. Rebuild all sixteen and confirm the deal is unchanged — same slice fingerprint, 1,877 marks
+ across sixteen parts, 0 answered marks re-asked:
+ `for n in 1..16; node scripts/build-mark-report.mjs --rows out/mark-rows.line-tilt.json
+ --set fallback --seed 23 --part $n/16 --answered … --out out/sit.fallback-$n-of-16.html`
+2. `pnpm --filter @hifth/etl test` — the new assertions above, including the two hazard guards.
+3. Re-run the scorer over the two banked transcripts and confirm it prints the hand and the
+ against-what-ships figures as **two** numbers under two sentences.
+4. Open part 1 on the phone over the tailnet, in **dark mode**, and confirm by eye: both boxes
+ visible on white paper; the buttons do not move between cards; the drag does not stutter on
+ the largest card; affirm is above the fold and obviously pressed when pressed.
+5. `pnpm issues:doc && pnpm gate:issues`; `pnpm guide` after the ledger edit;
+ `pnpm gate:validation`.
+6. `git add -A && make ci`. Commit code and docs **separately**.
+
+## Operational
+
+- `cd /Users/omareid/Workspace/git/hifth && ./scripts/with-lock.sh
"sh -c ''"`, and
+ re-export `PATH` **inside** the quoted command every time.
+- Registers are hand-edited, never generated. Never `--no-verify`.
+- `build-mark-report.mjs`, `serve-sittings.mjs`, `score-mark-report.mjs` and their tests are all
+ still **untracked** — CI does not run them today. This work arrives as part of their first
+ commit, not as a diff against a baseline.
diff --git a/.claude/prompts/hifth-continue.md b/.claude/prompts/hifth-continue.md
new file mode 100644
index 00000000..9a529cfd
--- /dev/null
+++ b/.claude/prompts/hifth-continue.md
@@ -0,0 +1,329 @@
+# Continuing work on Hifth
+
+You are picking up work on **Hifth**, a Quran app for huffaz (people memorizing/reciting the
+Quran). This prompt is self-contained — read it fully before touching anything, then read
+`/Users/omareid/Workspace/git/hifth/CLAUDE.md`, which governs how documentation, decisions, and
+registers work in this repo and is not repeated here.
+
+## The end goal
+
+**Ship web v1.0.** Per `docs/PLAN.md`'s own tracking table, every engineering loop (0 through 6b)
+is complete; **Loop 7 is in flight and what remains is not a loop but a person** — a hafiz
+revision session with zero friction notes, plus a handful of on-device checks only a human can
+run. Everything below is either (a) work still needed to make that session trustworthy — the
+piece-union guard, the open placement decision — or (b) work parked beside it that doesn't block
+it — the Experience Atlas page, Track B (Capacitor), the tajweed colour picker.
+
+Do not treat this as a laundry list to clear in order. Read what's open, form a view on what
+matters most against the v1.0 goal, and say so before diving in — most of what's below needs a
+judgment call, not just execution.
+
+## The working-backwards plan (trust first)
+
+The organizing question is not "what's open?" but **"what has to be true for a hafiz to open this
+during revision and trust it — and keep trusting it?"** Everything below is ordered by working
+backwards from that, not by task age. A hafiz's eye on the mus'haf is exact: a rectangle a hair off
+the mark, an Arabic numeral written wrong, a hop that lands one ayah early — each is seen instantly
+and, once seen, discredits the whole instrument. Trust is the product, and it decomposes into three
+things, in order of how fast a breach destroys it:
+
+1. **It does not lie about the page.** Every mark sits on the ink it claims; every hop lands where it
+ says; nothing user-visible is quietly wrong.
+2. **It survives the real session.** On an actual phone, offline, for the length of a revision — no
+ jank, no lost place, screen reader intact.
+3. **It becomes theirs.** It learns where *this* hafiz personally slips, so it stops being a generic
+ tool and becomes their revision companion. (This is the confusion-point feature — new this session.)
+
+Working backwards from those, all meaningful enhancements rank into four tiers. **Tiers A and B are
+the road to web v1.0; C and D come after it.**
+
+**Tier A — the v1.0 gate itself (nothing ships without these).**
+- **A1. Rule `mark-placement`, then wire the guard.** The piece-union guard's evidence is now
+ complete (§1, item ㉟): run on the never-sat marks it endorses ~73% and refuses ~27%, catching the
+ swept-in-neighbour candidates. Ruling the open decision (option H) and wiring the 142 endorsed
+ corrections into what the app draws is what turns that evidence into a page that doesn't lie. The
+ ruling is the owner's; the wiring waits on it. This is the single highest-leverage open item.
+- **A2. The human checks that *are* Loop 7's exit** — `docs/validation/ledger.json`, all `USER:`:
+ the on-device perf verdict (#57), the screen-reader walkthrough (#58), offline-survival-8-day,
+ revision-record-lands-on-a-phone, and the three placement-by-eye checks
+ (`placement-correction-by-eye`, `placement-holds-off-its-own-pages`, `placement-what-kind-of-wrong`).
+ These are the literal "not a loop but a person." No agent can run them; help the user run `/validate`.
+
+**Tier B — trust a hafiz's eye would break on (do before or alongside the revision session).**
+- **B1. `arabic-number-agreement`** — 37 forms silently substitute Arabic numerals; a hafiz sees a
+ wrong numeral at a glance. Each fix is a one-line edit but needs a hafiz's approval, so pair it with
+ the revision session rather than guessing.
+- **B2. `the-frame-is-registered-on-ornaments-and-used-to-place-text`** — the named root cause of a
+ run of alignment symptoms; fixing the root is worth more than the symptoms.
+- **B3. `the-crop-draws-neighbours-with-nothing-saying-so`** — `comparison-crop` is decided (F) but
+ the build doesn't yet say so on the page.
+
+**Tier C — make it theirs / stop latent rot (deepens trust; not a v1.0 blocker).**
+- **C1. The confusion-point feature** — a hafiz documents where their memory slips between ayahs, and
+ the app suggests the similar verses (mutashabihat) they likely confused. Design is being drafted this
+ session into `docs/design/confusion-points.md` (a design, to become an action plan later).
+- **C2. Data-integrity risks** — `diacritic-ids-are-an-unversioned-wire-format`,
+ `the-page-table-has-only-one-witness`, `position-might-separate-the-look-alike-pairs`. Latent today;
+ they bite a future edition or a future reader.
+
+**Tier D — parked until after web v1.0.**
+- **D1. Experience Atlas** — merge the worktree's front-door page + `gate-artifacts.mjs` (§3). A
+ convenience, not a trust gate.
+- **D2. `mark-C` (#171), `tj-5` colour picker (#176), `mark-D` (#177)** — decided or unblocked, not
+ yet built; none block the revision session.
+- **D3. Track B (Capacitor, #10)** — gated on web v1.0 and possibly on `gpl-and-the-app-store`.
+
+The rest of this file is the detail behind each tier — read the section a tier points at when you pick
+it up. The tier is the priority; the section is the substance.
+
+## Two working trees, same repo
+
+- **Main repo, branch `mark-nudge-identify-the-mark`** — `/Users/omareid/Workspace/git/hifth`.
+ Working tree is clean as of this prompt. This is where nearly everything below happens.
+- **Worktree, branch `experience-atlas`** — `/Users/omareid/Workspace/git/hifth-experience-atlas`.
+ Same repo, checked out separately. Has real uncommitted work sitting in it — see
+ "The Experience Atlas worktree" below.
+
+**Standing rule carried into this session: never commit in either tree unless explicitly asked.**
+Same for publishing/republishing an artifact to claude.ai — that always needs a per-action ask.
+
+## What's actively open, in priority order
+
+### 1. Task #208 — the piece-union guard (`mark-H/E`), in progress
+
+This is the most-developed open thread and the nearest thing to a v1.0 blocker among the
+engineering work. Full narrative lives in `docs/design/mark-registration.md`, items ㉘ through
+㉞ (search for `### ㉞` to land on the latest). Short version:
+
+- The app's mark-placement rectangles sometimes come from a "piece-union" candidate that reaches
+ for a mark's actual ink rather than resizing a guess toward it. That reach can go wrong two
+ ways: growing the candidate too large, or (the more common failure) shrinking it too small.
+ Item ㉜ designed a guard that only caught growth; items ㉝ and ㉞ tested it against ground
+ truth and found **a symmetric ratio guard — refuse when the candidate's area is more than
+ ~1.75x or less than ~0.571x the shipped rectangle's area — catches 87% of real disagreements
+ (34 of 39) at a 1.9% false-refusal rate (2 of 106)**, confirmed on 181 ground-truth marks
+ (weak-size-part1 + weak-size-part3 sittings combined).
+- The scoring script is committed: `packages/etl/scripts/score-piece-union-guard.mjs`.
+- **The re-deal is now made — run it now — and executed (item ㉟, this session).** ㉜/㉝/㉞ each
+ left open whether to trust the guard's verdicts on the never-sat marks or hold for another
+ wrong-size sitting first; the owner chose to run it, on the reasoning ㉞ already surfaced (the
+ cutoff held under a *doubling* of ground truth, so a third sitting moves it less than the second
+ did, while it blocks nothing). Applied at 1.75/0.571 to every never-sat refused mark with a
+ piece-union candidate: **142 of 195 accepted (73%), 53 refused (27%)** — the refusals catching
+ candidates grown or shrunk implausibly (worst area ratios 29.6, 20.5, 18.2, plainly swept-in
+ neighbours). The accept rate holds at ~73–75% whichever way "never sat" is read. Note the corpus
+ has moved well past ㉞'s counts: 209 refused marks now carry a reader's answer (not 145), so
+ waiting for `weak-size-part2` buys even less than ㉞ thought.
+- **What ㉟ deliberately did NOT do — the one open step here:** wiring the 142 endorsed corrections
+ into what the app actually draws. That act *is* adopting option H of the open `mark-placement`
+ decision (§2), so it waits on that ruling rather than pre-empting it. ㉟ is the evidence that ruling
+ needs; the wiring is A1 in the plan above.
+- Related, blocked-on-this: `mark-H/C` (task #188, breaking the fit-to-ink / score-by-ink
+ circularity) and `mark-H/D` (task #189, deciding what ships — word boxes move too).
+
+### 2. The open decision: `mark-placement`
+
+`docs/decisions.json` → id `mark-placement`, the only decision left with `status: open` (20
+total, 19 decided/living). Six options (A/B/F/G/H/I) for whether the app corrects rectangle
+placement per-page, per-line, or per-mark; the page arguing it is
+`docs/design/mark-placement.html`, built by `node scripts/build-placement-options.mjs`, published
+at `https://claude.ai/code/artifact/7652b2f5-61a1-4072-bfab-ef3b649e55f5`.
+
+**This page changed in the last session** (commit `a5c4160`, already landed): each option now
+says what a reader would actually notice, not just how far off the rectangle measures, and a
+drawn legend replaced the one-line colour gloss. **The published claude.ai artifact was not
+republished with that change — it is currently stale relative to the checked-in page.** Before
+this decision gets ruled on, either republish the artifact (ask first — publishing needs explicit
+per-action confirmation) or note the drift explicitly if leaving it be for now.
+
+This decision is also the natural output of task #208: option H ("put each mark where its own ink
+is, and line the rest up") is the piece-union approach, so how the guard/re-deal question above
+resolves is direct evidence for ruling on this.
+
+### 3. The Experience Atlas worktree
+
+`/Users/omareid/Workspace/git/hifth-experience-atlas`, branch `experience-atlas`, has real
+uncommitted work — a "Hifth Experience Atlas" landing page (one front door listing every shipped
+feature, every design-decision page, every unclaimed published artifact, and the registers'
+health) plus a new CI gate, `scripts/gate-artifacts.mjs`, that enforces the `docs/artifacts.json`
+schema described in that file's own header comment.
+
+Current `git status --short` in that tree:
+
+```
+ M .githooks/pre-commit
+ M .github/workflows/ci.yml
+ M Makefile
+ M docs/artifacts.json
+ M docs/design/decision-board.html
+ M docs/design/mark-labels.md
+ M docs/design/sub-word-marks.md
+ M docs/issues.json
+ M docs/issues.md
+ M docs/map.json
+ M package.json
+?? docs/design/experience-atlas.html
+?? scripts/artifacts.mjs
+?? scripts/build-experience-atlas.mjs
+?? scripts/gate-artifacts.mjs
+```
+
+This was built by an earlier build agent and has already been checked once: a real bug (two
+colliding `### ①` open-question headings in `mark-labels.md`, causing `docs/issues.json`'s
+`mark-labels-no-register-home` entry to point at the wrong heading) was found and fixed, and all
+four gates (`gate-artifacts.mjs` plus the pre-existing three) pass, both full and `--files`-scoped.
+The already-published Experience Atlas artifact
+(`https://claude.ai/code/artifact/19e5206d-3f76-4c47-ac26-b857b8c8b923`) does not need
+republishing for that fix — its build script (`scripts/build-experience-atlas.mjs`) only reads
+`decisions.json`, `artifacts.json`, and `map.json`'s `appFeatures`, never `issues.json`.
+
+**What's actually left here is a human decision, not more verification: does this page and gate
+get merged into the main branch, and if so, when/how.** It has not been reviewed by the user yet.
+Don't re-verify from scratch — the fix is solid — but do check whether anything has moved in the
+worktree since (`git status`, `git diff`) before assuming this description is still current.
+
+### 4. Open issues register — `docs/issues.json`
+
+37 open of 150 total (25 question, 4 defect, 8 risk) in the main repo. Full detail lives in the
+register and its rendered form (`make issues` / `docs/issues.md`) — don't restate it here, it
+rots. The defect/risk items worth knowing about by name before you start:
+
+- `arabic-number-agreement` (defect) — 37 forms silently substitute Arabic numerals; each fix is
+ a one-line JSON edit but needs a hafiz's eye to approve.
+- `the-crop-draws-neighbours-with-nothing-saying-so` (defect) — decision `comparison-crop` is
+ decided (option F) but the build doesn't yet say so on the page.
+- `gpl-and-the-app-store` (risk) — Track B (Capacitor/App Store) may be blocked by a GPL/App
+ Store licensing conflict; unresolved.
+- `track-b-order-is-unachievable` (defect) — half-fixed; a citation was checked and found to not
+ fully support the claim it's attached to.
+- `diacritic-ids-are-an-unversioned-wire-format` (risk) — marks ship as bare integers, not
+ versioned names.
+- `the-correction-is-only-judged-where-it-was-fitted` (risk) — placement correction coverage gap.
+- `the-frame-is-registered-on-ornaments-and-used-to-place-text` (defect) — named root cause of a
+ run of alignment symptoms.
+- `reach-for-the-ink-rather-than-resize-toward-it-guard` (risk) — **this is task #208's own
+ issues.json row**; its note says explicitly "read item ㉝" for current state, which is now
+ superseded by ㉞ (see section 1 above) — the issue row itself may need a refresh once #208's
+ re-deal question is settled.
+- `the-displacement-correction-could-go-stale` (risk) — only bites if a per-page correction is
+ adopted; no re-derivation mechanism exists.
+- `position-might-separate-the-look-alike-pairs` (risk) — shape alone can't separate certain
+ diacritic pairs (fatha/kasra, fathatan/kasratan).
+- `nothing-checks-the-package-tree-licences` (risk) — `gate:license*` gates don't scan
+ `node_modules`.
+- `the-page-table-has-only-one-witness` (risk) — a single data table backs three shipped outputs
+ with nothing cross-checking it.
+
+Run `make issues` (or `node scripts/build-issues-doc.mjs` to rebuild, if it's been hand-edited)
+to see the full current picture, including the 25 open questions not listed above.
+
+### 5. Human-only validation — `docs/validation/ledger.json`
+
+9 of 11 checks are `pending`, and **none of them can be run by an agent**:
+`perf-verdict-on-device`, `screen-reader-walkthrough`, `kfgqpc-terms-primary-source`,
+`edge-spot-audit`, `offline-survival-8-day`, `revision-record-lands-on-a-phone`,
+`placement-correction-by-eye`, `placement-holds-off-its-own-pages`, `placement-what-kind-of-wrong`.
+These map to task-board items #55, #57, #58, #59, #61 (all tagged `USER:`) and are the literal
+"what remains is not a loop but a person" from `docs/PLAN.md`. Don't try to substitute automated
+checks for these — flag them to the user and, if useful, help them run `/validate` to walk
+through what's needed.
+
+### 6. Parked, not blocking v1.0
+
+- **Task #171 `mark-C`** — ship the shards, gate, and skin at mark granularity (the `B` option
+ decided in the last session, see below).
+- **Task #176 `tj-5`** — the colour-settings surface itself (readers changing tajweed colours),
+ now unblocked by the `tajweed-colours` decision below but not yet built.
+- **Task #177 `mark-D`** — whether the ink in the app's boxes matches what it claims.
+- **Task #52** — tajweed golden row, **blocked by #9** (Loop 7), gated on hafiz sign-off plus a
+ test-only skin flag.
+- **Task #10 / Track B** — Capacitor iOS-then-Android wrapping, **blocked by #9**, and possibly on
+ the `gpl-and-the-app-store` risk above getting resolved first.
+
+### Task-tool dependency graph, as of this prompt
+
+The session task tracker (`TaskList`) currently holds these 14 open items, with three real
+blocking edges — **#9 is the load-bearing one**, since both #10 and #52 sit behind it:
+
+```
+#9 Loop 7 → web v1.0 ← blocked by #57, #58 (both USER: on-device checks)
+ ├─ #10 Track B (Capacitor) ← blocked by #9
+ └─ #52 Tajweed golden row ← blocked by #9
+```
+
+Everything else (#55, #59, #61, #171, #176, #177, #188, #189, #208) is unblocked in the tracker
+and can be picked up independently. **Completed tasks are archived, not left in the live list**:
+when a task finishes, write a dated entry to `docs/tasks/done.md` (pointing back to the commit or
+design-doc item that carries the actual reasoning — don't restate it there) and then delete it
+from the tracker with `TaskUpdate({taskId, status: "deleted"})`. Tasks #209 and #210 (the
+piece-union script rebuild and its first area-ratio scoring) were archived there this session —
+see that file for what they were before assuming the tracker's history starts at #9.
+
+## What just landed (context, not open work)
+
+Two decisions were ruled on and committed in the last session — mentioned here only so you don't
+re-litigate them:
+
+- **`mark-granularity`** — decided **B** (colour the exact letter/mark a rule names, not the
+ whole verse), by omar, 2026-08-19. Record: `docs/decisions/mark-granularity.md`. This is what
+ unblocks task #171 (`mark-C`) — nothing yet implements it.
+- **`tajweed-colours`** — decided **B, with C offered behind an advanced setting**, by omar,
+ 2026-08-19. Record: `docs/decisions/tajweed-colours.md`. This is what unblocks task #176
+ (`tj-5`) — nothing yet implements it either.
+
+Both records and `docs/decisions.json`/`docs/decisions/README.md` were rebuilt and pass
+`node scripts/gate-decisions.mjs`. Commit `f75acf9`.
+
+`docs/design/mark-placement.html` also gained the reader-facing rewrite described in section 2
+above. Commit `a5c4160`.
+
+### This session (uncommitted as of writing — the standing no-commit rule held)
+
+- **The re-deal was made and executed** (§1). New design-doc item ㉟ in
+ `docs/design/mark-registration.md` records it; the `reach-for-the-ink…` issue row was refreshed
+ to point at ㉟ instead of the superseded ㉝, and `docs/issues.md` rebuilt. None committed.
+- **The confusion-point feature (Tier C1) was handed to a subagent** to draft
+ `docs/design/confusion-points.md` — a design, to become an action plan later. Check whether that
+ file has landed and read it before picking up C1.
+- **This prompt itself was rewritten** with the trust-first working-backwards plan at the top; the
+ tiers there are the current priority order.
+
+## Every published artifact, current as of this prompt
+
+This is the full `docs/artifacts.json` register — eleven pages published to claude.ai. Carry all
+eleven into the new session; don't rediscover them. `decision`/`page`/`builtBy` null means the
+row's own note explains why (usually: published from a scratch directory that's since been
+cleared, and never attached to a decision).
+
+| # | title | published | decision | checked-in page | status |
+|---|---|---|---|---|---|
+| 1 | Where should the sittings live? | 2026-08-17 | `sitting-hosting` | `docs/design/sitting-hosting.html` | decided (A), current |
+| 2 | Hifth Decision Board | 2026-08-17 | — | `docs/design/decision-board.html` | current, no decision to attach to |
+| 3 | What should the panel show around the ayah? | 2026-08-17 | `comparison-crop` | none checked in | decided (F); see issue `the-crop-draws-neighbours-with-nothing-saying-so` above |
+| 4 | Where the rectangles go | 2026-08-15 | `mark-placement` | none checked in | **STALE — checked-in `mark-placement.html` changed this session (commit `a5c4160`) and was not republished. This is the same open decision as section 2 above.** |
+| 5 | Registered on the Ornaments | 2026-08-13 | — | none | orphaned diagnosis; feeds the still-open `the-frame-is-registered-on-ornaments-and-used-to-place-text` issue; no home yet |
+| 6 | One colour a verse — should Hifth colour tajweed more finely? | 2026-08-08 | `mark-granularity` | none checked in | **now decided (B) — the artifact itself (the three drawn options) doesn't need to change, since the decision was which option, not the drawing** |
+| 7 | Whose colours are they? | 2026-08-08 | `tajweed-colours` | none checked in | **now decided (B, with C advanced) — same as above, artifact doesn't need to change** |
+| 8 | Two spellings, one word | 2026-08-07 | — | none | orphaned finding; belongs in whatever record covers the two text readings |
+| 9 | Two segmentations, one text — PLAN ⑬ | 2026-08-04 | — | none | orphaned; title uses internal shorthand, predates the "page must be checked in" rule |
+| 10 | Should the two leaves pan and zoom together? | 2026-08-04 | — | none | orphaned; reads like a decision in everything but filing — should become a real `docs/decisions.json` row |
+| 11 | Hifth Experience Atlas | 2026-08-26 | — | `docs/design/experience-atlas.html` | **only exists in the `experience-atlas` worktree, uncommitted — see section 3 above** |
+
+Rows 4, 6, 7 changed meaning or went stale specifically because of this-session-and-last-session
+work; the rest are unchanged but listed for completeness per the instruction not to let a
+published page exist that the tree doesn't know about.
+
+## How to start
+
+1. Confirm the state is still accurate — `git status` in both trees, `TaskList` for the live task
+ set, and re-check whether `docs/decisions.json`'s `mark-placement` is still the only open
+ decision. `docs/tasks/done.md` holds what's already been archived out of the tracker — check it
+ before assuming a task's history starts at its own ID.
+2. **The priority order is the working-backwards plan at the top, not task age.** Tiers A and B are
+ the road to web v1.0; C and D wait. The highest-leverage open item is A1 — ruling `mark-placement`
+ and wiring the guard (evidence now complete, item ㉟). If you pick up the guard thread, read
+ `docs/design/mark-registration.md` from `### ㉟` for where it actually stands (not ㉞ — that's
+ superseded).
+3. Don't commit or publish anything without asking first, per the standing rule above.
+4. Whatever you pick, say *why it's the trust-first move* before diving in — the destination is a
+ hafiz who trusts this, and every item earns its place by how directly it serves that.
diff --git a/.claude/settings.json b/.claude/settings.json
index c0b6312f..de3f50a5 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -2,5 +2,18 @@
"plansDirectory": ".claude/plans",
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Artifact",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"$CLAUDE_PROJECT_DIR/scripts/hook-artifact.sh\""
+ }
+ ]
+ }
+ ]
}
}
diff --git a/.claude/skills/decide/SKILL.md b/.claude/skills/decide/SKILL.md
index 54d32c6d..c53b4054 100644
--- a/.claude/skills/decide/SKILL.md
+++ b/.claude/skills/decide/SKILL.md
@@ -122,16 +122,22 @@ can rebuild it. Split the generator when the data needs something not in the rep
findings, and a default mode that renders from **committed bytes only**. Then the page
rebuilds on a fresh clone and the expensive half runs once.
-Two outputs, and the gate refuses one without the other:
+Two addresses, and the gate refuses one without the other:
- **the checked-in page**, under `docs/`, a full HTML document — a fragment renders in quirks
- mode from `file://`, which is where it is read from
-- **the published copy**, which is what a person is actually sent
-
-The published copy is served under a policy that blocks every external host, so **inline
-everything**: no relative URLs, no CDN links, no external fonts. A referenced asset does not
-error, it silently renders nothing. Where the same picture repeats, define it once and
-reference it rather than embedding several copies.
+ mode from `file://`, which is where it is read from in a clone
+- **the public copy**, the same file on the app's own site at the same path,
+ `https://blog.bytesofpurpose.com/hifth/docs/…` — the build stages every page under `docs/`
+ there, so merging the page publishes it and nothing else has to happen
+
+Reference the vendored artwork relatively, as `../../apps/web/public/assets/…` from
+`docs/design/`, so the page opens from a clone; the staging step rewrites those references to
+where the site serves the same files, and rewrites a link to a markdown record to where the
+repository host renders it. Any other relative link fails the build. No external hosts, no CDN
+fonts: a page that needs something the site does not carry is a page that will one day render
+nothing. Where the same picture repeats, define it once and reference it rather than embedding
+several copies. A copy put on another host for a conversation is allowed and goes in
+`docs/artifacts.json`, but it is a copy, not the address.
Before publishing, check what the bytes carry. This repo ships **no Qur'an text** — a
standing rule kept by the shipped bytes, not only by policy. A page built from mus'haf assets
@@ -153,8 +159,10 @@ The row stores **one sentence of its own**: the plain-language question. Everyth
points at. Do not summarise the decision into the register — the record owns the answer, and a
copy would be right for a while and then quietly stop being right.
-`artifact` is the published link; `page` is the same page checked in here. Both, always: a
-link with no copy dies when the host does, and a copy with no link cannot be sent to anybody.
+`artifact` is the page's address on the site — the site plus the page's path and nothing else;
+the gate derives it and refuses a row that differs. `page` is the same page checked in here.
+Both, always: a link with no copy dies when the host does, and a copy with no link cannot be
+sent to anybody.
The record named in `doc` has to link to each, or its argument is about a picture the reader
cannot reach.
diff --git a/.claude/skills/handling-sitting-iteration-feedback/SKILL.md b/.claude/skills/handling-sitting-iteration-feedback/SKILL.md
new file mode 100644
index 00000000..2e57dd6d
--- /dev/null
+++ b/.claude/skills/handling-sitting-iteration-feedback/SKILL.md
@@ -0,0 +1,284 @@
+---
+name: handling-sitting-iteration-feedback
+description: Turn what a sitting found into a change in the placement rule — form the hypothesis from the reader's corrections, test it against the ink at corpus scale before believing it, score any candidate fix against the reader's own answers as held-out ground truth, and check it for regressions on the marks that already work. Use after review-sitting has read a sitting, when asked whether a sitting teaches the algorithm anything, when a by-eye pattern needs confirming or refuting, or when someone asks for better initial placement.
+---
+
+# Taking a sitting back to the rule
+
+`review-sitting` ends by routing findings to the registers that own them. That is
+where a sitting stops being an hour and starts being a record. This skill is the
+step after: **does the record change the rule, and how would you know?**
+
+It exists because the obvious way to do this is wrong, and it was done the wrong way
+once here at full length before the mistake showed up.
+
+## The trap, stated first
+
+A reader corrects a hundred marks. You sort the corrections by the name of the mark
+and a pattern falls out — these move sideways, those move down, these want growing.
+It is a real pattern in real data and it is extremely tempting to write it into the
+rule as a per-class offset.
+
+**It is almost certainly a fact about which marks got into the sitting, not about the
+marks.** A sitting is drawn from a population, and every population this project deals
+from is *selected* — the fallback set is by construction the marks the ink search
+refused, the bands are by construction the weakest matches. Whatever made them
+eligible is the first thing any pattern in them is describing.
+
+Worked example, and the numbers are the ones this file was written from. A part of 106
+fallback marks came back sorted into three clean groups: single marks moved sideways
+with size untouched, marks above the letter moved downward two to three times as far,
+doubled marks barely moved but wanted growing. Coherent, linguistic, and it looked
+like a per-class correction waiting to be written.
+
+Fitting the production line rule over all 326,515 marks and measuring what it leaves
+behind, class by class, killed it in one command:
+
+```
+class n resid x resid y
+fatha 121,277 0.011 0.013
+sukun 36,616 -0.022 -0.016
+superscript alef 9,358 -0.032 -0.054
+successive fathatan 2,897 0.001 -0.001
+ALL 322,011 0.004 0.002
+```
+
+Every class within 0.09 units of zero, on boxes about 6.3 by 3.4. There is no
+per-class offset anywhere in the print. The reader's pattern was the shape of the
+refusal criteria, seen from the inside.
+
+## The order
+
+```
+ ① hypothesise read the corrections, say what you think the rule gets wrong
+ ② refute test it against the ink, at corpus scale, before believing it
+ ③ relocate if refuted, the pattern belongs to the selection — go read the criteria
+ ④ score candidate fixes, against the reader's answers as ground truth
+ ⑤ regress what does the fix do to the marks that already work?
+ ⑥ escalate prefer a change that only fires where the old rule failed
+```
+
+Steps ② and ⑤ are the ones people skip, and they are the two that stop a plausible
+finding shipping as a wrong one.
+
+---
+
+## ① Hypothesise
+
+From the settled ruling, not from the transcript. Read the residual the rule actually
+leaves — **`settled` minus `drawn`**, which is what the reader added on top of what
+shipped.
+
+Do not read `to`. It is `settled` minus `box`, measured from the *raw* rectangle, so it
+contains the correction the rule already applied and will tell you the rule is far more
+wrong than it is. Both fields are in every ruling and they differ by about a factor of
+two; the whole of one wrong analysis in this repo's history is the two being swapped.
+
+```
+settled - box what the mark needed in total, from raw
+drawn - box what the rule already did about it
+settled - drawn what the rule still got wrong <- this one
+```
+
+Group by whatever you suspect — the mark's name, the page, the line, where on the line
+it sits. Medians, not means; a sitting has a handful of marks that really are somewhere
+else and a mean will follow them.
+
+## ② Refute
+
+**Before believing any pattern, ask the ink.** There are 326,515 measured
+displacements on disk in `packages/etl/out/mark-rows.line-tilt.json` and the fitter
+that ships is importable, so the test costs one script and no reader:
+
+```js
+import { correctionFor } from "./lib/registration-grain.mjs";
+const { apply } = correctionFor("line-tilt", rows);
+// residual = r.dx - apply(r).dx, grouped by whatever you hypothesised
+```
+
+Marks with `ink < 0.02` estimate nothing and are dropped — that is what the production
+`groupBy` does and your test must do the same or it is measuring a different thing.
+
+Three outcomes, and all three are useful:
+
+- **Confirmed at scale.** Rare, and worth a lot when it happens. Now you can fit the
+ correction from 300,000 measurements instead of from a hundred hand-corrections,
+ which is both more accurate and free of the pointing artefact.
+- **Refuted.** The pattern is a property of the selected population. Go to ③. This is
+ the common case and finding it out costs minutes.
+- **The ink cannot see it.** Some things are genuinely invisible here — the search
+ moves a rectangle but never resizes it, so *size* error does not appear in `dx, dy`
+ at all and has to be got at sideways (see below).
+
+## ③ Relocate — read the criteria, not the marks
+
+If a pattern is a fact about the selection, the selection is defined in code and can
+be read. For the placement sittings it is in `build-mark-report.mjs`:
+
+```js
+const atEdge = (r) => Math.abs(Math.abs(r.dx) - radius) < EPS || ...
+const placed = (r) => r.iouBest >= iouFloor && !atEdge(r);
+```
+
+Two ways to be refused, and **they are different failures that want different fixes.**
+Split the population by them before anything else:
+
+- **hit the search radius** — the true answer is further away than the search looks.
+ `dx, dy` is not a measurement, it is a value pinned at the boundary. The direction is
+ still information: on 100 such marks the pinned direction agreed with the reader on
+ 92 across and 99 down. The search quits pointing at the answer.
+- **found ink, matched badly** — the search could reach it and still scored under the
+ floor. If the movement needed is small and the overlap is capped well below what a
+ good match scores, the rectangle is the wrong **size**, and no amount of moving it
+ will help. That is invisible to `dx, dy` and shows up instead as a class refused at
+ several times the base rate while matching worse even when accepted.
+
+The sanity number to hold: a mark the search is happy with scores about **0.909**.
+Anything sitting at 0.45 has a shape problem, not a position problem.
+
+**Then check that the criterion actually fires.** Reading it is not enough — a refusal
+test is a claim about a measurement, and the measurement has to be able to support it.
+The one here nearly cost the whole result:
+
+```
+"the search ran out of room" == its offset came back exactly on the boundary
+```
+
+True only if the search cannot return an offset *past* the boundary, and it could —
+its refinement pass swept around the coarse winner without being clamped to the
+region, so a winner on the boundary let it step a quarter unit beyond. Every mark
+that did so stopped looking like a refusal. **2,252 marks, 1,923 of them shipping as
+successfully placed**, at a median match of 0.859 against 0.909 — nearly three times
+the edge population that was being counted, all on the wrong side of it.
+
+The general shape, and it is worth looking for by name: **a population defined by a
+sentinel value is only as trustworthy as the guarantee that produces the sentinel.**
+Ask what happens at the boundary, and prove it rather than reading it.
+
+## ④ Score against the reader
+
+This is what the sitting was bought for and it is the one thing the corpus cannot give
+you: **a set of marks where a human has said where the box goes.**
+
+Every candidate — including the one that ships — gets scored the same way, as distance
+from where the reader put it:
+
+```
+hypot(candidate[0] - m.settled[0], candidate[1] - m.settled[1])
+```
+
+Report median, p90, and the share within one unit and within half a unit, with the raw
+rectangle and the shipped rule as the two baselines. A fix with no baseline beside it
+is a number nobody can size.
+
+The one from this file:
+
+```
+answer median within 1 within 1/2
+the raw rectangle 4.292 - -
+the printed line, as we ship it 1.974 31% 19%
+the ink, searched +/- 3 (today) 1.816 40% 28%
+the ink, searched +/- 8 0.099 84% 83%
+```
+
+**Ground truth is finite and does not regenerate.** Every candidate you score against
+these marks spends a little of their independence. Score the two or three that matter,
+not a sweep.
+
+## ⑤ Regress — what does it do to the marks that already work?
+
+**A fix is not measured on the population it was designed for.** The refused marks are
+0.57% of the corpus; the other 99.43% have answers today that nothing in the sitting
+questioned, and the only way to ship a disaster here is to improve the small half while
+quietly moving the large one.
+
+Re-run the candidate over marks that were *already accepted* and compare answers:
+
+```
+still accepted: 66,077 of 66,193 (99.82%)
+answer moved > 0.5: 3,073 (4.64%)
+answer moved > 2: 2,722 (4.11%) <- the wider window snapping onto the neighbour
+```
+
+That 4.11% is the whole reason the fix below is shaped the way it is. A wider search
+finds a *better-scoring* match that is the adjacent mark's ink, and it does it
+confidently. There is no ground truth on those, so they cannot be adjudicated — only
+avoided.
+
+## ⑥ Escalate rather than replace
+
+When a change helps the refused and disturbs the accepted, do not weigh them against
+each other. **Make the change fire only where the old rule failed.**
+
+```
+search at the current radius, as now
+ if that refuses this mark, and only then, search again wider
+```
+
+By construction the accepted marks keep their answer bit-for-bit, so the regression
+risk is not small, it is zero. The cost is also near zero, because the escalation runs
+on the fraction that failed rather than on the corpus.
+
+**Then verify the by-construction claim, because it is a claim about code.** Re-run
+and diff the accepted population against its old answers. Here that was 14,398 marks,
+of which 14,046 came back byte-identical — and the 352 that did not were every one of
+them a mark the old search had reported outside its own boundary, which is the defect
+above rather than a breach of the guarantee. That distinction is the whole difference
+between "the fix leaked" and "the fix uncovered something", and only the diff can tell
+you which you have.
+
+Check that the escalated rule still refuses honestly — a fix that accepts everything
+has destroyed the signal that told you which marks to sit:
+
+```
+where the wide search accepts: median error 0.090, 91% within half a unit
+where it still refuses: median error 1.543, 24% within half a unit
+```
+
+Its refusals are still the hard marks. That is what makes the next sitting worth
+sitting.
+
+---
+
+## What this changes downstream
+
+A fix that recovers most of a sitting population **deletes most of the queue**. That is
+the point, and it is not the agent's call: it invalidates the current deal, re-deals
+every part nobody has sat, and resets each reader's stored place. Say the size of it
+and let somebody choose:
+
+> fixing this shrinks the fallback population from 1,877 to roughly 165 — about 91% of
+> the sittings you have queued stop existing, replaced by a smaller set of genuinely
+> hard marks
+
+Then finish `review-sitting` ⑤ and ⑥ against the new rows: rebuild the parts, run
+`pnpm audit:sittings` with the same `--answered` list, `pnpm sit:index`, and do **not**
+restart the server.
+
+The answers already given do not go stale when the rule changes. They stop being a
+queue of work and become the ground truth that proved the fix — which is worth more,
+and is the only reason any of this could be measured at all.
+
+## Where each finding goes
+
+Same registers as `review-sitting`, and the split is by what kind of claim it is:
+
+| the finding | where |
+| --- | --- |
+| the rule is wrong in a named way, reproduced, with numbers | `docs/design/mark-registration.md`, `confirmed` |
+| something the ink refuted that a sitting suggested | the same item, as the correction — **leave the wrong guess standing beside it** |
+| a fix that needs a corpus recompute before it can be judged | `docs/issues.json`, `severity: risk`, `owner: agent` |
+| a number a reader has to re-check after the fix | `docs/validation/ledger.json` |
+
+Leaving the refuted guess in the record is the house rule and it is load-bearing here:
+the per-class theory was reachable, defensible, and wrong, and the next person to read
+a sitting will reach for it again unless the document says out loud that it was tried.
+
+## What this skill will not do
+
+- **Believe a sitting on its own.** A hundred hand-corrections propose; 326,515
+ measurements dispose.
+- **Fit a correction to the reader's answers and then score it on them.** That is the
+ one thing the ground truth cannot survive.
+- **Ship a fix that was never run against the marks it was not designed for.**
+- **Re-deal a queue without saying what it costs the person holding it.**
diff --git a/.claude/skills/mushaf-reference/SKILL.md b/.claude/skills/mushaf-reference/SKILL.md
index f4355693..14bd4db3 100644
--- a/.claude/skills/mushaf-reference/SKILL.md
+++ b/.claude/skills/mushaf-reference/SKILL.md
@@ -107,9 +107,14 @@ These are not style preferences. Each one is a rule this repo already enforces s
the geometry comes from the vendored KFGQPC SVGs, and a differently-printed page table
would be "a source of disagreement rather than of truth." Check against it; do not
import it.
-2. **No Quran text enters this repo.** The standing rule is in
- `packages/etl/scripts/morphology.mjs` — *"There is no Quran text in this repo and there
- will not be"* — and `gate:notext` / `gate:text-sources` enforce it. The probe asks for
+2. **Nothing we vendor and nothing we ship is Quran text.** Not the unscoped form
+ this heading used to carry — it contradicted the rule quoted directly beneath it,
+ and the tree has never satisfied it. The standing rule is in
+ `packages/etl/scripts/morphology.mjs` — *"nothing this project vendors, and nothing it
+ ships, is Quran text"* — and `gate:scripture` is what enforces it, failing on any run of
+ three consecutive fully-vowelled words in any source file. It is not `gate:notext`, which
+ this line used to name: that one forbids `` elements in page artwork, for a Safari
+ paint bug, and the misreading of its name cost two loops of work. The probe asks for
verse *keys* only (no `fields` parameter), so the text never crosses the wire. Keep it
that way: it is much easier to stay clean than to prove you got clean.
3. **Record verdicts, not scripture.** A note in `verified-edges.json`, `issues.json` or a
diff --git a/.claude/skills/review-sitting/SKILL.md b/.claude/skills/review-sitting/SKILL.md
new file mode 100644
index 00000000..ae6586e7
--- /dev/null
+++ b/.claude/skills/review-sitting/SKILL.md
@@ -0,0 +1,289 @@
+---
+name: review-sitting
+description: Work through what came out of a by-eye sitting — settle its answers into one row per mark, score it, read what it actually says, route the findings to the registers that own them, and rebuild the sittings nobody has sat yet so the next hour asks a better question. Use after somebody finishes or banks a sitting, when asked what a sitting found, when annotations need normalizing or deduplicating, or when a sitting's numbers look wrong. Every step ends in something checked in.
+---
+
+# Reading a sitting back
+
+A sitting is an hour of the scarcest thing this project has. What comes out of it is
+a list of events — this rectangle was pushed left, then a little back, then called
+the wrong shape as well, then stretched — in a file that is not checked in, in a
+format nobody reads, saying nothing anybody can quote.
+
+Turning that into something the project keeps is six steps, and **the order is not
+optional**: the first two produce the numbers, the third decides what they mean, and
+the rest are the only reason the hour was worth buying. A sitting that ends at
+step two has to be re-sat by whoever needs its answer next.
+
+```
+ settle the events become one row per mark → a ruling, checked in
+ score the rows become rates, per population → a number with an interval
+ read what does it say, and what is it not saying
+ route each finding goes to the register that owns it
+ rebuild the sittings nobody has sat yet get rebuilt
+ hand back the front door is rebuilt and served, at one address
+```
+
+## Words this uses
+
+**Mark** — one vowel sign or other small mark of the print; the thing a rectangle is
+drawn around. **Sitting** — one person, one hour, one screen full of marks, one at a
+time. **Transcript** — the file a sitting hands over. **Ruling** — what this skill
+writes: the settled answer, checked in. **Settle** — collapse everything said about
+one mark into what the reader was left saying about it.
+
+---
+
+## ① Settle
+
+```
+pnpm report:settle packages/etl/out/.json --rows packages/etl/out/mark-rows.line-tilt.json
+```
+
+Pass as many transcripts as you like; they are sorted by when they were handed over
+and settled oldest-first, so a mark looked at twice settles on the later look. It
+writes `docs/validation/rulings/-mark-report-.settled.json` — pass `--out`
+if you want it somewhere else, and **always pass `--out` when you are only
+experimenting**, or you overwrite a committed ruling.
+
+### What settling does, and why it is not optional
+
+The transcript records the **route**. What the reader said is the **resting place**.
+
+A reader pushes a rectangle a unit left, overshoots, pushes it a unit right, and
+moves on. Every scheme that reads the route gets that mark wrong, and the two
+obvious ones get it wrong in the two most convincing directions: averaging the
+presses says it never moved, adding up their sizes says it moved twice as far as it
+did. Neither reads as an error. **This has already cost this project a number** — an
+earlier scorer printed *nothing moved* for twenty-six marks that had every one of
+them been dragged the better part of two units.
+
+So both position and size settle to the last thing the reader did, and a
+left-then-right, a grow-then-shrink, and eleven presses in a row all collapse to
+where the rectangle was when they let go of it. Words gather and repeat once —
+being called moved eleven times is one complaint, not eleven, and counting it eleven
+times lets one stubborn mark outvote a whole page of easy ones.
+
+What the route leaves behind is a separate count, and it is a finding about the
+**controls** rather than about the mark: a rectangle that takes nine presses to
+settle is one the pad is not letting anybody place. Moving and reshaping are counted
+apart, because they are complaints about different controls.
+
+### The two distances, which are never differenced
+
+Each row carries two, and they answer different questions:
+
+- **the reader's hand** — how far they moved the rectangle *we drew them*. This is
+ how wrong our correction looked to somebody sitting in front of the print.
+- **where they landed against what ships** — measured from the uncorrected box, so
+ it carries our correction plus theirs. This is the one to set beside a measurement
+ taken off the ink, because that one is measured from the same place.
+
+The gap between them is only the correction already applied. Subtracting one from
+the other tells you nothing and looks exactly like finding a discrepancy, which is
+why both are written down under separate words.
+
+### It refuses two things
+
+A sitting taken against different displacements from the one on disk, and an answer
+that disagrees with the displacements about which of the two placement rules drew a
+mark. Both would file answers about marks that were never on the screen, or file
+answers about one option against the other, and both come out looking like evidence.
+If it refuses, do not work around it — find out which file moved.
+
+---
+
+## ② Score
+
+```
+cd packages/etl && node scripts/score-mark-report.mjs out/.json --rows out/mark-rows.line-tilt.json
+```
+
+The settler writes the record; the scorer computes the rates. It prints them **once
+per population and never once overall** — marks placed from their own ink and marks
+fallen back to the printed line are two different options being decided between, and
+a single rate over both is a fact about whatever mix the sampler happened to draw,
+wearing the clothes of a fact about the mus'haf.
+
+Neither script exits non-zero for bad news. A sitting that finds everything wrong
+has done its job, and a build that failed for it would teach everybody to stop
+sitting them. They exit 2 only when they refuse to read a file.
+
+---
+
+## ③ Read it
+
+Four questions, in this order. The third and fourth are the ones people skip.
+
+**What share of marks came back with a complaint?** Take it from the scorer, not the
+settler: the settler's share is over the marks somebody *said something about*, and
+the rate is over the marks they *looked at*, because passing a mark in silence is
+itself a verdict that nothing is wrong with it.
+
+**Is the interval narrow enough to mean anything?** Sixty marks cannot tell 2% from
+14%. The interval is the finding, not the point estimate.
+
+**Is this a measurement of the print, or of the sitting?** A population selected for
+being hard comes back mostly wrong no matter how good the correction is. So does a
+card that makes affirming cost more than faulting. Both are facts about the
+instrument and both look exactly like a result. If every settled mark carries a
+complaint, the settler says so out loud — read that as a prompt, not as a footnote.
+
+**Did the count claimed match the answers given?** You cannot say something about a
+mark you never looked at. The sitting page got this wrong once — it banked what was
+*left* rather than what had been *seen* — and nothing caught it until the rates went
+past a hundred per cent. Both readers now raise a low count to the floor and say the
+file's name when they do. **That warning means a defect in the page, not a quirk of
+the file**: go and find it.
+
+---
+
+## ④ Route the findings
+
+Each kind of answer belongs somewhere different, and the routing is the point of
+having a vocabulary of six words rather than one.
+
+| what the reader said | where it goes |
+| --- | --- |
+| our rectangle is in the wrong place, or the wrong shape, or around the wrong ink | the correction itself — the rate is the evidence for whether the current rule stays |
+| the print is odd here | `docs/issues.json` — a defect in vendored data, and the only route by which a reader's eye reaches the catalog |
+| banked, could not say | read the notes; a run of these sharing a fault with no button is a measured statement that the vocabulary is wrong |
+| it took nine goes to settle this one | the sitting page's controls, not the correction |
+
+`pnpm report:settle … --issues` drafts the register row for the odd-in-the-print
+marks and **prints it rather than writing it**. Paste it in by hand, edited. Every
+register in this repo is hand-edited, always — a script that wrote to one would be
+the second thing claiming authorship of a register meant to have exactly one. Then:
+
+```
+pnpm issues:doc && pnpm gate:issues
+```
+
+Anything that **distorted a measurement** gets a row of its own, whoever's fault it
+was — ours included. That is this repo's line for a review tool: findings about
+ergonomics do not get rows, findings that made a number wrong do.
+
+### Then bank the check itself
+
+```
+make record CHECK=placement-what-kind-of-wrong RESULT=''
+```
+
+The words are the artifact: a check marked done with no result is indistinguishable
+from a check nobody ran. **Then do what its `tunes` step printed** — a manual result
+has to end up tightening something automated, or it has to be bought again by hand
+forever. The `validate` skill is the whole of that contract.
+
+---
+
+## ⑤ Rebuild what nobody has sat yet
+
+This is the step that makes the next hour better than the last one, and it is the
+one most likely to be forgotten because nothing fails when it is skipped.
+
+Rebuilding does two things at once. It drops every mark that now carries a standing
+answer, so the count actually falls — without that, a reader who has answered two
+hundred marks is handed the same sittings with the same numbers on them and no
+evidence anywhere that they did anything, which is a bad way to ask somebody for
+forty more hours. And it picks up whatever step ④ changed about the page itself.
+
+```
+cd packages/etl
+for n in $(seq 1 16); do
+ node scripts/build-mark-report.mjs --rows out/mark-rows.line-tilt.json \
+ --set fallback --seed 23 --part $n/16 \
+ --answered out/mark-answers.jsonl,out/.json \
+ --out out/sit.fallback-$n-of-16.html
+done
+```
+
+`--answered` takes the running log the serving side appends to *and* any handed-over
+transcript, in any mix — they carry the same statements in the same shape, and a
+reader should never be punished for having banked their work one way rather than the
+other. A retraction takes a mark back out of the answered set.
+
+**Do not restart the server to pick up a rebuild.** It reads each file fresh per
+request, so a rebuilt sitting is live immediately; restarting mints a new token and
+every page a reader already has open quietly stops banking answers.
+
+Then confirm the deal did not move — same number of parts, same total, and no mark
+that has been answered coming back round again. **Give it the same `--answered` list
+the rebuild got**, or it will grade the rebuild against a different set of answers
+than the rebuild used and be confidently wrong in whichever direction the difference
+runs:
+
+```
+pnpm audit:sittings -- --answered packages/etl/out/mark-answers.jsonl,packages/etl/out/.json
+```
+
+It reads each built part back and holds it to what it says about itself: that the
+rectangles came from the displacements now on disk, that every part knows about every
+answer given, that nothing already answered is asked again, that no mark is dealt into
+two parts and none into nobody's, and that the count the reader is shown describes the
+population it claims to. A clean run prints the deal in one line — how many marks
+across how many parts, how many already answered, how many in all — which is the
+number worth putting in the commit message.
+
+It is not a gate and cannot be one: the parts are build products and the answers
+accumulate on the machine doing the serving, so in CI it would pass by being unable to
+look. Run it here, where the evidence is.
+
+---
+
+## ⑥ Hand it back
+
+Everything above happens on this machine. A reader sits with a phone, and until
+somebody starts the server there is nothing for them to open — which for a long time
+was an incantation nobody had written down. It is two commands:
+
+```
+pnpm sit:index # the front door, counted out of the sittings on disk
+pnpm sit:serve # serves them, and writes every answer down as it arrives
+```
+
+Rebuild the front door **after** the rebuild in ⑤ and not before. It counts the marks
+out of the parts themselves and the progress out of the running log, so a door built
+against the old parts advertises a deal that is no longer there — the exact failure
+that had it claiming 1,851 marks left on a day when 1,710 were.
+
+The server prints **one address and says not to use the others**. That is not a
+stylistic preference: a browser keeps a reader's place per address, compared as text,
+so the machine's name and the machine's number are two different memories of the same
+sitting. Somebody who begins at one and returns at the other is dropped back at card
+one. Nothing is lost — the front door and every sitting ask this machine for
+everything it has heard — but the minute of thinking an hour evaporated is real, and
+the front door now says so on screen when it notices it is being read at the wrong
+spelling.
+
+Both commands take the same flags they always did, so `--dir`, `--port` and `--host`
+are still there for the cases the defaults do not cover. The default host is the
+private network rather than this machine alone, which is what makes a phone able to
+reach it at all; it is deliberately not every interface, because a sitting has a write
+endpoint on it and the network at a café is an interface too.
+
+---
+
+## What this skill will not do for you
+
+**It will not decide whether the correction is good enough.** That is an open
+decision with a record and an options page, and a rate is one input to it.
+
+**It will not write to a register.** Every one of them is hand-edited. The scripts
+draft; a person pastes.
+
+**It will not settle sittings taken against different rectangles.** Two people
+answering about different pictures is two sets of statements, not one, and merging
+them produces a document that is wrong in a way nothing downstream can detect.
+
+## The traps, in one place
+
+- Pass `--out` on any run you are not intending to commit, or you overwrite a ruling.
+- The two distances are never subtracted from each other.
+- The share the settler prints is not the rate. The scorer's is.
+- A count of marks looked at can only ever go up. If it went down, the page is wrong.
+- The registers are hand-edited. All of them. Always.
+- One address, always the same one. Two spellings of this machine are two separate
+ memories of the same sitting as far as a browser is concerned.
+- The front door is rebuilt after the parts, never before.
+- No Arabic text and no crop of the print is ever committed — the sitting pages draw
+ the mus'haf's own artwork and stay in the gitignored output directory.
diff --git a/.claude/skills/run-app/SKILL.md b/.claude/skills/run-app/SKILL.md
new file mode 100644
index 00000000..67cc741b
--- /dev/null
+++ b/.claude/skills/run-app/SKILL.md
@@ -0,0 +1,54 @@
+---
+name: run-app
+description: Launch the actual Hifth app and open it in a browser — the real navigation instrument, not a picture of it. Use when asked to run, start, open, or screenshot "the app", "Hifth", or "the desktop/mobile UI", or to confirm a change works in the running app. The project's answer to the built-in `run` skill.
+---
+
+# Running Hifth
+
+The app is a Vite dev server. It is **served**, not a file you open.
+
+```
+pnpm dev # serves the web app
+open http://localhost:5173/
+```
+
+Leave `pnpm dev` running in the background; it hot-reloads.
+
+## The PATH gotcha
+
+A non-login shell here has **node 18** on `PATH`, which has no `pnpm` and is too old
+for this repo (`engines` wants `>=20`). The `pnpm` that works ships with the nvm
+node-20 install. Put it in front before running anything:
+
+```
+export PATH="/Users/omareid/.nvm/versions/node/v20.20.2/bin:$PATH"
+```
+
+(`.nvmrc` pins 22, but the reliable, present-on-disk toolchain is node 20 above.
+`nvm use` in an interactive shell works too.)
+
+## The app is not the same as a picture of the app
+
+Do not open a file from `docs/design/*.html` when someone asks for "the app" or
+"the UI". Those pages are **decision mocks** — a drawing of one question, on a real
+mus'haf page, so a reader can answer it. They are indexed in `docs/decisions.json`
+(and, once published, `docs/artifacts.json`), not served by the app. The app is
+`localhost:5173`. `packages/etl/out/*.html` are generated sitting outputs, not the
+app either.
+
+If the ask is genuinely "open that design page", `open docs/design/.html` —
+but confirm which, because "desktop UI" reads both ways.
+
+## Seeing a change — three ways a fix gets checked where it cannot fail
+
+1. **A hash-only navigation is not a reload.** The app routes on `#/…`, so pointing
+ the tab at a new `#` address keeps the old module graph running. After an edit,
+ force a real reload (`location.reload()`, or another path and back) before
+ reading the DOM. The served source can be new while the page is still old; two
+ sessions have lost turns to this.
+2. **Two servers, two truths.** `pnpm dev` (5173) wraps the tree in StrictMode and
+ double-mounts everything; `vite preview` (4173) is the built app, which strips it.
+ A fault seen on one and not the other is a dev-only artefact — say so, and do not
+ ship a fix for the built app that only the dev server needed.
+3. **e2e runs the build.** `pnpm --filter @hifth/web build` first, and free 4173
+ (`lsof -ti:4173 | xargs kill`) or Playwright refuses to start its own.
diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md
new file mode 100644
index 00000000..78f075eb
--- /dev/null
+++ b/.claude/skills/testing/SKILL.md
@@ -0,0 +1,121 @@
+---
+name: testing
+description: How Hifth is tested — the unit, end-to-end, and golden-image layers, how to run each, and the current policy that only NON-MOBILE tests are exercised until the mobile app is implemented. Use when writing or running tests, adding a regression, deciding which suite a change belongs in, or when a mobile (iPhone/Android/golden) test will not run in this environment.
+---
+
+# Testing Hifth
+
+Three layers, each answering a question the layer below it cannot reach.
+
+| layer | what it proves | where | runner |
+| --- | --- | --- | --- |
+| **unit / contract** | logic, wiring, message completeness, DOM order | `**/*.test.ts(x)`, co-located | Vitest (jsdom) |
+| **end-to-end** | what a browser lays out — geometry, direction, real events | `apps/web/e2e/*.spec.ts` | Playwright |
+| **golden image** | the pixels — a wash, a clone's coordinate space, a skin shift | `e2e/golden.spec.ts` → `e2e/__screenshots__/` | Playwright (chromium, mobile viewport) |
+
+The split is deliberate: a claim goes in the **lowest** layer that can hold it. DOM
+order is a unit test; DOM order becoming *sides* under an RTL flow is e2e; the colour
+of the band is a golden. Don't assert in e2e what jsdom could have caught, and don't
+screenshot what a geometry assertion states more precisely.
+
+## The current policy: non-mobile only
+
+**Until the mobile app is implemented, we run the non-mobile tests only.** That means:
+
+- **Unit tests** (all of them — they are jsdom, viewport-independent).
+- **The `desktop` Playwright project** — chromium at 1440×900, the two-leaf spread.
+
+We do **not** currently gate on the **mobile** e2e projects — `iphone` (WebKit),
+`android` (Chromium mobile), or `golden` (mobile-viewport screenshots). They are not
+deleted and their specs are not wrong; they are waiting on two things this repo does
+not yet have stood up: the mobile implementation they assert against, and a matching
+browser toolchain in the working environment (WebKit in particular tends to lag the
+Playwright CLI version — the "Playwright was just installed or updated" nag is that
+mismatch, not a real gap).
+
+**So when you add a regression for a change that is visible in both layouts, its
+runnable home is a unit test or the `desktop` project.** The mobile e2e is still the
+right place to *also* state the claim for the future — write it there too when it
+belongs there (e.g. the English wordmark's natural home is `lang.spec.ts`) — but do
+not treat a green mobile run as a precondition for landing, and do not treat a mobile
+project that will not launch here as a failure of your change. Verify on desktop +
+jsdom, say which you ran, and note the mobile assertion is deferred.
+
+## Running them
+
+Everything runs from the **repo root**, and the PATH gotcha from the `run-app` skill
+applies — a non-login shell here has node 18, which has no `pnpm`:
+
+```
+export PATH="/Users/omareid/.nvm/versions/node/v20.20.2/bin:$PATH"
+```
+
+### Unit tests
+
+```
+pnpm -C apps/web exec vitest run # the whole web suite
+pnpm -C apps/web exec vitest run src/App.test.tsx # one file
+pnpm -r test # every package (core, etl, web)
+```
+
+`@hifth/core` must be built before the packages that import it (`make core`, or
+`make test` which does it for you) — the Loop 0 lesson.
+
+### End-to-end — the non-mobile project
+
+```
+pnpm -C apps/web exec playwright test --project=desktop
+pnpm -C apps/web exec playwright test desktop.spec.ts --project=desktop -g "holds the slider"
+```
+
+By default Playwright builds `dist/` and serves it on **:4173** (a clean production
+build — this is what a real run asserts). For a fast inner loop against a `vite dev`
+you already have open on **:5173**, point the run at it instead — this skips the build
+and the `--strictPort` preview server entirely:
+
+```
+HIFTH_BASE_URL=http://localhost:5173 pnpm -C apps/web exec playwright test --project=desktop
+```
+
+Use `HIFTH_BASE_URL` for iterating; let the default preview build run before you
+believe a pass. (`HIFTH_REUSE_SERVER=1` is a different, narrower escape hatch — it
+lets the built-in server adopt whatever is on :4173; opt-in because it will happily
+test the wrong build.)
+
+### Reading a failure
+
+```
+make report # opens the last run's traces, DOM snapshots, console, network,
+ # and the three-way image diff for a golden failure
+```
+
+## What mobile testing will be, once it is stood up
+
+Recorded here so the plan is not lost while the projects are dark:
+
+- **`iphone`** — WebKit, iPhone viewport, `hasTouch`. The acceptance device (PLAN §8):
+ the smoke tour and the phone-specific chrome (the colophon sheet, the collapsed
+ language switch, the page-turn band on a single leaf) live here.
+- **`android`** — Chromium, Pixel viewport. The second mobile engine.
+- **`golden`** — chromium at the phone viewport with `deviceScaleFactor: 2`,
+ `isMobile`, photographing one SVG per shot. Platform-split baselines under
+ `__screenshots__/{platform}/` (`make golden` for darwin, `make golden-linux` for the
+ CI-shaped linux set); `gate:golden-env` fails the build if the local Playwright and
+ the CI image disagree.
+- **Bringing them back**: install the matching browsers (`pnpm -C apps/web exec
+ playwright install`), stand up the mobile layout the specs assert against, then run
+ `make e2e` (iphone + android + golden) and reconcile. At that point the deferred
+ assertions written into `lang.spec.ts` and the other phone specs start earning their
+ keep, and this policy section gets rewritten to "all layers run".
+
+## Adding a regression (the rule of thumb)
+
+1. **Can jsdom see it?** (logic, wiring, a rendered string, DOM order) → unit test,
+ co-located `*.test.tsx`. Runs now, runs everywhere.
+2. **Does it need a real layout engine?** (geometry, computed direction, a native
+ control's greed, real key/wheel events) → `desktop` project e2e. Runs now.
+3. **Is it inherently a phone claim?** (single-leaf band, collapsed chrome, a mobile
+ viewport's arithmetic) → write it in the phone spec for the future, and find a
+ desktop-or-jsdom stand-in to guard it **now** if the change ships now.
+4. **Is it a pixel?** (colour, wash, exact placement) → golden — deferred with the
+ rest of mobile until the golden project runs here.
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 0ff0b7f7..bcd4de05 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -77,6 +77,31 @@ if [ -n "$staged" ] && command -v node >/dev/null 2>&1; then
fi
fi
+# The rendered register pages, unscoped. Four pages under docs/ are built from
+# the JSON registers and stamped with a hash of what they were built from:
+# use-cases.md, issues.md, tasks.md and decisions/README.md. The scoped checks
+# above ask whether a pointer still resolves; they do not ask whether the page
+# was re-rendered, and on 2026-09-01 a commit edited docs/use-cases.json,
+# passed this hook, and shipped with the use-cases gate red until the next full
+# `pnpm gates`. The hash is a function of the register and of the documents it
+# reads titles from (PLAN.md, the design docs), so a stale page cannot be
+# predicted from the staged paths — hence unscoped, and cheap: each check is a
+# hash comparison, well under a second for all three. The decisions README is
+# already covered above, since its hash depends only on decisions.json.
+if [ -n "$staged" ] && command -v node >/dev/null 2>&1; then
+ for gate in use-cases issues tasks; do
+ if ! out=$(node "scripts/gate-$gate.mjs" 2>&1); then
+ echo "$out"
+ echo ""
+ echo "pre-commit: a rendered register page is stale or its register is broken (above)."
+ echo " re-render every generated page: make render-docs"
+ echo " then stage the page it rewrote alongside your change."
+ echo " to bypass this one commit: git commit --no-verify"
+ exit 1
+ fi
+ done
+fi
+
# The licence the reader is shown and the licence on record, on the one commit
# that can still change either cheaply. Same reasoning as the map above, with a
# sharper edge: a wrong licence line is not a stale pointer, it is a false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 62439ff5..a94a085b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,6 +103,13 @@ jobs:
- name: Gate — text sources
run: pnpm gate:text-sources
+ # The gate above asks where rendered text came from. This one asks whether
+ # any running scripture is in the tree at all — which is a different
+ # question, and the one nothing was asking when twelve verses were typed
+ # into a source file and shipped in the bundle.
+ - name: Gate — no running scripture in the source tree
+ run: pnpm gate:scripture
+
- name: Gate — license (SOURCES.md)
run: pnpm gate:license
@@ -114,6 +121,17 @@ jobs:
- name: Gate — the licence on screen is the licence on record
run: pnpm gate:license-copy
+ # gate:license-copy above checks that a quotation did not drift. It says
+ # nothing about whether the SET of quotations is still complete, and that
+ # is the gap the adjacency shards fell through: they were built from a
+ # second, GPL'd upstream for the whole life of the spans feature, named
+ # neither in the licence table nor beside the data, because the drift
+ # arrived as a feature and nobody edits a licence file to ship a feature.
+ # This traces each builder's imports and fails when a shipped asset tree
+ # reads a vendored input nothing has accounted for.
+ - name: Gate — every asset tree names the upstreams it was built from
+ run: pnpm gate:notices
+
# The manual-validation ledger. This does NOT fail on outstanding checks —
# a phone that has not been held yet is a fact, not a broken build, and a
# permanently red gate teaches everyone to ignore it. It fails when the
@@ -176,6 +194,14 @@ jobs:
- name: Gate — the pages are the pinned print, and all of it is tappable
run: pnpm gate:pages
+ # The pen that draws a selection reads each verse's box as a run of
+ # rectangles. Three of its defects were found by readers one page at a
+ # time; this sweeps all 6,236 boxes with the pen itself and holds the
+ # count it cannot draw as lines, so the next class shows as a number
+ # that moved rather than a screenshot in a message.
+ - name: Gate — every ayah box the pen cannot draw as lines is a known one
+ run: pnpm gate:boxes
+
# The word boxes are geometry vendored from a second print of the same
# mus'haf and fitted onto our frame, so a hash alone would not prove much:
# this re-measures every box against the polygon it claims, with its own
@@ -215,6 +241,15 @@ jobs:
- name: Gate — every open item is indexed, and the index still resolves
run: pnpm gate:issues
+ # docs/tasks.md is the same open items cut by whose turn it is, rendered
+ # from the decision register, the validation ledger, the issue index and
+ # the roadmap at once. It is the page somebody opens to decide what to
+ # spend an hour on, so a stale row does real damage: it sends them at
+ # something already finished, or hides the thing that was waiting. This
+ # checks the committed copy was built from the registers as they stand.
+ - name: Gate — the open-work page was built from today's registers
+ run: pnpm gate:tasks
+
# docs/decisions.json indexes every decision record, and holds the two
# links that make an open one answerable by somebody who has never opened
# this repo: the published page and the copy checked in beside it. This
@@ -487,6 +522,10 @@ jobs:
echo "::error::downloaded artifact has no index.html — refusing to publish an empty site"
exit 1
}
+ test -s site/docs/index.html || {
+ echo "::error::downloaded artifact has no docs/index.html — the design pages did not get staged (scripts/stage-docs.mjs)"
+ exit 1
+ }
- uses: actions/upload-pages-artifact@v5
with:
diff --git a/.lighthouserc.json b/.lighthouserc.json
index bbf7a737..68ba1192 100644
--- a/.lighthouserc.json
+++ b/.lighthouserc.json
@@ -2,6 +2,12 @@
"//": [
"Lighthouse CI gate (PLAN §Loop 6a exit: Lighthouse ≥90; Loop 4b exit: TTI < 2.5 s",
"on mid-Android). Run with `make lighthouse`, or in CI via the `lighthouse` job.",
+ "",
+ "`url` pins the audit to the app. Without it lhci audits every .html it finds in",
+ "the dist, and since 2026-09-01 the build stages every design page under docs/",
+ "into dist/docs/ (scripts/stage-docs.mjs) — documents, not the app, and not what",
+ "the ≥90 criterion is about. The port in that URL is replaced with the static",
+ "server's; the path is what matters.",
"Invoked as `pnpm dlx @lhci/cli autorun` — deliberately NOT a devDependency: it",
"pulls Lighthouse and a Chrome launcher (tens of MB) that nothing else in the repo",
"needs, and adding it would churn the shared lockfile for a tool that runs once",
@@ -54,6 +60,7 @@
"step plus one cold start cannot carry it."
],
"staticDistDir": "apps/web/dist",
+ "url": ["http://localhost/index.html"],
"numberOfRuns": 5,
"settings": {
"formFactor": "mobile",
diff --git a/CLAUDE.md b/CLAUDE.md
index 546f0a12..bcc360ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,11 +54,13 @@ Adding one:
pnpm gate:decisions it refuses the ways this rots
```
-An **open** decision must carry two things or the gate fails: an `artifact` — the published
-page anyone can open in a browser — and a `page`, the same thing checked into `docs/`. Both,
-always. A link with no copy dies the day the host does; a copy with no link cannot be sent to
-anybody. Whatever is checked in names the script that rebuilds it, and the record links to
-both. The `decide` skill walks the whole thing.
+An **open** decision must carry two things or the gate fails: an `artifact` — the page's own
+address on the site, which anyone can open in a browser — and a `page`, the same thing
+checked into `docs/`. Both, always, and they must agree: the address is derived from the
+path, so the gate can tell when a row says anything else. A link with no copy dies the day
+the host does; a copy with no link cannot be sent to anybody. Whatever is checked in names
+the script that rebuilds it, and the record links to both. The `decide` skill walks the whole
+thing.
A row also names the decisions that constrain it, in `related` — the one part of a decision
no single record can hold, because relatedness is a fact about a *pair* and the second half
@@ -69,7 +71,7 @@ is one they will not find from the end they happen to be standing at.
### What the gate actually refuses
A question that is not a question · a question written in file names, paths, symbols or
-commands · an artifact link that is not an absolute `https://claude.ai/…` URL · an artifact
+commands · an artifact link that is not the checked-in page's own address on the site · an artifact
with no checked-in page, or a page with no link · a page nobody can rebuild · a record that
never links its own picture · an open decision with fewer than two options · a decision
marked settled that does not say who settled it · a related decision that does not name it
@@ -78,6 +80,21 @@ back · a record in `docs/decisions/` with no row in the index · a stale `READM
It does **not** refuse an open decision. Unanswered questions are the normal state of a live
project, and a gate that failed for having one would be switched off inside a week.
+## Every design is public
+
+The second tenet, and the reason the first one can be held to. A page drawn to decide
+something is served from the app's own site, at the same path it has in this repository, from
+the moment it is merged: `docs/design/page-bar-options.html` in the tree is
+`https://blog.bytesofpurpose.com/hifth/docs/design/page-bar-options.html` on the web, and the
+front door to all of them is `https://blog.bytesofpurpose.com/hifth/docs/`. The build stages
+them and the app's colophon links them. Publishing a design is a merge to main and nothing else.
+
+So **a page that only exists as a link on some other host is not published; it is lost
+slowly.** A copy put elsewhere for a conversation is fine, and `docs/artifacts.json` lists
+those, but the address a record gives a reader is the one on the site, and the decision gate
+refuses any other. Nothing is hidden from the people the app is for: the reasoning is as public
+as the result, and a reader who disagrees with a choice can open the page it was made on.
+
## The other registers
Same rule in each: they index, they do not restate.
@@ -89,5 +106,23 @@ Same rule in each: they index, they do not restate.
| `docs/use-cases.json` | who uses this, and what proves it? | `make use-cases` |
| `docs/decisions.json` | what did we decide, and why? | `make decisions` · the `decide` skill |
| `docs/validation/ledger.json` | what can only a human check? | `make validate` |
+| `docs/artifacts.json` | what have we published, and can anyone still see it? | `pnpm artifacts` |
`docs/map.json` and every other JSON register is **hand-edited, never generated**.
+
+### The one register no gate can check
+
+Publishing a page mints its address on somebody else's host, and nothing writes that address
+back here. The only record that a publish happened at all is the session log it happened in,
+which lives outside this repository on one laptop — so `pnpm artifacts` can only run where the
+evidence is, and a gate would pass in CI by being unable to look. It is not called one for that
+reason. A hook runs it the moment a page is published and says what is missing, which is the
+only point at which the page, its subject and the reason for it are all still in hand.
+
+This was written after counting. Nine pages had gone out and the tree named five; the other
+four had been drawn in a scratch directory that was later emptied, so a diagnosis, a comparison
+carrying a recommendation, a plan and a finding now exist only as links. That is the failure
+the decision gate already refuses — *a link with no copy dies the day the host does* — reaching
+a page nobody had thought to attach to a decision. Since 2026-09-01 the build serves every
+page under `docs/` from the site, so a merged page cannot leave by that door at all; the
+register is for the copies that still do.
diff --git a/LICENSES.md b/LICENSES.md
index 416fa8cb..39d348b5 100644
--- a/LICENSES.md
+++ b/LICENSES.md
@@ -7,15 +7,50 @@ be inaccurate. This file is the map: which terms cover which paths, and why.
`SOURCES.md` is the authority on every third-party source — its provenance, its verbatim
license text, and where the notice ships. This file only says how those terms compose.
-## The three buckets
+## The buckets, by door
+
+Something leaves this project through three doors, and "may this be distributed?" has a
+different answer at each — so the map is organised by door first and by path second. Each row
+says what goes out, through which door, under whose terms. The doors and what goes through
+each were measured in `docs/design/what-we-distribute.md`; the numbers below are its.
+
+### Through the repository — public, cloneable
+
+The whole tree, every vendored input verbatim. This is the door the copyleft obligation is
+discharged at: the corresponding source for every derived tree below is here, and reachable.
| Paths | Terms | Whose choice |
|---|---|---|
| `apps/web/`, `packages/core/`, `packages/etl/` (scripts), `scripts/`, `docs/`, config | **GPL-3.0-or-later** | Ours |
+| `packages/etl/data/**` — 17 vendored input files, 12.3 MB, verbatim | Each source's own terms — **not ours to relicense** | KFGQPC, QAC, quran-tajweed, Tanzil, Waqar144 |
+| `apps/web/public/assets/**` — the derived trees, checked in as built | The site door's terms, below | As below |
+
+### Through the deployed site — public, fetched by browsers
+
+Derived assets and the app bundle. No vendored input reaches a browser; what does is built
+from them, and some of it inherits their terms.
+
+| Paths | Terms | Whose choice |
+|---|---|---|
+| The app bundle, `apps/web/dist/`, about 120 KB gz | **GPL-3.0-or-later** | Ours |
| `apps/web/public/assets/roots/**` | **GPL-3.0** (inherited) | The Quranic Arabic Corpus's |
| `apps/web/public/assets/skins/**` | **CC BY 4.0** (inherited) | quran-tajweed's |
-| `apps/web/public/assets/adj/**` | Free use with attribution (inherited) | Waqar144's |
-| `apps/web/public/assets/pages/**`, `packages/etl/data/**` | Each source's own terms — **not ours to relicense** | KFGQPC, QAC, quran-tajweed |
+| `apps/web/public/assets/adj/**` | **GPL-3.0** (inherited) *and* free use with attribution (inherited) *and* **CC BY** (inherited) | The Quranic Arabic Corpus's *and* Waqar144's *and* Tanzil's |
+| `apps/web/public/assets/words/**` | **GPL-3.0-or-later** | Ours — rectangles we measured |
+| `apps/web/public/assets/manifest.json` | **GPL-3.0-or-later**, with one question open (below) | Ours |
+| `apps/web/public/assets/pages/**` | KFGQPC's own terms — **not ours to relicense** | KFGQPC |
+
+### Through a store binary — no such door yet
+
+Nothing goes through it. If one is opened it would carry the site door's contents wrapped in
+an installable, and that is where two clauses in the inherited grants bite that a static site
+never trips — the technological-measures clause of CC BY 4.0, and the GPL's conveying terms on
+a binary rather than a page. `docs/design/track-b-native.md` is the record for that door.
+
+Until 2026-09-01 this file had one table, organised by path, which silently meant the site
+door: it said nothing about the repository, where every vendored corpus actually is and where
+the copyleft obligation is discharged, and nothing about a store. A reader got an answer to
+"may this be distributed?" without being told which distribution it answered for.
## Why GPL for our code
@@ -52,9 +87,100 @@ it; the terms say changing is not allowed). We satisfy both readings rather than
one: the file is vendored **verbatim and never edited**, and the shards are derived from it
at build time. `docs/decisions/loop-5.md` records the full analysis.
-The app *code* is not a derivative of the corpus data — it reads the shards at runtime —
-so nothing here reaches our source by way of the data. Our code is GPL because we chose
-it, not because the corpus required it.
+The app *code* does not read the corpus data at build time: every asset it touches is
+fetched by URL at runtime, and no source file imports or reads anything under
+`packages/etl/data/`. Our code is GPL because we chose it, not because the corpus required it.
+
+That is a statement about one boundary, and it used to be written as though it covered all of
+them — *"nothing here reaches our source by way of the data"*. It does not, because content
+can arrive by being **typed**, and once did: twelve verses were transcribed into a source file
+and shipped in the bundle for the life of a feature, having crossed no build step at all.
+`gate:scripture` now refuses running scripture anywhere in the tree, which is the check that
+sentence was quietly standing in for.
+
+## Why the adjacency shards have three parents, and not one
+
+`apps/web/public/assets/adj/**` carried one upstream for most of this project's life, and the
+row above said so: the mutashabihat pairings are Waqar144's, free to use with attribution.
+That stopped being true when the shards started carrying **spans** — which words of an ayah
+a pairing actually shares — and nothing in a licence file moved, because nothing about the
+drift was a licence edit. It arrived as a feature.
+
+`build-adjacency.mjs` imports `sharedRuns` from `morphology.mjs`, and that module reads the
+same GPL-licensed Quranic Arabic Corpus morphology the root shards are built from. Its result
+is written into every pairing it can answer for as `span` and `toSpan`. Today that is **2,544
+of 3,002 edges across 114 shard files** — not an edge case, the common case.
+
+So under the same strict reading of *derivative* applied to the root shards above, the
+adjacency shards are a GPL derivative too, and one of their two parents was unnamed. Both were
+named then. The pairings remain Waqar144's and still owe attribution; the spans are the
+corpus's and carry GPL terms forward, with the same two consequences the root shards have.
+
+A third parent was named on 2026-09-01, and it arrived the same way the second did — as a
+feature, through no licence edit. **510 of 3,002 edges** carry a flag saying whether a pairing
+stays inside one juz, computed from the juz table in the shared core package; that table is
+derived from the Tanzil structural metadata (`tanzil-quran-metadata` in `SOURCES.md`, CC BY,
+attribution mandatory). No Tanzil byte ships — the flag is a boolean — but attribution is owed
+for what was derived, so the row and the notice name Tanzil too. What made this one harder to
+see than the second: `gate:notices` traces what a builder reads through its *imports*, and by
+its own header it does not follow imports into the core package, so a builder can reach a new
+upstream through core and the trace stays green. The declaration in the gate names Tanzil by
+hand for now; whether the trace should follow core is still open, in
+`docs/design/what-we-depend-on.md` ③.
+
+The asymmetry worth keeping in mind: a static site discharges §4–6 by handing the browser the
+whole bundle, which is this file's own argument for plain GPL over AGPL. A wrapped store
+binary is the thing that has to be licensed, which is why this matters more to Track B than to
+the web build — see `docs/design/track-b-native.md`.
+
+The notice travels with this data too, as of 2026-08-16. `assets/adj//NOTICE.txt` is
+written by the build beside the shards, and it names **both** parents — the way to get a
+two-parent row wrong twice is to fix it once, and a notice that discharged the GPL half while
+dropping Waqar144's attribution would have been the same one-parent mistake wearing the other
+hat. Since 2026-09-01 it names all three. The corpus's copyright block in it is not a copy of the one beside the root shards: both
+are read out of the source file on every build by the same function, because the corpus's own
+terms ask that its notice be *reproduced*, and two readers of one quotation is precisely how a
+reproduction stops being one.
+
+Nothing had noticed any of this, which is its own finding. `gate:notices` now traces what each
+builder actually reads, in imports rather than in prose, and fails the build when a shipped
+asset tree reads a vendored input that neither this table nor the tree's notice accounts for.
+
+**What this does not yet settle.** Building that trace immediately surfaced one more candidate
+parent: every edge in every shard carries the page it lands on and the distance in pages to its
+pair, and those come from a table derived from the KFGQPC page corpus — bucketed in the row
+below under terms neither upstream here grants. Whether a table of which page an ayah falls on
+is expression that carries terms forward, or a fact about a printing, is a licensing question
+and not an engineering one. It is recorded rather than guessed, because naming a third parent
+without grounds would overstate what this app owes in a notice handed to every reader, and
+leaving it unnamed understates it if the answer runs the other way.
+
+## Why the word rectangles are ours
+
+`apps/web/public/assets/words/**` is 604 files of geometry: for every word of the mus'haf,
+a rectangle on **our** page frame. It shipped for months in no row of the table above and
+under no heading in this file — not because anyone decided it needed none, but because
+nothing was reading the folder. `gate:notices` reads it now, and this is the row it asked for.
+
+The rectangles were fitted from a second SVG print of the same mus'haf, set per word rather
+than per page (`word-geometry-mushafdatabase` in `SOURCES.md`). **No byte of that print
+ships.** 378 MB was read to write 2.2 MB of numbers, and the ink stays the print we already
+had. Its grant is a Sadaqa-e-Jaria one — use, copy, modify, publish, distribute and derive,
+commercially included, with no prior approval and no attribution obliged — so the tree
+inherits nothing that has to travel with it. The colophon credits it anyway, and `SOURCES.md`
+says in as many words that the credit is a courtesy so that a later reader does not mistake it
+for a condition, or mistake a condition for a courtesy.
+
+## The manifest's one open question
+
+`apps/web/public/assets/manifest.json` is ours, and it ships one thing that is under a
+question: the complete table of which page each of the 6,236 ayahs falls on, byte-identical to
+the vendored copy the pipeline reads. That is the same table, and the same question, as the
+"what this does not yet settle" note above — whether a page table is expression that carries
+its source's terms forward, or a fact about a printing. It reaches three shipped outputs, not
+one: the adjacency shards, every root-shard occurrence, and this file. The question is tracked
+with all three attached to it rather than answered here, for the reason given above: guessing
+either way misinforms every reader who opens the colophon.
## The deploy trigger
@@ -64,12 +190,32 @@ it, not because the corpus required it.
corresponding source has to be reachable by anyone who loads the page.
Practically: **do not publish the site while this repository is private.** Either open the
-repository, or publish the ETL script and its pinned input alongside the deployment. Today
-the repository is private and nothing is published, so no obligation has triggered.
+repository, or publish the ETL script and its pinned input alongside the deployment.
+
+**That trigger has since fired, and it is discharged.** The site is published and the
+repository is public, so the corresponding source is reachable by anyone who loads the page:
+`build-roots.mjs` and `build-adjacency.mjs` are in it, along with the pinned
+`quranic-corpus-morphology-0.4.txt` both derive from. The app also offers the source
+explicitly rather than leaving a reader to find it — `SOURCE_REPO` in `apps/web/src/provenance.ts`,
+pinned to the commit the bundle was built from, and `scripts/check-source-offer.mjs` follows
+the link so a moved repository fails a check instead of quietly breaking the offer.
+
+This paragraph said the opposite for a while after it stopped being true, which is the failure
+worth naming rather than just correcting: an obligation that triggers on an *event* — a first
+public deploy — is described in a file nobody edits on the day the event happens. If the
+repository is ever taken private again, or the site is ever served from somewhere that does
+not carry the source, this is the paragraph that has to change back.
## What we do not license
The mushaf page artwork (`assets/pages/**`) is KFGQPC's, distributed under the Complex's
-own terms, and is reproduced here unmodified. Nothing in `LICENSE` grants rights to it.
+own terms. Nothing in `LICENSE` grants rights to it.
+
+It is not *unmodified*, which this paragraph used to claim. Three transforms are applied and
+each is declared, reproducible and asserted on every build — the artwork is minified at a
+fixed coordinate precision, and a short list of malformed shapes and identifiers is repaired.
+`SOURCES.md` names all three and says how many of each there are; the pipeline's own header is
+the authority on what they do. Nothing is hand-edited, which is the promise that was actually
+being made.
The same holds for every vendored input under `packages/etl/data/`. When in doubt, the
per-source entry in `SOURCES.md` governs — not this file, and not `LICENSE`.
diff --git a/Makefile b/Makefile
index 17b49e20..7cea3bb8 100644
--- a/Makefile
+++ b/Makefile
@@ -213,8 +213,10 @@ ci: core ## Full local mirror of the CI build-test-gate job, IN CI ORDER
$(PNPM) audit:corpus
$(PNPM) gate:notext
$(PNPM) gate:text-sources
+ $(PNPM) gate:scripture
$(PNPM) gate:license
$(PNPM) gate:license-copy
+ $(PNPM) gate:notices
$(PNPM) gate:validation
$(PNPM) gate:verified-edges
$(PNPM) gate:edges
@@ -224,11 +226,13 @@ ci: core ## Full local mirror of the CI build-test-gate job, IN CI ORDER
$(PNPM) gate:golden-size
$(PNPM) gate:assets
$(PNPM) gate:pages
+ $(PNPM) gate:boxes
$(PNPM) gate:words
$(PNPM) gate:align
$(PNPM) gate:map
$(PNPM) gate:use-cases
$(PNPM) gate:issues
+ $(PNPM) gate:tasks
$(PNPM) gate:decisions
$(PNPM) gate:quran-meta
$(PNPM) gate:tajweed
@@ -390,10 +394,28 @@ issues: ## What is still open, worst first: make issues · make issues ID=
@# docs/decisions.json is the source, and like the issue catalog it is an
@@ -411,6 +433,9 @@ decisions: ## What has been decided and what is still open: make decisions ·
decisions-doc: ## Re-render docs/decisions/README.md from docs/decisions.json
@node scripts/build-decisions-doc.mjs
+.PHONY: render-docs
+render-docs: use-cases-doc issues-doc tasks-doc decisions-doc ## Re-render every generated register page (the pre-commit hook refuses a stale one)
+
.PHONY: validate
validate: ## Outstanding manual checks — or one check's full runbook: make validate CHECK=
@# The edge coverage table rides along with the outstanding-checks list
@@ -604,6 +629,7 @@ help: ## List targets (this)
@echo " Golden images: make golden (diff against this platform's baselines)"
@echo " make golden-update (accept new ones — review the PNG diff!)"
@echo " make golden-linux UPDATE=1 (refresh the CI/linux set)"
+ @echo " Registers: make render-docs (re-render the four generated pages the hook checks)"
@echo " Bundle size: make budget-update (accept a new JS baseline — read the diff!)"
@echo " Parallel work: make lock L=build CMD=\"pnpm -r test\" | make lock-status"
@echo " the protocol: docs/PARALLEL-AGENTS.md"
diff --git a/README.md b/README.md
index 2a91ab12..b53f5682 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,9 @@ statuses, gates and open follow-ups live there and are deliberately not restated
- **Open items:** [`docs/issues.md`](docs/issues.md) — everything unfinished, indexed from the four
registers that hold it (the plan's follow-ups, the backlog, each design doc's open questions,
the manual-check ledger); `make issues` prints it worst-first
+- **Whose turn is it:** [`docs/tasks.md`](docs/tasks.md) — the same items ordered by who has to move
+ them, and the only page that names the open decisions and the human-only checks rather than
+ pointing at them by identifier; `make tasks` prints the counts
- **Manual-check register:** [`docs/validation/ledger.json`](docs/validation/ledger.json) — the
checks no CI job can make (a phone, a screen reader, a printed mushaf), each with a runbook
and a recorded verdict; `make validate` in the terminal, `make guide` on the phone
diff --git a/SOURCES.md b/SOURCES.md
index 7d33cf1c..336cdd3c 100644
--- a/SOURCES.md
+++ b/SOURCES.md
@@ -82,15 +82,29 @@ href: https://github.com/quranpedia/quran-svg
qurancomplex.gov.sa in an ordinary browser and confirm the wording is still what
NOTICE.md records. Nothing in the build depends on the answer; the entry moves
from PROVISIONAL to CONFIRMED-pending-that-glance.
-- **Immutability:** SVG bytes are copied verbatim and never edited (PLAN §8). Loop
- 4b applies exactly two declared transforms, both reproducible and both asserted:
- **svgo** at the version and config recorded in the pin (the config was recovered
- by search until it reproduced Loop 0's three pages byte-for-byte), and **two
- `id` repairs** — 19:3 on p305 and 75:5 on p577 carry path geometry where every
- other polygon carries `verse-`. The count is asserted to be
- exactly two and the ayahs exactly those two, so a future pin that fixes them
- upstream fails loudly instead of drifting silently. `pnpm gate:pages` re-checks
- the vendored hashes offline on every CI run.
+- **Immutability:** SVG bytes are never hand-edited (PLAN §8). Loop 4b applies
+ **three** declared transforms, each reproducible and each asserted:
+ 1. **svgo** at the version and config recorded in the pin — the config was
+ recovered by search until it reproduced Loop 0's three pages byte-for-byte.
+ It rewrites path data at a fixed coordinate precision, so the artwork that
+ ships is minified rather than byte-identical to upstream.
+ 2. **Two `id` repairs** — 19:3 on p305 and 75:5 on p577 carry path geometry
+ where every other polygon carries `verse-`. The count is
+ asserted to be exactly two and the ayahs exactly those two, so a future pin
+ that fixes them upstream fails loudly instead of drifting silently.
+ 3. **Twenty-three polygon repairs across nineteen pages.** Upstream gives some
+ ayahs a tappable box that does not cover the ayah — a page's first ayah given
+ only its last line, two lines under one line of polygon, a rect squashed off
+ the line grid. The ayah is readable either way; it simply cannot be *tapped*,
+ which for this app means it cannot be reached. Each repair carries the exact
+ upstream shape it replaces, so a pin that fixes one upstream dies rather than
+ silently re-breaking it, and `gate:pages` re-derives the defect from the
+ committed bytes on its own — it knows nothing of the repair table and simply
+ demands that no ink sits outside a polygon.
+
+ This entry said *two* transforms for as long as there were three; the pipeline's
+ own header is the authority and has been right throughout. `pnpm gate:pages`
+ re-checks the vendored hashes offline on every CI run.
---
diff --git a/apps/web/e2e/__screenshots__/darwin/p1-selection-plain.png b/apps/web/e2e/__screenshots__/darwin/p1-selection-plain.png
index bf6a8ab8..8223c51b 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p1-selection-plain.png and b/apps/web/e2e/__screenshots__/darwin/p1-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p19-breadcrumb-plain.png b/apps/web/e2e/__screenshots__/darwin/p19-breadcrumb-plain.png
index f7023443..13bac359 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p19-breadcrumb-plain.png and b/apps/web/e2e/__screenshots__/darwin/p19-breadcrumb-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p19-phrase-plain.png b/apps/web/e2e/__screenshots__/darwin/p19-phrase-plain.png
index bf3a66d7..cf69b617 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p19-phrase-plain.png and b/apps/web/e2e/__screenshots__/darwin/p19-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p19-selection-plain.png b/apps/web/e2e/__screenshots__/darwin/p19-selection-plain.png
index f2758dc8..c4212387 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p19-selection-plain.png and b/apps/web/e2e/__screenshots__/darwin/p19-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p604-phrase-plain.png b/apps/web/e2e/__screenshots__/darwin/p604-phrase-plain.png
index 4e887927..7c49dc17 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p604-phrase-plain.png and b/apps/web/e2e/__screenshots__/darwin/p604-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p604-selection-plain.png b/apps/web/e2e/__screenshots__/darwin/p604-selection-plain.png
index ec4ae8f0..914c21ea 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p604-selection-plain.png and b/apps/web/e2e/__screenshots__/darwin/p604-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p7-marquee-plain.png b/apps/web/e2e/__screenshots__/darwin/p7-marquee-plain.png
index c830db24..c3814f62 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p7-marquee-plain.png and b/apps/web/e2e/__screenshots__/darwin/p7-marquee-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p7-phrase-plain.png b/apps/web/e2e/__screenshots__/darwin/p7-phrase-plain.png
index dd02de80..ca73fd81 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p7-phrase-plain.png and b/apps/web/e2e/__screenshots__/darwin/p7-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p7-selection-plain.png b/apps/web/e2e/__screenshots__/darwin/p7-selection-plain.png
index f9567c48..315808c0 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p7-selection-plain.png and b/apps/web/e2e/__screenshots__/darwin/p7-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p9-breadcrumb-plain.png b/apps/web/e2e/__screenshots__/darwin/p9-breadcrumb-plain.png
index 3370cb86..2b4f8362 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p9-breadcrumb-plain.png and b/apps/web/e2e/__screenshots__/darwin/p9-breadcrumb-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p9-phrase-plain.png b/apps/web/e2e/__screenshots__/darwin/p9-phrase-plain.png
index 550e8c0a..101aca58 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p9-phrase-plain.png and b/apps/web/e2e/__screenshots__/darwin/p9-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/darwin/p9-selection-plain.png b/apps/web/e2e/__screenshots__/darwin/p9-selection-plain.png
index 752493a2..1a511940 100644
Binary files a/apps/web/e2e/__screenshots__/darwin/p9-selection-plain.png and b/apps/web/e2e/__screenshots__/darwin/p9-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p1-selection-plain.png b/apps/web/e2e/__screenshots__/linux/p1-selection-plain.png
index bf6a8ab8..8223c51b 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p1-selection-plain.png and b/apps/web/e2e/__screenshots__/linux/p1-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p19-breadcrumb-plain.png b/apps/web/e2e/__screenshots__/linux/p19-breadcrumb-plain.png
index e0d12436..ff6e3826 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p19-breadcrumb-plain.png and b/apps/web/e2e/__screenshots__/linux/p19-breadcrumb-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p19-phrase-plain.png b/apps/web/e2e/__screenshots__/linux/p19-phrase-plain.png
index bf3a66d7..cf69b617 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p19-phrase-plain.png and b/apps/web/e2e/__screenshots__/linux/p19-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p19-selection-plain.png b/apps/web/e2e/__screenshots__/linux/p19-selection-plain.png
index 478545bf..b0cff7af 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p19-selection-plain.png and b/apps/web/e2e/__screenshots__/linux/p19-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p604-phrase-plain.png b/apps/web/e2e/__screenshots__/linux/p604-phrase-plain.png
index 429cff4c..2450abff 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p604-phrase-plain.png and b/apps/web/e2e/__screenshots__/linux/p604-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p604-selection-plain.png b/apps/web/e2e/__screenshots__/linux/p604-selection-plain.png
index ec4ae8f0..914c21ea 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p604-selection-plain.png and b/apps/web/e2e/__screenshots__/linux/p604-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p7-marquee-plain.png b/apps/web/e2e/__screenshots__/linux/p7-marquee-plain.png
index c830db24..c3814f62 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p7-marquee-plain.png and b/apps/web/e2e/__screenshots__/linux/p7-marquee-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p7-phrase-plain.png b/apps/web/e2e/__screenshots__/linux/p7-phrase-plain.png
index dd02de80..ca73fd81 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p7-phrase-plain.png and b/apps/web/e2e/__screenshots__/linux/p7-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p7-selection-plain.png b/apps/web/e2e/__screenshots__/linux/p7-selection-plain.png
index 183e8540..eaa282aa 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p7-selection-plain.png and b/apps/web/e2e/__screenshots__/linux/p7-selection-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p9-breadcrumb-plain.png b/apps/web/e2e/__screenshots__/linux/p9-breadcrumb-plain.png
index e90b9556..5f0078e0 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p9-breadcrumb-plain.png and b/apps/web/e2e/__screenshots__/linux/p9-breadcrumb-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p9-phrase-plain.png b/apps/web/e2e/__screenshots__/linux/p9-phrase-plain.png
index 2be21b4e..54088460 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p9-phrase-plain.png and b/apps/web/e2e/__screenshots__/linux/p9-phrase-plain.png differ
diff --git a/apps/web/e2e/__screenshots__/linux/p9-selection-plain.png b/apps/web/e2e/__screenshots__/linux/p9-selection-plain.png
index cad2ddec..57419be0 100644
Binary files a/apps/web/e2e/__screenshots__/linux/p9-selection-plain.png and b/apps/web/e2e/__screenshots__/linux/p9-selection-plain.png differ
diff --git a/apps/web/e2e/contrast.spec.ts b/apps/web/e2e/contrast.spec.ts
index 05cd730a..94680ae6 100644
--- a/apps/web/e2e/contrast.spec.ts
+++ b/apps/web/e2e/contrast.spec.ts
@@ -110,10 +110,13 @@ const SURFACES: readonly Surface[] = [
.tap();
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
- // Expanding the row is the point: the diff's own labels ("here" /
- // "there", the context ellipsis) are the faintest text in the app.
+ // Expanding the row is the point: the labels naming each side of the
+ // comparison are the faintest text in the app. Waiting on the "here" one
+ // also waits out the two page loads the panel does on expand, so the
+ // measurement below runs against the finished surface rather than a
+ // half-drawn one.
await sheet.getByRole("button", { name: /البقرة · ٢:١٢٣ شفاعة/ }).tap();
- await expect(sheet.getByText(/عَدْلٌ/).first()).toBeVisible();
+ await expect(sheet.getByText(/٢:٤٨ · هنا/)).toBeVisible();
},
},
{
diff --git a/apps/web/e2e/desktop.spec.ts b/apps/web/e2e/desktop.spec.ts
index 222c1842..05c7d368 100644
--- a/apps/web/e2e/desktop.spec.ts
+++ b/apps/web/e2e/desktop.spec.ts
@@ -1,6 +1,7 @@
import { test, expect, type Locator, type Page } from "@playwright/test";
import { watchFolds, foldsSeen } from "./fold";
import { contextWithout } from "./inventory";
+import { ayahTarget } from "./ayah";
/*
* The desktop spread — an open mus'haf, and honest about the half it does not
@@ -94,6 +95,14 @@ const scaleOf = (page: Page, pageNo: number): Promise =>
.locator("xpath=..")
.evaluate((el) => new DOMMatrix(getComputedStyle(el).transform).a);
+/** The chrome's page-mode radios. Desktop only — that is where the second leaf is. */
+const modeBtn = (page: Page, which: "one" | "two"): Locator =>
+ page.getByRole("radio", { name: which === "one" ? "صفحة واحدة" : "صفحتان" });
+
+/** The stepper's two buttons. */
+const zoomBtn = (page: Page, dir: "in" | "out"): Locator =>
+ page.getByRole("button", { name: dir === "in" ? "تكبير" : "تصغير" });
+
test.describe("Hifth · the desktop spread", () => {
test("appears above the breakpoint and does not exist below it", async ({ page }) => {
await page.goto("/#/hafs-kfqc/p7");
@@ -390,6 +399,29 @@ test.describe("Hifth · the desktop spread", () => {
await expect(page.locator(NUM)).toHaveText("7");
});
+ test("a turn moves your place: the URL follows the page, and the highlight lets go", async ({
+ page,
+ }) => {
+ // Turning a leaf is moving your place, not browsing away from it (settled
+ // with the owner, against the older "paging does not touch the selection").
+ // So a landed turn drops the highlighted ayah, empties the hop trail, and
+ // lets the address fall back from the ayah form to the page's own anchor —
+ // the whole point being that a link copied after a turn points at the page
+ // the reader is looking at, not the ayah they left three leaves ago.
+ await page.goto("/#/hafs-kfqc/2:48");
+ await expect(pageSvg(page, 7)).toBeVisible({ timeout: 20_000 });
+ // The place is held: the current-ayah bead is up and the URL is the ayah.
+ await expect(page.getByRole("button", { name: /الآية الحالية البقرة · ٢:٤٨/ })).toBeVisible();
+ await expect.poll(() => new URL(page.url()).hash).toBe("#/hafs-kfqc/2:48");
+
+ await page.keyboard.press("ArrowLeft");
+ await expect(page.locator(NUM)).toHaveText("8");
+
+ // The leaf landed, so the place let go: no bead, and the address is the page.
+ await expect(page.getByRole("button", { name: /الآية الحالية/ })).toHaveCount(0);
+ await expect.poll(() => new URL(page.url()).hash).toBe("#/hafs-kfqc/p8");
+ });
+
test("a turn inside one opening draws no band", async ({ page }) => {
// §3.5, and the row 4b made reachable. Both leaves of (7,8) are already on
// screen and the crease between them is already drawn — permanently, by the
@@ -503,6 +535,111 @@ test.describe("Hifth · the desktop spread", () => {
const clip = await book(page).evaluate((el) => getComputedStyle(el).overflow);
expect(clip, "the open book stopped clipping the parked band").toBe("hidden");
});
+
+ /*
+ * The fore-edge grab — turning the page the way a hand does.
+ *
+ * On the spread the swipe-across-the-middle turn is gone (a desktop reader
+ * pans and selects through the text), and the page turns by its outer edge
+ * instead. Three claims, and they are exactly the sentence the reader wrote:
+ * hovering the edge shows a hand; a drag that begins on it turns the page; a
+ * drag that begins anywhere else does not. The band is the same one every
+ * other turn draws, so its behaviour is not re-asserted here — only that the
+ * grab reaches it. The left edge pulls forward into the book, so it is driven
+ * from page 8, where forward (8 → 9) leaves the opening and a band exists to
+ * catch; a turn inside the opening draws none, which the rows above own.
+ */
+ const grabRail = (page: Page, side: "left" | "right"): Locator =>
+ page.getByTestId(`edge-grab-${side}`);
+
+ test("the fore-edge wears a hand, and a grab from it turns the page", async ({ page }) => {
+ await watchFolds(page);
+ await page.goto("/#/hafs-kfqc/p8");
+ await expect(spread(page)).toBeVisible();
+ await expect(pageSvg(page, 8)).toBeVisible();
+
+ // The affordance the reader was promised: the outer edge is a thing you can
+ // pick up. Asserted on the computed cursor, because that *is* the promise —
+ // there is nothing else on screen that says "grab here".
+ const cursor = await grabRail(page, "left").evaluate((el) => getComputedStyle(el).cursor);
+ expect(cursor, "the fore-edge did not offer a hand").toBe("grab");
+
+ // Grab the left edge near its top corner — the widest part of the strip, and
+ // the corner a hand reaches for — and sweep it across into the book. The
+ // press is close to the outer edge, where the grab region exists at every
+ // height; the middle of the fore-edge is deliberately left to the page.
+ const rail = await boxOf(grabRail(page, "left"));
+ const y = rail.y + rail.height * 0.2;
+ await page.mouse.move(rail.x + 6, y);
+ await page.mouse.down();
+ // Past a quarter of the leaf, so the commit rule keeps the turn. Several
+ // steps because it is a drag, not a teleport — but the grab is a trigger,
+ // not a tracked band: nothing is drawn while the hand moves, and the turn
+ // plays on release.
+ for (let i = 1; i <= 8; i += 1) await page.mouse.move(rail.x + 6 + i * 45, y);
+ await page.mouse.up();
+
+ // It turned, and on release it drew the same book-wide band a keyed turn
+ // does — proof the grab reached the one stage that owns turning and played
+ // the ordinary animated turn, rather than turning some second way of its own.
+ await expect(page.locator(NUM)).toHaveText("9");
+ const seen = await foldsSeen(page);
+ expect(seen.length, "the grab turned the page without drawing a fold").toBeGreaterThan(0);
+ expect(seen[0]!.host, "the grabbed turn's band was not the shared one").toBe("page-book");
+ });
+
+ test("a grab that stops short of the threshold turns nothing, and creeps no band", async ({
+ page,
+ }) => {
+ await watchFolds(page);
+ await page.goto("/#/hafs-kfqc/p8");
+ await expect(spread(page)).toBeVisible();
+ await expect(pageSvg(page, 8)).toBeVisible();
+
+ // Grab the left fore-edge and pull it a little — past the few pixels of slop
+ // that tell a click from a drag, but nowhere near the quarter-leaf the commit
+ // rule wants. This is the reader who picks the edge up, thinks better of it,
+ // and puts it down.
+ const rail = await boxOf(grabRail(page, "left"));
+ const y = rail.y + rail.height * 0.2;
+ await page.mouse.move(rail.x + 6, y);
+ await page.mouse.down();
+ for (let i = 1; i <= 6; i += 1) await page.mouse.move(rail.x + 6 + i * 12, y);
+
+ // Mid-drag, the whole point of the trigger: no band creeps across the book.
+ // A tracked band would sit here, part-way over a spread whose pages have not
+ // changed — the "vertical bar on the same page" this replaced.
+ expect(await foldsSeen(page), "a band crept across the book during the drag").toEqual([]);
+
+ await page.mouse.up();
+
+ // And releasing short commits nothing: still on 8, and no band ever drawn.
+ await expect(page.locator(NUM)).toHaveText("8");
+ expect(await foldsSeen(page), "a short grab still turned the page").toEqual([]);
+ });
+
+ test("a drag that does not start at the fore-edge does not turn the page", async ({ page }) => {
+ await watchFolds(page);
+ await page.goto("/#/hafs-kfqc/p8");
+ await expect(spread(page)).toBeVisible();
+ await expect(pageSvg(page, 8)).toBeVisible();
+
+ // The same long horizontal sweep, but begun in the middle of the page rather
+ // than on its edge. This is the half of the reader's sentence that the old
+ // swipe-to-turn would have failed: it turned from anywhere, and the point of
+ // the edge grab is that the text is now free to be dragged without turning.
+ const leaf = await boxOf(pageSvg(page, 8));
+ const cy = leaf.y + leaf.height / 2;
+ await page.mouse.move(leaf.x + leaf.width / 2, cy);
+ await page.mouse.down();
+ for (let i = 1; i <= 8; i += 1) await page.mouse.move(leaf.x + leaf.width / 2 + i * 40, cy);
+ await page.mouse.up();
+
+ // Still on 8, and no band was ever inserted — the drag through the text was
+ // not a turn at all.
+ await expect(page.locator(NUM)).toHaveText("8");
+ expect(await foldsSeen(page), "a mid-page drag turned the page").toEqual([]);
+ });
});
/*
@@ -581,6 +718,65 @@ test.describe("Hifth · the revision map at desktop", () => {
});
});
+/*
+ * The page bar on a wide window — `desktop.md` §8 ⑤, the same shape of defect the
+ * revision map had and one row up.
+ *
+ * The bar is the app's bottom chrome, so its hairline and paper are meant to run
+ * the full width of the window — that is what says "this is the floor of the app"
+ * rather than "this is a widget floating in a field". But the control inside it is
+ * a native ` `, and a native range track takes every pixel its
+ * box is given: with the bar full-bleed and nothing holding the track in, a
+ * 1440px window drew the slider as a hairline the whole width of the screen, a
+ * thumb travelling four feet to cross seven pages. The fix holds the *controls* to
+ * `--controls-max` (60rem, the width the desktop mocks drew the book at) by
+ * growing the bar's side padding, and leaves the border and background full-bleed.
+ *
+ * So the claim is two-sided and both sides matter: the bar spans the window, and
+ * the track does not. A cap on the whole bar would pass the second half and lose
+ * the first — a centred pill with a moat of desk on either side, which is the
+ * opposite mistake and the reason the padding grows instead of a max-width being
+ * set. Measured against the real layout engine because the native track's
+ * greediness is exactly what jsdom does not model.
+ */
+test.describe("Hifth · the page bar at desktop", () => {
+ test("holds the slider to the book's width while the bar stays full-bleed", async ({ page }) => {
+ await page.goto("/#/hafs-kfqc/p7");
+ await expect(spread(page)).toBeVisible();
+
+ const bar = page.getByRole("navigation", { name: "شريط الصفحات" });
+ await expect(bar).toBeVisible();
+ const barBox = await boxOf(bar);
+ const slider = await boxOf(page.getByRole("slider"));
+ const vw = page.viewportSize()!.width;
+
+ // The bar is the floor: its box runs the whole window. Not `=== vw` — a
+ // scrollbar or a sub-pixel rounding is not the regression. A bar capped to
+ // the controls would come back ~960 here, less than two-thirds of the window.
+ expect(barBox.width, "the bar stopped spanning the window").toBeGreaterThan(vw - 20);
+
+ // The track does not. 60rem is 960px; the slider is the middle column inside
+ // that, minus the two edge buttons, so it lands comfortably under the cap and
+ // nowhere near the window. The failure this guards — a full-width native
+ // track — comes back ~1400 here, so the threshold has a wide margin either
+ // side of both the pass (~860) and the fail (~1400).
+ expect(slider.width, "the slider stretched to the full window width").toBeLessThan(960);
+ expect(
+ slider.width,
+ `the slider is ${Math.round(slider.width)}px of a ${vw}px window — the track was not held in`,
+ ).toBeLessThan(vw * 0.75);
+
+ // And it is centred in the window, not shoved to one side — the padding grows
+ // equally on both edges, so the book above and the controls below share an
+ // axis. A one-sided cap would satisfy the width assertions and still sit the
+ // slider against the left of the desk.
+ expect(
+ Math.abs(slider.x + slider.width / 2 - vw / 2),
+ "the slider is bounded but not centred under the book",
+ ).toBeLessThan(barBox.width * 0.1);
+ });
+});
+
/*
* A tablet in landscape — `desktop.md` §8 ③.
*
@@ -795,10 +991,9 @@ test.describe("Hifth · the wheel", () => {
/*
* The book closes when the reader says so — `desktop.md` §8 ②, second answer.
*
- * The outcome §8 ② argued for survives: a spread is an offer of *more of the
- * book at once*, and a reader who wants one magnified page is declining it, so
- * past that point the facing leaf goes to zero and the live one takes the desk.
- * What is gone is the *mechanism*. It used to be derived — zoom past fit and the
+ * The toggle stands on its own: a reader who wants one page and the whole desk
+ * for it says so, and the facing leaf goes to zero. What is gone is the
+ * *mechanism* that used to decide the mode *for* them — zoom past fit and the
* book closed itself — and three separate desyncs came out of that one
* derivation:
*
@@ -815,18 +1010,16 @@ test.describe("Hifth · the wheel", () => {
* These rows are the same three claims, re-put to the controls that replaced the
* derivation — ② by construction, and it has its own row because a resize is the
* one input nothing in the stage reports.
+ *
+ * The stepper works with the book open now, and grows both leaves together — a
+ * later reversal of §8 ②'s finding, made on the reader's own call. The two
+ * leaves are kept at one magnification by construction, not by a shared view:
+ * the live stage owns the level, and the facing leaf is told to match whatever
+ * the live one lands at, so there is still only one number and nothing to desync.
*/
test.describe("Hifth · one page or two, and how big", () => {
const soloOf = (page: Page): Promise => book(page).getAttribute("data-solo");
- /** The chrome's page-mode radios. Desktop only — that is where the second leaf is. */
- const modeBtn = (page: Page, which: "one" | "two"): Locator =>
- page.getByRole("radio", { name: which === "one" ? "صفحة واحدة" : "صفحتان" });
-
- /** The stepper's two buttons. */
- const zoomBtn = (page: Page, dir: "in" | "out"): Locator =>
- page.getByRole("button", { name: dir === "in" ? "تكبير" : "تصغير" });
-
/**
* The level as the eye reads it, scoped to the stepper rather than to the page.
*
@@ -870,38 +1063,88 @@ test.describe("Hifth · one page or two, and how big", () => {
await expect(pageSvg(page, 8)).toBeVisible();
});
- test("the stepper magnifies the one leaf, and is off while there are two", async ({ page }) => {
+ test("the stepper magnifies one leaf alone, and both leaves of a spread together", async ({
+ page,
+ }) => {
await page.goto("/#/hafs-kfqc/p7");
await expect(pageSvg(page, 7)).toBeVisible({ timeout: 20_000 });
- // Two magnified leaves lose their edges and read as one continuous column,
- // and §3's measurement is that a leaf in a spread is height-bound at ~398 px
- // anyway. So the stepper is disabled here, with the toggle as the way out.
- await expect(zoomBtn(page, "in")).toBeDisabled();
- await expect(zoomBtn(page, "out")).toBeDisabled();
-
- await modeBtn(page, "one").click();
- await expect.poll(() => soloOf(page)).toBe("true");
- const fit = await boxOf(pageSvg(page, 7));
+ // Two pages open, and the stepper is live: the reader magnifies the whole
+ // opening, so one press grows *both* leaves to the same rung. This reverses
+ // the older finding that two enlarged pages read as one column — the reader
+ // asked for them to grow together, and the record that reversed it says why.
+ await expect(spread(page)).toBeVisible();
await expect(zoomBtn(page, "in")).toBeEnabled();
+ const liveFit = await restingBox(page, 7);
+ const faceFit = await restingBox(page, 8);
- // One rung: 1 → 1.25. A ladder rather than a multiplier, so the readout can
- // be believed — and it is the readout, not the matrix, that a reader reads.
await zoomBtn(page, "in").click();
await expect.poll(() => scaleOf(page, 7)).toBeCloseTo(1.25, 2);
+ await expect.poll(() => scaleOf(page, 8), "the facing leaf grew with the live one").toBeCloseTo(
+ 1.25,
+ 2,
+ );
await expect(readout(page)).toHaveText("١٢٥٪");
await expect(page.locator("[aria-live='polite']")).toHaveText("التكبير ١٢٥٪");
- // The page grew, and grew by about the rung rather than to the desk.
- const bigger = await boxOf(pageSvg(page, 7));
- expect(bigger.width).toBeGreaterThan(fit.width * 1.15);
- expect(bigger.width, "the page stretched past its step").toBeLessThan(fit.width * 1.35);
+ // Both leaves grew, and each by about the rung rather than to the desk.
+ const liveBig = await restingBox(page, 7);
+ const faceBig = await restingBox(page, 8);
+ expect(liveBig.width, "the live leaf stretched past its step").toBeGreaterThan(
+ liveFit.width * 1.15,
+ );
+ expect(faceBig.width, "the facing leaf stretched past its step").toBeGreaterThan(
+ faceFit.width * 1.15,
+ );
+
+ // The opening grew as one sheet, not two swelling blobs: the fold held and the
+ // pages opened *outward* into the desk. This is the reader's report — a magnify
+ // that used to crush the middle and run the outer margins off the screen,
+ // because each leaf grew from its own centre. Now each leaf is pinned at its
+ // gutter edge, so the two inner edges that meet at the fold stay put while the
+ // two outer edges move apart. The live leaf is the right-hand page: its left
+ // edge is the fold, its right edge is the outer margin.
+ const fold = (b: { x: number; width: number }) => b.x; // right leaf: inner edge is its left
+ const faceFold = (b: { x: number; width: number }) => b.x + b.width; // left leaf: inner is its right
+ // Held to within a couple of pixels — the anchor is exact, the slack is
+ // sub-pixel rounding in the rendered box, not the leaf drifting off the fold.
+ expect(
+ Math.abs(fold(liveBig) - fold(liveFit)),
+ "the fold under the live leaf held",
+ ).toBeLessThan(3);
+ expect(
+ Math.abs(faceFold(faceBig) - faceFold(faceFit)),
+ "the fold under the facing leaf held",
+ ).toBeLessThan(3);
+ expect(liveBig.x + liveBig.width, "the live leaf grew outward, away from the fold").toBeGreaterThan(
+ liveFit.x + liveFit.width,
+ );
+ expect(faceBig.x, "the facing leaf grew outward, away from the fold").toBeLessThan(faceFit.x);
+ // And at this rung the outward growth is still inside the desk — nothing is
+ // clipped by the viewport. (The desk margin is ~325 px a side at fit; a step to
+ // 125% spends ~100 of it.) The two leaves stay level about the fold, so the
+ // opening reads as one book and not a torn one.
+ const vw = page.viewportSize()!.width;
+ expect(liveBig.x + liveBig.width, "the live leaf's outer margin is still on screen").toBeLessThan(
+ vw,
+ );
+ expect(faceBig.x, "the facing leaf's outer margin is still on screen").toBeGreaterThan(0);
+
+ // Close to one leaf. The live page takes the whole desk and keeps the level
+ // it had in the spread — closing changes how much of the book is shown, not
+ // how big it is — and the stepper keeps working on the one page that is left.
+ await modeBtn(page, "one").click();
+ await expect.poll(() => soloOf(page)).toBe("true");
+ await expect(readout(page)).toHaveText("١٢٥٪");
+ await zoomBtn(page, "in").click();
+ await expect.poll(() => scaleOf(page, 7)).toBeCloseTo(1.5, 2);
+ await expect(readout(page)).toHaveText("١٥٠٪");
- // Opening the book takes the magnification back with it — the other half of
- // "zoom needs one page", closing the door a reader could otherwise walk back
- // through by zooming first and opening after.
+ // Re-open the book: it starts at fit, so the two leaves agree from the first
+ // frame rather than opening at two different sizes.
await modeBtn(page, "two").click();
await expect.poll(() => scaleOf(page, 7)).toBeCloseTo(1, 2);
+ await expect.poll(() => scaleOf(page, 8)).toBeCloseTo(1, 2);
await expect(readout(page)).toHaveText("١٠٠٪");
});
@@ -968,3 +1211,410 @@ test.describe("Hifth · one page or two, and how big", () => {
await expect.poll(() => scaleOf(page, 7)).toBeCloseTo(1.25, 2);
});
});
+
+/*
+ * The trail bar holds its height — selecting an ayah must not move the page.
+ *
+ * The bar at the foot of the app is quiet until you pick an ayah, and then it
+ * fills with a row of touch-sized controls: the bead you are on, the roots
+ * trigger, the share button. Each is `--touch-min` tall, and a bar sized only
+ * to `min-height: --touch-min` was 44px empty and 61px full — the controls, the
+ * bar's own block padding and its top hairline outgrew the box the moment they
+ * appeared. The stage above is `flex: 1` and centres the page in whatever height
+ * is left, so those 17px came straight off the stage and the whole mus'haf
+ * jumped upward on the first tap of a reading session. A reader aiming at a
+ * second ayah found the page had walked out from under the finger.
+ *
+ * This is the single-page layout's claim, so the window is sized below the
+ * spread's gate before it is made — on the spread the ayah's controls move onto
+ * the facing leaf instead (see the scripture-floor test above, which is the
+ * reason the bar is *not* padded taller when two leaves are open). 390×844 is a
+ * phone-shaped window; the desktop project reaches it by resizing, because it is
+ * the one project with a mouse to click an ayah where a phone would tap.
+ *
+ * So the claim is two heights, read before and after a selection, each the same
+ * number: the bar itself, and the stage above it. The stage is the outcome — it
+ * is the height the growing bar used to eat — while the bar is the mechanism, so
+ * a future layout that holds the stage still by some other means still passes.
+ * The page's own position is deliberately *not* asserted: selecting an ayah pans
+ * the view to centre it, which moves the page on purpose, and that pan is a
+ * different thing from the bar shoving the whole stage upward.
+ */
+test.describe("Hifth · the trail bar holds its height", () => {
+ test("selecting an ayah does not resize the bar or shove the page up", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/#/hafs-kfqc/p7");
+ await expect(spread(page)).toHaveCount(0);
+ await expect(pageSvg(page, 7)).toBeVisible();
+
+ const bar = page.getByRole("contentinfo");
+ const stage = page.getByRole("main");
+ await expect(bar).toBeVisible();
+
+ // Nothing is selected yet: the bar shows only its hint, and no bead exists.
+ const bead = page.getByRole("button", { name: /الآية الحالية/ });
+ await expect(bead).toHaveCount(0);
+
+ const barEmpty = await boxOf(bar);
+ const stageEmpty = await boxOf(stage);
+
+ // Pick an ayah with a plain click where a finger would land (see ./ayah).
+ const { x, y } = await ayahTarget(page, "#verse-55");
+ await page.mouse.click(x, y);
+
+ // The selection landed — the bar is now populated with the current bead.
+ await expect(bead).toBeVisible();
+
+ const barFull = await boxOf(bar);
+ expect(barFull.height, "the trail bar grew when an ayah was selected").toBe(barEmpty.height);
+ expect(
+ (await boxOf(stage)).height,
+ "the growing bar ate into the stage the page reads on",
+ ).toBe(stageEmpty.height);
+ });
+});
+
+/*
+ * A juz jump lands the two leaves level — with no flash of one sitting high.
+ *
+ * A jump to another juz is not a page turn: the live leaf relocates across many
+ * openings at once, and the facing leaf remounts to the new opening's other
+ * page. The live leaf's relocation reveals its incoming page and *then* frames
+ * it, and the framing runs a frame later — so for one paint the incoming page
+ * wore no position and sat at the top-left of its leaf, one centring-offset
+ * above the facing page beside it. On a fast machine that is a single frame; on
+ * a real one the incoming ~170 KB page's parse stalls the correcting frame long
+ * enough to see and screenshot, which is how this arrived — "sometimes things
+ * aren't aligned after a juz jump", with a picture of two leaves at different
+ * heights.
+ *
+ * So the claim is not a resting measurement — both leaves settle level with or
+ * without the fix — but that they are level *at every frame* of the jump. This
+ * watches the two visible pages across the settle and fails if any single frame
+ * caught them more than a pixel apart. The offset it guards against is a whole
+ * centring gap (~9px), so the threshold is loose enough to ignore the sub-pixel
+ * rounding a live layout carries and still catch the flash.
+ */
+test("a juz jump keeps the two leaves level through every frame", async ({ page }) => {
+ await watchFolds(page);
+ await page.goto("/#/hafs-kfqc/p8");
+ await expect(pageSvg(page, 8)).toBeVisible();
+ await expect(spread(page)).toBeVisible();
+ // A cursor over the book, so the wheel's juz jump has somewhere to land.
+ await page.mouse.move(400, 450);
+
+ // Sample both visible leaves' top edges every frame while the jump settles.
+ const poll = page.evaluate<{ pair: boolean; gap: number }[]>(
+ () =>
+ new Promise((resolve) => {
+ const frames: { pair: boolean; gap: number }[] = [];
+ const start = performance.now();
+ const tick = () => {
+ const ys = Array.from(
+ document.querySelectorAll('svg[aria-labelledby^="page-label-"]'),
+ )
+ .filter((n) => (n as SVGElement).getClientRects().length > 0)
+ .map((n) => n.getBoundingClientRect().top);
+ frames.push({
+ pair: ys.length === 2,
+ gap: ys.length === 2 ? Math.abs(ys[0]! - ys[1]!) : 0,
+ });
+ if (performance.now() - start < 700) requestAnimationFrame(tick);
+ else resolve(frames);
+ };
+ requestAnimationFrame(tick);
+ }),
+ );
+
+ // Jump forward one juz (Shift + wheel is the desktop juz control).
+ await page.keyboard.down("Shift");
+ await page.mouse.wheel(0, 120);
+ await page.keyboard.up("Shift");
+
+ const frames = await poll;
+ // The jump actually happened — a second leaf was on screen at some point.
+ expect(frames.some((f) => f.pair), "the jump never drew a second leaf").toBe(true);
+ const worst = Math.max(...frames.filter((f) => f.pair).map((f) => f.gap));
+ expect(worst, "the two leaves flashed misaligned during the juz jump").toBeLessThan(1.5);
+ // And the jump was a relocation, not a turn: no fold band crosses on a hop.
+ expect((await foldsSeen(page)).length, "a juz jump drew a fold band").toBe(0);
+});
+
+/*
+ * A page turn lands the two leaves level too — the jump's sibling claim.
+ *
+ * Turning a leaf crosses into the next opening and remounts the facing page, the
+ * same remount the juz jump above watches: the incoming leaf could paint for one
+ * frame at its layer's top-left, one centring-offset above the page beside it,
+ * before the landing frame corrects it. The centring on the turn's landing is
+ * what forecloses that, and this is the guard on it — reported against the back
+ * of the book ("misalignment after flipping pages", a picture of At-Takwir riding
+ * high over Abasa), so the turn is taken there rather than in Al-Baqarah where a
+ * cold page's parse cost is a different number.
+ *
+ * Same shape as the jump's guard: not a resting measurement — both leaves settle
+ * level regardless — but every frame of the turn, failing if any single one
+ * caught them more than a pixel apart. Unlike the jump, a turn *does* draw a fold,
+ * so that is asserted rather than its absence — the two claims are otherwise the
+ * same claim from the two verbs.
+ */
+test("a page turn keeps the two leaves level through every frame", async ({ page }) => {
+ await watchFolds(page);
+ // Start one opening back, so the forward turn crosses *into* At-Takwir facing
+ // Abasa (the opening the report pictured) — a within-opening step would remount
+ // nothing and draw no fold, exercising neither half of the claim.
+ await page.goto("/#/hafs-kfqc/p584");
+ await expect(pageSvg(page, 584)).toBeVisible({ timeout: 20_000 });
+ await expect(spread(page)).toBeVisible();
+ // A cursor over the book, so a wheel would have somewhere to land — the arrow
+ // does not need it, but it keeps the rig identical to the jump's above.
+ await page.mouse.move(400, 450);
+
+ const poll = page.evaluate<{ pair: boolean; gap: number }[]>(
+ () =>
+ new Promise((resolve) => {
+ const frames: { pair: boolean; gap: number }[] = [];
+ const start = performance.now();
+ const tick = () => {
+ const ys = Array.from(
+ document.querySelectorAll('svg[aria-labelledby^="page-label-"]'),
+ )
+ .filter((n) => (n as SVGElement).getClientRects().length > 0)
+ .map((n) => n.getBoundingClientRect().top);
+ frames.push({
+ pair: ys.length === 2,
+ gap: ys.length === 2 ? Math.abs(ys[0]! - ys[1]!) : 0,
+ });
+ if (performance.now() - start < 700) requestAnimationFrame(tick);
+ else resolve(frames);
+ };
+ requestAnimationFrame(tick);
+ }),
+ );
+
+ // ← turns forward, into the next opening.
+ await page.keyboard.press("ArrowLeft");
+
+ const frames = await poll;
+ expect(frames.some((f) => f.pair), "the turn never drew a second leaf").toBe(true);
+ const worst = Math.max(...frames.filter((f) => f.pair).map((f) => f.gap));
+ expect(worst, "the two leaves flashed misaligned during the page turn").toBeLessThan(1.5);
+ // And this one *is* a turn: a fold band crossed the book.
+ expect((await foldsSeen(page)).length, "a page turn drew no fold band").toBeGreaterThan(0);
+});
+
+/*
+ * The live bead in the trail bar hides the string it hangs on.
+ *
+ * The footer's beads thread along a hairline "string" drawn behind them, and the
+ * live bead — the ayah you are on — glows amber to echo the on-page selection.
+ * That amber is deliberately translucent, the same wash the selection multiplies
+ * over scripture; drawn as the bead's only fill it let the string show straight
+ * through the label, a hairline struck through the current ayah's text. Every
+ * other bead hides the string because its paper is opaque, so the fix composites
+ * the wash over that same opaque paper.
+ *
+ * Occlusion is a fact about paint, which jsdom cannot see and a screenshot would
+ * assert too bluntly. What a real layout engine *can* answer, and what the fix
+ * turns on, is whether the bead carries an opaque layer at all: the wash alone
+ * computes to a see-through fill colour with no image behind it, while the fix
+ * leaves a fully opaque paper colour under a gradient. So the claim is the live
+ * bead's own background is opaque — the string cannot reach the glyph through it.
+ *
+ * Phone-shaped window and a mouse, the same rig as the trail-bar test above: the
+ * bead only appears once an ayah is picked, and the desktop project is the one
+ * with a pointer to pick it.
+ */
+test("the live trail bead is opaque, so the string cannot show through its label", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/#/hafs-kfqc/p7");
+ await expect(spread(page)).toHaveCount(0);
+ await expect(pageSvg(page, 7)).toBeVisible();
+
+ const { x, y } = await ayahTarget(page, "#verse-55");
+ await page.mouse.click(x, y);
+
+ const bead = page.locator('[class*="beadCurrent"]');
+ await expect(bead).toBeVisible();
+
+ // The alpha of the bead's own resting background colour. The wash-only bug left
+ // this translucent; the fix's opaque paper layer computes to alpha 1.
+ const alpha = await bead.evaluate((el) => {
+ const bg = getComputedStyle(el).backgroundColor; // rgb(...) or rgba(...)
+ const m = bg.match(/rgba?\(([^)]+)\)/);
+ if (!m) return 1;
+ const parts = m[1]!.split(",").map((s) => parseFloat(s.trim()));
+ return parts.length < 4 ? 1 : parts[3]!;
+ });
+ expect(alpha, "the live bead's background is translucent — the string shows through it").toBe(1);
+});
+
+/*
+ * Every road onto a page lands the two leaves level — the invariant, not a verb.
+ *
+ * The jump's guard and the turn's guard above each watch one road, and that is
+ * how the thread they came from went: four leaf-placement defects (a juz jump, a
+ * turn, a tap, a zoom), each one road that had dropped or reordered the same
+ * settle step, each found by eye and fixed on its own road, none of the fixes
+ * reaching the next. The stage now runs every road through one step, and this
+ * is the guard on *that*: every road there is, driven through one measurement —
+ * through every frame the two leaves on screen sit at the same height, and at
+ * rest they meet at the spine — so the day a road stops settling, the row for
+ * that road fails, not a reader.
+ *
+ * Taken at the back of the book, where the report that started the thread was
+ * pictured (At-Takwir riding high over Abasa).
+ */
+test.describe("every road onto a page lands the leaves level", () => {
+ type Road = { name: string; drive: (page: Page) => Promise; lands: number };
+ const roads: Road[] = [
+ // The cold mount. Nothing to drive: its frames are the resting frames.
+ { name: "a cold open", lands: 584, drive: async () => {} },
+ {
+ name: "a deep link to another page",
+ lands: 300,
+ drive: (page) =>
+ page.evaluate(() => {
+ location.hash = "#/hafs-kfqc/p300";
+ }),
+ },
+ {
+ name: "the page bar",
+ lands: 300,
+ drive: (page) => page.getByRole("slider").fill("300"),
+ },
+ {
+ name: "a page turn",
+ lands: 585,
+ drive: (page) => page.keyboard.press("ArrowLeft"),
+ },
+ {
+ // Back from the middle of juz 30 lands on its own opening, 582 — the ⏮ rule.
+ name: "a juz jump",
+ lands: 582,
+ drive: async (page) => {
+ await page.keyboard.down("Shift");
+ await page.mouse.wheel(0, -120);
+ await page.keyboard.up("Shift");
+ },
+ },
+ {
+ name: "a zoom step in and back out",
+ lands: 584,
+ drive: async (page) => {
+ await zoomBtn(page, "in").click();
+ await expect.poll(() => scaleOf(page, 584)).toBeCloseTo(1.25, 2);
+ await zoomBtn(page, "out").click();
+ await expect.poll(() => scaleOf(page, 584)).toBeCloseTo(1, 2);
+ },
+ },
+ {
+ name: "closing the book and opening it again",
+ lands: 584,
+ drive: async (page) => {
+ await modeBtn(page, "one").click();
+ await expect(pageSvg(page, 583)).toHaveCount(0);
+ await modeBtn(page, "two").click();
+ },
+ },
+ {
+ name: "a resize across the breakpoint and back",
+ lands: 584,
+ drive: async (page) => {
+ await page.setViewportSize({ width: 800, height: 900 });
+ await expect(spread(page)).toHaveCount(0);
+ await page.setViewportSize({ width: 1440, height: 900 });
+ },
+ },
+ ];
+
+ /**
+ * Both painted leaves, every frame for `ms`: are there two, and how far apart
+ * are their heads.
+ *
+ * Read *after* each paint, not inside the animation frame the way the two
+ * guards above do. Reopening the book taught the difference: the frame in
+ * which the spread's layout comes back has both leaves still wearing their
+ * one-leaf transforms when an animation-frame callback measures them, 313 px
+ * apart — and the stage's resize observer then corrects both before that
+ * frame is ever painted. A reader never sees it. A rig that read there would
+ * fail a road that is right, so it reads in the task after the frame, which
+ * is the first moment a reader could have. A flash that survives a paint (the
+ * jump's and the turn's, before their fixes) is still a painted frame, and is
+ * still caught. Zero-sized leaves are not leaves: the closed book keeps the
+ * facing host in the tree at no width.
+ */
+ const framesOf = (page: Page, ms: number) =>
+ page.evaluate<{ pair: boolean; gap: number }[], number>(
+ (ms) =>
+ new Promise((resolve) => {
+ const frames: { pair: boolean; gap: number }[] = [];
+ const start = performance.now();
+ const tick = () =>
+ requestAnimationFrame(() =>
+ setTimeout(() => {
+ const ys = Array.from(
+ document.querySelectorAll('svg[aria-labelledby^="page-label-"]'),
+ )
+ .map((n) => n.getBoundingClientRect())
+ .filter((r) => r.width > 0 && r.height > 0)
+ .map((r) => r.top);
+ frames.push({
+ pair: ys.length === 2,
+ gap: ys.length === 2 ? Math.abs(ys[0]! - ys[1]!) : 0,
+ });
+ if (performance.now() - start < ms) tick();
+ else resolve(frames);
+ }, 0),
+ );
+ tick();
+ }),
+ ms,
+ );
+
+ /** The opening at rest: how many leaves, how level, and the width of the seam between them. */
+ const atRest = (page: Page) =>
+ page.evaluate(() => {
+ const rs = Array.from(document.querySelectorAll('svg[aria-labelledby^="page-label-"]'))
+ .map((n) => n.getBoundingClientRect())
+ .filter((r) => r.width > 0 && r.height > 0)
+ .sort((a, b) => a.x - b.x);
+ const two = rs.length === 2;
+ return {
+ leaves: rs.length,
+ gap: two ? Math.abs(rs[0]!.top - rs[1]!.top) : NaN,
+ seam: two ? rs[1]!.left - rs[0]!.right : NaN,
+ height: two ? Math.abs(rs[0]!.height - rs[1]!.height) : NaN,
+ };
+ });
+
+ for (const road of roads) {
+ test(road.name, async ({ page }) => {
+ await page.goto("/#/hafs-kfqc/p584");
+ await expect(pageSvg(page, 584)).toBeVisible({ timeout: 20_000 });
+ await expect(spread(page)).toBeVisible();
+ await page.mouse.move(400, 450);
+
+ const poll = framesOf(page, 700);
+ await road.drive(page);
+ const frames = await poll;
+
+ expect(frames.some((f) => f.pair), `${road.name} never showed two leaves`).toBe(true);
+ const worst = Math.max(...frames.filter((f) => f.pair).map((f) => f.gap));
+ expect(worst, `${road.name}: the two leaves flashed misaligned`).toBeLessThan(1.5);
+
+ // And at rest, once the road has landed where it said it would.
+ await expect(pageSvg(page, road.lands)).toBeVisible({ timeout: 20_000 });
+ await expect.poll(async () => (await atRest(page)).leaves).toBe(2);
+ const rest = await atRest(page);
+ expect(rest.gap, `${road.name}: the leaves came to rest at different heights`).toBeLessThan(1.5);
+ expect(rest.height, `${road.name}: the leaves came to rest at different sizes`).toBeLessThan(
+ 1.5,
+ );
+ // Two 1px leaf borders sit between the papers; a hundred did, once.
+ expect(rest.seam, `${road.name}: the leaves came to rest apart at the spine`).toBeLessThan(8);
+ });
+ }
+});
diff --git a/apps/web/e2e/lang.spec.ts b/apps/web/e2e/lang.spec.ts
index e26a7908..e368587f 100644
--- a/apps/web/e2e/lang.spec.ts
+++ b/apps/web/e2e/lang.spec.ts
@@ -38,6 +38,20 @@ test.describe("Hifth · language", () => {
await expect(page.getByRole("button", { name: /About Hifth/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^Go to/ })).toBeVisible();
await expect(page.locator("header")).toContainText("Page");
+
+ // The wordmark speaks English too. It used to be pinned to «حفظ · مِلاحة
+ // للحُفّاظ» in both languages — a decision this feature reverses, because the
+ // wordmark is chrome and the note under the language switch already promises
+ // only the mus'haf and the verse text stay Arabic. The name is a name, so
+ // the English is its transliteration — the same "Hifth" the colophon uses in
+ // prose — not a translation. `.mark` is `aria-hidden`, so it is reached by
+ // text rather than by role; the About button above already carries the name
+ // to a screen reader.
+ const brand = page.getByRole("button", { name: /About Hifth/ });
+ await expect(brand).toContainText("Hifth");
+ await expect(brand).toContainText("Navigation for huffaz");
+ // And it is not the Arabic wordmark hiding behind an English chrome.
+ await expect(page.locator("header")).not.toContainText("حفظ");
});
test("the mus'haf, the rail, the trail and the page bar stay right-to-left", async ({
diff --git a/apps/web/e2e/revision.spec.ts b/apps/web/e2e/revision.spec.ts
index 63469982..9bb5e40e 100644
--- a/apps/web/e2e/revision.spec.ts
+++ b/apps/web/e2e/revision.spec.ts
@@ -185,7 +185,7 @@ test.describe("Hifth · the revision map", () => {
// week. What it does pin is the invariant that makes that case survivable —
// the map never renders an empty picture without saying how long it has been
// recording.
- await expect(sheet.getByText(/^يُسجَّل منذ ٢٠[٠-٩]{2}-[٠-٩]{2}-[٠-٩]{2}$/)).toBeVisible();
+ await expect(sheet.getByText(/^نشِط منذ [٠-٩]{1,2} \S+ ٢٠[٠-٩]{2}$/)).toBeVisible();
});
});
diff --git a/apps/web/e2e/share-a11y.spec.ts b/apps/web/e2e/share-a11y.spec.ts
index 8a533a01..dfef6063 100644
--- a/apps/web/e2e/share-a11y.spec.ts
+++ b/apps/web/e2e/share-a11y.spec.ts
@@ -96,7 +96,9 @@ test.describe("Hifth · share links (spec §7)", () => {
});
test.describe("Hifth · diff view (spec §3)", () => {
- test("expanding a hop row reveals the token diff of the two readings", async ({ page }) => {
+ test("expanding a hop row draws both ayahs from the page, with the leftover washed", async ({
+ page,
+ }) => {
await page.goto("/");
await tapAyah(page, "#verse-55");
const loopChip = page
@@ -107,11 +109,23 @@ test.describe("Hifth · diff view (spec §3)", () => {
await expect(sheet).toBeVisible();
// The 2:123 row's expander (the labelled text button, not the hop button)
- // toggles the token diff showing شفاعة (here) vs عدل (target).
+ // opens the comparison. The row's own label carries the hand-written note.
const expander = sheet.getByRole("button", { name: /البقرة · ٢:١٢٣ شفاعة/ });
await expander.tap();
- await expect(sheet.getByText(/شَفَاعَةٌ/).first()).toBeVisible();
- await expect(sheet.getByText(/عَدْلٌ/).first()).toBeVisible();
+
+ // Both ayahs are named, and the one the reader is standing on says so.
+ await expect(sheet.getByText(/٢:٤٨ · هنا/)).toBeVisible();
+ await expect(sheet.getByText(/٢:١٢٣$/).first()).toBeVisible();
+
+ // Each side is a crop of the printed page — real artwork, not a
+ // transcription — carrying at least one wash over the words it does not
+ // share. The washes are the only rounded rectangles drawn into a page root.
+ const crops = sheet.locator("svg[aria-hidden='true'][focusable='false']");
+ await expect(crops).toHaveCount(2);
+ await expect(crops.first().locator("> rect[rx='1']").first()).toBeVisible();
+ await expect(crops.last().locator("> rect[rx='1']").first()).toBeVisible();
+ // The crop is a window onto the leaf, not the whole leaf.
+ expect(await crops.first().getAttribute("viewBox")).not.toBe("0 0 235 235");
});
});
diff --git a/apps/web/package.json b/apps/web/package.json
index d7775b4a..8adfb0b6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,7 +7,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -p tsconfig.json --noEmit && vite build",
+ "build": "tsc -p tsconfig.json --noEmit && vite build && node ../../scripts/stage-docs.mjs",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
diff --git a/apps/web/public/assets/adj/hafs-kfqc/NOTICE.txt b/apps/web/public/assets/adj/hafs-kfqc/NOTICE.txt
new file mode 100644
index 00000000..0831229b
--- /dev/null
+++ b/apps/web/public/assets/adj/hafs-kfqc/NOTICE.txt
@@ -0,0 +1,53 @@
+Mutashabihat pairings derived from Waqar144/Quran_Mutashabihat_Data
+ https://github.com/Waqar144/Quran_Mutashabihat_Data
+ commit f35f6d5d6e7d07f44e6a652d868b298fcd12e318
+ Free to use as you see fit; the author asks that the project be credited,
+ and credits in turn the work of Qari Idrees Al-Asim, rahimahu Allah.
+
+The word ranges on each edge (the span / toSpan fields) are computed from
+the Quranic Arabic Corpus morphology (version 0.4), which is GPL-licensed,
+so these files carry GPL-3.0 terms forward as well as the terms above. The
+corresponding source is packages/etl/scripts/build-adjacency.mjs and the
+pinned input packages/etl/data/roots/quranic-corpus-morphology-0.4.txt,
+both in the repository named in the app's colophon.
+
+The sameJuz flag on an edge is computed from the juz table in @hifth/core,
+which is derived from the Tanzil Quran metadata (quran-data.xml,
+(C) 2008-2009 Tanzil.info, CC BY). Attribution: Tanzil Project,
+https://tanzil.net. No Tanzil bytes ship; the flag is a boolean.
+
+These files contain ayah numbers, word indices and edge metadata only —
+no Quran text.
+
+Generated by packages/etl/scripts/build-adjacency.mjs; see SOURCES.md and
+LICENSES.md for how the three sets of terms compose.
+
+# PLEASE DO NOT REMOVE OR CHANGE THIS COPYRIGHT BLOCK
+#====================================================================
+#
+# Quranic Arabic Corpus (morphology, version 0.4)
+# Copyright (C) 2011 Kais Dukes
+# License: GNU General Public License
+#
+# The Quranic Arabic Corpus includes syntactic and morphological
+# annotation of the Quran, and builds on the verified Arabic text
+# distributed by the Tanzil project.
+#
+# TERMS OF USE:
+#
+# - Permission is granted to copy and distribute verbatim copies
+# of this file, but CHANGING IT IS NOT ALLOWED.
+#
+# - This annotation can be used in any website or application,
+# provided its source (the Quranic Arabic Corpus) is clearly
+# indicated, and a link is made to http://corpus.quran.com to enable
+# users to keep track of changes.
+#
+# - This copyright notice shall be included in all verbatim copies
+# of the text, and shall be reproduced appropriately in all works
+# derived from or containing substantial portion of this file.
+#
+# Please check updates at: http://corpus.quran.com/download
+
+# PLEASE DO NOT REMOVE OR CHANGE THIS COPYRIGHT BLOCK
+#====================================================================
diff --git a/apps/web/src/App.module.css b/apps/web/src/App.module.css
index bfb2a72a..7e787bc6 100644
--- a/apps/web/src/App.module.css
+++ b/apps/web/src/App.module.css
@@ -219,6 +219,35 @@
background: var(--paper-raised);
}
+/*
+ * Reserve the control row even when the bar is empty — but only where there is
+ * a single page, not the spread.
+ *
+ * Picking an ayah swaps the quiet hint for a row of touch-sized controls (a
+ * bead, the roots trigger, the share button, each `--touch-min` tall). At the
+ * bare `min-height` above the empty bar is 44px and the populated one is 61px:
+ * the controls, this bar's block padding and its top hairline together outgrow
+ * the box the moment they appear (border-box). Those 17px come off the flex
+ * stage above, and the page — centred in what height is left — slides up on the
+ * first tap of a reading session, walking out from under a finger aiming at the
+ * next ayah. So the empty bar is made to reserve the same height the controls
+ * will need, and the bar stops moving the mus'haf.
+ *
+ * Why not everywhere: on the spread the facing leaf is height-constrained at the
+ * breakpoint corner, where a leaf must still give at least as much scripture as
+ * the narrowest phone (docs/design/desktop.md §3, asserted in desktop.spec.ts).
+ * That corner has no spare height to hand a taller bar, and the spread has its
+ * own answer coming — the ayah's controls move onto the opposite leaf rather
+ * than into this bar. So the reservation is scoped to the layout that has no
+ * facing page: everything below the spread's `(min-width: 1024px) and
+ * (min-height: 740px)` gate, which is also where the bug was reported.
+ */
+@media not all and (min-width: 1024px) and (min-height: 740px) {
+ .trail {
+ min-height: calc(var(--touch-min) + 2 * var(--space-2) + 1px);
+ }
+}
+
.trailHint {
font-size: var(--text-sm);
color: var(--ink-faint);
diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index dfffa3f9..87ab7d6e 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -4,6 +4,7 @@ import "fake-indexeddb/auto";
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import { App } from "./App";
+import { LangProvider } from "./i18n";
import { forgetRecord, readRecord } from "./revision-store";
const MANIFEST = {
@@ -102,6 +103,35 @@ describe("App shell", () => {
expect(document.querySelector('[dir="rtl"]')).toBeInTheDocument();
});
+ it("renders an English wordmark when the chrome is English", () => {
+ // The wordmark follows the UI language. It was pinned to «حفظ · مِلاحة
+ // للحُفّاظ» in both languages; this reverses that, because the wordmark is
+ // chrome and only the mus'haf and the verse text stay Arabic. This is the
+ // regression that runs on every push — the English path's home is the phone
+ // e2e project (`lang.spec.ts`), which is not exercised until mobile testing
+ // is stood up, so the jsdom shell carries the assertion in the meantime.
+ //
+ // Wrapped in a real `LangProvider`, unlike the row above: a bare ` `
+ // takes the context's hard-wired Arabic default (see i18n.tsx — deliberately
+ // Arabic so the pre-existing unit tests never depended on jsdom's
+ // `navigator.language`). The provider is where the language actually comes
+ // from, and `detectLang` reads the stored choice first.
+ localStorage.setItem("hifth.lang.v1", "en");
+ try {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Hifth")).toBeInTheDocument();
+ expect(screen.getByText("Navigation for huffaz")).toBeInTheDocument();
+ // Not the Arabic wordmark wearing an English chrome.
+ expect(screen.queryByText("حفظ")).not.toBeInTheDocument();
+ } finally {
+ localStorage.removeItem("hifth.lang.v1");
+ }
+ });
+
it("shows the starting page number (7)", () => {
render( );
expect(screen.getByText("7")).toBeInTheDocument();
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 3ff476d2..101c2936 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -48,6 +48,7 @@ import { useHashRouter } from "./useHashRouter";
import { DESKTOP_QUERY, useMediaQuery } from "./useMediaQuery";
import { PageStage, type PageStageHandle } from "./components/PageStage";
import { PageSpread } from "./components/PageSpread";
+import { EdgeGrabRails, type EdgeTurnDriver } from "./components/EdgeGrabRails";
import { DesktopChrome } from "./components/DesktopChrome";
import { HopRail } from "./components/HopRail";
import { HopPopover } from "./components/HopPopover";
@@ -140,6 +141,16 @@ export function App(): JSX.Element {
const [coachUp, setCoachUp] = useState(() => !coachDismissed());
const stageRef = useRef(null);
+ /*
+ * The facing leaf's handle — held for one reason only: magnification. When two
+ * pages are open the reader magnifies the whole opening, so the stepper drives
+ * this leaf to the same level as the live one and the two grow together. It is
+ * *not* how the book is steered: hops and turns still go through the live stage
+ * above (its ref), because a landing has to relocate the reader, and relocating
+ * onto the facing leaf swaps the two roles anyway. So this ref carries zoom and
+ * nothing else, and is null below the breakpoint, where there is no second leaf.
+ */
+ const facingStageRef = useRef(null);
/*
* The open book's own element, so a page turn's fold can be portalled into it.
*
@@ -204,6 +215,24 @@ export function App(): JSX.Element {
*/
const bookOpenRef = useRef(false);
bookOpenRef.current = desktop && pageMode === "two";
+ /*
+ * The fore-edge grab, wired to the one stage that owns turning.
+ *
+ * The rails live on the book because one of the two edges is the facing leaf,
+ * a page the live stage never feels a pointer on (see `EdgeGrabRails`). Every
+ * verb goes to `stageRef` — the live leaf — and never to the facing one: only
+ * the live stage draws the fold, and a second band in the same book is the one
+ * thing the transition design forbids. Memoised on `[]`, since `stageRef` is
+ * stable and the verbs read `.current` at call time.
+ */
+ const edgeTurn = useMemo(
+ () => ({
+ begin: (step) => stageRef.current?.beginEdgeTurn(step),
+ track: (dx) => stageRef.current?.trackEdgeTurn(dx),
+ release: (dx, velocityX) => stageRef.current?.releaseEdgeTurn(dx, velocityX),
+ }),
+ [],
+ );
/*
* The stage's four imperative verbs, wrapped once, so that the readout above
* cannot drift from the paper below it.
@@ -222,9 +251,22 @@ export function App(): JSX.Element {
* reader has not arrived at yet.
*/
const stage = useMemo(() => {
+ // The facing leaf carries whatever the live leaf lands at, so the opening is
+ // one magnification and never two. Read off the live stage rather than the
+ // caller's request, because a turn ends at fit and a hop with the book open
+ // ends at fit too (see `navigateTo` below) — mirroring the landed value keeps
+ // the two leaves agreeing without this having to know which verb ran.
+ // The facing leaf is the left-hand page of the opening, so its gutter is its
+ // right edge: pinned there, it grows leftward, away from the fold. (The live
+ // leaf is the right-hand page and pins at its left edge, below.) The ref is
+ // null unless the book is open, so this is a no-op on a lone leaf.
+ const mirrorFacing = (): void => {
+ facingStageRef.current?.setZoom(stageRef.current?.zoomNow() ?? 1, "right");
+ };
const settle = (work: Promise | undefined, missing: T): Promise =>
(work ?? Promise.resolve(missing)).then((landed) => {
setZoom(stageRef.current?.zoomNow() ?? 1);
+ mirrorFacing();
return landed;
});
return {
@@ -250,20 +292,34 @@ export function App(): JSX.Element {
showPage: (next: number) => settle(stageRef.current?.showPage(next), undefined),
turnTo: (next: number) => settle(stageRef.current?.turnTo(next), false),
// Synchronous, because this one *is* the gesture: the stepper presses and
- // the paper has already moved by the time the handler returns.
- setZoom: (z: number) => setZoom(stageRef.current?.setZoom(z) ?? 1),
+ // the paper has already moved by the time the handler returns. The facing
+ // leaf gets the same asked-for level and clamps it against its own box, so
+ // both leaves land on the rung the reader pressed even where their fits
+ // differ; the readout follows the live leaf.
+ //
+ // With the book open, each leaf pins at its gutter edge so the opening
+ // grows outward from the fold as one sheet rather than each leaf swelling
+ // from its own middle — which crushed the fold and pushed the outer margins
+ // off-screen. The live leaf is the right-hand page (gutter on its left); on
+ // a lone leaf there is no fold, so it grows from its centre.
+ setZoom: (z: number) => {
+ const applied = stageRef.current?.setZoom(z, bookOpenRef.current ? "left" : "center") ?? 1;
+ facingStageRef.current?.setZoom(z, "right");
+ setZoom(applied);
+ },
};
}, []);
/*
- * Open or close the book — and drop the magnification when opening it.
+ * Open or close the book — and start the opening at fit.
*
- * Closing keeps whatever the reader was at; opening does not. §8 ② rendered
- * two magnified leaves side by side and found they lose their edges and read
- * as one continuous column, and §3's measurement is that a leaf in a spread is
- * height-bound at about 398 px, so there is nothing there for a zoom to buy.
- * The stepper is disabled in this mode for the same reason; this is the other
- * half of that, closing the door the reader could otherwise walk back through
- * by zooming first and opening after.
+ * Both leaves now magnify together (the reader asked for it, over the older
+ * finding that two enlarged pages read as one column — see the decision that
+ * reversed it). But the facing leaf mounts fresh at fit the instant the spread
+ * appears, so if the live leaf carried a leftover magnification in, the two
+ * would open at different sizes. Dropping to fit on open makes both leaves
+ * agree from the first frame; the stepper then grows them together from there.
+ * Closing keeps whatever the reader was at, because one leaf has nothing to
+ * disagree with.
*/
const handlePageMode = useCallback(
(mode: "one" | "two") => {
@@ -624,11 +680,26 @@ export function App(): JSX.Element {
return declared ?? pageTurns.pages[pageTurns.pages.length - 1] ?? 1;
}, [manifest, pageTurns]);
+ // The surah at each page's head — its anchor ayah's surah, the same
+ // `polygons[0]` the turn anchors above use — built once so a drag, which asks
+ // per value, reads a map rather than re-scanning the manifest each time.
+ const surahByPage = useMemo(() => {
+ const byPage = new Map();
+ for (const pm of manifest?.pages ?? []) {
+ const surah = pm.polygons[0]?.surah;
+ if (surah !== undefined) byPage.set(pm.page, surah);
+ }
+ return byPage;
+ }, [manifest]);
+
// Land on a page. The single navigation path for every way of turning one —
// the arrow keys, the page bar's edge buttons, and letting go of its slider —
// so there is one place where "the stage moved" and "the header changed" can
- // get out of step, rather than three. Paging does not touch the selection:
- // you are browsing, not moving your place.
+ // get out of step, rather than three. A *turn* moves your place: the
+ // highlighted ayah and the hop trail belonged to the page you left, so a
+ // landed turn clears them and the address falls back to the page's own anchor
+ // (`p585`). A *jump* — scrub, juz, deep link — is a deliberate move to a page
+ // and never carries a selection of its own, so it needs no clearing.
//
// `said` is what to announce on arrival. The slider passes a different string
// when it had to snap, because a landing the reader did not ask for has to be
@@ -662,8 +733,16 @@ export function App(): JSX.Element {
// the reader still is.
void stage.turnTo(next).then((landed) => {
if (pendingPageRef.current !== next) return; // a newer turn owns it
- if (landed) setPage(next);
- else pendingPageRef.current = pageRef.current;
+ if (landed) {
+ setPage(next);
+ // The place you were holding was on the page you just left. Drop it
+ // as the leaf lands — atomically with the header — so the highlight,
+ // the back-beads and the ayah in the URL all leave together and the
+ // address becomes the page you are now on.
+ setSelectedKey(null);
+ setSelectedRange(null);
+ setTrail([]);
+ } else pendingPageRef.current = pageRef.current;
});
return;
}
@@ -736,6 +815,28 @@ export function App(): JSX.Element {
*/
const juzStarts = useMemo(() => juzPageIndex(manifest?.pages ?? []), [manifest]);
+ // Where a page sits in the book, for the page bar's scrub readout: its surah
+ // (above) and its juz. The juz is read off the openings — the highest one at
+ // or before the page — so a page carrying a boundary reads as the juz that
+ // *opens* on it, which is the juz its detent marks. `juzOfPage` would answer
+ // the lowest juz with any ayah there instead; the two differ only on the page
+ // a boundary falls, and the detent's reading is the one that matches the bar.
+ // A juz no page vendored is `null` here and simply skipped — its number never
+ // becomes the answer, and the next opening below the page wins.
+ const pageContext = useCallback(
+ (p: number): { juz: number; surah: number } | null => {
+ const surah = surahByPage.get(p);
+ if (surah === undefined) return null;
+ let juz = 1;
+ for (let i = 0; i < juzStarts.length; i++) {
+ const opening = juzStarts[i];
+ if (opening != null && opening <= p) juz = i + 1;
+ }
+ return { juz, surah };
+ },
+ [surahByPage, juzStarts],
+ );
+
/*
* Jump a whole juz — `Shift`+wheel over either leaf.
*
@@ -1183,15 +1284,20 @@ export function App(): JSX.Element {
aria-haspopup="dialog"
onClick={() => setColophonOpen(true)}
>
- {/* The name and the tagline stay Arabic in both languages: «حفظ» is
- what the app is called, not a word to translate, and the pair is
- load-bearing for the header's height in the golden images. */}
-
- حفظ
-
-
- مِلاحة للحُفّاظ
+ {/* The name and the tagline follow the UI language: «حفظ · مِلاحة
+ للحُفّاظ» in Arabic, "Hifth · Navigation for huffaz" in English. The
+ wordmark is chrome, not scripture — the note under the language
+ switch already promises only the mus'haf and the verse text stay
+ Arabic — and English prose throughout the app (the colophon, its
+ title) already calls it "Hifth". No `lang`/`dir` override here, so
+ each inherits the header's own direction. The golden images are
+ recorded in Arabic (playwright's default locale), so the wordmark
+ there is unchanged; `lang.spec.ts` is where the English wordmark is
+ asserted. */}
+
+ {t.wordmark}
+ {t.tagline}
{/* The chip already meant *where am I*; pressing it now also answers
*where have I been*. It opens the revision map rather than a sixth
@@ -1314,19 +1420,27 @@ export function App(): JSX.Element {
which is exactly why the guard has to be here rather than in the
state. */
solo={desktop && pageMode === "one"}
+ /* The grabbable fore-edges. Only above the breakpoint is there a
+ book with two outer edges to grab; the phone still turns by
+ swiping the leaf itself, so it gets no rails and keeps its
+ gesture. */
+ edgeRails={desktop ? : undefined}
renderFacing={(facing) => (
/* The facing leaf gets its own stage rather than a second
visible host inside the current one: PageStage's whole
correctness argument is that there is exactly one imperative
write path to one visible host, and two transforms inside it
is a bigger change than two instances beside each other.
- No `ref` — the imperative handle is how App drives *the*
- stage, and a hop that landed on the facing leaf would have to
- move the reader there anyway, at which point the two swap
- roles through `page`. Handlers are shared so an ayah on the
- facing leaf is as tappable as one on this leaf. */
+ Its ref carries one thing only — magnification, so the opening
+ grows as a pair (`facingStageRef`). It is still not how the
+ book is *steered*: a hop or a turn goes through the live stage,
+ and a landing on this leaf would relocate the reader here
+ anyway, at which point the two swap roles through `page`.
+ Handlers are shared so an ayah on the facing leaf is as
+ tappable as one on this leaf. */
t.ayahAria(t.ayahLabel(key) ?? key)}
skin={skin}
tajweedLookup={tajweed?.lookup ?? null}
+ /* On the desktop spread the page turns by its fore-edge, not by
+ a swipe across its middle: the edge rails drive the fold, and
+ the stage's own swipe-to-turn is off so a drag through the
+ text is free to pan and select. The phone keeps the swipe —
+ it has no rails and no edge to spare. */
+ dragToTurn={!desktop}
/* Only the live stage turns pages, and only on a desktop
spread does the fold belong to something wider than it. */
foldTarget={desktop ? bookRef : null}
@@ -1519,6 +1639,8 @@ export function App(): JSX.Element {
page={page}
onStep={stepPage}
onGoTo={handleScrubTo}
+ juzStarts={juzStarts}
+ pageContext={pageContext}
/>
diff --git a/apps/web/src/components/Colophon.test.tsx b/apps/web/src/components/Colophon.test.tsx
index f24ecd3b..5962657a 100644
--- a/apps/web/src/components/Colophon.test.tsx
+++ b/apps/web/src/components/Colophon.test.tsx
@@ -33,6 +33,14 @@ describe("Colophon", () => {
expect(link.getAttribute("href")).toContain(SOURCE_REPO);
});
+ it("links the designs and decisions the site serves beside the app", () => {
+ render( {}} />);
+ // Relative: the build stages docs/ next to the app, so the link holds on
+ // any host the app is served from and needs no knowledge of which.
+ const link = screen.getByRole("link", { name: /التصاميم والقرارات/ });
+ expect(link).toHaveAttribute("href", "./docs/");
+ });
+
it("credits every source whose licence asks to be named", () => {
render( {}} />);
// The Quranic Arabic Corpus requires the link, verbatim: "a link is made to
diff --git a/apps/web/src/components/Colophon.tsx b/apps/web/src/components/Colophon.tsx
index 8ce57b29..35925b35 100644
--- a/apps/web/src/components/Colophon.tsx
+++ b/apps/web/src/components/Colophon.tsx
@@ -269,6 +269,19 @@ export function Colophon({ open, onClose }: ColophonProps): JSX.Element | null {
does not exist — the link above falls back to the repository. */
{t.devBuildNote}
)}
+ {/* Every page this project drew to decide something, served from this
+ site so a reader can see the reasoning and not only the result —
+ the CLAUDE.md tenet that a design is public or it is not published.
+ Relative on purpose: the build stages docs/ beside the app
+ (scripts/stage-docs.mjs), whichever host serves it. */}
+
+ {t.designsLink}
+
diff --git a/apps/web/src/components/DesktopChrome.test.tsx b/apps/web/src/components/DesktopChrome.test.tsx
index 24f39dc2..4fd9c950 100644
--- a/apps/web/src/components/DesktopChrome.test.tsx
+++ b/apps/web/src/components/DesktopChrome.test.tsx
@@ -273,21 +273,20 @@ describe("DesktopChrome · the zoom stepper", () => {
expect(steppers().out).toBeEnabled();
});
- it("is off in two-page mode, and says why on the control itself", () => {
- // Two magnified leaves lose their edges and read as one continuous column,
- // and a leaf in a spread is height-bound at ~398px anyway, so zoom there
- // buys nothing (desktop.md §8 ②). The toggle beside it is the way out — and
- // the tooltip is here rather than nowhere because this is the one of the
- // three disabled cases whose reason is *not* visible from the control.
+ it("works in two-page mode too — the spread magnifies both leaves together", () => {
+ // It used to be disabled here, on the finding that two enlarged pages lose
+ // their edges and read as one column. The reader reversed that: a spread is
+ // magnified as a pair, so the stepper is live with the book open, greyed only
+ // at the ends of the ladder, and it carries no "switch to one page" tooltip
+ // because there is nothing to switch to. App is what drives both leaves to
+ // the level a press asks for; the control just asks. (See the decision.)
const { onZoom } = chrome({ pageMode: "two", zoom: 1 });
const { out, into } = steppers();
- expect(out).toBeDisabled();
- expect(into).toBeDisabled();
- for (const b of [out, into]) {
- expect(b.getAttribute("title")).toBe("التكبير يحتاج صفحة واحدة — بدّل إلى صفحة واحدة أولًا");
- }
+ expect(into).toBeEnabled();
+ expect(out).toBeEnabled(); // 1 is a rung with room below (0.8) and above (1.25)
+ for (const b of [out, into]) expect(b.getAttribute("title")).toBeNull();
fireEvent.click(into);
- expect(onZoom).not.toHaveBeenCalled();
+ expect(onZoom).toHaveBeenCalledWith(1.25);
});
it("shows the level without becoming a second announcer", () => {
diff --git a/apps/web/src/components/DesktopChrome.tsx b/apps/web/src/components/DesktopChrome.tsx
index a96998eb..7b6ca30a 100644
--- a/apps/web/src/components/DesktopChrome.tsx
+++ b/apps/web/src/components/DesktopChrome.tsx
@@ -62,11 +62,13 @@ export function DesktopChrome({
onZoom,
}: DesktopChromeProps): JSX.Element {
const { t, lang, setLang } = useT();
- // Two-page mode disables the stepper on top of the two ends of the ladder,
- // and it is the one of the three that owes an explanation — see `zoomTwoPage`.
- const spread = pageMode === "two";
- const out = spread ? null : rung(zoom, -1);
- const into = spread ? null : rung(zoom, 1);
+ // The stepper works whether the book is open or closed: a spread magnifies
+ // both leaves together, so the only thing that greys a button out is reaching
+ // the end of the ladder. (It used to be off entirely while two pages showed;
+ // the reader asked for the opposite, and the record that reversed it names why
+ // the old finding no longer holds.)
+ const out = rung(zoom, -1);
+ const into = rung(zoom, 1);
return (
@@ -138,10 +140,6 @@ export function DesktopChrome({
type="button"
className={styles.zoomBtn}
aria-label={t.zoomOut}
- /* The explanation rides on the control whenever the reason it is
- off is not visible from the control itself. At the bottom rung
- the reason *is* visible — the readout says 80%. */
- title={spread ? t.zoomTwoPage : undefined}
disabled={out === null}
onClick={() => out !== null && onZoom(out)}
>
@@ -152,7 +150,6 @@ export function DesktopChrome({
type="button"
className={styles.zoomBtn}
aria-label={t.zoomIn}
- title={spread ? t.zoomTwoPage : undefined}
disabled={into === null}
onClick={() => into !== null && onZoom(into)}
>
diff --git a/apps/web/src/components/DiffView.module.css b/apps/web/src/components/DiffView.module.css
index 1e376185..fcff0289 100644
--- a/apps/web/src/components/DiffView.module.css
+++ b/apps/web/src/components/DiffView.module.css
@@ -1,7 +1,13 @@
/*
- * DiffView — the token diff under a hop row. Two stacked verse readings; the
- * divergent tokens carry a faint wash (terracotta vs verdigris) so the eye lands
- * on exactly what differs while the shared opening stays quiet ink.
+ * DiffView — the look-alike panel under a hop row. Two stacked crops of the
+ * mus'haf itself, one per ayah, with the words the pair does NOT share carrying
+ * a faint wash (terracotta vs verdigris) so the eye lands on exactly what
+ * differs while the shared opening stays quiet ink.
+ *
+ * The ink is the page's own artwork, not type: the panel used to render a
+ * transcription, in a plainer spelling than the page underneath it, so a reader
+ * comparing the two saw different letters in each. Cropping the print removes
+ * that gap by construction — there is only one set of letters now.
*/
.diff {
@@ -26,24 +32,45 @@
color: var(--ink-faint);
}
-.verse {
- margin: 0;
- font-family: var(--font-arabic);
- font-size: var(--text-md);
- line-height: 2;
- color: var(--ink);
+/*
+ * The crop keeps whatever aspect its own lines give it — an ayah of one line is
+ * a strip, one that wraps is taller. Nothing is normalised to a fixed height,
+ * because that would either shrink the ink below reading size or pad every
+ * short ayah out to the tallest.
+ */
+.crop {
+ width: 100%;
+ overflow: hidden;
+ border-radius: var(--radius-sm);
+}
+
+.page {
+ display: block;
+ width: 100%;
+ height: auto;
+ /*
+ * The mus'haf page is paper and stays paper. It is never re-themed — the same
+ * rule the stage keeps — because the artwork's ink is a fixed dark and would
+ * vanish against a dark ground.
+ */
+ background: var(--mushaf-paper, #fdfbf5);
}
+/*
+ * The washes sit *under* nothing — they are appended after the page's paths, so
+ * they lie over the ink. Kept translucent for that reason: a solid fill would
+ * hide the very words it is pointing at.
+ */
.dA {
- color: var(--diff-a);
- background: var(--diff-a-wash);
- border-radius: var(--radius-sm);
- padding-inline: 2px;
+ fill: var(--diff-a-wash);
+ stroke: var(--diff-a);
+ stroke-width: 0.3;
+ vector-effect: non-scaling-stroke;
}
.dB {
- color: var(--diff-b);
- background: var(--diff-b-wash);
- border-radius: var(--radius-sm);
- padding-inline: 2px;
+ fill: var(--diff-b-wash);
+ stroke: var(--diff-b);
+ stroke-width: 0.3;
+ vector-effect: non-scaling-stroke;
}
diff --git a/apps/web/src/components/DiffView.test.tsx b/apps/web/src/components/DiffView.test.tsx
new file mode 100644
index 00000000..66835d88
--- /dev/null
+++ b/apps/web/src/components/DiffView.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, waitFor } from "@testing-library/react";
+import type { Edge, WordShard } from "@hifth/core";
+import { DiffView } from "./DiffView";
+import styles from "./DiffView.module.css";
+
+/**
+ * Geometry chosen so an assertion reads as arithmetic: word *n* sits at x = 10n,
+ * eight wide, all on one line — so a run of words collapses to a single band and
+ * the band's x-span says which words it covers without a lookup. Real pages are
+ * right-to-left and their boxes are tight around glyphs; neither fact changes
+ * what is being tested here, which is *which indices get washed*.
+ */
+const boxes = (count: number) =>
+ Array.from({ length: count }, (_, i) => [10 * (i + 1), 20, 8, 10] as const);
+
+const SHARDS: Record
= {
+ // 2:48 is printed on page 7 in 23 words, 2:123 on page 19 in 22.
+ 7: { page: 7, words: { "2:48": { from: 1, boxes: boxes(23) } } },
+ 19: { page: 19, words: { "2:123": { from: 1, boxes: boxes(22) } } },
+};
+
+const PAGE_SVG = ' ';
+
+vi.mock("../assets", () => ({
+ loadPageSvg: vi.fn(async () => PAGE_SVG),
+ loadWordShard: vi.fn(async (_edition: string, page: number) => SHARDS[page] ?? null),
+}));
+
+/** The 2:48 ↔ 2:123 edge as it ships: both sides match on their first 13 words. */
+const EDGE: Edge = {
+ type: "mutashabih",
+ to: "quran/hafs-kfqc/2:123",
+ page: 19,
+ dir: { dSurah: 0, dPage: 12, sameJuz: true },
+ span: { from: [1, 13] },
+ toSpan: { from: [1, 13] },
+};
+
+const FROM = "quran/hafs-kfqc/2:48";
+
+/** Every wash rectangle on the page, in the order the component appended them. */
+function washes(root: HTMLElement, cls: string): SVGRectElement[] {
+ return Array.from(root.querySelectorAll(`rect.${cls}`));
+}
+
+/** A wash's covered x-span, undoing the half-unit of bleed it is drawn with. */
+function xSpan(rect: SVGRectElement): [number, number] {
+ const x = Number(rect.getAttribute("x")) + 0.5;
+ return [x, x + Number(rect.getAttribute("width")) - 1];
+}
+
+describe("DiffView (spec §3 — why these two are confusable)", () => {
+ it("washes the words each side does not share, on both sides", async () => {
+ const { container } = render( );
+ await waitFor(() => expect(container.querySelectorAll("svg")).toHaveLength(2));
+
+ // 2:48 shares 1–13 of its 23 words, so 14–23 is what differs: x 140 → 238.
+ const a = washes(container, styles.dA as string);
+ expect(a).toHaveLength(1);
+ expect(xSpan(a[0] as SVGRectElement)).toEqual([140, 238]);
+
+ // 2:123 shares the same opening but is 22 words, so 14–22: x 140 → 228.
+ const b = washes(container, styles.dB as string);
+ expect(b).toHaveLength(1);
+ expect(xSpan(b[0] as SVGRectElement)).toEqual([140, 228]);
+ });
+
+ it("crops each page to the ayah rather than showing the whole leaf", async () => {
+ const { container } = render( );
+ await waitFor(() => expect(container.querySelectorAll("svg")).toHaveLength(2));
+ const [from] = Array.from(container.querySelectorAll("svg"));
+ // 23 words spanning x 10–238, y 20–30, with two units of air around them.
+ expect(from?.getAttribute("viewBox")).toBe("8 18 232 14");
+ // A crop has no intrinsic size — it fills the row it is drawn into.
+ expect(from?.hasAttribute("width")).toBe(false);
+ });
+
+ it("names both ayahs, and says which one the reader is standing on", async () => {
+ const { container } = render( );
+ await waitFor(() => expect(container.querySelectorAll("svg")).toHaveLength(2));
+ const labels = Array.from(
+ container.querySelectorAll(`.${styles.who as string}`),
+ (el) => el.textContent,
+ );
+ expect(labels[0]).toMatch(/٢:٤٨ · هنا/);
+ expect(labels[1]).toMatch(/٢:١٢٣/);
+ expect(labels[1]).not.toMatch(/هنا/);
+ });
+
+ it("hides the artwork from screen readers — the label above it is the name", async () => {
+ const { container } = render( );
+ await waitFor(() => expect(container.querySelectorAll("svg")).toHaveLength(2));
+ for (const svg of Array.from(container.querySelectorAll("svg"))) {
+ expect(svg.getAttribute("aria-hidden")).toBe("true");
+ expect(svg.getAttribute("focusable")).toBe("false");
+ }
+ });
+
+ it("renders nothing when the edge matches in more than one place and names none", () => {
+ // 452 of 2,996 look-alike edges are this shape. The row keeps its plain note.
+ const { span: _span, ...noSpan } = EDGE;
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when a side's page has no geometry to hand", async () => {
+ const { container } = render(
+ ,
+ );
+ // Give the load a turn to settle, then confirm it stayed empty.
+ await waitFor(() => expect(container).toBeEmptyDOMElement());
+ });
+});
diff --git a/apps/web/src/components/DiffView.tsx b/apps/web/src/components/DiffView.tsx
index 2d842d9a..7b2509d5 100644
--- a/apps/web/src/components/DiffView.tsx
+++ b/apps/web/src/components/DiffView.tsx
@@ -1,52 +1,171 @@
-import { diffPair, type DiffToken } from "@hifth/core";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ divergentRuns,
+ isWordShard,
+ wordDiff,
+ WordIndex,
+ type DiffSide,
+ type Edge,
+ type Rect,
+} from "@hifth/core";
+import { loadPageSvg, loadWordShard } from "../assets";
import { useT } from "../i18n";
import styles from "./DiffView.module.css";
interface DiffViewProps {
+ /** The look-alike edge this row is about — it carries both shared runs. */
+ edge: Edge;
/** Source ayah key ("here" — where the reader currently is). */
fromKey: string;
- /** Hop target ayah key. */
- toKey: string;
+}
+
+/** A page's artwork and its word geometry, which are always wanted together. */
+interface Loaded {
+ readonly markup: string;
+ readonly index: WordIndex;
+}
+
+/** Breathing room around a crop, in page units — about a letter's width. */
+const PAD = 2;
+
+/** The smallest rectangle containing all of them, or null if there are none. */
+function union(rects: readonly Rect[]): Rect | null {
+ if (rects.length === 0) return null;
+ let left = Infinity;
+ let top = Infinity;
+ let right = -Infinity;
+ let bottom = -Infinity;
+ for (const r of rects) {
+ left = Math.min(left, r.x);
+ top = Math.min(top, r.y);
+ right = Math.max(right, r.x + r.width);
+ bottom = Math.max(bottom, r.y + r.height);
+ }
+ return { x: left, y: top, width: right - left, height: bottom - top };
}
/**
- * Render one ayah's pre-classified tokens; divergent tokens carry a wash.
+ * One side of the comparison: the ayah as the mus'haf prints it, with the words
+ * it does *not* share with its partner washed.
*
- * `lang="ar" dir="rtl"` is pinned and never follows the UI language: this is
- * scripture, and it is the same text in both. The English chrome around it is a
- * label for the reader, not a translation of what is inside.
+ * The page's own markup is mounted once and then cropped by overriding the
+ * `viewBox` — word boxes and page artwork are authored in the same user units
+ * (page 1 is `0 0 235 235`, and its words run x 11.6–227.5, y 19.5–211.7), so a
+ * band rectangle is a crop rectangle with no conversion in between. Nothing is
+ * redrawn or re-parsed when the wash changes; only the overlay rectangles move.
*/
-function TokenRow({ tokens }: { tokens: readonly DiffToken[] }): JSX.Element {
- return (
-
- {tokens.map((tok, i) => {
- const cls =
- tok.cls === 1 ? styles.dA : tok.cls === 2 ? styles.dB : undefined;
- // Tokens are phrases; re-join with spaces (the fixture stores them split).
- return (
-
- {tok.text}
- {i < tokens.length - 1 ? " " : ""}
-
- );
- })}
-
- );
+function PrintedAyah({
+ side,
+ loaded,
+ wash,
+}: {
+ side: DiffSide;
+ loaded: Loaded;
+ wash: string;
+}): JSX.Element | null {
+ const host = useRef(null);
+
+ const present = loaded.index.span(side.key);
+ const lines = present ? loaded.index.bandsFor(side.key, present.from, present.to) : [];
+ const box = union(lines);
+
+ const washes = present
+ ? divergentRuns(present, side.shared).flatMap(([from, to]) =>
+ loaded.index.bandsFor(side.key, from, to),
+ )
+ : [];
+
+ useEffect(() => {
+ const el = host.current;
+ if (!el || !box) return;
+ // Same idiom as the stage: hand the browser the page's markup as authored
+ // (PageStage.tsx does `host.innerHTML = markup`), then take hold of the
+ // root it produced. Re-cropping is an attribute write, not a re-parse.
+ el.innerHTML = loaded.markup;
+ const svg = el.firstElementChild as SVGSVGElement | null;
+ if (!svg) return;
+ svg.setAttribute(
+ "viewBox",
+ `${box.x - PAD} ${box.y - PAD} ${box.width + PAD * 2} ${box.height + PAD * 2}`,
+ );
+ svg.removeAttribute("width");
+ svg.removeAttribute("height");
+ svg.setAttribute("class", styles.page as string);
+ // The artwork is decoration here — the label above the crop is what names
+ // the ayah, and a screen reader should not walk 20 KB of path data.
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("focusable", "false");
+ // Wash the leftover. Drawn into the page's own root so the rectangles share
+ // its coordinate space rather than being positioned against the element.
+ for (const r of washes) {
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", String(r.x - 0.5));
+ rect.setAttribute("y", String(r.y - 0.5));
+ rect.setAttribute("width", String(r.width + 1));
+ rect.setAttribute("height", String(r.height + 1));
+ rect.setAttribute("rx", "1");
+ rect.setAttribute("class", wash);
+ svg.appendChild(rect);
+ }
+ }, [loaded, box, washes, wash]);
+
+ if (!box) return null;
+ return
;
}
/**
- * DiffView (spec §3 diff) — the token-level "why these are confusable" panel a
- * hop row expands to. It stacks the source ayah and the target with their
- * divergent readings washed (2:48 vs 2:123 = شفاعة/عدل order swap). Purely a
- * function of the two keys' vendored token text; renders nothing when either
- * side has no text, so the row falls back to its plain note.
+ * DiffView (spec §3 diff) — the "why these two are confusable" panel a hop row
+ * expands to.
+ *
+ * It stacks the source ayah and its look-alike **as the mus'haf prints them**,
+ * cropped out of the page artwork that already ships, and washes the words the
+ * two do not have in common. Which words those are is not a judgement made here:
+ * the edge carries the matching run on both sides in the print's own word
+ * numbering, and the leftover at either end is what differs.
+ *
+ * Renders nothing when the edge names no words (452 of 2,996 look-alike edges
+ * match in more than one place, so they name none), or when either page's
+ * artwork or geometry is not to hand — the row keeps its plain note, exactly as
+ * it did for every pair the old twelve-ayah table did not cover.
*/
-export function DiffView({ fromKey, toKey }: DiffViewProps): JSX.Element | null {
+export function DiffView({ edge, fromKey }: DiffViewProps): JSX.Element | null {
const { t } = useT();
- const pair = diffPair(fromKey, toKey);
- if (!pair) return null;
- const fromLabel = t.ayahLabel(fromKey) ?? fromKey;
- const toLabel = t.ayahLabel(toKey) ?? toKey;
+ const diff = useMemo(() => wordDiff(edge, fromKey), [edge, fromKey]);
+ const edition = useMemo(() => editionOf(edge.to), [edge.to]);
+ const [sides, setSides] = useState<{ from: Loaded; to: Loaded } | null>(null);
+
+ useEffect(() => {
+ if (!diff) return;
+ let live = true;
+ // Fetched here rather than threaded down from the stage: the panel only
+ // exists while a row is expanded, the source page is already in the browser
+ // cache because the reader is looking at it, and the target's page is the
+ // one a hop would need next anyway.
+ const load = async (s: DiffSide): Promise => {
+ const [markup, shard] = await Promise.all([
+ loadPageSvg(edition, s.page).catch(() => null),
+ loadWordShard(edition, s.page),
+ ]);
+ if (!markup || !shard || !isWordShard(shard)) return null;
+ const index = new WordIndex(shard);
+ return index.has(s.key) ? { markup, index } : null;
+ };
+ void Promise.all([load(diff.from), load(diff.to)]).then(([from, to]) => {
+ if (!live) return;
+ setSides(from && to ? { from, to } : null);
+ });
+ return () => {
+ live = false;
+ };
+ }, [diff, edition]);
+
+ if (!diff || !sides) return null;
+ // The sides carry bare refs, which is what the geometry is keyed by — but the
+ // surah name comes from the canonical form, so it is rebuilt for the label
+ // rather than carried twice.
+ const named = (bare: string) => t.ayahLabel(`quran/${edition}/${bare}`) ?? bare;
+ const fromLabel = named(diff.from.key);
+ const toLabel = named(diff.to.key);
return (
@@ -54,12 +173,18 @@ export function DiffView({ fromKey, toKey }: DiffViewProps): JSX.Element | null
{fromLabel} · {t.hereTag}
-
+
);
}
+
+/** `"quran/hafs-kfqc/2:123"` → `"hafs-kfqc"`. */
+function editionOf(key: string): string {
+ const parts = key.split("/");
+ return parts.length >= 2 ? (parts[1] as string) : "hafs-kfqc";
+}
diff --git a/apps/web/src/components/EdgeGrabRails.module.css b/apps/web/src/components/EdgeGrabRails.module.css
new file mode 100644
index 00000000..3ec9f997
--- /dev/null
+++ b/apps/web/src/components/EdgeGrabRails.module.css
@@ -0,0 +1,60 @@
+/*
+ * The grab rails — the two outer fore-edges of the open book, made grabbable.
+ *
+ * Absolutely placed on `.book` (PageSpread.module.css), which is the positioning
+ * context and clips them to itself. Physical `left`/`right`, not logical: the
+ * turn direction is a fact about the hand ("drag right is forward"), not about
+ * the writing direction, so it must not flip with `dir`.
+ */
+.rail {
+ position: absolute;
+ inset-block: 0;
+ /* The widest the grab reaches — at the corners. The midline is pinched to a
+ sliver by the clip-path, so this is a ceiling, not the felt width. */
+ inline-size: 3.5rem;
+ z-index: 2;
+ /* The affordance the reader was promised: a page you can pick up. */
+ cursor: grab;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ /* A whisper of shading that deepens toward the fore-edge, so the grabbable
+ strip is discoverable without drawing a hard border the eye reads as chrome.
+ Invisible until hovered — between turns the book is just a book. */
+ opacity: 0;
+ transition: opacity var(--dur-fast, 120ms) ease;
+}
+
+.rail[data-side="left"] {
+ left: 0;
+ background: linear-gradient(to left, transparent, var(--gutter-shadow, rgba(0, 0, 0, 0.08)));
+ /* Full reach at the top and bottom corners, pinched to ~30% at the midline. */
+ clip-path: polygon(0% 0%, 100% 0%, 30% 50%, 100% 100%, 0% 100%);
+}
+
+.rail[data-side="right"] {
+ right: 0;
+ background: linear-gradient(to right, transparent, var(--gutter-shadow, rgba(0, 0, 0, 0.08)));
+ clip-path: polygon(100% 0%, 0% 0%, 70% 50%, 0% 100%, 100% 100%);
+}
+
+.rail:hover {
+ opacity: 1;
+}
+
+/* The closed hand while a leaf is actually being pulled — and it must win over
+ `:hover`, which is why the visible feedback is here rather than on `:active`
+ alone. Held from pointer-down to release through the component's own state, so
+ it survives the pointer leaving the strip mid-turn. */
+.rail[data-grabbing="true"] {
+ cursor: grabbing;
+ opacity: 1;
+}
+
+/* A reader who has turned off motion still gets the hand and the turn; the only
+ thing dropped is the fade-in, which appears at once instead. */
+@media (prefers-reduced-motion: reduce) {
+ .rail {
+ transition: none;
+ }
+}
diff --git a/apps/web/src/components/EdgeGrabRails.tsx b/apps/web/src/components/EdgeGrabRails.tsx
new file mode 100644
index 00000000..d6859e61
--- /dev/null
+++ b/apps/web/src/components/EdgeGrabRails.tsx
@@ -0,0 +1,131 @@
+import { useRef, useState } from "react";
+import styles from "./EdgeGrabRails.module.css";
+
+/**
+ * What a grabbed edge does to the book. Three verbs, one per phase of a drag,
+ * so the rails never hold any turn state of their own — they hand the drag to
+ * the one surface that owns turning and let it draw. `step` is +1 forward /
+ * −1 back; the caller fixes it per edge (see {@link EdgeGrabRails}).
+ */
+export interface EdgeTurnDriver {
+ begin: (step: 1 | -1) => void;
+ track: (dx: number) => void;
+ release: (dx: number, velocityX: number) => void;
+}
+
+/** The live drag, kept off React state so a move does not re-render the book. */
+interface Grab {
+ step: 1 | -1;
+ startX: number;
+ lastX: number;
+ lastT: number;
+ begun: boolean;
+}
+
+/**
+ * A grab must have travelled this far before it is a turn. Below it, a press on
+ * the edge is a click that lands nowhere — no band flashes on and retreats, and
+ * a reader who clicks the fore-edge by accident sees nothing happen.
+ */
+const GRAB_SLOP_PX = 4;
+
+/**
+ * The outer edges of an open mus'haf, made grabbable.
+ *
+ * ## Why the edges, and why here
+ *
+ * A physical reader turns a page by its fore-edge. This is that: hovering the
+ * outer edge of either leaf shows a hand, and a drag that begins there sweeps
+ * the leaf across — while a drag that begins anywhere else does not turn the
+ * page at all (on the desktop spread the stage's own swipe-to-turn is off, so
+ * the middle of the page is free to pan and select). The reading direction is
+ * the print's: the earlier pages are on the right, so the **left** edge pulls
+ * *forward* into the book and the **right** edge pulls *back* toward the start.
+ *
+ * The rails sit on the book rather than inside a leaf because one of the two
+ * edges belongs to the *facing* leaf — a page the turning stage does not own
+ * and gets no pointer from. So the grab is caught here and handed to the stage
+ * through {@link EdgeTurnDriver}; the rails themselves know nothing about the
+ * fold, exactly as the book knows nothing about it (the band is portalled in).
+ *
+ * ## The shape
+ *
+ * Wider at the corners than down the middle — a physical page is easiest to
+ * lift by a corner, and a thin strip that swallowed the whole outer margin
+ * would eat the fore-edge a reader wants to select against. The clip-path bows
+ * the grab region inward at top and bottom and pinches it to a sliver at the
+ * midline, so the middle of the fore-edge stays the page's.
+ */
+export function EdgeGrabRails({ driver }: { driver?: EdgeTurnDriver }): JSX.Element | null {
+ // Which side, if any, is being held right now — only to swap the cursor to a
+ // closed hand. The drag's numbers live in the ref beside it.
+ const [held, setHeld] = useState<"left" | "right" | null>(null);
+ const grab = useRef(null);
+
+ if (!driver) return null;
+
+ const rail = (side: "left" | "right", step: 1 | -1): JSX.Element => (
+ {
+ // Left button only, and take the pointer so the whole drag arrives here
+ // even when it leaves the strip — a page turn crosses the book.
+ if (e.button !== 0) return;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ grab.current = { step, startX: e.clientX, lastX: e.clientX, lastT: e.timeStamp, begun: false };
+ setHeld(side);
+ }}
+ onPointerMove={(e) => {
+ const g = grab.current;
+ if (!g) return;
+ const dx = e.clientX - g.startX;
+ // Hold the band back until the grab has actually moved: a still press is
+ // not a turn, and beginning one would flash a fold on and take it back.
+ if (!g.begun && Math.abs(dx) < GRAB_SLOP_PX) return;
+ if (!g.begun) {
+ driver.begin(g.step);
+ g.begun = true;
+ }
+ driver.track(dx);
+ g.lastX = e.clientX;
+ g.lastT = e.timeStamp;
+ }}
+ onPointerUp={(e) => {
+ const g = grab.current;
+ grab.current = null;
+ setHeld(null);
+ if (!g) return;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ if (!g.begun) return; // a click on the edge, not a turn
+ const dx = e.clientX - g.startX;
+ // Signed pixels-per-millisecond over the last move, for the flick rule.
+ // Zero when the finger paused before lifting, which is the same as a
+ // slow release — only the distance decides then.
+ const dt = e.timeStamp - g.lastT;
+ const velocityX = dt > 0 ? (e.clientX - g.lastX) / dt : 0;
+ driver.release(dx, velocityX);
+ }}
+ onPointerCancel={() => {
+ const g = grab.current;
+ grab.current = null;
+ setHeld(null);
+ // A cancelled grab (a second pointer, the OS taking over) releases at
+ // rest so the band retreats rather than committing on a stroke the
+ // reader did not finish.
+ if (g?.begun) driver.release(0, 0);
+ }}
+ />
+ );
+
+ return (
+ <>
+ {rail("left", 1)}
+ {rail("right", -1)}
+ >
+ );
+}
diff --git a/apps/web/src/components/HighlightMenu.test.tsx b/apps/web/src/components/HighlightMenu.test.tsx
index 400e0ddb..26d04322 100644
--- a/apps/web/src/components/HighlightMenu.test.tsx
+++ b/apps/web/src/components/HighlightMenu.test.tsx
@@ -3,6 +3,16 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import type { AppState, MergedEdge } from "@hifth/core";
import { HighlightMenu } from "./HighlightMenu";
+/**
+ * The panel itself is a crop of two mus'haf pages and needs both pages' artwork
+ * and word geometry to draw anything — none of which is this file's subject.
+ * What the menu owns is *which ayah the panel is told it is standing on*, so the
+ * stub reports exactly that and `DiffView.test.tsx` covers the drawing.
+ */
+vi.mock("./DiffView", () => ({
+ DiffView: ({ fromKey }: { fromKey: string }) =>
,
+}));
+
const ED = "hafs-kfqc";
const k = (ref: string) => `quran/${ED}/${ref}`;
const RANGE = [k("2:47"), k("2:48")];
@@ -17,13 +27,17 @@ function edge(p: Partial
& Pick {
expect(screen.getByText(/غير متوفّرة بعد/)).toBeInTheDocument();
});
- it("expands a row into the token diff, comparing against that row's source ayah", () => {
+ it("expands a row into the comparison, standing on that row's source ayah", () => {
renderMenu();
const row = screen.getByRole("button", { expanded: false });
fireEvent.click(row);
expect(row).toHaveAttribute("aria-expanded", "true");
- // The diff's "here" side is 2:47 — the member that produced this edge.
- expect(screen.getByText(/البقرة · ٢:٤٧ · هنا/)).toBeInTheDocument();
+ // The panel's "here" side is 2:47 — the member that produced this edge.
+ expect(document.querySelector(`[data-here="${k("2:47")}"]`)).toBeInTheDocument();
});
it("a row both members contributed diffs against the one that produced it", () => {
@@ -97,12 +111,14 @@ describe("HighlightMenu (spec §9 — the drag-highlight menu)", () => {
sources: [k("2:47"), k("2:48")],
from: k("2:48"),
note: "شفاعة ↔ عدل",
+ span: { from: [1, 13] },
+ toSpan: { from: [1, 13] },
}),
],
});
expect(screen.getByText("من ٢:٤٧، ٢:٤٨")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { expanded: false }));
- expect(screen.getByText(/البقرة · ٢:٤٨ · هنا/)).toBeInTheDocument();
+ expect(document.querySelector(`[data-here="${k("2:48")}"]`)).toBeInTheDocument();
});
it("hops with the merged edge (the caller reads its `from` for the trail)", () => {
diff --git a/apps/web/src/components/HighlightMenu.tsx b/apps/web/src/components/HighlightMenu.tsx
index 7defc393..43e4976d 100644
--- a/apps/web/src/components/HighlightMenu.tsx
+++ b/apps/web/src/components/HighlightMenu.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { diffPair, type AppState, type MergedEdge } from "@hifth/core";
+import { wordDiff, type AppState, type MergedEdge } from "@hifth/core";
import { useT } from "../i18n";
import { DiffView } from "./DiffView";
import { ShareSheet } from "./ShareSheet";
@@ -40,8 +40,9 @@ function bareTarget(to: string): string {
* Where `HopPopover` answers "where does *this ayah* go?", this answers it for a
* whole highlighted passage: one merged list, deduped by (target, type), each
* row naming the range member it came from so a merged hop is never anonymous.
- * Rows carry the same affordances as the popover's — expand for the token diff
- * (spec §3), 44px leap button, and an honest disabled state with "page not
+ * Rows carry the same affordances as the popover's — expand to see both ayahs
+ * cropped out of the printed page with what they do not share washed (spec §3),
+ * 44px leap button, and an honest disabled state with "page not
* available yet" for an un-vendored target (Plan Q6). Copy-link shares the range
* itself through the §7 range form (`#/hafs-kfqc/2:47-2:48`).
*
@@ -155,7 +156,7 @@ export function HighlightMenu({
// The range member whose edge won the merge — the ayah this row's
// note is about, and so the diff's "here" and the leap's origin.
const fromKey = edge.from;
- const diffable = diffPair(fromKey, toKey) !== null;
+ const diffable = wordDiff(edge, fromKey) !== null;
const isOpen = expanded === edge.to;
const diffId = `range-diff-${edge.to.replace(/[^\w-]/g, "-")}`;
const fromRefs = edge.sources.map((k) => t.ayahRef(k) ?? k).join(t.refJoin);
@@ -210,7 +211,7 @@ export function HighlightMenu({
{isOpen && diffable && (
-
+
)}
diff --git a/apps/web/src/components/HopPopover.tsx b/apps/web/src/components/HopPopover.tsx
index f07f4573..2fc8b369 100644
--- a/apps/web/src/components/HopPopover.tsx
+++ b/apps/web/src/components/HopPopover.tsx
@@ -27,8 +27,9 @@ function focusables(root: HTMLElement): HTMLElement[] {
/**
* HopPopover — the bottom-sheet hop list (spec §9). Opened by a rail chip, it
* lists that bucket's edges hifz-ordered (nearest first), each row a "hop there"
- * arc-arrow that expands to a token diff (spec §3) showing *why* the pair is
- * confusable. An un-vendored target shows its link + note but the leap is
+ * arc-arrow that expands to show *why* the pair is confusable (spec §3): both
+ * ayahs cropped out of the printed page, with the words they do not share
+ * washed. An un-vendored target shows its link + note but the leap is
* disabled with an honest "page not available yet" (Plan Q6).
*
* A11y (Loop 3): a real modal dialog — focus moves in on open, Tab is trapped,
@@ -163,7 +164,7 @@ export function HopPopover({
{isOpen && fromKey && (
-
+
)}
diff --git a/apps/web/src/components/PageSlider.module.css b/apps/web/src/components/PageSlider.module.css
index 91aabb3d..226f282e 100644
--- a/apps/web/src/components/PageSlider.module.css
+++ b/apps/web/src/components/PageSlider.module.css
@@ -13,6 +13,14 @@
.bar {
--thumb: 22px;
+ /* How wide the *controls* are allowed to get. The bar's border and background
+ stay full-bleed (it is the bottom edge of the app); only the buttons, track
+ and inventory are held to this width so a wide desktop window does not
+ stretch the native range into a hairline the length of the screen
+ (`e2e/desktop.spec.ts`). ~960px, the width the desktop mocks drew the book
+ at, so the bar reads as belonging to the book above it rather than the
+ window around it. */
+ --controls-max: 60rem;
display: grid;
grid-template-columns: auto 1fr auto;
@@ -24,6 +32,20 @@
background: var(--paper-raised);
}
+/*
+ * Desktop: hold the controls to `--controls-max`, centred, by growing the
+ * horizontal padding rather than capping the bar — the hairline and paper stay
+ * edge-to-edge (the bar is the app's bottom chrome), and only the grid's three
+ * columns are pulled in. `max(...)` keeps the phone's `--space-3` gutter as the
+ * floor, so nothing changes below the width where the window first exceeds the
+ * cap. Same 900px breakpoint the rest of the desktop chrome uses.
+ */
+@media (min-width: 900px) {
+ .bar {
+ padding-inline: max(var(--space-3), calc((100% - var(--controls-max)) / 2));
+ }
+}
+
/*
* The two page turns. Full touch targets in both axes even though the glyph is
* tiny — these are the controls a thumb reaches for without looking, and they
@@ -85,6 +107,30 @@
border-radius: var(--radius-pill);
}
+/*
+ * The native thumb is kept for its whole keyboard and assistive-technology
+ * contract, but not for its paint: the visible handle is the page icon drawn
+ * over it (`.handle`). So the thumb keeps the `--thumb` hit target the runs are
+ * measured against and turns transparent, and the reader grabs a leaf of the
+ * book instead of a browser puck. Both engines, because each names the thumb its
+ * own way and a rule for one is silently ignored by the other.
+ */
+.range::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ inline-size: var(--thumb);
+ block-size: var(--thumb);
+ background: transparent;
+ border: none;
+}
+
+.range::-moz-range-thumb {
+ inline-size: var(--thumb);
+ block-size: var(--thumb);
+ background: transparent;
+ border: none;
+}
+
/*
* The vendored inventory, drawn over the rail — one bar per *run* of contiguous
* held pages, positioned and sized by the component from each run's fractions
@@ -127,6 +173,61 @@
background: var(--accent-strong);
}
+/*
+ * The 30 juz, drawn as green detents — the coarse layer of the bar. Their own
+ * container, positioned like the inventory's, and taller than a run so the two
+ * layers read apart: the grey rail says which pages are here, the green ticks
+ * say which part of the book each stretch is. `pointer-events: none` so they
+ * never intercept a drag (the whole rail belongs to the thumb underneath).
+ */
+.juzRail {
+ position: absolute;
+ inset-inline: 0;
+ inset-block-start: 50%;
+ block-size: 0;
+ pointer-events: none;
+}
+
+.juz {
+ position: absolute;
+ inset-block-start: -9px;
+ inline-size: 2px;
+ block-size: 18px;
+ border-radius: 1px;
+ background: var(--accent);
+ opacity: 0.65;
+}
+
+/*
+ * The handle: a page icon over the invisible native thumb, centred on the same
+ * value. `pointer-events: none` so the drag stays the input's — the handle is
+ * paint, not a second control. Sized a touch beyond the thumb so the leaf reads
+ * clearly; a soft shadow lifts it off the rail and the ticks beneath it.
+ */
+.handle {
+ position: absolute;
+ inset-block-start: 50%;
+ inline-size: 26px;
+ block-size: 26px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ color: var(--accent-strong);
+ filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.25));
+}
+
+/* Centring is a physical shift: `translateX(-50%)` moves left in both writing
+ directions, correct under LTR and half a handle adrift under RTL. The bar is
+ always RTL, but stating both keeps the rule visible if that ever changes. */
+.bar[dir="rtl"] .handle {
+ transform: translate(50%, -50%);
+}
+
+.handleIcon {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+}
+
/*
* The scrub readout. It floats above the thumb rather than sitting in the
* header, because the header must keep naming the page the stage has actually
@@ -159,6 +260,15 @@
transform: translateX(50%);
}
+/* The juz · surah line — the book's landmarks for the page under the thumb. A
+ step down from the page number it sits under, and a hair dimmer, so the popover
+ reads as "page, then where that page is" rather than three equal facts. */
+.context {
+ color: var(--paper);
+ font-size: var(--text-xs);
+ opacity: 0.9;
+}
+
.snap {
color: var(--paper);
font-size: var(--text-xs);
diff --git a/apps/web/src/components/PageSlider.test.tsx b/apps/web/src/components/PageSlider.test.tsx
index 5ec55e57..09526196 100644
--- a/apps/web/src/components/PageSlider.test.tsx
+++ b/apps/web/src/components/PageSlider.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { PageSlider } from "./PageSlider";
/**
@@ -170,4 +170,71 @@ describe("PageSlider", () => {
expect(onGoTo).not.toHaveBeenCalled();
for (const button of screen.getAllByRole("button")) expect(button).toBeDisabled();
});
+
+ // ---- the juz detents, the page-icon handle and the landmark popover ----
+
+ const JUZ = (starts: readonly (number | null)[]) => starts;
+
+ it("draws one green detent per juz it can place, numbered by position", () => {
+ // A juz no page vendored is a null slot, not a missing slot: the detent
+ // drawn after it must still know it is juz 3, or every gap in the inventory
+ // would renumber the whole book behind it.
+ slider({ juzStarts: JUZ([1, null, 42, 62]) });
+ const detents = document.querySelectorAll("[data-testid='juz-detent']");
+ expect(detents).toHaveLength(3);
+ expect([...detents].map((d) => d.getAttribute("data-juz"))).toEqual(["1", "3", "4"]);
+ });
+
+ it("draws no detent rail when no juz can be placed", () => {
+ // All-null and empty are the same picture — the plain scrubber — and the
+ // rail node itself should not be there to reserve a layer for nothing.
+ slider({ juzStarts: JUZ([null, null, null]) });
+ expect(document.querySelectorAll("[data-testid='juz-detent']")).toHaveLength(0);
+ cleanup();
+ slider();
+ expect(document.querySelectorAll("[data-testid='juz-detent']")).toHaveLength(0);
+ });
+
+ it("hands the reader a page to grab, drawn over the native thumb", () => {
+ // The handle is paint over the input, never a second control: the range
+ // keeps the role, the value text and the keyboard; the leaf is aria-hidden.
+ slider();
+ const handle = document.querySelector("[data-testid='page-handle']");
+ expect(handle).not.toBeNull();
+ expect(handle!.getAttribute("aria-hidden")).toBe("true");
+ expect(handle!.querySelector("svg")).not.toBeNull();
+ expect(screen.getAllByRole("slider")).toHaveLength(1);
+ });
+
+ it("floats no handle over a dead track", () => {
+ slider({ available: [] });
+ expect(document.querySelector("[data-testid='page-handle']")).toBeNull();
+ });
+
+ it("moves the handle with the drag, not only on release", () => {
+ // Mid-drag the leaf sits where the thumb is, so what the reader is holding
+ // and what they see under their finger are the same page.
+ const input = slider();
+ const at = () =>
+ (document.querySelector("[data-testid='page-handle']") as HTMLElement).style.insetInlineStart;
+ const rest = at();
+ fireEvent.input(input, { target: { value: "300" } });
+ expect(at()).not.toBe(rest);
+ });
+
+ it("names the juz and the surah under the thumb while dragging", () => {
+ const input = slider({ pageContext: () => ({ juz: 5, surah: 4 }) });
+ fireEvent.input(input, { target: { value: "300" } });
+ const line = screen.getByText(/النساء/);
+ expect(line.textContent).toMatch(/5|٥/);
+ });
+
+ it("leaves the landmarks off when the page cannot be placed", () => {
+ // Off the vendored inventory the caller answers null, and the popover says
+ // the page number and nothing it would have to guess.
+ const input = slider({ pageContext: () => null });
+ fireEvent.input(input, { target: { value: "300" } });
+ expect(screen.getByText("صفحة 300 من 604")).toBeTruthy();
+ expect(screen.queryByText(/النساء/)).toBeNull();
+ });
});
diff --git a/apps/web/src/components/PageSlider.tsx b/apps/web/src/components/PageSlider.tsx
index da8463cb..70121932 100644
--- a/apps/web/src/components/PageSlider.tsx
+++ b/apps/web/src/components/PageSlider.tsx
@@ -23,6 +23,51 @@ interface PageSliderProps {
* thumb was let go, so the caller can say so when the two differ.
*/
onGoTo: (landed: number, asked: number) => void;
+ /**
+ * The page each of the 30 juz opens on — the green detents drawn along the
+ * track, the coarse landmarks a hafiz reads the book by. One slot per juz in
+ * order, and `null` for a juz no vendored page carries, so the drawn detent
+ * still knows its own juz number by position. Optional: an empty (or all-null)
+ * list draws no detents, and the bar is the plain page scrubber it was.
+ */
+ juzStarts?: readonly (number | null)[];
+ /**
+ * Where a page sits in the book — its juz and the surah at its head — for the
+ * scrub readout. A function, not a table, because the caller owns the mapping:
+ * it holds the manifest, the bar does not. `null` for a page it cannot place
+ * (outside the vendored inventory), which just leaves that line off the popover.
+ */
+ pageContext?: (page: number) => { juz: number; surah: number } | null;
+}
+
+/**
+ * The handle, drawn as a leaf of the mus'haf rather than a browser puck. It is
+ * painted *over* the native thumb (which is kept, sized, and made invisible), so
+ * the reader grabs a page while the range input underneath keeps every scrap of
+ * its keyboard and assistive-technology behaviour. `currentColor` is the track's
+ * accent, `--paper` the page — a small card the colour of the book above it.
+ */
+function PageHandleIcon(): JSX.Element {
+ return (
+
+
+
+
+
+
+ );
}
/**
@@ -81,6 +126,8 @@ export function PageSlider({
page,
onStep,
onGoTo,
+ juzStarts = [],
+ pageContext,
}: PageSliderProps): JSX.Element {
const { t } = useT();
const inputRef = useRef
(null);
@@ -95,6 +142,11 @@ export function PageSlider({
// during a drag at all.
const runs = useMemo(() => pageRuns(available), [available]);
const landing = scrub === null ? null : nearestPage(available, scrub);
+ // Where the thumb is over the book — its juz and the surah at that page's head
+ // — named in the popover so a scrub is read in the book's own landmarks, not
+ // only in a page number. Null off the vendored inventory, which just drops the
+ // line rather than guessing.
+ const context = scrub === null || !pageContext ? null : pageContext(scrub);
const commit = useCallback(
(wanted: number) => {
@@ -234,6 +286,49 @@ export function PageSlider({
/>
+ {/* The 30 juz, one green detent each, at the page each opens on. These are
+ the coarse landmarks a hafiz navigates by — the book is thirty parts
+ before it is 604 pages — so they sit above the inventory rail as their
+ own layer. Decorative to a screen reader: the input already speaks its
+ value, and the juz is named in the popover a listener cannot see.
+ `pointer-events: none` so a detent never eats a drag aimed at the
+ thumb crossing it. */}
+ {juzStarts.some((start) => start !== null) && (
+
+ {juzStarts.map((start, i) =>
+ start === null ? null : (
+
+ ),
+ )}
+
+ )}
+
+ {/* The handle, a page rather than a puck, painted over the invisible
+ native thumb at the same value — see `PageHandleIcon`. Hidden while
+ the bar is inert (no inventory), so no lone leaf floats over a dead
+ track. Follows the drag: `value` is the scrub value mid-drag, the
+ loaded page at rest. */}
+ {!empty && (
+