Files
brain-of-reese/tests/unit/test_remove_confirm_modal.py
T
ducoterra ffa919b8bf fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the
five navbar views (Chat, RAG, Sources, Tuning, History) were separate
HTML documents, so a navbar click was a REAL cross-document navigation
— the chat page unloaded, the in-flight SSE fetch was aborted, and the
phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled")
stopped the model. Observed: send question -> click RAG mid-stream ->
click Chat -> the answer never finished: no `query_log` row, and on
return a dangling question with no brain record (the pre-token pagehide
partial persist skips because `acc` is empty).

Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06,
flagged per AGENTS.md rule 3, not silently deviated): "real navigation
cancels the fetch" now means LEAVING THE APP — tab close,
external/other-document navigation, the Stop button. In-app navbar
switches are client-side view switches and no longer cancel.

Fix — Option A (SPA shell), chosen over B (Service Worker owns the
stream) and C (server-side turn registry + resume):
- frontend/index.html is the shell: ONE `<main id="main">` holds the
  five `<section class="view">` blocks; hidden views carry BOTH
  `hidden` and `inert` (WCAG — no focus/keyboard traversal). The
  shared header, the single `doc-modal-*` skeleton, and the
  `#app-version` footer each exist exactly once; the per-view copies
  from the four folded pages are dropped.
- New frontend/assets/router.js (vanilla module — no framework, no
  bundler, No-CDN rule intact): lazy-imports a view module on FIRST
  show only (mount-once, hide-forever — the chat view's in-flight SSE
  reader persists across switches; that persistence IS the fix);
  intercepts same-shell navbar links with preventDefault +
  history.pushState (never a document load); handles popstate; single
  writer of `.nav-link` active state (is-active + aria-current),
  document.title, and the per-view meta description (values carried
  over from the old pages' heads, brand-resolved at write time).
- Each folded page's JS becomes `export async function mount(root)` —
  root-scoped queries; `initSharedHeader()` dropped (the header boots
  once in the shell via the chat module; the admin flag comes from the
  same cached `fetchIsAdmin()` promise — zero extra requests).
- app/main.py: a small list-driven route factory serves the shell for
  /tuning.html, /sources.html, /git-sources.html, /history.html —
  registered AFTER the API routers and BEFORE the static catch-all
  (routes-first). The phase-33 caching middleware applies no-cache +
  `?v=` rewriting unchanged; app/core/caching.py needed NO change
  (the view paths did not change — pinned by the integration tests).
- The four old view .html files are DELETED (one source of truth);
  deep links to the old URLs keep working (the router picks the view
  from the pathname); `/?chat=<id>` is unaffected; the Containerfile
  bundles router.js (inlining the lazy view modules) and drops the
  folded page files.
- app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell
  keeps long saved answers in the chat, and the old cap (stricter than
  the 24_000-char total history budget) 422-rejected any second turn
  in such a chat (found by the phase-42 E2E suite on the shell).

Boundaries: login.html, shared.html, doc-edit.html, document.html
REMAIN separate documents (flow pages, not navbar tabs); a mid-stream
navigation to doc-edit/document.html still cancels per phase 48
(follow-up candidate, out of scope). The SSE API is unchanged. Real
departures still cancel the turn — phase 48 intact (pinned by
tests/e2e/test_stop_generation.py, unchanged, and by the new suite's
real-departure control).

Tests:
- Phase-20 suite REWRITTEN to the new semantics
  (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer
  cancels — the stream survives the switch and the FULL answer
  settles; the pagehide partial persist REMAINS for real departures
  (the partial's exact shape — first streamed chunk prefix, no done
  metadata — is still pinned there).
- NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock
  LLM): the owner repro (send -> RAG mid-stream -> Chat: window
  sentinel survives = same document, FULL answer, exactly one brain
  turn in bor.chat.v1, exactly one settled query_log row, auto-saved
  row matches) + the same mid-stream switch against the other three
  views + the real-departure-still-cancels control + the no-switch
  baseline.
- tests/unit/test_frontend_router.py: source-level pins of the router
  invariants (click interceptor targets ONLY same-shell view paths,
  pushState-only switches, mount-once guard, hidden+inert pair,
  single-writer active state/title); shell-route integration tests
  (each folded path serves the shell with no-cache + `?v=` body; a
  non-view path still 404s); the file-reading unit pins re-pointed at
  the shell (the four view files are gone — the shell is the source
  of truth).

Verification (this commit): full suite green — 1565 unit+integration
tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the
phase's E2E suites green in isolation (house protocol, AGENTS.md rule
9). Owner repro verified in a real browser against the real LLM
(dev server :8010, headful Chromium): "tell me about everquest" ->
RAG mid-stream -> Chat — the answer completed with one brain bubble
and no error banner, `query_log` gained exactly one settled row
(deflected=True: the dev KB holds no EverQuest docs — the settle, not
the topic, is the proof), zero "chat: turn cancelled" lines for that
turn; the control (real navigation to /shared.html mid-stream) still
cancelled (no settled row, the cancel line logged, the partial
persisted on return). Screenshots: .agents/screenshots/76_manual_*.

Phase 76 (76_spa_nav_shell) complete — moved to
.agents/phases/complete/.
2026-09-06 06:31:31 -04:00

477 lines
23 KiB
Python

"""Unit: the remove confirmation modal on /git-sources.html (phase 69,
task 02).
Removal is a TOTAL removal (owner request 2026-09-02): the row, the
source's indexed documents (chunks + embeddings), and — for git clones
and uploaded archives — the files on the server's disk, all immediately
(task 01's ``DELETE /api/git-sources/{id}`` rewire). The page must
confirm that through a real in-app ``role="alertdialog"`` modal —
``window.confirm`` is retired — that names the source and states the
policy BEFORE the request goes out.
The browser behavior itself is E2E-gated by the phase-69 story suite
(``tests/e2e/test_source_removal_cleanup.py`` — task 03: focus,
Escape, ≥44px targets, the a11y interaction layer); like the other
frontend-adjacent unit files (``test_stale_ui_copy.py`` /
``test_summary_edit_ui.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* ``window.confirm`` is GONE from the whole frontend (the only
native confirm the app ever shipped);
* the static ``#remove-confirm-dialog`` markup — ``role="alertdialog"``,
``aria-modal``, ``aria-labelledby``/``aria-describedby``, hidden by
default, the six child ids, the ``role="alert"`` error line, real
``type="button"`` buttons, the locked modal copy (verbatim);
* the JS lifecycle — ``openRemoveConfirm`` (textContent-only source
population with makeRow's ``value`` expression, error cleared, focus
on Cancel, the trigger recorded), cancel = Escape / Cancel button /
backdrop (no request; focus returns to the trigger; a no-op while a
DELETE is in flight), ``confirmRemove`` (§7.4 in-flight state: both
buttons disable + "Removing…", success → close/reload/announce —
the removal confirmation is the LAST announcement, non-2xx → the
in-modal alert line + dialog stays open, network →
the fixed reachable? line, re-enable in the finally);
* the stale "prunes on the next sync" removal copy is GONE from
``git-sources.js`` + the shell (phase 76 task 02 folded git-
sources.html into index.html's Sources view); the new hint copy is
PRESENT (the README pins are task 03's);
* styles.css — the modal classes on the house dark-tech palette
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
``[hidden]`` override, the err-token destructive pair.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
# Phase 76 (task 02): git-sources.html is folded into the ONE-document
# shell — the dialog markup now lives in the Sources view section of
# index.html (moved verbatim, ids unchanged).
SHELL_HTML = FRONTEND / "index.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The locked modal copy (phase 69, 00_phase.md) — one fixed paragraph
#: for both kinds (the UI cannot tell an upload from the owner's own
#: directory; naming the source above it makes the target
#: unambiguous). Pinned verbatim against the normalized text.
MODAL_COPY = (
"This permanently removes the source entry, all of its indexed "
"documents from the knowledge base, and — for git clones and "
"uploaded archives — the files on the server's disk. Files in "
"your own local directories are never touched. This cannot be undone."
)
#: The new hint-box contract (phase 69): removal is immediate and
#: total; the modal spells it out; the Sync button mirrors the
#: remaining sources (upstream churn is still pruned on that run).
HINT_TOTAL_REMOVAL = "Removing a source is a total removal, done immediately"
HINT_MODAL_SPELLS_OUT = (
"the confirmation modal spells out exactly what will be deleted"
)
HINT_FOREVER_SAFE = "files in your own local directories are never touched"
#: The success announce (the existing #git-sources-announcer live
#: region).
ANNOUNCE_OK = "Source removed — its files and index entries were cleaned up."
#: The retired confirm copy (task 02 stale-copy pins).
OLD_STAY_INDEXED = "stays indexed until the next sync"
# "prunes/pruned … on the next sync" in any shape (the old hint +
# confirm message).
OLD_NEXT_SYNC = re.compile(r"prun\w+[^.]{0,120}?next sync")
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _norm(text: str) -> str:
"""Collapse whitespace runs — the markup wraps long lines, so the
locked copy is pinned against the normalized text."""
return re.sub(r"\s+", " ", text).strip()
def _js() -> str:
return _text(JS)
def _css() -> str:
return _text(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_summary_edit_ui.py helper)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in git-sources.js")
def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
"""The <tag … id=…> element's full markup (a balanced-tag walk —
the dialog nests the backdrop / panel / actions divs; the hint is
a <p>)."""
marker = f'id="{id_attr}"'
i = html.find(marker)
assert i != -1, f"missing id={id_attr} in the shell's Sources view"
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
assert opens, f"no <{tag}> owns id={id_attr}"
open_i = opens[-1]
depth = 0
for m in re.finditer(rf"<{tag}\b[^>]*>|</{tag}>", html[open_i :]):
if m.group(0).startswith(f"</{tag}>"):
depth -= 1
else:
depth += 1
if depth == 0:
return html[open_i : open_i + m.end()]
raise AssertionError(f"unbalanced <{tag}> for id={id_attr}")
# ---------- window.confirm is gone (the whole frontend) ----------
def test_window_confirm_is_gone_from_the_whole_frontend() -> None:
"""``window.confirm`` — the only native confirm the app ever
shipped — is absent from EVERY frontend file (markup, scripts,
styles — comments included: the retirement note must not keep the
literal)."""
for path in sorted(FRONTEND.rglob("*")):
if path.is_file():
assert "window.confirm" not in path.read_text(encoding="utf-8"), (
f"window.confirm survived in {path.relative_to(FRONTEND)}"
)
# ---------- the static dialog markup ----------
def test_dialog_markup_is_the_locked_alertdialog() -> None:
"""#remove-confirm-dialog: role="alertdialog" + aria-modal + the
labelled/describedby pair, hidden by default, inside the manager
(#git-sources-content — the static-markup convention, stable E2E
selectors); all six child ids present; the error line is
role="alert"; both buttons are real type="button"; the title is
the locked h2; the modal copy is the locked paragraph verbatim."""
html = _text(SHELL_HTML)
frag = _element_block(html, "remove-confirm-dialog")
open_tag = frag[: frag.find(">") + 1]
assert 'role="alertdialog"' in open_tag
assert 'aria-modal="true"' in open_tag
assert 'aria-labelledby="remove-confirm-title"' in open_tag
assert 'aria-describedby="remove-confirm-copy"' in open_tag
assert "hidden" in open_tag, "the dialog ships hidden"
# Static markup INSIDE the manager (the #git-sources-hint / gate
# convention) — E2E can wait for the content to be revealed.
assert html.find('id="git-sources-content"') < html.find("remove-confirm-dialog")
for child in (
"remove-confirm-title",
"remove-confirm-source",
"remove-confirm-copy",
"remove-confirm-error",
"remove-confirm-cancel",
"remove-confirm-remove",
):
assert f'id="{child}"' in frag, f"missing #{child} in the dialog"
title = re.search(r"<h2[^>]*id=\"remove-confirm-title\"[^>]*>(.*?)</h2>", frag, re.S)
assert title and _norm(title.group(1)) == "Remove this source?"
err = re.search(r"<p[^>]*id=\"remove-confirm-error\"[^>]*>", frag)
assert err and 'role="alert"' in err.group(0), "the in-modal error is role=alert"
for btn in ("remove-confirm-cancel", "remove-confirm-remove"):
m = re.search(rf"<button[^>]*id=\"{btn}\"[^>]*>", frag)
assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button"
cancel = re.search(r'<button[^>]*id="remove-confirm-cancel"[^>]*>(.*?)</button>', frag, re.S)
remove = re.search(r'<button[^>]*id="remove-confirm-remove"[^>]*>(.*?)</button>', frag, re.S)
assert cancel and _norm(cancel.group(1)) == "Cancel"
assert remove and _norm(remove.group(1)) == "Remove source"
assert _norm(MODAL_COPY) in _norm(frag), "the locked modal copy, verbatim"
# The source value is a <code> (mono) — textContent-only in JS.
src = re.search(r"<code[^>]*id=\"remove-confirm-source\"[^>]*>", frag)
assert src, "#remove-confirm-source is a <code>"
# ---------- the JS lifecycle ----------
def test_open_populates_source_via_text_content_and_focuses_cancel() -> None:
"""openRemoveConfirm(s, triggerBtn): the source value is
textContent ONLY (never innerHTML — the credential-masking
discipline, phase 32) with makeRow's exact ``value`` expression
(``s.path ?? s.url`` for local rows, ``s.url`` for git); the error
line clears; the dialog unhides; the trigger is recorded; the
keydown handler attaches; and focus lands on Cancel — the safe
default for a destructive action (AFTER the unhide)."""
body = _fn(_js(), "openRemoveConfirm")
assert "s.kind === \"local\"" in body, "the kind-typed value branch"
assert (
"removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body
), "the same `value` expression makeRow uses, via textContent"
assert "removeSourceEl.innerHTML" not in _js(), "XSS contract: textContent only"
assert "removeError.hidden = true" in body, "a new attempt starts clean"
assert "removeTriggerBtn = triggerBtn" in body, "the trigger is recorded"
trigger_i = body.find("removeTriggerBtn = triggerBtn")
unhide_i = body.find("removeDialog.hidden = false")
attach_i = body.find('document.addEventListener("keydown", onRemoveDialogKeydown)')
focus_i = body.find("removeCancelBtn.focus()")
assert -1 < trigger_i < unhide_i < attach_i < focus_i, (
"record the trigger → unhide → attach keydown → focus Cancel"
)
def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None:
"""makeRow's per-row Remove button opens the modal
(openRemoveConfirm(s, btn)) — no confirm call, no per-row error
span (the modal carries the in-flight error line); the retired
.git-source-row-error class is gone from the JS AND the CSS."""
js = _js()
make = _fn(js, "makeRow")
assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal"
assert "removeSource(" not in js, "the window.confirm-era flow is gone"
assert "git-source-row-error" not in js
assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired"
def test_cancel_paths_close_without_a_request() -> None:
"""Cancel (Cancel button / Escape / backdrop) closes as cancel:
the dialog hides, the error line clears, the buttons reset
("Remove source"), the keydown handler detaches, and focus
RETURNS to the recorded trigger — and the cancel path never
fetches. A cancel while a DELETE is in flight is a no-op (no
half-cancel of an in-progress server-side removal)."""
js = _js()
cancel = _fn(js, "cancelRemoveConfirm")
guard_i = cancel.find("if (removeInFlight) return")
close_i = cancel.find("closeRemoveConfirm()")
assert -1 < guard_i < close_i, "the in-flight guard precedes the close"
assert "fetch" not in cancel, "cancel never sends a request"
close = _fn(js, "closeRemoveConfirm")
hide_i = close.find("removeDialog.hidden = true")
clear_i = close.find("removeError.hidden = true")
reset_i = close.find('removeRemoveBtn.textContent = "Remove source"')
detach_i = close.find('document.removeEventListener("keydown", onRemoveDialogKeydown)')
save_i = close.find("const trigger = removeTriggerBtn")
null_i = close.find("removeTriggerBtn = null")
focus_i = close.find("trigger.focus()")
assert -1 < hide_i < clear_i < reset_i < detach_i < save_i < null_i < focus_i, (
"hide → clear → reset → detach → save trigger → focus return"
)
assert "removeCancelBtn.disabled = false" in close
assert "removeRemoveBtn.disabled = false" in close
def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None:
"""While open (handler attached on document in openRemoveConfirm,
detached in closeRemoveConfirm): Escape → preventDefault +
cancelRemoveConfirm; the dim backdrop AND the Cancel button wire
to cancelRemoveConfirm (only "Remove source" wires to
confirmRemove). Tab/Shift+Tab stay inside the two-button modal
(aria-modal honored for keyboard users)."""
js = _js()
keydown = _fn(js, "onRemoveDialogKeydown")
esc_i = keydown.find('e.key === "Escape"')
prevent_i = keydown.find("e.preventDefault()", esc_i)
cancel_i = keydown.find("cancelRemoveConfirm()", esc_i)
assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel"
assert 'e.key === "Tab"' in keydown, "the two-button focus cycle"
assert 'removeCancelBtn.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeBackdrop.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeRemoveBtn.addEventListener("click", confirmRemove)' in js
def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
"""confirmRemove: the §7.4 in-flight state BEFORE the fetch —
both buttons disable + the confirm relabels "Removing…"; one
DELETE /api/git-sources/{id}. Success (204): close (focus return)
→ loadSources → announce (the removal confirmation is the LAST
announcement — the reload's "N sources listed." must not
overwrite it). Non-2xx: the in-modal role=alert line
(apiDetail, 422 shape-aware) and the dialog STAYS open (no
closeRemoveConfirm in the failure slice). Network failure: the
fixed reachable? line. The finally re-enables BOTH buttons +
relabels "Remove source" — never stale on any outcome."""
body = _fn(_js(), "confirmRemove")
guard_i = body.find("if (!removingId || removeInFlight) return")
inflight_i = body.find("removeInFlight = true")
clear_i = body.find("removeError.hidden = true")
dis_c = body.find("removeCancelBtn.disabled = true")
dis_r = body.find("removeRemoveBtn.disabled = true")
label_i = body.find('"Removing…"')
fetch_i = body.find("fetch(`/api/git-sources/${encodeURIComponent(removingId)}`")
method_i = body.find('method: "DELETE"', fetch_i)
assert -1 < guard_i < inflight_i < clear_i < dis_c < dis_r < label_i < fetch_i < method_i, (
"guard → in-flight → clear error → disable both + label → DELETE"
)
# Success: close → reload → announce (the exact order, the exact
# announce string) — the removal confirmation is the LAST
# announcement: the reload's "N sources listed." must not
# overwrite it (phase 69 task 03's E2E pins the success line on
# the announcer after a real removal).
ok_i = body.find("if (r.ok)")
close_i = body.find("closeRemoveConfirm()", ok_i)
reload_i = body.find("await loadSources()", close_i)
announce_i = body.find(f'announce("{ANNOUNCE_OK}")', reload_i)
assert -1 < ok_i < close_i < reload_i < announce_i
assert body.count("closeRemoveConfirm()") == 1, (
"only the success path closes — failures stay open for one retry"
)
# Non-2xx: the in-modal alert line, dialog stays open.
nonok_i = body.find("Could not remove the source — try again.")
catch_i = body.find("} catch {")
assert -1 < nonok_i < catch_i
assert "await apiDetail(r," in body[body.find("if (r.ok)"):catch_i], (
"the server detail is apiDetail-extracted (422 shape-aware)"
)
failure_slice = body[body.rfind("// non-2xx", 0, catch_i):catch_i]
assert "closeRemoveConfirm" not in failure_slice, "failure keeps the dialog open"
# Network: the fixed reachable? line.
net_i = body.find("Could not remove the source — is the app reachable?", catch_i)
assert -1 < net_i < body.find("finally"), "the network copy lands in the catch"
# Never stale: the finally re-enables BOTH buttons + relabels.
fin_i = body.find("finally")
fin = body[fin_i:]
assert "removeInFlight = false" in fin
assert "removeCancelBtn.disabled = false" in fin
assert "removeRemoveBtn.disabled = false" in fin
assert 'removeRemoveBtn.textContent = "Remove source"' in fin
# ---------- the stale copy is gone; the new hint is present ----------
def test_stale_next_sync_removal_copy_is_gone() -> None:
"""The phase-35 "prunes on the next sync" removal contract is
superseded: the retired confirm copy AND any 'prune(s/d) … next
sync' shape are absent from git-sources.js + the shell (code AND
comments — the docstring copy moved with the flow). The README
pins are task 03's."""
for path in (JS, SHELL_HTML):
raw = _text(path)
norm = _norm(raw)
assert OLD_STAY_INDEXED not in raw, (
f"retired confirm copy still in {path.name}"
)
m = OLD_NEXT_SYNC.search(norm)
assert m is None, f"'next sync' removal copy survived in {path.name}: {m.group(0)!r}"
def test_new_hint_copy_is_present_in_the_html() -> None:
"""The #git-sources-hint carries the new contract: removal is a
total removal, done immediately (the modal spells it out; foreign
local directories are never touched), and the Sync button still
mirrors the remaining sources (upstream churn is pruned on that
run — not 'on the next sync')."""
hint = _norm(_element_block(_text(SHELL_HTML), "git-sources-hint", tag="p"))
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
assert frag in hint, f"the new hint copy is missing: {frag!r}"
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
assert "next sync" not in hint, "no 'next sync' removal claim in the hint"
def test_module_docstring_carries_the_new_contract() -> None:
"""The git-sources.js module docstring's remove bullet + scope
boundary moved with the flow: the modal names the source + states
the policy, and removal performs the FULL cleanup server-side
(row + index + app-managed files) — with the navigating-away
note (the row + index commit first; an interrupted file step
leaves an inert orphan dir)."""
doc = _js().split("*/", 2)[0] # the module docstring (first block)
for frag in (
'role="alertdialog"',
"textContent ONLY",
'relabels "Removing…"',
"not recommended",
"inert orphan",
"FULL cleanup server-side",
"foreign local directories are never touched",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------- styles.css ----------
def test_modal_css_classes_present_and_house_tokens_only() -> None:
"""styles.css carries the modal class family on the house
dark-tech palette (phase-08 tokens): the overlay + backdrop +
panel, the title / source / copy / error / actions / two-button
chrome; the [hidden] override (the documented, testable
contract); the global 3px :focus-visible outline is NOT
suppressed; no CDN; no blur (the phase-08 no-blur perf anchor)."""
css = _css()
for cls in (
".remove-confirm",
".remove-confirm-backdrop",
".remove-confirm-panel",
".remove-confirm-title",
".remove-confirm-source",
".remove-confirm-copy",
".remove-confirm-error",
".remove-confirm-actions",
".remove-confirm-btn",
".remove-confirm-cancel",
".remove-confirm-remove",
):
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
f"styles.css must style {cls}"
)
hidden = css.find(".remove-confirm[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 60], (
"the hidden attr must beat the display rule"
)
assert ":focus-visible {" in css and "outline: 3px solid var(--brand)" in css
# No blur (the phase-08 no-blur perf anchor) — the backdrop rule
# itself must not carry backdrop-filter.
backdrop = css[css.find(".remove-confirm-backdrop {") :]
backdrop = backdrop[: backdrop.find("\n}")]
# Comments stripped — the note "no backdrop-filter (no-blur)" must
# not trip the pin; only a real declaration may.
backdrop = re.sub(r"/\*.*?\*/", "", backdrop, flags=re.S)
assert "backdrop-filter" not in backdrop, "no blur (phase-08 perf anchor)"
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_modal_css_targets_and_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both buttons >=44px; the
destructive button rides the err token family (err-ink on err-bg
9.3:1, the err-line border — the .tuning-delete / .steering-delete
convention; the hover inverts to --bg on --err-line, 5.2:1);
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
error line is the err pair; the panel caps at the 46rem
chat-column width or the viewport."""
css = _css()
btn = css[css.find(".remove-confirm-btn {"):]
btn = btn[: btn.find("\n}")]
assert "min-height: 44px" in btn and "min-width: 44px" in btn
remove = css[css.find(".remove-confirm-remove {"):]
remove = remove[: remove.find("\n}")]
for prop in ("var(--err-bg)", "var(--err-ink)", "var(--err-line)"):
assert prop in remove, f"the destructive pair must keep {prop}"
hover = css[css.find(".remove-confirm-remove:hover:not(:disabled) {"):]
hover = hover[: hover.find("\n}")]
assert "var(--err-line)" in hover and "var(--bg)" in hover, (
"the hover inversion: dark --bg on --err-line (5.2:1)"
)
cancel = css[css.find(".remove-confirm-cancel {"):]
cancel = cancel[: cancel.find("\n}")]
assert "var(--ink-soft)" in cancel and "transparent" in cancel
err = css[css.find(".remove-confirm-error {"):]
err = err[: err.find("\n}")]
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = css[css.find(".remove-confirm-panel {"):]
panel = panel[: panel.find("\n}")]
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"