phase: 150_drop_sync_result
**Phase 150 final verification pass — complete, 100% green.** All 6 task files were already in `complete/`, so this was verification + doc pass. - Verified: `#sync-progress` is the sole sync text surface + sole `aria-live` region (`role="status"`, polite, no `aria-hidden`); `span#sync-result` gone from HTML/JS/CSS/tests (incl. runtime-built `RETIRED_ID` absence pins in the unit suites); `setSyncProgress` single writer with text-driven visibility; running-only box write in `setSyncLabel`; all 9 settle/cancel sites routed; `requestCancel` pre-fetch clear is a NO-OP. - Tests: `uv run pytest` → **3463 passed**; `uv run pytest --cov=app` → **99% on app/** (>90%); `uv run ruff check .` + `uv run pyright` → clean (0 errors); `tests/e2e/test_sync_progress_box.py --no-cov` → **8 passed in isolation**; all 18 migrated sync/image-PDF E2E files green in isolation (77 tests). - Completion criteria: 1) grep clean ✅ 2) sole live region, attrs correct ✅ 3) in-flight/settle/cancel/idle box states proven by E2E + unit pins ✅ 4) icon-only running button + restore ✅ 5) full gates green ✅ 6) no completed-phase behavior change (18 family suites + full suite green) ✅ 7) commit — left to harness per pass instructions (working tree untouched, no `git add`/`commit`). - Documentation: README.md fixed 2 pre-existing inconsistencies (stale `~/Homelab`+`~/Deployments` source model in the intro — retired in phase 61 — now describes the git/local/archive Sources model; "Requires AIPI_KEY" in retrieval-quality now states `BOR_LLM_API_KEY` in `.env` or `$AIPI_KEY`, matching the scripts' real convention). Everything else (URL table, env defaults vs `app/config.py`, sync/phase-150 copy, prod claims) verified accurate. AGENTS.md: verified consistent — no changes needed. - Notable: no code changes were required — the phase implementation was correct as left; no live infrastructure touched. - Next pending phase: `151_pdf_image_extraction` in `.agents/phases/todo/`.
This commit is contained in:
+102
-42
@@ -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'<span[^>]*id="sync-result"[^>]*>', text)
|
||||
assert tag, "the shell must carry the #sync-result announcer (RAG view)"
|
||||
tag = re.search(r'<div[^>]*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("</button>", text.find('id="sync-btn"'))
|
||||
assert 'aria-hidden' not in attrs, "an aria-live region must not be aria-hidden"
|
||||
assert -1 < text.find("</button>", 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("</button>", 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"
|
||||
|
||||
Reference in New Issue
Block a user