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
@@ -1,47 +0,0 @@
# 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.
@@ -1,45 +0,0 @@
# Task 01 — `ui_settings` store + resolver + admin-gated API
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "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."
**Story:** n/a (TODO-derived).
## Objective
The persistence + resolution half: a single-row `ui_settings` table (3 nullable strings, 8 nullable colors), the `app/core/theming.py` resolver (DB-over-env strings, built-in color fallback), the admin-gated `GET/PUT /api/ui-settings`, and `/api/config` serving the **effective** strings. No frontend or injection yet.
## Work
1. `app/models.py` — new `UiSettings` model (table `ui_settings`, house docstring citing phase 91 + the NULL = default rule):
- `id: Mapped[int]` PK (`default=1` — the single row is always id 1; `GET` creates nothing, `PUT` upserts).
- `app_name`, `input_placeholder`, `footer_text` — `Mapped[str | None]`, `String(300)`, `nullable=True` (NULL/empty = "use the env default" — B1).
- `bg`, `surface`, `ink`, `ink_soft`, `line`, `brand`, `brand_soft`, `brand_ink` — `Mapped[str | None]`, `String(7)`, `nullable=True` (NULL = built-in — B1).
- Update the module docstring's table list with one line for `ui_settings`.
2. `alembic/versions/0014_ui_settings.py` (NEW — house header style, `Revision ID: 0014`, `Revises: 0013`, one-sentence rationale: the phase-91 admin Theme tab persists its values in one row; NULL = default): create the table (PK `id`, the 3 strings `sa.String(300, nullable=True)`, the 8 colors `sa.String(7, nullable=True)`); `downgrade()` drops it.
3. `app/core/theming.py` (NEW — module docstring: phase 91, the single source of the built-in identity palette, re-homing the `frontend/assets/themes/README.md` variable table + the 5 contrast pairs BEFORE task 03 deletes that file):
- `BUILTIN_COLORS: dict[str, str]` — the 8 built-ins keyed by bare variable name: `{"bg": "#0f0a0a", "surface": "#1a0f0f", "ink": "#f0e6e6", "ink_soft": "#b8a8a8", "line": "#2d1a1a", "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5"}` (copied from `frontend/assets/styles.css` `:root`).
- `COLOR_FIELDS: tuple[str, ...]` — the 8 keys in the README's order (used by the resolver, the API, and the tag renderer).
- `effective_settings(session) -> dict[str, str]` — strings: the DB value if it is a non-empty `str`, else the env value (`get_settings().app_name` / `.input_placeholder` / `.footer_text`); colors: the DB value if not None, else `BUILTIN_COLORS[k]`. Returns 11 keys (`app_name`, `input_placeholder`, `footer_text`, + the 8 colors).
- `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;…;}</style>` with all 8 in `COLOR_FIELDS` order (task 02 consumes this; landing it here keeps the pure logic unit-testable in one module).
4. `app/schemas.py` — `UiSettingsIn` (all 11 fields `str | None = None`) + `UiSettingsOut` (all 11 fields `str`).
5. `app/api/ui_settings.py` (NEW router, house style — the tokens router's `require_admin` precedent):
- `GET /` → `effective_settings(session)` → `UiSettingsOut`.
- `PUT /` → validate: each string `strip()`, empty → None, `len > 300` → `HTTPException(422, f"{field} is too long (max 300)")`; each color must match `^#[0-9a-fA-F]{6}$` → lowercase, else `HTTPException(422, f"{field} must be a #rrggbb hex color")`; **a color equal to its built-in is stored as None** (defaults leave the row empty — the no-op injection contract). Upsert the id-1 row (SELECT → update-or-insert; a concurrent PUT is single-admin — the last writer wins, note it in the docstring), commit, return the new effective values.
- Both routes admin-only (the router-level dependency, `require_admin` — 403 anonymous + token user).
6. `app/main.py` — `include_router(ui_settings_router, prefix="/api/ui-settings")` (with the other admin routers).
7. `app/api/config.py` — `GET /api/config` now serves the **effective** strings (open the short-lived session the sync endpoints use — `app/db.py` house pattern; the route is sync, matching the middleware world): `app_name` / `input_placeholder` / `footer_text` from `effective_settings`; the `theme` key STAYS for this task (task 03 deletes it) and `docs_repo_configured` / `version` are untouched. Update the module + route docstrings (phase 91: effective values = DB-over-env).
8. Tests:
- `tests/unit/test_theming.py` (NEW): `BUILTIN_COLORS` equals the `:root` values parsed out of `frontend/assets/styles.css` (drift guard); `effective_settings` — missing row → env strings + built-ins; DB row overrides win; empty-string DB string falls back to env; `theme_style_tag` — all-built-in → `""`, one changed → the tag with all 8 vars in order, exact string shape.
- `tests/unit/test_ui_settings.py` (NEW): PUT validation (422 too-long naming the field, 422 bad hex naming the field, lowercase normalization, built-in→None normalization, empty string→None), GET effective merge, upsert creates-then-updates (id 1).
- `tests/integration/` (extend the existing API-test convention — find the file that tests `/api/config` or the tokens admin gate and follow it): anonymous GET/PUT → 403; token-user (role "user") → 403; admin → 200 both; `/api/config` — env-only deployment returns the env strings; after an admin PUT, `/api/config` returns the DB strings; the `theme` key still present in THIS task's state.
- ASSUMPTION: (B1, owner-locked 2026-09-09) colors have NO env fallback — the built-in palette is the default; the three strings keep their env vars as fallbacks, DB value wins when set.
- ASSUMPTION: (B3, owner-locked 2026-09-09) the tab covers exactly the 8 identity variables; the semantic families (`--accent-*`, `--ok-*`, `--err-*`) stay non-configurable.
## Testing & Quality
- Unit/integration: the behaviors listed in Work item 8.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `uv run alembic upgrade head` applies `0014` cleanly; `uv run pytest tests/unit/test_theming.py tests/unit/test_ui_settings.py -v --no-cov` green.
- [ ] `GET /api/ui-settings` (admin) returns the effective 11 keys; `PUT` persists + normalizes; 403 for anonymous/token users.
- [ ] `/api/config` returns DB-over-env strings (integration-proven).
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
- [ ] no behavior change in completed work (brand.js still reads `theme` from `/api/config` — untouched until task 03).
@@ -1,37 +0,0 @@
# Task 02 — Pre-paint injection: `<style id="bor-theme">` before `</head>`
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Theme should load immediately, not pop in after the page load."
**Story:** n/a (TODO-derived).
## Objective
A themed deployment renders its palette on the **first paint**: every served HTML page (all `HTML_PAGES` + `/shared/<token>`) carries an inline `<style id="bor-theme">:root{…}</style>` inserted server-side into the response body, before the browser applies any stylesheet. Unset deployments (no row / all-defaults row) stay byte-identical to today.
## Work
1. `app/core/theming.py` — add the pure injection helper (keeps the middleware DB-agnostic and pure-testable):
- `inject_theme(html: str, tag: str) -> str` — `tag == ""` or no first `</head>` occurrence → return `html` unchanged; `id="bor-theme"` already present (defensive idempotence — the static files never contain it) → unchanged; else insert the tag (with a leading newline for readable HTML) immediately BEFORE the first `</head>`.
2. `app/core/caching.py` — `CachingMiddleware.dispatch`, the HTML rewrite branch (the `try: new_body = rewrite_asset_refs(…)` block):
- After the asset-ref rewrite, build the tag: open a **short-lived session** (the sync-endpoint pattern from `app/db.py`), `tag = theme_style_tag(effective_colors(effective_settings(db)))` (task 01's resolver + renderer — the resolver returns the 11 effective keys; feed the 8 color keys to the renderer; if task 01 shaped `effective_settings` to return colors directly, use that — keep ONE call), close in `finally`. Wrap the whole read in `try/except Exception` → `logger.exception("cache busting: theme read failed for %s — serving without the theme tag", path)` and `tag = ""` (loadHealth house style — a DB blip or a pre-migration boot serves the built-in palette, the page never breaks, and the no-cache headers still apply).
- `new_body = inject_theme(html, tag).encode("utf-8")` (replace the current direct `rewrite_asset_refs(…).encode(…)` line).
- The no-tag path MUST produce the exact bytes of today: `inject_theme(html, "")` is the identity — assert it in tests (the byte-identical contract, B4).
- Module docstring: one paragraph — the phase-91 inline theme tag (the pre-paint theme; the asset rewrite is untouched; `/api/*` and `/assets/*` still byte-identical).
- NO process cache for the tag (the theme changes at runtime from the admin tab; the per-response single-row SELECT is the design, owner-locked in `00_phase.md`).
3. `tests/unit/test_caching.py` — extend (follow the file's existing app-builder convention):
- the unset case: the served page body is byte-identical to the phase-33/54 rewrite-only output (no `#bor-theme`).
- the themed case: with a `ui_settings` row (one changed color), `GET /` (and one non-shell page, e.g. `/document.html`, plus a `/shared/<token>`-shaped response via the prefix branch) carries the tag immediately before `</head>`, with all 8 `--*` vars in `COLOR_FIELDS` order.
- the DB-failure case: monkeypatch the session/`effective_settings` to raise → the page still serves (200, no tag, no-cache headers intact).
- `inject_theme` pure tests (in `tests/unit/test_theming.py`): empty tag identity, no-`</head>` identity, double-injection idempotence, exact placement string.
4. Verify (running app, DB up): `uv run alembic upgrade head` then `uv run uvicorn app.main:app` + `curl -s / | rg -c "bor-theme"` → 0 (unset); after an admin `PUT /api/ui-settings` with one color → 1, and the tag sits before `</head>` (manual check — the E2E (task 06) is the permanent proof).
- ASSUMPTION: (B4, owner-locked 2026-09-09) only the 8 COLORS are injected pre-paint; the 3 strings keep the `brand.js` runtime application.
- ASSUMPTION: the tag is computed per response (no process cache) — homelab page traffic makes the single-row SELECT negligible, and a runtime theme change must be visible on the next request without a restart.
## Testing & Quality
- Unit/integration: Work item 3 (caching + theming); the existing caching suite (phase 33/54 assertions: no-304, `?v=` rewrite, immutable assets, SSE pass-through) stays green untouched.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] Unset deployment: `curl -s /` is byte-identical to the pre-task output (modulo the existing `?v=` rewrite) — no `#bor-theme` anywhere.
- [ ] Themed deployment: every HTML page + `/shared/<token>` carries exactly one `<style id="bor-theme">` immediately before `</head>`; the browser's computed `--brand` equals the saved value on first paint (E2E task 06 proves it).
- [ ] `uv run pytest tests/unit/test_caching.py tests/unit/test_theming.py -v --no-cov` green.
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
- [ ] no behavior change in completed work (caching contract, SSE, assets — all untouched).
@@ -1,34 +0,0 @@
# Task 03 — Retire the CSS-file theming (`BOR_THEME` + `frontend/assets/themes/`)
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Remove the custom css file theming."
**Story:** n/a (TODO-derived).
## Objective
The file-based theme mechanism is gone end-to-end: the env var, its validator, the `/api/config` key, the brand.js `<link>` insertion, the themes directory, and the Containerfile ship line. The only remaining theming surface is the admin tab (tasks 01/02 already landed; 04/05/06 follow).
## Work
1. `frontend/assets/themes/README.md` → **re-home before deleting**: the built-in 8-variable table and the 5 contrast pairs must live on in `app/core/theming.py`'s module docstring (task 01 already re-homed them per `00_phase.md` — verify the docstring is complete: variable table with roles + built-in values, the 5 WCAG pairs, the "never white-on-brand" note) so nothing is lost. Then delete the whole directory: `frontend/assets/themes/` (`indigo.css`, `README.md`).
2. `app/config.py` — delete the `theme: str = ""` setting (and its docstring block) and the `_theme_bare_css_filename` field_validator; update the module docstring/settings list if it names `BOR_THEME`.
3. `app/api/config.py` — drop the `"theme": settings.theme` key from the `/api/config` response; update the route docstring (the phase-62 key list loses `theme`; the phase-91 effective strings stay).
4. `frontend/assets/brand.js` — delete step 7 (the `themeName` block: the `#theme-override` guard, the styles-link finder, the `<link>` insertion, the onerror degrade) and its docstring paragraphs (the item-7 contract line, the "7." in the numbered list — renumber nothing else; the no-op-property paragraph's theme mention). The `BOR_CONFIG_PROMISE` fetch and the name/placeholder/footer passes are untouched (B4).
5. `Containerfile` — delete the `&& cp -r ./assets/themes /out/assets/themes \` stage-1 line (line 27) and fix the continuation chain so the remaining `cp` lines still concatenate.
6. Affected tests — update in place (no deleted coverage without a replacement):
- `tests/unit/test_themes.py` — this file tests the retired mechanism: DELETE it (its subject no longer exists; the built-in-value drift guard now lives in `tests/unit/test_theming.py`'s `styles.css` parse test, which is the replacement).
- `tests/unit/test_config.py` — remove the `theme` validator tests (boot-refusal on bad filenames, the happy path); keep every other Settings test.
- `tests/unit/test_frontend_brand.py` — remove the step-7 assertions (link insertion, `#theme-override` guard, missing-file degrade); keep the name/placeholder/footer/boot-promise assertions.
- `tests/e2e/test_ui_customization.py` — strip the theme legs (set `BOR_THEME`, assert the link / themed page); keep + green the app-name / placeholder / footer legs (their behavior is unchanged — `/api/config` now answers with the effective values, which for an env-only deployment are the same strings).
- Sweep: `rg -n "BOR_THEME|theme-override|assets/themes|indigo" app/ frontend/ tests/ Containerfile README.md .env.example` → fix or remove every hit (doc-history citations inside completed-phase files under `.agents/phases/complete/` are history — never touched; the `README.md` deployment section naming `BOR_THEME` gets the paragraph re-pointed at the admin Theme tab).
- ASSUMPTION: (B2, owner-locked 2026-09-09) `BOR_THEME` and `frontend/assets/themes/` are deleted entirely — the Theme tab is the only theming surface; a deployment with `BOR_THEME` set in `.env` simply ignores it (no boot failure — the setting no longer exists; note the removal in the commit body).
- ASSUMPTION: the `.env.example` entry for `BOR_THEME` (if present — verify) is deleted; `.env` files are local artifacts (never edited, never committed).
## Testing & Quality
- Unit/integration: the updated suites of Work item 6 green; `rg` sweep clean.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `rg -n "BOR_THEME|theme-override|assets/themes|indigo\.css" app/ frontend/ tests/ Containerfile .env.example` → no matches.
- [ ] `frontend/assets/themes/` does not exist; `git status` shows the deletions staged.
- [ ] An app booted WITHOUT `BOR_THEME` in `.env` serves byte-identical pages (built-in palette; the no-op injection from task 02).
- [ ] `uv run pytest -v --no-cov` (full unit + integration) green; `uv run ruff check . && uv run pyright` clean.
- [ ] no behavior change in completed work (brand name/placeholder/footer application, caching, sync — all green).
@@ -1,38 +0,0 @@
# Task 04 — The 7th shell view: nav link, `#view-theme` skeleton, router + route wiring
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "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."
**Story:** n/a (TODO-derived).
## Objective
The Theme tab exists in the shell exactly like Tuning/Tokens: an admin-only nav link, a `#view-theme` section (gate + static form skeleton), the `router.js` pathname mapping, the `/theme.html` shell route, and the `HTML_PAGES` entry. The editor behavior lands in task 05.
## Work
1. `frontend/index.html` — nav: after the `#nav-tokens` link, `<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>` (ship-hidden — `header.js` reveals for admin; no mobile-dropdown copy exists for the other admin nav links, so none is added).
2. `frontend/index.html` — new view section AFTER `#view-tokens` (before the doc modal / page footer), the exact house pattern (hidden + inert pair — WCAG contract, AGENTS.md rule 5):
- `<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">` with a container:
- `#theme-gate` — the EXACT `#sources-gate` pattern (phase 16): glyph, `#theme-gate-title` "Sign in to change the theme", sub-copy (the theme + branding is admin-only; chat stays open), `#theme-gate-link` → `/login.html?next=/theme.html`.
- `#theme-content` (hidden — `theme.js` reveals it for admin, the `#git-sources-content` pattern) holding:
- page-head: `<h1>Theme</h1>` + a sub naming the behavior — "Pick the palette and branding; the theme is baked into the page it is served on — it applies on the first paint, no pop-in."
- `#theme-form` (STATIC markup — the E2E-stable-selectors house convention; `onsubmit` handled by `theme.js`, no real submit):
- "Branding" fieldset/group: 3 labeled text inputs — `#theme-app-name` (label "App name", `maxlength=300`), `#theme-placeholder` (label "Chat input placeholder", `maxlength=300`), `#theme-footer` (label "Footer line", `maxlength=300`).
- "Palette" fieldset/group: 8 labeled `<input type="color">` — `#theme-bg`, `#theme-surface`, `#theme-ink`, `#theme-ink-soft`, `#theme-line`, `#theme-brand`, `#theme-brand-soft`, `#theme-brand-ink` (each visible `<label for=…>` names the variable + its role, e.g. "Brand accent (buttons, links)").
- `#theme-save` (type=button, primary — "Save theme") and `#theme-reset` (type=button, secondary — "Reset to defaults").
- `#theme-error` (`role="alert"`, hidden), `#theme-result` (`role="status"`, hidden), `#theme-contrast` (`role="alert"`, hidden — the WCAG warnings from task 05).
- The per-view footer stays where the other views' footers are (the fold convention of the RAG/Sources sections).
3. `frontend/assets/header.js` — in `initSharedHeader()`, after the Tokens nav-link block: the same two-line reveal — `const navTheme = document.querySelector("#nav-theme"); if (navTheme) navTheme.hidden = !admin;` — with a phase-91 comment (admin-only, ship-hidden/reveal-for-admin contract).
4. `frontend/assets/router.js` — the seven-view mapping, one entry in each of the five tables (the phase-76/79 pattern): `VIEW["/theme.html"] = "theme"` (pathname → slug); `VIEW_PATH.theme = "/theme.html"` (nav-link active stamping); `TITLES.theme = "Theme · Brain of Reese"`; `DESCRIPTIONS.theme` (one line, the view's one-liner); `VIEW_MODULES.theme = () => import("./theme.js")` (the dynamic-import pattern — the module exports `mount(root)` and the router mounts a view ONCE, root = the view's `<section id="view-theme">`, scoped lookups, the `fetchIsAdmin()` gate from `header.js` — the phase-76 shell-view-module convention).
5. `app/main.py` — add `"/theme.html"` to the `_shell_routes(...)` tuple (after `"/tokens.html"`, phase-91 comment) so the deep link serves the shell.
6. `app/core/caching.py` — add `"/theme.html"` to `HTML_PAGES` (the no-cache + `?v=<token>` contract — without it the deep link pins stale assets after a deploy).
7. `frontend/assets/styles.css` — minimal skeleton styles for the view (the `.theme-shell` container, the fieldset/group spacing, the color-input row — reuse the tuning view's form classes where they fit; the editor's full styling lands in task 05).
- ASSUMPTION: (B5, owner-locked 2026-09-09) the tab is admin-only, same pattern as Tuning/Tokens: hidden by default, `header.js` reveals for admin; anonymous sees the gate; a token user gets the anonymous branch (link hidden, gate shown).
## Testing & Quality
- Unit/integration: no new Python logic beyond the two tuple entries — the existing shell-route/caching suites must stay green; the view's behavior is proven by task 06's E2E.
- Coverage: **>90%** on this task's new/modified code (trivial here).
## Completion Criteria
- [ ] `curl -s /theme.html` serves the shell (same body as `/`); the router shows `#view-theme` for that pathname.
- [ ] Admin session: the "Theme" nav link is visible on every page; the form skeleton renders. Anonymous: link hidden, `#theme-gate` shown.
- [ ] The existing E2E suites (nav/header/caching related — `test_tuning_nav_link.py`, `test_cache_busting.py`, `test_asset_cache_revalidation.py`) stay green; `uv run pytest -v --no-cov` (unit + integration) green; `uv run ruff check . && uv run pyright` clean.
- [ ] no behavior change in completed work.
@@ -1,33 +0,0 @@
# Task 05 — The editor: `theme.js` (pickers, fields, live preview, save/reset, contrast warnings)
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "…with buttons and color pickers." (the tab the previous task wired; this task gives it behavior)
**Story:** n/a (TODO-derived).
## Objective
`theme.js` makes the Theme tab work: it populates the form with the EFFECTIVE values, previews color changes live across the whole page while picking, saves via the admin API with the §7.4 never-stale button lifecycle, resets to the built-in defaults, and warns (never blocks) when a saved palette would break the 5 WCAG text/background pairs.
## Work
1. `frontend/assets/theme.js` (NEW — the phase-76 shell-view-module contract, the `tuning.js`/`tokens.js` shape):
- `export async function mount(root)` — every DOM lookup scoped to `root` (`#theme-gate`, `#theme-content`, `#theme-form`, the 11 inputs, `#theme-save`, `#theme-reset`, `#theme-error`, `#theme-result`, `#theme-contrast`); module-top state only (no top-level DOM access — the explicit-init house convention).
- **Gate:** `fetchIsAdmin()` (the cached `header.js` promise — zero extra requests): admin → hide `#theme-gate`, reveal `#theme-content`; anonymous/token-user → gate stays (the nav link is already hidden by `header.js`; the direct-URL case is the gate's job).
- **Load:** GET `/api/ui-settings` → populate the 11 inputs with the EFFECTIVE values (the tab always shows the live theme — env defaults when the row is empty). A failed fetch keeps the static form + shows `#theme-error` with a retry (the loadHealth house style — never a blanked panel).
- **Live preview:** on `input` of any of the 8 color inputs → `document.documentElement.style.setProperty("--" + var, value)`; on `input` of a text field → no page effect (strings apply on save via `brand.js` on the next load — say so in the fieldset legend/sub-copy). On reset and on every successful save: clear all 8 `setProperty` overrides (`removeProperty`) so the page reflects the served (injected) theme, not stale preview state.
- **Save** (`#theme-save`): the §7.4 lifecycle — disable + "Saving…" → PUT `/api/ui-settings` with the 11 form values (a cleared/empty text field → `null`; colors always their current hex — the server's built-in→NULL normalization keeps the row empty when the owner saves the defaults) → 200: `#theme-result` "Theme saved." (role=status), refetch + re-populate (canonical state), clear the preview overrides, clear `#theme-contrast` → re-enable + restore label. Failure: 422 → `#theme-error` with the server detail (naming the field), form KEPT, button restored; other non-2xx → the fixed error line.
- **Reset** (`#theme-reset`): same §7.4 lifecycle ("Resetting…") → PUT with all 11 values `null` → `#theme-result` "Reset to the built-in theme." → refetch + re-populate (the env/built-in defaults) + clear preview overrides.
- **WCAG contrast (the `00_phase.md` Design's 5 pairs):** a local pure helper — `contrastRatio(fgHex, bgHex)` via WCAG relative luminance (sRGB → linear → L) — evaluated on every `input`/save over the CURRENT form values for the 5 pairs (`--ink` on `--bg`, `--ink` on `--surface`, `--ink-soft` on `--surface`, `--bg` on `--brand`, `--brand-ink` on `--surface`); any pair < 4.5:1 → `#theme-contrast` (role=alert) lists each failing pair ("--ink on --bg: 3.2:1 — needs 4.5:1"); all pass → hidden. **Warning-only** — Save is never disabled by it (the owner's homelab palette; the built-in stays AA).
- A `bor:view-refresh` listener (the phase-77 re-show hook) → re-run the load (the tab always shows the settled server state when re-shown).
2. `frontend/assets/styles.css` — the view's styling: `.theme-shell` (the container, the 46rem/72rem frame conventions per AGENTS.md rule 5 — this is a form view, the tuning-view width pattern), the fieldset/group headings, the 3-column (desktop) / 1-column (mobile) palette grid with the color swatch inputs sized for touch, the `#theme-contrast` warning styling (the `--err-*` family — states are semantic colors, never themed), the §7.4 disabled-button look (the existing `.sync-btn`/form-button conventions).
3. `frontend/index.html` — the `#theme-content` sub-copy naming the preview + save behavior (one line under the h1: "Changes preview live as you pick; **Save theme** bakes the palette into every page it is served on — it applies on the first paint, no pop-in."); the palette fieldset legend noting the 5 pairs are checked against WCAG 2.1 AA (4.5:1).
- ASSUMPTION: (B4, owner-locked 2026-09-09) the 3 strings keep the `brand.js` runtime application — the tab saves them to `ui_settings` and they take effect via the `/api/config` boot fetch on the next page load (the live preview covers colors only; the sub-copy says so).
## Testing & Quality
- Unit/integration: no new Python logic — the existing suites must stay green; the editor is proven by task 06's E2E (save/reset/preview/gate) — keep `theme.js` free of any behavior that the E2E cannot see.
- Coverage: **>90%** on this task's new/modified code (frontend — exercised by the E2E).
## Completion Criteria
- [ ] Admin on `/theme.html`: the form shows the effective values; moving a picker repaints the page live; Save/Reset follow the disable-relabel-restore lifecycle and land role=status confirmations; a bad save shows the server detail in `#theme-error` and keeps the form.
- [ ] Setting `--ink` to a near-`--bg` value lists the failing pair in `#theme-contrast` and still allows saving; restoring AA hides it.
- [ ] Anonymous direct load of `/theme.html`: the gate (not the form).
- [ ] `uv run pytest -v --no-cov` (unit + integration) green; `uv run ruff check . && uv run pyright` clean.
- [ ] no behavior change in completed work (the other views' mounts/gates untouched).
@@ -1,28 +0,0 @@
# Task 06 — Story E2E: the Theme tab, the pre-paint proof, the admin gate, the reset
**Phase:** `91_admin_theme_tab` · **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 — one story, one phase, one isolated E2E file: `tests/e2e/test_admin_theme_tab.py`).
## Objective
The phase's dedicated Playwright suite proves the whole contract: the admin tab edits all 11 values with fields and pickers; a saved theme is baked into the served HTML (inline `:root` before `</head>` — the no-pop-in proof) and visible to anonymous visitors; the API and the tab are admin-only; reset restores the built-in palette and byte-identical pages.
## Work
1. `tests/e2e/test_admin_theme_tab.py` (NEW — module header in the house style: story gate, isolated-run command `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov`, DB up: `podman compose up -d db`, the per-module app env pattern from the tuning/tokens suites — a scratch DB state via the autouse clean/truncate helper, `BOR_GIT_SOURCES` forced empty, the env branding vars UNSET so the effective strings start at the template defaults):
- **Test 1 — the tab (admin):** sign in as admin → `/theme.html` → nav "Theme" link visible, `#theme-content` revealed, the gate hidden → the form shows the 11 inputs with the effective defaults (3 template strings + the 8 built-in hexes, read straight from `styles.css`'s `:root` in-test so the test can't drift) → set a distinct palette (e.g. `--brand` `#4f46e5`, `--bg` `#0b1020`, `--surface` `#111730`, `--ink` `#e6e9f5`, `--ink-soft` `#a8b0d0`, `--line` `#232a4a`, `--brand-soft` `#1e2447`, `--brand-ink` `#c7d2fe`) + app name "Theme E2E" + placeholder "Ask the themed brain…" + footer "E2E footer" → **Save theme** → button "Saving…" then restored, `#theme-result` "Theme saved." (role=status) → the inputs re-populate to the saved (effective) values.
- **Test 2 — pre-paint, for everyone:** after the save, `page.goto("/")` (admin) → assert the RAW served HTML (`page.content()` before any JS mutation is observable) contains `<style id="bor-theme">` immediately before `</head>` with all 8 `--*` vars = the saved hexes, and `getComputedStyle(document.documentElement).getPropertyValue("--brand")` === the saved hex on load (the first-paint proof — the inline tag precedes every stylesheet application); a FRESH anonymous context (no auth) → `page.goto("/")` → the same inline tag + computed values (anonymous visitors see the theme) AND the app name "Theme E2E" applied post-fetch by `brand.js` (the B4 split: colors pre-paint, strings via the boot fetch) → `page.goto("/shared/<token>")`-shaped page is not required (the middleware branch is unit-tested in task 02) — skip.
- **Test 3 — the gate + the 403:** anonymous context → `/theme.html` → the gate visible (`#theme-gate`, sign-in link `?next=/theme.html`), `#theme-content` hidden/inert, the "Theme" nav link hidden; API: anonymous `PUT /api/ui-settings` → 403; a token-user session (the phase-79 token-login helper from `tests/e2e/test_api_tokens.py`'s pattern) → `PUT` → 403 and the nav link hidden on their shell.
- **Test 4 — reset restores the built-in:** admin → `/theme.html` → **Reset to defaults** → "Reset to the built-in theme." → the form re-populates to the 11 defaults → `page.goto("/")` → NO `<style id="bor-theme">` in the served HTML (byte-identical to the unset deployment) and computed `--brand` back to `#f43f5e`.
- **Test 5 — contrast warning:** admin → set only `--ink` to a color within 0.1 ratio of `--bg` (a deterministic failing pair, e.g. `--ink` `#101010` on `--bg` `#0f0a0a`) → `#theme-contrast` lists that pair with the ratio (role=alert) → **Save theme** still succeeds (warning-only) → restore (Reset) so the suite's final state is clean.
2. Fixtures/helpers — per-module local (the archive-upload suite's convention): the app-server env, the admin-login helper (reuse `tests/e2e/auth_helpers.py` if it exposes a page-level admin sign-in — check first; otherwise mirror the local pattern), the DB clean helper; the 8 built-in hexes parsed in-test from `frontend/assets/styles.css` (single source of truth — never duplicated in the test body).
- ASSUMPTION: (B5, owner-locked 2026-09-09) admin-only like Tuning/Tokens — the gate for anonymous, 403 for token users.
- ASSUMPTION: the "no pop-in" assertion is made on the served HTML + the computed style at load (the inline tag is the mechanism the middleware unit tests pin; the E2E asserts the observable consequence).
## Testing & Quality
- E2E in isolation (DB up): `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` — green (5 tests).
- Coverage: this task adds no `app/` code — the phase coverage gate (>90%) is satisfied by tasks 01/02.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` green.
- [ ] Proven end-to-end: save → inline `:root` in the raw HTML before `</head>` → themed computed colors at first paint for admin AND anonymous → gate + 403s for non-admins → contrast warning lists the failing pair without blocking → reset → no tag, built-in palette.
- [ ] no behavior change in completed work.