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
@@ -0,0 +1,23 @@
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.
@@ -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 68.73s (0:01:08)
All checks passed!
29 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,10 @@
Everything is verified and green. Final report:
**Phase 04 / Task 01 — one-cell stage: complete.**
- **Implemented**: `src/mirror/static/app.css` `.stage` now declares `grid-template: 1fr / 1fr` (one cell) plus `.stage > * { grid-area: 1 / 1; }`, so `#preview`, `#placeholder`, `#livetag` overlap in one cell; invariant comment added at the rule. `.livetag` stays absolute, `.stage video` untouched, `share.js`/`mirror.html` untouched. Diff confined to the `/* share */` block.
- **Instrumented browser check** (Chromium `channel="chromium"`, `http://127.0.0.1`): idle `#placeholder` topOffset now 1 @320×568 and 187.4 @1280×800 (centred, no longer the constant 151 px row); `#start` bottom 37 px below stage at 320 (was 187) and fully inside at 1280 with `elementFromPoint` = `BUTTON#start`; live state A/B-probed against old CSS — geometry byte-identical at both sizes (placeholder hidden, `#stop` clickable, video + `#livetag` unchanged → no visual regression).
- **Screenshots**: `docs/screenshots/phase-04/` — `idle-1280x800.png`, `idle-390x844.png`, `live-1280x800.png` (visually verified: CTA centred, live video fills stage, LIVE top-left).
- **Gates**: `uv run ruff check . && uv run ruff format --check .` clean · `uv run pyright` 0 errors · `uv run pytest` **245 passed, 4 skipped** (realdisplay-only) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` **3 passed, 1 skipped** (pre-existing documented host-audio skip). Coverage 95% (floor >90%).
- **Docs**: no README/AGENTS.md changes — README makes no claim about the share-page layout that this fix alters (no stale statement; the one pre-existing warning is a Starlette dependency deprecation, untouched).
- **Next pending task**: `02_size_the_stage_for_real_viewports.md`.
@@ -0,0 +1,34 @@
...................................................................ssss. [ 28%]
........................................................................ [ 57%]
........................................................................ [ 86%]
................................. [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
245 passed, 4 skipped, 1 warning in 66.61s (0:01:06)
All checks passed!
28 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,16 @@
All green. Final state verified — everything complete:
**Task 02 complete.** Report:
**Implemented**
- `app.css` (entirely inside the `/* share */` block): `.stage` floor `min-height: 264px`, ceiling `max-height: min(70vh, 640px)` + `min(70svh, 640px)` (vh fallback), `align-content: safe center`; `.stage .placeholder` escape hatch `max-height: 100%; overflow: auto`; `aspect-ratio: 4/3` at ≤900px, `auto` + 16px padding at ≤480px
- Two fixes beyond the literal task: (1) media queries placed *after* the base rules — before that, equal-specificity source order made them lose; (2) `width: 100%` on `.stage` — a stretched grid item with aspect-ratio gets its *width* recomputed from a max-height-clamped height (stage shrank to 1024px, 534px gap beside the panel); a definite width keeps the full column and the video letterboxes, per the task's ASSUMPTION
- `tests/e2e/test_live_mirror.py::TestStageSizing`: the 6 measured failure viewports (button inside stage + inside viewport, floor ≥263, stage bottom on-screen) + 1920×1080 ceiling (≤641px, Session panel reachable) — in `test_live_mirror.py` since `test_share_layout.py` belongs to task 03
**Before → after** (measured, Chromium over 127.0.0.1): 320×568 stage 180→264, `#start` clipped→263–315 inside; 900×480 542→336 (on-screen); 1280×450 564→315; 1920×1080 964→640, panel bottom 499 < 1080. Test verified **red before** the fix (6/7 viewports + ceiling fail) and green after.
**Gates**: `uv run ruff check . && uv run ruff format --check .` clean · `uv run pyright` 0 errors · `uv run pytest` 252 passed, 4 skipped (realdisplay-gated) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` 3 passed, 1 skipped (documented env-gated audio) · `pytest tests/test_app.py -m "not e2e"` 68 passed · coverage 95% (no new src code; phase is CSS-only per 00_phase.md)
**Docs**: README.md — added the sharer-facing narrow-screen line (320px viewport, CTA always on screen). AGENTS.md — no stale statements, no edit.
**Next pending task**: `03_reachable_cta_e2e` — the 12-viewport `elementFromPoint` sweep in `tests/e2e/test_share_layout.py`, idle + live `#stop`.
@@ -0,0 +1,34 @@
........................................................................ [ 28%]
..ssss.................................................................. [ 56%]
........................................................................ [ 84%]
........................................ [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
252 passed, 4 skipped, 1 warning in 66.55s (0:01:06)
All checks passed!
28 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,17 @@
All green. Report:
**Implemented**
- `tests/e2e/test_share_layout.py`: `#start` hit-test sweep over the 12 task viewports (verbatim `elementFromPoint` probe; asserts `disabled`, `hit_is_button` with `hit` named in the failure message, `in_stage`, `in_viewport`, `no_h_scroll`) plus live-state sweep at 320×568 / 390×844 / 1280×450 asserting `#stop` hit-testable, `#placeholder` hidden, `#livetag` visible inside the stage — reusing `base_url`/`browser`/`start_sharing` from `tests/e2e/conftest.py`.
- Pre-fix verification (temporary revert to HEAD CSS, then restored): **7/12 idle viewports fail** — 320×568 (`hit: H2`), 360×640 (`hit: #name`), 390×844 (`hit: LABEL`), 430×932 (`hit: H2`), 480×800 (`hit: MAIN`), 900×480 (`hit: null`), 1280×450 (`hit: null`); 700×900, 900×700, 1024×600, 1280×800, 1920×1080 pass. Matches the 00_phase diagnosis (480×800's hit landed on `MAIN` this run vs `#share-link` in the original table — same clip, culprit named in output). Please fold these failing widths into the phase commit message.
**Gates (all green)**
- `uv run ruff check . && uv run ruff format --check .` — All checks passed; 29 files formatted
- `uv run pyright` — 0 errors
- `uv run pytest` — 267 passed, 4 skipped (realdisplay gate), 95% coverage
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 3 passed, 1 skipped (host offers no audio source; pre-existing, documented in the test)
**Docs** — no doc impact: README already documents the 320px phone behaviour (tasks 01–02); this task is test-only, no stale AGENTS.md facts.
**Decisions** — git stash unavailable per harness rules, so pre-fix check used a file swap of `app.css` (restored immediately); live probe parameterised on selector since `#stop` lives in the Session panel, not the stage (`in_stage` asserted for `#start` only).
**Next pending task** — `04_css_guard_and_docs` (`.agents/phases/todo/04_reachable_share_cta/04_css_guard_and_docs.md`).
@@ -0,0 +1,34 @@
........................................................................ [ 26%]
.................ssss................................................... [ 53%]
........................................................................ [ 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
267 passed, 4 skipped, 1 warning in 69.49s (0:01:09)
All checks passed!
29 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,13 @@
Everything is verified and green. Final report:
**Phase 04, Task 04 — complete. All gates green.**
- **`TestStylesheet::test_the_share_stage_is_one_cell`** — asserts `.stage` declares `grid-template: 1fr / 1fr` (or explicit rows/columns of 1fr) *and* `.stage > *` carries `grid-area: 1 / 1`; docstring records the 300×150 idle-video / 151px-stack / `overflow: hidden` clip reason.
- **`TestStylesheet::test_the_stage_has_a_floor_and_a_ceiling`** — `.stage` must keep `min-height` and `max-height`; any rule combining `overflow: hidden` with `aspect-ratio` needs `min-height` in the same block (the general statement of the bug).
- **`test_hidden_always_means_gone`** — `.placeholder` added to the `[hidden]`-toggled component list, with the inline comment corrected so it stays accurate.
- **Revert verification (by hand)**: deleting the grid lines fails the one-cell guard; deleting `min-height: 264px` fails the floor check; deleting both `max-height` lines fails the ceiling check. CSS restored; all 5 `TestStylesheet` tests green.
- **README.md L10** — added the `/share` alias (served by `app.py`, asserted by `test_pages_render`, previously undocumented). The narrow-screen user-facing line (L39) was already present from an earlier task; full-file consistency pass found no other gaps (ports, config defaults, key tables, kiosk commands all verified against code/docs).
- **AGENTS.md L44** — fixed the one stale fact: "Two pages" → "Three pages and one WebSocket" (the index page exists and is tested). No narrative added; "coverage ~95%" re-measured at 95%, still accurate.
- **PLAN.md** — untouched, but L204's "per-leg `maxBitrate` cap (default 12 Mbit/s)" is contradicted by reality: phase 02 raised the default to 25 Mbit/s (`config.py` L15, README L72). Reported here for the user to amend; I did not edit it.
- **Gates**: `uv run ruff check . && uv run ruff format --check .` clean · `uv run pyright` 0 errors · `uv run pytest` 269 passed / 4 skipped (realdisplay flag-gated) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` 3 passed / 1 skipped (host has no audio source — documented platform limitation). Phases 01–03 e2e suites (keyboard, sharpness, audio) all still green.
- **Next pending task**: none — task 04 was the last task of phase 04; the phase is ready for its final validation pass.
@@ -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 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
269 passed, 4 skipped, 1 warning in 68.64s (0:01:08)
All checks passed!
29 files already formatted
0 errors, 0 warnings, 0 informations
+1 -1
View File
@@ -41,7 +41,7 @@ is a roster and a signaling relay. Linux is the reference platform.
| Rule | Why it exists |
|---|---|
| **No external assets.** No CDN, remote font, remote icon, `@import`, or analytics | LAN/offline correctness. `tests/test_app.py::TestClientScripts::test_nothing_is_loaded_from_a_cdn` fails the build if you add one |
| **No framework, no bundler** in `src/mirror/static/` | Two pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **No framework, no bundler** in `src/mirror/static/` | Three pages and one WebSocket. `node --check` is the entire frontend toolchain |
| **Media never passes through the server** | The server relays JSON only. Anything that puts frames in the Python process violates A3 |
| **`Registry` stays pure** — no sockets, no FastAPI, no `await` | It returns `list[Envelope]`. Transport lives in `Hub`. This is why the policy has 100%-ish unit coverage |
| **camelCase on the wire, snake_case in Python**, via `alias_generator=to_camel` | A silently-dropped `displaySurface` was a real bug we shipped. Any new multi-word field needs an alias test |
+5 -1
View File
@@ -7,7 +7,7 @@ your network:
| Page | URL | What it does |
| ------------ | ------------------ | ----------------------------------------------------------------------- |
| **Share** | `/mirror` | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Share** | `/mirror` (also `/share`) | Picks a window, screen, or tab via the OS-native picker and mirrors it. |
| **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Index** | `/` | Signpost + live count of mirrors. |
@@ -36,6 +36,10 @@ Linux is the primary target. On Linux the native picker is X11/Wayland's own
(Chromium uses `xdg-desktop-portal` on Wayland/GNOME/KDE, and its built-in X11
picker on Xorg), so nothing has to be installed on the client side.
The share page works on a phone as well as a desktop: `/mirror` is usable
down to a 320 px-wide viewport, and the preview keeps its shape but never
shrinks below the controls it holds, so the Share button is always on screen.
The target *client* setup for an always-on display is Fedora Silverblue running
GNOME Kiosk — see [`docs/kiosk-silverblue.md`](docs/kiosk-silverblue.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+66 -5
View File
@@ -225,27 +225,88 @@ a { color: var(--accent); }
align-items: start;
}
@media (max-width: 900px) {
.share { grid-template-columns: 1fr; }
}
/* The stage is ONE cell. Its children — the <video>, the idle placeholder,
the LIVE badge — share that cell, so the placeholder overlays the video the
way the markup intends instead of stacking into a row below it.
Invariant for the next reader: a child added to #preview-stage shares that
cell and must never push another child out of view. `overflow: hidden` is
there to clip the live video to the rounded corners; a box whose content no
longer fits a fixed-aspect stage is the size rule's job, not this one's
(the floor/ceiling below). */
.stage {
position: relative;
/* Definite (100%), not stretched-auto: with an aspect-ratio, a stretched
item whose height is clamped by max-height gets its *width* recomputed
from the clamped height (the stage would shrink and leave a gap beside
the panel). A definite width keeps the full column; the video then
letterboxes inside (object-fit: contain) instead. */
width: 100%;
aspect-ratio: 16 / 10;
/* Floor and ceiling: the aspect ratio is the *preferred* shape, and these
bound it. 264px is the measured idle placeholder at 320px wide + slack —
a placeholder taller than the stage scrolls (see below) instead of being
trapped under `overflow: hidden`. The ceiling: the preview is a preview;
on 1920x1080 the ratio alone wanted 964px and pushed the Session panel
off-screen (the video letterboxes inside, so nothing is lost). vh first
as the silent fallback where svh is unsupported — the ceiling is then
never load-bearing on an old browser; dvh deliberately avoided, a mobile
URL bar must not resize the stage mid-share. */
min-height: 264px;
max-height: min(70vh, 640px);
max-height: min(70svh, 640px);
background: #05070a;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: grid;
grid-template: 1fr / 1fr; /* one cell, not one row per child */
place-items: center;
/* The escape hatch's other half: if a row ever overflows the box, align to
the start instead of centring into a clip at both edges (the
centred-overflow trap that hid half the Share button). */
align-content: safe center;
}
/* Every child lives in that one cell: #preview, #placeholder and #livetag
overlap instead of stacking. DOM order paints the placeholder above the video
(no z-index); .livetag stays position: absolute, so grid-area is inert for
it. */
.stage > * { grid-area: 1 / 1; }
.stage video { width: 100%; height: 100%; object-fit: contain; display: block; }
.stage .placeholder { text-align: center; padding: 24px; color: var(--fg-dim); }
/* Escape hatch: the placeholder may never be taller than the stage; if its
content outgrows it, it scrolls. Paired with `safe center` above, content
that is too tall is always reachable, whichever way the geometry falls. */
.stage .placeholder {
text-align: center;
padding: 24px;
color: var(--fg-dim);
max-height: 100%;
overflow: auto;
}
.stage .placeholder h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.stage .placeholder p { margin: 0 0 18px; font-size: 14px; }
/* Narrow-width shape rules come *after* the base rules above: same
specificity, so source order decides, and a phone must not be left with
the wide-screen ratio or the wide-screen padding. Single-column below
900px, and the stage stops asking for a wide-screen shape there (4:3
instead of 16:10): the floor does the real work, the ratio only keeps
the box from looking like a letterbox. */
@media (max-width: 900px) {
.share { grid-template-columns: 1fr; }
.stage { aspect-ratio: 4 / 3; }
}
/* A phone (≤480px) gets a stage as tall as its content rather than any
letterbox, and the placeholder's padding buys back 16px. min-height still
does the real work in both cases. */
@media (max-width: 480px) {
.stage { aspect-ratio: auto; }
.stage .placeholder { padding: 16px; }
}
.livetag {
position: absolute;
top: 12px;
+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."""