chore(agent): add phase 05 (autohide_stage_controls) from TODO.md

The staged-tile overlay (caption bar, controls, Exit) now fades away by
default and reveals briefly on mouse hover / wall keypress, per the TODO
item. Three tasks: CSS rest-state hide, JS reveal-on-activity with a 3 s
idle timer, and a dedicated E2E lifecycle suite.

LLM-Generated: true
This commit is contained in:
2026-11-02 18:54:20 -05:00
parent 5bb39792a9
commit 80656a3592
4 changed files with 240 additions and 0 deletions
@@ -0,0 +1,43 @@
# Phase 05 — The staged mirror's overlay fades away
**Source:** `TODO.md` L3 — "the staged (fullscreen) tile's details should only show up briefly on mouse hover and then fade away when full screen"
**Story:** n/a
**Context:** `PLAN.md` §5 (E2E ladder — assertions on the viewer's side), `docs/kiosk-silverblue.md` (the mouseless-wall rule: "no pointer affordance may be the only way to see state"), `src/mirror/static/app.css` (staged-tile block, L594–612), `src/mirror/static/watch.js` (`stage()`/`unstage()`, the wall key router)
## Objective
A staged mirror is the video first. While a tile is on stage, its caption bar
(label; device · surface · dims · bitrate · fps — the `sub` line) and its
control cluster must be **hidden by default**, so a shared terminal's bottom
line is never covered. They appear **briefly** on mouse hover and on any wall
keypress (the kiosk has no mouse), and fade away again after an idle timeout.
## Dependencies
- `04_reachable_share_cta` (complete) — the e2e conventions this phase's tests
reuse (the `device`-style two-browser fixture with the fake-media capture of
`tests/e2e/`), and the current `stage()`/`unstage()` behaviour this phase
modifies
## Tasks
1. `01_hide_staged_overlay_at_rest.md` — staged `.cap` + `.actions` hidden at rest (CSS); the existing staged-caption test now asserts the new rest state
2. `02_reveal_on_activity.md` — reveal on pointer/keypress with a 3 s idle auto-hide (`watch.js` + the `[data-reveal]` CSS rules)
3. `03_e2e_autofade_lifecycle.md` — dedicated E2E: hidden at rest → mouse reveal → idle fade → key reveal → Exit usable while revealed → grid kiosk rules unchanged
## Testing & Quality
- Unit/integration: the behaviour is frontend — per `PLAN.md` §5 it is asserted
in the browser (a real staged tile under the fake-media capture), never by
unit tests pretending to be WebRTC. The CSS-structure guards in
`tests/test_app.py::TestStylesheet` (`.actions` hide + non-hover reveal pair)
must keep passing.
- Coverage: **>90%** on new/modified code — the JS/CSS change is exercised end
to end by `tests/e2e/test_stage_autofade.py`, which runs in isolation in
this phase's final pass.
- No Python behaviour changes; the four gates of `PLAN.md` §5 stay green.
## Completion Criteria
- [ ] with a staged tile idle, computed opacity of `.cap` and `.actions` is `0` — the shared video's bottom edge is unobstructed (the motivating case: a shared terminal's last line is visible)
- [ ] mouse movement over the staged tile reveals both (opacity `1`); after ~3 s of idle they fade back to `0`
- [ ] any wall keypress while staged reveals them, and the **Exit** button is visible and clickable while revealed (clicking it unstages)
- [ ] grid-tile behaviour is byte-for-byte unchanged (unselected `.actions` hidden; `data-selected`/focus reveal — the kiosk screenshot rule)
- [ ] test suite green (all four gates of `PLAN.md` §5), coverage >90%
- [ ] no behavior change in completed phases
@@ -0,0 +1,60 @@
# Task 01 — Hide the staged overlay at rest
**Phase:** `05_autohide_stage_controls` · **Source:** `TODO.md:3` — ""Primary Monitory", "Firefox 2160x1440", 2.2 Mbps, 20fps and the word "Exit" all block the screen share at the bottom. This makes sharing terminal windows annoying because it's difficult to see the bottom line. Those details should only show up briefly on mouse hover and then fade away when full screen."
## Objective
While a tile is staged (fullscreen), its caption bar and its control cluster
must be **hidden by default** so the shared video — a terminal's bottom line
included — is never covered. Grid-tile styling stays exactly as it is today.
## Work
1. `src/mirror/static/app.css` (staged block, L594–612) — change
`#watch-stage .tile .cap` from `opacity: 0.28` to `opacity: 0`; **delete**
the three staged reveal rules (`#watch-stage .tile:hover .cap`,
`:focus-within`, `[data-selected]`) and `#watch-stage .tile .actions
{ opacity: 1 }`; **add** `#watch-stage .tile .actions { opacity: 0 }`, and
make both staged hide rules pointer-transparent — but a
`pointer-events: none` container does **not** shield children whose
default is `auto`, so the hidden Exit/mute buttons would stay live dead
zones over the video: scope it to the container *and its children*:
`#watch-stage .tile .cap, #watch-stage .tile .cap *` and `#watch-stage
.tile .actions, #watch-stage .tile .actions *` with `pointer-events:
none`.
The id-scoped selector must outrank the grid rules `.tile[data-selected]
.actions` and `.tile:focus-within .actions`, which also match the staged
tile (it keeps `data-selected` and holds focus) — an id in the selector is
what makes that true; verify by the computed-opacity assertion in step 3,
not by assumption.
2. `src/mirror/static/app.css` — replace the comment block `/* The staged
caption is quiet (0.28) so the video is what is being watched; a kiosk has
no mouse, so keyboard focus and the selection state are the way to read it
back. */` with the new contract: hidden at rest; task 02's `data-reveal`
(pointer activity or a keypress) is the only staged reveal, and the small
`::after` audio mark is unaffected (it is a separate rule and stays).
3. `tests/e2e/test_keyboard_wall.py::TestFocusVisibleControls::test_focus_moves_to_the_stage_and_back_with_g`
— replace the `# The staged caption is legible while the tile holds focus`
block (which waits for `.cap` opacity `1`) with the new rest-state
assertion: `wait_for_function` (timeout **10 s**) that the staged tile's
`.cap` **and** `.actions` computed opacity are `0`. 10 s stays green under
task 02 as well: an Enter-press reveal lasts 3 s + 200 ms fade, so the
settled `0` is always reachable inside the window.
Do **not** touch the grid guard in the same file
(`TestFocusVisibleControls::test_the_wall_stays_quiet_until_something_is_focused` —
grid `.actions` opacity `0` at rest / `data-selected` + focus reveal) — those
rules are intact.
## Testing & Quality
- Unit/integration: `tests/test_app.py::TestStylesheet` guards must keep
passing — in particular `test_a_hidden_control_is_not_revealed_only_by_hover`
(the grid `.actions` hide + non-hover reveal pair is untouched) and
`test_no_rule_reintroduces_a_conflicting_display_for_hidden`.
- Coverage: **>90%** on this task's new/modified code — CSS is exercised by
the updated E2E assertion per project convention (no CSS unit harness).
## Completion Criteria
- [ ] with a staged tile and no pointer activity, `getComputedStyle` opacity of `.cap` and `.actions` is `0` (asserted by the updated `test_focus_moves_to_the_stage_and_back_with_g`)
- [ ] grid tiles: unselected `.actions` still `0`; `data-selected`/focus reveal still `1` (existing E2E tests green)
- [ ] the `::after` audio mark (`data-audio="on"`/`"off"`) still renders on the staged tile (no rule touched it; spot-checked by the existing audio E2E tests)
- [ ] all four gates of `PLAN.md` §5 green
@@ -0,0 +1,76 @@
# Task 02 — Reveal on activity, auto-hide when idle
**Phase:** `05_autohide_stage_controls` · **Source:** `TODO.md:3` — "Those details should only show up briefly on mouse hover and then fade away when full screen."
## Objective
While staged, the caption and controls appear on mouse hover — and, for the
mouseless kiosk, on any wall keypress — then fade away again after an idle
timeout, so a parked pointer (or a one-time keypress) does not pin them up.
## Work
1. `src/mirror/static/watch.js` — add the idle mechanism beside
`stage()`/`unstage()`: `const STAGE_REVEAL_IDLE_MS = 3000;` (named
constant; a per-module `let stageRevealTimer = 0;`) and
`function revealStageControls()`: return when `state.staged` is null; set
`data-reveal` on the staged tile's element; clear and re-arm
`stageRevealTimer = setTimeout(...)` for `STAGE_REVEAL_IDLE_MS`; the
callback deletes the attribute **only if that tile is still staged**
(`state.staged === tile.id`) — the tile may have been unstaged or replaced
(n/p, 1-9, a click) before the timer fires.
- `ASSUMPTION: A1 — 3 s is the TODO's "briefly"; the fade itself is the
existing CSS opacity transition (~0.2 s on `.cap`, 0.15 s on `.actions`),
so no new animation is added (and `prefers-reduced-motion` already kills
transitions globally).`
2. `src/mirror/static/watch.js` — wire the triggers:
`ui.stage.addEventListener("pointermove", revealStageControls)` and
`ui.stage.addEventListener("pointerdown", revealStageControls)`
(`pointerdown` = a touch tap reveals, so a phone watcher can reach the
controls — `ASSUMPTION: A5`); and in `onWallKey`, after the bail-outs
(defaultPrevented / chords / text field) but before the `switch`,
`if (state.staged) revealStageControls();` — **any** wall keypress while
staged reveals, handled or not (a silent no-op key flashing the caption is
the debuggable outcome; the call never `preventDefault`s).
- `ASSUMPTION: A4 — keypress reveal is what keeps the kiosk rule intact
("no pointer affordance may be the only way to see state",
docs/kiosk-silverblue.md + the wall key-router header): the staged tile
always holds focus (`stage()` calls `tile.el.focus()`), so the old
`:focus-within` reveal can no longer be used without defeating the TODO.`
3. `src/mirror/static/watch.js` — `unstage()`: clear `stageRevealTimer` and
delete `data-reveal` from the departing tile, so a pending reveal can never
leak back into the grid (grid tiles never read `data-reveal`).
- `ASSUMPTION: A2 — the hidden state is tied to the **staged** state (the
in-page `#watch-stage` covers the viewport, `position: fixed; inset: 0`),
not to `document.fullscreenElement`: the Silverblue kiosk forces the
browser *window* fullscreen (docs/kiosk-silverblue.md,
window-config.ini) and the element `requestFullscreen()` there is a
deliberate no-op — a fullscreen-element check would leave the kiosk's
caption permanently up.`
- `ASSUMPTION: A3 — the top-right `.actions` cluster (mute/expand) fades
with the caption: same occlusion class. The small "live" `.badge` (top-
left) and the `::after` audio mark stay always visible: status, not
detail, and both are tiny.`
4. `src/mirror/static/app.css` — add the reveal pair, mirroring task 01's
container+children scoping: `#watch-stage .tile[data-reveal] .cap,
#watch-stage .tile[data-reveal] .cap * { opacity: 1; pointer-events:
auto }` and the same for `.actions` — id-scoped, outranking task 01's
hide rules; `pointer-events: auto` is what makes the Exit/mute buttons
clickable again the moment the overlay is revealed (setting `opacity: 1`
on the children is harmless); the existing `transition` on both base
rules gives the fade in and out.
## Testing & Quality
- Unit/integration: exercised by the E2E in task 03 (real staged tile; real
mouse and keyboard events through Playwright); the page must stay free of
`pageerror`/`console` errors — the e2e suites capture both from the first
line.
- Coverage: **>90%** on this task's new/modified code — the reveal lifecycle
is asserted in the browser per project convention.
## Completion Criteria
- [ ] mouse move over the staged tile sets `data-reveal` (computed opacity `1`); after 3 s + transition with no pointer input, the attribute is gone and opacity is `0` again
- [ ] a wall keypress (`m`) while staged reveals the overlay; a parked tile hides it again
- [ ] unstaging (Exit click / `g` / Esc) clears the attribute and the pending timer; re-staging starts hidden
- [ ] replacing the staged tile (n/p / 1-9 / click) never leaves a stale `data-reveal` on the departing tile
- [ ] all four gates of `PLAN.md` §5 green
@@ -0,0 +1,61 @@
# Task 03 — E2E: the staged overlay's auto-hide lifecycle
**Phase:** `05_autohide_stage_controls` · **Source:** `TODO.md:3` — ""Primary Monitory", "Firefox 2160x1440", 2.2 Mbps, 20fps and the word "Exit" all block the screen share at the bottom. This makes sharing terminal windows annoying because it's difficult to see the bottom line."
## Objective
A dedicated Playwright E2E suite that pins the whole lifecycle on a real
capture: hidden at rest, mouse reveal, idle fade, key reveal, Exit usable
while revealed, and the grid's kiosk reveal rules unchanged. Per `PLAN.md`
§5, everything is asserted on the **viewer's** side (the wall's DOM state),
never on the sharer's preview.
## Work
1. `tests/e2e/test_stage_autofade.py` — new file following the e2e
conventions of `tests/e2e/test_keyboard_wall.py`: a `device`-style fixture
(two browser contexts over the session `base_url`/`browser` fixtures,
fake-media capture, `pageerror` + `console` captured from the first line
of the test and asserted `[]` at the end); a helper that shares one mirror
and stages it (`Enter` → `body.staged`); and a probe that reads, for the
staged tile, the computed opacity of `.cap` and `.actions` plus the
presence of `data-reveal`.
2. `tests/e2e/test_stage_autofade.py` — the tests (each ends with
`assert errors == []`):
- **rest** — after staging and the settled fade, computed opacity of
`.cap` and `.actions` is `0` (the motivating case: the shared video's
bottom edge is unobstructed; no element other than the `video` itself
answers a `document.elementFromPoint` probe at the bottom-centre of the
stage).
- **mouse reveal** — `viewer.mouse.move(...)` inside `#watch-stage` →
`data-reveal` present, both opacities `1`.
- **idle fade** — after the last mouse move, wait out 3 s + transition →
`data-reveal` gone, both opacities `0`.
- **key reveal + Exit** — a wall keypress (`m`; tolerate and assert its
mute flip, as `TestWallCommands::test_m_flips_the_staged_videos_muted_flag`
does) reveals the overlay; while revealed the **Exit** button is visible
(`hidden` false) and a click on it unstages (`body` loses `staged`).
- **grid guard** — with nothing staged: an unselected tile's `.actions`
computed opacity is `0`; the `data-selected` tile's is `1` (the kiosk
screenshot rule survives the id-scoped overrides).
3. `tests/e2e/test_stage_autofade.py` — keep the module's docstring in the
style of its neighbours: name the bug it pins (the caption bar covered a
shared terminal's bottom line because the staged tile forced
`data-selected` → `.cap` opacity `1`), the probe that reproduces it
(computed opacity of `.cap` while staged and idle), and why each test
exists.
## Testing & Quality
- Unit/integration: this **is** the test suite — real capture → encode →
SRTP → decode, then DOM assertions on the viewer per `PLAN.md` §5 (content
assertions over event assertions; the rest-state test's hit probe is the
strongest form: *nothing* but the video is at the bottom edge).
- Coverage: **>90%** on this task's new/modified code — the entire reveal
lifecycle is asserted here; the suite runs in isolation in the phase's
final pass (`uv run pytest tests/e2e/test_stage_autofade.py`).
## Completion Criteria
- [ ] all five tests in `test_stage_autofade.py` pass, in isolation and inside `uv run pytest -m e2e`
- [ ] no regression in `test_keyboard_wall.py` (stage/unstage, focus hand-back, `m`, Exit — phases 01–04 behaviour intact)
- [ ] no new external asset, no new dependency (`PLAN.md` §8)
- [ ] all four gates of `PLAN.md` §5 green