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/`.
13 KiB
Phase 91 — Admin Theme tab: pickers + fields, pre-paint theme, CSS-file theming retired
Source: TODO.md L4 — "Custom theming isn't really working. The page loads red first and then the theme "pops" into view, replacing words and colors in an obvious way. Remove the custom css file theming. Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers. Theme should load immediately, not pop in after the page load."
Story: n/a (TODO-derived — owner roadmap confirmation 2026-09-09).
Context: Today's theming: BOR_THEME=<file>.css (bare-filename validated at boot in app/config.py::_theme_bare_css_filename, fail-loud) → served by GET /api/config → frontend/assets/brand.js step 7 inserts <link rel="stylesheet" href="/assets/themes/<file>" id="theme-override"> after the boot fetch settles — so the page paints with the built-in red-first palette, then the theme's :root overrides swap in: the pop-in the owner saw. A theme file is one :root block overriding the 8 identity variables (--bg, --surface, --ink, --ink-soft, --line, --brand, --brand-soft, --brand-ink — built-ins in frontend/assets/styles.css; authoring guide + the 5 contrast pairs in frontend/assets/themes/README.md; the semantic families --accent-*/--ok-*/--err-* are deliberately NOT identity). The env vars also carry 3 strings the same boot fetch applies via brand.js: BOR_APP_NAME (name passes incl. the TreeWalker prose replace), BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT (empty = template default = byte-identical contract). Containerfile line 27 ships assets/themes into the image. Admin-tab pattern (Tuning/Tokens, phases 76/79): a hidden <a class="nav-link" id="nav-<x>" hidden> in the shell header revealed by frontend/assets/header.js when whoami says role === "admin"; a <section class="view" id="view-<x>" hidden inert aria-label="…" tabindex="-1"> in frontend/index.html (admin gate = the #sources-gate pattern, manager content #<x>-content hidden until admin); frontend/assets/router.js maps the pathname (PATH_TO_VIEW, VIEW_HREFS, title + meta tables, lazy module import on first show); app/main.py::_shell_routes tuple serves the shell at the .html path; app/core/caching.py::HTML_PAGES gets the entry (no-cache + ?v=<token> rewrite). The injection point: CachingMiddleware (phase 33/54) already buffers EVERY known HTML page's response body (and /shared/<token> by prefix) to rewrite asset refs — it is the one place that can put the theme into the HTML before first paint with zero client timing.
Objective
Retire the CSS-file theming entirely (BOR_THEME, frontend/assets/themes/, the brand.js link insertion, the Containerfile ship line). Ship a new admin-only Theme tab (a 7th shell view at /theme.html) where the owner sets — with text fields and color pickers, persisted to a new single-row ui_settings table — everything the env vars and the custom CSS supported: the app name, the input placeholder, the footer text, and the 8 identity colors. The effective theme is injected as an inline <style>:root{…}</style> into every served HTML page at serve time, so a themed deployment renders its palette on the first paint — no red flash, no pop-in.
Dependencies
90_upload_no_scan(todo) — pipeline predecessor (execution order) only; no code dependency (this phase touchesapp/config.py,app/api/config.py,app/core/caching.py,app/core/theming.py,app/models.py, a newapp/api/ui_settings.py, the shell frontend, and the Containerfile — none of which phase 90's pins reach; its suites must stay green unchanged).
Design (shared by all tasks — the executor reads this, not the chat)
- Storage (task 01).
ui_settings— ONE row (PKid, the row is created/updated by PUT; GET upserts nothing — a missing row means "defaults"). Columns, all nullable (NULL = "use the default"):app_name,input_placeholder,footer_text—String(300); default = the env value (settings.app_nameetc. — B1: env vars stay as the fallback; a non-empty DB value wins; an empty/NULL DB value falls back to the env value).bg,surface,ink,ink_soft,line,brand,brand_soft,brand_ink—String(7)(#rrggbb); default = the BUILT-IN value fromapp/core/theming.py(B1: no env fallback for colors — the built-in palette IS the default).- Alembic
0014_ui_settings.py(revises0013; downgrade drops the table).
- Effective values. One resolver used by BOTH
/api/configand the API:app/core/theming.py::effective_settings(session) -> dict— strings: DB value if it is a non-empty string, else the env value; colors: DB value if set, else the built-in.app/core/theming.py::BUILTIN_COLORS: dict[str, str]— the 8 built-ins keyed by variable name WITHOUT the--({"bg": "#0f0a0a", "surface": "#1a0f0f", "ink": "#f0e6e6", "ink_soft": "#b8a8a8", "line": "#2d1a1a", "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5"}— must mirrorstyles.css:root; a unit test parsesstyles.cssand asserts equality so the two can never drift). - API (task 01).
app/api/ui_settings.py—GET /api/ui-settings(admin,require_adminlike the tokens router): the effective values ({app_name, input_placeholder, footer_text, bg, surface, ink, ink_soft, line, brand, brand_soft, brand_ink}).PUT /api/ui-settings(admin): bodyUiSettingsIn— every field optionalstr | None; each string trimmed, empty → NULL, >300 chars → 422 (fixed detail naming the field); each color must match^#[0-9a-fA-F]{6}$(lowercased on store) else 422 naming the field. Normalization (owner-locked rule): a color submitted equal to its built-in value is stored as NULL — "save the defaults" must leave the row empty so an unset deployment stays byte-identical (B4's no-op injection). Response: the new effective values. /api/config(tasks 01 + 03). Serves the effective strings (resolver, DB-over-env) instead of the rawsettingsvalues; thethemekey is DELETED (task 03). Needs a DB session — the house pattern (see how the sync/chats endpoints open short-lived sessions viaapp/db.py).- Pre-paint injection (task 02).
app/core/theming.py::theme_style_tag(colors: dict[str, str]) -> str—""when every color equals its built-in (the byte-identical contract), else<style id="bor-theme">:root{--bg:#0f0a0a;--surface:…;}</style>(all 8, in the README's order).app/core/caching.py— in the HTML rewrite branch (the one that already callsrewrite_asset_refs): after the rewrite,html = inject_theme(html, tag)— a small pure helper that inserts the tag immediately BEFORE the first</head>(idempotent: skips ifid="bor-theme"already present — it can't be, the static files never contain it, but the helper is pure-tested). The middleware fetches the row ONCE per response (short-lived session, the sync pattern) — a single-row SELECT, homelab page traffic; NO process cache (the theme changes at runtime from the tab). Applies to everyHTML_PAGESentry AND/shared/<token>(same branch). Non-theme responses (/assets/*,/api/*) untouched. - The tab (tasks 04 + 05).
#nav-theme("Theme",href="/theme.html", hidden; header.js admin reveal, same block as#nav-tokens);#view-themesection inindex.htmlAFTER#view-tokens— the admin gate (#theme-gate, the exact#sources-gatepattern,?next=/theme.html) +#theme-content(hidden; revealed for admin) holding STATIC form markup (the E2E-stable-selectors house convention):#theme-formwith 3 labeled text inputs (#theme-app-name,#theme-placeholder,#theme-footer) + 8 labeled<input type="color">(#theme-bg…#theme-brand-ink, each label shows the variable name + role),#theme-save(primary),#theme-reset(secondary, "Reset to defaults"),#theme-error(role="alert", hidden),#theme-result(role="status", hidden),#theme-contrast(role="alert", hidden — the WCAG warnings).router.js:PATH_TO_VIEW["/theme.html"] = "theme",VIEW_HREFS.theme = "/theme.html", title "Theme · Brain of Reese" + meta description, lazy import oftheme.json first show (the tuning/tokens module pattern).main.py:"/theme.html"in the_shell_routestuple.caching.py:"/theme.html"inHTML_PAGES. - The editor (task 05).
frontend/assets/theme.js: mount → GET/api/ui-settings(admin; 403/anonymous never mounts — the gate covers it) → populate (inputs show the EFFECTIVE values, so a fresh tab shows the live theme) → live preview: oninput,document.documentElement.style.setProperty("--" + var, value)(andremovePropertyback to the built-in when a field is cleared / on reset) — the owner sees the change across the whole page while picking → Save (§7.4: disable + "Saving…" → PUT form values — cleared text field →null; colors always their current hex, the server's built-in→NULL normalization keeps the row empty on defaults) →#theme-result"Theme saved." (role=status) + refetch + re-populate (canonical state) → Reset → PUT all-null → same lifecycle → "Reset to the built-in theme." → error paths: 422 →#theme-errorwith the server detail, fields kept. WCAG contrast (the themes README's 5 pairs, client-side):--inkon--bg,--inkon--surface,--ink-softon--surface,--bgon--brand(the button-ink pattern),--brand-inkon--surface— WCAG relative-luminance ratio; any pair < 4.5:1 →#theme-contrastlists the failing pairs ("--ink on --bg: 3.2:1 — needs 4.5:1"), WARNING-ONLY (the owner can still save — it's their homelab palette; AGENTS.md rule 5 is met by the warning + the built-in staying AA). - Strings stay runtime-applied (B4, owner-locked). App name / placeholder / footer continue to flow through
/api/config→brand.jsexactly as today (the boot fetch + DOM passes) — only the COLORS move to pre-paint injection (the colors are what paint the page; the name/placeholder/footer are text swaps the owner never complained pop). - Retirement (task 03). Delete:
Settings.theme+_theme_bare_css_filename(app/config.py), thethemekey in/api/config(after task 01 repoints it at the effective strings),frontend/assets/themes/(indigo.css,README.md— the 5 contrast pairs + built-in table are re-homed intoapp/core/theming.py's docstring +00_phase.mdbefore deletion), brand.js step 7 + its docstring paragraphs, the Containerfilecp -r ./assets/themesline (fix the&&chain).
Tasks
01_ui_settings_store.md— the single-rowui_settingstable + migration0014, theapp/core/theming.pyresolver, the admin-gatedGET/PUT /api/ui-settings,/api/configserves effective strings; unit + integration tests.02_inline_theme_injection.md—theme_style_tag+ theCachingMiddlewarebefore-</head>injection (byte-identical no-op when unset); caching/theming unit tests.03_retire_css_file_theming.md— deleteBOR_THEME+ validator, the/api/configtheme key,frontend/assets/themes/, brand.js step 7, the Containerfile ship line; affected suites updated/deleted in place.04_theme_tab_shell.md— the 7th shell view:#nav-theme+#view-theme(gate + static form skeleton),header.jsadmin reveal,router.jsmapping,main.pyshell route,HTML_PAGESentry.05_theme_editor.md—theme.js: effective-value populate, live preview, Save/Reset (§7.4), WCAG contrast warnings; view styles.06_e2e_theme_tab.md— the story's dedicated E2E: save a palette → served HTML carries the inline:root(first paint, no pop) → anonymous sees it → 403 for non-admins → reset restores byte-identical output.
Testing & Quality
- Unit/integration: the resolver (DB-over-env strings, built-in color fallback, empty→NULL), the PUT validation (422s naming the field, built-in→NULL normalization), the admin gate (403 anonymous + token user, 200 admin), the injection (placement before
</head>, no-op byte-identity, idempotence,BUILTIN_COLORS↔styles.cssdrift test),/api/configeffective strings; affected existing suites updated in place. - Coverage: >90% on new/modified code (
uv run pytest --cov=app --cov-report=term-missing). - This phase's Playwright E2E suite:
tests/e2e/test_admin_theme_tab.py, run in isolation (uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov).
Completion Criteria
- An unset deployment serves byte-identical HTML to today's built-in (no
#bor-themetag anywhere);rg "BOR_THEME|themes/" app/ frontend/ Containerfile→ nothing (the doc-history citations in comments excepted, house style). - Admin-only:
/theme.htmlshows the gate to anonymous and the form to admin;PUT /api/ui-settings→ 403 for anonymous AND token users. - After a save, every HTML page (incl.
/shared/<token>) carries<style id="bor-theme">before</head>and the computed--brandmatches on first paint — no pop-in. - Reset restores the built-in palette and byte-identical HTML; the 5 contrast pairs warn below 4.5:1.
- Full test suite green,
app/coverage >90%,uv run ruff check . && uv run pyrightclean. - One atomic Conventional Commits commit for the phase (
--no-gpg-sign),.agents/phase files moved tocomplete/by the pipeline.