phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

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`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+169 -79
View File
@@ -34,7 +34,7 @@
* aria-label, the input label, the meta descriptions);
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the two UI-customization
* string keys, applied in this same .then, AFTER the app_name
* string keys, applied in this same pass, AFTER the app_name
* passes and INDEPENDENT of them (they apply even when the
* name is the default/empty). Each empty value is a no-op —
* an unset deployment stays byte-identical:
@@ -54,11 +54,37 @@
* (the loadHealth house style: progressive enhancement, the page
* never breaks).
*
* ONE pass, TWO callers (phase 190, task 03 — A2). Steps 1–6 live in
* the named function applyBrandFromConfig(cfg), the single owner of the
* brand DOM. It runs (a) at BOOT, through applyBrand() awaiting the
* parse-time fetch — byte-identical to the pre-phase single pass — and
* (b) on a LIVE re-apply, when the admin saves or resets a brand string
* in the Settings panel (assets/settings.js) or saves/resets the Theme
* tab (assets/theme.js): those surfaces fetch a fresh /api/config and
* hand it to the SAME function, so "saved" is visible without a reload
* (the PLAN §7.4 never-stale contract, extended from the phase-92
* palette to the strings). Because a live re-apply runs against a
* document whose previous pass already replaced the default literal,
* step 7 re-runs the three literal passes over the name the page
* CURRENTLY shows (window.BOR_BRAND_APPLIED) — at boot that is "" so
* step 7 never runs and the boot path stays exactly the phase-39
* sequence. The function is on this layer's existing export surface —
* the window globals (window.BOR_BRAND, window.BOR_CONFIG_PROMISE) —
* as window.applyBrandFromConfig; brand.js stays the ONE owner of the
* brand DOM and no surface copies a step.
*
* No-op property: with the customization env vars (BOR_APP_NAME,
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
* with the template defaults themselves — the name IS the literal, the
* placeholder and footer are the phase-61 copy (re-setting them is
* invisible) — so an unset deployment renders byte-identical.
* invisible) — so an unset deployment renders byte-identical. The
* "empty writes nothing" rule for the two phase-62 keys holds on a live
* re-apply too (the page keeps what it shows): /api/config cannot
* distinguish "cleared" from "unset", and both env defaults are
* non-empty, so an empty value only ever comes from an operator saving
* an empty string (and then the page keeps what it shows). A panel
* RESET deletes the row, so the resolver hands back the legacy tab row
* or the env default (both non-empty) and that name re-applies live.
*/
/* The synchronous default — set BEFORE any fetch, so module scripts
@@ -76,6 +102,12 @@ window.BOR_DOCS_REPO_CONFIGURED = false;
sync with it. */
const BRAND_LITERAL = "Brain of Reese";
/* Phase 190 (task 03): the name the DOM passes last applied — "" until
the first apply (the boot pass). A live re-apply needs it: the page
no longer carries the default literal where the brand shows, so the
re-apply replaces THIS name too (step 7 below). */
window.BOR_BRAND_APPLIED = "";
/* The name is operator-controlled: HTML-escape it before it touches
innerHTML (the markdown.js escape pattern — local on purpose, no
cross-module import for a 5-line helper). */
@@ -108,106 +140,164 @@ const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
);
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name;
/* THE shared brand pass (phase 190, task 03) — the ONE owner of the
brand DOM. Synchronous, idempotent, and safe to run against an
already-branded document: boot (applyBrand below, via the settled
fetch) and the live re-apply (settings.js after a brand-string
save/reset, theme.js after a save/reset) run THIS function and
nothing else. `cfg` is a /api/config payload; a null/missing payload
touches nothing (the loadHealth house style — never a blanked brand). */
function applyBrandFromConfig(cfg) {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
const previous =
typeof window.BOR_BRAND_APPLIED === "string" ? window.BOR_BRAND_APPLIED : "";
if (name) {
// A configured name: apply it (empty / missing: the default
// stands — and the phase-62 keys below are INDEPENDENT of the
// name, so they still apply).
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
// 7. Phase 190 (task 03, A2) — the RE-APPLY clause. A live re-apply
// runs on a document whose steps 1 / 3 / 4 above already rewrote
// the default literal into the PREVIOUS name, so a second rename
// has to replace what the page SHOWS, not (only) the literal:
// the same three places, one more source string. At boot
// `previous` is "" — this block never runs and the boot pass is
// byte-identical to the pre-phase sequence. (.brand-text, the
// composer placeholder and the footer line need nothing here:
// steps 2 / 5 / 6 assign their value outright.)
if (previous && previous !== name) {
document.title = document.title.replaceAll(previous, name);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(previous)) {
node.nodeValue = node.nodeValue.replaceAll(previous, name);
}
}
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
if (v && v.includes(previous)) {
el.setAttribute(attr, v.replaceAll(previous, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
if (v && v.includes(previous)) {
el.setAttribute("content", v.replaceAll(previous, name));
}
}
}
}
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the two customization STRING keys — applied
// here, INDEPENDENT of the app_name block above (they apply even
// when the name is the default/empty). Each empty value is a
// no-op, so an unset deployment stays byte-identical (no attribute
// or element touched, no second network call). Color theming is
// server-side since phase 91 (task 03) — not this layer's job.
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
// The name this document now shows — the source string the NEXT
// re-apply replaces (phase 190, task 03).
window.BOR_BRAND_APPLIED = name;
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
});
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the two customization STRING keys — applied
// here, INDEPENDENT of the app_name block above (they apply even
// when the name is the default/empty). Each empty value is a
// no-op, so an unset deployment stays byte-identical (no attribute
// or element touched, no second network call). Color theming is
// server-side since phase 91 (task 03) — not this layer's job.
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
// 5. The composer placeholder — the chat page only:
// #message-input exists only on index.html, so every other
// page no-ops via the null guard.
document.querySelector("#message-input")?.setAttribute(
"placeholder",
placeholder,
);
}
const footerText =
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
if (footerText) {
// 6. The footer line on every page (all 9 carry the phase-61
// .footer-text hook). textContent on purpose: an operator
// string can't inject markup.
document.querySelectorAll(".footer-text").forEach((el) => {
el.textContent = footerText;
});
}
}
/* This layer's export surface is the window globals it has always had
(window.BOR_BRAND, window.BOR_CONFIG_PROMISE); the shared pass joins
them so the page's MODULE scripts — assets/settings.js and
assets/theme.js, which cannot import from a classic script — call the
ONE function instead of copying a step (phase 190, task 03). */
window.applyBrandFromConfig = applyBrandFromConfig;
/* The boot call site — unchanged in behavior (phase 39): it awaits the
parse-time fetch and hands the settled payload to the shared pass. */
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => applyBrandFromConfig(cfg));
}
/* The DOM passes run once the document is ready AND the config is
+2 -2
View File
@@ -81,7 +81,7 @@ const VIEW = {
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette — colors only since phase 190)
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
};
@@ -138,7 +138,7 @@ const DESCRIPTIONS = {
history:
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
tokens: "Generate and revoke the API tokens that let people use the app.",
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
theme: "Set the palette — the theme is baked into every served page, live on the first paint.",
settings: "Edit any application setting — a saved value wins over the environment, live on save; reset hands control back.",
};
+63
View File
@@ -75,6 +75,18 @@
* button, Escape / No cancels) → DELETE /api/admin/settings →
* the status line `Cleared N overrides.` (N from the
* response) + a re-GET.
* • the live brand re-apply (phase 190, task 03 — A2, the second half
* of "takes effect on clicking save") — when a save / Reset / Clear
* all touched one of the three brand strings (`app_name`,
* `input_placeholder`, `footer_text` — the panel's own keys), the
* OPEN document is re-branded from a fresh `GET /api/config`
* through `window.applyBrandFromConfig` (the ONE pass brand.js owns
* and the boot runs — this file never writes the title / brand /
* footer / placeholder itself). No brand key in the batch → no
* extra request; a failed re-apply leaves the document exactly as
* it was (never a blanked brand); the catalogue re-GET stays the
* catalogue's own refresh. The Theme tab (theme.js) runs the SAME
* shared pass after its own save / reset.
* • search — #settings-filter: a case-insensitive substring over
* the label + BOR_* name cells, applied on input with NO fetch
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
@@ -447,6 +459,48 @@ export async function mount(root) {
}
}
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
* The phase-168 header contract is "the change should take effect on
* clicking 'save'" — for the three BRAND strings that means the OPEN
* document too, not just the catalogue. One pass, ONE owner:
* brand.js exports the boot's own function as
* window.applyBrandFromConfig, and this helper feeds it a fresh
* GET /api/config (the same no-store request the boot makes).
*
* • gated on the key — a save / Reset of any of the ~72 other rows
* costs NO extra request and no DOM churn; Clear all passes all
* three because clearing hands every one of them back to the
* Theme-tab row or the environment;
* • the §7.4 guard, EXTENDED not forked — the save itself is already
* inside `saveInFlight` (this helper is awaited before that flag
* drops, so a second Save can never start mid-re-apply) and the
* one flag below covers the settle paths that share the pass, so a
* second save / Reset / Clear all never stacks a second fetch;
* • never a blanked brand — a non-2xx or an unreachable app returns
* WITHOUT touching the DOM (the phase-168 "never a blanked panel"
* precedent): the open page keeps its last applied name, footer
* and placeholder, and the save's own status / error line stays
* the visibility. */
const BRAND_KEYS = ["app_name", "input_placeholder", "footer_text"];
let brandReapplyInFlight = false;
async function reapplyBrand(keys) {
if (!keys.some((key) => BRAND_KEYS.includes(key))) return; // not a brand row
if (brandReapplyInFlight) return; // one re-apply at a time (§7.4)
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 (the
brand never blanks; the settle path's own line says it) */
} finally {
brandReapplyInFlight = false;
}
}
/* ---------- Save (dirty rows only — stop at the first failure,
K4; the §7.4 never-stale lifecycle) ---------- */
async function saveSettings() {
@@ -499,6 +553,9 @@ export async function mount(root) {
);
const input = root.querySelector("#set-" + failure.key);
if (input) input.focus();
/* A brand row that DID land before the failure changed the app
(K4 saves the prefix) — the open document re-reads it too. */
await reapplyBrand(saved);
} else {
/* One status line (role=status) — a saved restart-required
row says so (the owner is never told "saved" and then sees
@@ -511,6 +568,10 @@ export async function mount(root) {
(anyRestart ? " — applies on the next restart" : ""),
);
await loadSettings(); // every badge/value is the settled state
/* …and the brand strings apply to the OPEN document (no reload) —
additive to the catalogue re-GET above, still inside the
saveInFlight guard (phase 190, task 03). */
await reapplyBrand(saved);
}
saveInFlight = false;
if (saveBtn) {
@@ -535,6 +596,7 @@ export async function mount(root) {
}
announce(`${envVar} is back to the environment value.`);
await loadSettings(); // the badge falls back to env/default
await reapplyBrand([key]); // …and the open document falls back too
}
/* ---------- Clear all overrides (the house two-step inline
@@ -570,6 +632,7 @@ export async function mount(root) {
restore();
announce(`Cleared ${cleared} overrides.`);
await loadSettings(); // every badge falls back to env/default
await reapplyBrand(BRAND_KEYS); // every brand string may have moved
}
function showClearAllConfirm() {
+107 -50
View File
@@ -1,5 +1,7 @@
/* Brain of Reese — Theme view module (phase 91, task 05; phase 92,
* task 04): the admin palette + branding editor.
* 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
@@ -17,19 +19,22 @@
* 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 20 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 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. 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.
* • 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
@@ -95,10 +100,10 @@
* 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 20 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
* 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
@@ -111,13 +116,26 @@
* 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 20 values
* null (the API's documented "defaults" operation) → #theme-result
* • 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
@@ -168,21 +186,22 @@ export async function mount(root) {
const SAVE_LABEL = "Save theme";
const RESET_LABEL = "Reset to defaults";
/* The 20 form fields, in the form's order: `field` is the API key
(the input's name attribute), `id` the E2E-stable element id,
`kind` how the value is read for a PUT — a string field that is
empty after the trim sends null (the server stores NULL = "use
the default"); a color field always sends its current #rrggbb
(the server's built-in→NULL normalization keeps the row empty
when the owner saves the defaults). 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,
/* 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: "app_name", id: "theme-app-name", kind: "string" },
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
{ field: "footer_text", id: "theme-footer", kind: "string" },
{ field: "bg", id: "theme-bg", kind: "color" },
{ field: "surface", id: "theme-surface", kind: "color" },
{ field: "ink", id: "theme-ink", kind: "color" },
@@ -324,8 +343,8 @@ export async function mount(root) {
* inline style beats the served <style id="bor-theme"> :root, so
* the whole page repaints live while the owner is picking. An empty
* value removes the override (the page falls back to the served
* theme). Text fields write NOTHING here — the strings apply via
* brand.js on the next page load (the sub-copy says so). */
* 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);
@@ -488,9 +507,10 @@ export async function mount(root) {
}
}
/* GET /api/ui-settings → populate the 20 inputs with the EFFECTIVE
values (the tab always shows the live theme — env defaults when
the row is empty) and re-check the eight pairs (a SAVED palette
/* 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
@@ -524,17 +544,16 @@ export async function mount(root) {
/* ---------- the PUT (Save + Reset share it) ---------- */
function collectBody() {
/* All 20 fields — the 3 strings + the 17 colors (the 9 identity
+ the 8 semantic state, phase 93). */
/* 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 : "";
if (f.kind === "string") {
body[f.field] = value.trim() || null; // cleared field → null
} else {
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
return body;
}
@@ -577,6 +596,39 @@ export async function mount(root) {
}
}
/* ---------- 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) {
@@ -592,11 +644,12 @@ export async function mount(root) {
/* 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; // all null = the defaults
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);
@@ -611,6 +664,11 @@ export async function mount(root) {
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) ----------
@@ -642,10 +700,9 @@ export async function mount(root) {
/* Bindings — armed BEFORE the first load: a fast owner can start
picking while the GET is still out; the preview writes are
idempotent and the settled load re-populates afterwards. Color
inputs drive the live preview + the contrast re-check; text
inputs drive neither (B4 — the strings apply on the next page
load, the sub-copy says so). */
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;
+17 -48
View File
@@ -1275,8 +1275,9 @@
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
folded view (the phase-76 fold pattern, the phase-79 Tokens
view as the most recent precedent): the admin-only palette +
branding editor. /theme.html serves THIS document (the shell
view as the most recent precedent): the admin-only palette
editor (the brand strings are the Settings panel's since phase
190). /theme.html serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. The CSS-file theming (BOR_THEME, phase 62) is
retired (task 03): the effective theme is injected into every
@@ -1297,7 +1298,7 @@
<div class="container theme-shell">
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
#sources-gate pattern (phase 16) and the same .sources-gate
visual language: the palette + branding is what the login
visual language: the palette is what the login
locks (B5, owner-locked 2026-09-09). Visible for
anonymous, hidden for the admin (theme.js). The gate's
Sign in returns to the Theme view (the header's ?next=
@@ -1308,7 +1309,7 @@
</div>
<h2 id="theme-gate-title">Sign in to change the theme</h2>
<p class="sources-gate-sub">
The palette and branding are admin-only. Chat — and any
The palette is admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
@@ -1325,7 +1326,9 @@
Changes preview live as you pick;
<strong>Save theme</strong> bakes the palette into every
page it is served on — it applies on the first paint, no
pop-in.
pop-in. The app name, the chat placeholder and the footer
line are the Settings panel's: it owns the brand strings
and applies them the moment they are saved.
</p>
</div>
@@ -1333,50 +1336,16 @@
the bindings — effective-value populate, the live preview,
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
in #theme-contrast). No real submit: both buttons are
type="button"; maxlength=300 mirrors the server's 300-char
limit (the server re-validates — 422 naming the field).
The color inputs ship the BUILT-IN values (app/core/
theming.py BUILTIN_COLORS) — task 05 re-populates them
with the EFFECTIVE values on mount. -->
type="button". The 17 color inputs ship the BUILT-IN
values (app/core/theming.py BUILTIN_COLORS) — task 05
re-populates them with the EFFECTIVE values on mount.
Phase 190 (task 02, A1): the old "Branding" fieldset and
its three string inputs (#theme-app-name /
#theme-placeholder / #theme-footer) are GONE — the admin
Settings panel is the single writable owner of the brand
strings, so the tab neither shows nor sends them (a save
here can no longer shadow a panel value). -->
<form id="theme-form" novalidate>
<fieldset class="theme-group">
<legend class="theme-group-title">Branding</legend>
<!-- B4 (owner-locked): the strings keep the brand.js
runtime application — they apply via the /api/config
boot fetch on the NEXT page load; the live preview
covers the palette only. An empty field restores the
default (the env value). -->
<p class="theme-note">
These strings apply on the next page load — the live
preview covers the palette only. Leaving a field empty
restores its default.
</p>
<label for="theme-app-name">App name</label>
<input
id="theme-app-name"
name="app_name"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-placeholder">Chat input placeholder</label>
<input
id="theme-placeholder"
name="input_placeholder"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-footer">Footer line</label>
<input
id="theme-footer"
name="footer_text"
type="text"
maxlength="300"
autocomplete="off"
>
</fieldset>
<fieldset class="theme-group">
<!-- The eight text/background pairs (the five identity
pairs + the three semantic ink-on-bg pairs from the