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