Files
brain-of-reese/frontend/assets/theme.js
T
ducoterra df91c6316c
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 92_theme_save_and_coverage
**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix.

- Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated.
- `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site)
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed**
- Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions.
- Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04).
- Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
2026-09-10 00:23:08 -04:00

562 lines
25 KiB
JavaScript

/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
* task 04): the admin palette + branding editor.
*
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
* shape): the router (assets/router.js) lazy-imports this module on
* FIRST show of #view-theme and calls mount(root) ONCE (mount-once,
* hide-forever — root is the view's <section id="view-theme">, every
* DOM lookup scoped to it). The initSharedHeader() call never happens
* here: in the shell the shared header boots exactly once, via the
* chat module (app.js) at shell boot.
*
* What the editor does:
*
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
* header.js exports, zero extra requests): admin hides #theme-gate
* and reveals #theme-content; anonymous / token-user keeps the
* gate (the #nav-theme link is already hidden by header.js — the
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
* /api/ui-settings request is ever made outside the admin branch.
* • load — GET /api/ui-settings → populate the 12 inputs with the
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
* merge): the tab always shows the live theme — env defaults when
* the row is empty. A failed fetch keeps the static form (the
* built-in values ship in the inputs) and shows #theme-error with
* a retry (the loadHealth house style — never a blanked panel).
* • live preview (colors only, B4) — on `input` of any of the 9
* color pickers the value is written straight onto <html> as an
* inline custom property, so the WHOLE page repaints (every view,
* the header) while the owner is picking. Text fields have NO page
* effect: the 3 strings keep the brand.js runtime application
* (owner-locked B4) — they apply via the /api/config boot fetch on
* the NEXT page load, and the sub-copy says so.
* • served-theme sync (phase 92, defect 1) — the phase-91 defect:
* Save/Reset removed the preview overrides, and the page then
* fell back to the <style id="bor-theme"> tag baked into THIS
* document at PAGE LOAD — i.e. the PREVIOUS theme — so the owner
* had to reload to see what they just saved. The fix: after every
* SETTLED read of the effective values (Save, Reset, re-show,
* initial mount) applyServedTheme() reconciles the OPEN document
* to those values in two halves. (1) The #bor-theme tag's DOM
* text — themeRootContent is byte-identical to the INNER content
* of app.core.theming.theme_style_tag (the tag is removed when
* the palette is the built-in one — the server's no-op case) — so
* the document mirrors what the next load serves. (2) The 9
* identity variables as inline custom properties on <html> (CSSOM
* setProperty / removeProperty — the live preview's mechanism) —
* THIS half is what repaints the open page, because Chromium
* re-checks a <style> element's content against style-src on
* EVERY DOM-API content change (verified E2E against this repo's
* phase-82/91 CSP: textContent on the served tag, createElement +
* appendChild, and replaceChildren are all blocked unless the new
* content's sha256 is in the page's policy — which a fresh
* palette can never be, since the header hashed what was served
* at load). The reconcile removes every override that equals its
* built-in, so <html>'s style holds exactly the settled
* non-default values (empty for a built-in palette) and never a
* stale pick. The initial mount self-heals too: a row changed in
* another browser since this page loaded is reflected the moment
* the admin opens the tab (a normal load is a no-op — the served
* tag and the overrides agree).
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
* PUT /api/ui-settings with the 12 form values (a cleared/empty
* text field → null; colors always their current hex — the
* server's built-in→NULL normalization keeps the row empty when
* the owner saves the defaults) → 200: #theme-result "Theme
* saved." (role=status), refetch + re-populate (canonical state),
* reconcile the open document to the settled values (#bor-theme
* text + the <html> overrides — the OPEN page paints the saved
* palette, no reload; a failed refetch keeps the current
* overrides, which ARE the saved values — the PUT body came from
* these very inputs), re-check the contrast pairs → re-enable +
* restore the label
* (the finally — a click can never leave a button stuck). 422:
* #theme-error carries the SERVER detail (it names the offending
* field), the form is KEPT (the owner fixes + retries); any other
* non-2xx: the fixed error line; a network error: the "is the app
* reachable?" line.
* • Reset — the same lifecycle ("Resetting…") with all 12 values
* null (the API's documented "defaults" operation) → #theme-result
* "Reset to the built-in theme." → refetch + re-populate (the
* env/built-in defaults) → reconcile the open document: the tag
* is REMOVED (effective = the built-ins → content null) and the
* <html> overrides are dropped (a failed refetch still drops
* them — the picks are stale once the reset landed).
* • WCAG contrast (the 00_phase design's five pairs — the pairs the
* layout actually pairs, see app/core/theming.py's docstring):
* ink on bg, ink on surface, ink-soft on surface, bg on brand
* (the text on brand buttons is the dark background ink — never
* white on brand), brand-ink on surface. Evaluated on every color
* `input` and after every load/save over the CURRENT form values,
* via WCAG relative luminance (sRGB → linear → L). Any pair under
* 4.5:1 is listed in #theme-contrast (role=alert) as "--ink on
* --bg: 3.2:1 — needs 4.5:1"; all pass → the warning hides.
* WARNING-ONLY: it never disables Save (the owner's homelab
* palette — the built-in stays AA, so the default deployment is
* warning-free).
* • re-show — the phase-77 hook: a user-initiated re-show of this
* already-mounted view makes the router dispatch bor:view-refresh
* on the section — re-run the load then (the tab always shows the
* settled server state when re-shown), and reconcile the open
* document to the settled values (#bor-theme text + the <html>
* overrides — the page paints the current theme: the re-show had
* the SAME latent revert as Save — a stale tag and a stale
* pick). Armed only in the ADMIN branch, after the whoami gate
* passes: anonymous shows the gate and never fetches.
*
* Every value is rendered with textContent / input.value — this file
* never builds HTML (the XSS-safe-by-construction house rule).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const gateEl = root.querySelector("#theme-gate");
const contentEl = root.querySelector("#theme-content");
const saveBtn = root.querySelector("#theme-save");
const resetBtn = root.querySelector("#theme-reset");
const errorEl = root.querySelector("#theme-error");
const resultEl = root.querySelector("#theme-result");
const contrastEl = root.querySelector("#theme-contrast");
const SAVE_LABEL = "Save theme";
const RESET_LABEL = "Reset to defaults";
/* The 12 form fields, in the form's order: `field` is the API key
(the input's name attribute), `id` the E2E-stable element id,
`kind` how the value is read for a PUT — a string field that is
empty after the trim sends null (the server stores NULL = "use
the default"); a color field always sends its current #rrggbb
(the server's built-in→NULL normalization keeps the row empty
when the owner saves the defaults). */
const FIELDS = [
{ field: "app_name", id: "theme-app-name", kind: "string" },
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
{ field: "footer_text", id: "theme-footer", kind: "string" },
{ field: "bg", id: "theme-bg", kind: "color" },
{ field: "surface", id: "theme-surface", kind: "color" },
{ field: "ink", id: "theme-ink", kind: "color" },
{ field: "ink_soft", id: "theme-ink-soft", kind: "color" },
{ field: "line", id: "theme-line", kind: "color" },
{ field: "grid_line", id: "theme-grid-line", kind: "color" },
{ field: "brand", id: "theme-brand", kind: "color" },
{ field: "brand_soft", id: "theme-brand-soft", kind: "color" },
{ field: "brand_ink", id: "theme-brand-ink", kind: "color" },
];
const inputs = {};
for (const f of FIELDS) inputs[f.field] = root.querySelector("#" + f.id);
const isHex = (v) => typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v);
const cssVar = (field) => "--" + field.replace(/_/g, "-");
/* ---------- WCAG contrast (the five pairs) ----------
* Relative luminance per WCAG 2.1: each sRGB channel is linearized
* (the 0.04045 threshold) then weighted (0.2126 / 0.7152 / 0.0722);
* the ratio is (L_lighter + 0.05) / (L_darker + 0.05). The five
* pairs (foreground, background) are exactly the ones the layout
* pairs — app/core/theming.py's docstring is the authoritative
* table. */
function channelLuminance(channel) {
const s = channel / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function relLuminance(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (
0.2126 * channelLuminance(r) +
0.7152 * channelLuminance(g) +
0.0722 * channelLuminance(b)
);
}
function contrastRatio(fgHex, bgHex) {
const a = relLuminance(fgHex);
const b = relLuminance(bgHex);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
const AA_MIN = 4.5; // WCAG 2.1 AA for normal-size text (AGENTS.md rule 5)
const PAIRS = [
["ink", "bg"],
["ink", "surface"],
["ink_soft", "surface"],
["bg", "brand"],
["brand_ink", "surface"],
];
/* Re-evaluate the five pairs over the CURRENT form values. Any pair
under 4.5:1 is listed in #theme-contrast (one line per failing
pair, " · "-joined — textContent, never HTML); all pass → the
warning hides. A pair whose input is not a valid hex (defensive —
the color inputs always are) is skipped. WARNING-ONLY: this never
touches Save (the owner can still save a failing palette). */
function updateContrast() {
if (!contrastEl) return;
const failures = [];
for (const [fg, bg] of PAIRS) {
const fgHex = inputs[fg] ? inputs[fg].value : "";
const bgHex = inputs[bg] ? inputs[bg].value : "";
if (!isHex(fgHex) || !isHex(bgHex)) continue;
const ratio = contrastRatio(fgHex, bgHex);
if (ratio < AA_MIN) {
failures.push(
`${cssVar(fg)} on ${cssVar(bg)}: ${ratio.toFixed(1)}:1 — needs 4.5:1`,
);
}
}
if (failures.length) {
contrastEl.textContent = failures.join(" · ");
contrastEl.hidden = false;
} else {
contrastEl.textContent = "";
contrastEl.hidden = true;
}
}
/* ---------- feedback lines (never stale, §7.4) ---------- */
function clearError() {
if (!errorEl) return;
errorEl.textContent = "";
errorEl.hidden = true;
}
function showError(message) {
if (!errorEl) return;
errorEl.textContent = message; // textContent — the server detail is data
errorEl.hidden = false;
}
function showResult(message) {
if (!resultEl) return;
resultEl.textContent = message; // role=status announces it
resultEl.hidden = false;
}
/* FastAPI error bodies: a string detail (the house 422s — the detail
names the offending field) or the validation-error array (the
first entry's msg). Same extraction as tuning.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- live preview (colors only — B4) ----------
* The value lands on <html> as an inline custom property: the
* inline style beats the served <style id="bor-theme"> :root, so
* the whole page repaints live while the owner is picking. An empty
* value removes the override (the page falls back to the served
* theme). Text fields write NOTHING here — the strings apply via
* brand.js on the next page load (the sub-copy says so). */
function previewColor(field, value) {
if (value) {
document.documentElement.style.setProperty(cssVar(field), value);
} else {
document.documentElement.style.removeProperty(cssVar(field));
}
}
/* Drop all 9 preview overrides so the page paints the served
(injected) theme — the "never stale" half of the contract: after
a save / reset / re-show the page shows what the server serves,
not a pick that was never (or no longer) saved. */
function clearPreview() {
for (const f of FIELDS) {
if (f.kind === "color") {
document.documentElement.style.removeProperty(cssVar(f.field));
}
}
}
/* ---------- served-theme sync (phase 92, defect 1) ----------
* The phase-91 defect: after a Save/Reset the preview overrides
* were removed and the page fell back to the <style id="bor-theme">
* tag baked into THIS document at PAGE LOAD — the PREVIOUS theme —
* so the owner had to reload to see the saved palette. The fix
* reconciles the OPEN document to the settled effective values in
* two halves: the #bor-theme tag's DOM text (what the next load
* would serve) and the 9 identity variables as inline custom
* properties on <html> (what repaints the page NOW — see
* applyServedTheme's CSP note). */
/* The :root string the server would inject on the NEXT load for
these effective values. null when every color field equals its
captured BUILTINS value — the server's no-op case (no tag served,
none to keep). Otherwise all 9 colors in FIELDS order (== the
server's COLOR_FIELDS order) — byte-identical to the INNER
content of app.core.theming.theme_style_tag's tag (lowercased hex
from the resolver), so a saved theme never jumps between the
client view and a fresh load. Pure: input → string, no DOM. */
function themeRootContent(colors) {
for (const f of FIELDS) {
if (f.kind !== "color" || colors[f.field] === BUILTINS[f.field]) continue;
const declarations = FIELDS.filter((g) => g.kind === "color")
.map((g) => `--${g.field.replace(/_/g, "-")}:${colors[g.field]};`)
.join("");
return `:root{${declarations}}`;
}
return null;
}
/* The <html> override half — the ONLY CSP-clean way to paint a
palette this page's CSP header has not hashed in: CSSOM
setProperty / removeProperty on the EXISTING <html> style (the
live preview's mechanism — an un-checked CSSOM mutation, verified
E2E under both the plain A1 and the themed 'self' + sha256
policies). setProperty for every effective color that differs
from its built-in, removeProperty for the built-in ones — the
attribute therefore holds exactly the settled non-default values
(empty for a built-in palette) and never a stale pick. */
function applyInlineOverrides(effective) {
for (const f of FIELDS) {
if (f.kind !== "color") continue;
const value = effective[f.field];
if (value && value !== BUILTINS[f.field]) {
document.documentElement.style.setProperty(cssVar(f.field), value);
} else {
document.documentElement.style.removeProperty(cssVar(f.field));
}
}
}
/* Reconcile the open document to the settled effective values
(phase 92, defect 1). DOM-text half: content null → remove the
tag; no tag → create it (createElement + textContent only — never
innerHTML); tag present → update only when the content differs.
Paint half: the <html> overrides (above). CSP (phase 82/91 — A1
+ the served tag's sha256, no 'unsafe-inline'): Chromium
re-checks a <style> element's content against style-src on EVERY
DOM-API content change — textContent on the served tag,
createElement + textContent + appendChild, replaceChildren, even
insert-empty-then-set are all BLOCKED unless the new content's
sha256 is in the page's policy (verified E2E — the phase-92 task
04 probe). A freshly-saved palette can never be in the policy
(the header hashed the content served at load), so the tag's new
text is visually inert until a reload — which serves matching
content + hash; the <html> overrides are what repaint the open
page. The DOM text is still synced so the open document mirrors
what the next load serves (and the no-op case keeps the document
tag-free, like the served HTML). */
function applyServedTheme(effective) {
const content = themeRootContent(effective);
const el = document.getElementById("bor-theme");
if (content === null) {
if (el) el.remove();
} else if (el === null) {
const style = document.createElement("style");
style.id = "bor-theme";
style.textContent = content;
document.head.appendChild(style);
} else if (el.textContent !== content) {
el.textContent = content;
}
applyInlineOverrides(effective);
}
/* ---------- load / populate (effective values) ---------- */
function populate(settings) {
for (const f of FIELDS) {
const input = inputs[f.field];
const value = settings[f.field];
if (input && typeof value === "string" && value) input.value = value;
}
}
/* GET /api/ui-settings → populate the 12 inputs with the EFFECTIVE
values (the tab always shows the live theme — env defaults when
the row is empty) and re-check the five pairs (a SAVED palette
can itself fail AA — the warning then tracks it). A failed fetch
keeps the static form + shows #theme-error with a retry (the
loadHealth house style — never a blanked panel). Returns the
SETTLED settings object (the applyServedTheme input) or null on
any failure path. */
async function loadSettings() {
clearError();
let r;
try {
r = await fetch("/api/ui-settings");
} catch {
showError("Couldn't load the theme — is the app reachable?");
return null;
}
if (!r.ok) {
showError("Couldn't load the theme — try again.");
return null;
}
let settings;
try {
settings = await r.json();
} catch {
showError("Couldn't load the theme — try again.");
return null;
}
populate(settings);
updateContrast();
return settings;
}
/* ---------- the PUT (Save + Reset share it) ---------- */
function collectBody() {
const body = {};
for (const f of FIELDS) {
const input = inputs[f.field];
const value = input ? input.value : "";
if (f.kind === "string") {
body[f.field] = value.trim() || null; // cleared field → null
} else {
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
}
return body;
}
function setBusy(busy) {
if (saveBtn) saveBtn.disabled = busy;
if (resetBtn) resetBtn.disabled = busy;
}
/* One action at a time: both buttons are disabled while either PUT
is in flight (a double-fire would race the row). */
async function putSettings(body, busyLabel, button) {
setBusy(true);
if (button) button.textContent = busyLabel;
clearError();
try {
const r = await fetch("/api/ui-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) {
return { ok: true };
}
/* 422 → the server detail (it names the field); any other
non-2xx → the fixed line. The form is KEPT either way. */
const detail =
r.status === 422
? await apiDetail(r, "Couldn't save the theme — try again.")
: "Couldn't save the theme — try again.";
return { ok: false, detail };
} catch {
return {
ok: false,
detail: "Couldn't save the theme — is the app reachable?",
};
} finally {
setBusy(false);
if (button) button.textContent = button === saveBtn ? SAVE_LABEL : RESET_LABEL;
}
}
async function saveTheme() {
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
if (!outcome.ok) {
showError(outcome.detail);
return;
}
showResult("Theme saved."); // role=status
const settings = await loadSettings(); // refetch + re-populate
if (settings) {
applyServedTheme(settings); // reconcile the open document
}
/* A failed refetch keeps the current <html> overrides on purpose:
the PUT body came from these very inputs, so they ARE the saved
palette — never revert onto the stale tag (the phase-91 defect). */
}
async function resetTheme() {
const body = {};
for (const f of FIELDS) body[f.field] = null; // all null = the defaults
const outcome = await putSettings(body, "Resetting…", resetBtn);
if (!outcome.ok) {
showError(outcome.detail);
return;
}
showResult("Reset to the built-in theme."); // role=status
const settings = await loadSettings(); // the env / built-in defaults
if (settings) {
applyServedTheme(settings); // tag removed + overrides dropped
} else {
clearPreview(); // the reset landed — the picks are stale
}
}
/* ---------- view boot (phase 91 task 05) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
* header uses (zero extra requests). Anonymous / token-user: the
* gate in, the content out — and NO /api/ui-settings request at all
* (the router 403s them — the #tokens-gate contract). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true;
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
/* The 9 built-in hexes, captured from the color inputs' STATIC
values — at the top of the admin branch, BEFORE the first
loadSettings() below repopulates them with the EFFECTIVE values.
The static values ARE the built-ins (the house contract — the
E2E asserts them against styles.css's :root), so themeRootContent
keeps ONE source for the no-op check: no third hardcoded palette
copy in this file. */
const BUILTINS = {};
for (const f of FIELDS) {
if (f.kind === "color") BUILTINS[f.field] = inputs[f.field].value;
}
/* Bindings — armed BEFORE the first load: a fast owner can start
picking while the GET is still out; the preview writes are
idempotent and the settled load re-populates afterwards. Color
inputs drive the live preview + the contrast re-check; text
inputs drive neither (B4 — the strings apply on the next page
load, the sub-copy says so). */
for (const f of FIELDS) {
const input = inputs[f.field];
if (!input || f.kind !== "color") continue;
input.addEventListener("input", () => {
previewColor(f.field, input.value);
updateContrast();
});
}
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
if (resetBtn) resetBtn.addEventListener("click", () => void resetTheme());
/* Phase 77 (the re-show refresh contract): a user-initiated re-show
of this already-mounted view makes the router dispatch
bor:view-refresh on the section — re-run the load then (the tab
always shows the settled server state when re-shown) and drop the
preview overrides (the page paints the served theme, not a pick
left behind from before the switch). Armed ONLY here, after the
whoami gate passed: anonymous shows the gate and never fetches. */
root.addEventListener("bor:view-refresh", () => {
void loadSettings().then((settings) => {
if (settings) applyServedTheme(settings);
});
});
const settings = await loadSettings(); // the effective values
/* Self-heal: a row changed in another browser since this page loaded
is reflected the moment the admin opens the tab. A normal load is
a no-op in effect — the served tag and the reconciled overrides
agree, so the page never flickers. */
if (settings) applyServedTheme(settings);
}