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']*id="theme-form"[^>]*>', body), ( "the #theme-form must be STATIC markup in the shell" ) @@ -963,6 +966,16 @@ def test_theme_view_scaffold_in_the_shell() -> None: "theme-brand", "theme-brand-soft", "theme-brand-ink", + # The 8 semantic state pickers (phase 93 — the State colors + # fieldset after the palette fieldset). + "theme-ok-bg", + "theme-ok-ink", + "theme-err-bg", + "theme-err-ink", + "theme-err-line", + "theme-accent-bg", + "theme-accent-ink", + "theme-accent-line", ): assert re.search( rf']*for="{field_id}"[^>]*>', body diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index d7d793c..98052be 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -22,10 +22,11 @@ def test_all_tables_registered() -> None: def test_ui_settings_single_row_nullable_contract() -> None: - """Phase 91 (9 identity colors after phase 92, task 01): the + """Phase 91 (9 identity colors after phase 92, task 01; the 8 + semantic state colors after phase 93, task 01 — B3 revised): the single-row UI settings table — Integer PK ``id`` with the Python-side ``default=1`` (the row is always id 1), the 3 strings - VARCHAR(300) and the 9 identity colors VARCHAR(7), ALL nullable + VARCHAR(300) and the 17 palette colors VARCHAR(7), ALL nullable (NULL = default — B1: env value for the strings, the built-in palette for the colors).""" settings_table = Base.metadata.tables["ui_settings"] @@ -33,6 +34,8 @@ def test_ui_settings_single_row_nullable_contract() -> None: "id", "app_name", "input_placeholder", "footer_text", "bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink", + "ok_bg", "ok_ink", "err_bg", "err_ink", "err_line", + "accent_bg", "accent_ink", "accent_line", } pk = settings_table.c["id"] assert pk.primary_key is True, "ui_settings.id must be the PK" @@ -42,7 +45,9 @@ def test_ui_settings_single_row_nullable_contract() -> None: assert col.nullable is True, f"{name} must be NULL (env default)" assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)" for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line", - "brand", "brand_soft", "brand_ink"): + "brand", "brand_soft", "brand_ink", + "ok_bg", "ok_ink", "err_bg", "err_ink", "err_line", + "accent_bg", "accent_ink", "accent_line"): col = settings_table.c[name] assert col.nullable is True, f"{name} must be NULL (the built-in)" assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb" diff --git a/tests/unit/test_page_head_panel.py b/tests/unit/test_page_head_panel.py new file mode 100644 index 0000000..3026e60 --- /dev/null +++ b/tests/unit/test_page_head_panel.py @@ -0,0 +1,281 @@ +"""Unit: the phase-93 page-head surface panel contract (task 03 — +source pins). + +Owner direction (TODO.md L3): "Also the header and description of each +page needs a background - the grid makes it hard to read." Every +page's ``h1`` + description (the shell's standard ``.page-head`` frame) +sits on a solid ``var(--surface)`` panel — the house card language — +so the 44px background grid never fights the heading text. Browser +behavior (computed ``background-color`` non-transparent on every head, +the History flex row + 360px wrap unbroken, the monochrome theme +graying the panel) is E2E-gated by ``tests/e2e/ +test_theme_semantic_completion.py`` (task 04) and the existing +header/nav/responsive suites; here we pin the source-level contract +(house pattern: ``tests/unit/test_background_no_motion.py`` parses +``styles.css`` rules): + +* the shared ``.page-head`` rule declares the panel — a NON-transparent + ``background`` built from ``var(--surface)`` (no color literal of any + kind, no blur — the phase-08 perf anchor), the 1px ``var(--line)`` + border, the house card radius, and card-rhythm padding; +* the ``#view-history`` page-head keeps its SCOPED flex row (padding + lives on the flex container — the base rule — never on the + children), and the mobile ``.page-head-row { flex-wrap: wrap; }`` + survives; +* every shell view that carries a ``.page-head`` in the shell markup + is covered by the ONE rule (six views; ``#view-chat`` carries no + ``.page-head`` — its head is the navbar, audited), and + ``doc-edit.html`` keeps its ``.page-head``; +* the standalone pages: the shared page's head (``h1#shared-title`` + + the lede) is wrapped in the same ``.page-head`` class, while the two + heads that already sit inside a surfaced card are deliberately + UNTOUCHED (login's ``.login-card`` — surface card; document viewer's + ``.doc-titlebar`` inside the sticky surface ``.doc-header``). +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +STYLES_CSS = FRONTEND / "assets" / "styles.css" +INDEX_HTML = FRONTEND / "index.html" +SHARED_HTML = FRONTEND / "shared.html" +DOC_EDIT_HTML = FRONTEND / "doc-edit.html" +LOGIN_HTML = FRONTEND / "login.html" +DOCUMENT_HTML = FRONTEND / "document.html" + +# The six shell views whose h1 + description sit in a .page-head +# (#view-chat carries none — its head is the navbar; audited). +SHELL_VIEWS_WITH_PAGE_HEAD = ( + "view-tuning", + "view-rag", + "view-git-sources", + "view-history", + "view-tokens", + "view-theme", +) + + +def _css() -> str: + return STYLES_CSS.read_text(encoding="utf-8") + + +def _css_no_comments() -> str: + return re.sub(r"/\*[\s\S]*?\*/", "", _css()) + + +def _find_rule(css: str, selector: str) -> re.Match[str] | None: + """The first top-level ``selector { ... }`` rule (comments + stripped by the caller when prose must not interfere).""" + return re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([\s\S]*?)\n\}", css) + + +def _rule_block(css: str, selector: str) -> str: + rule = _find_rule(css, selector) + assert rule, f"styles.css must define a {selector} rule" + return rule.group(1) + + +def _view_section(body: str, view_id: str) -> str: + """The view section slice — from its opening tag to the next + sibling view (or ````). A first-```` slice would + cut short: the views nest gate sections (``
``) INSIDE, and the Git-sources / Theme + ``.page-head`` sits after the first nested close.""" + start = body.find(f'
", start)) + if e != -1 + ] + assert boundaries, "the shell must have a closing " + return body[start : min(boundaries)] + + +# -------------------------------------------------------------------------- +# The shared .page-head rule — the surface panel +# -------------------------------------------------------------------------- + + +def test_page_head_rule_is_the_surface_panel() -> None: + """The one shared rule declares the panel: a non-transparent + background from ``var(--surface)`` (itself tab-controlled — a + monochrome theme grays the head automatically), the 1px + ``var(--line)`` border, the house card radius, and card-rhythm + padding (the phase-93 panel: 1rem block / 1.25rem inline).""" + block = _rule_block(_css_no_comments(), ".page-head") + assert "background: var(--surface)" in block, ( + "the .page-head panel must fill with var(--surface) — solid, " + "never transparent, never a literal" + ) + assert "border: 1px solid var(--line)" in block, ( + "the panel must carry the house 1px --line border" + ) + assert "border-radius: var(--radius)" in block, ( + "the panel must use the house card radius" + ) + assert re.search(r"padding:\s*1rem\s+1\.25rem", block), ( + "the panel must carry the card-rhythm padding (1rem 1.25rem)" + ) + + +def test_page_head_rule_has_no_color_literal_and_no_blur() -> None: + """Phase-92 invariant + phase-08 perf anchor: the panel rule + introduces NO new color literal (hex / rgb / hsl / color-mix — + ``var(--…)`` only) and no filter/blur.""" + block = _rule_block(_css_no_comments(), ".page-head") + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), ( + "no hex color literal in the .page-head panel (phase-92 invariant)" + ) + for func in ("rgb(", "hsl(", "color-mix("): + assert func not in block, f"no {func}… literal in the .page-head panel" + assert "filter" not in block and "blur" not in block, ( + "no filter/blur in the .page-head panel (phase-08 perf anchor)" + ) + + +def test_page_head_background_is_solid_not_translucent() -> None: + """The panel is SOLID — the assumption the phase locked (not + translucent, not a full-bleed band, no blur): the background + declaration names exactly the surface variable, nothing mixed.""" + block = _rule_block(_css_no_comments(), ".page-head") + backgrounds = re.findall(r"(?m)^\s*background(?:-color)?:\s*([^;]+);", block) + assert backgrounds == ["var(--surface)"], ( + f"the .page-head background must be exactly var(--surface), " + f"got {backgrounds}" + ) + + +# -------------------------------------------------------------------------- +# Layout safety — the History flex row and the mobile wrap +# -------------------------------------------------------------------------- + + +def test_history_page_head_keeps_its_scoped_flex_row() -> None: + """The ``#view-history .page-head`` flex row (title left, refresh + pill right — phase 77) survives the panel: the scoped rule keeps + its flex declarations and adds NO padding of its own (the task-03 + rule — padding on the flex container, i.e. the base ``.page-head`` + rule, never on the children, so space-between + align stay intact + inside the padded box).""" + scoped = _rule_block(_css_no_comments(), "#view-history .page-head") + for decl in ( + "display: flex", + "flex-wrap: wrap", + "align-items: flex-start", + "justify-content: space-between", + ): + assert decl in scoped, f"#view-history .page-head must keep {decl!r}" + assert not re.search(r"(?m)^\s*padding\b", scoped), ( + "the scoped History rule must not add its own padding — the " + "base .page-head rule (the flex container) carries it" + ) + + +def test_mobile_page_head_row_wrap_survives() -> None: + """≤640px: ``.page-head-row { flex-wrap: wrap; }`` (the RAG head's + Sync pill drops below the title at full width) still exists in the + mobile block — the wrap keeps working INSIDE the panel.""" + mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", _css()) + assert mobile, "the mobile media query must exist" + assert ".page-head-row { flex-wrap: wrap; }" in mobile.group(1), ( + "the mobile .page-head-row wrap must survive the panel" + ) + + +# -------------------------------------------------------------------------- +# Coverage — every head that sits on the grid gets the panel +# -------------------------------------------------------------------------- + + +def test_every_shell_view_with_a_page_head_is_covered() -> None: + """The ONE rule covers every shell view that carries the class: + all six do (their ``class="page-head"`` div is static markup in + the shell); #view-chat carries NO .page-head (its head is the + navbar — audited, untouched).""" + body = INDEX_HTML.read_text(encoding="utf-8") + for view_id in SHELL_VIEWS_WITH_PAGE_HEAD: + assert 'class="page-head"' in _view_section(body, view_id), ( + f"#{view_id} must keep its .page-head (the shared rule panels it)" + ) + assert 'class="page-head"' not in _view_section(body, "view-chat"), ( + "#view-chat carries no .page-head (its head is the navbar)" + ) + + +def test_doc_edit_page_head_is_covered() -> None: + """doc-edit.html's head already IS a .page-head (h1 'Edit doc' + + the .page-sub lede) — the shared rule panels it with no markup + change.""" + html = DOC_EDIT_HTML.read_text(encoding="utf-8") + head = re.search( + r'
\s*

Edit doc

[\s\S]*?class="page-sub"', + html, + ) + assert head, "doc-edit.html must keep its .page-head (h1 + page-sub)" + + +def test_shared_page_head_is_wrapped_in_page_head() -> None: + """The shared page's head (``h1#shared-title`` + the read-only + lede) was a direct child of the grid-exposed ``.shared-shell`` — + it is now wrapped in the same ``.page-head`` class, so the shared + rule panels it (the h1 keeps the ``.page-head h1`` size per the + ``#shared-title`` rule).""" + html = SHARED_HTML.read_text(encoding="utf-8") + wrapped = re.search( + r'
\s*' + r"

Shared conversation

\s*" + r'

Shared via Brain of Reese — read-only\.

\s*' + r"
", + html, + ) + assert wrapped, ( + "shared.html must wrap the h1 + lede in a .page-head div" + ) + + +# -------------------------------------------------------------------------- +# Deliberate skips — heads already inside a surfaced card +# -------------------------------------------------------------------------- + + +def test_login_head_is_already_carded_and_untouched() -> None: + """login.html: h1#login-title lives inside section.login-card, + which is ALREADY the surface card (var(--surface) fill + border + + radius + shadow) — the TODO targets grid-exposed text, so the + login head gets no double panel.""" + html = LOGIN_HTML.read_text(encoding="utf-8") + card = re.search( + r'