From d0077e0db4a486bd5d8c2fdfbb957edd25e503e3 Mon Sep 17 00:00:00 2001
From: ducoterra
Date: Tue, 3 Nov 2026 23:50:43 -0500
Subject: [PATCH] phase: 09_wall_remote_page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
**Next pending phase** — none; `todo/` held only phase 09.
---
.../09_wall_remote_page/00_phase.md | 0
.../01_remote_route_and_page.md | 0
.../02_remote_controller_logic.md | 0
.../03_remote_e2e_docs_and_kiosk_url.md | 0
.../09_wall_remote_page__00_phase.a1.err | 0
.../09_wall_remote_page__00_phase.a1.md | 11 +
...09_wall_remote_page__00_phase.a1.md.status | 1 +
.../09_wall_remote_page__00_phase.a1.validate | 37 ++
...mote_page__01_remote_route_and_page.a1.err | 0
...emote_page__01_remote_route_and_page.a1.md | 9 +
...age__01_remote_route_and_page.a1.md.status | 1 +
...page__01_remote_route_and_page.a1.validate | 36 ++
...te_page__02_remote_controller_logic.a1.err | 0
...ote_page__02_remote_controller_logic.a1.md | 14 +
...e__02_remote_controller_logic.a1.md.status | 1 +
...ge__02_remote_controller_logic.a1.validate | 36 ++
...e__03_remote_e2e_docs_and_kiosk_url.a1.err | 0
...ge__03_remote_e2e_docs_and_kiosk_url.a1.md | 12 +
...remote_e2e_docs_and_kiosk_url.a1.md.status | 1 +
..._remote_e2e_docs_and_kiosk_url.a1.validate | 37 ++
AGENTS.md | 2 +-
README.md | 22 +-
SECURITY.md | 16 +-
docs/kiosk-silverblue.md | 35 +-
docs/kiosk-silverblue/check-kiosk.sh | 8 +-
docs/kiosk-silverblue/gnome-kiosk-script | 2 +-
docs/kiosk-silverblue/install-kiosk.sh | 5 +-
docs/kiosk-silverblue/kiosk.conf | 9 +-
src/mirror/app.py | 9 +-
src/mirror/static/app.css | 96 ++++
src/mirror/static/index.html | 8 +
src/mirror/static/remote.html | 81 ++++
src/mirror/static/remote.js | 308 +++++++++++++
src/mirror/static/watch.html | 1 +
tests/e2e/test_remote_control.py | 423 ++++++++++++++++++
tests/test_app.py | 176 +++++++-
36 files changed, 1354 insertions(+), 43 deletions(-)
rename .agents/phases/{todo => complete}/09_wall_remote_page/00_phase.md (100%)
rename .agents/phases/{todo => complete}/09_wall_remote_page/01_remote_route_and_page.md (100%)
rename .agents/phases/{todo => complete}/09_wall_remote_page/02_remote_controller_logic.md (100%)
rename .agents/phases/{todo => complete}/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md (100%)
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.err
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.err
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.err
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.err
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status
create mode 100644 .agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate
create mode 100644 src/mirror/static/remote.html
create mode 100644 src/mirror/static/remote.js
create mode 100644 tests/e2e/test_remote_control.py
diff --git a/.agents/phases/todo/09_wall_remote_page/00_phase.md b/.agents/phases/complete/09_wall_remote_page/00_phase.md
similarity index 100%
rename from .agents/phases/todo/09_wall_remote_page/00_phase.md
rename to .agents/phases/complete/09_wall_remote_page/00_phase.md
diff --git a/.agents/phases/todo/09_wall_remote_page/01_remote_route_and_page.md b/.agents/phases/complete/09_wall_remote_page/01_remote_route_and_page.md
similarity index 100%
rename from .agents/phases/todo/09_wall_remote_page/01_remote_route_and_page.md
rename to .agents/phases/complete/09_wall_remote_page/01_remote_route_and_page.md
diff --git a/.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md b/.agents/phases/complete/09_wall_remote_page/02_remote_controller_logic.md
similarity index 100%
rename from .agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md
rename to .agents/phases/complete/09_wall_remote_page/02_remote_controller_logic.md
diff --git a/.agents/phases/todo/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md b/.agents/phases/complete/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md
similarity index 100%
rename from .agents/phases/todo/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md
rename to .agents/phases/complete/09_wall_remote_page/03_remote_e2e_docs_and_kiosk_url.md
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md
new file mode 100644
index 0000000..43fff5f
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md
@@ -0,0 +1,11 @@
+**Phase 09 — final verification pass (all 3 tasks already in `complete/`; phase work was in the working tree, verified, nothing needed fixing)**
+
+**Gates** — `uv run ruff check . && uv run ruff format --check .` → clean; `uv run pyright` → 0 errors; `uv run pytest` → **360 passed, 5 skipped** (only the realdisplay rung), coverage 96% total / 95% app.py; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed, 1 skipped (documented host limitation: no audio source on the real X11 path); `uv run pytest tests/e2e/test_remote_control.py` → 8/8 passed; `node --check` on every `static/*.js` → all pass.
+
+**Completion criteria** — all met: (1) "Show on wall" stages on the display, asserted on display DOM + `/api/kiosk` ✓; (2) Back/Fullscreen + untouched second controller converge (TestConvergence) ✓; (3) `/api/streams` reports `viewers: 0` / `watching: 0` with only `/remote` open (TestACostsNothing) ✓; (4) followers count displays only — 0/1/1 with a second controller (TestFollowers) ✓; (5) controller works with no display: `followers: 0`, state stored (protocol test + e2e) ✓; (6) e2e + full `-m e2e` + realdisplay all green ✓; (7) README + SECURITY.md updated, Silverblue default now `/kiosk` (installer, conf, gnome script, checker) ✓; (8) suite 100% green, coverage >90% on new code ✓; (9) PLAN.md untouched; AGENTS.md changed only for a stale statement ✓.
+
+**Documentation** — README.md reviewed in full against code (page table, quickstart, env-var table vs `config.py`/`debug.py`, presets, keys, deployment, kiosk section): no inconsistencies found. AGENTS.md: fixed the one stale statement introduced by this phase ("Four pages" → "Five pages"); no other staleness, nothing added.
+
+**Decisions** — no repo changes were required; the one realdisplay skip is a pre-documented environment fact, not a failure.
+
+**Next pending phase** — none; `todo/` held only phase 09.
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate
new file mode 100644
index 0000000..9a2e518
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__00_phase.a1.validate
@@ -0,0 +1,37 @@
+........................................................................ [ 19%]
+..............................................sssss..................... [ 39%]
+........................................................................ [ 59%]
+........................................................................ [ 78%]
+........................................................................ [ 98%]
+..... [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 197 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 709 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
+360 passed, 5 skipped, 1 warning in 102.18s (0:01:42)
+All checks passed!
+34 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md
new file mode 100644
index 0000000..32dc003
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md
@@ -0,0 +1,9 @@
+All gates green. Task complete.
+
+**Report**
+
+- **Implemented:** `GET /remote` route in `src/mirror/app.py` (+ `PAGES` no-store); `static/remote.html` (bar + net pill, readout `#remote-state` with stable hooks, `#remote-back`/`#remote-fullscreen` controls, `#remote-list` + row template, empty state with share URL); `static/remote.js` stub (config, refusal notice, socket hello as plain `role:"viewer"` with no `kiosk` flag, net pill to online); `app.css` `.remote` styles (mono readout strip, wrapping control row, 320 px-safe rows, `#remote-fullscreen[aria-pressed="true"]` accent = tile audio button treatment); entry links in `watch.html` (`#remote-link`) and `index.html` (4th card); `/kiosk` untouched; `test_app.py` guards extended to the fourth page (render/no-store/asset/node-check/element-exists/stylesheet-module/id-collision over 5 pages with `{"net","notice","toast"}` as the only overlap, address gate, plus new `test_a_controller_is_media_free` and `test_the_remote_page_declares_its_control_hooks`).
+- **Results:** `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — 349 passed, 5 skipped (realdisplay opt-in), 100% green; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 4 passed, 1 documented platform skip; `uv run pytest tests/test_app.py` — 104 passed; coverage 95% overall, new route fully covered. Live checks: `curl -s/-I localhost:8055/remote` serves the page with `cache-control: no-store`; browser at 320 px: net pill online, empty state + share URL, zero console errors, both buttons reachable, no horizontal scroll.
+- **Docs:** README — `/remote` row in the page table + quickstart URL line (SECURITY.md/full controller docs left to task 03 per the phase plan). AGENTS.md — one stale fact only: "Four pages" → "Five pages".
+- **Deviation:** the task text names both the readout marker span and the Full screen button `remote-fullscreen` (a duplicate id that would fail the page's own uniqueness guard). The button keeps `#remote-fullscreen` (the `aria-pressed` CSS rule and the task's guard target it); the readout marker is `#remote-fullscreen-mark`, noted in the markup and asserted by the new hook guard.
+- **Next pending task:** `.agents/phases/todo/09_wall_remote_page/02_remote_controller_logic.md`
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate
new file mode 100644
index 0000000..93530b9
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__01_remote_route_and_page.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 20%]
+......................................sssss............................. [ 40%]
+........................................................................ [ 61%]
+........................................................................ [ 81%]
+.................................................................. [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 197 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 709 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
+349 passed, 5 skipped, 1 warning in 95.96s (0:01:35)
+All checks passed!
+33 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md
new file mode 100644
index 0000000..ec11f1c
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md
@@ -0,0 +1,14 @@
+All green. Final report:
+
+**Implemented (task 02 — `remote.js` controller logic):**
+- `src/mirror/static/remote.js`: `mirrors` Map + `layout` state; roster frames (`welcome` seeds, added/updated upsert, removed deletes, `signal` ignored); `renderMirrors()` (template rows, `data-on`/`aria-current`/`On the wall`, empty state); `renderState()` (staged title/`(gone)`/`no mirror`, fullscreen on/off on marker + button `aria-pressed`/label, followers plural handling); one-line hint (`no display following` / `offline`); `arrange()` writers with event delegation on `#remote-list`; one-time "…ended — back to the wall" toast; stale readout on socket close, re-sync on `welcome`.
+- `remote.html`: added `#remote-hint`; `app.css`: `.remote-hint` + dimmed `[data-stale]` readout.
+- `tests/test_app.py`: media-free guard extended (`MediaStream`, `navigator.mediaDevices`), new `test_a_controller_sends_exactly_one_frame_type_besides_hello`, hook list gains `remote-hint`, 2 protocol tests (controller drives layout, not counted in `followers`; `arrange` changes no subscriptions/audience).
+
+**Gates (all 4, exact commands):** ruff check+format clean · pyright 0 errors · `uv run pytest` **352 passed, 5 skipped** (documented realdisplay skips) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **4 passed, 1 documented skip** · coverage 95%, byte-identical to verified baseline (no `src/` Python touched).
+
+**Docs:** no impact — README already describes `/remote` accurately (task 01); deep docs are task 03's scope; no stale AGENTS.md statements.
+
+**Notable:** spec's "three spans" named `#remote-fullscreen`, but task 01's built HTML (locked contract, asserted in tests) uses `#remote-fullscreen-mark` for the readout span and `#remote-fullscreen` for the button — followed the built HTML (marker carries on/off, button carries label/aria). Pre-existing, verified in a reconstructed baseline: the non-gate combined run `MIRROR_REALDISPLAY=1 uv run pytest` errors 5 x11 fixture setups (Playwright sync-API double context); both official invocations are green, left untouched.
+
+**Next pending task:** `03_remote_e2e_docs_and_kiosk_url.md`.
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate
new file mode 100644
index 0000000..62aed55
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__02_remote_controller_logic.a1.validate
@@ -0,0 +1,36 @@
+........................................................................ [ 20%]
+......................................sssss............................. [ 40%]
+........................................................................ [ 60%]
+........................................................................ [ 80%]
+..................................................................... [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 197 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 709 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
+352 passed, 5 skipped, 1 warning in 96.76s (0:01:36)
+All checks passed!
+33 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.err b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md
new file mode 100644
index 0000000..59735d4
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md
@@ -0,0 +1,12 @@
+All changes verified. The AGENTS.md diff ("Five pages") was from task 01 — I left AGENTS.md untouched (no stale statement in it). Everything is complete and green.
+
+**Report**
+
+- **New `tests/e2e/test_remote_control.py`** (8 tests, `e2e` + `timeout(180)`, same `device()`/`open_device` setup as the kiosk suite): the click loop (`.show` → display `data-layout="stage"` + staged tile + `/api/kiosk`, `#remote-back` → wall), pixels-not-classes (`frames_matching` width>0, distinct≥2 on the display), fullscreen (server state + `body[data-fullscreen]`, `document.fullscreenElement` never asserted, comment says why), two-controller convergence, controller-costs-nothing (stream `viewers: 0` / `stats.watching: 0` with only `/remote`, moves when `/watch` joins), followers agreement (0/1/1 — a controller is not a display), kiosk `n` keypress moving the remote readout, teardown (display unstages, row/readout clear), `errors == []` in every case.
+- **Fix found while testing**: the kiosk suite leaves `fullscreen: true` on the shared session server (teardown resets `staged` only), which broke the toggle-based fullscreen test; the test now normalizes the shared state through the product at start (documented in the test).
+- **SECURITY.md**: §2 gains the "rearrange anybody's wall display" bullet (names `/remote`, same trust decision as watching, bounded by `MIRROR_ALLOW_CIDRS`) and the not-possible audio bullet; §3 gains the "No remote audio" row naming the real enforcing tests.
+- **README**: new "Driving an unattended wall" section (keys on the box visible to controllers, fullscreen requested-not-promised, in-memory state); Kiosk-client paragraph updated for the new default. Page table/Quickstart already had both endpoints.
+- **Silverblue tree → `/kiosk` in one change**: `install-kiosk.sh` default + comment, `kiosk.conf` URL + comment, `gnome-kiosk-script` fallback, `check-kiosk.sh` (accepts both walls, warning kept for the third case), `kiosk-silverblue.md` (top description, `KIOSK_URL` example, controller/migration/title paragraphs de-staled). `sh -n` clean on all scripts; checker logic verified via harness: `/kiosk` and `/watch` → ok, other → warn.
+- **Gates**: `uv run pytest tests/e2e/test_remote_control.py` → 8 passed; `uv run pytest -m e2e` → 118 passed; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 4 passed (1 documented platform skip); full `uv run pytest` → **360 passed, 5 skipped, 0 failed**; `ruff check`/`ruff format --check`/`pyright` all clean.
+- **Note**: one one-off `test_kiosk_layout.py` tile-id flake in a single full run (not reproduced in two subsequent full runs; my suite runs after it in file order, so it cannot be the cause). No doc impact on AGENTS.md.
+- **Next pending task**: none — task 03 is the last task of phase 09.
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate
new file mode 100644
index 0000000..96e3a3e
--- /dev/null
+++ b/.agents/reports/09_wall_remote_page/09_wall_remote_page__03_remote_e2e_docs_and_kiosk_url.a1.validate
@@ -0,0 +1,37 @@
+........................................................................ [ 19%]
+..............................................sssss..................... [ 39%]
+........................................................................ [ 59%]
+........................................................................ [ 78%]
+........................................................................ [ 98%]
+..... [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 197 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 709 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
+360 passed, 5 skipped, 1 warning in 102.09s (0:01:42)
+All checks passed!
+34 files already formatted
+0 errors, 0 warnings, 0 informations
diff --git a/AGENTS.md b/AGENTS.md
index bef2415..c1782f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,7 +41,7 @@ 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::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 |
+| **No framework, no bundler** in `src/mirror/static/` | Five 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 |
diff --git a/README.md b/README.md
index d19a9bb..c24a3e2 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,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. |
+| **Remote** | `/remote` | A controller for the shared wall, from a laptop or a phone: lists the live mirrors and the wall's state and sends the decisions (show this, back to the wall, full screen) — no video, no bandwidth. |
| **Index** | `/` | Signpost + live count of mirrors. |
Any number of people can mirror at the same time and any number of people can
@@ -32,6 +33,7 @@ Then, from another machine on the LAN:
http://:8055/mirror # to share
http://:8055/watch # to watch
http://:8055/kiosk # the unattended wall (kiosk display)
+http://:8055/remote # drive the wall from this device
```
Linux is the primary target. On Linux the native picker is X11/Wayland's own
@@ -78,6 +80,20 @@ 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).
+### Driving an unattended wall
+
+The shared state exists for a box with no keyboard: point the box at `/kiosk`,
+open `/remote` on a phone, and press **Show on wall** — the box stages that
+mirror, and **Back to the wall** and **Full screen** do the same for their
+decisions. When a keyboard is plugged into the box, the wall keys still work
+there, and a keypress performs the change *and* publishes the same `arrange`
+intent, so a key on the box is seen by every controller — a controller and a
+display converge on one state. Fullscreen is **requested, not promised**: a
+remote command carries no user activation, so the browser may refuse the
+request; the in-page stage fills the viewport anyway (and the kiosk box's
+window manager forces the window fullscreen). The state lives in the server's
+memory only: a restart returns the wall to a wall.
+
## Configuration
Environment variables (all optional; a `.env` file is loaded if present):
@@ -180,9 +196,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
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
+no monitor. The display page is `/kiosk`, and it is the installer default
+too; an existing `kiosk.conf` URL wins over the default, 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:
diff --git a/SECURITY.md b/SECURITY.md
index 8e0b67d..83d53ed 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -31,11 +31,16 @@ limiting, no accounts, and no audit trail.
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
ceiling).
-* Point the shared wall at any live mirror and toggle its fullscreen state
- (the `arrange` frame on `/ws`), which every kiosk display follows. The
- state has no audio field: a remote hand can stage and scale, but it can
- never make a display *sound* — that still needs a gesture on the device
- playing it (see §4 “Audio”).
+* **Rearrange anybody's wall display**: choose which mirror is on stage and
+ whether it is fullscreen, on every `/kiosk` client, immediately — open
+ `/remote` on a phone, or send one `arrange` frame on `/ws`. This is the
+ same trust decision as "watch anything anyone shares": it is visible, it is
+ presentation-only (no sound, see below), and `MIRROR_ALLOW_CIDRS` is what
+ bounds it.
+* What a visitor **cannot** do: make a display audible. The layout carries no
+ sound field, and every audible state traces to a gesture on the device that
+ plays it (the §4 “Audio” gesture rule) — a remote hand can stage and scale,
+ but a stranger's display never sounds because they asked.
## 3. What they cannot do (enforced and tested)
@@ -46,6 +51,7 @@ limiting, no accounts, and no audit trail.
| **No identity spoofing of the roster.** A viewer cannot publish a mirror; a peer cannot claim another's stream | role checks (`not_sharer`, `not_viewer`), stream ids are server-assigned | `test_one_peer_cannot_impersonate_another`, `test_a_viewer_cannot_publish` |
| **No oversized signaling.** Frames above 64 KiB are dropped before parsing | `MAX_MESSAGE_BYTES` | `test_an_oversized_frame_is_refused` |
| **No protocol fuzzing into a crash.** Bad JSON, non-object frames, unknown types and unknown roles produce an error frame; only fatal ones close the socket | `Hub.serve`/`_dispatch` | `TestSignalingSocket` |
+| **No remote audio, no remote mute override.** The layout carries no sound field, and every audible state traces to a gesture on the device that plays it | the layout model has no audio field, plus the `kiosk.js`/`remote.js` source guards | `test_kiosk_never_publishes_an_audio_field`, `test_a_controller_is_media_free`, `test_m_flips_the_staged_videos_muted_flag`, `test_only_the_staged_mirror_can_be_unmuted` |
| **No leaked peer connections.** Every teardown path notifies every viewer | `Registry._drop_stream`, `leave()` | `test_sharer_disconnect_removes_the_mirror`, `test_unpublish_clears_the_tile_for_every_viewer` |
| **Nothing leaves the network.** No CDN, no remote font, no analytics, no STUN by default | A10/A4 + a build-failing test | `test_nothing_is_loaded_from_a_cdn`, `test_config_does_not_leak_server_secrets` |
| **The server cannot see your screen.** Media is SRTP between peers; the server relays JSON only | A3 | Architectural: no media code exists in Python |
diff --git a/docs/kiosk-silverblue.md b/docs/kiosk-silverblue.md
index 36ff87a..15fd030 100644
--- a/docs/kiosk-silverblue.md
+++ b/docs/kiosk-silverblue.md
@@ -1,7 +1,7 @@
# Kiosk client — Fedora Silverblue
The target client for a wall display is **Fedora Silverblue running GNOME Kiosk,
-auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/watch`
+auto-logged in as `kiosk`, showing `https://mirror.reeseapps.com/kiosk`
fullscreen in Firefox.** One box takes about five minutes over SSH and needs no
keyboard, no monitor access, and no console.
@@ -56,7 +56,7 @@ be skipped when the packages are already layered. To point a client somewhere
other than the production wall, pass the URL:
```bash
-sudo KIOSK_URL=https://mirror.reeseapps.com/watch sh install-kiosk.sh
+sudo KIOSK_URL=https://mirror.reeseapps.com/kiosk sh install-kiosk.sh
```
Only `kiosk.conf` differs between clients; every other file stays byte-identical
@@ -87,17 +87,20 @@ for the moment a keyboard is plugged in (see [the key table](#operating-the-wall
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.
+another machine asked). The **controller side** is `/remote`: a page a person
+on another machine (a phone) opens to list the live mirrors, read the wall's
+state, and send the decisions — show this, back to the wall, full screen. Any
+client that can send an `arrange` frame on `/ws` is a controller too,
+`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**:
+`MIRROR_KIOSK_URL` until an operator re-points it and restarts `gdm`, because
+the installer deliberately preserves a client's URL: an existing `kiosk.conf`
+URL wins over the built-in default (which now points at `/kiosk`). 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"|' \
@@ -107,10 +110,10 @@ sudo systemctl restart gdm
(`pkill -u kiosk firefox` is the shorter path — the loop re-sources
`kiosk.conf` on every relaunch, see [the section below](#the-two-facts-that-make-this-work) —
-`gdm` is the clean restart when you want the whole session back.) The kiosk
-page serves `Wall · mirror`; `check-kiosk.sh`, untouched in this
-phase, still greps for `Watch · mirror`, and the checker is widened to
-accept the kiosk title in phase 09, when the default URL moves with it.
+`gdm` is the clean restart when you want the whole session back.) Both walls
+are served by the same tree; `check-kiosk.sh` accepts both titles
+(`Watch · mirror` and `Wall · mirror`) and
+warns for anything else.
---
@@ -347,7 +350,7 @@ The console is not an interface. Every claim below is checkable over SSH.
| The page loaded | the **server's** `GET /api/streams` shows the kiosk in `viewers` and its socket in `ss -tn` |
| Media really flows | the sharer's own page renders each viewer's `RTCPeerConnection.connectionState`; `connected` = ICE+DTLS+SRTP up |
| It is truly fullscreen | `dim-probe.py`: `viewport == window == screen == available` |
-| The URL is serving *mirror* | `check-kiosk.sh` greps the page for `Watch · mirror` |
+| The URL is serving *mirror* | `check-kiosk.sh` greps the page for the wall title — `Watch · mirror` or `Wall · mirror` |
| The kiosk holds the screen | `/sys/class/tty/tty0/active` equals the kiosk session's TTY and the session is `Active` — the one check that catches a covered wall |
| No greeter is fighting it | `journalctl -b \| grep -c "New session.*gdm-greeter"` is `0`; any non-zero number is a login screen on your wall |
| It survives a power cut | `sudo systemctl reboot`, then re-run `check-kiosk.sh` |
diff --git a/docs/kiosk-silverblue/check-kiosk.sh b/docs/kiosk-silverblue/check-kiosk.sh
index b703074..12808cb 100755
--- a/docs/kiosk-silverblue/check-kiosk.sh
+++ b/docs/kiosk-silverblue/check-kiosk.sh
@@ -64,9 +64,9 @@ if [ -n "$HOME_DIR" ]; then
"$HOME_DIR/.config/mirror/kiosk.conf" 2>/dev/null | head -1)
ok "kiosk URL: ${URL:-}"
case "$URL" in
- */watch) : ;;
+ */watch|*/kiosk) : ;;
"") bad "kiosk URL is empty" ;;
- *) warn "kiosk URL is not the /watch wall: $URL" ;;
+ *) warn "kiosk URL is neither wall (/watch or /kiosk): $URL — check DNS and the nginx vhost" ;;
esac
else
bad "kiosk config $HOME_DIR/.config/mirror/kiosk.conf is missing"
@@ -213,8 +213,10 @@ if [ -n "$URL" ]; then
CODE=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$URL" 2>/dev/null)
if printf '%s' "$BODY" | grep -q "Watch · mirror"; then
ok "$URL serves the mirror watch page (HTTP $CODE)"
+ elif printf '%s' "$BODY" | grep -q "Wall · mirror"; then
+ ok "$URL serves the mirror kiosk page (HTTP $CODE)"
else
- warn "$URL answered HTTP $CODE but it is NOT the mirror watch page — the kiosk"
+ warn "$URL answered HTTP $CODE but it is NOT a mirror wall — the kiosk"
warn "will happily display whatever this is. Check DNS and the nginx vhost."
info "title: $(printf '%s' "$BODY" | sed -n 's/.*\(.*\)<\/title>.*/\1/p' | head -1)"
fi
diff --git a/docs/kiosk-silverblue/gnome-kiosk-script b/docs/kiosk-silverblue/gnome-kiosk-script
index 85b8c85..bec7164 100755
--- a/docs/kiosk-silverblue/gnome-kiosk-script
+++ b/docs/kiosk-silverblue/gnome-kiosk-script
@@ -25,7 +25,7 @@ if [ -r "$CONF" ]; then
. "$CONF"
fi
-URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/watch}"
+URL="${MIRROR_KIOSK_URL:-https://mirror.reeseapps.com/kiosk}"
PROFILE="${MIRROR_KIOSK_PROFILE:-$HOME/.mirror-kiosk}"
LOG="${MIRROR_KIOSK_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/mirror-kiosk.log}"
POLL="${MIRROR_KIOSK_POLL:-5}"
diff --git a/docs/kiosk-silverblue/install-kiosk.sh b/docs/kiosk-silverblue/install-kiosk.sh
index e3b119d..9f8829c 100755
--- a/docs/kiosk-silverblue/install-kiosk.sh
+++ b/docs/kiosk-silverblue/install-kiosk.sh
@@ -17,7 +17,10 @@ set -eu
KIOSK_USER="${KIOSK_USER:-kiosk}"
# The default is for a NEW client only. An existing client keeps its own URL
# unless the operator names one — see "the URL is the one per-client value" below.
-KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/watch"
+# The default is the DISPLAY endpoint: /kiosk obeys the shared wall state, so
+# a device with a keyboard (the /remote page) can arrange a box that has none.
+# /watch is the operator's page — a wall a person stands at.
+KIOSK_URL_DEFAULT="https://mirror.reeseapps.com/kiosk"
KIOSK_URL="${KIOSK_URL:-}"
KIOSK_SESSION="gnome-kiosk-script-wayland"
HERE=$(cd "$(dirname "$0")" && pwd)
diff --git a/docs/kiosk-silverblue/kiosk.conf b/docs/kiosk-silverblue/kiosk.conf
index 8095eed..e58e761 100644
--- a/docs/kiosk-silverblue/kiosk.conf
+++ b/docs/kiosk-silverblue/kiosk.conf
@@ -7,9 +7,12 @@
# After editing: `sudo systemctl restart gdm` (or log the kiosk user out) to
# re-read it. The browser is restarted by the kiosk script's own loop.
-# The wall of every live mirror. `/` is the Share/Watch chooser and needs a
-# tap to get anywhere, which is wrong for a display nobody is standing at.
-MIRROR_KIOSK_URL="https://mirror.reeseapps.com/watch"
+# The controllable wall. `/kiosk` is the display endpoint: it obeys the shared
+# layout state, so a device with a keyboard (the /remote page, or any
+# `arrange` frame on /ws) decides what is on stage and whether it is
+# fullscreen — a box that has no keyboard can still be arranged. `/watch` is
+# the operator's page: a wall a person stands at.
+MIRROR_KIOSK_URL="https://mirror.reeseapps.com/kiosk"
# Optional overrides (uncomment to change):
diff --git a/src/mirror/app.py b/src/mirror/app.py
index da0c2b7..83a2c46 100644
--- a/src/mirror/app.py
+++ b/src/mirror/app.py
@@ -316,6 +316,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
itself from the shared layout (phase 07/08)."""
return _page("kiosk.html")
+ @app.get("/remote", response_class=HTMLResponse, include_in_schema=False)
+ async def remote_page() -> FileResponse:
+ """The controller: lists mirrors and drives the shared layout. Shows no
+ media and subscribes to nothing — a controller must not cost a sharer
+ bandwidth (PLAN.md §6)."""
+ return _page("remote.html")
+
@app.get("/api/config")
async def client_config() -> JSONResponse:
return JSONResponse(
@@ -356,7 +363,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return
await hub.serve(websocket)
- PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk")
+ PAGES = ("/", "/mirror", "/share", "/watch", "/kiosk", "/remote")
@app.middleware("http")
async def admission_and_cache_headers(request: Request, call_next): # type: ignore[no-untyped-def]
diff --git a/src/mirror/static/app.css b/src/mirror/static/app.css
index 4bd2228..3dcabc2 100644
--- a/src/mirror/static/app.css
+++ b/src/mirror/static/app.css
@@ -683,6 +683,102 @@ body.staged .stage { display: block; }
pointer-events: none;
}
+/* --------------------------------------------------------------- remote */
+
+/* /remote is a controller: a readout, the decision buttons and a list of
+ rows — no video, no tiles (PLAN.md §6). It is the phone page of the app,
+ so it must stay usable down to 320 px, where the share page's narrow-
+ viewport rules are the model: wrap, ellipsis, never run off the edge. */
+
+/* The readout: one strip under the bar in the monospace face — what the
+ server says the wall is doing, verbatim. `aria-live` on the section
+ (markup) is what makes it speak to a screen reader; the CSS only styles. */
+.wall-state {
+ margin: 14px 16px 0;
+ padding: 10px 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-2);
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--fg-dim);
+}
+
+.wall-state #remote-staged { color: var(--fg); }
+
+/* The decision buttons: one row that wraps, so at 320 px they stack and
+ stay reachable instead of running off the edge. */
+.remote-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 14px 16px 0;
+}
+
+/* The "on" state of the Full screen button: the same accent treatment the
+ tile's audio button gets when its aria-pressed is true, so "on" looks
+ the same everywhere in this app. */
+#remote-fullscreen[aria-pressed="true"] {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* The one-line hint under the controls: "no display following yet" (the
+ state is stored — a display pointed at /kiosk picks it up) and
+ "offline". The global `[hidden]` rule is what hides it; no rule of its
+ own may fight that. */
+.remote-hint {
+ margin: 10px 16px 0;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--fg-dim);
+}
+
+/* A readout that is a snapshot, not a statement: the socket dropped, so the
+ last staged label is shown dimmed until the next welcome re-syncs it. */
+.wall-state #remote-staged[data-stale] {
+ opacity: 0.55;
+}
+
+/* One row per mirror: title + sub + the decision. `flex-wrap` is the 320 px
+ escape hatch (the button drops under the title rather than clipping), and
+ `min-width: 0` on the grow is what lets the title ellipsis instead of
+ pushing the button out. */
+.mirror-list {
+ list-style: none;
+ margin: 14px 16px 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.mirror-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: var(--bg-2);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+}
+
+.mirror-row .grow { flex: 1; min-width: 0; }
+.mirror-row .title {
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mirror-row .sub {
+ color: var(--fg-dim);
+ font-family: var(--mono);
+ font-size: 11.5px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
diff --git a/src/mirror/static/index.html b/src/mirror/static/index.html
index a99e472..e2a987b 100644
--- a/src/mirror/static/index.html
+++ b/src/mirror/static/index.html
@@ -51,6 +51,14 @@
itself from the shared layout. No pointer, no keyboard, nothing to set up at the box.
+
+ connecting…
+
+
+
+
+
+
+ Wall: no mirror · fullscreen · no display following
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No mirrors live
+
+ Share from …
+
+
+
+
+
+
+
+
Shared screen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mirror/static/remote.js b/src/mirror/static/remote.js
new file mode 100644
index 0000000..54d4128
--- /dev/null
+++ b/src/mirror/static/remote.js
@@ -0,0 +1,308 @@
+/* The remote page: the hand that drives the wall.
+
+ It lists the live mirrors and the wall's current state, and offers the
+ three decisions (show this, back to the wall, full screen) as `arrange`
+ frames the display obeys. The page's correctness rests on one rule: the
+ only thing it displays about the wall is what the server last told it.
+ A control never paints a result locally and hopes the server agrees —
+ with two controllers open, an optimistic paint is a lie the moment the
+ other one acts, and a lie in the readout is worse than a 40 ms delay.
+
+ What this page never does: show media, or ask for a stream's track —
+ the mesh uploads N × bitrate per sharer, so a controller that consumed
+ a leg just to know what to point at would cost everyone (PLAN.md §6).
+ It keeps a list of mirrors, not tiles: no