|
| 1 | +# Reader Input Contracts |
| 2 | + |
| 3 | +How user input flows through the reader, and the contracts every input |
| 4 | +handler must respect. The modules in `src/lib/reader/input/` are the |
| 5 | +load-bearing implementation of this document — their doc comments carry the |
| 6 | +details; this file is the map. |
| 7 | + |
| 8 | +## Architecture |
| 9 | + |
| 10 | +``` |
| 11 | + ┌─────────────────────────────┐ |
| 12 | + │ Reader.svelte │ |
| 13 | + │ keyboard shortcuts, │ |
| 14 | + │ page-flip + overlay │ |
| 15 | + │ intents, volume nav │ |
| 16 | + └─────────┬───────────────────┘ |
| 17 | + props (callbacks) │ keyboard / $pagedZoom |
| 18 | + ┌─────────────────────────┼─────────────────────────┐ |
| 19 | + ▼ ▼ ▼ |
| 20 | +┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ |
| 21 | +│ PagedViewport │ │ VerticalScroll │ │ HorizontalScroll │ |
| 22 | +│ │ │ Reader │ │ Reader │ |
| 23 | +└───────┬───────┘ └───────┬───────┘ └─────────┬─────────┘ |
| 24 | + │ each surface owns ALL of its pointer input │ |
| 25 | + ▼ ▼ ▼ |
| 26 | + PointerGestureTracker · TapDiscriminator · classifySwipe · MotionGate |
| 27 | + (src/lib/reader/input/ — shared machinery) |
| 28 | +``` |
| 29 | + |
| 30 | +Each reading surface owns every gesture that starts on it. Reader owns |
| 31 | +keyboard shortcuts and supplies intent callbacks (`onPageFlip`, |
| 32 | +`onOverlayToggle`); it never touches pointer events. |
| 33 | + |
| 34 | +## The shared machinery |
| 35 | + |
| 36 | +| Module | Owns | |
| 37 | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 38 | +| `pointer-tracker.ts` | THE pointer state machine: pan threshold, pinch upgrade/downgrade/re-baseline, capture policy, leak-proof window-level release, `wasDrag`/`wasPinch` | |
| 39 | +| `gesture-target.ts` | Element-role classification (`textbox` / `interactive` / `page`) and the keyboard-ignore guard | |
| 40 | +| `tap.ts` | Tap vs double-tap timing (300 ms), deferred vs immediate commit, text-box dismissal swallowing | |
| 41 | +| `swipe.ts` | Pure swipe-to-flip classification (edge gating #186, pinch suppression, thresholds) | |
| 42 | +| `motion-gate.ts` | The interrupt contract: what must stop before new motion starts (`beforeZoom` / `beforeManualPan` / `beforeAnimatedScroll` / `beforeNav`) | |
| 43 | + |
| 44 | +Surfaces hold only **policy** (config objects) and **actuation** (scroll |
| 45 | +writes, camera moves); classification and lifecycle live in the shared |
| 46 | +modules. |
| 47 | + |
| 48 | +## Contracts that must not break |
| 49 | + |
| 50 | +### `.textBox` is an input-routing protocol, not styling |
| 51 | + |
| 52 | +OCR text boxes own their gestures (see `gesture-target.ts`): |
| 53 | + |
| 54 | +- **Double-tap on a text box is the AnkiConnect card-capture gesture.** |
| 55 | + TextBoxes.svelte handles it and `stopPropagation()`s; surfaces must also |
| 56 | + filter taps by role so pointer-based detection never zooms from a text |
| 57 | + box. Breaking this breaks users' Anki mining flow. |
| 58 | +- Mouse/pen drags on a text box are **text selection** (Yomitan/Migaku |
| 59 | + scanning) — never a pan. Single-finger **touch** is the exception: it |
| 60 | + pans everywhere, because touch has no drag-selection gesture. |
| 61 | +- After interacting with a text box, the next tap outside is a |
| 62 | + **dismissal** — it must not toggle the overlay |
| 63 | + (`TapDiscriminator.noteTextBoxInteraction`). |
| 64 | + |
| 65 | +### Pinch always wins |
| 66 | + |
| 67 | +Two pointers upgrade to pinch no matter where they pressed (text box |
| 68 | +included). Pointer-set changes re-baseline; dropping to one pointer |
| 69 | +ends the pinch. The survivor continues as a pan only on surfaces with |
| 70 | +incremental deltas (`pinchSurvivorPans` — paged); absolute-baseline |
| 71 | +surfaces (scroll readers) require a fresh press. |
| 72 | + |
| 73 | +### Releases are window-level |
| 74 | + |
| 75 | +`pointerup`/`pointercancel` listen on the window, always. A release landing |
| 76 | +on an overlay or outside the browser must still clean the pointer map — |
| 77 | +phantom entries get misread as pinches (this was a live production bug). |
| 78 | + |
| 79 | +### One gesture at a time (MotionGate) |
| 80 | + |
| 81 | +Every handler that starts motion opens with the gate call matching its |
| 82 | +intent. Never call `finishNow`/`stop`/`stopPan` combinations inline — add |
| 83 | +to the gate if a new intent appears. |
| 84 | + |
| 85 | +### Swipe-to-flip is edge-gated (#186) |
| 86 | + |
| 87 | +A touch swipe flips the page only if no pannable content was hidden in the |
| 88 | +swipe's direction **when the gesture began**. Gestures that pinched never |
| 89 | +flip (`tracker.wasPinch`). |
| 90 | + |
| 91 | +### Tap timing differs by surface family — deliberately |
| 92 | + |
| 93 | +- Scroll readers: **deferred** — overlay toggles 300 ms late so a |
| 94 | + double-tap (zoom) never flashes it. |
| 95 | +- Paged: **immediate** — overlay toggles instantly on every tap; a |
| 96 | + double-tap toggles twice (net zero) and zooms, reproducing the native |
| 97 | + click/click/dblclick sequence paged mode has always had. |
| 98 | + |
| 99 | +### Settle reasons gate progress reporting |
| 100 | + |
| 101 | +Zoom settles carry a `SettleReason` (`zoom-controller.ts`): only |
| 102 | +`'gesture'`/`'interrupt'` settles report reading progress; `'nav'` and |
| 103 | +`'reset'` settles are superseded by whatever caused them. |
| 104 | + |
| 105 | +## Per-surface policy matrix |
| 106 | + |
| 107 | +| Policy | PagedViewport | Scroll readers | |
| 108 | +| -------------- | --------------------------------- | --------------------------------------- | |
| 109 | +| Capture | deferred (at drag threshold) | immediate (at pan press) | |
| 110 | +| Pan deltas | incremental → `camera.adjustView` | totals → absolute scroll from baselines | |
| 111 | +| Text-box pan | suppressed for mouse/pen only | suppressed for all pointer types | |
| 112 | +| Pinch survivor | keeps panning | ignored until fresh press | |
| 113 | +| Tap commit | immediate | deferred (300 ms) | |
| 114 | +| Swipe-to-flip | yes (mobile setting, edge-gated) | no (panning is the scroll) | |
| 115 | +| Wheel | zoom or camera glide | zoom or (native/strip) scroll | |
| 116 | + |
| 117 | +## Testing |
| 118 | + |
| 119 | +Unit tests cover the shared machinery (`src/lib/reader/input/*.test.ts`, |
| 120 | +`src/lib/reader/page-nav.test.ts`); Playwright e2e covers zoom geometry |
| 121 | +(`e2e/zoom.spec.ts`). When changing gesture behavior, test manually with a |
| 122 | +Japanese-learning extension (Yomitan/Migaku) enabled and verify the Anki |
| 123 | +double-tap flow still captures. |
0 commit comments