diff --git a/.agents/phases/todo/07_kiosk_layout_state/00_phase.md b/.agents/phases/todo/07_kiosk_layout_state/00_phase.md new file mode 100644 index 0000000..63fc69c --- /dev/null +++ b/.agents/phases/todo/07_kiosk_layout_state/00_phase.md @@ -0,0 +1,113 @@ +# Phase 07 — Shared presentation state on the server + +**Source:** `TODO.md` L3 — "Add a /kiosk endpoint that is controllable by other clients… There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately…" (the server half of that item) +**Story:** n/a +**Context:** `.agents/PLAN.md` §2 A5 (one WebSocket, JSON, verb in / noun out), A6 (camelCase on the wire), A7 (in-memory state only — no database, no persistence), A8 (`Registry` stays pure and returns `list[Envelope]`), A11/A19 (no accounts; the address is the gate); `src/mirror/protocol.py`, `src/mirror/registry.py`, `src/mirror/app.py`; `tests/test_registry.py`, `tests/test_protocol.py`, `tests/test_app.py` + +## Objective + +Give the server one piece of presentation state — **which mirror is on stage, and +whether it should be fullscreen** — that any connected peer may change and that is +pushed to everyone the moment it changes. This is the piece the TODO item is +actually short of: `/watch` already stages and fullscreens locally (phase 01), but +that decision lives in one tab and dies there, so an unattended display can never +be driven from elsewhere. + +This phase is **server-side only**: no new page, no browser-visible behaviour yet. +It leaves the app exactly as usable as it was, with a wire format and a +`GET /api/kiosk` read surface that phases 08 and 09 build their clients on. + +## The contract this phase fixes (phases 08/09 code against it verbatim) + +Inbound — verb, like `publish`/`subscribe` (PLAN A5), from **any** peer: + +```json +{"type":"arrange","staged":"p1ab2c3d","fullscreen":true} +``` + +* `staged`: a stream id, or `null` for "back to the wall", or **absent** for "no + opinion". Absent and explicit-`null` are different requests, which is why the + model is read with pydantic's `model_fields_set` rather than with defaults. +* `fullscreen`: `bool`, absent = no opinion. +* A `staged` value that does not name a live mirror is refused with the existing + `unknown_stream` error and changes nothing. +* No audio/mute field. `SECURITY.md` §4: sound must trace back to a gesture on the + device that plays it — a remote hand cannot unmute a stranger's display. + +Outbound — noun, to every viewer and back to the sender: + +```json +{"type":"layout","staged":"p1ab2c3d","fullscreen":true,"followers":1} +``` + +* `followers` is how many connected peers announced `hello{"kiosk":true}`, i.e. + how many displays are obeying this. A controller is a plain viewer and is not + counted, which is what makes "nobody is showing a wall" distinguishable from + "the wall will follow". +* Emitted on: any accepted change, a kiosk display joining or leaving, and the + stale fallback when the staged mirror disappears. +* `welcome` carries the same object as its `layout` field, so a page is arranged + before it has asked for anything — the TODO's "navigate to it and it arranges + itself" depends on this. + +`GET /api/kiosk` answers the same object, so the state is debuggable with `curl` +exactly like `/api/streams` (AGENTS.md §8). + +## What is deliberately *not* here + +* **No third role.** `Role` stays `sharer | viewer`; a kiosk is a viewer that + happens to obey the layout. The signal-authorisation matrix and A4 are + untouched. +* **No media coupling.** Staging never subscribes, never negotiates, never touches + an `RTCPeerConnection` — the kiosk already subscribes to every mirror it can + show, so "which one is on stage" is pure presentation. +* **One state per server process.** Not per display, not per room. Two `/kiosk` + pages follow the same state; that is the product's single-wall model (A7). +* **No persistence.** Restart the server and the wall is a wall. Consistent with + A7's reasoning: state that outlives the peers is a lie. +* **No auto-stage-newest.** The state says what is on stage; nothing here invents + a value. (`PLAN.md` §7 item 2, "Wall UX", is where auto-stage belongs.) + +## Dependencies + +- `06_sharer_quality_presets` (complete) — the current wall/stage/keyboard + behaviour and the green four-gate baseline this phase must not disturb. + (`.agents/phases/todo/` is empty, so this is the phase directly before 07.) + +## Tasks + +1. `01_arrange_and_layout_frames.md` — the two message shapes in `protocol.py` + (`Arrange` inbound, `layout()` outbound builder) plus the `kiosk` flag on + `hello`, with parse/round-trip tests. +2. `02_registry_layout_policy.md` — `Layout` state, `Registry.set_layout()` and + `Registry.layout_dto()`, the fan-out set, the follower count, and the + stale-staged fallback on teardown; unit-tested with no I/O. +3. `03_dispatch_read_endpoint_and_wire_tests.md` — `Hub._dispatch` handles + `arrange`, `GET /api/kiosk` serves the state, and the whole path is covered + over a real `TestClient` WebSocket. + +## Testing & Quality + +- **Unit (`tests/test_registry.py`)** owns the policy: partial update vs explicit + clear, refusal of an unknown stream, fan-out to viewers plus the sender, + follower counting, the fallback when the staged mirror goes away, and `welcome` + carrying the state. +- **Wire (`tests/test_app.py`, `tests/test_protocol.py`)** owns the camelCase + round-trip and the socket semantics: an `arrange` frame from a sharer, from a + viewer, with a bad stream id (error frame, socket stays open), and the shape + + `no-store` of `/api/kiosk` behind the address gate. +- Coverage: **>90%** on new/modified code (the project sits ~95%; `registry.py` is + the product and must stay near 100%). +- All four gates from `AGENTS.md` §4, including `-m e2e` and the `realdisplay` + suite: this phase changes frames every browser test consumes, so "nothing in the + browser changed" is itself the assertion. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_registry.py tests/test_protocol.py tests/test_app.py` green, including the new layout cases +- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` answers `{"staged": null, "fullscreen": false, "followers": 0}` on an idle server, and reflects an `arrange` frame sent with `websocat` +- [ ] `welcome` carries a `layout` object; a second viewer connecting after an `arrange` receives the same state without being asked +- [ ] the staged mirror going away (unpublish, disconnect, eviction) resets `staged` to `null` and pushes one `layout` frame — never a stale id +- [ ] no page, no stylesheet and no browser test changed behaviour (`uv run pytest -m e2e` green with the existing assertions untouched) +- [ ] full test suite **100% passing**, coverage >90% on new code +- [ ] `PLAN.md` untouched (no anchor moved: A5/A7/A8/A11 all hold, see above) diff --git a/.agents/phases/todo/07_kiosk_layout_state/01_arrange_and_layout_frames.md b/.agents/phases/todo/07_kiosk_layout_state/01_arrange_and_layout_frames.md new file mode 100644 index 0000000..6b8dec3 --- /dev/null +++ b/.agents/phases/todo/07_kiosk_layout_state/01_arrange_and_layout_frames.md @@ -0,0 +1,92 @@ +# Task 01 — The `arrange` / `layout` frames + +**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately" +**Story:** n/a + +## Objective + +Put both halves of the presentation-state message on the wire: an inbound +`arrange` verb, an outbound `layout` noun, and a `kiosk` flag on `hello` so the +server can tell a display from a controller. No behaviour yet — `Registry` is +wired in task 02, the socket in task 03. + +## Work + +1. `src/mirror/protocol.py` — add the inbound verb after `Subscribe`/`Unsubscribe`, + in the `_Inbound` house style. Both fields are `| None` **and** optional, because + "leave what I did not mention alone" and "clear it" are different requests: + + ```python + class Arrange(_Inbound): + """Ask the wall to show a given mirror. Absent fields are left alone; + + ``staged: null`` explicitly returns the displays to the wall. The caller + does not have to be the display's operator — see ``SECURITY.md`` §2. + """ + + type: Literal["arrange"] + staged: str | None = None + fullscreen: bool | None = None + ``` + + Add `Arrange` to the `ClientMessage` discriminated union and re-export nothing + else — the union is what `Hub._dispatch` switches on. +2. `src/mirror/protocol.py` — `Hello` gains one field, single word so there is no + camelCase alias to get wrong (but round-trip it anyway, A6): + + ```python + #: true when this peer is a wall display that obeys the shared layout. + kiosk: bool = False + ``` + + Keep `Role = Literal["sharer", "viewer"]` exactly as it is: a kiosk is a viewer + with an attribute, not a third role (see the phase overview). +3. `src/mirror/protocol.py` — add the outbound builder beside `welcome()`: + + ```python + def layout( + *, staged: str | None, fullscreen: bool, followers: int + ) -> dict[str, Any]: + """The wall's presentation state, pushed to every viewer as it changes.""" + return {"type": "layout", "staged": staged, "fullscreen": fullscreen, "followers": followers} + ``` + + Key order and spelling are the contract in `00_phase.md`; the outbound builders + are hand-written dicts, so there is no alias generator to catch a typo here — + the round-trip test in this task is what catches it. +4. `src/mirror/protocol.py` — `welcome()` gains a `layout` parameter and a + `"layout"` key so a page arrives already arranged. Give it a keyword-only + default of `{"staged": None, "fullscreen": False, "followers": 0}`? **No** — + a mutable default is a shared dict. Take it as a required keyword; the one + caller (`Registry.join`) is updated in task 02, so this task must not leave a + call site broken: update `registry.py`'s `welcome(...)` call in the same commit + to pass `layout=protocol.layout(staged=None, fullscreen=False, followers=0)` + as a placeholder that task 02 replaces with the real state. +5. Do **not** touch `Hub`, `create_app`, `Registry` policy or any JS in this task. + +## Testing & Quality + +- `tests/test_protocol.py` — extend the message-parsing suite: + * `{"type":"arrange"}`, `{"type":"arrange","staged":null}` and + `{"type":"arrange","staged":"p1","fullscreen":true}` all parse, and + `model_fields_set` distinguishes the first two from the third (this is the + whole reason the model is written this way — test it, do not trust it). + * `hello` defaults `kiosk` to `False` and accepts `kiosk: true`. + * `protocol.layout(...)` emits exactly `type/staged/fullscreen/followers` — + assert the key set, which is how the outbound dicts are guarded elsewhere. +- `tests/test_app.py` — `test_an_unknown_message_type_is_reported_but_survivable` + must still pass, and `arrange` must *not* appear as unknown once task 03 lands; + nothing to assert here yet. +- Coverage: **>90%** on this task's new/modified code (the models and the builder + are covered directly by the parse/shape tests above). + +## Completion Criteria + +- [ ] `uv run pytest tests/test_protocol.py` green with the new `arrange`, `kiosk` + and `layout()` cases +- [ ] `uv run pyright` reports 0 errors (`Arrange` is in the union, so the + `isinstance` chain in `app.py` is exhaustive-checked in task 03) +- [ ] `uv run pytest -m e2e` still green with **no** test file edited — nothing a + browser does has changed +- [ ] full test suite **100% passing** +- [ ] no behavior change in completed work diff --git a/.agents/phases/todo/07_kiosk_layout_state/02_registry_layout_policy.md b/.agents/phases/todo/07_kiosk_layout_state/02_registry_layout_policy.md new file mode 100644 index 0000000..209bae8 --- /dev/null +++ b/.agents/phases/todo/07_kiosk_layout_state/02_registry_layout_policy.md @@ -0,0 +1,118 @@ +# Task 02 — `Registry` owns the layout: set, validate, broadcast, fall back + +**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately" +**Story:** n/a + +## Objective + +The state itself, in `Registry`, with the policy that makes it trustworthy: only +live mirrors may be named, the change reaches every viewer in the same call that +produced it, and a mirror disappearing never leaves a display pointing at nothing. +Pure Python, envelopes out — A8 to the letter. + +## Work + +1. `src/mirror/registry.py` — a sentinel for "the caller did not mention this + field", because `None` already means "clear it": + + ```python + class _Unset: + """Marker: the caller expressed no opinion, keep the current value.""" + + UNSET = _Unset() + ``` + +2. `src/mirror/registry.py` — the state next to `Peer`/`Stream`, with the + dataclass house style (`slots=True`): + + ```python + @dataclass(slots=True) + class Layout: + """What the wall displays. One value per server process (A7): there is + one wall, and every display obeying it is showing the same thing.""" + + staged: str | None = None + fullscreen: bool = False + ``` + + `staged` holds a stream id, which is also a sharer peer id (`streamId == + sharerId`, PLAN §3) — the fallback in step 6 relies on that identity. +3. `src/mirror/registry.py` — `Peer` gains `kiosk: bool = False`; `Registry.join()` + gains a `kiosk: bool = False` keyword and stores it. The follower count is + derived, never bookkept: `sum(1 for p in self.peers.values() if p.kiosk)`. +4. `src/mirror/registry.py` — `Registry.__init__` creates `self.layout = Layout()`; + `join()` builds its `welcome` with `layout=self.layout_dto()` (replacing task + 01's placeholder), so a page is arranged before it asks. +5. `src/mirror/registry.py` — the two public operations: + + * `layout_dto() -> dict[str, Any]` — `protocol.layout(staged=…, fullscreen=…, + followers=)`. This is the only place the DTO is built, so + `/api/kiosk` (task 03) and the socket can never disagree. + * `set_layout(peer_id, *, staged: Any = UNSET, fullscreen: Any = UNSET) + -> list[Envelope]` — + - `self._peer(peer_id)` first, so a dead peer gets `unknown_peer` like every + other operation; + - `staged` given as a non-`None` string → `self._stream(staged)` (reuses the + existing `unknown_stream` code — no new error code); + - `staged` explicitly `None` → clears; absent → unchanged; same shape for + `fullscreen`; + - if nothing actually changed, return **only** the ack to the sender — no + broadcast storm from a controller that re-sends the same state; + - otherwise return the broadcast: every peer whose role is `viewer`, **plus + the sender** (a sharer that sends an `arrange` still gets its own ack), + each carrying `protocol.layout(**self.layout_dto())`. Deduplicate the + sender into the set so no peer is sent the frame twice. +6. `src/mirror/registry.py` — the fallback, in `_drop_stream()` (the single funnel + for *every* removal path: `unpublish`, disconnect, replaced stream, capacity + eviction): if `self.layout.staged == stream.id`, set it to `None` and append + the layout broadcast to the envelopes it already returns. The comment there + already explains "tell every viewer, not just subscribers" — extend it: a + display aimed at a mirror that vanished must fall back to the wall *in the same + frame set*, because there is no polling to correct it later. +7. `src/mirror/registry.py` — follower joins/leaves must also push state, or a + controller sits on a stale `followers: 0` until someone moves a mirror: + `join()` appends the layout broadcast to the other viewers' envelopes (not to + the joiner — it got the state in its `welcome`), and `leave()` appends the same + broadcast when the departing peer had `kiosk=True`. +8. `src/mirror/registry.py` module docstring — add the invariant to the bulleted + list: *the layout names either nothing or a live mirror; a mirror's removal + clears it in the same envelope set.* +9. Do **not** touch `Hub`, `app.py`, or any JS. Do not add persistence, a layout + per peer, or an audio field (see `00_phase.md`, "What is deliberately not here"). + +## Testing & Quality + +- `tests/test_registry.py` — a new `TestLayout` class covering, in this order: + * default state is the wall, not fullscreen, zero followers; + * `set_layout` with `staged="pX"` broadcasts to viewers **and** the sender, and + no peer receives it twice; + * `staged` absent + `fullscreen=True` changes only fullscreen (the + partial-update rule, asserted on the returned DTO); + * `staged=None` clears an existing selection, and `set_layout` with no fields at + all produces no broadcast; + * an unknown stream id raises `RegistryError` with `code == "unknown_stream"` + and leaves `self.layout` untouched; + * a second `set_layout` with identical values sends the ack and nothing else; + * `unpublish`, `leave()` of the sharer, and a *replaced* stream each reset + `staged` to `None` and emit exactly one layout frame per viewer; + * `followers` counts only `kiosk=True` viewers, and a kiosk joining/leaving + pushes a layout frame whose `followers` is the new count; + * `welcome` of a peer that joins **after** an `arrange` carries that state — + this is "navigate to `/kiosk` and it arranges itself", stated at the layer + where it is decided. + Use the file's existing helpers/idioms (explicit peer ids via `id_factory`, the + `codes_and_targets` style helpers already in the module) rather than a new + harness. +- Coverage: **>90%** on this task's new/modified code — target ~100% of + `registry.py`, which the project treats as the product. + +## Completion Criteria + +- [ ] `uv run pytest tests/test_registry.py` green, including every `TestLayout` case above +- [ ] `uv run pyright` → 0 errors; `Registry` still imports no socket, no FastAPI, no `await` (A8, AGENTS.md §3) +- [ ] `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` finds nothing +- [ ] a layout naming a mirror that has gone is impossible to observe: the frame + every viewer receives after a teardown says `staged: null` +- [ ] full test suite **100% passing** +- [ ] no behavior change in completed work (no wire frame any current client sends + or reads changed shape) diff --git a/.agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md b/.agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md new file mode 100644 index 0000000..c112d7f --- /dev/null +++ b/.agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md @@ -0,0 +1,99 @@ +# Task 03 — Socket dispatch, `GET /api/kiosk`, and the wire suite + +**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "state … 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" +**Story:** n/a + +## Objective + +Connect the frames to the transport and make the state readable without a +browser: `Hub` accepts `arrange` from any peer and passes the caller's *expressed* +fields to `Registry`, and `GET /api/kiosk` answers the same DTO the socket pushes. +After this task the feature is real and drivable with `websocat`, one step below +the browser. + +## Work + +1. `src/mirror/app.py` — `Hub._introduce`: pass the flag through, + `self.registry.join(role=hello.role, name=hello.name, device=hello.device, + kiosk=hello.kiosk)`. +2. `src/mirror/app.py` — `Hub._dispatch`, in the existing `isinstance` chain + before the `else` that marks the union exhaustive: + + ```python + elif isinstance(message, protocol.Arrange): + fields = message.model_fields_set + outbox = self.registry.set_layout( + peer_id, + staged=message.staged if "staged" in fields else UNSET, + fullscreen=message.fullscreen if "fullscreen" in fields else UNSET, + ) + ``` + + Import `UNSET` from `.registry`. `model_fields_set` is the one place the + "absent ≠ null" rule survives the trip from JSON to policy; a comment there + should say so, because the next reader will try to simplify it to + `message.staged` and silently lose "back to the wall". +3. `src/mirror/app.py` — the read endpoint, next to `/api/streams`, same + `Cache-Control: no-store` (a roster that is cached is a lie about what is on + the wall right now): + + ```python + @app.get("/api/kiosk") + async def kiosk_layout() -> JSONResponse: + """Server truth about the wall display: what is on stage, fullscreen, and + how many displays are obeying it. Read-only by design — the only writer is + the `arrange` frame on /ws, so there is one path that can change the wall.""" + return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"}) + ``` + + ASSUMPTION: writes stay on the WebSocket only. An HTTP `POST` would be a second + write path with no authentication (A11) and no same-origin protection to lose, + and the WS already gives any admitted address the same power — so the read-only + endpoint buys the `curl` debugging (AGENTS.md §8) without buying a new surface. +4. `src/mirror/app.py` — **do not** add `/kiosk` to `PAGES` yet: there is no page + until phase 08. `/api/kiosk` is gated like every other route (only `/healthz` + is exempt, AGENTS.md §3), and must not be added to that exemption. +5. No JS in this task. + +## Testing & Quality + +- `tests/test_app.py` — extend `TestSignalingSocket` (or the class the existing + roster tests live in) with: + * the round-trip: sharer publishes, viewer connects, viewer sends + `{"type":"arrange","staged":,"fullscreen":true}` → both the sender + and a second viewer receive `layout` with `staged` and `fullscreen` set, and + `GET /api/kiosk` says the same; + * `followers` — two viewers with `hello{kiosk:true}` and one plain viewer + report `followers: 2`, and closing one of the kiosk sockets pushes a layout + frame that says `1`; + * a bad stream id answers `error{code:"unknown_stream"}` and **does not** close + the socket (send `ping`, get `pong`); + * an `arrange` with only `fullscreen` leaves `staged` alone end to end, and + `arrange{staged:null}` returns the wall; + * a **sharer** that sends `arrange` gets the ack, and a layout frame is not + pushed to other sharers (viewers plus sender only); + * the staged mirror's sharer disconnects → every viewer gets `staged: null`. +- `tests/test_app.py::TestAddressGate` — `/api/kiosk` is refused with the rest of + the app: add it to the parametrised "refused address gets nothing" list and + assert a refused address never learns the layout, plus one test that the real + address gets `200` and `cache-control: no-store`. +- `tests/test_app.py::TestConfigEndpoints`-style shape check: `/api/kiosk` returns + exactly the three keys (`staged`, `fullscreen`, `followers`) so a future field + addition is a decision, not a leak. +- Coverage: **>90%** on this task's new/modified code. + +## Completion Criteria + +- [ ] `websocat ws://localhost:8055/ws` can drive the wall by hand: `hello`, then + `{"type":"arrange","staged":"","fullscreen":true}` → + a `layout` frame comes back (AGENTS.md §8 rung 2) +- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` reflects that state + and answers `no-store` +- [ ] the staged mirror's sharer leaving resets `staged` to `null` for every + viewer, observed over the socket +- [ ] `uv run pytest tests/test_app.py` green (new cases + all existing, unedited) +- [ ] all four gates from `AGENTS.md` §4 green, including `-m e2e` and + `MIRROR_REALDISPLAY=1 -m realdisplay` with **no** browser-side change +- [ ] full test suite **100% passing**, coverage >90% on new code +- [ ] README.md updated for anything user-visible this task changes (a protocol + note is enough here; the pages come in phases 08/09) diff --git a/.agents/phases/todo/08_kiosk_page/00_phase.md b/.agents/phases/todo/08_kiosk_page/00_phase.md new file mode 100644 index 0000000..e34cb7b --- /dev/null +++ b/.agents/phases/todo/08_kiosk_page/00_phase.md @@ -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, `