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'.
This commit is contained in:
2026-09-01 12:04:06 -04:00
parent baefcde668
commit c738105932
17 changed files with 1057 additions and 73 deletions
+157 -58
View File
@@ -31,13 +31,42 @@
* 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).
* 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 BOR_APP_NAME unset the /api/config answer IS the
* literal, so every replacement below is a byte-identical no-op.
* 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
@@ -90,65 +119,135 @@ 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) return; // empty / missing: the default stands
window.BOR_BRAND = name;
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);
// 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));
// 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;
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, 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);
}
}
}