"""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 9 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 **9 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) ``grid_line`` ``#4a2626`` background grid texture — decorative, no contrast duty, like ``line`` ``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** — STORABLE since phase 93 (B3 REVISED, owner permission 2026-09-10, TODO.md L3 — the 2026-09-09 lock that they are "NOT identity, not configurable from the tab" is lifted; PLAN.md is being redone by the owner, the decision is recorded in the phase 93 overview): the three state families ``--ok-*`` (success green), ``--err-*`` (error red), ``--accent-*`` (deflection / tool-line amber) join the storable palette — 17 variables total, all ``NULL = built-in`` (B1, no env fallback for colors). They still encode *states* (a monochrome theme conveys state by its TEXT label — "text + color, never color alone", B5 — so grayscale stays honest), and the built-in values keep every existing AA ratio. Column names mirror the CSS variables (``frontend/assets/styles.css`` ``:root``): =================== ========== ================================================= Variable Built-in Role =================== ========== ================================================= ``ok_bg`` ``#10241b`` success pill/text background ("Theme saved.") ``ok_ink`` ``#6ee7a8`` success text (10.6:1 on ``ok_bg``) ``err_bg`` ``#2d0a0a`` Stale/Revoked pill background ``err_ink`` ``#fca5a5`` Stale/Revoked pill text (9.3:1 on ``err_bg``) ``err_line`` ``#ef4444`` pill border — decorative (no contrast duty, like ``line`` / ``grid_line``) ``accent_bg`` ``#2b2110`` deflection banner / tool-line background ``accent_ink`` ``#fbbf24`` tool-call line text (9.5:1 on ``accent_bg``) ``accent_line`` ``#f59e0b`` tool-line / deflection border — decorative (no contrast duty, like ``err_line``) =================== ========== ================================================= **The eight contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1, AGENTS.md rule 5) — the pairs the layout actually pairs: the five identity 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``) plus the three semantic ink-on-bg pairs (``ok_ink`` on ``ok_bg``, ``err_ink`` on ``err_bg``, ``accent_ink`` on ``accent_bg``). The two ``*_line`` state variables stay EXCLUDED — decorative borders, no contrast duty (the same rule as ``--line`` / ``--grid-line``). The tab's client-side warnings (phase 91 task 05, extended to the eight pairs in phase 93 task 02) compute exactly these eight ratios against the values being saved; the built-in palette itself passes, so the default deployment stays AA without any warning. **Mirror relationship:** ``frontend/assets/theme.js``'s ``PAIRS`` array is the client-side mirror of this table — the two must NEVER diverge (``tests/unit/test_theming.py`` pins the mirror: every ``PAIRS`` entry appears here as ``fg`` on ``bg``, exactly eight pairs). Effective-value resolution (:func:`effective_settings`) — the DB-over- env / DB-over-built-in merge (B1, owner-locked 2026-09-09) resolved column-by-column: * **the three brand strings** (``app_name`` / ``input_placeholder`` / ``footer_text``) — **Settings-panel row → Theme-tab row → env** (phase 190, A1 owner-confirmed 2026-10-18). The panel (the ``app_settings`` rows behind ``/api/admin/settings``, phase 168) is the SINGLE writable owner of the strings, so its row wins whenever it exists; the Theme-tab row (this module's ``ui_settings``) is the documented fallback for the rows it baked before this phase (no data migration — a legacy row keeps rendering what it holds until the panel overrides it); the ``BOR_`` env value is the last resort. Presence, never value comparison: an ``app_settings`` row that happens to equal the env value still owns the field (that equality is exactly how the phase-190 shadow looked from the owner's side). * **the 17 colors** — unchanged (B1): the ``ui_settings`` row when not ``NULL``, else the built-in palette. The ownership flip is strings-only. 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.core import settings_overlay from app.models import UiSettings #: The 17 built-in palette colors, keyed by BARE variable name (no #: ``--``): the 9 identity colors in the themes-README order, then the #: 8 semantic state colors (phase 93 — B3 revised). 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", "grid_line": "#4a2626", # the background grid texture (phase 92) "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5", # The 8 semantic state colors (phase 93 — the ok / err / accent # families; the *_line entries are decorative borders, no contrast # duty, like line / grid_line). "ok_bg": "#10241b", "ok_ink": "#6ee7a8", "err_bg": "#2d0a0a", "err_ink": "#fca5a5", "err_line": "#ef4444", "accent_bg": "#2b2110", "accent_ink": "#fbbf24", "accent_line": "#f59e0b", } #: The 17 color field names (dicts preserve insertion order) — the 9 #: identity variables in the README's order, then the 8 semantic state #: variables (identity, brand, then state). Used by the resolver, the #: API, and the ``theme_style_tag`` renderer (task 02). COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS) #: The 3 brand strings the ``ui_settings`` row carries. Since phase 190 #: (A1) the Settings panel (``app_settings``, phase 168) is their single #: writable OWNER and wins over this row; the row is the documented #: fallback for the values it baked in before that phase, with the env #: value last (B1: unlike the colors, the env vars stay the default). #: ``PUT /api/ui-settings`` no longer writes them. 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 three-way order of phase 190 (A1), per field: **Settings-panel row** → **Theme-tab row** → **env**. The panel row is ``getattr(settings, field)`` (the ``Settings`` the route passes already resolves through the phase-168 overlay, so a panel row IS its value) and it wins only when the overlay reports the key as an actual override (:func:`app.core.settings_overlay.applied_override_keys`, read ONCE here — presence, never a value compare). Otherwise the ``ui_settings`` value when it is a non-empty string (the phase-91 fallback a legacy baked-in row still lands in), else the env value (B1: the env vars stay the last resort). * **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8 semantic state) — the DB value when not ``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for colors — the built-in palette IS the default; the ownership flip is strings-only). 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 20 values (3 strings + 17 colors). """ if settings is None: settings = get_settings() row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() #: The keys the admin's Settings panel owns right now (phase 190 #: A1) — one SELECT on this same session, consulted per string #: field below with a membership test (no query per field). panel_keys = settings_overlay.applied_override_keys(session) effective: dict[str, str] = {} for field in STRING_FIELDS: if field in panel_keys: effective[field] = getattr(settings, field) continue 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 favicon_svg(colors: dict[str, str]) -> str: """Render the browser-tab favicon artwork from the effective palette. Phase 177 (task 01, A1 — owner-confirmed): the pure half of the themed favicon. ``GET /favicon.svg`` (task 02) serves this, and ``frontend/assets/theme.js``'s ``themeFaviconDataUri`` (task 04) is the client-side mirror — the two must NEVER diverge (the unit pins here keep them aligned). The artwork is the SAME art as the pre-phase static ``frontend/assets/favicon.svg`` (a hexagon + center dot + 4 spokes, ``viewBox 0 0 64 64``); only the THREE color roles swap, per the A1 mapping: =========================== =================== SVG attribute Palette role =========================== =================== hexagon path ``fill`` ``surface`` hexagon path ``stroke`` ``brand`` center circle ``fill`` ``brand`` spokes path ``stroke`` ``brand_ink`` =========================== =================== **Byte-identical contract:** for the built-in palette (the three role keys carrying their :data:`BUILTIN_COLORS` values) the output is EXACTLY the static file's ```` element — lowercase hex, same attribute order (pinned by the golden constant in ``tests/unit/test_theming.py``; the static file's comment header is file packaging, not art, and is NOT part of the contract). The input is the full 20-value effective dict (mirroring :func:`theme_style_tag`'s self-contained signature); the function reads its three keys. Pure: dict in → str out (no DB, no app, no defensive branches — the :func:`effective_settings` / :data:`BUILTIN_COLORS` contract guarantees the values are canonical lowercase 6-hex). **No state colors:** the 8 semantic state colors are NOT in the artwork — a favicon has no state meaning (the state families encode *states* on the page; the tab icon is identity only). """ return ( '' f'' f'' f'' "" ) 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 ``