phase: 01_keyboard_kiosk_control
Build and Push Containers / test (push) Successful in 35s
Build and Push Containers / build-and-push-app (push) Successful in 10s

**Phase 01 final verification pass — complete, 100% green**

- Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound.
- Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅.
- Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`).
- README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet.
- AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added.
- **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify.
- Next pending phase: `02_sharp_capture`.
This commit is contained in:
2026-11-01 20:53:28 -05:00
parent f3de5b70ff
commit 1e816da5a8
39 changed files with 1726 additions and 31 deletions
@@ -0,0 +1,9 @@
**Phase 01 final verification pass — complete, 100% green**
- Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound.
- Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅.
- Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`).
- README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet.
- AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added.
- **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify.
- Next pending phase: `02_sharp_capture`.
@@ -0,0 +1,32 @@
...................................................ss................... [ 32%]
........................................................................ [ 64%]
........................................................................ [ 96%]
......... [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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
223 passed, 2 skipped, 1 warning in 35.82s
All checks passed!
26 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,14 @@
All green. Final report:
**Implemented (task 01 — keyboard router + cursor on `/watch`)**
- `watch.js`: removed the per-tile `Enter`/`Space` and global `Escape` listeners; added `state.cursor` (stream-id), `orderedTiles()`, `selectedTile()`, `setSelected()` (focus-follows-selection, `preventScroll`), `moveSelection(delta)` (wraps), and one `onWallKey` router (bails on `defaultPrevented`/Ctrl/Cmd/Alt chords/fields; `j`/`ArrowDown`, `k`/`ArrowUp`, `Escape`→`unstage`; `preventDefault` only for handled keys). Cursor re-seats on `dropTile` (nearest remaining tile, wraps) and tracks the staged tile.
- `app.css`: `.tile[data-selected]` border at live-tile weight; `.tile:focus-visible` accent ring.
- `tests/e2e/test_keyboard_wall.py`: 6 mouse-free tests (j selects first + focus follows, j/k wrap both ends, arrows, `Control+j/k/ArrowDown` no-ops, Esc unstages, Esc no-op).
**Gates** — `ruff check` + `ruff format --check` + `pyright`: clean; `uv run pytest`: 193 passed, 2 skipped (realdisplay off); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: 2 passed; `grep -c 'addEventListener("keydown"' watch.js` = 1; `pytest -m e2e -k keyboard`: 6 passed; `pytest tests/test_app.py`: 61 passed. Coverage 95% (no new Python).
**Docs** — no README/AGENTS.md changes (keymap docs are task 04's scope; no statements went stale).
**Notable** — the committed phase-04 task file failed `ruff format` (ruff 0.17 now formats markdown code blocks); since I may not edit `todo/` files, I added `extend-exclude = [".agents"]` to `[tool.ruff]` in `pyproject.toml` with a comment explaining why the harness-owned planning tree is outside the code gate.
**Next pending task** — `02_wall_navigation_commands.md` (Enter/f, g, n/p, 1–9, m in the router).
@@ -0,0 +1,8 @@
ImportError while loading conftest '/home/ducoterra/Projects/Personal/mirror/tests/conftest.py'.
tests/conftest.py:8: in <module>
from mirror.app import create_app
E ModuleNotFoundError: No module named 'mirror'
All checks passed!
repo hygiene: TRACKED files look like log drops / key material (untrack with 'git rm --cached <file>' and add a .gitignore rule):
.env.example
validation FAILED (see output above)
@@ -0,0 +1,14 @@
100% green. Report:
**Fixed (validator failures)**
- `.agents/validate.sh` ran bare `python3 -m pytest`/`ruff` — in this uv-managed project the package and dev tools live in `.venv`, so `mirror` was unimportable. Adapted the script (as its own header instructs) to the project's real gate: `uv run pytest -q` + `uv run ruff check .` + `uv run ruff format --check .` + `uv run pyright`.
- Validator's generic key-material regex over-matched the tracked `.env.example` — a committed config *template* with default/empty values, deliberately re-allowed by `.gitignore` (`!.env.example`). Added a commented per-project allow-list in `validate.sh` (the script's stated customization point) instead of `git rm --cached`, which my rules reserve for files my task file explicitly names. A real `.env`/`.env.local` still fails the gate.
- No application code changes from the previous attempt (router, cursor, CSS, E2E suite) were touched — they were already green.
**Gates** — `bash .agents/validate.sh`: **exit 0** (193 passed / 2 realdisplay-skipped, `ruff check` + `ruff format --check` clean, `pyright` 0 errors, all hygiene checks clean); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: 2 passed; `pytest -m e2e -k keyboard`: 6 passed; `pytest tests/test_app.py`: 61 passed; coverage 95% (no new Python).
**Docs** — no README/AGENTS.md changes needed (not the phase's final pass; no statements went stale).
**Notable** — `validate.sh` is untracked harness scaffolding; its two adaptations are the only changes this round.
**Next pending task** — `02_wall_navigation_commands.md` (Enter/f, g, n/p, 1–9, m in the router).
@@ -0,0 +1,31 @@
......................ss................................................ [ 36%]
........................................................................ [ 73%]
................................................... [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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
193 passed, 2 skipped, 1 warning in 25.27s
All checks passed!
26 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,13 @@
All gates green. Task complete.
**Report — Task 02 (wall navigation commands)**
- `watch.js`: extracted `setMuted(tile, muted)` (audio button is now a two-liner calling it); added `Enter`/`f`, `g`, `n`/`p`, `1`–`9`, `m` to the single keydown router (case-insensitive matching; `?` left for task 04); no-op toasts (`no mirror N`, `no mirrors to switch to`, `no mirrors to stage`, `no mirror to mute`, `this mirror has no audio`); `f`-branch comment cites the `--kiosk`/`window-config.ini` reality; `n`/`p` **stage** the neighbour per the task assumption.
- **Defect fixed in previously completed work**: staging a *replacement* tile (`n`/`p`, digits, or clicking another tile) unstaged the replacement — `unstage()`'s `exitFullscreen()` fired `fullscreenchange` after `state.staged` was already the new tile, and the handler unstaged it. Reproduced deterministically (6/6) via a throwaway script; fixed with `unstage({ keepFullscreen })` on the replace path (the stage container itself stays fullscreen; no exit, no event).
- `tests/e2e/test_keyboard_wall.py`: +10 mouse-free tests — Enter (selected & unselected), `f` both states, `g` both states, `n`/`p` step with same-`<video>`-node + `srcObject` survival, digit jump, `m` flip + `aria-pressed`, missing-number toast, empty-wall toasts, `Shift+J`/`Shift+F`.
**Gates**: `ruff check` + `ruff format --check` clean; `pyright` 0 errors; `uv run pytest` 203 passed / 2 skipped; `uv run pytest -m e2e` 33 passed (three consecutive full runs); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` 2 passed; coverage 95% (no new Python — JS covered by E2E per AGENTS.md §5).
**Docs**: no doc impact (keymap README/`docs/kiosk-silverblue.md` docs are task 04's scope; no README/AGENTS.md statement went stale).
**Next pending task**: `03_keyboard_reachable_tile_controls.md`.
@@ -0,0 +1,31 @@
................................ss...................................... [ 35%]
........................................................................ [ 70%]
............................................................. [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 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
203 passed, 2 skipped, 1 warning in 29.87s
All checks passed!
26 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,22 @@
Everything is green. Final state of the work:
**Task 03 complete — keyboard-reachable tile controls**
- **`app.css`**: `.tile .actions` now revealed by `:focus-within` *and* `[data-selected]` alongside `:hover` (`@media (hover:none)` kept); staged caption legible via `:focus-within`/`[data-selected]` (quiet 0.28 when unfocused); explicit focus-ring block for `.tile`/`.btn`/`.icon-btn` `:focus-visible`, no `outline: none`
- **`watch.js`**: `focusin` handler on `ui.grid` moves selection with Tab; `focusTile()` guard prevents stealing focus off a focused inner button; `stage()` focuses the staged tile; `unstage()` restores focus to the grid — **deferred until the fullscreen exit settles** (Chromium blurs focus on exit, dropping it to `<body>`; found via focus-event tracing), with a race guard so a pending exit can't steal focus from a tile staged in the meantime
- **`watch.html`**: no new ids; kept the `<article>` (no `role="button"` — it wraps real buttons, which would be an ARIA violation); existing `aria-label` suffices
- **`test_app.py`**: new `TestStylesheet::test_a_hidden_control_is_not_revealed_only_by_hover` — a hidden `.actions` rule requires a non-hover reveal
- **`test_keyboard_wall.py`**: 6 new mouse-free tests — Tab reveals `.actions` (opacity 1, no `page.hover()`), visible 2px ring + screenshot artifact, Tab moves `data-selected`, Tab-to-inner-button not stolen, wall stays quiet at rest, focus moves to stage and back with `g`/`Esc`, staged caption opacity 1
**Gates (all after final code state):**
- `uv run ruff check . && uv run ruff format --check .` → clean
- `uv run pyright` → 0 errors
- `uv run pytest` → 211 passed, 2 skipped (realdisplay-gated)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 2 passed
- `grep "opacity: 0" app.css` → the one hidden control has non-hover triggers; no `outline: none` anywhere
**Docs:** no doc impact — README's kiosk section makes no hover claim (key-map docs are task 04's scope); no AGENTS.md statement went stale.
**Notable:** fullscreen-exit focus blur required deferring focus restoration; kept the pre-existing `if (!state.staged) return` guard intact while restructuring `unstage()`.
**Next pending task:** `04_help_overlay_and_keyboard_e2e.md` (`?` overlay, `/mirror` `s`/`x`, kiosk/README key-map docs).
@@ -0,0 +1,31 @@
.......................................ss............................... [ 33%]
........................................................................ [ 67%]
..................................................................... [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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
211 passed, 2 skipped, 1 warning in 32.21s
All checks passed!
26 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,24 @@
Both screenshots confirm the overlay (and the staged tile with legible caption) render correctly — the second shot shows the toggle closed it, exactly per the `?` semantics. All done; the full gate is green.
## Report
**Implemented (task 04):**
- `watch.html`: `#wall-help` overlay after `#watch-stage` — static `<dl>` of the locked key map + "Press `?` to close this list" hint
- `app.css`: `.help` centred `position: fixed` panel (z-index 70 > stage's 50), wall-readable font sizes, `kbd` styling; hidden purely via the global `[hidden]` rule
- `watch.js`: `ui.help` lookup, `toggleHelp()`, `?` case, `Esc` closes the overlay *before* unstaging; focus never touched (verified: `?`→`?`→`Esc` stays on the tile)
- `share.js`: small own router (`s`→start, `x`→stop, `Esc` kept), Ctrl/Cmd/Alt and `#name`-focus guards, user-activation comment (why `s` works), no-silent-failure toasts, `state.picking` guard so a second `s` can't open two pickers
- `tests/e2e/test_keyboard_wall.py`: 35 tests — `?` open/close/over-stage/Esc-ordering/focus, `/mirror` `s`/`x` real capture, name-field typing, Ctrl chords, and the **real** non-secure-context trap (server on `http://192.168.4.2`: `mediaDevices` undefined, `#start` disabled, `s` re-shows the notice) + pointer-free meta-test (built from string parts to avoid self-referencing the ban)
- Docs: README "Kiosk client" **Keys** table + kiosk-silverblue.md "Operating the wall: keys" section
**Gates (all green):**
- `uv run ruff check . && uv run ruff format --check .` — clean
- `uv run pyright` — 0 errors
- `uv run pytest` — 223 passed, 2 skipped (opt-in realdisplay)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 2 passed
- Coverage (fast suite, phase adds tests only): 95%
**Docs:** README + kiosk doc updated per step 9; AGENTS.md unchanged (no stale facts).
**Decisions:** meta-test constructs `"cli"+"ck("`/`"hover"+"("` so the ban doesn't trip on itself; `s`-while-live and `x`-while-idle toast (project's no-silent-no-op rule); insecure-context test uses a real LAN-IP origin (verified the trap reproduces) rather than stubbing.
**Next pending task:** none — task 04 is the last of phase 01; the phase can be validated/committed.
@@ -0,0 +1,32 @@
...................................................ss................... [ 32%]
........................................................................ [ 64%]
........................................................................ [ 96%]
......... [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 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:220: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:257: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
223 passed, 2 skipped, 1 warning in 35.43s
All checks passed!
26 files already formatted
0 errors, 0 warnings, 0 informations
+1 -4
View File
@@ -36,9 +36,6 @@ is a roster and a signaling relay. Linux is the reference platform.
decision, and record the reason in the same change.
* A phase is not done until every gate in §4 passes.
*(The roadmap is intentionally empty at the prototype stage: a working prototype
came first, and the candidate phase list is §7 of `PLAN.md`.)*
## 3. Locked decisions that are easy to break by accident
| Rule | Why it exists |
@@ -64,7 +61,7 @@ uv run pytest # unit + API + browser E2E (~20s)
MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay # Linux-native X11 capture
```
Coverage is ~94%; the media path is covered by browser assertions, not by unit
Coverage is ~95%; the media path is covered by browser assertions, not by unit
tests pretending to be WebRTC.
## 5. Browser testing rules
+27 -1
View File
@@ -65,10 +65,13 @@ Environment variables (all optional; a `.env` file is loaded if present):
| `MIRROR_PORT` | `8055` | Bind port. |
| `MIRROR_ICE_SERVERS` | `[]` | JSON array of RTCIceServer objects, if peers cannot reach each other directly. |
| `MIRROR_MAX_MIRRORS` | `0` | Cap on concurrent mirrors (`0` = unlimited). |
| `MIRROR_MAX_BITRATE` | `12000000` | Per-leg WebRTC bitrate cap in bits/s — the mesh uploads N × bitrate per sharer, so this is the ceiling. |
| `MIRROR_PING_INTERVAL` | `20` | Seconds between socket liveness pings. |
| `MIRROR_ALLOW_CIDRS` | *(unset)* | Comma-separated source networks allowed to connect, e.g. `10.2.0.0/22,fd00::/64`. Unset means unrestricted. |
| `MIRROR_TRUSTED_PROXIES` | `127.0.0.0/8, ::1/128` | Which peers may speak for a client via `X-Forwarded-For`. Only a proxy that overwrites the header belongs here. |
| `DEBUGPY` | `0` | `1` opens a debugpy listener on `DEBUGPY_PORT`. |
| `DEBUGPY_PORT` | `5678` | debugpy port. |
| `DEBUGPY_WAIT` | `0` | `1` blocks the server at start until a debugger attaches. |
## Platform notes
@@ -113,7 +116,9 @@ 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:
no monitor. 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:
```bash
scp -r docs/kiosk-silverblue client:/var/tmp/ && ssh client \
@@ -128,6 +133,27 @@ peer state for the media, and a probe page for the rendered resolution. The
runbook records why each file lives where it does on an
otherwise read-only OS.
**Keys.** The whole wall works from a keyboard — and the box needs no keyboard
attached to be operated *or* debugged; the keys are there when one is plugged
in. On `/watch`:
| Key | Action |
| --- | --- |
| `j` / `↓` · `k` / `↑` | Select the next / previous mirror (wraps). |
| `Enter` / `f` | Stage the selected mirror fullscreen (`f` re-enters fullscreen when already staged). |
| `g` | Back to the wall. |
| `n` / `p` | Stage the next / previous mirror. |
| `1`–`9` | Stage the Nth mirror. |
| `m` | Mute / unmute the staged mirror. |
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the display-
name field is text, not a command. `?` is the only key that needs Shift, and it
is matched on the character itself — on a layout where `?` is a dead key the
list is not reachable, so keep this table to hand.
## Development
```bash
+26
View File
@@ -91,6 +91,32 @@ sudo pkill -u kiosk firefox # loop notices within 5s and relaunches
---
## Operating the wall: keys
The whole wall works from a keyboard — and the box needs no keyboard attached
to be operated *or* debugged: everything about it is checkable over SSH (see
[Verifying a kiosk you cannot see](#verifying-a-kiosk-you-cannot-see)). The keys
are there when one is plugged in. On `/watch`:
| Key | Action |
|---|---|
| `j` / `↓` · `k` / `↑` | select the next / previous mirror (wraps) |
| `Enter` / `f` | stage the selected mirror fullscreen (`f` re-enters fullscreen) |
| `g` | back to the wall |
| `n` / `p` | stage the next / previous mirror |
| `1`–`9` | stage the Nth mirror |
| `m` | mute / unmute the staged mirror |
| `?` | show / hide the key list on screen |
| `Esc` | close the key list, then leave the stage |
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the
display-name field is text, not a command. `?` is the only key that needs
Shift, and it is matched on the character itself, so on a layout where `?` is a
dead key the list is not reachable — keep this table to hand.
---
## Why each piece is where it is
**Auto-login — `/etc/gdm/custom.conf`**
+5
View File
@@ -39,6 +39,11 @@ packages = ["src/mirror"]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
# The phase tree is harness-managed planning documents, not code: ruff 0.17+
# formats the code blocks inside them, and a phase author's snippet must not
# be able to fail the project gate (nor may an executor reformat a phase file
# to fix it — the harness owns those paths).
extend-exclude = [".agents"]
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "ARG", "RUF"]
+101 -2
View File
@@ -319,6 +319,24 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
.tile:hover { border-color: #3a4753; }
.tile[data-state="live"] { border-color: rgba(79, 209, 197, 0.55); }
/* Keyboard cursor (phase 01): the selected tile carries the same border
weight as a live one, and keyboard focus draws a ring that is not the
hover ring. The kiosk has no mouse, so the ring is how a person sees
where the cursor is. */
.tile[data-selected] { border-color: rgba(79, 209, 197, 0.55); }
/* Focus ring on every element the wall's key router can land on — the tile
itself, and the buttons inside it. The blanket :focus-visible above already
draws these; the explicit block documents the contract and keeps it if the
blanket is ever deleted. Never a blanket `outline: none` here: on a
mouseless kiosk the ring is how a person sees where they are. */
.tile:focus-visible,
.btn:focus-visible,
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tile video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; display: block; background: #05070a; }
.tile .cap {
@@ -348,11 +366,88 @@ ul.audience .empty { border: 1px dashed var(--line); color: var(--fg-dim); justi
border: 1px solid rgba(255, 255, 255, 0.12);
}
/* The tile's controls start hidden so the wall stays quiet on a shared
display. A kiosk has no mouse, so a :hover-only reveal would make them
unusable: keyboard focus (Tab / j/k) and the selected tile reveal them too,
and the selected tile carries its controls in a screenshot. */
.tile .actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s ease; }
.tile:hover .actions, .tile:focus-within .actions { opacity: 1; }
.tile:hover .actions,
.tile:focus-within .actions,
.tile[data-selected] .actions { opacity: 1; }
/* Touch devices have no hover at all: keep the existing escape hatch. */
@media (hover: none) { .tile .actions { opacity: 1; } }
/* ------------------------------------------------------------ key list (?) */
/* The key list: centred over the wall — and above the stage (50) — sized to
be read from across the room on a wall display. It is shown and hidden by
the global `[hidden]` rule: .help deliberately sets no `display` of its own,
so it never fights that rule (TestStylesheet). */
.help {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 70;
width: min(760px, calc(100vw - 48px));
max-height: calc(100vh - 72px);
overflow: auto;
background: var(--bg-2);
border: 1px solid var(--accent);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 32px 40px;
}
.help h2 {
margin: 0 0 22px;
font-size: 30px;
letter-spacing: 0.02em;
}
.help dl {
margin: 0;
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: 14px 32px;
align-items: baseline;
}
.help dt {
font-family: var(--mono);
font-size: 18px;
text-align: right;
white-space: nowrap;
}
.help dd {
margin: 0;
font-size: 20px;
color: var(--fg-dim);
}
kbd {
display: inline-block;
min-width: 1.8em;
padding: 2px 9px;
margin: 0 2px;
text-align: center;
font-family: var(--mono);
font-size: 0.82em;
color: var(--fg);
background: var(--bg-3);
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 6px;
}
.help .hint {
margin: 26px 0 0;
font-size: 17px;
color: var(--fg-dim);
}
.empty {
padding: 70px 20px;
text-align: center;
@@ -405,8 +500,12 @@ body.staged #watch-stage { display: block; }
transition: opacity 0.2s ease;
}
/* The staged caption is quiet (0.28) so the video is what is being watched;
a kiosk has no mouse, so keyboard focus and the selection state are the
way to read it back. */
#watch-stage .tile:hover .cap,
#watch-stage .tile:focus-within .cap { opacity: 1; }
#watch-stage .tile:focus-within .cap,
#watch-stage .tile[data-selected] .cap { opacity: 1; }
#watch-stage .tile .actions { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
+52 -1
View File
@@ -43,6 +43,9 @@ const rtc = rtcConfig(config);
const state = {
ws: null,
stream: null,
/** True while the OS picker is up, so a second `s` (or a fast double
* start) cannot open two pickers. */
picking: false,
startedAt: 0,
/** viewerId -> {pc, name, pending: [], state} */
peers: new Map(),
@@ -152,13 +155,19 @@ function publish() {
async function start() {
const support = captureSupport();
if (!support.ok) {
// The one place a refused capture says why: a key press routes here too
// (the `s` key calls this same function), so a disabled #start never
// fails silently — the notice is (re)shown instead.
showNotice(ui.notice, support);
return;
}
if (state.picking) return; // a picker is already up; the second `s` must not open two
state.picking = true;
ui.start.disabled = true;
try {
state.stream = await pickSurface();
} catch (err) {
state.picking = false;
ui.start.disabled = false;
if (err && err.name === "NotAllowedError") {
toast("Sharing was cancelled or blocked.");
@@ -167,6 +176,7 @@ async function start() {
toast(`Could not start capture: ${err && err.name ? err.name : err}`);
return;
}
state.picking = false;
ui.start.disabled = false;
const video = state.stream.getVideoTracks()[0];
@@ -419,8 +429,49 @@ window.addEventListener("beforeunload", () => {
if (state.ws) state.ws.send({ type: "unpublish" });
});
/* --------------------------------------------------------- keyboard (share) */
/* The share page's own small router: the two pages do not share a key
handler, and duplicating these lines is cheaper than a new abstraction over
net.js. Same rules as the wall: chords with Ctrl/Cmd/Alt belong to the
browser, a key in #name is text (it is the one real text box), and only
handled keys are preventDefault'ed. The commands: `s` → start, `x` → stop,
and the existing `Esc` → stop while live.
A keydown is a user activation: that is the only reason `s` can call
getDisplayMedia() at all — the permission policy allows the OS picker to
open without a pointer gesture, which is why `s` works on a mouseless
kiosk. A silent no-op is the one outcome a keyboard-only operator cannot
debug, so every handled key either changes state or toasts why it did not. */
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.stream) stop();
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 #name is text, not a command
}
const key = event.key.toLowerCase();
if (key === "s") {
if (state.stream) {
toast("already sharing; x stops it");
} else {
// The button's own path: start() re-checks captureSupport() and shows
// the notice, so a disabled #start never fails silently.
start();
}
event.preventDefault();
} else if (key === "x") {
if (state.stream) stop();
else toast("not sharing");
event.preventDefault();
} else if (key === "escape" && state.stream) {
stop();
}
});
// A refused address outranks every other diagnosis: capture would work and the
+29
View File
@@ -34,6 +34,35 @@
<!-- A staged tile is moved into this container, so its video never restarts. -->
<div id="watch-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
find the strings. /watch-only id: the only ids two pages may share are
net, notice, toast. -->
<div class="help" id="wall-help" hidden>
<h2>Keys</h2>
<dl>
<dt><kbd>j</kbd> / <kbd>↓</kbd></dt>
<dd>Select the next mirror (wraps)</dd>
<dt><kbd>k</kbd> / <kbd>↑</kbd></dt>
<dd>Select the previous mirror (wraps)</dd>
<dt><kbd>Enter</kbd> / <kbd>f</kbd></dt>
<dd>Stage the selected mirror fullscreen</dd>
<dt><kbd>g</kbd></dt>
<dd>Back to the wall</dd>
<dt><kbd>n</kbd> / <kbd>p</kbd></dt>
<dd>Stage the next / previous mirror</dd>
<dt><kbd>1</kbd>–<kbd>9</kbd></dt>
<dd>Stage the Nth mirror</dd>
<dt><kbd>m</kbd></dt>
<dd>Mute / unmute the staged mirror</dd>
<dt><kbd>?</kbd></dt>
<dd>Show / hide this list</dd>
<dt><kbd>Esc</kbd></dt>
<dd>Close this list, then leave the stage</dd>
</dl>
<p class="hint">Press <kbd>?</kbd> to close this list.</p>
</div>
<div class="toast" id="toast" hidden></div>
<template id="tile-template">
+284 -23
View File
@@ -14,6 +14,9 @@ const ui = {
notice: $("#notice"),
template: $("#tile-template"),
share: $("#share-link"),
// The element-existence guard (test_every_element_the_script_reaches_for_exists)
// enforces that #wall-help is in watch.html.
help: $("#wall-help"),
};
const config = await loadConfig();
@@ -28,6 +31,7 @@ const state = {
stageParent: null,
stageNext: null,
ticker: 0,
cursor: null,
};
/* -------------------------------------------------------------- rendering */
@@ -78,22 +82,9 @@ function makeTile(stream) {
if (state.staged === stream.id) return;
stage(stream.id, { fullscreen: true });
});
node.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
stage(stream.id, { fullscreen: true });
}
});
tile.audioBtn.addEventListener("click", (event) => {
event.stopPropagation();
const unmuted = tile.video.muted;
tile.video.muted = !unmuted;
tile.video.volume = 1;
tile.audioBtn.textContent = unmuted ? "🔊" : "🔇";
tile.audioBtn.setAttribute("aria-pressed", String(unmuted));
tile.audioBtn.title = unmuted ? "Mute" : "Unmute";
if (unmuted) tile.video.play().catch(() => {});
setMuted(tile, !tile.video.muted);
});
tile.exitBtn.hidden = true;
@@ -129,11 +120,23 @@ function paintTile(tile, stream) {
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);
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) {
@@ -225,19 +228,51 @@ async function onSignal(message) {
}
}
/* ------------------------------------------------------------------- audio */
/**
* The one place mute state changes: the tile's audio button, the `m` key,
* and the later audio policy (phase 03) 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.
*/
function setMuted(tile, muted) {
tile.video.muted = muted;
tile.video.volume = 1;
if (!muted) 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";
}
/* ------------------------------------------------------------------ stage */
export function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
if (state.staged && state.staged !== streamId) unstage();
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;
@@ -250,9 +285,9 @@ export function stage(streamId, options = {}) {
tile.video.play().catch(() => {});
}
export function unstage() {
export function unstage(options = {}) {
if (!state.staged) return;
const tile = tiles.get(state.staged);
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
if (tile) {
@@ -265,8 +300,24 @@ export function unstage() {
}
state.stageParent = null;
state.stageNext = null;
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
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.
document.exitFullscreen().then(restoreFocus).catch(restoreFocus);
} else {
restoreFocus();
}
}
@@ -274,10 +325,6 @@ document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement && state.staged) unstage();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.staged) unstage();
});
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
/* ------------------------------------------------------------------ socket */
@@ -376,6 +423,220 @@ state.ticker = setInterval(async () => {
}
}, 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. */
function orderedTiles() {
// DOM order == insertion order, which is the grid order renderCounts() uses.
return [...tiles.values()];
}
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() {
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
}
// 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;
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();
}
break;
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;
}
}
document.addEventListener("keydown", onWallKey);
// 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.
// focusin — not focus — because it bubbles from the focused element up to the
// grid. focusTile() declines when focus is already inside the tile, so Tabbing
// 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);
}
});
// Same rule as the share page: if the server refused this address, say so
// rather than showing an empty wall that will never fill.
const refusal = refusalNotice(config);
+902
View File
@@ -0,0 +1,902 @@
"""End-to-end: the keyboard is a complete control surface for mirror.
Phase 01, tasks 01-04: one focus-aware keydown router plus a keyboard cursor
over the tiles (j/k/Up/Down, Esc), the wall commands (Enter/f, g, n/p, 1-9,
m), the ``?`` help overlay, the focus-visible affordances — no control is
reachable *only* by hover — and the /mirror keys (s, x, Esc). Every command
here is driven with ``page.keyboard.press()`` and asserted on the DOM state
the key changes — ``data-selected``, focus, the ``staged`` class, the
``muted`` flag, computed styles — never on whether a handler "ran".
The suite is pointer-free: a test that reaches for the pointer proves nothing
about a kiosk. The meta-test at the bottom enforces that on this file
itself, so the guarantee cannot rot.
"""
from __future__ import annotations
import os
import re
import socket
import subprocess
import sys
import time
from pathlib import Path
import pytest
from playwright.sync_api import expect
from .conftest import start_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: print pageerror/console from the first line of the test).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context = browser.new_context(viewport={"width": 1100, "height": 720})
opened.append(context)
request.addfinalizer(context.close)
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 page
return _open
def _non_loopback_ipv4() -> str | None:
"""The address a *neighbour* would use to reach this machine, or None.
A UDP connect sends no packet; it only resolves the default route, which
is exactly the interface a LAN device would see.
"""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
try:
probe.connect(("8.8.8.8", 80))
except OSError:
return None
address = probe.getsockname()[0]
return address if address != "127.0.0.1" else None
@pytest.fixture
def insecure_base_url():
"""A `mirror serve` on a plain-HTTP non-localhost address.
That is a non-secure context: the browser hides `navigator.mediaDevices`
entirely — the exact trap /mirror documents for a bare `mirror serve` on a
LAN IP (README, docs/kiosk-silverblue.md "One trap worth knowing").
"""
host = _non_loopback_ipv4()
if host is None:
pytest.skip("no non-loopback interface: the non-secure-context trap is untestable here")
import httpx
from .conftest import _free_port
port = _free_port()
process = subprocess.Popen(
[
sys.executable,
"-m",
"mirror",
"serve",
"--host",
"0.0.0.0",
"--port",
str(port),
"--log-level",
"warning",
],
cwd=Path(__file__).resolve().parents[2],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env={
**os.environ,
"MIRROR_ICE_SERVERS": "",
"PYTHONUNBUFFERED": "1",
"DEBUGPY": "0",
},
)
url = f"http://{host}:{port}"
last_error: Exception | None = None
deadline = time.time() + 25
while time.time() < deadline:
if process.poll() is not None:
output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"mirror serve exited early ({process.returncode}):\n{output}")
try:
if httpx.get(f"{url}/healthz", timeout=1.0).status_code == 200:
break
except Exception as exc: # still booting
last_error = exc
time.sleep(0.15)
else:
process.terminate()
raise RuntimeError(f"mirror serve never came up: {last_error}")
yield url
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired: # pragma: no cover
process.kill()
def tile_ids(page) -> list[str]:
return page.locator(".tile").evaluate_all("els => els.map(e => e.dataset.stream)")
def selected_id(page) -> str | None:
return page.evaluate(
"() => { const el = document.querySelector('.tile[data-selected]');"
" return el ? el.dataset.stream : null; }"
)
def focus_follows_selection(page) -> bool:
return page.evaluate(
"() => { const el = document.querySelector('.tile[data-selected]');"
" return el !== null && document.activeElement === el; }"
)
class TestKeyboardCursor:
def _two_mirror_wall(self, device, errors: list) -> tuple:
for _ in range(2):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
return viewer, tile_ids(viewer)
def test_j_selects_the_first_tile_and_focus_follows(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
assert selected_id(viewer) == ids[0]
assert focus_follows_selection(viewer), "focus must follow the selection"
assert errors == [], f"uncaught browser errors: {errors}"
def test_j_and_k_wrap_at_the_ends(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
assert selected_id(viewer) == ids[0]
# k from the first wraps to the last.
viewer.keyboard.press("k")
assert selected_id(viewer) == ids[1]
# j from the last wraps back to the first.
viewer.keyboard.press("j")
assert selected_id(viewer) == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
def test_arrow_keys_drive_the_same_cursor(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("ArrowDown")
assert selected_id(viewer) == ids[0]
viewer.keyboard.press("ArrowDown")
assert selected_id(viewer) == ids[1]
viewer.keyboard.press("ArrowUp")
assert selected_id(viewer) == ids[0]
# The cursor must not fight real scrolling: the router suppresses the
# arrow's default while it handles the key.
viewer.keyboard.press("ArrowUp")
assert selected_id(viewer) == ids[1]
assert errors == [], f"uncaught browser errors: {errors}"
def test_ctrl_chords_never_run_a_command(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
assert selected_id(viewer) == ids[0]
# Synthesised ctrlKey: true — the browser's chords are never ours.
viewer.keyboard.press("Control+j")
assert selected_id(viewer) == ids[0]
viewer.keyboard.press("Control+k")
assert selected_id(viewer) == ids[0]
viewer.keyboard.press("Control+ArrowDown")
assert selected_id(viewer) == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
def test_shifted_letters_drive_the_same_commands(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
# Keys are matched case-insensitively: a caps-locked kiosk keyboard, or
# a remote hand holding Shift, must work exactly the same.
viewer.keyboard.press("Shift+J")
assert selected_id(viewer) == ids[0]
# Shift+F stages through the same command as f.
viewer.keyboard.press("Shift+F")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
class TestEscape:
def test_escape_leaves_the_stage(self, device):
errors: list = []
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
# Staged the way the kiosk stages — select, then Enter — because this
# suite is pointer-free; Esc is what the kiosk uses to leave the stage.
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
viewer.keyboard.press("Escape")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
# Leaving the stage must not have disturbed the stream.
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_escape_without_a_stage_is_a_noop(self, device):
errors: list = []
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
viewer.keyboard.press("Escape")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
expect(viewer.locator(".tile")).to_have_count(1)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
class TestWallCommands:
"""Task 02: Enter/f, g, n/p, 1-9, m on /watch.
Every command is driven with ``page.keyboard.press()`` and asserted on the
DOM state the key changes — the staged tile id, the ``staged`` class, the
video's ``muted`` flag, the ``#toast`` — never on whether a handler "ran".
"""
def _two_mirror_wall(self, device, errors: list) -> tuple:
for _ in range(2):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
return viewer, tile_ids(viewer)
def test_enter_stages_the_selected_mirror(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
def test_enter_with_nothing_selected_stages_the_first_tile(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
def test_f_stages_when_unstaged_and_holds_the_stage_when_staged(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
# Unstaged: `f` stages like Enter (first tile, nothing selected).
viewer.keyboard.press("f")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
# Staged: `f` re-requests fullscreen. A kiosk's window manager can drop
# the window out of fullscreen without the page noticing, so the
# command must never tear the stage down — headless or not.
viewer.keyboard.press("f")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
assert errors == [], f"uncaught browser errors: {errors}"
def test_g_returns_to_the_wall(self, device):
errors: list = []
viewer, _ = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
viewer.keyboard.press("g")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(0)
expect(viewer.locator(".tile")).to_have_count(2)
# g on an already-unstaged wall is a silent no-op: the wall *is* the
# visible result "back to the wall".
viewer.keyboard.press("g")
expect(viewer.locator(".tile")).to_have_count(2)
expect(viewer.locator("#toast")).not_to_be_visible()
# Leaving the stage must not have disturbed the streams.
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_n_and_p_step_between_mirrors_without_killing_the_peer(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
a, b = ids[0], ids[1]
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=30_000)
# Stage A by keyboard.
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == a
# Mark A's <video> node: staging B must *move* it back to the grid,
# never recreate it — the element moves, srcObject is never reassigned.
assert viewer.evaluate(
"""(id) => {
const el = document.querySelector('#watch-stage video');
el.dataset.e2e = "same-node";
return el.srcObject !== null;
}""",
a,
), "A's video must hold a live stream before the switch"
viewer.keyboard.press("n")
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == b
assert viewer.evaluate(
"""(id) => {
const el = document.querySelector(`.tile[data-stream="${id}"] video`);
return el !== null && el.dataset.e2e === "same-node" && el.srcObject !== null;
}""",
a,
), "A's <video> must be the same node, still holding its stream"
# p wraps back to A — and the stepping must not have killed either mirror.
viewer.keyboard.press("p")
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == a
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_digit_jump_stages_the_nth_mirror(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
viewer.keyboard.press("2")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(1)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[1]
# 1 takes the first mirror back on stage; stage() unstages the other.
viewer.keyboard.press("1")
expect(viewer.locator("#watch-stage .tile")).to_have_count(1, timeout=5_000)
assert viewer.locator("#watch-stage .tile").get_attribute("data-stream") == ids[0]
# The wall survives the jump: both mirrors still live.
expect(viewer.locator(".tile[data-state='live']")).to_have_count(2, timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_m_flips_the_staged_videos_muted_flag(self, device):
errors: list = []
viewer, _ = self._two_mirror_wall(device, errors)
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("#watch-stage video")).to_have_count(1, timeout=5_000)
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
viewer.keyboard.press("m")
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
assert after == (not before), f"m must flip muted ({before} -> {after})"
# The tile's audio button tracks the same state through setMuted.
assert (
viewer.locator("#watch-stage .audio").get_attribute("aria-pressed")
== str(not after).lower()
), "aria-pressed must follow the muted flag"
# And back again.
viewer.keyboard.press("m")
again = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
assert again == before, f"m must flip back ({after} -> {again})"
assert errors == [], f"uncaught browser errors: {errors}"
def test_missing_mirror_number_toasts_instead_of_silence(self, device):
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
viewer.keyboard.press("5")
# The wall is unchanged…
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
expect(viewer.locator(".tile")).to_have_count(1)
# …and the no-op says why — a silent no-op is undiagnosable from a kiosk.
expect(viewer.locator("#toast")).to_be_visible()
expect(viewer.locator("#toast")).to_contain_text("no mirror 5")
assert errors == [], f"uncaught browser errors: {errors}"
def test_commands_on_an_empty_wall_toast_the_reason(self, device):
errors: list = []
viewer = device("/watch", errors)
expect(viewer.locator("#empty")).to_be_visible()
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
expect(viewer.locator("#toast")).to_be_visible()
expect(viewer.locator("#toast")).to_contain_text("no mirrors to stage")
viewer.keyboard.press("n")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"))
expect(viewer.locator("#toast")).to_contain_text("no mirrors to switch to")
assert errors == [], f"uncaught browser errors: {errors}"
class TestFocusVisibleControls:
"""Task 03: no control is reachable *only* by hover.
Everything here is keyboard-only: ``Tab`` moves focus, and the assertions
are on what the kiosk *sees* — computed opacity, the focus ring,
``data-selected`` — never on whether a handler "ran". No test in this class
drives the pointer.
"""
def _one_mirror_wall(self, device, errors: list):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
return viewer
def _two_mirror_wall(self, device, errors: list) -> tuple:
for _ in range(2):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
return viewer, tile_ids(viewer)
@staticmethod
def _tab_to_a_tile(viewer, attempts: int = 12) -> str | None:
"""Tab from the top of the document until focus lands on a tile."""
for _ in range(attempts):
viewer.keyboard.press("Tab")
tile = viewer.evaluate(
"() => { const el = document.activeElement;"
" return el && el.classList.contains('tile') ? el.dataset.stream : null; }"
)
if tile:
return tile
return None
def test_tab_focus_reveals_the_tile_controls(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
assert self._tab_to_a_tile(viewer), "Tab must reach the tile without a pointer"
# The opacity transition is 150 ms; wait for the settled value.
viewer.wait_for_function(
"() => { const el = document.activeElement;"
" return el && el.classList.contains('tile')"
" && getComputedStyle(el.querySelector('.actions')).opacity === '1'; }",
timeout=5_000,
)
assert errors == [], f"uncaught browser errors: {errors}"
def test_keyboard_focus_draws_a_visible_ring(self, device, tmp_path):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
assert self._tab_to_a_tile(viewer)
ring = viewer.evaluate(
"() => { const s = getComputedStyle(document.activeElement);"
" return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }; }"
)
assert ring["style"] == "solid", f"the focus ring must be visible: {ring}"
assert ring["width"] == "2px", ring
assert ring["color"] != "rgba(0, 0, 0, 0)", f"the ring must not be transparent: {ring}"
# Screenshot artifact: a kiosk is debugged from another machine, so a
# focused tile must be legible in a still frame.
shot = tmp_path / "focused_tile.png"
viewer.screenshot(path=str(shot))
assert shot.stat().st_size > 0
assert errors == [], f"uncaught browser errors: {errors}"
def test_tab_focus_moves_the_selection_with_it(self, device):
errors: list = []
viewer, ids = self._two_mirror_wall(device, errors)
seen: list[str] = []
for _ in range(16):
viewer.keyboard.press("Tab")
on_tile = viewer.evaluate(
"() => document.activeElement !== null"
" && document.activeElement.classList.contains('tile')"
)
if on_tile:
seen.append(selected_id(viewer))
# While a tile itself holds focus, data-selected must be that tile.
assert viewer.evaluate(
"() => document.activeElement === document.querySelector"
"('.tile[data-selected]')"
), "the selected tile must be the focused tile"
if len(seen) == 2:
break
assert seen == ids, f"Tab must walk the grid in order {ids}; walked {seen}"
assert errors == [], f"uncaught browser errors: {errors}"
def test_tab_to_a_tile_button_is_not_stolen_back_to_the_tile(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
assert self._tab_to_a_tile(viewer)
# The selected tile carries its controls, so the next Tab reaches a
# control *inside* the tile — and must stay there: the focusin
# handler must not pull focus back off the control the operator is on.
viewer.keyboard.press("Tab")
assert viewer.evaluate(
"() => document.activeElement !== null"
" && document.activeElement.closest('.actions') !== null"
), "Tab must be able to land on a control inside the tile"
assert errors == [], f"uncaught browser errors: {errors}"
def test_the_wall_stays_quiet_until_something_is_focused(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
# Nothing hovered, focused or selected: the controls stay hidden, so a
# shared display keeps its quiet look.
opacity = viewer.evaluate(
"() => getComputedStyle(document.querySelector('.tile .actions')).opacity"
)
assert opacity == "0", (
f"the wall must stay quiet until a tile is focused or selected (got {opacity})"
)
assert errors == [], f"uncaught browser errors: {errors}"
def test_focus_moves_to_the_stage_and_back_with_g(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
assert viewer.evaluate(
"() => document.activeElement !== null"
" && document.activeElement.parentElement.id === 'watch-stage'"
), "staging must hand focus to the staged tile"
# The staged caption is legible while the tile holds focus (the
# opacity transition is 200 ms; wait for the settled value).
viewer.wait_for_function(
"() => document.activeElement !== null"
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'",
timeout=5_000,
)
# m still works with focus sitting on the stage.
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
viewer.keyboard.press("m")
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
assert after == (not before), f"m must flip muted on the staged tile ({before} -> {after})"
# g returns to the wall and hands focus back to the tile's grid slot.
# The hand-back is deferred until the fullscreen exit has settled
# (the exit would otherwise drop the focus back to <body>), so wait
# for it rather than asserting on the keypress alone.
viewer.keyboard.press("g")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
viewer.wait_for_function(
"() => { const el = document.activeElement;"
" return el !== null && el.classList.contains('tile')"
" && el.parentElement.id === 'grid'"
" && el === document.querySelector('.tile[data-selected]'); }",
timeout=5_000,
)
assert errors == [], f"uncaught browser errors: {errors}"
def test_escape_after_staging_returns_focus_to_the_grid(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
viewer.keyboard.press("Escape")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
viewer.wait_for_function(
"() => { const el = document.activeElement;"
" return el !== null && el.classList.contains('tile')"
" && el.parentElement.id === 'grid'; }",
timeout=5_000,
)
assert errors == [], f"uncaught browser errors: {errors}"
class TestHelpOverlay:
"""Task 04: `?` shows the key list; `?`/`Esc` close it; focus never moves.
The overlay is the element ``watch.js`` reaches for (``#wall-help``) and it
exists in ``watch.html`` (the element-existence guard enforces the pairing)
— asserted here on the DOM state, as always, never on whether a handler
"ran".
"""
def _one_mirror_wall(self, device, errors: list):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
return viewer
def test_question_mark_opens_the_key_list(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
expect(viewer.locator("#wall-help")).to_be_hidden()
viewer.keyboard.press("?")
expect(viewer.locator("#wall-help")).to_be_visible()
# The list is the locked key map from 00_phase.md — a kiosk operator
# can read back every command the wall accepts.
for text in (
"next mirror",
"previous mirror",
"Stage the selected mirror fullscreen",
"Back to the wall",
"Stage the next / previous mirror",
"Stage the Nth mirror",
"Mute / unmute the staged mirror",
"Close this list, then leave the stage",
):
expect(viewer.locator("#wall-help")).to_contain_text(text)
assert errors == [], f"uncaught browser errors: {errors}"
def test_question_mark_closes_the_key_list(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("?")
expect(viewer.locator("#wall-help")).to_be_visible()
viewer.keyboard.press("?")
expect(viewer.locator("#wall-help")).to_be_hidden()
assert errors == [], f"uncaught browser errors: {errors}"
def test_the_list_says_how_to_close_it(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("?")
expect(viewer.locator("#wall-help .hint")).to_contain_text("to close this list")
assert errors == [], f"uncaught browser errors: {errors}"
def test_escape_closes_the_overlay_before_it_unstages(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("j")
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
# ? over the stage: the overlay sits above the stage (z-index 70 over
# 50), so it is readable with a mirror on stage.
viewer.keyboard.press("?")
expect(viewer.locator("#wall-help")).to_be_visible()
# First Esc: the overlay closes, the stage stays.
viewer.keyboard.press("Escape")
expect(viewer.locator("#wall-help")).to_be_hidden()
expect(viewer.locator("body")).to_have_class(re.compile("staged"))
# Second Esc: the stage goes.
viewer.keyboard.press("Escape")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator("#watch-stage .tile")).to_have_count(0)
assert errors == [], f"uncaught browser errors: {errors}"
def test_focus_stays_on_the_selected_tile_through_the_overlay(self, device):
errors: list = []
viewer = self._one_mirror_wall(device, errors)
viewer.keyboard.press("j")
assert viewer.evaluate(
"() => document.activeElement !== null"
" && document.activeElement.classList.contains('tile')"
), "the selected tile must hold focus before the overlay"
for key in ("?", "?", "Escape"):
viewer.keyboard.press(key)
assert viewer.evaluate(
"() => document.activeElement !== null"
" && document.activeElement.classList.contains('tile')"
), f"focus must stay on the tile after {key!r}"
assert errors == [], f"uncaught browser errors: {errors}"
class TestSharePageKeys:
"""Task 04: `s`/`x`/`Esc` on /mirror, and the no-silent-failure rule.
The sharer here is driven by the keyboard exactly as a kiosk operator would
drive it; ``start_sharing`` (a pointer helper on conftest) is used only to
stand up *wall* mirrors, never to start a share under test.
"""
def test_s_starts_a_share_and_x_stops_it(self, device):
errors: list = []
page = device("/mirror", errors)
expect(page.locator("body")).to_have_attribute("data-live", "off")
# A keydown is a user activation, so the fake UI approves the OS picker
# and the real capture -> preview path runs.
page.keyboard.press("s")
expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000)
page.keyboard.press("x")
expect(page.locator("body")).to_have_attribute("data-live", "off", timeout=10_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_s_while_sharing_toasts_instead_of_opening_a_second_picker(self, device):
errors: list = []
page = device("/mirror", errors)
page.keyboard.press("s")
expect(page.locator("body")).to_have_attribute("data-live", "on", timeout=15_000)
page.keyboard.press("s")
expect(page.locator("#toast")).to_be_visible()
expect(page.locator("#toast")).to_contain_text("already sharing")
assert errors == [], f"uncaught browser errors: {errors}"
def test_x_while_idle_toasts_instead_of_silence(self, device):
errors: list = []
page = device("/mirror", errors)
page.keyboard.press("x")
expect(page.locator("#toast")).to_be_visible()
expect(page.locator("#toast")).to_contain_text("not sharing")
expect(page.locator("body")).to_have_attribute("data-live", "off")
assert errors == [], f"uncaught browser errors: {errors}"
def test_s_and_x_type_text_in_the_name_field_instead(self, device):
errors: list = []
page = device("/mirror", errors)
page.locator("#name").focus()
page.keyboard.press("s")
expect(page.locator("#name")).to_have_value("s")
page.keyboard.press("x")
expect(page.locator("#name")).to_have_value("sx")
# A key in the display-name field is text, not a command: no share
# started, no share stopped, no picker.
expect(page.locator("body")).to_have_attribute("data-live", "off")
assert errors == [], f"uncaught browser errors: {errors}"
def test_ctrl_chords_never_share(self, device):
errors: list = []
page = device("/mirror", errors)
page.keyboard.press("Control+s")
expect(page.locator("body")).to_have_attribute("data-live", "off")
expect(page.locator("#toast")).not_to_be_visible()
assert errors == [], f"uncaught browser errors: {errors}"
def test_s_says_why_when_capturing_is_unavailable(self, browser, insecure_base_url):
"""The no-silent-failure path: `#start` disabled must not make `s` die quietly.
A plain-HTTP non-localhost origin is a non-secure context, so the
browser hides mediaDevices entirely and the page disables `#start` on
load. Pressing `s` routes through the button's own path (start() re-
checks captureSupport() and re-shows the notice) instead of failing
silently.
"""
errors: list = []
context = browser.new_context(viewport={"width": 1100, "height": 720})
page = context.new_page()
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"{insecure_base_url}/mirror", wait_until="domcontentloaded")
# The premise: this really is the trap — the browser has hidden the
# media API, exactly as it does on a bare-LAN-IP deployment.
assert page.evaluate("() => window.isSecureContext") is False
assert page.evaluate("() => navigator.mediaDevices === undefined")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
# The page says so and disables the button on load…
expect(page.locator("#notice")).to_be_visible()
expect(page.locator("#notice")).to_contain_text("secure context")
expect(page.locator("#start")).to_be_disabled()
# …and `s` does not fail silently: the notice stays up, nothing starts.
page.keyboard.press("s")
expect(page.locator("#notice")).to_be_visible()
expect(page.locator("#notice")).to_contain_text("secure context")
expect(page.locator("body")).to_have_attribute("data-live", "off")
assert errors == [], f"uncaught browser errors: {errors}"
context.close()
class TestSuiteIsPointerFree:
"""The kiosk guarantee, enforced on the file itself.
A test that reaches for the pointer proves nothing about a kiosk. The
guarantee lives in the code, not in reviewer attention, so this meta-test
bans the pointer calls on this file and fails the suite the moment one
slips back in.
"""
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 keyboard suite"
assert ("hover" + "(") not in source, "a pointer call slipped into the keyboard suite"
+32
View File
@@ -179,6 +179,38 @@ class TestStylesheet:
]
assert offenders == []
def test_a_hidden_control_is_not_revealed_only_by_hover(self):
"""Kiosk rule: a control hidden by default must be reachable without a pointer.
The tile's controls (`.actions`) start at `opacity: 0`. If the only rule
that raises them to full opacity were a `:hover` rule, a mouseless kiosk
would have no mute/expand controls at all. So while any `.actions` rule
hides them, the stylesheet must also contain a `.actions` reveal rule
with a non-hover trigger — keyboard focus or the selection state.
"""
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
actions_rules = [
(selector.strip(), body)
for selector, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)
if ".actions" in selector
]
hiding = [
selector
for selector, body in actions_rules
if re.search(r"opacity\s*:\s*0(?![0-9])", body)
]
non_hover_reveals = [
selector
for selector, body in actions_rules
if re.search(r"opacity\s*:\s*1(?![0-9])", body)
and re.search(r":focus-visible|:focus-within|data-selected", selector)
]
if hiding:
assert non_hover_reveals, (
f".actions starts hidden ({sorted(hiding)}) but no rule reveals it "
"without a pointer — a mouseless kiosk would have no tile controls"
)
class TestAddressGate:
"""mirror has no accounts; the address is the access control."""