phase: 92_theme_save_and_coverage
**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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user