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
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:
+27
-12
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user