phase: 91_admin_theme_tab
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/`.
This commit is contained in:
+20
-64
@@ -32,9 +32,9 @@
|
||||
* 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
|
||||
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||
* settled config also carries the two UI-customization
|
||||
* string 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:
|
||||
@@ -43,30 +43,22 @@
|
||||
* 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.
|
||||
* operator string can't inject markup via textContent).
|
||||
* Phase 91 (task 03): color theming is no longer a brand.js
|
||||
* job — the retired CSS-file theme link (the old step 7) is
|
||||
* deleted with its env var; the SERVER now injects the
|
||||
* effective palette inline before first paint
|
||||
* (app/core/theming.py), so this layer keeps the text swaps
|
||||
* only.
|
||||
* • 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.
|
||||
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) 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) — so an unset deployment renders byte-identical.
|
||||
*/
|
||||
|
||||
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||
@@ -187,11 +179,12 @@ function applyBrand() {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// config also carries the two customization STRING 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). Color theming is
|
||||
// server-side since phase 91 (task 03) — not this layer's job.
|
||||
const placeholder =
|
||||
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
|
||||
if (placeholder) {
|
||||
@@ -214,43 +207,6 @@ function applyBrand() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,13 @@ export async function initSharedHeader() {
|
||||
// pages) is a no-op. A token user (role "user") never sees it.
|
||||
const navTokens = document.querySelector("#nav-tokens");
|
||||
if (navTokens) navTokens.hidden = !admin;
|
||||
// Phase 91 (task 04): the Theme nav link (the shell's seventh view —
|
||||
// the phase-34 one-bar contract ships it on every page's nav) —
|
||||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Tokens link above. Null-safe: a page without the link is a
|
||||
// no-op. A token user (role "user") never sees it.
|
||||
const navTheme = document.querySelector("#nav-theme");
|
||||
if (navTheme) navTheme.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Brain of Reese — shell router (phase 76, task 01).
|
||||
*
|
||||
* The five navbar views are views of ONE HTML shell (index.html), not
|
||||
* five documents: this module makes a navbar click a CLIENT-SIDE view
|
||||
* The seven navbar views are views of ONE HTML shell (index.html), not
|
||||
* seven documents: this module makes a navbar click a CLIENT-SIDE view
|
||||
* switch — history.pushState + show/hide — never a document load, so
|
||||
* the in-flight chat stream in the hidden view keeps streaming
|
||||
* through any switch and completes when the user returns to Chat.
|
||||
@@ -62,8 +62,10 @@
|
||||
* (module — this file). No CDN, no framework, no bundler dependency:
|
||||
* a plain ES module whose dynamic imports (./tuning.js, task 01;
|
||||
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03;
|
||||
* ./tokens.js in phase 79 task 06) resolve relatively in dev and are
|
||||
* inlined by the Containerfile's esbuild stage in the image.
|
||||
* ./tokens.js in phase 79 task 06; ./theme.js in phase 91 task 04 —
|
||||
* the wiring lands there, the editor fills it in task 05) resolve
|
||||
* relatively in dev and are inlined by the Containerfile's esbuild
|
||||
* stage in the image.
|
||||
*/
|
||||
|
||||
/* ---------- the view map (pathname → view name) ----------
|
||||
@@ -79,6 +81,7 @@ const VIEW = {
|
||||
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
||||
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
|
||||
};
|
||||
|
||||
/* The nav-link href the router stamps active for each view (the
|
||||
@@ -90,6 +93,7 @@ const VIEW_PATH = {
|
||||
"git-sources": "/git-sources.html",
|
||||
history: "/history.html",
|
||||
tokens: "/tokens.html",
|
||||
theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only)
|
||||
};
|
||||
|
||||
/* The lazy view modules — ONLY the non-chat views (chat needs no
|
||||
@@ -103,6 +107,7 @@ const VIEW_MODULES = {
|
||||
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
|
||||
history: () => import("./history.js"), // phase 76 task 03
|
||||
tokens: () => import("./tokens.js"), // phase 79 task 06
|
||||
theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor)
|
||||
};
|
||||
|
||||
/* Per-view document.head values, carried over from the old pages'
|
||||
@@ -116,6 +121,7 @@ const TITLES = {
|
||||
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
|
||||
history: "Saved chats · Brain of Reese", // old history.html <title>
|
||||
tokens: "Access tokens · Brain of Reese",
|
||||
theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new
|
||||
};
|
||||
const DESCRIPTIONS = {
|
||||
chat:
|
||||
@@ -128,6 +134,7 @@ const DESCRIPTIONS = {
|
||||
history:
|
||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
||||
tokens: "Generate and revoke the API tokens that let people use the app.",
|
||||
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
|
||||
};
|
||||
|
||||
/* The brand-resolved display name (phase 39 — brand.js is the single
|
||||
|
||||
@@ -2951,6 +2951,185 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
|
||||
The shell's seventh view (#view-theme): the admin palette + branding
|
||||
editor (task 05). The centered 46rem column (the .tuning-shell
|
||||
language — this is a form view, the tuning-view width pattern), the
|
||||
form card (the #tune-form language: surface fill, --line hairline,
|
||||
radius, shadow), the fieldset groups (Branding / Palette) with the
|
||||
.theme-note sub-copy, the 3-column (desktop) / 1-column (mobile)
|
||||
palette grid with the color swatch inputs sized for touch (44px —
|
||||
the label above is the second tap affordance), and the Save/Reset
|
||||
row (Save = the brand pill family, --bg ink on --brand 5.2:1 AA;
|
||||
Reset = the ghost button family, --line border; the §7.4
|
||||
disabled-while-in-flight look is the :disabled pair below). The
|
||||
feedback lines: error/result are the house role=status/alert line
|
||||
languages; #theme-contrast (the WCAG warning) uses the --err-*
|
||||
STATE family — states are semantic colors, never themed from the
|
||||
tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible
|
||||
via the global 3px outline rule. No CDN, system fonts. */
|
||||
.theme-shell {
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* The static form — one card holding the two fieldset groups + the
|
||||
actions row (the #tune-form card language). */
|
||||
#theme-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
}
|
||||
/* The fieldset groups: reset the UA border (the card IS the group's
|
||||
frame), the legend rides the group's title line (the
|
||||
.tuning-panel-title language — ink, 1rem, 700). */
|
||||
.theme-group {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.theme-group-title {
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* The branding text inputs: the #token-label language (surface is
|
||||
already the card fill — transparent field, --line hairline, ≥44px
|
||||
target, the global :focus-visible ring). */
|
||||
#theme-form fieldset label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
#theme-form fieldset input[type="text"] {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
/* The palette note under the branding legend (task 05): the strings
|
||||
apply on the next page load (B4) — the live preview covers the
|
||||
palette only. */
|
||||
.theme-note {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
/* The color-input grid (task 05): the 8 swatches in 3 columns
|
||||
(3 + 3 + 2 rows) — the cell is a .theme-color (label above the
|
||||
swatch); 1 column at the mobile breakpoint (below). */
|
||||
.theme-colors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.6rem 0.9rem;
|
||||
}
|
||||
.theme-color {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.theme-color label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.theme-color input[type="color"] {
|
||||
/* Touch-sized swatch (task 05): 44px tall (the WCAG 2.5.8 target —
|
||||
the label above is the second tap affordance). */
|
||||
inline-size: 56px;
|
||||
block-size: 44px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Save (primary) + Reset (secondary): the pill + ghost families
|
||||
(.history-refresh / .token-revoke languages — the global
|
||||
:focus-visible ring applies, no button-scoped focus override). */
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
#theme-save {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 (never white on brand) */
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
#theme-save:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
|
||||
#theme-save:disabled { opacity: 0.6; cursor: wait; }
|
||||
.theme-reset {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-reset:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
.theme-reset:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* The three feedback lines (task 05): error (role=alert — the
|
||||
server's 422 detail) and result (role=status) are the house line
|
||||
languages; min-height holds the layout so a line never reflows the
|
||||
form. #theme-contrast (the WCAG warning, task 05) takes the
|
||||
.tuning-error box treatment in the --err-* STATE family — states
|
||||
are semantic colors, never themed from the tab (B3): a failing
|
||||
pair reads as a warning and the box is warning-only (Save is never
|
||||
disabled by it). */
|
||||
.theme-error,
|
||||
.theme-result,
|
||||
.theme-contrast {
|
||||
display: block;
|
||||
margin: 0;
|
||||
min-height: 1.2em;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
.theme-error { color: var(--err-ink); }
|
||||
.theme-result { color: var(--ok-ink); }
|
||||
.theme-contrast {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
}
|
||||
|
||||
/* ---------- Shared page (phase 51, task 03) ----------
|
||||
/shared/<token>: the anonymous read-only conversation (owner-locked
|
||||
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
|
||||
@@ -3979,6 +4158,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#token-once-copy { width: 100%; }
|
||||
.tokens-actions-cell { white-space: normal; }
|
||||
.tokens-actions { flex-wrap: wrap; }
|
||||
/* Phase 91 task 05: the Theme form squeezes — the color grid drops
|
||||
to 1 column (the label + swatch pairs keep their ≥44px targets)
|
||||
and the actions row stacks (Save full width, Reset under it). */
|
||||
.theme-colors { grid-template-columns: 1fr; }
|
||||
.theme-actions { flex-direction: column; align-items: stretch; }
|
||||
#theme-save { width: 100%; }
|
||||
.theme-reset { width: 100%; }
|
||||
/* Phase 51: the shared page squeezes like the chat column — the
|
||||
title and the note step down (the empty-state-title family); the
|
||||
shell keeps its base 46rem column (the >=1500px 92rem override
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/* 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
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# Themes — authoring guide (phase 62)
|
||||
|
||||
A theme is a small CSS file that overrides the `:root` palette variables.
|
||||
That is the entire mechanism — no component CSS is theme-aware, every
|
||||
color in the app reads a `--*` variable, so a later stylesheet wins by
|
||||
cascade order.
|
||||
|
||||
## How a theme loads
|
||||
|
||||
1. Set `BOR_THEME=<file>` (a bare FILENAME, e.g. `BOR_THEME=indigo.css`).
|
||||
`app/config.py` validates it at startup — anything not matching
|
||||
`^[a-z0-9_-]+\.css$` (a path, `..`, uppercase, a missing extension)
|
||||
refuses to boot, naming the value (the phase-56 fail-loud house
|
||||
style).
|
||||
2. The value rides the existing boot fetch: `GET /api/config` →
|
||||
`frontend/assets/brand.js` inserts
|
||||
`<link rel="stylesheet" href="/assets/themes/<file>">` IMMEDIATELY
|
||||
AFTER the `styles.css` link — later wins the cascade.
|
||||
3. A theme file MISSING at runtime (typo past the validator, or the file
|
||||
deleted after the image was built) degrades to the built-in theme —
|
||||
`brand.js` warns in the console, the page never breaks (the
|
||||
loadHealth house style, A5).
|
||||
4. UNSET (`BOR_THEME` empty) ⇒ no link is inserted at all — the
|
||||
deployment renders byte-identical to the built-in dark-tech palette.
|
||||
Loading is opt-in via the env var, never by directory scanning.
|
||||
|
||||
## The variables
|
||||
|
||||
A theme overrides the **8 identity variables** in a single `:root` block.
|
||||
Built-in values (from `frontend/assets/styles.css`) for reference:
|
||||
|
||||
| Variable | Built-in | Role |
|
||||
| -------------- | ---------- | ----------------------------------------------------------- |
|
||||
| `--bg` | `#0f0a0a` | page background (text on it: `--ink`) |
|
||||
| `--surface` | `#1a0f0f` | cards, panels, code blocks (text on it: `--ink`) |
|
||||
| `--ink` | `#f0e6e6` | primary text |
|
||||
| `--ink-soft` | `#b8a8a8` | secondary text (5.1:1 on `--surface`) |
|
||||
| `--line` | `#2d1a1a` | decorative 1px borders (no contrast obligation) |
|
||||
| `--brand` | `#f43f5e` | brand accent — buttons, links (text ON it is `--bg`) |
|
||||
| `--brand-soft` | `#2d0a0a` | brand-tinted surface (chips, hover washes) |
|
||||
| `--brand-ink` | `#fca5a5` | brand-tinted text (9.0:1 on `--surface`) |
|
||||
|
||||
The **semantic families are deliberately NOT identity** — do not
|
||||
override them: `--accent-*` (deflection amber), `--ok-*` (success
|
||||
green), `--err-*` (error red) encode *states*, and they are already AA
|
||||
in the built-in theme. A theme that keeps them stays honest: your
|
||||
indigo app still tells success from error.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Filename:** `^[a-z0-9_-]+\.css$` — lowercase, bare filename, in this
|
||||
directory. The server validator rejects anything else at startup
|
||||
(naming the value), so keep the env var and the filename in lockstep.
|
||||
- **One `:root` block.** No selectors, no `@media`, no other
|
||||
declarations — the file overrides variables and nothing else (the
|
||||
cascade does the rest). `indigo.css` is the reference shape.
|
||||
- **Every text/background pair ≥ 4.5:1** (AGENTS.md rule 5, WCAG 2.1
|
||||
AA). The pairs that matter: `--ink` on `--bg` and on `--surface`,
|
||||
`--ink-soft` on `--surface`, `--bg` on `--brand` (the text on brand
|
||||
buttons is the DARK background ink — that is the pattern), and
|
||||
`--brand-ink` on `--surface`.
|
||||
- **Never white-on-brand.** The built-in documents the trap: white on
|
||||
`#f43f5e` is 3.7:1 — it fails. Pick a `--brand` whose luminance
|
||||
carries the dark `--bg` ink at ≥ 4.5:1 (indigo.css: 6.5:1).
|
||||
- Keep `--line` close to `--surface` (a 1px step, not a wall) — the
|
||||
layout reads by surfaces, not borders.
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Dev:** works immediately — the file is served from the static dir
|
||||
(`frontend/`, `BOR_STATIC_DIR`), so drop the file in, set
|
||||
`BOR_THEME`, restart uvicorn.
|
||||
- **Container:** rebuild the image. Stage 1 ships the WHOLE directory
|
||||
(`cp -r ./assets/themes /out/assets/themes` — no per-file esbuild), so
|
||||
a new or edited theme file needs **no Containerfile change** (A7):
|
||||
whatever is in `frontend/assets/themes/` at build time is what the
|
||||
image serves at `/assets/themes/…`.
|
||||
@@ -1,17 +0,0 @@
|
||||
/* Phase 62 example theme — dark indigo/slate.
|
||||
Overrides the :root identity palette from styles.css; every
|
||||
text/background pair meets WCAG 2.1 AA (>= 4.5:1):
|
||||
ink on bg 15.8:1 · ink on surface 14.7:1 · ink-soft on surface 8.3:1
|
||||
dark bg ink on brand 6.5:1 · brand-ink on surface 12.0:1.
|
||||
Semantic families (accent/ok/err) inherit the built-in theme.
|
||||
Load with BOR_THEME=indigo.css (authoring guide: themes/README.md). */
|
||||
:root {
|
||||
--bg: #0a0e1a;
|
||||
--surface: #111726;
|
||||
--ink: #e6e9f0;
|
||||
--ink-soft: #a8b0c8;
|
||||
--line: #232c44;
|
||||
--brand: #818cf8;
|
||||
--brand-soft: #1a1f38;
|
||||
--brand-ink: #c7d2fe;
|
||||
}
|
||||
Reference in New Issue
Block a user