chore(agent): phase 90-91 roadmap from TODO.md — upload without scan + admin theme tab
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# Phase 90 — Upload stops scanning: the RAG page's Sync button owns the scan
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list. Update the button text as well to read "Upload" rather than "Upload and scan""
|
||||||
|
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-09).
|
||||||
|
**Context:** `POST /api/git-sources/upload` (`app/api/git_sources.py::upload_archive`) does three inline gates (1. name/format 422, 2. one-at-a-time 409, 3. 1 MiB-chunk stream into a dotfile temp with the `upload_max_mb` 413 cap), returns 202 + `UploadAccepted`, then runs `_run_upload` in the background: 4. unpack (traversal/symlink/corrupt/over-cap → `failed`), 5. atomic swap-in (same-name re-upload replaces in place), 6. row upsert by `path` (`kind='local'`, `added_at` preserved, the row's saved `ignore_paths` captured — phase 89), 7. fail-fast `check_models`, 8. `import_sources(…, prune=True, progress=<hook>, ignore_by_root=…)` + change-gated `regenerate_overview`, 9. INFO log line, 10. `success` with the `UploadOut` count fields in `detail`. `GET /api/git-sources/upload/status` mirrors the phase-32 sync-status key set (`state`, `started_at`, `finished_at`, `detail`, `error`, `current_file`, `files_done`, `files_total`) and is what `frontend/assets/git-sources.js` polls (2 s poll, `startUploadPolling`; boot re-attach `initUploadStatus`). The Sources view (`#view-git-sources`, static form in `frontend/index.html`, `#archive-upload-btn` labeled "Upload & scan") and the RAG view (`#view-rag`) where the admin-only `#sync-btn` ("Sync sources") lives; `app/api/sync.py::_run_sync` already mirrors every `git_sources` row, including `kind='local'` (the stored directory, re-verified, missing dir → `local source missing: <path>`), honoring each row's `ignore_paths` (phase 89) — so a registered upload row is imported on the next sync with **no sync change**. The phase-89 per-row "Ignore paths" editor (`#ignore-editor-dialog` in `git-sources.js`) is the UI the deferral exists for: upload → row visible → edit ignore list → sync.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Uploading a source archive stores it, unpacks it, and registers the source row — and **nothing else**: no model check, no import, no overview regeneration. The scan is left to the "Sync sources" button on the RAG page, giving the owner time to edit the new source's ignore list first. The upload button reads "Upload" (was "Upload & scan") and all surrounding copy says the upload unpacks only and the sync scans.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `89_source_ignore_paths` (complete) — the per-row ignore-list editor is the workflow this phase unlocks; the upload still captures the row's saved `ignore_paths` on re-upload.
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
|
||||||
|
- **What the background run keeps (A1, owner-locked 2026-09-09).** Steps 1–6 unchanged: the three inline gates, the streaming receive, unpack (with all its sanitized `failed` states), the zero-entry `failed`, the atomic swap-in, the row upsert (existing row left as-is — `added_at` preserved, `ignore_paths` preserved — new row inserted with `kind='local'`). The scan = steps 7–8 (and their success detail) leave the upload entirely.
|
||||||
|
- **Status contract (A2, owner-locked 2026-09-09).** Same key set as today. Terminal `success` now carries `detail = {"message": "uploaded"}` (no count fields), `current_file = null`, `files_done = files_total = 0`. `failed` states unchanged in shape and wording (unpack/zero-entry/swap/row). `current_file`/`files_done`/`files_total` stay `null`/`0`/`0` for the whole background run (unpack has no file-level progress hook) — the UI's in-flight label loses its "(n/m)" file count for uploads (sync keeps its live file label; phase 64).
|
||||||
|
- **Sync unchanged (A4, owner-locked 2026-09-09).** `_run_sync` already imports `kind='local'` rows with prune + ignore lists; task 03 proves the full loop in E2E. No change to `app/api/sync.py` in this phase.
|
||||||
|
- **Copy (A3, owner-locked 2026-09-09).** Button label exactly **Upload** (static label, `restoreUploadButton`, and any relabel sites). The success moment keeps the phase-64 toast ("Successfully uploaded — <file>") but the settled result line points at the RAG page: "Uploaded <name> — press **Sync sources** to import it." `#git-sources-hint` and the table caption are re-worded: uploads unpack and register only; the Sync button scans.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_upload_defers_scan.md` — the background run stops after the row upsert; success status is a no-count "uploaded" payload; unit tests.
|
||||||
|
2. `02_upload_ui_and_copy.md` — "Upload" button, in-flight/result/toast copy, `#git-sources-hint` + caption; affected existing suites updated in place.
|
||||||
|
3. `03_e2e_upload_then_sync.md` — the story's dedicated Playwright E2E: upload → nothing indexed → edit ignore list → Sync sources on the RAG page → docs land, ignores honored.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: the upload pipeline's new terminal states (row registered, KB untouched, no model call); the status payload shape; the sync-imports-a-fresh-upload-row path already covered by the sync suites (no change) — plus every affected existing test updated, none deleted without replacement.
|
||||||
|
- Coverage: **>90%** on new/modified code (`uv run pytest --cov=app --cov-report=term-missing`).
|
||||||
|
- This phase's Playwright E2E suite: `tests/e2e/test_upload_no_scan.py`, run in isolation (`uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov`).
|
||||||
|
- Affected existing E2E suites (`test_archive_upload_sources.py`, `test_sync_upload_progress.py`) updated in place and green.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Uploading an archive on the Sources page settles with the "ready for sync" result and indexes **zero** documents; the source row is present with its ignore editor.
|
||||||
|
- [ ] The upload button reads "Upload"; no "Upload and scan" copy remains anywhere (`rg "Upload &" frontend/` → nothing).
|
||||||
|
- [ ] "Sync sources" on the RAG page imports the uploaded source (respecting its edited ignore list) — proven by the isolated E2E.
|
||||||
|
- [ ] 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.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Task 01 — The upload's background run stops after the row upsert (no scan)
|
||||||
|
|
||||||
|
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list."
|
||||||
|
**Story:** n/a (TODO-derived).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`_run_upload` keeps the gates, streaming, unpack, swap-in, and row upsert — and drops the model check, the import, and the overview regeneration. The upload's job ends with the source row registered and the folder on disk; the RAG page's Sync button performs the scan.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/api/git_sources.py` — `_run_upload` (the post-202 background pipeline):
|
||||||
|
- Keep steps 4–6 byte-for-byte in behavior: unpack (`unpack_archive` + zero-entry `failed`), atomic `swap_in`, the short-lived-session row upsert by `path` (existing row left as-is — `added_at` + `ignore_paths` preserved; new row `kind='local'`, `IntegrityError` backstop message unchanged).
|
||||||
|
- **Delete step 7** (the fail-fast `check_models` — its `ModelUnavailableError → failed` arm goes with it; model availability is the sync's concern) and **step 8** (`import_sources(…, prune=True, progress=…, ignore_by_root=…)` + the change-gated `regenerate_overview` call).
|
||||||
|
- Step 9: replace the scan INFO line with one line in the same PLAN §9 house shape for the upload leg: e.g. `upload: finished name=<name> bytes=<total_bytes> total_ms=<int> state=<state>` (unpack+register only — no file counts).
|
||||||
|
- Step 10: `success` state with `detail = {"message": "uploaded"}`, `current_file = None`, `files_done = files_total = 0` (A2 — the key set is unchanged; the UI composes the user copy).
|
||||||
|
- Rewrite the docstrings that describe the pipeline (`upload_archive`'s numbered docstring — renumber steps 4–6 + the new terminal; `_run_upload`'s docstring — drop the models/import failure modes, keep the unpack/zero-entry/swap/row ones; the module docstring if it summarizes the upload flow) — house style: the docstrings are the contract.
|
||||||
|
- Imports: drop what is now unused (`check_models` / `ModelUnavailableError`, `import_sources`, `regenerate_overview`, the `UploadOut`-related names if the schema goes — see 2.) so `ruff` stays clean.
|
||||||
|
2. `app/schemas.py` — if `UploadOut` (or the upload success-detail model) exists, remove it or reduce it to the new `{"message": "uploaded"}` shape — whichever keeps the schema surface honest; `UploadAccepted` (the 202 body) is unchanged.
|
||||||
|
3. `tests/unit/test_git_sources.py` — rework the upload pipeline tests to the new contract:
|
||||||
|
- success: the folder swapped in, the row upserted (new + same-name re-upload — `added_at` preserved, `ignore_paths` preserved), **no documents created, no chunks, no model calls** (assert via the fake LLM's call log / document count), status `success` with `detail == {"message": "uploaded"}` and `current_file is None`, `files_done == files_total == 0`.
|
||||||
|
- failure states unchanged: bad unpack, zero entries, swap failure, row `IntegrityError` — same sanitized `failed` messages; the `_upload_in_progress` flag released in `finally`.
|
||||||
|
- gates unchanged: format 422 (naming the accepted set), one-at-a-time 409, size 413.
|
||||||
|
- remove any assertion that the upload imports/prunes/embeds.
|
||||||
|
|
||||||
|
- ASSUMPTION: (A1, owner-locked 2026-09-09) unpacking stays part of the upload — the folder must exist on disk for sync to walk it; only the model check + import are deferred.
|
||||||
|
- ASSUMPTION: (A2, owner-locked 2026-09-09) the `upload/status` key set is unchanged; the success `detail` carries `{"message": "uploaded"}` instead of import counts.
|
||||||
|
- ASSUMPTION: (A4, owner-locked 2026-09-09) no change to `app/api/sync.py` — it already imports `kind='local'` rows (prune + `ignore_paths`); the E2E (task 03) proves the loop.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: the behaviors listed in Work item 3; the existing upload-status and sync suites stay green untouched.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/unit/test_git_sources.py -v --no-cov` green; no test asserts an upload-driven import.
|
||||||
|
- [ ] `rg "import_sources|check_models|regenerate_overview" app/api/git_sources.py` → no matches.
|
||||||
|
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed work (sync, CLI import, ignore paths all untouched).
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Task 02 — The Sources view says "Upload" and unpacks-only copy
|
||||||
|
|
||||||
|
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Update the button text as well to read "Upload" rather than "Upload and scan"" (plus the timing clause — "doesn't give the user time to edit the ignore list" — which the copy now names: edit the ignore list, then Sync).
|
||||||
|
**Story:** n/a (TODO-derived).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The upload affordance and every piece of surrounding copy match the new contract: the button reads **Upload**, the in-flight state covers unpacking only (no "(n/m)" file count), the settled result points the owner at the RAG page's Sync button, and the Sources-page hint/caption no longer claim uploads scan. Affected existing test suites are updated in place.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html`:
|
||||||
|
- `#archive-upload-btn` (in `#archive-upload-form`, `#view-git-sources`): label "Upload & scan" → **Upload** (A3 — exactly "Upload").
|
||||||
|
- The phase-49/64 comment block above the form: re-point the contract summary — 202 at safe-on-disk, toast, processing state covers UNPACK only, success line points at Sync sources.
|
||||||
|
- `#git-sources-hint` (`role="note"`): reword the upload sentence — "Uploads unpack and register the source only — re-uploading the same filename replaces that source in place (no new folder, no duplicate row). Press **Sync sources** on the RAG page to scan it; edit the source's ignore paths first if you want files excluded." The removal/total-removal sentences stay as-is.
|
||||||
|
- The `#git-sources-table` caption: "…and uploaded archives (unpacked under the upload directory)" → drop any scan implication (unpacked + registered; the Sync button imports them).
|
||||||
|
2. `frontend/assets/git-sources.js` (the upload block, phase 64 section):
|
||||||
|
- `restoreUploadButton()`: `"Upload & scan"` → `"Upload"`.
|
||||||
|
- `fmtUploadResult(detail)`: the sync-style count shape is gone — replace with the new success copy: read `detail.message` (task 01's `{"message": "uploaded"}`) and render `Uploaded <name> — press Sync sources to import it.` (the name from the accepted upload; keep the function's single-role: the `role=status` result line text).
|
||||||
|
- Poll tick (running branch): the label is now bare "Processing…" for the whole background run (unpack has no file-level progress — no "(n/m)", no file in the title); keep the 2 s cadence, the `uploadPollTimer` ownership, and the terminal branches (success → result line + announce + `loadSources`, no second toast; failed → sanitized banner + `loadSources`, file selection kept; idle → defensive restore).
|
||||||
|
- Toast (`showUploadToast` + its call site): unchanged wording ("Successfully uploaded — <file>") — it marks the 202, which still means "safely on disk".
|
||||||
|
- `initUploadStatus` boot re-attach: a running run re-enters the (now bare) processing state; a terminal `success` re-renders the new result line.
|
||||||
|
- Module header docstring (the phase 64/65 upload paragraph at the top of the file): update to the unpack-only contract, citing phase 90.
|
||||||
|
- The success announcement ("Archive uploaded: …" line) → reword to name the next step ("Archive uploaded — press Sync sources to import it.").
|
||||||
|
3. Affected existing suites — update in place (assert the NEW contract; do not delete coverage without a replacement):
|
||||||
|
- `tests/e2e/test_archive_upload_sources.py` — the upload leg: button text "Upload", 202 toast, processing without "(n/m)", settled "ready for sync" line, **no indexed documents after upload** (assert the RAG table is unchanged / stats 0 for the source), the source row present with its "Ignore paths" control; the gates (422/409/413) and re-upload-in-place assertions keep their intent against the new flow.
|
||||||
|
- `tests/e2e/test_sync_upload_progress.py` — the upload no longer shows live import progress: rework the upload legs to the unpack-only processing state; the SYNC progress assertions (live "Syncing… <file> (n/m)" label on the RAG page) stay green — add a leg where the sync that follows an upload DOES show its live file label and lands the counts.
|
||||||
|
- `tests/unit/test_frontend_sync_upload.py` — the JS contract assertions it makes (labels, result-line shape, poll ownership) updated to the task-01/02 contract.
|
||||||
|
4. Copy sweep: `rg -n "scan" frontend/ | rg -iv "scanner"` — every remaining "scan" mention in the Sources/upload context must be re-pointed at the RAG page's Sync button (the RAG page's own copy already says it).
|
||||||
|
|
||||||
|
- ASSUMPTION: (A3, owner-locked 2026-09-09) the button label is exactly "Upload" (the current "Upload & scan" and the TODO's "Upload and scan" both become "Upload").
|
||||||
|
- ASSUMPTION: the "Successfully uploaded — <file>" 202 toast wording is kept (phase-64 owner-locked A2 copy) — only the settled result line changes.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: task 3's suite updates green; `uv run pytest tests/unit/test_frontend_sync_upload.py tests/e2e -v --no-cov` for the touched files.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (frontend JS is exercised by the E2E suites; keep any Python-side coverage untouched).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `rg -n "Upload & scan|Upload and scan" frontend/ app/ tests/` → no matches.
|
||||||
|
- [ ] The upload flow in a running app: button "Upload" → 202 toast → bare "Processing…" → "Uploaded <name> — press Sync sources to import it." with zero new documents indexed.
|
||||||
|
- [ ] `tests/e2e/test_archive_upload_sources.py` and `tests/e2e/test_sync_upload_progress.py` green (updated in place).
|
||||||
|
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] no behavior change in completed work (sync progress UI, gate behavior, re-upload in place all preserved).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Task 03 — Story E2E: upload → (no scan) → edit ignores → Sync sources
|
||||||
|
|
||||||
|
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list."
|
||||||
|
**Story:** n/a (TODO-derived — one story, one phase, one isolated E2E file: `tests/e2e/test_upload_no_scan.py`).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The phase's dedicated Playwright suite proves the whole deferred-scan loop end-to-end against the real pipeline: an upload indexes nothing; the owner edits the new source's ignore list; the RAG page's "Sync sources" button performs the scan and honors the ignores.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_upload_no_scan.py` (NEW — module header in the house style, story gate + isolated-run command + the fixtures it borrows):
|
||||||
|
- **Fixtures** — reuse the patterns from `tests/e2e/test_archive_upload_sources.py` (do NOT import that module; mirror its local helpers): per-module app env (`BOR_UPLOAD_DIR` → scratch dir, `BOR_GIT_SOURCES` forced empty, mock LLM, `db_ready`, per-test `_clean` truncate), `_build_targz` in-test archive, `_admin_git_sources_page` login helper, `_docs` reader. The archive: `e2e-upload-no-scan.tar.gz` → `alpha.md`, `beta.md`, `notes/skipme.md` (markdown sentinels `ALPHA-…` / `BETA-…` / `SKIPME-…`). No `slow_llm` proxy needed on the upload leg (the upload makes zero LLM calls now); the sync leg is short (2 files + 1 ignored) — poll the RAG page's sync status/`#sync-result` with a generous timeout instead of racing a timer.
|
||||||
|
- **Test 1 — upload does not scan:** admin Sources page → assert the button reads exactly **Upload**; upload the archive → 202 "Successfully uploaded — e2e-upload-no-scan" toast → settled result line "Uploaded e2e-upload-no-scan — press Sync sources to import it." → assert **no documents** indexed from the source (the RAG page's table is empty of them, `_docs` shows none, the source's folder exists on the host under `BOR_UPLOAD_DIR` with all three files).
|
||||||
|
- **Test 2 — ignore list, then Sync scans:** (fresh state via the autouse clean) upload the archive → success line → open the row's phase-89 "Ignore paths" editor → add `notes` → save (row shows the "1 ignored" count tag) → navigate to the RAG page (`/sources.html`) → click "Sync sources" (`#sync-btn`) → wait for the sync to settle (`#sync-result` announces the counts, button restored) → assert the RAG table lists `alpha.md` and `beta.md` for the source and **not** `notes/skipme.md`.
|
||||||
|
- **Test 3 — re-upload replaces without a scan:** (fresh state) upload the archive, success, then upload a v2 archive (same basename, `beta.md` modified, `gamma.md` added, `alpha.md` dropped) → success again with exactly one source row (in-place replace, phase-49 contract) and still **zero** documents indexed from it.
|
||||||
|
2. `tests/e2e/conftest.py` / shared helpers — touch only if a genuinely shared helper is missing (the plan above uses per-module local helpers, so expect no changes).
|
||||||
|
|
||||||
|
- ASSUMPTION: (A4, owner-locked 2026-09-09) the sync backend already imports `kind='local'` rows with prune + `ignore_paths` — this suite is the proof; if it fails, the defect is fixed here or in `app/api/sync.py` as a minimal, documented correction (flag it in the task report).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E in isolation (DB up: `podman compose up -d db`): `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` — green.
|
||||||
|
- 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_upload_no_scan.py -v --no-cov` green (3 tests).
|
||||||
|
- [ ] The suite proves: zero docs after upload; ignore edit before sync is honored by the sync; re-upload stays in-place and scan-free.
|
||||||
|
- [ ] no behavior change in completed work.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user