"""The built-in identity palette + the effective UI-settings resolver (phase 91). Single source of the built-in **identity** palette. Phase 62's custom-CSS-file theming (an env var named a drop-in ``:root`` override stylesheet that ``brand.js`` linked AFTER the boot fetch — the "red first, then pop" the owner saw) is retired in this phase: task 03 deleted the env var, the example-stylesheet directory, and the link insertion, and the admin Theme tab is now the only theming surface. The contract that directory's authoring guide carried is re-homed here (built-in table, the five contrast pairs, the never-white-on-brand trap — see below), and the 8 variables + built-in values are the authoritative table (the unit drift test parses ``frontend/assets/styles.css``'s ``:root`` and asserts equality, so the two can never silently diverge). The **8 identity variables** (bare names, README order) and their built-in values (from ``frontend/assets/styles.css`` ``:root``): =================== ========== ================================================= Variable Built-in Role =================== ========== ================================================= ``bg`` ``#0f0a0a`` page background (text on it: ``ink``) ``surface`` ``#1a0f0f`` cards, panels, code blocks (text: ``ink``) ``ink`` ``#f0e6e6`` primary text ``ink_soft`` ``#b8a8a8`` secondary text (5.1:1 on ``surface``) ``line`` ``#2d1a1a`` decorative 1px borders (no contrast duty) ``brand`` ``#f43f5e`` brand accent — buttons, links (text ON it is the DARK ``bg`` ink) ``brand_soft`` ``#2d0a0a`` brand-tinted surface (chips, hover washes) ``brand_ink`` ``#fca5a5`` brand-tinted text (9.0:1 on ``surface``) =================== ========== ================================================= The **semantic families are deliberately NOT identity** (B3, owner-locked 2026-09-09): ``--accent-*`` (deflection amber), ``--ok-*`` (success green), ``--err-*`` (error red) encode *states*, are already AA in the built-in theme, and are not configurable from the tab — a theme that keeps them stays honest. **The five contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1, AGENTS.md rule 5) — the pairs the layout actually pairs: ``ink`` on ``bg``, ``ink`` on ``surface``, ``ink_soft`` on ``surface``, ``bg`` on ``brand`` (the text on brand buttons is the DARK background ink — that is the pattern; never white on brand: white on the built-in ``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on ``surface``. The tab's client-side warnings (task 05) compute exactly these five ratios against the values being saved; the built-in palette itself passes, so the default deployment stays AA without any warning. Effective-value resolution (:func:`effective_settings`) — the DB-over- env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single ``ui_settings`` row (id 1, task 01) wins column-by-column when set; a NULL/empty string column falls back to the ``BOR_`` env value, a NULL color column to the built-in. ONE resolver is used by BOTH ``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand layer), so the tab and the running UI can never disagree. """ from __future__ import annotations import base64 import hashlib from sqlalchemy import select from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.models import UiSettings #: The 8 built-in identity colors, keyed by BARE variable name (no ``--``) #: in the themes-README order. Copied from ``frontend/assets/styles.css`` #: ``:root`` — the unit drift test (``tests/unit/test_theming.py``) #: re-parses the stylesheet and asserts equality on every run. BUILTIN_COLORS: dict[str, str] = { "bg": "#0f0a0a", "surface": "#1a0f0f", "ink": "#f0e6e6", "ink_soft": "#b8a8a8", "line": "#2d1a1a", "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5", } #: The 8 color field names in the README's order (dicts preserve #: insertion order) — used by the resolver, the API, and the #: ``theme_style_tag`` renderer (task 02). COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS) #: The 3 display strings the ``ui_settings`` row carries — env fallback #: (B1: unlike the colors, the env vars stay the strings' default). STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text") def effective_settings( session: Session, settings: Settings | None = None ) -> dict[str, str]: """Resolve the EFFECTIVE UI settings — DB-over-env / DB-over-built-in. Reads the single ``ui_settings`` row (id 1) and merges it over the defaults, column by column: * **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``) — the DB value when it is a non-empty string, else the env value (``settings.app_name`` etc. — B1: the env vars stay the fallback); * **colors** (the 8 :data:`COLOR_FIELDS`) — the DB value when not ``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for colors — the built-in palette IS the default). A missing row (``GET`` creates nothing) means "defaults" — the env strings + the built-in palette. The ``settings`` parameter names the env-fallback source explicitly (the routes pass their dependency-injected instance so test overrides apply); ``None`` uses the cached :func:`app.config.get_settings`. Returns all 11 keys. """ if settings is None: settings = get_settings() row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() effective: dict[str, str] = {} for field in STRING_FIELDS: value = getattr(row, field, None) if row is not None else None effective[field] = value if isinstance(value, str) and value else getattr(settings, field) for key in COLOR_FIELDS: value = getattr(row, key, None) if row is not None else None effective[key] = value if value is not None else BUILTIN_COLORS[key] return effective def theme_style_tag(colors: dict[str, str]) -> str: """The pre-paint inline theme tag (task 02's injection input). ``""`` when every color equals its built-in — the byte-identical contract: an unset (or "defaults saved") deployment must serve exactly the pre-phase-91 HTML, no `` Pure function of its input — :func:`inject_theme` places it before the first ```` of every served HTML page (the phase-91 pre-paint injection), so the themed deployment renders its palette on the FIRST paint (no red flash, no pop-in). """ if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS): return "" declarations = "".join( f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS ) return f'' def inject_theme(html: str, tag: str) -> str: """Insert ``tag`` immediately BEFORE the first ```` of ``html`` — the pure half of the phase-91 pre-paint injection. The :class:`~app.core.caching.CachingMiddleware` (task 02) calls this on every known HTML page's rewritten body, so the helper stays pure (no DB, no app) and unit-testable on its own. Identity rules — the byte-identical contract (B4, owner-locked 2026-09-09): * ``tag == ""`` (an unset or "defaults saved" deployment — :func:`theme_style_tag` returns exactly that) → ``html`` is returned EXACTLY as passed in, byte for byte; * no ```` occurrence → unchanged (nothing to anchor to); * ``id="bor-theme"`` already present → unchanged (defensive idempotence — the static files never contain the id, and one body can never reach the helper twice, but the guarantee is free for a pure function). Otherwise the tag is placed with a leading newline (readable HTML) immediately before the FIRST ```` — the browser meets the complete ``:root`` override before it applies any stylesheet, so the palette is live on the first paint. """ if not tag or "" not in html or 'id="bor-theme"' in html: return html index = html.index("") return html[:index] + "\n" + tag + html[index:] def theme_csp_hash(tag: str) -> str: """The CSP3 ``sha256-`` source expression for an inline theme tag. Phase 91 (task 05 defect fix): the phase-82 CSP (A1 — ``default-src 'self'`` with no explicit ``style-src``) BLOCKS the inline ``", 1)[0] digest = hashlib.sha256(content.encode("utf-8")).digest() return "sha256-" + base64.b64encode(digest).decode("ascii")