phase: 149_sync_progress_box
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user