Files
brain-of-reese/frontend/assets/brand.js
T
ducoterra c738105932 feat(web): customizable placeholder, footer text, and color theme via BOR_* env vars
BOR_INPUT_PLACEHOLDER / BOR_FOOTER_TEXT / BOR_THEME (+ the indigo.css example theme); authoring guide: frontend/assets/themes/README.md, docs: README 'Customizing the look'.
2026-09-01 12:04:06 -04:00

266 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
* • Phase 59: window.BOR_DOCS_REPO_CONFIGURED = false synchronously
* (inert until proven) and window.BOR_CONFIG_PROMISE — the SAME
* fetch's promise, exposed at parse time so the chat page's boot
* (app.js) can await it BEFORE rendering any bubble; the "Save as
* doc" gating flag is then final, and a restored conversation of a
* configured admin never misses (or flashes) the button. The
* promise NEVER rejects — the error arm warns and resolves null;
* • fetch("/api/config", { cache: "no-store" }) — on success the
* docs flag is set from cfg.docs_repo_configured, and on 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);
* 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the three UI-customization
* keys, applied in this same .then, AFTER the app_name
* passes and INDEPENDENT of them (they apply even when the
* name is the default/empty). Each empty value is a no-op —
* an unset deployment stays byte-identical:
* 5. input_placeholder — non-empty → the #message-input
* placeholder (the chat page only; every other page
* no-ops via the null guard);
* 6. footer_text — non-empty → every .footer-text node's
* textContent (all 9 pages, the phase-61 hook; an
* operator string can't inject markup via textContent);
* 7. theme — non-empty → a <link rel="stylesheet"> inserted
* IMMEDIATELY AFTER the styles.css link (the theme's
* :root overrides win by cascade order). The styles.css
* finder matches the RAW attribute path with any query/
* fragment stripped — the phase-33/54 cache-busting
* middleware serves the HTML with the asset refs rewritten
* to "…/styles.css?v=<token>", and el.href (the absolute
* URL) would never end with "styles.css" once versioned.
* The filename is validated server-side (a bare *.css
* name — no path can reach here via /api/config); a
* MISSING file degrades to the built-in theme (onerror →
* console.warn — A5, the page never breaks). Guarded by
* #theme-override: never inserted twice.
* • fetch failure / empty name → the default stays + console.warn
* (the loadHealth house style: progressive enhancement, the page
* never breaks).
*
* No-op property: with the customization env vars (BOR_APP_NAME,
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) unset, /api/config
* answers with the template defaults themselves — the name IS the
* literal, the placeholder and footer are the phase-61 copy (re-setting
* them is invisible), the theme is empty (the link is skipped) — so an
* unset deployment renders byte-identical.
*/
/* The synchronous default — set BEFORE any fetch, so module scripts
reading window.BOR_BRAND at evaluation time always find a value. */
window.BOR_BRAND = "Brain of Reese";
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push flag —
surfaced the way app_name is (a window global, inert until the boot
fetch proves otherwise). false = the "Save as doc" action is hidden
for everyone (BOR_DOCS_REPO empty — the feature is off). */
window.BOR_DOCS_REPO_CONFIGURED = false;
/* 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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
/* The /api/config fetch — started at TOP LEVEL (parse time) so
window.BOR_CONFIG_PROMISE exists before the page's module scripts
evaluate (app.js's boot awaits it, above). Phase 59: the flag lands
here, the moment the answer arrives — before any DOM pass. The
promise NEVER rejects: the error arm warns (the loadHealth house
style — the page never breaks) and resolves to null, so the default
name + false flag stand. */
const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then(
(cfg) => {
window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;
return cfg;
},
(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);
return null;
},
);
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 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));
}
}
}
}
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the three customization keys — applied here,
// INDEPENDENT of the app_name block above (they apply even when
// the name is the default/empty). Each empty value is a no-op, so
// an unset deployment stays byte-identical (no attribute or
// element touched, no second network call).
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
const themeName = typeof cfg?.theme === "string" ? cfg.theme : "";
if (themeName) {
// 7. The theme stylesheet — a <link> inserted IMMEDIATELY AFTER
// the existing styles.css link, so the theme's :root
// overrides win by cascade order. The filename is validated
// server-side (task 01: a bare *.css name) — no path input
// can reach here via /api/config. Guarded by #theme-override:
// never applied twice (the loadHealth house style — never
// break the page, never double-apply). A missing file
// degrades to the built-in theme (A5): the onerror warns,
// nothing else.
if (!document.getElementById("theme-override")) {
// Phase 33/54: the served HTML may carry the cache-bust query
// (?v=<token>) on the asset ref — match on the RAW attribute
// path with query/fragment stripped, never on el.href (the
// absolute URL, which would include the token).
const stylesLink = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
).find((el) => {
const ref = (el.getAttribute("href") || "").split(/[?#]/)[0];
return ref.endsWith("styles.css");
});
if (stylesLink) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/themes/" + themeName;
link.id = "theme-override";
link.onerror = () =>
console.warn(
"brand: theme " + themeName +
" did not load — the built-in theme stands.",
);
stylesLink.insertAdjacentElement("afterend", link);
}
}
}
});
}
/* The DOM passes run once the document is ready AND the config is
settled (applyBrand awaits the parse-time promise) — the fetch may
resolve before or after DOMContentLoaded; both orderings apply the
brand exactly once. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", applyBrand);
} else {
applyBrand();
}