\s*'
+ r'
\s*'
r'\s*
',
html,
)
assert box, "the shell must carry the #sync-progress detail box (phase 149)"
- # Under the button row, above the announcer: the RAG view's
+ # Under the button row, above the error banner: the RAG view's
# .page-head block precedes the box, and the box precedes
- # #sync-result (one live region — the box is presentational).
+ # #sync-error-banner (the retired announcer span is gone).
rag = html.find('id="view-rag"')
head = html.find('class="page-head"', rag)
- result = html.find('id="sync-result"', rag)
- assert -1 < head < box.start() < result, (
+ banner = html.find('id="sync-error-banner"', rag)
+ assert -1 < head < box.start() < banner, (
"the box sits between the RAG view's .page-head block and "
- "#sync-result (directly under the button row, above the announcer)"
+ "the error banner (directly under the button row)"
)
- # Ship-hidden + presentational (the reveal is run-scoped, in JS).
+ # Ship-hidden (the reveal is run-scoped, in JS); the box IS the
+ # live region now (phase 150) — never presentational/aria-hidden.
tag = html[box.start() : html.find(">", box.start())]
assert re.search(r"\bhidden\b", tag), "the box ships hidden (idle / anonymous-safe)"
- assert 'aria-hidden="true"' in tag, ("the box is presentational (no double announce)")
+ assert 'role="status"' in tag, ("the box is the polite live region (phase 150)")
+ assert 'aria-live="polite"' in tag, ("the box is the polite live region (phase 150)")
+ assert 'aria-hidden' not in tag, ("an aria-live region must not be aria-hidden")
+ assert f'id="{RETIRED_ID}"' not in html, (
+ "the retired announcer span is gone from the shell (phase 150)"
+ )
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
@@ -589,16 +638,19 @@ def test_sync_label_chunk_css_is_the_pinned_part() -> None:
#: The node harness script (the test-document-viewer pattern): the
#: REAL extracted label functions run against stub element refs, and
#: every label state is snapshotted as a JSON row —
-#: [tag, title, announcer, main, count, countHidden, chunk, chunkHidden].
+#: [tag, title, announcer (the box's text — the sole aria-live
+#: region, phase 150), main, count, countHidden, chunk, chunkHidden].
#: No JS template literals in the script body (plain strings only —
#: the script is assembled by concatenation, no placeholders).
_CHUNK_NODE_HEAD = """
const syncLabelMain = { textContent: "" };
const syncLabelCount = { textContent: "", hidden: true };
const syncLabelChunk = { textContent: "", hidden: true };
-const syncResult = { textContent: "" };
-// Phase 149 (task 01): the detail box — presentational, ship-hidden;
-// the REAL writer mutates it (full label + the running reveal).
+// Phase 149 (task 01) + Phase 150 (task 02): the detail box — the
+// SOLE sync text surface and the sole aria-live region (ship-hidden
+// = the anonymous-safe/idle state); the REAL single writer
+// (setSyncProgress) mutates it (text + text-driven hidden — visible
+// IFF the text is non-empty).
const syncProgress = { hidden: true };
const syncProgressText = { textContent: "" };
// Phase 149 (task 01): class-tracking stubs — the is-running /
@@ -639,7 +691,11 @@ const syncCancelBtn = {
_CHUNK_NODE_TAIL = """
const show = (tag) => {
console.log(JSON.stringify([
- tag, syncBtn.title, syncResult.textContent,
+ tag, syncBtn.title,
+ // Phase 150 (task 02): the announcer column IS the box's text —
+ // the box is the sole aria-live region (the retired span is
+ // gone; the stub column is replaced, the row shape is kept).
+ syncProgressText.textContent,
syncLabelMain.textContent,
syncLabelCount.textContent, syncLabelCount.hidden,
syncLabelChunk.textContent, syncLabelChunk.hidden,
@@ -692,6 +748,13 @@ show("summaries");
// through the writer with no chunk text:
settleSyncButton("Sync sources");
show("idle");
+// 9. Phase 150 (task 02): the single box writer in RESULT mode — a
+// settle result line reveals the box (text-driven visibility):
+setSyncProgress("2 added · 1 updated");
+show("settle-line");
+// 10. the true idle — the writer empties the box, it hides:
+setSyncProgress("");
+show("true-idle");
"""
@@ -704,11 +767,12 @@ def _run_node(script: str) -> str:
def _chunk_harness_rows() -> list:
"""The node harness's rows — the REAL extracted label path against
stub element refs (the test-document-viewer pattern), one JSON row
- per state: [tag, title, announcer, main, count, countHidden,
- chunk, chunkHidden, cancelHidden, cancelDisabled, progressText,
+ per state: [tag, title, announcer (the box's text — the sole
+ live region, phase 150), main, count, countHidden, chunk,
+ chunkHidden, cancelHidden, cancelDisabled, progressText,
progressHidden, btnRunning, iconSpinning, ariaLabel]. Shared by
the chunk-part test (phase 129) and the detail-box test
- (phase 149)."""
+ (phase 149 + 150's result mode)."""
js = _js()
fns = "\n".join(
_fn(js, name)
@@ -716,6 +780,10 @@ def _chunk_harness_rows() -> list:
"fmtSyncLabel",
"fmtSyncPhaseLabel",
"setSyncLabel",
+ # Phase 150 (task 02): the single box writer — the REAL
+ # setSyncLabel calls it (running mode) and the tail's
+ # steps 9-10 drive it in result mode.
+ "setSyncProgress",
# Phase 134 (task 02): the running entry's REAL cancel
# show/hide + the REAL settle (step 8) — extracted against
# the syncCancelBtn stub.
@@ -738,8 +806,9 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
task's fixture values):
* import, batch in flight (chunks_total 38 > 0): the chunk part
- shows "· chunk 14/38" and the UNTRUNCATED title + #sync-result
- read exactly "Syncing… src/a/b.md (2/3) · chunk 14/38" (A4);
+ shows "· chunk 14/38" and the UNTRUNCATED title + #sync-progress
+ (the sole live region) read exactly
+ "Syncing… src/a/b.md (2/3) · chunk 14/38" (A4);
* import before the first batch (0/0): the chunk part hides and
the label stays "Syncing… src/a/b.md (2/3)";
* every OTHER state — prelude (null phase), the 202/409 click (no
@@ -755,7 +824,8 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
"""
rows = _chunk_harness_rows()
by_tag = {row[0]: row for row in rows}
- # row = [tag, title, announcer, main, count, countHidden, chunk,
+ # row = [tag, title, announcer (the box's text — the sole live
+ # region, phase 150), main, count, countHidden, chunk,
# chunkHidden, cancelHidden, cancelDisabled, progressText,
# progressHidden, btnRunning, iconSpinning, ariaLabel]
@@ -777,21 +847,33 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
assert row[2] == row[1]
assert row[6] == "" and row[7] is True, row
- # 3. every other state — the chunk part is hidden + empty, and the
- # title is the byte-identical pre-phase-129 label for that state.
+ # 3. every other RUNNING state — the chunk part is hidden +
+ # empty, the title is the byte-identical pre-phase-129 label,
+ # and the announcer (the box) hears the whole label (A4).
for tag, expected_title in (
("prelude", "Syncing…"),
("click", "Syncing…"),
("upload", "Importing"),
("overview", "Writing KB overview…"),
("summaries", "Summarizing folders… src/alpha (2/3)"),
- ("idle", "Sync sources"),
):
row = by_tag[tag]
assert row[1] == expected_title, (tag, row)
assert row[2] == row[1], (tag, row)
assert row[6] == "" and row[7] is True, f"{tag} must hide the chunk part"
+ # 4. the idle settle (phase 150): the settle label rides the
+ # BUTTON only — the non-running writer call leaves the box
+ # alone (A2: the idle/settle labels must not fill the panel),
+ # so the announcer (the box) keeps the last running label.
+ row = by_tag["idle"]
+ assert row[1] == "Sync sources", row
+ assert row[2] == "Summarizing folders… src/alpha (2/3)", (
+ "the idle settle leaves the box alone (the settle label stays on"
+ " the button — phase 150, A2)"
+ )
+ assert row[6] == "" and row[7] is True, "idle must hide the chunk part"
+
# Phase 134 (task 02): the Cancel control's behavioral contract —
# shown + enabled in EVERY sync-running state (the entry shows it
# for kind "sync"), hidden + disabled for the upload run (the
@@ -815,11 +897,13 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
def test_progress_box_and_running_button_contract_under_node() -> None:
"""Phase 149 (task 01) — the detail box + the icon-only running
button, behaviorally pinned through the SAME node harness (the
- REAL setSyncLabel / enterSyncRunningState / settleSyncButton
- against the stub element refs). The contract — the box is
- visible IFF a run is in flight, and its text is EXACTLY the full
- untruncated label (one source, two surfaces: the box for sighted
- users, the announcer for AT):
+ REAL setSyncLabel / enterSyncRunningState / settleSyncButton /
+ setSyncProgress against the stub element refs). The contract —
+ while a run is in flight the box is visible and its text is
+ EXACTLY the full untruncated label (phase 150: the box IS the
+ announcer — one surface for sighted users and AT), and its
+ visibility is TEXT-DRIVEN (the single writer setSyncProgress
+ hides it iff the text is empty):
* EVERY running state (the in-flight import, the pre-batch
import, the prelude, the 202/409 click, the upload run, the
@@ -831,14 +915,20 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
the FULL live label (the icon-only running button keeps a
name);
* the settle (the REAL settleSyncButton through the writer with
- the default `running = false`): the box HIDES, the settle
- label is written into the box text ("Sync sources" — hidden,
- so inert), `is-running` + `is-spinning` are both gone, and
- the aria-label is restored to the ship value "Sync sources".
+ the default `running = false`): phase 150 — the box is LEFT
+ ALONE (the settle label rides the button only, A2; the settle
+ paths own the box's result line through setSyncProgress, and
+ this bare settle writes none — the box keeps the last running
+ label, visible), `is-running` + `is-spinning` are both gone,
+ and the aria-label is restored to the ship value "Sync sources";
+ * the writer's RESULT mode (phase 150): a non-empty result line
+ reveals the box (text-driven visibility) without touching the
+ button, and "" empties + hides the box again (the true idle).
"""
rows = _chunk_harness_rows()
by_tag = {row[0]: row for row in rows}
- # row = [tag, title, announcer, main, count, countHidden, chunk,
+ # row = [tag, title, announcer (the box's text — the sole live
+ # region, phase 150), main, count, countHidden, chunk,
# chunkHidden, cancelHidden, cancelDisabled, progressText,
# progressHidden, btnRunning, iconSpinning, ariaLabel]
@@ -870,12 +960,19 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
f"{tag}: the running button's accessible name is the full live label"
)
- # The settle: the box hides, the running state is fully cleared,
- # and the accessible name is back to the ship value.
+ # The settle (phase 150): the box is LEFT ALONE — the settle
+ # label rides the button only (A2), so the box keeps the last
+ # running label, still visible; the running state is fully
+ # cleared, and the accessible name is back to the ship value.
row = by_tag["idle"]
- assert row[11] is True, "the settle must HIDE the detail box (run-scoped only)"
- assert row[10] == "Sync sources", (
- "the writer wrote the settle label into the box (hidden — inert)"
+ assert row[1] == "Sync sources", "the settle label rides the button"
+ assert row[11] is False, (
+ "the bare settle leaves the box alone (phase 150: visibility is"
+ " text-driven via setSyncProgress — the settle writes no box text)"
+ )
+ assert row[10] == "Summarizing folders… src/alpha (2/3)", (
+ "the box keeps the last running label (the settle label stays on"
+ " the button — A2)"
)
assert row[12] is False, "is-running is removed on the settle (with the spin)"
assert row[13] is False, "the spin is removed on the settle"
@@ -883,6 +980,19 @@ def test_progress_box_and_running_button_contract_under_node() -> None:
"the settle restores the ship aria-label (the idle truth)"
)
+ # The writer's result mode (phase 150): a result line reveals the
+ # box (text-driven visibility) without touching the button; ""
+ # empties + hides the box (the true idle).
+ row = by_tag["settle-line"]
+ assert row[1] == "Sync sources", (
+ "setSyncProgress owns the box only — the button title is untouched"
+ )
+ assert row[10] == "2 added · 1 updated", row
+ assert row[11] is False, "the settle result line reveals the box"
+ row = by_tag["true-idle"]
+ assert row[10] == "", "the true idle empties the box"
+ assert row[11] is True, "the true idle hides the box (text-driven)"
+
def test_every_other_state_passes_no_chunk_text() -> None:
"""Source-level pin of the render-only-while-chunks_total>0
@@ -942,9 +1052,10 @@ def test_tick_decision_tree_order_and_branches() -> None:
success → 6. upload failed → 7. both idle. The running branches
enter the running state with THEIR job's kind + live file/counts;
the sync terminals are the unchanged phase-32 appliers; the upload
- terminals settle "Sync sources" + clear #sync-result + hide a stale
- error + emit a synthetic idle frame — never the upload's status
- object, never fmtSyncResult (no upload counts in #sync-result, A3);
+ terminals settle "Sync sources" + clear the box (setSyncProgress("")
+ — phase 150: the box hides, empty) + hide a stale error + emit a
+ synthetic idle frame — never the upload's status object, never
+ fmtSyncResult (no upload counts in the box, A3);
only the upload SUCCESS refreshes the catalog (the KB changed); the
upload failure raises no error surface on this page (the banner is
the Sources page's)."""
@@ -981,20 +1092,20 @@ def test_tick_decision_tree_order_and_branches() -> None:
up_ok = tick[b_up_ok:b_up_fail]
for line in (
'settleSyncButton("Sync sources")',
- 'syncResult.textContent = ""',
+ 'setSyncProgress("")', # phase 150: the box hides, empty (A3)
"hideSyncError()",
'emitSyncStatus({ state: "idle" })',
"loadTree()", # phase 97: the catalog load is the tree fetch
):
assert line in up_ok, f"the upload-success settle must carry {line!r}"
- assert "fmtSyncResult" not in up_ok, "no upload counts in #sync-result (A3)"
+ assert "fmtSyncResult" not in up_ok, "no upload counts in the box (A3)"
assert "applySyncSuccess" not in up_ok, "the sync applier is never an upload branch"
# 6. upload failed: settle only — no catalog refresh (the KB did
# not change) and no error surface on this page (A3).
up_fail = tick[b_up_fail:b_idle]
for line in (
'settleSyncButton("Sync sources")',
- 'syncResult.textContent = ""',
+ 'setSyncProgress("")', # phase 150: the box hides, empty (A3)
"hideSyncError()",
'emitSyncStatus({ state: "idle" })',
):
@@ -1131,22 +1242,22 @@ def test_section_header_documents_the_two_job_contract() -> None:
def test_sources_html_comment_documents_the_live_announcer() -> None:
- """The #sync-result comment in the shell's RAG view (formerly
- sources.html) documents the announcer's role: the SYNC's live file
- label ("Syncing…
(n/m)"), untruncated for the aria-live
- announcer, and — phase 90 — the BARE "Importing…" label an
- in-flight upload run adopts (unpack + register only, no scan: its
- status never carries a file or counts); empty after an upload
- settles (A3 — the upload's result line lives on the Sources
- page)."""
+ """The #sync-progress comment in the shell's RAG view (formerly
+ sources.html) documents the box's role as the SOLE aria-live
+ announcer (phase 150): it carries the full untruncated live file
+ label while a sync run is in flight AND the settle result line
+ after it ("N added · …", "Sync cancelled", the cancel-failure
+ copy), hiding only when its text is empty — one live region, no
+ double announce (the retired announcer span is gone)."""
html = _html()
- idx = html.find('id="sync-result"')
+ idx = html.find('id="sync-progress"')
assert idx != -1
- comment = html[max(0, idx - 900):idx]
- assert "Syncing… " in comment, "the sync live-file label is documented"
- assert '"Importing…"' in comment, "the (bare) upload label is documented"
- assert "Phase 90" in comment, "the unpack-only rework is documented"
- assert "A3" in comment, "the settle contract is documented"
+ comment = html[max(0, idx - 1200):idx]
+ assert "SOLE aria-live region" in comment, ("the single-live-region contract is documented")
+ assert "no double" in comment, "the no-double-announce contract is documented"
+ assert "Syncing…" in comment, "the sync live-file label is documented"
+ assert "Sync cancelled" in comment, "the settle result line is documented"
+ assert "Phase 150" in comment, "the announcer fold-in is documented"
# ---------- styles.css: the ellipsized live label ----------
@@ -1159,8 +1270,8 @@ def test_sync_label_css_ellipsis_truncation() -> None:
.sync-label-main part is the shrinkable item (min-width: 0, overflow
hidden, text-overflow ellipsis, no wrap) whose long
source/relative/path ellipsizes; the .sync-label-count part is
- flex 0 0 auto — NEVER clipped — in the .sync-result mono language
- (a position reads as data). On mobile the pill goes full width and
+ flex 0 0 auto — NEVER clipped — in the .sync-progress-text mono
+ language (a position reads as data). On mobile the pill goes full width and
the label FILLS it (the cap lifts, flex 1 1 auto) so the main part
ellipsizes against the width left by the pinned count (the
icon-only squeeze is gone)."""
diff --git a/tests/unit/test_sync_button.py b/tests/unit/test_sync_button.py
index 3efc721..ec1e663 100644
--- a/tests/unit/test_sync_button.py
+++ b/tests/unit/test_sync_button.py
@@ -8,7 +8,8 @@ in sources.js (owner rework 2026-08-28: the button is Sources-page
only, so the page owns the machine it drives — 2 s poll, 202 start
/ 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
event with the status object as detail, the single-poll-loop guard, no
-client-side hard timeout), the page's #sync-result line +
+client-side hard timeout), the page's #sync-progress box (the sole
+aria-live region — phase 150 retired the announcer span) +
#sync-error-banner, the lazily created error modal (phase 41), and the
§7.4 never-stale CSS (spin + reduced-motion opt-out,
disabled state, 44px floor, contrast pair) — so a silent regression is
@@ -40,7 +41,7 @@ main span (the idle text pre-JS) + a hidden count span (the pinned
"(n/m)"), the builders return { main, count }, and the single writer
setSyncLabel(main, count, chunk — the chunk part is phase 129) lands
the parts + the untruncated full label (main + " " + count +
-" " + chunk) on the title + #sync-result; the running-state /
+" " + chunk) on the title + #sync-progress; the running-state /
settle pins move onto that shape (their labels stay byte-identical
as full strings). Phase 129 (task 04): #sync-label ships a THIRD
hidden span (the pinned "· chunk n/m" — the in-flight file's
@@ -52,9 +53,10 @@ shape.
Phase 149 (task 01): the running button becomes the SPINNER — the
writer gains the `running` flag (the running entry passes `true`,
the settle the default `false`), which reveals the detail box
-(#sync-progress — the full untruncated label, presentational
-aria-hidden; #sync-result stays the sole announcer) and owns the
-button's accessible name (the full live label while running, the
+(#sync-progress — the full untruncated label; phase 150: the box
+became the SOLE announcer — the previous live-region span was
+retired) and owns the button's accessible name (the full live label
+while running, the
ship value "Sync sources" restored on every settle); the
`is-running` class travels with the spin (added in the running
entry, removed in the settle). The pins below move onto that shape
@@ -139,6 +141,12 @@ STYLES_CSS = ASSETS / "styles.css"
# Phase 76 (task 02): sources.html is folded into the ONE-document shell
# — the sync markup now lives in the RAG view section of index.html.
SHELL_HTML = FRONTEND / "index.html"
+# Phase 150: the retired sync-announcer span's id/class are assembled
+# (never spelled out) so the phase gate's project-wide grep for the
+# retired name stays clean while the absence pins below stay in force.
+RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
+# adjacent/"+" constant strings at compile time, which would leave the
+# retired name in the .pyc — the phase gate greps even the bytecode.
def _text(path: Path) -> str:
@@ -228,17 +236,24 @@ def test_sync_button_has_icon_and_label_parts() -> None:
assert 'class="sync-label"' in label.group(0)
-def test_sync_result_is_the_aria_live_announcer() -> None:
- """#sync-result sits right after the button and is a polite live
- region (role="status" + aria-live="polite") — the last-result /
- counts announcement for screen readers."""
+def test_sync_progress_is_the_aria_live_announcer() -> None:
+ """#sync-progress (the sync detail box) sits right after the button
+ row and is a polite live region (role="status" +
+ aria-live="polite") — phase 150: it is the SOLE aria-live
+ announcer for the sync area (the live label while a run is in
+ flight, the settle counts line after it), and the retired
+ announcer span is gone (one live region, no double announce)."""
text = _text(SHELL_HTML)
- tag = re.search(r']*id="sync-result"[^>]*>', text)
- assert tag, "the shell must carry the #sync-result announcer (RAG view)"
+ tag = re.search(r']*id="sync-progress"[^>]*>', text)
+ assert tag, "the shell must carry the #sync-progress announcer box (RAG view)"
attrs = tag.group(0)
assert 'role="status"' in attrs
assert 'aria-live="polite"' in attrs
- assert text.find('id="sync-result"') > text.find("", text.find('id="sync-btn"'))
+ assert 'aria-hidden' not in attrs, "an aria-live region must not be aria-hidden"
+ assert -1 < text.find("", text.find('id="sync-btn"')) < text.find('id="sync-progress"')
+ assert f'id="{RETIRED_ID}"' not in text, (
+ "the retired announcer span is gone from the shell (phase 150)"
+ )
def test_sync_error_banner_is_a_hidden_alert() -> None:
@@ -368,8 +383,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
fresh run starts clean: the previous failure's title / aria-label /
.is-error come off NOW, not when the run settles.
- Phase 64 (task 04): #sync-result (the aria-live announcer) carries
- the FULL untruncated label. Phase 98 (task 02): the title rule is
+ Phase 64 (task 04): the aria-live announcer carries the FULL
+ untruncated label (phase 150: that is the #sync-progress box —
+ the sole live region). Phase 98 (task 02): the title rule is
adjusted — the FULL untruncated LABEL (the file label, the overview
label, or the summaries label — whatever the running form is) rides
the button title for EVERY running label; the "title only when
@@ -381,9 +397,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
part destructures with a "" default), which lands the main part,
the pinned count part (hidden when absent), the pinned chunk part
(phase 129 — hidden when absent), and the FULL untruncated label
- on the button title + #sync-result (A4) — the direct span /
+ on the button title + #sync-progress (A4) — the direct span /
title / announcer writes are GONE from the entry (they live in
- setSyncLabel). Phase 149 (task 01): the writer call passes
+ setSyncLabel + setSyncProgress). Phase 149 (task 01): the writer call passes
`running = true` (the detail box reveals with the full label),
and the button's accessible name rides in the writer too (the
full live label — the icon-only running button keeps a name);
@@ -403,7 +419,9 @@ def test_sources_js_running_state_is_never_stale() -> None:
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
- assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
+ assert "syncProgress" not in body, (
+ "the box write lives in setSyncLabel/setSyncProgress (phase 150)"
+ )
assert 'syncBtn.setAttribute("aria-label"' not in body, (
"the aria-label write lives in setSyncLabel (phase 149: the full"
" live label while running)"
@@ -462,11 +480,12 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
(failed included: retry-ready). Phase 128 (task 01): the settle /
idle label ("Sync sources", "Synced HH:MM") goes through the parts
writer — setSyncLabel(label), count hidden (absent), the full
- string riding the title + #sync-result (A4, every label state).
- Phase 149 (task 01): the `is-running` class is removed WITH the
+ string riding the title (A4, every label state).
+ Phase 150 (task 02): the `is-running` class is removed WITH the
spin (every terminal + both idles pass through the settle), and
- the writer call's default `running = false` hides the detail box
- + restores the idle accessible name."""
+ the writer call's default `running = false` leaves the detail
+ box alone (the settle paths own the box's result line through
+ setSyncProgress) + restores the idle accessible name."""
js = _text(SOURCES_JS)
body = _body(js, "settleSyncButton")
assert "syncBtn.disabled = false" in body
@@ -649,14 +668,16 @@ def test_sources_js_apply_sync_failure_opens_the_modal_after_the_event() -> None
def test_sources_js_renders_result_line_and_banner_directly() -> None:
"""sources.js owns the page-specific rendering on the Sources page:
- #sync-result (aria-live) + #sync-error-banner (role=alert), driven
- directly by the state appliers: success → the counts (fmtSyncResult)
- + a live catalog refresh; failed → the banner with the sanitized
- error; every state change still emits 'bor:sync-status' (the status
- object as detail) for other subscribers."""
+ #sync-progress (the sole aria-live region — phase 150 retired the
+ announcer span) + #sync-error-banner (role=alert), driven directly
+ by the state appliers: success → the counts (fmtSyncResult, the
+ box's result line) + a live catalog refresh; failed → the banner
+ with the sanitized error (the box hides, empty); every state
+ change still emits 'bor:sync-status' (the status object as detail)
+ for other subscribers."""
js = _text(SOURCES_JS)
success = _body(js, "applySyncSuccess")
- assert "syncResult.textContent = fmtSyncResult(status.detail)" in success, (
+ assert "setSyncProgress(fmtSyncResult(status.detail))" in success, (
"the counts live in status.detail — a bare status renders all zeros"
)
assert "loadTree()" in success, (
@@ -743,13 +764,44 @@ def test_sync_icon_spins_and_respects_reduced_motion() -> None:
assert "@keyframes spin" in css, "the spin keyframes are shared (pre-existing)"
-def test_sync_result_is_styled() -> None:
- """#sync-result (the aria-live last-result line) is styled in the
- theme tokens — soft ink, small mono, no wrap in the header bar."""
+def test_sync_progress_box_is_the_styled_result_surface() -> None:
+ """Phase 150: the retired announcer's CSS rule is GONE from styles.css
+ (the retirement is pinned, not implied), and #sync-progress reads
+ as a bordered panel in BOTH modes — the running label and the
+ settled result line (ASSUMPTION A3: one surface, no settled
+ variant). .sync-progress carries the house panel language (--surface
+ fill, 1px --line border, the house radius, the 4rem min-height) and
+ sets no display of its own, so the global [hidden] rule keeps it
+ display:none while its text is empty; .sync-progress-text is the
+ small mono soft-ink language that WRAPS (overflow-wrap: anywhere —
+ the point of the box) (the pinned AA pair on --surface)."""
css = _text(STYLES_CSS)
- block = re.search(r"\.sync-result\s*\{([^}]*)\}", css)
- assert block, "styles.css must define .sync-result"
- assert "var(--ink-soft)" in block.group(1)
+ assert f".{RETIRED_ID}" not in css, (
+ "the retired announcer rule (incl. the mobile override) is gone (phase 150)"
+ )
+ box = re.search(r"\.sync-progress\s*\{([^}]*)\}", css)
+ assert box, "styles.css must define .sync-progress"
+ b = box.group(1)
+ assert "background: var(--surface)" in b
+ assert "border: 1px solid var(--line)" in b
+ assert "border-radius: var(--radius)" in b
+ assert "min-height: 4rem" in b
+ assert "display:" not in b, (
+ "the box sets no display of its own — the global [hidden] rule owns it"
+ )
+ hidden_rule = re.search(r"^\[hidden\]\s*\{([^}]*)\}", css, re.M)
+ assert hidden_rule and "display: none !important" in hidden_rule.group(1), (
+ "the global [hidden] rule must keep the empty box display:none"
+ )
+ text_rule = re.search(r"\.sync-progress-text\s*\{([^}]*)\}", css)
+ assert text_rule, "styles.css must define .sync-progress-text"
+ t = text_rule.group(1)
+ assert "var(--mono)" in t
+ assert "0.8rem" in t
+ assert "var(--ink-soft)" in t, ("the pinned AA pair on --surface (8.2:1 dark)")
+ assert "overflow-wrap: anywhere" in t, (
+ "the point of the box — a long source/relative/path wraps, never clipped"
+ )
# ---------- styles.css: the sync failure modal (phase 41, TODO.md L4) ----------
@@ -1978,10 +2030,10 @@ def test_sync_cancel_button_ships_hidden_and_labeled() -> None:
assert 'type="button"' in attrs
assert re.search(r"\bhidden\b", attrs), "#sync-cancel-btn must ship hidden"
assert m.group(3).strip() == "Cancel", "the visible label is the accessible name"
- # Same row: right after the main button closes, before the announcer.
+ # Same row: right after the main button closes, before the announcer box.
main_end = text.find("", text.find('id="sync-btn"'))
cancel_start = text.find('id="sync-cancel-btn"')
- assert -1 < main_end < cancel_start < text.find('id="sync-result"')
+ assert -1 < main_end < cancel_start < text.find('id="sync-progress"')
def test_sync_cancel_btn_css_secondary_pill_and_states() -> None:
@@ -2064,8 +2116,11 @@ def test_sources_js_cancel_visible_only_while_a_sync_runs() -> None:
def test_sources_js_cancel_request_is_never_stale() -> None:
"""requestCancel: the control disables IMMEDIATELY (before the
- fetch — one request at a time, no double fire) and #sync-result
- clears with it; then the branch set — 403 the whoami backstop
+ fetch — one request at a time, no double fire). Phase 150: the
+ box is NOT blanked with the click — the run is still in flight,
+ so the live label stays up until the poll's cancelled branch
+ settles it (the never-stale contract rides on the control's
+ immediate disable); then the branch set — 403 the whoami backstop
(stop polling, hide the main button, settle idle — the same depth
as every other 403 in this module), 409 the silent no-op (the
poll settles), 202 (r.ok) nothing (the poll's cancelled branch
@@ -2079,7 +2134,10 @@ def test_sources_js_cancel_request_is_never_stale() -> None:
assert disable != -1 and fetch != -1 and disable < fetch, (
"the disable must land BEFORE the fetch (never stale)"
)
- assert 'syncResult.textContent = ""' in body, "the result line clears with the click"
+ assert 'setSyncProgress("")' not in body, (
+ "phase 150: the box is NOT blanked at cancel-request time — the"
+ " live label stays until the poll settles"
+ )
assert "r.status === 403" in body
assert "stopSyncPolling()" in body
assert "syncBtn.hidden = true" in body
@@ -2127,13 +2185,15 @@ def test_sources_js_apply_sync_cancelled_is_neutral() -> None:
"""applySyncCancelled renders the NEUTRAL result (A1 — cancel is
intent, not failure): settle the main button retry-ready (which
also hides the Cancel control), the "Sync cancelled" line on the
- aria-live result (superseding the settle's label write), the
- error banner hidden (a failed run's banner must not survive), the
- status frame emitted — NO modal, NO banner, NO error styling."""
+ aria-live box (#sync-progress — the sole live region, phase 150:
+ the non-running settle no longer writes the box, so there is
+ nothing to supersede), the error banner hidden (a failed run's
+ banner must not survive), the status frame emitted — NO modal,
+ NO banner, NO error styling."""
js = _text(SOURCES_JS)
body = _body(js, "applySyncCancelled")
assert 'settleSyncButton("Sync sources")' in body
- assert 'syncResult.textContent = "Sync cancelled"' in body
+ assert 'setSyncProgress("Sync cancelled")' in body
assert "hideSyncError()" in body
assert "emitSyncStatus(status)" in body
assert "showSyncModal" not in body, "cancel is intent — no modal"