All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
414 lines
17 KiB
JavaScript
414 lines
17 KiB
JavaScript
/* 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 <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 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 <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. 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 <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 8 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- 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 11 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 true
|
|
when the values are settled. */
|
|
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 false;
|
|
}
|
|
if (!r.ok) {
|
|
showError("Couldn't load the theme — try again.");
|
|
return false;
|
|
}
|
|
let settings;
|
|
try {
|
|
settings = await r.json();
|
|
} catch {
|
|
showError("Couldn't load the theme — try again.");
|
|
return false;
|
|
}
|
|
populate(settings);
|
|
updateContrast();
|
|
return true;
|
|
}
|
|
|
|
/* ---------- 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
|
|
await loadSettings(); // refetch + re-populate (canonical state)
|
|
clearPreview(); // the page paints the served theme, not the pick
|
|
}
|
|
|
|
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
|
|
await loadSettings(); // the env / built-in defaults, re-rendered
|
|
clearPreview(); // the page paints the served theme again
|
|
}
|
|
|
|
/* ---------- 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;
|
|
|
|
/* 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((settled) => {
|
|
if (settled) clearPreview();
|
|
});
|
|
});
|
|
|
|
await loadSettings(); // the effective values — the live theme
|
|
}
|