"""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) and NOTHING else since phase 190 (task 02, A1: the admin Settings panel is the single writable owner of the 3 brand strings, so the tab's "Branding" fieldset, its three string inputs and the string half of its PUT body are gone). 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 17 entries (the colors ONLY — no ``kind: "string"`` entry may come back) in the FORM's order, the server's ``theming.COLOR_FIELDS`` order (identity, brand, then state): everything that iterates FIELDS (live preview, ``collectBody``'s PUT body, ``clearPreview``, ``applyServedTheme``' tag content) covers the 8 semantic pickers automatically only if this order holds — and the body ``collectBody`` builds carries no string key either (the phase-190 pin: an old cached client's save can no longer re-bake a brand string); * ``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`` — the 17 color inputs carry E2E-stable ids + visible labels, and the three removed string inputs stay absent (``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` — the phase-190 A1 strip); the 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); * phase 177 (task 04, A4) — the favicon live preview: the ``data:image/svg+xml,`` + ``encodeURIComponent`` mechanism, the ``isHex`` guard (a non-hex input returns ``null`` — the link is left untouched), the EXACTLY-three driving fields (the binding's gate + the helper call), the mirror's artwork constants pinned against ``app/core/theming.py``'s ``favicon_svg`` source (the divergence guard), and the ``/favicon.svg`` restore wired to the four settle points (Save success, Reset success, the Reset-failure ``clearPreview()`` path, the ``bor:view-refresh`` re-show — and to no other point). """ from __future__ import annotations import re from pathlib import Path from typing import TypedDict 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 17 color form fields, in order ---------- def test_fields_lists_the_17_colors_and_no_strings() -> None: """``FIELDS`` has EXACTLY 17 entries — the color fields ONLY — 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). Phase 190 (task 02, A1) is what makes this a CONTRACT and not a coincidence: the admin Settings panel is the single writable owner of ``app_name`` / ``input_placeholder`` / ``footer_text``, so no entry may carry ``kind: "string"`` — an entry means a populated input AND a PUT-body key, and a string key is exactly how the Theme tab used to bake the then-effective name into its row and shadow every later panel change (``TODO.md`` L3).""" 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) == 17, f"FIELDS must list exactly 17 entries, got {len(fields)}" assert [(f, i) for f, i, _ in fields] == [ ("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 17 color fields in the form's order" assert all(kind == "color" for _, _, kind in fields), ( "every palette entry is kind color — no string field is back in the tab" ) for name in theming.STRING_FIELDS: assert f'{{ field: "{name}"' not in js[start:end], ( f"{name} left the Theme tab in phase 190 — the panel owns it" ) 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" ) def test_collectbody_builds_the_17_colors_and_no_string_keys() -> None: """``collectBody`` — the PUT body the Save button sends — is FIELDS-driven and therefore carries the 17 colors ONLY: no string key appears anywhere in its source (phase 190, A1), and the old trimmed-string branch (``value.trim() || null`` — the bake) is gone. The server ignores body strings defensively too (``app/api/ui_settings.py``), so this is the client half of "a Theme-tab save can never shadow the Settings panel again".""" js = _js() start = js.index("function collectBody()") end = js.index("\n }", start) body_src = js[start:end] assert body_src, "collectBody must exist" for name in theming.STRING_FIELDS: assert name not in body_src, f"{name} must never ride the PUT body" assert "value.trim()" not in body_src, "the string-trim bake branch must be gone" assert "for (const f of FIELDS)" in body_src, ( "the body stays FIELDS-driven — one ordering source for the PUT" ) assert "isHex(value)" in body_src, "the colors keep their hex-or-null rule" # ---------- 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 (17 color inputs) ---------- def test_theme_view_carries_the_17_labeled_color_inputs() -> None: """The 17 color inputs are STATIC markup in the shell (the E2E-stable selectors convention), each with a visible ``