phase: 149_sync_progress_box
Build and Push Containers / build-and-push-app (push) Successful in 3m35s
Build and Push Containers / build-and-push-db (push) Successful in 11s

All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.

**Phase 149 final verification report**

- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
This commit is contained in:
2026-10-01 21:14:48 -04:00
parent b233c8d714
commit dd524246ec
33 changed files with 2512 additions and 393 deletions
+295 -234
View File
@@ -1,8 +1,7 @@
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label
is fully visible at a phone viewport — the file path ellipsizes to fit
and the ``(n/m)`` count span sits in its own non-shrinking part that is
NEVER clipped — in BOTH long spans (import + summaries), while desktop
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync
detail is fully visible at a phone viewport — in BOTH long spans
(import + summaries) — while desktop keeps the pre-phase full
strings.
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
Playwright file per phase, run in isolation): the owner's report was
@@ -10,11 +9,40 @@ that ``Syncing… name/of/file/here (2/3)`` "doesn't show up on mobile" —
pre-phase, ``#sync-label`` was ONE inline-block span (whole label
string, one ellipsis) whose mobile media query LIFTED the
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 splits
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 split
the label into two parts (``.sync-label-main`, the shrinkable
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
the count is never the ellipsis's sacrifice.
Phase 149 (task 01) RE-POINTED the running-state pins: the owner's
follow-up was that the full live label (a long
``source/relative/path`` + the counts + the chunk part) is clipped
in the pill even on desktop, and the fix moves the running detail
out of the pill entirely — while a run is in flight the button is
the spinner (the ``is-running`` class; task 02's CSS hides the label
parts), and a detail box under the button row (``#sync-progress``)
carries the SAME full untruncated label — wrapping, never clipped,
``#sync-result`` staying the sole aria-live announcer. Tests 1–3
therefore pin the BOX: visible while the run is in flight, its text
the pre-phase byte-identical full string ("Syncing… <full
source/relative/path> (n/4)" / "Summarizing folders… <folder>
(n/3)" + the phase-129 "· chunk n/m" while a batch is in flight),
inside the viewport at 390 px and 1280 px, the button in its
running state, and the ``title`` carrying the same full string
(A4 — one source, two surfaces). The SETTLED pins (the phase-32
"Synced HH:MM" label, the count span hidden + empty, the box hidden)
are UNCHANGED. Test 4 (the pre-phase-128 rules' clip reproduction)
pins the old markup/CSS counterfactually — the label parts still
receive their text under any CSS (the writer is unchanged), so it
holds on the DOM text + injected rules regardless of the phase-149
box. The injected set ALSO neutralizes the phase-149 hide rule
(``.sync-btn.is-running .sync-label { display: none }`` — task 02's
specificity (0,2,0) would beat the injected (0,1,0) ``.sync-label``
rule and leave the parts display:none, making the geometry vacuous);
pre-phase, that rule simply did not exist, so the faithful
pre-phase counterfactual restores the parts' inline-block display
at matching specificity, last in the cascade.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
@@ -56,38 +84,34 @@ Test → observable mapping (Playwright Mapping Rule):
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
page on the sources view at viewport **390×844** (mobile); click
**Sync sources**; while the import phase is running (the page's
own 2 s poll): the rendered ``#sync-label``'s bounding box is
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct
negation), and the ``.sync-label-count`` part is VISIBLE (non-null
box) and NOT clipped — its right edge sits at or inside the label's
right edge and the button's right edge, with text matching
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main``
carries ``Syncing… <file>`` — the FULL source/relative/path
(visually ellipsized, but the DOM text and the button ``title``
are untruncated, A4). Phase 129: the label's THIRD pinned part —
``.sync-label-chunk``, "· chunk n/m" (the in-flight file's
embed-batch position, batch granularity) — renders while a file's
embed batch is in flight (``chunks_total > 0``) and rides the
untruncated ``title`` too; the snapshot accepts it (optional —
the pre-first-batch frame has no chunk part yet; the phase-129
dedicated suite pins it strictly).
own 2 s poll): the detail box (``#sync-progress``) is VISIBLE and
an atomic snapshot of: its text the FULL untruncated label
``Syncing… <full source/relative/path> (n/4)`` (+ the phase-129
"· chunk n/m" part while a batch is in flight — optional, the
pre-first-batch frame has none) with the ``(n/4)`` count IN the
text (never clipped — nothing is ellipsized away), the box's
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
bug's direct negation), the button in its running state
(``is-running`` — the spinner), and the button ``title``
carrying the SAME full string (A4 — one source, two surfaces).
The run then settles (the phase-32 terminal shape; the box hides).
2. ``test_mobile_label_shows_summaries_count`` — same mobile
viewport; through to the summaries phase: ``.sync-label-main``
matches ``Summarizing folders…`` (the folder part optional — the
phase's first poll may precede the first hook), the
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays
visible (same clip pins as test 1); on settle the button leaves
the running label (the phase-32 terminal ``Synced HH:MM``) and the
count span is hidden + empty.
viewport; through to the summaries phase: the box text matches
``Summarizing folders… [source/folder] (n/3)`` (the folder part
optional — the phase's first poll may precede the first hook — the
``(n/3)`` count always present, IN the text) with the same
box-geometry pins as test 1 (inside the viewport), the ``title``
carrying the same full string (A4); on settle the button leaves
the running label (the phase-32 terminal ``Synced HH:MM``), the
count span is hidden + empty, and the box hides.
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
sync: during import the full rendered label text (main + count +
optional phase-129 chunk concatenation) matches ``Syncing… <full
source/relative/path> (n/m)`` (the pre-phase byte-identical
string, the chunk part appended while a batch is in flight) and
the label's width respects the ``min(16rem, 40vw)`` cap (bounding
box width ≤ 256 px + slack — the shrinkable main part absorbs
the extra pinned part); the ``title`` carries the same
untruncated string (A4).
sync: during import the box text matches ``Syncing… <full
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
batch is in flight) — the pre-phase byte-identical FULL string
(phase 149 moved it from the pill's parts to the box — the text
is unchanged), the box inside the viewport, the button in its
running state (``is-running``), and the ``title`` carrying the
same untruncated string (A4).
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked
@@ -183,21 +207,26 @@ FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
#: ``done`` starts at 1 (the importer counts the file as current
#: BEFORE indexing it), so ``n`` ∈ 1..4.
#: BEFORE indexing it), so ``n`` ∈ 1..4. (Test 4 — the pre-phase
#: rules' clip reproduction — pins the PARTS' DOM text directly.)
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
#: The pre-phase byte-identical FULL string (main + " " + count — the
#: single span's text before phase 128; the A4 title keeps it), plus
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
#: file's embed-batch position, batch granularity) the title carries
#: while a file's embed batch is in flight.
#: file's embed-batch position, batch granularity). Phase 149 (task
#: 01): the box (#sync-progress) carries this EXACT string while the
#: import is in flight (tests 1 + 3) — the title carries it too
#: (A4 — one source, two surfaces).
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main
#: "Summarizing folders… [source/folder]" (the folder part omitted
#: while ``current_summary`` is null) + count "(n/3)".
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$")
SUMMARY_COUNT_RE = re.compile(r"^\(\d+/3\)$")
#: The summaries label's FULL string (phase 98 task 02, unit-pinned):
#: "Summarizing folders… [source/folder] (n/3)" (the folder part
#: omitted while ``current_summary`` is null — the phase's first poll
#: may precede the first hook — the (n/3) count always present). Phase
#: 149 (task 01): the box carries this EXACT string while the
#: summaries phase is in flight (test 2) — the title carries it too.
SUMMARY_FULL_RE = re.compile(r"^Summarizing folders…(?: \S+)? \(\d+/3\)$")
#: "Synced HH:MM" — the local-time last-result label (sources.js's
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
@@ -209,17 +238,15 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
#: The viewports (the task's mapping): the phone (the reported bug)
#: and the desktop (the unchanged-contract leg).
#: and the desktop (the unchanged-contract leg). Phase 149 (task 01):
#: the geometry pins now apply to the DETAIL BOX (a block in the
#: 72rem container — it can only shrink toward the container's
#: content width, never overflow the viewport); the old pill-label
#: cap (min(16rem, 40vw)) applied to the ellipsized pill label, which
#: is no longer the running surface (the button is the spinner).
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
#: The desktop cap: ``min(16rem, 40vw)`` = 256 px at the 16 px :root
#: (16rem) and the 1280 px viewport (40vw = 512 px) — the bounding
#: box width may not exceed the cap (a small slack covers float +
#: border rounding).
DESKTOP_CAP_PX = 256
CAP_SLACK_PX = 8
#: Generous settle budgets: a changed sync against the slowed LLM is
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
#: terminal state lands.
@@ -234,27 +261,127 @@ def _md(title: str, body: str) -> str:
# The in-browser snapshot functions (Playwright Mapping Rule — the
# rendered DOM is the observable).
#
# Each returns null while the awaited label state is absent, and an
# ATOMIC geometry snapshot once it is present: the part texts, the
# button title, and the getBoundingClientRect boxes of the label, the
# count part, and the button — read in the SAME in-browser evaluation
# that verified the label state, so the numbers can never straddle a
# 2 s poll tick (the parts + the title are written together in one
# JS task by setSyncLabel).
# Phase 149 (task 01): the RUNNING detail rides the detail box —
# #sync-progress (visible IFF a run is in flight) with the FULL
# untruncated label in its .sync-progress-text span. Each snapshot
# returns null while the awaited box state is absent, and an ATOMIC
# snapshot once it is present: the box's text + the button's title +
# is-running flag + the getBoundingClientRect boxes of the box and
# the button — read in the SAME in-browser evaluation that verified
# the box state, so the numbers can never straddle a 2 s poll tick
# (the box text + the title are written together in one JS task by
# the single setSyncLabel writer — A4: one source, two surfaces).
# ---------------------------------------------------------------------------
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing…
#: <full source/relative/path>" (the untruncated A4 text — the visual
#: ellipsis never touches textContent), the count part is VISIBLE
#: (not ``hidden``) with the "(n/4)" position, and the button title
#: carries the full untruncated label (main + " " + count, A4). Phase
#: 129: the THIRD pinned part — .sync-label-chunk ("· chunk n/m", the
#: in-flight file's embed-batch position) — rides the title too while
#: a file's embed batch is in flight (chunks_total > 0): the title
#: check accepts main + " " + count (+ " " + chunk when the part is
#: rendered) — optional, since the pre-first-batch frame has no chunk
#: part (the phase-129 dedicated suite pins the part strictly).
_JS_IMPORT_SNAPSHOT = """
#: Test 1/3 — the in-flight IMPORT detail: the box is VISIBLE and its
#: text is the FULL untruncated label — "Syncing… <full
#: source/relative/path> (n/4)" (+ the optional phase-129 " · chunk
#: n/m" part while a file's embed batch is in flight — the
#: pre-first-batch frame has none; the phase-129 dedicated suite pins
#: it strictly) — the count is IN the text (never clipped — nothing
#: is ellipsized away), the button is in its running state
#: (is-running — the spinner), and the title carries the SAME full
#: string (A4).
_JS_IMPORT_BOX_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = new RegExp(
"^Syncing… synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\\\\.md " +
"\\\\([1-4]/4\\\\)(?: · chunk \\\\d+/\\\\d+)?$"
);
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: Test 2 — the SUMMARIES detail: the box is VISIBLE and its text is
#: the full "Summarizing folders… [source/folder] (n/3)" (the folder
#: part optional — the phase's first poll may precede the first hook —
#: the (n/3) count always present, IN the text), the button is in its
#: running state, and the title carries the SAME full string (A4).
_JS_SUMMARY_BOX_SNAPSHOT = """
() => {
const box = document.querySelector('#sync-progress');
const textEl = document.querySelector('#sync-progress-text');
const btn = document.querySelector('#sync-btn');
if (!box || !textEl || !btn || box.hidden) return null;
const t = textEl.textContent;
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
if (!reFull.test(t)) return null;
const title = btn.getAttribute('title');
if (title !== t) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
boxText: t,
title,
isRunning: btn.classList.contains('is-running'),
boxRect: rect(box),
btnRect: rect(btn),
};
}
"""
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
#: (the bug reproduction): the base rule's inline-block + single
#: ellipsis with the mobile media query's lifted cap (``max-width:
#: none``) — the label sizes to its FULL content and its ONE ellipsis
#: owns the whole tail, so the count (now riding a child span inside
#: that ellipsized box) is clipped away with it. The parts' own rules
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
#: non-mono count font). Phase 149 (task 02): the set ALSO
#: neutralizes the phase-149 hide rule — ``.sync-btn.is-running
#: .sync-label { display: none }`` (0,2,0) would beat the injected
#: (0,1,0) ``.sync-label`` rule and leave the parts display:none, so
#: the counterfactual restores the parts' pre-phase inline-block
#: display at matching specificity (last in the cascade it wins);
#: pre-phase, the hide rule simply did not exist. The rules are
#: complete rule strings — they go in via CSSOM ``insertRule``
#: (appended to the origin's own linked stylesheet, so the cascade
#: position is "last wins" like an injected ``<style>`` tag — which
#: the A20 CSP would block). The probe measured this override
#: reproduces the owner's report at 390 px with the repo's fonts (the
#: count's box fully past the label's clip edge and past the pill's
#: right edge).
_PRE_PHASE_SYNC_RULES = (
".sync-label { display: inline-block; align-items: initial; gap: 0; "
"max-width: none; min-width: 0; overflow: hidden; "
"text-overflow: ellipsis; white-space: nowrap; }",
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
".sync-btn.is-running .sync-label { display: inline-block; }",
)
#: Test 4 ONLY — the PARTS-level in-flight IMPORT snapshot (the
#: counterfactual clip reproduction): the writer still lands the label
#: PARTS (main + count + optional chunk) in the DOM for every running
#: label (phase 149's box reads the same writer output), so under the
#: re-injected pre-phase-128 CSS the parts' texts + geometry are the
#: observable — the count part's box runs past the label's clip edge
#: (painted away) + off the pill's right edge (the bug). Reads the
#: parts' textContent + hidden flags, the button title (A4), and the
#: getBoundingClientRect boxes of the label, the count part, and the
#: button — atomically (one in-browser evaluation, so the numbers
#: never straddle a 2 s poll tick).
_JS_PARTS_IMPORT_SNAPSHOT = """
() => {
const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn');
@@ -287,61 +414,6 @@ _JS_IMPORT_SNAPSHOT = """
}
"""
#: Test 2 — the SUMMARIES label: main carries "Summarizing folders…
#: [source/folder]" (the folder part optional — the phase's first
#: poll may precede the first hook), the count part is visible with
#: the "(n/3)" position, and the title carries the full untruncated
#: label (A4).
_JS_SUMMARY_SNAPSHOT = """
() => {
const label = document.querySelector('#sync-label');
const btn = document.querySelector('#sync-btn');
if (!label || !btn) return null;
const main = label.querySelector('.sync-label-main');
const count = label.querySelector('.sync-label-count');
if (!main || !count || count.hidden) return null;
const reMain = /^Summarizing folders…(?: \\S+)?$/;
const reCount = /^\\(\\d+\\/3\\)$/;
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
const title = btn.getAttribute('title');
if (title !== main.textContent + ' ' + count.textContent) return null;
const rect = (el) => {
const b = el.getBoundingClientRect();
return { left: b.left, right: b.right, width: b.width, height: b.height };
};
return {
mainText: main.textContent,
countText: count.textContent,
title,
labelRect: rect(label),
countRect: rect(count),
btnRect: rect(btn),
};
}
"""
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
#: (the bug reproduction): the base rule's inline-block + single
#: ellipsis with the mobile media query's lifted cap (``max-width:
#: none``) — the label sizes to its FULL content and its ONE ellipsis
#: owns the whole tail, so the count (now riding a child span inside
#: that ellipsized box) is clipped away with it. The parts' own rules
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
#: non-mono count font). The rules are complete rule strings — they
#: go in via CSSOM ``insertRule`` (appended to the origin's own
#: linked stylesheet, so the cascade position is "last wins" like an
#: injected ``<style>`` tag — which the A20 CSP would block). The
#: probe measured this override reproduces the owner's report at
#: 390 px with the repo's fonts (the count's box fully past the
#: label's clip edge and past the pill's right edge).
_PRE_PHASE_SYNC_RULES = (
".sync-label { display: inline-block; align-items: initial; gap: 0; "
"max-width: none; min-width: 0; overflow: hidden; "
"text-overflow: ellipsis; white-space: nowrap; }",
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
)
# ---------------------------------------------------------------------------
# Fixtures
@@ -563,12 +635,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
# The click's immediate state (the 202 moment — the bare prefix,
# the phase-64 contract): disabled, aria-busy, spinning icon, and
# the label's MAIN part alone (the count part is hidden — its text
# is empty, so the parts' concatenation is exactly "Syncing…").
# the detail box revealed with the bare label (phase 149 — the
# running detail rides the box; the button is the spinner).
expect(btn).to_be_disabled()
expect(btn).to_have_attribute("aria-busy", "true")
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
expect(page.locator("#sync-label")).to_have_text("Syncing…")
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
expect(page.locator("#sync-error-banner")).to_be_hidden()
@@ -607,34 +679,32 @@ def _settle(page: Page, app_url: str) -> None:
count = page.locator(".sync-label-count")
expect(count).to_be_hidden()
expect(count).to_have_text("")
# Phase 149 (task 01): the detail box is run-scoped — the settle
# (the writer's default running = false) hides it again.
expect(page.locator("#sync-progress")).to_be_hidden()
expect(page.locator("#sync-error-banner")).to_be_hidden()
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None:
"""The phase 128 geometry contract for a rendered running label at
the given viewport: the label box is INSIDE the viewport (the bug's
direct negation), the count part is VISIBLE (a non-degenerate box),
and the count is NOT clipped — its right edge sits at or inside
the label's right edge (the label's own clip boundary) and the
button's right edge (the pill). The 1 px slack covers float
rounding only — the layout nests the boxes exactly."""
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
assert label["left"] >= 0, f"label runs off the left edge: {label}"
assert label["right"] <= viewport_w, (
f"label runs off the right edge of the {viewport_w}px viewport: {label}"
def _assert_box_fits_viewport(snap: dict[str, Any], viewport_w: int) -> None:
"""The phase 149 geometry contract for the rendered running detail
at the given viewport: the detail box is VISIBLE (a non-degenerate
box) and INSIDE the viewport (left ≥ 0, right ≤ the viewport
width — the owner's clipped-detail report, directly negated). The
box is a block in the 72rem centered container, so it can only
shrink toward the container's content width — it never overflows
the viewport — and its text is the FULL untruncated label (the
"(n/m)" count is IN the text; the wrap that keeps long paths
on-screen is task 02's overflow-wrap: anywhere, pinned by the
phase-149 dedicated suite). The 1 px slack covers float rounding
only."""
box = snap["boxRect"]
assert box["width"] > 0 and box["height"] > 0, (
f"the detail box has no visible box: {box}"
)
assert count["width"] > 0 and count["height"] > 0, (
f"the count part has no visible box: {count}"
)
assert count["left"] >= label["left"] - 1, (
f"the count part sits outside the label: {label} {count}"
)
assert count["right"] <= label["right"] + 1, (
f"the count part is clipped by the label (its box runs past the "
f"label's clip edge): label={label} count={count}"
)
assert count["right"] <= btn["right"] + 1, (
f"the count part runs off the pill: btn={btn} count={count}"
assert box["left"] >= 0, f"the detail box runs off the left edge: {box}"
assert box["right"] <= viewport_w + 1, (
f"the detail box runs off the right edge of the {viewport_w}px "
f"viewport: {box}"
)
@@ -650,47 +720,43 @@ def test_mobile_label_fits_viewport_with_count_in_import(
"""A fresh admin page on the sources view at a PHONE viewport
(390×844) clicks **Sync sources**; while the import phase is
running (the page's own 2 s poll renders it — the span outlives
the poll by ≥ 2× under the slow proxy), the rendered ``#sync-label``
is an atomic snapshot of: the label box INSIDE the viewport (left ≥
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count``
part VISIBLE with its "(n/4)" position and its right edge at or
inside the label's and the button's right edges (NEVER clipped —
the pre-phase geometry fails exactly these pins, test 4
reproduces it), and the ``.sync-label-main`` part carrying
``Syncing… <full source/relative/path>`` — the file part may be
VISUALLY ellipsized, but the DOM text is untruncated and the button
``title`` carries the FULL label (main + " " + count, A4). The run
then settles (the phase-32 terminal shape)."""
the poll by ≥ 2× under the slow proxy), the DETAIL BOX
(``#sync-progress`` — phase 149: the running detail left the
pill; the button is the spinner) is an atomic snapshot of:
VISIBLE with the FULL untruncated label ``Syncing… <full
source/relative/path> (n/4)`` (+ the phase-129 "· chunk n/m" part
while a batch is in flight — optional) with the ``(n/4)`` count IN
the text (NEVER clipped — nothing is ellipsized away), the box's
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
bug's direct negation), the button in its running state
(``is-running``), and the button ``title`` carrying the SAME full
string (A4 — one source, two surfaces). The run then settles (the
phase-32 terminal shape; the box hides)."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
# The page's own 2 s poll renders the in-flight import label; the
# atomic in-browser snapshot pins its text + geometry together.
# (The Playwright sync API wraps a non-primitive result in a
# JSHandle — .json_value() unwraps the snapshot dict.)
# The page's own 2 s poll renders the in-flight import detail in
# the box; the atomic in-browser snapshot pins its text + geometry
# together. (The Playwright sync API wraps a non-primitive result
# in a JSHandle — .json_value() unwraps the snapshot dict.)
snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The parts (the phase-64 byte-identical strings, unit-pinned):
# main = "Syncing… <full source/relative/path>" (untruncated — the
# visual ellipsis never touches textContent), count = "(n/4)".
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
# A4: the button title carries the UNTRUNCATED label — the full
# path, not the ellipsized visual tail (the in-browser check
# already required title == main + " " + count; pin it here too).
# Phase 129: the pinned chunk part ("· chunk n/m") rides the title
# while a file's embed batch is in flight (optional — the
# pre-first-batch frame has none; the phase-129 dedicated suite
# pins it strictly).
full = snap["mainText"] + " " + snap["countText"]
if snap["chunkText"]:
full += " " + snap["chunkText"]
assert snap["title"] == full, snap
# The geometry contract (the fix): label inside the viewport, the
# count visible and never clipped.
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
# The FULL untruncated label — the complete
# source/relative/path, the (n/4) count IN the text (never
# clipped), the optional phase-129 chunk part.
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
# A4: the button title carries the SAME untruncated label (the
# in-browser check already required title == box text; pin it here
# too — one source, two surfaces).
assert snap["title"] == snap["boxText"], snap
# Phase 149: the running button is the spinner (is-running rides
# the spin — the detail is the box's, not the pill's).
assert snap["isRunning"] is True, snap
# The geometry contract (the fix): the box is visible and inside
# the viewport.
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url)
@@ -707,24 +773,26 @@ def test_mobile_label_shows_summaries_count(
"""Same phone viewport; the sync runs through to the SUMMARIES
phase (the post-import span where the file count sits still — the
owner's "the number pauses" leg): the page's own 2 s poll renders
``.sync-label-main`` as ``Summarizing folders… [source/folder]``
(the folder part optional — the phase's first poll may precede the
first hook) with the VISIBLE ``.sync-label-count`` part matching
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins
as test 1) — and the title carrying the full untruncated label
(A4). On settle the button leaves the running label (the phase-32
terminal ``Synced HH:MM``) and the count span is hidden + empty."""
the DETAIL BOX (``#sync-progress`` — phase 149) with
``Summarizing folders… [source/folder] (n/3)`` (the folder part
optional — the phase's first poll may precede the first hook — the
``(n/3)`` count of the 3 candidate folders always present, IN the
text — never clipped) with the same box-geometry pins as test 1
(inside the viewport), the button in its running state, and the
title carrying the SAME full string (A4). On settle the button
leaves the running label (the phase-32 terminal ``Synced HH:MM``),
the count span is hidden + empty, and the box hides."""
page.set_viewport_size(MOBILE_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function(
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
_JS_SUMMARY_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"]
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"]
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
assert SUMMARY_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert snap["title"] == snap["boxText"], snap
assert snap["isRunning"] is True, snap
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
_settle(page, app_url)
@@ -738,43 +806,34 @@ def test_mobile_label_shows_summaries_count(
def test_desktop_label_unchanged(
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
) -> None:
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract:
during import the FULL rendered label text — the main + count
parts' concatenation (main + " " + count, the CSS gap standing in
for the old single span's space) — matches ``Syncing… <full
source/relative/path> (n/m)``: the pre-phase byte-identical string
(the parts rework changed the output SHAPE, not the text). The
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at
this viewport (16 px :root) — with the count part pinned at the
right edge and the main part ellipsized (the same cap/ellipsis the
pre-phase label had), and the button ``title`` carries the same
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract —
the TEXT: during import the detail box (``#sync-progress`` —
phase 149 moved the running detail out of the pill to the box;
the owner's report was that the full label was clipped in the
pill even on desktop) carries ``Syncing… <full
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
batch is in flight): the pre-phase byte-identical FULL string
(phase 149 changed the SURFACE, not the text). The box is inside
the viewport, the button is in its running state (``is-running``
— the spinner), and the button ``title`` carries the same
untruncated string (A4)."""
page.set_viewport_size(DESKTOP_VIEWPORT)
_start_sync(page, app_url, synclabel_dir)
snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
# The pre-phase byte-identical FULL string (main + " " + count +
# the phase-129 chunk part while a batch is in flight):
# "Syncing… <full source/relative/path> (n/m)" (+ " · chunk n/m").
full = snap["mainText"] + " " + snap["countText"]
if snap["chunkText"]:
full += " " + snap["chunkText"]
assert IMPORT_FULL_RE.match(full), full
assert snap["title"] == full, snap
# The count part is visible and pinned inside the label (no
# clipping on desktop either — the cap ellipsizes the MAIN part
# only).
count, label = snap["countRect"], snap["labelRect"]
assert count["width"] > 0 and count["height"] > 0, count
assert count["right"] <= label["right"] + 1, f"label={label} count={count}"
# The cap: min(16rem, 40vw) = 256 px (16 px :root, 1280 px
# viewport) — the bounding box width may not exceed it (+ slack).
assert label["width"] <= DESKTOP_CAP_PX + CAP_SLACK_PX, (
f"the label box exceeds the min(16rem, 40vw) cap: {label}"
)
# The pre-phase byte-identical FULL string ("Syncing… <full
# source/relative/path> (n/m)" + " · chunk n/m" while a batch is
# in flight) — now in the box, untruncated and wrapping.
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
assert snap["title"] == snap["boxText"], snap
# Phase 149: the running button is the spinner (is-running);
# the count is IN the box's full text (the never-clipped pin —
# nothing is ellipsized away on desktop either).
assert snap["isRunning"] is True, snap
_assert_box_fits_viewport(snap, DESKTOP_VIEWPORT["width"])
_settle(page, app_url)
@@ -791,9 +850,11 @@ def test_pre_phase_css_reproduces_the_clip(
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
re-injected (CSSOM ``insertRule`` into the origin's own linked
stylesheet — inline-block, the cap lifted, one ellipsis over the
whole label, the count riding the ellipsized tail): the reported
bug comes back geometrically — the count part's box extends past
the label's clip edge (its right edge outside the label's right
whole label, the count riding the ellipsized tail; the injected
set also neutralizes the phase-149 ``is-running`` hide rule, which
pre-phase did not exist): the reported bug comes back geometrically —
the count part's box extends past the label's clip edge (its right
edge outside the label's right
edge, so the label's ``overflow: hidden`` paints it away) and past
the pill's right edge (it runs off the button). This is the
geometry tests 1–2's "count not clipped" pins reject — the suite's
@@ -822,7 +883,7 @@ def test_pre_phase_css_reproduces_the_clip(
)
snap = page.wait_for_function(
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
_JS_PARTS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
).json_value()
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]