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>
15 KiB
SP-404MK2 layers
A combo trainer for the Roland SP-404MK2, wrapped as a sideloaded Android APK.
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-jdkfrom 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:
- Service worker registration — would cache
index.htmland serve a stale app after an APK update. Assets are already local, so it buys nothing. - Web manifest link — the APK's own manifest governs the app.
- Nothing else.
native.cssandnative.jsare 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(whatreference/has),shouldPassthroughInsetsbecomes true onceonPageCommitVisiblehas checked the tag and calledrequestApplyInsets(). 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--sabare 0, and.bnavand.sheetare 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. 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.
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
#111111outlines 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 —
#C9D2C4with 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
SAMPLEmeasured ~27.8px and sat flush against the border.RESAMPLE,REMAINandREVERSEare the only labels with no space in them, so they carry soft hyphens — everything else wraps at its space. SHIFTgetsflex: 1.25in 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
Still open: the typeface (mockups use Space Grotesk + Space Mono; Martian
Mono and Archivo are the alternates on the specimen board) and whether the accent
is #FF4A00 or the current, softer #ff7a1a. Neither blocks the data and logic
work, only the UI.
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:
-
The two chip rows sit flush on phones. The reference spaces them with
#strip .chips + .chips { margin-top: 6px }, but the.lblheading between them isdisplay:noneon 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. -
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:
.padsusesminmax(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. -
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.
-
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:noneon.unitplus per-pointerIdtracking in aMap. This is the most delicate part to port in Phase 2 — readexDown/exUp/drDown/drUpbefore touching it. store,standSKareconst/letat classic-script top level: reachable by bare name fromnative.js, but absent fromwindow.save/render/closeSheetare function declarations, so they are onwindowand can be wrapped.