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`
279 lines
12 KiB
Python
279 lines
12 KiB
Python
"""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
|