**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
7.2 KiB
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:
- Make the wall reusable (tasks 01–02).
/kioskmust not fork 400 lines of tile/WebRTC/stage code (AGENTS.md§6: reuse, don't duplicate), so the wall's core moves out ofwatch.jsintostatic/wall.jsand both pages consume it. Those two tasks change no behaviour — phases 01–06's suites are the gate. - 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.layoutis applied before the first roster tile finishes painting. A person who points a kiosk at/kioskmust 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 forf. - 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/kioskmay share with another page, and#netstays 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) — thearrange/layoutframes,hello.kiosk,welcome.layout, thefollowerscount,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
01_extract_wall_tiles_and_stage.md— the tile/peer/stage/mute core ofwatch.jsbecomesstatic/wall.js(createWall());app.cssre-keys the stage rules from#watch-stageto a.stageclass. No behaviour change.02_extract_wall_key_router.md— the singlekeydownswitch becomesinstallWallKeys()inwall.js, dispatching named commands to the page;/watch's key map is byte-identical in effect.03_kiosk_page_and_route.md—static/kiosk.html,GET /kioskinapp.py(and inPAGESforno-store), and every structural guard intest_app.pyextended to a third page.04_kiosk_layout_applier.md—static/kiosk.js:hello{kiosk:true}, the wall fromcreateWall(),applyLayout()onwelcome/layout, and local keys published back witharrange.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/kioskdocs.
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.pypass 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>ismuted playsinline, nothing from a CDN, the address gate refuses/kiosklike the rest of the app. A new guard:static/wall.jscontains 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 theconftestframes_matching()helper) and ondata-layout/data-fullscreen— never on whether a handler ran. Fullscreen is asserted on the stage class, not ondocument.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 e2egreen, including the newtest_kiosk_layout.py, with no existing e2e assertion edited- pointing a browser at
/kioskand sharing a mirror from a second browser process shows that mirror on the wall with no interaction on the first - a keypress on one
/kioskpage re-arranges a second/kioskpage (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") /kioskrenders no header, no share link, and refuses a blocked address with the same explanation page as/watchuv run pytest tests/test_app.pygreen with the guards extended to three pages, andwall.jsproven free of document-scoped lookups- all four gates from
AGENTS.md§4 green,MIRROR_REALDISPLAY=1 -m realdisplayincluded - full test suite 100% passing, coverage >90% on new code
- README.md documents
/kiosk;PLAN.mduntouched