phase: 92_theme_save_and_coverage
**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix. - Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated. - `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%) - `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site) - `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** - Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions. - Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04). - Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
+185
-37
@@ -1,5 +1,5 @@
|
||||
/* Brain of Reese — Theme view module (phase 91, task 05): the admin
|
||||
* palette + branding editor.
|
||||
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
|
||||
* task 04): the admin palette + branding editor.
|
||||
*
|
||||
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
|
||||
* shape): the router (assets/router.js) lazy-imports this module on
|
||||
@@ -17,38 +17,71 @@
|
||||
* 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
|
||||
* • load — GET /api/ui-settings → populate the 12 inputs with the
|
||||
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
|
||||
* merge): the tab always shows the live theme — env defaults when
|
||||
* the row is empty. A failed fetch keeps the static form (the
|
||||
* built-in values ship in the inputs) and shows #theme-error with
|
||||
* a retry (the loadHealth house style — never a blanked panel).
|
||||
* • live preview (colors only, B4) — on `input` of any of the 8
|
||||
* • live preview (colors only, B4) — on `input` of any of the 9
|
||||
* color pickers the value is written straight onto <html> as an
|
||||
* inline custom property, so the WHOLE page repaints (every view,
|
||||
* the header) while the owner is picking. Text fields have NO page
|
||||
* effect: the 3 strings keep the brand.js runtime application
|
||||
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
||||
* the NEXT page load, and the sub-copy says so. 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.
|
||||
* the NEXT page load, and the sub-copy says so.
|
||||
* • served-theme sync (phase 92, defect 1) — the phase-91 defect:
|
||||
* Save/Reset removed the preview overrides, and the page then
|
||||
* fell back to the <style id="bor-theme"> tag baked into THIS
|
||||
* document at PAGE LOAD — i.e. the PREVIOUS theme — so the owner
|
||||
* had to reload to see what they just saved. The fix: after every
|
||||
* SETTLED read of the effective values (Save, Reset, re-show,
|
||||
* initial mount) applyServedTheme() reconciles the OPEN document
|
||||
* to those values in two halves. (1) The #bor-theme tag's DOM
|
||||
* text — themeRootContent is byte-identical to the INNER content
|
||||
* of app.core.theming.theme_style_tag (the tag is removed when
|
||||
* the palette is the built-in one — the server's no-op case) — so
|
||||
* the document mirrors what the next load serves. (2) The 9
|
||||
* identity variables as inline custom properties on <html> (CSSOM
|
||||
* setProperty / removeProperty — the live preview's mechanism) —
|
||||
* THIS half is what repaints the open page, because Chromium
|
||||
* re-checks a <style> element's content against style-src on
|
||||
* EVERY DOM-API content change (verified E2E against this repo's
|
||||
* phase-82/91 CSP: textContent on the served tag, createElement +
|
||||
* appendChild, and replaceChildren are all blocked unless the new
|
||||
* content's sha256 is in the page's policy — which a fresh
|
||||
* palette can never be, since the header hashed what was served
|
||||
* at load). The reconcile removes every override that equals its
|
||||
* built-in, so <html>'s style holds exactly the settled
|
||||
* non-default values (empty for a built-in palette) and never a
|
||||
* stale pick. The initial mount self-heals too: a row changed in
|
||||
* another browser since this page loaded is reflected the moment
|
||||
* the admin opens the tab (a normal load is a no-op — the served
|
||||
* tag and the overrides agree).
|
||||
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
|
||||
* PUT /api/ui-settings with the 11 form values (a cleared/empty
|
||||
* PUT /api/ui-settings with the 12 form values (a cleared/empty
|
||||
* text field → null; colors always their current hex — the
|
||||
* server's built-in→NULL normalization keeps the row empty when
|
||||
* the owner saves the defaults) → 200: #theme-result "Theme
|
||||
* saved." (role=status), refetch + re-populate (canonical state),
|
||||
* 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
|
||||
* reconcile the open document to the settled values (#bor-theme
|
||||
* text + the <html> overrides — the OPEN page paints the saved
|
||||
* palette, no reload; a failed refetch keeps the current
|
||||
* overrides, which ARE the saved values — the PUT body came from
|
||||
* these very inputs), re-check the contrast pairs → re-enable +
|
||||
* restore the label
|
||||
* (the finally — a click can never leave a button stuck). 422:
|
||||
* #theme-error carries the SERVER detail (it names the offending
|
||||
* field), the form is KEPT (the owner fixes + retries); any other
|
||||
* non-2xx: the fixed error line; a network error: the "is the app
|
||||
* reachable?" line.
|
||||
* • Reset — the same lifecycle ("Resetting…") with all 12 values
|
||||
* null (the API's documented "defaults" operation) → #theme-result
|
||||
* "Reset to the built-in theme." → refetch + re-populate (the
|
||||
* env/built-in defaults) + clear the preview overrides.
|
||||
* env/built-in defaults) → reconcile the open document: the tag
|
||||
* is REMOVED (effective = the built-ins → content null) and the
|
||||
* <html> overrides are dropped (a failed refetch still drops
|
||||
* them — the picks are stale once the reset landed).
|
||||
* • WCAG contrast (the 00_phase design's five pairs — the pairs the
|
||||
* layout actually pairs, see app/core/theming.py's docstring):
|
||||
* ink on bg, ink on surface, ink-soft on surface, bg on brand
|
||||
@@ -64,10 +97,12 @@
|
||||
* • 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.
|
||||
* 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).
|
||||
@@ -88,7 +123,7 @@ export async function mount(root) {
|
||||
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 12 form fields, in the form's order: `field` is the API key
|
||||
(the input's name attribute), `id` the E2E-stable element id,
|
||||
`kind` how the value is read for a PUT — a string field that is
|
||||
empty after the trim sends null (the server stores NULL = "use
|
||||
@@ -104,6 +139,7 @@ export async function mount(root) {
|
||||
{ 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" },
|
||||
@@ -232,7 +268,7 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Drop all 8 preview overrides so the page paints the served
|
||||
/* Drop all 9 preview overrides so the page paints the served
|
||||
(injected) theme — the "never stale" half of the contract: after
|
||||
a save / reset / re-show the page shows what the server serves,
|
||||
not a pick that was never (or no longer) saved. */
|
||||
@@ -244,6 +280,91 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- served-theme sync (phase 92, defect 1) ----------
|
||||
* The phase-91 defect: after a Save/Reset the preview overrides
|
||||
* were removed and the page fell back to the <style id="bor-theme">
|
||||
* tag baked into THIS document at PAGE LOAD — the PREVIOUS theme —
|
||||
* so the owner had to reload to see the saved palette. The fix
|
||||
* reconciles the OPEN document to the settled effective values in
|
||||
* two halves: the #bor-theme tag's DOM text (what the next load
|
||||
* would serve) and the 9 identity variables as inline custom
|
||||
* properties on <html> (what repaints the page NOW — see
|
||||
* applyServedTheme's CSP note). */
|
||||
|
||||
/* The :root string the server would inject on the NEXT load for
|
||||
these effective values. null when every color field equals its
|
||||
captured BUILTINS value — the server's no-op case (no tag served,
|
||||
none to keep). Otherwise all 9 colors in FIELDS order (== the
|
||||
server's COLOR_FIELDS order) — byte-identical to the INNER
|
||||
content of app.core.theming.theme_style_tag's tag (lowercased hex
|
||||
from the resolver), so a saved theme never jumps between the
|
||||
client view and a fresh load. Pure: input → string, no DOM. */
|
||||
function themeRootContent(colors) {
|
||||
for (const f of FIELDS) {
|
||||
if (f.kind !== "color" || colors[f.field] === BUILTINS[f.field]) continue;
|
||||
const declarations = FIELDS.filter((g) => g.kind === "color")
|
||||
.map((g) => `--${g.field.replace(/_/g, "-")}:${colors[g.field]};`)
|
||||
.join("");
|
||||
return `:root{${declarations}}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* The <html> override half — the ONLY CSP-clean way to paint a
|
||||
palette this page's CSP header has not hashed in: CSSOM
|
||||
setProperty / removeProperty on the EXISTING <html> style (the
|
||||
live preview's mechanism — an un-checked CSSOM mutation, verified
|
||||
E2E under both the plain A1 and the themed 'self' + sha256
|
||||
policies). setProperty for every effective color that differs
|
||||
from its built-in, removeProperty for the built-in ones — the
|
||||
attribute therefore holds exactly the settled non-default values
|
||||
(empty for a built-in palette) and never a stale pick. */
|
||||
function applyInlineOverrides(effective) {
|
||||
for (const f of FIELDS) {
|
||||
if (f.kind !== "color") continue;
|
||||
const value = effective[f.field];
|
||||
if (value && value !== BUILTINS[f.field]) {
|
||||
document.documentElement.style.setProperty(cssVar(f.field), value);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty(cssVar(f.field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Reconcile the open document to the settled effective values
|
||||
(phase 92, defect 1). DOM-text half: content null → remove the
|
||||
tag; no tag → create it (createElement + textContent only — never
|
||||
innerHTML); tag present → update only when the content differs.
|
||||
Paint half: the <html> overrides (above). CSP (phase 82/91 — A1
|
||||
+ the served tag's sha256, no 'unsafe-inline'): Chromium
|
||||
re-checks a <style> element's content against style-src on EVERY
|
||||
DOM-API content change — textContent on the served tag,
|
||||
createElement + textContent + appendChild, replaceChildren, even
|
||||
insert-empty-then-set are all BLOCKED unless the new content's
|
||||
sha256 is in the page's policy (verified E2E — the phase-92 task
|
||||
04 probe). A freshly-saved palette can never be in the policy
|
||||
(the header hashed the content served at load), so the tag's new
|
||||
text is visually inert until a reload — which serves matching
|
||||
content + hash; the <html> overrides are what repaint the open
|
||||
page. The DOM text is still synced so the open document mirrors
|
||||
what the next load serves (and the no-op case keeps the document
|
||||
tag-free, like the served HTML). */
|
||||
function applyServedTheme(effective) {
|
||||
const content = themeRootContent(effective);
|
||||
const el = document.getElementById("bor-theme");
|
||||
if (content === null) {
|
||||
if (el) el.remove();
|
||||
} else if (el === null) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "bor-theme";
|
||||
style.textContent = content;
|
||||
document.head.appendChild(style);
|
||||
} else if (el.textContent !== content) {
|
||||
el.textContent = content;
|
||||
}
|
||||
applyInlineOverrides(effective);
|
||||
}
|
||||
|
||||
/* ---------- load / populate (effective values) ---------- */
|
||||
|
||||
function populate(settings) {
|
||||
@@ -254,13 +375,14 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* GET /api/ui-settings → populate the 11 inputs with the EFFECTIVE
|
||||
/* GET /api/ui-settings → populate the 12 inputs with the EFFECTIVE
|
||||
values (the tab always shows the live theme — env defaults when
|
||||
the row is empty) and re-check the five pairs (a SAVED palette
|
||||
can itself fail AA — the warning then tracks it). A failed fetch
|
||||
keeps the static form + shows #theme-error with a retry (the
|
||||
loadHealth house style — never a blanked panel). Returns true
|
||||
when the values are settled. */
|
||||
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;
|
||||
@@ -268,22 +390,22 @@ export async function mount(root) {
|
||||
r = await fetch("/api/ui-settings");
|
||||
} catch {
|
||||
showError("Couldn't load the theme — is the app reachable?");
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showError("Couldn't load the theme — try again.");
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
let settings;
|
||||
try {
|
||||
settings = await r.json();
|
||||
} catch {
|
||||
showError("Couldn't load the theme — try again.");
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
populate(settings);
|
||||
updateContrast();
|
||||
return true;
|
||||
return settings;
|
||||
}
|
||||
|
||||
/* ---------- the PUT (Save + Reset share it) ---------- */
|
||||
@@ -347,8 +469,13 @@ export async function mount(root) {
|
||||
return;
|
||||
}
|
||||
showResult("Theme saved."); // role=status
|
||||
await loadSettings(); // refetch + re-populate (canonical state)
|
||||
clearPreview(); // the page paints the served theme, not the pick
|
||||
const settings = await loadSettings(); // refetch + re-populate
|
||||
if (settings) {
|
||||
applyServedTheme(settings); // reconcile the open document
|
||||
}
|
||||
/* A failed refetch keeps the current <html> overrides on purpose:
|
||||
the PUT body came from these very inputs, so they ARE the saved
|
||||
palette — never revert onto the stale tag (the phase-91 defect). */
|
||||
}
|
||||
|
||||
async function resetTheme() {
|
||||
@@ -360,8 +487,12 @@ export async function mount(root) {
|
||||
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
|
||||
const settings = await loadSettings(); // the env / built-in defaults
|
||||
if (settings) {
|
||||
applyServedTheme(settings); // tag removed + overrides dropped
|
||||
} else {
|
||||
clearPreview(); // the reset landed — the picks are stale
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- view boot (phase 91 task 05) ----------
|
||||
@@ -379,6 +510,18 @@ export async function mount(root) {
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
|
||||
/* The 9 built-in hexes, captured from the color inputs' STATIC
|
||||
values — at the top of the admin branch, BEFORE the first
|
||||
loadSettings() below repopulates them with the EFFECTIVE values.
|
||||
The static values ARE the built-ins (the house contract — the
|
||||
E2E asserts them against styles.css's :root), so themeRootContent
|
||||
keeps ONE source for the no-op check: no third hardcoded palette
|
||||
copy in this file. */
|
||||
const BUILTINS = {};
|
||||
for (const f of FIELDS) {
|
||||
if (f.kind === "color") BUILTINS[f.field] = inputs[f.field].value;
|
||||
}
|
||||
|
||||
/* Bindings — armed BEFORE the first load: a fast owner can start
|
||||
picking while the GET is still out; the preview writes are
|
||||
idempotent and the settled load re-populates afterwards. Color
|
||||
@@ -404,10 +547,15 @@ export async function mount(root) {
|
||||
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();
|
||||
void loadSettings().then((settings) => {
|
||||
if (settings) applyServedTheme(settings);
|
||||
});
|
||||
});
|
||||
|
||||
await loadSettings(); // the effective values — the live theme
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user