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
+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}"