473 lines
22 KiB
Python
473 lines
22 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`` + ``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)"
|