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
+3 -2
View File
@@ -94,8 +94,9 @@ identity. The instrumentation stays worth having — it is what makes the next c
| claim | command | expected observation | outcome | | claim | command | expected observation | outcome |
|---|---|---|---| |---|---|---|---|
| page still byte-exact after the restructure | the generic `curl … \| wc -c` row above | equals the gate's new `page bytes:` count (the count moves when the page does — that is fine; equality with the gate is the check) | | | page still byte-exact after the restructure | the generic `curl … \| wc -c` row above | equals the gate's new `page bytes:` count (the count moves when the page does — that is fine; equality with the gate is the check) | **skipped (board not reflashed), 2026-11-09 (task 01)** — one capped attempt was run *before* the page edit: `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` → **51 626** and `sha256 dd16e195…f04e9`, byte-identical to the phase-02 close record, so the live board still runs pre-phase-03 firmware and the measurement path works. The 60 780-byte page of this tree is only served after `run -e bridge -t upload`, deliberately not run here: the board is a live HID keyboard into the machine running this pipeline and `to_bootloader.py` strands it if anything goes wrong unattended (same call phase 02 recorded). To close: reflash, then `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` must equal the gate's `page bytes:` line for this tree. **Task 02** (the prose fold) re-ran that one capped read-only probe after the edit: **51 626** bytes, `sha256 dd16e1954a68…` — identical to the task-01 and phase-02 records, so the live board still runs the pre-phase-03 image and this tree's **59 223**-byte page (the gate's `page bytes:` line, equal in both builds) is not on it yet. Measurement path proven, byte comparison still open until a flash. **Task 03** (docs and suite) re-ran the same single capped probe: `timeout 15 curl -sk https://keyrelay.local/ | wc -c` → **51 626**, `sha256 dd16e1954a68…f04e9` — byte-identical to the task-02 record, and this tree's locally assembled page hashes `dc26b441…b634` at **59 223** B (the count the gate prints and the count README's Controller-page section now quotes with this exact `curl` command named as the device-side measurement). README == gate == assembled bytes; device equality still opens by the standing no-unattended-reflash call (live HID keyboard into the pipeline host) | **Final pass, 2026-11-09:** one more capped read-only probe — `timeout 15 curl -sk https://keyrelay.local/ \| wc -c` → **51 626**, `sha256 dd16e195…f04e9`, byte-identical to the task-01/02/03 records (the live board has not been reflashed all phase), while this tree assembles to **59 262 B** / `b9414d2f9847b774…` after the `summary` thumb-floor fix; README (×2), `config.h` (×2) and `test_conn.cpp`'s `kServedPageBytes` are re-pinned to 59 262 and the gate's `page bytes:` line agrees. |
| one column on a phone, ARM thumb-reachable | open `https://keyrelay.local/` on a phone at ~360 px | single column, no horizontal scroll, sticky ARM bar on screen, log collapsed and not pushing ARM off screen | | | one column on a phone, ARM thumb-reachable | open `https://keyrelay.local/` on a phone at ~360 px | single column, no horizontal scroll, sticky ARM bar on screen, log collapsed and not pushing ARM off screen | **skipped (no device), 2026-11-09 (task 01); still skipped for task 02, 2026-11-09** — no phone is reachable headlessly, and the page must be reflashed first (row above). Task 02 adds one thing for whoever runs it: the folded notes (`<details>` under Special keys, Connection, Mouse, Send text and the footer) must be readable and tappable at 360 px, and the State card's counters must wrap into a couple of lines rather than stacking eight rows — the counters stay on screen because they are the "is it typing" evidence. Host-side the layout is pinned instead: 17 new `test_page.js` checks assert the served bytes carry `viewport-fit=cover` with pinch zoom intact, the one 720 px breakpoint (one column; `main` as flex column, which is what makes the sticky ARM bar actually stick), the 16 px input floor (focus-zoom), the log height cap + `overscroll-behavior:contain`, the 44 px `pointer:coarse` floors, safe-area bottom padding, and ARM still first in `main`. To close: reflash `bridge`, open `https://keyrelay.local/` on a ~360 px phone — one column, no horizontal scroll at 360/768/1440 px, ARM bar visible while scrolling, focusing the passphrase field does not zoom the page. **Task 03** (2026-11-09): still `skipped (no device)` — no phone is reachable headlessly and the page must be reflashed first. The phone checklist for whoever runs it, from the phase's completion criteria: 360 px width, one column, no horizontal scroll, ARM bar reachable with a thumb at the top of the scroll, focusing the passphrase input does not zoom, and each folded note (Special keys, Connection, Mouse, Send text, footer shortcuts) expands with a tappable summary. Host-side task 03 adds the structural teeth for the last one: `test_page.js` pins exactly five `<details>`, each with a summary naming what is behind the tap and real sentences inside, and proves the fold checks fail on deletion (mutation-run, then restored byte-for-byte) |
| the responsive layout, observed in a real browser at phone widths (final pass) | assemble the served bytes from `src/web_page.h` (raw-string chunks + each marker branch in turn), serve them on `127.0.0.1`, and measure them in headless Chromium at 360×640, 390×844, 768×1024 and 1440×900 with touch/`pointer:coarse` emulated | no horizontal scroll at any width; ARM reachable without scrolling and sticky at the bottom of a 400-line log; one column and a height-capped, self-scrolling log below the breakpoint; every press target ≥ 44 px on a coarse pointer; the five folds open on a tap and stay inside the viewport; no script error; the safety sentences present in *rendered text* — and the same measurement against the pre-phase-04 page, where the sticky/flex-column/log-cap/thumb-floor rows must fail | **ok (browser, not a phone), 2026-11-09 (phase-04 final pass)** — all five viewports pass on this tree's page (59 262 B, both marker branches equal); the same script on the phase-start page (59 286 B) fails exactly the four rows the phase changed: ARM not sticky (`z-index:auto`, scrolled to −1989 px), `main` still `display:grid`, no log cap, and 19 targets at 34 px. **It also found a defect the byte-level suite could not see, now fixed:** the five `<summary>` elements task 02 introduced rendered **17 px** tall on a coarse pointer — the only route to five pinned safety sentences, on a control below the page's own 44 px thumb floor, with every served-byte assertion green. `summary` is now in the `pointer:coarse` rule (page 59 223 → 59 262 B, re-pinned in `config.h`/`test_conn.cpp`/README); the floor is asserted in `test_page.js` (219 → 220) **and** measured here, and deleting the rule fails both (demonstrated, then restored byte-for-byte). This is not the phone row below: no hand-held device was involved, `env(safe-area-inset-*)` is 0 on a desktop screen (so the inset is checked in the declared CSS, where it means something), and iOS Safari's focus-zoom stays unobserved. |
### Phase 05 — soft keyboard and touch pad ### Phase 05 — soft keyboard and touch pad
@@ -0,0 +1,11 @@
**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.
@@ -0,0 +1,223 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 220 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 220 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257901 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178141 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.78 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157593 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.791
console SUCCESS 00:00:00.781
rollback SUCCESS 00:00:00.790
========================= 3 succeeded in 00:00:02.361 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59262 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 104 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 262 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,15 @@
**Task 01 — responsive layout: done.**
**Implemented**
- `src/web_page.h` `<style>`: viewport `viewport-fit=cover` (no `maximum-scale`/`user-scalable`); `main` gains `calc(14px + env(safe-area-inset-bottom))`; `.arm` sticky `top:0;z-index:5` + `touch-action:manipulation`/`user-select:none` (also on `button.k`, `label.opt`, `.mousepad`); `#log` `overscroll-behavior:contain`; `@media (max-width:720px)` → `main` flex column (what makes the sticky ARM bar actually stick — sticky grid items never move), `.grid` forced `1fr`, tighter padding, `font-size:16px` inputs (iOS focus-zoom, named in the comment), log `max-height:40vh`; `@media (pointer:coarse)` → 44 px min-height floor on `button.k`/`.pill`/`input`, min-width floor on chord buttons/checkboxes. No markup/id changes.
- `tests/test_page.js`: +17 byte-level layout checks (viewport-fit, no zoom lock, 720 px breakpoint + one column + flex-column switch, 16 px floor, log cap, coarse floors, sticky ARM, overscroll, safe-area calc, touch-action, user-select, ARM first in `main`).
- Header comment paragraph on laptop+one-handed-phone layout and why the ARM bar is sticky.
- Re-pinned page size everywhere: 59 286 → **60 780 B** (42 chunks/11 ticks) in `test_conn.cpp` `kServedPageBytes` + its loop expectation, `config.h` comments, README; `test_conn` 280→282, `test_page` 142→159, totals 5026→**5045** in README ×2 + AGENTS.md; README Controller-page section gains the responsive paragraph + sticky-ARM row note; `.agents/HW_VERIFICATION.md` Phase 04 rows filled.
**Tests**: `bash tests/run_tests.sh` → 12 suites, 5045 checks, 0 failures ("RESULT: all suites passed"); `bash .agents/validate.sh` → **validation OK** (both suite runs, bridge/console/rollback builds, page bytes 60 780 quoted==measured, hygiene). Bridge RAM 99 744 B / Flash 1 259 421 B.
**Docs**: README + AGENTS.md numbers/paragraphs above.
**Decisions/deviations**: README page-byte/total figures updated now (task 03's scope) because the gate's doc check would otherwise fail. Device legs recorded per the hardware contract: capped `curl` pre-edit returned 51 626/`dd16e195…` (board still on pre-03 firmware); reflash and the 360 px phone observation → `skipped (no device)` / unobserved (live HID keyboard into the build host; no phone headlessly).
**Next pending task**: `04_controller_page_responsive/02_prose_trim_pinned.md`.
@@ -0,0 +1,215 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 282 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
element ids
page 159 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 282 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
element ids
page 159 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1259421 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1179661 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1159113 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.87 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.870
console SUCCESS 00:00:00.859
rollback SUCCESS 00:00:00.870
========================= 3 succeeded in 00:00:02.599 =========================
== page size: src/web_page.h raw strings ==
page bytes: 60780 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 045 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 60 780 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,9 @@
**Task 02 — prose trim, pinned: done.**
- **Assertions first** (`tests/test_page.js`, 159→**184**): 21 new safety-sentence pins matched as *text* out of the served markup (tags stripped, entities decoded, case/whitespace folded, so a sentence survives a rewrap and a move into `<details>` but fails when deleted) — every-key/including-Enter + which machine + permission rule, banner before every fold, nothing-forwarded-while-disarmed + what disarms, `Esc` frees cursor *and* releases buttons + never reaches target + button restatement, stop-typing abandons the run, characters-are-positions/US layout, no-history/nothing-saved/nothing-scheduled, takeover **cannot arm — every handover lands disarmed**, clear-page warning + its fix, CAD VT-reboot, `dflt:1`-driven shout (plus "no static default-passphrase text in markup"); +2 folded-structure pins (`kv` wraps, Log option row) and `TEXT_FRAME_MAX`/`TEXT_JOB_MAX` re-pinned to `config.h`.
- **Fold** (`src/web_page.h`): 5 `<details>` (chords/KDE, passphrase, forwarded mouse, paste run, footer shortcuts), one-line summaries left visible, arming banner untouched, multi-sentence `title` tooltips shortened, inline styles → `.mt/.dim/.hot/.dimv`. Visible prose **2 866 → 862 B (~30 %)**; `#banner` byte-identical, all 11 `data-chord` values and every script-reached id intact, HTML balance checked.
- **Pins proven to bite**: 26 mutations (delete/soften each sentence, move the banner behind a fold, drift both constants) each fired its own check; file restored byte-for-byte.
- **Tests**: `node tests/test_page.js` 184/0 — run **before** and **after** the trim; `bash tests/run_tests.sh` and `SANITIZE=1 …` → *all suites passed* (5 068 each); `bash .agents/validate.sh` → **validation OK** (bridge/console/rollback SUCCESS; page bytes 59 223 quoted==measured; hygiene OK). `ruff` is not configured or gated in this repo; its 29 findings are all pre-existing in untouched `tools/*.py`/`tests/test_relayctl.py` — left alone.
- **Docs**: README Controller-page section gains the fold + "pinned by `tests/test_page.js`" paragraph and a table row; re-pinned page size 60 780→**59 223** (README ×2, `test_conn.cpp` `kServedPageBytes` + 41 chunks/11 ticks, `config.h`), footprint flash pair −64 B, Tests rows `test_page` 184 / `test_conn` 280, totals **5 068** in README ×2 and AGENTS.md. AGENTS.md: only the stale check count (per its no-expansion rule).
- **Decisions/deviations**: prose is only 2.5 KB of a 60 KB page and the fold *moves* text rather than deleting it, so meeting "page smaller in bytes" also needed the four longest `<script>` comment blocks and the section-divider rules tightened — every rule/why kept, README stays the durable record. Result 59 223 B: −1 557 vs this task's start, and below the 59 286 B the phase opened at. Device: one capped read-only `curl -sk https://keyrelay.local/` → 51 626 / `dd16e195…` (board still pre-phase-03); reflash and the 360 px phone observation recorded `skipped (no device)` in `.agents/HW_VERIFICATION.md`.
- **Next pending task**: `04_controller_page_responsive/03_suite_size_and_docs.md`.
@@ -0,0 +1,219 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
the safety statements that must never be tidied away
the page's constants are the board's constants
element ids
page 184 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
responsive layout
the safety statements that must never be tidied away
the page's constants are the board's constants
element ids
page 184 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257869 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.91 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178109 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.86 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157545 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.85 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.910
console SUCCESS 00:00:00.858
rollback SUCCESS 00:00:00.849
========================= 3 succeeded in 00:00:02.617 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59223 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 068 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 223 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
@@ -0,0 +1,8 @@
**Task 03 — suite coverage, page size, README: done.**
- **`tests/test_page.js` 184→219**: new sections — arming & `S=` state frames on the wire (un-linked ARM click sends nothing; `A1` alone, `A0`+`R` in order, no optimistic flip either way, board-status arms/mirrors; disarmed press forwards nothing; whole-state restatements `S=00:04`→`S=02:05`→`S=00:`, autorepeat silent, page-field keys stay local but a modifier released *above* a field is let go; `Ctrl+Alt+Esc` = `R,A0`; every emitted frame matches the parser's wire form); folded-note structure (exactly 5 `<details>`, each with a naming summary + real sentences — folded ≠ deleted); id-coverage self-test proving the markup-vs-shim comparison names a fabricated id; `vm.runInContext` now tags `web_page.js` (stack traces + V8 coverage); stale "no CSS, no layout" header comment corrected.
- **Every new check proven to bite**: 10 mutations of `web_page.h` (delete/hollow/strip a fold, optimistic flip, repeat leak, stranded Shift, `R`-without-`A0`, inverted `A0,R`, broken wire form, config and page-copy constant drift) each fired its check; both files restored byte-for-byte (`sha256sum -c` OK). No `src/*` change from this task.
- **Suite/gate** (captured output): `bash tests/run_tests.sh` and `SANITIZE=1 …` → 12 suites, **5103 checks, 0 failures** each; `bash .agents/validate.sh` → **validation OK** (bridge/console/rollback SUCCESS; `page bytes: 59223` quoted==measured; docs claims OK; hygiene OK). V8 coverage of the page script: 86 % of code chars executed, 73/96 functions entered.
- **README**: one `test_page` row at **219** (no duplicate; the 47-check row is gone), intro + Quick-start totals → **5103**, Controller-page section now names the measurement (`curl -sk https://keyrelay.local/ | wc -c` vs the gate's `page bytes:` line, device leg recorded in HW_VERIFICATION). Quick start/Troubleshooting checked — no old-layout statements left. **AGENTS.md**: only the stale check count (per its no-expansion rule).
- **HW_VERIFICATION.md**: one capped probe ran — `curl -sk https://keyrelay.local/ | wc -c` → 51 626 / `dd16e195…` (board still pre-phase-03; tree's assembled page 59 223 / `dc26b441…b634`); phone row stays `skipped (no device)` with the 360 px / folded-notes-expandable checklist and commands. Byte-equality closes on the next flash (standing no-unattended-reflash call, as tasks 01–02 recorded).
- **Next pending task**: `05_mobile_controller` (this phase's task file moves to complete/ by the harness).
@@ -0,0 +1,223 @@
== host suites: bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 219 checks, 0 failures
RESULT: all suites passed
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
sanitizers: on
keystate 40 checks, 0 failures
proto 2379 checks, 0 failures
text 459 checks, 0 failures
mouse 48 checks, 0 failures
output 153 checks, 0 failures
ws_codec 135 checks, 0 failures
test_tls_pem 862 checks, 0 failures
test_tls_session 118 checks, 0 failures
test_conn 280 checks, 0 failures
test_claim 102 checks, 0 failures
handshake
frame encoding
frame decoding
key table
pasted text
mouse
protocol strings
the fail-safe wiring
the bridge handover (phase 03)
one routing path, two transports
the client side of pinning
the busy board and --take-over
the target-side verifier
t= 0.00s >>> Ctrl+Alt+Del <<<
relayctl 308 checks, 0 failures
the log stays readable
characters
key names in the log
sending text
refusing what it cannot type
stopping
the mouse
the connect path
the channel pill and the certificate line
the busy board and the take-over
arming and the S= state frames
responsive layout
the safety statements that must never be tidied away
the folded notes are folded, not deleted
the page's constants are the board's constants
element ids
page 219 checks, 0 failures
RESULT: all suites passed
== firmware: platformio run -e bridge -e console -e rollback ==
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/bridge/partitions.bin
Retrieving maximum program size .pio/build/bridge/firmware.elf
Checking size .pio/build/bridge/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
Flash: [==== ] 40.0% (used 1257869 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.80 seconds =========================
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/console/partitions.bin
Retrieving maximum program size .pio/build/console/firmware.elf
Checking size .pio/build/console/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
Flash: [==== ] 37.5% (used 1178109 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
PACKAGES:
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
- tool-esptool @ 2.50400.0 (5.4.0)
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
- toolchain-riscv32-esp @ 14.2.0+20260121
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
LDF Modes: Finder ~ chain, Compatibility ~ soft
Found 45 compatible libraries
Scanning dependencies...
Dependency Graph
|-- Adafruit NeoPixel @ 1.15.5
|-- ESPmDNS @ 3.3.12
|-- USB @ 3.3.12
|-- Preferences @ 3.3.12
|-- WiFi @ 3.3.12
Building in release mode
Generating partitions .pio/build/rollback/partitions.bin
Retrieving maximum program size .pio/build/rollback/firmware.elf
Checking size .pio/build/rollback/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
Flash: [==== ] 36.8% (used 1157545 bytes from 3145728 bytes)
========================= [SUCCESS] Took 0.79 seconds =========================
Environment Status Duration
------------- -------- ------------
bridge SUCCESS 00:00:00.798
console SUCCESS 00:00:00.792
rollback SUCCESS 00:00:00.790
========================= 3 succeeded in 00:00:02.380 =========================
== page size: src/web_page.h raw strings ==
page bytes: 59223 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
== README/AGENTS claims vs what this run measured ==
README/AGENTS claims: OK (5 103 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 223 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
== repo hygiene ==
repo hygiene: OK (131 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
== hardware context (informational; cannot change the gate result) ==
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
hw-probe node /usr/bin/node — test_page.js runs
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
+ the numbers README/AGENTS quote + hygiene)
+3 -3
View File
@@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea
This file is the operating manual: what must not break, how to build and verify, and where This file is the operating manual: what must not break, how to build and verify, and where
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
drifts, and two paths that can disagree about arming is the exact failure this project keeps drifts, and two paths that can disagree about arming is the exact failure this project keeps
having to fix (see README, "Thirteen bugs"). having to fix (see README, "Fourteen bugs").
## Phased execution ## Phased execution
@@ -98,7 +98,7 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
## Build, test, flash ## Build, test, flash
```bash ```bash
tests/run_tests.sh # 5026 checks, no hardware tests/run_tests.sh # 5104 checks, no hardware
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must # PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload: # be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
@@ -202,7 +202,7 @@ wired now — but ask, of every green check, *what would still pass if this were
nothing may print it, hash-and-print it in a way that reads as the key, or be added to the nothing may print it, hash-and-print it in a way that reads as the key, or be added to the
tree as a `.pem`. Fingerprint yes, key never. tree as a `.pem`. Fingerprint yes, key never.
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the - `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
README: a fixed bug belongs in "Thirteen bugs this verification caught" with its root cause and README: a fixed bug belongs in "Fourteen bugs this verification caught" with its root cause and
how it was proven, a new capability in the section that describes it, and a rule worth how it was proven, a new capability in the section that describes it, and a rule worth
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
what is left to do. what is left to do.
+95 -20
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -112,7 +112,7 @@ constexpr uint32_t kTlsHandshakeMs = 10000;
// 64 KB of heap at the full two sessions. mbedtls_ssl_conf_max_frag_len(4096) was NOT // 64 KB of heap at the full two sessions. mbedtls_ssl_conf_max_frag_len(4096) was NOT
// applied: the measured free heap with two live sessions (see .agents/HW_VERIFICATION.md, // applied: the measured free heap with two live sessions (see .agents/HW_VERIFICATION.md,
// phase 02 task 03) fits with margin, so the smaller-fragments saving is not worth the // phase 02 task 03) fits with margin, so the smaller-fragments saving is not worth the
// smaller record ceiling on the 48 KB page. Re-measure before changing that answer. // smaller record ceiling on the ~58 KB page. Re-measure before changing that answer.
constexpr size_t kTlsMaxSessions = 2; constexpr size_t kTlsMaxSessions = 2;
// Bytes staged between the server's writes and a transport that may not accept one this // Bytes staged between the server's writes and a transport that may not accept one this
@@ -139,11 +139,11 @@ constexpr size_t kConnWriteChunk = 4096;
// write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one // write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one
// segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The // segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The
// cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase // cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase
// report against the phase 01 baseline. The served page is 59 286 B (the count the gate // report against the phase 01 baseline. The served page is 59 262 B (the count the gate
// prints), so a page is 41 chunks. // prints), so a page is 41 chunks.
constexpr size_t kPageChunkBytes = 1460; constexpr size_t kPageChunkBytes = 1460;
// The per-tick budget for draining a slot's queue: 4 chunks, so the 59 286-byte page leaves // The per-tick budget for draining a slot's queue: 4 chunks, so the 59 262-byte page leaves
// in 11 ticks of pump() and no single loop() iteration can spend more than four transport // in 11 ticks of pump() and no single loop() iteration can spend more than four transport
// writes on one socket -- whatever that socket's peer is doing. loop() runs continuously // writes on one socket -- whatever that socket's peer is doing. loop() runs continuously
// (nothing in it delays), so the page is out in milliseconds; the budget is not about // (nothing in it delays), so the page is out in milliseconds; the budget is not about
+1 -1
View File
@@ -56,7 +56,7 @@
* - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which * - TLS hands `mbedtls_ssl_write` the identical (ptr, len) until it accepts them, which
* is that library's documented resume rule and is enforced in tls::Session; * is that library's documented resume rule and is enforced in tls::Session;
* - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and * - both are capped per call (`kConnWriteChunk`) and per tick (`kPageBytesPerTick`), and
* a body that lives in flash is copied through a bounded RAM chunk, so a 48 KB page * a body that lives in flash is copied through a bounded RAM chunk, so a ~58 KB page
* leaves in bounded pieces across ticks instead of in one call that can wait. * leaves in bounded pieces across ticks instead of in one call that can wait.
* Nothing here ever waits. A peer that stops reading is dropped by the caller's stall * Nothing here ever waits. A peer that stops reading is dropped by the caller's stall
* deadline, which is the same rule that already applied to a stalled *request*. * deadline, which is the same rule that already applied to a stalled *request*.
+1 -1
View File
@@ -507,7 +507,7 @@ void ControlServer::serveReadonly(const ws::Request &req, BuiltResponse &rsp,
if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) { if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) {
// The page is the one body too big to queue: it is streamed out of flash through a // The page is the one body too big to queue: it is streamed out of flash through a
// bounded chunk (config.h kPageChunkBytes / kPageBytesPerTick), which is what lets a // bounded chunk (config.h kPageChunkBytes / kPageBytesPerTick), which is what lets a
// 48 KB response leave across ticks on either transport without one blocking write. // ~58 KB response leave across ticks on either transport without one blocking write.
buildResponse(rsp, "200 OK", "text/html; charset=utf-8", BuiltResponse::Body::Text, buildResponse(rsp, "200 OK", "text/html; charset=utf-8", BuiltResponse::Body::Text,
kIndexHtml, kIndexHtmlLen, nullptr, /*textInFlash=*/true); kIndexHtml, kIndexHtmlLen, nullptr, /*textInFlash=*/true);
return; return;
+1 -1
View File
@@ -161,7 +161,7 @@ class ControlServer {
// A response built but not yet written: the status line and headers, plus a body that // A response built but not yet written: the status line and headers, plus a body that
// lives somewhere else -- a string literal in flash, the rendered status JSON, or the // lives somewhere else -- a string literal in flash, the rendered status JSON, or the
// 48 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of // ~58 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of
// the response (literals and kIndexHtml live forever; the JSON lives in this struct, // the response (literals and kIndexHtml live forever; the JSON lives in this struct,
// which the caller keeps until the response is queued), which is what makes "build once, // which the caller keeps until the response is queued), which is what makes "build once,
// queue on either transport" safe. // queue on either transport" safe.
+208 -176
View File
@@ -54,6 +54,19 @@
* those positions are what travel as HID usage ids. If the TARGET is set to a * those positions are what travel as HID usage ids. If the TARGET is set to a
* non-US layout, positions map to different characters -- the same caveat every * non-US layout, positions map to different characters -- the same caveat every
* KVM has. Fix it on the target (set its layout to US) or remap CODE_TO_USAGE. * KVM has. Fix it on the target (set its layout to US) or remap CODE_TO_USAGE.
*
* Screen layout: this page is used on a laptop AND on a phone held in one hand
* over a target you are standing at. One breakpoint (720 px) switches to a
* single column with a sticky ARM bar; a coarse-pointer query puts every press
* target on a 44 px thumb floor; inputs never render below 16 px because iOS
* Safari zooms the page on focusing a smaller field and never zooms back out.
* The ARM bar is sticky because armed-or-disarmed is the one fact that must
* survive scrolling -- a state you have to scroll up to read is a state you
* press blind. Pinch zoom stays on: the viewport only adds viewport-fit=cover,
* and without it env(safe-area-inset-*) is always 0. Every one of these layout
* claims is asserted by tests/test_page.js against the served bytes, because
* no compiler reads this string and a missing breakpoint fails silently -- it
* just renders the laptop layout onto a phone.
*/ */
#pragma once #pragma once
@@ -69,7 +82,7 @@ static const char kIndexHtml[] PROGMEM =
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>keyrelay</title> <title>keyrelay</title>
<style> <style>
:root{ :root{
@@ -89,10 +102,11 @@ h1 b{color:var(--accent)}
.pill.on{color:var(--ok);border-color:#1f3b26;background:#12261a} .pill.on{color:var(--ok);border-color:#1f3b26;background:#12261a}
.pill.off{color:var(--dim)} .pill.off{color:var(--dim)}
.pill.bad{color:var(--hot);border-color:#4a1d1d;background:#2a1214} .pill.bad{color:var(--hot);border-color:#4a1d1d;background:#2a1214}
main{padding:14px;display:grid;gap:14px;max-width:980px} main{padding:14px;padding-bottom:calc(14px + env(safe-area-inset-bottom));
display:grid;gap:14px;max-width:980px}
.arm{width:100%;padding:26px;font:inherit;font-size:22px;font-weight:700;letter-spacing:2px; .arm{width:100%;padding:26px;font:inherit;font-size:22px;font-weight:700;letter-spacing:2px;
color:var(--fg);background:var(--panel);border:2px solid var(--line);border-radius:12px; color:var(--fg);background:var(--panel);border:2px solid var(--line);border-radius:12px;
cursor:pointer} cursor:pointer;position:sticky;top:0;z-index:5;touch-action:manipulation;user-select:none}
.arm:hover{border-color:var(--accent)} .arm:hover{border-color:var(--accent)}
.arm.armed{color:#fff;background:#4a1216;border-color:var(--hot); .arm.armed{color:#fff;background:#4a1216;border-color:var(--hot);
box-shadow:0 0 0 1px var(--hot),0 0 26px -6px var(--hot)} box-shadow:0 0 0 1px var(--hot),0 0 26px -6px var(--hot)}
@@ -109,13 +123,15 @@ main{padding:14px;display:grid;gap:14px;max-width:980px}
.key{border:1px solid var(--line);border-radius:5px;padding:1px 7px;font-size:12px;background:#0b0f14} .key{border:1px solid var(--line);border-radius:5px;padding:1px 7px;font-size:12px;background:#0b0f14}
.key.mod{color:var(--accent);border-color:#1f3552} .key.mod{color:var(--accent);border-color:#1f3552}
button.k{font:inherit;font-size:12.5px;color:var(--fg);background:#0b0f14; button.k{font:inherit;font-size:12.5px;color:var(--fg);background:#0b0f14;
border:1px solid var(--line);border-radius:7px;padding:7px 9px;cursor:pointer} border:1px solid var(--line);border-radius:7px;padding:7px 9px;cursor:pointer;
touch-action:manipulation;user-select:none}
button.k:hover{border-color:var(--accent);color:#fff} button.k:hover{border-color:var(--accent);color:#fff}
button.k.danger{color:var(--warn);border-color:#4a3a12} button.k.danger{color:var(--warn);border-color:#4a3a12}
button.k.danger:hover{border-color:var(--hot);color:#fff;background:#2a1214} button.k.danger:hover{border-color:var(--hot);color:#fff;background:#2a1214}
button.k.armed-confirm{color:#fff;background:#4a1216;border-color:var(--hot)} button.k.armed-confirm{color:#fff;background:#4a1216;border-color:var(--hot)}
.row{display:flex;flex-wrap:wrap;gap:6px;align-items:center} .row{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
label.opt{display:flex;gap:7px;align-items:center;font-size:12.5px;color:var(--dim);cursor:pointer} label.opt{display:flex;gap:7px;align-items:center;font-size:12.5px;color:var(--dim);
cursor:pointer;touch-action:manipulation}
input[type=password],input[type=text]{font:inherit;font-size:13px;background:#0b0f14; input[type=password],input[type=text]{font:inherit;font-size:13px;background:#0b0f14;
color:var(--fg);border:1px solid var(--line);border-radius:7px;padding:6px 8px;width:150px} color:var(--fg);border:1px solid var(--line);border-radius:7px;padding:6px 8px;width:150px}
textarea{font:inherit;font-size:13px;background:#0b0f14;color:var(--fg);width:100%; textarea{font:inherit;font-size:13px;background:#0b0f14;color:var(--fg);width:100%;
@@ -128,7 +144,19 @@ button.k:disabled:hover{color:#4d565f;border-color:var(--line);background:#0b0f1
.rowhead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px} .rowhead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:8px}
.rowhead h2{margin:0} .rowhead h2{margin:0}
p.hint{color:var(--dim);font-size:11.5px;margin:8px 0 0} p.hint{color:var(--dim);font-size:11.5px;margin:8px 0 0}
#log{height:150px;overflow-y:auto;font-size:12px;border:1px solid var(--line);border-radius:8px; .mt{margin-top:7px}
.hot{color:var(--hot)}
.dimv{color:var(--dim);font-weight:400}
.dim{color:var(--dim)}
/* The long explanations live behind a tap. A details element is zero script,
* keyboard accessible, printable, works with JS off, and degrades to visible
* text in a browser that ignores it -- the safe direction for a safety note. */
details{margin:7px 0 0}
summary{color:var(--accent);font-size:11.5px;cursor:pointer;
touch-action:manipulation;user-select:none}
details p{margin:6px 0 0}
#log{height:150px;overflow-y:auto;overscroll-behavior:contain;font-size:12px;
border:1px solid var(--line);border-radius:8px;
background:#0b0f14;padding:7px 9px;white-space:pre-wrap} background:#0b0f14;padding:7px 9px;white-space:pre-wrap}
#log .e{color:var(--hot)} #log .e{color:var(--hot)}
#log .w{color:var(--warn)} #log .w{color:var(--warn)}
@@ -138,17 +166,45 @@ kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;paddi
font-size:11.5px;background:#0b0f14} font-size:11.5px;background:#0b0f14}
.mousepad{height:118px;border:1px dashed var(--line);border-radius:10px;background:#0b0f14; .mousepad{height:118px;border:1px dashed var(--line);border-radius:10px;background:#0b0f14;
display:flex;align-items:center;justify-content:center;text-align:center;padding:0 14px; display:flex;align-items:center;justify-content:center;text-align:center;padding:0 14px;
color:var(--dim);font-size:12.5px;cursor:crosshair;user-select:none} color:var(--dim);font-size:12.5px;cursor:crosshair;user-select:none;touch-action:manipulation}
.mousepad:hover{border-color:var(--accent);color:var(--fg)} .mousepad:hover{border-color:var(--accent);color:var(--fg)}
.mousepad.captured{border-style:solid;border-color:var(--hot);color:var(--hot); .mousepad.captured{border-style:solid;border-color:var(--hot);color:var(--hot);
background:#1d0d10;cursor:none} background:#1d0d10;cursor:none}
body.armed{outline:3px solid var(--hot);outline-offset:-3px} body.armed{outline:3px solid var(--hot);outline-offset:-3px}
body.off{opacity:.92} body.off{opacity:.92}
/* One breakpoint (720 px) plus a pointer query: one the README can describe
* beats five nobody can remember. main goes flex-column here because a sticky
* *grid* item is pinned inside its own area and would never move. The 16 px
* input floor is iOS Safari's focus-zoom threshold, not taste. */
@media (max-width:720px){
main{display:flex;flex-direction:column;gap:12px;padding:10px;
padding-bottom:calc(10px + env(safe-area-inset-bottom))}
.grid{grid-template-columns:1fr}
header{padding:8px 10px}
.arm{padding:18px;font-size:20px}
input[type=password],input[type=text],textarea{font-size:16px}
#log{height:auto;max-height:40vh}
/* The counters are the "is it typing" evidence, so they stay visible -- they just
* stop stacking into eight rows and pushing the ARM bar off a small screen. */
.kv{display:flex;flex-wrap:wrap;gap:1px 10px}
.kv div:nth-child(even){text-align:left}
.rowhead{gap:8px;margin-bottom:6px}
.rowhead label.opt{font-size:12px}
}
/* The thumb floor: on a coarse pointer every press target is 44 px tall, and
* wide where it was narrow. Fine pointers keep the dense laptop layout. summary
* is a press target too -- the folded notes are the only route to five safety
* sentences, and it measured 17 px before it was on this list. */
@media (pointer:coarse){
button.k,.pill,input,summary{min-height:44px}
button.k,input[type=checkbox]{min-width:44px}
summary{padding:13px 0}
}
</style> </style>
</head> </head>
<body class="off"> <body class="off">
<header> <header>
<h1>key<b>relay</b> <span id="ver" style="color:var(--dim);font-weight:400"></span></h1> <h1>key<b>relay</b> <span id="ver" class="dimv"></span></h1>
<div class="pills"> <div class="pills">
<span class="pill" id="pNet">network</span> <span class="pill" id="pNet">network</span>
<span class="pill" id="pUsb">target</span> <span class="pill" id="pUsb">target</span>
@@ -173,11 +229,11 @@ body.off{opacity:.92}
<div class="card"> <div class="card">
<h2>Special keys</h2> <h2>Special keys</h2>
<div class="row"> <div class="row">
<button class="k danger" id="cad" title="Press twice within 3s. The laptop's own OS swallows the real Ctrl+Alt+Del, so it has to come from here. On a Linux virtual terminal this reboots the target."> <button class="k danger" id="cad" title="Press twice within 3s. The laptop's own OS eats the real one. On a Linux VT this reboots the target.">
Ctrl+Alt+Del</button> Ctrl+Alt+Del</button>
<button class="k" id="release" title="All keys up, stay armed. Also Ctrl+Alt+Esc.">Release keys</button> <button class="k" id="release" title="All keys up, stay armed. Also Ctrl+Alt+Esc.">Release keys</button>
</div> </div>
<div class="row" style="margin-top:6px"> <div class="row mt">
<button class="k" data-chord="05:3a" title="Ctrl+Alt+F1">&#8976; VT1</button> <button class="k" data-chord="05:3a" title="Ctrl+Alt+F1">&#8976; VT1</button>
<button class="k" data-chord="05:3b" title="Ctrl+Alt+F2">&#8976; VT2</button> <button class="k" data-chord="05:3b" title="Ctrl+Alt+F2">&#8976; VT2</button>
<button class="k" data-chord="05:3c" title="Ctrl+Alt+F3">&#8976; VT3</button> <button class="k" data-chord="05:3c" title="Ctrl+Alt+F3">&#8976; VT3</button>
@@ -187,41 +243,40 @@ body.off{opacity:.92}
<button class="k" data-chord="00:46" title="PrintScreen">PrtSc</button> <button class="k" data-chord="00:46" title="PrintScreen">PrtSc</button>
<button class="k" data-chord="00:65" title="Application menu key">Menu</button> <button class="k" data-chord="00:65" title="Application menu key">Menu</button>
</div> </div>
<div class="row" style="margin-top:6px"> <div class="row mt">
<button class="k" data-chord="08:50" title="Super+Left &mdash; KDE: snap the focused window to the left half of the screen">&#8678; snap left</button> <button class="k" data-chord="08:50" title="Super+Left (KDE: window to the left half)">&#8678; snap left</button>
<button class="k" data-chord="08:4f" title="Super+Right &mdash; KDE: snap the focused window to the right half of the screen">snap right &#8680;</button> <button class="k" data-chord="08:4f" title="Super+Right (KDE: window to the right half)">snap right &#8680;</button>
<button class="k" data-chord="08:4b" title="Super+PageUp &mdash; KDE: make the focused window fullscreen">&#x26F6; fullscreen</button> <button class="k" data-chord="08:4b" title="Super+PageUp (KDE: fullscreen)">&#x26F6; fullscreen</button>
</div> </div>
<p style="color:var(--dim);font-size:11.5px;margin:8px 0 0"> <p class="hint">The laptop's window manager grabs these chords before the browser
These are here because the laptop's window manager grabs them before the browser sees them, so the bridge types them instead &mdash; armed, one deliberate click each.
ever sees them &mdash; press Super+Left on the laptop and the laptop tiles its own
window. So the bridge types the chord instead and the machine it is plugged into
is the one that reacts. Which window does what is that machine's own keybinding
(these three are KDE Plasma's: window left, window right, fullscreen). They need
the bridge armed, and each one is a single deliberate click.
</p> </p>
<details><summary>why these are buttons</summary><p class="hint">Press Super+Left on
the laptop and the laptop tiles its own window &mdash; the browser never sees that
chord. What a chord does on the target is that machine's own keybinding; these three
are KDE Plasma's: window left, window right, fullscreen.</p></details>
</div> </div>
<div class="card"> <div class="card">
<h2>State</h2> <h2>State</h2>
<div class="kv"> <div class="kv">
<div>mode</div><div id="sMode">&mdash;</div> <div>mode</div><div id="sMode">&mdash;</div>
<div>address</div><div id="sIp">&mdash;</div> <div>ip</div><div id="sIp">&mdash;</div>
<div>uptime</div><div id="sUp">&mdash;</div> <div>up</div><div id="sUp">&mdash;</div>
<div>keys sent</div><div id="sKeys">0</div> <div>keys</div><div id="sKeys">0</div>
<div>text sent</div><div id="sTyped">0</div> <div>text</div><div id="sTyped">0</div>
<div>chords</div><div id="sChords">0</div> <div>chords</div><div id="sChords">0</div>
<div>mouse reports</div><div id="sMouse">0</div> <div>mouse</div><div id="sMouse">0</div>
<div>dropped</div><div id="sDrop">0</div> <div>dropped</div><div id="sDrop">0</div>
</div> </div>
<h2 style="margin-top:10px">Held now</h2> <h2 class="mt">Held now</h2>
<div class="keys" id="held"><span style="color:var(--dim)">nothing</span></div> <div class="keys" id="held"><span class="dim">nothing</span></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Connection</h2> <h2>Connection</h2>
<div class="banner" id="clearWarn" hidden> <div class="banner" id="clearWarn" hidden>
<b style="color:var(--hot)">This page arrived in the clear.</b> A TLS board <b class="hot">This page arrived in the clear.</b> A TLS board
answers plaintext requests with a redirect and never with these bytes, so an answers plaintext requests with a redirect and never with these bytes, so an
<code>http://</code> copy of this page is a stale cache, a downgrade, or <code>http://</code> copy of this page is a stale cache, a downgrade, or
somebody else&rsquo;s server &mdash; and it will not take a passphrase. Reload somebody else&rsquo;s server &mdash; and it will not take a passphrase. Reload
@@ -231,20 +286,19 @@ body.off{opacity:.92}
<div class="row"> <div class="row">
<input type="password" id="token" placeholder="passphrase" autocomplete="off"> <input type="password" id="token" placeholder="passphrase" autocomplete="off">
<button class="k" id="connect">connect</button> <button class="k" id="connect">connect</button>
<button class="k danger" id="takeover" hidden title="Appears only when the board says another controller holds the bridge. One deliberate click: authenticate, then ask for the bridge (X). The other session is told (E12), every key and every mouse button is released, and the board comes back DISARMED &mdash; you arm again on purpose.">take over</button> <button class="k danger" id="takeover" hidden title="Only shown when the board says another controller holds the bridge. One click takes it; the board lands DISARMED.">take over</button>
</div> </div>
<label class="opt" style="margin-top:9px"> <label class="opt mt">
<input type="checkbox" id="keepArmed"> <input type="checkbox" id="keepArmed">
stay armed if this tab loses focus stay armed if this tab loses focus
</label> </label>
<p style="color:var(--dim);font-size:11.5px;margin:8px 0 0"> <p class="hint">The board checks this on every connection; without it nothing can
The passphrase is checked by the board on every connection. Without it nothing arm, so anyone else on the network gets a page that does nothing.</p>
can be armed, so anyone else on the network gets a page that does nothing. <details><summary>what the passphrase does and does not buy</summary><p class="hint">
Someone who <b>has</b> it can take the bridge from a session left connected Someone who <b>has</b> it can take the bridge from a session left connected
&mdash; that is what <b>take over</b> is for &mdash; and cannot arm it by doing &mdash; that is what <b>take over</b> is for &mdash; and cannot arm it by doing
so: every handover lands disarmed, with the other session&rsquo;s keys and so: every handover lands disarmed, every key and button released, the other screen
buttons released and its screen told. told.</p></details>
</p>
<p class="hint" id="certFp" hidden></p> <p class="hint" id="certFp" hidden></p>
</div> </div>
</div> </div>
@@ -252,35 +306,34 @@ body.off{opacity:.92}
<div class="card"> <div class="card">
<div class="rowhead"> <div class="rowhead">
<h2>Mouse</h2> <h2>Mouse</h2>
<button class="k" id="mCapture" title="Asks the browser for Pointer Lock, the same thing a web game does: your cursor cannot escape this tab while the board is forwarding it. Escape gives it back.">capture the pointer</button> <button class="k" id="mCapture" title="Pointer Lock, as a web game uses it: the cursor cannot leave this tab while forwarded. Esc gives it back.">capture the pointer</button>
<button class="k danger" id="mRelease" title="Every mouse button up. The cursor stays captured.">release buttons</button> <button class="k danger" id="mRelease" title="Every mouse button up. The cursor stays captured.">release buttons</button>
<span class="pill off" id="mStat">not captured</span> <span class="pill off" id="mStat">not captured</span>
</div> </div>
<div class="mousepad" id="mPad" tabindex="-1"> <div class="mousepad" id="mPad" tabindex="-1">
<span id="mPadText">click here to hand this tab the mouse</span> <span id="mPadText">click here to hand this tab the mouse</span>
</div> </div>
<div class="row" style="margin-top:8px"> <div class="row mt">
<span style="color:var(--dim);font-size:12.5px">buttons held</span> <span class="dim">buttons held</span>
<div class="keys" id="mHeld"><span style="color:var(--dim)">none</span></div> <div class="keys" id="mHeld"><span class="dim">none</span></div>
<span class="pill off" id="mFrames">0 reports</span> <span class="pill off" id="mFrames">0 reports</span>
</div> </div>
<p class="hint"> <p class="hint">Movement is <b>relative</b>, like a real mouse. <kbd>Esc</kbd> frees
Movement is <b>relative</b>, like a real mouse: the board says how far the pointer the cursor and lets go of every button.</p>
moved, never where it is, so the target's own pointer speed and acceleration decide <details><summary>how a forwarded mouse behaves</summary><p class="hint">The board says
where that lands. Buttons are restated on every frame, which is why a frame lost in how far the pointer moved, never where it is: the target's own pointer speed decides where
the WiFi cannot leave one held down. <kbd>Esc</kbd> frees the cursor and lets go of it lands. Buttons are restated on every frame, which is why a frame lost in the WiFi
every button &mdash; the browser keeps that key for itself, so it never reaches the cannot leave one held down. The browser keeps <kbd>Esc</kbd>, so it never reaches the
target, and losing the tab does the same. No auto-click, no macro, no saved gesture, target, and losing the tab does the same. No auto-click, no macro, no saved gesture,
nothing scheduled: if nobody is moving a mouse, nothing moves. Capture needs the nothing scheduled. Capture needs the bridge armed and <code>mouse on</code> over
bridge armed and this board's mouse interface on (<code>mouse on</code> over serial). serial.</p></details>
</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Send text</h2> <h2>Send text</h2>
<textarea id="paste" rows="3" spellcheck="false" autocomplete="off" <textarea id="paste" rows="3" spellcheck="false" autocomplete="off"
placeholder="paste text here. Nothing is sent until you press the button below." ></textarea> placeholder="paste text here. Nothing is sent until you press the button below." ></textarea>
<div class="row" style="margin-top:7px"> <div class="row mt">
<button class="k" id="typeSend" disabled>type 0 characters</button> <button class="k" id="typeSend" disabled>type 0 characters</button>
<button class="k danger" id="typeStop" disabled>stop typing</button> <button class="k danger" id="typeStop" disabled>stop typing</button>
<label class="opt" title="Off by default: a paste that quietly loses a character is worse than one that refuses"> <label class="opt" title="Off by default: a paste that quietly loses a character is worse than one that refuses">
@@ -289,14 +342,13 @@ body.off{opacity:.92}
<input type="checkbox" id="trailingEnter">press Enter when done</label> <input type="checkbox" id="trailingEnter">press Enter when done</label>
<span class="pill off" id="typeStat">idle</span> <span class="pill off" id="typeStat">idle</span>
</div> </div>
<p class="hint" id="typeHint"> <p class="hint" id="typeHint">The board types these as keystrokes, while armed.
The board types these characters as keystrokes, at a pace it keeps itself, so the <b>stop typing</b> or <kbd>Ctrl+Alt+Esc</kbd> abandons the rest of a run. Characters are
target sees a keyboard and not a paste. It needs the bridge armed, and one press of positions, so the target must be set to a US layout.</p>
this button &mdash; there is no history, nothing is saved, and nothing is ever typed <details><summary>what a run is and is not</summary><p class="hint">The pace is the
later. <b>stop typing</b> or <kbd>Ctrl+Alt+Esc</kbd> abandons the rest of a run. board's own, so the target sees a keyboard, not a paste, and that button is the only
Layout matters: characters are positions, so the target must be set to a US layout trigger: no history, nothing is saved, nothing typed later. Disarming, losing this tab
for these to land as they read here. or a board error ends a run too.</p></details>
</p>
</div> </div>
<div class="card"> <div class="card">
@@ -311,11 +363,11 @@ body.off{opacity:.92}
</main> </main>
<footer> <footer>
Shortcuts while armed: <kbd>Ctrl+Alt+Esc</kbd> release every key and every mouse button
and disarm &middot; <kbd>Ctrl+Alt+d</kbd> the Ctrl+Alt+Del confirm &middot; while the
pointer is captured <kbd>Esc</kbd> belongs to the browser and frees the cursor &middot;
every other key is forwarded to the target untouched.
Nothing is forwarded while disarmed; reload, focus loss and socket loss all disarm. Nothing is forwarded while disarmed; reload, focus loss and socket loss all disarm.
<details><summary>shortcuts while armed</summary><p class="hint">
<kbd>Ctrl+Alt+Esc</kbd> releases every key and every mouse button and disarms &middot;
<kbd>Ctrl+Alt+d</kbd> is the Ctrl+Alt+Del confirm &middot; while captured, <kbd>Esc</kbd>
belongs to the browser &middot; every other key goes to the target untouched.</p></details>
</footer> </footer>
<script> <script>
@@ -337,11 +389,11 @@ body.off{opacity:.92}
"var KR_TLS_BUILD=0;" "var KR_TLS_BUILD=0;"
#endif #endif
R"HTML( R"HTML(
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* KeyboardEvent.code -> USB HID usage id (usage page 0x07). * KeyboardEvent.code -> USB HID usage id (usage page 0x07).
* code names a physical position, so this table is US-ANSI based; it is * code names a physical position, so this table is US-ANSI based; it is
* the one thing to edit for a different board. * the one thing to edit for a different board.
* ------------------------------------------------------------------ */ * ---------------------------------------- */
const CODE_TO_USAGE = { const CODE_TO_USAGE = {
KeyA:0x04,KeyB:0x05,KeyC:0x06,KeyD:0x07,KeyE:0x08,KeyF:0x09,KeyG:0x0a,KeyH:0x0b, KeyA:0x04,KeyB:0x05,KeyC:0x06,KeyD:0x07,KeyE:0x08,KeyF:0x09,KeyG:0x0a,KeyH:0x0b,
KeyI:0x0c,KeyJ:0x0d,KeyK:0x0e,KeyL:0x0f,KeyM:0x10,KeyN:0x11,KeyO:0x12,KeyP:0x13, KeyI:0x0c,KeyJ:0x0d,KeyK:0x0e,KeyL:0x0f,KeyM:0x10,KeyN:0x11,KeyO:0x12,KeyP:0x13,
@@ -381,13 +433,11 @@ const LOG_LINES = 150;
/** /**
* One line per thing worth reading. * One line per thing worth reading.
* *
* Two rules, both of them here rather than at the call sites, because a log you * Two rules, here rather than at the call sites, because a log you cannot read is
* cannot read is worse than no log: * worse than no log: nothing empty ever lands (a blank line is pure height, and a
* * few a second pushed the key lines off the visible area); and an identical line
* - nothing empty ever lands. A blank line is pure height, and a few of them a * repeating is counted, not stacked -- three hundred copies of one retry say one
* second pushed the actual key lines off the visible area for good. * thing, once.
* - an identical line repeating is counted, not repeated. Three hundred copies of
* the same retry say one thing and say it once.
*/ */
function log(msg, cls) { function log(msg, cls) {
const body = (msg === undefined || msg === null) ? "" : String(msg); const body = (msg === undefined || msg === null) ? "" : String(msg);
@@ -422,10 +472,10 @@ function logKey(label, frame) {
log("key " + label + " " + frame, "g"); log("key " + label + " " + frame, "g");
} }
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Local key state. We restate the WHOLE state on every event, so a lost * Local key state. We restate the WHOLE state on every event, so a lost
* frame cannot leave a key stuck: the next restatement fixes everything. * frame cannot leave a key stuck: the next restatement fixes everything.
* ------------------------------------------------------------------ */ * ---------------------------------------- */
let mods = 0; let mods = 0;
let keys = []; // usage ids, press order let keys = []; // usage ids, press order
@@ -442,7 +492,7 @@ function paintHeld() {
const bits = []; const bits = [];
for (let i = 0; i < 8; i++) if (mods & (1 << i)) bits.push(MOD_NAME[i]); for (let i = 0; i < 8; i++) if (mods & (1 << i)) bits.push(MOD_NAME[i]);
for (const u of keys) bits.push(USAGE_NAME[u] || ("0x" + u.toString(16))); for (const u of keys) bits.push(USAGE_NAME[u] || ("0x" + u.toString(16)));
if (!bits.length) { el.innerHTML = '<span style="color:var(--dim)">nothing</span>'; return; } if (!bits.length) { el.innerHTML = '<span class="dim">nothing</span>'; return; }
for (const b of bits) { for (const b of bits) {
const s = document.createElement("span"); const s = document.createElement("span");
s.className = "key" + (MOD_NAME.includes(b) ? " mod" : ""); s.className = "key" + (MOD_NAME.includes(b) ? " mod" : "");
@@ -474,29 +524,25 @@ function describePress(code) {
return (held.length ? held.join("+") + "+" : "") + self; return (held.length ? held.join("+") + "+" : "") + self;
} }
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Link * Link
* ------------------------------------------------------------------ */ * ---------------------------------------- */
let ws = null, armed = false, authed = false, reconnectAt = 0, status = null; let ws = null, armed = false, authed = false, reconnectAt = 0, status = null;
/* Facts about the link that are not the socket object itself. /* Facts about the link that are not the socket object itself.
* gotOk / gotStatus what THIS socket has been told: a controller gets `OK` * gotOk / gotStatus what THIS socket has been told. A controller gets `OK` and a
* and a status frame immediately; a pending claim (phase 03, * status frame at once; a pending claim (phase 03) gets `OK` and
* the board held by someone else) gets `OK` and no status -- * no status, because that frame belongs to the controller alone.
* the status frame belongs to the controller alone. "OK then * So "OK, then never adopted" IS the board reporting `busy` in
* never adopted" is therefore the board reporting `busy` in * the only terms it gives a socket that does not hold the bridge.
* the only terms it gives a socket that does not hold the * busyKnown the board said a controller holds it: by not adopting us, by
* bridge. * E12, or by a busy frame while we are not the controller.
* busyKnown the board has said a controller holds the bridge: by not * takenOver our own session was displaced (E12).
* adopting us, by E12, or by a status frame saying busy while * autoReconnect whether the 2 s loop may reconnect -- off for exactly those two
* we are not the controller. * facts, because re-claiming at once turns two tabs into a fight
* takenOver our own session was displaced by another controller (E12). * over a keyboard. Back on the next deliberate click.
* autoReconnect whether the 2 s loop may reconnect at all. Turned off by * pendingTakeover this socket came from the take-over button; its first status
* exactly those two facts: a page that immediately re-claims * frame is the moment to say what happened. */
* turns two tabs into a fight over a keyboard. It comes back
* on the next deliberate click (connect, or take over).
* pendingTakeover this socket was opened by the take-over button; its first
* status frame is the moment to say what happened. */
let gotOk = false, gotStatus = false, busyKnown = false, takenOver = false; let gotOk = false, gotStatus = false, busyKnown = false, takenOver = false;
let autoReconnect = true, pendingTakeover = false; let autoReconnect = true, pendingTakeover = false;
@@ -508,13 +554,11 @@ function setPill(cls, text) {
function showTakeover(on) { $("takeover").hidden = !on; } function showTakeover(on) { $("takeover").hidden = !on; }
/* The TLS build's page on an http: origin is a page nobody legitimate served: a /* A TLS build's page on an http: origin is bytes nobody legitimate served: a
* KR_TLS=1 board answers :80 with a redirect and never with these bytes, so an * KR_TLS=1 board answers :80 with a redirect, never with these. So an http: tab is
* http: tab is a cache, a downgrade, or somebody else's server. Refuse before the * a cache, a downgrade, or somebody else's server -- refuse before the passphrase
* passphrase can leave it. This is the guard the other way round from the scheme * can leave it. It never falls back to the plaintext scheme: silently taking the
* choice below -- and it never falls back to the plaintext scheme, because * weaker option is the failure this transport exists to prevent. */
* silently taking the weaker option is the failure this transport exists to
* prevent. */
function servedInClear() { function servedInClear() {
return KR_TLS_BUILD === 1 && location.protocol !== "https:"; return KR_TLS_BUILD === 1 && location.protocol !== "https:";
} }
@@ -528,16 +572,14 @@ function tokenValue() {
function connect() { openSession(false); } function connect() { openSession(false); }
/** /**
* One socket builder for both gestures: ordinary connect, and the deliberate * One socket builder for both gestures: connect, and the deliberate take-over.
* take-over. `takeover` means: after the passphrase send `X` and nothing else * `takeover` means: after the passphrase send `X` and nothing else -- no H, no
* -- no H, no state, no keepalive -- because one frame is one command and one * state, no keepalive -- one frame one command, one click one intent. Before the
* click is one intent. Before the click nothing about the bridge is sent; after * click nothing about the bridge is sent; after it this is an ordinary controller,
* it the page is an ordinary controller, starting DISARMED, because the handover * starting DISARMED, because the handover lands disarmed (control_server.cpp
* itself lands disarmed (control_server.cpp handOverClaim) and only an `A1` typed * handOverClaim) and only an `A1` from here on arms. `H` is also the probe that
* from here on arms. `H` on an ordinary socket doubles as the probe that says * says which one we are: a controller answers with a status frame, the pending-claim
* which one we are: a controller answers it with a status frame, the board's one * slot with E6 and a drop -- and onGone() reads that drop as "held".
* pending-claim slot answers it with E6 and a drop -- and the drop is what
* onGone() reads as "held by another controller".
*/ */
function openSession(takeover) { function openSession(takeover) {
if (servedInClear()) { if (servedInClear()) {
@@ -568,10 +610,10 @@ function openSession(takeover) {
} }
/* The socket went -- and which sentence is true depends on what the board said /* The socket went -- and which sentence is true depends on what the board said
* before it did. This is where a refused or un-adopted upgrade stops being a * before it did. This is where a refused or un-adopted upgrade stops being a 2 s
* 2-second retry loop: a page that keeps re-claiming against a controller that * retry loop: re-claiming against a controller that is not coming back just
* is not coming back just occupies the board's one claim slot, so the page comes * occupies the board's one claim slot, so the page comes down and waits for one
* down and waits for one deliberate click instead. */ * deliberate click. */
function onGone() { function onGone() {
authed = false; authed = false;
if (takenOver) { if (takenOver) {
@@ -584,10 +626,10 @@ function onGone() {
} }
setArmed(false, "link lost"); setArmed(false, "link lost");
if (gotOk && !gotStatus) { if (gotOk && !gotStatus) {
/* Authenticated, never adopted: the board put us in its spare slot as a /* Authenticated, never adopted: the board put us in its spare slot as a pending
* pending claim, which IS the board reporting busy -- a controller holds the * claim, which IS the board reporting busy -- a controller holds the keyboard and
* keyboard and the status frame that would have said so belongs to it alone. * the status frame saying so belongs to it alone. Link state only: that it exists,
* Counter-and-link-state terms only: we are told that it exists, never who. */ * never who. */
busyKnown = true; busyKnown = true;
setPill("bad", "held by another controller"); setPill("bad", "held by another controller");
showTakeover(true); showTakeover(true);
@@ -646,13 +688,12 @@ function onFrame(raw) {
setArmed(false, "no room for a claim: another controller holds the bridge"); setArmed(false, "no room for a claim: another controller holds the bridge");
} }
else if (code === "12") { else if (code === "12") {
/* E12 is "you were DROPPED": another controller took this bridge, and it /* E12 is "you were DROPPED": another controller took this bridge through the
* arrived through the release funnel -- the board has already released every * release funnel -- every key and every mouse button already released, the board
* key and every mouse button and is DISARMED. The local mirror is the same * DISARMED. The local mirror is the same gesture: setArmed(false) ends the typing
* gesture: setArmed(false) ends the typing run, the keys and the mouse here, * run, the keys and the mouse here, and the log says why for whoever is at this
* and the log carries the reason the operator of this screen needs to read. * screen. Then this page stays down -- auto-reclaiming is how two tabs become a
* Then this page stays down; auto-reclaiming is how two tabs become a fight, * fight, and coming back is one deliberate click on TAKE OVER. */
* and coming back is one deliberate click on TAKE OVER. */
takenOver = true; busyKnown = true; autoReconnect = false; takenOver = true; busyKnown = true; autoReconnect = false;
setArmed(false, "another controller took over"); setArmed(false, "another controller took over");
setPill("bad", "taken over"); setPill("bad", "taken over");
@@ -667,10 +708,10 @@ function onFrame(raw) {
log("board: " + raw); log("board: " + raw);
} }
/* The channel and its certificate, rendered from the status frame only. `tls` is /* The channel and its certificate, rendered from the status frame only: `tls` is
* what the board compiled in and holds; `fp` is the label of the certificate it * what the board holds, `fp` the label of the certificate it actually serves --
* actually serves -- never assembled from anything this page invents, because the * never assembled from anything this page invents, because the operator compares
* operator compares these exact bytes against `cert` on the serial console. */ * these exact bytes against `cert` on the serial console. */
function paintChannel(s) { function paintChannel(s) {
const p = $("pTls"); const p = $("pTls");
if (s.tls) { p.className = "pill on"; p.textContent = "tls"; } if (s.tls) { p.className = "pill on"; p.textContent = "tls"; }
@@ -691,10 +732,10 @@ function applyStatus(s) {
status = s; status = s;
paintChannel(s); paintChannel(s);
if (pendingTakeover) { if (pendingTakeover) {
/* The frame the handover promised: this page asked for the bridge with one /* The frame the handover promised: this page asked for the bridge with one click,
* click, the board ran the funnel, and what it says now is the truth about * the board ran the funnel, and what it says now is the truth about the state we
* the state we start in -- DISARMED. Saying it out loud is the difference * start in -- DISARMED. Saying it out loud is the difference between "it works
* between "it works again" and "it works again, from zero". */ * again" and "it works again, from zero". */
pendingTakeover = false; pendingTakeover = false;
log("bridge taken over -- the board is DISARMED; arm again on purpose", "g"); log("bridge taken over -- the board is DISARMED; arm again on purpose", "g");
} }
@@ -731,7 +772,7 @@ function applyStatus(s) {
if (mButtons !== (s.mbtn || 0) && !mCaptured) paintMouse(); if (mButtons !== (s.mbtn || 0) && !mCaptured) paintMouse();
refreshMouseUi(); refreshMouseUi();
if (s.dflt) { if (s.dflt) {
$("banner").innerHTML = '<b style="color:var(--hot)">The board is still using the ' $("banner").innerHTML = '<b class="hot">The board is still using the '
+ 'default passphrase.</b> Set your own with the serial command <code>token ' + 'default passphrase.</b> Set your own with the serial command <code>token '
+ '&lt;passphrase&gt;</code> before you plug this into anything.'; + '&lt;passphrase&gt;</code> before you plug this into anything.';
} }
@@ -740,10 +781,10 @@ function applyStatus(s) {
refreshSendBox(); refreshSendBox();
} }
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Arming. Off is the only safe default, so this is the one state change * Arming. Off is the only safe default, so this is the one state change
* that always travels, and that the board also controls (its button). * that always travels, and that the board also controls (its button).
* ------------------------------------------------------------------ */ * ---------------------------------------- */
function setArmed(on, why) { function setArmed(on, why) {
armed = on; armed = on;
const b = $("arm"); const b = $("arm");
@@ -770,9 +811,9 @@ $("arm").addEventListener("click", () => {
/* The board's status heartbeat confirms it; no optimistic flip. */ /* The board's status heartbeat confirms it; no optimistic flip. */
}); });
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Key capture * Key capture
* ------------------------------------------------------------------ */ * ---------------------------------------- */
document.addEventListener("keydown", ev => { document.addEventListener("keydown", ev => {
/* Ctrl+Alt+Esc is the panic key, armed or not: it must always work. */ /* Ctrl+Alt+Esc is the panic key, armed or not: it must always work. */
if (ev.ctrlKey && ev.altKey && ev.code === "Escape") { if (ev.ctrlKey && ev.altKey && ev.code === "Escape") {
@@ -859,9 +900,9 @@ document.addEventListener("visibilitychange", () => {
if (document.hidden) dropEverything("tab hidden"); if (document.hidden) dropEverything("tab hidden");
}); });
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Special keys * Special keys
* ------------------------------------------------------------------ */ * ---------------------------------------- */
let cadArmedUntil = 0; let cadArmedUntil = 0;
function armCad() { function armCad() {
const now = Date.now(); const now = Date.now();
@@ -903,32 +944,25 @@ function fire(frame) {
log("chord " + frame, "g"); log("chord " + frame, "g");
} }
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Mouse * Mouse
* *
* Capture, not grab. This tab asks the browser for Pointer Lock -- the * Capture, not grab: Pointer Lock, the request a web game makes. While this tab holds
* same request a web game makes -- and while it holds it, the cursor * it the cursor cannot leave the tab and every movement goes to the board; Escape
* cannot leave the tab and every movement goes to the board. Escape * always gives it back, the browser's own guarantee -- which is why the mouse needs no
* always gives the cursor back: that is the browser's own guarantee and
* nothing here can take it away, which is why the mouse does not need a
* second panic key. * second panic key.
* *
* What goes on the wire is one report per frame: the complete button * One report per frame: the whole button byte, then this frame's movement. Buttons are
* byte, then this frame's movement. Buttons are restated every frame like * restated like the keys, so a lost frame did not click; movement is a delta with no
* the keys are, so a lost frame did not click and the next frame is the * position accumulated anywhere, so a lost frame travels a little less -- all a relative
* truth either way. Movement is a delta with no accumulated position * mouse promises. That asymmetry is why an over-long movement is reported rather than
* anywhere, so a lost frame means the pointer travelled a little less -- * fatal, and why a mouse-frame overflow still releases and disarms: the frame that does
* which is all a relative mouse ever promises. That asymmetry is why * not fit may be the one letting go of a button.
* dropping the tail of a huge movement is reported rather than fatal, and
* why a queue overflow on a mouse frame still releases and disarms: the
* frame that does not fit may be the one letting go of a button.
* *
* The rate is capped in the browser, not on the board: MOUSE_FRAME_MS is * The rate is capped here, not on the board (MOUSE_FRAME_MS, MOUSE_MAX_FRAMES), and the
* the shortest interval between frames out of this tab, and MOUSE_MAX_FRAMES * excess is said out loud: a lagging cursor is survivable, a silent divergence is not.
* how many one flush may emit. Beyond that the movement is dropped and * README "The mouse" is the durable version of all of this.
* said out loud -- a cursor that lagged is survivable, a silent divergence * ---------------------------------------- */
* is not.
* ------------------------------------------------------------------ */
const MOUSE_DELTA_MAX = 127; // one report's worth == config.h kMouseDeltaMax const MOUSE_DELTA_MAX = 127; // one report's worth == config.h kMouseDeltaMax
const MOUSE_BUTTON_MASK = 0x1f; // five buttons == config.h kMouseButtonMask const MOUSE_BUTTON_MASK = 0x1f; // five buttons == config.h kMouseButtonMask
const MOUSE_FRAME_MS = 8; // 125 Hz: the ceiling this tab sends at const MOUSE_FRAME_MS = 8; // 125 Hz: the ceiling this tab sends at
@@ -960,7 +994,7 @@ function paintMouse() {
held.textContent = ""; held.textContent = "";
const bits = []; const bits = [];
for (const bit of [0x01, 0x02, 0x04, 0x08, 0x10]) if (mButtons & bit) bits.push(bit); for (const bit of [0x01, 0x02, 0x04, 0x08, 0x10]) if (mButtons & bit) bits.push(bit);
if (!bits.length) held.innerHTML = '<span style="color:var(--dim)">none</span>'; if (!bits.length) held.innerHTML = '<span class="dim">none</span>';
for (const bit of bits) { for (const bit of bits) {
const s = document.createElement("span"); const s = document.createElement("span");
s.className = "key mod"; s.className = "key mod";
@@ -1129,22 +1163,20 @@ document.addEventListener("wheel", ev => {
* often than every MOUSE_FRAME_MS, however fast the browser delivers events. */ * often than every MOUSE_FRAME_MS, however fast the browser delivers events. */
setInterval(() => { if (mCaptured) flushMouse(); }, MOUSE_FRAME_MS); setInterval(() => { if (mCaptured) flushMouse(); }, MOUSE_FRAME_MS);
/* ------------------------------------------------------------------ * /* ---------------------------------------- *
* Pasted text * Pasted text
* *
* A human pasted this into the box and pressed its button -- that is the whole * A human pasted this and pressed its button -- the whole justification. The
* justification for it. The difference between a remote keyboard and an * difference between a remote keyboard and an unattended injection device is a
* unattended injection device is a deliberate action with the arming light on, * deliberate action with the arming light on, and this keeps both: no history,
* and this keeps both. It is not a macro facility: there is no history, nothing * nothing scheduled or repeated, nothing outliving the run.
* is scheduled or repeated, and nothing outlives the run.
* *
* The board does the typing and keeps the pace, so a stall in this tab cannot * The board types and keeps the pace, so a stall in this tab cannot become a burst
* become a burst of keys on the target. Flow control is the board's own `typing` * of keys on the target. Flow control is the board's own `typing` count: the next
* count -- the next piece goes out when the board says it is empty, not when a * piece goes out when the board says it is empty, not when a timer guessed. It
* timer guessed it would be. It holds kTextJobCap characters, far more than one * holds kTextJobCap characters, far more than one frame's worth, so a heartbeat
* frame's worth, so a heartbeat that was already in flight when a frame was sent * already in flight can let one piece start early without overrunning the buffer.
* can let one piece start early without ever overrunning the buffer. * ---------------------------------------- */
* ------------------------------------------------------------------ */
const TEXT_FRAME_MAX = 120; // characters per P= frame == config.h kTextFrameMax const TEXT_FRAME_MAX = 120; // characters per P= frame == config.h kTextFrameMax
const TEXT_JOB_MAX = 2048; // characters per run == config.h kTextJobCap const TEXT_JOB_MAX = 2048; // characters per run == config.h kTextJobCap
const TEXT_PACE_MS = 12; // milliseconds per character we ask for const TEXT_PACE_MS = 12; // milliseconds per character we ask for
+4 -4
View File
@@ -67,7 +67,7 @@ namespace {
// tests/test_relayctl.py ("the page the transport sends"). The page carries one // tests/test_relayctl.py ("the page the transport sends"). The page carries one
// 19-byte #if KR_TLS build marker and both spellings are the same length, so this // 19-byte #if KR_TLS build marker and both spellings are the same length, so this
// number is build-independent -- a fact test_relayctl pins, not a hope. // number is build-independent -- a fact test_relayctl pins, not a hope.
constexpr size_t kServedPageBytes = 59286; constexpr size_t kServedPageBytes = 59262;
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */ /** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
struct DrainLog { struct DrainLog {
@@ -311,10 +311,10 @@ int main() {
CHECK_EQ(fs.sent(), 20); CHECK_EQ(fs.sent(), 20);
CASE("flash send: the real page, on the real budget"); CASE("flash send: the real page, on the real budget");
// 59 286 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write // 59 262 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
// and kPageBytesPerTick per tick: the exact numbers the device check compares against. // and kPageBytesPerTick per tick: the exact numbers the device check compares against.
{ {
// A stand-in of the served page's size (59 286 B, the `page bytes:` line the gate // A stand-in of the served page's size (59 262 B, the `page bytes:` line the gate
// prints). tests/test_relayctl.py pins this constant against the raw string in // prints). tests/test_relayctl.py pins this constant against the raw string in
// src/web_page.h, so if the page changes and this number does not, a check fails -- // src/web_page.h, so if the page changes and this number does not, a check fails --
// the same "test the table" rule as the mouse bits and the evdev codes. // the same "test the table" rule as the mouse bits and the evdev codes.
@@ -342,7 +342,7 @@ int main() {
CHECK_EQ(total, sizeof(big)); CHECK_EQ(total, sizeof(big));
CHECK(worstPiece <= kPageChunkBytes); CHECK(worstPiece <= kPageChunkBytes);
CHECK(worstTick <= kPageBytesPerTick); CHECK(worstTick <= kPageBytesPerTick);
// 59 286 / 1460 = 41 chunks; / 5840 per tick = 11 ticks. If either number changes in // 59 262 / 1460 = 41 chunks; / 5840 per tick = 11 ticks. If either number changes in
// config.h this line moves, and the report has to move with it. // config.h this line moves, and the report has to move with it.
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 11); CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 11);
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 41); CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 41);
+354 -3
View File
@@ -9,8 +9,12 @@
// that matter, which are what the log is allowed to contain, which characters may // 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. // 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. // It is not a rendering test -- nothing here lays out a page. But it is the only
// tests/run_tests.sh skips it when node is not installed. // 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"; "use strict";
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
@@ -183,7 +187,9 @@ if (from < 0 || to < 0) {
const pageScript = served.slice(from + "<script>".length, to); const pageScript = served.slice(from + "<script>".length, to);
const context = vm.createContext(sandbox); 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 x = sandbox.__x;
const logEl = x.logEl; 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"); "coming back is one deliberate click away, never zero");
x.setArmed(false, "harness: done"); 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"); console.log("element ids");
const markup = served; const markup = served;
const present = new Set(); 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(); const missing = [...requestedIds].filter(id => !present.has(id)).sort();
checkEq(missing.join(","), "", checkEq(missing.join(","), "",
"every element the script reached for exists in the page"); "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`); console.log(`page ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);