From c73810593218e2770de7ab98d932dc11ee2fabae Mon Sep 17 00:00:00 2001 From: ducoterra Date: Tue, 1 Sep 2026 12:04:06 -0400 Subject: [PATCH] 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'. --- .env.example | 3 + Containerfile | 1 + README.md | 18 ++ app/api/config.py | 18 +- app/config.py | 30 +++ frontend/assets/brand.js | 215 ++++++++++++----- frontend/assets/themes/README.md | 77 ++++++ frontend/assets/themes/indigo.css | 17 ++ tests/conftest.py | 9 + tests/e2e/conftest.py | 10 + tests/e2e/test_configurable_brand.py | 20 +- tests/e2e/test_ui_customization.py | 331 ++++++++++++++++++++++++++ tests/integration/test_api.py | 51 +++- tests/unit/test_config.py | 74 ++++++ tests/unit/test_frontend_brand.py | 54 +++++ tests/unit/test_save_as_doc_button.py | 7 +- tests/unit/test_themes.py | 195 +++++++++++++++ 17 files changed, 1057 insertions(+), 73 deletions(-) create mode 100644 frontend/assets/themes/README.md create mode 100644 frontend/assets/themes/indigo.css create mode 100644 tests/e2e/test_ui_customization.py create mode 100644 tests/unit/test_themes.py diff --git a/.env.example b/.env.example index a7ffd18..4088536 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ # --- App --- BOR_ENVIRONMENT=development # 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_STATIC_DIR=frontend # dev default; container sets /app/static diff --git a/Containerfile b/Containerfile index 70b21a1..6bda853 100644 --- a/Containerfile +++ b/Containerfile @@ -26,6 +26,7 @@ RUN mkdir -p /out/assets \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && 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/ # ---------- Stage 2: python dependencies ---------- diff --git a/README.md b/README.md index d988af8..6d48791 100644 --- a/README.md +++ b/README.md @@ -732,6 +732,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`. | Env | Default | Meaning | |-----|---------|---------| | `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page ``, 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_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint | | `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) | | `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 - **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`). diff --git a/app/api/config.py b/app/api/config.py index 7d2f061..95d47c6 100644 --- a/app/api/config.py +++ b/app/api/config.py @@ -1,5 +1,7 @@ """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 fastapi import APIRouter, Depends @@ -12,12 +14,18 @@ router = APIRouter(tags=["config"]) @router.get("/config") def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008 """Public app metadata for the frontend brand layer (phase 39) + - the phase-59 ``docs_repo_configured`` flag — the chat page's - "Save as doc" button gating, surfaced the way ``app_name`` is - (the SAME boot fetch, no new network surface). Inert false while - ``BOR_DOCS_REPO`` is empty (the feature is off, D3).""" + the phase-59 ``docs_repo_configured`` flag + the phase-62 UI + customization keys (``input_placeholder``, ``footer_text``, + ``theme``) — all display strings, the SAME boot fetch (no new + 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 { "app_name": settings.app_name, "version": settings.app_version, "docs_repo_configured": settings.docs_configured, + "input_placeholder": settings.input_placeholder, + "footer_text": settings.footer_text, + "theme": settings.theme, } diff --git a/app/config.py b/app/config.py index 06fddf5..6576073 100644 --- a/app/config.py +++ b/app/config.py @@ -46,6 +46,15 @@ class Settings(BaseSettings): log_level: str = "INFO" 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_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). 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") @classmethod def _import_extensions_known(cls, v: str) -> str: diff --git a/frontend/assets/brand.js b/frontend/assets/brand.js index 0189e13..ca13094 100644 --- a/frontend/assets/brand.js +++ b/frontend/assets/brand.js @@ -31,13 +31,42 @@ * skipped so page source is never mutated; * 4. an attribute pass — the aria-label / placeholder / meta * 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 * (the loadHealth house style: progressive enhancement, the page * never breaks). * - * No-op property: with BOR_APP_NAME unset the /api/config answer IS the - * literal, so every replacement below is a byte-identical no-op. + * No-op property: with the customization env vars (BOR_APP_NAME, + * 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 @@ -90,65 +119,135 @@ window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE; function applyBrand() { BOR_CONFIG_PROMISE.then((cfg) => { const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : ""; - if (!name) return; // empty / missing: the default stands - window.BOR_BRAND = name; + 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; - // 1. The document title (global replace of the literal — covers - // every page's static "<…> · Brain of Reese" titles). - document.title = document.title.replaceAll(BRAND_LITERAL, name); + // 1. The document title (global replace of the literal — covers + // every page's static "<…> · Brain of Reese" titles). + document.title = document.title.replaceAll(BRAND_LITERAL, name); - // 2. The header brand on every page: a name starting "Brain of " - // keeps the bold split (the current look), anything else - // renders plain — the name is always escaped. - for (const el of document.querySelectorAll(".brand-text")) { - if (name.startsWith("Brain of ")) { - const rest = name.slice("Brain of ".length); - el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`; - } else { - el.textContent = name; - } - } - - // 3. Prose: a TreeWalker over the body's text nodes replaces the - // literal (the empty-state h1, any other copy). Text nodes - // inside <script>/<style> are rejected — the page source must - // never be rewritten. - const walker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_TEXT, - { - acceptNode(node) { - const tag = node.parentElement ? node.parentElement.tagName : ""; - return tag === "SCRIPT" || tag === "STYLE" - ? NodeFilter.FILTER_REJECT - : NodeFilter.FILTER_ACCEPT; - }, - }, - ); - const nodes = []; - while (walker.nextNode()) nodes.push(walker.currentNode); - for (const node of nodes) { - if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) { - node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name); - } - } - - // 4. Attributes: the #messages aria-label, the composer input - // label, the meta descriptions — aria-label / placeholder / - // meta content only, each replaced in place. - for (const el of document.querySelectorAll( - "[aria-label], [placeholder], meta[content]", - )) { - for (const attr of ["aria-label", "placeholder"]) { - const v = el.getAttribute(attr); - if (v && v.includes(BRAND_LITERAL)) { - el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name)); + // 2. The header brand on every page: a name starting "Brain of " + // keeps the bold split (the current look), anything else + // renders plain — the name is always escaped. + for (const el of document.querySelectorAll(".brand-text")) { + if (name.startsWith("Brain of ")) { + const rest = name.slice("Brain of ".length); + el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`; + } else { + el.textContent = name; } } - if (el.tagName === "META") { - const v = el.getAttribute("content"); - if (v && v.includes(BRAND_LITERAL)) { - el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name)); + + // 3. Prose: a TreeWalker over the body's text nodes replaces the + // literal (the empty-state h1, any other copy). Text nodes + // inside <script>/<style> are rejected — the page source must + // never be rewritten. + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + const tag = node.parentElement ? node.parentElement.tagName : ""; + return tag === "SCRIPT" || tag === "STYLE" + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT; + }, + }, + ); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) { + node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name); + } + } + + // 4. Attributes: the #messages aria-label, the composer input + // label, the meta descriptions — aria-label / placeholder / + // meta content only, each replaced in place. + for (const el of document.querySelectorAll( + "[aria-label], [placeholder], meta[content]", + )) { + for (const attr of ["aria-label", "placeholder"]) { + const v = el.getAttribute(attr); + if (v && v.includes(BRAND_LITERAL)) { + el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name)); + } + } + if (el.tagName === "META") { + const v = el.getAttribute("content"); + if (v && v.includes(BRAND_LITERAL)) { + el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name)); + } + } + } + } + + // 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); } } } diff --git a/frontend/assets/themes/README.md b/frontend/assets/themes/README.md new file mode 100644 index 0000000..4e6ab13 --- /dev/null +++ b/frontend/assets/themes/README.md @@ -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/…`. diff --git a/frontend/assets/themes/indigo.css b/frontend/assets/themes/indigo.css new file mode 100644 index 0000000..a4ae752 --- /dev/null +++ b/frontend/assets/themes/indigo.css @@ -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; +} diff --git a/tests/conftest.py b/tests/conftest.py index 7216087..7f22eb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,15 @@ from app.config import Settings as _Settings # noqa: E402 os.environ["BOR_DOCS_REPO"] = "" 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.main import app as fastapi_app # noqa: E402 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e088c90..788c6e5 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -112,6 +112,16 @@ def app_server(mock_llm: int) -> Iterator[str]: env["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 (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( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], diff --git a/tests/e2e/test_configurable_brand.py b/tests/e2e/test_configurable_brand.py index aab4da3..26cc40a 100644 --- a/tests/e2e/test_configurable_brand.py +++ b/tests/e2e/test_configurable_brand.py @@ -146,16 +146,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non body = r.json() # Phase 59 (task 05): the third key is the docs-push flag — the # "Save as doc" gating; both instances run with BOR_DOCS_REPO - # empty, so it is the inert false here. - assert set(body) == {"app_name", "version", "docs_repo_configured"} + # empty, so it is the inert false here. Phase 62 (task 01): the + # 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["docs_repo_configured"] is False # 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) 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 # --------------------------------------------------------------------------- diff --git a/tests/e2e/test_ui_customization.py b/tests/e2e/test_ui_customization.py new file mode 100644 index 0000000..dca44e2 --- /dev/null +++ b/tests/e2e/test_ui_customization.py @@ -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 diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 7526a29..19ab7eb 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -18,16 +18,26 @@ def test_health_reports_ok(client) -> None: def test_config_returns_default_app_metadata(client) -> None: - """GET /api/config is public (anonymous) and returns exactly three - keys — the phase-39 app metadata + the phase-59 docs flag (inert - false while BOR_DOCS_REPO is empty — the "Save as doc" gating).""" + """GET /api/config is public (anonymous) and returns exactly six + keys — the phase-39 app metadata, the phase-59 docs flag (inert + 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") assert r.status_code == 200 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["version"] == get_settings().app_version 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: @@ -42,7 +52,10 @@ def test_config_follows_overridden_app_name(client) -> None: r = client.get("/api/config") assert r.status_code == 200 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["version"] == "0.1.0" 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() +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: """Phase 59 (task 05): ``docs_repo_configured`` mirrors ``settings.docs_configured`` — a real bool (never a truthy string) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d20b7ef..3e9bca1 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -397,3 +397,77 @@ def test_docs_branchs_garbage_ignored_when_repo_unset( s = _settings() assert s.docs_configured is False 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() diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py index 2d40c59..9c8d992 100644 --- a/tests/unit/test_frontend_brand.py +++ b/tests/unit/test_frontend_brand.py @@ -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}" +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: """The fallback literal lives in the page scripts' brand() reads — exactly one copy per file (a second copy could drift out of sync).""" diff --git a/tests/unit/test_save_as_doc_button.py b/tests/unit/test_save_as_doc_button.py index 6f694f2..0e035fa 100644 --- a/tests/unit/test_save_as_doc_button.py +++ b/tests/unit/test_save_as_doc_button.py @@ -51,7 +51,12 @@ def test_app_config_dict_carries_the_docs_flag() -> None: s = _settings() 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 False diff --git a/tests/unit/test_themes.py b/tests/unit/test_themes.py new file mode 100644 index 0000000..078c805 --- /dev/null +++ b/tests/unit/test_themes.py @@ -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}"