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:
@@ -0,0 +1,106 @@
|
||||
# Phase 08 — The `/kiosk` page: a wall that arranges itself
|
||||
|
||||
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk and it will automatically arrange itself to the client's preferences." (the display-client half of that item)
|
||||
**Story:** n/a
|
||||
**Context:** `.agents/PLAN.md` §2 A9 (hand-written ES modules, no framework/bundler), A10 (nothing from outside the origin), A13 (Playwright, `channel="chromium"`); `AGENTS.md` §6 (reuse `net.js` rather than duplicating socket logic; ids must not collide across pages); phase 01 (`01_keyboard_kiosk_control` — the keyboard router, the stage mechanics, the mouseless rule); phase `07_kiosk_layout_state` (the `arrange`/`layout` contract, `hello.kiosk`, `GET /api/kiosk`); `src/mirror/static/watch.js`, `watch.html`, `app.css`, `net.js`; `docs/kiosk-silverblue.md` (the target box: Firefox `--kiosk`, **no keyboard, no mouse, no console**)
|
||||
|
||||
## Objective
|
||||
|
||||
`GET /kiosk`: a chrome-free wall for an unattended display that subscribes to every
|
||||
mirror like `/watch` does and then **does what the server state says** — stages the
|
||||
mirror the state names, fullscreen or not — on arrival and on every change, with no
|
||||
input from anyone standing at the box.
|
||||
|
||||
The phase is deliberately two halves:
|
||||
|
||||
1. **Make the wall reusable** (tasks 01–02). `/kiosk` must not fork 400 lines of
|
||||
tile/WebRTC/stage code (`AGENTS.md` §6: reuse, don't duplicate), so the wall's
|
||||
core moves out of `watch.js` into `static/wall.js` and both pages consume it.
|
||||
Those two tasks change **no behaviour** — phases 01–06's suites are the gate.
|
||||
2. **Build the page on it** (tasks 03–05): the route, the markup, the layout
|
||||
applier, and the browser proof.
|
||||
|
||||
## Rules this page lives by
|
||||
|
||||
* **Arrival is arrangement.** `welcome.layout` is applied before the first roster
|
||||
tile finishes painting. A person who points a kiosk at `/kiosk` must not have to
|
||||
touch it.
|
||||
* **The state is the boss, locally too.** A keypress on the kiosk performs the
|
||||
action *and* publishes it (`arrange`), so the controller and the display cannot
|
||||
disagree — same as every other shared state: one writer path, everyone re-reads.
|
||||
* **Fullscreen is best-effort, the stage is the guarantee.** A remote command
|
||||
carries no user activation, so `requestFullscreen()` may be refused; the in-page
|
||||
stage fills the viewport anyway and the Silverblue window manager already forces
|
||||
the window fullscreen (`docs/kiosk-silverblue/window-config.ini`). The page asks
|
||||
and never blocks on the answer — exactly the stance phase 01 documented for `f`.
|
||||
* **Never invent state.** No auto-staging of the newest mirror, no remember-last
|
||||
across a server restart. An empty state means the wall.
|
||||
* **No audio commands.** The layout carries no sound field (phase 07,
|
||||
`SECURITY.md` §4); a display never becomes audible because a stranger asked.
|
||||
* **The chrome stays off the screen.** No header bar, no "Share a screen" button,
|
||||
no counts in a title bar — a display shows mirrors. The three shared ids
|
||||
(`net`, `notice`, `toast`) are the only elements `/kiosk` may share with another
|
||||
page, and `#net` stays because the connection state is the one thing that must be
|
||||
legible in a screenshot of a wall nobody is looking at.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `07_kiosk_layout_state` (todo) — the `arrange`/`layout` frames, `hello.kiosk`,
|
||||
`welcome.layout`, the `followers` count, `GET /api/kiosk`. Task 01 of this phase
|
||||
has no dependency on it and may run first, but the phase is not complete until
|
||||
07 exists.
|
||||
- `01_keyboard_kiosk_control` (complete) — the router, `stage()`/`unstage()`, the
|
||||
mouseless rule and the pointer-free suite this phase extracts and extends.
|
||||
- `06_sharer_quality_presets` (complete) — the current behaviour of the wall.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_extract_wall_tiles_and_stage.md` — the tile/peer/stage/mute core of
|
||||
`watch.js` becomes `static/wall.js` (`createWall()`); `app.css` re-keys the
|
||||
stage rules from `#watch-stage` to a `.stage` class. No behaviour change.
|
||||
2. `02_extract_wall_key_router.md` — the single `keydown` switch becomes
|
||||
`installWallKeys()` in `wall.js`, dispatching named commands to the page;
|
||||
`/watch`'s key map is byte-identical in effect.
|
||||
3. `03_kiosk_page_and_route.md` — `static/kiosk.html`, `GET /kiosk` in `app.py`
|
||||
(and in `PAGES` for `no-store`), and every structural guard in `test_app.py`
|
||||
extended to a third page.
|
||||
4. `04_kiosk_layout_applier.md` — `static/kiosk.js`: `hello{kiosk:true}`, the wall
|
||||
from `createWall()`, `applyLayout()` on `welcome`/`layout`, and local keys
|
||||
published back with `arrange`.
|
||||
5. `05_kiosk_layout_e2e_and_docs.md` — `tests/e2e/test_kiosk_layout.py`: a real
|
||||
sharer, two kiosk displays (isolated contexts, hence separate sockets and peer
|
||||
ids), and the assertion that one display's keypress re-arranges the other,
|
||||
proved on the second display's decoded pixels; plus the `/kiosk` docs.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **Refactor gate (tasks 01–02)**: `tests/e2e/test_keyboard_wall.py`,
|
||||
`test_live_mirror.py`, `test_audio_share.py`, `test_stage_autofade.py`,
|
||||
`test_share_layout.py` pass **unedited**. A changed assertion in one of them
|
||||
during a "behaviour-preserving" extraction is a bug report, not a fix.
|
||||
- **Structure gate (task 03)**: `test_app.py`'s guards extended — page renders,
|
||||
`no-store`, `node --check`, every `$("#id")` exists, ids do not collide across
|
||||
*three* pages, `<video>` is `muted playsinline`, nothing from a CDN, the address
|
||||
gate refuses `/kiosk` like the rest of the app. A new guard: `static/wall.js`
|
||||
contains **no** `$("#…")` — one module serving two pages takes its elements as
|
||||
arguments or it will look them up in the wrong document.
|
||||
- **Behaviour gate (tasks 04–05)**: a dedicated Playwright suite, pointer-free like
|
||||
phase 01's (a mouse on the kiosk would defeat the point), asserting on the
|
||||
*second* display's `<video>` (`videoWidth`, distinct frames via the `conftest`
|
||||
`frames_matching()` helper) and on `data-layout`/`data-fullscreen` — never on
|
||||
whether a handler ran. Fullscreen is asserted on the stage class, not on
|
||||
`document.fullscreenElement`, which the browser may legitimately refuse.
|
||||
- Coverage: **>90%** on new/modified code. The Python surface added here is one
|
||||
route; the JS is covered by browser assertions, per `AGENTS.md` §4.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest -m e2e` green, including the new `test_kiosk_layout.py`, with **no existing e2e assertion edited**
|
||||
- [ ] pointing a browser at `/kiosk` and sharing a mirror from a second browser process shows that mirror on the wall with no interaction on the first
|
||||
- [ ] a keypress on one `/kiosk` page re-arranges a second `/kiosk` page (the shared-state property, asserted cross-page, not in one page's DOM)
|
||||
- [ ] the staged mirror going away returns both displays to the wall (`data-layout="wall"`)
|
||||
- [ ] `/kiosk` renders no header, no share link, and refuses a blocked address with the same explanation page as `/watch`
|
||||
- [ ] `uv run pytest tests/test_app.py` green with the guards extended to three pages, and `wall.js` proven free of document-scoped lookups
|
||||
- [ ] all four gates from `AGENTS.md` §4 green, `MIRROR_REALDISPLAY=1 -m realdisplay` included
|
||||
- [ ] full test suite **100% passing**, coverage >90% on new code
|
||||
- [ ] README.md documents `/kiosk`; `PLAN.md` untouched
|
||||
@@ -0,0 +1,125 @@
|
||||
# Task 01 — Extract the wall (tiles, peers, stage) into `static/wall.js`
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) … [that] will automatically arrange itself" (the wall has to exist twice before a page can arrange it)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Move the wall's core out of `watch.js` into a reusable `static/wall.js` module that
|
||||
takes its elements and its socket from the page, and have `watch.js` consume it.
|
||||
**Pure refactor: `/watch` must behave and look exactly as it did.** The kiosk page is
|
||||
impossible without this, and forking the tile/WebRTC code instead would violate
|
||||
`AGENTS.md` §6.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/wall.js` (new) — a factory, no document queries, no socket of
|
||||
its own, no top-level side effects beyond the export:
|
||||
|
||||
```js
|
||||
/**
|
||||
* The wall: one tile per live mirror, one RTCPeerConnection per tile, and a
|
||||
* stage the staged tile's own element moves into (so its <video> and its
|
||||
* connection never restart — AGENTS.md §6).
|
||||
*
|
||||
* Nothing here queries the document: the page hands over the elements it owns,
|
||||
* because one module now serves /watch, /kiosk and (phase 09) /remote, whose
|
||||
* ids must not collide (test_the_two_pages_share_no_element_ids…).
|
||||
*
|
||||
* @param {{grid: Element, stageEl: Element, template: HTMLTemplateElement,
|
||||
* send: (payload: object) => boolean,
|
||||
* onChange?: (summary: {mirrors: number, live: number}) => void,
|
||||
* stats?: boolean}} options
|
||||
*/
|
||||
export function createWall(options) { … }
|
||||
```
|
||||
|
||||
Return the surface the pages need — at minimum `absorb(stream)`,
|
||||
`drop(streamId)`, `onSignal(message)`, `stage(streamId, options)`,
|
||||
`unstage(options)`, `setMuted(tile, muted)`, `ordered()`, `selectedId()`,
|
||||
`setSelected(streamId)`, `moveSelection(delta)`, `tile(id)`, `closePeers()`, and
|
||||
a `stagedId()` accessor. Keep the existing function bodies as the implementation;
|
||||
the change is *which object owns the state*, not what the code does.
|
||||
2. `src/mirror/static/wall.js` — move, verbatim except for the `ui.`/`state.` reads
|
||||
the factory replaces, from `watch.js`: `tiles`, `makeTile`, `paintTile`,
|
||||
`dropTile`, `closePeer`, `ensurePeer` (with its `ontrack`/`onicecandidate`/
|
||||
`onconnectionstatechange` handlers and the retry-with-backoff), `onSignal`,
|
||||
`setMuted`, `subscribe`, `absorb`, `orderedTiles`, `stage`, `unstage`,
|
||||
`revealStageControls` + `stageRevealTimer` + `STAGE_REVEAL_IDLE_MS`,
|
||||
`fullscreenExitInFlight`, and the stats ticker. Every comment that explains *why*
|
||||
(the single-MediaStream-per-tile story, the audio-only-on-stage policy, the
|
||||
fullscreen-exit race, "never move `srcObject`, move the element") travels with
|
||||
the code — those comments are why phases 01–06 do not get re-broken.
|
||||
`renderCounts()` does **not** move: it writes `#counts`/`#empty`, which are
|
||||
`/watch` elements. The wall calls `onChange({mirrors, live})` wherever
|
||||
`renderCounts()` is called today, and `watch.js` keeps its own `renderCounts()`
|
||||
as that callback.
|
||||
3. `src/mirror/static/wall.js` — the socket is the page's. Take `send(payload)` and
|
||||
use it wherever the moved code used `state.ws.send(...)`
|
||||
(`subscribe`, ICE candidates, the retry's re-`subscribe`), and guard on its
|
||||
truthiness the way the code guards on `state.ws` today. This is what lets a page
|
||||
whose socket is not open yet build a wall, and what keeps `net.js` as the only
|
||||
place a socket exists (`AGENTS.md` §6).
|
||||
4. `src/mirror/static/watch.js` — delete the moved code, import
|
||||
`{ createWall }` from `./wall.js`, build the wall after `loadConfig()` with the
|
||||
elements already in its `ui` map (`grid`, `stage`, `template`) and
|
||||
`send: (payload) => state.ws.send(payload)`, and call `wall.absorb/drop/onSignal`
|
||||
from the `onMessage` switch, `wall.closePeers()` from `onClose`,
|
||||
`wall.stage/unstage/…` from the key router (task 02 moves the router itself).
|
||||
Keep in `watch.js`: the config load, the socket, `renderCounts`, `setNet`, the
|
||||
help overlay, the `focusin` handler (via `wall.setSelected`), the refusal notice,
|
||||
the share link.
|
||||
5. `src/mirror/static/watch.js` — keep the `fullscreenchange` listener **in the
|
||||
page** (it is a page policy: `/watch` unstages when the browser leaves
|
||||
fullscreen; the kiosk will do something different, phase 08 task 04), calling
|
||||
`wall.unstage()`. The `fullscreenExitInFlight` flag it depends on moves into
|
||||
`wall.js`, so expose the flag's two behaviours through `unstage()`/a small
|
||||
`handleFullscreenChange(hasFullscreen)` hook — the shape is yours, but the race
|
||||
documented at watch.js L345–352 must stay closed.
|
||||
6. `src/mirror/static/app.css` — the stage rules are keyed to the `/watch` id and
|
||||
would not style a second page's stage. Add `class="stage"` to
|
||||
`<div id="watch-stage">` in `watch.html` (keep the id — the e2e suites and
|
||||
`watch.js` both select it) and re-key these selectors from `#watch-stage` to
|
||||
`.stage`, changing nothing else in the blocks: `#watch-stage { … }` (L565),
|
||||
`body.staged #watch-stage` (L574), `#watch-stage .tile` (L577),
|
||||
`#watch-stage .tile video`, `#watch-stage .tile .cap`, and the
|
||||
`#watch-stage .tile[data-audio="on"|"off"]::after` pair (L471, L477). No new
|
||||
`display` declarations, no `[hidden]` rules — `TestStylesheet` guards those.
|
||||
7. `src/mirror/static/watch.js` — the dead `if (ui.unstage) …` branch at L473: it is
|
||||
unreachable today (`ui` has no `unstage` key) and moving it would launder dead
|
||||
code into the new module. Delete it and leave a one-line comment saying the exit
|
||||
control is the per-tile `.exit` button, so nobody re-adds a second exit.
|
||||
8. Do **not** add `/kiosk`, `kiosk.html` or `kiosk.js` in this task, and do not
|
||||
change any key binding, DOM structure of the tile, or visual rule beyond the
|
||||
`#watch-stage` → `.stage` re-key.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **The gate is the existing suites, unedited** (`AGENTS.md` §4/§5):
|
||||
`uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage or share_layout"`,
|
||||
then the full `-m e2e`. These cover tile rendering, the stage transitions, audio
|
||||
policy, the autofade timing, and the peer-connection probes through
|
||||
`window.__probe` — i.e. exactly the machinery being moved.
|
||||
- `tests/test_app.py` — `test_scripts_parse_as_es_modules` gains `"wall.js"`,
|
||||
`test_static_assets_are_served` gains `"wall.js"`.
|
||||
`test_every_element_the_script_reaches_for_exists` must still pass for
|
||||
`watch.js` (the ids it still reaches for exist) — and add the new module rule:
|
||||
*`wall.js` must contain no `$("#…")`*, because a module shared by pages with
|
||||
different ids may never look an element up in the document it happens to be
|
||||
loaded in. Assert it as a source-level guard next to the others, with a comment
|
||||
naming `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` as the
|
||||
reason it exists.
|
||||
- Visual check while working: `/watch` at 1280×800 and 390×844, staged and
|
||||
unstaged — the stage must be pixel-equivalent (the re-key must not change
|
||||
specificity in a way that reorders a rule).
|
||||
- Coverage: **>90%** on new/modified code — the moved code is covered by the e2e
|
||||
suites that already exercise it; the module has no new logic in this task.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest -m e2e` green with **zero assertions changed** in any existing file
|
||||
- [ ] `uv run pytest tests/test_app.py` green, including `wall.js` in the parse/asset lists and the new "no `$("#…")` in `wall.js`" guard
|
||||
- [ ] `/watch` looks and behaves as before: grid, click-to-stage, `Esc`/`g`, the autofading overlay, one audible mirror
|
||||
- [ ] `git diff --stat` shows the moved lines as moves, not rewrites (a reviewer should be able to read the diff as "cut, paste, re-point at `options`")
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task 02 — The key router becomes `installWallKeys()` in `wall.js`
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "the kiosk doesn't have a keyboard/mouse plugged in most of the time so I can't full screen/switch views" (the display must accept the same keys *and* report them)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Move `/watch`'s single `keydown` switch into `wall.js` as `installWallKeys()`, so
|
||||
`/kiosk` gets the identical key map for free and — this is the point — can publish
|
||||
what a key did instead of only performing it. Again **no behaviour change on
|
||||
`/watch`**: phase 01's pointer-free suite is the gate.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/wall.js` — export the router, keeping the invariant phase 01
|
||||
wrote down (one listener, one switch; every command is a case here, never a new
|
||||
listener):
|
||||
|
||||
```js
|
||||
/**
|
||||
* The one keydown listener for a wall page. It maps keys to named commands and
|
||||
* hands them to the page; it never decides what a command does to shared state.
|
||||
*
|
||||
* A page returns `true` only when the key actually changed something — the
|
||||
* router calls `preventDefault()` on exactly those, which is today's discipline
|
||||
* on /watch (`g` unstaged and `Esc` with nothing open are not "handled" and keep
|
||||
* their browser default).
|
||||
*
|
||||
* Never intercepted: any chord with Ctrl/Cmd/Alt (back, reload, copy belong to
|
||||
* the browser) and every key inside an <input>/<textarea>/<select>/contenteditable.
|
||||
*
|
||||
* @param {{wall: object, onCommand: (command: string, detail: {key: string, index?: number, event: KeyboardEvent}) => boolean, root?: Document}} options
|
||||
*/
|
||||
export function installWallKeys(options) { … }
|
||||
```
|
||||
|
||||
2. `src/mirror/static/wall.js` — move `onWallKey`'s switch body into the module,
|
||||
translating each case into a command name, in this order and with these names
|
||||
(they are the contract `/kiosk` publishes against):
|
||||
|
||||
| key(s) | command | detail |
|
||||
|---|---|---|
|
||||
| `j` `arrowdown` | `next` | — (module performs `wall.moveSelection(1)` itself, then reports handled) |
|
||||
| `k` `arrowup` | `previous` | — |
|
||||
| `enter` | `stage` | — |
|
||||
| `f` | `fullscreen` | — |
|
||||
| `g` | `wall` | — |
|
||||
| `n` / `p` | `step_next` / `step_previous` | — |
|
||||
| `m` | `mute` | — |
|
||||
| `?` | `help` | — |
|
||||
| `escape` | `escape` | — |
|
||||
| `1`–`9` | `jump` | `index` (0-based) |
|
||||
|
||||
Keys are matched case-insensitively exactly as today (a caps-locked kiosk, a
|
||||
remote hand holding Shift). `next`/`previous` stay module-side because moving the
|
||||
selection is wall state, not page policy — the page still sees the command, so a
|
||||
page that wants to publish the new cursor can.
|
||||
3. `src/mirror/static/wall.js` — the "any keypress while staged reveals the
|
||||
overlay" behaviour (today `if (state.staged) revealStageControls();` before the
|
||||
switch) moves with it, *before* dispatch, and must not `preventDefault` —
|
||||
an unhandled key keeps flashing the caption, which is the debuggable outcome
|
||||
phase 01 documented.
|
||||
4. `src/mirror/static/wall.js` — commands that are pure wall operations
|
||||
(`stage`, `wall`, `step_*`, `mute`, `jump`) get a **default implementation in the
|
||||
module** used when the page returns nothing for them, so `onCommand` can be a
|
||||
thin observer. `stage`/`jump` resolve their target with today's rule (the
|
||||
selected tile, else the first; `toast` when the wall is empty — `toast` comes
|
||||
from `net.js`, which `wall.js` may import). `help` and `escape` have **no**
|
||||
default: they are page policy (`/watch` closes the `?` overlay before leaving the
|
||||
stage), and a page that ignores them must not break the router.
|
||||
5. `src/mirror/static/watch.js` — delete `onWallKey`, `moveSelection`,
|
||||
`stageSelectedOrFirst`, `stepStage`, `toggleHelp`'s key wiring and the
|
||||
`document.addEventListener("keydown", …)` line; call
|
||||
`installWallKeys({ wall, onCommand })` instead, where `onCommand` keeps today's
|
||||
exact behaviour for `help` (toggle `#wall-help`, return whether it changed) and
|
||||
`escape` (overlay first, then `wall.unstage()`, return whether either happened),
|
||||
and returns `false`/nothing for the commands the module already performs.
|
||||
`?`'s "focus is deliberately never touched" comment moves with the behaviour.
|
||||
6. `src/mirror/static/watch.js` — keep `focusTile`, `setSelected` and the grid's
|
||||
`focusin` handler as they are now that they call into `wall` (task 01); Tab must
|
||||
still agree with `j`/`k`.
|
||||
7. No new page, no route, no kiosk markup in this task.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **The gate**: `uv run pytest tests/e2e/test_keyboard_wall.py` **unedited** — the
|
||||
file drives every binding with `page.keyboard.press()` and asserts the DOM result,
|
||||
and its meta-test forbids the pointer in itself. If a case fails, the router moved
|
||||
wrong; do not touch the test.
|
||||
- `tests/test_app.py` — add one source-level guard in the spirit of the existing
|
||||
ones: `installWallKeys` is defined in `wall.js` and `watch.js` contains **no**
|
||||
`addEventListener("keydown"` — the "one listener per wall page" rule should fail
|
||||
the build when someone adds a second one, which was phase 01's whole point.
|
||||
- `uv run pytest -m e2e -k "keyboard or live_mirror or audio or stage"` green, then
|
||||
the whole `-m e2e`.
|
||||
- Coverage: **>90%** on new/modified code (the router is exercised by the browser
|
||||
suite on every binding).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] every key in phase 01's key map does on `/watch` exactly what it did before, asserted by `tests/e2e/test_keyboard_wall.py` with no edit
|
||||
- [ ] `grep -n 'addEventListener("keydown"' src/mirror/static/*.js` shows exactly one hit, inside `wall.js`
|
||||
- [ ] `g` when already unstaged and `Esc` with nothing open still do not `preventDefault` (browser default preserved)
|
||||
- [ ] `uv run pytest tests/test_app.py` green including the new single-listener guard
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,118 @@
|
||||
# Task 03 — `GET /kiosk` and the page that hosts the wall
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "Add a /kiosk endpoint … so I can just navigate to that endpoint on the kiosk"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The endpoint and the markup: a chrome-free page whose only job is to hold a wall and
|
||||
expose the state a test (and a screenshot) can read. No layout behaviour yet —
|
||||
task 04 writes `kiosk.js` against this skeleton.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/app.py` — the route, beside `/watch`, in the same shape:
|
||||
|
||||
```python
|
||||
@app.get("/kiosk", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def kiosk_page() -> FileResponse:
|
||||
"""A wall for an unattended display: subscribes like /watch and arranges
|
||||
itself from the shared layout (phase 07/08)."""
|
||||
return _page("kiosk.html")
|
||||
```
|
||||
|
||||
Add `"/kiosk"` to the `PAGES` tuple so the middleware keeps sending
|
||||
`Cache-Control: no-store` on it, like the other pages.
|
||||
2. `src/mirror/static/kiosk.html` (new) — model it on `watch.html` (same `<head>`,
|
||||
`/static/app.css`, the same inline `data:` favicon — **no** external asset, A10),
|
||||
`<title>Wall · mirror</title>` — `docs/kiosk-silverblue/check-kiosk.sh` proves a
|
||||
client is showing what it should by grepping the served page's `<title>`, so this
|
||||
string is a contract, not a label (phase 09 task 03 widens that check to accept
|
||||
it) — and these elements and nothing else:
|
||||
|
||||
* `<body class="kiosk" data-layout="wall" data-fullscreen="false">` — the two
|
||||
layout knobs, mirrored onto the body so a screenshot and a test can read the
|
||||
state without reaching into JS (`docs/kiosk-silverblue/dim-probe.py` judges a
|
||||
wall from a screenshot; `data-audio` on a tile exists for the same reason).
|
||||
* `<main class="grid" id="kiosk-grid" hidden>` and `<div class="empty" id="kiosk-empty">`
|
||||
— the classes are what the stylesheet and `wall.js` select; the ids are page-unique.
|
||||
The empty state reads "No mirrors live" plus
|
||||
`Share from <code id="kiosk-share-url">…</code>` — `kiosk.js` fills that with
|
||||
`location.origin + "/mirror"`, because nobody is standing at the box to click
|
||||
anything and the person reading the screen needs a URL to type elsewhere.
|
||||
* `<div class="stage" id="kiosk-stage" aria-live="polite">` — **`class="stage"`
|
||||
is mandatory**: task 01 re-keyed the staging rules off `#watch-stage`, and
|
||||
without the class the kiosk stage would be an unstyled `div`.
|
||||
* the shared chrome ids and only these: `<span class="net" id="net"
|
||||
data-state="connecting"><i></i><span>connecting…</span></span>`,
|
||||
`<div class="notice" id="notice" hidden>`, `<div class="toast" id="toast" hidden>`.
|
||||
`#net` is not decoration — it is how a wall reports "the server is gone" in a
|
||||
screenshot, and every e2e `device()` helper waits on it.
|
||||
* `<template id="kiosk-tile-template">` — a copy of `watch.html`'s
|
||||
`#tile-template` tile (`article.tile[tabindex=0][data-state=connecting]`,
|
||||
`<video autoplay playsinline muted>`, `.badge`, `.actions` with `.audio` and
|
||||
`.expand`, `.cap` with `.title`/`.sub`/`.exit`). Name it
|
||||
`kiosk-tile-template`, never `tile-template`: the id-collision guard is the
|
||||
reason two pages may not share an id, and relaxing it for convenience is how
|
||||
the `#stage` bug happened.
|
||||
* `<div class="hud" id="kiosk-hud" aria-live="polite">` — one line the script
|
||||
owns: `N mirrors · wall` / `… · <label> · fullscreen`. It is the wall's
|
||||
self-description for a room with no keyboard.
|
||||
* **No** `<header class="bar">`, no counts in a title bar, no "Share a screen"
|
||||
link: a display shows mirrors.
|
||||
* `<script type="module" src="/static/kiosk.js">` — task 04 writes it; create a
|
||||
placeholder `kiosk.js` in this task that only imports `loadConfig` and
|
||||
`refusalNotice` from `./net.js` so the page parses, serves and shows a refusal
|
||||
notice, and nothing more.
|
||||
3. `src/mirror/static/app.css` — the new page's chrome only: `.kiosk` (no header
|
||||
space, full-bleed grid), `.kiosk .net` (fixed top-right, the same pill), `.hud`
|
||||
(fixed bottom-left, `--mono`, `--fg-dim`, small, `pointer-events: none`). Do not
|
||||
duplicate any stage/tile rule — those are `.stage`/`.tile` now. Add no rule whose
|
||||
selector mentions `[hidden]` and no `display` declaration on a component the
|
||||
stylesheet already owns (`TestStylesheet` guards both).
|
||||
4. `src/mirror/static/index.html` — a third `<a class="card" href="/kiosk">`
|
||||
("GET /kiosk — An unattended wall") so the endpoint is discoverable from `/`.
|
||||
Cards carry no ids, so the id guard is unaffected.
|
||||
5. Do **not** touch `/remote` (phase 09), `watch.html`, or the layout applier.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_app.py` — extend the structural guards to the third page; each of
|
||||
these is an existing test gaining a case, not a new invention:
|
||||
* `TestPages::test_pages_render` → `("/kiosk", "kiosk-tile-template")`;
|
||||
* `test_pages_are_not_cacheable` → `client.get("/kiosk")` is `no-store`;
|
||||
* `test_static_assets_are_served` → `kiosk.js`;
|
||||
* `test_scripts_parse_as_es_modules` → `"kiosk.js"` (`node --check`);
|
||||
* `test_every_element_the_script_reaches_for_exists` → `("kiosk.js", "kiosk.html")`;
|
||||
* `test_both_pages_load_the_stylesheet_and_their_module` → the `kiosk.html` /
|
||||
`kiosk.js` pair (rename it if the name now lies — the guard matters more than
|
||||
the name);
|
||||
* `test_videos_are_muted_and_inline_so_autoplay_is_not_blocked` → `kiosk.html`
|
||||
(both autoplay rules apply to a kiosk: iOS/Safari blocks the wall otherwise);
|
||||
* `test_the_two_pages_share_no_element_ids_but_the_shared_chrome` → add
|
||||
`kiosk.html` to the ids map and assert `kiosk ∩ watch`, `kiosk ∩ mirror`,
|
||||
`kiosk ∩ index` are all exactly `{"net","notice","toast"}`. Keep the existing
|
||||
assertions verbatim; `#kiosk-*` prefixes are what make these pass.
|
||||
* add `#kiosk-grid`, `#kiosk-stage`, `#kiosk-hud` to the "elements a page must
|
||||
have" style check if the file has one, and assert `class="stage"` appears on
|
||||
`#kiosk-stage` **and** `#watch-stage` — a page whose stage lost the class
|
||||
renders a full-size unstyled video, which is exactly the class of bug the
|
||||
stylesheet guards exist for.
|
||||
- `tests/test_app.py::TestAddressGate` — `/kiosk` joins the parametrised "a refused
|
||||
address gets no page, no roster, no assets" list, and one test that an admitted
|
||||
address gets `200`.
|
||||
- `tests/e2e/test_live_mirror.py` — the refused-address parametr list
|
||||
`("/mirror", "/watch")` gains `"/kiosk"`, and its
|
||||
`expect(page.locator("#start, #grid, #notice")).to_have_count(0)` selector list
|
||||
gains `#kiosk-grid` / `#kiosk-stage` so the assertion actually covers the new page.
|
||||
- Coverage: **>90%** on new/modified Python (one route — covered by the page tests).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `curl -s localhost:8055/kiosk | head -20` serves the page and `curl -sI` shows `cache-control: no-store`
|
||||
- [ ] opening `/kiosk` in a browser shows an empty-state wall with the share URL in it, no header bar, the net pill top-right, and no console errors
|
||||
- [ ] `uv run pytest tests/test_app.py` green with every guard above extended to three pages
|
||||
- [ ] `uv run pytest -m e2e` green (existing suites unedited except the two parametrisations named here)
|
||||
- [ ] `grep -c 'id="' src/mirror/static/kiosk.html` ids appear in no other page except `net`/`notice`/`toast` — which the extended guard now proves
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,122 @@
|
||||
# Task 04 — `kiosk.js`: obey the layout, publish the keystrokes
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "state (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately 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
|
||||
|
||||
The behaviour: `/kiosk` opens, subscribes to every mirror, and arranges itself from
|
||||
`welcome.layout` without being touched; every `layout` frame that follows re-arranges
|
||||
it; and a keypress on the box — rare, but it happens when a keyboard is plugged in to
|
||||
diagnose — performs the change **and** publishes it, so the controller and the display
|
||||
converge instead of disagreeing.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/kiosk.js` — replace task 03's placeholder. Module shape, in
|
||||
the order `watch.js` uses it: `loadConfig()` → `rtcConfig()` → `ui` map →
|
||||
`createWall()` → `connectSignaling()` → `installWallKeys()`. The `ui` map is
|
||||
`grid`, `stage`, `template`, `empty`, `shareUrl`, `hud`, `net`, `toast`, `notice`
|
||||
— every one of them exists in `kiosk.html` (the element guard proves it).
|
||||
2. `src/mirror/static/kiosk.js` — announce as a display, not a controller:
|
||||
`state.ws.send({type:"hello", role:"viewer", kiosk:true, name:"", device: deviceLabel()})`.
|
||||
`deviceLabel()` comes from `net.js`. `kiosk:true` is what feeds `followers`, and a
|
||||
`/kiosk` page must be counted while a `/remote` page must not.
|
||||
3. `src/mirror/static/kiosk.js` — the applier, the one function that touches the
|
||||
stage:
|
||||
|
||||
```js
|
||||
/**
|
||||
* Make the page match the server's layout. Idempotent: a frame that repeats
|
||||
* what is already applied changes nothing, so a controller that re-sends (or a
|
||||
* follower join that re-broadcasts) cannot re-request fullscreen forever.
|
||||
*
|
||||
* The caller is never trusted to have waited: `staged` names a live mirror when
|
||||
* the server accepts it, and may have died on the way here, so an unknown id
|
||||
* keeps the wall and is retried by the next roster frame.
|
||||
*/
|
||||
function applyLayout(next) { … }
|
||||
```
|
||||
|
||||
* `next.staged` is a tile the wall has → `wall.stage(id)`; is `null` →
|
||||
`wall.unstage()`; names something unknown → unstage and remember the intent,
|
||||
re-applying whenever `absorb()` gains that tile.
|
||||
* `next.fullscreen` → request on the same target `/watch` uses (the stage element
|
||||
when staged, `document.documentElement` for the wall), `.catch(() => {})` and
|
||||
never awaited. A remote command carries no user activation: the request is a
|
||||
courtesy, the `.stage` CSS is the guarantee, and the Silverblue window is
|
||||
already fullscreen (`docs/kiosk-silverblue/window-config.ini`). Say exactly that
|
||||
in a comment — the next reader will otherwise "fix" it by awaiting.
|
||||
* Mirror both knobs onto the body — `document.body.dataset.layout =
|
||||
staged ? "stage" : "wall"` and `document.body.dataset.fullscreen = "true"|"false"`,
|
||||
and set `document.title` to the staged mirror's label (`"mirror — <label>"`, else
|
||||
`"mirror — wall"`), because a window list and a screenshot are the only views a
|
||||
headless box offers.
|
||||
4. `src/mirror/static/kiosk.js` — apply on arrival and on every change:
|
||||
`welcome` → `applyLayout(message.layout)` (before `renderCounts()`-equivalent work,
|
||||
so the wall never flashes between the roster and the stage), `layout` →
|
||||
`applyLayout(message)`. `stream_added`/`stream_updated` → `wall.absorb()` then
|
||||
`applyLayout(state.layout)` so a remembered intent lands the moment its tile does.
|
||||
`stream_removed` → `wall.drop()` (the server pushes the cleared layout behind it —
|
||||
do not "helpfully" unstage locally, or two frames will fight).
|
||||
5. `src/mirror/static/kiosk.js` — the keys, via `installWallKeys({wall, onCommand})`,
|
||||
each entry doing the local change **and** publishing the same intent:
|
||||
|
||||
| command | local effect | published `arrange` |
|
||||
|---|---|---|
|
||||
| `next` / `previous` | (module moves the selection) | none — the cursor is local to this page |
|
||||
| `stage` (`Enter`) | `wall.stage(cursor ?? first)` | `{staged: <id>}` |
|
||||
| `wall` (`g`), `escape` | `wall.unstage()` | `{staged: null}` |
|
||||
| `step_next` / `step_previous` | stage the neighbour | `{staged: <id>}` |
|
||||
| `jump` (`1`–`9`) | stage the Nth | `{staged: <id>}` |
|
||||
| `fullscreen` (`f`) | re-request fullscreen | `{fullscreen: true}` |
|
||||
| `mute` (`m`) | `wall.setMuted(tile, …)` | **none** |
|
||||
|
||||
`mute` is never published, and the code must say why in one comment line:
|
||||
`SECURITY.md` §4 — sound only ever starts from a gesture on the device that plays
|
||||
it, so a display must not become audible because another machine asked. `help`
|
||||
(`?`) has no target on this page (no overlay markup) and returns not-handled.
|
||||
6. `src/mirror/static/kiosk.js` — what the page does **not** publish: the browser's
|
||||
own fullscreen exits. Register no `fullscreenchange` policy that unstages (the WM
|
||||
dropping fullscreen is not a person asking to leave the stage — on `/watch` that
|
||||
is a deliberate choice, on a wall it would fight the state) and do not echo
|
||||
`fullscreen:false` because the browser refused a request; that would make a
|
||||
controller's "fullscreen" button look broken and would be reporting the browser's
|
||||
refusal as a user preference.
|
||||
ASSUMPTION: publish what a person asked for, not what the browser conceded.
|
||||
7. `src/mirror/static/kiosk.js` — the honest-but-quiet details:
|
||||
* `onChange({mirrors, live})` → `#kiosk-hud` text (`3 mirrors · wall`,
|
||||
`3 mirrors · Alice's laptop · fullscreen`) and the `#kiosk-grid`/`#kiosk-empty`
|
||||
`hidden` toggles `/watch` does in `renderCounts()`;
|
||||
* `#kiosk-share-url` gets `location.origin + "/mirror"` on load;
|
||||
* `refusalNotice(config)` → `showNotice(ui.notice, …)` and never open a socket,
|
||||
exactly as `/watch` does;
|
||||
* `onClose` → `wall.closePeers()` and mark the tiles stale; the next `welcome`
|
||||
re-applies the layout, which is what makes a server restart recover on its own.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_app.py` — the element guard from task 03 already forces every
|
||||
`$("#…")` in `kiosk.js` to exist; add one source-level guard that `kiosk.js`
|
||||
**never** sends a `mute`/audio field in an `arrange` frame (assert no `arrange`
|
||||
object literal in the file contains `muted`/`audio`) — the security rule should
|
||||
fail the build, not live in a comment.
|
||||
- `uv run pytest -m e2e` — `/watch`'s behaviour must be untouched by this task; the
|
||||
new page's behaviour is covered in task 05.
|
||||
- Manual, before task 05 exists: `uv run mirror serve`, two browser windows, one on
|
||||
`/kiosk`; drive the state with `websocat` (`{"type":"arrange","staged":"<id>"}`) and
|
||||
watch the page stage it — AGENTS.md §8's "reproduce at the lowest layer" ladder
|
||||
reversed: prove the layer works before automating it.
|
||||
- Coverage: **>90%** on new/modified code — the applier is exercised by task 05's
|
||||
suite; the guards above cover the security rules statically.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] a `/kiosk` tab opened **after** an `arrange` is already showing that mirror when it finishes loading (no flash of the wall)
|
||||
- [ ] an `arrange` sent from `websocat` re-arranges an open `/kiosk` tab within one frame
|
||||
- [ ] a keypress on `/kiosk` (`Enter`, `g`, `n`, `p`, `1`–`9`, `f`) changes `GET /api/kiosk` — the display is a first-class controller
|
||||
- [ ] `m` on `/kiosk` mutes locally and leaves `/api/kiosk` byte-identical
|
||||
- [ ] `document.body.dataset.layout` / `dataset.fullscreen` agree with what is on screen at every point a test can observe
|
||||
- [ ] `uv run pytest tests/test_app.py` and the full `-m e2e` suite green, existing assertions unedited
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -0,0 +1,92 @@
|
||||
# Task 05 — `tests/e2e/test_kiosk_layout.py` and the kiosk docs
|
||||
|
||||
**Phase:** `08_kiosk_page` · **Source:** `TODO.md:3` — "will update immediately 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 two properties the TODO item actually claims — **it arranges itself on
|
||||
arrival** and **one client's action moves another client's display** — in a browser,
|
||||
on pixels, with no pointer anywhere near the display. Then write down how to point a
|
||||
real kiosk box at it.
|
||||
|
||||
## Work
|
||||
|
||||
1. `tests/e2e/test_kiosk_layout.py` (new) — `pytestmark = [pytest.mark.e2e,
|
||||
pytest.mark.timeout(180)]`, and the file's own `device()` fixture copied in the
|
||||
suite's established shape (isolated context per device, `page.on("pageerror")` and
|
||||
`console` errors collected from the first line of each test — `AGENTS.md` §8),
|
||||
waiting on `#net` `data-state=online`.
|
||||
Isolated **contexts** are the right unit here: each is its own socket and peer id,
|
||||
which is what "another client" means to the server. The media path itself is
|
||||
already proven by `test_live_mirror.py`; this suite proves the *state* path and
|
||||
still asserts on real decoded frames so nothing is taken on trust.
|
||||
2. `tests/e2e/test_kiosk_layout.py` — the cases:
|
||||
* **arrival is arrangement**: sharer on `/mirror` + `start_sharing()`; drive the
|
||||
state from a driver page (`page.evaluate` over a plain `WebSocket` to `/ws`,
|
||||
`hello` then `{"type":"arrange","staged":<id>,"fullscreen":true}`); *then* open
|
||||
`/kiosk`; assert it settles staged — `body[data-layout="stage"]`,
|
||||
`#kiosk-stage .tile[data-stream="<id>"]`, and
|
||||
`frames_matching(kiosk, "#kiosk-stage video")` showing `width > 0` and
|
||||
`distinct >= 2`. The frame assertion is what proves media crossed to the
|
||||
display, not just a CSS class (`AGENTS.md` §5).
|
||||
* **immediate update across clients**: two `/kiosk` pages, no input on the second;
|
||||
press `Enter` on the first (a keypress, so the display stays pointer-free);
|
||||
assert the second page's `data-layout` flips to `stage`, its staged tile id
|
||||
matches, and `GET /api/kiosk` reports the same id — one action, two displays,
|
||||
one source of truth.
|
||||
* **stepping**: `n` then `p` on page A move page B through the mirrors (start two
|
||||
sharers; assert the staged `data-stream` on B changes and wraps).
|
||||
* **teardown is a state change**: `stop_sharing()` on the sharer → both kiosk pages
|
||||
return to `body[data-layout="wall"]` and `/api/kiosk` reports
|
||||
`{"staged": null, …}` — no display may sit on a dead tile.
|
||||
* **followers**: with one `/kiosk` open, `/api/kiosk` says `followers: 1`; open a
|
||||
`/watch` page (a plain viewer, not a display) and it still says `1`; close the
|
||||
kiosk and it says `0`.
|
||||
* **nothing lies**: every case asserts `errors == []`.
|
||||
3. `tests/e2e/test_kiosk_layout.py` — keep it pointer-free and say so in the module
|
||||
docstring, in phase 01's words ("a test that reaches for the mouse proves nothing
|
||||
about a kiosk"); add the same style of self-guard the keyboard suite uses if it
|
||||
fits (scan this file for `.click(` and fail on it) — the driver's raw `WebSocket`
|
||||
calls in `page.evaluate` are not a pointer and must be allowed explicitly rather
|
||||
than by loosening the rule.
|
||||
4. Do **not** assert on `document.fullscreenElement` anywhere: headless Chromium may
|
||||
refuse an element fullscreen without user activation and the real kiosk's window
|
||||
manager forces fullscreen anyway (`docs/kiosk-silverblue/window-config.ini`).
|
||||
Assert `body[data-fullscreen]` and the `.stage` state, which are what the app
|
||||
controls. A comment must say this, or the next reader will "strengthen" the test
|
||||
into a flake.
|
||||
5. `docs/kiosk-silverblue.md` — a section, *"Two endpoints: `/watch` and `/kiosk`"*:
|
||||
`/watch` is a person operating a wall, `/kiosk` is a display obeying a wall, the
|
||||
keys still work on `/kiosk` for the moment a keyboard is plugged in, and the
|
||||
controller side arrives in phase 09 (`/remote`). Point out the migration rule
|
||||
honestly: existing clients keep their own `MIRROR_KIOSK_URL`, so nothing changes
|
||||
under a box until its `kiosk.conf` is re-pointed and `gdm` restarted. Do **not**
|
||||
change `install-kiosk.sh`, `kiosk.conf`, `gnome-kiosk-script` or `check-kiosk.sh`
|
||||
in this phase — the default URL moves in phase 09, when there is a way to drive it.
|
||||
6. `README.md` — the page table gains the `/kiosk` row; the "Keys" section gains one
|
||||
sentence: the wall keys work on `/kiosk` too, and a change made there is what every
|
||||
other display follows.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `uv run pytest tests/e2e/test_kiosk_layout.py -x` green on its own, then the whole
|
||||
`-m e2e` (the suites share one session-scoped server, so a leaked peer in the new
|
||||
file will show up as a tile count in someone else's test — clean up contexts via
|
||||
the fixture's finalizers, as the existing suites do).
|
||||
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` stays green: `/kiosk` must not
|
||||
have changed what a real capture looks like to a viewer.
|
||||
- Coverage: this task adds no product code; it is the **behavioural** coverage the
|
||||
phase's JS relies on (the project's rule — media and page behaviour are asserted in
|
||||
a browser, not simulated in Python).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest tests/e2e/test_kiosk_layout.py` green, including the arrival case and the two-display case
|
||||
- [ ] the two-display test passes with the second display never receiving a `click()`, `focus()` or `hover()` from the test
|
||||
- [ ] `stop_sharing()` returns both kiosk pages to `data-layout="wall"` within the assertion timeout (no stale tile, no dead `<video>`)
|
||||
- [ ] `/api/kiosk`'s `followers` counts displays only, asserted over a real socket lifecycle
|
||||
- [ ] `uv run pytest -m e2e` and `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` green
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md and `docs/kiosk-silverblue.md` describe what exists after this phase, and nothing that arrives in phase 09
|
||||
- [ ] no behavior change in completed work
|
||||
Reference in New Issue
Block a user