Converts the three open TODO items into executable phases (Protocol B append, numbered after the existing 149): - 150_drop_sync_result — retire span.sync-result; #sync-progress becomes the single sync surface and the sole aria-live region (TODO L5) - 151_pdf_image_extraction — dedupe PDF image occurrences (46 placements -> 15 unique in podman-systemd.pdf), composite /SMask so masked images are not blank, and skip near-white decorative underlays (TODO L4) - 152_sync_progress_image_pass — report the PDF vision-image position so the sync label stops freezing at the last chunk (TODO L3) Both reported bugs were reproduced against test_sources/podman-systemd.pdf before authoring; root causes are recorded in the phase overviews. The TODO file is cleared now that its items live in .agents/phases/. LLM-Generated: true
11 KiB
Phase 150 — retire span.sync-result: #sync-progress is the single sync text surface
Source: TODO.md L5 — "Get rid of the span sync-result now that we have the div sync-progress"
Story: n/a (TODO-derived phase, Protocol B append).
Context: the Sources page (frontend/index.html, the view-rag section). Phase 149 added #sync-progress (L493–495 — <div class="sync-progress" id="sync-progress" hidden aria-hidden="true"> + the inner <span class="sync-progress-text" id="sync-progress-text">), the bordered panel under the button row that carries the full untruncated live label while a run is in flight. The pre-149 announcer survived beside it: <span class="sync-result" id="sync-result" role="status" aria-live="polite"> (L511) — today the SOLE aria-live region, which receives (a) the same live label while a run runs (a byte-identical duplicate of the box), (b) the settle counts line (fmtSyncResult(detail) — "N added · M updated · …"), (c) the "Sync cancelled" line, (d) the cancel-request failure copy, (e) "" on the sync-failure / idle / upload-settle paths (plus the settle label "Synced HH:MM" and the idle "Sync sources", written through the shared writer). The single writer is frontend/assets/sources.js setSyncLabel(mainText, countText, chunkText, running) (L809–843): it writes the button parts, the title, the box text + hidden = !running, the button aria-label, AND syncResult.textContent = full (L834). The syncResult element handle is L750; the other write sites are L1092 (applySyncSuccess — the counts line), L1107 (applySyncIdle — clear), L1125 (applySyncCancelled — "Sync cancelled"), L1213/L1224 (the upload settles — clear), L1291/L1298/L1310 (the cancel paths — clear + "Couldn't cancel the sync — try again."). CSS: .sync-progress L2252, .sync-progress-text L2259, .sync-result L2284–2290, the mobile .sync-result override at L5639, and the "the sync-result shape" comments at L2938 / L3659 (those rules belong to OTHER elements — the upload success line and the action feedback line — and keep their own classes).
Locked (owner decision, chat — this phase): #sync-progress becomes the single sync text surface AND the sole aria-live region: role="status" aria-live="polite" moves onto the box, aria-hidden is dropped, and span.sync-result is deleted from the template. The box keeps the phase-149 running behavior AND takes over the announcer's content: it is visible while a run is in flight (the live label) and stays visible after a settle to carry the result line (the counts / "Sync cancelled" / cancel-failure copy), hiding only when its text is empty (idle, upload settles, failures — exactly the states where #sync-result is empty today). Every string that reaches #sync-result today reaches the box instead — no copy changes.
Objective
One element, one live region: the sync/upload live label, the settle result line, the cancel line and the click copy all render in #sync-progress, which announces them exactly once; #sync-result no longer exists in the DOM, the JS, or the CSS.
Dependencies
149_sync_progress_box(complete) — the box, its single-writer wiring, and its CSS are what this phase folds the announcer into. Pipeline order only: no phase is pending intodo/.
Design (shared by all tasks — the executor reads this, not the chat)
- The element (task 01):
#sync-progresskeeps its id, class, and inner#sync-progress-textspan (the textContent target — the house rule: text only, never HTML) and gainsrole="status" aria-live="polite;aria-hidden="true"is REMOVED (an aria-live region that is aria-hidden announces nothing — the two attributes are mutually exclusive by intent). It stays ship-hidden (hidden) — the anonymous-safe/idle state.span.sync-result(L511) and its explanatory comment block (L496–510) are deleted; the box's comment (L483–492) is rewritten to say it is now the SOLE announcer (the phase-149 "presentational (aria-hidden)" sentence is retired — leave a one-line note that phase 150 folded the announcer in, the file's pinned-comment convention). - The writer (task 02): introduce ONE function that owns the box —
setSyncProgress(text)— which setssyncProgressText.textContent = textandsyncProgress.hidden = (text === "")(the global[hidden]rule does the display). Two content modes, decided at the callsites (the same "the callsites are the only decision sites" rule phase 149 established):- running mode —
setSyncLabel(main, count, chunk, running)keeps writing the box withfullonly whenrunning === true(exactly today's reveal rule). Whenrunning === falseit does NOT touch the box: the idle/settle button labels ("Sync sources", "Synced HH:MM") must not fill a big panel (today they land on the small#sync-resultline where they read as a duplicate of the button — that artifact is retired, not migrated). - result mode — the settle paths call
setSyncProgress(text)explicitly:applySyncSuccess→fmtSyncResult(status.detail)(the counts line),applySyncCancelled→"Sync cancelled",applySyncFailure→""(the banner + modal carry the error),applySyncIdle→"", the two upload settles →""(the upload result lives on the Sources page — phase 90 A3),requestCancel's network/non-2xx branches →"Couldn't cancel the sync — try again."(string unchanged).requestCancel's opening clear (L1291, which today blanks the announcer while the run is still in flight) becomes a NO-OP on the box — the live label stays up until the poll'scancelledbranch settles it (blanking would hide the running surface for up to one 2 s poll). ThesyncResulthandle (L750) and everyif (syncResult)guard are deleted. There is no 202/409 click copy:startSync's 202/409 branch enters the running state (L1255–1262), so the box reveals the bare "Syncing…" label throughsetSyncLabelas today.
- running mode —
- The CSS (task 03): delete the
.sync-resultrule (L2284–2290) and the mobile.sync-resultoverride (L5639); the two "the sync-result shape" comments (L2938, L3659) are reworded to reference.sync-progress-text(they describe the mono/soft-ink language those other elements share — the rules themselves and their selectors are untouched)..sync-progressneeds NO new rule for the settled state (the panel already reads as a calm box; the global[hidden] { display: none !important }rule keeps it gone when empty — verify it still applies, the box sets nodisplayof its own). Contrast stays AA:.sync-progress-textuses--ink-softon--surface(8.2:1 built-in dark, AA in both built-ins). - The test migration (tasks 04/05): mechanical selector migration —
#sync-result→#sync-progress(assert on the box's text, i.e.#sync-progress-textfor textContent,#sync-progressforhidden/attributes). Settled-state copy assertions must stay byte-identical (the counts line string itself does not change). - ASSUMPTION A1 (owner-locked): the box stays visible after settle to hold the counts line and hides only when its text is empty. The alternative (box running-only, result line elsewhere) was rejected.
- ASSUMPTION A2: the settle label "Synced HH:MM" is no longer written to the live region (it stays on the button, visible). The announcer hears the counts line that superseded it in the same tick a moment ago — the merge makes one surface, and a panel reading "Synced HH:MM" under a button reading "Synced HH:MM" is the duplicate this retirement exists to remove.
- ASSUMPTION A3: the box's settled appearance needs no new CSS (the same bordered panel reads fine for a result line); only the running-only min-height stays, so the panel does not jump between modes.
Tasks
01_html_single_surface.md— deletespan#sync-result, moverole=status aria-live=politeonto#sync-progress, droparia-hidden, rewrite the two comment blocks.02_js_single_writer.md—sources.js: addsetSyncProgress, route all 9 announcer sites through it, delete thesyncResulthandle, change the box visibility rule to text-driven.03_css_retire_sync_result.md—styles.css: remove.sync-result+ its mobile override, reword the two shape comments.04_unit_test_migration.md—tests/unit/test_sync_button.py(20 refs) +tests/unit/test_frontend_sync_upload.py(26 refs) migrated to the box.05_e2e_sync_family.md— the 12 sync-family E2E files' selectors migrated + the dedicatedtests/e2e/test_sync_progress_box.pyextended with the single-live-region + settled-line assertions.06_e2e_image_pdf_family.md— the 6 image/PDF E2E files migrated (their sync-done barriers move to the box) + the a11y/no-CDN/full-gate pass and the phase commit.
Testing & Quality
- Unit: the two frontend-parsing suites (
test_sync_button.py,test_frontend_sync_upload.py) pin the writer contract on the box — running label visible + announced, settle counts line, "Sync cancelled", the idle/upload clears, the "Couldn't cancel" copy — and assert#sync-resultis ABSENT from the template (the retirement is pinned, not implied). - E2E:
uv run pytest tests/e2e/test_sync_progress_box.py -v --no-covin isolation (the slow-LLM proxy recipe already in that file) proves in-flight → settle → idle on the real sync path; every pre-existing sync/image/PDF E2E that touched the announcer passes after migration (12 sync-family files + 6 image/PDF files). - Coverage: >90% on
app/(this phase touches noapp/logic — the floor must simply not regress). - Accessibility gate (AGENTS.md rule 5): exactly ONE
aria-liveregion in the sync area; the box is astatusregion (polite — no alert spam); the button keeps its accessible name in both states; focus-visible and contrast unchanged.
Completion Criteria
grep -rn "sync-result\|syncResult" frontend/ tests/returns NOTHING (the element, its class, and its JS handle are gone from source and tests)#sync-progresscarriesrole="status" aria-live="polite"and noaria-hidden; it is the onlyaria-liveelement in the sync area- in flight: the box shows the full untruncated live label (wrapping, never clipped); at settle: the box shows the counts line byte-identical to the pre-phase
#sync-resultstring ("N added · …") and "Sync cancelled" for a cancel; at idle / upload-settle / sync-failure: the box ishiddenand empty - the button is still the icon-only spinner while running and restores "Sync sources" on settle (phase 149 behavior unchanged)
uv run pytestgreen;uv run pytest tests/e2e/test_sync_progress_box.py -v --no-covgreen in isolation;uv run ruff check . && uv run pyrightclean;app/coverage >90%- no behavior change in completed phases (the cancel control, the 409 double-trigger, the error banner/modal, the upload run's status, the sync status API)
- one atomic commit per AGENTS.md rule 8 (
--no-gpg-sign)