docs(agent): phase 04 - reachable share CTA at narrow widths
The Share button on /mirror is unclickable below ~700px and below the fold in short windows. Measured with elementFromPoint on Chromium: 320x568 stage 288x180 button 187px past the stage bottom hit=H2 390x844 stage 358x224 button 113px past hit=LABEL 480x800 stage 448x280 button 36px past hit=#share-link 900x480 stage 868x543 inside the stage, below the viewport hit=null Root cause: .stage is display:grid with place-items:center, so <video>, .placeholder and .livetag occupy three stacked rows rather than one overlay cell. An idle <video> is intrinsically 300x150, so the placeholder always starts at stage.top + 151 and runs out of the overflow:hidden box; aspect-ratio 16/10 with no min/max height then clips it when narrow and overshoots the viewport when short. #stop measures clickable everywhere (the placeholder is hidden when live), so the fix is scoped to the idle stage. Phase: one-cell stage, floor + ceiling + scroll escape, a 12-viewport clickability sweep (idle and live), and stylesheet guards. LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
# Phase 04 — The Share button must always be clickable
|
||||||
|
|
||||||
|
**Source:** user request in chat — "the share button is cut off by the preview-stage, I can't click it in narrower view widths."
|
||||||
|
**Story:** n/a
|
||||||
|
**Context:** `src/mirror/static/app.css` `.share` / `.stage` / `.placeholder` / `.btn.big` (the `/* share */` block, ~L220–250), `src/mirror/static/mirror.html` (`#preview-stage` > `#preview`, `#placeholder` > `#start`, `#livetag`), `tests/test_app.py::TestStylesheet`, `tests/e2e/`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
`#start` ("Share my screen") is reachable and clickable at every viewport a
|
||||||
|
person might realistically open `/mirror` on — measured from 320×568 to 1920×1080,
|
||||||
|
and on short windows like 900×480. Today it is not, and this is not a cosmetic
|
||||||
|
wrinkle: a sharer on a phone, a narrow side-by-side window, or a small laptop
|
||||||
|
window **cannot start sharing at all**.
|
||||||
|
|
||||||
|
## Measured on the reference host (Chromium, `channel="chromium"`, `http://127.0.0.1`)
|
||||||
|
|
||||||
|
| Viewport | `#preview-stage` box | `#start` vs the stage box | `document.elementFromPoint(colour of button centre)` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 320×568 | 288×180 | button bottom **187 px below** the stage | `H2` — not the button |
|
||||||
|
| 360×640 | 328×205 | 132 px below | the panel's `#name` input |
|
||||||
|
| 390×844 | 358×224 | 113 px below | a `LABEL` |
|
||||||
|
| 480×800 | 448×280 | 36 px below | `#share-link` |
|
||||||
|
| 700×900 | 668×418 | inside | `#start` ✅ |
|
||||||
|
| 900×480 | 868×543 | inside the stage, **below the viewport** | `null` |
|
||||||
|
| 1280×450 | 902×564 | inside, **below the viewport** | `null` |
|
||||||
|
|
||||||
|
Two independent failure modes, and the diagnosis is exact:
|
||||||
|
|
||||||
|
1. **The stage is a two-row grid, not an overlay.** `.stage` is
|
||||||
|
`display: grid; place-items: center`, and its children — `<video id="preview">`,
|
||||||
|
`.placeholder`, `.livetag` — are three grid items, so they occupy three stacked
|
||||||
|
rows. `place-items: center` centres each item *in its own row*; it does not put
|
||||||
|
them in one cell. A `<video>` with no `srcObject` has an intrinsic **300×150**
|
||||||
|
box, so it reserves ~150 px and the placeholder starts at a constant
|
||||||
|
`stage.top + 151` at *every* width. `.livetag` escaped this because it is
|
||||||
|
`position: absolute`; `.placeholder` never was.
|
||||||
|
2. **The stage's height is a function of its width with no floor and no ceiling.**
|
||||||
|
`aspect-ratio: 16 / 10` makes the box 180 px tall at 320 px wide while the
|
||||||
|
stacked content needs ~151 + 240 px, and `.stage { overflow: hidden }` (needed
|
||||||
|
for the rounded corners over live video) clips the remainder silently — with
|
||||||
|
no scroll container to reach it. On a short, wide window the same rule makes
|
||||||
|
the box *taller than the viewport* (543 px in a 480 px window), pushing the CTA
|
||||||
|
below the fold.
|
||||||
|
|
||||||
|
For scale, the placeholder's own content height measures **240 px at 320 px wide**
|
||||||
|
and **189 px at ≥480 px**.
|
||||||
|
|
||||||
|
## The live state is not affected (verified, keep it that way)
|
||||||
|
|
||||||
|
With a share running, `#placeholder` is `hidden`, the stack collapses, and
|
||||||
|
`#stop` measures clickable at 320×568, 390×844 and 1280×450 (`hit_stop: true`).
|
||||||
|
This phase must not change that.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `03_audio_share` (todo) — the preceding phase by number; no functional
|
||||||
|
dependency, but it and `01_keyboard_kiosk_control` both edit `app.css`. Keep
|
||||||
|
this phase's diff inside the `/* share */` block so the three never conflict.
|
||||||
|
|
||||||
|
## Boundaries (explicitly out of scope)
|
||||||
|
|
||||||
|
- No redesign of `/mirror`, no new markup for the stage, no JS/behaviour change —
|
||||||
|
`share.js` is not part of this fix.
|
||||||
|
- `/watch` is untouched: `#watch-stage` is `position: fixed; inset: 0` with no
|
||||||
|
`aspect-ratio`, and `.tile` genuinely wants its stacked rows.
|
||||||
|
- No breakpoint framework, no CSS preprocessor, no new dependency, no external
|
||||||
|
asset (PLAN A9/A10 are locked).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. `01_one_cell_stage.md` — make the stage a single grid cell so the placeholder
|
||||||
|
overlays the video the way the markup intends.
|
||||||
|
2. `02_size_the_stage_for_real_viewports.md` — a floor and a ceiling for the box,
|
||||||
|
and an escape hatch so content taller than the stage can never be unreachable.
|
||||||
|
3. `03_reachable_cta_e2e.md` — the viewport sweep that proves the button is
|
||||||
|
clickable, idle and live.
|
||||||
|
4. `04_css_guard_and_docs.md` — the stylesheet guard that stops the two-row grid
|
||||||
|
coming back, and the documentation pass.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- Unit/integration for all new logic: this phase adds no Python logic, so its
|
||||||
|
"unit" layer is a static stylesheet guard in `tests/test_app.py::TestStylesheet`
|
||||||
|
(regex over `app.css`, matching the style of `test_hidden_always_means_gone` and
|
||||||
|
`test_no_rule_reintroduces_a_conflicting_display_for_hidden`), and its real
|
||||||
|
layer is a browser suite.
|
||||||
|
- **`elementFromPoint` is the assertion**, not "the button has non-zero size":
|
||||||
|
`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 fails today.
|
||||||
|
- Success criterion: the **entire** project test suite passes 100% (every test,
|
||||||
|
not just this phase's — pre-existing failures included) with **>90% coverage**
|
||||||
|
on new/modified code, and all four gates from `AGENTS.md` §4 are green:
|
||||||
|
`uv run ruff check . && uv run ruff format --check .` · `uv run pyright` ·
|
||||||
|
`uv run pytest` · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`.
|
||||||
|
- `tests/test_app.py::TestClientScripts` guards must stay green: no new element
|
||||||
|
ids, and the only cross-page ids remain `net`, `notice`, `toast`.
|
||||||
|
- No `ntfy` anywhere in this phase (it is outbound-only and cannot answer
|
||||||
|
questions).
|
||||||
|
|
||||||
|
## Documentation & Consistency (mandatory)
|
||||||
|
|
||||||
|
- Each task updates `README.md` wherever its own change is user-visible (here: a
|
||||||
|
narrow-screen/phone sharer).
|
||||||
|
- The phase's final pass reviews `README.md` **in its entirety** against the
|
||||||
|
project as it will stand after this phase and fixes every inconsistency found,
|
||||||
|
**including pre-existing ones not caused by this phase** (stale ports, commands
|
||||||
|
that no longer exist, a described layout that no longer matches).
|
||||||
|
- For `AGENTS.md` the final pass fixes **stale statements only** — a dead command,
|
||||||
|
a changed rule, an outdated fact — and **never adds or expands its content**.
|
||||||
|
- `PLAN.md` is not edited by this phase. Nothing here needs a new anchor: it is
|
||||||
|
plain CSS in the locked hand-written-frontend stack (A9/A10). `svh`/`dvh` units
|
||||||
|
and `align-content: safe center` are standard CSS with no dependency; the final
|
||||||
|
pass confirms both on the Firefox kiosk target (Firefox 101+ / ESR) and keeps a
|
||||||
|
`vh` fallback where the value is load-bearing.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `#start` passes the `elementFromPoint` hit test and is fully inside both the
|
||||||
|
stage box and the viewport at 320×568, 360×640, 390×844, 430×932, 480×800,
|
||||||
|
700×900, 900×480, 1024×600, 1280×450, 1280×800 and 1920×1080.
|
||||||
|
- [ ] `#stop` remains clickable at those sizes **while sharing** (unchanged
|
||||||
|
behaviour, asserted rather than assumed).
|
||||||
|
- [ ] `.stage` places its children in one grid cell: `grep`/regex guard shows no
|
||||||
|
plain-flow `.placeholder` sibling of the video, and `overflow: hidden` is
|
||||||
|
paired with a min-height or a scroll escape.
|
||||||
|
- [ ] Live video still fills the stage with rounded corners (no regression from
|
||||||
|
the single-cell change) and `#livetag` still sits top-left.
|
||||||
|
- [ ] `README.md` is accurate and consistent with the whole project; `AGENTS.md`
|
||||||
|
contains no stale statements (verified in the final pass); the full test
|
||||||
|
suite is 100% passing.
|
||||||
|
- [ ] No behavior change in phases 01–03: keyboard navigation, sharpness and
|
||||||
|
audio gates all still green.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Task 01 — One cell, so the placeholder overlays the video
|
||||||
|
|
||||||
|
**Phase:** `04_reachable_share_cta` · **Source:** user request in chat — "the share button is cut off by the preview-stage"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make `#preview-stage` a single grid cell. Today its children stack into rows, the
|
||||||
|
idle `<video>` reserves a ~150 px row (an empty `<video>` is intrinsically
|
||||||
|
300×150) and the placeholder — with the Share button in it — starts at
|
||||||
|
`stage.top + 151` at every viewport width and runs off the bottom into
|
||||||
|
`overflow: hidden`.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `src/mirror/static/app.css` — in the `.stage` rule (~L232–241) keep
|
||||||
|
`position: relative`, `aspect-ratio`, `background`, `border`,
|
||||||
|
`border-radius`, `overflow: hidden` and `place-items: center`, and make the
|
||||||
|
cell explicit:
|
||||||
|
|
||||||
|
```css
|
||||||
|
grid-template: 1fr / 1fr; /* one cell, not one row per child */
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `src/mirror/static/app.css` — immediately after it, put every child in that
|
||||||
|
cell:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.stage > * { grid-area: 1 / 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
so `#preview`, `#placeholder` and `#livetag` overlap instead of stacking.
|
||||||
|
DOM order already paints the placeholder above the video; do not add `z-index`.
|
||||||
|
3. `src/mirror/static/app.css` — leave `.livetag` as `position: absolute` (an
|
||||||
|
abspos child is out of flow, so `grid-area` is inert for it, and moving it into
|
||||||
|
the grid flow would be a visual change beyond this fix).
|
||||||
|
4. `src/mirror/static/app.css` — leave `.stage video { width: 100%; height: 100%;
|
||||||
|
object-fit: contain; }` alone: inside one cell it now means what it always
|
||||||
|
claimed to, and `object-fit: contain` keeps live video letterboxed with the
|
||||||
|
rounded corners intact.
|
||||||
|
5. `src/mirror/static/app.css` — add a comment at the rule stating the invariant
|
||||||
|
the next reader must not break: *the stage is one cell; a child added to
|
||||||
|
`#preview-stage` shares that cell and must never push another child out of
|
||||||
|
view.*
|
||||||
|
6. Do **not** touch `share.js` (`renderIdle()`/`renderLive()` already toggle
|
||||||
|
`#placeholder.hidden` correctly) and do not touch `mirror.html`.
|
||||||
|
|
||||||
|
ASSUMPTION: a single grid cell rather than `.placeholder { position: absolute;
|
||||||
|
inset: 0 }`. Both fix the stacking; the cell keeps the placeholder in flow (so its
|
||||||
|
height participates in layout, which task 02 needs for the floor/ceiling) and it
|
||||||
|
also fixes `#livetag`'s siblings for free.
|
||||||
|
ASSUMPTION: no change to the `.stage` border-radius or background — the corner
|
||||||
|
clipping over live video is intentional and is what `overflow: hidden` is for.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- Manual/instrumented check while working (Chromium, `channel="chromium"`, over
|
||||||
|
`http://127.0.0.1:<port>`): at 320×568 and 1280×800 record
|
||||||
|
`#placeholder`'s offset from `#preview-stage` — it must no longer be the
|
||||||
|
constant 151 px, and `#start`'s bottom must be inside the stage at 1280×800
|
||||||
|
and closer to inside at 320×568 (task 02 finishes the narrow case).
|
||||||
|
- `uv run pytest tests/test_app.py` — the stylesheet and script guards stay
|
||||||
|
green; `[hidden] { display: none !important }` still beats the new
|
||||||
|
`grid-template` on `#placeholder` when it is hidden.
|
||||||
|
- Screenshot pair (idle, 1280×800 and 390×844) attached to the commit or written
|
||||||
|
into the commit message, so the visual change is reviewable.
|
||||||
|
- Coverage: **>90%** on new/modified code (CSS only; covered by the browser suite
|
||||||
|
in task 03 and the guard in task 04).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `.stage` declares a single explicit cell and `.stage > *` declares
|
||||||
|
`grid-area: 1 / 1`.
|
||||||
|
- [ ] `#placeholder` is centred over the video box rather than sitting below a
|
||||||
|
150 px empty-video row (verified in the browser, both idle and live).
|
||||||
|
- [ ] Live share still fills the stage, letterboxed, with rounded corners and
|
||||||
|
`#livetag` top-left — no visual regression.
|
||||||
|
- [ ] `uv run pytest tests/test_app.py` green; `uv run ruff check . &&
|
||||||
|
uv run ruff format --check . && uv run pyright` clean.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Task 02 — Give the stage a floor, a ceiling, and an escape hatch
|
||||||
|
|
||||||
|
**Phase:** `04_reachable_share_cta` · **Source:** user request in chat — "cut off … in narrower view widths"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
`aspect-ratio: 16 / 10` alone makes the stage 180 px tall at 320 px wide (the
|
||||||
|
placeholder needs 240 px there) and 543 px tall in a 480 px-high window (the CTA
|
||||||
|
ends up below the fold). Bound the box from both ends and make "content taller
|
||||||
|
than the stage" impossible to trap.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `src/mirror/static/app.css` — add to `.stage`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
min-height: 264px; /* 240px of placeholder at 320px wide + slack */
|
||||||
|
max-height: min(70vh, 640px); /* fallback for browsers without svh */
|
||||||
|
max-height: min(70svh, 640px);
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `aspect-ratio: 16 / 10` — with a floor and a ceiling it becomes the
|
||||||
|
*preferred* shape rather than a hard derivation.
|
||||||
|
2. `src/mirror/static/app.css` — escape hatch so nothing can ever be unreachable
|
||||||
|
again, whichever way the geometry falls:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.stage .placeholder { max-height: 100%; overflow: auto; }
|
||||||
|
```
|
||||||
|
|
||||||
|
and centre the cell with `align-content: safe center` (or `place-content: safe
|
||||||
|
center`) on `.stage`, so overflowing content aligns to the start instead of
|
||||||
|
being clipped in half by both edges — the classic centred-overflow trap that
|
||||||
|
hid half the button here.
|
||||||
|
3. `src/mirror/static/app.css` — at narrow widths stop asking for a wide-screen
|
||||||
|
shape at all: inside `@media (max-width: 900px)` (the existing single-column
|
||||||
|
breakpoint, ~L228) add `.stage { aspect-ratio: 4 / 3; }`, and
|
||||||
|
`@media (max-width: 480px) { .stage { aspect-ratio: auto; } }` so a phone gets
|
||||||
|
a stage as tall as its content rather than a letterbox with a button in the
|
||||||
|
clip. Keep `min-height` doing the real work in both cases.
|
||||||
|
4. `src/mirror/static/app.css` — trim the placeholder's own padding on small
|
||||||
|
screens (`@media (max-width: 480px) { .stage .placeholder { padding: 16px; } }`)
|
||||||
|
to buy back the 16 px, and leave `.btn.big` (measured 168×52) untouched — it is
|
||||||
|
already a comfortable ≥44 px target.
|
||||||
|
5. `src/mirror/static/app.css` — keep the whole diff inside the `/* share */`
|
||||||
|
block so it cannot collide with phase 01's focus rules or phase 03's audio
|
||||||
|
marks in the same file.
|
||||||
|
|
||||||
|
ASSUMPTION: `min-height: 264px` from the measured 240 px of placeholder content at
|
||||||
|
320 px wide, not from a round number. If a future copy change makes the idle
|
||||||
|
placeholder taller, the `overflow: auto` + `safe center` pair keeps it reachable
|
||||||
|
rather than clipped.
|
||||||
|
ASSUMPTION: `max-height: min(70svh, 640px)` — the preview is a *preview*; on a
|
||||||
|
1920×1080 desktop the aspect ratio alone wanted 964 px and pushed the Session
|
||||||
|
panel off the screen. The live video letterboxes inside it (`object-fit: contain`),
|
||||||
|
so nothing is lost but the crop is visible.
|
||||||
|
ASSUMPTION: `svh` with a preceding `vh` declaration (silent fallback where `svh`
|
||||||
|
is unsupported). The kiosk fleet is Firefox on Silverblue; both are supported
|
||||||
|
there, and the fallback means the ceiling is never load-bearing on an old browser.
|
||||||
|
ASSUMPTION: no `dvh`, because a mobile URL bar changing the stage height mid-share
|
||||||
|
is worse than a slightly conservative ceiling.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- Browser check at the measured failure points (320×568, 360×640, 390×844,
|
||||||
|
480×800, 900×480, 1280×450): `#start`'s rect must be inside both the stage rect
|
||||||
|
and the viewport rect. Record before/after numbers in the commit message — the
|
||||||
|
"before" column is in `00_phase.md`.
|
||||||
|
- `uv run pytest tests/test_app.py::TestStylesheet` — `test_hidden_always_means_gone`
|
||||||
|
and `test_no_rule_reintroduces_a_conflicting_display_for_hidden` stay green with
|
||||||
|
the new `overflow`/`max-height` rules (reveal with opacity/overflow, never
|
||||||
|
re-`display` something `hidden`).
|
||||||
|
- `uv run pytest -m e2e` — the existing share tests
|
||||||
|
(`test_the_idle_share_page_offers_only_a_start`,
|
||||||
|
`test_a_shared_window_reaches_a_viewer_as_moving_pixels`) unchanged.
|
||||||
|
- Coverage: **>90%** on new/modified code.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `#start` is fully inside `#preview-stage` and inside the viewport at 320×568,
|
||||||
|
390×844 and 480×800 (the three clip cases), and fully above the fold at
|
||||||
|
900×480 and 1280×450 (the two fold cases).
|
||||||
|
- [ ] `.stage` has both a `min-height` and a `max-height`, and the placeholder has
|
||||||
|
a scroll escape.
|
||||||
|
- [ ] At 1920×1080 the Session panel is reachable without scrolling past a
|
||||||
|
964 px preview.
|
||||||
|
- [ ] `uv run pytest tests/test_app.py -m "not e2e"` green; gates clean.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Task 03 — Prove the CTA is clickable at every width, idle and live
|
||||||
|
|
||||||
|
**Phase:** `04_reachable_share_cta` · **Source:** user request in chat — "I can't click it in narrower view widths"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
A browser suite that fails today and passes after tasks 01–02, and that would
|
||||||
|
catch the same class of regression on any future `/mirror` layout change. The
|
||||||
|
assertion is a hit test, not a size check.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `tests/e2e/test_share_layout.py` — new file, `pytestmark = pytest.mark.e2e`,
|
||||||
|
reusing the `base_url` / `browser` / `start_sharing` fixtures and helpers from
|
||||||
|
`tests/e2e/conftest.py` (`channel="chromium"`, the fake-media-stream args, and
|
||||||
|
the page-over-`http://127.0.0.1` rule — `mediaDevices` is undefined elsewhere
|
||||||
|
and the page would render the insecure-origin notice instead of the button).
|
||||||
|
2. Parametrise the viewport sweep — exactly the shapes measured in
|
||||||
|
`00_phase.md`, so the two failure modes stay individually covered:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
]
|
||||||
|
```
|
||||||
|
One page per shape (`browser.new_page(viewport=...)`) or one page driven
|
||||||
|
through `set_viewport_size` — either is fine; reload after each resize.
|
||||||
|
3. The probe (this is the code that reproduced the bug — keep it verbatim in the
|
||||||
|
test so the next reader can re-run the diagnosis):
|
||||||
|
|
||||||
|
```js
|
||||||
|
() => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert all of `in_stage`, `in_viewport`, `hit_is_button`, `no_h_scroll`, and
|
||||||
|
`disabled is False` — a disabled button would make the hit test vacuous. On
|
||||||
|
failure the assertion message must include `hit`, which named the culprit
|
||||||
|
(`H2`, `LABEL`, `#name`, `#share-link`) during diagnosis.
|
||||||
|
4. Live-state sweep in the same file: `start_sharing(page)`, then at
|
||||||
|
320×568, 390×844 and 1280×450 assert `#stop` passes the same hit test,
|
||||||
|
`#placeholder` is `hidden`, `#livetag` is visible and inside the stage rect,
|
||||||
|
and the viewer side is untouched (leave that to the existing media tests — this
|
||||||
|
is layout, not pixels).
|
||||||
|
5. `tests/e2e/test_share_layout.py` — verify the test **fails before the fix**:
|
||||||
|
run it against the pre-phase CSS (e.g. `git stash` of tasks 01–02, or a
|
||||||
|
temporary revert) and paste the failing widths into the commit message. A
|
||||||
|
layout test that never failed is not believed.
|
||||||
|
|
||||||
|
ASSUMPTION: the sweep uses `elementFromPoint` rather than `page.click("#start")`
|
||||||
|
because clicking starts a real capture and each click would need teardown; the hit
|
||||||
|
test is the same claim with no side effects. Task 03 step 4 still exercises a real
|
||||||
|
`start_sharing()` once, so the interaction path is not untested.
|
||||||
|
ASSUMPTION: 320 px is the floor of "realistic". Below that the page is expected to
|
||||||
|
scroll, not to fit.
|
||||||
|
ASSUMPTION: `+/- 1px` tolerance on the containment comparisons for sub-pixel
|
||||||
|
layout at fractional device pixel ratios.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `uv run pytest -m e2e -k share_layout` — green after tasks 01–02, red before.
|
||||||
|
- `uv run pytest -m e2e` — whole browser suite green, including
|
||||||
|
`tests/e2e/test_keyboard_wall.py` if phase 01 has landed by then.
|
||||||
|
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` unaffected (no capture
|
||||||
|
change), but run it once in the phase's final pass.
|
||||||
|
- Coverage: **>90%** on new/modified code; this task is tests + nothing else.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] `tests/e2e/test_share_layout.py` exists, is parametrised over the 12
|
||||||
|
viewports, and asserts the `elementFromPoint` hit test for `#start`.
|
||||||
|
- [ ] The same file asserts `#stop` is clickable while a share is live at three
|
||||||
|
of those shapes.
|
||||||
|
- [ ] The commit message records the pre-fix failure (which widths failed and what
|
||||||
|
`hit` reported).
|
||||||
|
- [ ] `uv run pytest` fully green; gates clean.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Task 04 — Keep the two-row grid from coming back, and fix the docs
|
||||||
|
|
||||||
|
**Phase:** `04_reachable_share_cta` · **Source:** user request in chat — "the share button is cut off by the preview-stage"
|
||||||
|
**Story:** n/a
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
A static stylesheet guard that fails loudly if `.stage` regresses into a stack of
|
||||||
|
rows or loses its floor, plus the phase's documentation pass.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
|
||||||
|
1. `tests/test_app.py::TestStylesheet` — add a guard in the file's existing regex
|
||||||
|
style (see `test_hidden_always_means_gone`), something like
|
||||||
|
`test_the_share_stage_is_one_cell`:
|
||||||
|
* the `.stage { … }` rule contains `grid-template: 1fr / 1fr` (or an equivalent
|
||||||
|
explicit single cell) **and** a `.stage > *` rule with `grid-area: 1 / 1`;
|
||||||
|
* the reason goes in the docstring, because the test looks arbitrary: *the
|
||||||
|
stage's children overlap on purpose; in plain flow an idle `<video>` is
|
||||||
|
intrinsically 300×150 and pushes the Share button out of an
|
||||||
|
`overflow: hidden` box.*
|
||||||
|
2. `tests/test_app.py::TestStylesheet` — add
|
||||||
|
`test_the_stage_has_a_floor_and_a_ceiling`: the `.stage` rule contains both
|
||||||
|
`min-height` and `max-height`, and any rule that combines `overflow: hidden`
|
||||||
|
with `aspect-ratio` has a `min-height` in the same block. This is the general
|
||||||
|
statement of the bug — an aspect-derived box with no floor is a clip.
|
||||||
|
3. `tests/test_app.py::TestStylesheet::test_hidden_always_means_gone` — add
|
||||||
|
`.placeholder` to the list of components that set their own `display` and are
|
||||||
|
toggled with `hidden`, since tasks 01–02 give it `overflow`/`max-height` and it
|
||||||
|
is toggled by `renderIdle()`/`renderLive()`.
|
||||||
|
4. `README.md` — add the sharer-facing line where the pages/quickstart are
|
||||||
|
described: `/mirror` works down to a 320 px-wide viewport, the preview keeps
|
||||||
|
its shape but never shrinks below the controls it holds. Keep it user-facing;
|
||||||
|
no CSS internals in the README.
|
||||||
|
5. `README.md` **full consistency pass** (mandatory, phase-level): read the whole
|
||||||
|
file against the project as it stands after phases 01–04 and fix every
|
||||||
|
inconsistency, **including pre-existing ones this phase did not cause** — stale
|
||||||
|
ports or commands, a described layout that no longer matches, a
|
||||||
|
`MIRROR_MAX_BITRATE` default that phase 02 changes, keyboard behaviour phase 01
|
||||||
|
adds. Cite the file:line of each fix in the commit message.
|
||||||
|
6. `AGENTS.md` — fix **stale statements only** (a dead command, a changed rule, an
|
||||||
|
outdated fact), never add or expand content. If nothing is stale, say so in the
|
||||||
|
commit message rather than inventing an edit.
|
||||||
|
7. `.agents/PLAN.md` — not touched by this phase. If the pass finds a PLAN
|
||||||
|
statement contradicted by reality (e.g. §6's "default 12 Mbit/s"), report it in
|
||||||
|
the commit message and to the user; only they may amend it.
|
||||||
|
|
||||||
|
ASSUMPTION: the guards assert the *structure* (`grid-template` + `grid-area`)
|
||||||
|
rather than a pixel value, matching the existing stylesheet tests' approach —
|
||||||
|
pixel assertions belong in `tests/e2e/test_share_layout.py`, where a real browser
|
||||||
|
can answer them.
|
||||||
|
ASSUMPTION: no new CSS custom property for the stage floor. One value, one place,
|
||||||
|
and a variable would hide the number from the guard regexes.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
|
||||||
|
- `uv run pytest tests/test_app.py::TestStylesheet` — the new guards pass, and each
|
||||||
|
one fails when its CSS change is reverted (verify by hand; note it in the commit
|
||||||
|
message).
|
||||||
|
- `uv run pytest -m "not e2e"` fast suite green, then the whole suite including
|
||||||
|
`-m e2e` and `-m realdisplay` in the final pass.
|
||||||
|
- Coverage: **>90%** on new/modified code; the entire project suite is 100%
|
||||||
|
passing before this phase is called done (`AGENTS.md` §4).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
|
||||||
|
- [ ] Two new `TestStylesheet` guards exist, pass, and demonstrably fail when the
|
||||||
|
corresponding CSS is reverted.
|
||||||
|
- [ ] `.placeholder` is in the `[hidden]` component list.
|
||||||
|
- [ ] README states the narrow-screen behaviour and survives a full-file
|
||||||
|
consistency review with every finding fixed or explicitly reported.
|
||||||
|
- [ ] `AGENTS.md` has no stale statements and no new narrative.
|
||||||
|
- [ ] Full gate: `uv run ruff check . && uv run ruff format --check . && uv run
|
||||||
|
pyright && uv run pytest && MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`.
|
||||||
|
- [ ] Committed per `AGENTS.md` §9: Conventional Commits, `--no-gpg-sign`,
|
||||||
|
`LLM-Generated: true` trailer.
|
||||||
Reference in New Issue
Block a user