Files
404-Layers/CLAUDE.md
ncode 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

11 KiB
Raw Blame History

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

Edge-to-edge is handled natively, not avoided. Android 16 is API 36 and Capacitor 8 targets SDK 36, which is exactly the combination where windowOptOutEdgeToEdgeEnforcement stops working. An Android WebView never populates env(safe-area-inset-*), so MainActivity installs an OnApplyWindowInsetsListener, converts px → dp, and pushes the values into --ins-t/r/b/l via window.__applyInsets(). native.css re-points the reference's --sab at --ins-b, so .bnav and .sheet need no changes.

The push retries on a bounded schedule (25 × 200ms) because insets typically arrive before the page has run any JS.

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.

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/java/dev/ncode/sp404layers/MainActivity.java — inset bridge
  • android/app/src/main/AndroidManifest.xml — android:screenOrientation="portrait"

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.