/* Brain of Reese — Theme view module (phase 91, task 05): 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
, 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 11 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 8 * color pickers the value is written straight onto 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. On every * successful save, on Reset, and on a re-show refresh all 8 * overrides are removed (removeProperty) so the page reflects the * served (injected) theme, never stale preview state. * • Save — the §7.4 never-stale lifecycle: disable + "Saving…" → * PUT /api/ui-settings with the 11 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), * clear the preview overrides, 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 11 values * null (the API's documented "defaults" operation) → #theme-result * "Reset to the built-in theme." → refetch + re-populate (the * env/built-in defaults) + clear the preview overrides. * • 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 clear the preview * overrides (the page paints the served theme, not 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 11 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: "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 as an inline custom property: the * inline style beats the served