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 ## Driving the device
**The screen must be awake and unlocked before launching, every time.** While **Launch through `scripts/device.sh`, never a bare `am start`.** TCL denies the
the display dozes, TCL denies the WebView renderer and the app comes up blank WebView renderer in the window just after `adb install -r`, and the app then
with no crash, no JS error and an empty `document.title`. The failure is sticky comes up blank with no crash, no JS error and an empty `document.title`. The
for the life of the process, so waking the phone afterwards does not repair it — failure is sticky for the life of the process, so the app must be force-stopped
the app has to be force-stopped and started again. 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: `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 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 selector never matches. Measured gap: 0px. Both selected chips are also
light-filled and left-aligned, so they visually merge into one shape. 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 2. **Both chip rows collapse into two pickers side by side on one 44px row.**
sheet, freeing a row for the panel. The sheet rows show `CTXHINT`, which the Only one layer is ever active, so neither the mode nor the layer needs a
phone layout otherwise never displays. 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 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. 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 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 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. 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 **A blank grey window means the renderer was denied — relaunch.** TCL's
`AppBootManagerService` refuses to let the app bind the WebView's sandboxed `AppBootManagerService` sometimes refuses to let the app bind the WebView's
renderer process while the display is dozing: sandboxed renderer process:
``` ```
W/AppBootManager: Type[TYPE_SERVICE];Result[false]; W/AppBootManager: Type[TYPE_SERVICE];Result[false];
Target[org.chromium.content.app.SandboxedProcessService0]; 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. 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 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 empty `document.title`, and DevTools `Runtime.evaluate` hangs. The failure is
sticky for the life of the process — reopening from Recents keeps the grey 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. window. Only `am force-stop` plus a fresh start clears it.
This is an OEM policy, not an app bug, and it only bites when launching over adb.
**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 ## Gotchas

View File

@@ -42,6 +42,13 @@
single .ctxbtn below, leaving one chip row with real touch targets. single .ctxbtn below, leaving one chip row with real touch targets.
Phase 2 must carry this - see CLAUDE.md. */ 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{ .ctxbtn{
display: flex; align-items: center; justify-content: space-between; gap: 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
width: 100%; min-height: 44px; padding: 8px 14px; width: 100%; min-height: 44px; padding: 8px 14px;

View File

@@ -128,6 +128,47 @@
.join(""); .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() { function reshapeStrip() {
var strip = document.getElementById("strip"); var strip = document.getElementById("strip");
if (!strip) return; if (!strip) return;
@@ -136,18 +177,19 @@
// Drill renders a .stripbtn and Find an input, so both are left alone. // Drill renders a .stripbtn and Find an input, so both are left alone.
var rows = strip.querySelectorAll(".chips"); var rows = strip.querySelectorAll(".chips");
if (phone() && rows.length >= 2) { if (phone() && rows.length >= 2) {
var btn = document.createElement("button"); layerOptions = snapshotLayers(rows[1]);
btn.className = "ctxbtn";
btn.setAttribute("data-ctxsheet", ""); // Only one layer is ever active, so both rows collapse to pickers. Side by
btn.setAttribute("aria-label", "Mode: " + CTX[st.ctx] + ". Change mode"); // side they cost one 44px row instead of two, which the panel needs: its
var label = document.createElement("b"); // pad rows sit at their minmax(34px,1fr) floor and clip below that.
label.textContent = CTX[st.ctx]; var held = st.held.length ? st.held.map(mName).join(" + ") : "No modifier";
var caret = document.createElement("span"); var bar = document.createElement("div");
caret.setAttribute("aria-hidden", "true"); bar.className = "stripbar";
caret.textContent = "▾"; bar.appendChild(pickerBtn("data-ctxsheet", CTX[st.ctx], "Mode: " + CTX[st.ctx] + ". Change mode"));
btn.appendChild(label); bar.appendChild(pickerBtn("data-layersheet", held, "Layer: " + held + ". Change layer"));
btn.appendChild(caret);
rows[0].replaceWith(btn); rows[0].replaceWith(bar);
rows[1].remove();
} }
strip.querySelectorAll(".chips").forEach(function (r) { strip.querySelectorAll(".chips").forEach(function (r) {
@@ -171,9 +213,15 @@
openSheet("Where you are", ctxSheetHTML()); openSheet("Where you are", ctxSheetHTML());
return; 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 // The reference's own handler runs first (registered earlier) and applies
// data-ctx, so this only has to dismiss the sheet afterwards. // data-ctx / data-layer, so this only has to dismiss the sheet afterwards.
if (t.hasAttribute("data-ctx") && t.closest("#sheet")) closeSheet(); // 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. // The first render already ran before this script loaded.

View File

@@ -1,25 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Device helpers for driving the app over adb. # Device helpers for driving the app over adb.
# #
# TCL's AppBootManagerService refuses to let the app bind the WebView's renderer # TCL's AppBootManagerService sometimes refuses to let the app bind the
# process while the display is dozing: # WebView's renderer process:
# #
# W/AppBootManager: Result[false]; Target[...SandboxedProcessService0]; # 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 # 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 # document.title, and DevTools Runtime.evaluate hangs. It is sticky for the life
# of the process, so reopening from Recents keeps the blank window. # of the process, so reopening from Recents keeps the blank window.
# #
# So: always ensure the screen is awake before launching, and say so plainly # Observed trigger is the window just after `adb install -r` replaces the
# when it is not, rather than leaving a blank window to puzzle over. # 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 set -euo pipefail
ADB="${ADB:-$HOME/Android/Sdk/platform-tools/adb}" ADB="${ADB:-$HOME/Android/Sdk/platform-tools/adb}"
PKG=dev.ncode.sp404layers PKG=dev.ncode.sp404layers
ACT="$PKG/.MainActivity" ACT="$PKG/.MainActivity"
PORT=9333 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() { ensure_awake() {
"$ADB" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true "$ADB" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true
"$ADB" shell input keyevent 82 >/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 if [ "$wake" != "Awake" ] || [ "$lock" = "true" ]; then
echo "" >&2 echo "" >&2
echo " ####################################################################" >&2 echo " #############################################################" >&2
echo " # UNLOCK THE PHONE #" >&2 echo " # UNLOCK THE PHONE #" >&2
echo " # #" >&2
echo " # wakefulness=$wake lockscreen=$lock" >&2 echo " # wakefulness=$wake lockscreen=$lock" >&2
echo " # #" >&2 echo " # Nothing on screen can be verified while it is locked. #" >&2
echo " # While the screen is off, TCL denies the WebView renderer and #" >&2 echo " #############################################################" >&2
echo " # the app shows a blank window with no error anywhere. #" >&2
echo " ####################################################################" >&2
echo "" >&2 echo "" >&2
return 1 return 1
fi fi
@@ -54,18 +58,16 @@ sleep_normal() {
echo "stay-awake: reverted to system default" echo "stay-awake: reverted to system default"
} }
# Force-stop, start, and wait until the page is actually parsed - not merely # Wait until the page is actually parsed, not merely until the activity is up:
# until the activity is up, which happens even when the renderer was denied. # the activity starts fine even when the renderer was denied.
launch() { page_loaded() {
ensure_awake || return 1
"$ADB" shell am force-stop "$PKG"
"$ADB" shell am start -W -n "$ACT" >/dev/null
local pid sock i local pid sock i
# Read the pid AFTER starting: an `adb install -r` kills the old process, and # Read the pid AFTER starting. `adb install -r` kills the old process, so a pid
# a stale pid makes the devtools socket lookup fail while the app is fine. # 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') 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) sock=$("$ADB" shell cat /proc/net/unix | grep -o "webview_devtools_remote_$pid" | head -1)
if [ -n "$sock" ]; then if [ -n "$sock" ]; then
"$ADB" forward "tcp:$PORT" "localabstract:$sock" >/dev/null 2>&1 || true "$ADB" forward "tcp:$PORT" "localabstract:$sock" >/dev/null 2>&1 || true
@@ -75,9 +77,23 @@ launch() {
fi fi
fi fi
done done
return 1
}
echo "ERROR: activity started but the page never loaded." >&2 launch() {
echo "Renderer denials in the log:" >&2 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 "$ADB" logcat -d | grep -E 'AppBootManager.*Result\[false\]|ChildProcessConn: Failed' | tail -4 >&2
return 1 return 1
} }