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>
The release build type reuses the debug signing config, so a downloaded APK
installs over an existing one instead of demanding an uninstall. This never
goes to Play, which rejects debug keys anyway, so there is no private key
worth safeguarding for years and no way to strand anyone on a version that
can no longer be updated.
That choice is load-bearing in one direction: a release build is not
DEBUGGABLE, so adb shell run-as stops reading app data back out, and there is
no in-app restore. A signature change would have cost the drill progress.
npm run release stages release/404-Layers-<version>.apk with a checksum and
verifies the signature, since an unsigned APK builds fine and only fails at
install time, on someone else's phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name was SP-404MK2 layers, which is 16 characters and truncates in
the launcher. It is set in five places, one of which cap sync
regenerates from appName, so all five move together: capacitor.config,
strings.xml, index.html, package.json and CLAUDE.md. reference/ keeps
the old name because it is frozen. appId is untouched - changing it
would make this a different app to Android, meaning a fresh install and
the drill progress on the phone orphaned.
The icon is a 3x3 pad grid with the centre pad lit: the app's own
subject, legible when a launcher masks it to a circle, and in the
settled palette. Three columns rather than the hardware's four so the
cells survive 48px, which I checked at 800% magnification.
scripts/make-icon.mjs draws it with ImageMagick primitives, because
there is no SVG rasteriser here - no Inkscape, no rsvg-convert, and no
SVG delegate in ImageMagick 6. It is in the repo because the icon would
otherwise be 15 binary PNGs with no record of how they were made.
Two things that took a few attempts. The draw commands have to be built
as a real array: three tries in bash collapsed all nine into one
argument, which ImageMagick read as a filename. And the convert calls
need -strip, because without it the pixels are identical between runs
but the bytes are not, so the PNGs churned and the script could not be
shown to be their source. Two consecutive runs are now byte-identical.
Also removed two orphaned Capacitor vectors, 204 lines of gradient
swoosh and grid pattern that nothing referenced once the adaptive icon
pointed at @mipmap and @color.
Icons total 52KB. Built and installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tabs were rendering Explore. They now take the whole screen, as the
reference does by hiding the unit on each: a list or a page of prose
wants the room more than the panel does. Picking a search result jumps
to Explore with that combo lit, which is the reference's data-go.
The rules text is extracted verbatim rather than retyped or reworded.
Two bugs on the way there, both worth recording:
- RULES is a template literal that itself contains template literals
inside a ${} interpolation, so the bracket scanner used for the combo
table stops at the first inner backtick. The nine title and body pairs
are matched out of the source instead, with a count assertion so drift
fails loudly rather than silently dropping a rule.
- PADMAP was read with g() but never added to the list of declarations
evaluated into the vm, so it was simply not in scope.
The extractor now asserts 9 rules and 4 pad-map rows alongside the 159
combos.
141 tests, typecheck clean. Both tabs verified by rendering them, Find
with a seeded query since headless Firefox cannot type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vite + React + TypeScript + Vitest. No router, state library or UI kit.
The 159-row combo table is extracted from reference/index.html by
scripts/extract-reference-data.mjs rather than retyped: it balances
brackets past string literals to slice out the pure-data declarations and
evaluates them in a vm with no DOM. raw.ts and labels.ts are generated.
The extracted count matches the 159 the Phase 1 app reported on device.
Entry.id keeps the reference's format verbatim (shift>p1@S#FIX VEL),
built from the unsplit context string, so drill progress already on the
phone migrates instead of being orphaned.
The domain is pure where the reference was not: boxes go in and new
boxes come out, and the random source is injected. That is what made
card selection testable.
Two bugs found by doing so:
- pickCard returned the card just answered when random() gave exactly 0.
A zero-weight card does not move the accumulator, so "r <= 0" matched
it. The reference has this; at one draw in 2^53 it is invisible in use,
but a pinned random source hits it every time. Fixed to "r < 0".
- boxDistribution incremented through an index that
noUncheckedIndexedAccess types as possibly undefined.
65 tests. Several assert numbers observed on the device: 159 entries,
sample-mode layers of 3 / 35 / 2, a 16-card SHIFT+pads deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app sometimes drew under the status and gesture bars and sometimes
between them. Cause: Capacitor 8 ships a SystemBars plugin that already
handles insets, from a listener on the decor view, and my MainActivity
had installed another one on the WebView. Different views, so they
stacked rather than replaced each other and the result depended on which
fired last.
SystemBars switches on the viewport meta tag. With viewport-fit=cover it
passes insets through once onPageCommitVisible has checked the tag, decor
padding drops to 0 and the WebView grows 735 -> 820, leaving the page to
place itself from injected CSS vars. Without it the plugin pads the decor
view and the WebView is laid out between the bars already.
So sync-www.sh now strips viewport-fit=cover from the generated www/
(patch 5), MainActivity is an empty BridgeActivity, native.css and
native.js no longer touch insets, and bar style moves to
capacitor.config.json. No native->web handshake remains, so there is
nothing to race and no launch-time layout jump.
Verified identical across 3 cold launches, 2 resumes and an in-place
reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px.
Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take
the same room either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The two chip rows sat flush: the reference spaces them with
'#strip .chips + .chips', but the .lbl between them is display:none on
phones and still in the DOM, so the sibling selector never matched.
Measured gap was 0px, and both selected chips are light-filled and
left-aligned, so they merged into one shape.
- collapse the 7 context chips into one 44px control opening the existing
sheet, whose rows now show CTXHINT (never surfaced on phones before)
- chip targets 34.9px -> 44px; they scroll, so a mis-hit scrolled instead
of tapping
- right-edge fade only on rows that actually overflow (703px and 952px of
content in a 360px viewport, scrollbars hidden)
Applied in native/ so reference/ stays frozen; CLAUDE.md records what the
React port must carry.
scripts/device.sh makes the screen-off renderer denial unmissable: it
verifies the phone is awake, waits for the page to parse rather than for
the activity, and reads the pid after start (adb install -r kills the old
process, which produced one false 'page never loaded').
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>