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);
}