diff --git a/CLAUDE.md b/CLAUDE.md index d5cb9c9..ff196d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,11 +41,15 @@ ANDROID_HOME=$HOME/Android/Sdk ## Driving the device -**The screen must be awake and unlocked before launching, every time.** While -the display dozes, TCL denies the WebView renderer and the app 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 waking the phone afterwards does not repair it — -the app has to be force-stopped and started again. +**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: @@ -142,9 +146,21 @@ they will not come along with the reference: 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. **The 7 context chips collapse into one 44px control** that opens the existing - sheet, freeing a row for the panel. The sheet rows show `CTXHINT`, which the - phone layout otherwise never displays. +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 @@ -179,22 +195,32 @@ 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 screen was off when the app started.** TCL's -`AppBootManagerService` refuses to let the app bind the WebView's sandboxed -renderer process while the display is dozing: +**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_off & caller care] + 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. Wake and unlock the phone first, then `am force-stop` and relaunch. -This is an OEM policy, not an app bug, and it only bites when launching over adb. +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 diff --git a/native/native.css b/native/native.css index c1db018..66a0b4e 100644 --- a/native/native.css +++ b/native/native.css @@ -42,6 +42,13 @@ single .ctxbtn below, leaving one chip row with real touch targets. Phase 2 must carry this - see CLAUDE.md. */ + /* Mode and layer pickers share one row: only one layer is ever active, so + neither needs a scrolling row of its own. One 44px row instead of two gives + the panel back 52px, which it needs - its pad rows sit at their + minmax(34px,1fr) floor, so anything less clips the illustration. */ + .stripbar{ display: flex; gap: 8px; } + .stripbar .ctxbtn{ flex: 1 1 0; min-width: 0; font-size: 1rem; padding: 8px 12px; } + .ctxbtn{ display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-height: 44px; padding: 8px 14px; diff --git a/native/native.js b/native/native.js index 49d52dc..33f7c45 100644 --- a/native/native.js +++ b/native/native.js @@ -128,6 +128,47 @@ .join(""); } + /* Snapshot of the reference's own layer chips, refreshed on every render. + Read from its DOM rather than recomputed, so the set, ordering and counts + cannot drift from what renderExplore() decided. */ + var layerOptions = []; + + function snapshotLayers(row) { + return [].map.call(row.querySelectorAll(".chip"), function (c) { + var clone = c.cloneNode(true); + var small = clone.querySelector("small"); + var count = small ? small.textContent.trim() : ""; + if (small) small.remove(); + return { layer: c.getAttribute("data-layer") || "", label: clone.textContent.trim(), count: count }; + }); + } + + function layerSheetHTML() { + var cur = layerKey(st.held); + return layerOptions + .map(function (o) { + return '"; + }) + .join(""); + } + + function pickerBtn(sheetAttr, text, aria) { + var btn = document.createElement("button"); + btn.className = "ctxbtn"; + btn.setAttribute(sheetAttr, ""); + btn.setAttribute("aria-label", aria); + var label = document.createElement("b"); + label.textContent = text; + var caret = document.createElement("span"); + caret.setAttribute("aria-hidden", "true"); + caret.textContent = "▾"; + btn.appendChild(label); + btn.appendChild(caret); + return btn; + } + function reshapeStrip() { var strip = document.getElementById("strip"); if (!strip) return; @@ -136,18 +177,19 @@ // Drill renders a .stripbtn and Find an input, so both are left alone. var rows = strip.querySelectorAll(".chips"); if (phone() && rows.length >= 2) { - var btn = document.createElement("button"); - btn.className = "ctxbtn"; - btn.setAttribute("data-ctxsheet", ""); - btn.setAttribute("aria-label", "Mode: " + CTX[st.ctx] + ". Change mode"); - var label = document.createElement("b"); - label.textContent = CTX[st.ctx]; - var caret = document.createElement("span"); - caret.setAttribute("aria-hidden", "true"); - caret.textContent = "▾"; - btn.appendChild(label); - btn.appendChild(caret); - rows[0].replaceWith(btn); + layerOptions = snapshotLayers(rows[1]); + + // Only one layer is ever active, so both rows collapse to pickers. Side by + // side they cost one 44px row instead of two, which the panel needs: its + // pad rows sit at their minmax(34px,1fr) floor and clip below that. + var held = st.held.length ? st.held.map(mName).join(" + ") : "No modifier"; + var bar = document.createElement("div"); + bar.className = "stripbar"; + bar.appendChild(pickerBtn("data-ctxsheet", CTX[st.ctx], "Mode: " + CTX[st.ctx] + ". Change mode")); + bar.appendChild(pickerBtn("data-layersheet", held, "Layer: " + held + ". Change layer")); + + rows[0].replaceWith(bar); + rows[1].remove(); } strip.querySelectorAll(".chips").forEach(function (r) { @@ -171,9 +213,15 @@ openSheet("Where you are", ctxSheetHTML()); return; } + if (t.hasAttribute("data-layersheet")) { + openSheet("Layers in " + CTX[st.ctx], layerSheetHTML()); + return; + } // The reference's own handler runs first (registered earlier) and applies - // data-ctx, so this only has to dismiss the sheet afterwards. - if (t.hasAttribute("data-ctx") && t.closest("#sheet")) closeSheet(); + // data-ctx / data-layer, so this only has to dismiss the sheet afterwards. + // data-layer="" (no modifier) is intentional: the reference tests + // `d.layer !== undefined`, so an empty value still selects that layer. + if ((t.hasAttribute("data-ctx") || t.hasAttribute("data-layer")) && t.closest("#sheet")) closeSheet(); }); // The first render already ran before this script loaded. diff --git a/scripts/device.sh b/scripts/device.sh index 310efa4..ec47ded 100755 --- a/scripts/device.sh +++ b/scripts/device.sh @@ -1,25 +1,32 @@ #!/usr/bin/env bash # Device helpers for driving the app over adb. # -# TCL's AppBootManagerService refuses to let the app bind the WebView's renderer -# process while the display is dozing: +# TCL's AppBootManagerService sometimes refuses to let the app bind the +# WebView's renderer process: # # W/AppBootManager: Result[false]; Target[...SandboxedProcessService0]; -# Caller[dev.ncode.sp404layers]; Reason[screen_off & caller care] +# 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: no crash, no JS error, an empty # document.title, and DevTools Runtime.evaluate hangs. It is sticky for the life # of the process, so reopening from Recents keeps the blank window. # -# So: always ensure the screen is awake before launching, and say so plainly -# when it is not, rather than leaving a blank window to puzzle over. +# Observed trigger is the window just after `adb install -r` replaces the +# package - every failure followed an install, and an immediate relaunch with no +# install has always succeeded. The Reason string reports the current screen +# state ("screen_on" and "screen_off" have both been seen denying), so screen +# state is not the cause. Hence: launch retries the whole cycle. set -euo pipefail ADB="${ADB:-$HOME/Android/Sdk/platform-tools/adb}" PKG=dev.ncode.sp404layers ACT="$PKG/.MainActivity" PORT=9333 +ATTEMPTS=3 +# Not the cause of the denial, but a locked phone cannot be checked visually +# and input keyevents will not reach the app. ensure_awake() { "$ADB" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true "$ADB" shell input keyevent 82 >/dev/null 2>&1 || true @@ -30,14 +37,11 @@ ensure_awake() { if [ "$wake" != "Awake" ] || [ "$lock" = "true" ]; then echo "" >&2 - echo " ####################################################################" >&2 - echo " # UNLOCK THE PHONE #" >&2 - echo " # #" >&2 + echo " #############################################################" >&2 + echo " # UNLOCK THE PHONE #" >&2 echo " # wakefulness=$wake lockscreen=$lock" >&2 - echo " # #" >&2 - echo " # While the screen is off, TCL denies the WebView renderer and #" >&2 - echo " # the app shows a blank window with no error anywhere. #" >&2 - echo " ####################################################################" >&2 + echo " # Nothing on screen can be verified while it is locked. #" >&2 + echo " #############################################################" >&2 echo "" >&2 return 1 fi @@ -54,18 +58,16 @@ sleep_normal() { echo "stay-awake: reverted to system default" } -# Force-stop, start, and wait until the page is actually parsed - not merely -# until the activity is up, which happens even when the renderer was denied. -launch() { - ensure_awake || return 1 - "$ADB" shell am force-stop "$PKG" - "$ADB" shell am start -W -n "$ACT" >/dev/null - +# Wait until the page is actually parsed, not merely until the activity is up: +# the activity starts fine even when the renderer was denied. +page_loaded() { local pid sock i - # Read the pid AFTER starting: an `adb install -r` kills the old process, and - # a stale pid makes the devtools socket lookup fail while the app is fine. + # Read the pid AFTER starting. `adb install -r` kills the old process, so a pid + # captured earlier points at a dead one and the socket lookup fails while the + # app itself is healthy - that produced one false "page never loaded". pid=$("$ADB" shell pidof "$PKG" | tr -d '\r') - for i in $(seq 1 60); do + [ -n "$pid" ] || return 1 + for i in $(seq 1 40); do sock=$("$ADB" shell cat /proc/net/unix | grep -o "webview_devtools_remote_$pid" | head -1) if [ -n "$sock" ]; then "$ADB" forward "tcp:$PORT" "localabstract:$sock" >/dev/null 2>&1 || true @@ -75,9 +77,23 @@ launch() { fi fi done + return 1 +} - echo "ERROR: activity started but the page never loaded." >&2 - echo "Renderer denials in the log:" >&2 +launch() { + ensure_awake || return 1 + local attempt + for attempt in $(seq 1 $ATTEMPTS); do + "$ADB" shell am force-stop "$PKG" + "$ADB" shell am start -W -n "$ACT" >/dev/null + if page_loaded; then + [ "$attempt" -gt 1 ] && echo "(took $attempt attempts - post-install renderer denial)" + return 0 + fi + echo "attempt $attempt: renderer denied, retrying" >&2 + done + + echo "ERROR: page never loaded after $ATTEMPTS attempts." >&2 "$ADB" logcat -d | grep -E 'AppBootManager.*Result\[false\]|ChildProcessConn: Failed' | tail -4 >&2 return 1 }