"""Buttons that destroy something look like it, everywhere. `button.danger` only coloured the label, so a destructive button was the default purple with red text on it: the look of a primary action. Test users read it as one. It is now solid red with white text, and every button that deletes, removes, revokes or unlinks carries the class. """ from __future__ import annotations import re from pathlib import Path import pytest import factories as fac from test_routes import bind_to_only_portfolio, make_login ROOT = Path(__file__).resolve().parent.parent TEMPLATES = ROOT / "app" / "templates" CSS = (ROOT / "app" / "static" / "style.css").read_text(encoding="utf-8") HTML = {"accept": "text/html"} DESTROYS = re.compile(r"/(delete|remove|revoke|unlink)\b") def _luminance(hex_colour: str) -> float: channels = [int(hex_colour[i:i + 2], 16) / 255 for i in (1, 3, 5)] linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] def _contrast(a: str, b: str) -> float: high, low = sorted((_luminance(a), _luminance(b)), reverse=True) return (high + 0.05) / (low + 0.05) def _theme_blocks() -> dict[str, str]: """The light tokens, and the two ways of asking for dark.""" light = CSS[CSS.index(":root {"):] dark_auto = CSS[CSS.index('body[data-theme="auto"] {'):] dark = CSS[CSS.index('body[data-theme="dark"] {'):] return {name: block[:block.index("}")] for name, block in (("light", light), ("auto dark", dark_auto), ("dark", dark))} @pytest.mark.parametrize("theme", ["light", "auto dark", "dark"]) def test_white_reads_on_the_danger_red_in_every_theme(theme): """WCAG AA for body text, 4.5:1. The dark theme's `--down` (#ef4444) is only 3.8:1 behind white, which is why the button has its own token.""" found = re.search(r"--danger:\s*(#[0-9a-fA-F]{6})", _theme_blocks()[theme]) assert found, f"no --danger colour in the {theme} theme" assert _contrast(found.group(1), "#ffffff") >= 4.5, found.group(1) def test_the_danger_button_is_solid_red_with_white_text(): rule = re.search(r"\nbutton\.danger, a\.button\.danger\s*\{([^}]*)\}", CSS) assert rule, "no button.danger rule" assert re.search(r"background:\s*var\(--danger\)", rule.group(1)), rule.group(1) assert re.search(r"color:\s*#fff(fff)?\b", rule.group(1)), rule.group(1) def _forms_that_destroy() -> list[tuple[str, str, str]]: """(template, form id or "", the form's markup) for every POST form whose action deletes, removes, revokes or unlinks. Signing a session out is not on the list though its route says "revoke": nothing is lost, and a red button there would be alarm for nothing.""" found = [] for path in sorted(TEMPLATES.glob("*.html")): text = path.read_text(encoding="utf-8") for match in re.finditer(r"
", text, re.S): tag = match.group(0)[:match.group(0).index(">") + 1] action = re.search(r'action="([^"]*)"', tag) if (action and DESTROYS.search(action.group(1)) and not action.group(1).startswith("/profile/sessions/")): form_id = re.search(r'\bid="([^"]+)"', tag) found.append((path.name, form_id.group(1) if form_id else "", match.group(0))) return found def test_there_are_destructive_forms_to_check(): """An empty sweep proves nothing.""" assert len(_forms_that_destroy()) >= 8 def test_every_button_that_destroys_something_is_a_danger_button(): everything = "\n".join(p.read_text(encoding="utf-8") for p in TEMPLATES.glob("*.html")) plain = [] for name, form_id, markup in _forms_that_destroy(): buttons = re.findall(r"