phase: 93_theme_semantic_completion
Build and Push Containers / build-and-push-app (push) Successful in 1m56s
Build and Push Containers / build-and-push-db (push) Successful in 11s

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`
This commit is contained in:
2026-09-10 16:43:08 -04:00
parent d4f38ad3ce
commit 9188be259b
44 changed files with 3019 additions and 196 deletions
+17 -4
View File
@@ -902,8 +902,10 @@ def test_theme_view_scaffold_in_the_shell() -> None:
the ship-hidden #theme-content (the #git-sources-content pattern)
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
#theme-form with the 3 labeled branding text inputs (maxlength=300
— the server re-validates) + the 9 labeled type=color palette inputs
(the 9 identity variables, in the theming.COLOR_FIELDS order), the
— the server re-validates) + the 17 labeled type=color palette
inputs (the 9 identity variables, then the 8 semantic state
variables in the State colors fieldset — phase 93 — in the
theming.COLOR_FIELDS order), the
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
(no real submit), and the three task-05 feedback lines: #theme-error
(role=alert), #theme-result (role=status), #theme-contrast
@@ -941,8 +943,9 @@ def test_theme_view_scaffold_in_the_shell() -> None:
)
# The static form skeleton (the E2E-stable-selectors house
# convention): the 3 labeled branding text inputs (maxlength=300)
# and the 9 labeled type=color palette inputs (the 9 identity
# variables — one per theming.COLOR_FIELDS field).
# and the 17 labeled type=color palette inputs (the 9 identity
# variables, then the 8 semantic state variables — phase 93 —
# one per theming.COLOR_FIELDS field).
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
"the #theme-form must be STATIC markup in the shell"
)
@@ -963,6 +966,16 @@ def test_theme_view_scaffold_in_the_shell() -> None:
"theme-brand",
"theme-brand-soft",
"theme-brand-ink",
# The 8 semantic state pickers (phase 93 — the State colors
# fieldset after the palette fieldset).
"theme-ok-bg",
"theme-ok-ink",
"theme-err-bg",
"theme-err-ink",
"theme-err-line",
"theme-accent-bg",
"theme-accent-ink",
"theme-accent-line",
):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
+8 -3
View File
@@ -22,10 +22,11 @@ def test_all_tables_registered() -> None:
def test_ui_settings_single_row_nullable_contract() -> None:
"""Phase 91 (9 identity colors after phase 92, task 01): the
"""Phase 91 (9 identity colors after phase 92, task 01; the 8
semantic state colors after phase 93, task 01 — B3 revised): the
single-row UI settings table — Integer PK ``id`` with the
Python-side ``default=1`` (the row is always id 1), the 3 strings
VARCHAR(300) and the 9 identity colors VARCHAR(7), ALL nullable
VARCHAR(300) and the 17 palette colors VARCHAR(7), ALL nullable
(NULL = default — B1: env value for the strings, the built-in
palette for the colors)."""
settings_table = Base.metadata.tables["ui_settings"]
@@ -33,6 +34,8 @@ def test_ui_settings_single_row_nullable_contract() -> None:
"id", "app_name", "input_placeholder", "footer_text",
"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",
}
pk = settings_table.c["id"]
assert pk.primary_key is True, "ui_settings.id must be the PK"
@@ -42,7 +45,9 @@ def test_ui_settings_single_row_nullable_contract() -> None:
assert col.nullable is True, f"{name} must be NULL (env default)"
assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)"
for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line",
"brand", "brand_soft", "brand_ink"):
"brand", "brand_soft", "brand_ink",
"ok_bg", "ok_ink", "err_bg", "err_ink", "err_line",
"accent_bg", "accent_ink", "accent_line"):
col = settings_table.c[name]
assert col.nullable is True, f"{name} must be NULL (the built-in)"
assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb"
+281
View File
@@ -0,0 +1,281 @@
"""Unit: the phase-93 page-head surface panel contract (task 03 —
source pins).
Owner direction (TODO.md L3): "Also the header and description of each
page needs a background - the grid makes it hard to read." Every
page's ``h1`` + description (the shell's standard ``.page-head`` frame)
sits on a solid ``var(--surface)`` panel — the house card language —
so the 44px background grid never fights the heading text. Browser
behavior (computed ``background-color`` non-transparent on every head,
the History flex row + 360px wrap unbroken, the monochrome theme
graying the panel) is E2E-gated by ``tests/e2e/
test_theme_semantic_completion.py`` (task 04) and the existing
header/nav/responsive suites; here we pin the source-level contract
(house pattern: ``tests/unit/test_background_no_motion.py`` parses
``styles.css`` rules):
* the shared ``.page-head`` rule declares the panel — a NON-transparent
``background`` built from ``var(--surface)`` (no color literal of any
kind, no blur — the phase-08 perf anchor), the 1px ``var(--line)``
border, the house card radius, and card-rhythm padding;
* the ``#view-history`` page-head keeps its SCOPED flex row (padding
lives on the flex container — the base rule — never on the
children), and the mobile ``.page-head-row { flex-wrap: wrap; }``
survives;
* every shell view that carries a ``.page-head`` in the shell markup
is covered by the ONE rule (six views; ``#view-chat`` carries no
``.page-head`` — its head is the navbar, audited), and
``doc-edit.html`` keeps its ``.page-head``;
* the standalone pages: the shared page's head (``h1#shared-title`` +
the lede) is wrapped in the same ``.page-head`` class, while the two
heads that already sit inside a surfaced card are deliberately
UNTOUCHED (login's ``.login-card`` — surface card; document viewer's
``.doc-titlebar`` inside the sticky surface ``.doc-header``).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
INDEX_HTML = FRONTEND / "index.html"
SHARED_HTML = FRONTEND / "shared.html"
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
LOGIN_HTML = FRONTEND / "login.html"
DOCUMENT_HTML = FRONTEND / "document.html"
# The six shell views whose h1 + description sit in a .page-head
# (#view-chat carries none — its head is the navbar; audited).
SHELL_VIEWS_WITH_PAGE_HEAD = (
"view-tuning",
"view-rag",
"view-git-sources",
"view-history",
"view-tokens",
"view-theme",
)
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _css_no_comments() -> str:
return re.sub(r"/\*[\s\S]*?\*/", "", _css())
def _find_rule(css: str, selector: str) -> re.Match[str] | None:
"""The first top-level ``selector { ... }`` rule (comments
stripped by the caller when prose must not interfere)."""
return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css)
def _rule_block(css: str, selector: str) -> str:
rule = _find_rule(css, selector)
assert rule, f"styles.css must define a {selector} rule"
return rule.group(1)
def _view_section(body: str, view_id: str) -> str:
"""The view section slice — from its opening tag to the next
sibling view (or ``</main>``). A first-``</section>`` slice would
cut short: the views nest gate sections (``<section
class="sources-gate">``) INSIDE, and the Git-sources / Theme
``.page-head`` sits after the first nested close."""
start = body.find(f'<section class="view" id="{view_id}"')
assert start != -1, f"the #{view_id} section must be in the shell"
boundaries = [
e
for e in (body.find('<section class="view"', start + 1), body.find("</main>", start))
if e != -1
]
assert boundaries, "the shell must have a closing </main>"
return body[start : min(boundaries)]
# --------------------------------------------------------------------------
# The shared .page-head rule — the surface panel
# --------------------------------------------------------------------------
def test_page_head_rule_is_the_surface_panel() -> None:
"""The one shared rule declares the panel: a non-transparent
background from ``var(--surface)`` (itself tab-controlled — a
monochrome theme grays the head automatically), the 1px
``var(--line)`` border, the house card radius, and card-rhythm
padding (the phase-93 panel: 1rem block / 1.25rem inline)."""
block = _rule_block(_css_no_comments(), ".page-head")
assert "background: var(--surface)" in block, (
"the .page-head panel must fill with var(--surface) — solid, "
"never transparent, never a literal"
)
assert "border: 1px solid var(--line)" in block, (
"the panel must carry the house 1px --line border"
)
assert "border-radius: var(--radius)" in block, (
"the panel must use the house card radius"
)
assert re.search(r"padding:\s*1rem\s+1\.25rem", block), (
"the panel must carry the card-rhythm padding (1rem 1.25rem)"
)
def test_page_head_rule_has_no_color_literal_and_no_blur() -> None:
"""Phase-92 invariant + phase-08 perf anchor: the panel rule
introduces NO new color literal (hex / rgb / hsl / color-mix —
``var(--…)`` only) and no filter/blur."""
block = _rule_block(_css_no_comments(), ".page-head")
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), (
"no hex color literal in the .page-head panel (phase-92 invariant)"
)
for func in ("rgb(", "hsl(", "color-mix("):
assert func not in block, f"no {func}… literal in the .page-head panel"
assert "filter" not in block and "blur" not in block, (
"no filter/blur in the .page-head panel (phase-08 perf anchor)"
)
def test_page_head_background_is_solid_not_translucent() -> None:
"""The panel is SOLID — the assumption the phase locked (not
translucent, not a full-bleed band, no blur): the background
declaration names exactly the surface variable, nothing mixed."""
block = _rule_block(_css_no_comments(), ".page-head")
backgrounds = re.findall(r"(?m)^\s*background(?:-color)?:\s*([^;]+);", block)
assert backgrounds == ["var(--surface)"], (
f"the .page-head background must be exactly var(--surface), "
f"got {backgrounds}"
)
# --------------------------------------------------------------------------
# Layout safety — the History flex row and the mobile wrap
# --------------------------------------------------------------------------
def test_history_page_head_keeps_its_scoped_flex_row() -> None:
"""The ``#view-history .page-head`` flex row (title left, refresh
pill right — phase 77) survives the panel: the scoped rule keeps
its flex declarations and adds NO padding of its own (the task-03
rule — padding on the flex container, i.e. the base ``.page-head``
rule, never on the children, so space-between + align stay intact
inside the padded box)."""
scoped = _rule_block(_css_no_comments(), "#view-history .page-head")
for decl in (
"display: flex",
"flex-wrap: wrap",
"align-items: flex-start",
"justify-content: space-between",
):
assert decl in scoped, f"#view-history .page-head must keep {decl!r}"
assert not re.search(r"(?m)^\s*padding\b", scoped), (
"the scoped History rule must not add its own padding — the "
"base .page-head rule (the flex container) carries it"
)
def test_mobile_page_head_row_wrap_survives() -> None:
"""≤640px: ``.page-head-row { flex-wrap: wrap; }`` (the RAG head's
Sync pill drops below the title at full width) still exists in the
mobile block — the wrap keeps working INSIDE the panel."""
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", _css())
assert mobile, "the mobile media query must exist"
assert ".page-head-row { flex-wrap: wrap; }" in mobile.group(1), (
"the mobile .page-head-row wrap must survive the panel"
)
# --------------------------------------------------------------------------
# Coverage — every head that sits on the grid gets the panel
# --------------------------------------------------------------------------
def test_every_shell_view_with_a_page_head_is_covered() -> None:
"""The ONE rule covers every shell view that carries the class:
all six do (their ``class="page-head"`` div is static markup in
the shell); #view-chat carries NO .page-head (its head is the
navbar — audited, untouched)."""
body = INDEX_HTML.read_text(encoding="utf-8")
for view_id in SHELL_VIEWS_WITH_PAGE_HEAD:
assert 'class="page-head"' in _view_section(body, view_id), (
f"#{view_id} must keep its .page-head (the shared rule panels it)"
)
assert 'class="page-head"' not in _view_section(body, "view-chat"), (
"#view-chat carries no .page-head (its head is the navbar)"
)
def test_doc_edit_page_head_is_covered() -> None:
"""doc-edit.html's head already IS a .page-head (h1 'Edit doc' +
the .page-sub lede) — the shared rule panels it with no markup
change."""
html = DOC_EDIT_HTML.read_text(encoding="utf-8")
head = re.search(
r'<div class="page-head">\s*<h1>Edit doc</h1>[\s\S]*?class="page-sub"',
html,
)
assert head, "doc-edit.html must keep its .page-head (h1 + page-sub)"
def test_shared_page_head_is_wrapped_in_page_head() -> None:
"""The shared page's head (``h1#shared-title`` + the read-only
lede) was a direct child of the grid-exposed ``.shared-shell`` —
it is now wrapped in the same ``.page-head`` class, so the shared
rule panels it (the h1 keeps the ``.page-head h1`` size per the
``#shared-title`` rule)."""
html = SHARED_HTML.read_text(encoding="utf-8")
wrapped = re.search(
r'<div class="page-head">\s*'
r"<h1 id=\"shared-title\">Shared conversation</h1>\s*"
r'<p class="shared-note">Shared via Brain of Reese — read-only\.</p>\s*'
r"</div>",
html,
)
assert wrapped, (
"shared.html must wrap the h1 + lede in a .page-head div"
)
# --------------------------------------------------------------------------
# Deliberate skips — heads already inside a surfaced card
# --------------------------------------------------------------------------
def test_login_head_is_already_carded_and_untouched() -> None:
"""login.html: h1#login-title lives inside section.login-card,
which is ALREADY the surface card (var(--surface) fill + border +
radius + shadow) — the TODO targets grid-exposed text, so the
login head gets no double panel."""
html = LOGIN_HTML.read_text(encoding="utf-8")
card = re.search(
r'<section class="login-card"[^>]*>[\s\S]*?'
r'<h1 id="login-title">Sign in</h1>',
html,
)
assert card, "the login h1 must sit inside the .login-card"
card_css = _rule_block(_css_no_comments(), ".login-card")
assert "background: var(--surface)" in card_css
assert "border: 1px solid var(--line)" in card_css
assert "border-radius: var(--radius)" in card_css
assert 'class="page-head"' not in html, (
"login.html must not gain a .page-head (the card already panels it)"
)
def test_document_head_is_already_carded_and_untouched() -> None:
"""document.html: h1#doc-title lives in the .doc-titlebar row 2
inside the STICKY .doc-header, which is already surface-filled
(var(--surface) — the two-row pinned bar) — no double panel."""
html = DOCUMENT_HTML.read_text(encoding="utf-8")
titlebar = re.search(
r'<div class="doc-titlebar">[\s\S]*?<h1 id="doc-title">', html
)
assert titlebar, "the document h1 must sit inside the .doc-titlebar"
header_css = _rule_block(_css_no_comments(), ".doc-header")
assert "background: var(--surface)" in header_css, (
"the sticky .doc-header must stay surface-filled"
)
assert 'class="page-head"' not in html, (
"document.html must not gain a .page-head (the titlebar is "
"already inside the surface header)"
)
+278
View File
@@ -0,0 +1,278 @@
"""Unit: the phase-93 Theme-tab frontend contract (task 02 — source
pins).
The Theme tab edits ALL 17 palette variables (the 9 identity + the 8
semantic state colors — B3 revised, owner permission 2026-09-10,
TODO.md L3) plus the 3 branding strings: 20 form fields total. The
live behavior (live preview, the Save/Reset PUT body, the served-theme
sync, the contrast warnings) is E2E-gated (``tests/e2e/
test_admin_theme_tab.py`` + the phase-93 monochrome suite, task 04);
here we pin the source-level invariants the editor's FIELDS-driven
design depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_big_read_progress.py`` reads
frontend sources and asserts on their mechanisms):
* ``theme.js`` ``FIELDS`` — exactly 20 entries in the FORM's order
(the 3 branding strings, then the 17 colors in the server's
``theming.COLOR_FIELDS`` order — identity, brand, then state):
everything that iterates FIELDS (live preview, ``collectBody``'s PUT
body, ``clearPreview``, ``applyServedTheme``'s tag content) covers
the 8 semantic pickers automatically only if this order holds;
* ``theme.js`` ``PAIRS`` — exactly the EIGHT WCAG 2.1 AA
(4.5:1) pairs: the five identity pairs + the three semantic
ink-on-bg pairs; the two ``*_line`` state variables stay EXCLUDED
(decorative borders, no contrast duty — the same rule as
``--line`` / ``--grid-line``); the docstring mirror is pinned in
``tests/unit/test_theming.py``;
* ``index.html`` ``#view-theme`` — all 20 inputs carry E2E-stable ids
+ visible labels; the 17 color inputs are ``type="color"`` and ship
the BUILT-IN static values — asserted against
``frontend/assets/styles.css``'s ``:root`` parsed in-test (the
house drift pattern — the same guard ``test_theming.py`` runs on
``BUILTIN_COLORS``), never a third hardcoded palette copy;
* the "State colors" fieldset sits AFTER the palette fieldset (the
task-02 form shape), and the palette legend's "five pairs" copy is
the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept).
"""
from __future__ import annotations
import re
from pathlib import Path
from app.core import theming
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
INDEX_HTML = FRONTEND / "index.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
THEME_JS = FRONTEND / "assets" / "theme.js"
def _js() -> str:
return THEME_JS.read_text(encoding="utf-8")
def _html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _theme_view(body: str) -> str:
"""The ``#view-theme`` section slice (the router pin's convention:
from the section's opening tag to the ``</main>`` that closes the
single main)."""
view = body.find('<section class="view" id="view-theme"')
assert view != -1, "the #view-theme section must be in the shell"
main_end = body.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
return body[view:main_end]
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped (test_theming's parser)."""
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))
# ---------- theme.js: FIELDS — the 20 form fields, in order ----------
def test_fields_lists_all_20_in_form_order() -> None:
"""``FIELDS`` has EXACTLY 20 entries — the 3 branding strings,
then the 17 color fields — in the form's own order (the palette
fieldset's 9 identity pickers, then the State colors fieldset's 8
semantic pickers), each with its E2E-stable ``theme-*`` id and
kind (the color entries are ``kind: "color"`` — the live preview,
``collectBody``, and the served-theme sync all key on it)."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
fields = re.findall(
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
assert len(fields) == 20, f"FIELDS must list exactly 20 entries, got {len(fields)}"
assert [(f, i) for f, i, _ in fields] == [
("app_name", "theme-app-name"),
("input_placeholder", "theme-placeholder"),
("footer_text", "theme-footer"),
("bg", "theme-bg"),
("surface", "theme-surface"),
("ink", "theme-ink"),
("ink_soft", "theme-ink-soft"),
("line", "theme-line"),
("grid_line", "theme-grid-line"),
("brand", "theme-brand"),
("brand_soft", "theme-brand-soft"),
("brand_ink", "theme-brand-ink"),
("ok_bg", "theme-ok-bg"),
("ok_ink", "theme-ok-ink"),
("err_bg", "theme-err-bg"),
("err_ink", "theme-err-ink"),
("err_line", "theme-err-line"),
("accent_bg", "theme-accent-bg"),
("accent_ink", "theme-accent-ink"),
("accent_line", "theme-accent-line"),
], "FIELDS must list the 20 fields in the form's order"
assert all(kind == "color" for _, _, kind in fields[3:]), (
"the 17 palette entries are all kind color"
)
assert all(kind == "string" for _, _, kind in fields[:3])
def test_fields_color_order_is_the_server_color_fields_order() -> None:
"""The 17 color entries of ``FIELDS`` follow the server's
``theming.COLOR_FIELDS`` order (identity, brand, then state) —
the invariant that keeps ``collectBody``'s PUT body, the
``applyServedTheme`` tag content, and the pre-paint tag in the
SAME order without a second ordering copy."""
js = _js()
start = js.index("const FIELDS = [")
end = js.index("];", start)
fields = re.findall(
r'\{\s*field:\s*"([a-z_]+)",\s*id:\s*"(theme-[a-z-]+)",\s*kind:\s*"([a-z]+)"\s*\}',
js[start:end],
)
color_fields = [f for f, _, kind in fields if kind == "color"]
assert tuple(color_fields) == theming.COLOR_FIELDS, (
"FIELDS' color order must equal the server's COLOR_FIELDS order"
)
# ---------- theme.js: PAIRS — exactly the eight WCAG pairs ----------
def test_pairs_has_exactly_the_eight_pairs() -> None:
"""``PAIRS`` has EXACTLY 8 entries (the five identity pairs, then
the three semantic ink-on-bg pairs — phase 93) in the
authoritative order; no other pair is warned about (the list is
the whole warning surface)."""
js = _js()
start = js.index("const PAIRS = [")
end = js.index("];", start)
pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end])
assert pairs == [
("ink", "bg"),
("ink", "surface"),
("ink_soft", "surface"),
("bg", "brand"),
("brand_ink", "surface"),
("ok_ink", "ok_bg"),
("err_ink", "err_bg"),
("accent_ink", "accent_bg"),
], f"PAIRS must be exactly the eight pairs, got {pairs}"
def test_line_vars_are_excluded_from_pairs() -> None:
"""The two ``*_line`` state variables (like ``line`` /
``grid_line``) are DECORATIVE borders — no contrast duty — so
none of them appears in a PAIRS entry (the ``err_line`` /
``accent_line`` exclusion the phase-93 design locked; the
identity ``line`` / ``grid_line`` exclusion predates it)."""
js = _js()
start = js.index("const PAIRS = [")
end = js.index("];", start)
pairs = re.findall(r'\[\s*"([a-z_]+)"\s*,\s*"([a-z_]+)"\s*\]', js[start:end])
for field, _ in pairs:
assert not field.endswith("_line") and field not in ("line", "grid_line"), (
f"decorative border var {field} must not be a contrast foreground"
)
for _, background in pairs:
assert not background.endswith("_line") and background not in ("line", "grid_line"), (
f"decorative border var {background} must not be a contrast background"
)
# ---------- index.html: the #view-theme form (20 inputs) ----------
def test_theme_view_carries_all_20_labeled_inputs() -> None:
"""All 20 inputs are STATIC markup in the shell (the E2E-stable
selectors convention): each with a visible ``<label for>``; the 17
palette inputs are ``type="color"`` (the 3 branding inputs
``type="text"``)."""
body = _theme_view(_html())
text_ids = ("theme-app-name", "theme-placeholder", "theme-footer")
for field_id in text_ids:
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="text"[^>]*>', body), (
f"#{field_id} must be a text input"
)
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
assert re.search(rf'<label[^>]*for="{field_id}"[^>]*>', body), (
f"missing the visible label for #{field_id}"
)
assert re.search(rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body), (
f"#{field_id} must be a type=color input"
)
def test_theme_view_color_inputs_ship_the_builtin_static_values() -> None:
"""The house contract: the color inputs ship the BUILT-IN values —
asserted against ``styles.css``'s ``:root`` parsed in-test (no
third hardcoded palette copy): ``theme.js`` captures these static
values as its ``BUILTINS`` no-op check, so a drift here would
break the byte-identical no-op save for every owner."""
body = _theme_view(_html())
decls = _root_declarations()
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
css_name = f"--{field.replace('_', '-')}"
assert css_name in decls, f"styles.css :root is missing {css_name}"
match = re.search(rf'<input[^>]*id="{field_id}"[^>]*>', body)
assert match is not None, f"#{field_id} is missing from #view-theme"
assert f'value="{decls[css_name].strip()}"' in match.group(0), (
f"#{field_id} must ship the built-in static value "
f"{decls[css_name].strip()!r}, got {match.group(0)!r}"
)
def test_state_colors_fieldset_after_the_palette() -> None:
"""The task-02 form shape: a "State colors" fieldset with the 8
semantic pickers sits AFTER the palette fieldset (the fieldset
order the FIELDS order mirrors), and the palette legend's old
"five pairs" copy is the "eight pairs" copy (the WCAG 2.1 AA
(4.5:1) wording kept)."""
body = _theme_view(_html())
palette = re.search(
r'<fieldset[^>]*class="theme-group"[^>]*>\s*'
r"(?:(?!</fieldset>).)*?Palette — eight pairs checked against "
r"WCAG 2.1 AA \(4\.5:1\)",
body,
re.S,
)
assert palette, "the palette fieldset's legend must say 'eight pairs'"
assert "five pairs" not in body, "the old 'five pairs' copy must be gone"
state = re.search(
r'<fieldset[^>]*class="theme-group"[^>]*>\s*'
r"(?:(?!</fieldset>).)*?<legend[^>]*>State colors</legend>",
body,
re.S,
)
assert state, "the 'State colors' fieldset must be in #view-theme"
assert state.start() > palette.start(), (
"the State colors fieldset must come after the palette fieldset"
)
# All 8 semantic pickers live inside the State colors fieldset.
state_body = body[state.start() : body.index("</fieldset>", state.start())]
for field in ("ok_bg", "ok_ink", "err_bg", "err_ink",
"err_line", "accent_bg", "accent_ink", "accent_line"):
field_id = f"theme-{field.replace('_', '-')}"
assert f'id="{field_id}"' in state_body, (
f"#{field_id} must live in the State colors fieldset"
)
# …and NOT in the palette fieldset (the 9 identity pickers only).
palette_body = body[palette.start() : body.index("</fieldset>", palette.start())]
for field in theming.COLOR_FIELDS:
field_id = f"theme-{field.replace('_', '-')}"
if field in ("ok_bg", "ok_ink", "err_bg", "err_ink",
"err_line", "accent_bg", "accent_ink", "accent_line"):
assert field_id not in palette_body
else:
assert field_id in palette_body
+87 -25
View File
@@ -7,12 +7,12 @@ 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 9 built-ins 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;
* ``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 9 variables, ``COLOR_FIELDS``
``""``) 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
@@ -67,13 +67,16 @@ def _root_declarations() -> dict[str, str]:
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 9 identity variables — no more, no fewer)."""
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",
}, f"BUILTIN_COLORS must name exactly the 9 identity variables, got {sorted(builtin_names)}"
"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}"
@@ -83,16 +86,19 @@ def test_builtin_colors_match_styles_css_root() -> None:
)
def test_color_fields_are_the_nine_keys_in_readme_order() -> None:
"""``COLOR_FIELDS`` is the 9 keys in the themes-README order — the
order the resolver, the API, and the tag renderer all rely on.
(Phase 92, task 01: ``grid_line`` is the 9th identity variable,
slotting in between ``line`` and ``brand`` — structural colors
first, brand last.)"""
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")
@@ -113,12 +119,14 @@ def _env_settings() -> Settings:
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 12 keys."""
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"
@@ -169,7 +177,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
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 12 keys."""
values it reports must be real ``str``s for all 20 values."""
from app.config import get_settings
_start_row_missing(db)
@@ -196,27 +204,40 @@ def test_theme_style_tag_all_builtins_is_empty_string() -> None:
assert theming.theme_style_tag(colors) != ""
def test_theme_style_tag_one_changed_carries_all_nine_in_order() -> None:
"""A single non-built-in color still emits ALL 9 variables, in
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace).
Phase 92 (task 01): the tag carries ``--grid-line:#4a2626;`` between
``--line`` and ``--brand`` (the 9th identity variable — the
background grid texture)."""
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["brand"] = "#818cf8"
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:#818cf8;"
"--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 "--brand:#818cf8;" in tag
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:
@@ -226,11 +247,13 @@ def test_theme_style_tag_multiple_changed() -> None:
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 9 dashed names is the COLOR_FIELDS order.
# 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]
@@ -327,3 +350,42 @@ def test_theme_csp_hash_changes_with_the_palette() -> None:
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]
+56 -4
View File
@@ -1,11 +1,14 @@
"""Unit: the admin UI-settings API (phase 91, task 01).
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
contract and the GET/PUT persistence on the single ``ui_settings`` row:
contract and the GET/PUT persistence on the single ``ui_settings`` row
(all 17 palette colors since phase 93, task 01 — the 9 identity +
the 8 semantic state):
* PUT validation — the 422s NAME the offending field (fixed details):
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
prefix, 3-digit shorthand, 8 hex chars, missing ``#``);
prefix, 3-digit shorthand, 8 hex chars, missing ``#``) — identity AND
semantic fields alike (the loops are ``COLOR_FIELDS``-driven);
* normalization — colors are lowercased on store; a color EQUAL to its
built-in is stored as NULL (the owner-locked rule: "save the defaults"
must leave the row empty — the no-op injection contract); an empty /
@@ -38,6 +41,10 @@ ALL_NULL_BODY: dict[str, str | None] = {
"bg": None, "surface": None, "ink": None, "ink_soft": None,
"line": None, "grid_line": None, "brand": None, "brand_soft": None,
"brand_ink": None,
# The 8 semantic state colors (phase 93, task 01).
"ok_bg": None, "ok_ink": None, "err_bg": None, "err_ink": None,
"err_line": None, "accent_bg": None, "accent_ink": None,
"accent_line": None,
}
@@ -80,8 +87,8 @@ def test_put_too_long_string_422_names_the_field(
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
"""Each of the 9 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
"""Each of the 17 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a
422 naming that field — 3-digit shorthand, 8 hex digits, a bare hex
without ``#``, a named color, and the empty string (the color clear
operation is ``null``, not ``""``)."""
for field in theming.COLOR_FIELDS:
@@ -95,6 +102,15 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
r = admin_client.put("/api/ui-settings", json={"grid_line": "nope"})
assert r.status_code == 422, r.text
assert r.json()["detail"] == "grid_line must be a #rrggbb hex color"
# Phase 93 (task 01): the semantic state fields name their 422 the
# same fixed way (the loop covers all 8 via COLOR_FIELDS; the
# explicit case pins a semantic field name in the detail).
r = admin_client.put("/api/ui-settings", json={"err_line": "nope"})
assert r.status_code == 422, r.text
assert r.json()["detail"] == "err_line must be a #rrggbb hex color"
r = admin_client.put("/api/ui-settings", json={"accent_ink": "#12345678"})
assert r.status_code == 422, r.text
assert r.json()["detail"] == "accent_ink must be a #rrggbb hex color"
def test_put_lowercases_colors_on_store(
@@ -158,6 +174,42 @@ def test_put_grid_line_built_in_is_stored_as_null(
assert row.grid_line == "#123123" # non-built-in is stored as-is
def test_put_semantic_built_in_is_stored_as_null(
admin_client: TestClient, db: Session
) -> None:
"""Phase 93 (task 01): the 8 semantic state colors get the same
owner-locked normalization as the 9 identity colors — a value equal
to its built-in stores NULL (the response still reports the
built-in; the compare happens AFTER the lowercase — one value is
PUT uppercase, proving it); a NON-built-in value is stored as-is
(lowercased)."""
r = admin_client.put("/api/ui-settings", json={"ok_ink": "#6EE7A8"})
assert r.status_code == 200, r.text
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
row = _row(db)
assert row is not None
assert row.ok_ink is None # built-in (uppercase in) → NULL
r = admin_client.put("/api/ui-settings", json={"err_bg": "#2D0A0A"})
assert r.status_code == 200, r.text
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
row = _row(db)
assert row is not None
assert row.err_bg is None # the second built-in (uppercase) → NULL too
r = admin_client.put("/api/ui-settings", json={"accent_ink": "#CCCCCC"})
assert r.status_code == 200, r.text
assert r.json()["accent_ink"] == "#cccccc"
db.expire_all()
row = _row(db)
assert row is not None
assert row.accent_ink == "#cccccc" # non-built-in stored as-is, lowercased
# A semantic field NOT in the body clears back to NULL (full
# replacement — the built-in returns via the resolver).
assert row.ok_ink is None
assert r.json()["ok_ink"] == theming.BUILTIN_COLORS["ok_ink"]
def test_put_empty_string_is_the_clear_operation(
admin_client: TestClient, db: Session
) -> None: