diff --git a/CLAUDE.md b/CLAUDE.md index 01e59be..d5cb9c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,36 @@ JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 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. + +`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 to actually parse | +| `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. + +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`: @@ -101,6 +131,31 @@ arrive before the page has run any JS. 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. +## 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. **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. +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/`: diff --git a/native/native.css b/native/native.css index f16f67c..c1db018 100644 --- a/native/native.css +++ b/native/native.css @@ -33,4 +33,47 @@ padding-left: var(--ins-l); padding-right: var(--ins-r); } + + /* ---------- strip ergonomics (Phase 1 UI fix) ---------- + The reference spaces its two chip rows with `#strip .chips + .chips`, but + on phones the .lbl heading between them is display:none and still in the + DOM, so the adjacent-sibling selector never matches and the rows sit flush + (measured: 0px gap). native.js collapses the 7 context chips into the + single .ctxbtn below, leaving one chip row with real touch targets. + Phase 2 must carry this - see CLAUDE.md. */ + + .ctxbtn{ + display: flex; align-items: center; justify-content: space-between; gap: 10px; + width: 100%; min-height: 44px; padding: 8px 14px; + font-family: var(--fc); font-size: 1.05rem; font-weight: 600; + background: var(--card); color: var(--ink); + border: 1px solid var(--line); border-radius: 10px; + text-align: left; cursor: pointer; + } + .ctxbtn b{ font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ctxbtn > span{ color: var(--muted); flex: 0 0 auto; } + + /* Real 44px targets. These scroll horizontally, so an undersized target + turns a mis-hit into a scroll rather than a tap. */ + #strip .chips{ margin-top: 8px; } + #strip .chips .chip{ min-height: 44px; padding: 8px 14px; } + + /* Scroll affordance. native.js sets data-more only on rows that actually + overflow, so the fade never appears where there is nothing to reach. */ + #strip .chips[data-more]{ + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); + mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); + } } + +/* Context picker rows inside the sheet. Uses CTXHINT from the reference, which + the phone layout otherwise never shows. */ +.ctxopt{ + display: block; width: 100%; text-align: left; color: inherit; + background: var(--card); border: 1px solid var(--line); border-radius: 10px; + padding: 10px 14px; margin-bottom: 8px; min-height: 44px; cursor: pointer; +} +.ctxopt[aria-pressed="true"]{ background: var(--ink); color: var(--page); border-color: var(--ink); } +.ctxopt b{ display: block; font-family: var(--fc); font-size: 1.1rem; line-height: 1.2; } +.ctxopt .hint{ font-size: .85rem; color: var(--muted); } +.ctxopt[aria-pressed="true"] .hint{ color: var(--page); opacity: .75; } diff --git a/native/native.js b/native/native.js index ad930ad..49d52dc 100644 --- a/native/native.js +++ b/native/native.js @@ -110,4 +110,72 @@ App.exitApp(); }); } + + /* ---------- strip ergonomics (Phase 1 UI fix) ---------- + Collapses the 7 context chips into one 44px control that opens the existing + sheet, which frees a row and fixes the flush-rows bug described in + native.css. renderExplore() rewrites #strip wholesale on every render, so + this re-applies afterwards rather than running once. */ + + function phone() { return matchMedia("(max-width:820px)").matches; } + + function ctxSheetHTML() { + return Object.keys(CTX) + .map(function (c) { + return '"; + }) + .join(""); + } + + function reshapeStrip() { + var strip = document.getElementById("strip"); + if (!strip) return; + + // Two .chips rows means the Explore strip (context row first, layers second). + // 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); + } + + strip.querySelectorAll(".chips").forEach(function (r) { + if (r.scrollWidth > r.clientWidth + 1) r.setAttribute("data-more", ""); + else r.removeAttribute("data-more"); + }); + } + + var origRenderExplore = window.renderExplore; + if (typeof origRenderExplore === "function") { + window.renderExplore = function () { + origRenderExplore.apply(this, arguments); + reshapeStrip(); + }; + } + + document.addEventListener("click", function (ev) { + var t = ev.target.closest && ev.target.closest("button"); + if (!t) return; + if (t.hasAttribute("data-ctxsheet")) { + openSheet("Where you are", ctxSheetHTML()); + 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(); + }); + + // The first render already ran before this script loaded. + reshapeStrip(); })(); diff --git a/package.json b/package.json index af8cbf6..0809a17 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "sync": "npm run sync:www && npx cap sync android", "apk": "npm run sync && cd android && ./gradlew assembleDebug", "install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk", - "run:android": "npm run apk && npm run install:apk", + "device:awake": "bash scripts/device.sh awake", + "device:keep-awake": "bash scripts/device.sh keep-awake", + "device:sleep-normal": "bash scripts/device.sh sleep-normal", + "device:launch": "bash scripts/device.sh launch", + "run:android": "npm run apk && npm run install:apk && npm run device:launch", "logcat": "adb logcat -s Capacitor:V Capacitor/Console:V chromium:V" }, "dependencies": { diff --git a/scripts/device.sh b/scripts/device.sh new file mode 100755 index 0000000..310efa4 --- /dev/null +++ b/scripts/device.sh @@ -0,0 +1,91 @@ +#!/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: +# +# W/AppBootManager: Result[false]; Target[...SandboxedProcessService0]; +# Caller[dev.ncode.sp404layers]; Reason[screen_off & caller care] +# +# 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. +set -euo pipefail + +ADB="${ADB:-$HOME/Android/Sdk/platform-tools/adb}" +PKG=dev.ncode.sp404layers +ACT="$PKG/.MainActivity" +PORT=9333 + +ensure_awake() { + "$ADB" shell input keyevent KEYCODE_WAKEUP >/dev/null 2>&1 || true + "$ADB" shell input keyevent 82 >/dev/null 2>&1 || true + + local wake lock + wake=$("$ADB" shell dumpsys power | grep -m1 -o 'mWakefulness=[A-Za-z]*' | cut -d= -f2 | tr -d '\r') + lock=$("$ADB" shell dumpsys window | grep -m1 -o 'mDreamingLockscreen=[a-z]*' | cut -d= -f2 | tr -d '\r') + + if [ "$wake" != "Awake" ] || [ "$lock" = "true" ]; then + echo "" >&2 + echo " ####################################################################" >&2 + echo " # UNLOCK THE PHONE #" >&2 + echo " # #" >&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 "" >&2 + return 1 + fi + echo "device awake and unlocked" +} + +keep_awake() { + "$ADB" shell svc power stayon usb >/dev/null + echo "stay-awake while charging: ON (revert with: scripts/device.sh sleep-normal)" +} + +sleep_normal() { + "$ADB" shell svc power stayon false >/dev/null + 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 + + 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. + pid=$("$ADB" shell pidof "$PKG" | tr -d '\r') + for i in $(seq 1 60); 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 + if curl -s --max-time 1 "http://127.0.0.1:$PORT/json" | grep -q 'SP-404'; then + echo "app loaded (pid $pid, $i polls)" + return 0 + fi + fi + done + + echo "ERROR: activity started but the page never loaded." >&2 + echo "Renderer denials in the log:" >&2 + "$ADB" logcat -d | grep -E 'AppBootManager.*Result\[false\]|ChildProcessConn: Failed' | tail -4 >&2 + return 1 +} + +case "${1:-}" in + awake) ensure_awake ;; + keep-awake) keep_awake ;; + sleep-normal) sleep_normal ;; + launch) launch ;; + *) echo "usage: $0 {awake|keep-awake|sleep-normal|launch}" >&2; exit 2 ;; +esac