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>
368 lines
18 KiB
Markdown
368 lines
18 KiB
Markdown
# 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.
|
||
|
||
**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.** 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: 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/` |
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
**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.
|