phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
+177
View File
@@ -54,6 +54,12 @@
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the shell's seventh
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
@@ -943,6 +949,177 @@
</div>
</section>
<!-- 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
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
served page's <head> server-side (app/core/caching.py +
app/core/theming.py), so it paints on the FIRST paint — no
red flash, no pop-in. SHIPS hidden (anonymous-safe — the gate
is what anonymous sees; theme.js reveals the content for
admin only). The hidden + inert pair is the WCAG contract:
a hidden view must not receive focus or keyboard traversal
(AGENTS.md rule 5). Mounted lazily — assets/router.js imports
theme.js on first show only (mount-once, hide-forever; the
editor lands in task 05). The form is STATIC markup (the
E2E-stable-selectors house convention) — the onsubmit
binding + live preview + Save/Reset lifecycle land in task 05
(theme.js); there is no real submit (every button is
type="button"). -->
<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">
<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
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=
convention; the static href is the no-JS fallback). -->
<section class="sources-gate" id="theme-gate" aria-labelledby="theme-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</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
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
</section>
<!-- Phase 91 (task 04): the editor — SHIPS hidden
(anonymous-safe; the gate is what anonymous sees). theme.js
reveals it once the cached whoami says admin (the
#git-sources-content pattern). -->
<div id="theme-content" hidden>
<div class="page-head">
<h1>Theme</h1>
<p class="page-sub">
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.
</p>
</div>
<!-- Static form skeleton (E2E-stable selectors; task 05 wires
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. -->
<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 five text/background pairs are checked against
WCAG 2.1 AA (4.5:1) as the owner picks (theme.js —
the app/core/theming.py docstring is the authoritative
pair table); failures list in #theme-contrast as a
warning and never block a save. -->
<legend class="theme-group-title">
Palette — five pairs checked against WCAG 2.1 AA (4.5:1)
</legend>
<div class="theme-colors">
<div class="theme-color">
<label for="theme-bg">Background (--bg)</label>
<input id="theme-bg" name="bg" type="color" value="#0f0a0a">
</div>
<div class="theme-color">
<label for="theme-surface">Surface (--surface)</label>
<input id="theme-surface" name="surface" type="color" value="#1a0f0f">
</div>
<div class="theme-color">
<label for="theme-ink">Text (--ink)</label>
<input id="theme-ink" name="ink" type="color" value="#f0e6e6">
</div>
<div class="theme-color">
<label for="theme-ink-soft">Secondary text (--ink-soft)</label>
<input id="theme-ink-soft" name="ink_soft" type="color" value="#b8a8a8">
</div>
<div class="theme-color">
<label for="theme-line">Border (--line)</label>
<input id="theme-line" name="line" type="color" value="#2d1a1a">
</div>
<div class="theme-color">
<label for="theme-brand">Brand accent (--brand) — buttons, links</label>
<input id="theme-brand" name="brand" type="color" value="#f43f5e">
</div>
<div class="theme-color">
<label for="theme-brand-soft">Brand tint (--brand-soft)</label>
<input id="theme-brand-soft" name="brand_soft" type="color" value="#2d0a0a">
</div>
<div class="theme-color">
<label for="theme-brand-ink">Brand text (--brand-ink)</label>
<input id="theme-brand-ink" name="brand_ink" type="color" value="#fca5a5">
</div>
</div>
</fieldset>
<!-- Save (primary) + Reset to defaults (secondary). Both
type="button" (no real submit); task 05 runs the §7.4
never-stale lifecycle ("Saving…" while the PUT is out,
re-enabled on success AND failure). -->
<div class="theme-actions">
<button type="button" id="theme-save">Save theme</button>
<button type="button" class="theme-reset" id="theme-reset">Reset to defaults</button>
</div>
</form>
<!-- task 05 owns all three lines: #theme-error (the server's
422 detail — the fields are kept on failure), #theme-result
("Theme saved." / "Reset to the built-in theme."),
#theme-contrast (the WCAG warnings for the five pairs —
warning-only, the owner can still save). -->
<p class="theme-error" id="theme-error" role="alert" hidden></p>
<p class="theme-result" id="theme-result" role="status" aria-live="polite" hidden></p>
<p class="theme-contrast" id="theme-contrast" role="alert" hidden></p>
</div>
</div>
</section>
</main>
<!-- Phase 79 (task 05): the in-app token gate — a body-level