chore(agent): phase roadmap from TODO.md, 3 phases

The one TODO item — a /kiosk endpoint whose state (fullscreen, which
mirror) any client can manipulate — becomes three phases, since it is
three workstreams:

- 07_kiosk_layout_state: the state on the server. `arrange` in, `layout`
  out (staged/fullscreen/followers), Registry policy with a stale-staged
  fallback on teardown, `GET /api/kiosk` for curl-level debugging.
- 08_kiosk_page: the wall made reusable (`static/wall.js`, behaviour-
  preserving) and the chrome-free /kiosk page that arranges itself from
  the state and publishes a local keypress back.
- 09_wall_remote_page: /remote, the controller you open on a phone —
  shows no media and subscribes to nothing, proven by /api/streams'
  watcher count — plus the security line and the kiosk box's new URL.

Locked anchors hold: no third role, state stays in memory (A7), Registry
stays pure (A8), no external assets (A10), no remote audio command
(SECURITY.md §4). PLAN.md untouched, per the user's decision, so its §7
roadmap table does not list 07-09.

LLM-Generated: true
This commit is contained in:
2026-11-03 09:15:12 -05:00
parent 3f93481c54
commit 1f60a6b40c
14 changed files with 1526 additions and 0 deletions
@@ -0,0 +1,90 @@
# 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
@@ -0,0 +1,109 @@
# 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
@@ -0,0 +1,117 @@
# 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
@@ -0,0 +1,119 @@
# 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