feat(sources): removing a source deletes its files and index entries behind a confirmation modal
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
"""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`` + ``git-sources.html``; 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"
|
||||
HTML = FRONTEND / "git-sources.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 git-sources.html"
|
||||
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(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 + git-sources.html
|
||||
(code AND comments — the docstring copy moved with the flow).
|
||||
The README pins are task 03's."""
|
||||
for path in (JS, 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(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)"
|
||||
Reference in New Issue
Block a user