phase: 01_keyboard_kiosk_control
**Phase 01 final verification pass — complete, 100% green** - Verified all 4 tasks done (files in `complete/`); implementation review: key router in `watch.js`, share-page router, CSS focus/selection affordances, `?` overlay, pointer-free E2E suite — all sound. - Gates: `ruff check` + `ruff format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` → 223 passed, 2 skipped (realdisplay), 1 pre-existing FastAPI deprecation warning · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` → 2 passed ✅ · `pytest -m e2e` → 51 passed (incl. `test_keyboard_wall.py`, which a meta-test keeps pointer-free) · `node --check` on all static JS ✅. - Completion criteria: all 6 ✅ (j/k+Enter, f/g/n/p/1–9/m/?/Esc keyboard-only E2E; no hover-only controls via CSS guard + focus-ring E2E; `#name` text-safe; e2e/realdisplay green; key map documented in README "Kiosk client" and `docs/kiosk-silverblue.md`). - README fixed: added missing env vars `MIRROR_MAX_BITRATE`, `MIRROR_PING_INTERVAL`, `DEBUGPY_WAIT` to the config table; added the one-time `rpm-ostree` layering note before the kiosk install snippet. - AGENTS.md fixed: removed stale "roadmap is intentionally empty" parenthetical (3 phases pending); coverage ~94% → ~95%. No content added. - **Unresolved, needs your call:** README/SECURITY.md say `mirror.reeseapps.com`; `docs/kiosk-silverblue.md` (reference-client naming table with real IPs) says `mirror.reeselink.com`. I couldn't verify which is the live deployment domain, so I changed neither — say the word and I'll unify. - Next pending phase: `02_sharp_capture`.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# Phase 01 — Keyboard-only control of the wall
|
||||
|
||||
**Source:** `TODO.md` L3 — "Need keyboard shortcuts. This will be deployed on a kiosk mode linux client that won't have a mouse. Need to be able to fullscreen a mirror, switch between them easily, etc."
|
||||
**Story:** n/a
|
||||
**Context:** `.agents/PLAN.md` §2 A9 (hand-written ES modules, no framework), A10 (nothing from outside the origin), A13 (Playwright, `channel="chromium"`); `docs/kiosk-silverblue.md` (the target box: GNOME Kiosk + Firefox `--kiosk` pointed at `/watch`, set up over SSH with **no keyboard, no monitor, no console**); `src/mirror/static/watch.js`, `watch.html`, `share.js`, `mirror.html`, `app.css`
|
||||
|
||||
## Objective
|
||||
|
||||
Operate the whole product from a keyboard. On `/watch`: select a mirror, take it
|
||||
fullscreen over the wall, step to the next/previous, jump by number, mute, and get
|
||||
an on-screen list of the keys. On `/mirror`: start and stop a share without a
|
||||
pointer.
|
||||
|
||||
Just as important: **remove the pointer-only affordances**. `.tile .actions` is
|
||||
`opacity: 0` until `:hover` (`app.css` L351–352) and the staged caption fades to
|
||||
`0.28` (L396–409) — on a kiosk with no mouse those controls do not exist, so a
|
||||
mirror can currently be neither un-muted nor exited once it is on stage.
|
||||
|
||||
## Dependencies
|
||||
|
||||
— (none). This is the first phase of the roadmap.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_keyboard_command_router.md` — one focus-aware keydown router on `/watch`
|
||||
plus a keyboard "cursor" over the tiles (`j`/`k`/`↑`/`↓`).
|
||||
2. `02_wall_navigation_commands.md` — `Enter`/`f`, `g`, `n`/`p`, `1`–`9`, `m`
|
||||
wired to the existing `stage()`/`unstage()` plumbing.
|
||||
3. `03_keyboard_reachable_tile_controls.md` — focus-visible affordances: every
|
||||
hover-only control becomes reachable and legible without a mouse.
|
||||
4. `04_help_overlay_and_keyboard_e2e.md` — `?` help overlay, `/mirror` `s`/`x`,
|
||||
a mouse-free Playwright suite, and the kiosk/README keyboard docs.
|
||||
|
||||
## Key map (locked by the user for this phase)
|
||||
|
||||
| Key | Scope | Action |
|
||||
|---|---|---|
|
||||
| `j` `k` `↓` `↑` | `/watch` | move the selection to next / previous tile (wraps) |
|
||||
| `Enter` `f` | `/watch` | stage the selected mirror fullscreen (`f` re-enters fullscreen when already staged) |
|
||||
| `g` | `/watch` | back to the wall (unstage) |
|
||||
| `n` `p` | `/watch` | stage next / previous mirror |
|
||||
| `1`–`9` | `/watch` | stage the Nth mirror in grid order |
|
||||
| `m` | `/watch` | mute / unmute the staged mirror |
|
||||
| `?` | `/watch` | toggle the help overlay |
|
||||
| `Esc` | both | `/watch` unstages (already exists); `/mirror` stops sharing (already exists) |
|
||||
| `s` `x` | `/mirror` | start / stop sharing |
|
||||
|
||||
Never intercepted: any chord containing `Ctrl`/`Cmd`/`Alt`, and every key while an
|
||||
`<input>`, `<textarea>` or `contenteditable` has focus (the `/mirror` display-name
|
||||
field is the one real text box).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **Browser E2E (`-m e2e`)** is the only honest gate for this phase: the product
|
||||
is "a key produces a visible change in a browser". New suite
|
||||
`tests/e2e/test_keyboard_wall.py` drives everything with `page.keyboard.press()`
|
||||
and never `click()`s — a test that reaches for the mouse proves nothing about a
|
||||
kiosk.
|
||||
- Assert the **observable** result of each key: `document.body.classList`
|
||||
contains/omits `staged`, `#watch-stage .tile[data-stream]` equals the expected
|
||||
id, the tile under selection carries `data-selected`, the staged `<video>.muted`
|
||||
flips. Not "the handler ran".
|
||||
- `tests/test_app.py` guards must stay green, and be extended where they are too
|
||||
narrow: `test_every_element_the_script_reaches_for_exists` (any new `$("#id")`
|
||||
needs the element in its page) and
|
||||
`test_the_two_pages_share_no_element_ids_but_the_shared_chrome` (the help
|
||||
overlay is `/watch`-only; the only allowed cross-page ids remain `net`,
|
||||
`notice`, `toast`).
|
||||
- `TestStylesheet::test_no_rule_reintroduces_a_conflicting_display_for_hidden`
|
||||
constrains the new CSS: no rule may un-hide something `hidden`.
|
||||
- Coverage: **>90%** on new/modified Python (this phase adds none beyond tests);
|
||||
the JS is covered by the E2E assertions above, per `AGENTS.md` §5.
|
||||
- Full gate at the end of every task: `uv run ruff check . && uv run ruff format
|
||||
--check . && uv run pyright && uv run pytest`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] On `/watch` with two live mirrors, `j`/`k` move a *visible* selection and
|
||||
`Enter` puts the selected mirror on stage — driven only by
|
||||
`page.keyboard.press()`.
|
||||
- [ ] `f` re-enters fullscreen, `g` returns to the wall, `n`/`p` step between
|
||||
mirrors, `1`–`9` jump, `m` toggles the staged mirror's audio, `?` shows and
|
||||
hides the key list, `Esc` exits — all with no pointer events in the test.
|
||||
- [ ] No control on either page is reachable *only* by `:hover`; keyboard focus
|
||||
draws a visible ring (`.tile:focus-visible`) and reveals tile actions.
|
||||
- [ ] Keys typed into `#name` on `/mirror` never trigger a command.
|
||||
- [ ] `uv run pytest -m e2e` green, including the new mouse-free suite;
|
||||
`MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` unaffected.
|
||||
- [ ] README "Kiosk client" and `docs/kiosk-silverblue.md` document the key map.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Task 01 — One keyboard router with a cursor over the tiles
|
||||
|
||||
**Phase:** `01_keyboard_kiosk_control` · **Source:** `TODO.md:3` — "Need keyboard shortcuts. This will be deployed on a kiosk mode linux client that won't have a mouse. Need to be able to fullscreen a mirror, switch between them easily, etc."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Give `/watch` a single place that decides what a keypress means, and a keyboard
|
||||
"cursor" that can reach every tile. Replaces the two ad-hoc handlers that exist
|
||||
today (a per-tile `Enter`/`Space` listener and a global `Escape` listener) so the
|
||||
later tasks add commands, not more listeners.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/watch.js` — delete the `node.addEventListener("keydown", …)`
|
||||
block inside `makeTile()` (the `Enter`/` ` → `stage()` one) and the standalone
|
||||
`document.addEventListener("keydown", …)` that handles `Escape`. Both move into
|
||||
one router added at the bottom of the file, next to the other global wiring.
|
||||
2. `src/mirror/static/watch.js` — add the cursor to the existing `state` object:
|
||||
`cursor: null` (a stream id, not an index — tiles come and go on
|
||||
`stream_added`/`stream_removed`, the map order is the grid order). Add
|
||||
`orderedTiles()` returning `[...tiles.values()]` (DOM order == insertion order,
|
||||
which is what `renderCounts()` already assumes) and `selectedTile()` returning
|
||||
the tile for `state.cursor`, or `null` when it is stale.
|
||||
3. `src/mirror/static/watch.js` — add `setSelected(streamId)`: clears
|
||||
`data-selected` on the previously selected element, sets it on the new one,
|
||||
stores `state.cursor`, and calls
|
||||
`tile.el.focus({ preventScroll: true })` so focus-follows-selection works
|
||||
without the grid jumping (the tiles are `tabindex="0"` already). When a tile is
|
||||
dropped (`dropTile`) or staged, keep `state.cursor` coherent: point it at the
|
||||
nearest remaining tile, and at the staged tile while `state.staged` is set.
|
||||
4. `src/mirror/static/watch.js` — add `moveSelection(delta)` (wraps:
|
||||
`(index + delta + n) % n`, no-op when `n === 0`) and the router
|
||||
`onWallKey(event)`:
|
||||
* bail out **first** when `event.defaultPrevented`, or when
|
||||
`event.ctrlKey || event.metaKey || event.altKey`, or when `event.target` is
|
||||
`INPUT`/`TEXTAREA`/`SELECT` or `isContentEditable` — the wall has no text box
|
||||
today but `/mirror` does, and the router is copied by task 04;
|
||||
* `switch (event.key)` on `j`/`ArrowDown` → `moveSelection(+1)`, `k`/`ArrowUp`
|
||||
→ `moveSelection(-1)`; leave the other commands unhandled here.
|
||||
* `event.preventDefault()` **only** for keys it actually handled.
|
||||
5. `src/mirror/static/watch.js` — register it once:
|
||||
`document.addEventListener("keydown", onWallKey)` and preserve today's
|
||||
behaviour by handling `Escape` → `unstage()` in the same router (that is the
|
||||
listener being deleted in step 1, so `Escape` must not regress).
|
||||
6. `src/mirror/static/app.css` — a selection ring that is *not* the hover ring:
|
||||
`.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }`
|
||||
and `.tile[data-selected]` with the same visual weight as
|
||||
`.tile[data-state="live"]`'s border, so the selection is visible in a screenshot.
|
||||
7. Comment the router with the rule the kiosk depends on: *no command may require
|
||||
a pointer; no pointer affordance may be the only way to see state.*
|
||||
|
||||
ASSUMPTION: the cursor is a stream id and selection wraps at the ends (no
|
||||
"stop at the last tile"), because a wall with 20 mirrors is navigated in a loop
|
||||
from a remote.
|
||||
ASSUMPTION: `Tab` is left to the browser (it still walks the real focus order —
|
||||
tiles, then the header links); the cursor is an overlay on top of focus, not a
|
||||
replacement for it, so assistive tech keeps working.
|
||||
ASSUMPTION: this task adds only `j`/`k`/arrows/`Esc`. The rest of the key map
|
||||
lands in task 02, and `?` in task 04 — the router is the only place a key is read.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `tests/test_app.py::TestClientScripts::test_scripts_parse_as_es_modules` —
|
||||
`node --check` must stay clean on `watch.js`.
|
||||
- New E2E in `tests/e2e/test_keyboard_wall.py` (created by task 04; if that file
|
||||
does not exist yet, create it here with the `pytestmark = pytest.mark.e2e`
|
||||
pattern and the `device` fixture style from `tests/e2e/test_live_mirror.py`):
|
||||
open `/mirror`, start sharing, open `/watch`, then
|
||||
`page.keyboard.press("j")` and assert the first tile carries
|
||||
`data-selected`; press `k` and assert it wrapped to the last tile; press `Escape`
|
||||
on a staged mirror and assert `document.body.classList` no longer contains
|
||||
`staged`.
|
||||
- Assert that a synthesised `keydown` with `ctrlKey: true` does **not** move the
|
||||
selection (`page.keyboard.press("Control+j")`).
|
||||
- Coverage: **>90%** on this task's new/modified code (JS: covered by the E2E
|
||||
assertions above; Python: unchanged).
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `grep -c 'addEventListener("keydown"' src/mirror/static/watch.js` reports
|
||||
exactly **1** — the router.
|
||||
- [ ] `uv run pytest -m e2e -k keyboard` green for select / wrap / Escape.
|
||||
- [ ] `uv run pytest tests/test_app.py` green (scripts parse, ids intact).
|
||||
- [ ] `uv run ruff check . && uv run ruff format --check . && uv run pyright`
|
||||
clean.
|
||||
- [ ] No behavior change in completed work (staging, fan-out, teardown, refusal
|
||||
notices all still work by mouse).
|
||||
@@ -0,0 +1,80 @@
|
||||
# Task 02 — Wall commands: stage, step, jump, mute
|
||||
|
||||
**Phase:** `01_keyboard_kiosk_control` · **Source:** `TODO.md:3` — "Need keyboard shortcuts … Need to be able to fullscreen a mirror, switch between them easily, etc."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Wire the rest of the key map onto the plumbing that already exists — `stage()`
|
||||
and `unstage()` in `watch.js` already move the tile element and request
|
||||
fullscreen, so this task adds commands, not mechanics.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/watch.js` — extract the mute logic out of the audio button
|
||||
handler in `makeTile()` into `setMuted(tile, muted)`: sets
|
||||
`tile.video.muted`, `tile.video.volume = 1`, `tile.video.play().catch(() => {})`
|
||||
when unmuting, and updates `tile.audioBtn` text/`aria-pressed`/`title`. The
|
||||
button handler becomes a two-liner that calls it. (Phase 03 builds its audio
|
||||
policy on this helper — keep the signature `(tile, muted)`.)
|
||||
2. `src/mirror/static/watch.js` — add the commands to the router from task 01, in
|
||||
one `switch (event.key)`:
|
||||
* `Enter` → `stage(state.cursor, { fullscreen: true })`; if nothing is
|
||||
selected, stage the first tile.
|
||||
* `f` → when already staged, re-request fullscreen
|
||||
(`ui.stage.requestFullscreen({ navigationUI: "hide" }).catch(() => {})`) —
|
||||
a kiosk's window manager can drop out of fullscreen without mirror noticing;
|
||||
when not staged, same as `Enter`.
|
||||
* `g` → `unstage()`.
|
||||
* `n` / `p` → stage the tile after/before the current selection (or after the
|
||||
staged one), wrapping; `stage()` already unstages the previous tile.
|
||||
* `1`–`9` → `stage(orderedTiles()[Number(key) - 1]?.id)` — a no-op with a toast
|
||||
when there is no Nth mirror.
|
||||
* `m` → `setMuted(stagedOrSelectedTile, !(…).video.muted)`.
|
||||
3. `src/mirror/static/watch.js` — every command either does something visible or
|
||||
says why it did not, through the existing `toast()` helper (`"no mirrors to
|
||||
switch to"`, `"no mirror 4"`). A silent no-op is the one outcome a
|
||||
keyboard-only operator cannot debug.
|
||||
4. `src/mirror/static/watch.js` — keep the pointer paths intact: the tile click
|
||||
handler, `.expand`, `.audio` and `.exit` all still work, now sharing the same
|
||||
`stage`/`unstage`/`setMuted` functions.
|
||||
5. Comment the `f` branch with the Firefox `--kiosk` reality from
|
||||
`docs/kiosk-silverblue.md` (`window-config.ini` forces the *window* fullscreen;
|
||||
an element `requestFullscreen()` on top of that is allowed and simply no-ops if
|
||||
the browser refuses).
|
||||
|
||||
ASSUMPTION: `n`/`p` **stage** their target rather than only moving the selection,
|
||||
because the TODO's use case is "switch between them" on a wall — a remote operator
|
||||
cannot reach an `Enter` afterwards without seeing what is selected.
|
||||
ASSUMPTION: keys are matched case-insensitively (`toLowerCase()`), so `Shift+J`
|
||||
and a caps-lock kiosk keyboard still work; `?` (which needs `Shift`) is matched on
|
||||
`event.key === "?"` and handled in task 04, not here.
|
||||
ASSUMPTION: `m` on a mirror whose stream has no audio track still toggles the
|
||||
element's `muted` and toasts `"this mirror has no audio"` — the alternative
|
||||
(nothing happens) is indistinguishable from a broken keyboard.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- E2E in `tests/e2e/test_keyboard_wall.py`, two sharers + one viewer (see
|
||||
`test_several_mirrors_land_on_one_wall` for the shape): `j` `Enter` stages
|
||||
tile A (`#watch-stage .tile[data-stream]` == A), `n` stages B **without**
|
||||
tearing A's peer connection down (assert A's `<video>` element is the same node
|
||||
it was — the element moves, `srcObject` is never reassigned), `g` returns to the
|
||||
wall, `1` stages the first mirror, `m` flips `document.querySelector("#watch-stage video").muted`.
|
||||
- Assert the no-op feedback: with one mirror live, `page.keyboard.press("5")`
|
||||
leaves the wall unchanged **and** `#toast` is visible.
|
||||
- Regression: `uv run pytest -m e2e` must still pass
|
||||
`test_clicking_a_tile_takes_it_on_stage`,
|
||||
`test_exit_only_appears_on_the_tile_that_is_on_stage` and
|
||||
`test_one_mirror_fans_out_to_several_viewers` unchanged.
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] The five commands (`Enter`/`f`, `g`, `n`/`p`, `1`–`9`, `m`) each have an
|
||||
E2E assertion that uses `page.keyboard.press()` only.
|
||||
- [ ] Staged mirror A → `n` → mirror B leaves A's `RTCPeerConnection` alive
|
||||
(verified by the same `<video>` node and its `srcObject`).
|
||||
- [ ] Mouse behaviour is unchanged: `uv run pytest -m e2e` fully green.
|
||||
- [ ] `uv run ruff check . && uv run ruff format --check . && uv run pyright`
|
||||
clean.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Task 03 — Controls that do not need a mouse to be seen
|
||||
|
||||
**Phase:** `01_keyboard_kiosk_control` · **Source:** `TODO.md:3` — "This will be deployed on a kiosk mode linux client that won't have a mouse."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The wall's controls exist in the DOM but are invisible without a pointer, and on
|
||||
a kiosk they are therefore unusable. Make every affordance discoverable and
|
||||
operable by keyboard alone, and make the on-screen state legible in a screenshot
|
||||
(a kiosk is debugged from another machine — see `docs/kiosk-silverblue/dim-probe.py`).
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/app.css` — `.tile .actions` (L351–354) is `opacity: 0`
|
||||
unless `:hover` / `:focus-within`, with a `@media (hover: none)` escape hatch
|
||||
that a desktop Firefox on a mouse-less kiosk does **not** match. Add the
|
||||
keyboard states alongside:
|
||||
`.tile:focus-within .actions, .tile:focus-visible .actions,`
|
||||
`.tile[data-selected] .actions { opacity: 1; }`
|
||||
2. `src/mirror/static/app.css` — the staged caption (L396–409) fades to
|
||||
`opacity: 0.28` and only reaches `1` on `:hover`/`:focus-within`. Make it
|
||||
readable for the keyboard case too (`#watch-stage .tile:focus-within .cap`,
|
||||
`.tile[data-selected] .cap`) and keep the quiet look when nothing is focused.
|
||||
3. `src/mirror/static/app.css` — a visible focus ring for every interactive
|
||||
element the router can land on: `.tile:focus-visible`, `.btn:focus-visible`,
|
||||
`.icon-btn:focus-visible`. Do **not** add a blanket `outline: none` anywhere.
|
||||
4. `src/mirror/static/watch.js` — selection and focus must agree:
|
||||
`setSelected()` (task 01) focuses the element; the browser's own `Tab` focus
|
||||
must in turn move the selection, so add a `focusin` handler on `ui.grid` that
|
||||
calls `setSelected()` for the tile that received focus. Without this, `Tab`
|
||||
and `j`/`k` disagree about where the operator is.
|
||||
5. `src/mirror/static/watch.js` — after `unstage()`, return focus to the tile's
|
||||
grid position (it is re-inserted at `state.stageParent`/`state.stageNext`);
|
||||
after `stage()`, focus the staged tile element so `Esc`/`m`/`g` keep working
|
||||
regardless of what had focus before.
|
||||
6. `src/mirror/static/watch.html` — the tile template: give the `<article>`
|
||||
`role="button"` semantics only if it keeps working for real buttons inside it
|
||||
(the `.audio`/`.expand`/`.exit` `<button>`s are already reachable); prefer
|
||||
keeping the `<article>` and adding `aria-keyshortcuts`-free plain labelling —
|
||||
the `aria-label` set in `paintTile()` already says `Watch <label>`.
|
||||
Keep `tests/test_app.py::test_the_two_pages_share_no_element_ids_but_the_shared_chrome`
|
||||
satisfiable: **add no new ids on this task.**
|
||||
7. `src/mirror/static/app.css` — verify against
|
||||
`TestStylesheet::test_no_rule_reintroduces_a_conflicting_display_for_hidden`:
|
||||
reveal with `opacity`/`visibility`, never with `display`.
|
||||
|
||||
ASSUMPTION: revealing on `data-selected`/`:focus-visible` rather than making
|
||||
`.actions` permanently visible, so the wall keeps its current quiet look for a
|
||||
shared display while remaining operable without a pointer.
|
||||
ASSUMPTION: `@media (hover: none)` is kept (touch phones) and the new selectors
|
||||
are added next to it, not in place of it.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `uv run pytest tests/test_app.py::TestStylesheet` — both stylesheet guards stay
|
||||
green; add a case there asserting every `.actions` rule that sets `opacity`
|
||||
also has a non-hover trigger (a regex over the stylesheet is enough and is the
|
||||
style the existing tests use).
|
||||
- E2E in `tests/e2e/test_keyboard_wall.py`: with a mirror live, focus a tile with
|
||||
`page.keyboard.press("Tab")` until `document.activeElement` is a tile, then
|
||||
assert `getComputedStyle(tile.querySelector(".actions")).opacity === "1"` and
|
||||
that `.tile:focus-visible` produced a visible outline — with **no**
|
||||
`page.hover()` anywhere in the test.
|
||||
- E2E: `Tab` moves the selection (`data-selected` follows `document.activeElement`).
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `grep -n "opacity: 0" src/mirror/static/app.css` shows no control that is
|
||||
revealed *only* by `:hover`.
|
||||
- [ ] A keyboard-only E2E run can reach and read the audio/expand controls of a
|
||||
tile without a single pointer event.
|
||||
- [ ] Screenshot of a focused tile shows an unambiguous focus ring (check the
|
||||
Playwright trace/screenshot artifact).
|
||||
- [ ] `uv run pytest` green; no visual regression on the share page.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Task 04 — Key help, share-page keys, and the mouse-free suite
|
||||
|
||||
**Phase:** `01_keyboard_kiosk_control` · **Source:** `TODO.md:3` — "Need keyboard shortcuts. This will be deployed on a kiosk mode linux client that won't have a mouse. Need to be able to fullscreen a mirror, switch between them easily, etc."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Finish the capability: the operator can discover the keys on screen (`?`), drive
|
||||
`/mirror` from the keyboard, and there is a committed E2E suite that proves the
|
||||
whole wall works with the pointer left at rest.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/mirror/static/watch.html` — add the overlay after `#watch-stage`:
|
||||
`<div class="help" id="wall-help" hidden>` containing a `<dl>` of the key map
|
||||
from `00_phase.md` and a line saying `? closes this`. `/watch`-only id: the
|
||||
only ids two pages may share are `net`, `notice`, `toast`
|
||||
(`tests/test_app.py::test_the_two_pages_share_no_element_ids_but_the_shared_chrome`).
|
||||
2. `src/mirror/static/app.css` — `.help` as a centred, `position: fixed` panel
|
||||
above the wall (`z-index` above `#watch-stage`'s `50`), readable at 3 m on a
|
||||
wall display; reveal/hide via the global `[hidden] { display: none !important }`
|
||||
rule that already exists — do not fight it (see `TestStylesheet`).
|
||||
3. `src/mirror/static/watch.js` — reach for it once (`help: $("#wall-help")`,
|
||||
which the element-existence guard then enforces) and add `toggleHelp()` to the
|
||||
router: `?` (i.e. `event.key === "?"`) toggles it; `Escape` closes the overlay
|
||||
**first** and only unstages when the overlay was not open. Keep focus on the
|
||||
tile so `?` → `?` → `Esc` returns exactly where the operator was.
|
||||
4. `src/mirror/static/share.js` — a small router of its own (the two pages do not
|
||||
share a key handler; duplicating eight lines is cheaper than a new abstraction
|
||||
over `net.js`): ignore `Ctrl`/`Cmd`/`Alt` chords and any key while `#name`
|
||||
has focus, then `s` → `start()`, `x` → `stop()`, and keep the existing
|
||||
`Escape` → `stop()`. A `keydown` is a user activation, so `getDisplayMedia()`
|
||||
from `s` is allowed by the autoplay/permission policy — comment that, because
|
||||
it is the reason `s` works at all.
|
||||
5. `src/mirror/static/share.js` — when `#start` is disabled (a refused address or
|
||||
no secure context) the `s` key must not silently fail: route to the same
|
||||
`showNotice()`/`captureSupport()` path the button click uses (it already does —
|
||||
`start()` checks `captureSupport()` — confirm and cover it).
|
||||
6. `tests/e2e/test_keyboard_wall.py` — the suite tasks 01–03 hang their assertions
|
||||
on. `pytestmark = pytest.mark.e2e`, reuse the `device`/`base_url`/`browser`
|
||||
fixtures and `start_sharing()` helper from `tests/e2e/conftest.py`, and keep the
|
||||
file **pointer-free**: no `click()`, no `hover()`. Cases: select+wrap, stage,
|
||||
`n`/`p` between two mirrors, `1`–`9`, `g`, `m`, `?` open/close, `Esc`
|
||||
overlay-then-stage, and `/mirror` `s`/`x` starting and stopping a real capture.
|
||||
Print `pageerror`/`console` from the first line of each test (`AGENTS.md` §8).
|
||||
7. `README.md` (the "Kiosk client" section, ~L111) and
|
||||
`docs/kiosk-silverblue.md` — a short "Keys" table for the wall, and the note
|
||||
that the kiosk needs no keyboard attached to be operated *or* debugged, but the
|
||||
keys are there when one is plugged in.
|
||||
|
||||
ASSUMPTION: the overlay is static markup in `watch.html` rather than generated
|
||||
from a JS map, so `test_every_element_the_script_reaches_for_exists` keeps
|
||||
verifying it and a translator could later find the strings in the page.
|
||||
ASSUMPTION: `/mirror` gets only `s`/`x`/`Esc`. Adding keys that mirror the wall on
|
||||
the share page would tempt a kiosk to share by accident; sharing always stays an
|
||||
explicit, chosen act.
|
||||
ASSUMPTION: `?` is the only key requiring `Shift` and is matched on `event.key`,
|
||||
so layouts where `?` is a dead key are simply left alone (the overlay's existence
|
||||
is documented in the README for those operators).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- `uv run pytest tests/test_app.py::TestClientScripts` — `node --check` on the
|
||||
changed modules, every new `$("#…")` present in its page, no new cross-page id.
|
||||
- `uv run pytest tests/e2e/test_keyboard_wall.py` — the full keyboard walk, green
|
||||
and pointer-free. Enforce "pointer-free" in the test file itself:
|
||||
`assert "click(" not in Path(__file__).read_text()` in a tiny meta-test, so the
|
||||
guarantee cannot rot.
|
||||
- `uv run pytest -m "not e2e"` fast suite green; `MIRROR_REALDISPLAY=1 uv run
|
||||
pytest -m realdisplay` unaffected (this task touches no capture code).
|
||||
- Coverage: **>90%** on new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `?` opens a readable key list over the wall; `?` or `Esc` closes it; the
|
||||
overlay is the element `watch.js` reaches for and it exists in `watch.html`.
|
||||
- [ ] `s` starts and `x` stops a share on `/mirror`, and `s` in the display-name
|
||||
field types an "s" instead.
|
||||
- [ ] `uv run pytest -m e2e` green, including the pointer-free keyboard suite.
|
||||
- [ ] README and `docs/kiosk-silverblue.md` both list the keys.
|
||||
- [ ] Full gate: `uv run ruff check . && uv run ruff format --check . && uv run
|
||||
pyright && uv run pytest`.
|
||||
Reference in New Issue
Block a user