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,113 @@
|
|||||||
|
# Phase 07 — Shared presentation state on the server
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "Add a /kiosk endpoint that is controllable by other clients… There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately…" (the server half of that item)
|
||||||
|
**Story:** n/a
|
||||||
|
**Context:** `.agents/PLAN.md` §2 A5 (one WebSocket, JSON, verb in / noun out), A6 (camelCase on the wire), A7 (in-memory state only — no database, no persistence), A8 (`Registry` stays pure and returns `list[Envelope]`), A11/A19 (no accounts; the address is the gate); `src/mirror/protocol.py`, `src/mirror/registry.py`, `src/mirror/app.py`; `tests/test_registry.py`, `tests/test_protocol.py`, `tests/test_app.py`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Give the server one piece of presentation state — **which mirror is on stage, and
|
||||||
|
whether it should be fullscreen** — that any connected peer may change and that is
|
||||||
|
pushed to everyone the moment it changes. This is the piece the TODO item is
|
||||||
|
actually short of: `/watch` already stages and fullscreens locally (phase 01), but
|
||||||
|
that decision lives in one tab and dies there, so an unattended display can never
|
||||||
|
be driven from elsewhere.
|
||||||
|
|
||||||
|
This phase is **server-side only**: no new page, no browser-visible behaviour yet.
|
||||||
|
It leaves the app exactly as usable as it was, with a wire format and a
|
||||||
|
`GET /api/kiosk` read surface that phases 08 and 09 build their clients on.
|
||||||
|
|
||||||
|
## The contract this phase fixes (phases 08/09 code against it verbatim)
|
||||||
|
|
||||||
|
Inbound — verb, like `publish`/`subscribe` (PLAN A5), from **any** peer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"arrange","staged":"p1ab2c3d","fullscreen":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `staged`: a stream id, or `null` for "back to the wall", or **absent** for "no
|
||||||
|
opinion". Absent and explicit-`null` are different requests, which is why the
|
||||||
|
model is read with pydantic's `model_fields_set` rather than with defaults.
|
||||||
|
* `fullscreen`: `bool`, absent = no opinion.
|
||||||
|
* A `staged` value that does not name a live mirror is refused with the existing
|
||||||
|
`unknown_stream` error and changes nothing.
|
||||||
|
* No audio/mute field. `SECURITY.md` §4: sound must trace back to a gesture on the
|
||||||
|
device that plays it — a remote hand cannot unmute a stranger's display.
|
||||||
|
|
||||||
|
Outbound — noun, to every viewer and back to the sender:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"layout","staged":"p1ab2c3d","fullscreen":true,"followers":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `followers` is how many connected peers announced `hello{"kiosk":true}`, i.e.
|
||||||
|
how many displays are obeying this. A controller is a plain viewer and is not
|
||||||
|
counted, which is what makes "nobody is showing a wall" distinguishable from
|
||||||
|
"the wall will follow".
|
||||||
|
* Emitted on: any accepted change, a kiosk display joining or leaving, and the
|
||||||
|
stale fallback when the staged mirror disappears.
|
||||||
|
* `welcome` carries the same object as its `layout` field, so a page is arranged
|
||||||
|
before it has asked for anything — the TODO's "navigate to it and it arranges
|
||||||
|
itself" depends on this.
|
||||||
|
|
||||||
|
`GET /api/kiosk` answers the same object, so the state is debuggable with `curl`
|
||||||
|
exactly like `/api/streams` (AGENTS.md §8).
|
||||||
|
|
||||||
|
## What is deliberately *not* here
|
||||||
|
|
||||||
|
* **No third role.** `Role` stays `sharer | viewer`; a kiosk is a viewer that
|
||||||
|
happens to obey the layout. The signal-authorisation matrix and A4 are
|
||||||
|
untouched.
|
||||||
|
* **No media coupling.** Staging never subscribes, never negotiates, never touches
|
||||||
|
an `RTCPeerConnection` — the kiosk already subscribes to every mirror it can
|
||||||
|
show, so "which one is on stage" is pure presentation.
|
||||||
|
* **One state per server process.** Not per display, not per room. Two `/kiosk`
|
||||||
|
pages follow the same state; that is the product's single-wall model (A7).
|
||||||
|
* **No persistence.** Restart the server and the wall is a wall. Consistent with
|
||||||
|
A7's reasoning: state that outlives the peers is a lie.
|
||||||
|
* **No auto-stage-newest.** The state says what is on stage; nothing here invents
|
||||||
|
a value. (`PLAN.md` §7 item 2, "Wall UX", is where auto-stage belongs.)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `06_sharer_quality_presets` (complete) — the current wall/stage/keyboard
|
||||||
|
behaviour and the green four-gate baseline this phase must not disturb.
|
||||||
|
(`.agents/phases/todo/` is empty, so this is the phase directly before 07.)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. `01_arrange_and_layout_frames.md` — the two message shapes in `protocol.py`
|
||||||
|
(`Arrange` inbound, `layout()` outbound builder) plus the `kiosk` flag on
|
||||||
|
`hello`, with parse/round-trip tests.
|
||||||
|
2. `02_registry_layout_policy.md` — `Layout` state, `Registry.set_layout()` and
|
||||||
|
`Registry.layout_dto()`, the fan-out set, the follower count, and the
|
||||||
|
stale-staged fallback on teardown; unit-tested with no I/O.
|
||||||
|
3. `03_dispatch_read_endpoint_and_wire_tests.md` — `Hub._dispatch` handles
|
||||||
|
`arrange`, `GET /api/kiosk` serves the state, and the whole path is covered
|
||||||
|
over a real `TestClient` WebSocket.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- **Unit (`tests/test_registry.py`)** owns the policy: partial update vs explicit
|
||||||
|
clear, refusal of an unknown stream, fan-out to viewers plus the sender,
|
||||||
|
follower counting, the fallback when the staged mirror goes away, and `welcome`
|
||||||
|
carrying the state.
|
||||||
|
- **Wire (`tests/test_app.py`, `tests/test_protocol.py`)** owns the camelCase
|
||||||
|
round-trip and the socket semantics: an `arrange` frame from a sharer, from a
|
||||||
|
viewer, with a bad stream id (error frame, socket stays open), and the shape +
|
||||||
|
`no-store` of `/api/kiosk` behind the address gate.
|
||||||
|
- Coverage: **>90%** on new/modified code (the project sits ~95%; `registry.py` is
|
||||||
|
the product and must stay near 100%).
|
||||||
|
- All four gates from `AGENTS.md` §4, including `-m e2e` and the `realdisplay`
|
||||||
|
suite: this phase changes frames every browser test consumes, so "nothing in the
|
||||||
|
browser changed" is itself the assertion.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `uv run pytest tests/test_registry.py tests/test_protocol.py tests/test_app.py` green, including the new layout cases
|
||||||
|
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` answers `{"staged": null, "fullscreen": false, "followers": 0}` on an idle server, and reflects an `arrange` frame sent with `websocat`
|
||||||
|
- [ ] `welcome` carries a `layout` object; a second viewer connecting after an `arrange` receives the same state without being asked
|
||||||
|
- [ ] the staged mirror going away (unpublish, disconnect, eviction) resets `staged` to `null` and pushes one `layout` frame — never a stale id
|
||||||
|
- [ ] no page, no stylesheet and no browser test changed behaviour (`uv run pytest -m e2e` green with the existing assertions untouched)
|
||||||
|
- [ ] full test suite **100% passing**, coverage >90% on new code
|
||||||
|
- [ ] `PLAN.md` untouched (no anchor moved: A5/A7/A8/A11 all hold, see above)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Task 01 — The `arrange` / `layout` frames
|
||||||
|
|
||||||
|
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "There should be a /kiosk endpoint which stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Put both halves of the presentation-state message on the wire: an inbound
|
||||||
|
`arrange` verb, an outbound `layout` noun, and a `kiosk` flag on `hello` so the
|
||||||
|
server can tell a display from a controller. No behaviour yet — `Registry` is
|
||||||
|
wired in task 02, the socket in task 03.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `src/mirror/protocol.py` — add the inbound verb after `Subscribe`/`Unsubscribe`,
|
||||||
|
in the `_Inbound` house style. Both fields are `| None` **and** optional, because
|
||||||
|
"leave what I did not mention alone" and "clear it" are different requests:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Arrange(_Inbound):
|
||||||
|
"""Ask the wall to show a given mirror. Absent fields are left alone;
|
||||||
|
|
||||||
|
``staged: null`` explicitly returns the displays to the wall. The caller
|
||||||
|
does not have to be the display's operator — see ``SECURITY.md`` §2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Literal["arrange"]
|
||||||
|
staged: str | None = None
|
||||||
|
fullscreen: bool | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `Arrange` to the `ClientMessage` discriminated union and re-export nothing
|
||||||
|
else — the union is what `Hub._dispatch` switches on.
|
||||||
|
2. `src/mirror/protocol.py` — `Hello` gains one field, single word so there is no
|
||||||
|
camelCase alias to get wrong (but round-trip it anyway, A6):
|
||||||
|
|
||||||
|
```python
|
||||||
|
#: true when this peer is a wall display that obeys the shared layout.
|
||||||
|
kiosk: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `Role = Literal["sharer", "viewer"]` exactly as it is: a kiosk is a viewer
|
||||||
|
with an attribute, not a third role (see the phase overview).
|
||||||
|
3. `src/mirror/protocol.py` — add the outbound builder beside `welcome()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def layout(
|
||||||
|
*, staged: str | None, fullscreen: bool, followers: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The wall's presentation state, pushed to every viewer as it changes."""
|
||||||
|
return {"type": "layout", "staged": staged, "fullscreen": fullscreen, "followers": followers}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key order and spelling are the contract in `00_phase.md`; the outbound builders
|
||||||
|
are hand-written dicts, so there is no alias generator to catch a typo here —
|
||||||
|
the round-trip test in this task is what catches it.
|
||||||
|
4. `src/mirror/protocol.py` — `welcome()` gains a `layout` parameter and a
|
||||||
|
`"layout"` key so a page arrives already arranged. Give it a keyword-only
|
||||||
|
default of `{"staged": None, "fullscreen": False, "followers": 0}`? **No** —
|
||||||
|
a mutable default is a shared dict. Take it as a required keyword; the one
|
||||||
|
caller (`Registry.join`) is updated in task 02, so this task must not leave a
|
||||||
|
call site broken: update `registry.py`'s `welcome(...)` call in the same commit
|
||||||
|
to pass `layout=protocol.layout(staged=None, fullscreen=False, followers=0)`
|
||||||
|
as a placeholder that task 02 replaces with the real state.
|
||||||
|
5. Do **not** touch `Hub`, `create_app`, `Registry` policy or any JS in this task.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `tests/test_protocol.py` — extend the message-parsing suite:
|
||||||
|
* `{"type":"arrange"}`, `{"type":"arrange","staged":null}` and
|
||||||
|
`{"type":"arrange","staged":"p1","fullscreen":true}` all parse, and
|
||||||
|
`model_fields_set` distinguishes the first two from the third (this is the
|
||||||
|
whole reason the model is written this way — test it, do not trust it).
|
||||||
|
* `hello` defaults `kiosk` to `False` and accepts `kiosk: true`.
|
||||||
|
* `protocol.layout(...)` emits exactly `type/staged/fullscreen/followers` —
|
||||||
|
assert the key set, which is how the outbound dicts are guarded elsewhere.
|
||||||
|
- `tests/test_app.py` — `test_an_unknown_message_type_is_reported_but_survivable`
|
||||||
|
must still pass, and `arrange` must *not* appear as unknown once task 03 lands;
|
||||||
|
nothing to assert here yet.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (the models and the builder
|
||||||
|
are covered directly by the parse/shape tests above).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `uv run pytest tests/test_protocol.py` green with the new `arrange`, `kiosk`
|
||||||
|
and `layout()` cases
|
||||||
|
- [ ] `uv run pyright` reports 0 errors (`Arrange` is in the union, so the
|
||||||
|
`isinstance` chain in `app.py` is exhaustive-checked in task 03)
|
||||||
|
- [ ] `uv run pytest -m e2e` still green with **no** test file edited — nothing a
|
||||||
|
browser does has changed
|
||||||
|
- [ ] full test suite **100% passing**
|
||||||
|
- [ ] no behavior change in completed work
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Task 02 — `Registry` owns the layout: set, validate, broadcast, fall back
|
||||||
|
|
||||||
|
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "stores 'state' (fullscreen, which mirror is being viewed) that any client can manipulate and will update immediately"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
The state itself, in `Registry`, with the policy that makes it trustworthy: only
|
||||||
|
live mirrors may be named, the change reaches every viewer in the same call that
|
||||||
|
produced it, and a mirror disappearing never leaves a display pointing at nothing.
|
||||||
|
Pure Python, envelopes out — A8 to the letter.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `src/mirror/registry.py` — a sentinel for "the caller did not mention this
|
||||||
|
field", because `None` already means "clear it":
|
||||||
|
|
||||||
|
```python
|
||||||
|
class _Unset:
|
||||||
|
"""Marker: the caller expressed no opinion, keep the current value."""
|
||||||
|
|
||||||
|
UNSET = _Unset()
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `src/mirror/registry.py` — the state next to `Peer`/`Stream`, with the
|
||||||
|
dataclass house style (`slots=True`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Layout:
|
||||||
|
"""What the wall displays. One value per server process (A7): there is
|
||||||
|
one wall, and every display obeying it is showing the same thing."""
|
||||||
|
|
||||||
|
staged: str | None = None
|
||||||
|
fullscreen: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
`staged` holds a stream id, which is also a sharer peer id (`streamId ==
|
||||||
|
sharerId`, PLAN §3) — the fallback in step 6 relies on that identity.
|
||||||
|
3. `src/mirror/registry.py` — `Peer` gains `kiosk: bool = False`; `Registry.join()`
|
||||||
|
gains a `kiosk: bool = False` keyword and stores it. The follower count is
|
||||||
|
derived, never bookkept: `sum(1 for p in self.peers.values() if p.kiosk)`.
|
||||||
|
4. `src/mirror/registry.py` — `Registry.__init__` creates `self.layout = Layout()`;
|
||||||
|
`join()` builds its `welcome` with `layout=self.layout_dto()` (replacing task
|
||||||
|
01's placeholder), so a page is arranged before it asks.
|
||||||
|
5. `src/mirror/registry.py` — the two public operations:
|
||||||
|
|
||||||
|
* `layout_dto() -> dict[str, Any]` — `protocol.layout(staged=…, fullscreen=…,
|
||||||
|
followers=<derived count>)`. This is the only place the DTO is built, so
|
||||||
|
`/api/kiosk` (task 03) and the socket can never disagree.
|
||||||
|
* `set_layout(peer_id, *, staged: Any = UNSET, fullscreen: Any = UNSET)
|
||||||
|
-> list[Envelope]` —
|
||||||
|
- `self._peer(peer_id)` first, so a dead peer gets `unknown_peer` like every
|
||||||
|
other operation;
|
||||||
|
- `staged` given as a non-`None` string → `self._stream(staged)` (reuses the
|
||||||
|
existing `unknown_stream` code — no new error code);
|
||||||
|
- `staged` explicitly `None` → clears; absent → unchanged; same shape for
|
||||||
|
`fullscreen`;
|
||||||
|
- if nothing actually changed, return **only** the ack to the sender — no
|
||||||
|
broadcast storm from a controller that re-sends the same state;
|
||||||
|
- otherwise return the broadcast: every peer whose role is `viewer`, **plus
|
||||||
|
the sender** (a sharer that sends an `arrange` still gets its own ack),
|
||||||
|
each carrying `protocol.layout(**self.layout_dto())`. Deduplicate the
|
||||||
|
sender into the set so no peer is sent the frame twice.
|
||||||
|
6. `src/mirror/registry.py` — the fallback, in `_drop_stream()` (the single funnel
|
||||||
|
for *every* removal path: `unpublish`, disconnect, replaced stream, capacity
|
||||||
|
eviction): if `self.layout.staged == stream.id`, set it to `None` and append
|
||||||
|
the layout broadcast to the envelopes it already returns. The comment there
|
||||||
|
already explains "tell every viewer, not just subscribers" — extend it: a
|
||||||
|
display aimed at a mirror that vanished must fall back to the wall *in the same
|
||||||
|
frame set*, because there is no polling to correct it later.
|
||||||
|
7. `src/mirror/registry.py` — follower joins/leaves must also push state, or a
|
||||||
|
controller sits on a stale `followers: 0` until someone moves a mirror:
|
||||||
|
`join()` appends the layout broadcast to the other viewers' envelopes (not to
|
||||||
|
the joiner — it got the state in its `welcome`), and `leave()` appends the same
|
||||||
|
broadcast when the departing peer had `kiosk=True`.
|
||||||
|
8. `src/mirror/registry.py` module docstring — add the invariant to the bulleted
|
||||||
|
list: *the layout names either nothing or a live mirror; a mirror's removal
|
||||||
|
clears it in the same envelope set.*
|
||||||
|
9. Do **not** touch `Hub`, `app.py`, or any JS. Do not add persistence, a layout
|
||||||
|
per peer, or an audio field (see `00_phase.md`, "What is deliberately not here").
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `tests/test_registry.py` — a new `TestLayout` class covering, in this order:
|
||||||
|
* default state is the wall, not fullscreen, zero followers;
|
||||||
|
* `set_layout` with `staged="pX"` broadcasts to viewers **and** the sender, and
|
||||||
|
no peer receives it twice;
|
||||||
|
* `staged` absent + `fullscreen=True` changes only fullscreen (the
|
||||||
|
partial-update rule, asserted on the returned DTO);
|
||||||
|
* `staged=None` clears an existing selection, and `set_layout` with no fields at
|
||||||
|
all produces no broadcast;
|
||||||
|
* an unknown stream id raises `RegistryError` with `code == "unknown_stream"`
|
||||||
|
and leaves `self.layout` untouched;
|
||||||
|
* a second `set_layout` with identical values sends the ack and nothing else;
|
||||||
|
* `unpublish`, `leave()` of the sharer, and a *replaced* stream each reset
|
||||||
|
`staged` to `None` and emit exactly one layout frame per viewer;
|
||||||
|
* `followers` counts only `kiosk=True` viewers, and a kiosk joining/leaving
|
||||||
|
pushes a layout frame whose `followers` is the new count;
|
||||||
|
* `welcome` of a peer that joins **after** an `arrange` carries that state —
|
||||||
|
this is "navigate to `/kiosk` and it arranges itself", stated at the layer
|
||||||
|
where it is decided.
|
||||||
|
Use the file's existing helpers/idioms (explicit peer ids via `id_factory`, the
|
||||||
|
`codes_and_targets` style helpers already in the module) rather than a new
|
||||||
|
harness.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code — target ~100% of
|
||||||
|
`registry.py`, which the project treats as the product.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `uv run pytest tests/test_registry.py` green, including every `TestLayout` case above
|
||||||
|
- [ ] `uv run pyright` → 0 errors; `Registry` still imports no socket, no FastAPI, no `await` (A8, AGENTS.md §3)
|
||||||
|
- [ ] `grep -n "await\|WebSocket\|fastapi" src/mirror/registry.py` finds nothing
|
||||||
|
- [ ] a layout naming a mirror that has gone is impossible to observe: the frame
|
||||||
|
every viewer receives after a teardown says `staged: null`
|
||||||
|
- [ ] full test suite **100% passing**
|
||||||
|
- [ ] no behavior change in completed work (no wire frame any current client sends
|
||||||
|
or reads changed shape)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Task 03 — Socket dispatch, `GET /api/kiosk`, and the wire suite
|
||||||
|
|
||||||
|
**Phase:** `07_kiosk_layout_state` · **Source:** `TODO.md:3` — "state … 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"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Connect the frames to the transport and make the state readable without a
|
||||||
|
browser: `Hub` accepts `arrange` from any peer and passes the caller's *expressed*
|
||||||
|
fields to `Registry`, and `GET /api/kiosk` answers the same DTO the socket pushes.
|
||||||
|
After this task the feature is real and drivable with `websocat`, one step below
|
||||||
|
the browser.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `src/mirror/app.py` — `Hub._introduce`: pass the flag through,
|
||||||
|
`self.registry.join(role=hello.role, name=hello.name, device=hello.device,
|
||||||
|
kiosk=hello.kiosk)`.
|
||||||
|
2. `src/mirror/app.py` — `Hub._dispatch`, in the existing `isinstance` chain
|
||||||
|
before the `else` that marks the union exhaustive:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif isinstance(message, protocol.Arrange):
|
||||||
|
fields = message.model_fields_set
|
||||||
|
outbox = self.registry.set_layout(
|
||||||
|
peer_id,
|
||||||
|
staged=message.staged if "staged" in fields else UNSET,
|
||||||
|
fullscreen=message.fullscreen if "fullscreen" in fields else UNSET,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `UNSET` from `.registry`. `model_fields_set` is the one place the
|
||||||
|
"absent ≠ null" rule survives the trip from JSON to policy; a comment there
|
||||||
|
should say so, because the next reader will try to simplify it to
|
||||||
|
`message.staged` and silently lose "back to the wall".
|
||||||
|
3. `src/mirror/app.py` — the read endpoint, next to `/api/streams`, same
|
||||||
|
`Cache-Control: no-store` (a roster that is cached is a lie about what is on
|
||||||
|
the wall right now):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/kiosk")
|
||||||
|
async def kiosk_layout() -> JSONResponse:
|
||||||
|
"""Server truth about the wall display: what is on stage, fullscreen, and
|
||||||
|
how many displays are obeying it. Read-only by design — the only writer is
|
||||||
|
the `arrange` frame on /ws, so there is one path that can change the wall."""
|
||||||
|
return JSONResponse(registry.layout_dto(), headers={"Cache-Control": "no-store"})
|
||||||
|
```
|
||||||
|
|
||||||
|
ASSUMPTION: writes stay on the WebSocket only. An HTTP `POST` would be a second
|
||||||
|
write path with no authentication (A11) and no same-origin protection to lose,
|
||||||
|
and the WS already gives any admitted address the same power — so the read-only
|
||||||
|
endpoint buys the `curl` debugging (AGENTS.md §8) without buying a new surface.
|
||||||
|
4. `src/mirror/app.py` — **do not** add `/kiosk` to `PAGES` yet: there is no page
|
||||||
|
until phase 08. `/api/kiosk` is gated like every other route (only `/healthz`
|
||||||
|
is exempt, AGENTS.md §3), and must not be added to that exemption.
|
||||||
|
5. No JS in this task.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `tests/test_app.py` — extend `TestSignalingSocket` (or the class the existing
|
||||||
|
roster tests live in) with:
|
||||||
|
* the round-trip: sharer publishes, viewer connects, viewer sends
|
||||||
|
`{"type":"arrange","staged":<streamId>,"fullscreen":true}` → both the sender
|
||||||
|
and a second viewer receive `layout` with `staged` and `fullscreen` set, and
|
||||||
|
`GET /api/kiosk` says the same;
|
||||||
|
* `followers` — two viewers with `hello{kiosk:true}` and one plain viewer
|
||||||
|
report `followers: 2`, and closing one of the kiosk sockets pushes a layout
|
||||||
|
frame that says `1`;
|
||||||
|
* a bad stream id answers `error{code:"unknown_stream"}` and **does not** close
|
||||||
|
the socket (send `ping`, get `pong`);
|
||||||
|
* an `arrange` with only `fullscreen` leaves `staged` alone end to end, and
|
||||||
|
`arrange{staged:null}` returns the wall;
|
||||||
|
* a **sharer** that sends `arrange` gets the ack, and a layout frame is not
|
||||||
|
pushed to other sharers (viewers plus sender only);
|
||||||
|
* the staged mirror's sharer disconnects → every viewer gets `staged: null`.
|
||||||
|
- `tests/test_app.py::TestAddressGate` — `/api/kiosk` is refused with the rest of
|
||||||
|
the app: add it to the parametrised "refused address gets nothing" list and
|
||||||
|
assert a refused address never learns the layout, plus one test that the real
|
||||||
|
address gets `200` and `cache-control: no-store`.
|
||||||
|
- `tests/test_app.py::TestConfigEndpoints`-style shape check: `/api/kiosk` returns
|
||||||
|
exactly the three keys (`staged`, `fullscreen`, `followers`) so a future field
|
||||||
|
addition is a decision, not a leak.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `websocat ws://localhost:8055/ws` can drive the wall by hand: `hello`, then
|
||||||
|
`{"type":"arrange","staged":"<id from /api/streams>","fullscreen":true}` →
|
||||||
|
a `layout` frame comes back (AGENTS.md §8 rung 2)
|
||||||
|
- [ ] `curl -s localhost:8055/api/kiosk | python -m json.tool` reflects that state
|
||||||
|
and answers `no-store`
|
||||||
|
- [ ] the staged mirror's sharer leaving resets `staged` to `null` for every
|
||||||
|
viewer, observed over the socket
|
||||||
|
- [ ] `uv run pytest tests/test_app.py` green (new cases + all existing, unedited)
|
||||||
|
- [ ] all four gates from `AGENTS.md` §4 green, including `-m e2e` and
|
||||||
|
`MIRROR_REALDISPLAY=1 -m realdisplay` with **no** browser-side change
|
||||||
|
- [ ] full test suite **100% passing**, coverage >90% on new code
|
||||||
|
- [ ] README.md updated for anything user-visible this task changes (a protocol
|
||||||
|
note is enough here; the pages come in phases 08/09)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user