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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>