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:
@@ -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
|
||||
Reference in New Issue
Block a user