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

The one TODO item — a /kiosk endpoint whose state (fullscreen, which
mirror) any client can manipulate — becomes three phases, since it is
three workstreams:

- 07_kiosk_layout_state: the state on the server. `arrange` in, `layout`
  out (staged/fullscreen/followers), Registry policy with a stale-staged
  fallback on teardown, `GET /api/kiosk` for curl-level debugging.
- 08_kiosk_page: the wall made reusable (`static/wall.js`, behaviour-
  preserving) and the chrome-free /kiosk page that arranges itself from
  the state and publishes a local keypress back.
- 09_wall_remote_page: /remote, the controller you open on a phone —
  shows no media and subscribes to nothing, proven by /api/streams'
  watcher count — plus the security line and the kiosk box's new URL.

Locked anchors hold: no third role, state stays in memory (A7), Registry
stays pure (A8), no external assets (A10), no remote audio command
(SECURITY.md §4). PLAN.md untouched, per the user's decision, so its §7
roadmap table does not list 07-09.

LLM-Generated: true
This commit is contained in:
2026-11-03 09:15:12 -05:00
parent 3f93481c54
commit 1f60a6b40c
14 changed files with 1526 additions and 0 deletions
@@ -0,0 +1,106 @@
# Phase 08 — The `/kiosk` page: a wall that arranges itself
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences." (the display-client half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A9 (hand-written ES modules, no framework/bundler), A10 (nothing from outside the origin), A13 (Playwright, `channel="chromium"`); `AGENTS.md` §6 (reuse `net.js` rather than duplicating socket logic; ids must not collide across pages); phase 01 (`01_keyboard_kiosk_control` — the keyboard router, the stage mechanics, the mouseless rule); phase `07_kiosk_layout_state` (the `arrange`/`layout` contract, `hello.kiosk`, `GET /api/kiosk`); `src/mirror/static/watch.js`, `watch.html`, `app.css`, `net.js`; `docs/kiosk-silverblue.md` (the target box: Firefox `--kiosk`, **no keyboard, no mouse, no console**)
## Objective
`GET /kiosk`: a chrome-free wall for an unattended display that subscribes to every
mirror like `/watch` does and then **does what the server state says** — stages the
mirror the state names, fullscreen or not — on arrival and on every change, with no
input from anyone standing at the box.
The phase is deliberately two halves:
1. **Make the wall reusable** (tasks 01–02). `/kiosk` must not fork 400 lines of
tile/WebRTC/stage code (`AGENTS.md` §6: reuse, don't duplicate), so the wall's
core moves out of `watch.js` into `static/wall.js` and both pages consume it.
Those two tasks change **no behaviour** — phases 01–06's suites are the gate.
2. **Build the page on it** (tasks 03–05): the route, the markup, the layout
applier, and the browser proof.
## Rules this page lives by
* **Arrival is arrangement.** `welcome.layout` is applied before the first roster
tile finishes painting. A person who points a kiosk at `/kiosk` must not have to
touch it.
* **The state is the boss, locally too.** A keypress on the kiosk performs the
action *and* publishes it (`arrange`), so the controller and the display cannot
disagree — same as every other shared state: one writer path, everyone re-reads.
* **Fullscreen is best-effort, the stage is the guarantee.** A remote command
carries no user activation, so `requestFullscreen()` may be refused; the in-page
stage fills the viewport anyway and the Silverblue window manager already forces
the window fullscreen (`docs/kiosk-silverblue/window-config.ini`). The page asks
and never blocks on the answer — exactly the stance phase 01 documented for `f`.
* **Never invent state.** No auto-staging of the newest mirror, no remember-last
across a server restart. An empty state means the wall.
* **No audio commands.** The layout carries no sound field (phase 07,
`SECURITY.md` §4); a display never becomes audible because a stranger asked.
* **The chrome stays off the screen.** No header bar, no "Share a screen" button,
no counts in a title bar — a display shows mirrors. The three shared ids
(`net`, `notice`, `toast`) are the only elements `/kiosk` may share with another
page, and `#net` stays because the connection state is the one thing that must be
legible in a screenshot of a wall nobody is looking at.
## Dependencies
- `07_kiosk_layout_state` (todo) — the `arrange`/`layout` frames, `hello.kiosk`,
`welcome.layout`, the `followers` count, `GET /api/kiosk`. Task 01 of this phase
has no dependency on it and may run first, but the phase is not complete until
07 exists.
- `01_keyboard_kiosk_control` (complete) — the router, `stage()`/`unstage()`, the
mouseless rule and the pointer-free suite this phase extracts and extends.
- `06_sharer_quality_presets` (complete) — the current behaviour of the wall.
## Tasks
1. `01_extract_wall_tiles_and_stage.md` — the tile/peer/stage/mute core of
`watch.js` becomes `static/wall.js` (`createWall()`); `app.css` re-keys the
stage rules from `#watch-stage` to a `.stage` class. No behaviour change.
2. `02_extract_wall_key_router.md` — the single `keydown` switch becomes
`installWallKeys()` in `wall.js`, dispatching named commands to the page;
`/watch`'s key map is byte-identical in effect.
3. `03_kiosk_page_and_route.md` — `static/kiosk.html`, `GET /kiosk` in `app.py`
(and in `PAGES` for `no-store`), and every structural guard in `test_app.py`
extended to a third page.
4. `04_kiosk_layout_applier.md` — `static/kiosk.js`: `hello{kiosk:true}`, the wall
from `createWall()`, `applyLayout()` on `welcome`/`layout`, and local keys
published back with `arrange`.
5. `05_kiosk_layout_e2e_and_docs.md` — `tests/e2e/test_kiosk_layout.py`: a real
sharer, two kiosk displays (isolated contexts, hence separate sockets and peer
ids), and the assertion that one display's keypress re-arranges the other,
proved on the second display's decoded pixels; plus the `/kiosk` docs.
## Testing & Quality
- **Refactor gate (tasks 01–02)**: `tests/e2e/test_keyboard_wall.py`,
`test_live_mirror.py`, `test_audio_share.py`, `test_stage_autofade.py`,
`test_share_layout.py` pass **unedited**. A changed assertion in one of them
during a "behaviour-preserving" extraction is a bug report, not a fix.
- **Structure gate (task 03)**: `test_app.py`'s guards extended — page renders,
`no-store`, `node --check`, every `$("#id")` exists, ids do not collide across
*three* pages, `<video>` is `muted playsinline`, nothing from a CDN, the address
gate refuses `/kiosk` like the rest of the app. A new guard: `static/wall.js`
contains **no** `$("#…")` — one module serving two pages takes its elements as
arguments or it will look them up in the wrong document.
- **Behaviour gate (tasks 04–05)**: a dedicated Playwright suite, pointer-free like
phase 01's (a mouse on the kiosk would defeat the point), asserting on the
*second* display's `<video>` (`videoWidth`, distinct frames via the `conftest`
`frames_matching()` helper) and on `data-layout`/`data-fullscreen` — never on
whether a handler ran. Fullscreen is asserted on the stage class, not on
`document.fullscreenElement`, which the browser may legitimately refuse.
- Coverage: **>90%** on new/modified code. The Python surface added here is one
route; the JS is covered by browser assertions, per `AGENTS.md` §4.
## Completion Criteria
- [ ] `uv run pytest -m e2e` green, including the new `test_kiosk_layout.py`, with **no existing e2e assertion edited**
- [ ] pointing a browser at `/kiosk` and sharing a mirror from a second browser process shows that mirror on the wall with no interaction on the first
- [ ] a keypress on one `/kiosk` page re-arranges a second `/kiosk` page (the shared-state property, asserted cross-page, not in one page's DOM)
- [ ] the staged mirror going away returns both displays to the wall (`data-layout="wall"`)
- [ ] `/kiosk` renders no header, no share link, and refuses a blocked address with the same explanation page as `/watch`
- [ ] `uv run pytest tests/test_app.py` green with the guards extended to three pages, and `wall.js` proven free of document-scoped lookups
- [ ] all four gates from `AGENTS.md` §4 green, `MIRROR_REALDISPLAY=1 -m realdisplay` included
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] README.md documents `/kiosk`; `PLAN.md` untouched