15 Commits

Author SHA1 Message Date
6ddf6f9a77 Show the gesture, and offer the full text when the card clips
Two faults behind one report. The card clipped long descriptions, and it
never rendered the how field at all - so for SHIFT + REMAIN, where the
instruction IS a three-second hold, the most important part was missing
rather than merely shortened.

Measured rather than assumed: that entry is Live mode, how is "Hold
SHIFT, hold REMAIN 3 s", desc is 112 characters, and the pair renders
43px tall into a 29px two-line clamp. A full line was being lost.

The gesture now leads the card text in the accent colour, and a MORE
button opens a sheet with the combo, the gesture, the full description,
the modes it applies in and the manual page. Whether to offer MORE is
decided by measuring scrollHeight against clientHeight, not by guessing
a character count, so it appears exactly when text is really cut. 39 of
159 entries qualify: 24 carry a gesture, 17 have a description over 110
characters.

The card stays 84px because the panel needs that space, so a sheet is
the right place for the overflow, as in the reference.

141 tests, typecheck clean, installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:10:41 +03:00
6889167fcf Fix lit pads not highlighting; stop swallowing haptics errors
A lit pad kept its grey text and only its border turned orange, so pads
looked unhighlighted next to every other key. Cause was cascade order,
not logic: .pad is a single-class selector like .lit, so equal
specificity meant whichever came last won, and .pad was declared after.
The state classes now sit at the bottom of the file with a comment
saying why, and a lit pad computes rgb(255,74,0) for text and border.

native.ts reported nothing when haptics failed because I wrote
catch(() => {}) and discarded the reason. It now reports once per
distinct failure - enough to read in logcat, quiet enough not to spam on
every key press.

That reporting shows the software path is fine: isNativePlatform true,
Haptics available and bridged, VIBRATE granted, and both impact and
vibrate resolve without rejecting. So the silence is below the bridge -
most likely the phone's own touch-vibration setting - and not something
this code can detect.

141 tests, typecheck clean, installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:05:01 +03:00
b95cff1be1 Phase 2: Explore's latch, haptics, back button, label overflow
Four things reported on the device, all real.

Tapping a modifier did nothing. setLayer was only reachable from a
search result or the layer sheet, so the reference's latch was simply
never ported. It is now, with the semantics intact rather than reduced
to a toggle: hold to peek and it lifts away, tap to lock and it stays,
tap again to release, and reading a lit key while holding a modifier
spends that modifier so lifting drops it. Knobs and pads never latch,
which is what NOLATCH is for. held is now the source of truth and the
layer key is derived from it, as in the reference.

That needed per-control release events. Panel knew which control each
pointer held and threw it away; it now reports onRelease before the
gesture-wide callback, because the latch decides hold-versus-tap from
how long one key was down.

Haptics and the hardware back button were missing entirely - Phase 1 did
both in native.js and the port had no equivalent. native.ts adds them,
no-ops in a browser so the dev server is unaffected. Back owns the whole
stack: sheet, then Explore, then exit.

Grade haptics fire from the transition into answered, not from the four
dispatch sites. Grading happens in the reducer, so deciding correctness
again at each call site would be a second source of truth free to drift.

PINGPONG overflowed its key. Lit keys show the entry's short label, which
comes from the data and can be one unbreakable word; the soft hyphens
only cover printed labels. Keys now break anywhere and clip.

141 tests, typecheck clean, installed and running on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:00:23 +03:00
470ebf41dc 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>
2026-09-17 10:52:04 +03:00
6d1f219e8a Phase 2: Find and Rules
Both tabs were rendering Explore. They now take the whole screen, as the
reference does by hiding the unit on each: a list or a page of prose
wants the room more than the panel does. Picking a search result jumps
to Explore with that combo lit, which is the reference's data-go.

The rules text is extracted verbatim rather than retyped or reworded.
Two bugs on the way there, both worth recording:

- RULES is a template literal that itself contains template literals
  inside a ${} interpolation, so the bracket scanner used for the combo
  table stops at the first inner backtick. The nine title and body pairs
  are matched out of the source instead, with a count assertion so drift
  fails loudly rather than silently dropping a rule.
- PADMAP was read with g() but never added to the list of declarations
  evaluated into the vm, so it was simply not in scope.

The extractor now asserts 9 rules and 4 pad-map rows alongside the 159
combos.

141 tests, typecheck clean. Both tabs verified by rendering them, Find
with a seeded query since headless Firefox cannot type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:49:50 +03:00
b875342864 Phase 2: persistence, and real pickers instead of cycling
The stored blob is now read once at startup through useReducer's lazy
initialiser and written whenever it changes, so deck, direction,
answering mode, context and the Leitner boxes all survive a restart.

persistedState.ts is the only part that touches the browser; the parsing
stays in domain/storage.ts. Every access is guarded, because localStorage
throws rather than returning null in a private window, with site data
blocked, or in a WebView that has not finished starting.

That guard is now tested, along with the claim this whole migration rests
on: a payload copied verbatim out of CapacitorStorage.xml on the phone
loads back with its boxes intact. If that test ever fails, the React app
has orphaned real drill progress. Also covered: damaged JSON, storage
that throws, localStorage absent entirely, and a refused quota on write.

Explore's pickers open a bottom sheet rather than cycling blindly through
options, and the drill settings button is no longer dead - deck,
direction and answering mode, each showing how many cards it selects.
Sheet is shared by all three.

Also fixed a silent one: .sheetLabel was used three times and did not
exist. CSS modules are index-typed, so that compiles clean and renders
with no class at all.

141 tests, typecheck clean. Sheets verified by rendering them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:46:42 +03:00
9e1ffa0c85 Phase 2: the Drill screen
App now owns the panel once and each tab supplies what is lit, what the
screen says, and what fills the strip and the 84px card box. Drill adds
its strip (deck plus the five Leitner bars) and its card, which has three
states: press this combo, name this combo, or the verdict.

Two defects fixed that only rendering could show.

Dealing the first card happened during render. React tolerated it and
the capture looked fine, but dealing picks at random and StrictMode
renders twice, so it was fragile rather than wrong today. It is an
effect now.

The card box overflowed by 8.5px and clipped CLEAR and CHECK. The box is
84px, which leaves 68px once borders and padding are removed, and a 10px
button with 5px padding and a 2px border is 26px tall - two rows of those
plus the title do not fit. Buttons are 21px now, and the stylesheet
records the budget for all three states so the next change has to do the
arithmetic rather than guess.

Verified by rendering the asking state and the answered state, the
latter by temporarily forcing a graded card and restoring the source
afterwards. Typecheck clean, 132 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:43:19 +03:00
6f1540635e Phase 2: drill state as a pure reducer
Drill transitions live in drillReducer.ts with no React, so they test
like the domain does. Choosing the next card needs randomness, so that
happens outside and arrives in the deal action; the reducer only ever
transforms what it is given.

Rules it encodes, each now pinned by a test:

- Show me counts as a miss. You did not know it, so the card goes back
  to box 1 rather than being skipped.
- Presses are inert once a card is graded, because the panel is showing
  the answer at that point.
- Tapping toggles, so a mis-hit can be taken back; holding accumulates,
  because a second report from a held finger is just contact.
- Changing deck or direction drops the current card, since it may not be
  in the new pool. Changing only the answering mode keeps it.
- A card cannot be graded twice.

132 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:37:21 +03:00
c7c827ecf8 Phase 2: the info card, selection, and what 35 lit keys taught
InfoCard fills the box that was empty: the layer hint and instruction
when nothing is picked, or the selected combo as chips with its name,
description and manual page. App carries the selection, and pressing a
control that holds several functions cycles through them, as the
reference does.

Lit is now an outline rather than a fill, and solid orange means
selected. This deviates from the mockups on purpose. They only ever
showed three lit keys; the SHIFT layer lights 35 of 54 controls, and
solid orange across all of them is a wall instead of a map. I checked
that by rendering the SHIFT layer rather than assuming, which meant
temporarily defaulting to it, capturing, and restoring the source.

That capture also caught a defect nothing else would have: SHIFT + VALUE
is MARK FN inside a 30px circle, and border-radius alone does not clip,
so the label sat outside the knob. Round keys now hide overflow and
shrink when lit.

Verified in light, dark, and on the SHIFT layer at 360x735. Typecheck
clean, 112 tests, www/ untouched so the Phase 1 APK still installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:22:45 +03:00
4961500fa7 Phase 2: render the panel and fix what looking revealed
Rendering it headlessly for the first time turned up two defects that
neither typecheck nor 112 tests could see:

- pads were labelled PAD 1, PAD 2 rather than printing their numeral.
  The hardware prints the number, and swaps in a function name only when
  a layer lights the pad up; Key now does the same.
- the LCD showed MODE:S, the context code, instead of the mode name. A
  single letter tells the reader nothing.

Both are only visible by looking, which is the point: the geometry was
arithmetically correct the whole time and still read wrong.

CLAUDE.md now records how to look. There is no Chromium here, but
Firefox screenshots headlessly with no new dependency, given three
things that otherwise waste an afternoon: -no-remote and a throwaway
profile, or it opens a tab in the running browser and captures nothing;
an HTTP server, because module scripts will not load over file://; and a
user.js pref to force dark mode.

Verified in both light and dark at 360x735. 112 tests, build clean,
www/ untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:17:16 +03:00
563caf0658 Phase 2: the panel
Panel.tsx is presentational - told what is lit, reports presses, holds no
state. The arrangement lives in panelLayout.ts as data, so the component
stays readable and the layout can be tested.

panelLayout.test.ts asserts allPanelControls covers all 54 labelled
controls exactly once; a typo'd id would otherwise quietly vanish from
the panel. It also pins the bank row at six keys ending in SHIFT, because
Panel.module.css widens that key with :last-child:nth-child(6), a
coupling that stops matching silently if the row changes length.

Geometry is the measured one: 8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8
= 525. The screen is the hardware's arrangement - a 124px circle behind a
168x84 LCD, showing only as caps above and below, with no ring around the
display. Key labels are 7px with no tracking, which is what stops
RESAMPLE overflowing its 35.9px button.

cx() absorbs CSS-module lookups typing as string | undefined under
noUncheckedIndexedAccess, rather than turning that flag off - it has
already caught real bugs.

Two fixed on the way through: panelLabel used PL without importing it,
which broke typecheck and the layout test at runtime; and ten className
type errors from the same CSS-module typing, in the places that needed a
definite string rather than React's optional one.

112 tests, build clean, www/ untouched so the Phase 1 APK still installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:12:09 +03:00
956dc57763 Phase 2: React scaffolding, self-hosted fonts, design tokens
Vite builds to dist/, deliberately not www/. www/ is still produced by
sync-www.sh for the Phase 1 APK, which stays installable throughout the
port; cutover is a one-line webDir change in capacitor.config.json.
base is relative because Capacitor serves the bundle from the APK, where
absolute asset paths would 404.

index.html omits viewport-fit=cover on purpose. That string is what
makes Capacitor's SystemBars plugin pass insets through to the page;
without it the plugin pads the decor view and the layout is always
between the status and gesture bars. Phase 1 learned this the hard way.

Fonts are self-hosted for offline use, latin subsets only: three files
at 68K, against the 133K Barlow costs today. Space Grotesk is variable,
so one file covers 400-700. Dropped latin-ext and vietnamese.

Carets are drawn as SVG, not typed. U+25BE and U+23EE sit outside every
Latin subset and would fall back to a system font mid-label.

base.css carries the settled palette and the measured vertical budget as
tokens, so a component cannot quietly exceed it:
  8 + 44 + 8 + 525 + 8 + 84 + 8 + 50 = 735

Verified: typecheck clean, 103 tests, vite build emits with the fonts
copied, and www/ is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 01:05:09 +03:00
ad847faa2e Phase 2: persisted state and backup parsing
storage.ts owns the blob that survives a restart - boxes, ctx, deck,
dir, input - plus validation for text a person pastes into the backup
box. Pure: parsing and serialising only. Where it is kept, localStorage
mirrored into Capacitor Preferences, stays the shell's problem.

STORAGE_KEY is still sp404mk2-layers-v1, unchanged from Phase 1, so
progress already on the phone is read rather than orphaned. The test
parses a payload copied verbatim out of CapacitorStorage.xml on the
device, so a change that would break migration fails the suite.

Two levels of strictness, matching the reference: reading our own
storage is lenient and falls back per field, while a pasted backup must
be an object carrying a boxes object or it is refused outright.

input defaults to hold rather than sniffing for a coarse pointer as the
reference did - that is a DOM question, so the shell decides it.

The domain layer is now complete: entries, layers, drill, format,
search, storage. 103 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:57:38 +03:00
43fb51396c Phase 2: label formatting and the Find search
format.ts turns ids into the words printed on the panel: controlName,
targetName (a group reads "PAD 1-16" rather than sixteen names), and
comboParts/comboText for the "SHIFT + PAD 1" lines.

search.ts matches a query against everything printed about an entry -
name, panel label, description, modifiers, target and contexts - so
"metronome", "SHIFT" and "sub pad" all find something. An empty query
returns nothing, not everything: Find opens on a prompt.

One test was wrong rather than the code. Searching "dj mode" does not
only match the context name: the entry named "Enter DJ mode" lives in
sample mode, because that is where you press it. Asserting every result
was in context D was too strong, and surfacing that combo is the useful
behaviour. The assertion now documents it.

87 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:55:31 +03:00
b32c38019e Phase 2: domain layer with tests
Vite + React + TypeScript + Vitest. No router, state library or UI kit.

The 159-row combo table is extracted from reference/index.html by
scripts/extract-reference-data.mjs rather than retyped: it balances
brackets past string literals to slice out the pure-data declarations and
evaluates them in a vm with no DOM. raw.ts and labels.ts are generated.
The extracted count matches the 159 the Phase 1 app reported on device.

Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL),
built from the unsplit context string, so drill progress already on the
phone migrates instead of being orphaned.

The domain is pure where the reference was not: boxes go in and new
boxes come out, and the random source is injected. That is what made
card selection testable.

Two bugs found by doing so:

- pickCard returned the card just answered when random() gave exactly 0.
  A zero-weight card does not move the accumulator, so "r <= 0" matched
  it. The reference has this; at one draw in 2^53 it is invisible in use,
  but a pinned random source hits it every time. Fixed to "r < 0".
- boxDistribution incremented through an index that
  noUncheckedIndexedAccess types as possibly undefined.

65 tests. Several assert numbers observed on the device: 159 entries,
sample-mode layers of 3 / 35 / 2, a 16-card SHIFT+pads deck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:54:34 +03:00