Files
brain-of-reese/.agents/phases/todo/91_admin_theme_tab/00_phase.md
T

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 touches app/config.py, app/api/config.py, app/core/caching.py, app/core/theming.py, app/models.py, a new app/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 (PK id, 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_name etc. — 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 from app/core/theming.py (B1: no env fallback for colors — the built-in palette IS the default).
    • Alembic 0014_ui_settings.py (revises 0013; downgrade drops the table).
  • Effective values. One resolver used by BOTH /api/config and 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 mirror styles.css :root; a unit test parses styles.css and asserts equality so the two can never drift).
  • API (task 01). app/api/ui_settings.py — GET /api/ui-settings (admin, require_admin like 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): body UiSettingsIn — every field optional str | 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 raw settings values; the theme key is DELETED (task 03). Needs a DB session — the house pattern (see how the sync/chats endpoints open short-lived sessions via app/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 calls rewrite_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 if id="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 every HTML_PAGES entry 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-theme section in index.html AFTER #view-tokens — the admin gate (#theme-gate, the exact #sources-gate pattern, ?next=/theme.html) + #theme-content (hidden; revealed for admin) holding STATIC form markup (the E2E-stable-selectors house convention): #theme-form with 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 of theme.js on first show (the tuning/tokens module pattern). main.py: "/theme.html" in the _shell_routes tuple. caching.py: "/theme.html" in HTML_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: on input, document.documentElement.style.setProperty("--" + var, value) (and removeProperty back 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-error with the server detail, fields kept. WCAG contrast (the themes README's 5 pairs, client-side): --ink on --bg, --ink on --surface, --ink-soft on --surface, --bg on --brand (the button-ink pattern), --brand-ink on --surface — WCAG relative-luminance ratio; any pair < 4.5:1 → #theme-contrast lists 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.js exactly 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), the theme key 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 into app/core/theming.py's docstring + 00_phase.md before deletion), brand.js step 7 + its docstring paragraphs, the Containerfile cp -r ./assets/themes line (fix the && chain).

Tasks

  1. 01_ui_settings_store.md — the single-row ui_settings table + migration 0014, the app/core/theming.py resolver, the admin-gated GET/PUT /api/ui-settings, /api/config serves effective strings; unit + integration tests.
  2. 02_inline_theme_injection.md — theme_style_tag + the CachingMiddleware before-</head> injection (byte-identical no-op when unset); caching/theming unit tests.
  3. 03_retire_css_file_theming.md — delete BOR_THEME + validator, the /api/config theme key, frontend/assets/themes/, brand.js step 7, the Containerfile ship line; affected suites updated/deleted in place.
  4. 04_theme_tab_shell.md — the 7th shell view: #nav-theme + #view-theme (gate + static form skeleton), header.js admin reveal, router.js mapping, main.py shell route, HTML_PAGES entry.
  5. 05_theme_editor.md — theme.js: effective-value populate, live preview, Save/Reset (§7.4), WCAG contrast warnings; view styles.
  6. 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.css drift test), /api/config effective 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-theme tag anywhere); rg "BOR_THEME|themes/" app/ frontend/ Containerfile → nothing (the doc-history citations in comments excepted, house style).
  • Admin-only: /theme.html shows 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 --brand matches 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 pyright clean.
  • One atomic Conventional Commits commit for the phase (--no-gpg-sign), .agents/ phase files moved to complete/ by the pipeline.