phase: 177_themed_favicon
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything is verified. Final report:

## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)

**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.

**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed

**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).

**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.

**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.

**Next pending phase:** none — 177 was the last; TODO.md is cleared.
This commit is contained in:
2026-10-11 15:33:39 -04:00
parent dd119d534a
commit 36eddce672
40 changed files with 2351 additions and 16 deletions
+701
View File
@@ -0,0 +1,701 @@
"""Phase 177 E2E (Playwright): the tab icon carries the effective
palette — the route, the pre-paint tag, the live preview, the
restore contract.
Source: TODO.md L3 — "We need the favicon to change colors to
match the theme." (owner roadmap confirmation 2026-10-13, A1–A4.)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_default_state_serves_the_golden_route`` — the default
(no ``ui_settings`` row) deployment: ``GET /favicon.svg`` is 200
``image/svg+xml`` + ``no-cache`` with the body byte-identical
to the golden art (``FAVICON_GOLDEN`` from
``tests.unit.test_theming`` — ONE source); the five pages'
served HTML reference the route (``href="/favicon.svg"`` —
bare, never ``/assets/favicon.svg``); the deleted static file
is 404.
2. ``test_themed_state_serves_the_effective_palette`` — with a
saved palette (the 3 driving colors distinct): the route's SVG
carries the three hexes in their EXACT roles (attribute-parsed,
never substring) and is byte-equal to the server renderer's
output; a fresh page load STILL injects the ``#bor-theme`` tag
immediately before ``</head>`` with the full 17-var content +
the phase-91 CSP hash (the tag/CSP contract is untouched by the
favicon work), and the browser's first paint is the themed
paint (the computed ``--surface`` / ``--brand``).
3. ``test_live_preview_repaints_the_favicon`` — an admin's live
(unsaved) pick of the three driving fields sets
``link[rel="icon"]`` to a ``data:image/svg+xml`` URI that,
decoded, carries the three picked hexes in their exact roles
(attribute-parsed). Headless Chromium never fetches favicons
(probe-verified — zero request events, not even for the
initial ``/favicon.svg``), so the suite pins the href state +
the decoded art as the repaint contract and guards the CSP
path NEGATIVELY: no CSP violation is ever reported against a
``data:`` URI (the phase-123 ``img-src 'self' data:``
carve-out makes a ``data:`` favicon legal — CSP3 governs
``link[rel=icon]`` by ``img-src``; a blocked fetch would
surface as a ``requestfailed`` with a CSP error or a console
violation, which the suite asserts does not happen). A
non-driving pick (``#theme-bg``) never touches the href.
4. ``test_save_restores_the_route_url`` — Save: the href is
EXACTLY ``/favicon.svg`` and the route now serves the saved
palette's bytes (the settled palette governs).
5. ``test_reset_restores_the_route_url`` — after a Save, Reset:
the href is ``/favicon.svg`` and the route serves the built-in
golden bytes.
6. ``test_view_refresh_restores_the_route_url`` — a user-initiated
re-show (the router's ``bor:view-refresh`` dispatch — the
phase-77 hook) restores the href after a live pick.
Zero external requests across the whole suite (AGENTS rule 6 — the
house E2E assertion idiom): a ``page.on("request")`` collector
asserts every http(s) URL is the local origin (a ``data:`` URI is
exempt by design — the phase-123 carve-out).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware + the favicon route read for
EVERY request — a leftover themed row would repaint other suites'
pages and icons) 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-92 file
(test_theme_save_and_coverage.py) 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 subprocess
import sys
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from urllib.parse import unquote
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.core import theming
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,
)
from tests.unit.test_theming import FAVICON_GOLDEN
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The three driving fields (A1 — the artwork's color roles: hexagon
# fill, hexagon stroke + center dot, 4 spokes) with E2E values
# distinct from every built-in.
DRIVING: dict[str, str] = {
"surface": "#102030",
"brand": "#4f46e5",
"brand_ink": "#a5b4fc",
}
#: The E2E-stable color-input ids (the form's own markup — the
#: static E2E-stable-selectors house convention).
INPUT_IDS = {
field: f"#theme-{field.replace('_', '-')}" for field in theming.COLOR_FIELDS
}
#: The five templates whose served HTML must reference the route.
PAGES = ("/", "/document.html", "/login.html", "/shared.html", "/doc-edit.html")
#: The data: URI prefix the live preview sets (the phase-123
#: img-src 'self' data: carve-out makes it CSP-legal).
DATA_PREFIX = "data:image/svg+xml,"
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(
f"--{k.replace('_', '-')}:{colors[k]};" for k in theming.COLOR_FIELDS
)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
def _expected_favicon_bytes(colors: dict[str, str]) -> bytes:
"""The route's body for a palette: the SERVER renderer's output
(imported — ONE source, never a test-side mirror of the art)."""
return theming.favicon_svg(colors).encode("utf-8")
def _attr(element: ET.Element, name: str) -> str:
"""An SVG attribute, asserted non-null (a missing role attribute
is a test failure, not a parseable value)."""
value = element.get(name)
assert value is not None, f"missing the {name} attribute"
return value
def _parse_roles(svg: str) -> dict[str, str]:
"""The three color roles, ATTRIBUTE-parsed (the unit pin's
idiom — never substring): the hexagon's fill (surface), the
hexagon's stroke (brand), the center circle's fill (brand),
the spokes' stroke (brand_ink)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg)
assert root.tag == f"{ns}svg", root.tag
hexagon, circle, spokes = list(root)
return {
"surface": _attr(hexagon, "fill"),
"brand": _attr(hexagon, "stroke"),
"brand_circle": _attr(circle, "fill"),
"brand_ink": _attr(spokes, "stroke"),
}
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)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _favicon_href(page: Page) -> str:
"""The tab icon's current href (the attribute's raw state)."""
return page.evaluate(
"() => document.querySelector('link[rel=icon]').getAttribute('href')"
)
def _wait_data_href(page: Page) -> None:
"""The href is a ``data:`` URI (the live preview has landed)."""
page.wait_for_function(
f"() => (document.querySelector('link[rel=icon]')"
f".getAttribute('href') || '').startsWith('{DATA_PREFIX}')",
timeout=15_000,
)
def _wait_route_href(page: Page) -> None:
"""The href is EXACTLY the route URL (the restore has landed —
the settled palette governs)."""
page.wait_for_function(
"() => (document.querySelector('link[rel=icon]')"
'.getAttribute(\'href\') || \'\') === \'/favicon.svg\'',
timeout=15_000,
)
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the 3 driving colors
(the rest null → the built-ins — the route's bytes need only
the three colors in the artwork)."""
body: dict[str, object] = {}
for field in theming.STRING_FIELDS:
body[field] = None
for field in theming.COLOR_FIELDS:
body[field] = None
body.update(DRIVING)
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# 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 + the favicon route read it for
EVERY request — a leftover themed row would repaint other
suites' pages and icons) 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 _zero_external(
page: Page,
) -> tuple[list[str], list[str], list[str], list[str]]:
"""AGENTS rule 6 (the house E2E assertion idiom): every http(s)
request is the local origin; ``data:`` URIs are exempt by
design (the phase-123 ``img-src 'self' data:`` carve-out).
Also collects the CSP-failure signals: a failed ``data:``
fetch (a blocked fetch carries a CSP error in its failure
message) and console CSP violations ("Refused to …" / "CSP"
against a ``data:`` URI). Returns (externals, data_requests,
failed_data, csp_violations). Headless Chromium never fetches
favicons (probe-verified), so ``data_requests`` is expected to
stay empty — the negative assertions (``failed_data`` +
``csp_violations``) are the CSP guard: a future change that
blocks the ``data:`` URI surfaces here, and a real
(non-headless) browser run additionally sees the fetch in
``data_requests``."""
externals: list[str] = []
data_requests: list[str] = []
failed_data: list[str] = []
csp_violations: list[str] = []
def _watch(request: object) -> None:
url = getattr(request, "url", "")
if url.startswith("data:"):
data_requests.append(url)
return
if url.startswith(("http://", "https://")) and "127.0.0.1" not in url:
externals.append(url)
def _watch_failed(request: object) -> None:
url = getattr(request, "url", "")
failure = getattr(request, "failure", None)
if url.startswith("data:") and failure:
failed_data.append(str(failure))
def _watch_console(message: object) -> None:
msg = getattr(message, "text", "")
if ("CSP" in msg or "Refused to" in msg) and "data:" in msg:
csp_violations.append(msg)
page.on("request", _watch)
page.on("requestfailed", _watch_failed)
page.on("console", _watch_console)
return externals, data_requests, failed_data, csp_violations
def _assert_zero_external(
externals: list[str], failed_data: list[str], csp_violations: list[str]
) -> None:
"""The suite's tail assertion: zero external requests, no
``data:`` URI CSP-blocked (a blocked fetch carries a CSP error
in its failure message), and no CSP violation reported
against a ``data:`` URI (the phase-123 carve-out holds)."""
assert not externals, f"external requests: {externals}"
assert not failed_data, f"data: URIs blocked (CSP?): {failed_data}"
assert not csp_violations, f"CSP violations: {csp_violations}"
# ---------------------------------------------------------------------------
# 1. Default state: the golden route, the five page references, the
# deleted static file
# ---------------------------------------------------------------------------
def test_default_state_serves_the_golden_route(app_url: str, db_ready: None) -> None:
"""The default (no ``ui_settings`` row) deployment: the route
is byte-identical to the golden art (imported — ONE source),
with the svg media type + ``no-cache``; the five pages' served
HTML reference the route (bare — no ``?v=``, never
``/assets/favicon.svg``); the deleted static file is 404."""
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/svg+xml")
assert r.headers["cache-control"] == "no-cache"
assert r.content == FAVICON_GOLDEN.encode("utf-8")
for path in PAGES:
page = httpx.get(app_url + path, timeout=10)
assert page.status_code == 200, path
assert (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
in page.text
), f"{path} must reference the themed route"
assert "/assets/favicon.svg" not in page.text, (
f"{path} must never reference the deleted static file"
)
assert "favicon.svg?v=" not in page.text, (
f"{path} must keep the favicon link bare (no ?v=)"
)
gone = httpx.get(app_url + "/assets/favicon.svg", timeout=10)
assert gone.status_code == 404
# ---------------------------------------------------------------------------
# 2. Themed state: the route carries the effective palette; the
# pre-paint tag + the first paint are untouched (phase 91)
# ---------------------------------------------------------------------------
def test_themed_state_serves_the_effective_palette(
page: Page, app_url: str, db_ready: None
) -> None:
"""With a saved palette (the 3 driving colors distinct): the
route's SVG carries the three hexes in their EXACT roles
(attribute-parsed) and is byte-equal to the server renderer;
a fresh page load STILL injects the ``#bor-theme`` tag
(17-var content, immediately before ``</head>`` + the phase-91
CSP hash — the tag/CSP contract is untouched by the favicon
work) and the browser's first paint is the themed paint."""
page.set_default_timeout(30_000)
login(page, app_url)
_seed_theme_via_api(app_url, _cookies(page))
# The route: the three hexes in their exact roles…
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
roles = _parse_roles(r.text)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# …and byte-equal to the server renderer's output (ONE source).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
assert r.content == _expected_favicon_bytes(effective)
# The pre-paint tag non-regression (phase 91): the RAW served
# HTML carries the 17-var tag immediately before ``</head>``…
tag = _expected_tag(effective)
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
assert r.text.count(tag) == 1, "the 17-var tag must be injected exactly once"
start = r.text.index(tag)
assert start + len(tag) == r.text.index("</head>"), (
"the tag must end exactly where </head> begins"
)
# …with the phase-91 CSP hash (the favicon work never touched
# the tag/CSP contract).
assert "style-src 'self' 'sha256-" in r.headers.get(
"content-security-policy", ""
), "the themed page must carry the phase-91 CSP hash"
# A fresh page load: the first paint IS the themed paint.
page.goto(app_url + "/")
page.wait_for_function(
"""(d) => {
const cs = getComputedStyle(document.documentElement);
return cs.getPropertyValue('--surface').trim() === d.surface
&& cs.getPropertyValue('--brand').trim() === d.brand;
}""",
arg=DRIVING,
timeout=15_000,
)
# ---------------------------------------------------------------------------
# 3. Live preview: an unsaved pick repaints the favicon via the
# CSP-legal data: URI; a non-driving pick never touches it
# ---------------------------------------------------------------------------
def test_live_preview_repaints_the_favicon(
page: Page, app_url: str, db_ready: None
) -> None:
"""An admin's live (unsaved) pick of the three driving fields
sets ``link[rel="icon"]`` to a ``data:`` URI that, decoded,
carries the three picked hexes in their exact roles; the
browser FETCHES the ``data:`` URI (the phase-123 carve-out —
a blocked fetch would be a ``requestfailed`` with a CSP error);
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A clean start: the href is the route (no row → the golden
# art served by the route).
assert _favicon_href(page) == "/favicon.svg"
# Set the three driving fields (page.fill fires the input event
# — the house _fill_theme_form idiom).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# The href is a data: URI, and — decoded — carries the three
# picked hexes in their exact roles (attribute-parsed).
href = _favicon_href(page)
assert href.startswith(DATA_PREFIX)
svg = unquote(href[len(DATA_PREFIX):])
roles = _parse_roles(svg)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# A non-driving pick never touches the href.
page.fill(INPUT_IDS["bg"], "#000000")
assert _favicon_href(page) == href, "a non-driving pick must not touch the href"
# The CSP guard (negative — headless never fetches favicons):
# no failed data: fetch, no CSP violation against the URI.
_assert_zero_external(externals, failed_data, csp_violations)
# ---------------------------------------------------------------------------
# 4. Restore: Save — the href is EXACTLY /favicon.svg and the route
# serves the saved palette's bytes (the settled palette governs)
# ---------------------------------------------------------------------------
def test_save_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, Save: the §7.4 lifecycle lands and the
href is EXACTLY ``/favicon.svg``; the route now serves the
saved palette's bytes (the settled palette governs — the route
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# Save through the real form.
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == _expected_favicon_bytes(effective)
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 5. Restore: Reset — the href is /favicon.svg and the route serves
# the built-in golden bytes
# ---------------------------------------------------------------------------
def test_reset_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a Save (the route is themed), a fresh live pick, then
Reset: the §7.4 lifecycle lands and the href is
``/favicon.svg``; the route serves the built-in golden bytes
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
_wait_route_href(page)
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.content == _expected_favicon_bytes(
{**theming.BUILTIN_COLORS, **DRIVING}
)
# A fresh live pick — the href is the data: one the reset
# restores (a distinct value, so the repaint is guaranteed).
page.fill(INPUT_IDS["surface"], "#0a1525")
_wait_data_href(page)
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
# API's documented "defaults" operation).
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL…
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# …and the route serves the built-in golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 6. Restore: the bor:view-refresh re-show (the phase-77 hook)
# ---------------------------------------------------------------------------
def test_view_refresh_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, a user-initiated re-show (leave the Theme
view, come back — the router dispatches ``bor:view-refresh``
on the already-mounted section): the handler re-runs the load,
reconciles, and the href is restored to ``/favicon.svg`` (the
settled palette governs — in this test's row-less state, the
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# A user-initiated re-show: away (the RAG view — a real view
# switch), then back — the router's phase-77 hook dispatches
# bor:view-refresh on the mounted Theme section.
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible(timeout=15_000)
page.click("#nav-theme")
expect(page.locator("#view-theme")).to_be_visible(timeout=15_000)
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: no row was saved, so the route
# still serves the golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)