Commit Graph

20 Commits

Author SHA1 Message Date
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
9e897a26ea Record the settled visual spec in CLAUDE.md
Direction A (manual) with a dark counterpart is settled, so reference/
is demoted: it remains authoritative for behaviour and data - combos,
layer logic, Leitner scheduling, the pointer model - but no longer for
appearance. This section and the canvas govern that.

Captures the language (flat fills, hard zero-blur offset shadows, 2px
outlines, a single #FF4A00 that means 'live', positive LCD, no green or
red), the panel construction drawn from the hardware (circle behind the
display, silkscreen group captions, FX buttons dark with orange text),
the measured type rules that stop labels overflowing, and the vertical
budget nothing may exceed.

Typeface and accent are still open and are noted as such; neither
blocks the data and logic work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:26:12 +03:00
e2e9fcddb5 Design canvas: fix key label fit; direction settled
RESAMPLE still sat flush against its border after the soft hyphen,
because the type was simply too large for the button. A left-block
button is 35.9px wide, leaving 27.9px of usable text width once the
2px borders and padding are removed; SAMPLE at 8px with .03em tracking
measured ~27.8px.

Key labels drop to 7px with no tracking and 1px side padding, which
gives SAMPLE 6.8px of headroom and REVERSE 2.3px. SHIFT, the longest
label in the six-button bank row, gets flex:1.25 - the hardware makes
that button wider too.

Direction settled: A (manual, light) with the dark counterpart. It will
be implemented in the React port rather than retrofitted onto the
Phase 1 vanilla app, so the UI is only built once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:24:57 +03:00
12a86fb5db Design canvas, third pass: usable screen, fix label overflow
RESAMPLE was overflowing its button and REMAIN was touching its
borders. Both lack a space, so unlike PTN EDIT or BPM SYNC they cannot
wrap. Restored the soft hyphens the reference already uses
(RE-SAMPLE, RE-MAIN, RE-VERSE) plus hyphens:manual on the key style.

The screen was faithful to the object but too small for the job it does
here. The circle no longer encloses the display: a 124px circle sits
behind a 168x84 LCD, so it reads as two 20px caps above and below with
no ring around the rectangle. Area goes up 2.4x, enough for the display
to name the layer and list what is on it, or show the card and verdict
when drilling.

Room came from the control rows (-6), knobs (-2), info card (-4) and
tab bar (-4):
  8 + 44 + 8 + 525 + 8 + 84 + 8 + 50 = 735
  8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8 = 525
Pad rows are 43px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:15:45 +03:00
ae142cdf52 Design canvas, second pass: direction A settled, drawn from the hardware
Feedback: direction A (light manual) is right; the control block reads
as busy; the screen should match the real assembly; the dark option was
flat because it had no shadows.

Screen — the hardware's actual arrangement: a black circular housing
with a small rectangular positive LCD inset (dark text on pale
grey-green), status line top and bottom, replacing the ellipse with
floating text.

Control block — grouped as the device is silkscreened (PATTERN
SEQUENCER, SAMPLING, SAMPLE EDIT, BANK), each group a hairline bracket
caption over its row, with 10px between the two blocks and 7px between
groups instead of three undifferentiated rows.

FX buttons are now dark with orange text, as on the unit.

Dark mode replaces Blueprint: A's counterpart with filled bodies and
hard black offset shadows, the panel lighter than the ground so the
shadows read. Blueprint and the current app move to an 'Earlier' page.

Room came from trimming the info card 116->88 and the tab bar 58->54,
so the panel grows 485->517 and pad rows stay at exactly 44px:
  8 + 44 + 8 + 517 + 8 + 88 + 8 + 54 = 735
  8 + 38 + 8 + 104 + 10 + 140 + 10 + 191 + 8 = 517

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:08:15 +03:00
091bdc2a96 Add design canvas: two TE-inspired directions before the React port
Design inspiration is the Teenage Engineering EP-133 manual: flat fills,
heavy outlines, hard zero-blur offset shadows, one orange accent,
uppercase technical type, monospaced numerals.

Six artboards drawn at the real measured 360x735 with the actual
vertical budget (44px pickers, 485px panel, 116px info, 58px tabs), so
a mockup cannot fit in a way the device won't:

- Current       the app as it stands, for comparison
- A Manual      light ground, 2px black outlines, black pads
- B Blueprint   pure black, 1px white hairlines, no fills
- Specimen      type candidates and palette, incl. which orange

Type is identical across both directions so the light/dark choice is
not confounded with the font choice. Green/red drill feedback is
replaced by orange fill vs dashed outline - neither exists in this
palette.

The seeded canvas payload is gitignored; these files are its source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:56:04 +03:00
066b9e8c34 Render between the system bars deterministically; delete my inset bridge
The app sometimes drew under the status and gesture bars and sometimes
between them. Cause: Capacitor 8 ships a SystemBars plugin that already
handles insets, from a listener on the decor view, and my MainActivity
had installed another one on the WebView. Different views, so they
stacked rather than replaced each other and the result depended on which
fired last.

SystemBars switches on the viewport meta tag. With viewport-fit=cover it
passes insets through once onPageCommitVisible has checked the tag, decor
padding drops to 0 and the WebView grows 735 -> 820, leaving the page to
place itself from injected CSS vars. Without it the plugin pads the decor
view and the WebView is laid out between the bars already.

So sync-www.sh now strips viewport-fit=cover from the generated www/
(patch 5), MainActivity is an empty BridgeActivity, native.css and
native.js no longer touch insets, and bar style moves to
capacitor.config.json. No native->web handshake remains, so there is
nothing to race and no launch-time layout jump.

Verified identical across 3 cold launches, 2 resumes and an in-place
reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px.

Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take
the same room either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:26:31 +03:00
5f9d89d9fb Collapse mode and layer onto one picker row; fix renderer diagnosis
Only one layer is ever active, so neither mode nor layer needs a scrolling
row. Two pickers side by side cost one 44px row instead of two.

The panel needed that space: .pads uses minmax(34px,1fr) rows and sat
exactly at the 34px floor, so the SP-404 illustration clipped at the
bottom. Measured on device:

  strip   96px -> 44px
  panel  433px -> 485px
  pads   142px -> 194px, rows 34px -> 44.75px
  unit overflow 1px -> 0

The layer list is read out of the reference's own chips at render time,
so its set, ordering and counts cannot drift from renderExplore().

Also corrects the renderer-denial root cause. It is not screen state: a
denial was observed with Reason[screen_on & caller care]. Every failure
followed 'adb install -r' and an immediate relaunch has always succeeded,
so device.sh now retries the force-stop/start cycle instead of blaming
the lockscreen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 23:04:13 +03:00
f8565c35cd Fix phone strip ergonomics; add device launch helper
The two chip rows sat flush: the reference spaces them with
'#strip .chips + .chips', but the .lbl between them is display:none on
phones and still in the DOM, so the sibling selector never matched.
Measured gap was 0px, and both selected chips are light-filled and
left-aligned, so they merged into one shape.

- collapse the 7 context chips into one 44px control opening the existing
  sheet, whose rows now show CTXHINT (never surfaced on phones before)
- chip targets 34.9px -> 44px; they scroll, so a mis-hit scrolled instead
  of tapping
- right-edge fade only on rows that actually overflow (703px and 952px of
  content in a 360px viewport, scrollbars hidden)

Applied in native/ so reference/ stays frozen; CLAUDE.md records what the
React port must carry.

scripts/device.sh makes the screen-off renderer denial unmissable: it
verifies the phone is awake, waits for the page to parse rather than for
the activity, and reads the pid after start (adb install -r kills the old
process, which produced one false 'page never loaded').

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:34:50 +03:00
6445114203 Document TCL device quirks found during on-device verification
Two device-level issues cost real debugging time and neither is an app bug:

- adb could not open the phone: Ubuntu's udev rules match vendor AND
  product, and list only older TCL products, so nothing matched 1bbb:0c01.
- A blank grey window: TCL's AppBootManagerService refuses to let the app
  bind the WebView renderer service while the display is dozing, so the
  page never parses. No crash, no JS error, empty document.title, and the
  failure is sticky for the process lifetime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:46:34 +03:00
1a8a12f7f3 Phase 1: wrap the reference web app in Capacitor for Android
Wraps reference/sp404-layers as a sideloadable APK without editing the
reference, which stays the frozen spec for the Phase 2 React port.

- sync-www.sh builds www/ from reference/ + native/, asserting each patch
  anchor so a reference change fails loudly rather than shipping broken
- drops the service worker (would serve a stale app after an APK update)
- haptics via a navigator.vibrate override, so buzz() needs no change
- localStorage mirrored into Preferences; sync load()/save() untouched
- hardware back: sheet -> Explore tab -> exit
- edge-to-edge handled in MainActivity: Android 16 + targetSdk 36 removes
  the opt-out, and a WebView never fills env(safe-area-inset-*), so insets
  are pushed into CSS custom properties instead

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:26:35 +03:00