All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
348 lines
17 KiB
Python
348 lines
17 KiB
Python
"""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 ``<svg>`` 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 (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
f'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="{colors["surface"]}"'
|
|
f' stroke="{colors["brand"]}" stroke-width="4" stroke-linejoin="round"/>'
|
|
f'<circle cx="32" cy="32" r="6.5" fill="{colors["brand"]}"/>'
|
|
f'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5"'
|
|
f' stroke="{colors["brand_ink"]}" stroke-width="3" stroke-linecap="round"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
|
|
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 ``<style>`` tag anywhere.
|
|
Otherwise one ``<style id="bor-theme">`` tag with ALL 17 variables in
|
|
:data:`COLOR_FIELDS` order (the 9 identity + the 8 semantic state
|
|
variables — the non-overridden ones repeat their built-in value, so
|
|
the tag is a complete ``:root`` override and the page never mixes
|
|
partial palettes)::
|
|
|
|
<style id="bor-theme">:root{--bg:#0f0a0a;…;--accent-line:#f59e0b}</style>
|
|
|
|
Pure function of its input — :func:`inject_theme` places it before
|
|
the first ``</head>`` 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'<style id="bor-theme">:root{{{declarations}}}</style>'
|
|
|
|
|
|
def inject_theme(html: str, tag: str) -> str:
|
|
"""Insert ``tag`` immediately BEFORE the first ``</head>`` 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 ``</head>`` 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 ``</head>`` — 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 "</head>" not in html or 'id="bor-theme"' in html:
|
|
return html
|
|
index = html.index("</head>")
|
|
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 ``<style id="bor-theme">`` tag in every real browser
|
|
(``style-src`` falls back to ``default-src 'self'``), so the
|
|
pre-paint injection would be dead bytes in the served HTML. The
|
|
fix is the strictest one that works: the hashing source expression
|
|
of the tag's EXACT content (CSP3 §13.4 — the character data between
|
|
the tags; the rendered content carries no leading/trailing
|
|
whitespace, so no stripping applies). The caching middleware
|
|
publishes it on themed HTML pages only, as ``style-src 'self'
|
|
'sha256-…'`` appended to the A1 string — the current theme is the
|
|
only inline style ever permitted, and a different palette (or any
|
|
other inline style) is still blocked. No blanket
|
|
``'unsafe-inline'`` — the A1 posture holds everywhere else. Returns
|
|
``""`` for an empty tag (an unset/defaults deployment keeps the
|
|
plain A1 policy — the byte- AND header-identical contract).
|
|
"""
|
|
if not tag:
|
|
return ""
|
|
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
|
|
digest = hashlib.sha256(content.encode("utf-8")).digest()
|
|
return "sha256-" + base64.b64encode(digest).decode("ascii")
|