phase: 92_theme_save_and_coverage
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix.

- Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated.
- `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site)
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed**
- Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions.
- Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04).
- Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
2026-09-10 00:23:08 -04:00
parent d22d260b8b
commit df91c6316c
49 changed files with 2282 additions and 189 deletions
+21 -18
View File
@@ -17,8 +17,8 @@ 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
11 inputs show the effective defaults (the 3 template strings +
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
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
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
@@ -29,7 +29,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 8 vars = the saved hexes, placed
id="bor-theme">`` with all 9 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 +44,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
11 defaults, serves NO theme tag, and the served bytes equal a
12 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:
@@ -103,7 +103,9 @@ from e2e.conftest import (
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette (task 06): a full non-built-in indigo set —
# 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).
PALETTE: dict[str, str] = {
@@ -112,6 +114,7 @@ PALETTE: dict[str, str] = {
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"grid_line": "#2b3550",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
@@ -142,7 +145,7 @@ COLOR_INPUT_IDS: dict[str, str] = {
def _builtin_colors() -> dict[str, str]:
"""The 8 built-in identity hexes parsed OUT of
"""The 9 built-in identity hexes 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."""
@@ -173,7 +176,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 8 vars in COLOR_FIELDS
``colors``: one ``:root`` override, all 9 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>'
@@ -323,8 +326,8 @@ def _fill_theme_form(
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 11 inputs: the 3 text fields (``strings``, default
the E2E set) + the 8 color pickers (``palette``)."""
"""Fill the 12 inputs: the 3 text fields (``strings``, default
the E2E set) + the 9 color pickers (``palette``)."""
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"])
@@ -334,7 +337,7 @@ def _fill_theme_form(
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 11 inputs show the given effective values."""
"""Assert all 12 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"])
@@ -344,7 +347,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 8 vars = the given hexes, placed IMMEDIATELY before
all 9 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)."""
@@ -357,7 +360,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 8 computed ``:root`` custom
"""The first-paint proof: all 9 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
@@ -399,8 +402,8 @@ 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 11 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 8 built-in hexes parsed straight out of
# 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).
_expect_form_values(page, defaults, builtin)
@@ -432,7 +435,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 11 values
# The row landed in Postgres (the id-1 single row, all 12 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
with SessionLocal() as db:
@@ -472,7 +475,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 8 vars = the saved
# bytes): exactly one inline theme tag, all 9 vars = the saved
# hexes, immediately before </head> (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
@@ -603,7 +606,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -650,7 +653,7 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 11 defaults (the env/built-in
# The form re-populates to the 12 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
+767
View File
@@ -0,0 +1,767 @@
"""Phase 92 E2E (Playwright): Save/Reset repaint the OPEN page without a
reload, and the Theme tab's variables drive the ENTIRE site.
Source: owner chat defect report (post-phase-91): (1) "Clicking 'save
theme' reverts the theme back to the previous theme, a refresh is
required to see the new theme."; (2) "Not everything is controllable
via the theme controls. Certain buttons and text are still light pink
on highlight, for example. The background grid never changes color." —
"The theme controls should allow manipulating the entire site's theme."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_save_applies_live_without_reload`` — defect 1 (Save): the
§7.4 save lifecycle lands, and — with NO navigation — the OPEN
document mirrors the saved state: the computed ``:root`` palette is
the saved 9 hexes, the ``#bor-theme`` tag's DOM text is the exact
9-var ``:root{…}`` the next load would serve, and ``<html>``'s
inline style holds EXACTLY the 9 saved custom properties (no stale
pick). The server agrees (the raw ``/`` carries the 9-var tag), and
an SPA nav to Chat (same document) keeps the saved palette on the
computed ``--brand`` and the ``.send-btn`` fill.
2. ``test_reset_applies_live_without_reload`` — defect 1 (Reset): on a
THEMED load (the served tag is present), the §7.4 reset lifecycle
lands, and — with NO navigation — the ``#bor-theme`` tag is REMOVED
from the live document, the computed ``:root`` palette is the 9
built-ins (parsed from ``styles.css`` in-test), ``<html>`` carries
no overrides, the served HTML has no tag, and the with-row bytes
equal a row-less deployment byte for byte (the no-op contract end
to end, now 9-wide).
3. ``test_theme_controls_drive_the_whole_site`` — defect 2: with the
palette seeded, a fresh load's first paint is the themed paint
(the raw HTML carries the 9-var tag immediately before ``</head>``
+ the phase-91 CSP ``style-src 'self' 'sha256-…'``), and the
browser-COMPUTED values prove every themed surface follows the tab:
the background grid texture (``body::before`` — the owner's named
defect) at 60% ``--grid-line``, ``::selection`` at 45% ``--brand``,
the button hovers at the derived ``--brand-hover`` (and NOT the
legacy indigo ``#7d88f5``), the nav-link wash at the saved
``--brand-soft`` (exact), and the wordmark at the saved
``--surface`` (exact).
CSP reality (why the open-page repaint rides the ``<html>`` overrides
— the one deviation from the ``00_phase.md`` design): the repo's strict
policy (phase 82/91 — A1 + ``style-src 'self' 'sha256-<served tag>'``,
no ``'unsafe-inline'``) makes the design's "sync the tag, then clear
the preview" shape impossible in a real browser: Chromium re-checks
``style-src`` on EVERY DOM-API content change to a ``<style>`` element
(``textContent`` on the served tag, ``createElement`` +
``appendChild``, ``replaceChildren`` — all blocked for a fresh
palette; verified by a standalone probe against this repo's CSP
shape), so a cleared preview would fall back to the STALE served tag —
the original defect. The only CSP-clean repaint path is CSSOM
``setProperty`` on ``<html>`` (the phase-91 live preview's mechanism —
an unchecked mutation), which task 04's ``applyServedTheme`` uses as
the PAINT half, while the tag's DOM TEXT is still synced (the MIRROR
half — what the next load serves, inert until the reload that serves
matching content + hash). Test 1 therefore asserts the paint half as
"the ``<html>`` overrides are EXACTLY the saved palette" (not an empty
style attribute, as the cleared-preview shape would leave — on a
reset the attribute IS empty, and test 2 asserts exactly that).
Computed-value assertions read the browser's SERIALIZED colors
(``rgb(…)`` for plain-var surfaces, ``color(srgb …)`` for
``color-mix()`` results — custom properties return tokens, USED
properties resolve) with the ±1/channel (±1/255 float) tolerance; the
tolerance absorbs un-pinned browser rounding yet still fails any
legacy hardcoded value by orders of magnitude.
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware reads for EVERY served page — a
leftover themed row would repaint other suites' pages) and
``api_tokens`` rows across suites. An autouse fixture truncates
``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
after every test (never a TRUNCATE on ``api_tokens`` — the shared
DB may hold the owner's real tokens).
Per-module app env (the tuning/tokens/archive-upload pattern): the
module-scoped ``app_server`` override boots the same env block as
the shared conftest server with the branding vars pinned to the
CODE defaults (an operator's local ``.env`` may carry the owner's
name/placeholder/footer, and "the effective strings start at the
template defaults" must hold regardless — the phase-61/62
leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
not render as env rows in this suite's app). The phase-91 file is the
copy source — the two modules' scaffolds stay in lockstep so a
future conftest refactor touches both at once.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette: the phase-91 8-value indigo set PLUS the
# 9th identity var (phase 92, task 01) — grid_line #2b3550, distinct
# from its built-in #4a2626 and from line #232a4a. 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).
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"grid_line": "#2b3550",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
COLOR_INPUT_IDS: dict[str, str] = {
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
}
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 9 built-in identity hexes 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."""
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
assert root is not None, "styles.css must open with its :root block"
colors: dict[str, str] = {}
for name in COLOR_FIELDS:
match = re.search(
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
)
assert match is not None, f"--{name} missing from styles.css :root"
colors[name] = match.group(1).lower()
return colors
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
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>'
def _expected_tag_content(colors: dict[str, str]) -> str:
"""The tag's INNER ``:root{…}`` string — the ``#bor-theme``
element's ``textContent`` after a settled save (task 04's mirror
half is byte-identical to it: same 9 fields, same order, the
resolver's lowercased hexes)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f":root{{{declarations}}}"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""All 9 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 properties return the specified token, so
the string compare is stable — the ``.trim()`` rides out any token
whitespace)."""
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
page.wait_for_function(
"""(expected) => {
const cs = getComputedStyle(document.documentElement);
return Object.entries(expected).every(
([k, v]) => cs.getPropertyValue(k).trim() === v
);
}""",
arg=expected,
timeout=timeout,
)
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from
the class fields — never drifts from ``app/config.py``; the
module server pins the same values, so the effective strings
start exactly here)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate is
the last visible step — the app-name input carries the effective
default (the static markup ships it empty, so only a settled
``loadSettings`` can have written it). From here on, no in-flight
mount load can race the test's own save/reset refetch (an
out-of-order settle would re-reconcile the open document onto a
stale read)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
"""The ``<html>`` inline custom properties task 04's
``applyServedTheme`` leaves after settling on ``colors`` (the
CSSOM PAINT half — the only CSP-clean repaint path, see the module
docstring): exactly the vars that differ from their built-in, at
the saved values (empty for the built-in palette)."""
return {
f"--{field.replace('_', '-')}": value
for field, value in colors.items()
if value != builtins[field]
}
def _wait_settled_open_document(
page: Page, tag_text: str | None, overrides: dict[str, str]
) -> None:
"""The OPEN document mirrors the settled state (defect 1): the
``#bor-theme`` tag's DOM text is ``tag_text`` (``None`` = the tag
is REMOVED — the no-op/reset case) and ``<html>``'s inline style
holds EXACTLY the ``overrides`` custom properties (no stale pick —
the pre-task-04 code fails this wait: its save cleared the preview
onto the stale served tag, leaving neither the synced tag text
nor the paint-half overrides). The absent-tag case rides the ``""``
sentinel: Playwright's wait_for_function serializes a Python
``None`` arg as JS ``undefined`` (not ``null`` — probe-verified),
and the real tag content is never empty anyway."""
page.wait_for_function(
"""(expected) => {
const el = document.getElementById('bor-theme');
if (expected.tag === '') {
if (el !== null) return false;
} else if (el === null || el.textContent !== expected.tag) {
return false;
}
const s = document.documentElement.style;
const actual = {};
for (let i = 0; i < s.length; i++) {
const p = s[i];
if (p.startsWith('--')) actual[p] = s.getPropertyValue(p);
}
const keys = Object.keys(actual).sort();
const expKeys = Object.keys(expected.overrides).sort();
if (keys.length !== expKeys.length) return false;
return keys.every(
(k, i) => k === expKeys[i] && actual[k] === expected.overrides[k]
);
}""",
arg={"tag": "" if tag_text is None else tag_text, "overrides": overrides},
timeout=15_000,
)
# ---------------------------------------------------------------------------
# color-mix resolution (the browser's sRGB interpolation, for the
# used-surface assertions — the serialized strings are compared with
# the ±1/255 tolerance, never raw color-mix(…) tokens)
# ---------------------------------------------------------------------------
#: The modern serialization the browser uses for color-mix() results:
#: ``color(srgb R G B[/ A])`` — 0..1 float channels, alpha optional
#: (opaque). Plain-var surfaces serialize as ``rgb(R, G, B)`` (8-bit).
_COLOR_SRGB = re.compile(
r"color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*/\s*([0-9.]+))?"
)
#: The ±1/channel tolerance (±1/255 in the 0..1 float space) — browser
#: rounding is not pinned by the spec, and the window still fails any
#: legacy hardcoded value by orders of magnitude.
_TOL = 1.0 / 255.0 + 1e-9
def _hex_channels(hex_str: str) -> tuple[float, float, float]:
"""``#rrggbb`` → (r, g, b) in 0..1 floats."""
return (
int(hex_str[1:3], 16) / 255.0,
int(hex_str[3:5], 16) / 255.0,
int(hex_str[5:7], 16) / 255.0,
)
def _mix(
a_hex: str, percent: float, b_hex: str | None = None
) -> tuple[float, float, float, float]:
"""The browser's ``color-mix(in srgb, A p%, B)`` — CSS Color 4:
sRGB interpolation is PREMULTIPLIED (with ``B = transparent`` =
(0,0,0,0) — ``b_hex=None`` — the result is simply A at alpha
``p/100``). Returns (r, g, b, a) in 0..1 straight channels."""
a = (*_hex_channels(a_hex), 1.0)
b: tuple[float, float, float, float] = (
(0.0, 0.0, 0.0, 0.0)
if b_hex is None
else (*_hex_channels(b_hex), 1.0)
)
w = percent / 100.0
alpha = a[3] * w + b[3] * (1.0 - w)
if alpha == 0.0:
return (0.0, 0.0, 0.0, 0.0)
prem = tuple(a[i] * a[3] * w + b[i] * b[3] * (1.0 - w) for i in range(3))
return (prem[0] / alpha, prem[1] / alpha, prem[2] / alpha, alpha)
def _parse_color_srgb(ser: str) -> tuple[float, float, float, float]:
"""Parse the browser's ``color(srgb R G B[/ A])`` serialization
(the color-mix() result form) into 0..1 floats (opaque → a=1)."""
match = _COLOR_SRGB.search(ser)
assert match is not None, f"no color(srgb …) serialization in {ser!r}"
r, g, b = (float(match.group(i)) for i in (1, 2, 3))
a = float(match.group(4)) if match.group(4) is not None else 1.0
return (r, g, b, a)
def _close(got: tuple[float, float, float, float], want: tuple[float, float, float, float]) -> None:
"""±1/channel (float) on r/g/b, ~exact on alpha (the browser
serializes the exact mix alpha)."""
for i in range(3):
assert abs(got[i] - want[i]) <= _TOL, (
f"channel {i}: {got[i]} !~ {want[i]} (full: {got} vs {want})"
)
assert abs(got[3] - want[3]) <= 1e-4, f"alpha: {got[3]} != {want[3]}"
# ---------------------------------------------------------------------------
# Per-module app env (the tuning/tokens/archive-upload pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the branding vars
are pinned to the CODE defaults (the effective strings start at
the template defaults regardless of an operator's local
``.env`` — the phase-61/62 leak-guard pattern the shared conftest
server applies to its two string vars; this one pins all three,
including ``BOR_APP_NAME``, which the shared server leaves to the
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
``.env``'s git repo must not render as env rows in this
suite's app)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — no chat turn is
# ever sent in this suite, but the app boots with the same shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
Settings.model_fields["suggestions"].default
)
# The branding vars: "unset" = the template defaults (the code
# defaults, derived from the class fields — the local ``.env`` may
# carry the owner's values, and this suite's assertions need the
# TEMPLATE defaults, not the owner's).
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
env["BOR_INPUT_PLACEHOLDER"] = (
Settings.model_fields["input_placeholder"].default
)
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _clean_ui_state() -> None:
"""Fresh theme + token state per test: truncate the single-row
``ui_settings`` (the middleware reads it for EVERY page — a
leftover themed row would repaint other suites' pages) and
delete this suite's issued tokens (label-scoped on ``e2e-`` —
never a TRUNCATE: the shared DB may hold the owner's real
tokens)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_ui_state()
yield
_clean_ui_state()
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
"""Intercept ``PUT /api/ui-settings`` and hold it for
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
while it is held, the page's fetch is guaranteed pending, so the
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
labels) is observable deterministically — a localhost PUT
settles in milliseconds, so without the hold the window is a
race. GETs (the load + the save's refetch) pass straight
through."""
def handle(route: Route) -> None:
if route.request.method == "PUT":
time.sleep(hold_s)
route.continue_()
page.route("**/api/ui-settings", handle)
def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
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``)."""
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"])
page.fill("#theme-footer", text_values["footer_text"])
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
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
``</head>`` (``inject_theme``'s exact placement: the tag ends
exactly where ``</head>`` begins and carries the injector's
single leading newline)."""
tag = _expected_tag(colors)
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
start = raw.index(tag)
head = raw.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
# ---------------------------------------------------------------------------
# 1. Defect 1 (Save): the §7.4 lifecycle lands and the OPEN page
# repaints the saved palette — no navigation, no reload
# ---------------------------------------------------------------------------
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The mount's initial load has settled (the row-less state — no
# tag, no overrides) before the test touches the form: no
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
# Fill the 12 inputs (the 3 strings + the 9-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-save")).to_have_text("Saving…")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
expect(page.locator("#theme-save")).to_have_text("Save theme")
expect(page.locator("#theme-save")).to_be_enabled()
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# NO navigation (the URL never leaves the Theme view): the OPEN
# document mirrors the settled save — the tag's DOM text is the
# exact 9-var :root{…} the next load would serve (task 04's mirror
# half) and <html>'s inline style holds EXACTLY the 9 saved
# custom properties (the paint half — no stale pick; the pre-fix
# code cleared the preview onto the STALE served tag and fails
# this wait).
expect(page).to_have_url(APP_URL + "/theme.html")
_wait_settled_open_document(
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
)
# …and all 9 computed :root custom properties ARE the saved hexes
# (the open page painted the saved palette — defect 1 gone).
_wait_theme_computed(page, PALETTE)
# The server agrees: the RAW served HTML (a fresh request) carries
# the 9-var tag immediately before </head>.
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
# SPA navigation (the router's view switch — same document, NO
# reload): the theme view hides, the chat view shows, and the
# saved palette survives on the live computed values.
page.click('a.nav-link[href="/"]')
expect(page.locator("#view-theme")).to_be_hidden()
expect(page.locator("#view-chat")).to_be_visible(timeout=15_000)
assert (
page.evaluate(
"() => getComputedStyle(document.documentElement)"
".getPropertyValue('--brand').trim()"
)
== PALETTE["brand"]
)
# .send-btn { background: var(--brand) } — the used color is the
# saved brand, exact (8-bit hex → rgb() serialization).
expect(page.locator(".send-btn")).to_have_css(
"background-color", "rgb(79, 70, 229)"
)
# ---------------------------------------------------------------------------
# 2. Defect 1 (Reset): on a themed load, Reset removes the tag from
# the live document and paints the built-ins — no navigation
# ---------------------------------------------------------------------------
def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# Seed the theme via the API, THEN load the shell: the served
# document carries the 9-var tag (a THEMED load — the reset must
# remove it from the live document, not just stop serving it).
_seed_theme_via_api(app_url, _cookies(page))
page.goto(app_url + "/theme.html")
# Served-state sanity: the first paint IS the themed paint.
_wait_theme_computed(page, PALETTE)
# AND the mount's initial load has settled: its applyServedTheme
# has run (the <html> overrides exist — the document mirrors the
# served theme). From here on, only the reset's own refetch can
# re-reconcile the open document.
_wait_settled_open_document(
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
)
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
# API's documented "defaults" operation).
_hold_theme_puts(page)
try:
page.click("#theme-reset")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# NO navigation: the #bor-theme tag is REMOVED from the live
# document (effective = the built-ins → content null → the tag
# goes) and the page paints the 9 built-ins (parsed from
# styles.css in-test) with <html> carrying no overrides at all —
# the reset is the one case where the cleared-preview shape and
# the overrides shape agree: the style attribute is empty.
expect(page).to_have_url(APP_URL + "/theme.html")
_wait_settled_open_document(page, None, {})
_wait_theme_computed(page, builtin)
assert (
page.evaluate("() => (document.documentElement.getAttribute('style') || '').trim()")
== ""
)
# The server agrees: no tag served (the all-NULL row is the
# no-op)…
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the byte-identical contract end to end: the with-row bytes
# equal a ROW-LESS deployment byte for byte (now 9-wide — a
# defaults-saved row never adds a byte).
with_row = r.content
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
without_row = httpx.get(app_url + "/", timeout=10).content
assert with_row == without_row, (
"a defaults-saved row must serve byte-identical HTML"
)
# ---------------------------------------------------------------------------
# 3. Defect 2: the tab's variables drive EVERY themed surface — the
# grid, the selection, the hovers, the wordmark (computed values)
# ---------------------------------------------------------------------------
def test_theme_controls_drive_the_whole_site(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme_via_api(app_url, _cookies(page))
# Pre-paint with the 9th var: the raw served HTML carries the
# 9-var tag immediately before </head>, permitted in a real
# browser only via the phase-91 CSP hash (now 9-wide).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# Fresh load: the first paint is the themed paint.
page.goto(app_url + "/")
_wait_theme_computed(page, PALETTE)
# The background grid (the owner's named defect: "the background
# grid never changes color") — body::before's 1px line stops are
# color-mix(in srgb, var(--grid-line) 60%, transparent), which the
# browser serializes as color(srgb … / 0.6) at the grid line's
# channels (premultiplied sRGB with transparent = the source at
# the mix alpha).
grid_image = page.evaluate(
"() => getComputedStyle(document.body, '::before').backgroundImage"
)
want = _mix(PALETTE["grid_line"], 60.0, None)
stops = [
(float(m.group(1)), float(m.group(2)), float(m.group(3)),
float(m.group(4)) if m.group(4) is not None else 1.0)
for m in _COLOR_SRGB.finditer(grid_image)
]
assert any(
abs(stop[3] - want[3]) <= 1e-4
and all(
abs(got - want_c) <= _TOL
for got, want_c in zip(stop[:3], want[:3], strict=True)
)
for stop in stops
), f"the 60% --grid-line stop {want} is not in the grid: {grid_image!r}"
# ::selection — color-mix(in srgb, var(--brand) 45%, transparent):
# the brand's channels at alpha exactly 0.45.
selection = page.evaluate(
"() => getComputedStyle(document.documentElement, '::selection').backgroundColor"
)
_close(_parse_color_srgb(selection), _mix(PALETTE["brand"], 45.0, None))
# The button hovers (the "light pink on highlight" defect) — the
# derived --brand-hover: the brand at 86% toward white.
page.hover(".new-chat-btn")
new_chat_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('.new-chat-btn')).backgroundColor"
)
_close(_parse_color_srgb(new_chat_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
# Explicitly NOT the legacy indigo hover (#7d88f5 — the incoherent
# pre-phase-92 literal that survived under the rose brand):
assert new_chat_bg != "rgb(125, 136, 245)", new_chat_bg
assert abs(_parse_color_srgb(new_chat_bg)[0] - 125 / 255.0) > _TOL
page.hover(".send-btn")
send_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('.send-btn')).backgroundColor"
)
_close(_parse_color_srgb(send_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
# The house nav-link hover wash — the saved --brand-soft, EXACT
# (a plain var resolves to the 8-bit hex serialization).
page.hover("#nav-sources")
nav_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('#nav-sources')).backgroundColor"
)
assert nav_bg == "rgb(30, 36, 71)", nav_bg
# The wordmark (the static brand-mark SVG that "never themes") —
# its first path's fill is var(--surface): the saved surface,
# EXACT.
fill = page.evaluate(
"() => getComputedStyle(document.querySelector('.brand-mark path')).fill"
)
assert fill == "rgb(17, 23, 48)", fill
+33 -1
View File
@@ -27,12 +27,13 @@ from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.main import app as fastapi_app
from app.models import UiSettings
from tests.conftest import ADMIN_PASSWORD
@@ -120,6 +121,37 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
def test_admin_grid_line_validation_and_normalization(
client: TestClient, db: Session
) -> None:
"""Phase 92 (task 01): the 9th identity color against the LIVE API —
a bad hex is a 422 naming ``grid_line`` (same fixed detail as the
other 8); the built-in value stores NULL (the response still
reports the built-in — the no-op normalization); a non-built-in
value is stored and reported back. The admin gate itself is pinned
unchanged by the tests above (router-wide ``require_admin``)."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
r = 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"
r = client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is not None, "the PUT upsert creates the id-1 row"
assert row.grid_line is None # built-in → NULL normalization
r = client.put("/api/ui-settings", json={"grid_line": "#123123"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == "#123123"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["grid_line"] == "#123123" # the stored value reads back
assert len(r.json()) == 12 # the 12-key response shape (9 colors + 3 strings)
def _config_keys() -> set[str]:
"""The /api/config key set after task 03: the five phase-39/59/62
keys — the retired CSS-file theming's ``theme`` key is gone."""
+13 -9
View File
@@ -108,17 +108,21 @@ def test_no_background_layer_declares_animation() -> None:
def test_grid_layer_is_static_and_unchanged() -> None:
"""The owner removed the animated part, not the grid: body::before
keeps 44px cells, 1px lines at the fixed 60% line alpha (warm
rebrand tone), and the widened radial mask (both the -webkit- and
standard mask properties) — and carries NO animation."""
keeps 44px cells, 1px lines at 60% of the grid line color, and the
widened radial mask (both the -webkit- and standard mask
properties) — and carries NO animation. Phase 92 (task 02): the
line color is the 9th identity variable --grid-line at 60% (the
built-in #4a2626 reproduces the old warm tone exactly — and the
tab's Grid lines picker now repaints this texture)."""
block = _rule_block(_css(), GRID_LAYER)
grid = "color-mix(in srgb, var(--grid-line) 60%, transparent)"
assert "background-size: 44px 44px" in block
assert (
"linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
), "grid must keep horizontal 1px lines at 60% line alpha"
assert (
"linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block
), "grid must keep vertical 1px lines at 60% line alpha"
assert f"linear-gradient(to right, {grid} 1px, transparent 1px)" in block, (
"grid must keep horizontal 1px lines at 60% --grid-line"
)
assert f"linear-gradient(to bottom, {grid} 1px, transparent 1px)" in block, (
"grid must keep vertical 1px lines at 60% --grid-line"
)
mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)"
assert f"-webkit-mask-image: {mask};" in block
assert f"mask-image: {mask};" in block
+36 -2
View File
@@ -796,7 +796,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
``/``, the non-shell ``/document.html``, and the dynamic
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
newline, nothing between), with all 9 ``--*`` vars in ``COLOR_FIELDS``
order and the changed value; the ``?v=`` asset rewrite still applies
alongside."""
db.execute(text("DELETE FROM ui_settings"))
@@ -819,7 +819,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
# (nothing between the tag and the close).
assert "\n" + tag + "</head>" in r.text
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
# All 8 vars, COLOR_FIELDS order, the changed value present.
# All 9 vars, COLOR_FIELDS order, the changed value present.
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
assert declared is not None
names = re.findall(r"--([a-z-]+):", declared.group(1))
@@ -842,6 +842,40 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
db.commit()
def test_middleware_grid_only_change_tag_carries_grid_line(db: Session) -> None:
"""Phase 92 (task 01): the 9th identity variable — the OTHER 8 colors
at built-in + ONLY ``grid_line`` set still breaks the no-op contract:
the tag is NON-empty and carries ALL 9 vars (``--grid-line:`` with
the changed value, the rest their built-ins, ``COLOR_FIELDS`` order)
with the matching style-src CSP hash."""
db.execute(text("DELETE FROM ui_settings"))
db.add(UiSettings(id=1, grid_line="#123123"))
db.commit()
try:
colors = dict(theming.BUILTIN_COLORS)
colors["grid_line"] = "#123123" # one changed color, rest built-in
tag = theming.theme_style_tag(colors)
assert tag != "" # the no-op contract holds ONLY for all-built-in
assert "--grid-line:#123123;" in tag
client = TestClient(_theme_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.text.count('id="bor-theme"') == 1
assert "\n" + tag + "</head>" in r.text
# All 9 vars, COLOR_FIELDS order (grid_line between line and brand).
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
assert declared is not None
names = re.findall(r"--([a-z-]+):", declared.group(1))
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
assert names.index("grid-line") == 5
assert r.headers["content-security-policy"] == (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
finally:
db.execute(text("DELETE FROM ui_settings"))
db.commit()
@pytest.mark.parametrize(
("what",),
[("session",), ("resolver",)],
+3 -1
View File
@@ -245,7 +245,9 @@ def test_new_chat_button_style_contract() -> None:
assert "background: var(--brand)" in body, "solid brand fill (the rebrand)"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert hover and "var(--brand-hover)" in hover.group(1), (
"hover lightens the brand fill (phase 92: --brand-hover)"
)
# Mobile (≤640px): the button sits in .chat-shell, not the navbar —
# the label stays visible and the plus icon is hidden (room in the
# body); the pill stays ≥44px via min-height.
+7 -3
View File
@@ -97,15 +97,19 @@ def test_reduced_motion_calm_not_removed() -> None:
def test_busy_button_style_tokens() -> None:
"""Phase 48 (revised contract, owner-locked 2026-08-29): in flight
the button is the enabled Stop control — "Stop" label, .is-stop
class (rose treatment, 6.3:1 with the #fff label), spinner hidden;
idle/error keep the brand Send button (dark ink on brand 5.2:1).
class (--brand-stop treatment — the brand darkened toward --bg,
5.8:1 with the white label at the built-in default, phase 92),
spinner hidden; idle/error keep the brand Send button
(dark ink on brand 5.2:1).
The spinner element stays in the markup + CSS (16px dark arc — the
reduced-motion pin below) but the state machine never shows it: the
Stop label + treatment carry the in-flight state."""
css = _css()
js = _js()
assert ".send-btn.is-stop" in css
assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)"
assert "background: var(--brand-stop)" in css, (
"the stop background: the brand darkened toward --bg (5.8:1 with the white label)"
)
assert ".send-btn.is-stop:hover" in css, "the darker hover step"
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js
+5 -4
View File
@@ -759,7 +759,7 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
assert "min-height: 44px" in body, "the comfortable touch target"
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
"the in-flight disabled state is dimmed (the house language)"
)
@@ -902,8 +902,8 @@ 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 8 labeled type=color palette inputs
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
— the server re-validates) + the 9 labeled type=color palette inputs
(the 9 identity variables, 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,7 +941,7 @@ 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 8 labeled type=color palette inputs (the 8 identity
# and the 9 labeled type=color palette inputs (the 9 identity
# variables — 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"
@@ -959,6 +959,7 @@ def test_theme_view_scaffold_in_the_shell() -> None:
"theme-ink",
"theme-ink-soft",
"theme-line",
"theme-grid-line",
"theme-brand",
"theme-brand-soft",
"theme-brand-ink",
+8 -7
View File
@@ -22,15 +22,16 @@ def test_all_tables_registered() -> None:
def test_ui_settings_single_row_nullable_contract() -> None:
"""Phase 91: 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 8 identity colors VARCHAR(7), ALL
nullable (NULL = default — B1: env value for the strings, the
built-in palette for the colors)."""
"""Phase 91 (9 identity colors after phase 92, task 01): 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
(NULL = default — B1: env value for the strings, the built-in
palette for the colors)."""
settings_table = Base.metadata.tables["ui_settings"]
assert set(settings_table.c.keys()) == {
"id", "app_name", "input_placeholder", "footer_text",
"bg", "surface", "ink", "ink_soft", "line",
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
"brand", "brand_soft", "brand_ink",
}
pk = settings_table.c["id"]
@@ -40,7 +41,7 @@ def test_ui_settings_single_row_nullable_contract() -> None:
col = settings_table.c[name]
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",
for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line",
"brand", "brand_soft", "brand_ink"):
col = settings_table.c[name]
assert col.nullable is True, f"{name} must be NULL (the built-in)"
+6 -2
View File
@@ -453,7 +453,9 @@ def test_share_button_css_is_the_exact_save_family() -> None:
assert "background: var(--brand)" in body, "same solid brand fill as Save"
assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)"
hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert hover and "var(--brand-hover)" in hover.group(1), (
"hover lightens the brand fill (phase 92: --brand-hover)"
)
svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css)
assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
@@ -1013,7 +1015,9 @@ def test_stale_banner_css_is_the_kb_banner_family() -> None:
):
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert hover and "var(--brand-hover)" in hover.group(1), (
"hover lightens the brand fill (phase 92: --brand-hover)"
)
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
"the redo glyph rides the 16px pill size"
)
+6 -3
View File
@@ -600,8 +600,9 @@ def test_sync_result_is_styled() -> None:
def test_sync_modal_css_error_palette_and_stacking() -> None:
""".sync-modal-backdrop: fixed, full-viewport, rgba dim, z-index
above the sticky header; .sync-modal: the centered ≈28rem panel on
""".sync-modal-backdrop: fixed, full-viewport, the --bg-82% dim
(phase 92: color-mix of the identity variable), z-index above the
sticky header; .sync-modal: the centered ≈28rem panel on
the phase-08 error palette (panel on --err-bg, 1px --err-line
border, --err-ink error text, --ink title — all computed ≥4.5:1);
open/close via .is-open (visibility/opacity)."""
@@ -612,7 +613,9 @@ def test_sync_modal_css_error_palette_and_stacking() -> None:
assert "position: fixed" in b
assert "inset: 0" in b
assert "z-index: 1000" in b, "above the sticky header (20) + skip-link (100)"
assert "rgba(" in b, "the dim over the page"
assert "color-mix(in srgb, var(--bg) 82%, transparent)" in b, (
"the dim over the page (phase 92: --bg at 82%)"
)
open_state = re.search(r"\.sync-modal-backdrop\.is-open\s*\{([^}]*)\}", css)
assert open_state, ".is-open must be the open state"
assert "visibility: visible" in open_state.group(1)
+35 -16
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 8 built-ins must equal the
* ``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;
* ``theme_style_tag`` — the byte-identical contract (all built-in →
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
``""``) and the exact tag shape (all 9 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
@@ -45,6 +45,14 @@ def _delete_row() -> Any:
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."""
@@ -59,13 +67,13 @@ 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 8 identity variables — no more, no fewer)."""
the 9 identity variables — no more, no fewer)."""
decls = _root_declarations()
builtin_names = set(theming.BUILTIN_COLORS)
assert builtin_names == {
"bg", "surface", "ink", "ink_soft", "line",
"bg", "surface", "ink", "ink_soft", "line", "grid_line",
"brand", "brand_soft", "brand_ink",
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
}, f"BUILTIN_COLORS must name exactly the 9 identity 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}"
@@ -75,12 +83,15 @@ def test_builtin_colors_match_styles_css_root() -> None:
)
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
order the resolver, the API, and the tag renderer all rely on."""
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.)"""
assert theming.COLOR_FIELDS == (
"bg", "surface", "ink", "ink_soft",
"line", "brand", "brand_soft", "brand_ink",
"line", "grid_line", "brand", "brand_soft", "brand_ink",
)
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
@@ -102,7 +113,8 @@ 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 11 keys."""
strings + the built-in palette, all 12 keys."""
_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())
@@ -117,6 +129,7 @@ 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:
@@ -140,6 +153,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None
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:
@@ -155,9 +169,10 @@ 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 11 keys."""
values it reports must be real ``str``s for all 12 keys."""
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
@@ -181,16 +196,20 @@ 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_eight_in_order() -> None:
"""A single non-built-in color still emits ALL 8 variables, in
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
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)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
tag = theming.theme_style_tag(colors)
assert tag == (
'<style id="bor-theme">:root{'
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#818cf8;"
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"}</style>"
)
# The changed value lands under the dashed CSS name…
@@ -211,7 +230,7 @@ def test_theme_style_tag_multiple_changed() -> None:
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
assert "--brand-ink:#c7d2fe;" in tag
assert tag.endswith("}</style>")
# The order of the 8 dashed names is the COLOR_FIELDS order.
# The order of the 9 dashed names is the COLOR_FIELDS order.
names = re.findall(r"--([a-z-]+):", tag)
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
+34 -3
View File
@@ -36,7 +36,8 @@ from app.models import UiSettings
ALL_NULL_BODY: dict[str, str | None] = {
"app_name": None, "input_placeholder": None, "footer_text": None,
"bg": None, "surface": None, "ink": None, "ink_soft": None,
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
"line": None, "grid_line": None, "brand": None, "brand_soft": None,
"brand_ink": None,
}
@@ -79,7 +80,7 @@ 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 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
"""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
without ``#``, a named color, and the empty string (the color clear
operation is ``null``, not ``""``)."""
@@ -88,6 +89,12 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
r = admin_client.put("/api/ui-settings", json={field: bad})
assert r.status_code == 422, (field, bad, r.text)
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
# Phase 92 (task 01): the 9th identity color names its 422 the same
# fixed way as the other 8 (the loop above already covers it via
# COLOR_FIELDS; the explicit case pins the field name in the detail).
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"
def test_put_lowercases_colors_on_store(
@@ -127,6 +134,30 @@ def test_put_built_in_color_is_stored_as_null(
assert getattr(row, field) is None, f"{field} must be stored as NULL"
def test_put_grid_line_built_in_is_stored_as_null(
admin_client: TestClient, db: Session
) -> None:
"""Phase 92 (task 01): the 9th identity color gets the same
owner-locked normalization as the other 8 — PUTting the built-in
grid hex stores NULL (the response still reports the built-in, and
the row's grid column stays empty); a NON-built-in value is stored
as-is (lowercased)."""
r = admin_client.put("/api/ui-settings", json={"grid_line": "#4a2626"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"]
row = _row(db)
assert row is not None
assert row.grid_line is None # built-in → NULL
r = admin_client.put("/api/ui-settings", json={"grid_line": "#123123"})
assert r.status_code == 200, r.text
assert r.json()["grid_line"] == "#123123"
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
row = _row(db)
assert row is not None
assert row.grid_line == "#123123" # non-built-in is stored as-is
def test_put_empty_string_is_the_clear_operation(
admin_client: TestClient, db: Session
) -> None:
@@ -147,7 +178,7 @@ def test_put_empty_string_is_the_clear_operation(
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
"""GET reports the DB values over the defaults, column by column: a
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
built-ins and the env strings — all 11 keys, no nulls."""
built-ins and the env strings — all 12 keys, no nulls."""
db.add(UiSettings(id=1, bg="#123456"))
db.commit()
r = admin_client.get("/api/ui-settings")