phase: 08_kiosk_page
Build and Push Containers / test (push) Successful in 42s
Build and Push Containers / build-and-push-app (push) Successful in 9s

**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`.
This commit is contained in:
2026-11-03 22:38:22 -05:00
parent 736da1c1ef
commit cd29156a7d
45 changed files with 2389 additions and 750 deletions
@@ -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`.
@@ -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
@@ -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).
@@ -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
@@ -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`
@@ -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
@@ -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, `<title>Wall · mirror</title>` (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`
@@ -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
@@ -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)
@@ -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
@@ -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).
@@ -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
+3 -3
View File
@@ -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
```
+18 -5
View File
@@ -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://<this-machine>:8055/mirror # to share
http://<this-machine>:8055/watch # to watch
http://<this-machine>: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
+50
View File
@@ -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 `<title>Wall · mirror`; `check-kiosk.sh`, untouched in this
phase, still greps for `<title>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
+9 -3
View File
@@ -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]
+78 -29
View File
@@ -225,15 +225,19 @@ a { color: var(--accent); }
align-items: start;
}
/* The stage is ONE cell. Its children — the <video>, the idle placeholder,
the LIVE badge — share that cell, so the placeholder overlays the video the
way the markup intends instead of stacking into a row below it.
/* The preview stage is ONE cell. Its children — the <video>, the idle
placeholder, the LIVE badge — share that cell, so the placeholder overlays
the video the way the markup intends instead of stacking into a row below
it. It is `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into on /watch and /kiosk) is the only thing that may
carry `class="stage"` in this stylesheet — sharing the class would let the
two stages inherit each other's rules (fixed overlay vs inline preview).
Invariant for the next reader: a child added to #preview-stage shares that
cell and must never push another child out of view. `overflow: hidden` is
there to clip the live video to the rounded corners; a box whose content no
longer fits a fixed-aspect stage is the size rule's job, not this one's
(the floor/ceiling below). */
.stage {
.preview-stage {
position: relative;
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
item whose height is clamped by max-height gets its *width* recomputed
@@ -271,22 +275,22 @@ a { color: var(--accent); }
overlap instead of stacking. DOM order paints the placeholder above the video
(no z-index); .livetag stays position: absolute, so grid-area is inert for
it. */
.stage > * { grid-area: 1 / 1; }
.preview-stage > * { grid-area: 1 / 1; }
.stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.preview-stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Escape hatch: the placeholder may never be taller than the stage; if its
content outgrows it, it scrolls. Paired with `safe center` above, content
that is too tall is always reachable, whichever way the geometry falls. */
.stage .placeholder {
.preview-stage .placeholder {
text-align: center;
padding: 24px;
color: var(--fg-dim);
max-height: 100%;
overflow: auto;
}
.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
.preview-stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.preview-stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
/* Narrow-width shape rules come *after* the base rules above: same
specificity, so source order decides, and a phone must not be left with
@@ -296,15 +300,15 @@ a { color: var(--accent); }
the box from looking like a letterbox. */
@media (max-width: 900px) {
.share { grid-template-columns: 1fr; }
.stage { aspect-ratio: 4 / 3; }
.preview-stage { aspect-ratio: 4 / 3; }
}
/* A phone (≤480px) gets a stage as tall as its content rather than any
letterbox, and the placeholder's padding buys back 16px. min-height still
does the real work in both cases. */
@media (max-width: 480px) {
.stage { aspect-ratio: auto; }
.stage .placeholder { padding: 16px; }
.preview-stage { aspect-ratio: auto; }
.preview-stage .placeholder { padding: 16px; }
}
.livetag {
@@ -468,13 +472,13 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
/* The staged tile: the mark shows the live state — sound on, or muted with
audio available (dimmed, so a staged-but-quiet mirror is still readable). */
#watch-stage .tile[data-audio="on"]::after {
.stage .tile[data-audio="on"]::after {
content: "🔊";
font-size: 18px;
line-height: 1;
}
#watch-stage .tile[data-audio="off"]::after {
.stage .tile[data-audio="off"]::after {
content: "🔇";
font-size: 18px;
line-height: 1;
@@ -562,7 +566,9 @@ kbd {
/* Staging: one mirror fills the viewport; the grid is hidden behind it. */
body.staged .grid, body.staged .empty { display: none; }
#watch-stage {
/* The wall's stage (class-keyed so /watch and /kiosk share the rules; the
share page's preview is `preview-stage`, never `stage`). */
.stage {
position: fixed;
inset: 0;
z-index: 50;
@@ -570,11 +576,11 @@ body.staged .grid, body.staged .empty { display: none; }
display: none;
}
body.staged #watch-stage { display: block; }
body.staged .stage { display: block; }
/* The staged tile *is* the stage: the element itself moves here, so its
RTCPeerConnection and <video> never have to be torn down. */
#watch-stage .tile {
.stage .tile {
width: 100%;
height: 100%;
border: 0;
@@ -584,14 +590,14 @@ body.staged #watch-stage { display: block; }
flex-direction: column;
}
#watch-stage .tile video {
.stage .tile video {
flex: 1 1 auto;
min-height: 0;
height: auto;
aspect-ratio: auto;
}
#watch-stage .tile .cap {
.stage .tile .cap {
position: absolute;
left: 0;
right: 0;
@@ -607,17 +613,19 @@ body.staged #watch-stage { display: block; }
bottom line included — is never covered. `data-reveal` (pointer activity
or a wall keypress) is the only staged reveal: it shows the caption and
the control cluster briefly and fades them back on idle. The small
`::after` audio mark is a separate rule and is untouched. The id-scoped
selectors outrank the grid rules (`.tile[data-selected] .actions`,
`::after` audio mark is a separate rule and is untouched. The stage-scoped
selectors win over the grid rules (`.tile[data-selected] .actions`,
`.tile:focus-within .actions`, the `hover: none` media rule) which also
match the staged tile — it keeps `data-selected` and holds focus. */
#watch-stage .tile .actions { opacity: 0; }
match the staged tile: they tie on specificity, source order decides, and
these rules sit after the grid rules. The staged tile keeps `data-selected`
and holds focus. */
.stage .tile .actions { opacity: 0; }
/* A `pointer-events: none` container does not shield children whose default
is `auto`: scope it to the container *and its children* so the hidden
Exit/mute buttons are not live dead zones over the video. */
#watch-stage .tile .cap, #watch-stage .tile .cap *,
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
.stage .tile .cap, .stage .tile .cap *,
.stage .tile .actions, .stage .tile .actions * { pointer-events: none; }
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
staged) is the only staged reveal: it restores opacity and makes the
@@ -626,14 +634,55 @@ body.staged #watch-stage { display: block; }
shields an `auto` child (setting `opacity: 1` on the children themselves
is harmless). The transitions on the base rules give the fade in and
out; under `prefers-reduced-motion` the global transition-kill makes the
reveal instant, which is the correct degraded behaviour. The id-scoped
selectors outrank every grid rule that also matches the staged tile. */
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
reveal instant, which is the correct degraded behaviour. The stage-scoped
selectors win over every grid rule that also matches the staged tile
(tied on specificity, source order decides). */
.stage .tile[data-reveal] .cap, .stage .tile[data-reveal] .cap *,
.stage .tile[data-reveal] .actions, .stage .tile[data-reveal] .actions * {
opacity: 1;
pointer-events: auto;
}
/* --------------------------------------------------------------- kiosk */
/* /kiosk is a display, not a page: no header bar, no counts, no share
button. The grid is the whole viewport — there is no chrome above it to
clear — and it scrolls when the wall outgrows the room. */
.kiosk .grid {
height: 100%;
overflow-y: auto;
}
/* The shared net pill pinned where a screenshot must find it. The kiosk has
no header to hold it, so it fixes itself top-right: the same pill, the
same states (.net owns its look) plus a backdrop so it stays legible over
the wall. It sits above the stage (z-index 60 over 50) on purpose: "the
server is gone" must be readable even with a tile on stage. */
.kiosk .net {
position: fixed;
top: 14px;
right: 16px;
z-index: 60;
padding: 6px 12px;
background: rgba(11, 13, 16, 0.88);
border: 1px solid var(--line);
border-radius: 999px;
}
/* The wall's self-description for a room with no keyboard: one fixed line
bottom-left (kiosk.js owns it — "N mirrors · wall", or
"… · <label> · fullscreen"), never interactive. */
.hud {
position: fixed;
left: 16px;
bottom: 12px;
z-index: 40;
font-family: var(--mono);
font-size: 12px;
color: var(--fg-dim);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+8
View File
@@ -43,6 +43,14 @@
jump to it.
</p>
</a>
<a class="card" href="/kiosk">
<div class="kicker">GET /kiosk</div>
<h2>An unattended wall</h2>
<p>
A chrome-free wall for a kiosk display: it shows every live mirror and arranges
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
</p>
</a>
</div>
<div class="livebar" id="livebar">Checking for mirrors…</div>
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<!-- The title is a contract, not a label: docs/kiosk-silverblue/check-kiosk.sh
proves a client is showing what it should by grepping the served page's
<title>. -->
<title>Wall · mirror</title>
<link rel="stylesheet" href="/static/app.css" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='12' fill='%234fd1c5'/%3E%3C/svg%3E"
/>
</head>
<!-- The two layout knobs, mirrored onto the body so a screenshot and a test
can read the state without reaching into JS (the tile's data-audio is
seeded for the same reason). The layout applier (kiosk.js, phase 08
task 04) is the only writer. -->
<body class="kiosk" data-layout="wall" data-fullscreen="false">
<!-- No header bar, no counts, no "Share a screen" link: a display shows
mirrors. The net pill is the one chrome a wall keeps — it is how a
wall reports "the server is gone" in a screenshot, and every e2e
device() helper waits on it. -->
<span class="net" id="net" data-state="connecting"><i></i><span>connecting…</span></span>
<div class="notice" id="notice" hidden></div>
<main class="grid" id="kiosk-grid" hidden></main>
<div class="empty" id="kiosk-empty">
<h2>No mirrors live</h2>
<p>
Share from <code id="kiosk-share-url">…</code>
</p>
</div>
<!-- A staged tile is moved into this container, so its video never
restarts. class="stage" is mandatory: the stage rules are
class-keyed (phase 08 task 01), and a stage without the class renders
a full-size unstyled video. -->
<div class="stage" id="kiosk-stage" aria-live="polite"></div>
<!-- The wall's self-description for a room with no keyboard: one line the
script owns — "N mirrors · wall", or "… · <label> · fullscreen". -->
<div class="hud" id="kiosk-hud" aria-live="polite"></div>
<div class="toast" id="toast" hidden></div>
<!-- A copy of watch.html's #tile-template, renamed: the id-collision guard
is the reason two pages may not share an id, and relaxing it for
convenience is how the #stage bug happened. -->
<template id="kiosk-tile-template">
<article class="tile" tabindex="0" data-state="connecting">
<video autoplay playsinline muted></video>
<div class="badge">connecting…</div>
<div class="actions">
<button class="icon-btn audio" aria-pressed="false" title="Unmute" hidden>🔇</button>
<button class="icon-btn expand" title="Full screen">⤢</button>
</div>
<div class="cap">
<div class="grow">
<div class="title">Shared screen</div>
<div class="sub"></div>
</div>
<button class="btn ghost exit" hidden>Exit</button>
</div>
</article>
</template>
<script type="module" src="/static/kiosk.js"></script>
</body>
</html>
+363
View File
@@ -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();
+5 -1
View File
@@ -22,7 +22,11 @@
<div class="notice" id="notice" hidden></div>
<main class="share">
<section class="stage" id="preview-stage">
<!-- `preview-stage`, not `stage`: the wall's stage (the container a
staged tile moves into, /watch and /kiosk) is the only thing that
may carry `class="stage"` in this stylesheet — sharing the class
would let the two stages inherit each other's rules. -->
<section class="preview-stage" id="preview-stage">
<video id="preview" autoplay playsinline muted></video>
<div class="placeholder" id="placeholder">
<h2>Nothing is being shared</h2>
+847
View File
@@ -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 <video> and its
* connection never restart — AGENTS.md §6).
*
* Nothing here queries the document: the page hands over the elements it owns,
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
* ids must not collide (test_the_two_pages_share_no_element_ids…).
*
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
* send: (payload: object) => boolean,
* 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
// <body>, 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 <input>/<textarea>/<select>/contenteditable.
*
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
*/
export function installWallKeys(options) {
const wall = options.wall;
const onCommand = options.onCommand || null;
const root = options.root || document;
/* ------------------------------------------------- default wall commands */
function defaultStage() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = wall.selectedTile() || wall.ordered()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return true;
}
wall.stage(tile.id, { fullscreen: true });
return true;
}
function defaultFullscreen() {
if (wall.stagedId()) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state.
wall.reRequestFullscreen();
} else {
// When unstaged it stages like Enter.
defaultStage();
}
return true;
}
function defaultWall() {
const wasStaged = wall.stagedId() !== null;
wall.unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
return wasStaged;
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = wall.ordered();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return true;
}
const selectedId = wall.selectedId();
let index = selectedId ? ordered.findIndex((tile) => tile.id === selectedId) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
wall.stage(next.id, { fullscreen: true });
return true;
}
function defaultMute() {
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);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
return true;
}
function defaultJump(detail) {
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
const tile = wall.ordered()[detail.index];
if (tile) {
wall.stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${detail.index + 1}`);
}
return true;
}
const defaults = {
next: () => {
wall.moveSelection(1);
return true;
},
previous: () => {
wall.moveSelection(-1);
return true;
},
stage: defaultStage,
fullscreen: defaultFullscreen,
wall: defaultWall,
step_next: () => stepStage(1),
step_previous: () => stepStage(-1),
mute: defaultMute,
jump: defaultJump,
};
function onKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not —
// before dispatch, so an unhandled key still gets the flash. (See the
// docstring: this never preventDefaults.)
if (wall.stagedId()) wall.revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
let command = null;
const detail = { key, event };
switch (key) {
case "j":
case "arrowdown":
command = "next";
break;
case "k":
case "arrowup":
command = "previous";
break;
case "enter":
command = "stage";
break;
case "f":
command = "fullscreen";
break;
case "g":
command = "wall";
break;
case "n":
command = "step_next";
break;
case "p":
command = "step_previous";
break;
case "m":
command = "mute";
break;
case "?":
command = "help";
break;
case "escape":
command = "escape";
break;
default:
if (/^[1-9]$/.test(key)) {
command = "jump";
detail.index = Number(key) - 1; // 0-based
}
break;
}
if (!command) return;
// The page speaks first: when it does not handle the key (returns nothing
// or false), the module performs the wall command. A page that returns true
// has handled it itself — for `help`/`escape` that is the only way.
let handled = onCommand ? onCommand(command, detail) === true : false;
if (!handled && defaults[command]) {
handled = defaults[command](detail) === true;
}
if (handled) event.preventDefault();
}
root.addEventListener("keydown", onKey);
}
+5 -2
View File
@@ -31,8 +31,11 @@
</p>
</div>
<!-- A staged tile is moved into this container, so its video never restarts. -->
<div id="watch-stage" aria-live="polite"></div>
<!-- A staged tile is moved into this container, so its video never restarts.
The stage is class-keyed (not id-keyed) so a second page's stage (the
/kiosk wall, phase 08) picks up the same rules; #watch-stage stays so
the e2e suites and wall's selectors keep one anchor. -->
<div id="watch-stage" class="stage" aria-live="polite"></div>
<!-- The key list: on a mouseless kiosk `?` is the only way to see the key
map, so it lives in the page (static markup) where a translator can
+72 -684
View File
@@ -1,8 +1,12 @@
/* The watch page: a grid of every live mirror. Click one and it takes the
* stage — the tile element itself moves into the stage container, so the
* RTCPeerConnection and the <video> keep running through the transition. */
* RTCPeerConnection and the <video> keep running through the transition.
* The tile/peer/stage machinery and the one keydown listener live in wall.js
* (createWall, installWallKeys); this page owns the socket, the page chrome
* (counts, net, help, share link) and the key policy (help/escape). */
import { $, bitrateFormat, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { $, connectSignaling, loadConfig, refusalNotice, rtcConfig, showNotice, toast } from "./net.js";
import { createWall, installWallKeys } from "./wall.js";
const ui = {
grid: $("#grid"),
@@ -22,17 +26,22 @@ const ui = {
const config = await loadConfig();
const rtc = rtcConfig(config);
/** streamId -> {id, sharerId, el, video, badge, sub, pc, pending, attempts, stream, stats} */
const tiles = new Map();
const state = { ws: null };
const state = {
ws: null,
staged: null,
stageParent: null,
stageNext: null,
ticker: 0,
cursor: null,
};
/* The wall owns the tiles, the peers and the stage; the page hands it its
elements and the socket's send — guarded on the socket the way the moved
code guarded on state.ws, so the wall can be built before the socket opens
(net.js stays the only place a socket exists, AGENTS.md §6) — and learns
the counts back through renderCounts(). */
const wall = createWall({
grid: ui.grid,
stageEl: ui.stage,
template: ui.template,
rtc,
send: (payload) => (state.ws ? state.ws.send(payload) : false),
onChange: renderCounts,
stats: true,
});
/* -------------------------------------------------------------- rendering */
@@ -43,456 +52,37 @@ function setNet(next, detail) {
if (label) label.textContent = detail || next;
}
function renderCounts() {
const n = tiles.size;
const live = [...tiles.values()].filter((t) => t.pc && t.pc.connectionState === "connected").length;
function renderCounts({ mirrors, live }) {
const n = mirrors;
ui.counts.textContent =
n === 0 ? "no mirrors" : `${n} mirror${n === 1 ? "" : "s"}${live ? ` · ${live} live` : ""}`;
ui.empty.hidden = n > 0;
ui.grid.hidden = n === 0;
}
function surfaceShort(setting) {
return { window: "window", monitor: "screen", browser: "tab" }[setting] || setting || "";
}
function makeTile(stream) {
const node = ui.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 });
});
ui.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 dropTile(streamId) {
const tile = tiles.get(streamId);
if (!tile) return;
const wasCursor = state.cursor === streamId;
const slot = wasCursor ? orderedTiles().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();
renderCounts();
if (wasCursor) {
const remaining = orderedTiles();
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 && state.ws) {
state.ws.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;
renderCounts();
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) && state.ws) state.ws.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);
state.ws.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;
export 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;
ui.stage.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 = ui.stage;
if (target.requestFullscreen) {
target.requestFullscreen({ navigationUI: "hide" }).catch(() => {
/* in-page stage still covers the viewport; fullscreen is a bonus */
});
}
}
tile.video.play().catch(() => {});
}
export 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 {
ui.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
// <body>, 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();
}
}
// The browser's fullscreen state is page policy: /watch unstages when the
// browser (or the kiosk's window manager) leaves fullscreen on its own. The
// exit-race flag and its two behaviours live in wall.js (the
// handleFullscreenChange hook); the listener stays here so the kiosk page can
// observe the same event without the /watch policy (phase 08 task 04).
document.addEventListener("fullscreenchange", () => {
if (document.fullscreenElement) {
// (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();
wall.handleFullscreenChange(document.fullscreenElement !== null);
});
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
// reach the controls without a hover; the idle timeout is what fades them
// again, so a parked pointer never pins the overlay up.
ui.stage.addEventListener("pointermove", revealStageControls);
ui.stage.addEventListener("pointerdown", revealStageControls);
ui.stage.addEventListener("pointermove", wall.revealStageControls);
ui.stage.addEventListener("pointerdown", wall.revealStageControls);
// The only stage-exit control is the per-tile `.exit` button: there is no
// page-level unstage control, and there must not be a second one.
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
if (state.ws) state.ws.send({ type: "subscribe", streamId });
}
function absorb(stream) {
if (!stream || !stream.id) return;
const tile = tiles.get(stream.id) || makeTile(stream);
paintTile(tile, stream);
renderCounts();
if (!tile.pc) subscribe(stream.id);
}
state.ws = connectSignaling({
refused: !!config.refused,
refusedReason: config.refusedMessage,
@@ -505,30 +95,24 @@ state.ws = connectSignaling({
onClose() {
// The roster is invalid the moment the socket drops; the fresh `welcome`
// after reconnect rebuilds it.
for (const streamId of [...tiles.keys()]) {
const tile = tiles.get(streamId);
closePeer(tile);
tile.el.dataset.state = "stale";
tile.badge.textContent = "reconnecting…";
}
wall.closePeers();
},
onMessage(message) {
switch (message.type) {
case "welcome":
for (const stream of message.streams || []) absorb(stream);
renderCounts();
for (const stream of message.streams || []) wall.absorb(stream);
break;
case "stream_added":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_updated":
absorb(message.stream);
wall.absorb(message.stream);
break;
case "stream_removed":
dropTile(message.streamId);
wall.drop(message.streamId);
break;
case "signal":
onSignal(message);
wall.onSignal(message);
break;
case "error":
console.warn("server:", message.code, message.message);
@@ -540,248 +124,52 @@ state.ws = connectSignaling({
},
});
/* ----------------------------------------------------------------- ticker */
state.ticker = 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);
/* -------------------------------------------------------- keyboard router */
/* The kiosk rule this page lives by: no command may require a pointer, and no
pointer affordance may be the only way to see state. This is the ONE place
a keypress on /watch is read — one listener, one switch. Every command in the
key map (j/k/arrows, Enter/f, g, n/p, 1-9, m, ?, Esc) is a case here, never
a new listener. Chords with Ctrl/Cmd/Alt belong to the browser (back, reload,
copy), and a key in a text field is text, not a command, so both bail out
before the switch. Only keys this router actually handled are
preventDefault'ed. */
pointer affordance may be the only way to see state. The one keydown
listener is installed by wall.js (installWallKeys): one listener, one
switch. Every command in the key map (j/k/arrows, Enter/f, g, n/p, 1-9, m,
?, Esc) is a case there, never a new listener. Chords with Ctrl/Cmd/Alt
belong to the browser (back, reload, copy), and a key in a text field is
text, not a command, so both bail out before the switch. Only keys the
router actually handled are preventDefault'ed.
function orderedTiles() {
// DOM order == insertion order, which is the grid order renderCounts() uses.
return [...tiles.values()];
}
/watch's onCommand is a thin observer: the wall commands (j/k, Enter, f, g,
n/p, m, 1-9) run in wall.js's defaults, and the page answers only `help`
and `escape` — page policy, no default. */
function selectedTile() {
return state.cursor ? tiles.get(state.cursor) || null : null;
}
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 ordered = orderedTiles();
const n = ordered.length;
if (n === 0) return;
const index = state.cursor ? ordered.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(ordered[delta > 0 ? 0 : n - 1].id);
return;
}
setSelected(ordered[(index + delta + n) % n].id);
}
/**
* `?` shows and hides the key list. Focus is deliberately never touched: the
* selected tile holds it throughout, so `?` → `?` → `Esc` lands the operator
* exactly where they were — stage included.
*/
function toggleHelp() {
function onCommand(command, detail) {
switch (command) {
case "help":
// `?` shows and hides the key list. Focus is deliberately never touched:
// the selected tile holds it throughout, so `?` → `?` → `Esc` lands the
// operator exactly where they were — stage included. The toggle always
// flips, so the key was always handled.
ui.help.hidden = !ui.help.hidden;
}
function stageSelectedOrFirst() {
// Enter and `f` (when unstaged) share a target: the selected tile, or the
// first tile when nothing is selected yet. With nothing on the wall there
// is nothing to show, so say why instead of staying silent.
const tile = selectedTile() || orderedTiles()[0] || null;
if (!tile) {
toast("no mirrors to stage");
return;
}
stage(tile.id, { fullscreen: true });
}
function stepStage(delta) {
// `n`/`p` *stage* the neighbour, not just move the selection: a remote
// operator cannot reach an Enter afterwards, so the step is the switch.
// The cursor sits on the staged tile while staged (stage() moves it), so
// stepping from the stage walks the wall; stage() unstages the previous
// tile, and unstaging moves — never recreates — the tile element.
const ordered = orderedTiles();
if (ordered.length === 0) {
toast("no mirrors to switch to");
return;
}
let index = state.cursor ? ordered.findIndex((tile) => tile.id === state.cursor) : -1;
if (index === -1) index = delta > 0 ? -1 : 0; // no cursor: n lands first, p wraps to last
const next = ordered[(index + delta + ordered.length) % ordered.length];
stage(next.id, { fullscreen: true });
}
function onWallKey(event) {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not: a
// key in 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 this call
// never preventDefaults, so unhandled keys keep their defaults.
if (state.staged) revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
const key = event.key.toLowerCase();
switch (key) {
case "j":
case "arrowdown":
moveSelection(1);
event.preventDefault();
break;
case "k":
case "arrowup":
moveSelection(-1);
event.preventDefault();
break;
case "enter":
stageSelectedOrFirst();
event.preventDefault(); // the default would click a focused button instead
break;
case "f":
if (state.staged) {
// The kiosk's window manager can drop the *window* out of fullscreen
// without mirror noticing (docs/kiosk-silverblue.md: window-config.ini
// forces the window fullscreen; an element requestFullscreen() on top
// of that is allowed and simply no-ops if the browser refuses). So
// `f` re-requests rather than tracking state. When unstaged it stages
// like Enter.
if (ui.stage.requestFullscreen) {
ui.stage.requestFullscreen({ navigationUI: "hide" }).catch(() => {});
}
} else {
stageSelectedOrFirst();
}
event.preventDefault();
break;
case "g": {
const wasStaged = state.staged !== null;
unstage();
// When already unstaged the wall *is* the visible result "back to the
// wall", so there is nothing to toast; the key was only handled if it
// changed state.
if (wasStaged) event.preventDefault();
break;
}
case "n":
case "p":
stepStage(key === "n" ? 1 : -1);
event.preventDefault();
break;
case "m": {
const tile = (state.staged && tiles.get(state.staged)) || selectedTile();
if (!tile) {
toast("no mirror to mute");
} else {
setMuted(tile, !tile.video.muted);
// A stream with no audio track still toggles the element's `muted`,
// but "I muted a silent mirror" and "the keyboard is broken" look
// identical without a word.
if (tile.audioBtn.hidden) toast("this mirror has no audio");
}
event.preventDefault();
break;
}
case "?":
toggleHelp();
event.preventDefault();
break;
return true;
case "escape":
// The overlay sits above the stage (z-index 70 over 50), so Esc closes
// it FIRST and only leaves the stage when it was not open: ? over a
// staged mirror is two Escapes out of the wall, in that order.
if (!ui.help.hidden) {
ui.help.hidden = true;
event.preventDefault();
} else if (state.staged) {
unstage();
event.preventDefault();
return true;
}
break;
if (wall.stagedId()) {
wall.unstage();
return true;
}
// Nothing open: not handled — the browser keeps its default.
return false;
default:
// 1-9: jump to the Nth mirror in grid order. A no-op gets a toast — a
// silent no-op is the one outcome a keyboard-only operator cannot debug.
if (/^[1-9]$/.test(key)) {
const tile = orderedTiles()[Number(key) - 1];
if (tile) {
stage(tile.id, { fullscreen: true });
} else {
toast(`no mirror ${key}`);
}
event.preventDefault();
}
break;
// j/k, Enter, f, g, n/p, m, 1-9 are wall commands: the defaults in
// wall.js perform them; /watch has no policy of its own for them.
return false;
}
}
document.addEventListener("keydown", onWallKey);
installWallKeys({ wall, onCommand });
// Tab is the browser's own cursor, and it must agree with j/k: when a tile
// (or a control inside one) receives focus, the selection moves with it.
@@ -790,8 +178,8 @@ document.addEventListener("keydown", onWallKey);
// to a tile's own button is not stolen back to the tile.
ui.grid.addEventListener("focusin", (event) => {
const node = event.target instanceof Element ? event.target.closest(".tile") : null;
if (node && node.dataset.stream && tiles.has(node.dataset.stream)) {
setSelected(node.dataset.stream);
if (node && node.dataset.stream && wall.tile(node.dataset.stream)) {
wall.setSelected(node.dataset.stream);
}
});
@@ -801,4 +189,4 @@ const refusal = refusalNotice(config);
if (refusal) showNotice(ui.notice, refusal);
if (ui.share) ui.share.href = `${location.origin}/mirror`;
renderCounts();
renderCounts({ mirrors: 0, live: 0 });
+360
View File
@@ -0,0 +1,360 @@
"""End-to-end: the /kiosk display arranges itself, and obeys one client over another.
Phase 08, task 05: the two properties the TODO item claims — **it arranges
itself on arrival** (point a display at /kiosk and it does what the shared
layout already says, with nobody standing at the box) and **one client's
action moves another client's display** (a keypress on one kiosk re-arranges
the other, asserted on the second display's decoded pixels) — plus the
``followers`` count (displays only, not plain viewers) and the teardown
fallback (a dead mirror returns every display to the wall in the same frame
set; no display may sit on a dead tile).
Isolated contexts are the unit of "a client": each is its own socket and its
own peer id, which is exactly what "another client" means to the server. The
media path itself is proven by ``test_live_mirror.py``; this suite proves the
*state* path and still asserts on real decoded frames, so nothing is taken on
trust.
The suite is pointer-free: a test that reaches for the mouse proves nothing
about a kiosk. The only input a display receives here is a keypress; the
server state is driven by raw ``WebSocket`` frames in ``page.evaluate`` — a
wire-level client, not a pointer. That is what the meta-test at the bottom
permits: it bans pointer calls, not the driver's socket.
Fullscreen is asserted on the page's own knobs — ``body[data-fullscreen]``
and the staged state — and **never on ``document.fullscreenElement``**:
headless Chromium may refuse an element ``requestFullscreen()`` without user
activation, and the real kiosk's window manager forces the window fullscreen
anyway (``docs/kiosk-silverblue/window-config.ini``). The knobs are what the
app controls (the same stance ``kiosk.js`` documents for its best-effort
request); a "strengthened" assertion on ``fullscreenElement`` turns this
suite into a flake.
"""
from __future__ import annotations
import time
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import expect
from .conftest import frames_matching, start_sharing, stop_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
Uncaught page errors and console errors are captured from the first line
of the test and surfaced through the ``errors == []`` assertion
(AGENTS.md §8). Contexts are closed by finalizers, so a leaked peer here
cannot surface as a tile count in another suite's test (they share one
session-scoped server).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context, page = open_device(base_url, browser, request, path, errors)
opened.append(context)
return page
return _open
def open_device(base_url, browser, request, path: str, errors: list | None = None):
"""Open one device; return ``(context, page)``.
The context is closed exactly once — by a finalizer at test end, or by
the test itself (the guard flag is what makes a finalizer after an
explicit close a no-op).
"""
context = browser.new_context(viewport={"width": 1100, "height": 720})
closed = [False]
def _finalize() -> None:
if not closed[0]:
closed[0] = True
context.close()
request.addfinalizer(_finalize)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"console",
lambda message: (
errors.append(f"console: {message.text}") if message.type == "error" else None
),
)
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return context, page
def mirror_ids(base_url, count: int, timeout: float = 15.0) -> list[str]:
"""The server's truth about what is live (AGENTS.md §8, rung 1)."""
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
payload = httpx.get(f"{base_url}/api/streams", timeout=5).json()
last = [mirror["id"] for mirror in payload["mirrors"]]
if len(last) == count:
return last
time.sleep(0.2)
raise AssertionError(f"expected {count} mirror(s) on /api/streams, saw {last}")
def kiosk_state(base_url) -> dict:
return httpx.get(f"{base_url}/api/kiosk", timeout=5).json()
def wait_for_kiosk(base_url, predicate, what: str, timeout: float = 15.0) -> dict:
deadline = time.time() + timeout
last: dict = {}
while time.time() < deadline:
last = kiosk_state(base_url)
if predicate(last):
return last
time.sleep(0.2)
raise AssertionError(f"{what}; last /api/kiosk was {last}")
# The driver: a plain WebSocket client, opened from inside a page so the
# handshake runs in a real browser. It is wire-level state, not a pointer —
# the meta-test below allows it on exactly that ground. It closes the socket
# once the server has acknowledged the change: the layout is process state,
# not the driver's, so the kiosk's `welcome` (which carries it) cannot race
# the driver's exit.
DRIVER = """
async ([url, staged, fullscreen]) => {
const ws = new WebSocket(url);
await new Promise((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error('driver socket did not open'));
});
const nextFrame = (type) => new Promise((resolve, reject) => {
const timer = setTimeout(
() => { ws.close(); reject(new Error(`driver timed out waiting for ${type}`)); },
10_000);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === type) { clearTimeout(timer); resolve(message); }
};
});
ws.send(JSON.stringify({ type: 'hello', role: 'viewer', name: '', device: 'e2e layout driver' }));
const welcome = await nextFrame('welcome');
ws.send(JSON.stringify({ type: 'arrange', staged, fullscreen }));
const layout = await nextFrame('layout');
ws.close();
return { peerId: welcome.peerId, layout };
}
"""
def drive_layout(page, base_url, staged: str | None, fullscreen: bool) -> dict:
"""Send one `arrange` frame over a raw socket; return the server's ack."""
ws_url = base_url.replace("http://", "ws://") + "/ws"
return page.evaluate(DRIVER, [ws_url, staged, fullscreen])
def expect_staged(page, stream_id: str, timeout: int = 15_000) -> None:
"""The display's stage holds exactly the tile the layout names."""
expect(page.locator("#kiosk-stage .tile")).to_have_count(1, timeout=timeout)
assert page.locator("#kiosk-stage .tile").get_attribute("data-stream") == stream_id
class TestArrivalIsArrangement:
def test_a_display_opened_after_an_arrange_shows_it_without_input(self, device, base_url):
"""Arrival is arrangement: the state is applied before the first tile
paints, so the person pointing a kiosk at /kiosk touches nothing."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
# The controller acts first, over a raw socket: stage + fullscreen, and
# the server's own ack says it took.
driver = device("/watch", errors)
ack = drive_layout(driver, base_url, stream_id, True)
assert ack["layout"]["staged"] == stream_id
assert ack["layout"]["fullscreen"] is True
# The display opens *after* the arrange. No key, no pointer: it must
# settle on the stage.
kiosk = device("/kiosk", errors)
expect(kiosk.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
# The page's own knob, not document.fullscreenElement: headless may
# refuse the request and the window manager's window-fullscreen is the
# guarantee, so the knob (what the app controls) is the assertion.
expect(kiosk.locator("body")).to_have_attribute("data-fullscreen", "true", timeout=15_000)
expect_staged(kiosk, stream_id)
# The frame assertion is what proves media crossed to the display, not
# just a CSS class (AGENTS.md §5): decoded, *changing* pixels on the
# staged <video>.
expect(kiosk.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(kiosk, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["height"] > 0, f"no decoded frame size on the display: {frames}"
assert frames["distinct"] >= 2, f"the staged video is frozen: {frames}"
# One source of truth: the server says what the pixels show.
state = kiosk_state(base_url)
assert state["staged"] == stream_id
assert state["fullscreen"] is True
# The display is counted; the driver (a plain viewer) is not.
assert state["followers"] == 1, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestOneClientMovesAnother:
def test_a_keypress_on_one_display_rearranges_the_other(self, device, base_url):
"""One action, two displays, one source of truth — and the second
display never receives a pointer call (or any input at all)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors) # nothing is ever typed or pointed here
expect(first.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
# The only input in this test: one keypress on the first display.
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect(second.locator("body")).to_have_attribute("data-layout", "stage", timeout=15_000)
expect_staged(second, stream_id)
# The display is showing it, not merely labelled: a live, moving leg.
expect(second.locator("#kiosk-stage .tile[data-state='live']")).to_have_count(
1, timeout=25_000
)
frames = frames_matching(second, "#kiosk-stage video")
assert frames["width"] > 0, f"no decoded frame on the second display: {frames}"
assert frames["distinct"] >= 2, f"the second display's video is frozen: {frames}"
# The server agrees with both displays.
state = kiosk_state(base_url)
assert state["staged"] == stream_id, state
assert errors == [], f"uncaught browser errors: {errors}"
def test_n_and_p_on_one_display_walk_the_other_through_the_mirrors(self, device, base_url):
errors: list = []
for _ in range(2):
start_sharing(device("/mirror", errors))
first_id, second_id = mirror_ids(base_url, 2)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
expect(second.locator("body")).to_have_attribute("data-layout", "wall")
expect(second.locator(".tile")).to_have_count(2, timeout=15_000)
# n: no cursor yet, so it lands on the first mirror…
first.keyboard.press("n")
expect_staged(second, first_id)
# …p walks to the second…
first.keyboard.press("p")
expect_staged(second, second_id)
# …and wraps back to the first.
first.keyboard.press("p")
expect_staged(second, first_id)
# The server walked the same way (checked at the wrap, where a stale
# broadcast would still show the second mirror).
state = kiosk_state(base_url)
assert state["staged"] == first_id, state
# Both mirrors survived the walking: the display's wall holds both.
expect(second.locator(".tile")).to_have_count(2)
expect(second.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_dead_mirror_returns_every_display_to_the_wall(self, device, base_url):
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
(stream_id,) = mirror_ids(base_url, 1)
first = device("/kiosk", errors)
second = device("/kiosk", errors)
first.keyboard.press("Enter")
expect(first.locator("body")).to_have_attribute("data-layout", "stage", timeout=5_000)
expect_staged(second, stream_id)
stop_sharing(sharer)
# No display may sit on a dead tile: both back on the wall, in time.
expect(first.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(second.locator("body")).to_have_attribute("data-layout", "wall", timeout=10_000)
expect(first.locator("#kiosk-stage .tile")).to_have_count(0)
expect(second.locator("#kiosk-stage .tile")).to_have_count(0)
# And the wall is actually empty, not just unstaged: the dead tile is
# gone, so there is no stale <video> to clear.
expect(first.locator(".tile")).to_have_count(0, timeout=10_000)
expect(second.locator(".tile")).to_have_count(0, timeout=10_000)
state = wait_for_kiosk(base_url, lambda s: s["staged"] is None, "the layout cleared")
assert state["followers"] == 2, state
assert errors == [], f"uncaught browser errors: {errors}"
class TestFollowers:
def test_followers_counts_displays_only_over_a_real_socket_lifecycle(
self, base_url, browser, request
):
"""`followers` is how many *displays* are obeying the wall — a plain
viewer (a /watch tab) is not one, and a display leaving is noticed
without any state to poll: the count is derived from the roster."""
errors: list = []
assert kiosk_state(base_url)["followers"] == 0, "the suite must start with no displays"
context, _kiosk = open_device(base_url, browser, request, "/kiosk", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the display counted")
# A plain viewer joins: the wall follows the same state, but the count
# is about displays, not about who is reading the state.
open_device(base_url, browser, request, "/watch", errors)
wait_for_kiosk(base_url, lambda s: s["followers"] == 1, "the viewer not counted")
# The display leaves (its socket closes through a real context close)
# and the count falls back with it.
context.close()
wait_for_kiosk(base_url, lambda s: s["followers"] == 0, "the display counted out")
assert errors == [], f"uncaught browser errors: {errors}"
class TestSuiteIsPointerFree:
"""The kiosk guarantee, enforced on this file itself.
A test that reaches for the mouse proves nothing about a kiosk. The
guarantee lives in the code, not in reviewer attention, so this meta-test
bans pointer calls on this file and fails the suite the moment one slips
back in. The driver's raw ``WebSocket`` frames (``page.evaluate``) are
wire-level, not a pointer, and are allowed *explicitly* on that ground —
this guard bans the two pointer calls (click and hover) anywhere in this
file, nothing else, and does not loosen to skip the driver's block: a
pointer call anywhere in this file is a failure.
"""
def test_the_suite_never_reaches_for_the_pointer(self):
source = Path(__file__).read_text()
# Built from parts so this very assertion does not contain the banned
# literals (a plain string here would trip the check on itself).
assert ("cli" + "ck(") not in source, "a pointer call slipped into the kiosk suite"
assert ("hover" + "(") not in source, "a pointer call slipped into the kiosk suite"
+4 -2
View File
@@ -544,7 +544,7 @@ class TestAddressGate:
like a bug in the tool.
"""
@pytest.mark.parametrize("path", ["/mirror", "/watch"])
@pytest.mark.parametrize("path", ["/mirror", "/watch", "/kiosk"])
def test_a_refused_address_sees_why_and_none_of_the_app(self, browser, restricted_url, path):
context = browser.new_context(viewport={"width": 1100, "height": 720})
page = context.new_page()
@@ -557,7 +557,9 @@ class TestAddressGate:
# front of a proxy is undiagnosable.
expect(page.locator("body")).to_contain_text("127.0.0.1")
# None of the app shell leaked past the gate.
expect(page.locator("#start, #grid, #notice")).to_have_count(0)
expect(page.locator("#start, #grid, #notice, #kiosk-grid, #kiosk-stage")).to_have_count(
0
)
finally:
context.close()
+158 -20
View File
@@ -80,6 +80,7 @@ class TestPages:
("/mirror", "Share my screen"),
("/share", "Share my screen"),
("/watch", "tile-template"),
("/kiosk", "kiosk-tile-template"),
],
)
def test_pages_render(self, client, path, marker):
@@ -88,14 +89,17 @@ class TestPages:
assert response.headers["content-type"].startswith("text/html")
assert marker in response.text
def test_pages_are_not_cacheable(self, client):
assert client.get("/watch").headers["cache-control"] == "no-store"
@pytest.mark.parametrize("path", ["/", "/mirror", "/share", "/watch", "/kiosk"])
def test_pages_are_not_cacheable(self, client, path):
assert client.get(path).headers["cache-control"] == "no-store"
def test_the_two_pages_use_the_media_apis_they_claim(self):
# The watch page's media path lives in wall.js (phase 08): the page
# builds the wall, and the wall owns the RTCPeerConnection.
share = (STATIC / "share.js").read_text()
watch = (STATIC / "watch.js").read_text()
wall = (STATIC / "wall.js").read_text()
assert "getDisplayMedia" in share
assert "RTCPeerConnection" in share and "RTCPeerConnection" in watch
assert "RTCPeerConnection" in share and "RTCPeerConnection" in wall
assert "new WebSocket" in (STATIC / "net.js").read_text()
def test_nothing_is_loaded_from_a_cdn(self):
@@ -109,7 +113,10 @@ class TestPages:
offenders.append(f"{file.name} (@import)")
assert offenders == []
@pytest.mark.parametrize("asset", ["app.css", "net.js", "share.js", "watch.js"])
@pytest.mark.parametrize(
"asset",
["app.css", "net.js", "share.js", "watch.js", "wall.js", "kiosk.js"],
)
def test_static_assets_are_served(self, client, asset):
assert client.get(f"/static/{asset}").status_code == 200
@@ -124,7 +131,7 @@ class TestClientScripts:
NODE = shutil.which("node")
@pytest.mark.skipif(NODE is None, reason="node is not installed")
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js"])
@pytest.mark.parametrize("script", ["net.js", "share.js", "watch.js", "wall.js", "kiosk.js"])
def test_scripts_parse_as_es_modules(self, script, tmp_path):
copy = tmp_path / "subject.mjs"
copy.write_text((STATIC / script).read_text())
@@ -133,7 +140,11 @@ class TestClientScripts:
@pytest.mark.parametrize(
("script", "page"),
[("share.js", "mirror.html"), ("watch.js", "watch.html")],
[
("share.js", "mirror.html"),
("watch.js", "watch.html"),
("kiosk.js", "kiosk.html"),
],
)
def test_every_element_the_script_reaches_for_exists(self, script, page):
html = (STATIC / page).read_text()
@@ -141,8 +152,100 @@ class TestClientScripts:
missing = {name for name in wanted if f'id="{name}"' not in html}
assert not missing, f"{script} looks up {sorted(missing)} which {page} does not render"
def test_both_pages_load_the_stylesheet_and_their_module(self):
for page, script in (("mirror.html", "share.js"), ("watch.html", "watch.js")):
def test_wall_never_looks_up_elements_in_the_document(self):
"""wall.js serves /watch and /kiosk (and phase 09's /remote) from one
module; an element lookup would resolve in whichever page happens to
load it. The elements come from the page instead, which is why the
element ids must not collide across pages (see
test_the_two_pages_share_no_element_ids_but_the_shared_chrome) — so
the module is guarded at the source level, here, next to the other
client-script guards."""
source = (STATIC / "wall.js").read_text()
offenders = re.findall(r"""\$\(['\"]#([A-Za-z0-9_-]+)['\"]\)""", source)
assert not offenders, f"wall.js must not query the document; found {offenders}"
def test_the_wall_key_router_is_one_listener_in_wall_js(self):
"""One listener, one switch, per wall page — phase 01's whole point.
`installWallKeys` (wall.js) is the single keydown listener a wall page
gets (/watch, /kiosk, phase 09's /remote): a second listener on the
same document would run the same key twice (j would move the cursor
twice) or race the first. The share page is not a wall page — its
s/x/Esc listener lives in share.js and is page policy — but a wall
page module must never add a keydown listener of its own, so a second
`addEventListener("keydown"` in a wall page module fails the build.
"""
wall = (STATIC / "wall.js").read_text()
assert "export function installWallKeys" in wall, (
"installWallKeys must be defined in wall.js (the one keydown listener)"
)
assert len(re.findall(r'addEventListener\(\s*["\']keydown["\']', wall)) == 1, (
"wall.js must hold exactly one keydown listener (the router)"
)
offenders = [
file.name
for file in sorted(STATIC.glob("*.js"))
if file.name not in ("share.js", "wall.js")
and re.search(r'addEventListener\(\s*["\']keydown["\']', file.read_text())
]
assert offenders == [], (
"a wall page module added its own keydown listener; the one listener "
f"per wall page lives in wall.js: {offenders}"
)
@staticmethod
def _object_literal_around(source, pos):
"""The object literal containing ``source[pos:``.
``pos`` points at a ``type: "arrange"`` key. Walking back, the nearest
``{`` that is not inside a deeper ``{...}`` span is the literal's start
— the literal itself when ``type`` is its first member, the enclosing
object when it is not. Forward brace matching then closes it.
"""
depth = 0
start = None
for i in range(pos - 1, -1, -1):
if source[i] == "}":
depth += 1
elif source[i] == "{":
if depth == 0:
start = i
break
depth -= 1
assert start is not None, "no object literal before the arrange key"
depth = 0
for i in range(start, len(source)):
if source[i] == "{":
depth += 1
elif source[i] == "}":
depth -= 1
if depth == 0:
return source[start : i + 1]
raise AssertionError("unbalanced braces around the arrange frame")
def test_kiosk_never_publishes_an_audio_field(self):
"""The layout has no audio field (SECURITY.md §4): a display must not
become audible because another machine asked, so ``kiosk.js`` may never
put a ``muted``/``audio`` field in an ``arrange`` frame. The rule is a
security rule, so it fails the build here instead of living in a
comment (phase 08 task 04). At least one ``arrange`` frame must exist:
the display is a first-class controller, and this guard must not pass
on a file that simply stopped publishing."""
source = (STATIC / "kiosk.js").read_text()
matches = list(re.finditer(r"type\s*:\s*[\"\']arrange[\"\']", source))
assert matches, "kiosk.js publishes no arrange frames at all"
for match in matches:
literal = self._object_literal_around(source, match.start())
assert not re.search(r"\b(muted|audio)\s*:", literal), (
f"an arrange frame carries an audio field: {literal}"
)
def test_the_pages_load_the_stylesheet_and_their_module(self):
for page, script in (
("mirror.html", "share.js"),
("watch.html", "watch.js"),
("kiosk.html", "kiosk.js"),
):
html = (STATIC / page).read_text()
assert 'href="/static/app.css"' in html
assert f'src="/static/{script}"' in html
@@ -289,23 +392,55 @@ class TestClientScripts:
)
def test_the_two_pages_share_no_element_ids_but_the_shared_chrome(self):
"""Both pages consume one stylesheet, so a duplicated id means a colliding rule.
"""The pages consume one stylesheet, so a duplicated id means a colliding rule.
This is not hypothetical: a `#stage` on both pages once made the share
page's preview inherit `display: none` from the watch page's overlay.
The kiosk page's ids carry the `kiosk-` prefix for exactly this reason;
the only ids it may share with another page are the shared chrome
(net, notice, toast).
"""
shared = {"net", "notice", "toast"}
ids = {
page: set(re.findall(r'id="([A-Za-z0-9_-]+)"', (STATIC / page).read_text()))
for page in ("mirror.html", "watch.html", "index.html")
for page in ("mirror.html", "watch.html", "index.html", "kiosk.html")
}
assert ids["mirror.html"] & ids["watch.html"] == shared
assert ids["index.html"] & ids["mirror.html"] <= shared | {"livebar"}
assert ids["kiosk.html"] & ids["watch.html"] == shared
assert ids["kiosk.html"] & ids["mirror.html"] == shared
# index.html has no notice/toast (a signpost, not an app page), so its
# intersection with the kiosk is a subset of the shared chrome — the
# same shape as the index/mirror assertion above.
assert ids["kiosk.html"] & ids["index.html"] <= shared
for page, found in ids.items():
assert len(found) == len(set(found)), f"{page} declares an id twice"
def test_the_wall_pages_render_their_stage_classed(self):
"""The stage rules are class-keyed (`.stage`, phase 08 task 01): a page
whose stage lost the class renders a full-size unstyled video, which is
exactly the class of bug the stylesheet guards exist for. Both wall
pages keep it, and the kiosk's structural elements (grid, stage, hud)
exist for the layout applier to take them. (There was no
elements-a-page-must-have check before; this is the one.)
"""
kiosk = (STATIC / "kiosk.html").read_text()
for element_id in ("kiosk-grid", "kiosk-stage", "kiosk-hud"):
assert f'id="{element_id}"' in kiosk, f"kiosk.html is missing #{element_id}"
for page, stage_id in (("watch.html", "watch-stage"), ("kiosk.html", "kiosk-stage")):
stage_tags = [
tag
for tag in re.findall(r"<div[^>]*>", (STATIC / page).read_text())
if f'id="{stage_id}"' in tag
]
assert stage_tags, f'{page} is missing <div id="{stage_id}">'
assert 'class="stage"' in stage_tags[0], (
f'{page}#{stage_id} lost class="stage": the stage rules are '
"class-keyed, so an unclassed stage renders a full-size unstyled video"
)
def test_videos_are_muted_and_inline_so_autoplay_is_not_blocked(self):
for page in ("mirror.html", "watch.html"):
for page in ("mirror.html", "watch.html", "kiosk.html"):
for tag in re.findall(r"<video[^>]*>", (STATIC / page).read_text()):
assert "muted" in tag, f"{page}: {tag} would be blocked by autoplay policy"
assert "playsinline" in tag, f"{page}: {tag} would hijack iOS into fullscreen"
@@ -378,8 +513,9 @@ class TestStylesheet:
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
selector and the first child's header as its body; the child rules
that follow still come back as their own pairs, which is all these
guards need — every assertion is about a top-level block (``.stage``,
``.stage > *``) or about properties that must sit in the *same* block
guards need — every assertion is about a top-level block
(``.preview-stage``, ``.preview-stage > *``) or about properties that
must sit in the *same* block
(``overflow``/``aspect-ratio``/``min-height`` together).
"""
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
@@ -396,8 +532,8 @@ class TestStylesheet:
without the other stacks the rows back.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
or (
@@ -409,7 +545,7 @@ class TestStylesheet:
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
)
child_bodies = [body for sel, body in rules if sel == ".stage > *"]
child_bodies = [body for sel, body in rules if sel == ".preview-stage > *"]
assert child_bodies and any(
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
), (
@@ -431,8 +567,8 @@ class TestStylesheet:
content again.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
stage_bodies = [body for sel, body in rules if sel == ".preview-stage"]
assert stage_bodies, ".preview-stage rule missing from app.css"
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
".stage must keep its floor (min-height)"
)
@@ -460,7 +596,8 @@ class TestAddressGate:
assert client.get("/watch").status_code == 200
@pytest.mark.parametrize(
"path", ["/", "/mirror", "/watch", "/api/streams", "/api/kiosk", "/static/app.css"]
"path",
["/", "/mirror", "/watch", "/kiosk", "/api/streams", "/api/kiosk", "/static/app.css"],
)
def test_a_refused_address_gets_no_page_no_roster_no_assets(self, path):
with gated(self.ALLOW) as client:
@@ -527,6 +664,7 @@ class TestAddressGate:
def test_the_real_address_is_in_and_connects(self):
with gated(self.ALLOW, client_address="10.2.1.10") as client:
assert client.get("/watch").status_code == 200
assert client.get("/kiosk").status_code == 200
with client.websocket_connect("/ws") as socket:
assert handshake(socket, "viewer")