Files
mirror/.agents/phases/todo/08_kiosk_page/00_phase.md
T
ducoterra 1f60a6b40c 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
2026-11-03 09:15:12 -05:00

7.2 KiB
Raw Blame History

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