Files
404-Layers/CLAUDE.md
ncode e65591431c Stop device:launch crying wolf on release builds
A release APK is not DEBUGGABLE, so Capacitor never enables web-contents
debugging and the devtools socket is never opened. page_loaded waited for that
socket regardless, so it reported "renderer denied" three times for a healthy
app, force-stopping and restarting it on each retry. It now branches: debug
builds still wait for the page to parse; release builds check the process
survived with no denial against our package, and say the page load is assumed
rather than claiming a verification that is not possible over adb.

The failure diagnostic was unfiltered and unbounded, so it printed other apps'
denials from minutes earlier. It is now scoped to our package and to the
current launch.

Also fixes keep-awake, which still ran the "usb" form that CLAUDE.md had
already recorded as ineffective on this AC-charging phone, and corrects the
documented value: "true" measures as 15 here, not 7.

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

514 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (debug builds) |
| `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.
**On a release build it cannot do that, and says so.** A release APK is not
`DEBUGGABLE`, so Capacitor never calls `setWebContentsDebuggingEnabled` and
`webview_devtools_remote_<pid>` is never opened — the page simply cannot be
inspected over adb. Demanding that socket anyway made the helper report
`renderer denied` three times for a perfectly healthy app, force-stopping and
restarting it each time. It now checks that the process survived and that
nothing denied *our* renderer, then prints `page load assumed, not verified`. A
blank grey window and a working one are genuinely indistinguishable from here,
so that wording is the honest limit, not hedging.
The denial grep is filtered by package and bounded to the current launch
(`logcat -d -T`, timestamp from `adb shell "date +'%m-%d %H:%M:%S.000'"` — the
format must be one quoted argument or toybox `date` rejects it). Unfiltered, it
printed `com.xiaomi.wearable` denials from minutes earlier and sent one session
chasing a denial that had never happened to us.
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` sets every
charging-source bit and works; measured as **15** here (AC|USB|wireless|dock),
not the 7 an earlier note claimed — 7 predates dock charging. 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.