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
@@ -0,0 +1,125 @@
# Task 01 — Extract the wall (tiles, peers, stage) into `static/wall.js`
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) … [that] will automatically arrange itself" (the wall has to exist twice before a page can arrange it)
**Story:** n/a
## Objective
Move the wall's core out of `watch.js` into a reusable `static/wall.js` module that
takes its elements and its socket from the page, and have `watch.js` consume it.
**Pure refactor: `/watch` must behave and look exactly as it did.** The kiosk page is
impossible without this, and forking the tile/WebRTC code instead would violate
`AGENTS.md` §6.
## Work
1. `src/mirror/static/wall.js` (new) — a factory, no document queries, no socket of
its own, no top-level side effects beyond the export:
```js
/**
* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
* stage the staged tile's own element moves into (so its <video> and its
* connection never restart — AGENTS.md §6).
*
* Nothing here queries the document: the page hands over the elements it owns,
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
* ids must not collide (test_the_two_pages_share_no_element_ids…).
*
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
* send: (payload: object) => boolean,
* onChange?: (summary: {mirrors: number, live: number}) => void,
* stats?: boolean}} options
*/
export function createWall(options) { … }
```
Return the surface the pages need — at minimum `absorb(stream)`,
`drop(streamId)`, `onSignal(message)`, `stage(streamId, options)`,
`unstage(options)`, `setMuted(tile, muted)`, `ordered()`, `selectedId()`,
`setSelected(streamId)`, `moveSelection(delta)`, `tile(id)`, `closePeers()`, and
a `stagedId()` accessor. Keep the existing function bodies as the implementation;
the change is *which object owns the state*, not what the code does.
2. `src/mirror/static/wall.js` — move, verbatim except for the `ui.`/`state.` reads
the factory replaces, from `watch.js`: `tiles`, `makeTile`, `paintTile`,
`dropTile`, `closePeer`, `ensurePeer` (with its `ontrack`/`onicecandidate`/
`onconnectionstatechange` handlers and the retry-with-backoff), `onSignal`,
`setMuted`, `subscribe`, `absorb`, `orderedTiles`, `stage`, `unstage`,
`revealStageControls` + `stageRevealTimer` + `STAGE_REVEAL_IDLE_MS`,
`fullscreenExitInFlight`, and the stats ticker. Every comment that explains *why*
(the single-MediaStream-per-tile story, the audio-only-on-stage policy, the
fullscreen-exit race, "never move `srcObject`, move the element") travels with
the code — those comments are why phases 01–06 do not get re-broken.
`renderCounts()` does **not** move: it writes `#counts`/`#empty`, which are
`/watch` elements. The wall calls `onChange({mirrors, live})` wherever
`renderCounts()` is called today, and `watch.js` keeps its own `renderCounts()`
as that callback.
3. `src/mirror/static/wall.js` — the socket is the page's. Take `send(payload)` and
use it wherever the moved code used `state.ws.send(...)`
(`subscribe`, ICE candidates, the retry's re-`subscribe`), and guard on its
truthiness the way the code guards on `state.ws` today. This is what lets a page
whose socket is not open yet build a wall, and what keeps `net.js` as the only
place a socket exists (`AGENTS.md` §6).
4. `src/mirror/static/watch.js` — delete the moved code, import
`{ createWall }` from `./wall.js`, build the wall after `loadConfig()` with the
elements already in its `ui` map (`grid`, `stage`, `template`) and
`send: (payload) => state.ws.send(payload)`, and call `wall.absorb/drop/onSignal`
from the `onMessage` switch, `wall.closePeers()` from `onClose`,
`wall.stage/unstage/…` from the key router (task 02 moves the router itself).
Keep in `watch.js`: the config load, the socket, `renderCounts`, `setNet`, the
help overlay, the `focusin` handler (via `wall.setSelected`), the refusal notice,
the share link.
5. `src/mirror/static/watch.js` — keep the `fullscreenchange` listener **in the
page** (it is a page policy: `/watch` unstages when the browser leaves
fullscreen; the kiosk will do something different, phase 08 task 04), calling
`wall.unstage()`. The `fullscreenExitInFlight` flag it depends on moves into
`wall.js`, so expose the flag's two behaviours through `unstage()`/a small
`handleFullscreenChange(hasFullscreen)` hook — the shape is yours, but the race
documented at watch.js L345–352 must stay closed.
6. `src/mirror/static/app.css` — the stage rules are keyed to the `/watch` id and
would not style a second page's stage. Add `class="stage"` to
`<div id="watch-stage">` in `watch.html` (keep the id — the e2e suites and
`watch.js` both select it) and re-key these selectors from `#watch-stage` to
`.stage`, changing nothing else in the blocks: `#watch-stage { … }` (L565),
`body.staged #watch-stage` (L574), `#watch-stage .tile` (L577),
`#watch-stage .tile video`, `#watch-stage .tile .cap`, and the
`#watch-stage .tile[data-audio="on"|"off"]::after` pair (L471, L477). No new
`display` declarations, no `[hidden]` rules — `TestStylesheet` guards those.
7. `src/mirror/static/watch.js` — the dead `if (ui.unstage) …` branch at L473: it is
unreachable today (`ui` has no `unstage` key) and moving it would launder dead
code into the new module. Delete it and leave a one-line comment saying the exit
control is the per-tile `.exit` button, so nobody re-adds a second exit.
8. Do **not** add `/kiosk`, `kiosk.html` or `kiosk.js` in this task, and do not
change any key binding, DOM structure of the tile, or visual rule beyond the
`#watch-stage` → `.stage` re-key.
## Testing & Quality
- **The gate is the existing suites, unedited** (`AGENTS.md` §4/§5):
`uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage or share_layout"`,
then the full `-m e2e`. These cover tile rendering, the stage transitions, audio
policy, the autofade timing, and the peer-connection probes through
`window.__probe` — i.e. exactly the machinery being moved.
- `tests/test_app.py` — `test_scripts_parse_as_es_modules` gains `"wall.js"`,
`test_static_assets_are_served` gains `"wall.js"`.
`test_every_element_the_script_reaches_for_exists` must still pass for
`watch.js` (the ids it still reaches for exist) — and add the new module rule:
*`wall.js` must contain no `$("#…")`*, because a module shared by pages with
different ids may never look an element up in the document it happens to be
loaded in. Assert it as a source-level guard next to the others, with a comment
naming `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` as the
reason it exists.
- Visual check while working: `/watch` at 1280×800 and 390×844, staged and
unstaged — the stage must be pixel-equivalent (the re-key must not change
specificity in a way that reorders a rule).
- Coverage: **>90%** on new/modified code — the moved code is covered by the e2e
suites that already exercise it; the module has no new logic in this task.
## Completion Criteria
- [ ] `uv run pytest -m e2e` green with **zero assertions changed** in any existing file
- [ ] `uv run pytest tests/test_app.py` green, including `wall.js` in the parse/asset lists and the new "no `$("#…")` in `wall.js`" guard
- [ ] `/watch` looks and behaves as before: grid, click-to-stage, `Esc`/`g`, the autofading overlay, one audible mirror
- [ ] `git diff --stat` shows the moved lines as moves, not rewrites (a reviewer should be able to read the diff as "cut, paste, re-point at `options`")
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,106 @@
# Task 02 — The key router becomes `installWallKeys()` in `wall.js`
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views" (the display must accept the same keys *and* report them)
**Story:** n/a
## Objective
Move `/watch`'s single `keydown` switch into `wall.js` as `installWallKeys()`, so
`/kiosk` gets the identical key map for free and — this is the point — can publish
what a key did instead of only performing it. Again **no behaviour change on
`/watch`**: phase 01's pointer-free suite is the gate.
## Work
1. `src/mirror/static/wall.js` — export the router, keeping the invariant phase 01
wrote down (one listener, one switch; every command is a case here, never a new
listener):
```js
/**
* The one keydown listener for a wall page. It maps keys to named commands and
* hands them to the page; it never decides what a command does to shared state.
*
* A page returns `true` only when the key actually changed something — the
* router calls `preventDefault()` on exactly those, which is today's discipline
* on /watch (`g` unstaged and `Esc` with nothing open are not "handled" and keep
* their browser default).
*
* Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
* the browser) and every key inside an <input>/<textarea>/<select>/contenteditable.
*
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
*/
export function installWallKeys(options) { … }
```
2. `src/mirror/static/wall.js` — move `onWallKey`'s switch body into the module,
translating each case into a command name, in this order and with these names
(they are the contract `/kiosk` publishes against):
| key(s) | command | detail |
|---|---|---|
| `j` `arrowdown` | `next` | — (module performs `wall.moveSelection(1)` itself, then reports handled) |
| `k` `arrowup` | `previous` | — |
| `enter` | `stage` | — |
| `f` | `fullscreen` | — |
| `g` | `wall` | — |
| `n` / `p` | `step_next` / `step_previous` | — |
| `m` | `mute` | — |
| `?` | `help` | — |
| `escape` | `escape` | — |
| `1`–`9` | `jump` | `index` (0-based) |
Keys are matched case-insensitively exactly as today (a caps-locked kiosk, a
remote hand holding Shift). `next`/`previous` stay module-side because moving the
selection is wall state, not page policy — the page still sees the command, so a
page that wants to publish the new cursor can.
3. `src/mirror/static/wall.js` — the "any keypress while staged reveals the
overlay" behaviour (today `if (state.staged) revealStageControls();` before the
switch) moves with it, *before* dispatch, and must not `preventDefault` —
an unhandled key keeps flashing the caption, which is the debuggable outcome
phase 01 documented.
4. `src/mirror/static/wall.js` — commands that are pure wall operations
(`stage`, `wall`, `step_*`, `mute`, `jump`) get a **default implementation in the
module** used when the page returns nothing for them, so `onCommand` can be a
thin observer. `stage`/`jump` resolve their target with today's rule (the
selected tile, else the first; `toast` when the wall is empty — `toast` comes
from `net.js`, which `wall.js` may import). `help` and `escape` have **no**
default: they are page policy (`/watch` closes the `?` overlay before leaving the
stage), and a page that ignores them must not break the router.
5. `src/mirror/static/watch.js` — delete `onWallKey`, `moveSelection`,
`stageSelectedOrFirst`, `stepStage`, `toggleHelp`'s key wiring and the
`document.addEventListener("keydown", …)` line; call
`installWallKeys({ wall, onCommand })` instead, where `onCommand` keeps today's
exact behaviour for `help` (toggle `#wall-help`, return whether it changed) and
`escape` (overlay first, then `wall.unstage()`, return whether either happened),
and returns `false`/nothing for the commands the module already performs.
`?`'s "focus is deliberately never touched" comment moves with the behaviour.
6. `src/mirror/static/watch.js` — keep `focusTile`, `setSelected` and the grid's
`focusin` handler as they are now that they call into `wall` (task 01); Tab must
still agree with `j`/`k`.
7. No new page, no route, no kiosk markup in this task.
## Testing & Quality
- **The gate**: `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** — the
file drives every binding with `page.keyboard.press()` and asserts the DOM result,
and its meta-test forbids the pointer in itself. If a case fails, the router moved
wrong; do not touch the test.
- `tests/test_app.py` — add one source-level guard in the spirit of the existing
ones: `installWallKeys` is defined in `wall.js` and `watch.js` contains **no**
`addEventListener("keydown"` — the "one listener per wall page" rule should fail
the build when someone adds a second one, which was phase 01's whole point.
- `uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage"` green, then
the whole `-m e2e`.
- Coverage: **>90%** on new/modified code (the router is exercised by the browser
suite on every binding).
## Completion Criteria
- [ ] every key in phase 01's key map does on `/watch` exactly what it did before, asserted by `tests/e2e/test_keyboard_wall.py` with no edit
- [ ] `grep -n 'addEventListener("keydown"' src/mirror/static/*.js` shows exactly one hit, inside `wall.js`
- [ ] `g` when already unstaged and `Esc` with nothing open still do not `preventDefault` (browser default preserved)
- [ ] `uv run pytest tests/test_app.py` green including the new single-listener guard
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,118 @@
# Task 03 — `GET /kiosk` and the page that hosts the wall
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk"
**Story:** n/a
## Objective
The endpoint and the markup: a chrome-free page whose only job is to hold a wall and
expose the state a test (and a screenshot) can read. No layout behaviour yet —
task 04 writes `kiosk.js` against this skeleton.
## Work
1. `src/mirror/app.py` — the route, beside `/watch`, in the same shape:
```python
@app.get("/kiosk", response_class=HTMLResponse, include_in_schema=False)
async def kiosk_page() -> FileResponse:
"""A wall for an unattended display: subscribes like /watch and arranges
itself from the shared layout (phase 07/08)."""
return _page("kiosk.html")
```
Add `"/kiosk"` to the `PAGES` tuple so the middleware keeps sending
`Cache-Control: no-store` on it, like the other pages.
2. `src/mirror/static/kiosk.html` (new) — model it on `watch.html` (same `<head>`,
`/static/app.css`, the same inline `data:` favicon — **no** external asset, A10),
`<title>Wall · mirror</title>` — `docs/kiosk-silverblue/check-kiosk.sh` proves a
client is showing what it should by grepping the served page's `<title>`, so this
string is a contract, not a label (phase 09 task 03 widens that check to accept
it) — and these elements and nothing else:
* `<body class="kiosk" data-layout="wall" data-fullscreen="false">` — the two
layout knobs, mirrored onto the body so a screenshot and a test can read the
state without reaching into JS (`docs/kiosk-silverblue/dim-probe.py` judges a
wall from a screenshot; `data-audio` on a tile exists for the same reason).
* `<main class="grid" id="kiosk-grid" hidden>` and `<div class="empty" id="kiosk-empty">`
— the classes are what the stylesheet and `wall.js` select; the ids are page-unique.
The empty state reads "No mirrors live" plus
`Share from <code id="kiosk-share-url">…</code>` — `kiosk.js` fills that with
`location.origin + "/mirror"`, because nobody is standing at the box to click
anything and the person reading the screen needs a URL to type elsewhere.
* `<div class="stage" id="kiosk-stage" aria-live="polite">` — **`class="stage"`
is mandatory**: task 01 re-keyed the staging rules off `#watch-stage`, and
without the class the kiosk stage would be an unstyled `div`.
* the shared chrome ids and only these: `<span class="net" id="net"
data-state="connecting"><i></i><span>connecting…</span></span>`,
`<div class="notice" id="notice" hidden>`, `<div class="toast" id="toast" hidden>`.
`#net` is not decoration — it is how a wall reports "the server is gone" in a
screenshot, and every e2e `device()` helper waits on it.
* `<template id="kiosk-tile-template">` — a copy of `watch.html`'s
`#tile-template` tile (`article.tile[tabindex=0][data-state=connecting]`,
`<video autoplay playsinline muted>`, `.badge`, `.actions` with `.audio` and
`.expand`, `.cap` with `.title`/`.sub`/`.exit`). Name it
`kiosk-tile-template`, never `tile-template`: the id-collision guard is the
reason two pages may not share an id, and relaxing it for convenience is how
the `#stage` bug happened.
* `<div class="hud" id="kiosk-hud" aria-live="polite">` — one line the script
owns: `N mirrors · wall` / `… · <label> · fullscreen`. It is the wall's
self-description for a room with no keyboard.
* **No** `<header class="bar">`, no counts in a title bar, no "Share a screen"
link: a display shows mirrors.
* `<script type="module" src="/static/kiosk.js">` — task 04 writes it; create a
placeholder `kiosk.js` in this task that only imports `loadConfig` and
`refusalNotice` from `./net.js` so the page parses, serves and shows a refusal
notice, and nothing more.
3. `src/mirror/static/app.css` — the new page's chrome only: `.kiosk` (no header
space, full-bleed grid), `.kiosk .net` (fixed top-right, the same pill), `.hud`
(fixed bottom-left, `--mono`, `--fg-dim`, small, `pointer-events: none`). Do not
duplicate any stage/tile rule — those are `.stage`/`.tile` now. Add no rule whose
selector mentions `[hidden]` and no `display` declaration on a component the
stylesheet already owns (`TestStylesheet` guards both).
4. `src/mirror/static/index.html` — a third `<a class="card" href="/kiosk">`
("GET /kiosk — An unattended wall") so the endpoint is discoverable from `/`.
Cards carry no ids, so the id guard is unaffected.
5. Do **not** touch `/remote` (phase 09), `watch.html`, or the layout applier.
## Testing & Quality
- `tests/test_app.py` — extend the structural guards to the third page; each of
these is an existing test gaining a case, not a new invention:
* `TestPages::test_pages_render` → `("/kiosk", "kiosk-tile-template")`;
* `test_pages_are_not_cacheable` → `client.get("/kiosk")` is `no-store`;
* `test_static_assets_are_served` → `kiosk.js`;
* `test_scripts_parse_as_es_modules` → `"kiosk.js"` (`node --check`);
* `test_every_element_the_script_reaches_for_exists` → `("kiosk.js", "kiosk.html")`;
* `test_both_pages_load_the_stylesheet_and_their_module` → the `kiosk.html` /
`kiosk.js` pair (rename it if the name now lies — the guard matters more than
the name);
* `test_videos_are_muted_and_inline_so_autoplay_is_not_blocked` → `kiosk.html`
(both autoplay rules apply to a kiosk: iOS/Safari blocks the wall otherwise);
* `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` → add
`kiosk.html` to the ids map and assert `kiosk ∩ watch`, `kiosk ∩ mirror`,
`kiosk ∩ index` are all exactly `{"net","notice","toast"}`. Keep the existing
assertions verbatim; `#kiosk-*` prefixes are what make these pass.
* add `#kiosk-grid`, `#kiosk-stage`, `#kiosk-hud` to the "elements a page must
have" style check if the file has one, and assert `class="stage"` appears on
`#kiosk-stage` **and** `#watch-stage` — a page whose stage lost the class
renders a full-size unstyled video, which is exactly the class of bug the
stylesheet guards exist for.
- `tests/test_app.py::TestAddressGate` — `/kiosk` joins the parametrised "a refused
address gets no page, no roster, no assets" list, and one test that an admitted
address gets `200`.
- `tests/e2e/test_live_mirror.py` — the refused-address parametr list
`("/mirror", "/watch")` gains `"/kiosk"`, and its
`expect(page.locator("#start, #grid, #notice")).to_have_count(0)` selector list
gains `#kiosk-grid` / `#kiosk-stage` so the assertion actually covers the new page.
- Coverage: **>90%** on new/modified Python (one route — covered by the page tests).
## Completion Criteria
- [ ] `curl -s localhost:8055/kiosk | head -20` serves the page and `curl -sI` shows `cache-control: no-store`
- [ ] opening `/kiosk` in a browser shows an empty-state wall with the share URL in it, no header bar, the net pill top-right, and no console errors
- [ ] `uv run pytest tests/test_app.py` green with every guard above extended to three pages
- [ ] `uv run pytest -m e2e` green (existing suites unedited except the two parametrisations named here)
- [ ] `grep -c 'id="' src/mirror/static/kiosk.html` ids appear in no other page except `net`/`notice`/`toast` — which the extended guard now proves
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,122 @@
# Task 04 — `kiosk.js`: obey the layout, publish the keystrokes
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
The behaviour: `/kiosk` opens, subscribes to every mirror, and arranges itself from
`welcome.layout` without being touched; every `layout` frame that follows re-arranges
it; and a keypress on the box — rare, but it happens when a keyboard is plugged in to
diagnose — performs the change **and** publishes it, so the controller and the display
converge instead of disagreeing.
## Work
1. `src/mirror/static/kiosk.js` — replace task 03's placeholder. Module shape, in
the order `watch.js` uses it: `loadConfig()` → `rtcConfig()` → `ui` map →
`createWall()` → `connectSignaling()` → `installWallKeys()`. The `ui` map is
`grid`, `stage`, `template`, `empty`, `shareUrl`, `hud`, `net`, `toast`, `notice`
— every one of them exists in `kiosk.html` (the element guard proves it).
2. `src/mirror/static/kiosk.js` — announce as a display, not a controller:
`state.ws.send({type:"hello", role:"viewer", kiosk:true, name:"", device: deviceLabel()})`.
`deviceLabel()` comes from `net.js`. `kiosk:true` is what feeds `followers`, and a
`/kiosk` page must be counted while a `/remote` page must not.
3. `src/mirror/static/kiosk.js` — the applier, the one function that touches the
stage:
```js
/**
* Make the page match the server's layout. Idempotent: a frame that repeats
* what is already applied changes nothing, so a controller that re-sends (or a
* follower join that re-broadcasts) cannot re-request fullscreen forever.
*
* The caller is never trusted to have waited: `staged` names a live mirror when
* the server accepts it, and may have died on the way here, so an unknown id
* keeps the wall and is retried by the next roster frame.
*/
function applyLayout(next) { … }
```
* `next.staged` is a tile the wall has → `wall.stage(id)`; is `null` →
`wall.unstage()`; names something unknown → unstage and remember the intent,
re-applying whenever `absorb()` gains that tile.
* `next.fullscreen` → request on the same target `/watch` uses (the stage element
when staged, `document.documentElement` for the wall), `.catch(() => {})` and
never awaited. A remote command carries no user activation: the request is a
courtesy, the `.stage` CSS is the guarantee, and the Silverblue window is
already fullscreen (`docs/kiosk-silverblue/window-config.ini`). Say exactly that
in a comment — the next reader will otherwise "fix" it by awaiting.
* Mirror both knobs onto the body — `document.body.dataset.layout =
staged ? "stage" : "wall"` and `document.body.dataset.fullscreen = "true"|"false"`,
and set `document.title` to the staged mirror's label (`"mirror — <label>"`, else
`"mirror — wall"`), because a window list and a screenshot are the only views a
headless box offers.
4. `src/mirror/static/kiosk.js` — apply on arrival and on every change:
`welcome` → `applyLayout(message.layout)` (before `renderCounts()`-equivalent work,
so the wall never flashes between the roster and the stage), `layout` →
`applyLayout(message)`. `stream_added`/`stream_updated` → `wall.absorb()` then
`applyLayout(state.layout)` so a remembered intent lands the moment its tile does.
`stream_removed` → `wall.drop()` (the server pushes the cleared layout behind it —
do not "helpfully" unstage locally, or two frames will fight).
5. `src/mirror/static/kiosk.js` — the keys, via `installWallKeys({wall, onCommand})`,
each entry doing the local change **and** publishing the same intent:
| command | local effect | published `arrange` |
|---|---|---|
| `next` / `previous` | (module moves the selection) | none — the cursor is local to this page |
| `stage` (`Enter`) | `wall.stage(cursor ?? first)` | `{staged: <id>}` |
| `wall` (`g`), `escape` | `wall.unstage()` | `{staged: null}` |
| `step_next` / `step_previous` | stage the neighbour | `{staged: <id>}` |
| `jump` (`1`–`9`) | stage the Nth | `{staged: <id>}` |
| `fullscreen` (`f`) | re-request fullscreen | `{fullscreen: true}` |
| `mute` (`m`) | `wall.setMuted(tile, …)` | **none** |
`mute` is never published, and the code must say why in one comment line:
`SECURITY.md` §4 — sound only ever starts from a gesture on the device that plays
it, so a display must not become audible because another machine asked. `help`
(`?`) has no target on this page (no overlay markup) and returns not-handled.
6. `src/mirror/static/kiosk.js` — what the page does **not** publish: the browser's
own fullscreen exits. Register no `fullscreenchange` policy that unstages (the WM
dropping fullscreen is not a person asking to leave the stage — on `/watch` that
is a deliberate choice, on a wall it would fight the state) and do not echo
`fullscreen:false` because the browser refused a request; that would make a
controller's "fullscreen" button look broken and would be reporting the browser's
refusal as a user preference.
ASSUMPTION: publish what a person asked for, not what the browser conceded.
7. `src/mirror/static/kiosk.js` — the honest-but-quiet details:
* `onChange({mirrors, live})` → `#kiosk-hud` text (`3 mirrors · wall`,
`3 mirrors · Alice's laptop · fullscreen`) and the `#kiosk-grid`/`#kiosk-empty`
`hidden` toggles `/watch` does in `renderCounts()`;
* `#kiosk-share-url` gets `location.origin + "/mirror"` on load;
* `refusalNotice(config)` → `showNotice(ui.notice, …)` and never open a socket,
exactly as `/watch` does;
* `onClose` → `wall.closePeers()` and mark the tiles stale; the next `welcome`
re-applies the layout, which is what makes a server restart recover on its own.
## Testing & Quality
- `tests/test_app.py` — the element guard from task 03 already forces every
`$("#…")` in `kiosk.js` to exist; add one source-level guard that `kiosk.js`
**never** sends a `mute`/audio field in an `arrange` frame (assert no `arrange`
object literal in the file contains `muted`/`audio`) — the security rule should
fail the build, not live in a comment.
- `uv run pytest -m e2e` — `/watch`'s behaviour must be untouched by this task; the
new page's behaviour is covered in task 05.
- Manual, before task 05 exists: `uv run mirror serve`, two browser windows, one on
`/kiosk`; drive the state with `websocat` (`{"type":"arrange","staged":"<id>"}`) and
watch the page stage it — AGENTS.md §8's "reproduce at the lowest layer" ladder
reversed: prove the layer works before automating it.
- Coverage: **>90%** on new/modified code — the applier is exercised by task 05's
suite; the guards above cover the security rules statically.
## Completion Criteria
- [ ] a `/kiosk` tab opened **after** an `arrange` is already showing that mirror when it finishes loading (no flash of the wall)
- [ ] an `arrange` sent from `websocat` re-arranges an open `/kiosk` tab within one frame
- [ ] a keypress on `/kiosk` (`Enter`, `g`, `n`, `p`, `1`–`9`, `f`) changes `GET /api/kiosk` — the display is a first-class controller
- [ ] `m` on `/kiosk` mutes locally and leaves `/api/kiosk` byte-identical
- [ ] `document.body.dataset.layout` / `dataset.fullscreen` agree with what is on screen at every point a test can observe
- [ ] `uv run pytest tests/test_app.py` and the full `-m e2e` suite green, existing assertions unedited
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -0,0 +1,92 @@
# Task 05 — `tests/e2e/test_kiosk_layout.py` and the kiosk docs
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "will update immediately so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
Prove the two properties the TODO item actually claims — **it arranges itself on
arrival** and **one client's action moves another client's display** — in a browser,
on pixels, with no pointer anywhere near the display. Then write down how to point a
real kiosk box at it.
## Work
1. `tests/e2e/test_kiosk_layout.py` (new) — `pytestmark = [pytest.mark.e2e,
pytest.mark.timeout(180)]`, and the file's own `device()` fixture copied in the
suite's established shape (isolated context per device, `page.on("pageerror")` and
`console` errors collected from the first line of each test — `AGENTS.md` §8),
waiting on `#net` `data-state=online`.
Isolated **contexts** are the right unit here: each is its own socket and peer id,
which is what "another client" means to the server. The media path itself is
already proven by `test_live_mirror.py`; this suite proves the *state* path and
still asserts on real decoded frames so nothing is taken on trust.
2. `tests/e2e/test_kiosk_layout.py` — the cases:
* **arrival is arrangement**: sharer on `/mirror` + `start_sharing()`; drive the
state from a driver page (`page.evaluate` over a plain `WebSocket` to `/ws`,
`hello` then `{"type":"arrange","staged":<id>,"fullscreen":true}`); *then* open
`/kiosk`; assert it settles staged — `body[data-layout="stage"]`,
`#kiosk-stage .tile[data-stream="<id>"]`, and
`frames_matching(kiosk, "#kiosk-stage video")` showing `width > 0` and
`distinct >= 2`. The frame assertion is what proves media crossed to the
display, not just a CSS class (`AGENTS.md` §5).
* **immediate update across clients**: two `/kiosk` pages, no input on the second;
press `Enter` on the first (a keypress, so the display stays pointer-free);
assert the second page's `data-layout` flips to `stage`, its staged tile id
matches, and `GET /api/kiosk` reports the same id — one action, two displays,
one source of truth.
* **stepping**: `n` then `p` on page A move page B through the mirrors (start two
sharers; assert the staged `data-stream` on B changes and wraps).
* **teardown is a state change**: `stop_sharing()` on the sharer → both kiosk pages
return to `body[data-layout="wall"]` and `/api/kiosk` reports
`{"staged": null, …}` — no display may sit on a dead tile.
* **followers**: with one `/kiosk` open, `/api/kiosk` says `followers: 1`; open a
`/watch` page (a plain viewer, not a display) and it still says `1`; close the
kiosk and it says `0`.
* **nothing lies**: every case asserts `errors == []`.
3. `tests/e2e/test_kiosk_layout.py` — keep it pointer-free and say so in the module
docstring, in phase 01's words ("a test that reaches for the mouse proves nothing
about a kiosk"); add the same style of self-guard the keyboard suite uses if it
fits (scan this file for `.click(` and fail on it) — the driver's raw `WebSocket`
calls in `page.evaluate` are not a pointer and must be allowed explicitly rather
than by loosening the rule.
4. Do **not** assert on `document.fullscreenElement` anywhere: headless Chromium may
refuse an element fullscreen without user activation and the real kiosk's window
manager forces fullscreen anyway (`docs/kiosk-silverblue/window-config.ini`).
Assert `body[data-fullscreen]` and the `.stage` state, which are what the app
controls. A comment must say this, or the next reader will "strengthen" the test
into a flake.
5. `docs/kiosk-silverblue.md` — a section, *"Two endpoints: `/watch` and `/kiosk`"*:
`/watch` is a person operating a wall, `/kiosk` is a display obeying a wall, the
keys still work on `/kiosk` for the moment a keyboard is plugged in, and the
controller side arrives in phase 09 (`/remote`). Point out the migration rule
honestly: existing clients keep their own `MIRROR_KIOSK_URL`, so nothing changes
under a box until its `kiosk.conf` is re-pointed and `gdm` restarted. Do **not**
change `install-kiosk.sh`, `kiosk.conf`, `gnome-kiosk-script` or `check-kiosk.sh`
in this phase — the default URL moves in phase 09, when there is a way to drive it.
6. `README.md` — the page table gains the `/kiosk` row; the "Keys" section gains one
sentence: the wall keys work on `/kiosk` too, and a change made there is what every
other display follows.
## Testing & Quality
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` green on its own, then the whole
`-m e2e` (the suites share one session-scoped server, so a leaked peer in the new
file will show up as a tile count in someone else's test — clean up contexts via
the fixture's finalizers, as the existing suites do).
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` stays green: `/kiosk` must not
have changed what a real capture looks like to a viewer.
- Coverage: this task adds no product code; it is the **behavioural** coverage the
phase's JS relies on (the project's rule — media and page behaviour are asserted in
a browser, not simulated in Python).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_kiosk_layout.py` green, including the arrival case and the two-display case
- [ ] the two-display test passes with the second display never receiving a `click()`, `focus()` or `hover()` from the test
- [ ] `stop_sharing()` returns both kiosk pages to `data-layout="wall"` within the assertion timeout (no stale tile, no dead `<video>`)
- [ ] `/api/kiosk`'s `followers` counts displays only, asserted over a real socket lifecycle
- [ ] `uv run pytest -m e2e` and `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` green
- [ ] full test suite **100% passing**
- [ ] README.md and `docs/kiosk-silverblue.md` describe what exists after this phase, and nothing that arrives in phase 09
- [ ] no behavior change in completed work