chore(agent): phase roadmap from TODO.md, 5 phases

The four open items in TODO.md become an executable pipeline: 01 the
verification gate (host suites + sanitiser + both firmware envs, and the
hardware contract so no unit blocks on a board nobody is holding), 02 HTTPS
and wss:// with an identity the board generates itself into NVS, 03 session
takeover that always lands disarmed, 04 a responsive page whose safety
statements are pinned before they are trimmed, 05 the phone as a controller
(press-and-hold pad, soft keyboard, modifier latches over a new Q= frame).

TODO.md is cleared to its empty title; the items live in .agents/phases/ now.
AGENTS.md gains the five protocol rules and nothing else.

LLM-Generated: true
This commit is contained in:
2026-11-08 23:28:25 -05:00
parent ece0313d02
commit 56aa97d60d
28 changed files with 2033 additions and 0 deletions
@@ -0,0 +1,96 @@
# Phase 05 — The phone is a controller: soft keyboard and touch pointer
**Source:** `TODO.md` L5 — "Needs to be mobile compatible - click arm and then mobile
keyboard pops up. Mobile trackpad for touch trackpad."
**Story:** `n/a`
**Context:** `src/web_page.h` (the mouse block, `takeMouse()`/`flushMouse()`/`dropMouse()`,
`inPageField()`, `setArmed()`), `src/text.h` (`strokeFor()`), `src/proto.h`, `src/mouse.h`,
`src/config.h`, `tests/test_page.js`, README "Controller page" / "Safety model"
## Objective
Arm the bridge on a phone and the soft keyboard appears so you can actually type into the
target; press and hold the pad and the target's pointer moves, let go and every button is up.
Two capabilities the desktop gets from hardware — a physical keyboard and Pointer Lock — get
honest software equivalents, with the same guarantees restated in touch terms rather than
waived.
## The two equivalences, stated up front
| desktop guarantee | touch equivalent |
|---|---|
| Pointer Lock: the cursor cannot escape the tab, and `Esc` always frees it | **finger down on the pad is the capture, finger up is the release** — movement stops and every button lets go the instant the touch ends, and the Release button is 44 px and on screen. The capture cannot outlive the finger that asked for it. |
| Modifiers are physical keys held with a second hand | an **on-screen latch row** whose state is lit, restated in every frame, and cleared by a tap — never a sticky Ctrl that silently modifies the next six keys. |
Both are *more* conservative than the desktop case, not less: a touch capture ends when you
stop touching, which is stronger than a lock you have to remember to escape.
## What must not be traded away
- Movement is **relative**, one report per frame, deltas capped at ±127 and split so the net
displacement survives (invariant 7: splitting is the controller's job).
- No auto-click, no gesture, no macro, nothing scheduled. The button row is held-while-pressed.
- Capture requires armed **and** the board reporting `mouse:1`, and `E10`/`E11` still refuse
rather than ignore.
- Characters are typed by the board's own map. The page must **not** grow a second
character→usage table: the board owns `strokeFor()`, which is why the new `Q=` frame carries
*modifiers and hex bytes*, not usages. (`AGENTS.md`: when you add a table that decodes
something somebody else defined, test it — better still, do not add it.)
- Anything a touch session can leave behind (a latch, a held button, a focused field) dies in
the same funnel: disarm, blur, visibility change, link loss.
## Dependencies
- `04_controller_page_responsive` (todo) — the single-column layout, 44 px targets and
`touch-action:manipulation` are what make a touch pad usable; the folded prose is what makes
room for the latch row and the button row.
- `03_session_takeover` (todo) — `E12` handling and the `busy` pill, because a phone is exactly
the device that finds the laptop holding the bridge.
## Tasks
1. `01_touch_trackpad.md` — the pad: press-and-hold capture, relative movement from
`touchmove`, two-finger drag as the wheel, an explicit held-while-pressed button row, and
release on `touchend`/`touchcancel`/blur/disarm.
2. `02_soft_keyboard_capture.md` — ARM focuses a forwarding field so the soft keyboard pops;
printable characters leave as `P=0:<hex>` one per frame, named keys (Enter, Backspace, Tab,
arrows…) as a press/release pair; the field is cleared as soon as it is read.
3. `03_modifier_latch_q_frame.md` — the latch row and the `Q=<mods>:<hex>` frame that lets a
latched modifier type a character without the page owning a second character map. A real
four-place protocol change: `proto.h`, `web_page.h`, `relayctl.py`, README.
4. `04_mobile_tests_docs.md` — `test_page.js` touch/latch coverage, the named-key table pinned,
README's Controller page and Safety model rewritten for touch, `AGENTS.md`'s "three tables"
row, and the phone observation recorded.
## Testing & Quality
- `tests/test_page.js` — the shim already records document/window listeners and can `fire()`
them; extend it to dispatch `touchstart`/`touchmove`/`touchend`/`touchcancel` with multiple
touch points and `beforeinput` with `inputType`, then assert:
capture requires armed and `mouse:1`; one frame per report with nothing sent when idle; a
400×300-style drag split so no delta exceeds a signed byte and the net displacement is exact;
two-finger drag becomes wheel/pan not movement; a finger up releases every button **and**
stops movement; the button row's bits match `MOUSE_BTN`; the latch row's byte matches the
modifier names; a character becomes exactly one `P=0:` frame and a latched one becomes one
`Q=` frame; `E9`/`E10`/`E11` land in the log and cancel nothing they should not.
Every frame the page emits is parsed by the firmware's own parser — the suite rule that has
already caught mislabelled chords.
- `tests/test_proto.cpp`: `Q=` parses, bounds-checks (mods byte, cap on hex length), refuses
malformed input, round-trips.
- `tests/test_relayctl.py`: the tool builds `Q=` identically, and the button/name tables and
`MOUSE_BUTTONS` agreement extends to whatever the touch rows introduce.
- Coverage **>90%** on the changed page script and the new parser path.
- Device (best effort, one attempt): a phone on the board's AP — arm, keyboard appears, type a
word and `watch_target.py` shows the characters; hold the pad, move, lift, and the target
shows motion that stops and no button stuck.
## Completion Criteria
- [ ] Arm on a phone brings up the soft keyboard and typed characters land on the target.
- [ ] Hold the pad → the pointer moves; lift → motion stops and every button is up, observed
with `watch_target.py` or recorded as `skipped (no device)` with the exact commands.
- [ ] `Ctrl+C`-equivalent reachable with the latch row and lands as Ctrl+C on the target.
- [ ] `Q=` in the README wire table, `proto.h`, `web_page.h` and `relayctl.py`, with the tests
that pin all four agreeing.
- [ ] No second character→usage table anywhere; the named-key table that does exist is pinned.
- [ ] `bash .agents/validate.sh` green; README and `AGENTS.md` describe the touch model.
@@ -0,0 +1,81 @@
# Task 01 — Touch pad: the capture is the finger
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible
— … **Mobile trackpad for touch trackpad.**"
**Story:** `n/a`
## Objective
`#mPad` becomes a touch pad: press and hold it and the target's pointer moves with your
finger, drag two fingers to scroll, and the moment the touch ends the movement stops and every
button lets go. Pointer Lock does not exist for touch, so the capture is the touch itself.
## Work
1. `src/web_page.h`, the mouse block:
- `touchstart` on `#mPad`: requires `armed` and `mouseAvailable()` (`s.mouse === 1`) — the
same two gates as `takeMouse()`, and the same `E10`/`E11` refusal path if the board
disagrees. Sets `mTouchActive`, records the anchor point, and takes **no** button.
- `touchmove`: compute the delta from the previous sample, feed the existing movement
pipeline (the code behind `flushMouse()`) so the ±127 cap and the multi-frame split are
the ones already tested — do not write a second splitter. Coalesce per report the way
`mousemove` does, and drop movement when there is no link rather than queueing it
(the page's existing rule: a queue of pointer motion delivered after you stopped moving
is the pointer version of a stuck key).
- Two-touch drag → wheel/pan through the same quantiser the desktop `wheel` handler uses
(notches with the remainder carried), not movement. `touchmove` with two fingers must not
also emit dx/dy.
- `touchend`/`touchcancel`, and `blur`/`visibilitychange`/disarm/link loss: end the capture,
`dropMouse()` (which releases every button through the funnel), reset the anchor. `touchcancel`
is the browser taking the gesture away — incoming call, notification shade, a palm — and it
must land exactly like lifting the finger.
- A visible button row beside the pad: **Left / Right / Middle**, held while pressed
(`touchstart` down, `touchend` up), carrying the existing `MOUSE_BTN` bits. Tap on the pad
itself never clicks. The row is 44 px and shows which bits are down, so "left is held" is
a thing you can see before you ask what is stuck.
- The pad's label changes with state (`not captured` / `captured — move to move, lift to
release`) since a phone has no cursor to indicate either.
2. `refreshMouseUi()` / `paintMouse()` / `mouseAvailable()` — teach them the touch state so
the pills say what is true. `mCaptured` (Pointer Lock) and `mTouchActive` are different
states that must render distinctly and never both claim the pointer.
3. Keep the desktop path byte-for-byte identical: `mousemove`, the Pointer Lock request,
`Esc`, `wheel` on a mouse — phase 04's assertions and today's `test_page.js` mouse checks
must stay green without edit.
4. `tests/test_page.js` — extend the shim for multi-point touch and add: capture requires
armed + `mouse:1`; a drag produces one `M=` per flush with no idle frames; a large drag
splits with the net displacement exact; two fingers produce wheel/pan and no dx/dy;
`touchend` and `touchcancel` each release every button and stop movement; button-row bits
equal `MOUSE_BTN`; every emitted frame parses with the firmware's own parser.
5. `README.md` — Controller page: the pad row of the element table describes the touch
behaviour; "What capture does and does not do" gains the touch sentence (this is
elaborated properly in task 04 — here, only what this task changes).
- ASSUMPTION: one finger moves, two fingers scroll, and clicking is an explicit held button
rather than a tap. Tap-to-click on a pad whose whole job is movement produces clicks the
operator did not ask for, and on a remote keyboard a click the target did not expect is the
worst class of surprise.
- ASSUMPTION: no "tap-and-hold to drag" (holding a button while moving). It needs a second
simultaneous gesture and any mis-timing strands a button on the target; the button row gives
the same capability with visible state.
- ASSUMPTION: scale of finger-delta to HID delta is 1:1 (one CSS pixel → one report unit) at
first. If it feels slow on hardware, the tuning knob goes in `config.h`-style naming on the
page side with the measured value in the comment — the board must not invent a pointer speed
of its own.
## Testing & Quality
- `node tests/test_page.js`: the new touch checks pass and the existing mouse checks are
unmodified (prove it: the diff of that test file adds, it does not change, mouse assertions).
- `bash .agents/validate.sh` green; `test_proto.cpp`, `test_mouse.cpp`, `test_output.cpp`
untouched.
- Device (best effort, one attempt): a phone on the board's AP — movement lands, lift stops
it, and `watch_target.py` shows no stuck button across 20 press/release cycles.
## Completion Criteria
- [ ] Press-and-hold on the pad moves the target's pointer; lifting stops it and releases
every button (observed, or recorded `skipped (no device)` with the commands).
- [ ] Two-finger drag scrolls and never moves the pointer.
- [ ] Left/Right/Middle are held-while-pressed and visible; tap on the pad clicks nothing.
- [ ] Desktop Pointer Lock path unchanged and still covered by the pre-existing checks.
- [ ] Full test suite green.
@@ -0,0 +1,94 @@
# Task 02 — Arm on a phone and the keyboard appears
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
**click arm and then mobile keyboard pops up.**"
**Story:** `n/a`
## Objective
Pressing ARM on a touch device focuses a forwarding field so the soft keyboard opens and stays
open, and everything typed into it becomes keystrokes on the target: printable characters via
one `P=0:<hex>` frame each, named keys as a press/release pair — with nothing held in the page
any longer than the frame that carries it.
## Work
1. `src/web_page.h`:
- A forwarding field: `<input id="keyfield" type="text" autocomplete="off" autocapitalize="none"
autocorrect="off" spellcheck="false" enterkeyhint="done" inputmode="text">`, visible while
armed on a coarse pointer (a visible field is honest: you can see the caret is in it),
collapsed to a one-line "keyboard" strip otherwise.
- `$("arm").addEventListener` → after arming is confirmed by the board (`setArmed(true)`),
`focus()` the field when `matchMedia('(pointer: coarse)').matches` — that is the
"keyboard pops up" the TODO asks for. Disarming blurs it and clears it.
- Read it with `beforeinput`, not `keydown`: soft keyboards are unreliable about `keydown`
with a usable `key`/`code` (Gboard and iOS both deliver `beforeinput` with `inputType`),
and `beforeinput` is the one event that says what the keyboard actually inserted:
- `insertText` → for each character, `send("P=0:" + hex(charBytes))` — the board's own
`strokeFor()` does the mapping and the pacing, so **no second character table on the
page** and no `Shift` guess on the laptop side;
- `deleteContentBackward` → Backspace; `insertLineBreak`/`insertParagraph` → Enter;
`deleteWordBackward` → a few Backspaces, documented, not a clever trick;
- then `field.value = ""` immediately. The field is a courier, not a buffer: invariant 4
says nothing typed may outlive the keystroke, and a DOM node full of the last paragraph
you sent is exactly that.
- Named keys (`keydown`, where `ev.key` is a name and not a character): Enter, Tab,
Backspace, Delete, Escape, the four arrows, Home/End/PageUp/PageDown — a small
`KEYNAME_TO_USAGE` map (≤20 entries, usage ids from `CODE_TO_USAGE`), sending
`S=<mods>:<usage>` then `S=<mods>:` (release) — a press/release pair, not an event, and it
self-heals if one of the two is lost.
- `inPageField()`: the passphrase box, the paste textarea and the log stay exempt from
forwarding; `#keyfield` is the **deliberate exception** and the comment must say so —
otherwise this field is "fixed" back into the exemption in six months and the phone stops
working.
- `Escape` from the soft keyboard is forwarded to the target (there is no Pointer Lock to
free on touch). While a desktop Pointer Lock is held, `Esc` stays the browser's — the
existing rule is not relaxed, it simply does not apply to a device that never took the lock.
- The field is never `preventDefault`ed (a soft keyboard that is cancelled does not type at
all), which is also why the desktop shortcut-swallowing logic must stay scoped to
non-coarse input.
2. `src/config.h` / page constants — nothing new for `P=`; assert the existing
`TEXT_FRAME_MAX` still bounds a single character trivially and that a rapid burst of
characters produces one frame each and is refused with `E8` (log it) if the board's queue
is full, rather than the page buffering them.
3. `tests/test_page.js` — extend the shim with `beforeinput`/`inputType` and a focusable
element, then assert: arming focuses the field on coarse pointers only; each inserted
character produces exactly one `P=0:` frame with the right hex; `field.value` is empty after
each event (nothing accumulates); Backspace/Enter produce the press/release pair; the
passphrase field and paste box still swallow input; disarm blurs and clears the field; a
`P=0:` frame the firmware cannot express yields the `E9` log line and nothing else is typed.
4. `README.md` — Controller page: the keyboard field row and the sentence that characters are
typed by the board's map, which is why the phone needs no layout of its own — and the
consequence that a non-US target still means non-US output (`E9` rather than a guess).
- ASSUMPTION: `beforeinput` as the primary source with `keydown` as the fallback for named
keys. Reading `value` on `input` instead would lose the distinction between "inserted" and
"autocompleted", and iOS predictive replacement arrives as `insertReplacementText` — which
this task deliberately **refuses** (log it, type nothing) because accepting a completion the
operator did not press is the same family of failure as silently sanitising a paste.
- ASSUMPTION: emoji, CJK and anything else `strokeFor()` cannot express produce `E9` per
character. That is the reject-never-guess rule arriving on the phone, and the log line says
what happened so it does not look like the board dropped keys.
- ASSUMPTION: the field is visible while armed on a phone. A hidden focused input that pops a
keyboard with no on-screen indication of *what* is receiving the keys is exactly the kind of
invisible state this project keeps having to fix.
## Testing & Quality
- `node tests/test_page.js` with the new focus/`beforeinput` coverage; every frame the page
emits still parsed by the firmware's parser; existing paste assertions unchanged (the paste
path and the keyboard path both use `P=` and must not interfere — assert that a keyboard
frame during a paste run does not disturb the paste's flow control).
- `bash .agents/validate.sh` green; page byte growth recorded against the gate's count.
- Device (best effort, one attempt): phone, arm, keyboard appears, type `relay` and
`watch_target.py` on the target shows `r e l a y` in order with no duplicates; type an emoji →
one `E9` and nothing on the target.
## Completion Criteria
- [ ] ARM on a phone opens the soft keyboard without a second tap.
- [ ] Typed characters land on the target, one frame each, in order, with no duplicates and no
accumulation in the field.
- [ ] Backspace and Enter work as press/release pairs; autocomplete text is refused, not typed.
- [ ] The passphrase box and paste box still swallow input while armed.
- [ ] Full test suite green; `src/text.h` untouched (the map did not move).
@@ -0,0 +1,92 @@
# Task 03 — Modifiers you can see: the latch row, and `Q=` to carry them
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible…"
(a soft keyboard sends no modifier keys, so without this the phone can type letters and
nothing else — no Ctrl+C, no Alt+Tab, no Ctrl+L)
**Story:** `n/a`
## Objective
An on-screen Ctrl / Alt / Shift / Super latch row whose state is visible and restated on every
frame, and one new frame — `Q=<mods>:<hex>` — that says "type these characters with these
modifiers held", so the **board's** character map stays the only character map. This is a
wire-protocol change and therefore four edits plus tests, exactly as `AGENTS.md` demands.
## Work
1. `src/proto.h` — `Cmd::TypedWithMods` for `Q=<mods>:<hex>` and a documented line in the
header comment's direction table:
`c -> d Q=<mods>:<hex> type these characters with <mods> held in addition to whatever
each character's own stroke needs`. Same bounds as `P=` (`kTextFrameMax` characters per
frame, cap on the hex length), same all-or-nothing acceptance, same `E9` for a character the
map cannot express.
2. `src/text.h` — the stroke path gains the modifier overlay: for each character,
`strokeFor()` + `OR` the frame's mods, then the same pacing and the same report sequence.
Two hard refusals, both `E6`/`E9` per the existing convention and **never** silently
clipped (invariant 5):
- **`Ctrl+Alt+Del` cannot be composed this way.** If the overlay plus the stroke's own mods
produce Ctrl+Alt and the usage is `Del` — or the usage set ever contains `0x4c` with
Ctrl+Alt — refuse the whole frame. Invariant 6 says CAD is fixed on the device and never
assembled from wire bytes; `Q=` must not become the door around that rule. Same for the
VT-switch chords the README documents as dangerous on a Linux VT.
- The overlay never releases a key: `Q=` types and lets go, it does not latch state.
3. `src/main.cpp` / `src/output.h` — wire the new command into the same typed-run machinery
`P=` uses (one run, one funnel: `R`, disarm, disconnect cancel it). If the queue is full the
frame is refused with `E8` like `P=`, and `typing` counts it.
4. `src/web_page.h`:
- A latch row: `Ctrl Alt Shift Super`, sticky toggles, using the existing `MOD_CODE` bits,
lit when active, `44px`, grouped with the existing chord buttons so there is one place
for "keys the phone does not have".
- Printable characters: `P=0:<hex>` when no latch is down, `Q=<mods>:<hex>` when one is.
The latch is cleared by tapping it again, by disarm, by blur/visibility change and by link
loss — never left set between sessions (a sticky Ctrl that silently modifies the next six
characters is this project's stuck-key bug with a new face).
- Named keys keep using `S=<mods>:<usage>` (it already carries the modifier byte) and
include the latches in that byte.
- A "held now" echo of the latches in the existing `#held` readout, so what the board is
being told is on screen.
5. `tools/relayctl.py` — `type --with-mods ctrl+alt TEXT` (or the equivalent subcommand shape
the tool already uses) building `Q=` byte-for-byte like the page; the tool's `MOUSE_BUTTONS`
/ modifier name tables extended and pinned against the page.
6. `README.md` — the **Wire protocol** table gains `Q=` with its modifiers column and refusal
rules; the error-code list gets whatever `Q=` refuses with; the Controller page element
table gains the latch row; **Layout caveats** gains one sentence: the phone borrows the
board's map, so the same US-layout caveat applies to it.
- ASSUMPTION: `Q=` rather than making the page resolve characters to usages. A second
character→usage table would have to be tested against `strokeFor()` forever — the
"three tables that must agree" problem `AGENTS.md` exists to prevent, self-inflicted. One
frame with a modifier byte keeps the map in `text.h`, which is already walked across the
whole printable range by `test_text.cpp`.
- ASSUMPTION: sticky (toggle) latches, not press-and-hold. On a phone there is no second hand
free to hold Ctrl while tapping, and a latch whose state is lit and restated is verifiable
in a way a half-held key is not.
- ASSUMPTION: `Q=` inherits `P=`'s pacing (`0` = the board's default). A phone cannot type
faster than the board's pace anyway, and letting the controller choose the pace for a
latched character buys nothing but a new way to mis-time a target.
## Testing & Quality
- `tests/test_text.cpp`: overlay arithmetic (no mods, Ctrl only, Ctrl+Shift where the stroke
already needs Shift), refusal cases including the composed-CAD attempt and the VT chords, the
exact report sequence for `Q=05:61` (Ctrl+Alt+a) asserted byte by byte, cancellation of a `Q=`
run by `R`/disarm, buffer-full behaviour, and the `Q=` render/parse round trip at the largest
legal frame.
- `tests/test_proto.cpp`: `Q=` parses, malformed variants (`Q=`, `Q=zz:`, `Q=05`, over-long
hex) are `Cmd::Bad`, and the existing `P=`/`S=`/`Y=` assertions unchanged.
- `tests/test_relayctl.py`: the tool's `Q=` frames are byte-identical to the page's, parsed by
the firmware's own parser; modifier-name tables agree across page, tool and `config.h`.
- `tests/test_page.js`: latch toggles produce the right byte; a character with a latch produces
one `Q=` frame; disarm/blur clears the latch; the chord buttons still send their
`data-chord` bytes unchanged (`test_proto.cpp`'s label-vs-bytes assertion must stay green).
- Coverage **>90%** on the parser and stroke-overlay changes.
## Completion Criteria
- [ ] `Q=` present and identical in `proto.h`, `web_page.h`, `relayctl.py` and the README table.
- [ ] Ctrl+C on a phone lands as Ctrl+C on the target (observed with `watch_target.py` or
recorded `skipped (no device)` with the commands).
- [ ] `Q=` cannot compose Ctrl+Alt+Del or the VT-switch chords — proven by a test that fails
when the refusal is removed (show both runs), and the reasoning is in `text.h`.
- [ ] Latches clear on disarm, blur and link loss, and are shown in the held-keys readout.
- [ ] Full test suite green, including the sanitiser run (this task changes buffer arithmetic).
@@ -0,0 +1,78 @@
# Task 04 — Make the touch model legible, and prove the tables
**Phase:** `05_mobile_controller` · **Source:** `TODO.md:5` — "Needs to be mobile compatible -
click arm and then mobile keyboard pops up. Mobile trackpad for touch trackpad." (the half
that keeps it working after this chat is gone)
**Story:** `n/a`
## Objective
The touch equivalences are written down where they will be read before someone "improves"
them, the one new table this phase introduces is pinned against what it maps to, and the phone
observations are recorded as observations.
## Work
1. `tests/test_page.js` — the last touch assertions, added in the suite's existing voice:
every frame the page emits in touch mode parses with the firmware's own parser; the pad's
frames never exceed one signed byte in either axis; `touchcancel` is treated exactly like
`touchend` (a notification shade stealing the gesture must not leave a button down); the
pad capture ending does **not** disarm the keyboard (release and disarm are different acts,
already true for `R`); the mouse capture and the keyboard stay independent — taking the pad
does not arm, and disarming drops the pad.
2. `tests/test_relayctl.py` — the named-key table (`KEYNAME_TO_USAGE`, the ≤20-entry page map
for Enter/Tab/arrows/…) pinned: every entry's usage id checked against the value the
firmware's parser and `CODE_TO_USAGE` agree on, and the set of names checked against a list
in the test — the table decodes something the HID spec defined, so the *table* is tested,
not anybody's confidence in it (`AGENTS.md`, the `REL_*` lesson: codes guessed from field
order were wrong while the feature appeared to work).
3. `README.md`:
- **Controller page**: a short "On a phone" block — arm focuses the forwarding field, the
latch row, the pad's press-and-hold, the button row, `E9` on emoji.
- **Safety model**: the touch equivalences written as rules, not as UI description —
finger-down is the capture and finger-up ends it; the latch is visible and cleared by every
fail-safe; `Esc` cannot be forwarded from a soft keyboard because the phone's panic path is
the Release button, which is 44 px and on screen. Add the sentence that says *why* the
touch capture is stronger than Pointer Lock rather than weaker.
- **Security model**: the trust-boundary row for "Whoever holds the phone" — pointer only
while armed and while the finger is down.
- **Wire protocol**: `Q=` already landed in task 03; here the surrounding prose (the
`<mods>` explanation, the refusal list) is brought in line with what the code actually does.
- **Tests** table: the new counts, and the touch/table pins named the way the mouse pins are
named. Intro total updated from real output.
- **Measured behaviour**: the phone numbers — keyboard latency per character through `P=`,
pad deltas per cm of travel, anything `watch_target.py` showed. Numbers, with the command.
- **Backlog**: anything this phase discovered and deliberately did not do (e.g. gesture-free
right-click on a tablet with a hover-capable pointer, a `beforeinput` path for
autocompletion, a non-US soft keyboard), so the discovery is not lost.
4. `AGENTS.md` — the "Three tables that must agree" table gains the one new row this phase
created (`KEYNAME_TO_USAGE` in `src/web_page.h`, pinned by `tests/test_relayctl.py`), in the
existing format. Nothing else: the file is the operating manual, not a changelog.
5. `.agents/HW_VERIFICATION.md` — the phone rows: keyboard pops on ARM, characters in order,
latch combo on the target, pad move/stop/no stuck button, `E9` on emoji — each `ok` with the
command and what was seen, or `skipped (no device)` with the command to run.
6. `.agents/PLAN.md` — no edits (it is locked); if this phase revealed that an anchor was
wrong, that goes in the phase report and the final pass raises it, not into the file.
- ASSUMPTION: the named-key map stays page-only (the terminal tool does not need a soft
keyboard). The pin exists so the page's map cannot drift from the HID ids it names, not to
synchronise two copies.
## Testing & Quality
- Full gate green: suites, sanitiser run, `bridge` + `console` builds.
- Each new pin demonstrated to fail on an injected break (a wrong usage id in
`KEYNAME_TO_USAGE`, a `touchcancel` handler removed), then reverted.
- README internally consistent: intro counts, Tests table, Controller page, Safety model and
Backlog all describe the same page the board serves (`curl -sk … | wc -c` agrees with the
gate's number).
## Completion Criteria
- [ ] `KEYNAME_TO_USAGE` pinned by name **and** by usage id, with the failure demonstration.
- [ ] Touch safety rules in the README as rules, including why finger-up is stronger than Esc.
- [ ] `AGENTS.md` table row added, nothing else in that file changed.
- [ ] Phone observations recorded honestly (`ok` or `skipped (no device)` + command) — no claim
in that file that nobody made.
- [ ] `bash .agents/validate.sh` green; full test suite green; no regression in phases 01–04
(TLS still terminates, takeover still disarms, layout still single-column).