Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra f51d0fe9ae phase: 05_autohide_stage_controls
Build and Push Containers / test (push) Successful in 32s
Build and Push Containers / build-and-push-app (push) Successful in 8s
**Phase 05 final verification pass — all green.**

- Verified implementation in tree: staged `.cap`/`.actions` hidden at rest with container+children `pointer-events: none` (`app.css`); `revealStageControls()` with 3 s idle timer, `pointermove`/`pointerdown` + wall-keypress wiring, `unstage()` timer/attribute cleanup (`watch.js`); `tests/e2e/test_stage_autofade.py` (5 tests) present; `test_keyboard_wall.py` rest-state assertion updated per task 01.
- Gates: `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — **274 passed, 4 skipped** (realdisplay-gated), coverage **95%** (>90% ✓); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — **3 passed, 1 skipped** (documented platform skip: no audio source on host).
- Dedicated phase suite `uv run pytest tests/e2e/test_stage_autofade.py` — **5/5 in isolation**.
- Criteria: idle opacity 0 + bottom-edge hit probe ✓; mouse reveal → 1 → idle fade → 0 ✓; keypress reveal + Exit visible/clickable/unstages ✓; grid rules byte-for-byte unchanged (CSS diff touches only the staged block; grid guard tests green) ✓; suite green, coverage >90% ✓; phases 01–04 suites all green (no regression) ✓.
- Documentation: README.md reviewed in full — all commands, env vars (incl. `DEBUGPY_*`, 25 Mbit/s default), `--allow-cidr`, kiosk/share key tables, index-page and auto-hide descriptions match the code; the only stale "12 Mbit/s" default lives in locked PLAN.md §6, README is correct — no change made. AGENTS.md: all facts (gates, test names, starlette 1.10, e2e flags, ~95% coverage) current — verified, no changes needed.
- Notable: no code changes were required this pass — the phase work was already complete and correct; nothing to fix.
- Next pending phase: none — phase 05 is the last in `todo/`.
2026-11-02 19:41:54 -05:00
ducoterra 80656a3592 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
2026-11-02 18:54:20 -05:00
31 changed files with 950 additions and 19 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
@@ -0,0 +1,9 @@
**Phase 05 final verification pass — all green.**
- Verified implementation in tree: staged `.cap`/`.actions` hidden at rest with container+children `pointer-events: none` (`app.css`); `revealStageControls()` with 3 s idle timer, `pointermove`/`pointerdown` + wall-keypress wiring, `unstage()` timer/attribute cleanup (`watch.js`); `tests/e2e/test_stage_autofade.py` (5 tests) present; `test_keyboard_wall.py` rest-state assertion updated per task 01.
- Gates: `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors; `uv run pytest` — **274 passed, 4 skipped** (realdisplay-gated), coverage **95%** (>90% ✓); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — **3 passed, 1 skipped** (documented platform skip: no audio source on host).
- Dedicated phase suite `uv run pytest tests/e2e/test_stage_autofade.py` — **5/5 in isolation**.
- Criteria: idle opacity 0 + bottom-edge hit probe ✓; mouse reveal → 1 → idle fade → 0 ✓; keypress reveal + Exit visible/clickable/unstages ✓; grid rules byte-for-byte unchanged (CSS diff touches only the staged block; grid guard tests green) ✓; suite green, coverage >90% ✓; phases 01–04 suites all green (no regression) ✓.
- Documentation: README.md reviewed in full — all commands, env vars (incl. `DEBUGPY_*`, 25 Mbit/s default), `--allow-cidr`, kiosk/share key tables, index-page and auto-hide descriptions match the code; the only stale "12 Mbit/s" default lives in locked PLAN.md §6, README is correct — no change made. AGENTS.md: all facts (gates, test names, starlette 1.10, e2e flags, ~95% coverage) current — verified, no changes needed.
- Notable: no code changes were required this pass — the phase work was already complete and correct; nothing to fix.
- Next pending phase: none — phase 05 is the last in `todo/`.
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
......................ssss.............................................. [ 51%]
........................................................................ [ 77%]
.............................................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
274 passed, 4 skipped, 1 warning in 96.65s (0:01:36)
All checks passed!
31 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,16 @@
All gates verified green after the final code changes. Done.
**Report — Task 01 (phase 05): hide the staged overlay at rest**
- `app.css`: staged `.cap` `opacity: 0.28 → 0`; deleted the three staged reveal rules and staged `.actions { opacity: 1 }`; added id-scoped `#watch-stage .tile .actions { opacity: 0 }` (outranks the grid `data-selected`/`focus-within`/`hover:none` rules — verified by the E2E computed-opacity assertion, not assumption); `pointer-events: none` scoped to both containers *and their children* (no dead zones); comment block replaced with the new contract (hidden at rest, task-02 `data-reveal` is the only staged reveal, `::after` audio mark untouched)
- `test_keyboard_wall.py`: `test_focus_moves_to_the_stage_and_back_with_g` now waits (10 s) for staged `.cap` **and** `.actions` computed opacity `0`; grid guard test untouched
- `test_live_mirror.py` ×2 + `test_audio_share.py` ×1 (deviation, forced by the task's own CSS): with the overlay pointer-transparent at rest and no reveal yet, the existing `#watch-stage .exit` / staged `.audio` clicks are impossible — replaced those unstage/mute actions with the `g`/`m` keys (comment notes the revealed-state Exit click is asserted by task 03's E2E); `to_be_visible` assertions kept
- README: one accurate-now-and-still-true line — staged caption/controls are hidden at rest so the video is never covered. No AGENTS.md impact (no statement touched by this task went stale)
Gates (all green, in order):
- `uv run ruff check . && uv run ruff format --check .` → passed / 30 files formatted
- `uv run pyright` → 0 errors
- `uv run pytest` → **269 passed, 4 skipped** (realdisplay marker)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host has no audio source for real capture — documented platform limit)
Next pending task: `02_reveal_on_activity.md`
@@ -0,0 +1,34 @@
........................................................................ [ 26%]
.................ssss................................................... [ 52%]
........................................................................ [ 79%]
......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
269 passed, 4 skipped, 1 warning in 92.81s (0:01:32)
All checks passed!
30 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,19 @@
Everything is green and in order. Final report:
**Implemented (task 02 — reveal on activity, auto-hide when idle)**
- `watch.js`: `STAGE_REVEAL_IDLE_MS = 3000` + `let stageRevealTimer = 0` + `revealStageControls()` — sets `data-reveal` on the staged tile, clears/re-arms the timer; callback deletes the attribute only if `state.staged === tile.id` (tile may be unstaged/replaced before it fires)
- Triggers wired per spec: `pointermove` + `pointerdown` (touch tap, A5) on `#watch-stage`; `if (state.staged) revealStageControls()` in `onWallKey` after bail-outs, before the `switch`, never `preventDefault`
- `unstage()`: clears the pending timer and deletes `data-reveal` from the departing tile — no stale reveal can leak into the grid (grid rules never read it; re-staging starts hidden)
- `app.css`: id-scoped `[data-reveal]` reveal pair for `.cap`/`.actions` + children (`opacity: 1; pointer-events: auto`), outranking the task-01 hide rules; existing transitions give the fade, `prefers-reduced-motion` already degrades it to instant
**Verification (temporary E2E, deleted after — the dedicated suite is task 03's deliverable)**
- 5 browser tests on a real staged tile (fake-media capture): mouse reveal → 3 s idle fade, `m`-keypress reveal → fade, unstage clears attribute+timer (survived 3.6 s timer window), n/p + 1-9 replacement leaves no stale `data-reveal`, Exit clickable while revealed and unstages — all passed
- `uv run ruff check . && uv run ruff format --check .` — clean; `uv run pyright` — 0 errors
- `uv run pytest` — 269 passed, 4 skipped (realdisplay-gated), coverage 95% (no Python code changed; JS/CSS verified in-browser per convention)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 3 passed, 1 env skip (host has no audio source)
**Docs**: README.md — staged-overlay paragraph extended with the reveal/~3 s auto-hide behaviour; AGENTS.md — no impact (no statement became stale)
**Notable**: a keypress that *causes* staging (Enter) doesn't reveal — `state.staged` is null pre-switch, so the overlay stays hidden at rest, keeping task 01's 10 s rest-state test green.
**Next pending task**: `03_e2e_autofade_lifecycle.md`
@@ -0,0 +1,34 @@
........................................................................ [ 26%]
.................ssss................................................... [ 52%]
........................................................................ [ 79%]
......................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
269 passed, 4 skipped, 1 warning in 95.80s (0:01:35)
All checks passed!
30 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,10 @@
All four gates green. Task complete.
**Report**
- **Implemented:** `tests/e2e/test_stage_autofade.py` — 5-test suite pinning the staged overlay lifecycle on a real capture: `rest` (opacity 0 for `.cap`/`.actions` + `elementFromPoint` bottom-centre hit is the `<video>` itself), `mouse reveal`, `idle fade` (3 s + transition), `key reveal + Exit` (`m` mute flip tolerated/asserted; Exit visible + clickable while revealed; click unstages; no stale `data-reveal` leaks to the grid), `grid guard` (unselected `.actions` 0 / `data-selected` 1). `device` fixture, `share_one_and_stage` helper, `staged_overlay` probe; `pageerror`+`console` asserted `[]` in every test; docstring names the bug (staged tile kept `data-selected`/focus → grid rules forced `.cap` opaque over a shared terminal's bottom line) and its repro probe.
- **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean (31 files); `uv run pyright` → 0 errors; `uv run pytest` → 274 passed, 4 skipped (realdisplay, env-gated); `uv run pytest -m e2e` → 94 passed, 4 skipped; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed, 1 skipped (host audio, documented); suite in isolation: 5 passed.
- **Regressions:** `test_keyboard_wall.py` 35/35 passed — phases 01–04 intact.
- **Docs:** no doc impact — README already documented the auto-hide behaviour (updated in tasks 01/02); AGENTS.md has no stale statements.
- **Decisions:** one Playwright API fix — `wait_for_function` takes `arg` keyword-only in this version. No Python behaviour changes, no new assets/dependencies.
- **Next pending task:** none — this was the last task of phase 05; phase completion criteria are met.
@@ -0,0 +1,89 @@
...F.................................................................... [ 25%]
......................ssss.............................................. [ 51%]
........................................................................ [ 77%]
.............................................................. [100%]
=================================== FAILURES ===================================
_ TestStagedAudioPlayback.test_unstaging_re_mutes_and_re_staging_does_not_revive_audio _
self = <tests.e2e.test_audio_share.TestStagedAudioPlayback object at 0x7f1fdf890cd0>
base_url = 'http://127.0.0.1:38615'
browser = <Browser type=<BrowserType name=chromium executable_path=/home/ducoterra/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome> version=156.0.8078.4>
request = <FixtureRequest for <Function test_unstaging_re_mutes_and_re_staging_does_not_revive_audio>>
device = <function device.<locals>._open at 0x7f1fdf2e9590>
def test_unstaging_re_mutes_and_re_staging_does_not_revive_audio(
self, base_url, browser, request, device
):
"""`g` returns the wall to silence, and re-staging the same mirror does
not revive the sound — every audible state must trace back to a
gesture (SECURITY.md §4)."""
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
viewer = open_viewer(browser, base_url, request, errors=errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
viewer.keyboard.press("Enter") # nothing selected yet: the first tile
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
time.sleep(2.0)
assert viewer.evaluate(f"() => {self.STAGE_MUTED}") is True
viewer.keyboard.press("m")
assert viewer.evaluate(f"() => {self.STAGE_MUTED}") is False
# Back to the wall: the tile the stage leaves behind is muted.
viewer.keyboard.press("g")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
assert viewer.evaluate(f"() => {self.GRID_MUTED}") is True
# Re-staging the same mirror does not bring the sound back.
viewer.keyboard.press("Enter")
> expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
E AssertionError: Locator expected to have class 're.compile('staged')'
E Actual value:
E Call log:
E - Expect "to_have_class" locator("body") with timeout 5000ms
E - waiting for locator("body")
E 14 × locator resolved to <body class="">…</body>
E - unexpected value ""
E
E Aria snapshot:
E - banner:
E - text: mirror 1 mirror · 1 live online
E - link "Share a screen":
E - /url: http://127.0.0.1:38615/mirror
E - main:
E - article "Watch screen:-3:0"
tests/e2e/test_audio_share.py:448: AssertionError
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
FAILED tests/e2e/test_audio_share.py::TestStagedAudioPlayback::test_unstaging_re_mutes_and_re_staging_does_not_revive_audio
1 failed, 273 passed, 4 skipped, 1 warning in 102.56s (0:01:42)
All checks passed!
31 files already formatted
0 errors, 0 warnings, 0 informations
validation FAILED (see output above)
@@ -0,0 +1,15 @@
All checks complete. Final verification of the full gate state:
- `validate.sh` (harness gate: full pytest + ruff + format + pyright + repo hygiene): **274 passed, 4 skipped** (realdisplay, env-gated), `All checks passed!`, `31 files already formatted`, `0 errors`, no hygiene findings
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: **3 passed, 1 skipped** (host audio, documented in the module docstring)
- Failing test `test_unstaging_re_mutes_and_re_staging_does_not_revive_audio`: 6/6 clean runs after the fix (plus 6/6 pre-fix runs were green when isolated — the failure is load/timing-dependent, exposed under the full-suite run)
- README consistency review: verified against code — all routes (`/`, `/mirror`, `/share`, `/watch`, `/api/config`, `/api/streams`, `/healthz`, `/ws`), all 11 config variables with defaults (`0.0.0.0`, `8055`, `25000000`, `20`, `127.0.0.0/8, ::1/128`, …), both key tables, the audio states (`shared`/`silent`/`not offered` in `share.js`), and the staged-overlay auto-hide paragraph. No inconsistencies found — no README change needed.
- AGENTS.md stale-fact check: every referenced file/test/command (`registry.py`, `access.py`, `test_nothing_is_loaded_from_a_cdn`, the gates, `DEBUGPY=1` on 5678, `--allow-cidr`) still holds; nothing stale — no AGENTS.md change.
**Report**
- **Fixed:** pre-existing race in `src/mirror/static/watch.js` — `unstage()`'s async `exitFullscreen()` settling after an immediate re-stage (`g` → `Enter`): the late `fullscreenchange` (element null, `state.staged` = the *new* tile) tore down the fresh stage, so `body` never kept `staged`. Added a `fullscreenExitInFlight` flag set in `unstage()`: the handler now ignores the exit of an exit we initiated, clears the flag when the document re-enters fullscreen (re-stage's request won) or the exit settles, and still unstages on a genuine external/WM exit; a rejected exit clears the stale flag when the document is still fullscreen. `restoreFocus` hand-back unchanged.
- **Gates:** `.agents/validate.sh` (full suite + linters) → 274 passed, 4 skipped (realdisplay), ruff clean, 31 files formatted, pyright 0 errors, hygiene clean; `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → 3 passed, 1 skipped (host audio, documented); failing audio test 6/6 green post-fix.
- **Docs:** README fully cross-checked against code (routes, config defaults, keys, audio states, overlay auto-hide) — no inconsistencies, no change; AGENTS.md stale-fact check — no stale statements, no change.
- **Decisions:** fixed the defect in app code (not the test) — the test's `g`→`Enter` sequence is legitimate user behaviour; the old-exit event must not kill a new stage.
- **Next pending task:** none — phase 05 complete.
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
......................ssss.............................................. [ 51%]
........................................................................ [ 77%]
.............................................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
274 passed, 4 skipped, 1 warning in 100.65s (0:01:40)
All checks passed!
31 files already formatted
0 errors, 0 warnings, 0 informations
+6
View File
@@ -169,6 +169,12 @@ in. On `/watch`:
| `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. |
A staged mirror's caption bar and control cluster are **hidden at rest**, so
the shared video — a terminal's bottom line included — is never covered by
them. A pointer move over the stage (a tap, for touch) or any key press
while staged reveals them briefly; after ~3 s without activity they fade
back, so a parked pointer or a one-time key press never pins them up.
A mirror's audio is only heard while it is on the stage: every mirror starts
muted, unmuting one (the tile's audio button or `m`) mutes the others, and
leaving the stage re-mutes it. Sound never starts without a click or a
+31 -8
View File
@@ -599,17 +599,40 @@ body.staged #watch-stage { display: block; }
background: linear-gradient(to top, rgba(4, 6, 10, 0.92), rgba(4, 6, 10, 0));
border-top: 0;
padding: 26px 16px 12px;
opacity: 0.28;
opacity: 0;
transition: opacity 0.2s ease;
}
/* 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. */
#watch-stage .tile:hover .cap,
#watch-stage .tile:focus-within .cap,
#watch-stage .tile[data-selected] .cap { opacity: 1; }
#watch-stage .tile .actions { opacity: 1; }
/* The staged overlay is hidden at rest so the shared video — a terminal's
bottom line included — is never covered. `data-reveal` (pointer activity
or a wall keypress) is the only staged reveal: it shows the caption and
the control cluster briefly and fades them back on idle. The small
`::after` audio mark is a separate rule and is untouched. The id-scoped
selectors outrank the grid rules (`.tile[data-selected] .actions`,
`.tile:focus-within .actions`, the `hover: none` media rule) which also
match the staged tile — it keeps `data-selected` and holds focus. */
#watch-stage .tile .actions { opacity: 0; }
/* A `pointer-events: none` container does not shield children whose default
is `auto`: scope it to the container *and its children* so the hidden
Exit/mute buttons are not live dead zones over the video. */
#watch-stage .tile .cap, #watch-stage .tile .cap *,
#watch-stage .tile .actions, #watch-stage .tile .actions * { pointer-events: none; }
/* `data-reveal` (pointer activity on the stage, or a wall keypress while
staged) is the only staged reveal: it restores opacity and makes the
Exit/mute buttons clickable again the moment the overlay is up — the
`pointer-events: auto` on the children, since a `none` container never
shields an `auto` child (setting `opacity: 1` on the children themselves
is harmless). The transitions on the base rules give the fade in and
out; under `prefers-reduced-motion` the global transition-kill makes the
reveal instant, which is the correct degraded behaviour. The id-scoped
selectors outrank every grid rule that also matches the staged tile. */
#watch-stage .tile[data-reveal] .cap, #watch-stage .tile[data-reveal] .cap *,
#watch-stage .tile[data-reveal] .actions, #watch-stage .tile[data-reveal] .actions * {
opacity: 1;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
+82 -2
View File
@@ -318,6 +318,41 @@ function setMuted(tile, muted) {
/* ------------------------------------------------------------------ stage */
/* The staged overlay (caption bar + control cluster) is hidden at rest so a
shared terminal's bottom line is never covered. Pointer activity on the
stage, and any wall keypress while staged (the kiosk has no mouse),
reveal it via `data-reveal`; this timeout is what fades it back, so a
parked pointer or a one-time keypress never pins it up. */
const STAGE_REVEAL_IDLE_MS = 3000;
let stageRevealTimer = 0;
function revealStageControls() {
if (!state.staged) return;
const tile = tiles.get(state.staged);
if (!tile) return;
tile.el.dataset.reveal = "";
clearTimeout(stageRevealTimer);
stageRevealTimer = setTimeout(() => {
stageRevealTimer = 0;
// The tile may have been unstaged or replaced (n/p, 1-9, a click)
// before the timer fires; only the tile still on stage loses the
// attribute. (unstage() clears the timer, so this guard also covers
// a replacement whose keepFullscreen unstage ran in between.)
if (state.staged === tile.id) delete tile.el.dataset.reveal;
}, STAGE_REVEAL_IDLE_MS);
}
/* A `document.exitFullscreen()` that `unstage()` started is in flight.
The exit settles asynchronously, and a keypress in that window (g followed
by an immediate Enter) stages a new tile before the exit's
`fullscreenchange` event lands: `state.staged` is then the NEW tile, and
the handler would tear the fresh stage down with the old exit. While the
flag is set, an exit event belongs to the unstage that already tore the
stage down synchronously, so it is ignored; the flag is cleared when the
document becomes fullscreen again (a re-stage's request won over the exit)
or when the exit settles. */
let fullscreenExitInFlight = false;
export function stage(streamId, options = {}) {
const tile = tiles.get(streamId);
if (!tile) return;
@@ -361,6 +396,14 @@ export function unstage(options = {}) {
const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null;
document.body.classList.remove("staged");
// The reveal belongs to the stage: every exit (Exit click, `g`, Esc, or a
// replacement's keepFullscreen) clears the pending timeout and the
// departing tile's `data-reveal`, so a stale reveal can never leak back
// into the grid — grid tiles never read the attribute, and re-staging
// must start hidden.
clearTimeout(stageRevealTimer);
stageRevealTimer = 0;
if (tile) delete tile.el.dataset.reveal;
if (tile) {
tile.exitBtn.hidden = true;
if (state.stageParent) {
@@ -393,18 +436,49 @@ export function unstage(options = {}) {
// Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so
// the cursor is handed back to the tile only after the exit has settled.
document.exitFullscreen().then(restoreFocus).catch(restoreFocus);
// The in-flight flag keeps the exit's late `fullscreenchange` from
// tearing down a stage re-entered in the meantime (see the handler).
fullscreenExitInFlight = true;
document.exitFullscreen().then(restoreFocus).catch(() => {
// A rejected exit never settles: if the document is still fullscreen
// the exit was pre-empted (or lost user activation) and the flag is
// stale — drop it so a genuine later exit still unstages.
if (document.fullscreenElement) fullscreenExitInFlight = false;
restoreFocus();
});
} else {
restoreFocus();
}
}
document.addEventListener("fullscreenchange", () => {
if (!document.fullscreenElement && state.staged) unstage();
if (document.fullscreenElement) {
// (Re-)entered fullscreen — a re-stage's request won over our exit, so
// the in-flight flag is stale from this point on.
fullscreenExitInFlight = false;
return;
}
if (fullscreenExitInFlight) {
// Our own unstage's exit settling: the stage was already torn down
// synchronously, and a keypress in the exit window may have re-staged —
// the event must not take that new stage down with it.
fullscreenExitInFlight = false;
return;
}
// The browser (or the kiosk's window manager) left fullscreen on its own;
// the in-page stage is what the operator asked to leave.
if (state.staged) unstage();
});
if (ui.unstage) ui.unstage.addEventListener("click", unstage);
// Pointer activity on the stage reveals the overlay. `pointerdown` — not
// just `pointermove` — is a deliberate touch tap, so a phone watcher can
// reach the controls without a hover; the idle timeout is what fades them
// again, so a parked pointer never pins the overlay up.
ui.stage.addEventListener("pointermove", revealStageControls);
ui.stage.addEventListener("pointerdown", revealStageControls);
/* ------------------------------------------------------------------ socket */
function subscribe(streamId) {
@@ -606,6 +680,12 @@ function onWallKey(event) {
) {
return; // a key typed in a text field is text, not a command
}
// Any wall keypress while staged reveals the overlay, handled or not: a
// key in a mouseless kiosk is the only way to read the caption and reach
// the controls (a silent no-op key flashing the caption is the
// debuggable outcome). The idle timeout fades it again, and this call
// never preventDefaults, so unhandled keys keep their defaults.
if (state.staged) revealStageControls();
// Match case-insensitively: a caps-locked kiosk keyboard, or a remote hand
// holding Shift, must drive the wall exactly the same. "?" is the only key
// that needs Shift; it lowercases to itself, so the same match covers it.
+4 -2
View File
@@ -481,8 +481,10 @@ class TestStagedAudioPlayback:
# Button and `m` cannot disagree: the glyph and aria-pressed come from
# setMuted(), so the button shows the live state.
expect(stage_tile.locator(".audio")).to_have_attribute("aria-pressed", "true")
# A second click on the button mutes again (stayed on stage).
stage_tile.locator(".audio").click()
# A second toggle mutes again (stayed on stage). The staged button is
# not clickable yet — phase 05 hides the overlay at rest — so `m`
# drives the same `setMuted()` path the button would.
viewer.keyboard.press("m")
expect(stage_tile).to_have_attribute("data-audio", "off")
# Back to the wall: silent again.
+10 -5
View File
@@ -618,12 +618,17 @@ class TestFocusVisibleControls:
" && document.activeElement.parentElement.id === 'watch-stage'"
), "staging must hand focus to the staged tile"
# The staged caption is legible while the tile holds focus (the
# opacity transition is 200 ms; wait for the settled value).
# The staged overlay is hidden at rest — caption bar and control
# cluster alike — so a shared terminal's bottom line is never
# covered (phase 05). 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.
viewer.wait_for_function(
"() => document.activeElement !== null"
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'",
timeout=5_000,
"() => { const tile = document.querySelector('#watch-stage .tile');"
" if (!tile) return false;"
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }",
timeout=10_000,
)
# m still works with focus sitting on the stage.
+9 -2
View File
@@ -390,9 +390,14 @@ class TestStageSizing:
expect(viewer.locator(".tile .exit")).not_to_be_visible()
viewer.locator(".tile").click()
# Phase 05: the Exit button still exists on the staged tile (and is
# still absent from the grid), but the overlay is hidden at rest and
# pointer-transparent until the phase's activity reveal lands, so the
# button itself is not clickable yet. `g` leaves the stage the same
# way; the revealed-state click is asserted by the phase 03 E2E.
expect(viewer.locator("#watch-stage .exit")).to_be_visible()
viewer.locator("#watch-stage .exit").click()
viewer.keyboard.press("g")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator(".tile .exit")).not_to_be_visible()
# Leaving the stage must not have disturbed the stream.
@@ -445,7 +450,9 @@ class TestManyToMany:
staged = viewer.locator("#watch-stage .tile").get_attribute("data-stream")
assert staged == ids[1]
viewer.locator("#watch-stage .exit").click()
# The staged overlay is pointer-transparent at rest (phase 05), so
# `g` leaves the stage in place of the Exit click for now.
viewer.keyboard.press("g")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
viewer.locator(".tile").nth(0).click()
+269
View File
@@ -0,0 +1,269 @@
"""End-to-end: the staged overlay's auto-hide lifecycle.
Phase 05, the bug this pins: while a tile was on stage it kept
``data-selected`` and held focus, so the *grid* reveal rules
(``.tile[data-selected] .actions``, ``.tile:focus-within .actions``) matched
the staged tile too and the caption bar (``.cap``: label; device · surface ·
dims · bitrate · fps) and the control cluster (``.actions``: mute/expand,
and the ``.cap``-side "Exit" button) sat over the shared video at all
times. The motivating case (TODO.md L3): sharing a terminal window — the
caption line and the word "Exit" covered the video, so the terminal's bottom
line could not be read. The repro probe: the computed opacity of ``.cap`` on
the staged tile while it is idle — it must be ``0``.
The fix contract this suite pins, per PLAN.md §5 asserted on the **viewer's**
side (computed opacity, the ``data-reveal`` attribute, the ``staged`` class,
the ``hidden`` flag, ``elementFromPoint``) — never on the sharer's preview,
never on whether a handler "ran":
- ``rest`` — hidden at rest; the stage's bottom edge is the ``<video>``
itself (the strongest form of the repro probe: nothing else answers a hit
probe at the bottom centre of the stage).
- ``mouse reveal`` — a pointer move inside ``#watch-stage`` brings both back
(opacity ``1``).
- ``idle fade`` — a parked pointer fades them again (3 s + transition).
- ``key reveal + Exit`` — a wall keypress (``m``; its mute flip is tolerated
and asserted, as the keyboard suite does) reveals the overlay, and the Exit
button is visible and clickable while revealed — clicking it unstages.
- ``grid guard`` — the grid's kiosk reveal rules survive the id-scoped
staged overrides: an unselected tile stays quiet, the selected tile
carries its controls.
"""
from __future__ import annotations
import re
import pytest
from playwright.sync_api import expect
from .conftest import start_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
Uncaught page errors and console errors are captured from the first line of
the test and surfaced through the ``errors == []`` assertion
(AGENTS.md §8: print pageerror/console from the first line of the test).
"""
opened = []
def _open(path: str = "/", errors: list | None = None):
context = browser.new_context(viewport={"width": 1100, "height": 720})
opened.append(context)
request.addfinalizer(context.close)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.on(
"console",
lambda message: (
errors.append(f"console: {message.text}") if message.type == "error" else None
),
)
page.goto(f"{base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return page
return _open
def share_one_and_stage(device, errors: list):
"""Stand up one mirror and stage it the kiosk way (Enter)."""
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(1, timeout=15_000)
viewer.keyboard.press("Enter")
expect(viewer.locator("body")).to_have_class(re.compile("staged"), timeout=5_000)
return viewer
STAGE_OVERLAY_PROBE = """
() => {
const tile = document.querySelector('#watch-stage .tile');
if (!tile) return { missing: true };
return {
cap: getComputedStyle(tile.querySelector('.cap')).opacity,
actions: getComputedStyle(tile.querySelector('.actions')).opacity,
reveal: tile.hasAttribute('data-reveal'),
};
}
"""
def staged_overlay(page) -> dict:
"""The staged tile's overlay state: computed opacities + data-reveal."""
return page.evaluate(STAGE_OVERLAY_PROBE)
# Settled-state probes. The fade is the existing opacity transition (~200 ms
# on .cap, ~150 ms on .actions), so "settled" is the exact 0 / 1 value, not a
# range.
OVERLAY_HIDDEN = (
"() => { const tile = document.querySelector('#watch-stage .tile');"
" if (!tile) return false;"
" return getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
)
OVERLAY_REVEALED = (
"() => { const tile = document.querySelector('#watch-stage .tile');"
" if (!tile) return false;"
" return tile.hasAttribute('data-reveal')"
" && getComputedStyle(tile.querySelector('.cap')).opacity === '1'"
" && getComputedStyle(tile.querySelector('.actions')).opacity === '1'; }"
)
OVERLAY_FADING = (
"() => { const tile = document.querySelector('#watch-stage .tile');"
" if (!tile) return false;"
" return !tile.hasAttribute('data-reveal')"
" && getComputedStyle(tile.querySelector('.cap')).opacity === '0'"
" && getComputedStyle(tile.querySelector('.actions')).opacity === '0'; }"
)
class TestStageAutoHideLifecycle:
"""The staged overlay's auto-hide lifecycle, on a real capture.
Hidden at rest → mouse reveal → idle fade → key reveal → Exit usable
while revealed → the grid's kiosk rules unchanged. Every test ends with
``assert errors == []``: the wall must stay free of uncaught page and
console errors through the whole lifecycle.
"""
def test_staged_overlay_is_hidden_at_rest(self, device):
errors: list = []
viewer = share_one_and_stage(device, errors)
# Settled rest state. An Enter stage does not reveal: the reveal
# trigger runs before the key's command, when nothing is staged yet,
# so the post-staging steady state is the one under test.
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
overlay = staged_overlay(viewer)
assert overlay["reveal"] is False, "an Enter stage must start hidden"
assert overlay["cap"] == "0"
assert overlay["actions"] == "0"
# The motivating case: the shared video's bottom edge is unobstructed.
# Nothing other than the <video> itself may answer a hit probe at the
# bottom centre of the stage — the caption bar and the control
# cluster must be pointer-transparent, not merely invisible.
probe = viewer.evaluate(
"""() => {
const stage = document.querySelector('#watch-stage');
const r = stage.getBoundingClientRect();
const el = document.elementFromPoint(r.left + r.width / 2, r.bottom - 4);
return el ? el.tagName : null;
}"""
)
assert probe == "VIDEO", f"the stage's bottom edge must be the video itself (got {probe!r})"
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_pointer_move_over_the_stage_reveals_the_overlay(self, device):
errors: list = []
viewer = share_one_and_stage(device, errors)
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
# A pointer move inside #watch-stage: the stage covers the viewport,
# so the centre is deep inside it. (The kiosk's own reveal is the
# keypress; this is the path a human watcher with a mouse gets.)
viewer.mouse.move(550, 360)
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
overlay = staged_overlay(viewer)
assert overlay["reveal"] is True
assert overlay["cap"] == "1"
assert overlay["actions"] == "1"
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_parked_pointer_fades_the_overlay_again(self, device):
errors: list = []
viewer = share_one_and_stage(device, errors)
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
viewer.mouse.move(550, 360)
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
# Now sit idle: the 3 s timeout fires, the ~200 ms fade follows, and
# the attribute is gone — a parked pointer must not pin the overlay up.
viewer.wait_for_function(OVERLAY_FADING, timeout=8_000)
overlay = staged_overlay(viewer)
assert overlay["reveal"] is False
assert overlay["cap"] == "0"
assert overlay["actions"] == "0"
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_wall_keypress_reveals_the_overlay_and_exit_unstages(self, device):
errors: list = []
viewer = share_one_and_stage(device, errors)
viewer.wait_for_function(OVERLAY_HIDDEN, timeout=10_000)
# `m` is a real wall command: tolerate and assert its mute flip, as
# the keyboard suite does. The same keypress also reveals the
# overlay — the kiosk's (mouseless) way to read the caption and
# reach the controls.
before = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
viewer.keyboard.press("m")
after = viewer.evaluate("() => document.querySelector('#watch-stage video').muted")
assert after == (not before), f"m must flip muted ({before} -> {after})"
viewer.wait_for_function(OVERLAY_REVEALED, timeout=5_000)
# While revealed, Exit is visible (its `hidden` flag was cleared by
# stage()) and the reveal's pointer-events:auto is what makes it
# clickable.
assert (
viewer.evaluate("() => document.querySelector('#watch-stage .exit').hidden") is False
), "Exit must be visible while the overlay is revealed"
# Clicking it unstages — the grid comes back.
viewer.click("#watch-stage .exit")
expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
# The departing tile must not leak its reveal into the grid.
assert viewer.evaluate("() => document.querySelector('.tile[data-reveal]') === null"), (
"a stale data-reveal must not leak back into the grid"
)
assert errors == [], f"uncaught browser errors: {errors}"
def test_grid_tile_reveal_rules_survive_the_staged_overrides(self, device):
errors: list = []
for _ in range(2):
start_sharing(device("/mirror", errors))
viewer = device("/watch", errors)
expect(viewer.locator(".tile")).to_have_count(2, timeout=15_000)
# Nothing is staged here, so the id-scoped staged rules cannot be
# what reveals anything: the kiosk screenshot rule must hold on its
# own — the selected tile carries its controls, the rest stay quiet.
viewer.keyboard.press("j")
selected = viewer.evaluate(
"() => document.querySelector('.tile[data-selected]').dataset.stream"
)
viewer.wait_for_function(
"""(id) => {
const el = document.querySelector(`.tile[data-stream="${id}"] .actions`);
return el !== null && getComputedStyle(el).opacity === '1';
}""",
arg=selected,
timeout=5_000,
)
# The unselected tile: hover, focus and selection all absent → 0.
other = viewer.evaluate(
"""(id) => {
const tile = [...document.querySelectorAll('.tile')]
.find((e) => e.dataset.stream !== id);
return tile ? getComputedStyle(tile.querySelector('.actions')).opacity : null;
}""",
selected,
)
assert other == "0", f"an unselected grid tile must stay quiet (got {other!r})"
assert errors == [], f"uncaught browser errors: {errors}"