phase: 09_wall_remote_page
Build and Push Containers / test (push) Successful in 45s
Build and Push Containers / build-and-push-app (push) Successful in 13s

**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.
This commit is contained in:
2026-11-03 23:50:43 -05:00
parent cd29156a7d
commit d0077e0db4
36 changed files with 1354 additions and 43 deletions
@@ -1,90 +0,0 @@
# Phase 09 — `/remote`: the hand that drives the wall
**Source:** `TODO.md` L3 — "…controllable by other clients. Problem is the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views… that any client can manipulate" (the controller half of that item)
**Story:** n/a
**Context:** `.agents/PLAN.md` §2 A9 (no framework, no bundler), A10 (no external assets), A11/A19 (no accounts; the address gate is the access control); `SECURITY.md` §2/§3/§4; phases `07_kiosk_layout_state` (the contract) and `08_kiosk_page` (the display that obeys it); `src/mirror/app.py`, `src/mirror/static/{net.js,app.css}`, `docs/kiosk-silverblue/`
## Objective
`GET /remote`: a page you open on the laptop or phone in your pocket. It lists the
live mirrors and the wall's current state and offers three decisions — *show this,
back to the wall, full screen* — each one an `arrange` frame the display obeys
instantly. Stepping (`n`/`p`) stays on the wall pages: on a controller the direct
choice is a row and a button, and a second copy of the wall's key map would be a
second thing to keep in sync. It shows **no video and subscribes to nothing**: a
controller must not consume a sharer's upstream just to know what to point at
(`PLAN.md` §6, "Mesh upload = N × bitrate per sharer").
`/remote` is its own page rather than a panel on `/watch` because the two questions
are different — "what do *I* want to look at" and "what should the *room* see" — and
collapsing them would either hijack every viewer's clicks or hide the controls behind
a wall of tiles. `/watch` keeps its local staging exactly as phases 01–06 defined it.
## Rules this page lives by
* **Read the state, never own it.** Every control reflects `layout` as the server
pushed it, so two controllers, a kiosk's own keystrokes and a page reload all agree.
A control never paints a result locally and hopes the server agrees.
* **Media-free, and provably so.** No `RTCPeerConnection`, no `subscribe` frame, no
`<video>`. The proof is a test on the server's own numbers: open `/remote` and
`/api/streams` must still report zero watchers.
* **Only buttons and text, focusable.** No custom key map here: `/watch`'s keys belong
to a wall, and a second copy of them on a controller page is a second thing to keep
in sync. Native `<button>`s are keyboard- and screen-reader-reachable and work on a
phone, which is the device this page is for.
* **Fullscreen is reported, not promised.** The page says what the state asks for and
that a browser may refuse it; it never implies it verified another machine's
fullscreen.
* **No audio, ever.** Same rule as the display (`SECURITY.md` §4): the layout has no
sound field and `/remote` does not invent one. A controller that could make a
stranger's display talk is not a controller, it is a prank.
## Dependencies
- `07_kiosk_layout_state` (todo) — `arrange`/`layout`, `followers`, `GET /api/kiosk`.
- `08_kiosk_page` (todo) — the `/kiosk` display that makes a controller meaningful,
and the e2e scaffolding (`device()`-style fixture, frame probe) this suite reuses.
- `01_keyboard_kiosk_control` (complete) — the key map this page deliberately does
**not** copy, and the mouseless rationale it serves.
## Tasks
1. `01_remote_route_and_page.md` — `GET /remote` in `app.py` (+ `PAGES`),
`static/remote.html` with the control cluster and status line, `app.css` for the
list, entry links from `/watch` and `/`, and the `test_app.py` structural guards
extended to a fourth page.
2. `02_remote_controller_logic.md` — `static/remote.js`: the roster as a list (no
media), the live layout readout with the `followers` count, the buttons that send
`arrange`, and the convergence behaviour when someone else changes the state.
3. `03_remote_e2e_docs_and_kiosk_url.md` — `tests/e2e/test_remote_control.py`
(controller → display, asserted on the display's DOM and on `/api/streams`'
watcher count), README + `SECURITY.md`, and the kiosk box's default URL moving to
`/kiosk`.
## Testing & Quality
- **Structure**: every `test_app.py` guard gains the `/remote` page — renders,
`no-store`, `node --check` of `remote.js`, element-existence, the cross-page id
rule now over four pages (`{"net","notice","toast"}` is still the only overlap),
nothing from a CDN, the address gate refusing `/remote` like the rest.
- **The media-free guarantee** is asserted twice, from both ends: a source-level
guard that `remote.js` contains no `RTCPeerConnection` and no `subscribe` frame, and
a browser test that the server's `watching`/`viewers` counters stay at zero with
`/remote` open. One of them will catch a well-meant future "preview thumbnail".
- **Behaviour**: `tests/e2e/test_remote_control.py` drives the controller with real
clicks (a person with a mouse driving a machine that has none — that is the whole
feature) and asserts on the *display's* DOM and on `/api/kiosk`, never on the
controller's optimism.
- Coverage: **>90%** on new/modified code; the Python surface is one route.
## Completion Criteria
- [ ] with a `/kiosk` display open, clicking "Show on wall" in `/remote` puts that mirror on the display within one frame, asserted in the browser
- [ ] "Back to the wall" and "Fullscreen" do the same, and a second `/remote` page updates without being touched
- [ ] `/api/streams` reports zero watchers while only `/remote` is open — driving the wall costs a sharer nothing
- [ ] `/remote` counts one follower per `/kiosk` and never counts itself
- [ ] a controller keeps working when there is no display at all (`/api/kiosk` `followers: 0`, the controls are enabled, the state is still stored)
- [ ] `uv run pytest tests/e2e/test_remote_control.py` green, then the full `-m e2e` and the `realdisplay` suite
- [ ] README + `SECURITY.md` describe what a visitor can now do to someone else's wall; the Silverblue box's default URL is `/kiosk`
- [ ] full test suite **100% passing**, coverage >90% on new code
- [ ] `PLAN.md` untouched; `AGENTS.md` touched only if a statement in it became stale
@@ -1,109 +0,0 @@
# Task 01 — `GET /remote` and the controller page
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "controllable by other clients … so I can't full screen/switch views"
**Story:** n/a
## Objective
The endpoint and the markup for the hand: a list of mirrors, a line that says what the
wall is doing, and the buttons that change it. `remote.js` becomes real in task 02;
here it is a stub that loads config and reports a refusal, so the page serves and the
guards can be written first.
## Work
1. `src/mirror/app.py` — the route beside `/kiosk`:
```python
@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")
```
Add `"/remote"` to the `PAGES` tuple so it is served `no-store` like the others.
2. `src/mirror/static/remote.html` (new) — same `<head>` conventions as the other
pages (app.css, inline `data:` favicon, no external asset), `<title>Remote ·
mirror</title>`, and these elements only:
* `<header class="bar">` with the brand and `<span class="net" id="net"
data-state="connecting">…</span>` (the shared chrome).
* `<div class="notice" id="notice" hidden>` and `<div class="toast" id="toast"
hidden>` — the other two shared ids, nothing else may overlap with another page.
* `<section class="wall-state" id="remote-state" aria-live="polite">` — the readout:
`Wall: <label>` / `Wall: no mirror`, a fullscreen marker, and a follower clause
`· 1 display following` / `· no display following`. Give the pieces stable hooks
(`<span id="remote-staged">`, `<span id="remote-fullscreen">`,
`<span id="remote-followers">`) so the text and the tests never have to parse a
sentence.
* `<div class="remote-controls">` with `<button class="btn" id="remote-back">Back
to the wall</button>` and `<button class="btn ghost" id="remote-fullscreen"
aria-pressed="false">Full screen</button>`.
* `<ul class="mirror-list" id="remote-list">` — the mirror rows live in
`<template id="remote-row-template">`: `<li class="mirror-row" data-stream>`,
`.title`, `.sub`, `<button class="btn ghost show">Show on wall</button>`.
* `<div class="empty" id="remote-empty">` for "No mirrors live" with the same
`Share from <code id="remote-share-url">…</code>` line the kiosk page uses —
the controller is usually the device that goes and asks somebody to share.
3. `src/mirror/static/remote.js` (new, stub) — import `loadConfig`, `refusalNotice`,
`showNotice`, `$` from `./net.js`; set `#remote-share-url`; open the socket enough
to drive `#net` to `online` (the e2e `device()` helper waits on it) and say hello
with `role:"viewer"` and **no `kiosk` flag**. Task 02 fills in the rest.
ASSUMPTION: a controller is a plain viewer in the protocol; `followers` counts
displays only, and that asymmetry is what makes "no display following" mean what it
says.
4. `src/mirror/static/app.css` — `.remote` page styles: `.wall-state` (a strip under
the bar, `--mono` for the readout, `aria-live` friendly), `.mirror-list` /
`.mirror-row` (one row per mirror, title + sub + button on one line, works down to
320 px — the share page's narrow-viewport rules are the model),
`.remote-controls` (button row, wraps). `[aria-pressed="true"]` on
`#remote-fullscreen` gets the same accent treatment the tile audio button uses, so
"on" looks the same everywhere in this app. No rule whose selector mentions
`[hidden]`; no new `display` on an existing component (`TestStylesheet` guards).
5. Entry points — the endpoint has to be findable from the pages a person already
opens:
* `src/mirror/static/watch.html` — `<a class="btn ghost" id="remote-link"
href="/remote">Drive the wall</a>` in the header, before `#share-link`. `watch.js`
needs no change for it (it is a plain link; the element guard only inspects ids
the script reaches for).
* `src/mirror/static/index.html` — a fourth `<a class="card" href="/remote">`
("GET /remote — Point the wall from another device"). Cards carry no ids.
6. `src/mirror/static/kiosk.html` — nothing. The display must not link its way out of
itself; a wall shows mirrors and no navigation.
## Testing & Quality
- `tests/test_app.py` — extend the guards to the fourth page, same list as phase 08
task 03: `test_pages_render` (`("/remote", "remote-row-template")`),
`test_pages_are_not_cacheable`, `test_static_assets_are_served` (`remote.js`),
`test_scripts_parse_as_es_modules` (`remote.js`),
`test_every_element_the_script_reaches_for_exists` (`("remote.js", "remote.html")`),
the stylesheet-and-module pair test, and
`test_the_two_pages_share_no_element_ids_but_the_shared_chrome` with `remote.html`
in the map — `remote ∩ watch`, `remote ∩ mirror`, `remote ∩ index`, `remote ∩ kiosk`
all exactly `{"net","notice","toast"}`.
- New guards for this page's one hard promise, next to the other source-level rules:
* `remote.js` contains no `RTCPeerConnection` and no
`type: "subscribe"`/`type:'subscribe'` string, and `remote.html` contains no
`<video>` — a controller that streams is a bug, and it must fail the build, not
get caught in review (the reason is stated in the test's docstring: `PLAN.md` §6's
mesh cost).
* `remote.html` declares `id="remote-state"`, `#remote-back`,
`#remote-fullscreen`, `#remote-list` — the element guard already covers this once
`remote.js` looks them up in task 02, but assert them here so task 01's stub and
task 02's page cannot drift.
- `tests/test_app.py::TestAddressGate` — `/remote` joins the refused-address list.
- Coverage: **>90%** on new/modified Python (one route).
## Completion Criteria
- [ ] `curl -s localhost:8055/remote` serves the page; `-I` shows `cache-control: no-store`
- [ ] opening `/remote` shows the header with the net pill online, an empty "No mirrors live" state with the share URL, and no console errors
- [ ] `/watch`'s header and `/`'s cards link to `/remote`, and `/kiosk` links to nothing
- [ ] `uv run pytest tests/test_app.py` green with the guards above extended to four pages, including the "no media in a controller" rules
- [ ] at a 320 px viewport the control row wraps and every button is reachable, no horizontal scroll
- [ ] `uv run pytest -m e2e` green (existing assertions unedited)
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -1,117 +0,0 @@
# Task 02 — `remote.js`: read the state, send the intent
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
**Story:** n/a
## Objective
Make the controller real: a list of mirrors built from the roster, a readout built
from the server's `layout`, and buttons that change the wall by sending `arrange` —
never by painting a result and hoping. The page's correctness rests on one rule: **the
only thing it displays about the wall is what the server last told it.**
## Work
1. `src/mirror/static/remote.js` — replace the stub's innards, keeping the `ui` map,
the config/refusal path and the socket from task 01. Module-local state:
```js
/** id -> {id, name, label, displaySurface, width, height, audio}, in server
* roster order (welcome.streams is oldest-first, stream_added appends). The
* controller keeps a list, not tiles: no <video>, no MediaStream, no
* RTCPeerConnection — see the "no media in a controller" guard in test_app.py. */
const mirrors = new Map();
let layout = { staged: null, fullscreen: false, followers: 0 };
```
2. `src/mirror/static/remote.js` — roster handling: `welcome` seeds `mirrors`,
`stream_added`/`stream_updated` upsert, `stream_removed` deletes, and each change
re-renders the list and the readout (a staged mirror's label appears in the
readout, so an update can change the readout without changing the list).
`signal` frames are ignored — this page never negotiates, and it should still be
true if a sharer misbehaves.
3. `src/mirror/static/remote.js` — `renderMirrors()`: one `li.mirror-row[data-stream]`
per mirror from `#remote-row-template`, title = `label || name || "Shared screen"`,
sub = `name · surface · W×H · audio|silent` (reuse the same words `watch.js`'s
`paintTile` prints, so the two pages describe a mirror identically), the row's
`.show` button, and `data-on=""` on the row whose id is `layout.staged` with the
button reading `On the wall` (plus `aria-current="true"`). Empty list →
`#remote-empty` shown, list `hidden`.
4. `src/mirror/static/remote.js` — `renderState()`: the three spans
(`#remote-staged`, `#remote-fullscreen`, `#remote-followers`) are filled from
`layout` and nothing else:
* staged → the mirror's title, or `"(gone)"` for the frame-in-transit case where
the server has not yet cleared it; `null` → `no mirror`;
* fullscreen → `on` / `off`, and `#remote-fullscreen`'s `aria-pressed` and label
(`Full screen: on`) follow the **server's** value;
* followers → `N displays following` / `1 display following` / `no display
following`. When it reads `no display following` the controls stay enabled and a
one-line hint appears under them: the state is still stored, so pointing a kiosk
at `/kiosk` immediately picks it up — that is the TODO's "navigate to it and it
arranges itself", seen from the other end.
5. `src/mirror/static/remote.js` — the three writers, each of which sends and then
**waits**:
```js
/**
* Send an intent and let the server's `layout` frame paint it. Never update
* `layout` here: with two controllers open, an optimistic local paint is a lie
* the moment the other one acts, and a lie in the readout is worse than a
* 40 ms delay.
*/
function arrange(patch) {
if (!state.ws.send({ type: "arrange", ...patch })) toast("not connected — nothing sent");
}
```
* row `.show` → `arrange({ staged: id })`;
* `#remote-back` → `arrange({ staged: null })`;
* `#remote-fullscreen` → `arrange({ fullscreen: !layout.fullscreen })` — toggling
from the server-reported value, never from the button's own `aria-pressed`.
Wire them once on the list container (event delegation on `#remote-list`, matching
how `watch.js` handles per-tile buttons) so a re-render cannot orphan a listener.
6. `src/mirror/static/remote.js` — the frames: `welcome` → seed `mirrors`, set
`layout = message.layout`, render both; `layout` → set `layout`, `renderState()`
**and** `renderMirrors()` (the `data-on` marker moved); `error` → `toast` the
message (an `unknown_stream` from a stale row is exactly the case where silence
would look like a broken button).
7. `src/mirror/static/remote.js` — the honest edge cases:
* a mirror that was on the wall disappears → the server's `layout` arrives with
`staged: null`; say so once (`toast("<label> ended — back to the wall")`) because
on a controller the change is invisible otherwise;
* socket closed → mark the readout stale (`state.ws.open === false`):
`#remote-staged` gets a `data-stale=""` and the hint reads "offline — the wall is
not following you"; on reopen, `welcome` re-syncs everything and clears it;
* the page never sends `subscribe`, and never reads `navigator.mediaDevices`.
8. Do not add a key map here (`AGENTS.md` §6, and the rationale in `00_phase.md`): the
buttons are native, focusable and phone-tappable. Do not touch `watch.js`,
`kiosk.js` or `wall.js` — the shared state is the only coupling between pages,
which is the point of the whole feature.
## Testing & Quality
- `tests/test_app.py` — source guards alongside the existing ones:
* `remote.js` sends exactly one message type other than `hello`: assert the file
contains `type: "arrange"` and contains no `type: "subscribe"`;
* `remote.js` contains no `RTCPeerConnection`, no `MediaStream` and no
`navigator.mediaDevices` (the task-01 guard, extended — a controller growing a
"preview" is the regression most likely to arrive with good intentions);
* the element guard (`("remote.js", "remote.html")`) now covers every `$("#…")`
the real script reaches for.
- `tests/test_app.py` — one protocol-level test that a `/remote`-shaped peer
(`hello{role:"viewer"}` with no `kiosk`) drives the layout and is **not** counted in
`followers`, and one that `arrange`ing a mirror a *second* viewer is watching
changes nobody's subscription (`/api/streams` `viewers` unchanged) — driving the
wall must not disturb the people watching it.
- Coverage: **>90%** on new/modified code; the browser behaviour lands in task 03.
## Completion Criteria
- [ ] with a `/kiosk` display open, `/remote`'s row for the mirror on the wall reads `On the wall` and carries `data-on`
- [ ] pressing "Back to the wall" leaves the button state and the readout to the server's frame: two `/remote` tabs show the same thing 100 ms after either one acts
- [ ] `#remote-fullscreen` toggles from the server-reported value (press it twice in two tabs and the two tabs never disagree)
- [ ] `/remote` with `followers: 0` still stores the state and says no display is following
- [ ] `uv run pytest tests/test_app.py` green, including the three new source guards and the two protocol tests
- [ ] `uv run pytest -m e2e` green with existing assertions unedited
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work
@@ -1,119 +0,0 @@
# Task 03 — Controller→display E2E, the security line, and the box's new URL
**Phase:** `09_wall_remote_page` · **Source:** `TODO.md:3` — "controllable by other clients … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences"
**Story:** n/a
## Objective
Prove the whole loop in a browser — a hand on a laptop, a mirror on the wall, a box
across the room with no keyboard — then write down what it means for the threat model
and point the Silverblue client's default at `/kiosk`.
## Work
1. `tests/e2e/test_remote_control.py` (new) — `pytestmark = [pytest.mark.e2e,
pytest.mark.timeout(180)]`, the suite's own `device()` fixture (isolated context
per device, `pageerror`/`console` collected from line one, wait on `#net`
`data-state=online`) exactly as `test_kiosk_layout.py` sets it up. Unlike the kiosk
suite, **this one clicks** — a person with a mouse driving a machine that has none
is the entire feature.
2. `tests/e2e/test_remote_control.py` — the cases:
* **the loop**: sharer `/mirror` + `start_sharing()`; a `/kiosk` display; a
`/remote` controller. Assert the row appears, click its `.show`; assert on the
**display** — `body[data-layout="stage"]` and
`#kiosk-stage .tile[data-stream="<id>"]` — and on `GET /api/kiosk`. Then click
`#remote-back` and assert the display is back on the wall.
* **pixels, not classes**: with the mirror on the display's stage,
`frames_matching(kiosk, "#kiosk-stage video")` reports `width > 0` and
`distinct >= 2` — the controller moved real media, per `AGENTS.md` §5.
* **full screen**: click `#remote-fullscreen` → `/api/kiosk` says
`fullscreen: true` and the display's `body[data-fullscreen="true"]`. Never assert
`document.fullscreenElement` (headless may refuse; the WM forces fullscreen on the
real box — say so in a comment so nobody re-adds the flake).
* **two controllers converge**: open a second `/remote`; act in the first; the
second's readout and `data-on` row update with no interaction.
* **a controller costs a sharer nothing**: with only `/remote` open,
`GET /api/streams` reports `viewers: 0` and `stats.watching: 0`; open a `/watch`
and the numbers move — the guard that keeps `/remote` from becoming a stealth
watcher.
* **followers**: `/api/kiosk` and `/remote`'s `#remote-followers` agree at 0, at 1
with a display open, and at 1 with a second `/remote` added (a controller is not
a display).
* **the display is a controller too**: press `n` on the `/kiosk` page and assert
the `/remote` readout follows it — the TODO's "any client can manipulate", in the
direction that would otherwise be forgotten.
* **gone is a state**: `stop_sharing()` → the display unstages, the controller's
row disappears and its readout says no mirror.
* every case asserts `errors == []`.
3. `SECURITY.md` — this is a new capability over other people's machines, so it goes
in the model, not just the README:
* §2 "What an unauthenticated visitor can do" gains a bullet: **rearrange anybody's
wall display** — choose which mirror is on stage and whether it is fullscreen, on
every `/kiosk` client, immediately; reachable by anyone the address gate admits
(`/remote`, or one `arrange` frame on `/ws`). Say plainly that this is the same
trust decision as "watch anything anyone shares", and that `MIRROR_ALLOW_CIDRS`
is what bounds it.
* §2 also gains the thing that is *not* possible: a visitor cannot make a display
audible, and why (`§4`'s gesture rule).
* §3's table gains a row: *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 — enforced by the layout model plus the `kiosk.js`/`remote.js`
source guards, tested by
`test_kiosk_mute_is_local_and_never_published`-style tests (name the real ones
from phases 08/09 in the row).
4. `README.md`:
* the page table gains `/remote` (and confirms the `/kiosk` row phase 08 added);
* a short section, *"Driving an unattended wall"*: point the box at `/kiosk`, open
`/remote` on your phone, press "Show on wall"; the wall keys still work on the
box when a keyboard is plugged in and a key there is seen by every controller;
fullscreen is requested, not promised; state lives in the server's memory and a
restart returns the wall to a wall;
* the Quickstart's URL list gains both endpoints.
5. The Silverblue client now defaults to the controllable wall. Change all four places
in one commit so a box installed from this tree cannot get a half-migrated URL:
* `docs/kiosk-silverblue/install-kiosk.sh` — `KIOSK_URL_DEFAULT` →
`https://mirror.reeseapps.com/kiosk`, and the comment above it explains that
`/kiosk` is the display endpoint while `/watch` is the operator's page;
* `docs/kiosk-silverblue/kiosk.conf` — `MIRROR_KIOSK_URL` → the `/kiosk` URL, with
its comment rewritten (it currently argues for `/watch` on the grounds that `/`
needs a tap; the new argument is that a display must be arrangeable from another
device);
* `docs/kiosk-silverblue/gnome-kiosk-script` — the built-in fallback URL (L28)
moves too, since a client with an unreadable conf file silently uses it;
* `docs/kiosk-silverblue/check-kiosk.sh` — the URL sanity check must accept both
walls: the `case "$URL"` branch that warns unless the URL ends in `/watch`
accepts `/kiosk` as equally right, and the body check that greps
`"Watch · mirror"` also accepts `"Wall · mirror"` (that string is the
`<title>` phase 08 gave `kiosk.html`). Keep the warning wording useful for the
genuine third case: "this is neither wall, check DNS and the nginx vhost".
* `docs/kiosk-silverblue.md` — the top-of-file description and the
`KIOSK_URL=…` example move to `/kiosk`, and state the migration rule in one
sentence: existing clients keep their own `kiosk.conf` until an operator
re-points it and restarts `gdm`, because the installer deliberately preserves a
client's URL.
6. Verify the shell changes without a box: `sh -n` on each script you touched, and run
`docs/kiosk-silverblue/check-kiosk.sh` logic in your head for a `/kiosk` URL — it
must report `ok`, not `warn`. These scripts are the only way anyone reaches a kiosk,
and a syntax error in one is discovered on a machine with no console.
## Testing & Quality
- `uv run pytest tests/e2e/test_remote_control.py -x` green alone, then the full
`-m e2e` (shared session server: close contexts via the fixture's finalizers or the
next suite sees extra tiles), then `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`.
- `sh -n docs/kiosk-silverblue/*.sh docs/kiosk-silverblue/gnome-kiosk-script` → clean.
- `uv run pytest tests/test_app.py` still green — the `/remote` guards from tasks 01/02
must not be relaxed to make anything here pass.
- Coverage: **>90%** on new/modified code; this task's product changes are docs and
shell, its tests are the browser suite.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_remote_control.py` green, including the loop, the pixels, the two-controller convergence and the zero-watcher assertions
- [ ] the TODO item's sentence is now true end to end: navigate a browser to `/kiosk`, and from another device open `/remote` and arrange it — no keyboard, no mouse, at the box
- [ ] `SECURITY.md` §2/§3 describe the new capability and the audio limit, naming the tests that enforce them
- [ ] `README.md` documents `/kiosk` and `/remote`, including that fullscreen is best-effort and state is in-memory
- [ ] a fresh `sudo sh install-kiosk.sh` would point a box at `/kiosk`, and `check-kiosk.sh` reports `ok` for a `/kiosk` URL and for an existing `/watch` one
- [ ] `uv run pytest -m e2e`, `-m realdisplay`, `ruff`, `pyright` all green
- [ ] full test suite **100% passing**
- [ ] no behavior change in completed work