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
+84 -7
View File
@@ -361,6 +361,31 @@
* "Sync cancelled", never a stuck running state). The bor:sync-status
* event shape is unchanged — the cancelled status object flows through
* emitSyncStatus exactly like every other state.
*
* Phase 149 (task 01) — the sync detail box + the icon-only running
* button: the owner's report was that the full live label (a long
* source/relative/path + the counts + the chunk part) is clipped in
* the pill even on desktop — the mobile solution (stuffing the parts
* in the button) had become the permanent surface. The fix: while a
* sync/upload run is in flight, the button shows ONLY the spinning
* icon (the `is-running` class — task 02's CSS hides the label parts;
* the button IS the loading spinner, exactly the owner's ask), and a
* NEW bordered text box under the button row (#sync-progress, the
* presentational `aria-hidden` panel) shows the SAME full untruncated
* live label — wrapping, never clipped, breathing room. #sync-result
* REMAINS the sole aria-live announcer (one live region, no double
* announce — the box is a visual duplicate for sighted users). The
* wiring rides the SINGLE writer: setSyncLabel gains the `running`
* flag (true on the running entry — the poll ticks, the 202/409
* click, both re-attach entries; the settle default false) and writes
* the full label into the box + reveals/hides it, and owns the
* button's accessible name (full live label while running, the ship
* value "Sync sources" restored on every settle). The `is-running`
* class travels with the spin: added in enterSyncRunningState, removed
* in settleSyncButton (every terminal + both idles pass through it).
* The settle rendering ("Synced HH:MM" + the counts line), the Cancel
* control, the 409 double-trigger, the error banner/modal, and the
* sync status API are all UNCHANGED.
*/
import { fetchIsAdmin } from "./header.js";
@@ -695,7 +720,11 @@ export async function mount(root) {
* import phase after the file's first batch)), #sync-cancel-btn
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
* only while a sync run is in flight), #sync-icon (the
* spinner icon), #sync-result (aria-live result line),
* spinner icon), #sync-result (aria-live result line — the SOLE
* live region), #sync-progress (Phase 149 task 01: the full
* untruncated live label's detail box under the button row —
* presentational, aria-hidden, visible only while a run is in
* flight; the .sync-progress-text span carries the label),
* #sync-error-banner / #sync-error-text (error banner).
*/
const syncBtn = root.querySelector("#sync-btn");
@@ -709,6 +738,15 @@ export async function mount(root) {
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
// Phase 149 (task 01): the sync detail box — the full UNTRUNCATED
// live label in a bordered panel under the button row, visible ONLY
// while a run is in flight (setSyncLabel reveals/hides it). The box
// is presentational (aria-hidden): #sync-result stays the SOLE
// aria-live announcer (one live region, no double announce).
const syncProgress = root.querySelector("#sync-progress");
const syncProgressText = syncProgress
? syncProgress.querySelector(".sync-progress-text")
: null;
const syncResult = root.querySelector("#sync-result");
const syncErrorBanner = root.querySelector("#sync-error-banner");
const syncErrorText = root.querySelector("#sync-error-text");
@@ -755,8 +793,20 @@ export async function mount(root) {
* them are the CSS gap). textContent only — the house rule, no
* HTML strings. Every label state goes through here (the running
* builders, the idle / settle labels, the 202/409 click, the
* re-attach). */
function setSyncLabel(mainText, countText = "", chunkText = "") {
* re-attach).
* Phase 149 (task 01): the fourth parameter — `running` (the
* in-flight flag; the callsites are the ONLY decision sites: the
* running entry passes true, the settle passes the default false).
* The box (#sync-progress) gets the SAME untruncated `full` string
* (textContent only) and is visible IFF `running` — the detail box
* rides the writer, so it can never disagree with the button
* title / aria / the announcer. The button's accessible name rides
* here too: while running the icon-only button (task 02 hides the
* label parts) reads the FULL live label (the title already carries
* it for hover); every non-running call restores the ship value
* "Sync sources" (the static HTML attribute is the idle truth —
* the JS only overwrites/restores it, never removes it). */
function setSyncLabel(mainText, countText = "", chunkText = "", running = false) {
if (syncLabelMain) syncLabelMain.textContent = mainText;
if (syncLabelCount) {
if (countText) {
@@ -782,6 +832,15 @@ export async function mount(root) {
(chunkText ? ` ${chunkText}` : "");
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
// Phase 149 (task 01): the detail box — the SAME untruncated label
// (one source, two surfaces: sighted users read the box, the
// announcer hears it), visible only while a run is in flight.
if (syncProgressText) syncProgressText.textContent = full;
if (syncProgress) syncProgress.hidden = !running;
// The running button is the spinner (task 02 hides the label
// parts) — its accessible name is the full live label; every
// settle restores the ship value (see the function docstring).
if (syncBtn) syncBtn.setAttribute("aria-label", running ? full : "Sync sources");
}
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
@@ -894,7 +953,13 @@ export async function mount(root) {
* same way (absent from the bare builders — the upload job and the
* 202/409 click — so the "" default keeps the part hidden there).
* Same never-stale mechanics as before (disabled, aria-busy,
* spinning icon, no is-error).
* spinning icon, no is-error). Phase 149 (task 01): the writer call
* passes `running = true` — the detail box reveals with the full
* label, and the button's accessible name becomes the full live
* label (the icon-only running button keeps a name; the writer
* owns the aria write, the entry no longer sets it) — plus the
* `is-running` class, which travels with the spin (task 02's CSS
* makes the running button icon-only).
*/
function enterSyncRunningState(kind, currentFile, done, total, status) {
if (!syncBtn) return;
@@ -904,10 +969,15 @@ export async function mount(root) {
kind === "sync" && status
? fmtSyncPhaseLabel(status)
: fmtSyncLabel(kind, currentFile, done, total);
setSyncLabel(main, count, chunk); // the parts + the A4 untruncated title / announcer
syncBtn.setAttribute("aria-label", "Sync sources");
// the parts + the A4 untruncated title / announcer + the phase-149
// detail box (revealed, full label) + the running aria-label
setSyncLabel(main, count, chunk, true);
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
// Phase 149 (task 01): the icon-only running button — the class
// and the spin travel together (one in-flight state; the settle
// removes both).
syncBtn.classList.add("is-running");
// Phase 134 (task 02): the Cancel control is visible + enabled
// ONLY while a SYNC run is in flight — the upload job is NOT
// cancellable in this phase (its run keeps the control hidden).
@@ -927,10 +997,17 @@ export async function mount(root) {
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
// Phase 149 (task 01): the class travels with the spin — EVERY
// settle (every terminal + both idles pass through here) leaves
// the running state.
syncBtn.classList.remove("is-running");
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
// "Synced HH:MM") go through the parts writer — count hidden
// (absent), the label's full string rides the title + #sync-result
// (A4, as with every label state).
// (A4, as with every label state). Phase 149 (task 01): the
// default `running = false` keeps the detail box hidden in every
// non-running state (idle, settled, error) and re-restores the
// button's idle accessible name.
setSyncLabel(label);
}
+44
View File
@@ -2236,6 +2236,50 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
white-space: nowrap;
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
}
/* Phase 149 (task 02): the sync detail box — the house's bordered panel
language (the .page-head card: --surface fill, 1px --line border,
the house radius; --line is decorative, no contrast duty, like every
other house border). It sits in the .sources-shell flex column whose
1.25rem gap IS the house spacing above and below — no margin-top
(it would double the gap). min-height ≈ 2 lines (0.8rem mono at the
body 1.55 line-height, + the 0.75rem block padding + the 2px border,
border-box) so even a one-line label reads as a calm panel with
breathing room. The box is hidden-attr-driven (the global [hidden]
!important rule keeps it display:none at idle — it exists only while
a run is in flight). Mobile: a block in the same .container rides the
100%-width phone shell — full width with no override (task 03's E2E
asserts it at 360px). */
.sync-progress {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 1rem;
min-height: 4rem;
}
.sync-progress-text {
display: block;
font-family: var(--mono); /* the .sync-result mono language (position reads as data) */
font-size: 0.8rem; /* the .sync-result size */
line-height: 1.55; /* the house body line-height */
color: var(--ink-soft); /* the .sync-result ink — a PINNED identity pair on --surface
(8.2:1 on the built-in dark, AA in both built-ins; the
theme gate keeps any custom theme at >= 4.5:1) */
overflow-wrap: anywhere; /* THE POINT of the box — a long source/relative/path WRAPS
inside the panel; the pre-phase pill ellipsis is retired
for the live detail (never clipped) */
}
/* Phase 149 (task 02): the running button IS the loading spinner (the
owner's ask) — the label parts hide via the .is-running class
(sources.js adds/removes it exactly where the icon's .is-spinning
travels). The pill's padding/border stay, so the control never
collapses: the 16px icon centers in a ~47x44px pill (desktop) or the
full-width 44px bar (mobile — the pre-phase .sync-btn { width: 100% }
rule keeps the big in-flight target, viewport-agnostic rule defined
ONCE here). :hover, :disabled, and :focus-visible are untouched —
they live on the .sync-btn element, and hiding the label span touches
neither. The idle "Sync sources" label (and the mobile ellipsis rules
for it) render exactly as pre-phase when the class is absent. */
.sync-btn.is-running .sync-label { display: none; }
/* Last-result announcer — soft ink, small mono */
.sync-result {
display: block;
+11
View File
@@ -482,6 +482,17 @@
to pull the latest and re-import.
</p>
</div>
<!-- Phase 149 (task 01): the sync detail box — the full UNTRUNCATED live
label ("Syncing… <source/rel/path> (n/m) · chunk n/m") in a bordered
panel under the button row, visible ONLY while a sync/upload run is
in flight (sources.js reveals/hides it through the single setSyncLabel
writer). Presentational (aria-hidden) — #sync-result stays the SOLE
aria-live announcer (one live region, no double announce). The button
itself is the spinner while running (task 02's .is-running CSS hides
the label parts). -->
<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">
<span class="sync-progress-text" id="sync-progress-text"></span>
</div>
<!-- #sync-result is the aria-live announcer: the last sync
result ("N added · …") when a sync settles, and — phase 64 —
the LIVE file label while a sync runs ("Syncing… <file>