Files
404-Layers/CLAUDE.md
ncode 1d2cbda50d Ship a signed release APK
The release build type reuses the debug signing config, so a downloaded APK
installs over an existing one instead of demanding an uninstall. This never
goes to Play, which rejects debug keys anyway, so there is no private key
worth safeguarding for years and no way to strand anyone on a version that
can no longer be updated.

That choice is load-bearing in one direction: a release build is not
DEBUGGABLE, so adb shell run-as stops reading app data back out, and there is
no in-app restore. A signature change would have cost the drill progress.

npm run release stages release/404-Layers-<version>.apk with a checksum and
verifies the signature, since an unsigned APK builds fine and only fails at
install time, on someone else's phone.

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

25 KiB
Raw Permalink Blame History

404 Layers

A combo trainer for the Roland SP-404MK2, sideloaded to an Android phone.

The name is set in five places and cap sync regenerates one of them: capacitor.config.json (appName, which Capacitor writes into strings.xml), android/.../values/strings.xml (app_name, title_activity_main), index.html <title>, package.json, and this file. reference/ keeps the old name on purpose — it is frozen.

appId must stay dev.ncode.sp404layers. Changing it makes a different app as far as Android is concerned: a fresh install, and the drill progress on the phone orphaned.

The launcher icon is generated, not hand-drawn: npm run icon runs scripts/make-icon.mjs, which draws a 3×3 pad grid with the centre pad lit using ImageMagick primitives — there is no SVG rasteriser on this machine. It writes the five adaptive foregrounds (art inside the inner 66%, because the launcher masks the rest), the five legacy square/round pairs, and previews to design/icon/ since a PNG on disk is the only way to actually look at it. The convert calls pass -strip: without it the pixels are identical between runs but the bytes are not, so the committed PNGs churn and the script cannot be shown to be their source.

Phase 1 (current): the original single-file web app, wrapped in Capacitor as-is. Phase 2 (next): port to React + TypeScript via Vite — data and pure logic with tests first, then UI, then multitouch input.

Layout

reference/     The original working web app. FROZEN — this is the spec.
               Never edit it. Behaviour, data and styling are all defined here.
native/        Native-only additions (native.css, native.js). Not in reference.
scripts/       install-android-toolchain.sh, sync-www.sh
www/           GENERATED by sync-www.sh. Never edit by hand, never committed.
android/       Generated by `npx cap add android`. A few files are hand-edited
               (see "Hand-edited native files" below).

Commands

Command What it does
npm run sync:www Rebuild www/ from reference/ + native/
npm run sync The above, then npx cap sync android
npm run apk Sync, then ./gradlew assembleDebug
npm run install:apk adb install -r the debug APK
npm run run:android Build + install in one go — the usual loop
npm run logcat Tail Capacitor + web console output from the device

APK lands at android/app/build/outputs/apk/debug/app-debug.apk.

The Gradle build needs these in the environment (persisted to ~/.zshrc by the install script):

JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
ANDROID_HOME=$HOME/Android/Sdk

Driving the device

Launch through scripts/device.sh, never a bare am start. TCL denies the WebView renderer in the window just after adb install -r, and the app then comes up blank with no crash, no JS error and an empty document.title. The failure is sticky for the life of the process, so the app must be force-stopped and started again — which is exactly what the helper retries.

Keep the phone unlocked while working. That is not what causes the denial (see Device notes), but nothing on screen can be verified through a lockscreen and input keyevent will not reach the app.

scripts/device.sh handles this so it cannot be forgotten:

Command What it does
npm run device:keep-awake Stay awake while charging. Do this at the start of a session.
npm run device:launch Check awake/unlocked, force-stop, start, wait for the page to actually parse
npm run device:awake Just the check — prints a loud warning if locked
npm run device:sleep-normal Revert the stay-awake setting when finished

device:launch waits for the page, not merely for the activity: the activity starts fine even when the renderer was denied. On failure it prints the AppBootManager denial lines rather than leaving a blank window to puzzle over.

It also reads the pid after starting the app. adb install -r kills the running process, so a pid captured earlier points at a dead process and the devtools socket lookup fails while the app itself is healthy — that produced one false "page never loaded" during Phase 1.

To attach Chrome DevTools by hand: chrome://inspect, or adb forward tcp:9333 localabstract:webview_devtools_remote_$(adb shell pidof dev.ncode.sp404layers) and open http://127.0.0.1:9333/json.

Toolchain (verified, do not bump blind)

Pinned against Capacitor 8.5.2's capacitor/build.gradle: compileSdk 36, targetSdk 36, minSdk 24, Java 21.

  • JDK 21 — required by Capacitor 8; openjdk-21-jdk from apt
  • cmdline-tools 15859902
  • build-tools 36.1.0 — pairs with compileSdk 36
  • platforms android-35, android-36

sdkmanager prints a deprecation warning pointing at the newer android CLI. It still works, and it writes the licenses/ directory that Gradle checks. The replacement downloads itself on first run and gates on an interactive ToS, which is worse in an idempotent install script. Revisit if it actually breaks.

Decisions

reference/ is never edited. It is the Phase 2 spec, so it has to stay byte-for-byte comparable. Everything native-specific is either an additive file or one of three removals applied by sync-www.sh, which asserts each anchor exists and fails loudly if the reference ever changes shape.

The three removals:

  1. Service worker registration — would cache index.html and serve a stale app after an APK update. Assets are already local, so it buys nothing.
  2. Web manifest link — the APK's own manifest governs the app.
  3. Nothing else. native.css and native.js are injected, not substituted.

Haptics needs no reference change. buzz() is a const and cannot be reassigned, but it calls navigator.vibrate, which can be overridden. native.js redefines that to dispatch to @capacitor/haptics: scalar → impact, array → notification({type:"SUCCESS"}), >15 → notification({type:"ERROR"}).

Storage mirrors rather than migrates. localStorage stays the synchronous source of truth so load()/save() keep their signatures. save() is wrapped to mirror into @capacitor/preferences (SharedPreferences), which survives a "clear app data" wipe. On boot, if localStorage is empty but Preferences is not, it restores and re-renders. A full async port was rejected as a refactor of code about to be rewritten in React anyway.

Back button is owned by native.js: sheet open → close it; otherwise not on Explore → go to Explore; otherwise exit. Registering a listener suppresses Capacitor's default history navigation.

System bars: let Capacitor do it, by dropping viewport-fit=cover. The app renders between the status and gesture bars, always.

Capacitor 8 ships a SystemBars plugin that owns inset handling from a listener on the decor view. Its behaviour hinges on the viewport meta tag:

  • with viewport-fit=cover (what reference/ has), shouldPassthroughInsets becomes true once onPageCommitVisible has checked the tag and called requestApplyInsets(). Decor padding drops to 0, the WebView grows from 735 to 820, and the page must place itself from injected CSS variables.
  • without it, the plugin pads the decor view instead. The WebView is 735px and already sits between the bars; env(safe-area-inset-*) and the reference's --sab are 0, and .bnav and .sheet are correct as written.

So sync-www.sh strips viewport-fit=cover from the generated www/ (patch 5). No native→web handshake exists, so there is nothing to race and no launch-time layout jump. Verified identical across cold launch, resume and in-place reload: h=735 padTop=8px overflowBy=0.

This costs no space. 820 − 45 − 114 and 735 − 8 − 66 both leave 661px, because the bars take the same room whether the WebView is padded or draws underneath. Reclaiming that 85px would mean hiding the bars, which is a different choice.

An earlier version did the opposite: MainActivity installed its own OnApplyWindowInsetsListener on the WebView and pushed --ins-t/r/b/l through window.__applyInsets(), with a 25 × 200ms retry. That was written without realising Capacitor already had a plugin for this. The two listeners were on different views, so they stacked rather than replaced each other, and the app drew under the bars at random depending on which fired last. MainActivity is now empty and bar styling is configured in capacitor.config.json (SystemBars.style: DARK).

No community plugins. Only first-party: @capacitor/core, /cli, /android, /haptics, /preferences, /app.

Debug signing, releases included. The debug keystore is stable per-machine, so Phase 2's APK installs over Phase 1's without an uninstall and drill progress survives. appId is dev.ncode.sp404layers and must not change for the same reason.

The release build type reuses that key on purpose, via the plugin's built-in signingConfig signingConfigs.debug, so no keystore path or password lands in a tracked file. This only ever ships as a sideloaded APK, never to Play — which rejects debug keys anyway. The trade: anyone can build an APK that upgrades over it, against which there is no key to keep safe for years and no way to strand anyone on a version that can no longer be updated.

Verified rather than assumed: the release APK's signer digest is 2494c983…551cc, identical to every debug build, so it installs in place. That matters more than it looks, because a release build is not DEBUGGABLE — the adb shell run-as trick that reads CapacitorStorage.xml back out stops working the moment one is installed, and there is no in-app restore to fall back on. A signature change would therefore have cost the drill progress outright.

npm run release (scripts/release.mjs) builds it, stages release/404-Layers-<version>.apk with a .sha256 beside it, and fails loudly if apksigner reports no certificate: an unsigned APK builds happily and only fails at install time, on someone else's phone. release/ is gitignored.

Phase 2: the React port

Stack: Vite + React + TypeScript + Vitest. No router, no state library, no UI kit — the app is four tabs and a reducer, and none of those earn their keep.

Command What it does
npm test Run the domain suite once
npm run test:watch Watch mode
npm run typecheck tsc --noEmit
npm run extract:data Regenerate the combo table from reference/
npm run release Build the signed release APK into release/, with checksum
src/domain/
  types.ts     hand-written: RawEntry, Entry, ContextCode, GroupId
  raw.ts       GENERATED — the 159-row combo table
  labels.ts    GENERATED — control labels, groups, contexts
  entries.ts   derives Entry[] from RAW
  layers.ts    which combos exist for a context + held modifiers
  drill.ts     decks, Leitner boxes, answer checking, card selection
  format.ts    ids to printed words, and the "SHIFT + PAD 1" lines
  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: 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 failing, which is why the layout is pinned by tests instead.

Do not trust adb shell input tap on this device. It cost six rounds of false conclusions in one session: taps that hit a neighbouring key, taps that hit nothing, and an entire diagnosis of "pointer events are dead" that turned out to be a re-locked screen swallowing the input. The CSS-to-device mapping is not simply css × devicePixelRatio — the viewport is 735 CSS px at dpr 3 = 2205 device px inside a 2460px screen, and the remaining 255px of system bars means the offset has to be measured, not assumed. Two rival offsets both failed.

When a known-good control appears dead, check the lock state before suspecting the code. For anything touch-related, one report from a person holding the phone beats an afternoon of synthetic input.

Stay awake needs svc power stayon true, not usb. The usb form writes stay_on_while_plugged_in = 2, and this phone charges over AC (USB powered: false), so it silently never engages — mStayOn stays false. true writes 7 (any source) and works. The screen timeout is separate and was 30s:

adb shell svc power stayon true
adb shell settings put system screen_off_timeout 1800000   # restore: 30000

Seeing the UI without the phone. There is no Chromium on this machine, but Firefox can screenshot headlessly with no extra dependency:

npx vite build
cd dist && python3 -m http.server 8777 --bind 127.0.0.1 &
firefox -no-remote --profile "$(mktemp -d)" --headless \
        --window-size=360,735 --screenshot out.png http://127.0.0.1:8777/

Three things that will otherwise waste time: -no-remote plus a throwaway --profile are required or it opens a tab in your running Firefox and captures nothing; module scripts will not load over file://, so it has to be served over HTTP; and dark mode needs a user.js in that profile with layout.css.prefers-color-scheme.content-override set to 0.

Do this after UI changes. The first render had pads labelled "PAD 1" instead of their numeral, and the LCD showing the context code MODE:S rather than the mode name — neither visible to typecheck or the test suite.

panelLayout.ts is data, and tested. allPanelControls() must cover all 54 labelled controls exactly once — a typo'd id would otherwise just quietly go missing from the panel. The bank row is pinned at six keys ending in SHIFT because Panel.module.css widens it with :last-child:nth-child(6), which stops matching silently if that row ever changes length.

Lit is an outline; the fill means selected. This deviates from the mockups, which only ever showed three lit keys at once. The SHIFT layer lights 35 of 54 controls, and solid orange across all of them is a wall rather than a map. Orange outline says "something is here"; solid orange says "this is the one you tapped".

Round keys clip and shrink when lit. SHIFT + VALUE is MARK FN inside a 30px circle. border-radius: 50% alone does not clip, so the label escaped the circle entirely; the knobs need overflow: hidden and a smaller size when lit.

Two strings are load-bearing for migration and must not be changed casually: Entry.id (below) and STORAGE_KEY, still sp404mk2-layers-v1. Together they mean the boxes already on the phone are picked up by the React app rather than orphaned. storage.test.ts parses a payload copied verbatim out of CapacitorStorage.xml on the device, so a change that would break that fails.

The combo table is never transcribed by hand. scripts/extract-reference-data.mjs lifts the literals out of reference/index.html, evaluating only pure-data declarations in a vm with no DOM, and writes raw.ts and labels.ts. Re-run it after any reference change; never edit those two files. Retyping 159 rows is precisely where silent errors would creep in, and the extraction cross-checks: 159 entries, matching the count the Phase 1 app reported on the device.

Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL) — built from the unsplit context string and the target as authored. That string is the key drill progress is stored under, so preserving it byte-for-byte means existing boxes on the phone migrate to the React app instead of being orphaned.

The domain layer is pure. The reference mutated a module-level store and called Math.random() inline; here boxes go in and new boxes come out, and the random source is injected. That is what makes card selection testable at all.

Tests assert against numbers observed on the device, not just against the code: 159 entries, sample-mode layer counts of 3 / 35 / 2 for no-modifier / SHIFT / SHIFT+REVERSE, and a 16-card SHIFT+pads deck.

Visual spec (settled — build Phase 2 against this)

The look is Direction A, "manual", after Teenage Engineering's EP-133 manual, with a dark counterpart. Canvas: https://claude.ai/artifact/4Jvjop1hEsJY2xzcpGrrgg Working files: design/canvas/*.dc.html + canvas.json (the seeded payload is gitignored and regenerates from them).

reference/ is now the behaviour and data spec only. It no longer governs appearance — this section and the canvas do. Combos, layer logic, Leitner scheduling and the pointer model still come from the reference.

The language:

  • Flat fills, no gradients, no blur. Depth is a hard zero-blur offset shadow: 4px on the panel, 3px on cards and pickers.
  • 2px #111111 outlines everywhere (dark mode: 2px #000000).
  • One accent, #FF4A00, meaning exactly one thing: this control is live. Pad numerals stay grey so the accent is never ambiguous.
  • Uppercase, technical labels; monospaced numerals and annotations.
  • No green or red. Drill feedback is orange fill for correct, dashed outline for not-quite.
  • The LCD is a positive display — #C9D2C4 with dark text — in both modes.

Panel construction, drawn from the hardware rather than invented:

  • The screen's circle sits behind the display, not around it: a 124px circle behind a 168×84 LCD shows only as two 20px caps above and below. The display names the layer and lists what is on it.
  • The control block is grouped as the unit is silkscreened (PATTERN SEQUENCER, SAMPLING, SAMPLE EDIT, BANK), each group a hairline bracket caption over its row, 10px between the two blocks and 7px between groups.
  • FX buttons are dark with orange text, as on the device.
  • Key labels are 7px, no letter-spacing, 1px side padding. A left-block button is 35.9px wide and only 27.9px of that is usable text width; at 8px with tracking SAMPLE measured ~27.8px and sat flush against the border. RESAMPLE, REMAIN and REVERSE are the only labels with no space in them, so they carry soft hyphens — everything else wraps at its space.
  • SHIFT gets flex: 1.25 in the six-button bank row, as the hardware widens it.

Vertical budget at the real 360×735, which nothing may exceed:

root   8 + 44 + 8 + 525 + 8 + 84 + 8 + 50            = 735
panel  8 + 36 + 8 + 124 + 10 + 134 + 10 + 187 + 8    = 525
pads   (187 - 15) / 4                                = 43px rows

Typeface: Space Grotesk + Space Mono. Space Grotesk for labels and headings, Space Mono for numerals and technical annotations. Self-hosted as woff2 under public/fonts/ — the app must work with no network, as Barlow did. Martian Mono was the alternate and was rejected on measurement: it is a wide mono, and key labels already clear their buttons by only 2.3px at 7px.

Accent: #FF4A00. TE's orange, not the softer #ff7a1a Phase 1 used. It holds against both the light body and the dark mode's near-black, which matters because the accent has exactly one job — marking a control as live.

Phase 2 must carry these UI changes

Phase 1 fixes a strip ergonomics problem in native/native.{css,js} rather than in reference/, which stays frozen. The React port must reproduce these, since they will not come along with the reference:

  1. The two chip rows sit flush on phones. The reference spaces them with #strip .chips + .chips { margin-top: 6px }, but the .lbl heading between them is display:none on phones and still in the DOM, so the adjacent-sibling selector never matches. Measured gap: 0px. Both selected chips are also light-filled and left-aligned, so they visually merge into one shape.

  2. Both chip rows collapse into two pickers side by side on one 44px row. Only one layer is ever active, so neither the mode nor the layer needs a scrolling row of its own. Each opens the existing sheet. The mode sheet shows CTXHINT, which the phone layout otherwise never displays; the layer sheet shows each layer's function count.

    This matters for the panel, not just for tidiness: .pads uses minmax(34px, 1fr) rows and on this device it sits exactly at that 34px floor, so the SP-404 illustration clips at the bottom if anything takes more room. Two rows cost 96px; one costs 44px, and the 52px goes to the panel.

    The layer list is read out of the reference's own chips at render time rather than recomputed, so the set, ordering and counts cannot drift from what renderExplore() decided. In React this becomes shared derived state instead.

  3. Chip targets go from 34.9px to 44px. They scroll horizontally, so an undersized target turns a mis-hit into a scroll instead of a tap.

  4. Overflowing rows get a right-edge fade. Scrollbars are hidden, and both rows overflow badly (703px and 952px of content in a 360px viewport) with nothing indicating more content. The fade is applied only to rows that actually overflow.

In React this should be real component state, not a post-render DOM patch: Phase 1 has to re-apply the collapse after every renderExplore() because that function rewrites #strip wholesale.

Hand-edited native files

cap sync does not regenerate these — keep them when recreating android/:

  • android/app/src/main/AndroidManifest.xml — android:screenOrientation="portrait"

That is the only one. MainActivity.java is deliberately an empty BridgeActivity: everything it used to do is either configured in capacitor.config.json or handled by Capacitor's own SystemBars plugin. If you find yourself adding an inset listener there, read the system-bars decision above first — that path has already been tried and reverted.

Device notes (TCL 50 PRO NXTPAPER 5G, Android 16 / API 36)

adb needs a udev rule. The phone is USB 1bbb:0c01. Ubuntu's android-sdk-platform-tools-common matches vendor and product, and lists only older TCL products, so nothing matched and the device node stayed root:root. Fixed with a vendor-wide rule (product IDs change with USB mode):

echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="1bbb", MODE="0660", GROUP="plugdev", TAG+="uaccess"' \
  | sudo tee /etc/udev/rules.d/51-android-tcl.rules
sudo udevadm control --reload-rules && sudo udevadm trigger

It lives in /etc/udev so apt upgrades don't overwrite it. The uaccess tag grants the logged-in session access, so no logout is needed.

A blank grey window means the renderer was denied — relaunch. TCL's AppBootManagerService sometimes refuses to let the app bind the WebView's sandboxed renderer process:

W/AppBootManager: Type[TYPE_SERVICE];Result[false];
  Target[org.chromium.content.app.SandboxedProcessService0];
  Caller[dev.ncode.sp404layers];Reason[screen_on & caller care]
E/cr_ChildProcessConn: Failed to establish the service connection.

With no renderer the page never parses, so there is no crash, no JS error, an empty document.title, and DevTools Runtime.evaluate hangs. The failure is sticky for the life of the process — reopening from Recents keeps the grey window. Only am force-stop plus a fresh start clears it.

The trigger is the window just after adb install -r replaces the package. Every observed failure followed an install; an immediate relaunch with no install has always succeeded on the first try. Do not trust the Reason string: it reports the current screen state, and both screen_off and screen_on have been seen denying, so screen state is not the cause. An earlier version of this file claimed it was — that was wrong.

scripts/device.sh launch therefore retries the whole force-stop/start cycle up to 3 times. This is an OEM policy, not an app bug, and it only bites when launching over adb — tapping the icon is fine.

Gotchas

  • The reference's multitouch relies on touch-action:none on .unit plus per-pointerId tracking in a Map. This is the most delicate part to port in Phase 2 — read exDown/exUp/drDown/drUp before touching it.
  • store, st and SK are const/let at classic-script top level: reachable by bare name from native.js, but absent from window. save/render/ closeSheet are function declarations, so they are on window and can be wrapped.