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 ``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. diff --git a/.agent/phases/todo/39_configurable_brand/03_e2e_docs_commit.md b/.agent/phases/todo/39_configurable_brand/03_e2e_docs_commit.md new file mode 100644 index 0000000..25f7e3e --- /dev/null +++ b/.agent/phases/todo/39_configurable_brand/03_e2e_docs_commit.md @@ -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/`. diff --git a/.env.example b/.env.example index c3a2894..6d98f44 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ # --- 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_LOG_LEVEL=INFO # BOR_STATIC_DIR=frontend # dev default; container sets /app/static diff --git a/Containerfile b/Containerfile index 543223e..6127617 100644 --- a/Containerfile +++ b/Containerfile @@ -20,6 +20,7 @@ RUN mkdir -p /out/assets \ && esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \ && esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \ && esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \ + && 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 ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html /out/ diff --git a/README.md b/README.md index 672beb0..7d7f649 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,7 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`. | 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_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 | diff --git a/app/api/config.py b/app/api/config.py new file mode 100644 index 0000000..5b3a6dc --- /dev/null +++ b/app/api/config.py @@ -0,0 +1,14 @@ +"""Public app metadata (display name + version) for the frontend brand layer.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.config import Settings, get_settings + +router = APIRouter(tags=["config"]) + + +@router.get("/config") +def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]: # noqa: B008 + """Public app metadata for the frontend brand layer (phase 39).""" + return {"app_name": settings.app_name, "version": settings.app_version} diff --git a/app/main.py b/app/main.py index 39e541c..d4c75cf 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.api.auth import router as auth_router from app.api.chat import router as chat_router +from app.api.config import router as config_router from app.api.docs import router as docs_router from app.api.git_sources import router as git_sources_router from app.api.health import router as health_router @@ -62,6 +63,7 @@ def create_app() -> FastAPI: # API routes first so they take precedence over the catch-all static mount. app.include_router(health_router, prefix="/api") + app.include_router(config_router, prefix="/api") app.include_router(auth_router, prefix="/api") app.include_router(suggestions_router, prefix="/api") app.include_router(docs_router, prefix="/api") diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 9260b1d..b09c1ec 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -47,8 +47,9 @@ * "thinking": the UI state itself stays "thinking" (button stays * disabled — never stale, PLAN §7.4) while the LABELS change — the * button says "Calling tool…", the #send-status + typing-indicator - * labels say what Brain is doing ("Brain of Reese is listing documents" - * / "Brain of Reese is reading source/path"), and a visible `.tool-call` + * labels say what Brain is doing ("…is listing documents" / + * "…is reading source/path" — the name prefix resolves from + * window.BOR_BRAND at call time, phase 39), and a visible `.tool-call` * line (own icon + accent color, distinct from the brand-ink Thinking * block) is appended above the answer, one per call, in order. * Append-only like thinking: frames are tolerated in any interleaving @@ -111,6 +112,14 @@ const banner = document.querySelector("#kb-banner"); const bannerText = document.querySelector("#kb-banner-text"); const versionEl = document.querySelector("#app-version"); +/* Phase 39: the display name resolves from one place — window.BOR_BRAND + * (the classic assets/brand.js sets it at parse time; its /api/config + * fetch refreshes it). Read LAZILY (a function, not a const string): + * a label set after the fetch lands carries the configured name; the + * literal below is only the no-config fallback — with the default name + * every label renders the pre-phase-39 bytes. */ +const brand = () => window.BOR_BRAND || "Brain of Reese"; + /* ---------- loading-feedback contract (PLAN §7.4) ---------- * Client-side guard: a pre-token stream that produces no delta within * TURN_TIMEOUT_MS is treated as hung → error state + banner. It is @@ -126,14 +135,20 @@ const UI_STATE = Object.freeze({ error: "error", }); +/* The #send-status live-region text per UI state (PLAN §7.4). The + * values are builders (phase 39): the brand entries resolve brand() at + * call time, never at module evaluation, so a label set after the + * /api/config fetch lands carries the configured name. */ const SEND_STATUS = Object.freeze({ - [UI_STATE.idle]: "", - [UI_STATE.thinking]: "Brain of Reese is thinking", - [UI_STATE.streaming]: "Brain of Reese is answering", - [UI_STATE.error]: "The last question failed — try again", + [UI_STATE.idle]: () => "", + [UI_STATE.thinking]: () => `${brand()} is thinking`, + [UI_STATE.streaming]: () => `${brand()} is answering`, + [UI_STATE.error]: () => "The last question failed — try again", }); -const TYPING_LABEL = "Brain of Reese is thinking"; +/* The typing indicator's accessible label (the 10s elapsed-seconds + * hint updates it from this base) — built at call time (phase 39). */ +const TYPING_LABEL = () => `${brand()} is thinking`; const ERROR_HINT = "Try again — if this persists, check the LLM is reachable."; /* A turn with no answer content (an empty stream, or reasoning that exhausted max_tokens — phase 17) still renders a bubble, and this exact @@ -350,7 +365,7 @@ function addTyping() { wrap.innerHTML = ` <span class="avatar" aria-hidden="true">${BRAIN_AVATAR}</span> <div class="msg-body"> - <div class="bubble typing" role="status" aria-label="${TYPING_LABEL}"> + <div class="bubble typing" role="status" aria-label="${TYPING_LABEL()}"> <span></span><span></span><span></span> </div> </div>`; @@ -528,7 +543,7 @@ function startThinkingClock() { if (secs < 10) return; // hint only after 10s of pre-token silence const bubble = document.querySelector("#typing-indicator .bubble"); if (bubble) { - bubble.setAttribute("aria-label", `Brain of Reese is still thinking (${secs}s)`); + bubble.setAttribute("aria-label", `${brand()} is still thinking (${secs}s)`); } }, 1000); } @@ -556,7 +571,7 @@ export function setUiState(state, errorDetail = "") { sendBtn.disabled = inFlight; sendBtn.querySelector(".spinner").hidden = !inFlight; sendLabel.textContent = inFlight ? "Thinking…" : "Send"; - sendStatus.textContent = SEND_STATUS[state] ?? ""; + sendStatus.textContent = SEND_STATUS[state]?.() ?? ""; if (state === UI_STATE.thinking) { addTyping(); @@ -963,8 +978,8 @@ async function handleSend(e) { if (!wrap) wrap = addMessage("brain", ""); const toolStatus = name === "read_document" && argument - ? `Brain of Reese is reading ${argument}` - : "Brain of Reese is listing documents"; + ? `${brand()} is reading ${argument}` + : `${brand()} is listing documents`; if (uiState === UI_STATE.thinking) { sendLabel.textContent = "Calling tool…"; sendStatus.textContent = toolStatus; diff --git a/frontend/assets/brand.js b/frontend/assets/brand.js new file mode 100644 index 0000000..308cc66 --- /dev/null +++ b/frontend/assets/brand.js @@ -0,0 +1,134 @@ +/* Brain of Reese — brand layer (phase 39). + * + * One env var (BOR_APP_NAME) drives the display name everywhere. This + * small CLASSIC script is the single owner of the resolution — it is not + * a module, so its top level runs at parse time: window.BOR_BRAND is + * readable from the first line of the page's module scripts (modules + * execute after parsing, so a module could not guarantee this). + * + * Contract (phase 39 locked decisions — A11 no CDN, runtime fetch): + * • window.BOR_BRAND = "Brain of Reese" synchronously — the default + * name renders immediately, no blank flash; + * • fetch("/api/config", { cache: "no-store" }) — on success with a + * non-empty app_name, window.BOR_BRAND is updated and the name is + * applied to the DOM: + * 1. document.title — global replace of the literal; + * 2. every .brand-text node — a name starting "Brain of " keeps + * the current look (Brain of <strong>rest</strong>), any other + * name renders plain (no bold); the name is HTML-escaped (an + * operator-controlled string must not inject markup); + * 3. a TreeWalker over the document's text nodes — the literal is + * replaced (the index empty-state h1 "Hey! I'm Brain of + * Reese." and any other prose); script/style text nodes are + * 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). + * • 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. + */ + +/* The synchronous default — set BEFORE any fetch, so module scripts + reading window.BOR_BRAND at evaluation time always find a value. */ +window.BOR_BRAND = "Brain of Reese"; + +/* The literal the DOM passes replace — the default name. The page + scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in + sync with it. */ +const BRAND_LITERAL = "Brain of Reese"; + +/* The name is operator-controlled: HTML-escape it before it touches + innerHTML (the markdown.js escape pattern — local on purpose, no + cross-module import for a 5-line helper). */ +function escapeHTML(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[c])); +} + +function applyBrand() { + fetch("/api/config", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .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; + + // 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)); + } + } + if (el.tagName === "META") { + const v = el.getAttribute("content"); + if (v && v.includes(BRAND_LITERAL)) { + el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name)); + } + } + } + }) + .catch((err) => { + // Fetch failure (or a non-JSON body): the default name stays — + // the page never breaks (the loadHealth house style). + console.warn("brand: /api/config did not answer — keeping the default name.", err); + }); +} + +/* The top level only sets the global (synchronously, at parse time); + the DOM passes run once the document is ready. */ +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", applyBrand); +} else { + applyBrand(); +} diff --git a/frontend/assets/document.js b/frontend/assets/document.js index b93423b..9bd12f1 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -55,6 +55,12 @@ import { fetchIsAdmin, initSharedHeader } from "./header.js"; +/* Phase 39: the page title's display name — window.BOR_BRAND (set at + * parse time by the classic assets/brand.js, refreshed from + * /api/config). This is a module, so the global is set by the time this + * evaluates; the literal is the no-config fallback only. */ +const brand = () => window.BOR_BRAND || "Brain of Reese"; + function fmtDate(iso) { try { return new Date(iso).toLocaleString(); @@ -180,13 +186,13 @@ if (document.querySelector("#doc-title")) { * the document.title (the modal keeps the page title untouched). */ function render(doc) { renderDocument(doc, { titleEl, metaEl, contentEl }); - document.title = `${doc.title} · Brain of Reese`; + document.title = `${doc.title} · ${brand()}`; } function showNotFound() { titleEl.textContent = "Document not found"; titleEl.removeAttribute("title"); // no stale full-title tooltip - document.title = "Document not found · Brain of Reese"; + document.title = `Document not found · ${brand()}`; metaEl.replaceChildren(); contentEl.replaceChildren(); notFoundEl.hidden = false; diff --git a/frontend/document.html b/frontend/document.html index 423a1eb..571ed84 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -153,6 +153,9 @@ </div> </footer> + <!-- Phase 39: the brand layer — classic script, first on the page: + window.BOR_BRAND at parse time, refreshed from /api/config. --> + <script src="assets/brand.js"></script> <script src="assets/markdown.js"></script> <!-- Phase 19: the shared header module loads through the page script's own `import "./header.js"` — a hoisted import that is evaluated diff --git a/frontend/git-sources.html b/frontend/git-sources.html index 0db05b0..1ca9db9 100644 --- a/frontend/git-sources.html +++ b/frontend/git-sources.html @@ -251,6 +251,9 @@ this body runs (the single-evaluation design: no direct header.js <script> tag; esbuild inlines it into the page bundle in the image build). --> + <!-- Phase 39: the brand layer — classic script, first on the page: + window.BOR_BRAND at parse time, refreshed from /api/config. --> + <script src="assets/brand.js"></script> <script type="module" src="/assets/git-sources.js"></script> </body> </html> diff --git a/frontend/index.html b/frontend/index.html index 8a10b24..63927e5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -149,6 +149,11 @@ </div> </footer> + <!-- Phase 39: the brand layer — a CLASSIC script, first on every + page: window.BOR_BRAND is set at parse time (before the module + scripts evaluate) and refreshed from /api/config (a byte- + identical no-op for the default name). --> + <script src="assets/brand.js"></script> <script src="assets/markdown.js"></script> <!-- Phase 19: the shared header module loads through the page script's own `import "./header.js"` — a hoisted import that is evaluated diff --git a/frontend/login.html b/frontend/login.html index 4f1e70b..b9cd752 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -141,6 +141,9 @@ <main>. The module loads through the page script's own `import "./header.js"` (hoisted, evaluated before the page script body calls initSharedHeader() at boot). --> + <!-- Phase 39: the brand layer — classic script, first on the page: + window.BOR_BRAND at parse time, refreshed from /api/config. --> + <script src="assets/brand.js"></script> <script type="module" src="/assets/login.js"></script> </body> </html> diff --git a/frontend/sources.html b/frontend/sources.html index f779b7e..5a30bb6 100644 --- a/frontend/sources.html +++ b/frontend/sources.html @@ -196,6 +196,9 @@ Phase 26: markdown.js (the classic global renderMarkdown) loads BEFORE the module script — the document modal renders md documents through it on this page too. --> + <!-- Phase 39: the brand layer — classic script, first on the page: + window.BOR_BRAND at parse time, refreshed from /api/config. --> + <script src="assets/brand.js"></script> <script src="assets/markdown.js"></script> <script type="module" src="/assets/sources.js"></script> diff --git a/frontend/tuning.html b/frontend/tuning.html index 0b51821..ccdc58b 100644 --- a/frontend/tuning.html +++ b/frontend/tuning.html @@ -156,6 +156,9 @@ tuning.js's own `import "./header.js"` — a hoisted import that is evaluated before the page script body calls initSharedHeader() at boot. No direct header.js <script> tag (single-evaluation design). --> + <!-- Phase 39: the brand layer — classic script, first on the page: + window.BOR_BRAND at parse time, refreshed from /api/config. --> + <script src="assets/brand.js"></script> <script src="assets/markdown.js"></script> <script type="module" src="/assets/tuning.js"></script> </body> diff --git a/tests/e2e/test_configurable_brand.py b/tests/e2e/test_configurable_brand.py new file mode 100644 index 0000000..6dd8eaf --- /dev/null +++ b/tests/e2e/test_configurable_brand.py @@ -0,0 +1,235 @@ +"""Phase 39 E2E (Playwright): the configurable app name (``BOR_APP_NAME``). + +Story: ``.agent/user_stories/configurable-brand.md`` +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_configurable_brand.py -v --no-cov + +Contract under test (owner ask — "a way to customize the name for +'Brain of'. Should be an env var"): + +* one env var (``BOR_APP_NAME``, default "Brain of Reese") drives the + display name on EVERY page — the ``<title>``s, the header + ``.brand-text`` brand, the empty-state greeting, the ``#messages`` + aria-label, and the chat status labels — via the public + ``GET /api/config`` + the classic ``assets/brand.js`` layer; +* a name starting ``"Brain of "`` keeps the bold split (``Brain of + <strong>rest</strong>``); the name is HTML-escaped (the story's + acceptance criteria, AC-2); +* with the variable UNSET the app is byte-identical to today — the + shared conftest server (the default name every other suite's + title/label assertions depend on) still renders "Brain of Reese". + +Determinism note: this story needs a SECOND app instance — the shared +conftest server keeps the default name, so ``testy_server`` boots the +same env block (same DB, the mock-LLM base URL, the admin auth, the +static dir, the mock-calibrated threshold) with exactly two changes: +``BOR_APP_NAME="Brain of Testy"`` and port ``APP_PORT + 1``. Every +assertion is settled-state: Playwright's ``expect`` retries ride out +the brand.js ``/api/config`` fetch (the name is applied asynchronously +after the fetch answers). The one chat turn is pre-token-labeled — the +``think out loud`` marker makes the mock stream ~2.7k chars of +reasoning at 12 chars / 0.02 s (~4.6 s) before the first content frame, +so the "… is thinking" label is stable for the whole window regardless +of what the turn answers (grounded or deflected — no DB seeding +needed). + +Test → story mapping (Playwright Mapping Rule): +1. ``test_api_config_serves_both_names`` +2. ``test_index_carries_the_custom_brand`` +3. ``test_other_pages_titles_carry_the_name`` +4. ``test_chat_status_label_uses_custom_name_pre_token`` +5. ``test_shared_server_keeps_the_default_name`` +""" +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, +) + +TESTY_PORT = APP_PORT + 1 +TESTY_URL = f"http://127.0.0.1:{TESTY_PORT}" +TESTY_NAME = "Brain of Testy" +DEFAULT_NAME = "Brain of Reese" + + +def expect_inner_html(page: Page, selector: str, expected: str, timeout: int = 15_000) -> None: + """Retrying innerHTML equality (Playwright's Python API ships no + ``to_have_inner_html`` assertion). The brand layer applies the name + when its ``/api/config`` fetch answers, so the check must poll — + ``wait_for_function`` does, on Playwright's own retry clock.""" + page.wait_for_function( + """([sel, expected]) => { + const el = document.querySelector(sel); + return el !== null && el.innerHTML === expected; + }""", + arg=[selector, expected], + timeout=timeout, + ) + + +@pytest.fixture(scope="session") +def testy_server(mock_llm: int) -> Iterator[str]: + """A SECOND app instance, booted with the overridden name. + + The shared conftest ``app_server`` keeps the default name (every + other suite's title/label assertions depend on it) — so this + fixture copies its env block verbatim (same DB, the mock-LLM base + URL, ``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, + ``BOR_STATIC_DIR``, ``BOR_RELEVANCE_THRESHOLD``) with exactly two + changes: ``BOR_APP_NAME="Brain of Testy"`` and a distinct port + (``APP_PORT + 1`` per the conftest convention). 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 turn behaves 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 + # The phase-39 override — the whole story: + env["BOR_APP_NAME"] = TESTY_NAME + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(TESTY_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{TESTY_URL}/api/health") + yield TESTY_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +# --------------------------------------------------------------------------- +# 1. The endpoint the brand layer reads — overridden on the second +# instance, default on the shared one +# --------------------------------------------------------------------------- + + +def test_api_config_serves_both_names(testy_server: str, app_server: str) -> None: + r = httpx.get(f"{TESTY_URL}/api/config", timeout=5) + assert r.status_code == 200 + body = r.json() + assert set(body) == {"app_name", "version"} + assert body["app_name"] == TESTY_NAME + + # The shared conftest instance keeps the default (the other + # suites' title/label contract rides on it). + r2 = httpx.get(f"{app_server}/api/config", timeout=5) + assert r2.status_code == 200 + assert r2.json()["app_name"] == DEFAULT_NAME + + +# --------------------------------------------------------------------------- +# 2. The chat page — title, header brand, greeting, stream aria-label +# --------------------------------------------------------------------------- + + +def test_index_carries_the_custom_brand(page: Page, testy_server: str) -> None: + page.goto(testy_server + "/") + # The brand layer applies the name when /api/config answers — the + # retrying expectations ride out that fetch. + expect(page).to_have_title(TESTY_NAME, timeout=15_000) + # The "Brain of …" name keeps the bold split (AC-2) — and the name + # arrives escaped (Testy has no special characters, but the markup + # shape is the contract). + expect_inner_html(page, ".brand-text", "Brain of <strong>Testy</strong>") + # The empty-state greeting (the TreeWalker prose pass). + expect(page.locator("#empty-state h1")).to_have_text( + f"Hey! I'm {TESTY_NAME}.", timeout=15_000 + ) + # The message stream's accessible label (the attribute pass). + expect(page.locator("#messages")).to_have_attribute( + "aria-label", f"Conversation with {TESTY_NAME}", timeout=15_000 + ) + + +# --------------------------------------------------------------------------- +# 3. The other pages — their <title>s carry the name too +# --------------------------------------------------------------------------- + + +def test_other_pages_titles_carry_the_name(page: Page, testy_server: str) -> None: + page.goto(testy_server + "/sources.html") + expect(page).to_have_title(f"Sources · {TESTY_NAME}", timeout=15_000) + + page.goto(testy_server + "/login.html") + expect(page).to_have_title(f"Sign in · {TESTY_NAME}", timeout=15_000) + + +# --------------------------------------------------------------------------- +# 4. The chat status label — a pre-token turn on the custom instance +# --------------------------------------------------------------------------- + + +def test_chat_status_label_uses_custom_name_pre_token( + page: Page, testy_server: str, db_ready: None +) -> None: + page.goto(testy_server + "/") + expect(page.locator("#send-btn")).to_be_enabled(timeout=15_000) + + # The ``think out loud`` marker (mock_llm.py) makes the answer + # stream ~2.7k chars of reasoning FIRST — the pre-token window. + # Works on the default (possibly empty) KB: a deflected answer is + # fine, the label assertion is pre-token, no seeding required. + page.fill("#message-input", "think out loud — what is the capital of France?") + page.click("#send-btn") + + # The pre-token status label carries the configured name (the + # ~4.6 s reasoning stream keeps the state "thinking" — the label + # is stable for the whole window). + expect(page.locator("#send-status")).to_have_text( + f"{TESTY_NAME} is thinking", timeout=15_000 + ) + + # The turn always finishes — the button is never left stale + # (PLAN §7.4), whatever the turn answered. + expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000) + + +# --------------------------------------------------------------------------- +# 5. The no-op regression — the shared default-name instance is +# byte-identical to today +# --------------------------------------------------------------------------- + + +def test_shared_server_keeps_the_default_name(page: Page, app_server: str) -> None: + page.goto(app_server + "/") + expect(page).to_have_title(DEFAULT_NAME, timeout=15_000) + # With the variable unset the brand pass is a no-op: the header + # renders exactly the pre-phase-39 markup. + expect_inner_html(page, ".brand-text", "Brain of <strong>Reese</strong>") diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 1a5a111..3b6e7df 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -17,6 +17,35 @@ def test_health_reports_ok(client) -> None: assert body["version"] +def test_config_returns_default_app_metadata(client) -> None: + """GET /api/config is public (anonymous) and returns exactly two keys.""" + r = client.get("/api/config") + assert r.status_code == 200 + body = r.json() + assert set(body) == {"app_name", "version"} + assert body["app_name"] == "Brain of Reese" + assert body["version"] == get_settings().app_version + + +def test_config_follows_overridden_app_name(client) -> None: + """GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).""" + from app.config import Settings + from app.main import app as fastapi_app + + fastapi_app.dependency_overrides[get_settings] = lambda: Settings( + app_name="Brain of Testy" + ) + try: + r = client.get("/api/config") + assert r.status_code == 200 + body = r.json() + assert set(body) == {"app_name", "version"} + assert body["app_name"] == "Brain of Testy" + assert body["version"] == "0.1.0" + finally: + fastapi_app.dependency_overrides.clear() + + def test_suggestions_returns_list(client) -> None: r = client.get("/api/suggestions") assert r.status_code == 200 diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py new file mode 100644 index 0000000..8639eb2 --- /dev/null +++ b/tests/unit/test_frontend_brand.py @@ -0,0 +1,173 @@ +"""Unit: the phase-39 brand layer (BOR_APP_NAME → window.BOR_BRAND). + +No Python logic exists for this task — the behavior lives in +``frontend/assets/brand.js`` + the templates + the page scripts, and it +is E2E-gated by the story suite (task 03). Like the other +frontend-adjacent unit files, this module pins the JS markers the story +depends on, so a silent regression in the brand layer is caught without +a browser. +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +ROOT = FRONTEND.parent +BRAND_JS = FRONTEND / "assets" / "brand.js" +APP_JS = FRONTEND / "assets" / "app.js" +DOCUMENT_JS = FRONTEND / "assets" / "document.js" +CONTAINERFILE = ROOT / "Containerfile" + +#: Every page in the app ships the brand layer (phase 39: "every visible +#: brand string on every page resolves from one place"). +HTML_PAGES = ( + "index.html", + "sources.html", + "tuning.html", + "document.html", + "login.html", + "git-sources.html", +) + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_brand_js_is_a_classic_script_with_synchronous_default() -> None: + """brand.js is NOT a module (its top level must run at parse time, + before the page's module scripts evaluate) and sets window.BOR_BRAND + synchronously, BEFORE the /api/config fetch starts.""" + js = _text(BRAND_JS) + assert not re.search(r"^\s*import\s", js, re.M), ( + "brand.js must be a classic script — no import statements" + ) + assert not re.search(r"^\s*export\s", js, re.M), ( + "brand.js must be a classic script — no export statements" + ) + default_idx = js.find('window.BOR_BRAND = "Brain of Reese"') + fetch_idx = js.find('fetch("/api/config"') + assert 0 <= default_idx < fetch_idx, ( + "the default name must be set at top level before the fetch" + ) + + +def test_brand_js_fetches_api_config_no_store() -> None: + """The single source of the name is GET /api/config, never cached — + a renamed app must not serve a stale name from the HTTP cache — and + a fetch failure only warns (the loadHealth house style: the page + never breaks).""" + js = _text(BRAND_JS) + assert 'fetch("/api/config", { cache: "no-store" })' in js + assert "console.warn" in js + + +def test_brand_js_defers_dom_application_until_ready() -> None: + """The DOM passes run on DOMContentLoaded while parsing, otherwise + immediately (the script sits at the end of <body>).""" + js = _text(BRAND_JS) + assert 'document.readyState === "loading"' in js + assert 'document.addEventListener("DOMContentLoaded", applyBrand)' in js + + +def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None: + """The four DOM passes (phase 39 task 02): the document title, + every .brand-text (bold split for "Brain of …" names, plain text + otherwise — the name is HTML-escaped before innerHTML), a + TreeWalker over the text nodes (script/style nodes rejected — the + page source is never rewritten), and the aria-label / placeholder / + meta content attributes.""" + js = _text(BRAND_JS) + assert "document.title.replaceAll" in js + assert ".brand-text" in js + assert 'name.startsWith("Brain of ")' in js + assert "escapeHTML" in js, "the name must be escaped before innerHTML" + assert "el.textContent = name" in js, "non-'Brain of ' names render plain" + assert "document.createTreeWalker" in js + assert "NodeFilter.SHOW_TEXT" in js + assert 'tag === "SCRIPT" || tag === "STYLE"' in js + assert "replaceAll(BRAND_LITERAL, name)" in js + for marker in ('"aria-label"', '"placeholder"', "meta[content]"): + assert marker in js, f"the attribute pass must cover {marker}" + + +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).""" + assert _text(APP_JS).count('"Brain of Reese"') == 1, ( + "app.js: the literal must appear only in the brand() fallback" + ) + assert _text(DOCUMENT_JS).count('"Brain of Reese"') == 1, ( + "document.js: the literal must appear only in the brand() fallback" + ) + + +def test_app_js_labels_resolve_the_name_at_call_time() -> None: + """Phase 39 task 02: the status labels, the typing label, the + elapsed-seconds hint and the tool labels read window.BOR_BRAND + through brand() — at CALL time, so a label set after /api/config + lands carries the configured name (a module-level const would + freeze the default).""" + js = _text(APP_JS) + assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js + assert "`${brand()} is thinking`" in js + assert "`${brand()} is answering`" in js + assert "`${brand()} is still thinking (${secs}s)`" in js + assert "`${brand()} is reading ${argument}`" in js + assert "`${brand()} is listing documents`" in js + # The frozen module-level literals must be gone (stale-name bug). + assert '"Brain of Reese is thinking"' not in js + assert '"Brain of Reese is answering"' not in js + + +def test_document_js_titles_resolve_the_name() -> None: + """The viewer page titles (render + not-found) carry the resolved + name — the module sets them itself, so it must use brand() (the + static document.html title is handled by the brand.js title pass).""" + js = _text(DOCUMENT_JS) + assert 'document.title = `${doc.title} · ${brand()}`' in js + assert 'document.title = `Document not found · ${brand()}`' in js + assert 'window.BOR_BRAND || "Brain of Reese"' in js + + +def test_every_page_loads_brand_js_before_its_module_script() -> None: + """All pages load brand.js as a CLASSIC script, before the page's + module script (modules execute after parsing — the synchronous + default must be set first).""" + for page in HTML_PAGES: + html = _text(FRONTEND / page) + brand_idx = html.find('<script src="assets/brand.js"></script>') + module_idx = html.find('<script type="module"') + assert brand_idx != -1, f"{page}: brand.js must load" + assert module_idx != -1, f"{page}: the page module must load" + assert brand_idx < module_idx, ( + f"{page}: brand.js must load BEFORE the module script" + ) + assert 'type="module"' not in html[brand_idx : brand_idx + 60], ( + f"{page}: brand.js must be a classic script" + ) + + +def test_containerfile_builds_brand_js_like_its_classic_sibling() -> None: + """The image build minifies brand.js (classic script — minify only, + no --bundle, exactly like markdown.js) so the container serves it.""" + cf = _text(CONTAINERFILE) + lines = [ln for ln in cf.splitlines() if "brand.js" in ln] + assert len(lines) == 1, "one esbuild line for brand.js" + line = lines[0] + assert "esbuild ./assets/brand.js" in line + assert "--minify" in line + assert "--bundle" not in line, ( + "classic script: minify only (the markdown.js pattern)" + ) + assert "--outfile=/out/assets/brand.js" in line + + +def test_no_cdn_in_the_brand_layer() -> None: + """AGENTS.md rule 6: the brand layer adds no external reference.""" + js = _text(BRAND_JS) + assert "http://" not in js and "https://" not in js + for page in HTML_PAGES: + html = _text(FRONTEND / page) + assert 'src="https://' not in html and 'href="https://' not in html diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index ec3cc63..e85f8ee 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -47,9 +47,18 @@ def test_state_machine_has_all_four_states() -> None: def test_typing_indicator_contract_strings() -> None: """The indicator's accessible label and the 10s elapsed-seconds hint - are the strings screen readers (and the E2E) rely on.""" + are the strings screen readers (and the E2E) rely on. + + Phase 39: the label is BUILT at call time from window.BOR_BRAND + (the classic brand.js layer) — `TYPING_LABEL()` — with the default + name as the only fallback, so the default path renders the same + bytes as before.""" js = _js() - assert 'TYPING_LABEL = "Brain of Reese is thinking"' in js + assert "TYPING_LABEL = () =>" in js, "the label must be built at call time (phase 39)" + assert "`${brand()} is thinking`" in js, "the label must resolve the name via brand()" + assert 'window.BOR_BRAND || "Brain of Reese"' in js, ( + "the no-config fallback must keep the default name" + ) assert "still thinking" in js, "elapsed-seconds hint must update the aria label" assert "secs < 10" in js, "the hint must only appear after 10s of silence" assert "role=\"status\"" in js, "typing bubble must be role=status" diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index fea8bcd..d279e09 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -54,15 +54,16 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None: def test_calling_tool_label_strings() -> None: """The 'calling tool' label strings the story keys off: the button - text and the status/typing-indicator labels (plain literals — - phase 39 centralizes brand strings; no helper here).""" + text and the status/typing-indicator labels. Phase 39 centralizes + the brand prefix: the name resolves from window.BOR_BRAND at call + time via brand() (the default name renders the same bytes).""" js = _js() tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') branch = js[tool_idx:delta_idx] assert '"Calling tool…"' in branch, "the button carries the calling-tool text" - assert '"Brain of Reese is listing documents"' in branch - assert "Brain of Reese is reading ${argument}" in branch + assert "`${brand()} is listing documents`" in branch + assert "`${brand()} is reading ${argument}`" in branch assert "sendStatus.textContent = toolStatus" in branch, ( "the #send-status live region announces what Brain is doing" )