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'.
266 lines
12 KiB
JavaScript
266 lines
12 KiB
JavaScript
/* 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) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
}[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();
|
||
}
|