phase: 04_reachable_share_cta
Build and Push Containers / test (push) Successful in 47s
Build and Push Containers / build-and-push-app (push) Successful in 10s

All verification is complete. The pending working-tree changes are exactly the phase 04 work (CSS, tests, the README narrow-screen line, the AGENTS.md "Two→Three pages" fix) — all accurate.

## Final report

**Verified (final pass — all 4 task files present in `complete/`):**
- Gate 1: `uv run ruff check . && uv run ruff format --check .` → all checks passed, 29 files formatted
- Gate 2: `uv run pyright` → 0 errors
- Gate 3: `uv run pytest` → **269 passed, 4 skipped** (realdisplay-gated), coverage 95%
- Gate 4: `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **3 passed, 1 skipped** (host delivers no audio source — documented in module docstring)

**Completion criteria:**
1. `#start` elementFromPoint at the 11 required sizes — ✅ `TestIdleShareButtonReachable` sweeps 12 viewports (superset); 12/12 passed
2. `#stop` clickable while sharing — ✅ `TestLiveStopButtonReachable` (320×568, 390×844, 1280×450) passed, incl. placeholder-hidden + livetag assertions
3. Single-cell stage guard — ✅ `test_the_share_stage_is_one_cell` + `test_the_stage_has_a_floor_and_a_ceiling` passed; CSS has `grid-template: 1fr / 1fr`, `.stage > * { grid-area: 1 / 1 }`, `min-height: 264px`, `max-height: min(70svh, 640px)` + vh fallback, `.placeholder { max-height: 100%; overflow: auto }`, `align-content: safe center`
4. Live video fills stage, rounded corners, `#livetag` top-left — ✅ verified by live-browser probe: video fills stage (decoded), stage radius 14px, livetag top-left, placeholder hidden
5. README accurate, AGENTS.md current, suite 100% green — ✅
6. Phases 01–03 unchanged — ✅ `test_keyboard_wall` + `test_audio_share` + `test_sender_fidelity`: 48 passed

**Documentation:** README.md reviewed in full (page table, quickstart, all 12 config defaults vs `config.py`, CLI flags, secure-context/wayland/audio sections, kiosk key table vs `watch.js`/`share.js`, dev commands) — **consistent, no fixes needed**. AGENTS.md verified statement-by-statement (commands, routes, test names, browser/realdisplay flags) — **no stale statements; no changes needed** (the pending "Two→Three pages" fix is correct).

**Decisions:** criterion 4 had no dedicated test, so verified directly with a throwaway probe reusing the e2e harness (script in /tmp, not committed). No live infrastructure touched. No defects found in prior work.

**Next pending phase:** none — `todo/` is empty of other phases.
This commit is contained in:
2026-11-01 23:26:36 -05:00
parent d43eb407f8
commit bf027672b5
34 changed files with 670 additions and 9 deletions
+88
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import re
import time
from typing import ClassVar
import pytest
from playwright.sync_api import expect
@@ -294,6 +295,93 @@ class TestChromeVisibility:
expect(sharer.locator("#start")).not_to_be_visible()
expect(sharer.locator("#placeholder")).not_to_be_visible()
class TestStageSizing:
"""The stage has a floor, a ceiling, and an escape hatch.
``aspect-ratio: 16 / 10`` alone made the stage 180 px tall at 320 px wide
(the idle placeholder needed ~240 px there) and 543 px tall in a 480 px
window (the Share button ended up below the fold). These tests pin the
six shapes measured in ``00_phase.md``: at each one ``#start`` must be
fully inside *both* the stage box and the viewport — inside the stage or
``overflow: hidden`` clips it, and inside the viewport or a finger can
never reach it. The 1080p case pins the ceiling: the Session panel must
be reachable without scrolling past a 964 px preview.
"""
# The three clip cases (button clipped out of the stage) and the two
# below-the-fold cases (stage taller than the viewport), plus 360x640,
# the fourth measured narrow shape.
FAILURE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
(320, 568),
(360, 640),
(390, 844),
(480, 800),
(900, 480),
(1280, 450),
]
STAGE_PROBE = """() => {
const s = document.querySelector('#preview-stage').getBoundingClientRect();
const r = document.querySelector('#start').getBoundingClientRect();
return {
stage_h: s.height,
stage_bottom: s.bottom,
start: {top: r.top, bottom: r.bottom, right: r.right},
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
in_viewport: r.top >= 0 && r.bottom <= innerHeight && r.right <= innerWidth,
stage_in_viewport: s.bottom <= innerHeight + 1,
};
}"""
@pytest.mark.parametrize("viewport", FAILURE_VIEWPORTS)
def test_the_share_button_is_inside_the_stage_and_the_viewport(
self, base_url, browser, request, viewport
):
context = browser.new_context(viewport={"width": viewport[0], "height": viewport[1]})
request.addfinalizer(context.close)
page = context.new_page()
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
expect(page.locator("#start")).to_be_visible(timeout=10_000)
result = page.evaluate(self.STAGE_PROBE)
message = f"{viewport[0]}x{viewport[1]}: {result}"
# The floor (264px, the measured idle placeholder + slack) and the
# ceiling (min(70svh, 640px)) must both hold: the box is never so
# short that the CTA clips out, and never so tall that it leaves
# the viewport.
assert result["stage_h"] >= 263, f"floor failed: {message}"
assert result["in_stage"], f"#start is clipped by the stage: {message}"
assert result["in_viewport"], f"#start is below the fold: {message}"
# The stage itself must also fit: on the two short-window shapes the
# aspect-ratio box used to run 543/564 px tall in a 480/450 px window,
# leaving only a scroll to reach the bottom of the page.
assert result["stage_in_viewport"], f"the stage itself overflows the viewport: {message}"
def test_the_ceiling_keeps_the_session_panel_reachable_at_1080p(
self, base_url, browser, request
):
"""1920x1080: the aspect ratio alone wanted a 964 px stage, which
pushed the Session panel off-screen. The 640 px ceiling must keep
the panel on screen — reachable without scrolling."""
context = browser.new_context(viewport={"width": 1920, "height": 1080})
request.addfinalizer(context.close)
page = context.new_page()
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
expect(page.locator("#start")).to_be_visible(timeout=10_000)
result = page.evaluate(
"""() => {
const s = document.querySelector('#preview-stage').getBoundingClientRect();
const p = document.querySelector('.panel').getBoundingClientRect();
return {stage_h: s.height, stage_w: s.width, panel_bottom: p.bottom};
}"""
)
assert result["stage_h"] <= 641, (
f"the 640px ceiling failed (aspect ratio alone wanted 964px): {result}"
)
assert result["panel_bottom"] <= 1080, (
f"the Session panel is not reachable without scrolling: {result}"
)
def test_exit_only_appears_on_the_tile_that_is_on_stage(self, device):
sharer = device("/mirror")
start_sharing(sharer)
+174
View File
@@ -0,0 +1,174 @@
"""End-to-end: the Share CTA is a *hit*, at every realistic viewport.
The bug this file pins: ``#preview-stage`` was a two-row grid (the idle
``<video>`` reserved a ~150 px row, the placeholder stacked below it and ran
into ``overflow: hidden``), and its height was an unbounded function of its
width (180 px tall at 320 px wide; 543 px in a 480 px window, pushing the
button below the fold). On the reference host,
``document.elementFromPoint`` at the button centre named ``H2`` at 320x568,
``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at 480x800, and
``null`` at 900x480 / 1280x450 — a finger or cursor would never have hit the
button.
The assertion is a **hit test, not a size check**:
``document.elementFromPoint(cx, cy).closest("#start")`` must be the button.
That is the only in-browser statement that means *a finger or a cursor would
hit it*, and it is exactly the check that failed. (A ``page.click`` would start
a real capture and need teardown; the hit test is the same claim with no side
effects. The live-state sweep below still exercises a real ``start_sharing()``
once per shape, so the interaction path is not untested.)
320 px is the floor of "realistic": below it the page is expected to scroll,
not to fit. The containment comparisons carry a +/-1 px tolerance for
sub-pixel layout at fractional device pixel ratios.
"""
from __future__ import annotations
from typing import ClassVar
import pytest
from playwright.sync_api import expect
from .conftest import start_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
# The sweep: the clip cases (button clipped out of the stage), the
# below-the-fold cases (stage taller than the viewport), and the control
# shapes in between — exactly the list in 03_reachable_cta_e2e.md.
VIEWPORTS = [
(320, 568),
(360, 640),
(390, 844),
(430, 932),
(480, 800), # the clip cases
(700, 900),
(900, 700),
(1024, 600),
(1280, 800),
(1920, 1080),
(900, 480),
(1280, 450), # the below-the-fold cases
]
# The probe that reproduced the bug — kept verbatim so the next reader can
# re-run the diagnosis. ``hit`` named the culprit during diagnosis: ``H2`` at
# 320x568, ``#name`` at 360x640, ``LABEL`` at 390x844, ``#share-link`` at
# 480x800, ``null`` at 900x480 / 1280x450.
HIT_PROBE = """() => {
const b = document.querySelector("#start");
const s = document.querySelector("#preview-stage").getBoundingClientRect();
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
disabled: b.disabled,
in_stage: r.bottom <= s.bottom + 1 && r.top >= s.top - 1,
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
hit_is_button: !!hit && !!hit.closest("#start"),
hit: hit ? hit.id || hit.tagName : null,
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
};
}"""
# The same hit test, parameterised on the button. Used for #stop while a share
# is live: #stop lives in the Session panel, not the stage, so "inside the
# stage" does not apply to it — clickability is the hit test plus the viewport.
STOP_PROBE = """(selector) => {
const b = document.querySelector(selector);
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
disabled: b.disabled,
in_viewport: r.bottom <= innerHeight && r.right <= innerWidth && r.top >= 0,
hit_is_button: !!hit && !!hit.closest(selector),
hit: hit ? hit.id || hit.tagName : null,
no_h_scroll: document.documentElement.scrollWidth <= innerWidth,
};
}"""
# The live-state invariants: the placeholder is hidden, the LIVE badge is
# visible and sits top-left inside the stage.
LIVE_STATE_PROBE = """() => {
const s = document.querySelector("#preview-stage").getBoundingClientRect();
const l = document.querySelector("#livetag").getBoundingClientRect();
return {
placeholder_hidden: document.querySelector("#placeholder").hidden,
livetag_visible: !document.querySelector("#livetag").hidden,
livetag_in_stage:
l.left >= s.left - 1 && l.top >= s.top - 1 &&
l.right <= s.right + 1 && l.bottom <= s.bottom + 1,
};
}"""
def _open_page(base_url, browser, request, width: int, height: int):
context = browser.new_context(viewport={"width": width, "height": height})
request.addfinalizer(context.close)
page = context.new_page()
page.goto(f"{base_url}/mirror", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return page
class TestIdleShareButtonReachable:
"""``#start`` must be hit-testable at every realistic viewport, idle.
A layout change that stacks the stage's children again (or un-bounds the
box's height) fails at least one of these: the clipped shapes fail
``in_stage``/``hit_is_button`` (the old bug), the short shapes fail
``in_viewport`` (the old bug), and ``no_h_scroll`` catches a layout that
hides the button behind horizontal scroll instead.
"""
@pytest.mark.parametrize("viewport", VIEWPORTS)
def test_the_share_button_is_hit_testable(self, base_url, browser, request, viewport):
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
expect(page.locator("#start")).to_be_visible(timeout=10_000)
result = page.evaluate(HIT_PROBE)
label = f"{viewport[0]}x{viewport[1]}: {result}"
# A disabled button would make the hit test vacuous.
assert result["disabled"] is False, f"#start is disabled: {label}"
# The point that a finger or cursor would land on must be the button.
assert result["hit_is_button"], (
f"#start is not hit-testable, elementFromPoint named {result['hit']!r}: {label}"
)
# Inside the stage or `overflow: hidden` clips it; inside the viewport
# or a finger can never reach it; no horizontal scroll to reach it.
assert result["in_stage"], f"#start is clipped by the stage: {label}"
assert result["in_viewport"], f"#start is outside the viewport: {label}"
assert result["no_h_scroll"], f"the button is behind a horizontal scroll: {label}"
class TestLiveStopButtonReachable:
"""The live state must not regress: while sharing, ``#stop`` stays
hit-testable, ``#placeholder`` is hidden, and ``#livetag`` sits top-left
inside the stage. The three shapes are the ones measured live in
``00_phase.md`` (``hit_stop: true`` after the fix).
"""
LIVE_VIEWPORTS: ClassVar[list[tuple[int, int]]] = [
(320, 568),
(390, 844),
(1280, 450),
]
@pytest.mark.parametrize("viewport", LIVE_VIEWPORTS)
def test_stop_is_hit_testable_while_sharing(self, base_url, browser, request, viewport):
page = _open_page(base_url, browser, request, viewport[0], viewport[1])
start_sharing(page)
stop = page.evaluate(STOP_PROBE, "#stop")
label = f"{viewport[0]}x{viewport[1]}: {stop}"
assert stop["disabled"] is False, f"#stop is disabled: {label}"
assert stop["hit_is_button"], (
f"#stop is not hit-testable, elementFromPoint named {stop['hit']!r}: {label}"
)
assert stop["in_viewport"], f"#stop is outside the viewport: {label}"
assert stop["no_h_scroll"], f"#stop is behind a horizontal scroll: {label}"
state = page.evaluate(LIVE_STATE_PROBE)
label = f"{viewport[0]}x{viewport[1]}: {state}"
assert state["placeholder_hidden"], f"#placeholder is still shown while sharing: {label}"
assert state["livetag_visible"], f"#livetag is not visible while sharing: {label}"
assert state["livetag_in_stage"], f"#livetag is not inside the stage: {label}"
+82 -2
View File
@@ -291,8 +291,11 @@ class TestStylesheet:
assert re.search(r"\[hidden\]\s*\{[^}]*display:\s*none\s*!important", css), (
"app.css must declare a global `[hidden] { display: none !important }`"
)
# Components that set `display` and are toggled with `hidden` in JS.
for selector in (".btn", ".livetag", ".icon-btn", ".toast", ".net"):
# Components toggled with `hidden` in JS. All but `.placeholder` set
# their own `display`, which would outrank the UA's `[hidden]` without
# the global rule; `.placeholder` is guarded because its block is the
# escape hatch that keeps its content inside the stage.
for selector in (".btn", ".livetag", ".icon-btn", ".toast", ".net", ".placeholder"):
assert selector in css, f"{selector} is toggled by `hidden`; it lives in app.css"
def test_no_rule_reintroduces_a_conflicting_display_for_hidden(self):
@@ -337,6 +340,83 @@ class TestStylesheet:
"without a pointer — a mouseless kiosk would have no tile controls"
)
def _rules(self) -> list[tuple[str, str]]:
r"""app.css as ``(selector, body)`` pairs, comments stripped.
The flat ``[^{}]+\{[^{}]*\}`` pass treats an ``@media`` header as a
selector and the first child's header as its body; the child rules
that follow still come back as their own pairs, which is all these
guards need — every assertion is about a top-level block (``.stage``,
``.stage > *``) or about properties that must sit in the *same* block
(``overflow``/``aspect-ratio``/``min-height`` together).
"""
css = re.sub(r"/\*.*?\*/", "", (STATIC / "app.css").read_text(), flags=re.S)
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css)]
def test_the_share_stage_is_one_cell(self):
"""The stage's children overlap on purpose.
In plain flow an idle ``<video>`` is intrinsically 300x150, so the
placeholder (and the Share button in it) starts 151px below the video
at *every* width and runs out of the ``overflow: hidden`` box.
``grid-template: 1fr / 1fr`` makes the stage one cell; ``grid-area:
1 / 1`` on every child is what puts them all in it. Either half
without the other stacks the rows back.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
assert any(
re.search(r"grid-template\s*:\s*1fr\s*/\s*1fr", body)
or (
re.search(r"grid-template-rows\s*:\s*1fr", body)
and re.search(r"grid-template-columns\s*:\s*1fr", body)
)
for body in stage_bodies
), (
".stage must declare an explicit single cell (grid-template: 1fr / 1fr, "
"or grid-template-rows/columns of 1fr) — otherwise its children stack into rows"
)
child_bodies = [body for sel, body in rules if sel == ".stage > *"]
assert child_bodies and any(
re.search(r"grid-area\s*:\s*1\s*/\s*1", body) for body in child_bodies
), (
"app.css must put every .stage child in that one cell (grid-area: 1 / 1) — "
"without it the placeholder stacks below the 300x150 video and the Share "
"button is clipped by overflow: hidden"
)
def test_the_stage_has_a_floor_and_a_ceiling(self):
"""An aspect-derived box with no floor is a clip.
``aspect-ratio`` alone made the stage 180px tall at 320px wide (the
idle placeholder needs 240px there) and 543px tall in a 480px-tall
window (the Share button below the fold). The floor and the ceiling
make the ratio the *preferred* shape instead of a hard derivation, and
the second check is the general statement of the bug: any rule that
keeps ``overflow: hidden`` on an aspect-ratio box must carry a
``min-height`` in the same block, or the hidden clip silently swallows
content again.
"""
rules = self._rules()
stage_bodies = [body for sel, body in rules if sel == ".stage"]
assert stage_bodies, ".stage rule missing from app.css"
assert any(re.search(r"min-height\s*:", body) for body in stage_bodies), (
".stage must keep its floor (min-height)"
)
assert any(re.search(r"max-height\s*:", body) for body in stage_bodies), (
".stage must keep its ceiling (max-height)"
)
for sel, body in rules:
if re.search(r"overflow[^:]*:\s*[^;]*hidden", body) and re.search(
r"aspect-ratio\s*:", body
):
assert re.search(r"min-height\s*:", body), (
f"{sel} combines `overflow: hidden` with `aspect-ratio` but has no "
"`min-height` in the same block — an aspect-derived box with no "
"floor clips its content silently"
)
class TestAddressGate:
"""mirror has no accounts; the address is the access control."""