BOR_INPUT_PLACEHOLDER / BOR_FOOTER_TEXT / BOR_THEME (+ the indigo.css example theme); authoring guide: frontend/assets/themes/README.md, docs: README 'Customizing the look'.
196 lines
7.4 KiB
Python
196 lines
7.4 KiB
Python
"""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 ``<link>``) 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}"
|