All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
753 lines
35 KiB
JavaScript
753 lines
35 KiB
JavaScript
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
|
|
* task 04; COLORS ONLY since phase 190, task 02): the admin palette
|
|
* editor — the three brand strings moved to the admin Settings panel
|
|
* (A1: that panel is their single writable owner).
|
|
*
|
|
* 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 17 color inputs with
|
|
* the EFFECTIVE values (the resolver's DB-over-env /
|
|
* DB-over-built-in merge): the tab always shows the live theme —
|
|
* the built-in palette 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). The response still carries the 3 brand
|
|
* strings (read-only, old-client compat) — populate() simply has no
|
|
* field to write them into.
|
|
* • live preview (colors only) — on `input` of any of the 17 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. The brand strings are not
|
|
* this tab's surface at all since phase 190 (A1) — the Settings
|
|
* panel edits them (and, from phase 190 task 03, applies them to
|
|
* the open document on save).
|
|
* • favicon live preview (phase 177, A4 — owner-confirmed
|
|
* 2026-10-13) — a pick of one of the THREE driving fields
|
|
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
|
|
* color roles: hexagon fill, hexagon stroke + center dot,
|
|
* 4 spokes) ALSO repaints the tab icon: the
|
|
* ``link[rel="icon"]`` href is set to
|
|
* ``themeFaviconDataUri(...)`` — a ``data:image/svg+xml,``
|
|
* URI (``encodeURIComponent``-ed) whose SVG is the
|
|
* CLIENT-SIDE MIRROR of ``app.core.theming.favicon_svg``
|
|
* (the same artwork, the same roles —
|
|
* ``tests/unit/test_theme_frontend.py`` keeps the two from
|
|
* diverging: the artwork constants are pinned against the
|
|
* server source, the ``PAIRS``-mirror house pattern).
|
|
* Rebuilt, not patched: a ``surface`` pick repaints with the
|
|
* CURRENT ``brand`` / ``brand_ink`` picks (the helper reads
|
|
* the three inputs' values). The ``isHex`` guard (belt-
|
|
* and-braces — the color inputs are always valid hex, the
|
|
* inputs are the ONLY source of the values, so there is no
|
|
* XSS path; the house "never build HTML" rule binds the DOM,
|
|
* the URI is built from constants + validated hexes) returns
|
|
* ``null`` on a non-hex input and the link is left
|
|
* untouched. The other 14 picks never touch the link.
|
|
* RESTORE TO THE ROUTE — the settled palette always wins: on
|
|
* every SETTLED read (Save success, after the
|
|
* ``applyServedTheme`` reconcile; Reset success, after the
|
|
* reconcile; the Reset-failure ``clearPreview()`` path — the
|
|
* picks are stale; the ``bor:view-refresh`` re-show, after
|
|
* its ``applyServedTheme``) the href goes back to
|
|
* ``"/favicon.svg"`` — the route re-reads the DB per request
|
|
* (NO process cache), so the restored URL is always fresh,
|
|
* and the page is ``no-cache``, so the browser revalidates
|
|
* the favicon with it. CSP (phase 123, per D4): ``data:`` is
|
|
* legal for the favicon via the ``img-src 'self' data:``
|
|
* carve-out — CSP3 governs ``link[rel=icon]`` by
|
|
* ``img-src``, and the carve-out is image-scoped (``data:``
|
|
* never becomes a script/style/fetch source).
|
|
* • 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 17
|
|
* palette 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 17 form colors (always their
|
|
* current hex — the server's built-in→NULL normalization keeps the
|
|
* row empty when the owner saves the defaults; NO string key in
|
|
* the body, phase 190 A1) → 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 17 colors
|
|
* null (the API's documented "defaults" operation for the palette;
|
|
* a brand string is reset from the Settings panel, phase 190 A1) →
|
|
* #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).
|
|
* • the live brand re-apply (phase 190, task 03 — A2) — after BOTH
|
|
* settle paths (Save success and Reset success) the open document
|
|
* re-reads `GET /api/config` through `window.applyBrandFromConfig`,
|
|
* the ONE brand pass brand.js owns and the boot runs (this file
|
|
* never writes the title / brand / footer / composer itself). The tab
|
|
* no longer EDITS the strings (A1) and its PUT never writes those
|
|
* columns — so the re-read is the SELF-HEAL half: whatever the server
|
|
* resolves (panel row → the legacy tab row → env) is what the open
|
|
* page shows the moment the settle lands, including a name the panel
|
|
* changed while this tab was open. One request at a time; a failed
|
|
* re-apply leaves the document as it was (never a blanked brand).
|
|
* • WCAG contrast (the phase-93 design's EIGHT pairs — the pairs
|
|
* the layout actually pairs, see app/core/theming.py's docstring —
|
|
* this file's PAIRS is its client-side mirror, and the two must
|
|
* never diverge; tests/unit/test_theming.py pins the mirror):
|
|
* the five identity pairs (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)
|
|
* plus the three semantic ink-on-bg pairs (phase 93 — the two
|
|
* *_line borders are decorative, no contrast duty): ok-ink on
|
|
* ok-bg, err-ink on err-bg, accent-ink on accent-bg. 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");
|
|
/* The tab icon's link (a document-level element, not in the
|
|
view — the favicon live preview writes its href; phase 177,
|
|
A4). */
|
|
const faviconLink = document.querySelector('link[rel="icon"]');
|
|
|
|
const SAVE_LABEL = "Save theme";
|
|
const RESET_LABEL = "Reset to defaults";
|
|
|
|
/* The 17 color 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 color field always
|
|
sends its current #rrggbb (the server's built-in→NULL
|
|
normalization keeps the row empty when the owner saves the
|
|
defaults). THE 3 BRAND STRINGS ARE NOT HERE (phase 190, A1 —
|
|
owner-confirmed 2026-10-18): the admin Settings panel is the
|
|
single writable owner of app_name / input_placeholder /
|
|
footer_text, so the tab renders no string input and its PUT body
|
|
carries no string key — a Theme-tab save can never re-bake a name
|
|
and shadow the panel (the TODO.md L3 incident). The 17 color
|
|
fields follow the server's theming.COLOR_FIELDS order (the 9
|
|
identity variables, then the 8 semantic state variables — phase
|
|
93), so everything FIELDS-driven (live preview, collectBody,
|
|
applyServedTheme's tag content) stays in COLOR_FIELDS order. */
|
|
const FIELDS = [
|
|
{ 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" },
|
|
/* The 8 semantic state colors (phase 93 — B3 revised): success,
|
|
error, and notice. The *_line fields are decorative borders —
|
|
no contrast duty (not in PAIRS, like line / grid_line). */
|
|
{ field: "ok_bg", id: "theme-ok-bg", kind: "color" },
|
|
{ field: "ok_ink", id: "theme-ok-ink", kind: "color" },
|
|
{ field: "err_bg", id: "theme-err-bg", kind: "color" },
|
|
{ field: "err_ink", id: "theme-err-ink", kind: "color" },
|
|
{ field: "err_line", id: "theme-err-line", kind: "color" },
|
|
{ field: "accent_bg", id: "theme-accent-bg", kind: "color" },
|
|
{ field: "accent_ink", id: "theme-accent-ink", kind: "color" },
|
|
{ field: "accent_line", id: "theme-accent-line", 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 eight 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 eight
|
|
* pairs (foreground, background) are exactly the ones the layout
|
|
* pairs — app/core/theming.py's docstring is the authoritative
|
|
* table; PAIRS is its client-side mirror (never diverge —
|
|
* tests/unit/test_theming.py pins it). */
|
|
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"],
|
|
/* The three semantic ink-on-bg pairs (phase 93) — the two
|
|
*_line borders stay excluded (decorative, no contrast duty). */
|
|
["ok_ink", "ok_bg"],
|
|
["err_ink", "err_bg"],
|
|
["accent_ink", "accent_bg"],
|
|
];
|
|
|
|
/* Re-evaluate the eight 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). Only the 17 color pickers bind here — the tab has no
|
|
* string input at all since phase 190 (A1). */
|
|
function previewColor(field, value) {
|
|
if (value) {
|
|
document.documentElement.style.setProperty(cssVar(field), value);
|
|
} else {
|
|
document.documentElement.style.removeProperty(cssVar(field));
|
|
}
|
|
}
|
|
|
|
/* Drop all 17 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- favicon live preview (phase 177 — A4) ----------
|
|
* The tab icon is the CLIENT-SIDE MIRROR of the server's
|
|
* favicon: app/core/theming.py's favicon_svg is the
|
|
* authoritative renderer — the SAME artwork (a hexagon +
|
|
* center dot + 4 spokes, viewBox 0 0 64 64), the SAME three
|
|
* color roles (hexagon fill ← surface, hexagon stroke +
|
|
* circle fill ← brand, spokes stroke ← brand_ink). The two
|
|
* must never diverge: tests/unit/test_theme_frontend.py
|
|
* pins this file's artwork constants (the viewBox, both path
|
|
* d-strings, the circle r) against the server's source (the
|
|
* PAIRS-mirror house pattern). CSP (phase 123, per D4):
|
|
* data: is legal for the favicon via the img-src 'self'
|
|
* data: carve-out — CSP3 governs link[rel="icon"] by
|
|
* img-src, and the carve-out is image-scoped (data: never
|
|
* becomes a script / style / fetch source). */
|
|
const FAVICON_DRIVERS = ["surface", "brand", "brand_ink"];
|
|
|
|
/* The data: URI for the three driving values — the pure half
|
|
of the favicon live preview. The isHex guard (belt-and-
|
|
braces: the color inputs are always valid hex, the inputs
|
|
are the ONLY source of the values, so there is no XSS path
|
|
— the house "never build HTML" rule binds the DOM, the URI
|
|
is built from constants + validated hexes) returns null on
|
|
a non-hex input: the link is then left untouched. */
|
|
function themeFaviconDataUri(surface, brand, brandInk) {
|
|
if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) return null;
|
|
const svg =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
+ '<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="' + surface
|
|
+ '" stroke="' + brand + '" stroke-width="4" stroke-linejoin="round"/>'
|
|
+ '<circle cx="32" cy="32" r="6.5" fill="' + brand + '"/>'
|
|
+ '<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="'
|
|
+ brandInk + '" stroke-width="3" stroke-linecap="round"/>'
|
|
+ "</svg>";
|
|
return "data:image/svg+xml," + encodeURIComponent(svg);
|
|
}
|
|
|
|
/* The settled palette always wins (A4): after every SETTLED
|
|
read (Save success, Reset success, the Reset-failure
|
|
clearPreview path, the bor:view-refresh re-show) the href
|
|
goes back to the route — which re-reads the DB per request
|
|
(NO process cache, so it is always fresh) and whose
|
|
no-cache makes the browser revalidate the favicon with it. */
|
|
function restoreFavicon() {
|
|
if (faviconLink) faviconLink.href = "/favicon.svg";
|
|
}
|
|
|
|
/* ---------- 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 17 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 17 color inputs with the
|
|
EFFECTIVE values (the tab always shows the live theme — the
|
|
built-in palette when the row is empty) and re-check the eight
|
|
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() {
|
|
/* The 17 colors ONLY (the 9 identity + the 8 semantic state) — no
|
|
string key ever rides the wire (phase 190, A1: the Settings
|
|
panel owns the brand strings, and the server ignores them in
|
|
this body anyway — an old cached client can no longer re-bake
|
|
one). */
|
|
const body = {};
|
|
for (const f of FIELDS) {
|
|
const input = inputs[f.field];
|
|
const value = input ? input.value : "";
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
|
|
* The reconcile above owns the COLORS; the three brand STRINGS are the
|
|
* Settings panel's (A1), and this tab's PUT never writes those columns.
|
|
* The re-read is still owed: the effective string can move under an
|
|
* open tab (the panel saved or reset one, another browser changed the
|
|
* row), and every settle is the moment the document is re-branded to
|
|
* what the server actually resolves. Both settle paths therefore run the
|
|
* SAME shared
|
|
* pass brand.js owns and the boot runs:
|
|
* fetch("/api/config") → window.applyBrandFromConfig(cfg). One code
|
|
* path for Save and Reset (the PUT body difference is the colors), one
|
|
* request at a time (§7.4 — a second settle never stacks a second
|
|
* fetch), and NEVER a blanked brand: a non-2xx or an unreachable app
|
|
* returns without touching the DOM, leaving the last applied name,
|
|
* footer and placeholder (the save's own status / error line is the
|
|
* visibility). */
|
|
let brandReapplyInFlight = false;
|
|
|
|
async function reapplyBrand() {
|
|
if (brandReapplyInFlight) return;
|
|
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
|
|
brandReapplyInFlight = true;
|
|
try {
|
|
const r = await fetch("/api/config", { cache: "no-store" });
|
|
if (!r.ok) return; // the brand keeps its last applied state
|
|
window.applyBrandFromConfig(await r.json());
|
|
} catch {
|
|
/* unreachable app or a non-JSON body — nothing is written */
|
|
} finally {
|
|
brandReapplyInFlight = false;
|
|
}
|
|
}
|
|
|
|
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
|
|
restoreFavicon(); // the settled palette governs the tab icon
|
|
}
|
|
/* 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). */
|
|
await reapplyBrand(); // the strings ride the same settle (task 03)
|
|
}
|
|
|
|
async function resetTheme() {
|
|
const body = {};
|
|
for (const f of FIELDS) body[f.field] = null; // 17 nulls = 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
|
|
restoreFavicon(); // the settled palette governs the tab icon
|
|
} else {
|
|
clearPreview(); // the reset landed — the picks are stale
|
|
restoreFavicon(); // stale picks — back to the route URL
|
|
}
|
|
/* The palette row is back to the defaults — and the open document
|
|
re-reads the effective brand too, so a string the panel moved
|
|
while this tab was open paints now, not on the next load
|
|
(phase 190, task 03). */
|
|
await reapplyBrand();
|
|
}
|
|
|
|
/* ---------- 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 17 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. The 17
|
|
color inputs drive the live preview + the contrast re-check (the
|
|
brand strings are the Settings panel's surface, phase 190 A1). */
|
|
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();
|
|
/* The favicon live preview (phase 177 — A4): ONLY the
|
|
three driving fields repaint the tab icon; the other 14
|
|
picks never touch the link. Rebuilt, not patched — the
|
|
helper reads the three inputs' CURRENT values, so a
|
|
surface pick repaints with the current brand /
|
|
brand_ink picks. A null URI (a non-hex value,
|
|
defensive) leaves the link untouched. */
|
|
if (FAVICON_DRIVERS.includes(f.field)) {
|
|
const uri = themeFaviconDataUri(
|
|
inputs.surface.value,
|
|
inputs.brand.value,
|
|
inputs.brand_ink.value,
|
|
);
|
|
if (uri && faviconLink) faviconLink.href = uri;
|
|
}
|
|
});
|
|
}
|
|
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);
|
|
restoreFavicon(); // the settled palette governs the tab icon
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|