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
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