From 1f60a6b40c6745cc74d54cc0450234a7a41833b9 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 3 Nov 2026 09:15:12 -0500 Subject: [PATCH] chore(agent): phase roadmap from TODO.md, 3 phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../todo/07_kiosk_layout_state/00_phase.md | 113 ++++++++++++++++ .../01_arrange_and_layout_frames.md | 92 +++++++++++++ .../02_registry_layout_policy.md | 118 +++++++++++++++++ ...3_dispatch_read_endpoint_and_wire_tests.md | 99 ++++++++++++++ .agents/phases/todo/08_kiosk_page/00_phase.md | 106 +++++++++++++++ .../01_extract_wall_tiles_and_stage.md | 125 ++++++++++++++++++ .../02_extract_wall_key_router.md | 106 +++++++++++++++ .../08_kiosk_page/03_kiosk_page_and_route.md | 118 +++++++++++++++++ .../08_kiosk_page/04_kiosk_layout_applier.md | 122 +++++++++++++++++ .../05_kiosk_layout_e2e_and_docs.md | 92 +++++++++++++ .../todo/09_wall_remote_page/00_phase.md | 90 +++++++++++++ .../01_remote_route_and_page.md | 109 +++++++++++++++ .../02_remote_controller_logic.md | 117 ++++++++++++++++ .../03_remote_e2e_docs_and_kiosk_url.md | 119 +++++++++++++++++ 14 files changed, 1526 insertions(+) create mode 100644 .agents/phases/todo/07_kiosk_layout_state/00_phase.md create mode 100644 .agents/phases/todo/07_kiosk_layout_state/01_arrange_and_layout_frames.md create mode 100644 .agents/phases/todo/07_kiosk_layout_state/02_registry_layout_policy.md create mode 100644 .agents/phases/todo/07_kiosk_layout_state/03_dispatch_read_endpoint_and_wire_tests.md create mode 100644 .agents/phases/todo/08_kiosk_page/00_phase.md create mode 100644 .agents/phases/todo/08_kiosk_page/01_extract_wall_tiles_and_stage.md create mode 100644 .agents/phases/todo/08_kiosk_page/02_extract_wall_key_router.md create mode 100644 .agents/phases/todo/08_kiosk_page/03_kiosk_page_and_route.md create mode 100644 .agents/phases/todo/08_kiosk_page/04_kiosk_layout_applier.md create mode 100644 .agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md create mode 100644 .agents/phases/todo/09_wall_remote_page/00_phase.md create mode 100644 .agents/phases/todo/09_wall_remote_page/01_remote_route_and_page.md create mode 100644 .agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md create mode 100644 .agents/phases/todo/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md 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, `