Splits the 2571-line cassandra.css into ten focused stylesheets: tokens (palette + fonts), layout (chrome), panels, dashboard, portfolio, log-chat, auth, settings, news, public. base.html and public_base.html load only what they need; auth pages (login, verify, unsubscribe confirm) load tokens + layout + auth. Brand drift-detection test repointed at tokens.css (where the palette now lives). 291 tests still pass.
83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
"""Drift-detection: brand palette in `app/branding.py` must match the CSS.
|
|
|
|
Both the website (tokens.css) and the email templates use the same
|
|
palette. The CSS hand-authors the values in :root and [data-theme="light"]
|
|
blocks; this test parses those blocks and asserts every variable matches
|
|
its counterpart in branding.py. If a colour changes, both must change.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app import branding
|
|
|
|
|
|
CSS_PATH = Path(__file__).resolve().parent.parent / "app" / "static" / "css" / "tokens.css"
|
|
|
|
|
|
def _extract_vars(css: str, selector: str) -> dict[str, str]:
|
|
"""Parse `--name: value;` declarations inside the first matching
|
|
selector block. Strips whitespace; lowercases hex values."""
|
|
# Match the selector followed by its block. Non-greedy on the body to
|
|
# stop at the first closing brace at the same depth (these blocks
|
|
# don't nest in tokens.css).
|
|
pattern = re.escape(selector) + r"\s*\{([^}]*)\}"
|
|
m = re.search(pattern, css)
|
|
if not m:
|
|
raise AssertionError(f"selector {selector!r} not found in CSS")
|
|
body = m.group(1)
|
|
out: dict[str, str] = {}
|
|
for line in body.splitlines():
|
|
decl = re.match(r"\s*--([a-z0-9-]+)\s*:\s*([^;]+);", line)
|
|
if not decl:
|
|
continue
|
|
name, value = decl.group(1), decl.group(2).strip().lower()
|
|
out[name] = value
|
|
return out
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def css_text() -> str:
|
|
return CSS_PATH.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_light_palette_matches_css(css_text):
|
|
# Light is the default — lives in `:root`. Dark is opt-in via the
|
|
# data-theme attribute toggle.
|
|
css_light = _extract_vars(css_text, ":root")
|
|
for key, expected in branding.LIGHT.items():
|
|
actual = css_light.get(key)
|
|
assert actual == expected.lower(), (
|
|
f"LIGHT[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
|
|
)
|
|
|
|
|
|
def test_dark_palette_matches_css(css_text):
|
|
css_dark = _extract_vars(css_text, '[data-theme="dark"]')
|
|
for key, expected in branding.DARK.items():
|
|
actual = css_dark.get(key)
|
|
assert actual == expected.lower(), (
|
|
f"DARK[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
|
|
)
|
|
|
|
|
|
def test_palette_keys_match_between_themes():
|
|
"""If a colour is defined in dark, it must also be defined in light
|
|
(and vice versa) — otherwise the theme switch leaves elements
|
|
unstyled."""
|
|
assert set(branding.DARK.keys()) == set(branding.LIGHT.keys())
|
|
|
|
|
|
def test_email_uses_branding_palette():
|
|
"""Sanity: the rendered OTP HTML should contain at least one of each
|
|
theme's key colours, confirming the substitution actually wired up."""
|
|
from app.services.email_service import render_otp_email
|
|
|
|
_, _, html = render_otp_email("123456", 15)
|
|
assert branding.LIGHT["accent"] in html
|
|
assert branding.DARK["accent"] in html
|
|
assert branding.LIGHT["bg"] in html
|
|
assert branding.DARK["bg"] in html
|