phase: 04_controller_page_responsive

**Final verification pass** (all three task reports PASS; tasks already in `complete/`). One real defect found and fixed.

**Commands / results.** `bash tests/run_tests.sh` → `RESULT: all suites passed`, **5 104** checks (page **220**). `SANITIZE=1 bash tests/run_tests.sh` → passed. `bash .agents/validate.sh` → `validation OK`; bridge/console/rollback `SUCCESS`; `page bytes: 59262`; `README/AGENTS claims: OK`; `repo hygiene: OK`. Real-browser leg (headless Chromium, served assembled bytes): no horizontal scroll at 360/390/768/1440, ARM first-in-`main` and sticky, log height-capped, all 5 folds openable, all 3 changed page statements execute.

**Fix.** The folded notes' `<summary>` — the only route to five pinned safety sentences — rendered **17 px** on coarse pointers, under the page's own 44 px thumb floor, while every byte-level assertion stayed green. `src/web_page.h`: `button.k,.pill,input,summary{min-height:44px}` + `summary{padding:13px 0}`; `tests/test_page.js` +1 check (219→220); mutation-tested (reverting the CSS fails host *and* browser legs, then restored byte-for-byte).

**Criteria.** 1 ✓ browser-measured (physical phone still unobserved). 2 ✓ 220 checks + prose untouched. 3 ✓ page 59 286→**59 262 B** (−24 vs phase start), README = gate figure. 4 ✓ one `test_page` row at 220, intro 5 104 = measured. 5 ✓ gate green; phases 01–03 assertions unchanged apart from the re-pinned byte constant.

**Docs.** Re-pinned 59 262 (README, `config.h`, `test_conn.cpp`), totals 5 103→5 104, README Tests row 220; re-measured footprint rows (flash 1 257 901 / 1 178 141 / 1 157 593, TLS +6 176/+100 308); fixed stale "48 KB"/"~50 KB" (×4 src comments, ×2 README) and the 51 626-byte page-delivery row; added **bug 14** with root cause and renamed *Thirteen*→*Fourteen bugs* in README + AGENTS.md's two stale references; added the Phase 04 browser+device row to `.agents/HW_VERIFICATION.md`.

**Deviations.** Device leg stays open: live board serves 51 626 B (pre-phase image); no reflash attempted, per the standing no-unattended-reflash rule — no byte-equality claim made. `ruff check tools tests`: 29 findings, identical at HEAD and `--isolated`, `E9,F` clean, no repo lint config or gate step → left untouched. `.agents/validate.sh` is **untracked and has never been committed** (`git log --all --` empty) though README/AGENTS/PLAN cite it 13× — harness needs `git add` it; I did not stage anything.
This commit is contained in:
2026-11-09 11:00:27 -05:00
parent d629a0ff51
commit c43a088ce8
30 changed files with 1600 additions and 214 deletions
+354 -3
View File
@@ -9,8 +9,12 @@
// that matter, which are what the log is allowed to contain, which characters may
// leave the tab, and when a run of pasted text stops.
//
// It is a behaviour test, not a rendering test: no CSS, no layout, no real keyboard.
// tests/run_tests.sh skips it when node is not installed.
// It is not a rendering test -- nothing here lays out a page. But it is the only
// place the served bytes can be seen without a browser, so the CSS a phone is the
// only real judge of (breakpoint, sticky ARM bar, thumb floors) is asserted at the
// byte level, the safety sentences are asserted as text read out of the markup,
// and the script itself is executed. Behaviour plus those pins is what a raw
// string gets. tests/run_tests.sh skips this file when node is not installed.
"use strict";
const fs = require("fs");
const path = require("path");
@@ -183,7 +187,9 @@ if (from < 0 || to < 0) {
const pageScript = served.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox);
vm.runInContext(pageScript + expose, context);
// filename so a throw inside the page names web_page.js, and so a V8 coverage
// run (NODE_V8_COVERAGE=… node tests/test_page.js) can pick the page script out.
vm.runInContext(pageScript + expose, context, { filename: "web_page.js" });
const x = sandbox.__x;
const logEl = x.logEl;
@@ -739,6 +745,342 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
"coming back is one deliberate click away, never zero");
x.setArmed(false, "harness: done");
console.log("arming and the S= state frames");
// Invariants 1 and 2, measured as bytes on the wire: disarmed forwards nothing,
// arming is a REQUEST the board answers (the page never flips its own state),
// and every keystroke restates the WHOLE state -- S=<mods>:<usages> -- so the
// next frame corrects any lost one. The safety section below pins what the page
// says; this pins what it sends, which is the half a restructure of the markup
// could silently have moved.
const sframes = () => sentFrames.filter(f => f.startsWith("S="));
const stateWire = []; // every S= frame emitted this section
const key = (type, code, extra) => {
const before = sentFrames.length;
docListeners.fire(type, Object.assign(
{ code, target: { tagName: "BODY" }, preventDefault() {} }, extra || {}));
for (const f of sentFrames.slice(before)) if (f.startsWith("S=")) stateWire.push(f);
};
sandbox.location.protocol = "https:";
byId("token").value = "hunter2";
x.authed = false; x.armed = false;
FakeWebSocket.instances.length = 0;
x.connect();
await wait(5); // onopen: T + H go out, authed stays false
sentFrames.length = 0;
byId("arm").fire("click");
checkEq(sentFrames.length, 0,
"an ARM click before the board has authenticated this socket asks for nothing");
const live = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
live.onmessage({ data: "OK" });
checkEq(x.authed, true, "the board's OK is what links the page");
sentFrames.length = 0;
byId("arm").fire("click");
checkEq(sentFrames.join(","), "A1",
"the ARM click sends A1 and nothing else -- one frame, one command");
checkEq(x.armed, false,
"and the page does not arm itself on its own say-so -- no optimistic flip, " +
"the status heartbeat confirms or it did not happen");
x.onFrame('S{"armed":1,"usb":1,"typing":0}');
checkEq(x.armed, true, "the board's status frame is what arms the page");
sentFrames.length = 0;
byId("arm").fire("click"); // click again while armed
checkEq(sentFrames.join(","), "A0,R",
"clicking again asks to disarm (A0) and releases every key (R) -- still no local flip");
x.onFrame('S{"armed":0,"usb":1,"typing":0}');
checkEq(x.armed, false, "and the disarm lands when the board says so");
sentFrames.length = 0;
key("keydown", "KeyA");
key("keyup", "KeyA");
checkEq(sentFrames.length, 0,
"while disarmed a whole press forwards nothing -- not even a restatement");
x.onFrame('S{"armed":1,"usb":1,"typing":0}'); // the board's own button arms it
checkEq(x.armed, true, "a board-side arm is mirrored here without any click");
sentFrames.length = 0;
key("keydown", "KeyA");
checkEq(sframes().join(","), "S=00:04",
"a press while armed restates the whole state: no modifiers, one usage (KeyA = 0x04)");
key("keydown", "KeyA", { repeat: true });
checkEq(sframes().length, 1,
"autorepeat is not a state change -- the held state already says the key is down");
key("keydown", "KeyB");
checkEq(sframes().slice(-1)[0], "S=00:04,05",
"the next restatement carries BOTH usages in press order, not an event for B alone");
key("keyup", "KeyA");
checkEq(sframes().slice(-1)[0], "S=00:05",
"a release is a full restatement too: what is still held, nothing about A");
key("keydown", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=02:05",
"a modifier enters the mods byte (Shift = 0x02) and the held usage survives it");
key("keyup", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=00:05", "and leaves it the same way");
key("keyup", "KeyB");
checkEq(sframes().slice(-1)[0], "S=00:",
"the EMPTY restatement travels -- keys-up is a state, not an absence of event");
sentFrames.length = 0;
key("keydown", "KeyA", { target: { tagName: "TEXTAREA" } });
checkEq(sframes().length, 0,
"a keystroke in the page's own text box belongs to the page, not to the target");
key("keydown", "ShiftLeft");
checkEq(sframes().slice(-1)[0], "S=02:", "set-up: Shift went down outside the field");
key("keyup", "ShiftLeft", { target: { tagName: "TEXTAREA" } });
checkEq(sframes().slice(-1)[0], "S=00:",
"a modifier released OVER the page's field is still let go -- releasing Shift " +
"above the text box must not strand it held on the target");
key("keydown", "KeyA"); // hold a key, then panic
sentFrames.length = 0;
key("keydown", "Escape", { ctrlKey: true, altKey: true });
checkEq(sentFrames.join(","), "R,A0",
"Ctrl+Alt+Esc releases every key (R) before disarming (A0), in that order");
checkEq(x.armed, false, "and the page lands disarmed with the keys reset");
check(stateWire.length > 8 &&
stateWire.every(f => /^S=[0-9a-f]{2}:(?:[0-9a-f]{2}(?:,[0-9a-f]{2})*)?$/.test(f)),
"every state frame this section emitted is the wire form the firmware parses " +
"(S=<hex mods>:<hex usages>) -- test_proto owns that parser");
console.log("responsive layout");
// Byte-level layout claims. This suite is the only place the page can be seen
// without a browser, and missing CSS fails silently -- it just renders the
// laptop layout onto a phone. Each check names the mechanism it pins, so a
// green run says the mechanism is in the served bytes, not that somebody felt
// good about a breakpoint.
function mediaBlock(query) {
const at = served.indexOf("@media " + query);
if (at < 0) return null;
const start = served.indexOf("{", at);
let depth = 0;
for (let i = start; i < served.length; i++) {
if (served[i] === "{") depth++;
else if (served[i] === "}" && --depth === 0) return served.slice(start, i + 1);
}
return null; // unbalanced braces: report as absent
}
const viewportMeta =
(served.match(/<meta name="viewport" content="([^"]*)"/) || [])[1] || "";
check(viewportMeta.includes("width=device-width"),
"the viewport sizes to the device instead of a fake desktop canvas");
check(viewportMeta.includes("viewport-fit=cover"),
"the viewport meta carries viewport-fit=cover -- without it env(safe-area-inset-*) " +
"is always 0 and the home indicator/notch sits on top of the page");
check(!/maximum-scale|user-scalable=no/.test(viewportMeta),
"and it does not disable pinch zoom -- a thumb floor must not be paid for with zoom");
const narrow = mediaBlock("(max-width:720px)");
check(narrow !== null, "the one 720 px breakpoint exists");
check(narrow !== null && /\.grid\s*\{[^}]*grid-template-columns\s*:\s*1fr/.test(narrow),
"at the breakpoint the card grid is forced to one column (the base rule keeps " +
"auto-fit/minmax(240px,1fr) for the laptop)");
check(narrow !== null && /main\s*\{[^}]*display\s*:\s*flex[^}]*flex-direction\s*:\s*column/.test(narrow),
"and main becomes a flex column there -- position:sticky on a grid item is pinned " +
"inside its own grid area and never sticks, so this display switch is what makes " +
"the ARM bar sticky for real");
check(narrow !== null &&
/input\[type=password\]\s*,\s*input\[type=text\]\s*,\s*textarea\s*\{[^}]*font-size\s*:\s*16px/.test(narrow),
"inputs and the text box step to 16 px below the breakpoint -- iOS Safari zooms " +
"the page on focusing a smaller field and never zooms back out");
check(narrow !== null && /#log\s*\{[^}]*max-height\s*:/.test(narrow),
"the log caps its own height on a phone instead of pushing the ARM bar off screen");
const coarse = mediaBlock("(pointer:coarse)");
check(coarse !== null, "a coarse-pointer query exists for the thumb floor");
check(coarse !== null &&
/button\.k[^{}]*\.pill[^{}]*input[^{}]*summary\s*\{[^}]*min-height\s*:\s*44px/.test(coarse),
"buttons, pills, inputs AND the folded notes' summary get a 44 px height floor on " +
"touch devices; the fine-pointer page is untouched");
check(coarse !== null && /min-width\s*:\s*44px/.test(coarse),
"and narrow targets (chord buttons, checkboxes) get the matching width floor");
// Why summary is in that list rather than a nice-to-have: task 02 moved five safety
// sentences behind a <details>, which made the summary the only way to read them on a
// phone. Its natural height is 17 px -- a tap target a thumb does not hit, in a build
// whose byte-level layout assertions would otherwise still all have been green. The
// rendered height is measured in a real browser at 360 px (.agents/HW_VERIFICATION.md);
// this pins that the rule the measurement depends on is still in the served bytes.
check(coarse !== null && /summary\s*\{[^}]*min-height\s*:\s*44px/.test(coarse) &&
/summary\s*\{[^}]*padding\s*:/.test(coarse),
"the fold's summary is a thumb-sized target, not the 17 px line of text it is by " +
"default -- it is the only route to the folded safety notes");
check(/\.arm\s*\{[^}]*position\s*:\s*sticky[^}]*top\s*:\s*0[^}]*z-index/.test(served),
"the ARM bar is sticky at the top of the scroll, above the cards -- the " +
"armed/disarmed state must survive any scroll position");
check(/#log\s*\{[^}]*overscroll-behavior\s*:\s*contain/.test(served),
"scrolling the log does not scroll the page out from under the ARM bar");
check(/padding-bottom\s*:\s*calc\(14px \+ env\(safe-area-inset-bottom\)\)/.test(served),
"main's bottom padding carries the safe-area inset, so the last card clears the " +
"home indicator (viewport-fit=cover above is what makes env() mean anything)");
check(/button\.k\s*\{[^}]*touch-action\s*:\s*manipulation/.test(served),
"controls set touch-action:manipulation -- no 300 ms double-tap-to-zoom between presses");
check(/button\.k\s*\{[^}]*user-select\s*:\s*none/.test(served) &&
/\.mousepad\s*\{[^}]*user-select\s*:\s*none/.test(served),
"pressing a control or the pad does not start a text selection");
check(/<main>\s*<button class="arm" id="arm"/.test(served),
"the ARM bar is still the first element in main -- on every screen it is the " +
"first thing below the header, no horizontal scroll assumed");
const narrowKv = narrow !== null && /\.kv\s*\{[^}]*display\s*:\s*flex[^}]*flex-wrap/.test(narrow);
check(narrowKv,
"the State card's counters collapse to a wrapped summary at the breakpoint " +
"instead of eight stacked rows -- they stay visible because they are the " +
"'is it typing' evidence, only shorter");
check(narrow !== null && /\.rowhead\s*\{[^}]*gap\s*:\s*\d+px/.test(narrow),
"and the Log card's option row tightens so the card is two lines on a phone: " +
"the options, then the log");
console.log("the safety statements that must never be tidied away");
// Every long hint on this page exists because somebody got hurt without it, and a
// page that reads as a wall of text invites exactly the edit that removes one. So
// each statement is pinned by the words the operator has to read, BEFORE the prose
// is folded -- the same assertions have to pass on the untrimmed page (they prove
// the sentences were there) and on the trimmed one (they prove the fold kept them).
//
// They match text, not markup: tags stripped, entities decoded, whitespace
// collapsed, case folded. That is what lets a sentence survive a rewrap and
// survive moving inside a <details>, while still failing the moment it is deleted.
// The matcher reads the served markup string rather than the shim's element tree --
// <details> needs no shim, and a safety note one tap away is still on the page as
// long as it is in the bytes the board serves. Anything matched here from the
// script alone would also match a comment, so the script's safety statements are
// pinned against `pageScript` explicitly instead.
const decode = s => s
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
.replace(/&mdash;/g, "\u2014").replace(/&ndash;/g, "\u2013")
.replace(/&rsquo;|&lsquo;/g, "'").replace(/&rdquo;|&ldquo;/g, '"')
.replace(/&middot;/g, "\u00b7").replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"')
.replace(/&amp;/g, "&");
const pageTextOf = html => decode(html
.replace(/<script[\s\S]*?<\/script>/gi, " ") // pinned against pageScript instead
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim().toLowerCase();
const PAGE = pageTextOf(served);
const says = (...phrases) => phrases.every(p => PAGE.includes(p));
// (1) the arming banner -- the one paragraph that must never sit behind a tap.
const bannerHtml =
(served.match(/<div class="banner" id="banner">([\s\S]*?)<\/div>/) || [])[1] || "";
const bannerText = pageTextOf(bannerHtml);
check(bannerText.includes("every key"),
"the arming banner says EVERY key this tab presses goes to the target");
check(/\benter\b/.test(bannerText),
"and it names Enter by name -- the key that commits, the one nobody expects to travel");
check(bannerText.includes("the machine the board is plugged into"),
"and it says which machine receives them (not the one you are typing on)");
check(/only on a machine you own|explicitly allowed to type on/.test(bannerText),
"and it keeps the permission rule: a machine you own or may type on");
const mainAt = served.indexOf("<main");
const firstDetails = served.indexOf("<details", mainAt);
const bannerAt = served.indexOf('id="banner"', mainAt);
check(bannerAt > 0 && (firstDetails < 0 || bannerAt < firstDetails),
"the arming banner is not inside or after a <details> -- no safety warning about " +
"arming is ever one tap away from being unseen (holds with no folds at all)");
// (2) disarmed is the default, and what puts it back.
check(says("nothing is forwarded while disarmed"),
"the page still states the default: disarmed, nothing forwarded");
check(says("reload", "focus loss", "socket loss"),
"and still names what disarms you: reload, focus loss, socket loss");
// (3) the pointer: Esc is the rollback, and it releases.
check(says("esc frees the cursor and lets go of every button"),
"the mouse note still says Esc frees the cursor AND lets go of every button");
check(says("never reaches the target"),
"and that Esc belongs to the browser, so the pointer's rollback cannot be typed at the target");
check(says("cannot leave one held down"),
"and that restating the button byte every frame is what stops a lost frame sticking one");
// (4) a run of pasted text is cancelable, out loud.
check(says("abandons the rest of a run"),
"the send-text note still says stop typing / Ctrl+Alt+Esc abandons the rest of a run");
// (5) characters are positions.
check(says("characters are positions", "us layout"),
"the layout caveat is still on the page: positions, so the target must be on a US layout");
// (6) nothing stored, scheduled or replayed.
check(says("no history", "nothing is saved"),
"the send-text note still says nothing is stored, kept or typed later");
check(says("nothing scheduled"),
"and the mouse note still says nothing is scheduled (no macro, no saved gesture)");
// (7) the default-passphrase shout is the board's to raise, never static text: a
// sentence in the markup would shout on a board that has a real passphrase and go
// quiet on one that does not. It replaces the arming banner's own text, so it is
// unmissable and it cannot be folded away with the hints.
check(/if \(s\.dflt\)[\s\S]{0,400}default passphrase/i.test(pageScript),
"the default-passphrase shout exists and is driven by the board's dflt flag");
check(/if \(s\.dflt\)[\s\S]{0,160}\$\("banner"\)\.innerHTML/.test(pageScript),
"and it lands on the arming banner, the one place that is never behind a tap");
check(!PAGE.includes("default passphrase"),
"and no static default-passphrase sentence hides in the markup -- the board raises it or nobody does");
// (8) a takeover cannot arm (phase 03). Guarded here so a later prose fold on the
// Connection card cannot drop the sentence that says so.
check(says("cannot arm it by doing so", "every handover lands disarmed"),
"the Connection card still says taking the bridge cannot arm it -- every handover lands disarmed");
// (9) the clear-loaded-page warning (phase 02) -- hidden until true, but present.
check(says("this page arrived in the clear"),
"the clear-page warning is still in the served bytes");
check(says("reload over"), "and it names the fix rather than just the fear");
// (10) Ctrl+Alt+Del is the one chord that can reboot a target, and its warning is
// said at press time (the tooltip is a tooltip, not a manual, so this is where the
// sentence has to live).
check(/virtual terminal this REBOOTS the target/.test(pageScript),
"the Ctrl+Alt+Del reboot warning is still said when the confirm is armed");
console.log("the folded notes are folded, not deleted");
// Task 02 moved five paragraphs behind <details>. A fold is only safe while
// there IS something behind it, so each one is checked structurally: the count
// is pinned (deleting a whole note is a content decision, not a tidy-up, and it
// must fail a suite), every fold names what is behind the tap, and every fold
// still carries sentences. The safety checks above prove WHICH sentences survive;
// these prove the containers did not hollow out.
const folds = [...served.matchAll(/<details\b[^>]*>([\s\S]*?)<\/details>/gi)]
.map(m => m[0]);
checkEq(folds.length, 5,
"the page carries exactly the five folded notes (chords, passphrase, mouse, " +
"run, shortcuts) -- one fewer is a deleted note, not a tidied one");
const summaries = folds.map(f =>
(f.match(/<summary>([\s\S]*?)<\/summary>/i) || [])[1] || "");
check(summaries.every(t => t.trim().length > 0),
"every fold opens with a summary that names what is behind the tap -- a bare " +
"<details> renders as 'Details', content one guess away");
for (const want of ["why these are buttons", "what the passphrase does and does not buy",
"how a forwarded mouse behaves", "what a run is and is not",
"shortcuts while armed"]) {
check(summaries.join(" | ").toLowerCase().includes(want),
`the folded note "${want}" is still one of the page's five`);
}
folds.forEach((f, i) => {
const body = pageTextOf(f.replace(/<summary>[\s\S]*?<\/summary>/i, " "));
check(body.length >= 120,
`fold ${i + 1} (${summaries[i] || "?"}) still carries its sentences `
+ `(${body.length} chars) -- a folded note is not a deleted one`);
});
console.log("the page's constants are the board's constants");
// Hand-copied numbers, so they are checked against config.h here too: three readers
// of the same arithmetic (here, test_proto.cpp, test_relayctl.py), none of them
// trusting the page to have kept its own copy honest.
const configText = fs.readFileSync(path.join(__dirname, "..", "src", "config.h"), "utf8");
const configNum = name =>
Number((configText.match(new RegExp("\\b" + name + "\\s*=\\s*(\\d+)")) || [])[1]);
checkEq(x.TEXT_FRAME_MAX, configNum("kTextFrameMax"),
"TEXT_FRAME_MAX is still config.h's kTextFrameMax -- drift here is an E8 mid-paste");
checkEq(x.TEXT_JOB_MAX, configNum("kTextJobCap"),
"TEXT_JOB_MAX is still config.h's kTextJobCap");
console.log("element ids");
const markup = served;
const present = new Set();
@@ -747,6 +1089,15 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "",
"every element the script reached for exists in the page");
// Prove this check bites. The shim hands out an element for ANY id, so a
// mistyped id is invisible everywhere except right here -- which means a broken
// matcher would also read as green. Hand it an id that cannot be in the markup
// and require the same comparison to name exactly that one.
byId("zzCoverSelfTest__notAnElement");
const control = [...requestedIds].filter(id => !present.has(id));
checkEq(control.join(","), "zzCoverSelfTest__notAnElement",
"and the id-coverage comparison itself works: an id missing from the markup " +
"is named by it (the shim would never have noticed)");
console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);