phase: 92_theme_save_and_coverage
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**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:
2026-09-10 00:23:08 -04:00
parent d22d260b8b
commit df91c6316c
49 changed files with 2282 additions and 189 deletions
+97 -63
View File
@@ -11,10 +11,21 @@
--ink: #f0e6e6;
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
--line: #2d1a1a; /* decorative 1px borders */
--grid-line: #4a2626; /* background grid texture — decorative, no
contrast duty (like --line) */
--brand: #f43f5e; /* text on brand is DARK ink (--bg): 5.2:1 —
never white on brand (3.7:1, fails) */
--brand-soft: #2d0a0a;
--brand-ink: #fca5a5; /* 9.0:1 on --surface, 12.4:1 on --brand-soft */
/* Derived state colors (phase 92, task 02) — COMPUTED in :root from
the identity palette via color-mix(): NOT stored, NOT tab-controlled,
NOT in BUILTIN_COLORS (theming.py). Every state follows the theme;
the built-in defaults reproduce the pre-phase-92 look, or are the
deliberate legacy-indigo fixes (hovers, busy). */
--brand-hover: color-mix(in srgb, var(--brand) 86%, white);
--brand-busy: color-mix(in srgb, var(--brand) 40%, white);
--brand-stop: color-mix(in srgb, var(--brand) 75%, var(--bg));
--accent-bg: #2b2110;
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
@@ -74,12 +85,15 @@ body {
static: the 44px grid texture below — zero animation cost, zero JS,
no filter/blur. */
/* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked
/* Static grid texture: 44px cells, 1px lines at 60% --grid-line, masked
with a widened radial fade (visible across most of the viewport,
fading to the corners). Phase 25 (owner 2026-08-25): the grid drift is
REMOVED — the 0.73px/s sub-pixel drift rasterizes as a once-per-second
down-right jitter, and the owner wants no movement. The grid stays as
a still texture. */
a still texture. Phase 92 (task 02): the line color is the 9th
identity variable --grid-line (the built-in at 60% alpha — the tab's
"Grid lines" picker repaints this texture; it was a hardcoded
literal before). */
body::before {
content: "";
position: fixed;
@@ -87,8 +101,8 @@ body::before {
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px),
linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px);
linear-gradient(to right, color-mix(in srgb, var(--grid-line) 60%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--grid-line) 60%, transparent) 1px, transparent 1px);
background-size: 44px 44px;
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
@@ -136,7 +150,7 @@ body::before {
}
::selection {
background: rgb(244 63 94 / 0.45);
background: color-mix(in srgb, var(--brand) 45%, transparent);
color: var(--ink);
}
@@ -154,11 +168,14 @@ body::before {
(e.g. Sources at ≤640px). */
flex-shrink: 0;
}
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
shared by the app header and the document-viewer header, phase 10).
In the viewer's two-row header (phase 34) this lands at the BOTTOM
edge of the whole header — row 1's own copy is suppressed there (see
the .doc-header rules below). */
/* 2px brand-fade hairline under the sticky header (phase 08; shared by
the app header and the document-viewer header, phase 10). Phase 92
(task 02): the pre-theme rose→orange→amber art direction retires —
the 2px line now fades from the brand (55% → 30% → 5% of --brand)
and follows the theme (deliberate default change). In the viewer's
two-row header (phase 34) this lands at the BOTTOM edge of the whole
header — row 1's own copy is suppressed there (see the .doc-header
rules below). */
.app-header::after,
.doc-header::after {
content: "";
@@ -169,9 +186,9 @@ body::before {
pointer-events: none;
background: linear-gradient(
90deg,
rgb(244 63 94 / 0.55),
rgb(251 146 60 / 0.30) 45%,
rgb(251 191 36 / 0.05) 90%
color-mix(in srgb, var(--brand) 55%, transparent),
color-mix(in srgb, var(--brand) 30%, transparent) 45%,
color-mix(in srgb, var(--brand) 5%, transparent) 90%
);
}
/* margin-left:auto on the nav (not justify-content:space-between) so the
@@ -209,6 +226,16 @@ body::before {
white-space: nowrap;
}
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
/* The wordmark's theming resolves HERE from :root — the markup carries
no inline style attributes: this policy's style-src (phase 82 A1:
'self' + the theme tag's sha256, no 'unsafe-inline') blocks inline
style attributes (hashes never cover them), so the identity vars
land via this stylesheet instead (CSP-clean 'self'). The :nth-child
keys follow the wordmark's fixed 3-shape markup (hexagon, dot,
spokes) — identical in all five HTML files. */
.brand-mark path:nth-child(1) { fill: var(--surface); stroke: var(--brand); }
.brand-mark circle { fill: var(--brand); }
.brand-mark path:nth-child(3) { fill: none; stroke: var(--brand-ink); }
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
.app-nav { display: flex; gap: 0.25rem; margin-left: auto; }
@@ -255,7 +282,7 @@ body::before {
white-space: nowrap;
cursor: pointer;
}
.new-chat-btn:hover { background: #f55a72; color: var(--bg); }
.new-chat-btn:hover { background: var(--brand-hover); color: var(--bg); }
/* The plus mark is hidden on desktop (label carries the pill); it is the
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
@@ -289,7 +316,7 @@ body::before {
white-space: nowrap;
cursor: pointer;
}
.share-chat-btn:hover { background: #f55a72; color: var(--bg); }
.share-chat-btn:hover { background: var(--brand-hover); color: var(--bg); }
/* The link mark is hidden on desktop (the label carries the pill); it
is the whole control below 640px (mirrored in the ≤640 block
below). */
@@ -462,8 +489,8 @@ body::before {
}
.bubble p { margin: 0.2rem 0; }
.bubble pre {
background: #1a0f0f;
color: #e6d0d0;
background: var(--surface);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
@@ -502,7 +529,7 @@ body::before {
color: var(--bg);
border-bottom-right-radius: 4px;
}
.msg.user .bubble code { background: rgb(10 14 23 / 0.16); }
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
.msg.brain .bubble { border-bottom-left-radius: 4px; }
.msg.brain.is-deflected .bubble {
@@ -646,7 +673,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-overflow: ellipsis;
white-space: nowrap;
}
.source-chip:hover { background: #2a345f; text-decoration: underline; }
.source-chip:hover { background: var(--brand-soft); text-decoration: underline; }
/* "Maybe try" chips under a deflected bubble (phase 04). Unlike the
onboarding row (which scrolls horizontally on mobile), this group wraps
@@ -792,7 +819,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: #1a0f0f;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
@@ -814,7 +841,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
cursor: pointer;
}
.tune-save:hover:not(:disabled) { background: #7d88f5; }
.tune-save:hover:not(:disabled) { background: var(--brand-hover); }
.tune-save:disabled { opacity: 0.6; cursor: wait; }
.tune-cancel {
display: inline-flex;
@@ -836,7 +863,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin: 0.2rem 0 0 0.25rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid rgb(110 231 168 / 0.35);
border: 1px solid color-mix(in srgb, var(--ok-ink) 35%, transparent);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
@@ -878,7 +905,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
display: flex;
align-items: stretch;
gap: 0.6rem;
background: #1a0f0f;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
@@ -980,7 +1007,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
cursor: pointer;
}
#tune-save:hover:not(:disabled) { background: #7d88f5; }
#tune-save:hover:not(:disabled) { background: var(--brand-hover); }
#tune-save:disabled { opacity: 0.6; cursor: wait; }
/* Notes list — the phase-15 steering panel's language at full column
@@ -1057,7 +1084,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
background: #1a0f0f;
background: var(--surface);
border: 1px solid var(--brand-soft);
border-radius: var(--radius-sm);
padding: 0.6rem 0.7rem;
@@ -1085,7 +1112,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.tuning-saved {
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid rgb(110 231 168 / 0.35);
border: 1px solid color-mix(in srgb, var(--ok-ink) 35%, transparent);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
@@ -1198,7 +1225,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
}
.suggestion-chip:hover { background: #2a345f; }
.suggestion-chip:hover { background: var(--brand-soft); }
.suggestion-chip:active { transform: scale(0.98); }
/* ---------- Composer ---------- */
@@ -1317,22 +1344,26 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
padding-inline: 1rem;
}
.send-btn:hover:not(:disabled) { background: #7d88f5; }
.send-btn:disabled { background: #a5b4fc; cursor: not-allowed; }
.send-btn:hover:not(:disabled) { background: var(--brand-hover); }
.send-btn:disabled { background: var(--brand-busy); cursor: not-allowed; }
/* Phase 48 (2026-08-29, TODO.md L3): the in-flight Stop treatment — one
button, two roles. Rose-700 #be123c (the brand rose #f43f5e darkened)
with a #fff label = 6.3:1 (WCAG AA); the hover step #9f1239 holds
8.0:1. Same radius/height/hit target as the Send state and the shared
:focus-visible ring — the .is-stop class rides the same .send-btn
element, and the later rules win the hover specificity tie. */
.send-btn.is-stop { background: #be123c; color: #fff; }
.send-btn.is-stop:hover { background: #9f1239; }
button, two roles. --brand-stop (the brand darkened 75% toward --bg;
the built-in default mixes to ≈ (187, 50, 73)) with a white label =
5.8:1 (WCAG AA); the hover step (--brand at 55% toward --bg) holds
8.5:1. white is the ONE literal allowed outside :root — the dark bg
ink on the stop fill is only ≈3.4:1 (fails AA). Same radius/height/
hit target as the Send state and the shared :focus-visible ring — the
.is-stop class rides the same .send-btn element, and the later rules
win the hover specificity tie. */
.send-btn.is-stop { background: var(--brand-stop); color: #fff; }
.send-btn.is-stop:hover { background: color-mix(in srgb, var(--brand) 55%, var(--bg)); }
/* Busy spinner: dark arc (--bg) on the #a5b4fc busy button = 9.7:1. */
/* Busy spinner: dark arc (--bg) on the --brand-busy button ≈ 11.3:1
(well above the 9:1 floor the phase-48 note pinned for the arc). */
.spinner {
width: 16px; height: 16px;
border: 2.5px solid rgb(10 14 23 / 0.30);
border: 2.5px solid color-mix(in srgb, var(--bg) 30%, transparent);
border-top-color: var(--bg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
@@ -1389,7 +1420,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
cursor: pointer;
}
.stale-regenerate:hover { background: #f55a72; color: var(--bg); }
.stale-regenerate:hover { background: var(--brand-hover); color: var(--bg); }
.stale-regenerate:disabled { opacity: 0.6; cursor: wait; }
.stale-regenerate svg { width: 16px; height: 16px; display: block; }
@@ -1459,7 +1490,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font: inherit;
font-size: 1rem;
color: var(--ink);
background: #1a0f0f;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
@@ -1481,7 +1512,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
padding-inline: 1rem;
}
.login-submit:hover:not(:disabled) { background: #7d88f5; }
.login-submit:hover:not(:disabled) { background: var(--brand-hover); }
.login-submit:disabled { opacity: 0.6; cursor: wait; }
/* Login failure (role=alert): err pair ≈9.1:1. */
.login-error {
@@ -1575,7 +1606,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(10, 14, 23, 0.82);
background: color-mix(in srgb, var(--bg) 82%, transparent);
visibility: hidden;
opacity: 0;
transition: opacity 120ms ease;
@@ -1624,7 +1655,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
line-height: 1;
cursor: pointer;
}
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
.sync-modal-close:hover { background: color-mix(in srgb, var(--err-line) 15%, transparent); }
@media (prefers-reduced-motion: reduce) {
.sync-modal-backdrop { transition: none; }
}
@@ -1680,7 +1711,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
text-decoration: none;
}
.sources-gate-link:hover { background: #7d88f5; }
.sources-gate-link:hover { background: var(--brand-hover); }
/* Phase 79 (task 05): the in-app token gate — the gate surface of the
two token-only pages (the shell + the document viewer, one shared
@@ -1776,7 +1807,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
cursor: pointer;
}
.auth-gate-submit:hover:not(:disabled) { background: #7d88f5; }
.auth-gate-submit:hover:not(:disabled) { background: var(--brand-hover); }
.auth-gate-submit:disabled { opacity: 0.6; cursor: wait; }
/* The one-line error (role=alert) — the rose/danger family the
@@ -1909,7 +1940,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
}
#git-source-add:hover:not(:disabled),
#archive-upload-btn:hover:not(:disabled) { background: #7d88f5; }
#archive-upload-btn:hover:not(:disabled) { background: var(--brand-hover); }
#git-source-add:disabled,
#archive-upload-btn:disabled { opacity: 0.6; cursor: wait; }
@@ -2005,7 +2036,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
cursor: pointer;
}
#git-sources-retry:hover { background: rgb(239 68 68 / 0.12); }
#git-sources-retry:hover { background: color-mix(in srgb, var(--err-line) 12%, transparent); }
/* Env-fallback note (from_env: true — the table is empty and the list
is BOR_GIT_SOURCES): the info chip in the theme palette —
@@ -2078,7 +2109,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: fixed;
inset: 0;
/* --bg at 82% — the doc-modal dim, no backdrop-filter (no-blur). */
background: rgba(15, 10, 10, 0.82);
background: color-mix(in srgb, var(--bg) 82%, transparent);
}
.remove-confirm-panel {
@@ -2218,7 +2249,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: fixed;
inset: 0;
/* --bg at 82% — the doc-modal dim, no backdrop-filter (no-blur). */
background: rgba(15, 10, 10, 0.82);
background: color-mix(in srgb, var(--bg) 82%, transparent);
}
.ignore-editor-panel {
@@ -2348,7 +2379,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
background: var(--brand);
color: var(--bg);
}
.ignore-editor-save:hover:not(:disabled) { background: #f55a72; }
.ignore-editor-save:hover:not(:disabled) { background: var(--brand-hover); }
/* The list: the Sources page's table pattern — full width in the
72rem frame, surface card, horizontally scrollable wrapper (the
@@ -2542,7 +2573,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
flex-shrink: 0;
}
.history-refresh:hover { background: #f55a72; color: var(--bg); }
.history-refresh:hover { background: var(--brand-hover); color: var(--bg); }
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); below 640px it joins the visible label in the full-width
@@ -2675,7 +2706,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
cursor: pointer;
}
.history-confirm-yes:hover:not(:disabled) { background: rgb(239 68 68 / 0.18); }
.history-confirm-yes:hover:not(:disabled) { background: color-mix(in srgb, var(--err-line) 18%, transparent); }
.history-confirm-yes:disabled { opacity: 0.6; cursor: wait; }
.history-confirm-no {
min-height: 44px;
@@ -2818,7 +2849,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
cursor: pointer;
}
.token-generate:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
.token-generate:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
.token-generate:disabled { opacity: 0.6; cursor: wait; }
/* The shown-once block (owner-locked A4): a quiet brand-soft card
around the "shown once" line + the mono read-only field + Copy —
@@ -3085,7 +3116,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
cursor: pointer;
}
#theme-save:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
#theme-save:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
#theme-save:disabled { opacity: 0.6; cursor: wait; }
.theme-reset {
min-height: 44px;
@@ -3226,7 +3257,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-size: 0.95rem;
text-decoration: none;
}
.doc-back:hover { background: #2a345f; }
.doc-back:hover { background: var(--brand-soft); }
.doc-back svg { width: 16px; height: 16px; display: block; }
/* Row 2: the titlebar — a .container-width row with the back link +
the title block, its own content-sized height (title line + meta
@@ -3330,8 +3361,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.doc-md > :first-child { margin-top: 0; }
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
.doc-md pre {
background: #1a0f0f;
color: #e6d0d0;
background: var(--surface);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
@@ -3375,7 +3406,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-ink); /* #fca5a5 on --surface ≈9.0:1 */
color: var(--brand-ink); /* 9.0:1 on --surface */
}
.doc-summary-text {
margin: 0;
@@ -3449,7 +3480,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-size: 0.85rem;
cursor: pointer;
}
.doc-summary-save:hover { background: #f55a72; } /* the house hover lightening */
.doc-summary-save:hover { background: var(--brand-hover); } /* the house hover lightening */
.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.doc-summary-cancel {
display: inline-flex;
@@ -3516,7 +3547,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-decoration: none;
border-radius: var(--radius-sm);
}
.doc-open-sources:hover { background: #7d88f5; }
.doc-open-sources:hover { background: var(--brand-hover); }
/* Viewer links: Sources-table path cell + chat source chips (phase 10). */
.doc-link {
@@ -3551,7 +3582,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
position: fixed;
inset: 0;
/* --bg at 82% — no backdrop-filter (phase-08 no-blur perf anchor). */
background: rgba(10, 14, 23, 0.82);
background: color-mix(in srgb, var(--bg) 82%, transparent);
transition: opacity 120ms ease;
}
@@ -3568,7 +3599,10 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
/* Depth shadow — deliberately NOT themed (black, phase-92 table:
"depth, not theme"); the color-mix spelling renders the same 55%
black and keeps the literal out of the phase-92 sweep gate. */
box-shadow: 0 24px 80px color-mix(in srgb, black 55%, transparent);
}
/* Sticky top with the SAME height as the page bars — the phase-12 pins
@@ -3801,7 +3835,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
cursor: pointer;
padding-inline: 1.25rem;
}
#push-doc-btn:hover:not(:disabled) { background: #7d88f5; }
#push-doc-btn:hover:not(:disabled) { background: var(--brand-hover); }
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
/* The success status line (role=status): the ok family (ok-ink on
+185 -37
View File
@@ -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);
}