"""Unit: the phase-93 Theme-tab frontend contract (task 02 — source pins). The Theme tab edits ALL 17 palette variables (the 9 identity + the 8 semantic state colors — B3 revised, owner permission 2026-09-10, TODO.md L3) plus the 3 branding strings: 20 form fields total. The live behavior (live preview, the Save/Reset PUT body, the served-theme sync, the contrast warnings) is E2E-gated (``tests/e2e/ test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04); here we pin the source-level invariants the editor's FIELDS-driven design depends on, so a silent regression is caught without a browser (house pattern: ``tests/unit/test_big_read_progress.py`` reads frontend sources and asserts on their mechanisms): * ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order (the 3 branding strings, then the 17 colors in the server's ``theming.COLOR_FIELDS`` order — identity, brand, then state): everything that iterates FIELDS (live preview, ``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``'s tag content) covers the 8 semantic pickers automatically only if this order holds; * ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA (4.5:1) pairs: the five identity pairs + the three semantic ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED (decorative borders, no contrast duty — the same rule as ``--line`` / ``--grid-line``); the docstring mirror is pinned in ``tests/unit/test_theming.py``; * ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids + visible labels; the 17 color inputs are ``type="color"`` and ship the BUILT-IN static values — asserted against ``frontend/assets/styles.css``'s ``:root`` parsed in-test (the house drift pattern — the same guard ``test_theming.py`` runs on ``BUILTIN_COLORS``), never a third hardcoded palette copy; * the "State colors" fieldset sits AFTER the palette fieldset (the task-02 form shape), and the palette legend's "five pairs" copy is the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept). """ from __future__ import annotations import re from pathlib import Path from app.core import theming FRONTEND = Path(__file__).resolve().parents[2] / "frontend" INDEX_HTML = FRONTEND / "index.html" STYLES_CSS = FRONTEND / "assets" / "styles.css" THEME_JS = FRONTEND / "assets" / "theme.js" def _js() -> str: return THEME_JS.read_text(encoding="utf-8") def _html() -> str: return INDEX_HTML.read_text(encoding="utf-8") def _theme_view(body: str) -> str: """The ``#view-theme`` section slice (the router pin's convention: from the section's opening tag to the ```` that closes the single main).""" view = body.find('
", view) assert view < main_end, "the view section lives inside the single main" return body[view:main_end] def _root_declarations() -> dict[str, str]: """The ``--name: value`` declarations of styles.css's (first) ``:root`` block, comments stripped (test_theming's parser).""" css = STYLES_CSS.read_text(encoding="utf-8") match = re.search(r":root\s*\{", css) assert match is not None, "styles.css must have a :root block" block = css[match.end() : css.index("}", match.end())] block = re.sub(r"/\*.*?\*/", "", block, flags=re.S) return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block)) # ---------- theme.js: FIELDS — the 20 form fields, in order ---------- def test_fields_lists_all_20_in_form_order() -> None: """``FIELDS`` has EXACTLY 20 entries — the 3 branding strings, then the 17 color fields — in the form's own order (the palette fieldset's 9 identity pickers, then the State colors fieldset's 8 semantic pickers), each with its E2E-stable ``theme-*`` id and kind (the color entries are ``kind: "color"`` — the live preview, ``collectBody``, and the served-theme sync all key on it).""" js = _js() start = js.index("const FIELDS = [") end = js.index("];", start) fields = re.findall( r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}', js[start:end], ) assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}" assert [(f, i) for f, i, _ in fields] == [ ("app_name", "theme-app-name"), ("input_placeholder", "theme-placeholder"), ("footer_text", "theme-footer"), ("bg", "theme-bg"), ("surface", "theme-surface"), ("ink", "theme-ink"), ("ink_soft", "theme-ink-soft"), ("line", "theme-line"), ("grid_line", "theme-grid-line"), ("brand", "theme-brand"), ("brand_soft", "theme-brand-soft"), ("brand_ink", "theme-brand-ink"), ("ok_bg", "theme-ok-bg"), ("ok_ink", "theme-ok-ink"), ("err_bg", "theme-err-bg"), ("err_ink", "theme-err-ink"), ("err_line", "theme-err-line"), ("accent_bg", "theme-accent-bg"), ("accent_ink", "theme-accent-ink"), ("accent_line", "theme-accent-line"), ], "FIELDS must list the 20 fields in the form's order" assert all(kind == "color" for _, _, kind in fields[3:]), ( "the 17 palette entries are all kind color" ) assert all(kind == "string" for _, _, kind in fields[:3]) def test_fields_color_order_is_the_server_color_fields_order() -> None: """The 17 color entries of ``FIELDS`` follow the server's ``theming.COLOR_FIELDS`` order (identity, brand, then state) — the invariant that keeps ``collectBody``'s PUT body, the ``applyServedTheme`` tag content, and the pre-paint tag in the SAME order without a second ordering copy.""" js = _js() start = js.index("const FIELDS = [") end = js.index("];", start) fields = re.findall( r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}', js[start:end], ) color_fields = [f for f, _, kind in fields if kind == "color"] assert tuple(color_fields) == theming.COLOR_FIELDS, ( "FIELDS' color order must equal the server's COLOR_FIELDS order" ) # ---------- theme.js: PAIRS — exactly the eight WCAG pairs ---------- def test_pairs_has_exactly_the_eight_pairs() -> None: """``PAIRS`` has EXACTLY 8 entries (the five identity pairs, then the three semantic ink-on-bg pairs — phase 93) in the authoritative order; no other pair is warned about (the list is the whole warning surface).""" js = _js() start = js.index("const PAIRS = [") end = js.index("];", start) pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end]) assert pairs == [ ("ink", "bg"), ("ink", "surface"), ("ink_soft", "surface"), ("bg", "brand"), ("brand_ink", "surface"), ("ok_ink", "ok_bg"), ("err_ink", "err_bg"), ("accent_ink", "accent_bg"), ], f"PAIRS must be exactly the eight pairs, got {pairs}" def test_line_vars_are_excluded_from_pairs() -> None: """The two ``*_line`` state variables (like ``line`` / ``grid_line``) are DECORATIVE borders — no contrast duty — so none of them appears in a PAIRS entry (the ``err_line`` / ``accent_line`` exclusion the phase-93 design locked; the identity ``line`` / ``grid_line`` exclusion predates it).""" js = _js() start = js.index("const PAIRS = [") end = js.index("];", start) pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end]) for field, _ in pairs: assert not field.endswith("_line") and field not in ("line", "grid_line"), ( f"decorative border var {field} must not be a contrast foreground" ) for _, background in pairs: assert not background.endswith("_line") and background not in ("line", "grid_line"), ( f"decorative border var {background} must not be a contrast background" ) # ---------- index.html: the #view-theme form (20 inputs) ---------- def test_theme_view_carries_all_20_labeled_inputs() -> None: """All 20 inputs are STATIC markup in the shell (the E2E-stable selectors convention): each with a visible ``