Phase 2: multitouch

Panel used onClick, which cannot express a chord at all - so hold mode,
which is the persisted default answering mode, was unusable. It now
tracks pointers per pointerId and reports onPress per control plus
onGestureEnd when the last finger lifts.

The split is deliberate: Panel owns pointer bookkeeping because it owns
the DOM, and App keeps deciding what a chord means. Hold mode grades on
onGestureEnd, tap mode accumulates presses and waits for Check, and
Explore still selects on press. Both map onto reducer actions that
already existed.

Two details carried over from the reference that are easy to delete by
accident, and are now written down:

- touch-action: none on the panel. Without it the browser claims the
  second finger as a pinch or a pan and no chord can ever form.
- pointerup is heard on the window, not the button, because fingers
  routinely lift outside the key they went down on.

Keyboard still works: Enter or Space presses and ends the gesture.

141 tests, typecheck clean, touch-action confirmed in the built CSS, and
the panel renders unchanged. Actual multi-finger behaviour cannot be
verified headlessly - that needs the phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:52:04 +03:00
parent 6d1f219e8a
commit 470ebf41dc
4 changed files with 130 additions and 15 deletions

View File

@@ -180,13 +180,32 @@ src/domain/
search.ts the Find tab's matcher
storage.ts the persisted blob, backup parsing, defaults
src/domain/
rules.ts GENERATED — the nine rules and the SHIFT + pad map
src/ui/
panelLayout.ts where every control sits; captions as silkscreened
Panel.tsx the panel, presentational: told what is lit, reports presses
cx.ts joins class names, dropping blanks
panelLayout.ts where every control sits; captions as silkscreened
Panel.tsx the panel: told what is lit, owns pointer bookkeeping
drillReducer.ts drill transitions, pure and React-free
persistedState.ts the only code that touches localStorage
Sheet.tsx bottom sheet, shared by both pickers and drill settings
InfoCard.tsx Explore's card · DrillStrip/DrillCard Drill's
FindPanel.tsx search and results · RulesPanel.tsx the rules page
cx.ts joins class names, dropping blanks
src/styles/base.css tokens: palette, fonts, the vertical budget
```
**Multitouch lives in `Panel`, meaning in `App`.** Answering a drill card means
holding two or three controls at once, so `Panel` tracks pointers per
`pointerId` and reports `onPress` per control plus `onGestureEnd` when the last
finger lifts. What a chord *means* stays in `App`: hold mode grades on
`onGestureEnd`, tap mode accumulates via `press` and waits for Check.
Two things make it work and are easy to delete by accident: `touch-action: none`
on the panel, without which the browser claims the second finger as a pinch and
no chord can ever form; and listening for `pointerup` on the **window**, because
fingers routinely lift outside the key they went down on.
**CSS modules type as `string | undefined`** under `noUncheckedIndexedAccess`, so
`cx()` absorbs that rather than the flag being turned off — it has caught real
bugs. The cost is that a mistyped class renders with no class at all rather than