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 | | 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 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 | | **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 | | **`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 | | **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 | | 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. | | **Watch** | `/watch` | Shows every screen currently being mirrored; click a tile to take the stage. |
| **Index** | `/` | Signpost + live count of mirrors. | | **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 (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. 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 The target *client* setup for an always-on display is Fedora Silverblue running
GNOME Kiosk — see [`docs/kiosk-silverblue.md`](docs/kiosk-silverblue.md). 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; align-items: start;
} }
@media (max-width: 900px) { /* The stage is ONE cell. Its children — the <video>, the idle placeholder,
.share { grid-template-columns: 1fr; } 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 { .stage {
position: relative; 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; 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; background: #05070a;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
display: grid; display: grid;
grid-template: 1fr / 1fr; /* one cell, not one row per child */
place-items: center; 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 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 h2 { color: var(--fg); margin: 14px 0 6px; font-size: 20px; }
.stage .placeholder p { margin: 0 0 18px; font-size: 14px; } .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 { .livetag {
position: absolute; position: absolute;
top: 12px; top: 12px;
+88
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import re import re
import time import time
from typing import ClassVar
import pytest import pytest
from playwright.sync_api import expect from playwright.sync_api import expect
@@ -294,6 +295,93 @@ class TestChromeVisibility:
expect(sharer.locator("#start")).not_to_be_visible() expect(sharer.locator("#start")).not_to_be_visible()
expect(sharer.locator("#placeholder")).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): def test_exit_only_appears_on_the_tile_that_is_on_stage(self, device):
sharer = device("/mirror") sharer = device("/mirror")
start_sharing(sharer) 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), ( assert re.search(r"\[hidden\]\s*\{[^}]*display:\s*none\s*!important", css), (
"app.css must declare a global `[hidden] { display: none !important }`" "app.css must declare a global `[hidden] { display: none !important }`"
) )
# Components that set `display` and are toggled with `hidden` in JS. # Components toggled with `hidden` in JS. All but `.placeholder` set
for selector in (".btn", ".livetag", ".icon-btn", ".toast", ".net"): # 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" 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): 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" "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: class TestAddressGate:
"""mirror has no accounts; the address is the access control.""" """mirror has no accounts; the address is the access control."""