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

392 lines
18 KiB
Python

"""Unit: the built-in identity palette + the effective-settings resolver
(phase 91, task 01).
Covers ``app/core/theming.py`` — the single source of the built-in
identity palette (re-homed from the retired phase-62 CSS-file themes'
authoring guide before task 03 deleted it) and the DB-over-env /
DB-over-built-in resolver shared by ``/api/ui-settings`` and
``/api/config``:
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 17 built-ins (9 identity +
8 semantic state, phase 93) must equal the values parsed straight
out of ``frontend/assets/styles.css``'s ``:root`` block, so the
Python palette and the stylesheet can never silently diverge;
* ``theme_style_tag`` — the byte-identical contract (all built-in →
``""``) and the exact tag shape (all 17 variables, ``COLOR_FIELDS``
order, lowercased hex);
* ``effective_settings`` — missing row → env strings + built-ins; a DB
row's set columns win; an empty-string DB string falls back to env
(the resolver treats "" as unset, B1). House DB-test pattern (the
``test_tokens`` precedent): the real compose Postgres, skipped with
clear instructions when the stack is not up.
"""
from __future__ import annotations
import base64
import hashlib
import re
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.core import theming
from app.models import UiSettings
REPO_ROOT = Path(__file__).resolve().parents[2]
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
def _delete_row() -> Any:
from sqlalchemy import delete
return delete(UiSettings).where(UiSettings.id == 1)
def _start_row_missing(db: Session) -> None:
"""The single row is global state: wipe it so every DB test starts
from the row-missing state it asserts (a stale row from an earlier
interrupted run must not break them)."""
db.execute(_delete_row())
db.commit()
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped, in file order."""
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))
def test_builtin_colors_match_styles_css_root() -> None:
"""The drift guard: every built-in equals the stylesheet's ``:root``
value for the same variable (and ``BUILTIN_COLORS`` names exactly
the 17 palette variables — the 9 identity + the 8 semantic state,
no more, no fewer; phase 93, task 01)."""
decls = _root_declarations()
builtin_names = set(theming.BUILTIN_COLORS)
assert builtin_names == {
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
"brand", "brand_soft", "brand_ink",
"ok_bg", "ok_ink", "err_bg", "err_ink", "err_line",
"accent_bg", "accent_ink", "accent_line",
}, f"BUILTIN_COLORS must name exactly the 17 palette variables, got {sorted(builtin_names)}"
for name, value in theming.BUILTIN_COLORS.items():
css_name = f"--{name.replace('_', '-')}"
assert css_name in decls, f"styles.css :root is missing {css_name}"
assert decls[css_name].strip() == value, (
f"{css_name} drifted: BUILTIN_COLORS has {value!r}, "
f"styles.css has {decls[css_name].strip()!r}"
)
def test_color_fields_are_the_17_keys_in_order() -> None:
"""``COLOR_FIELDS`` is the 17 keys — the 9 identity in the
themes-README order (phase 92: ``grid_line`` between ``line`` and
``brand``), then the 8 semantic state variables (phase 93: ok,
err, accent — identity, brand, then state) — the order the
resolver, the API, and the tag renderer all rely on."""
assert theming.COLOR_FIELDS == (
"bg", "surface", "ink", "ink_soft",
"line", "grid_line", "brand", "brand_soft", "brand_ink",
"ok_bg", "ok_ink", "err_bg", "err_ink", "err_line",
"accent_bg", "accent_ink", "accent_line",
)
assert len(theming.COLOR_FIELDS) == 17
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
def _env_settings() -> Settings:
"""An explicit env source (the resolver's optional ``settings``
parameter) — deterministic values, independent of the local ``.env``
(the ``/api/config`` env pins in test_api.py own the env-file
behaviour; this unit module only needs stable fallbacks)."""
return Settings(
app_name="Env Name",
input_placeholder="Env placeholder…",
footer_text="Env footer",
)
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
"""A missing row (GET creates nothing) means "defaults": the env
strings + the built-in palette, all 20 values (3 strings + 17
colors — phase 93, task 01)."""
_start_row_missing(db)
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is None, "the test starts from a row-missing state"
effective = theming.effective_settings(db, _env_settings())
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert len(effective) == 20 # 3 strings + 17 colors (9 identity + 8 semantic)
assert effective["app_name"] == "Env Name"
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
def test_effective_db_row_wins_column_by_column(db: Session) -> None:
"""Set columns win, unset columns fall back — per column, so a
partial row (only ``bg`` set) mixes the DB color with the built-ins
and the env strings."""
_start_row_missing(db)
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["bg"] == "#111111" # DB wins
assert effective["app_name"] == "DB Name" # DB wins
# Unset columns: env strings + the built-in colors.
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
for key in theming.COLOR_FIELDS:
if key != "bg":
assert effective[key] == theming.BUILTIN_COLORS[key]
finally:
db.execute(_delete_row())
db.commit()
def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None:
"""B1: an EMPTY string in the DB is unset — the resolver falls back
to the env value (a hand-edited row with '' can't blank the UI).
Colors: ``None`` → the built-in (an empty color is impossible through
the API — the hex validator — the resolver's not-None rule covers
the hand-edited edge by returning whatever the row holds)."""
_start_row_missing(db)
db.add(UiSettings(id=1, app_name=""))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Env Name" # "" → env fallback
assert effective["footer_text"] == "Env footer"
assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in
finally:
db.execute(_delete_row())
db.commit()
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
"""``settings=None`` (the design's call shape) resolves the env
fallback from the cached :func:`app.config.get_settings` — the
values it reports must be real ``str``s for all 20 values."""
from app.config import get_settings
_start_row_missing(db)
effective = theming.effective_settings(db)
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert effective["app_name"] == get_settings().app_name
for field in theming.STRING_FIELDS:
assert isinstance(effective[field], str) and effective[field]
assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS)
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
# ---------- theme_style_tag (pure) ----------
def test_theme_style_tag_all_builtins_is_empty_string() -> None:
"""The byte-identical contract: an unset (or "defaults saved")
deployment serves NO tag — exactly the pre-phase-91 HTML."""
assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == ""
# The tag is PURE string-equality: uppercase hex is NOT the built-in
# (the API's lowercasing-before-store is what makes stored hex
# canonical — pin the renderer's own contract here).
colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()}
assert theming.theme_style_tag(colors) != ""
def test_theme_style_tag_one_changed_semantic_carries_all_17_in_order() -> None:
"""A single NON-BUILT-IN SEMANTIC variable (phase 93, task 01) still
emits ALL 17 declarations, in ``COLOR_FIELDS`` order, with the exact
tag shape (no whitespace): the 9 identity variables keep their
built-ins, the 8 semantic variables carry ``--ok-ink:#444444;`` (the
change) plus the 7 other semantic built-ins — and the CSP hash
matches the tag's content (the runtime exemption contract)."""
colors = dict(theming.BUILTIN_COLORS)
colors["ok_ink"] = "#444444" # one non-default SEMANTIC var
tag = theming.theme_style_tag(colors)
assert tag == (
'<style id="bor-theme">:root{'
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#f43f5e;"
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"--ok-bg:#10241b;--ok-ink:#444444;--err-bg:#2d0a0a;--err-ink:#fca5a5;"
"--err-line:#ef4444;--accent-bg:#2b2110;--accent-ink:#fbbf24;"
"--accent-line:#f59e0b;"
"}</style>"
)
# The changed value lands under the dashed CSS name…
assert "--ok-ink:#444444;" in tag
# …and the underscored field (ink_soft) renders as --ink-soft.
assert "--ink-soft:#b8a8a8;" in tag
assert "--ink_soft" not in tag
# Exactly 17 declarations, COLOR_FIELDS order.
names = re.findall(r"--([a-z-]+):", tag)
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
# The hash is computed from the tag's EXACT content (CSP3 §13.4).
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
expected = "sha256-" + base64.b64encode(
hashlib.sha256(content.encode("utf-8")).digest()
).decode("ascii")
assert theming.theme_csp_hash(tag) == expected
def test_theme_style_tag_multiple_changed() -> None:
"""Two changed colors: both values present, the rest built-in, order
unchanged (the tag is a complete :root override — the page never
mixes a partial palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["bg"] = "#0a0e1a"
colors["brand_ink"] = "#c7d2fe"
colors["accent_ink"] = "#cccccc" # a semantic var joins the mix too
tag = theming.theme_style_tag(colors)
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
assert "--brand-ink:#c7d2fe;" in tag
assert "--accent-ink:#cccccc;" in tag
assert tag.endswith("}</style>")
# The order of the 17 dashed names is the COLOR_FIELDS order.
names = re.findall(r"--([a-z-]+):", tag)
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
# ---------- inject_theme (pure — task 02's injection helper) ----------
_HEAD_HTML = "<html><head><title>t</title></head><body><p>b</p></body></html>"
def test_inject_theme_empty_tag_is_identity() -> None:
"""``tag == ""`` (the unset / "defaults saved" deployment — what
``theme_style_tag" returns for all-built-in colors) → the html is
returned EXACTLY as passed in, byte for byte (B4's no-op
contract)."""
assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML
# Whitespace is NOT an empty tag — a real tag is always inserted.
assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML
def test_inject_theme_missing_head_is_identity() -> None:
"""No ``</head>`` occurrence → unchanged (nothing to anchor to);
the empty string (no ``</head>`` either) is identity too."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><body>no head</body></html>"
assert theming.inject_theme(html, tag) == html
assert theming.inject_theme("", tag) == ""
def test_inject_theme_exact_placement_before_first_head_close() -> None:
"""The tag lands with a leading newline immediately BEFORE the
first ``</head>`` — nothing between the tag and the close, nothing
moved after it."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><head><title>t</title></head><body>after</body></html>"
assert theming.inject_theme(html, tag) == (
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
)
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
# FIRST occurrence wins (the one that closes the real head).
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
assert theming.inject_theme(html2, tag) == (
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
)
def test_inject_theme_double_injection_is_idempotent() -> None:
"""The defensive idempotence rule keys on the id: once a
``id="bor-theme"`` tag is present the helper is the identity — the
page can never carry two theme tags, even for a different tag."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
once = theming.inject_theme(_HEAD_HTML, tag)
assert once.count('id="bor-theme"') == 1
assert theming.inject_theme(once, tag) == once
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
assert theming.inject_theme(once, other) == once
# ---------------------------------------------------------------------------
# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the
# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag
# in every real browser; the caching middleware publishes the hash as a
# style-src exemption on themed HTML pages only).
# ---------------------------------------------------------------------------
def test_theme_csp_hash_empty_tag_is_empty_string() -> None:
"""No tag (unset/defaults deployment) → no hash — the plain A1
policy stands and the header stays byte-identical to pre-91."""
assert theming.theme_csp_hash("") == ""
def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None:
"""CSP3 §13.4: the hash covers the character data BETWEEN the tags
(the ``:root{…}`` declarations — the rendered content carries no
leading/trailing whitespace, so no stripping applies), base64
after SHA-256, ``sha256-`` prefixed."""
tag = '<style id="bor-theme">:root{--bg:#111111}</style>'
expected = "sha256-" + base64.b64encode(
hashlib.sha256(b":root{--bg:#111111}").digest()
).decode("ascii")
assert theming.theme_csp_hash(tag) == expected
def test_theme_csp_hash_changes_with_the_palette() -> None:
"""A different palette → a different hash: the browser keeps
blocking the OLD tag once the theme changes (the exemption always
matches exactly the served bytes, never a stale palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
first = theming.theme_csp_hash(theming.theme_style_tag(colors))
colors["brand"] = "#22c55e"
second = theming.theme_csp_hash(theming.theme_style_tag(colors))
assert first
assert first != second
assert first.startswith("sha256-")
assert second.startswith("sha256-")
# ---------------------------------------------------------------------------
# Phase 93 (task 02): the docstring ↔ theme.js PAIRS mirror — the
# authoritative eight-pair table and the client-side warning list must
# NEVER diverge (the docstring names the mirror; this test pins it).
# ---------------------------------------------------------------------------
def _theme_js_pairs() -> list[tuple[str, str]]:
"""The (foreground, background) entries of ``theme.js``'s ``PAIRS``
array, in order (frontend source read as text — the house
pattern)."""
js = (REPO_ROOT / "frontend" / "assets" / "theme.js").read_text(encoding="utf-8")
start = js.index("const PAIRS = [")
end = js.index("];", start)
return re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end])
def test_docstring_pair_table_matches_theme_js_pairs() -> None:
"""The mirror contract: ``theme.js``'s ``PAIRS`` is exactly the
EIGHT pairs the module docstring's authoritative table names —
every PAIRS entry appears in the docstring as ``fg`` on ``bg``
(and the list has exactly eight entries, so a pair silently added
to ONE side fails)."""
# Line-wrap-tolerant: the docstring table wraps at 79 columns
# (``ink_soft``\non ``surface``), so newlines become spaces.
doc = (theming.__doc__ or "").replace("\n", " ")
pairs = _theme_js_pairs()
assert len(pairs) == 8, f"PAIRS must hold exactly 8 pairs, got {pairs}"
for fg, bg in pairs:
assert f"``{fg}`` on ``{bg}``" in doc, (
f"the docstring's authoritative pair table must name "
f"``{fg}`` on ``{bg}`` (the theme.js mirror)"
)
# The two *_line state variables stay EXCLUDED from the warning
# surface in BOTH places (decorative borders — no contrast duty).
assert "err_line" not in [f for f, _ in pairs]
assert "accent_line" not in [f for f, _ in pairs]