Collapse mode and layer onto one picker row; fix renderer diagnosis

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>
This commit is contained in:
2026-09-16 23:04:13 +03:00
parent f8565c35cd
commit 5f9d89d9fb
4 changed files with 149 additions and 52 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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 '<button class="ctxopt" data-layer="' + o.layer + '" aria-pressed="' + (o.layer === cur) + '">' +
"<b>" + o.label + '</b><span class="hint">' +
(o.count === "1" ? "1 function" : o.count + " functions") + " on this layer</span></button>";
})
.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.

View File

@@ -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
}