From cd29156a7dde777bc062ff7b6145b05bd75362c6 Mon Sep 17 00:00:00 2001
From: ducoterra
Date: Tue, 3 Nov 2026 22:38:22 -0500
Subject: [PATCH] phase: 08_kiosk_page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Phase 08 final verification pass — complete, all green.**
**Verified (all 5 tasks already implemented):**
- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
**Gates (exact outcomes):**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
**Documentation:**
- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
**Next pending phase:** `09_wall_remote_page`.
---
.../08_kiosk_page/00_phase.md | 0
.../01_extract_wall_tiles_and_stage.md | 0
.../02_extract_wall_key_router.md | 0
.../08_kiosk_page/03_kiosk_page_and_route.md | 0
.../08_kiosk_page/04_kiosk_layout_applier.md | 0
.../05_kiosk_layout_e2e_and_docs.md | 0
.../08_kiosk_page__00_phase.a1.err | 0
.../08_kiosk_page__00_phase.a1.md | 22 +
.../08_kiosk_page__00_phase.a1.md.status | 1 +
.../08_kiosk_page__00_phase.a1.validate | 36 +
...ge__01_extract_wall_tiles_and_stage.a1.err | 0
...age__01_extract_wall_tiles_and_stage.a1.md | 16 +
..._extract_wall_tiles_and_stage.a1.md.status | 1 +
...1_extract_wall_tiles_and_stage.a1.validate | 36 +
...sk_page__02_extract_wall_key_router.a1.err | 0
...osk_page__02_extract_wall_key_router.a1.md | 23 +
...e__02_extract_wall_key_router.a1.md.status | 1 +
...ge__02_extract_wall_key_router.a1.validate | 36 +
...kiosk_page__03_kiosk_page_and_route.a1.err | 0
..._kiosk_page__03_kiosk_page_and_route.a1.md | 19 +
...page__03_kiosk_page_and_route.a1.md.status | 1 +
..._page__03_kiosk_page_and_route.a1.validate | 36 +
...kiosk_page__04_kiosk_layout_applier.a1.err | 0
..._kiosk_page__04_kiosk_layout_applier.a1.md | 11 +
...page__04_kiosk_layout_applier.a1.md.status | 1 +
..._page__04_kiosk_layout_applier.a1.validate | 36 +
..._page__05_kiosk_layout_e2e_and_docs.a1.err | 0
...k_page__05_kiosk_layout_e2e_and_docs.a1.md | 21 +
..._05_kiosk_layout_e2e_and_docs.a1.md.status | 1 +
...__05_kiosk_layout_e2e_and_docs.a1.validate | 36 +
AGENTS.md | 6 +-
README.md | 23 +-
docs/kiosk-silverblue.md | 50 ++
src/mirror/app.py | 12 +-
src/mirror/static/app.css | 107 ++-
src/mirror/static/index.html | 8 +
src/mirror/static/kiosk.html | 74 ++
src/mirror/static/kiosk.js | 363 ++++++++
src/mirror/static/mirror.html | 6 +-
src/mirror/static/wall.js | 847 ++++++++++++++++++
src/mirror/static/watch.html | 7 +-
src/mirror/static/watch.js | 758 ++--------------
tests/e2e/test_kiosk_layout.py | 360 ++++++++
tests/e2e/test_live_mirror.py | 6 +-
tests/test_app.py | 178 +++-
45 files changed, 2389 insertions(+), 750 deletions(-)
rename .agents/phases/{todo => complete}/08_kiosk_page/00_phase.md (100%)
rename .agents/phases/{todo => complete}/08_kiosk_page/01_extract_wall_tiles_and_stage.md (100%)
rename .agents/phases/{todo => complete}/08_kiosk_page/02_extract_wall_key_router.md (100%)
rename .agents/phases/{todo => complete}/08_kiosk_page/03_kiosk_page_and_route.md (100%)
rename .agents/phases/{todo => complete}/08_kiosk_page/04_kiosk_layout_applier.md (100%)
rename .agents/phases/{todo => complete}/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md (100%)
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status
create mode 100644 .agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate
create mode 100644 src/mirror/static/kiosk.html
create mode 100644 src/mirror/static/kiosk.js
create mode 100644 src/mirror/static/wall.js
create mode 100644 tests/e2e/test_kiosk_layout.py
diff --git a/.agents/phases/todo/08_kiosk_page/00_phase.md b/.agents/phases/complete/08_kiosk_page/00_phase.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/00_phase.md
rename to .agents/phases/complete/08_kiosk_page/00_phase.md
diff --git a/.agents/phases/todo/08_kiosk_page/01_extract_wall_tiles_and_stage.md b/.agents/phases/complete/08_kiosk_page/01_extract_wall_tiles_and_stage.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/01_extract_wall_tiles_and_stage.md
rename to .agents/phases/complete/08_kiosk_page/01_extract_wall_tiles_and_stage.md
diff --git a/.agents/phases/todo/08_kiosk_page/02_extract_wall_key_router.md b/.agents/phases/complete/08_kiosk_page/02_extract_wall_key_router.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/02_extract_wall_key_router.md
rename to .agents/phases/complete/08_kiosk_page/02_extract_wall_key_router.md
diff --git a/.agents/phases/todo/08_kiosk_page/03_kiosk_page_and_route.md b/.agents/phases/complete/08_kiosk_page/03_kiosk_page_and_route.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/03_kiosk_page_and_route.md
rename to .agents/phases/complete/08_kiosk_page/03_kiosk_page_and_route.md
diff --git a/.agents/phases/todo/08_kiosk_page/04_kiosk_layout_applier.md b/.agents/phases/complete/08_kiosk_page/04_kiosk_layout_applier.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/04_kiosk_layout_applier.md
rename to .agents/phases/complete/08_kiosk_page/04_kiosk_layout_applier.md
diff --git a/.agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md b/.agents/phases/complete/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md
similarity index 100%
rename from .agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md
rename to .agents/phases/complete/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md
new file mode 100644
index 0000000..a762d21
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md
@@ -0,0 +1,22 @@
+**Phase 08 final verification pass — complete, all green.**
+
+**Verified (all 5 tasks already implemented):**
+- `static/wall.js` (`createWall` + `installWallKeys`), `kiosk.html`/`kiosk.js`, `GET /kiosk` route — guards: no `$("#…")` in wall.js, one keydown listener, kiosk ids no-collision across 3 pages, no audio field in `arrange`, no header/share link in markup, address gate covers `/kiosk`
+- Cross-page e2e: arrival-arrangement, keypress-on-one-display moves the other (asserted on the second display's decoded moving pixels), dead mirror → `data-layout="wall"` on both, `followers` count, pointer-free meta-test
+
+**Gates (exact outcomes):**
+- `uv run ruff check . && uv run ruff format --check .` — passed
+- `uv run pyright` — 0 errors
+- `uv run pytest` — 341 passed, 5 skipped (realdisplay-gated), 96s
+- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented: host has no audio source for real capture)
+- Coverage 96% (>90%); only warning is a starlette/httpx third-party deprecation
+
+**Completion criteria:** all 9 ✅ — e2e green incl. `test_kiosk_layout.py` (the one e2e diff is the phase-mandated address-gate extension to `/kiosk`; assertion logic untouched), full suite 100% passing, README documents `/kiosk`, PLAN.md untouched.
+
+**Documentation:**
+- README: fixed the Kiosk-client section that implied the display box is permanently pointed at `/watch` — now states the display page is `/kiosk` with the runbook's one-line re-point (installer default still `/watch` this phase, per docs). Rest of README verified against code (routes, config table, CLI flag, presets, key table) — consistent.
+- AGENTS.md: fixed 2 stale statements — CDN test moved to `TestPages` (was `TestClientScripts`), suite runtime ~50s → ~95s. No content added.
+
+**Notable:** PLAN.md §6 says "per-leg `maxBitrate` cap (default 12 Mbit/s)" but code/README default is 25 Mbit/s — pre-existing, left untouched (locked file; flagged for a user decision).
+
+**Next pending phase:** `09_wall_remote_page`.
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate
new file mode 100644
index 0000000..e919b32
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__00_phase.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 20%]
+......................................sssss............................. [ 41%]
+........................................................................ [ 62%]
+........................................................................ [ 83%]
+.......................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.15.0-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 194 10 95% 108, 113, 117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 706 31 96%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+341 passed, 5 skipped, 1 warning in 96.44s (0:01:36)
+All checks passed!
+33 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md
new file mode 100644
index 0000000..739891b
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md
@@ -0,0 +1,16 @@
+Task 01 complete — all gates green.
+
+**Implemented**
+- New `src/mirror/static/wall.js`: `createWall({grid, stageEl, template, send, rtc, onChange, stats})` owning tiles, peers, stage, selection, mute policy, autofade, fullscreen-exit race, stats ticker, `closePeers()`; returns the task's surface plus `selectedTile()`, `revealStageControls()`, `handleFullscreenChange()`. Zero document queries; socket via page's `send` (guarded on `state.ws` in the page closure, so the wall builds before the socket opens).
+- `watch.js` now a thin page: socket, `renderCounts` as the `onChange` callback, `fullscreenchange`/pointer/key bindings, help, refusal, share link. Dead `ui.unstage` branch deleted with a comment pointing at the per-tile `.exit` button.
+- `app.css`: `#watch-stage` → `.stage` (all 10 rules, comments updated to class-scoped/source-order rationale); `watch.html` stage gains `class="stage"`.
+- **Collision found & fixed:** the share page already used `class="stage"` — the literal re-key would have made `#preview-stage` inherit `position: fixed; inset: 0; display: none` (same specificity, later rule wins). Renamed the share preview to `.preview-stage` (matching its id) so `.stage` is wall-only; `test_share_layout`/`test_live_mirror` pass unedited, proving the share page is unchanged.
+- `test_app.py`: `wall.js` in parse/asset lists; new `test_wall_never_looks_up_elements_in_the_document` (cites the id-collision test as its reason); `test_the_two_pages_use_the_media_apis_they_claim` re-pointed at `wall.js` (RTCPeerConnection moved); the two TestStylesheet tests mechanically re-pointed to `.preview-stage`.
+
+**Gates** — `ruff check` + `format --check` clean; `pyright` 0 errors; targeted e2e (`-k "keyboard or live_mirror or audio or stage or share_layout"`) 92 passed **unedited**; full e2e 103 passed; full `uv run pytest` 322 passed / 5 skipped (realdisplay-marked); `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed / 1 platform-documented skip. No e2e file touched.
+
+**Docs** — no doc impact (pure refactor; README/AGENTS.md state nothing this changed).
+
+**Decisions** — `.preview-stage` rename (above); `rtc` added to the wall options (peers moved into the wall, config stays page-owned); redundant `renderCounts()` in the `welcome` case dropped (per-`absorb` `onChange` covers it, identical final state).
+
+**Next pending task** — `02_extract_wall_key_router.md` (phase 08).
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate
new file mode 100644
index 0000000..b69116e
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__01_extract_wall_tiles_and_stage.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 22%]
+...............................sssss.................................... [ 44%]
+........................................................................ [ 66%]
+........................................................................ [ 88%]
+....................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 703 32 95%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+322 passed, 5 skipped, 1 warning in 98.55s (0:01:38)
+All checks passed!
+32 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md
new file mode 100644
index 0000000..2008247
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md
@@ -0,0 +1,23 @@
+All done. Final state verified — here's the report:
+
+## Task 02 report — `installWallKeys()` in `wall.js`
+
+**Implemented**
+- `wall.js`: new `installWallKeys({ wall, onCommand, root? })` — the one `keydown` listener: case-insensitive key→command switch (`next`, `previous`, `stage`, `fullscreen`, `wall`, `step_next`, `step_previous`, `mute`, `jump` w/ 0-based `index`, `help`, `escape`), chord/text-field bail-outs, staged-reveal before dispatch (never preventDefaults), and `preventDefault()` on exactly the keys that changed something. Wall commands carry module defaults (moved verbatim from `watch.js`, with their "why" comments); `help`/`escape` have no default (page policy).
+- `wall.js`: `createWall` surface gains `reRequestFullscreen()` so the `f`-when-staged default can re-request on the stage element the wall owns (same element `/watch` used via `ui.stage`).
+- `watch.js`: deleted `onWallKey`, `stageSelectedOrFirst`, `stepStage`, `toggleHelp` and the `document.addEventListener("keydown", …)` line; now `installWallKeys({ wall, onCommand })` with `onCommand` answering only `help` (toggle `#wall-help`, always handled) and `escape` (overlay first, then `wall.unstage()`, handled iff either happened) — the `?` focus comment moved with the behaviour.
+- `test_app.py`: new guard — `installWallKeys` defined in `wall.js`, `wall.js` holds exactly one keydown listener, and no wall-page module (watch.js, future kiosk/remote) adds its own.
+
+**Results (all gates)**
+- `uv run ruff check . && uv run ruff format --check .` → clean
+- `uv run pyright` → 0 errors
+- `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** → 35/35 passed
+- `uv run pytest -m e2e` → 103 passed, 5 skipped (realdisplay-gated)
+- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (no audio source on host — pre-existing env skip)
+- `uv run pytest` (full) → **323 passed, 5 skipped**, 0 failed; coverage ~95% (JS router covered by browser assertions, per AGENTS.md §4)
+
+**Docs:** no doc impact (pure refactor, no user-visible change; README/AGENTS.md unaffected).
+
+**Notable decision:** `grep 'addEventListener("keydown"' static/*.js` shows the one `wall.js` hit *plus* `share.js`'s pre-existing `/mirror` s/x/Esc listener (share-page policy, not a wall page — untouched, out of scope); the test guard encodes the "one listener per wall page" rule exactly. `fullscreen` got a module default (not listed in the task's default list, but required for item 5's thin-observer `/watch` and for `/kiosk` to publish while the module performs).
+
+**Next pending task:** `08_kiosk_page/03_kiosk_page_and_route.md`
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate
new file mode 100644
index 0000000..73e0789
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__02_extract_wall_key_router.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 21%]
+...............................sssss.................................... [ 43%]
+........................................................................ [ 65%]
+........................................................................ [ 87%]
+........................................ [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 191 11 94% 108, 113, 116-117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 703 32 95%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+323 passed, 5 skipped, 1 warning in 92.90s (0:01:32)
+All checks passed!
+32 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md
new file mode 100644
index 0000000..79a957e
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md
@@ -0,0 +1,19 @@
+All done. Final state is 100% green across all four gates.
+
+**Report — phase 08, task 03 (`GET /kiosk` + page skeleton)**
+
+**Implemented**
+- `GET /kiosk` route in `app.py` (beside `/watch`), `/kiosk` added to `PAGES` (no-store); fixed the stale "Two pages" module docstring
+- `static/kiosk.html`: chrome-free wall — `body.kiosk[data-layout="wall"][data-fullscreen="false"]`, `#kiosk-grid`, `#kiosk-empty` + `#kiosk-share-url`, `#kiosk-stage` with mandatory `class="stage"`, `#kiosk-hud`, `#kiosk-tile-template`, shared `net`/`notice`/`toast` only, no header, `Wall · mirror` (the check-kiosk.sh contract)
+- Placeholder `static/kiosk.js` (serves, fills the share URL, shows refusal notice; wall/layout are task 04's)
+- `app.css`: `.kiosk .grid` full-bleed, `.kiosk .net` fixed top-right (z 60, above stage), `.hud` fixed bottom-left (mono, dim, pointer-events none)
+- `index.html`: third card "GET /kiosk — An unattended wall"
+- `test_app.py` guards extended to the third page (render, no-store×5, asset, `node --check`, element-reaches-for, stylesheet+module — renamed, video muted/playsinline, id-collision incl. `kiosk` with `#kiosk-*` prefix, new stage-class + structural-element guard, address-gate refused + admitted 200); `test_live_mirror.py` refused-address list + `#kiosk-grid`/`#kiosk-stage` selectors
+
+**Gates** — `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` **334 passed**, 5 skipped (realdisplay marker) ✅ · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` 4 passed, 1 skipped (host has no audio source — the test's own environmental skip, pre-existing) ✅ · curl: `GET /kiosk` 200 + `no-store`; browser check: empty-state wall, share URL filled, no header, net pill fixed top-right, zero console errors
+
+**Docs** — README: `/kiosk` in the page table, quickstart, and the shared-state paragraph (replaced "arrives in a later release"); AGENTS.md: "Three pages" → "Four pages" (stale count)
+
+**Decisions** — placeholder also imports `showNotice` (the only display path in `net.js`; "show a refusal notice" is impossible with only the two named imports); `kiosk ∩ index` id assertion is `<= shared` (index.html has no `notice`/`toast` by structure — same shape as the existing index assertion); id-guard test kept its original name (AGENTS.md and `wall.js` reference it)
+
+**Next task** — `08_kiosk_page/04_kiosk_layout_applier.md`
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate
new file mode 100644
index 0000000..9630ad2
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__03_kiosk_page_and_route.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 21%]
+................................sssss................................... [ 42%]
+........................................................................ [ 63%]
+........................................................................ [ 84%]
+................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.14.8-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 194 11 94% 108, 113, 116-117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 706 32 95%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+334 passed, 5 skipped, 1 warning in 98.11s (0:01:38)
+All checks passed!
+32 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md
new file mode 100644
index 0000000..34272f7
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md
@@ -0,0 +1,11 @@
+All done. Final report:
+
+**Phase 08, Task 04 — `kiosk.js`: obey the layout, publish the keystrokes — complete**
+
+- Verified the prior partial run's `kiosk.js` against the task spec (module shape, `hello{kiosk:true}`, idempotent `applyLayout()`, remembered-intent retry, best-effort never-awaited fullscreen with the required comment, body `data-layout`/`data-fullscreen` + title mirroring, per-command `arrange` publishing, `m` local-only with the SECURITY.md §4 comment, no `fullscreenchange` policy) — spec-compliant; fixed one broken indentation block in the `onMessage` switch
+- Added the missing `test_app.py` source-level guard: `test_kiosk_never_publishes_an_audio_field` — brace-matches every `type:"arrange"` object literal in `kiosk.js` (member-order independent), asserts none carries `muted:`/`audio:`, and asserts at least one `arrange` frame exists; negative controls verified to be caught
+- Verified every completion criterion with a throwaway real-browser/real-server script (deleted after): arrive-after-arrange shows the staged mirror with no wall flash (grid never paints a tile) and moving pixels on the stage; raw-socket (websocat) `arrange` re-arranges an open tab; `Enter`/`g`/`n`/`p`/`1`/`f` change `GET /api/kiosk`; `j`/`k` publish nothing; `m` flips `video.muted` with `/api/kiosk` byte-identical; a second kiosk display follows the first and teardown returns both to the wall; `followers` counts both kiosks only; zero page errors
+- Gates: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 32 files formatted; `uv run pyright` → 0 errors; `uv run pytest` → 335 passed, 5 skipped (realdisplay-gated), 96% coverage; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host audio-source limitation)
+- README.md: one sentence added — kiosk keypresses publish the same `arrange` intent (controller/display converge) and `m` is local-only; no AGENTS.md changes needed for this task (the "four pages" edit was task 03's and is correct)
+- Deviation note: task 04's kiosk.js was already written by the aborted prior run; my work was full spec verification, the indentation fix, and the missing guard — no behavior code was needed
+- Next pending task: `.agents/phases/todo/08_kiosk_page/05_kiosk_layout_e2e_and_docs.md` (the permanent `test_kiosk_layout.py` suite + docs)
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate
new file mode 100644
index 0000000..5e4af3c
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__04_kiosk_layout_applier.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 21%]
+................................sssss................................... [ 42%]
+........................................................................ [ 63%]
+........................................................................ [ 84%]
+.................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.15.0-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 194 10 95% 108, 113, 117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 706 31 96%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+335 passed, 5 skipped, 1 warning in 92.04s (0:01:32)
+All checks passed!
+32 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md
new file mode 100644
index 0000000..653ed3f
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md
@@ -0,0 +1,21 @@
+Everything is in place and verified. All gates were run after the final code/doc state (the last edit was a cosmetic link-label fix in the docs).
+
+## Report
+
+**Implemented**
+- `tests/e2e/test_kiosk_layout.py`: 5 behavioural tests + meta-guard — arrival-is-arrangement (driver `arrange` over a raw `page.evaluate` WebSocket *before* `/kiosk` opens; asserts `body[data-layout="stage"]`, `data-fullscreen="true"`, staged tile, decoded moving frames on `#kiosk-stage video`), one keypress on one display re-arranging a second pointer-free display (asserted on the second's decoded pixels + `/api/kiosk`), `n`/`p` stepping with wrap, teardown returning both displays to `data-layout="wall"` with no dead tiles, and `followers` counting displays only over a real socket lifecycle. Established `device()` shape (isolated contexts, finalizers, `#net` online wait, pageerror+console from the first line); the self-guard (same style as `test_keyboard_wall.py`, literals built from parts) bans pointer calls and explicitly allows the wire-level driver; the `document.fullscreenElement` non-assertion is commented as required.
+- `docs/kiosk-silverblue.md`: new "Two endpoints: `/watch` and `/kiosk`" section (operator vs. display, keys still work on `/kiosk`, controller arrives in phase 09 `/remote`, honest `MIRROR_KIOSK_URL` migration rule with re-point + `gdm` restart; no changes to `install-kiosk.sh`/`kiosk.conf`/`gnome-kiosk-script`/`check-kiosk.sh`).
+- `README.md`: one sentence in the Keys section (wall keys work on `/kiosk`; a change made there is what every other display follows). The `/kiosk` page-table row already existed from task 03.
+
+**Gates (all green)**
+- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors
+- `uv run pytest tests/e2e/test_kiosk_layout.py -x` — 6/6 passed
+- `uv run pytest -m e2e` — 110 passed, 5 skipped (realdisplay-only)
+- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 skipped (documented host-has-no-audio-source)
+- `uv run pytest` — 341 passed, 5 skipped, 0 failed; coverage 95% (unchanged; task adds no product code)
+
+**Docs** — `docs/kiosk-silverblue.md` + `README.md` updated as above; `AGENTS.md` no stale facts → untouched.
+
+**Decisions** — driver closes its socket after the server's `layout` ack (state is process-level, so no `window` global / no leaked peer); mid-test context close in the followers test is guarded so the finalizer can't double-close.
+
+**Next pending task** — none in `todo/` (phase 08 is complete; phase 09 `/remote` is not yet authored).
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate
new file mode 100644
index 0000000..ed51805
--- /dev/null
+++ b/.agents/reports/08_kiosk_page/08_kiosk_page__05_kiosk_layout_e2e_and_docs.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 20%]
+......................................sssss............................. [ 41%]
+........................................................................ [ 62%]
+........................................................................ [ 83%]
+.......................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1
+ /home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.15/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.15.0-final-0 ________________
+
+Name Stmts Miss Cover Missing
+------------------------------------------------------
+src/mirror/__init__.py 3 0 100%
+src/mirror/__main__.py 72 3 96% 129, 145, 156
+src/mirror/access.py 60 0 100%
+src/mirror/app.py 194 11 94% 108, 113, 116-117, 182-183, 212-216
+src/mirror/config.py 68 5 93% 110-111, 118-120
+src/mirror/debug.py 15 10 33% 19-28
+src/mirror/protocol.py 66 0 100%
+src/mirror/registry.py 228 3 99% 208, 226, 460
+------------------------------------------------------
+TOTAL 706 32 95%
+=========================== short test summary info ============================
+SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
+341 passed, 5 skipped, 1 warning in 96.45s (0:01:36)
+All checks passed!
+33 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/AGENTS.md b/AGENTS.md
index f964470..bef2415 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,8 +40,8 @@ is a roster and a signaling relay. Linux is the reference platform.
| Rule | Why it exists |
|---|---|
-| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
-| **No framework, no bundler** in `src/mirror/static/` | Three pages and one WebSocket. `node --check` is the entire frontend toolchain |
+| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestPages::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
+| **No framework, no bundler** in `src/mirror/static/` | Four pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
@@ -57,7 +57,7 @@ is a roster and a signaling relay. Linux is the reference platform.
```bash
uv run ruff check . && uv run ruff format --check .
uv run pyright # must be 0 errors
-uv run pytest # unit + API + browser E2E (~50s)
+uv run pytest # unit + API + browser E2E (~95s)
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
```
diff --git a/README.md b/README.md
index 884776f..d19a9bb 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ your network:
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
+| **Kiosk** | `/kiosk` | A chrome-free wall for an unattended display: no header, no counts — the net pill is the only chrome, and the shared layout state (below) arranges it. |
| **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can
@@ -30,6 +31,7 @@ Then, from another machine on the LAN:
```
http://:8055/mirror # to share
http://:8055/watch # to watch
+http://:8055/kiosk # the unattended wall (kiosk display)
```
Linux is the primary target. On Linux the native picker is X11/Wayland's own
@@ -67,9 +69,14 @@ field is "no opinion", `"staged": null` is "back to the wall". A change is pushe
immediately as a `layout` frame to every viewer (and back to the sender), and the
same object is readable at `GET /api/kiosk` — `curl`-debuggable like `/api/streams`,
`Cache-Control: no-store`. The state has no audio: sound always traces back to a
-gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The kiosk
-page that follows this state arrives in a later release; the wire and the read
-surface are what any client builds on.
+gesture on the device that plays it (see [SECURITY.md](SECURITY.md) §4). The
+`/kiosk` page is the display that follows this state: a chrome-free wall that
+subscribes to every mirror like `/watch` and does what the state says —
+`Cache-Control: no-store` on the read and on the page itself. If a keyboard is
+plugged in, the same keys as `/watch` work, and every keypress publishes the
+same `arrange` intent, so a controller and a display converge on one state
+(`m` is local-only: a display never becomes audible because another machine
+asked).
## Configuration
@@ -172,8 +179,10 @@ from this side:
A wall display is a Fedora Silverblue box running the locked-down GNOME Kiosk
session, auto-logged in as `kiosk`, with Firefox in `--kiosk` mode pointed at
-`/watch`. It is set up and repaired entirely over SSH — no keyboard, no console,
-no monitor. A brand-new box layers the kiosk packages once first
+the wall. It is set up and repaired entirely over SSH — no keyboard, no console,
+no monitor. The display page is `/kiosk`; the installer default still opens
+`/watch` and an existing `kiosk.conf` URL wins, so moving a box is the
+one-line re-point in [the runbook](docs/kiosk-silverblue.md#two-endpoints-watch-and-kiosk). A brand-new box layers the kiosk packages once first
(`rpm-ostree install gnome-kiosk gnome-kiosk-script-session`, then a reboot);
after that these two commands are both the setup and the repair path:
@@ -212,6 +221,10 @@ in. On `/watch`:
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
+The same wall keys work on `/kiosk` too — the unattended display — and a
+change made there (a keypress stages the mirror *and* publishes the same
+`arrange` intent) is what every other display follows.
+
A staged mirror's caption bar and control cluster are **hidden at rest**, so
the shared video — a terminal's bottom line included — is never covered by
them. A pointer move over the stage (a tap, for touch) or any key press
diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md
index 6fefab7..36ff87a 100644
--- a/docs/kiosk-silverblue.md
+++ b/docs/kiosk-silverblue.md
@@ -64,6 +64,56 @@ so the fleet is auditable.
---
+## Two endpoints: `/watch` and `/kiosk`
+
+The wall has two client pages, and they are not the same page twice:
+
+* **`/watch` is a wall a person operates.** The operator's page: the grid,
+ the keys, and the chrome a person standing in front of it needs — header,
+ share link, counts.
+* **`/kiosk` is a display that obeys a wall.** No chrome at all: no header,
+ no counts, no share button — the net pill is the only thing on screen that
+ is not a mirror. It subscribes to every mirror like `/watch` does and then
+ does what the shared layout state says — on arrival *and* on every change —
+ with no input from anyone standing at the box. Point the box at it and it
+ arranges itself: whichever mirror is on stage, fullscreen or not, is
+ applied before the first tile finishes painting, so a person who points a
+ kiosk at `/kiosk` never has to touch it. When a staged mirror goes away,
+ every display falls back to the wall in the same frame set; no display sits
+ on a dead tile.
+
+The state is the boss, locally too: **the wall keys still work on `/kiosk`**
+for the moment a keyboard is plugged in (see [the key table](#operating-the-wall-keys)),
+and a keypress there performs the change *and* publishes the same `arrange`
+intent, so a controller and a display converge on one state instead of
+disagreeing (`m` is local-only — a display never becomes audible because
+another machine asked). The **controller side** — a page a person on another
+machine drives the wall from — arrives in phase 09 (`/remote`); until then a
+controller is any client that can send an `arrange` frame on `/ws`, `websocat`
+included. `GET /api/kiosk` is the state's read surface: what is on stage,
+whether it is fullscreen, and how many displays (`followers`) are obeying it.
+
+**The migration rule, stated honestly.** Existing clients keep their own
+`MIRROR_KIOSK_URL`: an existing `kiosk.conf` URL wins over the built-in
+default, and the installer's default still points at `/watch` in this phase.
+So **nothing changes under a box until its `kiosk.conf` is re-pointed and
+`gdm` restarted**:
+
+```bash
+sudo sed -i 's|^MIRROR_KIOSK_URL=.*|MIRROR_KIOSK_URL="https://…/kiosk"|' \
+ /var/home/kiosk/.config/mirror/kiosk.conf
+sudo systemctl restart gdm
+```
+
+(`pkill -u kiosk firefox` is the shorter path — the loop re-sources
+`kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) —
+`gdm` is the clean restart when you want the whole session back.) The kiosk
+page serves `Wall · mirror`; `check-kiosk.sh`, untouched in this
+phase, still greps for `Watch · mirror`, and the checker is widened to
+accept the kiosk title in phase 09, when the default URL moves with it.
+
+---
+
## The two facts that make this work
**Silverblue's mutable seams.** `/usr` is read-only and belongs to the OSTree
diff --git a/src/mirror/app.py b/src/mirror/app.py
index 942f89b..da0c2b7 100644
--- a/src/mirror/app.py
+++ b/src/mirror/app.py
@@ -1,7 +1,7 @@
"""The HTTP + signaling server.
-Two pages and one WebSocket. Media flows browser-to-browser; the server only
-shuffles JSON describing who is mirroring what.
+A handful of pages and one WebSocket. Media flows browser-to-browser; the
+server only shuffles JSON describing who is mirroring what.
"""
from __future__ import annotations
@@ -310,6 +310,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"""The page that shows every live mirror and can stage one fullscreen."""
return _page("watch.html")
+ @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")
+
@app.get("/api/config")
async def client_config() -> JSONResponse:
return JSONResponse(
@@ -350,7 +356,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return
await hub.serve(websocket)
- PAGES = ("/", "/mirror", "/share", "/watch")
+ PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk")
@app.middleware("http")
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css
index 1e3226d..4bd2228 100644
--- a/src/mirror/static/app.css
+++ b/src/mirror/static/app.css
@@ -225,15 +225,19 @@ a { color: var(--accent); }
align-items: start;
}
-/* The stage is ONE cell. Its children — the
+
+
+
+
+
+
diff --git a/src/mirror/static/kiosk.js b/src/mirror/static/kiosk.js
new file mode 100644
index 0000000..3fe2e40
--- /dev/null
+++ b/src/mirror/static/kiosk.js
@@ -0,0 +1,363 @@
+/* The kiosk page: a chrome-free wall for an unattended display.
+
+ It is a display, not a controller: it announces `kiosk: true` in its hello
+ (so the layout frame's `followers` counts it), subscribes to every mirror
+ like /watch, and does what the shared layout says — on arrival
+ (`welcome.layout`) and on every `layout` frame after. A keypress on the box
+ (rare, but it happens when a keyboard is plugged in to diagnose) performs
+ the change AND publishes the same intent with an `arrange` frame, so the
+ controller and the display converge instead of disagreeing: one writer path,
+ everyone re-reads.
+
+ What this page never does:
+ * stage or unstage on its own — the state is the boss, locally too; an empty
+ state means the wall, and nothing auto-stages (never invent state);
+ * publish anything about sound — the layout has no audio field
+ (SECURITY.md §4), so `m` is local-only and a display must not become
+ audible because another machine asked;
+ * react to the browser's own fullscreen exits — the window manager dropping
+ fullscreen is not a person asking to leave the stage (on /watch it is a
+ deliberate policy; on a wall it would fight the state), and a refused
+ request is the browser's answer, not a preference to echo.
+ */
+
+import {
+ $,
+ connectSignaling,
+ deviceLabel,
+ loadConfig,
+ refusalNotice,
+ rtcConfig,
+ showNotice,
+ toast,
+} from "./net.js";
+import { createWall, installWallKeys } from "./wall.js";
+
+const config = await loadConfig();
+const rtc = rtcConfig(config);
+
+const ui = {
+ grid: $("#kiosk-grid"),
+ stage: $("#kiosk-stage"),
+ template: $("#kiosk-tile-template"),
+ empty: $("#kiosk-empty"),
+ shareUrl: $("#kiosk-share-url"),
+ hud: $("#kiosk-hud"),
+ net: $("#net"),
+ toast: $("#toast"),
+ notice: $("#notice"),
+};
+
+// Nobody is standing at the box to click anything, so the person reading the
+// screen needs a URL to type elsewhere.
+if (ui.shareUrl) ui.shareUrl.textContent = `${location.origin}/mirror`;
+
+const state = {
+ ws: null,
+ // The last layout frame: the single source of truth this page arranges from.
+ layout: null,
+ // What the page last applied — the applier's idempotency memory.
+ applied: null,
+ mirrors: 0,
+};
+
+/* -------------------------------------------------------------- rendering */
+
+function setNet(next, detail) {
+ if (!ui.net) return;
+ ui.net.dataset.state = next;
+ const label = ui.net.querySelector("span");
+ if (label) label.textContent = detail || next;
+}
+
+function renderHud() {
+ // The same hidden toggles /watch does in renderCounts(): the grid shows when
+ // there is something to show, the empty state when there is not.
+ ui.grid.hidden = state.mirrors === 0;
+ ui.empty.hidden = state.mirrors > 0;
+ const n = state.mirrors;
+ const words = `${n} mirror${n === 1 ? "" : "s"}`;
+ const stagedId = wall.stagedId();
+ const tile = stagedId ? wall.tile(stagedId) : null;
+ let text = tile ? `${words} · ${tile.title.textContent}` : `${words} · wall`;
+ // The wall's self-description for a room with no keyboard: it names the state
+ // knob, not the browser's concession — a refused request never reads as
+ // "not fullscreen" here.
+ if (state.layout && state.layout.fullscreen) text += " · fullscreen";
+ ui.hud.textContent = text;
+}
+
+function onChange({ mirrors }) {
+ state.mirrors = mirrors;
+ renderHud();
+}
+
+/* ---------------------------------------------------------------- applier */
+
+/**
+ * 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 idempotency check has two halves: the frame matches what was last
+ * applied, and the wall is actually in the state the frame names. The second
+ * half is what keeps a *remembered* intent alive — a frame that named a mirror
+ * not in the wall yet (it died on the way here, or has not arrived) leaves the
+ * page on the wall, so the wall does not match the frame, and the retry (the
+ * next roster frame re-runs this on `state.layout`) stages the tile the moment
+ * it lands.
+ *
+ * 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) {
+ if (!next) return;
+ const wants = next.staged || null;
+ const fullscreen = !!next.fullscreen;
+ const settled =
+ state.applied !== null &&
+ state.applied.staged === wants &&
+ state.applied.fullscreen === fullscreen &&
+ (wants === null || wall.stagedId() === wants);
+ if (settled) return;
+ state.applied = { staged: wants, fullscreen };
+
+ if (wants) {
+ const tile = wall.tile(wants);
+ if (tile) {
+ // fullscreen: false — the one fullscreen request for this frame is the
+ // one below, aimed at the target the page is actually showing.
+ wall.stage(wants, { fullscreen: false });
+ } else {
+ // The frame named a mirror the wall does not have: it died on the way
+ // here. Back to the wall; `state.layout` keeps the intent, and the next
+ // roster frame re-applies it.
+ wall.unstage();
+ }
+ } else {
+ wall.unstage();
+ }
+
+ if (fullscreen) {
+ // A remote command carries no user activation, so requestFullscreen() may
+ // be refused. The request is a courtesy, never awaited, and its refusal is
+ // never echoed back: the .stage CSS fills the viewport anyway (the stage
+ // is the guarantee) and the Silverblue window is already fullscreen
+ // (docs/kiosk-silverblue/window-config.ini). Do not "fix" this by
+ // awaiting or by publishing the refusal — the next reader will be tempted
+ // to.
+ const target = wall.stagedId() ? ui.stage : document.documentElement;
+ if (target.requestFullscreen) {
+ target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
+ /* the in-page stage is the guarantee; the fullscreen is a bonus */
+ });
+ }
+ }
+
+ // Mirror both knobs onto the body (a screenshot and a test read them without
+ // reaching into JS) and set the title: a window list and a screenshot are
+ // the only views a headless box offers.
+ const stagedId = wall.stagedId();
+ document.body.dataset.layout = stagedId ? "stage" : "wall";
+ document.body.dataset.fullscreen = fullscreen ? "true" : "false";
+ const tile = stagedId ? wall.tile(stagedId) : null;
+ document.title = tile ? `mirror — ${tile.title.textContent}` : "mirror — wall";
+ renderHud();
+}
+
+/* ------------------------------------------------------------------ wall */
+
+const wall = createWall({
+ grid: ui.grid,
+ stageEl: ui.stage,
+ template: ui.template,
+ rtc,
+ // Guarded on the socket the way watch.js guards it, so the wall can be
+ // built before the socket opens (net.js stays the only place a socket
+ // exists, AGENTS.md §6).
+ send: (payload) => (state.ws ? state.ws.send(payload) : false),
+ onChange,
+});
+
+/* ------------------------------------------------------------------ keys */
+
+// One writer path: the display performs the keypress and publishes the same
+// intent it performed, so a controller and a display converge on one state.
+function publish(frame) {
+ if (state.ws) state.ws.send(frame);
+}
+
+function onCommand(command, detail) {
+ switch (command) {
+ case "stage": {
+ // Same target the wall's default would stage — the cursor, else the
+ // first tile — same no-silent-no-op toasts.
+ const tile = wall.selectedTile() || wall.ordered()[0];
+ if (!tile) {
+ toast("no mirrors to stage");
+ return true;
+ }
+ wall.stage(tile.id);
+ publish({ type: "arrange", staged: tile.id });
+ return true;
+ }
+ case "wall":
+ case "escape": {
+ // Both leave the stage locally and publish the same intent. Like on
+ // /watch, a key that changed nothing is not "handled": the browser keeps
+ // its default (on a fullscreen wall that is the browser's own exit, and
+ // work item six says the page does not publish those).
+ const wasStaged = wall.stagedId() !== null;
+ wall.unstage();
+ if (wasStaged) publish({ type: "arrange", staged: null });
+ return wasStaged;
+ }
+ case "step_next":
+ case "step_previous": {
+ const ordered = wall.ordered();
+ if (ordered.length === 0) {
+ toast("no mirrors to switch to");
+ return true;
+ }
+ const selected = wall.selectedId();
+ const delta = command === "step_next" ? 1 : -1;
+ let index = selected ? ordered.findIndex((t) => t.id === selected) : -1;
+ if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n first, p last
+ const next = ordered[(index + delta + ordered.length) % ordered.length];
+ wall.stage(next.id);
+ publish({ type: "arrange", staged: next.id });
+ return true;
+ }
+ case "jump": {
+ const tile = wall.ordered()[detail.index];
+ if (!tile) {
+ toast(`no mirror ${detail.index + 1}`);
+ return true;
+ }
+ wall.stage(tile.id);
+ publish({ type: "arrange", staged: tile.id });
+ return true;
+ }
+ case "fullscreen": {
+ // The local effect is the *request*; the state's `fullscreen` knob is
+ // what the frame publishes. On the wall the target is the document, and
+ // pressing `f` there must not stage: that would invent a `staged` the
+ // frame does not carry.
+ if (wall.stagedId()) {
+ wall.reRequestFullscreen();
+ } else if (document.documentElement.requestFullscreen) {
+ document.documentElement
+ .requestFullscreen({ navigationUI: "hide" })
+ .catch(() => {
+ /* no user activation on a kiosk: the .stage CSS fills the
+ viewport anyway; the window manager already forces fullscreen */
+ });
+ }
+ publish({ type: "arrange", fullscreen: true });
+ return true;
+ }
+ case "mute": {
+ // Never published: 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.
+ const stagedId = wall.stagedId();
+ const tile = (stagedId && wall.tile(stagedId)) || wall.selectedTile();
+ if (!tile) {
+ toast("no mirror to mute");
+ } else {
+ wall.setMuted(tile, !tile.video.muted);
+ if (tile.audioBtn.hidden) toast("this mirror has no audio");
+ }
+ return true;
+ }
+ case "next":
+ case "previous":
+ // The cursor is local to this page: the module moves the selection and
+ // nothing is published (there is no cursor in the shared state).
+ return false;
+ case "help":
+ // This page has no key-list markup: `?` does nothing and keeps its
+ // browser default.
+ return false;
+ default:
+ return false;
+ }
+}
+
+/* ------------------------------------------------------------------ socket */
+
+state.ws = connectSignaling({
+ // A refused address never opens a socket (net.js says so via the stub);
+ // the notice is the only thing the page owes the person reading the screen.
+ refused: !!config.refused,
+ refusedReason: config.refusedMessage,
+ onOpen() {
+ // A display, not a controller: `kiosk: true` is what feeds `followers`,
+ // and a /kiosk page must be counted while a /remote page must not.
+ state.ws.send({ type: "hello", role: "viewer", kiosk: true, name: "", device: deviceLabel() });
+ },
+ onStatus(next, detail) {
+ setNet(next, detail);
+ },
+ onClose() {
+ // The roster is invalid the moment the socket drops: every leg is closed
+ // and its tile marked stale/reconnecting. The fresh `welcome` after
+ // reconnect re-applies the layout, which is what makes a server restart
+ // recover on its own.
+ wall.closePeers();
+ },
+ onMessage(message) {
+ switch (message.type) {
+ case "welcome": {
+ // Arrival is arrangement: the layout is applied before the roster
+ // paints, so the wall never flashes between the roster and the stage.
+ const layout = message.layout || { staged: null, fullscreen: false };
+ state.layout = layout;
+ applyLayout(layout);
+ for (const stream of message.streams || []) wall.absorb(stream);
+ // A layout that named a mirror the wall did not have yet (the roster
+ // arrives in the same frame) lands now that the tile does.
+ applyLayout(state.layout);
+ break;
+ }
+ case "layout":
+ state.layout = message;
+ applyLayout(message);
+ break;
+ case "stream_added":
+ case "stream_updated":
+ wall.absorb(message.stream);
+ // A remembered intent (a layout naming a mirror that was not in the
+ // wall yet) lands the moment its tile does.
+ applyLayout(state.layout);
+ break;
+ case "stream_removed":
+ // The wall unstages its own dropped tile, and the server pushes the
+ // cleared layout behind this frame — do not "helpfully" unstage here,
+ // or two frames will fight.
+ wall.drop(message.streamId);
+ break;
+ case "signal":
+ wall.onSignal(message);
+ break;
+ case "error":
+ console.warn("server:", message.code, message.message);
+ if (message.code === "capacity") toast(message.message);
+ break;
+ default:
+ break;
+ }
+ },
+});
+
+installWallKeys({ wall, onCommand });
+
+/* -------------------------------------------------------------------- misc */
+
+// Same rule as the share and watch pages: if the server refused this address,
+// say so rather than showing an empty wall that will never fill.
+const refusal = refusalNotice(config);
+if (refusal) showNotice(ui.notice, refusal);
+
+// The settled starting line: "0 mirrors · wall", grid hidden, empty shown.
+renderHud();
diff --git a/src/mirror/static/mirror.html b/src/mirror/static/mirror.html
index ace546b..99bf21f 100644
--- a/src/mirror/static/mirror.html
+++ b/src/mirror/static/mirror.html
@@ -22,7 +22,11 @@
-
+
+
Nothing is being shared
diff --git a/src/mirror/static/wall.js b/src/mirror/static/wall.js
new file mode 100644
index 0000000..220c5b5
--- /dev/null
+++ b/src/mirror/static/wall.js
@@ -0,0 +1,847 @@
+/* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
+ * stage the staged tile's own element moves into (so its 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,
+ * rtc?: object,
+ * onChange?: (summary: {mirrors: number, live: number}) => void,
+ * stats?: boolean}} options
+ * @returns the surface the pages drive the wall with: absorb, drop, onSignal,
+ * stage, unstage, reRequestFullscreen, setMuted, ordered, stagedId,
+ * selectedId, selectedTile, setSelected, moveSelection, tile, closePeers,
+ * revealStageControls, handleFullscreenChange
+ *
+ * The module's second export, installWallKeys(), is the one keydown listener
+ * a wall page gets: it maps keys to named commands, hands them to the page's
+ * onCommand, and performs the wall commands itself when the page does not.
+ */
+
+import { bitrateFormat, toast } from "./net.js";
+
+export function createWall(options) {
+ const { grid, stageEl, template, send, rtc } = options;
+ const onChange = options.onChange || null;
+
+ /** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
+ const tiles = new Map();
+
+ const state = {
+ staged: null,
+ stageParent: null,
+ stageNext: null,
+ cursor: null,
+ };
+
+ /* -------------------------------------------------------------- rendering */
+
+ // The count/empty chrome (#counts, #empty) belongs to the page, not the
+ // wall: the wall reports the numbers wherever renderCounts() ran before the
+ // extraction, and the page paints them.
+ function publishCounts() {
+ if (!onChange) return;
+ const n = tiles.size;
+ const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
+ onChange({ mirrors: n, live });
+ }
+
+ function surfaceShort(setting) {
+ return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
+ }
+
+ function makeTile(stream) {
+ const node = template.content.firstElementChild.cloneNode(true);
+ const tile = {
+ id: stream.id,
+ sharerId: stream.sharerId,
+ el: node,
+ video: node.querySelector("video"),
+ badge: node.querySelector(".badge"),
+ title: node.querySelector(".title"),
+ sub: node.querySelector(".sub"),
+ audioBtn: node.querySelector(".audio"),
+ exitBtn: node.querySelector(".exit"),
+ pc: null,
+ pending: [],
+ attempts: 0,
+ stream: null,
+ bytes: 0,
+ at: Date.now(),
+ name: stream.name || "Unnamed",
+ };
+
+ node.dataset.stream = stream.id;
+ node.addEventListener("click", (event) => {
+ if (event.target.closest("button")) return;
+ if (state.staged === stream.id) return;
+ stage(stream.id, { fullscreen: true });
+ });
+ tile.audioBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ setMuted(tile, !tile.video.muted);
+ });
+
+ tile.exitBtn.hidden = true;
+ tile.exitBtn.addEventListener("click", (event) => {
+ event.stopPropagation();
+ unstage();
+ });
+
+ node.querySelector(".expand").addEventListener("click", (event) => {
+ event.stopPropagation();
+ if (state.staged === stream.id) unstage();
+ else stage(stream.id, { fullscreen: true });
+ });
+
+ grid.appendChild(node);
+ tiles.set(stream.id, tile);
+ return tile;
+ }
+
+ function paintTile(tile, stream) {
+ tile.name = stream.name || tile.name;
+ const label = stream.label || stream.name || "Shared screen";
+ tile.title.textContent = label;
+ const dims = stream.width && stream.height ? `${stream.width}×${stream.height}` : "";
+ const parts = [surfaceShort(stream.displaySurface), dims, stream.audio ? "audio" : "silent"].filter(
+ Boolean,
+ );
+ tile.sub.textContent = `${stream.name || "Unknown device"}${parts.length ? ` · ${parts.join(" · ")}` : ""}`;
+ tile.el.setAttribute("aria-label", `Watch ${label}`);
+ tile.audioBtn.hidden = !stream.audio;
+ // The wall's audio state, readable without a pointer: a screenshot from
+ // another machine (docs/kiosk-silverblue/dim-probe.py) must show what the
+ // wall is doing, so the state lives on the element, not in the hover-only
+ // button. paintTile() seeds it from the server's `audio` flag; setMuted()
+ // keeps it in step with the element's live state.
+ tile.el.dataset.audio = stream.audio ? (tile.video.muted ? "off" : "on") : "none";
+ }
+
+ function drop(streamId) {
+ const tile = tiles.get(streamId);
+ if (!tile) return;
+ const wasCursor = state.cursor === streamId;
+ const slot = wasCursor ? ordered().findIndex((t) => t.id === streamId) : -1;
+ if (state.staged === streamId) unstage();
+ tiles.delete(streamId);
+ closePeer(tile);
+ // closePeer() is a no-op when the leg never negotiated; the stream is
+ // reset here too so a dropped tile never keeps a dead MediaStream.
+ tile.stream = null;
+ tile.el.remove();
+ publishCounts();
+ if (wasCursor) {
+ const remaining = ordered();
+ if (remaining.length === 0) {
+ state.cursor = null;
+ } else {
+ // The tile that takes over the dropped slot is the nearest; if the
+ // dropped tile was last, wrap to the first (the cursor wraps too).
+ setSelected(remaining[slot % remaining.length].id);
+ }
+ }
+ }
+
+ function closePeer(tile) {
+ if (!tile.pc) return;
+ const pc = tile.pc;
+ pc.onicecandidate = null;
+ pc.ontrack = null;
+ pc.onconnectionstatechange = null;
+ try {
+ pc.close();
+ } catch {
+ /* already closed */
+ }
+ tile.pc = null;
+ tile.pending = [];
+ // A retry builds a fresh connection and must not reattach dead tracks.
+ tile.stream = null;
+ }
+
+ /* ------------------------------------------------------------------ media */
+
+ function ensurePeer(tile) {
+ if (tile.pc) return tile.pc;
+ const pc = new RTCPeerConnection(rtc);
+ tile.pc = pc;
+
+ pc.onicecandidate = (event) => {
+ if (event.candidate && send) {
+ send({ type: "signal", to: tile.sharerId, payload: { candidate: event.candidate } });
+ }
+ };
+
+ // Do NOT `addTransceiver("audio", { direction: "recvonly" })` here. PLAN
+ // A4: the viewer only answers, and the sharer owns the track set. An
+ // answerer-side transceiver adds an m-line the offerer never sent and the
+ // m-lines must stay in the offerer's order — this is the first thing a
+ // future reader will be tempted to try when audio misbehaves.
+ pc.ontrack = (event) => {
+ // One MediaStream per tile, built by us: Chrome and Firefox disagree
+ // about whether addTransceiver tracks share a stream id, and the app
+ // must behave identically on both (the kiosk is Firefox, the sharers
+ // are usually Chrome). The old code assigned a fresh one-track stream
+ // per ontrack, so the second track to arrive erased the first: the
+ // audio callback's stream replaced the video's srcObject, the video
+ // kept playing from the still-attached transceiver, and the sound
+ // simply never came.
+ tile.stream = tile.stream || new MediaStream();
+ tile.stream.addTrack(event.track);
+ if (tile.video.srcObject !== tile.stream) {
+ tile.video.srcObject = tile.stream;
+ tile.video.play().catch(() => {
+ /* autoplay is covered by `muted`; a rejection means a gesture is needed */
+ });
+ }
+ // A track that ends is removed from the composed stream, not the
+ // connection: the video leg keeps working when a source's audio goes
+ // away (the video-only case), and the mute button tracks the live
+ // track set, so a source that stops offering audio loses its button.
+ event.track.addEventListener("ended", () => {
+ if (!tile.stream) return; // the peer was already closed; nothing to clean up
+ tile.stream.removeTrack(event.track);
+ const hasAudio = tile.stream.getAudioTracks().length > 0;
+ tile.audioBtn.hidden = !hasAudio;
+ tile.el.dataset.audio = hasAudio ? (tile.video.muted ? "off" : "on") : "none";
+ // The one tile that may be audible just lost its audio: re-mute it
+ // (which also resets the button/glyph, so the dead 🔇 cannot sit on
+ // an unpressed state) and say why the sound went — a silent wall in a
+ // shared room otherwise looks like a bug.
+ if (!hasAudio && state.staged === tile.id) {
+ setMuted(tile, true);
+ toast("This mirror stopped sharing audio.");
+ }
+ });
+ };
+
+ pc.onconnectionstatechange = () => {
+ tile.el.dataset.state = pc.connectionState === "connected" ? "live" : pc.connectionState;
+ tile.badge.textContent =
+ pc.connectionState === "connected"
+ ? "live"
+ : pc.connectionState === "connecting"
+ ? "connecting…"
+ : pc.connectionState;
+ publishCounts();
+ if (pc.connectionState === "failed") {
+ if (tile.attempts < 3) {
+ tile.attempts += 1;
+ tile.badge.textContent = `retry ${tile.attempts}/3`;
+ closePeer(tile);
+ setTimeout(() => {
+ if (tiles.has(tile.id) && send) send({ type: "subscribe", streamId: tile.id });
+ }, 700 * tile.attempts);
+ } else {
+ tile.badge.textContent = "unreachable";
+ }
+ }
+ };
+
+ return pc;
+ }
+
+ async function onSignal(message) {
+ const tile = [...tiles.values()].find((t) => t.sharerId === message.from);
+ if (!tile) return;
+ const pc = ensurePeer(tile);
+ const payload = message.payload || {};
+ try {
+ if (payload.sdp) {
+ await pc.setRemoteDescription(payload.sdp);
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ send({ type: "signal", to: message.from, payload: { sdp: pc.localDescription } });
+ const queued = tile.pending.splice(0);
+ for (const candidate of queued) await pc.addIceCandidate(candidate).catch(() => {});
+ } else if (payload.candidate) {
+ if (pc.remoteDescription && pc.remoteDescription.type) {
+ await pc.addIceCandidate(payload.candidate);
+ } else {
+ tile.pending.push(payload.candidate);
+ }
+ }
+ } catch (err) {
+ console.warn("viewer signal rejected", err);
+ }
+ }
+
+ /* ------------------------------------------------------------------- audio */
+
+ /**
+ * The one place mute state changes: the tile's audio button, the `m` key and
+ * the staged-only audio policy all go through here, so the video's
+ * `muted`/`volume`, the button text, `aria-pressed` and `title` cannot drift
+ * apart. Kept as `(tile, muted)` on purpose.
+ *
+ * The policy: **only the staged mirror may be audible.** Unmuting a tile in
+ * the grid stages it first — a wall of twenty mirrors must not become twenty
+ * sound sources, and staging is what a person who wants a mirror's audio was
+ * about to do anyway — and every other tile is muted through this same
+ * helper, so the button, the key and the policy can never disagree. Unmuting
+ * never happens on its own: every path that calls `setMuted(tile, false)`
+ * starts from a click or a keypress (SECURITY.md §4), and `stage()` and
+ * `unstage()` never change mute state on their own — joining or leaving the
+ * stage is never a sound source, and audio is never restored after an
+ * unstage or a reconnect.
+ */
+ function setMuted(tile, muted) {
+ if (!muted && state.staged !== tile.id) {
+ stage(tile.id, { fullscreen: true }); // a grid tile may not become audible in the grid
+ }
+ tile.video.muted = muted;
+ tile.video.volume = 1;
+ if (!muted) {
+ // Exactly one tile may be audible: mute every other tile first, then
+ // this one, so there is never a frame in which two mirrors play.
+ for (const other of tiles.values()) {
+ if (other !== tile) setMuted(other, true);
+ }
+ tile.video.play().catch(() => {}); // a muted play may be suspended
+ }
+ tile.audioBtn.textContent = muted ? "🔇" : "🔊";
+ tile.audioBtn.setAttribute("aria-pressed", String(!muted));
+ tile.audioBtn.title = muted ? "Unmute" : "Mute";
+ // The button tracks the live track set (paintTile and the `ended` handler
+ // keep it honest), so it is the one source of truth for "does this mirror
+ // have audio at all" — a toggle on a silent mirror is "none", never "on".
+ const hasAudio = !tile.audioBtn.hidden;
+ tile.el.dataset.audio = hasAudio ? (muted ? "off" : "on") : "none";
+ }
+
+ /* ------------------------------------------------------------------ stage */
+
+ /* The staged overlay (caption bar + control cluster) is hidden at rest so a
+ shared terminal's bottom line is never covered. Pointer activity on the
+ stage, and any wall keypress while staged (the kiosk has no mouse),
+ reveal it via `data-reveal`; this timeout is what fades it back, so a
+ parked pointer or a one-time keypress never pins it up. */
+ const STAGE_REVEAL_IDLE_MS = 3000;
+ let stageRevealTimer = 0;
+
+ function revealStageControls() {
+ if (!state.staged) return;
+ const tile = tiles.get(state.staged);
+ if (!tile) return;
+ tile.el.dataset.reveal = "";
+ clearTimeout(stageRevealTimer);
+ stageRevealTimer = setTimeout(() => {
+ stageRevealTimer = 0;
+ // The tile may have been unstaged or replaced (n/p, 1-9, a click)
+ // before the timer fires; only the tile still on stage loses the
+ // attribute. (unstage() clears the timer, so this guard also covers
+ // a replacement whose keepFullscreen unstage ran in between.)
+ if (state.staged === tile.id) delete tile.el.dataset.reveal;
+ }, STAGE_REVEAL_IDLE_MS);
+ }
+
+ /* A `document.exitFullscreen()` that `unstage()` started is in flight.
+ The exit settles asynchronously, and a keypress in that window (g followed
+ by an immediate Enter) stages a new tile before the exit's
+ `fullscreenchange` event lands: `state.staged` is then the NEW tile, and
+ the handler would tear the fresh stage down with the old exit. While the
+ flag is set, an exit event belongs to the unstage that already tore the
+ stage down synchronously, so it is ignored; the flag is cleared when the
+ document becomes fullscreen again (a re-stage's request won over the exit)
+ or when the exit settles. */
+ let fullscreenExitInFlight = false;
+
+ function stage(streamId, options = {}) {
+ const tile = tiles.get(streamId);
+ if (!tile) return;
+ if (state.staged && state.staged !== streamId) {
+ // Replacing the tile on stage: keep the stage's fullscreen. The fullscreen
+ // element is the stage container itself, so the replacement moves into a
+ // stage that is already fullscreen. Exiting first would deliver a
+ // fullscreenchange event after state.staged is already the new tile, and
+ // the handler would unstage the replacement — n/p stepping, 1-9 jumps and
+ // clicking another tile all go through this path on a kiosk.
+ unstage({ keepFullscreen: true });
+ }
+
+ state.stageParent = tile.el.parentNode;
+ state.stageNext = tile.el.nextSibling;
+ stageEl.appendChild(tile.el);
+ document.body.classList.add("staged");
+ state.staged = streamId;
+ // The cursor sits on whatever is on stage, so the selection stays coherent
+ // while the wall is hidden behind it.
+ state.cursor = streamId;
+ tile.el.dataset.selected = "";
+ tile.exitBtn.hidden = false;
+ // The cursor moves with the stage: focus the staged tile so Esc/m/g keep
+ // working no matter what held focus before (the grid is hidden behind it).
+ tile.el.focus({ preventScroll: true });
+
+ if (options.fullscreen !== false) {
+ const target = stageEl;
+ if (target.requestFullscreen) {
+ target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
+ /* in-page stage still covers the viewport; fullscreen is a bonus */
+ });
+ }
+ }
+ tile.video.play().catch(() => {});
+ }
+
+ function unstage(options = {}) {
+ if (!state.staged) return;
+ const tile = state.staged ? tiles.get(state.staged) : null;
+ state.staged = null;
+ document.body.classList.remove("staged");
+ // The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
+ // replacement's keepFullscreen) clears the pending timeout and the
+ // departing tile's `data-reveal`, so a stale reveal can never leak back
+ // into the grid — grid tiles never read the attribute, and re-staging
+ // must start hidden.
+ clearTimeout(stageRevealTimer);
+ stageRevealTimer = 0;
+ if (tile) delete tile.el.dataset.reveal;
+ if (tile) {
+ tile.exitBtn.hidden = true;
+ if (state.stageParent) {
+ state.stageParent.insertBefore(tile.el, state.stageNext);
+ } else {
+ grid.appendChild(tile.el);
+ }
+ }
+ state.stageParent = null;
+ state.stageNext = null;
+ if (tile) {
+ // Walking back to the wall is always silent: the kiosk in a shared room
+ // depends on it, and on a replacement (keepFullscreen) the departing tile
+ // goes quiet the same way. Re-staging the tile does not restore sound —
+ // every audible state must trace back to a gesture (SECURITY.md §4).
+ setMuted(tile, true);
+ }
+ const restoreFocus = () => {
+ // The tile is back in the grid at its old position; the cursor goes with
+ // it. The grid's focusin handler then agrees the selection with focus —
+ // focusTile() declines when focus is already on the tile, so this cannot
+ // loop. If a different tile was staged in the meantime (the exit is
+ // asynchronous), that tile already holds the cursor, so don't steal it.
+ if (tile && state.staged !== tile.id) tile.el.focus({ preventScroll: true });
+ };
+ // keepFullscreen: the caller is re-staging into the same stage container in
+ // the same tick, so its fullscreen (if granted) is left in place and its
+ // focus() lands the cursor on the new tile.
+ if (!options.keepFullscreen && document.fullscreenElement && document.exitFullscreen) {
+ // Exiting fullscreen makes the browser re-validate focus and drops it to
+ // , which would cancel a focus() made earlier in this tick — so
+ // the cursor is handed back to the tile only after the exit has settled.
+ // The in-flight flag keeps the exit's late `fullscreenchange` from
+ // tearing down a stage re-entered in the meantime (see the handler).
+ fullscreenExitInFlight = true;
+ document.exitFullscreen().then(restoreFocus).catch(() => {
+ // A rejected exit never settles: if the document is still fullscreen
+ // the exit was pre-empted (or lost user activation) and the flag is
+ // stale — drop it so a genuine later exit still unstages.
+ if (document.fullscreenElement) fullscreenExitInFlight = false;
+ restoreFocus();
+ });
+ } else {
+ restoreFocus();
+ }
+ }
+
+ /* `f` while staged: re-request fullscreen on the stage container. The
+ in-page stage fills the viewport anyway, so the request is best-effort
+ and a refusal (a remote command has no user activation) is not an error —
+ the stage is the guarantee, the fullscreen is a bonus (the same stance
+ installWallKeys documents for the whole key map). */
+ function reRequestFullscreen() {
+ if (stageEl.requestFullscreen) {
+ stageEl.requestFullscreen({ navigationUI: "hide" }).catch(() => {
+ /* in-page stage still covers the viewport; fullscreen is a bonus */
+ });
+ }
+ }
+
+ /* The page owns the `fullscreenchange` listener — what leaving fullscreen
+ means is page policy (/watch unstages; the kiosk will do something else,
+ phase 08 task 04). This hook owns the in-flight flag and the two
+ behaviours it implements. */
+ function handleFullscreenChange(hasFullscreen) {
+ if (hasFullscreen) {
+ // (Re-)entered fullscreen — a re-stage's request won over our exit, so
+ // the in-flight flag is stale from this point on.
+ fullscreenExitInFlight = false;
+ return;
+ }
+ if (fullscreenExitInFlight) {
+ // Our own unstage's exit settling: the stage was already torn down
+ // synchronously, and a keypress in the exit window may have re-staged —
+ // the event must not take that new stage down with it.
+ fullscreenExitInFlight = false;
+ return;
+ }
+ // The browser (or the kiosk's window manager) left fullscreen on its own;
+ // the in-page stage is what the operator asked to leave.
+ if (state.staged) unstage();
+ }
+
+ /* ------------------------------------------------------------------ socket */
+
+ function subscribe(streamId) {
+ if (send) send({ type: "subscribe", streamId });
+ }
+
+ function absorb(stream) {
+ if (!stream || !stream.id) return;
+ const tile = tiles.get(stream.id) || makeTile(stream);
+ paintTile(tile, stream);
+ publishCounts();
+ if (!tile.pc) subscribe(stream.id);
+ }
+
+ /* The socket drop is the page's event; the wall owns what the drop means
+ for its tiles: every leg is closed and marked stale/reconnecting. */
+ function closePeers() {
+ for (const streamId of [...tiles.keys()]) {
+ const tile = tiles.get(streamId);
+ closePeer(tile);
+ tile.el.dataset.state = "stale";
+ tile.badge.textContent = "reconnecting…";
+ }
+ }
+
+ /* ----------------------------------------------------------------- ticker */
+
+ if (options.stats) {
+ setInterval(async () => {
+ for (const tile of tiles.values()) {
+ if (!tile.pc) continue;
+ try {
+ const report = await tile.pc.getStats();
+ let fps = 0;
+ let bytes = 0;
+ let w = 0;
+ let h = 0;
+ report.forEach((entry) => {
+ if (entry.type === "inbound-rtp" && entry.kind === "video") {
+ fps = entry.framesPerSecond || 0;
+ bytes = entry.bytesReceived || 0;
+ w = entry.frameWidth || w;
+ h = entry.frameHeight || h;
+ }
+ });
+ const now = Date.now();
+ const dt = Math.max(0.25, (now - tile.at) / 1000);
+ const bps = ((bytes - tile.bytes) / dt) * 8;
+ tile.bytes = bytes;
+ tile.at = now;
+ if (w && h) {
+ const dims = `${w}×${h}`;
+ const head = tile.sub.textContent.split(" · ").slice(0, 1);
+ tile.sub.textContent = [...head, dims, bitrateFormat(bps), `${Math.round(fps)} fps`].join(
+ " · ",
+ );
+ }
+ } catch {
+ /* stats are advisory */
+ }
+ }
+ }, 1500);
+ }
+
+ /* --------------------------------------------------------- wall accessors */
+
+ function ordered() {
+ // DOM order == insertion order, which is the grid order the page's count
+ // renderer uses.
+ return [...tiles.values()];
+ }
+
+ function stagedId() {
+ return state.staged;
+ }
+
+ function selectedId() {
+ return state.cursor;
+ }
+
+ function selectedTile() {
+ return state.cursor ? tiles.get(state.cursor) || null : null;
+ }
+
+ function tile(id) {
+ return tiles.get(id);
+ }
+
+ function focusTile(tile) {
+ // Focus follows selection (tiles are tabindex="0"). preventScroll keeps the
+ // grid from jumping while a remote hand walks it. If focus is already inside
+ // this tile — Tab reached one of its buttons — the selection already agrees,
+ // so don't steal focus back off the control the operator is on.
+ const active = document.activeElement;
+ if (active === tile.el || (active && tile.el.contains(active))) return;
+ tile.el.focus({ preventScroll: true });
+ }
+
+ function setSelected(streamId) {
+ const tile = tiles.get(streamId);
+ if (!tile) return;
+ const previous = selectedTile();
+ if (previous && previous !== tile) delete previous.el.dataset.selected;
+ tile.el.dataset.selected = "";
+ state.cursor = streamId;
+ focusTile(tile);
+ }
+
+ function moveSelection(delta) {
+ const list = ordered();
+ const n = list.length;
+ if (n === 0) return;
+ const index = state.cursor ? list.findIndex((tile) => tile.id === state.cursor) : -1;
+ if (index === -1) {
+ // No live cursor yet: the first j lands on the first tile, the first k on
+ // the last. A wall with twenty mirrors is navigated in a loop from a
+ // remote, so the ends wrap.
+ setSelected(list[delta > 0 ? 0 : n - 1].id);
+ return;
+ }
+ setSelected(list[(index + delta + n) % n].id);
+ }
+
+ return {
+ absorb,
+ drop,
+ onSignal,
+ stage,
+ unstage,
+ reRequestFullscreen,
+ setMuted,
+ ordered,
+ stagedId,
+ selectedId,
+ selectedTile,
+ setSelected,
+ moveSelection,
+ tile,
+ closePeers,
+ revealStageControls,
+ handleFullscreenChange,
+ };
+}
+
+/* ----------------------------------------------------------------- keys */
+
+/**
+ * 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).
+ *
+ * The wall commands (`next`, `previous`, `stage`, `fullscreen`, `wall`,
+ * `step_next`, `step_previous`, `mute`, `jump`) have a default implementation
+ * here, used when the page returns nothing for them, so `onCommand` can be a
+ * thin observer: /watch answers only `help` and `escape`, and a page like
+ * /kiosk can publish what a key did (`arrange`) while the module performs it.
+ * `help` and `escape` have **no** default: they are page policy, and a page
+ * that ignores them must not break the router.
+ *
+ * `next`/`previous` run in the module on purpose: 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.
+ *
+ * Any keypress while staged reveals the overlay first, handled or not: a key on
+ * a mouseless kiosk is the only way to read the caption and reach the controls
+ * (a silent no-op key flashing the caption is the debuggable outcome). The idle
+ * timeout fades it again, and the reveal never preventDefaults, so unhandled
+ * keys keep their defaults.
+ *
+ * Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
+ * the browser) and every key inside an /