diff --git a/.agent/phases/complete/39_configurable_brand/01_config_endpoint.md b/.agent/phases/complete/39_configurable_brand/01_config_endpoint.md new file mode 100644 index 0000000..e29bdb4 --- /dev/null +++ b/.agent/phases/complete/39_configurable_brand/01_config_endpoint.md @@ -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. diff --git a/.agent/phases/complete/39_configurable_brand/02_frontend_branding.md b/.agent/phases/complete/39_configurable_brand/02_frontend_branding.md new file mode 100644 index 0000000..5016e00 --- /dev/null +++ b/.agent/phases/complete/39_configurable_brand/02_frontend_branding.md @@ -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 ' + escapeHTML(rest) + ''` (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 `` **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. diff --git a/.agent/phases/todo/39_configurable_brand/00_phase.md b/.agent/phases/todo/39_configurable_brand/00_phase.md new file mode 100644 index 0000000..66c02ef --- /dev/null +++ b/.agent/phases/todo/39_configurable_brand/00_phase.md @@ -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 `