phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
+20
-64
@@ -32,9 +32,9 @@
|
||||
* 4. an attribute pass — the aria-label / placeholder / meta
|
||||
* content attributes containing the literal (the #messages
|
||||
* aria-label, the input label, the meta descriptions);
|
||||
* 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||
* settled config also carries the three UI-customization
|
||||
* keys, applied in this same .then, AFTER the app_name
|
||||
* 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
|
||||
* 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:
|
||||
@@ -43,30 +43,22 @@
|
||||
* no-ops via the null guard);
|
||||
* 6. footer_text — non-empty → every .footer-text node's
|
||||
* textContent (all 9 pages, the phase-61 hook; an
|
||||
* operator string can't inject markup via textContent);
|
||||
* 7. theme — non-empty → a <link rel="stylesheet"> inserted
|
||||
* IMMEDIATELY AFTER the styles.css link (the theme's
|
||||
* :root overrides win by cascade order). The styles.css
|
||||
* finder matches the RAW attribute path with any query/
|
||||
* fragment stripped — the phase-33/54 cache-busting
|
||||
* middleware serves the HTML with the asset refs rewritten
|
||||
* to "…/styles.css?v=<token>", and el.href (the absolute
|
||||
* URL) would never end with "styles.css" once versioned.
|
||||
* The filename is validated server-side (a bare *.css
|
||||
* name — no path can reach here via /api/config); a
|
||||
* MISSING file degrades to the built-in theme (onerror →
|
||||
* console.warn — A5, the page never breaks). Guarded by
|
||||
* #theme-override: never inserted twice.
|
||||
* operator string can't inject markup via textContent).
|
||||
* Phase 91 (task 03): color theming is no longer a brand.js
|
||||
* job — the retired CSS-file theme link (the old step 7) is
|
||||
* deleted with its env var; the SERVER now injects the
|
||||
* effective palette inline before first paint
|
||||
* (app/core/theming.py), so this layer keeps the text swaps
|
||||
* only.
|
||||
* • fetch failure / empty name → the default stays + console.warn
|
||||
* (the loadHealth house style: progressive enhancement, the page
|
||||
* never breaks).
|
||||
*
|
||||
* No-op property: with the customization env vars (BOR_APP_NAME,
|
||||
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) 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), the theme is empty (the link is skipped) — so an
|
||||
* unset deployment renders byte-identical.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||
@@ -187,11 +179,12 @@ function applyBrand() {
|
||||
}
|
||||
|
||||
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||
// config also carries the three customization 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).
|
||||
// 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) {
|
||||
@@ -214,43 +207,6 @@ function applyBrand() {
|
||||
el.textContent = footerText;
|
||||
});
|
||||
}
|
||||
|
||||
const themeName = typeof cfg?.theme === "string" ? cfg.theme : "";
|
||||
if (themeName) {
|
||||
// 7. The theme stylesheet — a <link> inserted IMMEDIATELY AFTER
|
||||
// the existing styles.css link, so the theme's :root
|
||||
// overrides win by cascade order. The filename is validated
|
||||
// server-side (task 01: a bare *.css name) — no path input
|
||||
// can reach here via /api/config. Guarded by #theme-override:
|
||||
// never applied twice (the loadHealth house style — never
|
||||
// break the page, never double-apply). A missing file
|
||||
// degrades to the built-in theme (A5): the onerror warns,
|
||||
// nothing else.
|
||||
if (!document.getElementById("theme-override")) {
|
||||
// Phase 33/54: the served HTML may carry the cache-bust query
|
||||
// (?v=<token>) on the asset ref — match on the RAW attribute
|
||||
// path with query/fragment stripped, never on el.href (the
|
||||
// absolute URL, which would include the token).
|
||||
const stylesLink = Array.from(
|
||||
document.querySelectorAll('link[rel="stylesheet"]'),
|
||||
).find((el) => {
|
||||
const ref = (el.getAttribute("href") || "").split(/[?#]/)[0];
|
||||
return ref.endsWith("styles.css");
|
||||
});
|
||||
if (stylesLink) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/assets/themes/" + themeName;
|
||||
link.id = "theme-override";
|
||||
link.onerror = () =>
|
||||
console.warn(
|
||||
"brand: theme " + themeName +
|
||||
" did not load — the built-in theme stands.",
|
||||
);
|
||||
stylesLink.insertAdjacentElement("afterend", link);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user