diff --git a/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md b/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md
new file mode 100644
index 0000000..c34563e
--- /dev/null
+++ b/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md
@@ -0,0 +1,96 @@
+# Phase 92 — Theme defects: save applies live (no reload) + the theme controls 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."
+**Story:** n/a (owner-chat defect fix on `91_admin_theme_tab`).
+**Context:** Phase 91 shipped the admin Theme tab: 8 identity color pickers + 3 strings, persisted to the single-row `ui_settings` table (`app/api/ui_settings.py`, `app/core/theming.py`), injected pre-paint as `` by the `CachingMiddleware` (`app/core/caching.py`), edited live in `frontend/assets/theme.js`. **Defect 1 root cause (confirmed in code):** after a successful Save/Reset, `theme.js::saveTheme()` runs `loadSettings()` then `clearPreview()` — the live-preview overrides (inline custom properties on ``) are removed, and the page falls back to the `#bor-theme` tag that was baked into THIS document at page load — i.e. the PREVIOUS theme. The new palette only appears after a full reload (the server then injects the new tag). The phase-91 E2E masked this: its save test never asserts the live computed palette after save, and its reset test only checks computed values after a fresh `page.goto`. **Defect 2 root cause:** `frontend/assets/styles.css` still carries ~45 hardcoded color literals outside `:root` that no control can reach: the background grid (`body::before`, `rgb(74 38 38 / 0.6)` — never themes), `::selection` (brand at 45%, hardcoded), the header hairline gradient (hardcoded rose→orange→amber), every primary-button hover (`#f55a72` rose / legacy indigo `#7d88f5`), the disabled/busy state (indigo `#a5b4fc`), the Stop state (`#be123c`/`#9f1239`), three chip hovers (indigo `#2a345f`), six hardcoded `#1a0f0f` surface backgrounds + `#e6d0d0` code text, the ok/err alpha derivatives, four modal backdrops, the spinner track, and the static brand-mark SVG in all five HTML files (`fill="#1a0f0f" stroke="#f43f5e" … stroke="#fca5a5"` — the wordmark never themes).
+
+## Objective
+Fix both phase-91 defects so the Theme tab controls the entire site's theme: (1) Save/Reset must apply the new palette to the OPEN page immediately — no reload, no revert — by syncing the live document's `#bor-theme` tag to the saved state; (2) every color in the UI must be driven by the identity palette (directly or via `color-mix()` derivations) — zero hardcoded literals outside `:root` — with the background grid promoted to a 9th tab-controlled identity variable (`--grid-line`).
+
+## Dependencies
+- `91_admin_theme_tab` (complete) — the entire phase builds on it: the `ui_settings` table + resolver + admin API (`app/api/ui_settings.py`), `app/core/theming.py` (`BUILTIN_COLORS` / `COLOR_FIELDS` / `effective_settings` / `theme_style_tag` / `inject_theme` / `theme_csp_hash`), the pre-paint injection + CSP hash in `app/core/caching.py`, the tab shell + editor (`frontend/index.html` `#view-theme`, `frontend/assets/theme.js`), and the E2E suite `tests/e2e/test_admin_theme_tab.py` (updated in place, task 05). Its contracts (byte-identical no-op tag, admin-only gate, pre-paint first paint, §7.4 save lifecycle) all stay green.
+
+## Design (shared by all tasks — the executor reads this, not the chat)
+
+### Defect 1 — Save/Reset applies the theme to the open document (task 04)
+The server-side pre-paint injection is untouched. The fix is entirely client-side in `theme.js`: after any settled read of the effective values, SYNC THE DOCUMENT'S `#bor-theme` TAG to those values, mirroring what the server would inject on the next load.
+
+- `theme.js` gains two small pure helpers + one DOM sync:
+ - **Built-in capture:** `BUILTINS` — the 9 built-in hexes captured from the color inputs' STATIC values at mount (BEFORE the first `loadSettings()` repopulates). The static values are the built-ins by the house contract (the phase-91 E2E asserts them against `styles.css` `:root`), so this keeps ONE source — no third hardcoded palette copy.
+ - `themeRootContent(colors) -> string | null` — `null` when all 9 effective colors equal their built-ins (the no-op case), else `":root{" + Σ f"--{field.replace(/_/g,'-')}:value;" (COLOR_FIELDS/FIELDS order) + "}"` — byte-identical to the CONTENT of `app.core.theming.theme_style_tag`'s tag (same fields, same order, lowercased hex from the resolver).
+ - `applyServedTheme(effective)`: content `null` → `document.getElementById("bor-theme")?.remove()`; else if the tag exists → set `el.textContent = content` (only when different); else create it with `document.createElement("style")` + `el.id = "bor-theme"` + `textContent` and append to `document.head`.
+ - **CSP (why this shape):** the phase-82/91 CSP (`style-src 'self' 'sha256-…'`, no `'unsafe-inline'`) blocks inline `'
@@ -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
```` (``inject_theme``'s exact placement: the tag ends
exactly where ```` 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 (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
diff --git a/tests/e2e/test_theme_save_and_coverage.py b/tests/e2e/test_theme_save_and_coverage.py
new file mode 100644
index 0000000..9264d82
--- /dev/null
+++ b/tests/e2e/test_theme_save_and_coverage.py
@@ -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 ````'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), ```` 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 ````
+ + 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 ```` overrides
+— the one deviation from the ``00_phase.md`` design): the repo's strict
+policy (phase 82/91 — A1 + ``style-src 'self' 'sha256-'``,
+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 ``'
+
+
+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 ```` 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 ````'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
+ ```` (``inject_theme``'s exact placement: the tag ends
+ exactly where ```` 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("")
+ assert start + len(tag) == head, "the tag must end exactly where 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 '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 .
+ 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 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 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 , 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
diff --git a/tests/integration/test_ui_settings_api.py b/tests/integration/test_ui_settings_api.py
index 9bae17a..deb2fb1 100644
--- a/tests/integration/test_ui_settings_api.py
+++ b/tests/integration/test_ui_settings_api.py
@@ -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."""
diff --git a/tests/unit/test_background_no_motion.py b/tests/unit/test_background_no_motion.py
index b30adc1..874e2af 100644
--- a/tests/unit/test_background_no_motion.py
+++ b/tests/unit/test_background_no_motion.py
@@ -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
diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py
index 9a2dede..0bf8f10 100644
--- a/tests/unit/test_caching.py
+++ b/tests/unit/test_caching.py
@@ -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/`` (the prefix branch) — carries EXACTLY ONE
``', 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 + "" in r.text
+ # All 9 vars, COLOR_FIELDS order (grid_line between line and brand).
+ declared = re.search(r'', 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",)],
diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py
index f78121f..17b21e6 100644
--- a/tests/unit/test_chat_persistence.py
+++ b/tests/unit/test_chat_persistence.py
@@ -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.
diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py
index c9a3df8..350515f 100644
--- a/tests/unit/test_frontend_feedback.py
+++ b/tests/unit/test_frontend_feedback.py
@@ -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
diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py
index 2decd99..2b72128 100644
--- a/tests/unit/test_frontend_router.py
+++ b/tests/unit/test_frontend_router.py
@@ -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'