show sync button for admin
Build and Push Containers / build-and-push-app (push) Successful in 1m38s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-08-28 23:24:23 -04:00
parent 3a404eb161
commit 6bf7f456d4
17 changed files with 368 additions and 284 deletions
+8 -5
View File
@@ -30,9 +30,11 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js";
* *
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync * The §7.4 never-stale lifecycle: idle → click → POST /api/sync
* → running (2 s poll of GET /api/sync/status) → success | failed. * → running (2 s poll of GET /api/sync/status) → success | failed.
* Admin-only: the button is hidden for anonymous users (initSharedHeader * Admin-only: the button SHIPS hidden and the page boot below reveals it
* hides it). A failed run opens an error modal (same as the former * for the admin on the SAME cached whoami initSharedHeader() used (no
* header.js module — recreated here since the navbar button is gone). * extra fetch); the 403 branches stay as defense in depth. A failed run
* opens an error modal (same as the former header.js module — recreated
* here since the navbar button is gone).
* *
* Elements: #sync-btn (the button), #sync-label (the text), * Elements: #sync-btn (the button), #sync-label (the text),
* #sync-icon (the spinner icon), #sync-result (aria-live result * #sync-icon (the spinner icon), #sync-result (aria-live result
@@ -165,7 +167,7 @@ function closeSyncModal() {
function applySyncSuccess(status) { function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at); const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced"); settleSyncButton(time ? `Synced ${time}` : "Synced");
if (syncResult) syncResult.textContent = fmtSyncResult(status); if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError(); hideSyncError();
emitSyncStatus(status); emitSyncStatus(status);
// Refresh the catalog live — the KB just changed. // Refresh the catalog live — the KB just changed.
@@ -411,7 +413,8 @@ function showEmpty() {
(async () => { (async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar const admin = await initSharedHeader(); // phase 19: shared bar (cached whoami)
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
if (!(await isAdmin())) { if (!(await isAdmin())) {
// Anonymous: gate in, catalog out, and no /api/docs request at all. // Anonymous: gate in, catalog out, and no /api/docs request at all.
if (statCards) statCards.hidden = true; if (statCards) statCards.hidden = true;
+2 -2
View File
@@ -47,7 +47,7 @@ Test → story mapping (Playwright Mapping Rule):
::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``, ::before``/``::after`` spots): ``position: fixed``, ``z-index: -1``,
``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind ``pointer-events: none``, ``inset: 0`` (UI Structure Check: behind
content, click-through, full-viewport); the page canvas stays on content, click-through, full-viewport); the page canvas stays on
``<html>`` (``rgb(10, 14, 23)`` = ``var(--bg)``) and ``<body>`` ``<html>`` (``rgb(15, 10, 10)`` = ``var(--bg)``) and ``<body>``
stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion stays transparent (``rgba(0, 0, 0, 0)``) — the no-occlusion
contract. contract.
5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport 5. ``test_no_horizontal_overflow_with_layers`` — at a 360px viewport
@@ -71,7 +71,7 @@ from playwright.sync_api import Browser, Page
GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere GRID_OLD = "bg-grid-drift" # deleted in phase 25 — must not exist anywhere
GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades GLOWS = ("bg-glow-a", "bg-glow-b", "bg-glow-c") # the three phase-25 spot fades
PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette) PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background # Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test). # contract (single evaluate — one round-trip per test).
+2 -2
View File
@@ -67,7 +67,7 @@ Test → story mapping (Playwright Mapping Rule):
6. ``test_background_layers_contracts`` — all four pseudo-layers: 6. ``test_background_layers_contracts`` — all four pseudo-layers:
``position: fixed``, ``z-index: -1``, ``pointer-events: none``, ``position: fixed``, ``z-index: -1``, ``pointer-events: none``,
top/right/bottom/left all ``0px``; the ``documentElement`` computed top/right/bottom/left all ``0px``; the ``documentElement`` computed
background is ``rgb(10, 14, 23)`` (``var(--bg)`` — the canvas stays background is ``rgb(15, 10, 10)`` (``var(--bg)`` — the canvas stays
on ``html``); ``document.body`` computed background is on ``html``); ``document.body`` computed background is
``rgba(0, 0, 0, 0)`` (no occlusion). ``rgba(0, 0, 0, 0)`` (no occlusion).
7. ``test_reduced_motion_stills_all_layers`` — 7. ``test_reduced_motion_stills_all_layers`` —
@@ -99,7 +99,7 @@ GLOW_B = "bg-glow-b" # html::before — phase-08 cyan spot, 34s, -12s delay
GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay GLOW_C = "bg-glow-c" # html::after — third indigo spot, 42s, -23s delay
GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names GLOWS = (GLOW_A, GLOW_B, GLOW_C) # the three spot keyframe names
EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out EASE_IN_OUT = "ease-in-out" # computed animationTimingFunction for ease-in-out
PAGE_BG = "rgb(10, 14, 23)" # var(--bg) — the <html> canvas (phase-08 palette) PAGE_BG = "rgb(15, 10, 10)" # var(--bg) — the <html> canvas (dark-red rebrand #0f0a0a)
# Computed styles of all four pseudo-layers + the html/body background # Computed styles of all four pseudo-layers + the html/body background
# contract (single evaluate — one round-trip per test). # contract (single evaluate — one round-trip per test).
+5 -5
View File
@@ -272,13 +272,13 @@ def test_persists_across_page_navigation(
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected _ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources (phase 16: the catalog is admin-only — the trip # A trip to Sources (phase 16: the catalog is admin-only — the trip
# starts with a real form login). Phase 19 (owner permission # starts with a real form login). The New chat button is chat-page
# 2026-08-23): the New chat control is part of the SHARED bar, so it # only (owner rework 2026-08-28 — it left the shared bar), so the
# is present on the sources page too — only clicking it would clear # sources page carries none of it (pinned in
# the conversation (pinned in tests/e2e/test_shared_header.py). # tests/e2e/test_shared_header.py).
login(page, app_url, next="/sources.html") login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000) expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_be_visible() expect(page.locator("#new-chat-btn")).to_have_count(0)
# Back to the chat: the conversation is exactly as left — both turns, # Back to the chat: the conversation is exactly as left — both turns,
# the source chip, and the amber deflected bubble with its chips. # the source chip, and the amber deflected bubble with its chips.
+6 -4
View File
@@ -173,13 +173,15 @@ def test_dark_palette_and_contrast(
>= 4.5:1 from live computed styles (not eyeballed).""" >= 4.5:1 from live computed styles (not eyeballed)."""
for path in ("/", "/sources.html"): for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}") page.goto(f"{app_url}{path}")
# The visible page background is the <html> canvas (rgb(10, 14, 23) # The visible page background is the <html> canvas (rgb(15, 10, 10)
# = #0a0e17). <body> itself stays transparent so the z-index:-1 # = #0f0a0a — the 2026-08-28 dark-red rebrand; it was the phase-08
# grid/glow layers (test_animated_background) are not painted over. # #0a0e17 canvas). <body> itself stays transparent so the
# z-index:-1 grid/glow layers (test_animated_background) are not
# painted over.
bg = page.evaluate( bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor" "() => getComputedStyle(document.documentElement).backgroundColor"
) )
assert bg == "rgb(10, 14, 23)", f"expected the dark page bg on {path}, got {bg}" assert bg == "rgb(15, 10, 10)", f"expected the dark page bg on {path}, got {bg}"
body_bg = page.evaluate("() => getComputedStyle(document.body).backgroundColor") body_bg = page.evaluate("() => getComputedStyle(document.body).backgroundColor")
assert body_bg == "rgba(0, 0, 0, 0)", ( assert body_bg == "rgba(0, 0, 0, 0)", (
f"{path}: body must stay transparent (the background layers need to show)" f"{path}: body must stay transparent (the background layers need to show)"
+6 -5
View File
@@ -370,7 +370,7 @@ def test_standalone_page_still_works(
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md") page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty() expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)" assert bg == "rgb(15, 10, 10)", "the dark-red rebrand canvas (#0f0a0a)"
refs = page.evaluate( refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")] """() => [...document.querySelectorAll("script[src], link[href]")]
@@ -409,14 +409,15 @@ def test_modal_theme_and_no_cdn(
expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster") expect(page.locator("#doc-modal-title")).to_have_text("Kubernetes Homelab Cluster")
expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty() expect(page.locator("#doc-modal-content .doc-md")).not_to_be_empty()
# Dark theme (phase 08): the page background is untouched, and the # Dark theme (phase 08, dark-red rebrand 2026-08-28): the page
# modal panel sits on the Phase-08 surface colour (#121a2e). # background is untouched, and the modal panel sits on the --surface
# colour (#1a0f0f).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)" assert bg == "rgb(15, 10, 10)"
surface = page.evaluate( surface = page.evaluate(
"() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor" "() => getComputedStyle(document.querySelector('.doc-modal-panel')).backgroundColor"
) )
assert surface == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {surface}" assert surface == "rgb(26, 15, 15)", f"panel not on the --surface colour: {surface}"
# No-CDN: every script/link reference on the chat page (the touched # No-CDN: every script/link reference on the chat page (the touched
# page) is same-origin or a data: URI — the modal adds no assets. # page) is same-origin or a data: URI — the modal adds no assets.
+4 -3
View File
@@ -416,12 +416,13 @@ def test_tuning_page_a11y_and_no_cdn(
assert announcer.get_attribute("role") == "status" assert announcer.get_attribute("role") == "status"
assert announcer.get_attribute("aria-live") == "polite" assert announcer.get_attribute("aria-live") == "polite"
# ≥44px touch targets on every interactive control. # ≥44px touch targets on every interactive control (the New chat
# button is chat-page only — owner rework 2026-08-28 — so it is not
# a tuning-page target).
for selector in ( for selector in (
"#tune-save", "#tune-save",
"#tune-note", "#tune-note",
"#nav-tuning", "#nav-tuning",
".new-chat-btn",
"#sign-out-btn", "#sign-out-btn",
".tuning-edit", ".tuning-edit",
".tuning-delete", ".tuning-delete",
@@ -447,7 +448,7 @@ def test_tuning_page_a11y_and_no_cdn(
# Dark theme: the page canvas is the phase-08 dark bg (body stays # Dark theme: the page canvas is the phase-08 dark bg (body stays
# transparent for the background layers). # transparent for the background layers).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor") bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)", f"expected the dark page bg, got {bg}" assert bg == "rgb(15, 10, 10)", f"expected the dark page bg (rebrand #0f0a0a), got {bg}"
# Contrast: the live text/background pairs compute ≥ 4.5:1 (AA). # Contrast: the live text/background pairs compute ≥ 4.5:1 (AA).
pairs = page.evaluate( pairs = page.evaluate(
+3 -2
View File
@@ -114,8 +114,9 @@ def test_off_topic_question_deflects_honestly(
expect(group.first).to_have_attribute("role", "list") expect(group.first).to_have_attribute("role", "list")
# Chip component contract: brand pill, ≥44px touch target. # Chip component contract: brand pill, ≥44px touch target.
chip_style = chips.first.evaluate("el => getComputedStyle(el)") chip_style = chips.first.evaluate("el => getComputedStyle(el)")
assert chip_style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme) # --brand-soft #2d0a0a / --brand-ink #fca5a5 (dark-red rebrand)
assert chip_style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc assert chip_style["backgroundColor"] == "rgb(45, 10, 10)"
assert chip_style["color"] == "rgb(252, 165, 165)"
box = chips.first.bounding_box() box = chips.first.bounding_box()
assert box is not None and box["height"] >= 44 assert box is not None and box["height"] >= 44
+8 -10
View File
@@ -328,8 +328,9 @@ def test_completed_turn_unaffected(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. The DELIBERATE clear is untouched: New Chat from the sources page # 4. The DELIBERATE clear is untouched: New chat (chat-page only since
# still clears the conversation (phase 14/19 contract) # the owner rework 2026-08-28) still clears the conversation
# (phase 14 contract)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -341,18 +342,15 @@ def test_new_chat_still_clears_conversation(
_ask(page, QUESTION) _ask(page, QUESTION)
assert _stored(page) is not None assert _stored(page) is not None
# To the sources page (anonymous is fine — the shared bar carries the # The New chat button is chat-page only (it left the shared bar at
# New Chat control regardless of auth, phase 19). # owner request, 2026-08-28) — the deliberate clear runs right here.
page.goto(app_url + "/sources.html")
new_chat = page.locator("#new-chat-btn") new_chat = page.locator("#new-chat-btn")
expect(new_chat).to_be_visible() expect(new_chat).to_be_visible()
new_chat.click() new_chat.click()
# "New chat" on a non-chat page means "go to the chat, fresh": the # The chat lands on its empty state and the conversation key is
# land page shows the empty state and the conversation key is GONE — # GONE — the deliberate clear is unaffected by the phase-20 pagehide
# the deliberate clearChatStorage() is unaffected by the phase-20 # save point.
# pagehide save point.
expect(page).to_have_url(app_url + "/")
expect(page.locator("#empty-state")).to_be_visible() expect(page.locator("#empty-state")).to_be_visible()
expect(page.locator(".msg")).to_have_count(0) expect(page.locator(".msg")).to_have_count(0)
assert _stored(page) is None, "New Chat must clear the localStorage key" assert _stored(page) is None, "New Chat must clear the localStorage key"
+2 -2
View File
@@ -108,8 +108,8 @@ def test_onboarding_chips_render(
expect(page.locator("#empty-state")).to_be_visible() expect(page.locator("#empty-state")).to_be_visible()
# Chip component contract: brand pill, >=44px touch target. # Chip component contract: brand pill, >=44px touch target.
style = chips.first.evaluate("el => getComputedStyle(el)") style = chips.first.evaluate("el => getComputedStyle(el)")
assert style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme) assert style["backgroundColor"] == "rgb(45, 10, 10)" # --brand-soft #2d0a0a (dark-red rebrand)
assert style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc assert style["color"] == "rgb(252, 165, 165)" # --brand-ink #fca5a5 (dark-red rebrand)
assert style["borderRadius"] == "999px" assert style["borderRadius"] == "999px"
box = chips.first.bounding_box() box = chips.first.bounding_box()
assert box is not None and box["height"] >= 44 assert box is not None and box["height"] >= 44
+5 -4
View File
@@ -181,15 +181,16 @@ def test_full_page_shows_summary_and_original_together(
assert order[0] == "doc-summary", f"panel not first: {order}" assert order[0] == "doc-summary", f"panel not first: {order}"
assert "doc-raw" in order and order.index("doc-raw") > order.index("doc-summary") assert "doc-raw" in order and order.index("doc-raw") > order.index("doc-summary")
# Theme (phase 08): panel on the surface colour, title in brand-ink # Theme (phase 08, dark-red rebrand 2026-08-28): panel on the
# (#a5b4fc on #121a2e ≈8.7:1 ≥4.5:1), and the panel matches the # surface colour, title in brand-ink (--brand-ink #fca5a5 on
# --surface #1a0f0f = 9.0:1 ≥4.5:1), and the panel matches the
# raw-content width — it does not break the content layout. # raw-content width — it does not break the content layout.
bg = panel.evaluate("el => getComputedStyle(el).backgroundColor") bg = panel.evaluate("el => getComputedStyle(el).backgroundColor")
assert bg == "rgb(18, 26, 46)", f"panel not on the Phase-08 surface: {bg}" assert bg == "rgb(26, 15, 15)", f"panel not on the --surface colour (rebrand #1a0f0f): {bg}"
title_color = panel.locator(".doc-summary-title").evaluate( title_color = panel.locator(".doc-summary-title").evaluate(
"el => getComputedStyle(el).color" "el => getComputedStyle(el).color"
) )
assert title_color == "rgb(165, 180, 252)", f"title not brand-ink: {title_color}" assert title_color == "rgb(252, 165, 165)", f"title not brand-ink: {title_color}"
panel_box = panel.bounding_box() panel_box = panel.bounding_box()
pre_box = pre.bounding_box() pre_box = pre.bounding_box()
assert panel_box is not None and pre_box is not None assert panel_box is not None and pre_box is not None
+5 -4
View File
@@ -376,10 +376,11 @@ def test_sync_error_surfaces_unaffected(
app) the ``#sync-error-banner`` still renders the error off app) the ``#sync-error-banner`` still renders the error off
``bor:sync-status`` — the phase-32/34 contract the modal is ``bor:sync-status`` — the phase-32/34 contract the modal is
additive to.""" additive to."""
# Trigger the failure on the chat page (the modal is module-owned — # Trigger the failure on the Sources page (the button's home since
# it follows the button onto every page). The re-attach modal from # the owner rework 2026-08-28; the modal is page-local there). The
# the prior test's last failure closes first (clean trigger state). # re-attach modal from the prior test's last failure closes first
login(page, dead_app_url, next="/") # (clean trigger state).
login(page, dead_app_url)
_dismiss_reattached_modal(page) _dismiss_reattached_modal(page)
_trigger_dead_failure(page, dead_app_url) _trigger_dead_failure(page, dead_app_url)
+29 -21
View File
@@ -14,12 +14,15 @@ The new design (styles.css, pure CSS, zero JS, no `filter` — A11):
- grid (body::before): a STATIC texture — the drift animation and its - grid (body::before): a STATIC texture — the drift animation and its
keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a keyframes are deleted (the 0.73px/s sub-pixel drift rasterizes as a
once-per-second down-right jitter); once-per-second down-right jitter);
- three independent soft glow spots — body::after (phase-08 indigo, - three independent soft glow spots — body::after (26s), html::before
26s), html::before (phase-08 cyan, 34s, -12s delay), html::after (34s, -12s delay), html::after (42s, -23s delay) — each on its own
(third indigo, 42s, -23s delay) — each on its own SLOW opacity-only SLOW opacity-only fade (the whole-layer breathe keyframes are
fade (the whole-layer breathe keyframes are deleted), so the total deleted), so the total light fluxuates smoothly and irregularly;
light fluxuates smoothly and irregularly; LCM(26, 34, 42) = 4641s, LCM(26, 34, 42) = 4641s, so the composite pattern never repeats
so the composite pattern never repeats within a viewing session. within a viewing session. The 2026-08-28 rebrand recolored the
phase-08 indigo/cyan spots to the warm dark-red theme palette
(rose / orange / red) and the grid lines to the warm line tone —
structure (sizes, positions, alphas, periods) unchanged.
This file keeps the generic layer-plumbing pins (the no-occlusion This file keeps the generic layer-plumbing pins (the no-occlusion
contract, fixed / z-index -1 / pointer-events none — now across all contract, fixed / z-index -1 / pointer-events none — now across all
@@ -153,17 +156,20 @@ def test_grid_is_static_no_drift() -> None:
def test_grid_cells_and_line_contrast() -> None: def test_grid_cells_and_line_contrast() -> None:
"""44px cells with 1px lines at the phase-22 fixed 60% --line alpha """44px cells with 1px lines at the phase-22 fixed 60% line alpha,
(the static texture keeps the values that made the grid readable — in the rebrand warm line tone (2026-08-28; the phase-22 indigo
see tests/unit/test_background_no_motion.py for the phase-25 story).""" value rgb(38 48 74 / 0.6) was recolored with the dark-red theme)
— the static texture keeps the values that made the grid readable
(see tests/unit/test_background_no_motion.py for the phase-25
story)."""
block = _grid_rule(_css()) block = _grid_rule(_css())
assert "background-size: 44px 44px" in block assert "background-size: 44px 44px" in block
line = "linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)" line = "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
assert line in block, "grid must keep horizontal 1px lines at 60% --line" assert line in block, "grid must keep horizontal 1px lines at 60% line alpha"
assert ( assert (
"linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px)" "linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)"
in block in block
), "grid must keep vertical 1px lines at 60% --line" ), "grid must keep vertical 1px lines at 60% line alpha"
assert "0.35" not in block, "the too-faint 35% line alpha must not return" assert "0.35" not in block, "the too-faint 35% line alpha must not return"
@@ -216,21 +222,23 @@ def test_glow_keyframes_are_opacity_only() -> None:
) )
def test_glow_spots_keep_phase08_colors_and_add_a_third() -> None: def test_glow_spots_use_the_rebrand_warm_palette() -> None:
"""The phase-08 spots keep their colors, radii and positions — the """The three spots keep their radii and positions from the phase-25
indigo top-left stays on body::after, the cyan bottom-right moves to layout (rose top-left on body::after, orange bottom-right on
html::before — and a third soft indigo spot (52rem at 14% 86%) html::before, red bottom-left 52rem at 14% 86% on html::after) but
joins on html::after. All spots fade to transparent at 62%.""" wear the 2026-08-28 rebrand palette (warm dark-red theme; the
phase-08 indigo/cyan values are gone). All spots fade to
transparent at 62%."""
assert ( assert (
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), " "radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), "
"transparent 62%)" in _glow_rule(_css()) "transparent 62%)" in _glow_rule(_css())
) )
assert ( assert (
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), " "radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::before") "transparent 62%)" in _rule_block(_css(), "html::before")
) )
assert ( assert (
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), " "radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), "
"transparent 62%)" in _rule_block(_css(), "html::after") "transparent 62%)" in _rule_block(_css(), "html::after")
) )
+18 -13
View File
@@ -34,13 +34,16 @@ from tests.unit.test_background_animation import _css, _css_no_comments, _rule_b
ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after") ALL_LAYERS = ("body::before", "body::after", "html::before", "html::after")
# (layer, keyframes name, duration shorthand, single-spot gradient) # (layer, keyframes name, duration shorthand, single-spot gradient)
# The 2026-08-28 rebrand recolored the phase-08 indigo/cyan spots to the
# warm dark-red theme palette (rose / orange / red) — structure
# (radius, position, alpha, period, delay) is the phase-25 design.
GLOW_SPOTS = ( GLOW_SPOTS = (
("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite", ("body::after", "bg-glow-a", "animation: bg-glow-a 26s ease-in-out infinite",
"radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%)"), "radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%)"),
("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite", ("html::before", "bg-glow-b", "animation: bg-glow-b 34s ease-in-out -12s infinite",
"radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%)"), "radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%)"),
("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite", ("html::after", "bg-glow-c", "animation: bg-glow-c 42s ease-in-out -23s infinite",
"radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%)"), "radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%)"),
) )
@@ -87,16 +90,17 @@ def test_drift_and_breathe_keyframes_are_deleted() -> None:
def test_grid_keeps_its_static_texture() -> None: def test_grid_keeps_its_static_texture() -> None:
"""The owner rejected the grid's MOTION, not the grid: 44px cells, """The owner rejected the grid's MOTION, not the grid: 44px cells,
1px lines at 60% --line alpha, and the widened radial mask (both the 1px lines at the fixed 60% line alpha (rebrand warm tone,
-webkit- and standard mask properties) stay.""" 2026-08-28), and the widened radial mask (both the -webkit- and
standard mask properties) stay."""
block = _rule_block(_css(), "body::before") block = _rule_block(_css(), "body::before")
assert "background-size: 44px 44px" in block assert "background-size: 44px 44px" in block
assert ( assert (
"linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px)" in block "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
), "grid must keep horizontal 1px lines at 60% --line" ), "grid must keep horizontal 1px lines at 60% line alpha"
assert ( assert (
"linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px)" in block "linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
), "grid must keep vertical 1px lines at 60% --line" ), "grid must keep vertical 1px lines at 60% line alpha"
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)" mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block assert f"mask-image: {mask};" in block
@@ -138,10 +142,11 @@ def test_glow_layers_declare_no_transform_or_position_animation() -> None:
def test_each_spot_runs_its_own_slow_opacity_fade() -> None: def test_each_spot_runs_its_own_slow_opacity_fade() -> None:
"""body::after (phase-08 indigo, 26s), html::before (phase-08 cyan, """body::after (rose, 26s), html::before (orange, 34s, -12s delay),
34s, -12s delay), html::after (third indigo, 42s, -23s delay) — each html::after (red, 42s, -23s delay) — the rebrand warm palette on
glow layer's background-image is EXACTLY the single radial gradient the phase-25 layout; each glow layer's background-image is EXACTLY
from the spec (color, radius, position, 62% transparent stop).""" the single radial gradient from the spec (color, radius, position,
62% transparent stop)."""
for sel, _name, animation, gradient in GLOW_SPOTS: for sel, _name, animation, gradient in GLOW_SPOTS:
block = _rule_block(_css(), sel) block = _rule_block(_css(), sel)
assert animation in block, f"{sel} must run {animation}" assert animation in block, f"{sel} must run {animation}"
+43 -33
View File
@@ -10,8 +10,10 @@ Pinned design (PLAN §7.4 note / phase 14):
* save points: user message on send, brain message on ``done``; * save points: user message on send, brain message on ``done``;
* every ``localStorage`` access wrapped in try/catch (failure-safe); * every ``localStorage`` access wrapped in try/catch (failure-safe);
* size budget ~700k chars, oldest dropped first; * size budget ~700k chars, oldest dropped first;
* ``#new-chat-btn`` in the shared header bar (chat, sources, viewer — * ``#new-chat-btn`` chat-page only — phase 19 shipped it in the shared
phase 19, owner permission 2026-08-23), ≥44px, ghost pill. bar (chat, sources, viewer, owner permission 2026-08-23); it moved
from the navbar to index.html's .chat-shell at owner request
(2026-08-28). ≥44px, solid brand pill.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +24,10 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js" APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html" INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html" SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
DOCUMENT_HTML = FRONTEND / "document.html" DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css" STYLES_CSS = FRONTEND / "assets" / "styles.css"
@@ -151,53 +156,58 @@ def test_new_chat_clears_key_and_ui() -> None:
assert "removeItem(STORAGE_KEY)" in js assert "removeItem(STORAGE_KEY)" in js
def test_new_chat_button_in_the_shared_header_bar() -> None: def test_new_chat_button_lives_only_on_the_chat_page() -> None:
"""#new-chat-btn is a real type=button with an accessible name in the """#new-chat-btn is a real type=button with an accessible name. Moved
chat header (index.html). Phase 19 (owner permission 2026-08-23): it from the shared header bar to the chat page at owner request
is part of the SHARED bar — it also appears in sources.html and the (2026-08-28): it lives ONLY in index.html — inside <main>, in
document viewer, where it means "go to the chat, fresh" (the .chat-shell, above the #messages section (the single module binding
clear-storage + navigate binding is pinned in test_shared_header.py).""" + the no-op guard are pinned in test_shared_header.py)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML):
text = html.read_text(encoding="utf-8")
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', text)
assert btn, f"{html.name} must contain #new-chat-btn"
tag = btn.group(0)
assert 'type="button"' in tag
assert 'aria-label="New chat"' in tag
# Chat page specifics: the button sits after the nav, inside the header.
html = _index() html = _index()
btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html) btn = re.search(r'<button[^>]*id="new-chat-btn"[^>]*>', html)
assert btn, "index.html must contain #new-chat-btn" assert btn, "index.html must contain #new-chat-btn"
nav_idx = html.find('<nav class="app-nav"') tag = btn.group(0)
assert nav_idx != -1 and btn.start() > nav_idx, ( assert 'type="button"' in tag
"the button belongs after the nav, inside .header-inner" assert 'aria-label="New chat"' in tag
)
main_idx = html.find('main id="main"') main_idx = html.find('main id="main"')
assert main_idx != -1 and btn.start() < main_idx, "the button belongs in the header" assert main_idx != -1 and btn.start() > main_idx, "the button belongs inside <main>"
shell_idx = html.find('class="container chat-shell"')
assert shell_idx != -1 and shell_idx < btn.start(), "the button belongs in .chat-shell"
messages_idx = html.find('id="messages"')
assert messages_idx != -1 and btn.start() < messages_idx, (
"the button sits above the #messages section"
)
# No other page carries the button anymore (owner request 2026-08-28).
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
assert 'id="new-chat-btn"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the New chat button is chat-page only"
)
def test_new_chat_button_style_contract() -> None: def test_new_chat_button_style_contract() -> None:
"""Ghost pill like a nav link: Phase-08 tokens, ≥44px target, hover like """Solid brand pill (the 2026-08-28 rebrand; it was a ghost): --bg
.nav-link, focus-visible via the global rule; icon-only on phones with text on --brand (5.2:1 per the token note — WCAG AA), borderless,
the label hidden (aria-label keeps the accessible name).""" ≥44px target; hover lightens the brand fill; focus-visible via the
global rule. On the chat page the label stays visible even ≤640px
(the button is in .chat-shell, not the navbar) with the plus icon
hidden (aria-label keeps the accessible name either way)."""
css = _css() css = _css()
block = re.search(r"\.new-chat-btn \{([\s\S]*?)\n\}", css) block = re.search(r"\.new-chat-btn \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .new-chat-btn" assert block, "styles.css must style .new-chat-btn"
body = block.group(1) body = block.group(1)
assert "min-height: 44px" in body assert "min-height: 44px" in body
assert "border-radius: 999px" in body assert "border-radius: 999px" in body
assert "var(--line)" in body, "ghost: 1px line border, transparent background" assert "border: 0" in body
assert "background: transparent" in body assert "background: var(--brand)" in body, "solid brand fill (the rebrand)"
assert "var(--ink-soft)" in body assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css) hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "--brand-soft" in hover.group(1) and "--brand-ink" in hover.group(1), ( assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
"hover must match the nav-link brand pair" # Mobile (≤640px): the button sits in .chat-shell, not the navbar —
) # the label stays visible and the plus icon is hidden (room in the
# Mobile (≤640px): label hidden, icon shown — the pill stays ≥44px via # body); the pill stays ≥44px via min-height.
# min-height and never breaks the fixed-height header bar.
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css) mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing" assert mobile, "mobile media query missing"
assert ".new-chat-label { display: none; }" in mobile.group(1) assert ".chat-shell .new-chat-label { display: inline; }" in mobile.group(1)
assert ".chat-shell .new-chat-btn svg { display: none; }" in mobile.group(1)
assert ".new-chat-btn svg { display: block; }" in mobile.group(1) assert ".new-chat-btn svg { display: block; }" in mobile.group(1)
+80 -46
View File
@@ -26,6 +26,7 @@ STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html" INDEX_HTML = FRONTEND / "index.html"
SOURCES_HTML = FRONTEND / "sources.html" SOURCES_HTML = FRONTEND / "sources.html"
GIT_SOURCES_HTML = FRONTEND / "git-sources.html"
DOCUMENT_HTML = FRONTEND / "document.html" DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html" LOGIN_HTML = FRONTEND / "login.html"
TUNING_HTML = FRONTEND / "tuning.html" TUNING_HTML = FRONTEND / "tuning.html"
@@ -77,8 +78,15 @@ def test_init_shared_header_toggles_only_elements_that_exist() -> None:
assert fn != -1 assert fn != -1
body = js[fn : js.find("\n}", fn)] body = js[fn : js.find("\n}", fn)]
assert "await fetchIsAdmin()" in body assert "await fetchIsAdmin()" in body
for selector in ("#sign-in-link", "#sign-out-btn", "#nav-sources", "#nav-tuning"): for selector in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
assert f'querySelector("{selector}")' in body assert f'querySelector("{selector}")' in body
# Sign in: both the bar copy AND the mobile dropdown copy (phase 46)
# carry .sign-in-link — one class-based toggle (with the ?next= href
# rewrite on every copy) covers both.
assert 'querySelectorAll(".sign-in-link")' in body
# Sign out: both the bar copy AND the mobile dropdown copy (phase 46)
# carry .sign-out-btn — one class-based toggle covers both.
assert 'querySelectorAll(".sign-out-btn")' in body
assert "return admin" in body, "callers may reuse the flag" assert "return admin" in body, "callers may reuse the flag"
@@ -95,15 +103,19 @@ def test_clear_chat_storage_removes_the_phase14_key_silently() -> None:
def test_sign_out_binding_lives_in_the_shared_module() -> None: def test_sign_out_binding_lives_in_the_shared_module() -> None:
"""The #sign-out-btn click binding (disable → POST /api/logout → """The sign-out click binding (disable → POST /api/logout → reload)
reload) is owned by header.js at module import — exactly one is owned by header.js at module import — exactly one implementation
implementation for every page that loads it.""" for every page that loads it. It binds to ALL .sign-out-btn
elements (the bar copy for desktop + the mobile dropdown copy for
≤640px, phase 46), so both copies log out."""
js = _text(HEADER_JS) js = _text(HEADER_JS)
assert "querySelector(\"#sign-out-btn\")" in js assert js.count('querySelectorAll(".sign-out-btn")') >= 2, (
assert "signOutBtn.addEventListener" in js "the boot toggle + the click binding both use the class (bar + mobile copy)"
assert "signOutBtn.disabled = true" in js )
assert "btn.addEventListener(\"click\"" in js
assert "btn.disabled = true" in js
assert 'fetch("/api/logout", { method: "POST" })' in js assert 'fetch("/api/logout", { method: "POST" })' in js
assert "location.reload()" in js assert "window.location.reload()" in js
# ---------- HTML wiring: one shared bar on every page ---------- # ---------- HTML wiring: one shared bar on every page ----------
@@ -112,7 +124,7 @@ def test_sign_out_binding_lives_in_the_shared_module() -> None:
def test_nav_sources_ships_hidden_on_every_nav_page() -> None: def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
"""Phase 19 UX revision (owner permission 2026-08-23), completed on """Phase 19 UX revision (owner permission 2026-08-23), completed on
all five pages by phase 34 task 03 (owner confirmation 2026-08-26): all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
the Sources nav link is hidden for anonymous — so it SHIPS with the the RAG nav link is hidden for anonymous — so it SHIPS with the
hidden attribute (anonymous-safe default) on every page (they all hidden attribute (anonymous-safe default) on every page (they all
carry the nav now, viewer included).""" carry the nav now, viewer included)."""
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML): for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
@@ -122,28 +134,37 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
) )
def test_all_five_pages_share_the_header_control_order() -> None: def test_all_six_pages_share_the_header_control_order() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26): every page """Phase 34 task 03 (owner confirmation 2026-08-26) + phase 35/46
ships the IDENTICAL header control inventory in the IDENTICAL (the sixth page, git-sources): every page ships the IDENTICAL
order — brand, nav [Chat, Sources, Tuning], #sync-btn, #new-chat-btn, header control inventory in the IDENTICAL order — brand, the mobile
hamburger, nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning],
Sign in, Sign out (the #steering-toggle was removed from the navbar Sign in, Sign out (the #steering-toggle was removed from the navbar
at owner request, 2026-08-28) — inside the shared at owner request, 2026-08-28) — inside the shared .header-inner row
.header-inner row (the document viewer's row 1). Only the (the document viewer's row 1). The #sync-btn (Sources page only)
current-page is-active nav marker and the static ?next= fallback and the #new-chat-btn (chat page only, moved from the navbar at
may differ per page (task 05's story E2E pins the rendered owner request 2026-08-28) are NOT part of the shared bar anymore.
result).""" Only the current-page is-active nav marker and the static ?next=
fallback may differ per page (task 05's story E2E pins the
rendered result)."""
markers = ( markers = (
'class="brand"', 'class="brand"',
'<nav class="app-nav"', '<nav class="app-nav"',
'href="/"', 'href="/"',
'id="nav-sources"', 'id="nav-sources"',
'id="nav-git-sources"',
'id="nav-tuning"', 'id="nav-tuning"',
'id="sync-btn"',
'id="new-chat-btn"',
'id="sign-in-link"', 'id="sign-in-link"',
'id="sign-out-btn"', 'id="sign-out-btn"',
) )
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML): for html in (
INDEX_HTML,
SOURCES_HTML,
GIT_SOURCES_HTML,
DOCUMENT_HTML,
TUNING_HTML,
LOGIN_HTML,
):
text = _text(html) text = _text(html)
start = text.find('<div class="container header-inner">') start = text.find('<div class="container header-inner">')
assert start != -1, f"{html.name}: missing the shared .header-inner row" assert start != -1, f"{html.name}: missing the shared .header-inner row"
@@ -233,14 +254,18 @@ def test_viewer_carries_the_standard_nav() -> None:
def test_sources_and_viewer_carry_the_shared_controls() -> None: def test_sources_and_viewer_carry_the_shared_controls() -> None:
"""Sources AND the document viewer gain the New Chat button + the """Sources AND the document viewer carry the Sign in / Sign out pair
Sign in / Sign out pair (both starting hidden — initSharedHeader (both starting hidden — initSharedHeader reveals exactly one after
reveals exactly one after whoami), and their page scripts load whoami), and their page scripts load header.js (phase 23: via the
header.js (phase 23: via the page script's relative import, not a page script's relative import, not a direct script tag). The New
direct script tag).""" chat button is NOT on non-chat pages — it moved from the navbar to
the chat page at owner request (2026-08-28; the single binding is
pinned in test_new_chat_binding_is_single_and_module_owned)."""
for html in (SOURCES_HTML, DOCUMENT_HTML): for html in (SOURCES_HTML, DOCUMENT_HTML):
text = _text(html) text = _text(html)
assert 'id="new-chat-btn"' in text assert 'id="new-chat-btn"' not in text, (
f"{html.name}: the New chat button is chat-page only (owner request)"
)
assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text) assert re.search(r'id="sign-in-link"[^>]*\bhidden\b', text)
assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text) assert re.search(r'id="sign-out-btn"[^>]*\bhidden\b', text)
for js_file in (SOURCES_JS, DOCUMENT_JS): for js_file in (SOURCES_JS, DOCUMENT_JS):
@@ -287,23 +312,26 @@ def test_header_module_loads_before_the_page_script() -> None:
def test_login_page_carries_the_full_header() -> None: def test_login_page_carries_the_full_header() -> None:
"""Phase 34 task 03 (owner confirmation 2026-08-26): the noted """Phase 34 task 03 (owner confirmation 2026-08-26): the noted
boundary is reversed by owner decision — the login page finally boundary is reversed by owner decision — the login page finally
carries the FULL shared header: the nav gains the #nav-tuning link carries the FULL shared header: the nav with #nav-sources /
(same ship-hidden markup as the other pages), plus the admin-only #nav-git-sources / #nav-tuning (same ship-hidden markup as the
Sync button, New chat, and the Sign in / Sign out pair (ship hidden other pages) plus the Sign in / Sign out pair (ship hidden —
— initSharedHeader reveals exactly one after whoami; the static initSharedHeader reveals exactly one after whoami; the static
?next= fallback is the login page itself). The Tuning toggle is NOT ?next= fallback is the login page itself). The Tuning toggle is NOT
among them — removed from the navbar at owner request (2026-08-28). among them — removed from the navbar at owner request (2026-08-28) —
No nav link is "current" on the auth page.""" and neither is the Sync button or the New chat button (page-specific
controls, not shared-bar ones). No nav link is "current" on the
auth page."""
text = _text(LOGIN_HTML) text = _text(LOGIN_HTML)
for marker in ( for marker in (
'id="nav-sources"', 'id="nav-sources"',
'id="nav-git-sources"',
'id="nav-tuning"', 'id="nav-tuning"',
'id="sync-btn"',
'id="new-chat-btn"',
'id="sign-in-link"', 'id="sign-in-link"',
'id="sign-out-btn"', 'id="sign-out-btn"',
): ):
assert marker in text, f"login.html must carry {marker} (phase 34 full header)" assert marker in text, f"login.html must carry {marker} (phase 34 full header)"
assert 'id="sync-btn"' not in text, "the Sync button lives on the Sources page only"
assert 'id="new-chat-btn"' not in text, "the New chat button is chat-page only"
assert 'href="/login.html?next=/login.html"' in text, ( assert 'href="/login.html?next=/login.html"' in text, (
"the login page's Sign in returns to the login page (no-JS fallback)" "the login page's Sign in returns to the login page (no-JS fallback)"
) )
@@ -354,21 +382,27 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
def test_new_chat_binding_is_single_and_module_owned() -> None: def test_new_chat_binding_is_single_and_module_owned() -> None:
"""Phase 34 task 02: header.js owns the SINGLE #new-chat-btn binding """Phase 34 task 02 + owner rework (2026-08-28): header.js owns the
(module import, like the sign-out binding): on the chat page SINGLE #new-chat-btn binding (module import, like the sign-out
(#messages exists) it dispatches window "bor:new-chat" — app.js acts binding). The button now lives ONLY on the chat page (inside
through its own in-flight-turn guard + list reset; on every other .chat-shell, above #messages — moved from the navbar at owner
page it means "go to the chat, fresh" (clearChatStorage + navigate request), so the click ALWAYS dispatches window "bor:new-chat" and
to "/"). NO page script binds #new-chat-btn anymore, and each page app.js acts through its own in-flight-turn guard + list reset: no
still runs initSharedHeader() on the shared cached whoami. Phase 23: #messages branch, no clearChatStorage + navigate-to-"/" path
the import is relative (`./header.js`).""" anymore. NO page script binds #new-chat-btn, and each page still
runs initSharedHeader() on the shared cached whoami. Phase 23: the
import is relative (`./header.js`)."""
js = _text(HEADER_JS) js = _text(HEADER_JS)
assert 'querySelector("#new-chat-btn")' in js assert 'querySelector("#new-chat-btn")' in js
assert "newChatBtn.addEventListener" in js assert "newChatBtn.addEventListener" in js
assert 'querySelector("#messages")' in js, "the chat-page branch key" assert "if (newChatBtn)" in js, "a page without the button is a no-op"
assert 'new CustomEvent("bor:new-chat")' in js assert 'new CustomEvent("bor:new-chat")' in js
assert "clearChatStorage();" in js assert 'querySelector("#messages")' not in js, (
assert 'window.location.href = "/"' in js "no chat-page branch — the button is chat-page only (owner request)"
)
assert 'window.location.href = "/"' not in js, (
"no navigate-to-chat branch left (the button left the navbar)"
)
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS): for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
page_js = _text(js_file) page_js = _text(js_file)
assert 'from "./header.js"' in page_js assert 'from "./header.js"' in page_js
+142 -123
View File
@@ -2,14 +2,15 @@
The browser behavior is E2E-covered (tests/e2e/test_sync_button.py, The browser behavior is E2E-covered (tests/e2e/test_sync_button.py,
task 03); here we pin the source-level wiring — the anonymous-safe task 03); here we pin the source-level wiring — the anonymous-safe
ship-hidden button markup, the header.js admin reveal on the SAME ship-hidden button markup, the Sources-page admin reveal on the SAME
cached whoami (no extra fetch), the header.js sync state machine cached whoami (no extra fetch), the sync state machine that now lives
(moved here from sources.js in phase 34 task 02: 2 s poll, 202 start 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" / 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
event with the status object as detail, the single-poll-loop guard, no event with the status object as detail, the single-poll-loop guard, no
client-side hard timeout), the Sources page's event-driven client-side hard timeout), the page's #sync-result line +
#sync-result line + #sync-error-banner, and the §7.4 never-stale CSS #sync-error-banner, the lazily created error modal (phase 41), and the
(spin + reduced-motion opt-out, §7.4 never-stale CSS (spin + reduced-motion opt-out,
disabled state, 44px floor, contrast pair) — so a silent regression is disabled state, 44px floor, contrast pair) — so a silent regression is
caught without a browser. caught without a browser.
""" """
@@ -44,9 +45,9 @@ def _body(js: str, fn_name: str) -> str:
def test_sync_button_ships_hidden_and_labeled() -> None: def test_sync_button_ships_hidden_and_labeled() -> None:
"""#sync-btn SHIPS with the hidden attribute (anonymous-safe — """#sync-btn SHIPS with the hidden attribute (anonymous-safe —
header.js reveals it for the admin), is a real <button type= sources.js reveals it for the admin at page boot), is a real
"button">, and carries aria-label="Sync sources" so the accessible <button type="button">, and carries aria-label="Sync sources" so
name stays stable across the label states.""" the accessible name stays stable across the label states."""
tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML)) tag = re.search(r"<button[^>]*id=\"sync-btn\"[^>]*>", _text(SOURCES_HTML))
assert tag, "sources.html must carry the #sync-btn button" assert tag, "sources.html must carry the #sync-btn button"
attrs = tag.group(0) attrs = tag.group(0)
@@ -58,13 +59,17 @@ def test_sync_button_ships_hidden_and_labeled() -> None:
def test_sync_button_has_icon_and_label_span() -> None: def test_sync_button_has_icon_and_label_span() -> None:
"""The button body is a refresh-cycle svg (aria-hidden — decorative, """The button body is a refresh-cycle svg (aria-hidden — decorative,
the spin is the visible running state) + the #sync-label span with the spin is the visible running state) + the .sync-label span with
the idle text, so the label can be swapped by sources.js.""" the idle text, so the label can be swapped by sources.js."""
text = _text(SOURCES_HTML) text = _text(SOURCES_HTML)
btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))] btn = text[text.find('id="sync-btn"') : text.find("</button>", text.find('id="sync-btn"'))]
assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn) assert re.search(r'<svg[^>]*class="sync-icon"[^>]*aria-hidden="true"', btn)
assert 'id="sync-label"' in btn # class for the module query + id for the E2E label assertions
assert re.search(r'<span[^>]*id="sync-label"[^>]*>Sync sources</span>', btn) label = re.search(
r'<span\b[^>]*id="sync-label"[^>]*>\s*Sync sources\s*</span>', btn
)
assert label, "the #sync-label span carrying the idle text is missing"
assert 'class="sync-label"' in label.group(0)
def test_sync_result_is_the_aria_live_announcer() -> None: def test_sync_result_is_the_aria_live_announcer() -> None:
@@ -97,13 +102,13 @@ def test_sync_error_banner_is_a_hidden_alert() -> None:
def test_page_sub_copy_mentions_the_button() -> None: def test_page_sub_copy_mentions_the_button() -> None:
"""The page-sub copy still points at the import CLI and now names """The page-sub copy names the button as the one-click way to clone
the header button as the one-click alternative (task 02 step 1).""" the repos and re-import (the import CLI docs live elsewhere)."""
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL) sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
assert sub, "sources.html must keep the .page-sub copy" assert sub, "sources.html must keep the .page-sub copy"
assert "Re-run the import" in sub.group(1) copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
assert "Sync sources" in sub.group(1) assert "Press <strong>Sync sources</strong>" in copy
assert "header" in sub.group(1) assert "clone the repos and re-import" in copy
def test_sources_page_stays_cdn_free() -> None: def test_sources_page_stays_cdn_free() -> None:
@@ -115,61 +120,61 @@ def test_sources_page_stays_cdn_free() -> None:
assert 'href="https://' not in text assert 'href="https://' not in text
# ---------- header.js: the admin reveal ---------- # ---------- sources.js: the admin reveal (page boot) ----------
def test_header_reveals_sync_btn_on_the_admin_branch() -> None: def test_sources_js_reveals_sync_btn_on_the_admin_branch() -> None:
"""initSharedHeader reveals #sync-btn in the SAME admin branch as """The page boot reveals #sync-btn for the admin on the SAME cached
#nav-sources (hidden = !admin) — one cached whoami, no extra whoami initSharedHeader() used (no extra fetch — header.js keeps
whoami call; anonymous users never leave the hidden default. The the single /api/whoami call site); anonymous users never leave the
ref is the module-level one — the state machine shares it.""" ship-hidden default."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert 'querySelector("#sync-btn")' in js, "module-level #sync-btn ref" boot = js[js.rfind("(async () => {"):]
fn = js.find("function initSharedHeader") assert "const admin = await initSharedHeader()" in boot
assert fn != -1 assert "syncBtn.hidden = !admin" in boot, "#sync-btn must join the admin reveal"
body = js[fn : js.find("\n}", fn)]
assert "syncBtn.hidden = !admin" in body, "#sync-btn must join the admin reveal"
# The reveal must not introduce a second whoami call site. # The reveal must not introduce a second whoami call site.
assert js.count('fetch("/api/whoami")') == 1 header = _text(HEADER_JS)
assert header.count('fetch("/api/whoami")') == 1
assert 'fetch("/api/whoami")' not in js
# ---------- header.js: the sync state machine (moved here from # ---------- sources.js: the sync state machine (moved here from
# ---------- sources.js in phase 34 task 02) ---------- # ---------- header.js in the owner rework 2026-08-28) ----------
def test_header_js_owns_the_sync_button_elements() -> None: def test_sources_js_owns_the_sync_button_elements() -> None:
"""The button refs are module-level and null-safe: #sync-btn, """The button refs are module-level and null-safe: #sync-btn, the
#sync-label, the .sync-icon inside the button — a page without the .sync-label inside the button, the .sync-icon inside the button — a
markup is a complete no-op, exactly like the rest of the module.""" page without the markup is a complete no-op."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert 'querySelector("#sync-btn")' in js assert 'querySelector("#sync-btn")' in js
assert 'querySelector("#sync-label")' in js assert 'syncBtn.querySelector(".sync-label")' in js
assert 'syncBtn.querySelector(".sync-icon")' in js assert 'syncBtn.querySelector(".sync-icon")' in js
def test_header_js_calls_the_sync_api() -> None: def test_sources_js_calls_the_sync_api() -> None:
"""The click posts to POST /api/sync and the poll loop GETs """The click posts to POST /api/sync and the poll loop GETs
/api/sync/status — both through the same-origin API (A10).""" /api/sync/status — both through the same-origin API (A10)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert 'fetch("/api/sync", { method: "POST" })' in js assert 'fetch("/api/sync", { method: "POST" })' in js
assert 'fetch("/api/sync/status")' in js assert 'fetch("/api/sync/status")' in js
def test_header_js_polls_every_2000ms() -> None: def test_sources_js_polls_every_2000ms() -> None:
"""The feedback loop is a 2000 ms poll of the status endpoint, """The feedback loop is a 2000 ms poll of the status endpoint,
re-scheduled one tick at a time (setTimeout, not setInterval — an re-scheduled one tick at a time (setTimeout, not setInterval — an
in-flight fetch can never overlap the next tick).""" in-flight fetch can never overlap the next tick)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert "SYNC_POLL_MS = 2000" in js assert "SYNC_POLL_MS = 2000" in js
assert "setTimeout(tick, SYNC_POLL_MS)" in js assert "setTimeout(tick, SYNC_POLL_MS)" in js
assert "setInterval" not in js assert "setInterval" not in js
def test_header_js_adopts_409_and_starts_on_202() -> None: def test_sources_js_adopts_409_and_starts_on_202() -> None:
"""202 (started) and 409 (a run started elsewhere — e.g. a second """202 (started) and 409 (a run started elsewhere — e.g. a second
tab) both enter the running state and start polling: the UI never tab) both enter the running state and start polling: the UI never
starts a second run, it adopts the in-flight one.""" starts a second run, it adopts the in-flight one."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert "r.status === 202 || r.status === 409" in js assert "r.status === 202 || r.status === 409" in js
idx = js.find("r.status === 202 || r.status === 409") idx = js.find("r.status === 202 || r.status === 409")
branch = js[idx : idx + 400] branch = js[idx : idx + 400]
@@ -177,11 +182,11 @@ def test_header_js_adopts_409_and_starts_on_202() -> None:
assert "startSyncPolling()" in branch assert "startSyncPolling()" in branch
def test_header_js_hides_the_button_on_403() -> None: def test_sources_js_hides_the_button_on_403() -> None:
"""A 403 anywhere (POST, the status poll, the load re-attach) is """A 403 anywhere (POST, the status poll, the load re-attach) is
treated as not-admin: the button hides — defense in depth behind treated as not-admin: the button hides — defense in depth behind
the whoami reveal (the primary gate).""" the whoami reveal (the primary gate)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert len(re.findall(r"r\.status === 403", js)) >= 3, ( assert len(re.findall(r"r\.status === 403", js)) >= 3, (
"POST, the status poll, and the load re-attach must all handle 403" "POST, the status poll, and the load re-attach must all handle 403"
) )
@@ -190,13 +195,13 @@ def test_header_js_hides_the_button_on_403() -> None:
) )
def test_header_js_running_state_is_never_stale() -> None: def test_sources_js_running_state_is_never_stale() -> None:
"""Entering the running state disables the button, sets aria-busy, """Entering the running state disables the button, sets aria-busy,
spins the icon, and swaps the label to 'Syncing…' (the §7.4 spins the icon, and swaps the label to 'Syncing…' (the §7.4
feedback while the poll waits) — and a fresh run starts clean: the feedback while the poll waits) — and a fresh run starts clean: the
previous failure's title / aria-label / .is-error come off NOW, previous failure's title / aria-label / .is-error come off NOW,
not when the run settles.""" not when the run settles."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
body = _body(js, "enterSyncRunningState") body = _body(js, "enterSyncRunningState")
assert "syncBtn.disabled = true" in body assert "syncBtn.disabled = true" in body
assert 'syncBtn.setAttribute("aria-busy", "true")' in body assert 'syncBtn.setAttribute("aria-busy", "true")' in body
@@ -207,15 +212,14 @@ def test_header_js_running_state_is_never_stale() -> None:
assert '"Syncing…"' in body assert '"Syncing…"' in body
def test_header_js_terminal_states() -> None: def test_sources_js_terminal_states() -> None:
"""Terminal rendering: success → enabled + 'Synced HH:MM' (local """Terminal rendering: success → enabled + 'Synced HH:MM' (local
time of finished_at); failed → enabled + retry-ready 'Sync sources' time of finished_at); failed → enabled + retry-ready 'Sync sources'
label + the sanitized error in the button's title + aria-label + label + the sanitized error in the button's title + aria-label +
the .is-error class (non-Sources pages: that is where the failure the .is-error class. The counts formatting (fmtSyncResult) lives
is visible). The counts formatting (fmtSyncResult) lives here and here ('added' always announced, zero terms omitted — a no-op
is EXPORTED for the Sources page ('added' always announced, zero re-sync reads '0 added · 1 unchanged')."""
terms omitted — a no-op re-sync reads '0 added · 1 unchanged').""" js = _text(SOURCES_JS)
js = _text(HEADER_JS)
success = _body(js, "applySyncSuccess") success = _body(js, "applySyncSuccess")
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
@@ -236,24 +240,24 @@ def test_header_js_terminal_states() -> None:
assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at" assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
def test_header_js_failed_error_is_sanitized() -> None: def test_sources_js_failed_error_is_sanitized() -> None:
"""The button's title/aria-label error is sanitized for the """The button's title/aria-label error is sanitized for the
attributes: the server already masks credentials (sync.py attributes: the server already masks credentials (sync.py
_sanitize_error); the module collapses whitespace to a single line _sanitize_error); the page collapses whitespace to a single line
and caps the length, and a missing error still names a failure.""" and caps the length, and a missing error still names a failure."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
body = _body(js, "sanitizeSyncError") body = _body(js, "sanitizeSyncError")
assert "replace(/\\s+/g, \" \")" in body, "single line for the attributes" assert "replace(/\\s+/g, \" \")" in body, "single line for the attributes"
assert "200" in body, "long errors (chatty git stderr) are capped" assert "200" in body, "long errors (chatty git stderr) are capped"
assert '"The sync failed."' in body assert '"The sync failed."' in body
def test_header_js_settles_the_button_on_terminal() -> None: def test_sources_js_settles_the_button_on_terminal() -> None:
"""settleSyncButton re-enables the control, drops aria-busy, the """settleSyncButton re-enables the control, drops aria-busy, the
title, and the failed affordances, and un-spins the icon — the title, and the failed affordances, and un-spins the icon — the
button can never sit disabled after a run reaches a terminal state button can never sit disabled after a run reaches a terminal state
(failed included: retry-ready).""" (failed included: retry-ready)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
body = _body(js, "settleSyncButton") body = _body(js, "settleSyncButton")
assert "syncBtn.disabled = false" in body assert "syncBtn.disabled = false" in body
assert 'syncBtn.removeAttribute("aria-busy")' in body assert 'syncBtn.removeAttribute("aria-busy")' in body
@@ -262,11 +266,11 @@ def test_header_js_settles_the_button_on_terminal() -> None:
assert "syncIcon.classList.remove(\"is-spinning\")" in body assert "syncIcon.classList.remove(\"is-spinning\")" in body
def test_header_js_never_starts_a_second_poll_loop() -> None: def test_sources_js_never_starts_a_second_poll_loop() -> None:
"""startSyncPolling is guarded by the module-level timer: a 409 """startSyncPolling is guarded by the module-level timer: a 409
adoption, a reload re-attach, or a stray call can never run two adoption, a reload re-attach, or a stray call can never run two
poll loops at once (phase completion criterion).""" poll loops at once (phase completion criterion)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
fn = js.find("function startSyncPolling") fn = js.find("function startSyncPolling")
head = js[fn : js.find("const tick", fn)] head = js[fn : js.find("const tick", fn)]
assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), ( assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), (
@@ -275,27 +279,27 @@ def test_header_js_never_starts_a_second_poll_loop() -> None:
assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling") assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling")
def test_header_js_has_no_client_side_hard_timeout() -> None: def test_sources_js_has_no_client_side_hard_timeout() -> None:
"""Phase locked decision: a sync can legitimately run for minutes """Phase locked decision: a sync can legitimately run for minutes
(and outlive the page), so there is NO client-side hard timeout — (and outlive the page), so there is NO client-side hard timeout —
the 2 s poll is the feedback loop and the server state is the 2 s poll is the feedback loop and the server state is
authoritative (the 120 s LLM-turn guard must not leak into the authoritative (the 120 s LLM-turn guard must not leak into the
sync path).""" sync path)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert "TURN_TIMEOUT" not in js assert "TURN_TIMEOUT" not in js
sync_start = js.find("sync sources (phase 32") sync_start = js.find("Sync sources button (Sources page only)")
assert sync_start != -1, "the sync section marker comment" assert sync_start != -1, "the sync section marker comment"
assert "120" not in js[sync_start:] assert "120" not in js[sync_start:]
def test_header_js_reattaches_on_load_admin_only() -> None: def test_sources_js_reattaches_on_load_admin_only() -> None:
"""initSyncButton (run at module import, button pages only) awaits """initSyncButton (run at page boot, after the click binding)
the SAME cached whoami — ADMIN ONLY (non-admins never poll, the awaits the SAME cached whoami — ADMIN ONLY (non-admins never poll,
status endpoint is admin-only): a running run re-enters the the status endpoint is admin-only): a running run re-enters the
running state (reload mid-sync), a terminal run renders its last running state (reload mid-sync), a terminal run renders its last
result, idle settles retry-ready; the click binding wires result, idle settles retry-ready; the click binding wires
startSync to the button.""" startSync to the button."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
fn = js.find("function initSyncButton") fn = js.find("function initSyncButton")
assert fn != -1 assert fn != -1
body = js[fn : js.find("\n}\n", fn)] body = js[fn : js.find("\n}\n", fn)]
@@ -304,18 +308,18 @@ def test_header_js_reattaches_on_load_admin_only() -> None:
assert 'status.state === "running"' in body assert 'status.state === "running"' in body
assert 'status.state === "success"' in body assert 'status.state === "success"' in body
assert 'status.state === "failed"' in body assert 'status.state === "failed"' in body
assert "syncBtn.addEventListener(\"click\", startSync)" in js assert (
tail = js[js.rfind("if (syncBtn)") :] 'syncBtn.addEventListener("click", startSync);\n initSyncButton();'
assert "initSyncButton()" in tail, "boot re-attach runs at module import" in js
), "the click binding and the boot re-attach ship together, guarded on syncBtn"
def test_header_js_emits_bor_sync_status_on_state_changes() -> None: def test_sources_js_emits_bor_sync_status_on_state_changes() -> None:
"""Every state change dispatches window 'bor:sync-status' with the """Every state change dispatches window 'bor:sync-status' with the
status object as detail — the channel the Sources page's status object as detail — the channel other page scripts (or
banner/result line subscribe to. The click path emits the future ones) can subscribe to. The click path emits the synthetic
synthetic running frame IMMEDIATELY (no 2 s poll lag — the exact running frame IMMEDIATELY (no 2 s poll lag)."""
old enterRunningState clear behavior, now event-driven).""" js = _text(SOURCES_JS)
js = _text(HEADER_JS)
assert ( assert (
'window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }))' 'window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }))'
in js in js
@@ -327,23 +331,23 @@ def test_header_js_emits_bor_sync_status_on_state_changes() -> None:
assert "emitSyncStatus(status)" in _body(js, "initSyncButton") assert "emitSyncStatus(status)" in _body(js, "initSyncButton")
# ---------- header.js: the sync failure modal (phase 41, TODO.md L4) ---------- # ---------- sources.js: the sync failure modal (phase 41, TODO.md L4) ----------
def test_header_js_owns_a_lazily_created_sync_modal() -> None: def test_sources_js_owns_a_lazily_created_sync_modal() -> None:
"""The modal is module-owned and created lazily ONCE (module-level """The modal is page-owned and created lazily ONCE (module-level
`let syncModal = null`): a .sync-modal-backdrop holding a .sync-modal `let syncModal = null`): a .sync-modal-backdrop holding a
panel with role="alertdialog" + aria-modal + labelled/described ids .sync-modal panel with role="alertdialog" + aria-modal +
+ the close button, appended to document.body — no page-markup labelled/described ids + the close button, appended to
changes, so every page carrying #sync-btn gets it. The module document.body — no page-markup changes needed (the section marker
docstring's sync bullet records the modal (phase 41).""" records that the former header.js modal was recreated here)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
assert "let syncModal = null" in js, "module-level once-only modal ref" assert "let syncModal = null" in js, "module-level once-only modal ref"
assert 'role="alertdialog"' in js assert 'role="alertdialog"' in js
assert 'aria-modal="true"' in js assert 'aria-modal="true"' in js
assert 'aria-labelledby="sync-modal-title"' in js assert 'aria-labelledby="sync-modal-title"' in js
assert 'aria-describedby="sync-modal-error"' in js assert 'aria-describedby="sync-modal-error"' in js
assert "module-owned error modal" in js, "the docstring sync bullet" assert "sync failure modal" in js, "the modal section marker"
create = _body(js, "createSyncModal") create = _body(js, "createSyncModal")
assert "sync-modal-backdrop" in create assert "sync-modal-backdrop" in create
assert 'document.body.appendChild(backdrop)' in create assert 'document.body.appendChild(backdrop)' in create
@@ -358,7 +362,7 @@ def test_sync_modal_error_is_rendered_via_text_content() -> None:
innerHTML with user data in the open path), and it is set BEFORE innerHTML with user data in the open path), and it is set BEFORE
the already-open check, so a second failure while open updates the the already-open check, so a second failure while open updates the
text IN PLACE (no stacking, no focus jump).""" text IN PLACE (no stacking, no focus jump)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
body = _body(js, "showSyncModal") body = _body(js, "showSyncModal")
assert 'querySelector("#sync-modal-error").textContent' in body assert 'querySelector("#sync-modal-error").textContent' in body
assert "innerHTML" not in body, "the open path never touches innerHTML" assert "innerHTML" not in body, "the open path never touches innerHTML"
@@ -376,7 +380,7 @@ def test_sync_modal_focus_goes_in_and_out_to_sync_btn() -> None:
the close must still land on the control that started the run); the close must still land on the control that started the run);
on close: focus returns to the remembered element (guarded by on close: focus returns to the remembered element (guarded by
document.contains — a detached target is a no-op).""" document.contains — a detached target is a no-op)."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
open_body = _body(js, "showSyncModal") open_body = _body(js, "showSyncModal")
assert "const active = document.activeElement" in open_body assert "const active = document.activeElement" in open_body
assert "active !== document.body" in open_body, ( assert "active !== document.body" in open_body, (
@@ -396,7 +400,7 @@ def test_sync_modal_closes_via_button_esc_and_backdrop() -> None:
the modal is open), and a click on the backdrop element itself — the modal is open), and a click on the backdrop element itself —
the event.target check keeps clicks bubbling from the panel from the event.target check keeps clicks bubbling from the panel from
closing it.""" closing it."""
js = _text(HEADER_JS) js = _text(SOURCES_JS)
create = _body(js, "createSyncModal") create = _body(js, "createSyncModal")
assert 'addEventListener("click", closeSyncModal)' in create assert 'addEventListener("click", closeSyncModal)' in create
assert 'e.key === "Escape"' in create assert 'e.key === "Escape"' in create
@@ -405,13 +409,12 @@ def test_sync_modal_closes_via_button_esc_and_backdrop() -> None:
assert "e.target === backdrop" in create assert "e.target === backdrop" in create
def test_apply_sync_failure_opens_the_modal_after_the_event() -> None: def test_sources_js_apply_sync_failure_opens_the_modal_after_the_event() -> None:
"""applySyncFailure opens the modal with the SANITIZED error, and """applySyncFailure opens the modal with the SANITIZED error, and
the call comes AFTER the existing button title/aria/.is-error the call comes AFTER the existing button title/aria/.is-error
lines + emitSyncStatus (the bor:sync-status event the Sources lines + emitSyncStatus (the bor:sync-status event — those lines
banner renders off — those lines stay byte-identical; the modal is stay byte-identical; the modal is additive)."""
additive).""" js = _text(SOURCES_JS)
js = _text(HEADER_JS)
body = _body(js, "applySyncFailure") body = _body(js, "applySyncFailure")
call = body.find("showSyncModal(") call = body.find("showSyncModal(")
emit = body.find("emitSyncStatus(status)") emit = body.find("emitSyncStatus(status)")
@@ -425,59 +428,75 @@ def test_apply_sync_failure_opens_the_modal_after_the_event() -> None:
assert "emitSyncStatus(status)" in body assert "emitSyncStatus(status)" in body
# ---------- sources.js: the event-driven result line + banner ---------- # ---------- sources.js: the page-specific result line + banner ----------
def test_sources_js_renders_off_the_sync_status_event() -> None: def test_sources_js_renders_result_line_and_banner_directly() -> None:
"""The Sources page keeps ONLY its page-specific rendering: """sources.js owns the page-specific rendering on the Sources page:
#sync-result (aria-live) + #sync-error-banner (role=alert), driven #sync-result (aria-live) + #sync-error-banner (role=alert), driven
by the module's 'bor:sync-status' event (detail = the status directly by the state appliers: success → the counts (fmtSyncResult)
object): running → clear + hide; success → the counts (the + a live catalog refresh; failed → the banner with the sanitized
imported fmtSyncResult) + a live catalog refresh; failed → the error; every state change still emits 'bor:sync-status' (the status
banner with the error; idle → hide + clear.""" object as detail) for other subscribers."""
js = _text(SOURCES_JS) js = _text(SOURCES_JS)
assert 'window.addEventListener("bor:sync-status"' in js success = _body(js, "applySyncSuccess")
assert 'status.state === "running"' in js assert "syncResult.textContent = fmtSyncResult(status.detail)" in success, (
assert 'status.state === "success"' in js "the counts live in status.detail — a bare status renders all zeros"
assert 'status.state === "failed"' in js )
assert "fmtSyncResult(status.detail)" in js assert "loadDocs()" in success, "the catalog re-fetches live on a successful sync"
assert "loadDocs()" in js, "the catalog re-fetches live on a successful sync" failure = _body(js, "applySyncFailure")
assert "showSyncError(status.error)" in js assert "showSyncError(error)" in failure
assert "syncResult.textContent" in js assert "emitSyncStatus" in _body(js, "applySyncIdle")
assert (
'window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }))'
in _body(js, "emitSyncStatus")
)
def test_sources_js_no_longer_owns_the_sync_machine() -> None: def test_header_js_no_longer_owns_the_sync_machine() -> None:
"""The state machine is GONE from sources.js (header.js owns it): """The state machine is GONE from header.js (sources.js owns it —
no button refs, no POST, no status poll, no button-state helpers, the button is Sources-page only, so the page drives it): no button
no click binding, no load re-attach.""" refs, no POST, no status poll, no button-state helpers, no modal.
js = _text(SOURCES_JS) sources.js keeps every one of them."""
header = _text(HEADER_JS)
for gone in ( for gone in (
"SYNC_POLL_MS", "SYNC_POLL_MS",
"syncPollTimer", "syncPollTimer",
"startSyncPolling", "startSyncPolling",
"stopSyncPolling", "stopSyncPolling",
"enterRunningState", "enterSyncRunningState",
"settleSyncButton", "settleSyncButton",
"applySyncSuccess", "applySyncSuccess",
"applySyncFailure", "applySyncFailure",
"applySyncIdle", "applySyncIdle",
"initSyncButton", "initSyncButton",
"startSync", "syncModal",
"syncBtn",
'fetch("/api/sync", { method: "POST" })', 'fetch("/api/sync", { method: "POST" })',
'fetch("/api/sync/status")', 'fetch("/api/sync/status")',
'querySelector("#sync-btn")', 'querySelector("#sync-btn")',
'querySelector("#sync-label")',
'querySelector(".sync-icon")',
): ):
assert gone not in js, f"{gone!r} must be gone from sources.js (header.js owns it)" assert gone not in header, f"{gone!r} must be gone from header.js (sources.js owns it)"
js = _text(SOURCES_JS)
for kept in (
"SYNC_POLL_MS = 2000",
"syncPollTimer",
"startSyncPolling",
"enterSyncRunningState",
"initSyncButton",
'fetch("/api/sync", { method: "POST" })',
'fetch("/api/sync/status")',
'querySelector("#sync-btn")',
):
assert kept in js, f"{kept!r} must be in sources.js (the owner)"
# ---------- styles.css: the §7.4 states ---------- # ---------- styles.css: the §7.4 states ----------
def test_sync_button_css_ghost_pill_and_disabled_state() -> None: def test_sync_button_css_ghost_pill_and_disabled_state() -> None:
""".sync-btn is the same ghost pill as .new-chat-btn (contrast pair """.sync-btn is a pill in the shared button family (contrast pair
ink-soft on surface ≈6.9:1 ≥ 4.5:1), with a ≥44px touch floor and a ink-soft on surface ≥ 4.5:1), with a ≥44px touch floor and a
:disabled state (never stale — the busy look is visible).""" :disabled state (never stale — the busy look is visible)."""
css = _text(STYLES_CSS) css = _text(STYLES_CSS)
block = re.search(r"\.sync-btn\s*\{([^}]*)\}", css) block = re.search(r"\.sync-btn\s*\{([^}]*)\}", css)