feat(web): customizable placeholder, footer text, and color theme via BOR_* env vars

BOR_INPUT_PLACEHOLDER / BOR_FOOTER_TEXT / BOR_THEME (+ the indigo.css example theme); authoring guide: frontend/assets/themes/README.md, docs: README 'Customizing the look'.
This commit is contained in:
2026-09-01 12:04:06 -04:00
parent baefcde668
commit c738105932
17 changed files with 1057 additions and 73 deletions
+3
View File
@@ -5,6 +5,9 @@
# --- App --- # --- App ---
BOR_ENVIRONMENT=development BOR_ENVIRONMENT=development
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39) # BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
# BOR_INPUT_PLACEHOLDER=Ask me anything… # composer placeholder, chat page (phase 62)
# BOR_FOOTER_TEXT=Powered by self-hosted models # footer line on every page (phase 62)
# BOR_THEME= # filename under frontend/assets/themes/ (e.g. indigo.css) — overrides the built-in palette; empty = built-in (phase 62)
# BOR_LOG_LEVEL=INFO # BOR_LOG_LEVEL=INFO
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static # BOR_STATIC_DIR=frontend # dev default; container sets /app/static
+1
View File
@@ -26,6 +26,7 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp -r ./assets/themes /out/assets/themes \
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/ && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
# ---------- Stage 2: python dependencies ---------- # ---------- Stage 2: python dependencies ----------
+18
View File
@@ -732,6 +732,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| Env | Default | Meaning | | Env | Default | Meaning |
|-----|---------|---------| |-----|---------|---------|
| `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page `<title>`, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by `GET /api/config` and applied by `assets/brand.js`; a name starting `Brain of ` keeps the bold split (`Brain of <strong>rest</strong>`), any other name renders in normal weight. Unset ⇒ byte-identical to the default | | `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page `<title>`, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by `GET /api/config` and applied by `assets/brand.js`; a name starting `Brain of ` keeps the bold split (`Brain of <strong>rest</strong>`), any other name renders in normal weight. Unset ⇒ byte-identical to the default |
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | the chat composer placeholder (`#message-input`, chat page only); applied by `assets/brand.js` from `GET /api/config`; unset ⇒ the template default |
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | the footer line on all 9 pages (the `.footer-text` spans); same mechanism; unset ⇒ the template default |
| `BOR_THEME` | *(empty)* | a filename under `frontend/assets/themes/` (e.g. `indigo.css`) — a `:root` palette override injected after `styles.css` (later wins the cascade); the server refuses a malformed name at startup (bare `^[a-z0-9_-]+\.css$` filename); a missing file degrades to the built-in theme; unset ⇒ the built-in dark-tech palette |
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) | | `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint | | `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key | | `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
@@ -761,6 +764,21 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) | | `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
| `BOR_LOG_LEVEL` | `INFO` | app log level | | `BOR_LOG_LEVEL` | `INFO` | app log level |
### Customizing the look
The app ships as “Brain of Reese”, but every identity string is an env
var: `BOR_APP_NAME` (display name), `BOR_INPUT_PLACEHOLDER` (chat composer
placeholder), and `BOR_FOOTER_TEXT` (the footer line on every page) — all
served by `GET /api/config` and applied by `assets/brand.js` at boot.
Color themes are plain CSS variable overrides: write a `:root` block in
`frontend/assets/themes/` and point `BOR_THEME` at the filename
([the themes `README.md`](frontend/assets/themes/README.md) is the
authoring guide, `indigo.css` the working example). The server refuses a
malformed `BOR_THEME` at startup, and a missing theme file degrades to the
built-in palette — the page never breaks. Leave everything unset and the
app renders the defaults byte-identically: the dark-tech palette shown
throughout this README is the no-config default.
## Troubleshooting ## Troubleshooting
- **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`). - **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`).
+13 -5
View File
@@ -1,5 +1,7 @@
"""Public app metadata (display name + version) for the frontend brand """Public app metadata (display name + version) for the frontend brand
layer, plus the phase-59 docs-push flag (the "Save as doc" gating).""" layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
phase-62 UI customization strings (composer placeholder, footer line,
theme file name)."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -12,12 +14,18 @@ router = APIRouter(tags=["config"])
@router.get("/config") @router.get("/config")
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008 def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39) + """Public app metadata for the frontend brand layer (phase 39) +
the phase-59 ``docs_repo_configured`` flag — the chat page's the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
"Save as doc" button gating, surfaced the way ``app_name`` is customization keys (``input_placeholder``, ``footer_text``,
(the SAME boot fetch, no new network surface). Inert false while ``theme``) — all display strings, the SAME boot fetch (no new
``BOR_DOCS_REPO`` is empty (the feature is off, D3).""" network surface) and the same public posture as ``app_name``
(no secrets). Values are passed through verbatim: the frontend
brand layer treats an empty string as "keep the template default"
(the unset => byte-identical contract)."""
return { return {
"app_name": settings.app_name, "app_name": settings.app_name,
"version": settings.app_version, "version": settings.app_version,
"docs_repo_configured": settings.docs_configured, "docs_repo_configured": settings.docs_configured,
"input_placeholder": settings.input_placeholder,
"footer_text": settings.footer_text,
"theme": settings.theme,
} }
+30
View File
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
log_level: str = "INFO" log_level: str = "INFO"
static_dir: str = "frontend" static_dir: str = "frontend"
# --- UI customization (phase 62, TODO L3) ---
# Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
input_placeholder: str = "Ask me anything…"
footer_text: str = "Powered by self-hosted models"
#: Theme file NAME under frontend/assets/themes/ (e.g. "indigo.css");
#: empty = the built-in dark-tech palette. Validated: bare filename
#: only — no paths, no ".." (no-CDN: served from the static dir).
theme: str = ""
# --- Database (PostgreSQL 17 + pgvector) --- # --- Database (PostgreSQL 17 + pgvector) ---
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese" database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
@@ -202,6 +211,27 @@ class Settings(BaseSettings):
#: separate from ``sources_dir`` (the source checkouts). #: separate from ``sources_dir`` (the source checkouts).
docs_work_dir: str = "~/bor-docs" docs_work_dir: str = "~/bor-docs"
@field_validator("theme", mode="after")
@classmethod
def _theme_bare_css_filename(cls, v: str) -> str:
r"""Phase 62 (A5): the theme is a FILE NAME under
``frontend/assets/themes/``, served from the static dir
(no-CDN) — so only a bare lowercase ``.css`` filename is legal
(``^[a-z0-9_-]+\.css$``). Anything else (a path, ``..``,
uppercase, a missing extension) is a typo that would silently
404 at runtime — fail loudly at startup instead, naming the
offending value and the allowed shape (the phase-56 fail-loud
house style)."""
if v == "":
return v # empty = the built-in dark-tech palette
if re.fullmatch(r"[a-z0-9_-]+\.css", v) is None:
raise ValueError(
"theme must be a bare .css filename under "
"frontend/assets/themes/ (lowercase letters/digits/"
f"'_'/'-', e.g. 'indigo.css') — got {v!r}"
)
return v
@field_validator("import_extensions") @field_validator("import_extensions")
@classmethod @classmethod
def _import_extensions_known(cls, v: str) -> str: def _import_extensions_known(cls, v: str) -> str:
+103 -4
View File
@@ -31,13 +31,42 @@
* skipped so page source is never mutated; * skipped so page source is never mutated;
* 4. an attribute pass — the aria-label / placeholder / meta * 4. an attribute pass — the aria-label / placeholder / meta
* content attributes containing the literal (the #messages * content attributes containing the literal (the #messages
* aria-label, the input label, the meta descriptions). * aria-label, the input label, the meta descriptions);
* 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the three UI-customization
* keys, applied in this same .then, AFTER the app_name
* passes and INDEPENDENT of them (they apply even when the
* name is the default/empty). Each empty value is a no-op —
* an unset deployment stays byte-identical:
* 5. input_placeholder — non-empty → the #message-input
* placeholder (the chat page only; every other page
* no-ops via the null guard);
* 6. footer_text — non-empty → every .footer-text node's
* textContent (all 9 pages, the phase-61 hook; an
* operator string can't inject markup via textContent);
* 7. theme — non-empty → a <link rel="stylesheet"> inserted
* IMMEDIATELY AFTER the styles.css link (the theme's
* :root overrides win by cascade order). The styles.css
* finder matches the RAW attribute path with any query/
* fragment stripped — the phase-33/54 cache-busting
* middleware serves the HTML with the asset refs rewritten
* to "…/styles.css?v=<token>", and el.href (the absolute
* URL) would never end with "styles.css" once versioned.
* The filename is validated server-side (a bare *.css
* name — no path can reach here via /api/config); a
* MISSING file degrades to the built-in theme (onerror →
* console.warn — A5, the page never breaks). Guarded by
* #theme-override: never inserted twice.
* • fetch failure / empty name → the default stays + console.warn * • fetch failure / empty name → the default stays + console.warn
* (the loadHealth house style: progressive enhancement, the page * (the loadHealth house style: progressive enhancement, the page
* never breaks). * never breaks).
* *
* No-op property: with BOR_APP_NAME unset the /api/config answer IS the * No-op property: with the customization env vars (BOR_APP_NAME,
* literal, so every replacement below is a byte-identical no-op. * BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) unset, /api/config
* answers with the template defaults themselves — the name IS the
* literal, the placeholder and footer are the phase-61 copy (re-setting
* them is invisible), the theme is empty (the link is skipped) — so an
* unset deployment renders byte-identical.
*/ */
/* The synchronous default — set BEFORE any fetch, so module scripts /* The synchronous default — set BEFORE any fetch, so module scripts
@@ -90,7 +119,10 @@ window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() { function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => { BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : ""; const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (!name) return; // empty / missing: the default stands if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name; window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers // 1. The document title (global replace of the literal — covers
@@ -152,6 +184,73 @@ function applyBrand() {
} }
} }
} }
}
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the three customization keys — applied here,
// INDEPENDENT of the app_name block above (they apply even when
// the name is the default/empty). Each empty value is a no-op, so
// an unset deployment stays byte-identical (no attribute or
// element touched, no second network call).
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
const themeName = typeof cfg?.theme === "string" ? cfg.theme : "";
if (themeName) {
// 7. The theme stylesheet — a <link> inserted IMMEDIATELY AFTER
// the existing styles.css link, so the theme's :root
// overrides win by cascade order. The filename is validated
// server-side (task 01: a bare *.css name) — no path input
// can reach here via /api/config. Guarded by #theme-override:
// never applied twice (the loadHealth house style — never
// break the page, never double-apply). A missing file
// degrades to the built-in theme (A5): the onerror warns,
// nothing else.
if (!document.getElementById("theme-override")) {
// Phase 33/54: the served HTML may carry the cache-bust query
// (?v=<token>) on the asset ref — match on the RAW attribute
// path with query/fragment stripped, never on el.href (the
// absolute URL, which would include the token).
const stylesLink = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
).find((el) => {
const ref = (el.getAttribute("href") || "").split(/[?#]/)[0];
return ref.endsWith("styles.css");
});
if (stylesLink) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/themes/" + themeName;
link.id = "theme-override";
link.onerror = () =>
console.warn(
"brand: theme " + themeName +
" did not load — the built-in theme stands.",
);
stylesLink.insertAdjacentElement("afterend", link);
}
}
}
}); });
} }
+77
View File
@@ -0,0 +1,77 @@
# Themes — authoring guide (phase 62)
A theme is a small CSS file that overrides the `:root` palette variables.
That is the entire mechanism — no component CSS is theme-aware, every
color in the app reads a `--*` variable, so a later stylesheet wins by
cascade order.
## How a theme loads
1. Set `BOR_THEME=<file>` (a bare FILENAME, e.g. `BOR_THEME=indigo.css`).
`app/config.py` validates it at startup — anything not matching
`^[a-z0-9_-]+\.css$` (a path, `..`, uppercase, a missing extension)
refuses to boot, naming the value (the phase-56 fail-loud house
style).
2. The value rides the existing boot fetch: `GET /api/config` →
`frontend/assets/brand.js` inserts
`<link rel="stylesheet" href="/assets/themes/<file>">` IMMEDIATELY
AFTER the `styles.css` link — later wins the cascade.
3. A theme file MISSING at runtime (typo past the validator, or the file
deleted after the image was built) degrades to the built-in theme —
`brand.js` warns in the console, the page never breaks (the
loadHealth house style, A5).
4. UNSET (`BOR_THEME` empty) ⇒ no link is inserted at all — the
deployment renders byte-identical to the built-in dark-tech palette.
Loading is opt-in via the env var, never by directory scanning.
## The variables
A theme overrides the **8 identity variables** in a single `:root` block.
Built-in values (from `frontend/assets/styles.css`) for reference:
| Variable | Built-in | Role |
| -------------- | ---------- | ----------------------------------------------------------- |
| `--bg` | `#0f0a0a` | page background (text on it: `--ink`) |
| `--surface` | `#1a0f0f` | cards, panels, code blocks (text on it: `--ink`) |
| `--ink` | `#f0e6e6` | primary text |
| `--ink-soft` | `#b8a8a8` | secondary text (5.1:1 on `--surface`) |
| `--line` | `#2d1a1a` | decorative 1px borders (no contrast obligation) |
| `--brand` | `#f43f5e` | brand accent — buttons, links (text ON it is `--bg`) |
| `--brand-soft` | `#2d0a0a` | brand-tinted surface (chips, hover washes) |
| `--brand-ink` | `#fca5a5` | brand-tinted text (9.0:1 on `--surface`) |
The **semantic families are deliberately NOT identity** — do not
override them: `--accent-*` (deflection amber), `--ok-*` (success
green), `--err-*` (error red) encode *states*, and they are already AA
in the built-in theme. A theme that keeps them stays honest: your
indigo app still tells success from error.
## Rules
- **Filename:** `^[a-z0-9_-]+\.css$` — lowercase, bare filename, in this
directory. The server validator rejects anything else at startup
(naming the value), so keep the env var and the filename in lockstep.
- **One `:root` block.** No selectors, no `@media`, no other
declarations — the file overrides variables and nothing else (the
cascade does the rest). `indigo.css` is the reference shape.
- **Every text/background pair ≥ 4.5:1** (AGENTS.md rule 5, WCAG 2.1
AA). The pairs that matter: `--ink` on `--bg` and on `--surface`,
`--ink-soft` on `--surface`, `--bg` on `--brand` (the text on brand
buttons is the DARK background ink — that is the pattern), and
`--brand-ink` on `--surface`.
- **Never white-on-brand.** The built-in documents the trap: white on
`#f43f5e` is 3.7:1 — it fails. Pick a `--brand` whose luminance
carries the dark `--bg` ink at ≥ 4.5:1 (indigo.css: 6.5:1).
- Keep `--line` close to `--surface` (a 1px step, not a wall) — the
layout reads by surfaces, not borders.
## Deployment
- **Dev:** works immediately — the file is served from the static dir
(`frontend/`, `BOR_STATIC_DIR`), so drop the file in, set
`BOR_THEME`, restart uvicorn.
- **Container:** rebuild the image. Stage 1 ships the WHOLE directory
(`cp -r ./assets/themes /out/assets/themes` — no per-file esbuild), so
a new or edited theme file needs **no Containerfile change** (A7):
whatever is in `frontend/assets/themes/` at build time is what the
image serves at `/assets/themes/…`.
+17
View File
@@ -0,0 +1,17 @@
/* Phase 62 example theme — dark indigo/slate.
Overrides the :root identity palette from styles.css; every
text/background pair meets WCAG 2.1 AA (>= 4.5:1):
ink on bg 15.8:1 · ink on surface 14.7:1 · ink-soft on surface 8.3:1
dark bg ink on brand 6.5:1 · brand-ink on surface 12.0:1.
Semantic families (accent/ok/err) inherit the built-in theme.
Load with BOR_THEME=indigo.css (authoring guide: themes/README.md). */
:root {
--bg: #0a0e1a;
--surface: #111726;
--ink: #e6e9f0;
--ink-soft: #a8b0c8;
--line: #232c44;
--brand: #818cf8;
--brand-soft: #1a1f38;
--brand-ink: #c7d2fe;
}
+9
View File
@@ -38,6 +38,15 @@ from app.config import Settings as _Settings # noqa: E402
os.environ["BOR_DOCS_REPO"] = "" os.environ["BOR_DOCS_REPO"] = ""
os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"].default) os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"].default)
# Phase 62: the same leak class for the new UI customization settings —
# an operator's local ``.env`` may legitimately carry
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and
# the default-metadata pins must see the code defaults (derived from
# the class fields, same pattern as the suggestions line above).
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default
from app.db import SessionLocal, db_available # noqa: E402 from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402 from app.main import app as fastapi_app # noqa: E402
+10
View File
@@ -112,6 +112,16 @@ def app_server(mock_llm: int) -> Iterator[str]:
env["BOR_SUGGESTIONS"] = json.dumps( env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default _Settings.model_fields["suggestions"].default
) )
# Phase 62: the same leak class for the new UI customization
# settings — an operator's local (gitignored) ``.env`` may
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
# / ``BOR_THEME``, and the byte-identical default contract (task
# 05's ``test_default_server_is_byte_identical``) must see the code
# defaults (derived from the class fields, never drifts from
# ``app/config.py``).
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", [sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+16 -4
View File
@@ -146,16 +146,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
body = r.json() body = r.json()
# Phase 59 (task 05): the third key is the docs-push flag — the # Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO # "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. # empty, so it is the inert false here. Phase 62 (task 01): the
assert set(body) == {"app_name", "version", "docs_repo_configured"} # endpoint grew to six keys — this suite's instances carry no
# UI-customization overrides, so the three new keys are their
# defaults.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == TESTY_NAME assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other # The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it). # suites' title/label contract rides on it) — and its key set
# grew with the endpoint (phase 62).
r2 = httpx.get(f"{app_server}/api/config", timeout=5) r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200 assert r2.status_code == 200
assert r2.json()["app_name"] == DEFAULT_NAME r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert r2_body["app_name"] == DEFAULT_NAME
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+331
View File
@@ -0,0 +1,331 @@
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
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).
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``);
* 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.
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
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
collide) and the three 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.
Test → contract mapping (Playwright Mapping Rule):
1. ``test_config_serves_the_overrides``
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
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
import subprocess
import sys
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, expect
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
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:
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"
DEFAULT_PLACEHOLDER = "Ask me anything…"
DEFAULT_FOOTER = "Powered by self-hosted models"
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
overrides.
The shared conftest ``app_server`` keeps the defaults (every other
suite's placeholder/footer/palette assertions depend on it) — so
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
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
three env overrides below. Started after ``mock_llm`` is available
(its fixture dependency).
"""
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"
)
# The mock's token-overlap embeddings have their own score
# distribution — the same mock-calibrated threshold as the shared
# instance, so this suite's pages behave like every other story's.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
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
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
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"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{CUSTOM_URL}/api/health")
yield CUSTOM_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
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)."""
page.wait_for_function(
"""(expected) =>
getComputedStyle(document.documentElement)
.getPropertyValue("--brand")
.trim() === expected""",
arg=expected,
timeout=timeout,
)
# ---------------------------------------------------------------------------
# 1. The endpoint the brand layer reads — the three overrides, the
# six-key set, and the theme file served from the dev static dir
# ---------------------------------------------------------------------------
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).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
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
# ---------------------------------------------------------------------------
def test_chat_page_shows_custom_placeholder_footer_theme(
page: Page, custom_server: str
) -> None:
page.goto(custom_server + "/")
# 5. The composer placeholder — the retry rides out the brand.js
# /api/config fetch that applies it.
expect(page.locator("#message-input")).to_have_attribute(
"placeholder", CUSTOM_PLACEHOLDER, timeout=15_000
)
# 6. The footer line on the chat page.
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)
# ---------------------------------------------------------------------------
# 3. A second page — the footer applies multi-page; the placeholder
# application no-ops without a composer
# ---------------------------------------------------------------------------
def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> None:
page.goto(custom_server + "/login.html")
# This page has NO composer — the placeholder application no-ops
# there via the null guard (no error, no element touched).
expect(page.locator("#message-input")).to_have_count(0)
# The footer line applies on every page (the phase-61 hook).
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
)
# ---------------------------------------------------------------------------
# 4. The no-op regression — the shared default server is byte-identical
# to the phase-39/61 contract
# ---------------------------------------------------------------------------
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.
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.
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
+46 -5
View File
@@ -18,16 +18,26 @@ def test_health_reports_ok(client) -> None:
def test_config_returns_default_app_metadata(client) -> None: def test_config_returns_default_app_metadata(client) -> None:
"""GET /api/config is public (anonymous) and returns exactly three """GET /api/config is public (anonymous) and returns exactly six
keys — the phase-39 app metadata + the phase-59 docs flag (inert keys — the phase-39 app metadata, the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating).""" false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
and the phase-62 UI customization strings (composer placeholder,
footer line, theme file name)."""
r = client.get("/api/config") r = client.get("/api/config")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert set(body) == {"app_name", "version", "docs_repo_configured"} assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == "Brain of Reese" assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False assert body["docs_repo_configured"] is False
# Phase 62: UNSET => the phase-61 neutral copy stands (the
# byte-identical contract); an empty theme = the built-in palette.
assert body["input_placeholder"] == "Ask me anything…"
assert body["footer_text"] == "Powered by self-hosted models"
assert body["theme"] == ""
def test_config_follows_overridden_app_name(client) -> None: def test_config_follows_overridden_app_name(client) -> None:
@@ -42,7 +52,10 @@ def test_config_follows_overridden_app_name(client) -> None:
r = client.get("/api/config") r = client.get("/api/config")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert set(body) == {"app_name", "version", "docs_repo_configured"} assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["app_name"] == "Brain of Testy" assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0" assert body["version"] == "0.1.0"
assert body["docs_repo_configured"] is False assert body["docs_repo_configured"] is False
@@ -50,6 +63,34 @@ def test_config_follows_overridden_app_name(client) -> None:
fastapi_app.dependency_overrides.clear() fastapi_app.dependency_overrides.clear()
def test_config_serves_ui_customization_overrides(client) -> None:
"""Phase 62: the three UI customization keys mirror Settings
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` /
``BOR_THEME``) verbatim — the values the frontend brand layer
applies at boot, so this dict is the whole contract."""
from app.config import Settings
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
input_placeholder="Ask the vault…",
footer_text="Powered by my own models",
theme="indigo.css",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
assert body["theme"] == "indigo.css"
finally:
fastapi_app.dependency_overrides.clear()
def test_config_docs_flag_tracks_settings(client) -> None: def test_config_docs_flag_tracks_settings(client) -> None:
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors """Phase 59 (task 05): ``docs_repo_configured`` mirrors
``settings.docs_configured`` — a real bool (never a truthy string) ``settings.docs_configured`` — a real bool (never a truthy string)
+74
View File
@@ -397,3 +397,77 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
s = _settings() s = _settings()
assert s.docs_configured is False assert s.docs_configured is False
assert s.docs_branch == "bor docs.." # stored verbatim, never used assert s.docs_branch == "bor docs.." # stored verbatim, never used
# --- UI customization (phase 62, TODO L3) ---
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
phase-61 copy is the DEFAULT (composer placeholder + footer line),
and an empty theme = the built-in dark-tech palette."""
s = _settings()
assert s.input_placeholder == "Ask me anything…"
assert s.footer_text == "Powered by self-hosted models"
assert s.theme == ""
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""The three settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
placeholder/footer accept any string (empty is legal — the brand
layer then keeps the template default)."""
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
monkeypatch.setenv("BOR_THEME", "indigo.css")
s = _settings()
assert s.input_placeholder == "Ask the vault…"
assert s.footer_text == "Powered by my own models"
assert s.theme == "indigo.css"
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
assert _settings().input_placeholder == "" # empty stands
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
``.css`` filename (the ``indigo.css`` example) is the only
non-empty shape — dashes/underscores/digits are legal tokens."""
assert _settings().theme == "" # "" passes
assert _settings(theme="indigo.css").theme == "indigo.css"
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
@pytest.mark.parametrize(
("bad", "match"),
[
# uppercase — the shape is lowercase-only
("Indigo.css", "Indigo.css"),
# path escape — a theme is a filename, never a path
("../evil.css", r"\.\./evil\.css"),
("a/b.css", r"a/b\.css"),
("/abs.css", r"got '/abs\.css'"),
# a missing extension is not a theme file
("indigo", r"got 'indigo'"), # must not match the example text
],
)
def test_theme_validator_rejects_malformed_naming_the_value(
bad: str,
match: str,
) -> None:
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
runtime — the rejection names the offending value (the phase-56
fail-loud house style) alongside the allowed shape."""
with pytest.raises(ValidationError, match=match):
_settings(theme=bad)
def test_bor_theme_env_malformed_fails_startup_naming_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The startup path: a malformed ``BOR_THEME`` in the environment
fails Settings construction loudly (the app builds its settings at
import time, so this is a refused boot), naming the value — the
E2E boots-check lands in task 05."""
monkeypatch.setenv("BOR_THEME", "../evil.css")
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
_settings()
+54
View File
@@ -95,6 +95,60 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
assert marker in js, f"the attribute pass must cover {marker}" assert marker in js, f"the attribute pass must cover {marker}"
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
/api/config answer also drives the three customization keys — the
#message-input placeholder, every .footer-text node, and the theme
stylesheet link (inserted right after the styles.css link, guarded
by #theme-override, degrading with a console.warn on 404 — A5).
No second network call: the keys ride the existing boot fetch."""
js = _text(BRAND_JS)
assert js.count('= fetch("/api/config"') == 1, (
"the three keys must ride the existing boot fetch — no new call"
)
# 5. The composer placeholder (chat page only — the null guard
# no-ops on every other page).
assert 'document.querySelector("#message-input")' in js
assert "input_placeholder" in js
# 6. The footer line on all 9 pages (the phase-61 hook) — via
# textContent: an operator string can't inject markup.
assert 'document.querySelectorAll(".footer-text")' in js
assert "footer_text" in js
# 7. The theme link: /assets/themes/<name>, inserted right after
# the styles.css link, tagged #theme-override (the idempotency
# guard), with the A5 degradation warn.
assert '"/assets/themes/"' in js
assert 'link.id = "theme-override"' in js
assert 'getElementById("theme-override")' in js
assert 'insertAdjacentElement("afterend", link)' in js
assert "link.onerror" in js
assert '"brand: theme " + themeName' in js
# The styles.css finder must survive the phase-33/54 cache-bust
# rewrite: the SERVED HTML carries the asset ref with a
# ?v=<token> query (and el.href is the absolute URL), so the match
# has to run on the RAW attribute path with query/fragment
# stripped — el.href.endsWith(…) would silently skip the insertion
# (the theme never applied; found by the task-05 E2E).
assert 'getAttribute("href")' in js
assert "split(/[?#]/)[0]" in js
assert 'endsWith("styles.css")' in js
assert "el.href.endsWith" not in js
# The empty-skip no-op contract: each key is guarded before any
# DOM write, so an unset deployment stays byte-identical.
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
assert guard in js, (
f"an empty value must skip its application ({guard})"
)
# Independence: the phase-62 block sits AFTER the app_name passes
# in the same .then — never gated by the name.
assert js.index("// 4. Attributes:") < js.index("// Phase 62"), (
"the customization keys must apply after the app_name block, "
"even when the name is the default/empty"
)
# The app_name literal default pin still holds.
assert 'window.BOR_BRAND = "Brain of Reese"' in js
def test_page_scripts_keep_the_default_literal_exactly_once() -> None: def test_page_scripts_keep_the_default_literal_exactly_once() -> None:
"""The fallback literal lives in the page scripts' brand() reads — """The fallback literal lives in the page scripts' brand() reads —
exactly one copy per file (a second copy could drift out of sync).""" exactly one copy per file (a second copy could drift out of sync)."""
+6 -1
View File
@@ -51,7 +51,12 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
s = _settings() s = _settings()
body = app_config(s) body = app_config(s)
assert set(body) == {"app_name", "version", "docs_repo_configured"} # Phase 62 (task 01): the response grew to the six-key set — the
# phase-62 UI customization keys ride the SAME endpoint.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
assert body["docs_repo_configured"] is s.docs_configured assert body["docs_repo_configured"] is s.docs_configured
assert body["docs_repo_configured"] is False assert body["docs_repo_configured"] is False
+195
View File
@@ -0,0 +1,195 @@
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
the Containerfile line that ships it (A7).
No Python logic exists for this task — the mechanism lives in
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
drop-in stylesheet. Like the other frontend-adjacent unit files, this
module pins the assets as text, so a silent regression (a theme file
gaining a selector, a declaration drifting, the Containerfile line
vanishing) is caught without a browser. The browser-visible layer
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
``tests/e2e/test_ui_customization.py`` (task 05).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
FRONTEND = REPO_ROOT / "frontend"
THEMES = FRONTEND / "assets" / "themes"
INDIGO = THEMES / "indigo.css"
GUIDE = THEMES / "README.md"
CONTAINERFILE = REPO_ROOT / "Containerfile"
#: The 8 identity variables a theme may override — and the EXACT set
#: indigo.css ships (the semantic families accent/ok/err are states,
#: not identity: a theme that overrides them stops being honest).
IDENTITY_VARS = (
"--bg",
"--surface",
"--ink",
"--ink-soft",
"--line",
"--brand",
"--brand-soft",
"--brand-ink",
)
INDIGO_VALUES: dict[str, str] = {
"--bg": "#0a0e1a",
"--surface": "#111726",
"--ink": "#e6e9f0",
"--ink-soft": "#a8b0c8",
"--line": "#232c44",
"--brand": "#818cf8",
"--brand-soft": "#1a1f38",
"--brand-ink": "#c7d2fe",
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _strip_comments(css: str) -> str:
"""Drop ``/* … */`` comments — the pins assert against declarations,
not prose."""
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
def _declarations(css: str) -> dict[str, str]:
"""The ``--name: value`` declarations of the (single) ``:root``
block, in file order."""
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
def test_example_theme_files_exist() -> None:
"""The example theme and its authoring guide ship in the static
dir (served at /assets/themes/… in dev AND in the image)."""
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
"""The whole file is ONE ``:root`` block (the cascade is the entire
mechanism): after stripping comments the first non-whitespace
content is ``:root``, and no other rule, selector, or declaration
exists anywhere in the file."""
css = _strip_comments(_text(INDIGO))
assert css.lstrip().startswith(":root"), (
"indigo.css must start with the :root block (after its header "
"comment) — nothing may precede it"
)
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
"indigo.css must be exactly one :root block — no selectors, "
"no @media, no nested or extra rules"
)
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
"""EXACTLY the 8 identity overrides with the locked values — no
other declarations (a 9th declaration here would be the theme
reaching past the palette), and the semantic families
(accent/ok/err) must be untouched (they encode states)."""
decls = _declarations(_strip_comments(_text(INDIGO)))
assert set(decls) == set(IDENTITY_VARS), (
f"indigo.css must override exactly the 8 identity variables, got "
f"{sorted(decls)}"
)
for name in IDENTITY_VARS:
assert decls[name].strip() == INDIGO_VALUES[name], (
f"{name} drifted from the locked value "
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
)
for family in ("--accent-", "--ok-", "--err-"):
assert not any(k.startswith(family) for k in decls), (
f"semantic {family}* variables must stay the built-in "
f"theme (they encode states)"
)
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
"""The five identity text/background pairs, computed from the file's
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
(text on brand buttons is --bg, never white — the built-in's
documented 3.7:1 trap), brand-ink on surface."""
decls = _declarations(_strip_comments(_text(INDIGO)))
def lum(hexcolor: str) -> float:
h = hexcolor.lstrip("#")
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
lin = [
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in chans
]
r, g, b = lin
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(fg: str, bg: str) -> float:
l1, l2 = lum(fg), lum(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
pairs = (
("ink on bg", decls["--ink"], decls["--bg"]),
("ink on surface", decls["--ink"], decls["--surface"]),
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
("bg ink on brand", decls["--bg"], decls["--brand"]),
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
)
for name, fg, bg in pairs:
r = ratio(fg, bg)
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
def test_containerfile_ships_the_whole_themes_directory() -> None:
"""A7: stage 1 copies the WHOLE themes directory (no per-file
esbuild — a future theme file needs no Containerfile edit), and it
does so AFTER the styles.css minify line (so the served /assets/
tree is complete before the pages cp)."""
cf = _text(CONTAINERFILE)
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
lines = stage1.splitlines()
cp_idxs = [
i
for i, ln in enumerate(lines)
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
]
assert len(cp_idxs) == 1, (
"stage 1 must ship the themes directory with exactly one "
"'cp -r ./assets/themes /out/assets/themes' line"
)
styles_idxs = [
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
]
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
assert cp_idxs[0] > styles_idxs[0], (
"the themes cp must come AFTER the styles.css minify line"
)
cp_line = lines[cp_idxs[0]]
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
"A7: the themes directory is copied verbatim — no per-file "
"esbuild minify"
)
def test_authoring_guide_pins_the_contract() -> None:
"""The guide documents the load path (BOR_THEME → /api/config →
brand.js link after styles.css), the filename validator regex, the
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
and the A7 rebuild story (a new file needs no Containerfile edit)."""
guide = _text(GUIDE)
for marker in (
"BOR_THEME",
"/api/config",
"styles.css",
r"^[a-z0-9_-]+\.css$",
"4.5:1",
"white-on-brand",
"cp -r ./assets/themes /out/assets/themes",
):
assert marker in guide, f"themes/README.md must document {marker!r}"
for var in IDENTITY_VARS:
assert var in guide, f"the variable table must list {var}"