"""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 17 hexes, the ``#bor-theme`` tag's DOM text is the exact 17-var ``:root{…}`` the next load would serve, and ````'s inline style holds EXACTLY the 17 saved custom properties (no stale pick). The server agrees (the raw ``/`` carries the 17-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 17 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 17-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 17-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 17 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 17 computed ``:root`` custom properties equal the given hexes. The inline tag precedes every stylesheet application, so a themed deployment resolves them from the first style pass — no red flash, no pop-in (custom 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 20 inputs: the 3 text fields (``strings``, default the E2E set) + the 17 color pickers (``palette`` — all 17 vars, phase 93).""" text_values = strings if strings is not None else SAVED_STRINGS page.fill("#theme-app-name", text_values["app_name"]) page.fill("#theme-placeholder", text_values["input_placeholder"]) 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 17 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 20 inputs (the 3 strings + the 17-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 17-var :root{…} the next load would serve (task 04's # mirror half) and 's inline style holds EXACTLY the 17 # 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 17 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 17-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 17-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 17 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 17-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 full 17-var palette (phase 93 — the 8 # semantic state colors join the 9 identity vars): the raw served # HTML carries the 17-var tag immediately before , # permitted in a real browser only via the phase-91 CSP hash # (now 17-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