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`
This commit is contained in:
@@ -17,9 +17,10 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
|
||||
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
|
||||
admin sees the "Theme" nav link and the form (gate hidden); the
|
||||
12 inputs show the effective defaults (the 3 template strings +
|
||||
the 9 built-in hexes parsed out of ``styles.css``'s ``:root``
|
||||
IN-TEST — the suite can never drift from the stylesheet); Save
|
||||
20 inputs show the effective defaults (the 3 template strings +
|
||||
the 17 built-in hexes — the 9 identity + the 8 semantic state,
|
||||
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
|
||||
suite can never drift from the stylesheet); Save
|
||||
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
|
||||
held, then restored) and lands the role=status "Theme saved.";
|
||||
the inputs re-populate to the saved values; the persisted row is
|
||||
@@ -29,7 +30,7 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
|
||||
should load immediately, not pop in": after a save, the RAW
|
||||
served HTML of ``/`` carries exactly one ``<style
|
||||
id="bor-theme">`` with all 9 vars = the saved hexes, placed
|
||||
id="bor-theme">`` with all 17 vars = the saved hexes, placed
|
||||
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
|
||||
anonymous context — and the computed ``:root`` custom properties
|
||||
equal the saved hexes at load (the inline tag precedes every
|
||||
@@ -44,7 +45,7 @@ Test → contract mapping (one story, one phase, one isolated file):
|
||||
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
|
||||
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
|
||||
the role=status "Reset to the built-in theme.", re-populates the
|
||||
12 defaults, serves NO theme tag, and the served bytes equal a
|
||||
20 defaults, serves NO theme tag, and the served bytes equal a
|
||||
row-less deployment byte for byte (the no-op injection
|
||||
contract).
|
||||
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
|
||||
@@ -105,9 +106,14 @@ APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# The distinct E2E palette (task 06, extended in phase 92 task 05:
|
||||
# grid_line — the 9th identity var, distinct from its built-in
|
||||
# #4a2626 and from line #232a4a): a full non-built-in indigo set —
|
||||
# every value differs from its built-in, so the tag is non-empty and
|
||||
# every saved color is stored as-is (no built-in→NULL collapse).
|
||||
# #4a2626 and from line #232a4a; extended in phase 93 with the 8
|
||||
# semantic state colors, each distinct from its built-in): a full
|
||||
# non-built-in set over ALL 17 vars — every value differs from its
|
||||
# built-in, so the tag is non-empty and every saved color is stored
|
||||
# as-is (no built-in→NULL collapse). The semantic picks pass the
|
||||
# three new ink-on-bg AA pairs (10.5:1 / 8.2:1 / 9.9:1), so the
|
||||
# saved palette still fails exactly ONE of the eight pairs —
|
||||
# --bg on --brand (3.0:1) — the exact-text contrast assertion.
|
||||
PALETTE: dict[str, str] = {
|
||||
"bg": "#0b1020",
|
||||
"surface": "#111730",
|
||||
@@ -118,6 +124,14 @@ PALETTE: dict[str, str] = {
|
||||
"brand": "#4f46e5",
|
||||
"brand_soft": "#1e2447",
|
||||
"brand_ink": "#c7d2fe",
|
||||
"ok_bg": "#101c24",
|
||||
"ok_ink": "#86d9c0",
|
||||
"err_bg": "#20101c",
|
||||
"err_ink": "#e896b4",
|
||||
"err_line": "#d1608c",
|
||||
"accent_bg": "#1e1a10",
|
||||
"accent_ink": "#d9c37a",
|
||||
"accent_line": "#b8963e",
|
||||
}
|
||||
APP_NAME = "Theme E2E"
|
||||
PLACEHOLDER = "Ask the themed brain…"
|
||||
@@ -145,7 +159,8 @@ COLOR_INPUT_IDS: dict[str, str] = {
|
||||
|
||||
|
||||
def _builtin_colors() -> dict[str, str]:
|
||||
"""The 9 built-in identity hexes parsed OUT of
|
||||
"""The 17 built-in palette hexes (the 9 identity + the 8
|
||||
semantic state, phase 93) parsed OUT of
|
||||
``frontend/assets/styles.css``'s ``:root`` in-test — the single
|
||||
source of truth, so the suite can't drift from the stylesheet it
|
||||
asserts on."""
|
||||
@@ -176,7 +191,7 @@ def _template_defaults() -> dict[str, str]:
|
||||
|
||||
def _expected_tag(colors: dict[str, str]) -> str:
|
||||
"""The EXACT inline tag ``theme_style_tag`` renders for
|
||||
``colors``: one ``:root`` override, all 9 vars in COLOR_FIELDS
|
||||
``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
|
||||
order, no whitespace (the byte the middleware injects)."""
|
||||
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
@@ -326,8 +341,9 @@ def _fill_theme_form(
|
||||
palette: dict[str, str],
|
||||
strings: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Fill the 12 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 9 color pickers (``palette``)."""
|
||||
"""Fill the 20 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
|
||||
phase 93)."""
|
||||
text_values = strings if strings is not None else SAVED_STRINGS
|
||||
page.fill("#theme-app-name", text_values["app_name"])
|
||||
page.fill("#theme-placeholder", text_values["input_placeholder"])
|
||||
@@ -337,7 +353,7 @@ def _fill_theme_form(
|
||||
|
||||
|
||||
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
|
||||
"""Assert all 12 inputs show the given effective values."""
|
||||
"""Assert all 20 inputs show the given effective values."""
|
||||
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
|
||||
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
|
||||
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
|
||||
@@ -347,7 +363,7 @@ def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, s
|
||||
|
||||
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
"""The RAW served HTML carries exactly one inline theme tag, with
|
||||
all 9 vars = the given hexes, placed IMMEDIATELY before
|
||||
all 17 vars = the given hexes, placed IMMEDIATELY before
|
||||
``</head>`` (``inject_theme``'s exact placement: the tag ends
|
||||
exactly where ``</head>`` begins and carries the injector's
|
||||
single leading newline)."""
|
||||
@@ -360,7 +376,7 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
|
||||
|
||||
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
|
||||
"""The first-paint proof: all 9 computed ``:root`` custom
|
||||
"""The first-paint proof: all 17 computed ``:root`` custom
|
||||
properties equal the given hexes. The inline tag precedes every
|
||||
stylesheet application, so a themed deployment resolves them
|
||||
from the first style pass — no red flash, no pop-in (custom
|
||||
@@ -402,9 +418,10 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
expect(page.locator("#theme-gate")).to_be_hidden()
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The 12 inputs show the EFFECTIVE defaults: the 3 template
|
||||
# strings + the 9 built-in hexes parsed straight out of
|
||||
# styles.css's :root (the resolver's missing-row branch).
|
||||
# The 20 inputs show the EFFECTIVE defaults: the 3 template
|
||||
# strings + the 17 built-in hexes (9 identity + 8 semantic)
|
||||
# parsed straight out of styles.css's :root (the resolver's
|
||||
# missing-row branch).
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
|
||||
# Set a distinct palette + the 3 strings, then Save through the
|
||||
@@ -435,7 +452,7 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
# save's refetch is the canonical state).
|
||||
_expect_form_values(page, SAVED_STRINGS, PALETTE)
|
||||
|
||||
# The row landed in Postgres (the id-1 single row, all 12 values
|
||||
# The row landed in Postgres (the id-1 single row, all 20 values
|
||||
# — every palette color differs from its built-in, so nothing
|
||||
# collapsed to NULL).
|
||||
with SessionLocal() as db:
|
||||
@@ -447,10 +464,12 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
for field in COLOR_FIELDS:
|
||||
assert getattr(row, field) == PALETTE[field]
|
||||
|
||||
# The saved palette fails ONE of the five pairs — --bg on
|
||||
# The saved palette fails ONE of the eight pairs — --bg on
|
||||
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
|
||||
# warning lists it. Save was NEVER blocked (the warning-only
|
||||
# contract: the owner's homelab palette; the built-in stays AA).
|
||||
# warning lists it (the three semantic ink-on-bg pairs pass on
|
||||
# the saved picks — see the PALETTE comment). Save was NEVER
|
||||
# blocked (the warning-only contract: the owner's homelab
|
||||
# palette; the built-in stays AA).
|
||||
contrast = page.locator("#theme-contrast")
|
||||
expect(contrast).to_have_attribute("role", "alert")
|
||||
expect(contrast).to_be_visible()
|
||||
@@ -475,7 +494,7 @@ def test_saved_theme_is_pre_paint_for_everyone(
|
||||
_seed_theme_via_api(app_url, _cookies(page))
|
||||
|
||||
# The RAW served HTML (httpx — no JS at all, the server's own
|
||||
# bytes): exactly one inline theme tag, all 9 vars = the saved
|
||||
# bytes): exactly one inline theme tag, all 17 vars = the saved
|
||||
# hexes, immediately before </head> (the pre-paint mechanism the
|
||||
# middleware unit tests pin — this is its observable
|
||||
# consequence).
|
||||
@@ -606,7 +625,7 @@ def test_anonymous_and_token_user_are_walled(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
|
||||
# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
|
||||
# byte-identical served HTML (the no-op injection contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -653,11 +672,11 @@ def test_reset_restores_the_builtin_byte_identical(
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# The form re-populates to the 12 defaults (the env/built-in
|
||||
# The form re-populates to the 20 defaults (the env/built-in
|
||||
# merge, re-rendered from the refetch)…
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
# …and the WCAG warning is gone (the built-in palette passes all
|
||||
# five pairs).
|
||||
# eight pairs).
|
||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
||||
|
||||
# The served HTML is back to the built-in: NO theme tag anywhere
|
||||
@@ -694,12 +713,12 @@ def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: Non
|
||||
login(page, app_url, next="/theme.html")
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
# The form settles on the built-in defaults — the warning is
|
||||
# hidden (the built-in palette passes all five pairs).
|
||||
# hidden (the built-in palette passes all eight pairs).
|
||||
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
|
||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
||||
|
||||
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
|
||||
# picker's input event previews it live AND re-runs the five
|
||||
# picker's input event previews it live AND re-runs the eight
|
||||
# pairs — --ink on --bg (and --ink on --surface, the ink is now
|
||||
# the darker side of that pair too) fail, and each failing pair
|
||||
# is listed with its ratio in the role=alert line.
|
||||
|
||||
Reference in New Issue
Block a user