"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and the Containerfile line that ships it (A7). No Python logic exists for this task — the mechanism lives in ``brand.js`` (pinned by test_frontend_brand.py) and the theme is a drop-in stylesheet. Like the other frontend-adjacent unit files, this module pins the assets as text, so a silent regression (a theme file gaining a selector, a declaration drifting, the Containerfile line vanishing) is caught without a browser. The browser-visible layer (computed ``--brand``, the inserted ````) is E2E-gated by ``tests/e2e/test_ui_customization.py`` (task 05). """ from __future__ import annotations import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] FRONTEND = REPO_ROOT / "frontend" THEMES = FRONTEND / "assets" / "themes" INDIGO = THEMES / "indigo.css" GUIDE = THEMES / "README.md" CONTAINERFILE = REPO_ROOT / "Containerfile" #: The 8 identity variables a theme may override — and the EXACT set #: indigo.css ships (the semantic families accent/ok/err are states, #: not identity: a theme that overrides them stops being honest). IDENTITY_VARS = ( "--bg", "--surface", "--ink", "--ink-soft", "--line", "--brand", "--brand-soft", "--brand-ink", ) INDIGO_VALUES: dict[str, str] = { "--bg": "#0a0e1a", "--surface": "#111726", "--ink": "#e6e9f0", "--ink-soft": "#a8b0c8", "--line": "#232c44", "--brand": "#818cf8", "--brand-soft": "#1a1f38", "--brand-ink": "#c7d2fe", } def _text(path: Path) -> str: return path.read_text(encoding="utf-8") def _strip_comments(css: str) -> str: """Drop ``/* … */`` comments — the pins assert against declarations, not prose.""" return re.sub(r"/\*.*?\*/", "", css, flags=re.S) def _declarations(css: str) -> dict[str, str]: """The ``--name: value`` declarations of the (single) ``:root`` block, in file order.""" return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css)) def test_example_theme_files_exist() -> None: """The example theme and its authoring guide ship in the static dir (served at /assets/themes/… in dev AND in the image).""" assert INDIGO.is_file(), f"missing example theme: {INDIGO}" assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}" def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None: """The whole file is ONE ``:root`` block (the cascade is the entire mechanism): after stripping comments the first non-whitespace content is ``:root``, and no other rule, selector, or declaration exists anywhere in the file.""" css = _strip_comments(_text(INDIGO)) assert css.lstrip().startswith(":root"), ( "indigo.css must start with the :root block (after its header " "comment) — nothing may precede it" ) assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, ( "indigo.css must be exactly one :root block — no selectors, " "no @media, no nested or extra rules" ) def test_indigo_overrides_exactly_the_eight_identity_variables() -> None: """EXACTLY the 8 identity overrides with the locked values — no other declarations (a 9th declaration here would be the theme reaching past the palette), and the semantic families (accent/ok/err) must be untouched (they encode states).""" decls = _declarations(_strip_comments(_text(INDIGO))) assert set(decls) == set(IDENTITY_VARS), ( f"indigo.css must override exactly the 8 identity variables, got " f"{sorted(decls)}" ) for name in IDENTITY_VARS: assert decls[name].strip() == INDIGO_VALUES[name], ( f"{name} drifted from the locked value " f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}" ) for family in ("--accent-", "--ok-", "--err-"): assert not any(k.startswith(family) for k in decls), ( f"semantic {family}* variables must stay the built-in " f"theme (they encode states)" ) def test_indigo_identity_pairs_meet_wcag_aa() -> None: """The five identity text/background pairs, computed from the file's OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) — AGENTS.md rule 5. The pairs are the ones the layout actually pairs: ink on bg/surface, ink-soft on surface, the dark bg ink on brand (text on brand buttons is --bg, never white — the built-in's documented 3.7:1 trap), brand-ink on surface.""" decls = _declarations(_strip_comments(_text(INDIGO))) def lum(hexcolor: str) -> float: h = hexcolor.lstrip("#") chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) lin = [ c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in chans ] r, g, b = lin return 0.2126 * r + 0.7152 * g + 0.0722 * b def ratio(fg: str, bg: str) -> float: l1, l2 = lum(fg), lum(bg) return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05) pairs = ( ("ink on bg", decls["--ink"], decls["--bg"]), ("ink on surface", decls["--ink"], decls["--surface"]), ("ink-soft on surface", decls["--ink-soft"], decls["--surface"]), ("bg ink on brand", decls["--bg"], decls["--brand"]), ("brand-ink on surface", decls["--brand-ink"], decls["--surface"]), ) for name, fg, bg in pairs: r = ratio(fg, bg) assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)" def test_containerfile_ships_the_whole_themes_directory() -> None: """A7: stage 1 copies the WHOLE themes directory (no per-file esbuild — a future theme file needs no Containerfile edit), and it does so AFTER the styles.css minify line (so the served /assets/ tree is complete before the pages cp).""" cf = _text(CONTAINERFILE) stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0] lines = stage1.splitlines() cp_idxs = [ i for i, ln in enumerate(lines) if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln) ] assert len(cp_idxs) == 1, ( "stage 1 must ship the themes directory with exactly one " "'cp -r ./assets/themes /out/assets/themes' line" ) styles_idxs = [ i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln ] assert len(styles_idxs) == 1, "stage 1 must minify styles.css" assert cp_idxs[0] > styles_idxs[0], ( "the themes cp must come AFTER the styles.css minify line" ) cp_line = lines[cp_idxs[0]] assert "--bundle" not in cp_line and "esbuild" not in cp_line, ( "A7: the themes directory is copied verbatim — no per-file " "esbuild minify" ) def test_authoring_guide_pins_the_contract() -> None: """The guide documents the load path (BOR_THEME → /api/config → brand.js link after styles.css), the filename validator regex, the 8-variable table, the 4.5:1 bar, the never-white-on-brand trap, and the A7 rebuild story (a new file needs no Containerfile edit).""" guide = _text(GUIDE) for marker in ( "BOR_THEME", "/api/config", "styles.css", r"^[a-z0-9_-]+\.css$", "4.5:1", "white-on-brand", "cp -r ./assets/themes /out/assets/themes", ): assert marker in guide, f"themes/README.md must document {marker!r}" for var in IDENTITY_VARS: assert var in guide, f"the variable table must list {var}"