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/`.
This commit is contained in:
2026-11-02 19:41:54 -05:00
parent 80656a3592
commit f51d0fe9ae
31 changed files with 710 additions and 19 deletions
@@ -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. | | `?` | Show / hide this list on screen. |
| `Esc` | Close the key list, then leave the stage. | | `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 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 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 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)); background: linear-gradient(to top, rgba(4, 6, 10, 0.92), rgba(4, 6, 10, 0));
border-top: 0; border-top: 0;
padding: 26px 16px 12px; padding: 26px 16px 12px;
opacity: 0.28; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
/* The staged caption is quiet (0.28) so the video is what is being watched; /* The staged overlay is hidden at rest so the shared video — a terminal's
a kiosk has no mouse, so keyboard focus and the selection state are the bottom line included — is never covered. `data-reveal` (pointer activity
way to read it back. */ or a wall keypress) is the only staged reveal: it shows the caption and
#watch-stage .tile:hover .cap, the control cluster briefly and fades them back on idle. The small
#watch-stage .tile:focus-within .cap, `::after` audio mark is a separate rule and is untouched. The id-scoped
#watch-stage .tile[data-selected] .cap { opacity: 1; } selectors outrank the grid rules (`.tile[data-selected] .actions`,
#watch-stage .tile .actions { opacity: 1; } `.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) { @media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; } * { animation: none !important; transition: none !important; }
+82 -2
View File
@@ -318,6 +318,41 @@ function setMuted(tile, muted) {
/* ------------------------------------------------------------------ stage */ /* ------------------------------------------------------------------ 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 = {}) { export function stage(streamId, options = {}) {
const tile = tiles.get(streamId); const tile = tiles.get(streamId);
if (!tile) return; if (!tile) return;
@@ -361,6 +396,14 @@ export function unstage(options = {}) {
const tile = state.staged ? tiles.get(state.staged) : null; const tile = state.staged ? tiles.get(state.staged) : null;
state.staged = null; state.staged = null;
document.body.classList.remove("staged"); 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) { if (tile) {
tile.exitBtn.hidden = true; tile.exitBtn.hidden = true;
if (state.stageParent) { if (state.stageParent) {
@@ -393,18 +436,49 @@ export function unstage(options = {}) {
// Exiting fullscreen makes the browser re-validate focus and drops it to // Exiting fullscreen makes the browser re-validate focus and drops it to
// <body>, which would cancel a focus() made earlier in this tick — so // <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. // 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 { } else {
restoreFocus(); restoreFocus();
} }
} }
document.addEventListener("fullscreenchange", () => { 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); 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 */ /* ------------------------------------------------------------------ socket */
function subscribe(streamId) { function subscribe(streamId) {
@@ -606,6 +680,12 @@ function onWallKey(event) {
) { ) {
return; // a key typed in a text field is text, not a command 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 // 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 // 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. // 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 # Button and `m` cannot disagree: the glyph and aria-pressed come from
# setMuted(), so the button shows the live state. # setMuted(), so the button shows the live state.
expect(stage_tile.locator(".audio")).to_have_attribute("aria-pressed", "true") expect(stage_tile.locator(".audio")).to_have_attribute("aria-pressed", "true")
# A second click on the button mutes again (stayed on stage). # A second toggle mutes again (stayed on stage). The staged button is
stage_tile.locator(".audio").click() # 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") expect(stage_tile).to_have_attribute("data-audio", "off")
# Back to the wall: silent again. # Back to the wall: silent again.
+10 -5
View File
@@ -618,12 +618,17 @@ class TestFocusVisibleControls:
" && document.activeElement.parentElement.id === 'watch-stage'" " && document.activeElement.parentElement.id === 'watch-stage'"
), "staging must hand focus to the staged tile" ), "staging must hand focus to the staged tile"
# The staged caption is legible while the tile holds focus (the # The staged overlay is hidden at rest — caption bar and control
# opacity transition is 200 ms; wait for the settled value). # 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( viewer.wait_for_function(
"() => document.activeElement !== null" "() => { const tile = document.querySelector('#watch-stage .tile');"
" && getComputedStyle(document.activeElement.querySelector('.cap')).opacity === '1'", " if (!tile) return false;"
timeout=5_000, " 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. # 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() expect(viewer.locator(".tile .exit")).not_to_be_visible()
viewer.locator(".tile").click() 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() 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("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
expect(viewer.locator(".tile .exit")).not_to_be_visible() expect(viewer.locator(".tile .exit")).not_to_be_visible()
# Leaving the stage must not have disturbed the stream. # 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") staged = viewer.locator("#watch-stage .tile").get_attribute("data-stream")
assert staged == ids[1] 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) expect(viewer.locator("body")).not_to_have_class(re.compile("staged"), timeout=5_000)
viewer.locator(".tile").nth(0).click() 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}"