Phase 1: wrap the reference web app in Capacitor for Android
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>
This commit is contained in:
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## 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"`
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user