phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
+49 -143
View File
@@ -1,50 +1,53 @@
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
"""Phase 62 E2E (Playwright): UI customization — placeholder + footer.
Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese,
but I want anyone to be able to deploy it with their name… custom
message-input placeholder, custom footer-inner text, custom color
themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``,
``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5).
``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's
color-theming half — the CSS-file theme env var and its brand.js
``<link>`` insertion are gone; the admin Theme tab (phases 91,
tasks 04–06) is the only theming surface now, with its own dedicated
E2E suite.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_ui_customization.py -v --no-cov
Contract under test:
* an instance booted with ALL THREE customization vars set shows the
custom look end-to-end: the ``GET /api/config`` overrides, the chat
composer placeholder (``#message-input``), the footer line on multiple
pages (``.footer-text``), and the computed ``:root --brand`` from the
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
(the indigo example theme, ``--brand: #818cf8``);
* an instance booted with BOTH customization vars set shows the custom
look end-to-end: the ``GET /api/config`` overrides (now the five-key
set — the retired theming's ``theme`` key is gone), the chat composer
placeholder (``#message-input``), and the footer line on multiple
pages (``.footer-text``);
* with NOTHING set the shared conftest server is byte-identical to the
phase-39/61 no-op contract: the default placeholder, the default
footer, NO theme link, the built-in ``--brand: #f43f5e``;
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
naming the value — the phase-56 fail-loud style, proven end-to-end
via a real boot attempt, not just the validator unit test.
footer, the built-in ``--brand: #f43f5e``;
* the app NAME stays the default on the custom instance (this suite
does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's
job); the response's ``app_name`` key is the EFFECTIVE value (phase
91: DB-over-env — for an env-only deployment, the env string itself).
Determinism note: this story needs a SECOND app instance — the shared
conftest server keeps the defaults (every other suite's
placeholder/footer/palette assertions depend on it), so ``custom_server``
boots the same env block the phase-39 brand suite's ``testy_server``
boots (same DB, the mock-LLM base URL, the admin auth, the static dir,
the mock-calibrated threshold) with exactly three changes: port
the mock-calibrated threshold) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
collide) and the three env overrides. Every assertion is settled-state:
collide) and the two env overrides. Every assertion is settled-state:
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
fetch (the three keys are applied asynchronously, in the SAME fetch's
settled ``.then`` — no second network call). The one absence assertion
(no ``#theme-override`` on the default server) first waits for
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
fetch (the two string keys are applied asynchronously, in the SAME
fetch's settled ``.then`` — no second network call).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_config_serves_the_overrides``
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
2. ``test_chat_page_shows_custom_placeholder_and_footer``
3. ``test_footer_text_applies_on_other_pages``
4. ``test_default_server_is_byte_identical``
5. ``test_malformed_theme_refuses_startup``
"""
from __future__ import annotations
import os
@@ -67,13 +70,10 @@ from e2e.conftest import (
CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision
CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}"
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
# The three overrides (task 05) — the whole story:
# The two overrides (phase 62, task 05) — the surviving story legs:
CUSTOM_PLACEHOLDER = "Ask the archive…"
CUSTOM_FOOTER = "Custom footer line"
CUSTOM_THEME = "indigo.css"
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
# The phase-39/61 no-op contract on the shared default server:
DEFAULT_NAME = "Brain of Reese"
@@ -84,7 +84,7 @@ BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand
@pytest.fixture(scope="session")
def custom_server(mock_llm: int) -> Iterator[str]:
"""A SECOND app instance, booted with all three customization
"""A SECOND app instance, booted with both customization string
overrides.
The shared conftest ``app_server`` keeps the defaults (every other
@@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
this fixture copies the phase-39 brand suite's ``testy_server`` env
block verbatim (same DB, the mock-LLM base URL,
``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``,
``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port
``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
three env overrides below. Started after ``mock_llm`` is available
two env overrides below. Started after ``mock_llm`` is available
(its fixture dependency).
"""
env = dict(os.environ)
@@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
# 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
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs:
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
env["BOR_THEME"] = CUSTOM_THEME
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"],
@@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]:
proc.kill()
def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
"""Wait for the brand layer's boot fetch to settle.
The three customization keys are applied asynchronously, in the
settled ``/api/config`` promise's ``.then`` — absence assertions
(no ``#theme-override``) must not race that fetch. The promise
NEVER rejects (the brand.js contract), so its resolution means the
DOM pass has already run: ``applyBrand`` registered its callback on
the same promise at page load, before this wait's callback, and
promise callbacks run in registration order."""
page.wait_for_function(
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
timeout=timeout,
)
def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
"""Retrying computed ``:root --brand`` equality. Custom properties
return the SPECIFIED token from ``getComputedStyle`` (no color
normalization), so the string compare is stable: ``#818cf8`` is
exactly what indigo.css declares, ``#f43f5e`` exactly what
styles.css declares (the built-in)."""
normalization), so the string compare is stable: ``#f43f5e`` is
exactly what styles.css declares (the built-in — the page the
shared default server serves, with no ui_settings row, carries no
inline theme tag and the stylesheet value stands)."""
page.wait_for_function(
"""(expected) =>
getComputedStyle(document.documentElement)
@@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
# ---------------------------------------------------------------------------
# 1. The endpoint the brand layer reads — the three overrides, the
# six-key set, and the theme file served from the dev static dir
# 1. The endpoint the brand layer reads — the two overrides, the
# five-key set (the retired theming's theme key is gone)
# ---------------------------------------------------------------------------
@@ -180,33 +164,27 @@ def test_config_serves_the_overrides(custom_server: str) -> None:
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
# The six-key set (the phase-39/59/62 endpoint contract) with the
# three customization overrides — the app NAME stays the default
# (this suite does not re-test BOR_APP_NAME; that is the phase-39
# suite's job).
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone)
# with the two customization overrides — the app NAME stays the
# default (this suite does not re-test BOR_APP_NAME; that is the
# phase-39 suite's job).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == DEFAULT_NAME
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
assert body["footer_text"] == CUSTOM_FOOTER
assert body["theme"] == CUSTOM_THEME
# Served in dev from the static dir (the no-CDN rule): the theme
# file the boot fetch names is reachable at its served path, and
# it is the indigo example (its --brand is the E2E's theme proof).
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
assert r2.status_code == 200
assert f"--brand: {INDIGO_BRAND}" in r2.text
# ---------------------------------------------------------------------------
# 2. The chat page — placeholder, footer, the theme link + effect
# 2. The chat page — placeholder + footer (the theme legs are retired:
# colors are injected pre-paint server-side, phase 91 task 02)
# ---------------------------------------------------------------------------
def test_chat_page_shows_custom_placeholder_footer_theme(
def test_chat_page_shows_custom_placeholder_and_footer(
page: Page, custom_server: str
) -> None:
page.goto(custom_server + "/")
@@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme(
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# 7. The theme link in <head> — rel=stylesheet, the served path.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# And it takes effect: the computed :root --brand is the indigo
# value (the built-in #f43f5e means the theme never loaded).
expect_brand_var(page, INDIGO_BRAND)
# ---------------------------------------------------------------------------
@@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# The theme link rides in <head> on every page too.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# ---------------------------------------------------------------------------
@@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
def test_default_server_is_byte_identical(page: Page, app_server: str) -> None:
page.goto(app_server + "/")
# Settle the boot fetch BEFORE the absence assertion — it must not
# race the (absent) theme-link insertion.
wait_for_brand_settled(page)
# The phase-39/61 no-op contract: the template defaults stand.
# The phase-39/61 no-op contract: the template defaults stand
# (positive assertions on the static HTML — the brand layer's
# no-op paths touch nothing when the env vars are unset).
expect(page.locator("#message-input")).to_have_attribute(
"placeholder", DEFAULT_PLACEHOLDER
)
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
# With BOR_THEME unset the brand layer inserts NO theme link:
assert page.locator("#theme-override").count() == 0, (
"with BOR_THEME unset the brand layer must NOT insert a theme "
"link (the byte-identical no-op contract)"
)
# The built-in dark-tech palette stands.
# The built-in dark-tech palette stands: with no ui_settings row
# the server injects no inline theme tag (phase 91 task 02's
# no-op), so the stylesheet's --brand is the computed value.
expect_brand_var(page, BUILTIN_BRAND)
# ---------------------------------------------------------------------------
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
# ---------------------------------------------------------------------------
def test_malformed_theme_refuses_startup() -> None:
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
shape the A5 lock names as illegal) kills startup with the value
NAMED on stderr (the phase-56 fail-loud house style), proven
end-to-end via a real uvicorn boot attempt: the process exits
non-zero within the timeout without ever starting to listen.
``app.main`` builds its settings at import time
(``settings = get_settings()``), so the validator fires during the
ASGI app import — before admin auth, before the port binds."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The whole point: a malformed theme value.
env["BOR_THEME"] = "../evil.css"
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
try:
proc.wait(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
pytest.fail(
"the app kept running with BOR_THEME='../evil.css' — a "
"malformed theme must refuse startup, not silently 404"
)
assert proc.returncode != 0, (
"the malformed BOR_THEME must make uvicorn exit non-zero"
)
stderr = proc.stderr.read() if proc.stderr else ""
# Fail-loud names the offending value (phase-56 house style):
assert "'../evil.css'" in stderr, (
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
)
assert "theme must be a bare .css filename" in stderr