feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer
Build and Push Containers / build-and-push (push) Successful in 1m50s

One env var (BOR_APP_NAME, default "Brain of Reese") now drives the app's
display name everywhere (TODO.md L12 — owner ask: "a way to customize the
name for 'Brain of'. Should be an env var."). The existing app_name setting
is the source of truth (phase locked decision — no new variable, no rename);
with the variable unset the app is byte-identical to before.

Endpoint (A10 public/stateless, no secrets):
  GET /api/config → exactly {app_name, version} (app/api/config.py, the
  health.py pattern; registered before the static mount). Integration tests:
  anonymous 200, default values, a Settings override follows, key set is
  exactly two keys — no other setting may leak in later.

Frontend brand layer (A11 — runtime fetch, static templates stay static):
  assets/brand.js — a CLASSIC script, first on all six pages, so its top
  level runs at parse time: window.BOR_BRAND = "Brain of Reese"
  synchronously (the default renders immediately, no blank flash), then a
  no-store fetch of /api/config applies the name — document.title (global
  replace), every .brand-text (a name starting "Brain of " keeps the bold
  split Brain of <strong>rest</strong>, any other name renders plain; the
  operator-controlled name is HTML-escaped before innerHTML), a TreeWalker
  over text nodes (script/style rejected — page source never rewritten),
  and the aria-label/placeholder/meta-content attributes. Fetch failure
  keeps the default + console.warn (the loadHealth house style).
  app.js (status labels, typing label, elapsed-hint aria, tool labels) and
  document.js (viewer titles) read window.BOR_BRAND at CALL time via
  brand() — a label set after the fetch lands carries the configured name.
  Containerfile: esbuild minify line for brand.js (classic, like markdown.js);
  the phase-33 ?v= cache-busting picks the new asset ref up automatically.

E2E (A16 — one story, one file, isolated): test_configurable_brand.py boots
a SECOND app instance (same DB/mock-LLM/admin-auth env block, port APP_PORT+1,
BOR_APP_NAME="Brain of Testy") — the shared conftest server keeps the
default name so every other suite's title/label assertions stay untouched —
and asserts /api/config on both instances, the index title/brand/greeting/
#messages aria-label, the sources + login page titles, and one pre-token
chat turn (think out loud marker) whose #send-status reads "Brain of Testy
is thinking"; the no-op regression pins the shared server's default bytes.

Docs: .env.example App section + README configuration reference — what it
affects (titles, header brand, status labels, aria text), the default, the
bold-split rendering rule.

Gates: 695 unit+integration passed, app/ coverage 99% (>90%), story E2E
green in isolation (two consecutive runs), brand-string suites (smoke,
shared header, header consistency, chat persistence) green, ruff + pyright
clean.
This commit is contained in:
2026-08-27 02:24:16 -04:00
parent 94d7228510
commit fe55be0c35
23 changed files with 788 additions and 20 deletions
@@ -0,0 +1,27 @@
# Task 01 — `GET /api/config`
**Phase:** `39_configurable_brand` · **Source:** `TODO.md:12 — "Also need a way to customize the name for 'Brain of'. Should be an env var."`
**Story:** `.agent/user_stories/configurable-brand.md`
## Objective
A public, stateless endpoint that hands the frontend its display name (+ version) — the single source the brand layer reads.
## Work
1. `app/api/config.py` — a new router mirroring the `app/api/health.py` pattern:
```python
@router.get("/config")
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]:
"""Public app metadata for the frontend brand layer (phase 39)."""
return {"app_name": settings.app_name, "version": settings.app_version}
```
Public (no `require_admin` — the brand must render for anonymous users too, before any sign-in); stateless (A10); the response carries **exactly** these two keys (no other setting may leak in later — the test asserts the key set).
2. `app/main.py` — register it with the other routers (`app.include_router(config_router, prefix="/api")`), before the static mount (API routes take precedence — same order as the existing routers).
3. Tests (follow the health test's location and pattern): 200 for anonymous; default values (`"Brain of Reese"`, the current `app_version`); with an overridden `Settings` (`app_name="Brain of Testy"`) the response follows; the response key set == `{"app_name", "version"}`.
## Testing & Quality
- The tests above; full `uv run pytest` green.
- Coverage: **>90%** on the new module (keep the handler + docstring tight so it stays covered).
## Completion Criteria
- [ ] `GET /api/config` (anonymous) → `{"app_name": "Brain of Reese", "version": "0.1.0"}` (the current default version).
- [ ] The suite green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,34 @@
# Task 02 — `brand.js` + de-hard-coding
**Phase:** `39_configurable_brand` · **Source:** `TODO.md:12 — "Also need a way to customize the name for 'Brain of'. Should be an env var."`
**Story:** `.agent/user_stories/configurable-brand.md`
## Objective
Every visible brand string on every page resolves from one place (`window.BOR_BRAND`, fed by `/api/config`) — the default "Brain of Reese" renders immediately, and a fetch failure falls back to the default (the page never breaks).
## Work
1. `frontend/assets/brand.js` — a new small **classic** script (vanilla, no CDN — A11; not a module, so its top level runs at parse time):
- Top level: `window.BOR_BRAND = "Brain of Reese"` (synchronous default — module scripts execute after parsing, so the page scripts can read it from the first line).
- DOM application (deferred: `if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", applyBrand); else applyBrand();`):
1. `fetch("/api/config", { cache: "no-store" })`; on success with a non-empty `app_name`: `window.BOR_BRAND = app_name`, then:
2. `document.title = document.title.replaceAll("Brain of Reese", name)`.
3. Every `.brand-text` node: if the name starts with `"Brain of "` → `innerHTML = 'Brain of <strong>' + escapeHTML(rest) + '</strong>'` (the current look for the default name); else → `textContent = name` (plain, no bold). **HTML-escape the name** (an operator-controlled string must not inject markup).
4. A `TreeWalker` over the document's text nodes: replace the literal "Brain of Reese" with the name (catches the index empty-state h1 "Hey! I'm Brain of Reese." and any prose).
5. An attribute pass over `aria-label`, `placeholder`, and meta `content` attributes containing the literal → replace (the `#messages` aria-label "Conversation with Brain of Reese", the input label, the meta descriptions).
- On fetch failure: keep the default, `console.warn` (the `loadHealth` house style — progressive enhancement, never break the page).
- Small local `escapeHTML` helper (the `markdown.js` pattern — do not import across modules unless the build makes it easy).
2. The templates (`frontend/index.html`, `sources.html`, `tuning.html`, `document.html`, `login.html`) — add `<script src="assets/brand.js"></script>` **before** the page's module script on every page (classic script → runs at parse time; the module scripts execute later). No other template changes — the walker + attribute pass is the single mechanism; do **not** add `data-brand` markers. The phase-33 `?v=` rewriting picks the new ref up automatically (`app/core/caching.py` matches any `src="…assets/…"`).
3. `frontend/assets/app.js` — replace the "Brain of Reese" literals with `window.BOR_BRAND` reads: the `UI_STATE` labels (~L107: "… is thinking" / "… is answering"), `TYPING_LABEL` (~L112), the elapsed-hint aria-label (~L540). Pattern: `const brand = () => window.BOR_BRAND || "Brain of Reese";` + template strings. (Mid-turn staleness: a label set before the fetch lands keeps the old name for that turn — accepted, see the phase's locked decisions.)
4. `frontend/assets/document.js` — the page titles (L147/152: `${doc.title} · Brain of Reese` / `"Document not found · …"`) → use the same `window.BOR_BRAND` read (document.js is a module — `window.BOR_BRAND` is set by then).
5. `Containerfile` — add the esbuild line for the new file next to the others (L17–23 pattern, classic script like `markdown.js`): `esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js`.
6. Verify the no-op property: with the default settings the rendered DOM text is byte-identical to pre-phase on all five pages (the replace is a no-op for the default name) — the existing suites' title/label assertions are the guard; if any assert a string this task moved onto `window.BOR_BRAND`, the default path must render the identical bytes.
## Testing & Quality
- No Python logic — the story E2E (task 03) is the gate; the existing suites (which assert the default "Brain of Reese" titles/labels against the shared conftest server) must stay green **unchanged**.
- No CDN (rule 6): no new external tags. UI Structure Check (rule 5): no landmark/contrast change — the brand text keeps its existing classes and styling (the `innerHTML` rewrite only re-emits the same structure with the new name).
## Completion Criteria
- [ ] `BOR_APP_NAME` unset → all five pages render exactly as today (existing suites green).
- [ ] `BOR_APP_NAME="Brain of Testy"` → title/header/greeting/labels/aria all carry the new name (asserted by the story E2E, task 03).
- [ ] The Containerfile build includes brand.js; the asset ref is versioned like its siblings (phase 33).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,39 @@
# Phase 39 — Configurable app name (brand)
**Source:** `TODO.md` L12 — "Also need a way to customize the name for 'Brain of'. Should be an env var."
**Story:** `.agent/user_stories/configurable-brand.md`
**Context:** `app/config.py` (`app_name`, already `BOR_APP_NAME`, currently used only for the OpenAPI title at `app/main.py:48`), the five templates (`frontend/*.html` — the `.brand-text` spans, the `<title>`s, the meta descriptions, the index empty-state h1, the `aria-label`s), `frontend/assets/app.js` (status labels ~L107/112, the elapsed-hint aria ~L540), `frontend/assets/document.js` (page titles L147/152), `app/api/health.py` (the public stateless endpoint pattern), `app/core/caching.py` (phase 33: `?v=` rewriting of `assets/…` refs — automatic for any new asset file), the `Containerfile` esbuild stage (explicit per-asset lines, L17–23).
## Objective
Make **one env var** (`BOR_APP_NAME`, default "Brain of Reese") drive the app's display name everywhere — titles, the header brand, the status labels, the aria text, the greeting — via a public `/api/config` endpoint + a small `brand.js`, with zero behavior change when the variable is unset.
## Dependencies
- `34_consistent_navbar` (todo — runs before this phase) — the standard five-page header (the `.brand-text` nodes this phase re-skins).
- `01_infrastructure` (complete) — the public stateless endpoint pattern (`app/api/health.py`).
## Tasks
1. `01_config_endpoint.md` — public `GET /api/config` → `{app_name, version}` + tests.
2. `02_frontend_branding.md` — `brand.js` + the template/JS de-hard-coding + the Containerfile build line.
3. `03_e2e_docs_commit.md` — the story E2E (its own app instance with the overridden name), `.env.example` + README, commit, move the phase dir.
## Testing & Quality
- Unit/integration: the endpoint (200, the values, anonymous access, exactly two keys — no settings may leak later).
- E2E (mandatory, A16): `tests/e2e/test_configurable_brand.py` — a **second** app instance booted with `BOR_APP_NAME` overridden; run in isolation.
- Coverage: **>90%** on `app/`.
- Regression: the default-name behavior is byte-identical — the existing suites (which assert "Brain of Reese" titles/labels against the shared conftest server) stay green **unchanged**.
## Completion Criteria
- [ ] `GET /api/config` (anonymous) → `{"app_name": "Brain of Reese", "version": "0.1.0"}` by default; the response key set is exactly `{app_name, version}`.
- [ ] With `BOR_APP_NAME="Brain of Testy"`: the index title "Brain of Testy"; the header `.brand-text` renders `Brain of <strong>Testy</strong>`; the empty-state h1 "Hey! I'm Brain of Testy."; the `#messages` aria-label "Conversation with Brain of Testy"; the chat status label "Brain of Testy is thinking"; the other pages' titles carry the name; the document viewer title "… · Brain of Testy".
- [ ] With the variable unset: the existing E2E + unit suites green unchanged (no rename leak).
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run pytest tests/e2e/test_configurable_brand.py -v --no-cov` green in isolation.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6).
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.
## Locked decisions
- **The existing `app_name` setting is the source of truth (owner permission 2026-08-26)** — `BOR_APP_NAME` (default "Brain of Reese"); no new variable, no setting rename.
- **A11 honoured** — runtime fetch + JS application (no build-time template injection, no Jinja, no CDN); the static templates stay static.
- **A10 honoured** — `/api/config` is public and stateless; the response carries no secrets (exactly `app_name` + `version`).
- **Runtime fetch, brief flash accepted (owner permission 2026-08-26)** — the default name renders immediately and is replaced when `/api/config` answers (LAN latency; no re-paint machinery for in-flight turns — a mid-turn label keeps the previous name for that turn).
- **A16/A17 honoured** — one new story E2E suite + one atomic `--no-gpg-sign` commit.
@@ -0,0 +1,27 @@
# Task 03 — Story E2E + docs + commit
**Phase:** `39_configurable_brand` · **Source:** `TODO.md:12 — "Also need a way to customize the name for 'Brain of'. Should be an env var."`
**Story:** `.agent/user_stories/configurable-brand.md`
## Objective
The story's isolated Playwright suite against an app instance booted with the overridden name, the env docs, and the phase commit.
## Work
1. `tests/e2e/test_configurable_brand.py` (the story gate — one story, one file, run in isolation):
- A **second app instance** — the shared `app_server` conftest fixture keeps the default name (the other suites' title/label assertions depend on it). Copy the conftest `app_server` env block (same DB, the mock-LLM base URL, `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET`, `BOR_STATIC_DIR`, `BOR_RELEVANCE_THRESHOLD`) with two changes: `BOR_APP_NAME="Brain of Testy"` and a distinct port (`APP_PORT + 1` per the conftest convention). A session-scoped fixture **inside the test file**, started after `mock_llm` is available.
- Assertions (custom instance): index `document.title` == `"Brain of Testy"`; the `.brand-text` `innerHTML` == `Brain of <strong>Testy</strong>`; the empty-state h1 text == `"Hey! I'm Brain of Testy."`; the `#messages` `aria-label` == `"Conversation with Brain of Testy"`; the sources page title `"Sources · Brain of Testy"`; the login page title `"Sign in · Brain of Testy"`; one chat turn with a pre-token window (the `think out loud` marker) → the button label shows `"Brain of Testy is thinking"`.
- Default-name assertion (cheap regression in the same file): the shared conftest server's index title still == `"Brain of Reese"`.
- The chat-turn assertion works on the default (possibly empty) KB — a deflected answer is fine; the label assertion is pre-token, so no DB seeding is required.
2. `.env.example` — document `BOR_APP_NAME` in the App section (the display name on all pages; default "Brain of Reese").
3. README — the configuration section: `BOR_APP_NAME` (what it affects: titles, the header brand, the status labels, the aria text; the default; the bold-split rendering rule: names starting "Brain of " bold the remainder, any other name renders in normal weight).
4. Regression pass: `uv run pytest` + the coverage gate (>90%) + the isolated story E2E + the suites that assert brand strings (`test_smoke.py`, `test_shared_header.py`, `test_header_consistency.py`, `test_chat_persistence.py`) green.
5. Commit — one atomic `--no-gpg-sign` Conventional Commits commit for the whole phase (AGENTS.md rule 8), e.g. `feat(brand): configurable app name — BOR_APP_NAME drives /api/config + the frontend brand layer`; move the phase directory to `.agent/phases/complete/`.
## Testing & Quality
- The gates above are this task's quality bar (A16: one story, one isolated E2E file, coverage >90%).
## Completion Criteria
- [ ] The story E2E is green in isolation, deterministic across two consecutive runs (custom-name instance + the default-name assertion).
- [ ] The step-4 regression list green; coverage >90%.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase directory moved to `.agent/phases/complete/`.