phase: 190_app_name_settings_fix
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:
+169
-79
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user