Files
ducoterra 9188be259b
Build and Push Containers / build-and-push-app (push) Successful in 1m56s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 93_theme_semantic_completion
All verification is complete. Final report:

**Phase 93 — Theme semantic completion: FINAL VERIFICATION PASS — ALL GREEN**

- Verified full implementation in tree: migration `0016` (8 nullable semantic columns, applied at head), 17-var `BUILTIN_COLORS`/`COLOR_FIELDS`/`effective_settings`, API validation, `#view-theme` State-colors fieldset (17 pickers), `theme.js` FIELDS/PAIRS (5→8), `.page-head` surface panel (6 shell views + doc-edit + shared.html; login card / document sticky header audited as already-surfaced), mock_llm `content: None` fix
- Fixed 2 pre-existing defects (both fail identically on baseline `d4f38ad`, proven via worktree A/B): `test_nav_rename_sources` — expected nav tail missing the phase-91 "Theme" link; `test_stale_ui_copy` — now truncates `saved_chats` before/after (house `test_suggestion_chips` pattern) so the seed-chip contract is deterministic on the shared dev DB (owner's 22 saved chats triggered phase-80 last-3-questions)
- Tests: `uv run pytest --cov=app --cov-report=term-missing` → **1868 passed, app/ 99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
- E2E: dedicated `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → **8/8 in isolation** (all-gray 17-color theme: zero residual color on saved-result/Stale/Revoked/Local/tool-call elements, text labels intact, gray heads non-transparent, pre-paint tag, Reset → byte-identical no-tag); 15 theme/header/nav/responsive suites green in isolation; full 85-file combined run: only the 2 fixed pre-existing failures + 1 combined-run artifact (`test_sync_upload_progress`, green in isolation)
- Completion criteria: (1) monochrome E2E ✓ (2) default byte-identical, no `#bor-theme` tag ✓ (3) all page heads on solid surface ✓ (4) suite/coverage/lint/E2E green ✓ (5) phases 01–92 no behavior change ✓ (6) commit left to harness per protocol
- Notable: cleaned stray uvicorn leftovers from prior implementation pass (owner's `--reload` dev server untouched); no deviations from the phase design
- Next pending phase: `94_ls_tree_drilldown`
2026-09-10 16:43:08 -04:00

261 lines
13 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): 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 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 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 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).
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()
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 ``<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")