diff --git a/.agents/phases/todo/93_theme_semantic_completion/00_phase.md b/.agents/phases/complete/93_theme_semantic_completion/00_phase.md
similarity index 100%
rename from .agents/phases/todo/93_theme_semantic_completion/00_phase.md
rename to .agents/phases/complete/93_theme_semantic_completion/00_phase.md
diff --git a/.agents/phases/todo/93_theme_semantic_completion/01_semantic_columns.md b/.agents/phases/complete/93_theme_semantic_completion/01_semantic_columns.md
similarity index 100%
rename from .agents/phases/todo/93_theme_semantic_completion/01_semantic_columns.md
rename to .agents/phases/complete/93_theme_semantic_completion/01_semantic_columns.md
diff --git a/.agents/phases/todo/93_theme_semantic_completion/02_theme_tab_state_section.md b/.agents/phases/complete/93_theme_semantic_completion/02_theme_tab_state_section.md
similarity index 100%
rename from .agents/phases/todo/93_theme_semantic_completion/02_theme_tab_state_section.md
rename to .agents/phases/complete/93_theme_semantic_completion/02_theme_tab_state_section.md
diff --git a/.agents/phases/todo/93_theme_semantic_completion/03_page_head_background.md b/.agents/phases/complete/93_theme_semantic_completion/03_page_head_background.md
similarity index 100%
rename from .agents/phases/todo/93_theme_semantic_completion/03_page_head_background.md
rename to .agents/phases/complete/93_theme_semantic_completion/03_page_head_background.md
diff --git a/.agents/phases/todo/93_theme_semantic_completion/04_e2e_monochrome_theme.md b/.agents/phases/complete/93_theme_semantic_completion/04_e2e_monochrome_theme.md
similarity index 100%
rename from .agents/phases/todo/93_theme_semantic_completion/04_e2e_monochrome_theme.md
rename to .agents/phases/complete/93_theme_semantic_completion/04_e2e_monochrome_theme.md
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.err b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.md b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.md
new file mode 100644
index 0000000..20a9b8c
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.md
@@ -0,0 +1,11 @@
+All verification is complete. Final report:
+
+**Phase 93 — Theme semantic completion: FINAL VERIFICATION PASS — ALL GREEN**
+
+- Verified full implementation in tree: migration `0016` (8 nullable semantic columns, applied at head), 17-var `BUILTIN_COLORS`/`COLOR_FIELDS`/`effective_settings`, API validation, `#view-theme` State-colors fieldset (17 pickers), `theme.js` FIELDS/PAIRS (5→8), `.page-head` surface panel (6 shell views + doc-edit + shared.html; login card / document sticky header audited as already-surfaced), mock_llm `content: None` fix
+- Fixed 2 pre-existing defects (both fail identically on baseline `d4f38ad`, proven via worktree A/B): `test_nav_rename_sources` — expected nav tail missing the phase-91 "Theme" link; `test_stale_ui_copy` — now truncates `saved_chats` before/after (house `test_suggestion_chips` pattern) so the seed-chip contract is deterministic on the shared dev DB (owner's 22 saved chats triggered phase-80 last-3-questions)
+- Tests: `uv run pytest --cov=app --cov-report=term-missing` → **1868 passed, app/ 99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**
+- E2E: dedicated `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → **8/8 in isolation** (all-gray 17-color theme: zero residual color on saved-result/Stale/Revoked/Local/tool-call elements, text labels intact, gray heads non-transparent, pre-paint tag, Reset → byte-identical no-tag); 15 theme/header/nav/responsive suites green in isolation; full 85-file combined run: only the 2 fixed pre-existing failures + 1 combined-run artifact (`test_sync_upload_progress`, green in isolation)
+- Completion criteria: (1) monochrome E2E ✓ (2) default byte-identical, no `#bor-theme` tag ✓ (3) all page heads on solid surface ✓ (4) suite/coverage/lint/E2E green ✓ (5) phases 01–92 no behavior change ✓ (6) commit left to harness per protocol
+- Notable: cleaned stray uvicorn leftovers from prior implementation pass (owner's `--reload` dev server untouched); no deviations from the phase design
+- Next pending phase: `94_ls_tree_drilldown`
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.validate b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.validate
new file mode 100644
index 0000000..57e0726
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__00_phase.a1.validate
@@ -0,0 +1,92 @@
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+........................................................................ [ 77%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+.................................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 178 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 94 0 100%
+app/api/docs.py 50 0 100%
+app/api/git_sources.py 229 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 29 0 100%
+app/api/sync.py 101 0 100%
+app/api/tokens.py 28 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 132 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 33 0 100%
+app/db.py 21 0 100%
+app/main.py 66 0 100%
+app/models.py 118 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 222 0 100%
+app/rag/archive_upload.py 128 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/git_sources.py 14 0 100%
+app/rag/importer.py 190 3 98%
+app/rag/llm.py 217 0 100%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 88 0 100%
+app/rag/retriever.py 150 3 98%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 24 0 100%
+app/schemas.py 269 0 100%
+--------------------------------------------------
+TOTAL 3437 12 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.err b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.md b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.md
new file mode 100644
index 0000000..ea47360
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.md
@@ -0,0 +1,14 @@
+All gates green. Final report:
+
+**Task 01 complete — semantic color columns (server half)**
+- Migration `0016_ui_settings_semantic.py`: 8 nullable `String(7)` columns on `ui_settings` (`ok_bg/ok_ink, err_bg/err_ink/err_line, accent_bg/accent_ink/accent_line`), reversible downgrade (A13)
+- `app/models.py`: 8 `UiSettings` columns + B1 docstring (B3-revised citation); `app/core/theming.py`: `BUILTIN_COLORS` → 17 (drift-test-locked to `styles.css` `:root`), `COLOR_FIELDS` = 9 identity + 8 semantic, `effective_settings` resolves 20 values; contrast pairs 5→8 in the docstring (2 `_line` vars stay decorative)
+- `app/schemas.py`: 8 fields on `UiSettingsIn/Out`; `app/api/ui_settings.py`: zero logic change (COLOR_FIELDS-driven), docstrings updated; `styles.css` untouched (drift test confirms values)
+- Tests: drift guard covers 17; exact 17-declaration tag + CSP-hash match for a non-default semantic var; built-in→NULL + 422 field-naming for the new fields (unit + live-API); new `test_migration_0016.py` (upgrade/downgrade/round-trip); `test_models.py` column contract 9→17
+- `uv run alembic upgrade head` / `downgrade -1`: green (head 0016)
+- `uv run pytest --cov=app --cov-report=term-missing`: **1850 passed, TOTAL 99%** (>90%)
+- `uv run ruff check .`: all passed · `uv run pyright`: 0 errors
+- Smoke: no-op page serves no `#bor-theme` tag (B4 byte-identical); one semantic color → 17 decls in `COLOR_FIELDS` order + matching CSP hash
+
+**Note:** 3 tests in the phase-91 E2E `test_admin_theme_tab.py` fail in isolation by design — they drive DOM/PUT-body from `theming.COLOR_FIELDS` (now 17); the 8 new form inputs land in task 02, whose completion criteria re-asserts those suites green. No server regression.
+**Next pending task:** `93_theme_semantic_completion/02_theme_tab_state_section.md`
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.validate b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.validate
new file mode 100644
index 0000000..ce19d71
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__01_semantic_columns.a1.validate
@@ -0,0 +1,92 @@
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 27%]
+........................................................................ [ 31%]
+........................................................................ [ 35%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 54%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 66%]
+........................................................................ [ 70%]
+........................................................................ [ 73%]
+........................................................................ [ 77%]
+........................................................................ [ 81%]
+........................................................................ [ 85%]
+........................................................................ [ 89%]
+........................................................................ [ 93%]
+........................................................................ [ 97%]
+.................................................. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 178 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 94 0 100%
+app/api/docs.py 50 0 100%
+app/api/git_sources.py 229 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 29 0 100%
+app/api/sync.py 101 0 100%
+app/api/tokens.py 28 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 132 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 33 0 100%
+app/db.py 21 0 100%
+app/main.py 66 0 100%
+app/models.py 118 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 222 0 100%
+app/rag/archive_upload.py 128 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/git_sources.py 14 0 100%
+app/rag/importer.py 190 3 98%
+app/rag/llm.py 217 0 100%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 88 0 100%
+app/rag/retriever.py 150 3 98%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 24 0 100%
+app/schemas.py 269 0 100%
+--------------------------------------------------
+TOTAL 3437 12 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.err b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.md b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.md
new file mode 100644
index 0000000..277cf13
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.md
@@ -0,0 +1,15 @@
+All verification is complete. Final report:
+
+**Task 02 — Theme tab "State colors" section: complete**
+
+- `#view-theme` (index.html): new "State colors" fieldset after the palette — 8 labeled `type="color"` pickers (`theme-ok-bg`…`theme-accent-line`), static values = built-ins; "five pairs" copy → "eight pairs" (WCAG 2.1 AA (4.5:1) wording kept)
+- `theme.js`: `FIELDS` +8 semantic entries in `COLOR_FIELDS` order (live preview, PUT body, Reset, served-theme sync all cover them); `PAIRS` 5→8 (the two `*_line` vars excluded as decorative); mirror relationship noted in both `theme.js` and the `theming.py` docstring
+- **Defect fixed from task 01**: its `COLOR_FIELDS` 9→17 extension had broken both theme E2E suites (KeyError on 9-key `PALETTE`); extended their palettes to 17 vars (semantic picks pass the new AA pairs, preserving the exact-text contrast assertion)
+- Tests: new `tests/unit/test_theme_frontend.py` (6 pins: FIELDS 20-in-order, PAIRS exactly 8, `_line` excluded, 20 labeled inputs, static values vs `styles.css :root` drift-guard, fieldset order); `test_theming.py` docstring↔PAIRS mirror test; `test_frontend_router.py` skeleton pin +8
+
+**Gates (all green)**
+- `uv run pytest` → 1858 passed · `--cov=app` → **99%** (>90%) · `ruff check .` → clean · `pyright` → 0 errors
+- E2E in isolation: `test_admin_theme_tab.py` 5✓, `test_theme_save_and_coverage.py` 3✓, `test_configurable_brand.py` 5✓, `test_dark_tech_theme.py` 6✓, `test_header_consistency.py` 3✓
+- Throwaway Playwright probe (deleted): semantic live preview on ``, warning `--ok-ink on --ok-bg: 2.2:1 — needs 4.5:1`, PUT body = exactly 20 values, Reset → built-ins
+
+Next pending task: `.agents/phases/todo/93_theme_semantic_completion/03_page_head_background.md`
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.validate b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.validate
new file mode 100644
index 0000000..d13f59d
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__02_theme_tab_state_section.a1.validate
@@ -0,0 +1,92 @@
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 27%]
+........................................................................ [ 31%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 54%]
+........................................................................ [ 58%]
+........................................................................ [ 62%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+........................................................................ [ 77%]
+........................................................................ [ 81%]
+........................................................................ [ 85%]
+........................................................................ [ 89%]
+........................................................................ [ 93%]
+........................................................................ [ 96%]
+.......................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 178 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 94 0 100%
+app/api/docs.py 50 0 100%
+app/api/git_sources.py 229 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 29 0 100%
+app/api/sync.py 101 0 100%
+app/api/tokens.py 28 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 132 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 33 0 100%
+app/db.py 21 0 100%
+app/main.py 66 0 100%
+app/models.py 118 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 222 0 100%
+app/rag/archive_upload.py 128 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/git_sources.py 14 0 100%
+app/rag/importer.py 190 3 98%
+app/rag/llm.py 217 0 100%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 88 0 100%
+app/rag/retriever.py 150 3 98%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 24 0 100%
+app/schemas.py 269 0 100%
+--------------------------------------------------
+TOTAL 3437 12 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.err b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.md b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.md
new file mode 100644
index 0000000..c9d03b1
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.md
@@ -0,0 +1,13 @@
+All gates green. Final report:
+
+**Phase 93, task 03 — page-head surface panel: complete**
+
+- `.page-head` panel rule in `frontend/assets/styles.css`: solid `var(--surface)` bg, 1px `var(--line)` border, `var(--radius)`, `1rem 1.25rem` padding — one rule covers all 6 shell views (tuning/rag/git-sources/history/tokens/theme) + `doc-edit.html`; `#view-chat` audited (no `.page-head` — navbar head)
+- `frontend/shared.html`: h1 + lede wrapped in `.page-head`; `#shared-title` margin tuned to the page-head rhythm; login (`.login-card`) and document viewer (sticky `.doc-header`) heads already surfaced — deliberately untouched, pinned in tests
+- New `tests/unit/test_page_head_panel.py` (10 source-pin tests: panel decls, no literals/blur, History flex row + 360px wrap survival, view coverage, shared wrap, documented skips)
+- **Defect fixed** (pre-existing, red before my change): `test_history_page_width::test_desktop_unchanged` failed whenever other suites (e.g. `test_responsive_polish`) left saved chats in the shared dev DB — a single row pushes the table's auto-layout min width (~1130px) past the 1110px 72rem card. Fix: test now enforces its own declared empty-table contract (`TRUNCATE saved_chats` house precedent + label-scoped `e2e-` token delete)
+- Live-verified via Playwright: computed panel on all 7 heads, no 360px overflow, History flex row/wrap intact
+
+**Results:** `uv run pytest --cov=app` → 1868 passed, TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors; E2E gates green in isolation: `test_header_consistency`, `test_nav_consistency`, `test_sticky_navbar`, `test_responsive_polish`, `test_history_page_width` (incl. sequential after responsive_polish), `test_mobile_hamburger_nav`, `test_admin_theme_tab`, `test_theme_save_and_coverage`, `test_configurable_brand`, `test_dark_tech_theme`, `test_share_chat`, `test_save_share_ux`, `test_nav_switch_keeps_stream`, `test_sources_midstream_bug`
+
+**Deviations:** none — no git commit (harness commits the phase). Next pending task: `04_e2e_monochrome_theme.md`.
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.validate b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.validate
new file mode 100644
index 0000000..57e0726
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__03_page_head_background.a1.validate
@@ -0,0 +1,92 @@
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+........................................................................ [ 77%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+.................................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 178 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 94 0 100%
+app/api/docs.py 50 0 100%
+app/api/git_sources.py 229 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 29 0 100%
+app/api/sync.py 101 0 100%
+app/api/tokens.py 28 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 132 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 33 0 100%
+app/db.py 21 0 100%
+app/main.py 66 0 100%
+app/models.py 118 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 222 0 100%
+app/rag/archive_upload.py 128 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/git_sources.py 14 0 100%
+app/rag/importer.py 190 3 98%
+app/rag/llm.py 217 0 100%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 88 0 100%
+app/rag/retriever.py 150 3 98%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 24 0 100%
+app/schemas.py 269 0 100%
+--------------------------------------------------
+TOTAL 3437 12 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.err b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.md b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.md
new file mode 100644
index 0000000..ca51466
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.md
@@ -0,0 +1,10 @@
+All green. The task is complete — final report:
+
+**Phase 93, task 04 — E2E monochrome theme suite: complete**
+
+- New `tests/e2e/test_theme_semantic_completion.py` (8 tests, isolated, re-runnable): UI-save round-trip (PUT body captured — 17 grays + 3 default strings, gray "Theme saved.", no contrast warning), pre-paint 17-var tag + gray first paint (canvas + grid + `:root` props), all 7 shell-view heads + login panel non-transparent/gray/AA (chat head = navbar, mirroring task 03's audit), gray + text-intact Stale / Revoked (UI two-step) / Local / "Listing documents"+"Reading" tool lines, Reset → tag gone from live doc + fresh load byte-identical to row-less
+- Gray ramp pinned per task (`#111111…#787878`); module-level asserts re-derive R=G=B on all 17 + all 8 WCAG pairs ≥ 4.5:1 (min 6.7:1)
+- **Defect fixed** in `tests/e2e/mock_llm.py`: `_context` crashed (mock 500) on well-formed requests carrying assistant tool-call messages with explicit `content: None` (reachable via the round-cap forced-final path) — now `m.get("content") or ""`; surfaced when a one-doc seed hit the agent's `ALREADY_IN_CONTEXT` refusal — test now seeds the phase-37 two-doc pair (retrievable grounding doc + catalog-only read target)
+- **Results:** `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → 8 passed (×2 runs); `uv run pytest --cov=app` → 1868 passed, **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors
+- **Regression gates (isolation):** theme (admin_tab 5, save_coverage 3, brand 5, dark_tech 6), header 3, security 2, nav 7/7, sticky 3, responsive 7, mobile hamburger 8, tool suites (4/4/3/2/2/3/3), stale_chats 3, tokens 9, local_dir 3 — all green
+- No git commit (harness commits the phase); no deviations; next pending: none in phase 93 (phase complete — 94 `ls_tree_drilldown` is next)
diff --git a/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.validate b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.validate
new file mode 100644
index 0000000..57e0726
--- /dev/null
+++ b/.agents/reports/93_theme_semantic_completion/93_theme_semantic_completion__04_e2e_monochrome_theme.a1.validate
@@ -0,0 +1,92 @@
+........................................................................ [ 3%]
+........................................................................ [ 7%]
+........................................................................ [ 11%]
+........................................................................ [ 15%]
+........................................................................ [ 19%]
+........................................................................ [ 23%]
+........................................................................ [ 26%]
+........................................................................ [ 30%]
+........................................................................ [ 34%]
+........................................................................ [ 38%]
+........................................................................ [ 42%]
+........................................................................ [ 46%]
+........................................................................ [ 50%]
+........................................................................ [ 53%]
+........................................................................ [ 57%]
+........................................................................ [ 61%]
+........................................................................ [ 65%]
+........................................................................ [ 69%]
+........................................................................ [ 73%]
+........................................................................ [ 77%]
+........................................................................ [ 80%]
+........................................................................ [ 84%]
+........................................................................ [ 88%]
+........................................................................ [ 92%]
+........................................................................ [ 96%]
+.................................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 178 0 100%
+app/api/chats.py 110 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 94 0 100%
+app/api/docs.py 50 0 100%
+app/api/git_sources.py 229 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 29 0 100%
+app/api/sync.py 101 0 100%
+app/api/tokens.py 28 0 100%
+app/api/ui_settings.py 55 0 100%
+app/config.py 132 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 39 0 100%
+app/core/errors.py 5 0 100%
+app/core/logging.py 13 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/theming.py 38 0 100%
+app/core/tokens.py 33 0 100%
+app/db.py 21 0 100%
+app/main.py 66 0 100%
+app/models.py 118 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 222 0 100%
+app/rag/archive_upload.py 128 0 100%
+app/rag/chunker.py 206 4 98%
+app/rag/git_sources.py 14 0 100%
+app/rag/importer.py 190 3 98%
+app/rag/llm.py 217 0 100%
+app/rag/overview.py 71 0 100%
+app/rag/prompts.py 88 0 100%
+app/rag/retriever.py 150 3 98%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 24 0 100%
+app/schemas.py 269 0 100%
+--------------------------------------------------
+TOTAL 3437 12 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/alembic/versions/0016_ui_settings_semantic.py b/alembic/versions/0016_ui_settings_semantic.py
new file mode 100644
index 0000000..36f0191
--- /dev/null
+++ b/alembic/versions/0016_ui_settings_semantic.py
@@ -0,0 +1,62 @@
+"""ui_settings: the 8 semantic state colors (phase 93, task 01)
+
+Revision ID: 0016
+Revises: 0015
+Create Date: 2026-09-10
+
+Phase 93 (theme completion — the owner's black/white/gray theme still
+leaked color: the green "Theme saved." text, the red Stale/Revoked
+pills, the green LOCAL badge, the amber tool-call lines): the three
+semantic state families (``--ok-*``, ``--err-*``, ``--accent-*``) join
+the storable palette as 8 more tab-controlled variables (17 total —
+B3 revised, owner permission 2026-09-10, TODO.md L3; PLAN.md is being
+redone by the owner, the decision is recorded in the phase overview).
+They flow through the resolver, the pre-paint tag, the CSP hash, and
+the admin API exactly like the 9 identity colors (all of those are
+``COLOR_FIELDS``-driven — zero logic changes):
+
+* ``ui_settings.ok_bg`` / ``ok_ink`` — VARCHAR(7) NULL ``#rrggbb``
+ (NULL = the built-ins ``#10241b`` / ``#6ee7a8`` — exact);
+* ``ui_settings.err_bg`` / ``err_ink`` / ``err_line`` — VARCHAR(7)
+ NULL (NULL = ``#2d0a0a`` / ``#fca5a5`` / ``#ef4444`` — exact);
+* ``ui_settings.accent_bg`` / ``accent_ink`` / ``accent_line`` —
+ VARCHAR(7) NULL (NULL = ``#2b2110`` / ``#fbbf24`` / ``#f59e0b`` —
+ exact).
+
+EIGHT additive, fully reversible columns on the single-row table (A13);
+no server defaults (the row is created only by the PUT upsert, house
+rule).
+"""
+from __future__ import annotations
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "0016"
+down_revision = "0015"
+branch_labels = None
+depends_on = None
+
+#: The 8 semantic columns (name, built-in the NULL means) — upgrade
+#: adds them in this order, downgrade drops them in reverse.
+_SEMANTIC_COLUMNS: tuple[str, ...] = (
+ "ok_bg", "ok_ink",
+ "err_bg", "err_ink", "err_line",
+ "accent_bg", "accent_ink", "accent_line",
+)
+
+
+def upgrade() -> None:
+ for name in _SEMANTIC_COLUMNS:
+ op.add_column(
+ "ui_settings", sa.Column(name, sa.String(length=7), nullable=True)
+ )
+
+
+def downgrade() -> None:
+ # Safe order: the 8 columns are the only 0016 artefacts — dropping
+ # them (reverse order, for symmetry) leaves 0015's schema
+ # byte-identical (A13, fully reversible).
+ for name in reversed(_SEMANTIC_COLUMNS):
+ op.drop_column("ui_settings", name)
diff --git a/app/api/ui_settings.py b/app/api/ui_settings.py
index cd96738..d198c00 100644
--- a/app/api/ui_settings.py
+++ b/app/api/ui_settings.py
@@ -2,8 +2,9 @@
The persistence surface of the admin Theme tab (the tab itself lands in
tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the
-admin sets — the app name, input placeholder, footer text, and the 9
-identity colors. The whole router sits behind
+admin sets — the app name, input placeholder, footer text, and the 17
+palette colors (the 9 identity colors + the 8 semantic state colors,
+phase 93 — B3 revised). The whole router sits behind
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
403 on every route (only the admin themes the deployment).
@@ -73,11 +74,13 @@ def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
- """Validate + normalize the 9 identity colors: strict ``#rrggbb``
- (else 422 naming the field), lowercased on store, and a value equal
- to its BUILT-IN is stored as ``None`` — the owner-locked
- normalization that keeps "save the defaults" byte-identical (the
- row stays empty, the no-op injection contract)."""
+ """Validate + normalize the 17 palette colors (the 9 identity +
+ the 8 semantic state — all ``COLOR_FIELDS``-driven, phase 93):
+ strict ``#rrggbb`` (else 422 naming the field), lowercased on
+ store, and a value equal to its BUILT-IN is stored as ``None`` —
+ the owner-locked normalization that keeps "save the defaults"
+ byte-identical (the row stays empty, the no-op injection
+ contract)."""
values: dict[str, str | None] = {}
for field in theming.COLOR_FIELDS:
raw = getattr(payload, field)
@@ -113,7 +116,7 @@ def update_ui_settings(
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
- """Replace the single row with the body's 12 values (validated and
+ """Replace the single row with the body's 20 values (validated and
normalized — see the module docstring), then report the new
effective values.
diff --git a/app/core/theming.py b/app/core/theming.py
index 6e4d817..46e30a4 100644
--- a/app/core/theming.py
+++ b/app/core/theming.py
@@ -33,21 +33,54 @@ Variable Built-in Role
``brand_ink`` ``#fca5a5`` brand-tinted text (9.0:1 on ``surface``)
=================== ========== =================================================
-The **semantic families are deliberately NOT identity** (B3,
-owner-locked 2026-09-09): ``--accent-*`` (deflection amber), ``--ok-*``
-(success green), ``--err-*`` (error red) encode *states*, are already AA
-in the built-in theme, and are not configurable from the tab — a theme
-that keeps them stays honest.
+**The semantic families** — STORABLE since phase 93 (B3 REVISED, owner
+permission 2026-09-10, TODO.md L3 — the 2026-09-09 lock that they are
+"NOT identity, not configurable from the tab" is lifted; PLAN.md is
+being redone by the owner, the decision is recorded in the phase 93
+overview): the three state families ``--ok-*`` (success green),
+``--err-*`` (error red), ``--accent-*`` (deflection / tool-line amber)
+join the storable palette — 17 variables total, all ``NULL = built-in``
+(B1, no env fallback for colors). They still encode *states* (a
+monochrome theme conveys state by its TEXT label — "text + color, never
+color alone", B5 — so grayscale stays honest), and the built-in values
+keep every existing AA ratio. Column names mirror the CSS variables
+(``frontend/assets/styles.css`` ``:root``):
-**The five contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1,
-AGENTS.md rule 5) — the pairs the layout actually pairs: ``ink`` on
-``bg``, ``ink`` on ``surface``, ``ink_soft`` on ``surface``, ``bg`` on
-``brand`` (the text on brand buttons is the DARK background ink —
-that is the pattern; never white on brand: white on the built-in
-``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on ``surface``. The
-tab's client-side warnings (task 05) compute exactly these five ratios
-against the values being saved; the built-in palette itself passes, so
-the default deployment stays AA without any warning.
+=================== ========== =================================================
+Variable Built-in Role
+=================== ========== =================================================
+``ok_bg`` ``#10241b`` success pill/text background ("Theme saved.")
+``ok_ink`` ``#6ee7a8`` success text (10.6:1 on ``ok_bg``)
+``err_bg`` ``#2d0a0a`` Stale/Revoked pill background
+``err_ink`` ``#fca5a5`` Stale/Revoked pill text (9.3:1 on ``err_bg``)
+``err_line`` ``#ef4444`` pill border — decorative (no contrast duty,
+ like ``line`` / ``grid_line``)
+``accent_bg`` ``#2b2110`` deflection banner / tool-line background
+``accent_ink`` ``#fbbf24`` tool-call line text (9.5:1 on ``accent_bg``)
+``accent_line`` ``#f59e0b`` tool-line / deflection border — decorative
+ (no contrast duty, like ``err_line``)
+=================== ========== =================================================
+
+**The eight contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1,
+AGENTS.md rule 5) — the pairs the layout actually pairs: the five
+identity pairs (``ink`` on ``bg``, ``ink`` on ``surface``, ``ink_soft``
+on ``surface``, ``bg`` on ``brand`` (the text on brand buttons is the
+DARK background ink — that is the pattern; never white on brand: white
+on the built-in ``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on
+``surface``) plus the three semantic ink-on-bg pairs (``ok_ink`` on
+``ok_bg``, ``err_ink`` on ``err_bg``, ``accent_ink`` on ``accent_bg``).
+The two ``*_line`` state variables stay EXCLUDED — decorative borders,
+no contrast duty (the same rule as ``--line`` / ``--grid-line``). The
+tab's client-side warnings (phase 91 task 05, extended to the eight
+pairs in phase 93 task 02) compute exactly these eight ratios against
+the values being saved; the built-in palette itself passes, so the
+default deployment stays AA without any warning.
+
+**Mirror relationship:** ``frontend/assets/theme.js``'s ``PAIRS``
+array is the client-side mirror of this table — the two must NEVER
+diverge (``tests/unit/test_theming.py`` pins the mirror: every
+``PAIRS`` entry appears here as ``fg`` on ``bg``, exactly eight
+pairs).
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
@@ -68,10 +101,12 @@ from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import UiSettings
-#: The 9 built-in identity colors, keyed by BARE variable name (no ``--``)
-#: in the themes-README order. Copied from ``frontend/assets/styles.css``
-#: ``:root`` — the unit drift test (``tests/unit/test_theming.py``)
-#: re-parses the stylesheet and asserts equality on every run.
+#: The 17 built-in palette colors, keyed by BARE variable name (no
+#: ``--``): the 9 identity colors in the themes-README order, then the
+#: 8 semantic state colors (phase 93 — B3 revised). Copied from
+#: ``frontend/assets/styles.css`` ``:root`` — the unit drift test
+#: (``tests/unit/test_theming.py``) re-parses the stylesheet and asserts
+#: equality on every run.
BUILTIN_COLORS: dict[str, str] = {
"bg": "#0f0a0a",
"surface": "#1a0f0f",
@@ -82,11 +117,23 @@ BUILTIN_COLORS: dict[str, str] = {
"brand": "#f43f5e",
"brand_soft": "#2d0a0a",
"brand_ink": "#fca5a5",
+ # The 8 semantic state colors (phase 93 — the ok / err / accent
+ # families; the *_line entries are decorative borders, no contrast
+ # duty, like line / grid_line).
+ "ok_bg": "#10241b",
+ "ok_ink": "#6ee7a8",
+ "err_bg": "#2d0a0a",
+ "err_ink": "#fca5a5",
+ "err_line": "#ef4444",
+ "accent_bg": "#2b2110",
+ "accent_ink": "#fbbf24",
+ "accent_line": "#f59e0b",
}
-#: The 9 color field names in the README's order (dicts preserve
-#: insertion order) — used by the resolver, the API, and the
-#: ``theme_style_tag`` renderer (task 02).
+#: The 17 color field names (dicts preserve insertion order) — the 9
+#: identity variables in the README's order, then the 8 semantic state
+#: variables (identity, brand, then state). Used by the resolver, the
+#: API, and the ``theme_style_tag`` renderer (task 02).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
@@ -105,15 +152,17 @@ def effective_settings(
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
— the DB value when it is a non-empty string, else the env value
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
- * **colors** (the 9 :data:`COLOR_FIELDS`) — the DB value when not
- ``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for
- colors — the built-in palette IS the default).
+ * **colors** (the 17 :data:`COLOR_FIELDS` — 9 identity + 8
+ semantic state) — the DB value when not ``None``, else
+ :data:`BUILTIN_COLORS` (B1: no env fallback for colors — the
+ built-in palette IS the default).
A missing row (``GET`` creates nothing) means "defaults" — the env
strings + the built-in palette. The ``settings`` parameter names the
env-fallback source explicitly (the routes pass their
dependency-injected instance so test overrides apply); ``None`` uses
- the cached :func:`app.config.get_settings`. Returns all 12 keys.
+ the cached :func:`app.config.get_settings`. Returns all 20 values
+ (3 strings + 17 colors).
"""
if settings is None:
settings = get_settings()
@@ -134,12 +183,13 @@ def theme_style_tag(colors: dict[str, str]) -> str:
``""`` when every color equals its built-in — the byte-identical
contract: an unset (or "defaults saved") deployment must serve
exactly the pre-phase-91 HTML, no ``
+
Pure function of its input — :func:`inject_theme` places it before
the first ```` of every served HTML page (the phase-91
diff --git a/app/models.py b/app/models.py
index a9a3d20..28ddc5e 100644
--- a/app/models.py
+++ b/app/models.py
@@ -402,10 +402,15 @@ class UiSettings(Base):
2026-09-09): every column is nullable, and a NULL (or empty) column
means "use the default" — the env value for the three strings
(``settings.app_name`` etc.), the built-in palette
- (:data:`app.core.theming.BUILTIN_COLORS`) for the nine identity
- colors (B1: no env fallback for colors). :func:`app.core.theming.
- effective_settings` resolves the effective 12 values both the
- ``GET /api/ui-settings`` and ``GET /api/config`` endpoints serve.
+ (:data:`app.core.theming.BUILTIN_COLORS`) for the 17 palette colors
+ (B1: no env fallback for colors) — the 9 identity colors AND the
+ 8 semantic state colors (phase 93, task 01; B3 REVISED, owner
+ permission 2026-09-10, TODO.md L3 — the ``--ok-*`` / ``--err-*`` /
+ ``--accent-*`` families join the storable palette; PLAN.md is being
+ redone by the owner, the decision is recorded in the phase 93
+ overview). :func:`app.core.theming.effective_settings` resolves the
+ effective 20 values both the ``GET /api/ui-settings`` and
+ ``GET /api/config`` endpoints serve.
"""
__tablename__ = "ui_settings"
@@ -428,3 +433,16 @@ class UiSettings(Base):
brand: Mapped[str | None] = mapped_column(String(7), nullable=True)
brand_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
brand_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ # --- The 8 semantic state colors (phase 93, B3 revised; NULL = the
+ # built-in — B1), #rrggbb. Column names mirror the CSS variable
+ # names (``--ok-bg`` etc. in ``frontend/assets/styles.css``
+ # :root — the drift test in tests/unit/test_theming.py keeps
+ # the two in lockstep). ---
+ ok_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ ok_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ err_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ err_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ err_line: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ accent_bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ accent_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
+ accent_line: Mapped[str | None] = mapped_column(String(7), nullable=True)
diff --git a/app/schemas.py b/app/schemas.py
index 8042a8e..b090b2f 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -843,16 +843,29 @@ class UiSettingsIn(BaseModel):
brand: str | None = None
brand_soft: str | None = None
brand_ink: str | None = None
+ # The 8 semantic state colors (phase 93 — B3 revised): the same
+ # contract as the 9 identity colors (``null`` = back to the
+ # built-in; ``#rrggbb`` is validated + lowercased by the API).
+ ok_bg: str | None = None
+ ok_ink: str | None = None
+ err_bg: str | None = None
+ err_ink: str | None = None
+ err_line: str | None = None
+ accent_bg: str | None = None
+ accent_ink: str | None = None
+ accent_line: str | None = None
class UiSettingsOut(BaseModel):
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
- phase 91, task 01).
+ phase 91, task 01; 17 colors since phase 93).
- All 12 values, all non-null strings: the resolver's
+ All 20 values (3 strings + 17 palette colors — 9 identity + 8
+ semantic state), all non-null strings: the resolver's
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
the LIVE theme — a fresh (row-missing) deployment reports the env
- strings and the built-in palette.
+ strings and the built-in palette (the semantic colors report their
+ built-ins — the effective values, the NULL = built-in rule).
"""
app_name: str
@@ -867,3 +880,11 @@ class UiSettingsOut(BaseModel):
brand: str
brand_soft: str
brand_ink: str
+ ok_bg: str
+ ok_ink: str
+ err_bg: str
+ err_ink: str
+ err_line: str
+ accent_bg: str
+ accent_ink: str
+ accent_line: str
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 4af421e..e3f65f6 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -1533,6 +1533,25 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
gap: 1.25rem;
flex: 1;
}
+/* Phase 93 task 03 (TODO.md L3): "the header and description of each
+ page needs a background - the grid makes it hard to read." Every
+ page's h1 + description sits on a solid surface panel — the house
+ card language (var(--surface) fill, itself tab-controlled, so a
+ monochrome theme grays it automatically; the 1px --line border; the
+ house radius). No new color literal, no blur (the phase-08 perf
+ anchor). One rule covers the six shell views' .page-head (Global
+ Tuning, Knowledge base, Git sources, Saved chats, Access tokens,
+ Theme) + doc-edit.html, and the shared page's head (its h1 + lede
+ are wrapped in the same class in shared.html). #view-chat carries
+ no .page-head — its head is the navbar (audited, unchanged). The
+ History page-head keeps its flex row (scoped below — the padding
+ lives on the flex container, not the children). */
+.page-head {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 1rem 1.25rem;
+}
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.page-head-row {
display: flex;
@@ -3187,7 +3206,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
/* Page title (JS-filled with the shared chat's title; the static
fallback is "Shared conversation") — the page-head h1 size. */
-#shared-title { margin: 0; font-size: 1.7rem; }
+#shared-title { margin: 0 0 0.25rem; font-size: 1.7rem; }
/* The muted meta line under the h1 ("Shared via … — read-only."):
ink-soft on the page bg ≥8.6:1, the page-sub language. */
.shared-note {
diff --git a/frontend/assets/theme.js b/frontend/assets/theme.js
index b8827e6..07d9d68 100644
--- a/frontend/assets/theme.js
+++ b/frontend/assets/theme.js
@@ -17,13 +17,13 @@
* gate (the #nav-theme link is already hidden by header.js — the
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
* /api/ui-settings request is ever made outside the admin branch.
- * • load — GET /api/ui-settings → populate the 12 inputs with the
+ * • load — GET /api/ui-settings → populate the 20 inputs with the
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
* merge): the tab always shows the live theme — env defaults when
* the row is empty. A failed fetch keeps the static form (the
* built-in values ship in the inputs) and shows #theme-error with
* a retry (the loadHealth house style — never a blanked panel).
- * • live preview (colors only, B4) — on `input` of any of the 9
+ * • live preview (colors only, B4) — on `input` of any of the 17
* color pickers the value is written straight onto as an
* inline custom property, so the WHOLE page repaints (every view,
* the header) while the owner is picking. Text fields have NO page
@@ -41,8 +41,8 @@
* text — themeRootContent is byte-identical to the INNER content
* of app.core.theming.theme_style_tag (the tag is removed when
* the palette is the built-in one — the server's no-op case) — so
- * the document mirrors what the next load serves. (2) The 9
- * identity variables as inline custom properties on (CSSOM
+ * the document mirrors what the next load serves. (2) The 17
+ * palette variables as inline custom properties on (CSSOM
* setProperty / removeProperty — the live preview's mechanism) —
* THIS half is what repaints the open page, because Chromium
* re-checks a '
@@ -326,8 +341,9 @@ def _fill_theme_form(
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
- """Fill the 12 inputs: the 3 text fields (``strings``, default
- the E2E set) + the 9 color pickers (``palette``)."""
+ """Fill the 20 inputs: the 3 text fields (``strings``, default
+ the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
+ phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
@@ -337,7 +353,7 @@ def _fill_theme_form(
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
- """Assert all 12 inputs show the given effective values."""
+ """Assert all 20 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
@@ -347,7 +363,7 @@ def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, s
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
- all 9 vars = the given hexes, placed IMMEDIATELY before
+ all 17 vars = the given hexes, placed IMMEDIATELY before
```` (``inject_theme``'s exact placement: the tag ends
exactly where ```` begins and carries the injector's
single leading newline)."""
@@ -360,7 +376,7 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
- """The first-paint proof: all 9 computed ``:root`` custom
+ """The first-paint proof: all 17 computed ``:root`` custom
properties equal the given hexes. The inline tag precedes every
stylesheet application, so a themed deployment resolves them
from the first style pass — no red flash, no pop-in (custom
@@ -402,9 +418,10 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
- # The 12 inputs show the EFFECTIVE defaults: the 3 template
- # strings + the 9 built-in hexes parsed straight out of
- # styles.css's :root (the resolver's missing-row branch).
+ # The 20 inputs show the EFFECTIVE defaults: the 3 template
+ # strings + the 17 built-in hexes (9 identity + 8 semantic)
+ # parsed straight out of styles.css's :root (the resolver's
+ # missing-row branch).
_expect_form_values(page, defaults, builtin)
# Set a distinct palette + the 3 strings, then Save through the
@@ -435,7 +452,7 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
- # The row landed in Postgres (the id-1 single row, all 12 values
+ # The row landed in Postgres (the id-1 single row, all 20 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
with SessionLocal() as db:
@@ -447,10 +464,12 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
- # The saved palette fails ONE of the five pairs — --bg on
+ # The saved palette fails ONE of the eight pairs — --bg on
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
- # warning lists it. Save was NEVER blocked (the warning-only
- # contract: the owner's homelab palette; the built-in stays AA).
+ # warning lists it (the three semantic ink-on-bg pairs pass on
+ # the saved picks — see the PALETTE comment). Save was NEVER
+ # blocked (the warning-only contract: the owner's homelab
+ # palette; the built-in stays AA).
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible()
@@ -475,7 +494,7 @@ def test_saved_theme_is_pre_paint_for_everyone(
_seed_theme_via_api(app_url, _cookies(page))
# The RAW served HTML (httpx — no JS at all, the server's own
- # bytes): exactly one inline theme tag, all 9 vars = the saved
+ # bytes): exactly one inline theme tag, all 17 vars = the saved
# hexes, immediately before (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
@@ -606,7 +625,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
-# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
+# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -653,11 +672,11 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
- # The form re-populates to the 12 defaults (the env/built-in
+ # The form re-populates to the 20 defaults (the env/built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
- # five pairs).
+ # eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The served HTML is back to the built-in: NO theme tag anywhere
@@ -694,12 +713,12 @@ def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: Non
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the built-in defaults — the warning is
- # hidden (the built-in palette passes all five pairs).
+ # hidden (the built-in palette passes all eight pairs).
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
expect(page.locator("#theme-contrast")).to_be_hidden()
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
- # picker's input event previews it live AND re-runs the five
+ # picker's input event previews it live AND re-runs the eight
# pairs — --ink on --bg (and --ink on --surface, the ink is now
# the darker side of that pair too) fail, and each failing pair
# is listed with its ratio in the role=alert line.
diff --git a/tests/e2e/test_history_page_width.py b/tests/e2e/test_history_page_width.py
index 9ad1452..e67b658 100644
--- a/tests/e2e/test_history_page_width.py
+++ b/tests/e2e/test_history_page_width.py
@@ -73,7 +73,12 @@ copied from ``test_mobile_hamburger_nav.py`` (suites stay
self-contained — helpers copied, only ``auth_helpers`` imported).
Data: the History/Tokens contract holds with EMPTY tables (static
thead + the 640px ``min-width`` — no rows needed, and the suite never
-depends on saved chats or issued tokens). The RAG view, by contrast,
+depends on saved chats or issued tokens — test 5 ENFORCES that state,
+resetting ``saved_chats`` (TRUNCATE, the house precedent) and the
+``e2e-``-labeled tokens (label-scoped delete) before pinning, because
+other suites leave chats on the shared dev DB and a single History
+row pushes the table's auto-layout minimum width past the 1110px
+desktop card). The RAG view, by contrast,
HIDES its table and shows the "Nothing indexed yet" empty state on an
empty KB, so tests 4 and 5 seed the KB the house way (truncate +
import the 13 fixture docs, deterministic mock embeddings) and pin
@@ -183,6 +188,21 @@ def _wrap_handle(page: Page, view: str) -> JSHandle:
return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')")
+def _reset_history_and_e2e_tokens() -> None:
+ """The desktop pin's data state (see ``test_desktop_unchanged``):
+ an empty History table + no e2e-issued tokens — the row-width
+ interaction (auto table layout vs the 72rem card) is not the
+ contract under test, and other suites leave saved chats on the
+ shared dev DB. ``saved_chats``: TRUNCATE (the
+ ``test_bottom_chat_actions.py`` precedent); ``api_tokens``:
+ label-scoped ``e2e-`` delete only (the ``test_api_tokens.py``
+ pattern — the dev DB may hold the owner's real tokens)."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE saved_chats"))
+ db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
+ db.commit()
+
+
def _assert_viewport_width(page: Page, label: str) -> None:
"""THE pin (TODO.md L4): the document itself never pans past the
viewport — pre-fix, /history.html measured 626 and /tokens.html
@@ -391,9 +411,24 @@ def test_desktop_unchanged(
than the mobile 346px card. The zero-offset
``position: relative`` changed no layout, so desktop is
byte-identical in behavior. The KB is seeded the house way for
- the RAG view's table (it hides itself on an empty KB)."""
+ the RAG view's table (it hides itself on an empty KB).
+
+ Data state: the desktop pin is enforced on the EMPTY-table state
+ the module docstring declares ("the History/Tokens contract holds
+ with EMPTY tables") — the History table's auto layout cannot fit
+ ROWS in the 1110px 72rem card (the nowrap date/share/actions
+ cells + the ellipsized title column at its 34rem ``max-width``
+ sum to ~1130px of minimum column width), and other suites on the
+ shared dev DB (e.g. ``test_responsive_polish``) leave saved chats
+ behind. So this test resets the state it pins: ``saved_chats``
+ TRUNCATEd (the ``test_bottom_chat_actions.py`` house precedent —
+ the table is rendered empty here, rows are not under test) and
+ the ``e2e-``-labeled tokens deleted (the ``test_api_tokens.py``
+ label-scoped pattern — never a TRUNCATE; the shared dev DB may
+ hold the owner's real tokens)."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13
+ _reset_history_and_e2e_tokens()
page = browser.new_page(viewport=DESKTOP)
try:
login(page, app_url, next="/history.html")
diff --git a/tests/e2e/test_nav_rename_sources.py b/tests/e2e/test_nav_rename_sources.py
index 1e62833..4df355f 100644
--- a/tests/e2e/test_nav_rename_sources.py
+++ b/tests/e2e/test_nav_rename_sources.py
@@ -112,13 +112,13 @@ CURRENT_LINK: dict[str, str | None] = {
#: The four primary nav links, in their physical DOM order — the labels
#: after the phase-48 swap (ids/hrefs unchanged).
NAV_LABELS = ("Chat", "RAG", "Sources", "Tuning")
-#: The FIFTH + SIXTH a.nav-link in every page header (phase 53
-#: saved-chat history + phase 79 task 06 access tokens — both
-#: ship-hidden, revealed by header.js for admins). The DOM enumeration
-#: below therefore always sees them (pre-existing; the list matches the
-#: real nav — the phase-34 one-bar contract ships the SAME nav, incl.
-#: the Tokens link, on every page).
-NAV_TAIL = ("History", "Tokens")
+#: The FIFTH–SEVENTH a.nav-link in every page header (phase 53
+#: saved-chat history + phase 79 task 06 access tokens + phase 91
+#: theme tab — all ship-hidden, revealed by header.js for admins).
+#: The DOM enumeration below therefore always sees them (pre-existing;
+#: the list matches the real nav — the phase-34 one-bar contract ships
+#: the SAME nav, incl. the Tokens and Theme links, on every page).
+NAV_TAIL = ("History", "Tokens", "Theme")
#: The login.js script — route pattern for the redirect suppression.
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
@@ -199,8 +199,8 @@ def _assert_renamed_labels(page: Page, name: str) -> None:
expect(git).to_have_attribute("href", "/git-sources.html")
# The nav links (class nav-link) in physical DOM order — the four
- # primaries plus the admin-only History (phase 53) and Tokens
- # (phase 79 task 06) tail links.
+ # primaries plus the admin-only History (phase 53), Tokens
+ # (phase 79 task 06) and Theme (phase 91) tail links.
nav_texts = page.eval_on_selector_all(
".app-nav a.nav-link", "els => els.map(e => e.textContent.trim())"
)
diff --git a/tests/e2e/test_stale_ui_copy.py b/tests/e2e/test_stale_ui_copy.py
index 56d04d0..f56be8c 100644
--- a/tests/e2e/test_stale_ui_copy.py
+++ b/tests/e2e/test_stale_ui_copy.py
@@ -14,6 +14,14 @@ local ``.env`` cannot leak corpus-specific chips). Every assertion is a
settled-state check: static HTML + one ``GET /api/suggestions`` fetch;
Playwright's ``expect`` retries ride out the chip rendering.
+``saved_chats`` reset: the chips the chat page renders are the phase-80
+contract (the last 3 questions across ALL saved chats; the locked seed
+list is the FRESH-deployment fallback). An autouse fixture truncates
+``saved_chats`` before and after every test so the suite is deterministic
+on the shared dev DB (the ``test_suggestion_chips.py`` /
+``test_history_page_width.py`` precedent — other suites leave saved
+chats, incl. the owner's, on the shared DB).
+
Test → lock mapping (Playwright Mapping Rule):
1. ``test_chat_page_placeholder_meta_footer_are_the_locked_copy``
2. ``test_chat_page_shows_no_homelab_or_deployment_text``
@@ -23,8 +31,13 @@ Test → lock mapping (Playwright Mapping Rule):
"""
from __future__ import annotations
-from playwright.sync_api import Page, expect
+from collections.abc import Iterator
+import pytest
+from playwright.sync_api import Page, expect
+from sqlalchemy import text
+
+from app.db import SessionLocal
from e2e.auth_helpers import login
# The locked replacement copy (owner-locked A1 / A2 / A3 — same literals
@@ -43,6 +56,24 @@ CHIPS = [
]
+@pytest.fixture(autouse=True)
+def clean_chats(db_ready: None) -> Iterator[None]:
+ """The seed-list chip contract reads ``saved_chats`` (phase 80: the
+ chips are the last 3 questions asked; the locked list is the
+ zero-saved-question fallback). Truncate before AND after every test
+ so each test sees a fresh deployment (the
+ ``test_suggestion_chips.py`` pattern — the shared dev DB may hold
+ the owner's saved chats; the suites that assert on the chips own
+ the table state, house precedent)."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE saved_chats"))
+ db.commit()
+ yield
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE saved_chats"))
+ db.commit()
+
+
def test_chat_page_placeholder_meta_footer_are_the_locked_copy(
page: Page, app_url: str, db_ready: None
) -> None:
diff --git a/tests/e2e/test_theme_save_and_coverage.py b/tests/e2e/test_theme_save_and_coverage.py
index 9264d82..8ca4cbe 100644
--- a/tests/e2e/test_theme_save_and_coverage.py
+++ b/tests/e2e/test_theme_save_and_coverage.py
@@ -17,24 +17,25 @@ Test → contract mapping (one story, one phase, one isolated file):
1. ``test_save_applies_live_without_reload`` — defect 1 (Save): the
§7.4 save lifecycle lands, and — with NO navigation — the OPEN
document mirrors the saved state: the computed ``:root`` palette is
- the saved 9 hexes, the ``#bor-theme`` tag's DOM text is the exact
- 9-var ``:root{…}`` the next load would serve, and ````'s
- inline style holds EXACTLY the 9 saved custom properties (no stale
- pick). The server agrees (the raw ``/`` carries the 9-var tag), and
- an SPA nav to Chat (same document) keeps the saved palette on the
- computed ``--brand`` and the ``.send-btn`` fill.
+ the saved 17 hexes, the ``#bor-theme`` tag's DOM text is the exact
+ 17-var ``:root{…}`` the next load would serve, and ````'s
+ inline style holds EXACTLY the 17 saved custom properties (no
+ stale pick). The server agrees (the raw ``/`` carries the 17-var
+ tag), and an SPA nav to Chat (same document) keeps the saved
+ palette on the computed ``--brand`` and the ``.send-btn`` fill.
2. ``test_reset_applies_live_without_reload`` — defect 1 (Reset): on a
THEMED load (the served tag is present), the §7.4 reset lifecycle
lands, and — with NO navigation — the ``#bor-theme`` tag is REMOVED
- from the live document, the computed ``:root`` palette is the 9
+ from the live document, the computed ``:root`` palette is the 17
built-ins (parsed from ``styles.css`` in-test), ```` carries
no overrides, the served HTML has no tag, and the with-row bytes
equal a row-less deployment byte for byte (the no-op contract end
- to end, now 9-wide).
+ to end, now 17-wide).
3. ``test_theme_controls_drive_the_whole_site`` — defect 2: with the
palette seeded, a fresh load's first paint is the themed paint
- (the raw HTML carries the 9-var tag immediately before ````
- + the phase-91 CSP ``style-src 'self' 'sha256-…'``), and the
+ (the raw HTML carries the 17-var tag immediately before
+ ```` + the phase-91 CSP ``style-src 'self'
+ 'sha256-…'``), and the
browser-COMPUTED values prove every themed surface follows the tab:
the background grid texture (``body::before`` — the owner's named
defect) at 60% ``--grid-line``, ``::selection`` at 45% ``--brand``,
@@ -121,9 +122,10 @@ APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette: the phase-91 8-value indigo set PLUS the
# 9th identity var (phase 92, task 01) — grid_line #2b3550, distinct
-# from its built-in #4a2626 and from line #232a4a. Every value differs
-# from its built-in, so the tag is non-empty and every saved color is
-# stored as-is (no built-in→NULL collapse).
+# from its built-in #4a2626 and from line #232a4a — PLUS the 8
+# semantic state colors (phase 93), each distinct from its built-in.
+# Every value differs from its built-in, so the tag is non-empty and
+# every saved color is stored as-is (no built-in→NULL collapse).
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
@@ -134,6 +136,14 @@ PALETTE: dict[str, str] = {
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
+ "ok_bg": "#101c24",
+ "ok_ink": "#86d9c0",
+ "err_bg": "#20101c",
+ "err_ink": "#e896b4",
+ "err_line": "#d1608c",
+ "accent_bg": "#1e1a10",
+ "accent_ink": "#d9c37a",
+ "accent_line": "#b8963e",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
@@ -157,7 +167,8 @@ COLOR_INPUT_IDS: dict[str, str] = {
def _builtin_colors() -> dict[str, str]:
- """The 9 built-in identity hexes parsed OUT of
+ """The 17 built-in palette hexes (the 9 identity + the 8
+ semantic state, phase 93) parsed OUT of
``frontend/assets/styles.css``'s ``:root`` in-test — the single
source of truth, so the suite can't drift from the stylesheet it
asserts on."""
@@ -176,7 +187,7 @@ def _builtin_colors() -> dict[str, str]:
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
- ``colors``: one ``:root`` override, all 9 vars in COLOR_FIELDS
+ ``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f''
@@ -185,14 +196,14 @@ def _expected_tag(colors: dict[str, str]) -> str:
def _expected_tag_content(colors: dict[str, str]) -> str:
"""The tag's INNER ``:root{…}`` string — the ``#bor-theme``
element's ``textContent`` after a settled save (task 04's mirror
- half is byte-identical to it: same 9 fields, same order, the
+ half is byte-identical to it: same 17 fields, same order, the
resolver's lowercased hexes)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f":root{{{declarations}}}"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
- """All 9 computed ``:root`` custom properties equal the given
+ """All 17 computed ``:root`` custom properties equal the given
hexes. The inline tag precedes every stylesheet application, so a
themed deployment resolves them from the first style pass — no red
flash, no pop-in (custom properties return the specified token, so
@@ -500,8 +511,9 @@ def _fill_theme_form(
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
- """Fill the 12 inputs: the 3 text fields (``strings``, default
- the E2E set) + the 9 color pickers (``palette``)."""
+ """Fill the 20 inputs: the 3 text fields (``strings``, default
+ the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
+ phase 93)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
@@ -512,7 +524,7 @@ def _fill_theme_form(
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
- all 9 vars = the given hexes, placed IMMEDIATELY before
+ all 17 vars = the given hexes, placed IMMEDIATELY before
```` (``inject_theme``'s exact placement: the tag ends
exactly where ```` begins and carries the injector's
single leading newline)."""
@@ -541,7 +553,7 @@ def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: No
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
- # Fill the 12 inputs (the 3 strings + the 9-color palette) and Save
+ # Fill the 20 inputs (the 3 strings + the 17-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
@@ -564,21 +576,21 @@ def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: No
# NO navigation (the URL never leaves the Theme view): the OPEN
# document mirrors the settled save — the tag's DOM text is the
- # exact 9-var :root{…} the next load would serve (task 04's mirror
- # half) and 's inline style holds EXACTLY the 9 saved
- # custom properties (the paint half — no stale pick; the pre-fix
- # code cleared the preview onto the STALE served tag and fails
- # this wait).
+ # exact 17-var :root{…} the next load would serve (task 04's
+ # mirror half) and 's inline style holds EXACTLY the 17
+ # saved custom properties (the paint half — no stale pick; the
+ # pre-fix code cleared the preview onto the STALE served tag and
+ # fails this wait).
expect(page).to_have_url(APP_URL + "/theme.html")
_wait_settled_open_document(
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
)
- # …and all 9 computed :root custom properties ARE the saved hexes
+ # …and all 17 computed :root custom properties ARE the saved hexes
# (the open page painted the saved palette — defect 1 gone).
_wait_theme_computed(page, PALETTE)
# The server agrees: the RAW served HTML (a fresh request) carries
- # the 9-var tag immediately before .
+ # the 17-var tag immediately before .
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
@@ -616,7 +628,7 @@ def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: N
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# Seed the theme via the API, THEN load the shell: the served
- # document carries the 9-var tag (a THEMED load — the reset must
+ # document carries the 17-var tag (a THEMED load — the reset must
# remove it from the live document, not just stop serving it).
_seed_theme_via_api(app_url, _cookies(page))
page.goto(app_url + "/theme.html")
@@ -648,7 +660,7 @@ def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: N
# NO navigation: the #bor-theme tag is REMOVED from the live
# document (effective = the built-ins → content null → the tag
- # goes) and the page paints the 9 built-ins (parsed from
+ # goes) and the page paints the 17 built-ins (parsed from
# styles.css in-test) with carrying no overrides at all —
# the reset is the one case where the cleared-preview shape and
# the overrides shape agree: the style attribute is empty.
@@ -665,7 +677,7 @@ def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: N
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the byte-identical contract end to end: the with-row bytes
- # equal a ROW-LESS deployment byte for byte (now 9-wide — a
+ # equal a ROW-LESS deployment byte for byte (now 17-wide — a
# defaults-saved row never adds a byte).
with_row = r.content
with SessionLocal() as db:
@@ -688,9 +700,11 @@ def test_theme_controls_drive_the_whole_site(page: Page, app_url: str, db_ready:
login(page, app_url, next="/")
_seed_theme_via_api(app_url, _cookies(page))
- # Pre-paint with the 9th var: the raw served HTML carries the
- # 9-var tag immediately before , permitted in a real
- # browser only via the phase-91 CSP hash (now 9-wide).
+ # Pre-paint with the full 17-var palette (phase 93 — the 8
+ # semantic state colors join the 9 identity vars): the raw served
+ # HTML carries the 17-var tag immediately before ,
+ # permitted in a real browser only via the phase-91 CSP hash
+ # (now 17-wide).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
diff --git a/tests/e2e/test_theme_semantic_completion.py b/tests/e2e/test_theme_semantic_completion.py
new file mode 100644
index 0000000..6d77c47
--- /dev/null
+++ b/tests/e2e/test_theme_semantic_completion.py
@@ -0,0 +1,980 @@
+"""Phase 93 E2E (Playwright): a complete monochrome theme — the
+dedicated story suite (the story gate for the whole TODO item).
+
+Source: ``TODO.md`` L3 (owner 2026-09-10) — "I created a black/white/
+gray theme for brain of reese and found multiple cases of color still
+in the UI which tells me the customization is not complete. Screenshots
+are in the theme_fixes/ folder. Note the green text 'Theme saved', the
+red 'Revoked' tag, the red 'Stale' tag, The green 'Local' tag, The
+yellow 'Listing documents' and 'Reading' tool calls. Also the header
+and description of each page needs a background - the grid makes it
+hard to read."
+
+This suite proves the TODO is done: with an all-gray 17-color theme
+saved from the admin Theme tab, every state element the owner
+screenshot computes GRAY (``r == g == b`` — the "still color"
+detector), every state keeps its TEXT label (B5 — "text + color,
+never color alone", so a grayscale theme conveys state by words),
+every page head sits on a non-transparent, AA-readable surface panel,
+a fresh load paints the gray palette pre-paint, and Reset restores the
+byte-identical default (no ``#bor-theme`` tag).
+
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov
+
+The grayscale ramp (``GRAY`` below) is R=G=B on every channel of all
+17 colors and passes all EIGHT WCAG 2.1 AA contrast pairs (>= 4.5:1 —
+the weakest is --bg on --brand at 6.7:1). The module-level asserts
+re-derive BOTH properties on every run, so the suite cannot silently
+regress to a colored or failing palette. The same eight pairs are the
+client's ``theme.js`` ``PAIRS`` and the ``app/core/theming.py``
+docstring table — the three mirrors must never diverge.
+
+Test → screenshot mapping (every ``theme_fixes/`` shot is encoded as a
+computed-style assertion):
+
+1. ``test_tab_ui_save_gray_roundtrip`` — the form wiring (the point of
+ the task): the 8 NEW semantic pickers (plus the 9 identity) are
+ filled and the Save PUT body carries all 17 grays + the 3 default
+ strings; "Theme saved." (screenshot 7) computes GRAY ok-ink with
+ its text intact; the contrast warning stays hidden (all eight pairs
+ pass); the inputs re-populate to the saved grays; the id-1 row
+ stores all 17.
+2. ``test_pre_paint_gray_first_paint`` — persistence + first paint: a
+ direct-PUT seed; the RAW served HTML (no JS) carries exactly one
+ ``#bor-theme`` tag with all 17 gray vars immediately before
+ ```` (the CSP carries its strict hash); a fresh
+ ``page.goto`` paints the saved gray ``bg`` on the page canvas (the
+ ```` element — the body stays transparent by contract) AND
+ the gray grid texture on the first paint, and all 17 computed
+ ``:root`` custom properties equal the saved hexes at load.
+3. ``test_page_heads_gray_panel_readable`` — screenshots 2, 3, 4, 6, 8:
+ on the seven shell views (the chat view's head is the sticky
+ navbar — phase 93 task 03 audited ``#view-chat``: it carries no
+ ``.page-head``, the navbar IS the head) + the login page, the
+ h1/lede block's computed background is NON-TRANSPARENT and the gray
+ surface, the ink is the gray ink, and the OBSERVED ink-on-panel
+ ratio is >= 4.5 (the headings stay readable text).
+4. ``test_stale_pill_gray_labeled`` — screenshot 5: a
+ out-of-generation saved chat (the phase-53 seed pattern) renders
+ the "Stale" pill in History — gray err-ink on gray err-bg with the
+ gray err-line border, text intact.
+5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token
+ generated + revoked THROUGH the Tokens UI renders the "Revoked"
+ pill — the same gray err-family computed colors, text intact.
+6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
+ local-directory source renders the "Local" badge on Git sources —
+ gray ok-ink on gray ok-bg, text intact.
+7. ``test_tool_call_lines_gray`` — screenshot 1: a mock-LLM turn that
+ executes the tools renders the "Listing documents" / "Reading
+ " lines — gray accent-ink text, the gray accent-line
+ left border, and the gray brand-soft path chip, all text intact.
+8. ``test_reset_removes_tag_byte_identical`` — the no-op contract: the
+ gray tag is present in the live document pre-reset; Reset to
+ defaults removes it from the LIVE document, a fresh load serves NO
+ tag, the computed palette returns to the built-ins parsed from
+ ``styles.css``, and the served bytes equal a row-less deployment
+ byte for byte (B4).
+
+DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
+single row the caching middleware reads for EVERY served page — a
+leftover themed row would repaint other suites' pages) and
+``api_tokens`` rows across suites. An autouse fixture truncates
+``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
+after every test (never a TRUNCATE on ``api_tokens`` — the shared DB
+may hold the owner's real tokens). The stale-chat test inserts its own
+``saved_chats`` row (deleted in a ``finally`` — the shared DB may hold
+the owner's saved chats); the local-source test registers + removes
+its own row; the tool test resets the KB tables the house way
+(suites truncate/re-import per module and run in isolation).
+
+Per-module app env (the tuning/tokens/archive-upload pattern): the
+module-scoped ``app_server`` override boots the same env block as the
+shared conftest server with the branding vars pinned to the CODE
+defaults (the phase-61/62 leak-guard pattern, extended to
+``BOR_APP_NAME`` — "the effective strings start at the template
+defaults" must hold regardless of an operator's local ``.env``) and
+``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repos must
+not render as env rows under the table the local-badge test asserts
+on).
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import subprocess
+import sys
+import uuid
+from collections.abc import Iterator
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from playwright.sync_api import Browser, BrowserContext, Locator, Page, Route, expect
+from sqlalchemy import select, text
+from sqlalchemy.orm import Session
+
+from app.config import Settings
+from app.core.theming import COLOR_FIELDS
+from app.db import SessionLocal
+from app.models import Chunk, Document, SavedChat, UiSettings
+from app.rag.sources_meta import bump_sources_version
+from e2e.auth_helpers import login
+from e2e.conftest import (
+ ADMIN_PASSWORD,
+ REPO,
+ SESSION_SECRET,
+ USE_REAL_LLM,
+ _wait_http,
+)
+from tests.e2e.mock_llm import embed_text
+
+# Phase 79 (task 04, full inventory): the conftest session app owns its
+# port in a combined run — this module app binds its own port instead
+# (a same-port second uvicorn dies on bind and would drive the wrong
+# server). Env-overridable.
+APP_PORT = int(os.environ.get("E2E_APP_PORT_THEME93", "8131"))
+APP_URL = f"http://127.0.0.1:{APP_PORT}"
+
+# ---------------------------------------------------------------------------
+# The all-gray 17-color ramp (the task's example, verified)
+# ---------------------------------------------------------------------------
+
+#: Every channel R=G=B on every color (a true monochrome ramp), and all
+#: EIGHT WCAG 2.1 AA contrast pairs pass >= 4.5:1 (weakest: --bg on
+#: --brand, the button-ink pair — 6.7:1). The module-level asserts
+#: below re-derive both properties so a future edit to this table fails
+#: loudly at collection time, not mid-suite.
+GRAY: dict[str, str] = {
+ "bg": "#111111",
+ "surface": "#1e1e1e",
+ "ink": "#f2f2f2",
+ "ink_soft": "#b3b3b3",
+ "line": "#3a3a3a",
+ "grid_line": "#2b2b2b",
+ "brand": "#9a9a9a",
+ "brand_soft": "#2c2c2c",
+ "brand_ink": "#d4d4d4",
+ "ok_bg": "#161616",
+ "ok_ink": "#e0e0e0",
+ "err_bg": "#191919",
+ "err_ink": "#e6e6e6",
+ "err_line": "#6a6a6a",
+ "accent_bg": "#1c1c1c",
+ "accent_ink": "#dedede",
+ "accent_line": "#787878",
+}
+assert set(GRAY) == set(COLOR_FIELDS), "GRAY must cover all 17 palette colors"
+
+#: The eight contrast pairs (the app/core/theming.py docstring table —
+#: the authoritative mirror of theme.js PAIRS; the two must never
+#: diverge).
+PAIRS: tuple[tuple[str, str], ...] = (
+ ("ink", "bg"),
+ ("ink", "surface"),
+ ("ink_soft", "surface"),
+ ("bg", "brand"),
+ ("brand_ink", "surface"),
+ ("ok_ink", "ok_bg"),
+ ("err_ink", "err_bg"),
+ ("accent_ink", "accent_bg"),
+)
+
+
+def _hex_rgb(hexc: str) -> tuple[int, int, int]:
+ return (int(hexc[1:3], 16), int(hexc[3:5], 16), int(hexc[5:7], 16))
+
+
+def _wcag_luminance(rgb: tuple[float, float, float]) -> float:
+ """WCAG 2.1 relative luminance (the same math theme.js runs)."""
+
+ def lin(c: float) -> float:
+ s = c / 255
+ return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
+
+ r, g, b = rgb
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
+
+
+def _wcag_ratio_rgb(
+ fg: tuple[float, float, float], bg: tuple[float, float, float]
+) -> float:
+ l1, l2 = _wcag_luminance(fg), _wcag_luminance(bg)
+ return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
+
+
+for _field, _value in GRAY.items():
+ _r, _g, _b = _hex_rgb(_value)
+ assert _r == _g == _b, f"GRAY[{_field}] = {_value} is not grayscale (R=G=B required)"
+for _fg, _bg in PAIRS:
+ _ratio = _wcag_ratio_rgb(_hex_rgb(GRAY[_fg]), _hex_rgb(GRAY[_bg]))
+ assert _ratio >= 4.5, f"--{_fg} on --{_bg}: {_ratio:.2f}:1 < 4.5:1 (AA)"
+
+# ---------------------------------------------------------------------------
+# In-test constants (single sources of truth — never duplicated)
+# ---------------------------------------------------------------------------
+
+
+def _builtin_colors() -> dict[str, str]:
+ """The 17 built-in palette hexes (the 9 identity + the 8 semantic
+ state, phase 93) parsed OUT of ``frontend/assets/styles.css``'s
+ ``:root`` in-test — the single source of truth, so the suite can't
+ drift from the stylesheet it asserts on."""
+ css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
+ root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
+ assert root is not None, "styles.css must open with its :root block"
+ colors: dict[str, str] = {}
+ for name in COLOR_FIELDS:
+ match = re.search(
+ rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
+ )
+ assert match is not None, f"--{name} missing from styles.css :root"
+ colors[name] = match.group(1).lower()
+ return colors
+
+
+def _template_defaults() -> dict[str, str]:
+ """The 3 template strings from the CODE defaults (derived from the
+ class fields — never drifts from ``app/config.py``; the module
+ server pins the same values, so the effective strings start exactly
+ here — the strings stay default through this suite)."""
+ return {
+ "app_name": Settings.model_fields["app_name"].default,
+ "input_placeholder": Settings.model_fields["input_placeholder"].default,
+ "footer_text": Settings.model_fields["footer_text"].default,
+ }
+
+
+def _expected_tag(colors: dict[str, str]) -> str:
+ """The EXACT inline tag ``theme_style_tag`` renders for
+ ``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
+ order, no whitespace (the byte the middleware injects)."""
+ declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
+ return f''
+
+
+# ---------------------------------------------------------------------------
+# Per-module app env (the tuning/tokens/archive-upload pattern)
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def app_server(mock_llm: int) -> Iterator[str]:
+ """The real app under test — per-module env: the branding vars are
+ pinned to the CODE defaults (the effective strings start at the
+ template defaults regardless of an operator's local ``.env`` — the
+ phase-61/62 leak-guard pattern, extended to ``BOR_APP_NAME``) and
+ ``BOR_GIT_SOURCES`` is forced empty (the dev ``.env``'s git repo
+ must not render as env rows in this suite's Git-sources table)."""
+ env = dict(os.environ)
+ env.pop("DEBUGPY", None)
+ env["BOR_ENVIRONMENT"] = "e2e"
+ env["BOR_STATIC_DIR"] = str(REPO / "frontend")
+ env["BOR_LLM_BASE_URL"] = (
+ "https://aipi.reeseapps.com/v1"
+ if USE_REAL_LLM
+ else f"http://127.0.0.1:{mock_llm}/v1"
+ )
+ # Mock-calibrated threshold (conftest pattern) — the tool turn must
+ # be grounded against the mock's token-overlap embeddings.
+ env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
+ env["BOR_LLM_RETRY_DELAY"] = "0"
+ env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
+ env.setdefault(
+ "BOR_DATABASE_URL",
+ "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
+ )
+ # Phase 16: admin auth must be set or create_app() refuses to boot.
+ env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
+ env["BOR_SESSION_SECRET"] = SESSION_SECRET
+ env["BOR_DOCS_REPO"] = ""
+ env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
+ env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
+ env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
+ env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
+ env["BOR_GIT_SOURCES"] = ""
+ proc = subprocess.Popen(
+ [sys.executable, "-m", "uvicorn", "app.main:app",
+ "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+ cwd=REPO,
+ env=env,
+ )
+ try:
+ _wait_http(f"{APP_URL}/api/health")
+ yield APP_URL
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+
+@pytest.fixture(scope="module")
+def app_url(app_server: str) -> str:
+ return app_server
+
+
+@pytest.fixture(scope="module")
+def local_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """A plain (non-git) host directory the local-badge test registers
+ as a source (the app server runs on the same host, so the path is
+ visible to it — the ``test_local_directory_sources.py`` pattern)."""
+ root = tmp_path_factory.mktemp("bor_theme93_local")
+ (root / "note.md").write_text(
+ "# Local theme fixture\n\n"
+ "One small note that exists only to be a local source.\n",
+ encoding="utf-8",
+ )
+ assert (root / "note.md").is_file()
+ assert not (root / ".git").exists() # the story: NOT a git repo
+ return root
+
+
+# ---------------------------------------------------------------------------
+# DB isolation + helpers
+# ---------------------------------------------------------------------------
+
+
+def _clean_ui_state() -> None:
+ """Fresh theme + token state per test: truncate the single-row
+ ``ui_settings`` (the middleware reads it for EVERY page — a
+ leftover themed row would repaint other suites' pages) and delete
+ this suite's issued tokens (label-scoped on ``e2e-`` — never a
+ TRUNCATE: the shared DB may hold the owner's real tokens)."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE ui_settings"))
+ db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
+ db.commit()
+
+
+@pytest.fixture(autouse=True)
+def _clean(db_ready: None) -> Iterator[None]:
+ _clean_ui_state()
+ yield
+ _clean_ui_state()
+
+
+def _cookies(page: Page) -> dict[str, str]:
+ """The session cookies the browser context holds (the test's API
+ side sees exactly what that browser sees)."""
+ return {
+ c["name"]: c["value"]
+ for c in page.context.cookies()
+ if "name" in c and "value" in c
+ }
+
+
+def _seed_theme(app_url: str, cookies: dict[str, str]) -> None:
+ """The DIRECT-PUT seed (the API path — the UI save itself is
+ test 1's job): all 17 gray colors, strings left at their defaults
+ (omitted — the PUT replaces the row, absent = back to default)."""
+ r = httpx.put(
+ f"{app_url}/api/ui-settings", json=dict(GRAY), cookies=cookies, timeout=10
+ )
+ assert r.status_code == 200, r.text
+ # Every gray differs from its built-in, so the effective values are
+ # exactly the saved ramp (no built-in→NULL collapse happened).
+ body = r.json()
+ for field in COLOR_FIELDS:
+ assert body[field] == GRAY[field], f"effective {field} != {GRAY[field]}"
+
+
+#: One computed-style read: ``getComputedStyle(el)[prop]`` parsed into
+#: ``[r, g, b, alpha]`` (alpha 1 when the shorthand carries none).
+_RGB_EL_JS = """(el, prop) => {
+ const m = getComputedStyle(el)[prop].match(
+ /rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?\\)/
+ );
+ return m
+ ? [Number(m[1]), Number(m[2]), Number(m[3]),
+ m[4] === undefined ? 1 : Number(m[4])]
+ : null;
+}"""
+
+
+def _assert_gray(
+ loc: Locator,
+ prop: str,
+ expect_hex: str,
+ *,
+ label: str,
+ expect_opaque: bool = False,
+) -> tuple[float, float, float, float]:
+ """The "still color" detector (TODO.md L3): the element's COMPUTED
+ ``prop`` must be GRAYSCALE (``r == g == b``) AND equal to the saved
+ ramp's hex for the variable the stylesheet declares there. Returns
+ the observed ``[r, g, b, a]`` (the ratio checks reuse it)."""
+ rgb = loc.evaluate(_RGB_EL_JS, prop)
+ assert rgb is not None, f"{label}: no element for {prop!r}"
+ r, g, b, a = (float(v) for v in rgb)
+ assert r == g == b, (
+ f"{label} {prop} is still COLOR: rgba({r:.0f}, {g:.0f}, {b:.0f}, {a})"
+ )
+ assert (r, g, b) == _hex_rgb(expect_hex), (
+ f"{label} {prop}: rgb({r:.0f}, {g:.0f}, {b:.0f}) != {expect_hex}"
+ )
+ if expect_opaque:
+ assert a == 1, f"{label} {prop} is transparent (alpha {a})"
+ return (r, g, b, a)
+
+
+def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
+ """The first-paint proof: all 17 computed ``:root`` custom
+ properties equal the given hexes. The inline tag precedes every
+ stylesheet application, so a themed deployment resolves them from
+ the first style pass — no red flash, no pop-in (custom properties
+ return the specified token, so the string compare is stable — the
+ ``.trim()`` rides out any token whitespace)."""
+ expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
+ page.wait_for_function(
+ """(expected) => {
+ const cs = getComputedStyle(document.documentElement);
+ return Object.entries(expected).every(
+ ([k, v]) => cs.getPropertyValue(k).trim() === v
+ );
+ }""",
+ arg=expected,
+ timeout=timeout,
+ )
+
+
+# ---------------------------------------------------------------------------
+# 1. The tab UI: the 8 new pickers + Save carry all 17 grays over the
+# wire; "Theme saved." computes gray; no contrast warning
+# ---------------------------------------------------------------------------
+
+
+def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) -> None:
+ defaults = _template_defaults()
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/theme.html")
+ expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
+
+ # Fill ALL 17 pickers — the 9 identity + the 8 NEW semantic state
+ # pickers (the form wiring this task exists to prove: the pickers
+ # feed the PUT body, the live preview, and the refetch).
+ for field in COLOR_FIELDS:
+ page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
+ # The 3 strings stay default (untouched — they hold the effective
+ # template defaults the module server pins).
+
+ # Capture the PUT body the form builds (the wiring proof: all 17
+ # grays ride the wire together with the 3 default strings).
+ captured: dict[str, Any] = {}
+
+ def handle(route: Route) -> None:
+ if route.request.method == "PUT":
+ captured["body"] = route.request.post_data_json
+ route.continue_()
+
+ page.route("**/api/ui-settings", handle)
+ try:
+ page.click("#theme-save")
+ expect(page.locator("#theme-result")).to_have_text(
+ "Theme saved.", timeout=30_000
+ )
+ finally:
+ page.unroute("**/api/ui-settings")
+
+ # Screenshot 7: "Theme saved." — the green result text — now
+ # computes GRAY ok-ink, text intact, role=status preserved.
+ expect(page.locator("#theme-result")).to_have_attribute("role", "status")
+ _assert_gray(page.locator("#theme-result"), "color", GRAY["ok_ink"], label="result text")
+
+ # All EIGHT AA pairs pass on the saved ramp → the client's warning
+ # line (theme.js PAIRS — the mirror of the theming.py table) stays
+ # hidden (it re-checks the saved state on the save's refetch).
+ expect(page.locator("#theme-contrast")).to_be_hidden()
+
+ # The PUT carried every field: the 17 grays + the 3 default strings
+ # (the strings were never touched, so they ride at their defaults).
+ body = captured.get("body")
+ assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
+ assert body == {**GRAY, **defaults}, f"unexpected PUT body: {body}"
+
+ # The inputs re-populate to the SAVED (effective) grays (the
+ # save's refetch is the canonical state)…
+ for field in COLOR_FIELDS:
+ expect(page.locator(f"#theme-{field.replace('_', '-')}")).to_have_value(
+ GRAY[field]
+ )
+ # …and the id-1 row stores all 17 (none equal a built-in, so
+ # nothing collapsed to NULL).
+ with SessionLocal() as db:
+ row = db.get(UiSettings, 1)
+ assert row is not None, "the PUT must upsert the id-1 row"
+ for field in COLOR_FIELDS:
+ assert getattr(row, field) == GRAY[field]
+
+
+# ---------------------------------------------------------------------------
+# 2. Pre-paint + persistence: the 17-var gray tag on the wire AND the
+# gray first paint (canvas + grid + computed custom properties)
+# ---------------------------------------------------------------------------
+
+
+def test_pre_paint_gray_first_paint(page: Page, app_url: str, db_ready: None) -> None:
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+
+ # The RAW served HTML (httpx — no JS at all, the server's own
+ # bytes): exactly one inline theme tag with all 17 gray vars,
+ # placed IMMEDIATELY before (inject_theme's exact
+ # placement — the tag ends exactly where begins and
+ # carries the injector's single leading newline).
+ tag = _expected_tag(GRAY)
+ r = httpx.get(app_url + "/", timeout=10)
+ assert r.status_code == 200
+ assert r.text.count(tag) == 1, "expected exactly one theme tag in the raw HTML"
+ start = r.text.index(tag)
+ head = r.text.index("")
+ assert start + len(tag) == head, "the tag must end exactly where begins"
+ assert r.text[start - 1] == "\n", "the tag must carry the injector's leading newline"
+ # The CSP extension covers the 17-var tag (the strict sha256 source
+ # expression — no 'unsafe-inline').
+ csp = r.headers.get("content-security-policy", "")
+ assert "style-src 'self' 'sha256-" in csp, csp
+
+ # A FRESH navigation: the document carries the tag, and the FIRST
+ # paint is gray — the page background canvas is the element
+ # (the body stays transparent by the phase-08 contract), and the
+ # grid texture (body::before, 60% --grid-line) is gray too.
+ page.goto(app_url + "/")
+ assert tag in page.content(), "the served document must carry the theme tag"
+ _assert_gray(
+ page.locator("html"), "backgroundColor", GRAY["bg"],
+ label="page canvas", expect_opaque=True,
+ )
+ grid_image = page.evaluate(
+ "() => getComputedStyle(document.body, '::before').backgroundImage"
+ )
+ m = re.search(r"rgba?\((\d+),\s*(\d+),\s*(\d+)", grid_image)
+ assert m is not None, f"no grid texture gradient found: {grid_image!r}"
+ assert m.group(1) == m.group(2) == m.group(3), (
+ f"the grid texture is still colored: {grid_image!r}"
+ )
+
+ # All 17 computed :root custom properties equal the saved hexes at
+ # load (the inline tag precedes every stylesheet application — the
+ # first paint IS the themed paint — no red flash, no pop-in).
+ _wait_theme_computed(page, GRAY)
+
+
+# ---------------------------------------------------------------------------
+# 3. Page heads: non-transparent gray surface panel + AA ink on EVERY
+# page (the seven shell views + the login page)
+# ---------------------------------------------------------------------------
+
+#: ``(path, head selector, ink selector)`` — the seven shell views +
+#: the login page (the login check runs in a fresh ANONYMOUS context —
+#: a signed-in context is redirected straight to the app by
+#: login.js's boot whoami). The chat view's head is the STICKY NAVBAR
+#: (phase 93 task 03 audited #view-chat: it carries no .page-head —
+#: the navbar IS the head), so its pair is the app-header + the
+#: wordmark (ink on surface — the same pair as every .page-head).
+HEADS: tuple[tuple[str, str, str], ...] = (
+ ("/", "header.app-header", "header.app-header .brand"),
+ ("/tuning.html", "#view-tuning .page-head", "#view-tuning .page-head h1"),
+ ("/sources.html", "#view-rag .page-head", "#view-rag .page-head h1"),
+ ("/git-sources.html", "#view-git-sources .page-head", "#view-git-sources .page-head h1"),
+ ("/history.html", "#view-history .page-head", "#view-history .page-head h1"),
+ ("/tokens.html", "#view-tokens .page-head", "#view-tokens .page-head h1"),
+ ("/theme.html", "#view-theme .page-head", "#view-theme .page-head h1"),
+)
+
+
+def _assert_head_gray(page: Page, head_sel: str, ink_sel: str) -> None:
+ """One page head: NON-TRANSPARENT gray surface panel, gray ink, and
+ the OBSERVED ink-on-panel ratio >= 4.5 (WCAG 2.1 AA) — the
+ heading text stays present (readable is the point)."""
+ head = page.locator(head_sel)
+ ink = page.locator(ink_sel)
+ expect(head).to_be_visible()
+ head_rgb = _assert_gray(
+ head, "backgroundColor", GRAY["surface"],
+ label=f"head panel {head_sel}", expect_opaque=True,
+ )
+ ink_rgb = _assert_gray(ink, "color", GRAY["ink"], label=f"head ink {ink_sel}")
+ ratio = _wcag_ratio_rgb(ink_rgb[:3], head_rgb[:3])
+ assert ratio >= 4.5, f"{ink_sel} on {head_sel}: {ratio:.2f}:1 < 4.5:1"
+ expect(ink).not_to_have_text("") # the heading text is intact
+
+
+def test_page_heads_gray_panel_readable(
+ page: Page, browser: Browser, app_url: str, db_ready: None
+) -> None:
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+
+ # The seven shell views (each a fresh load — the themed shell
+ # document with the router activating the view).
+ for path, head_sel, ink_sel in HEADS:
+ page.goto(app_url + path)
+ _assert_head_gray(page, head_sel, ink_sel)
+
+ # The login page: its head block is the .login-card (phase 93
+ # task 03 audited it — already a surface card, deliberately
+ # untouched). A fresh anonymous context (the signed-in context
+ # would redirect away before the card renders).
+ anon_ctx: BrowserContext | None = None
+ try:
+ anon_ctx = browser.new_context(viewport={"width": 1280, "height": 800})
+ anon = anon_ctx.new_page()
+ anon.set_default_timeout(30_000)
+ anon.goto(app_url + "/login.html")
+ _assert_head_gray(anon, ".login-card", "#login-title")
+ finally:
+ if anon_ctx is not None:
+ anon_ctx.close()
+
+
+# ---------------------------------------------------------------------------
+# 4. History: the "Stale" pill (screenshot 5) — gray err family, text
+# intact (an out-of-generation saved chat — the phase-53 pattern)
+# ---------------------------------------------------------------------------
+
+STALE_TITLE = "Gray theme stale pin (phase 93)"
+
+
+def test_stale_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+ cookies = _cookies(page)
+
+ # Seed an out-of-generation saved chat (the phase-53 pattern: the
+ # version is monotonic — a row stamped against an OLDER generation
+ # is stale, computed server-side). Bump once so the stamp-0 row is
+ # strictly out of generation (stale = sources_version < current).
+ chat_id = uuid.uuid4()
+ with SessionLocal() as db:
+ bump_sources_version(db)
+ db.add(
+ SavedChat(
+ id=chat_id,
+ title=STALE_TITLE,
+ messages=[
+ {"who": "user", "text": "a question"},
+ {"who": "brain", "text": "an answer"},
+ ],
+ sources_version=0,
+ )
+ )
+ db.commit()
+ try:
+ page.goto(app_url + "/history.html")
+ row = page.locator(
+ "#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
+ )
+ expect(row.locator("a.history-title-link")).to_be_visible(timeout=15_000)
+ # Screenshot 5: the red "Stale" pill — now gray err-ink on
+ # gray err-bg with the gray err-line border, text intact.
+ pill = row.locator(".stale-pill")
+ expect(pill).to_have_count(1)
+ expect(pill).to_have_text("Stale")
+ _assert_gray(pill, "color", GRAY["err_ink"], label="stale pill text")
+ _assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="stale pill bg")
+ _assert_gray(
+ pill, "borderTopColor", GRAY["err_line"], label="stale pill border"
+ )
+ # The row's heading text (the title link) is intact too —
+ # state is words + color, never color alone (B5).
+ expect(row.locator("a.history-title-link")).to_have_text(STALE_TITLE)
+ finally:
+ # The shared DB may hold the owner's saved chats — delete only
+ # this suite's row (a 404 — already deleted — is fine).
+ httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
+
+
+# ---------------------------------------------------------------------------
+# 5. Tokens: the "Revoked" pill (screenshot 6) — gray err family, text
+# intact (generated + revoked THROUGH the UI two-step)
+# ---------------------------------------------------------------------------
+
+
+def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+
+ page.goto(app_url + "/tokens.html")
+ expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
+
+ # Generate a token through the UI (the phase-79 contract: the
+ # plaintext appears exactly once).
+ page.fill("#token-label", "e2e-theme93")
+ page.click("#token-generate")
+ row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
+ expect(row).to_have_count(1, timeout=15_000)
+ expect(row.locator(".stale-pill")).to_have_count(0) # Active: the em-dash
+
+ # Revoke through the UI two-step (Revoke → Yes — the inline
+ # confirm, no native dialog).
+ row.locator("button.token-revoke").click()
+ expect(row.locator(".history-confirm-yes")).to_be_visible()
+ row.locator(".history-confirm-yes").click()
+
+ # Screenshot 6: the red "Revoked" pill — now gray err-ink on gray
+ # err-bg with the gray err-line border, text intact.
+ revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
+ pill = revoked.locator(".stale-pill")
+ expect(pill).to_have_count(1)
+ expect(pill).to_have_text("Revoked", timeout=15_000)
+ _assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text")
+ _assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg")
+ _assert_gray(
+ pill, "borderTopColor", GRAY["err_line"], label="revoked pill border"
+ )
+ expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
+
+
+# ---------------------------------------------------------------------------
+# 6. Git sources: the "Local" badge (screenshot 3) — gray ok family,
+# text intact (a registered local-directory source)
+# ---------------------------------------------------------------------------
+
+
+def test_local_badge_gray_labeled(
+ page: Page, app_url: str, local_dir: Path, db_ready: None
+) -> None:
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+
+ # Register a local-directory source through the authenticated API
+ # (the phase-38 contract the phase-49 page used to wrap — the admin
+ # cookie rides the context's request API).
+ r = page.request.post(
+ f"{app_url}/api/git-sources", data={"kind": "local", "path": str(local_dir)}
+ )
+ assert r.status == 201, f"expected 201 for the local dir: {r.status} {r.text}"
+ source_id: str = r.json()["id"]
+ try:
+ page.goto(app_url + "/git-sources.html")
+ row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
+ expect(row).to_have_count(1, timeout=15_000)
+ # Screenshot 3: the green "Local" badge — now gray ok-ink on
+ # gray ok-bg, text intact.
+ badge = row.locator("span.git-source-kind.is-local")
+ expect(badge).to_have_count(1)
+ expect(badge).to_have_text("Local")
+ _assert_gray(badge, "color", GRAY["ok_ink"], label="local badge text")
+ _assert_gray(
+ badge, "backgroundColor", GRAY["ok_bg"], label="local badge bg"
+ )
+ finally:
+ # The shared DB may hold the owner's sources — remove only
+ # this suite's row (removal prunes nothing here: no sync ran).
+ page.request.delete(f"{app_url}/api/git-sources/{source_id}")
+
+
+# ---------------------------------------------------------------------------
+# 7. Chat: the "Listing documents" / "Reading" tool lines (screenshot 1)
+# — gray accent family, text intact (a mock-LLM turn that runs the
+# tools — the phase-37 deterministic ls → read → answer flow)
+# ---------------------------------------------------------------------------
+
+#: The two-document pair from the phase-37 seeding pattern — one RETRIEVABLE
+#: document (a chunk carrying the mock's own bag-of-words embedding) that
+#: grounds the turn, and one CATALOG-ONLY document (indexed, no chunks) the
+#: mock's single-read flow reads. The catalog-only document sorts FIRST
+#: ("Checklist" < "ThemeNotes") — the mock reads the first catalog line, and
+#: it must NOT be the in-context retrieval document: the agent's read tool
+#: refuses documents already in the prompt (ALREADY_IN_CONTEXT), and a
+#: refused single-read flow would re-loop ls/read to the round cap.
+READ_SOURCE = "Checklist"
+READ_PATH = "read-me.md"
+READ_SP = f"{READ_SOURCE}/{READ_PATH}"
+READ_DOC_CONTENT = (
+ "# Read Me\n\n"
+ "The checklist front page: gray theme pin.\n"
+)
+
+TOOL_DOC_SOURCE = "ThemeNotes"
+TOOL_DOC_PATH = "gray-theme.md"
+TOOL_DOC_SP = f"{TOOL_DOC_SOURCE}/{TOOL_DOC_PATH}"
+#: Repeats the marker question's key tokens (gray, theme, notes, page,
+#: head) — the mock's token-overlap embeddings cosine well past the
+#: E2E 0.30 threshold (plus FTS hits), so the turn is solidly grounded
+#: and the section is offered.
+TOOL_DOC_CONTENT = (
+ "# Gray Theme Notes\n\n"
+ "## Checklist\n\n"
+ + (
+ "Every page head gets the gray surface panel and the gray theme "
+ "notes keep the gray state labels readable.\n"
+ )
+ * 6
+)
+MARKER_QUESTION = (
+ "Use your tools: what do the gray theme notes say about the page head?"
+)
+
+
+def _seed_tool_docs(db: Session) -> None:
+ """The two-document pair (see the constants above): the retrievable
+ grounding document (one chunk carrying the mock's own bag-of-words
+ embedding) + the catalog-only read target (no chunks — retrieval
+ never puts it in context, so the agent's read tool accepts it)."""
+ db.add(
+ Document(
+ source=TOOL_DOC_SOURCE,
+ path=TOOL_DOC_PATH,
+ full_path=f"/tmp/{TOOL_DOC_PATH}",
+ title="Gray Theme Notes",
+ content=TOOL_DOC_CONTENT,
+ content_hash=hashlib.sha256(TOOL_DOC_CONTENT.encode()).hexdigest(),
+ indexed_at=datetime.now(UTC),
+ )
+ )
+ db.flush()
+ doc = db.scalar(
+ select(Document).where(
+ Document.source == TOOL_DOC_SOURCE, Document.path == TOOL_DOC_PATH
+ )
+ )
+ assert doc is not None, "the seed flush must assign the document id"
+ db.add(
+ Chunk(
+ document_id=doc.id,
+ position=0,
+ content=TOOL_DOC_CONTENT,
+ embedding=embed_text(TOOL_DOC_CONTENT),
+ )
+ )
+ db.add(
+ Document(
+ source=READ_SOURCE,
+ path=READ_PATH,
+ full_path=f"/tmp/{READ_PATH}",
+ title="Read Me",
+ content=READ_DOC_CONTENT,
+ content_hash=hashlib.sha256(READ_DOC_CONTENT.encode()).hexdigest(),
+ indexed_at=datetime.now(UTC),
+ )
+ )
+
+
+def _reset_kb_with_tool_docs() -> None:
+ """Truncate the KB tables (the house reset) and seed the pair — a
+ direct DB seed, so the turn is genuinely grounded and the mock's
+ single-read flow runs to completion: ls → read on the first catalog
+ line (the catalog-only document) → the quoted answer."""
+ with SessionLocal() as db:
+ db.execute(
+ text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
+ )
+ db.commit()
+ _seed_tool_docs(db)
+ db.commit()
+
+
+@pytest.mark.skipif(
+ USE_REAL_LLM,
+ reason="the deterministic ls → read tool flow is a mock-LLM contract (phase 37)",
+)
+def test_tool_call_lines_gray(page: Page, app_url: str, db_ready: None) -> None:
+ page.set_default_timeout(30_000)
+ _reset_kb_with_tool_docs()
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+ # The login's navigation PRE-DATES the save — a fresh load is the
+ # pre-paint gray document (the theme applies on first paint, so the
+ # computed checks below see the saved ramp, not the built-ins).
+ page.goto(app_url + "/")
+
+ page.fill("#message-input", MARKER_QUESTION)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(MARKER_QUESTION)
+ # The turn settles: the grounded answer lands (the mock's "Read
+ # . " — the read document reached the
+ # model) and the Send control recovers.
+ expect(
+ page.locator(".msg.brain .bubble:not(.typing)").last
+ ).not_to_have_text("", timeout=60_000)
+ expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000)
+
+ # Screenshot 1: the yellow "Listing documents" / "Reading" lines —
+ # now gray accent-ink text with the gray accent-line left border,
+ # both lines' text intact.
+ lines = page.locator(".msg.brain .tool-call")
+ expect(lines).to_have_count(2)
+ expect(lines.nth(0)).to_contain_text("Listing documents")
+ expect(lines.nth(1)).to_contain_text("Reading")
+ expect(lines.nth(1)).to_contain_text(READ_SP)
+ _assert_gray(lines.nth(0), "color", GRAY["accent_ink"], label="ls line text")
+ _assert_gray(
+ lines.nth(0), "borderLeftColor", GRAY["accent_line"], label="ls line border"
+ )
+ _assert_gray(lines.nth(1), "color", GRAY["accent_ink"], label="read line text")
+ # The path chip on the Reading line: gray brand-soft background +
+ # gray ink (the screenshot's code chip — still gray under the ramp).
+ code = lines.nth(1).locator("code")
+ _assert_gray(code, "backgroundColor", GRAY["brand_soft"], label="read chip bg")
+ _assert_gray(code, "color", GRAY["ink"], label="read chip text")
+
+
+# ---------------------------------------------------------------------------
+# 8. Reset: the gray tag leaves the LIVE document, a fresh load serves
+# NO tag, the built-ins return, and the bytes equal a row-less
+# deployment (the B4 no-op contract end to end)
+# ---------------------------------------------------------------------------
+
+
+def test_reset_removes_tag_byte_identical(page: Page, app_url: str, db_ready: None) -> None:
+ builtin = _builtin_colors()
+ page.set_default_timeout(30_000)
+ login(page, app_url, next="/")
+ _seed_theme(app_url, _cookies(page))
+
+ # The pre-reset baseline: the 17-var gray tag is in the LIVE
+ # document (pre-paint on this load) AND on the wire.
+ page.goto(app_url + "/theme.html")
+ expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
+ assert 'id="bor-theme"' in page.content(), "the live document must carry the tag"
+ assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
+
+ # Reset to defaults (the all-null PUT — the API's "defaults"
+ # operation; the §7.4 lifecycle lands the role=status line).
+ page.click("#theme-reset")
+ expect(page.locator("#theme-result")).to_have_text(
+ "Reset to the built-in theme.", timeout=30_000
+ )
+
+ # The tag is GONE from the live document (theme.js reconciles the
+ # #bor-theme tag's DOM text to the settled, now-default values —
+ # the no-op case removes the tag and clears the
+ # overrides)…
+ assert 'id="bor-theme"' not in page.content(), (
+ "the reset document must be tag-free"
+ )
+ # …and a FRESH load serves NO tag at all (the all-NULL row is the
+ # no-op injection).
+ r = httpx.get(app_url + "/", timeout=10)
+ assert "bor-theme" not in r.text, "a defaults-saved row must serve no theme tag"
+
+ # The byte-identical contract, proven end to end: the served bytes
+ # of the reset (all-NULL row) deployment equal the served bytes of
+ # a ROW-LESS deployment (the middleware's no-op path — no tag,
+ # plain A1 CSP, identical ?v= rewrite).
+ with_row = httpx.get(app_url + "/", timeout=10).content
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE ui_settings"))
+ db.commit()
+ without_row = httpx.get(app_url + "/", timeout=10).content
+ assert with_row == without_row, (
+ "a defaults-saved row must serve byte-identical HTML"
+ )
+
+ # And the palette is back to the built-ins (parsed from the
+ # stylesheet — no second copy).
+ page.goto(app_url + "/")
+ _wait_theme_computed(page, builtin)
diff --git a/tests/integration/test_migration_0016.py b/tests/integration/test_migration_0016.py
new file mode 100644
index 0000000..2ae048f
--- /dev/null
+++ b/tests/integration/test_migration_0016.py
@@ -0,0 +1,194 @@
+"""Integration: migration 0016 (ui_settings semantic colors) schema
+contract (phase 93, task 01).
+
+Drives the **real Alembic engine** against the live dev database
+(``podman compose up -d db``), mirroring the house pattern of
+``test_migration_0014.py`` (information_schema assertions on the state
+the migration must leave). The tests target the 0015 → 0016 step
+explicitly so later migrations cannot break them:
+
+* upgrade 0015 → 0016 → the 8 semantic columns exist with the full
+ contract (VARCHAR(7) NULL, no server defaults — the row is created
+ only by the PUT upsert, house rule) while the 0014/0015 columns
+ (``app_name``, ``grid_line``, ``brand_ink``) survive;
+* an inserted id-1 row round-trips its semantic values (the PUT
+ upsert's shape);
+* downgrade to 0015 → the 8 columns are GONE (A13 — reversible), the
+ rest of the ``ui_settings`` schema (and ``api_tokens``) survives;
+* upgrade back to 0016 → the 8 columns are back (round-trip).
+
+The ``alembic`` fixture guarantees the DB ends at head even if a test
+fails or the process is interrupted.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+
+import pytest
+from alembic.config import Config
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from alembic import command
+from app.db import db_available
+
+#: The 8 semantic columns (phase 93 — B3 revised): ok / err / accent
+#: families, VARCHAR(7) NULL ``#rrggbb``, NULL = the built-in (B1).
+SEMANTIC_COLUMNS = (
+ "ok_bg", "ok_ink",
+ "err_bg", "err_ink", "err_line",
+ "accent_bg", "accent_ink", "accent_line",
+)
+
+
+@pytest.fixture()
+def alembic(db: Session) -> Iterator[Config]:
+ """Real Alembic config bound to the dev DB (URL from app settings).
+
+ Starts at head (repairs an interrupted earlier run); teardown upgrades
+ to head no matter what happened, so the dev DB is never left below
+ head.
+ """
+ if not db_available():
+ pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
+ cfg = Config() # no alembic.ini file — env.py gets the URL from app config
+ cfg.set_main_option("script_location", "alembic")
+ command.upgrade(cfg, "head")
+ try:
+ yield cfg
+ finally:
+ command.upgrade(cfg, "head")
+
+
+def _version(db: Session) -> str | None:
+ return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
+
+
+def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
+ """(data_type, is_nullable, column_default, character_maximum_length)
+ for one table column."""
+ row = db.execute(
+ text(
+ "SELECT data_type, is_nullable, column_default, character_maximum_length"
+ " FROM information_schema.columns"
+ " WHERE table_name = :t AND column_name = :c"
+ ),
+ {"t": table, "c": column},
+ ).fetchone()
+ return tuple(row) if row is not None else None
+
+
+def _insert_row(db: Session) -> None:
+ """Insert the single row (the PUT upsert's shape) with two semantic
+ values set and the rest NULL — the NULL = built-in state the
+ resolver merges."""
+ db.execute(
+ text(
+ "INSERT INTO ui_settings (id, ok_ink, accent_bg) VALUES (1, :o, :a)"
+ ),
+ {"o": "#444444", "a": "#222222"},
+ )
+ db.commit()
+
+
+def _delete_row(db: Session) -> None:
+ db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
+ db.commit()
+
+
+def test_upgrade_to_0016_adds_the_8_semantic_columns(
+ db: Session, alembic: Config
+) -> None:
+ """Upgrade 0015 → 0016: the 8 semantic columns exist with the full
+ contract (VARCHAR(7) NULL — NULL = the built-in, B1; no server
+ defaults anywhere: a missing row means "defaults"); all 8 are
+ ABSENT at 0015 and the pre-0016 columns survive the upgrade."""
+ command.downgrade(alembic, "0015") # start from the pre-0016 state
+ assert _version(db) == "0015"
+ for name in SEMANTIC_COLUMNS:
+ assert _column(db, "ui_settings", name) is None, (
+ f"ui_settings.{name} must be absent at 0015"
+ )
+
+ command.upgrade(alembic, "0016")
+ assert _version(db) == "0016", "alembic_version must be at 0016"
+
+ for name in SEMANTIC_COLUMNS:
+ col = _column(db, "ui_settings", name)
+ assert col is not None, f"ui_settings.{name} is missing"
+ assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
+ assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)"
+ assert col[2] is None, f"ui_settings.{name} must have no server default"
+ assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb"
+
+ # The 0014/0015 columns survive the additive upgrade.
+ for name in ("app_name", "grid_line", "brand_ink"):
+ col = _column(db, "ui_settings", name)
+ assert col is not None, f"ui_settings.{name} must survive the upgrade"
+
+
+def test_inserted_id_1_row_round_trips_semantic_values(
+ db: Session, alembic: Config
+) -> None:
+ """At 0016, the single row (id 1, the PUT upsert's shape)
+ round-trips its semantic values verbatim and keeps the unset
+ columns NULL (identity and the other semantic columns)."""
+ command.upgrade(alembic, "head")
+ _insert_row(db)
+ try:
+ row = db.execute(
+ text(
+ "SELECT id, ok_ink, accent_bg, ok_bg, err_ink, accent_line"
+ " FROM ui_settings WHERE id = 1"
+ )
+ ).fetchone()
+ assert row is not None, "the ui_settings row must exist"
+ assert row[0] == 1, "the single row is always id 1"
+ assert row[1] == "#444444", "ok_ink must round-trip verbatim"
+ assert row[2] == "#222222", "accent_bg must round-trip verbatim"
+ assert row[3] is None, "ok_bg must stay NULL (the built-in, B1)"
+ assert row[4] is None, "err_ink must stay NULL (the built-in, B1)"
+ assert row[5] is None, "accent_line must stay NULL (the built-in, B1)"
+ finally:
+ _delete_row(db)
+
+
+def test_downgrade_to_0015_drops_the_8_columns(db: Session, alembic: Config) -> None:
+ """Downgrade 0016 → 0015: the 8 semantic columns are gone (A13 —
+ fully reversible) while the rest of the schema survives (the 0015
+ ``grid_line`` column, the 0014 strings, ``api_tokens``)."""
+ command.downgrade(alembic, "0015")
+ assert _version(db) == "0015"
+ for name in SEMANTIC_COLUMNS:
+ assert _column(db, "ui_settings", name) is None, (
+ f"ui_settings.{name} must be dropped"
+ )
+
+ grid = _column(db, "ui_settings", "grid_line")
+ assert grid is not None and grid[3] == 7, (
+ "grid_line (0015) must survive the downgrade"
+ )
+ app_name = _column(db, "ui_settings", "app_name")
+ assert app_name is not None and app_name[3] == 300, (
+ "app_name (0014) must survive the downgrade"
+ )
+ token_col = _column(db, "api_tokens", "token_hash")
+ assert token_col is not None and token_col[0] == "character varying", (
+ "api_tokens.token_hash must survive the downgrade"
+ )
+
+
+def test_upgrade_round_trip_restores_the_columns(db: Session, alembic: Config) -> None:
+ """Downgrade to 0015, then upgrade back to 0016: the 8 columns are
+ back with the column contract intact."""
+ command.downgrade(alembic, "0015")
+ command.upgrade(alembic, "0016")
+ assert _version(db) == "0016", "round-trip upgrade must land at 0016"
+
+ for name in SEMANTIC_COLUMNS:
+ col = _column(db, "ui_settings", name)
+ assert col is not None, f"ui_settings.{name} must be back"
+ assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
+ assert col[1] == "YES", f"ui_settings.{name} must be NULL after the round-trip"
+ assert col[3] == 7, f"ui_settings.{name} must be String(7) after the round-trip"
diff --git a/tests/integration/test_ui_settings_api.py b/tests/integration/test_ui_settings_api.py
index deb2fb1..e31c1d2 100644
--- a/tests/integration/test_ui_settings_api.py
+++ b/tests/integration/test_ui_settings_api.py
@@ -149,7 +149,70 @@ def test_admin_grid_line_validation_and_normalization(
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["grid_line"] == "#123123" # the stored value reads back
- assert len(r.json()) == 12 # the 12-key response shape (9 colors + 3 strings)
+ assert len(r.json()) == 20 # the 20-value shape (17 colors + 3 strings, phase 93)
+
+
+def test_admin_semantic_fields_round_trip(client: TestClient, db: Session) -> None:
+ """Phase 93 (task 01): the 8 semantic state colors against the LIVE
+ API — a bad hex is a 422 naming the field (same fixed detail as the
+ identity colors); a non-built-in value is lowercased on store and
+ reads back through GET; a built-in value stores NULL (the response
+ still reports the built-in); an absent (null) field stores NULL.
+ The response is the effective values for all 20 keys."""
+ client.post("/api/login", json={"password": ADMIN_PASSWORD})
+
+ r = client.put("/api/ui-settings", json={"ok_bg": "not-a-color"})
+ assert r.status_code == 422, r.text
+ assert r.json()["detail"] == "ok_bg must be a #rrggbb hex color"
+
+ r = client.put(
+ "/api/ui-settings",
+ json={"ok_ink": "#444444", "accent_bg": "#222222", "err_line": "#EFEFEF"},
+ )
+ assert r.status_code == 200, r.text
+ assert r.json()["ok_ink"] == "#444444" # stored + reported
+ assert r.json()["accent_bg"] == "#222222" # stored + reported
+ assert r.json()["err_line"] == "#efefef" # upper → lowercased on store
+
+ row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
+ assert row is not None, "the PUT upsert creates the id-1 row"
+ assert row.ok_ink == "#444444"
+ assert row.accent_bg == "#222222"
+ assert row.err_line == "#efefef" # the stored column, lowercase
+ assert row.err_bg is None # absent (null) field → NULL
+ assert row.accent_ink is None # absent (null) field → NULL
+
+ r = client.get("/api/ui-settings")
+ assert r.status_code == 200
+ body = r.json()
+ assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
+ assert len(body) == 20
+ # GET returns the EFFECTIVE values: the stored ones over the
+ # built-ins for the untouched 14 colors.
+ assert body["ok_ink"] == "#444444"
+ assert body["err_bg"] == theming.BUILTIN_COLORS["err_bg"]
+ assert body["accent_ink"] == theming.BUILTIN_COLORS["accent_ink"]
+ untouched = [k for k in theming.COLOR_FIELDS if k not in ("ok_ink", "accent_bg", "err_line")]
+ assert {k: body[k] for k in untouched} == {
+ k: theming.BUILTIN_COLORS[k] for k in untouched
+ } # the 14 untouched colors report their built-ins
+
+ # The built-in → NULL normalization: PUT the built-in back (one in
+ # uppercase) — the row's semantic columns return to NULL and the
+ # effective values are still the built-ins (no-op contract).
+ body_put = {"ok_ink": theming.BUILTIN_COLORS["ok_ink"].upper(),
+ "accent_bg": theming.BUILTIN_COLORS["accent_bg"],
+ "err_line": theming.BUILTIN_COLORS["err_line"]}
+ r = client.put("/api/ui-settings", json=body_put)
+ assert r.status_code == 200, r.text
+ db.expire_all()
+ row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
+ assert row is not None
+ assert row.ok_ink is None # built-in (uppercase in) → NULL
+ assert row.accent_bg is None # built-in → NULL
+ assert row.err_line is None # built-in → NULL
+ for key in theming.COLOR_FIELDS:
+ assert r.json()[key] == theming.BUILTIN_COLORS[key]
def _config_keys() -> set[str]:
diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py
index 2b72128..dc75a7d 100644
--- a/tests/unit/test_frontend_router.py
+++ b/tests/unit/test_frontend_router.py
@@ -902,8 +902,10 @@ def test_theme_view_scaffold_in_the_shell() -> None:
the ship-hidden #theme-content (the #git-sources-content pattern)
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
#theme-form with the 3 labeled branding text inputs (maxlength=300
- — the server re-validates) + the 9 labeled type=color palette inputs
- (the 9 identity variables, in the theming.COLOR_FIELDS order), the
+ — the server re-validates) + the 17 labeled type=color palette
+ inputs (the 9 identity variables, then the 8 semantic state
+ variables in the State colors fieldset — phase 93 — in the
+ theming.COLOR_FIELDS order), the
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
(no real submit), and the three task-05 feedback lines: #theme-error
(role=alert), #theme-result (role=status), #theme-contrast
@@ -941,8 +943,9 @@ def test_theme_view_scaffold_in_the_shell() -> None:
)
# The static form skeleton (the E2E-stable-selectors house
# convention): the 3 labeled branding text inputs (maxlength=300)
- # and the 9 labeled type=color palette inputs (the 9 identity
- # variables — one per theming.COLOR_FIELDS field).
+ # and the 17 labeled type=color palette inputs (the 9 identity
+ # variables, then the 8 semantic state variables — phase 93 —
+ # one per theming.COLOR_FIELDS field).
assert re.search(r'