phase: 91_admin_theme_tab
Build and Push Containers / build-and-push-app (push) Successful in 5m43s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All verification is complete — this pass needed no code changes. Final report:

**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**

- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
2026-09-09 17:22:24 -04:00
parent 3095c4c577
commit d22d260b8b
74 changed files with 4448 additions and 675 deletions
@@ -0,0 +1,11 @@
All verification is complete — this pass needed no code changes. Final report:
**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**
- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
- No defects found; zero changes made — working tree left exactly as the task executors left it.
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
- Next pending phase: **none** — 91 is the last phase in `todo/`.
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 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 109 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 251 0 100%
--------------------------------------------------
TOTAL 3410 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
Task complete. All gates green.
**Implemented (phase 91, task 01 — `ui_settings` store + resolver + admin API)**
- `UiSettings` model (single id-1 row, 3× String(300) + 8× String(7), all NULL = default) + migration `0014_ui_settings.py` (applies cleanly, downgrade drops)
- `app/core/theming.py`: `BUILTIN_COLORS` (re-homed themes-README table + 5 contrast pairs), `COLOR_FIELDS`, `effective_settings()` (DB-over-env strings / DB-over-built-in colors), `theme_style_tag()` ("" when all built-in — byte-identical contract)
- `GET/PUT /api/ui-settings` (router-wide `require_admin`): trim/empty→NULL, 422s naming the field (>300, non-`#rrggbb`), lowercase on store, built-in→NULL normalization, id-1 upsert
- `/api/config` now serves effective strings via short-lived session (sync house pattern); `theme` key untouched (task 03)
- Tests: `tests/unit/test_theming.py` (styles.css drift guard, tag shape, resolver), `tests/unit/test_ui_settings.py`, `tests/integration/test_ui_settings_api.py` (403 anon+token user, DB-over-env config), `tests/integration/test_migration_0014.py`; `test_api.py` config pins + `test_models.py` contract updated in place
**Results**
- `uv run pytest --cov=app --cov-report=term-missing` → 1834 passed, 99% (new files 100%)
- `uv run pytest tests/unit/test_theming.py tests/unit/test_ui_settings.py -v --no-cov` → 16 passed
- `uv run alembic upgrade head` → 0014 applied; `uv run ruff check . && uv run pyright` → clean (0 errors)
**Notable decisions**
- `effective_settings(session, settings=None)`: optional settings param keeps the design call shape but lets routes pass the dependency-injected Settings so existing `dependency_overrides` pins keep working
- PUT = full replacement (absent/null → NULL) — pinned by the all-null Reset contract
- `/api/config` pins now take the `db` fixture (skip-not-fail when stack down, empty-row state)
**Next pending task:** `91_admin_theme_tab/02_inline_theme_injection.md`
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................. [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 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 108 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 19 0 100%
app/core/theming.py 25 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 109 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 251 0 100%
--------------------------------------------------
TOTAL 3389 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.412).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
All green. Final report:
**Task 02 — pre-paint inline theme injection: complete**
- `app/core/theming.py::inject_theme(html, tag)`: pure helper — identity for empty tag / no `</head>` / tag already present; else inserts with leading newline immediately before the first `</head>`
- `app/core/caching.py::CachingMiddleware`: in the HTML rewrite branch, one short-lived `SessionLocal` per response (closed in `finally`, no process cache) → `theme_style_tag(effective_settings(...))` colors; any exception → `tag=""` + `logger.exception` (DB blip never breaks the page); `new_body = inject_theme(rewrite_asset_refs(...), tag)`; module docstring paragraph added
- Tests: 4 pure `inject_theme` tests (empty-tag identity, no-`</head>` identity, exact placement/first-occurrence, double-injection idempotence) + 3 middleware tests (unset = byte-identical to rewrite-only; themed = exactly one tag before `</head>` on `/`, `/document.html`, `/shared/<token>` with all 8 vars in `COLOR_FIELDS` order; DB failure ×2 parametrizations → 200, no tag, no-cache intact)
**Results**
- `uv run pytest tests/unit/test_caching.py tests/unit/test_theming.py -v --no-cov` → 56 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 1842 passed; app/ 99% (caching.py 100%, theming.py 100%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- Live check: unset → 0 `bor-theme` (byte-identical); admin PUT one color → 1 tag before `</head>` on all 7 pages; reset → byte-identical, row all-NULL
**Decisions**
- A stale `/tmp/full.log` (prior session, 00:34) showed a sync ignore-map ordering failure; traced to a pre-phase-90-fix tree — the committed phase 90 work already fixed it deterministically. A transient `effective_sources` ordering tweak was reverted; sync code untouched.
**Next pending task:** `.agents/phases/todo/91_admin_theme_tab/03_retire_css_file_theming.md`
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 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 141 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 120 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 19 0 100%
app/core/theming.py 30 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 109 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 251 0 100%
--------------------------------------------------
TOTAL 3406 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,12 @@
All green. Final state verified.
**Task 03 — Retire CSS-file theming: complete**
- Deleted `frontend/assets/themes/` (indigo.css + README — contract verified re-homed in `app/core/theming.py` docstring: 8-var table, 5 WCAG pairs, never-white-on-brand note) and `tests/unit/test_themes.py`
- `app/config.py`: removed `Settings.theme` + `_theme_bare_css_filename` validator; `app/api/config.py`: `theme` key dropped (5-key contract); `brand.js`: step-7 block + docstring paras removed (BOR_CONFIG_PROMISE/name/placeholder/footer untouched); Containerfile stage-1 `cp` line removed (chain verified by real build — no `themes/` in image); `.env.example` entry + README row removed, README customization section re-pointed at the admin Theme tab
- Updated in place: `test_config.py` (validator tests gone, `theme` field-absence pin added), `test_frontend_brand.py`, `test_save_as_doc_button.py`, `test_api.py`, `test_ui_settings_api.py` (added `test_api_config_carries_no_theme_key`), `test_ui_customization.py` (theme legs stripped; placeholder/footer/brand legs kept), `test_configurable_brand.py`, conftests, `test_big_read_progress.py`
- Tests: `uv run pytest -v --no-cov` → 1830 passed; `--cov=app` → 99% (modified modules 100%); `ruff check .` + `pyright` clean; sweep `rg "BOR_THEME|theme-override|assets/themes|indigo.css"` over app/ frontend/ tests/ Containerfile .env.example → no matches; `tests/e2e/test_ui_customization.py -v --no-cov` → 4 passed in isolation (+ configurable_brand 5, big_read 4 passed)
- Live checks: boot serves no `#bor-theme`, 5-key `/api/config`; leftover `BOR_THEME=../evil.css` boots fine and is ignored (B2)
- Notable: negative-assertion test reworded to avoid the retired literals; one operator-facing `BOR_THEME`-ignored note kept in README (outside the gate's paths)
Next pending task: `91_admin_theme_tab/04_theme_tab_shell.md`
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.............................. [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 120 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 19 0 100%
app/core/theming.py 30 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 109 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 251 0 100%
--------------------------------------------------
TOTAL 3397 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
All green. Task 04 is complete.
**Implemented (phase 91, task 04 — the 7th shell view):**
- `frontend/index.html`: `#nav-theme` (ship-hidden, after `#nav-tokens`) + `#view-theme` section (hidden+inert+tabindex=-1) after `#view-tokens`: `#theme-gate` (exact `#sources-gate` pattern, `?next=/theme.html`) and ship-hidden `#theme-content` with page-head, static `#theme-form` (3 labeled maxlength=300 text inputs + 8 labeled `type="color"` inputs `#theme-bg`…`#theme-brand-ink` on built-in values), `#theme-save`/`#theme-reset` (both type=button), `#theme-error`/`#theme-result`/`#theme-contrast` (hidden, role=alert/status/alert)
- `header.js`: two-line `#nav-theme` reveal for `role === "admin"` (phase-91 comment); one-bar copies added to document/login/shared.html (the phase-34 parity contract test_nav_consistency pins)
- `router.js`: all five table entries (VIEW/VIEW_PATH/VIEW_MODULES lazy `./theme.js`/TITLES "Theme · Brain of Reese"/DESCRIPTIONS)
- `app/main.py`: `"/theme.html"` in `_shell_routes`; `app/core/caching.py`: in `HTML_PAGES`
- `styles.css`: skeleton styles (46rem form column, form card, color grid 4→2 cols, brand-pill Save + ghost Reset, feedback lines)
- `theme.js`: stub with the `mount(root)` module contract (no-op — task 05 lands the editor)
**Test/lint/coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 1836 passed, `app/` TOTAL **99%** (>90% gate); unit 1375 / integration 464 separately green
- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings)
- E2E in isolation: `test_tuning_nav_link` 4✅, `test_cache_busting` 4✅, `test_asset_cache_revalidation` 5✅, plus `test_nav_consistency` 7✅, `test_mobile_hamburger_nav` 8✅, `test_header_consistency` 3✅, `test_navbar_refresh` 7✅, `test_history_page_width` 5✅
- Live smoke: `GET /theme.html` → 200, `cache-control: no-cache`, `?v=` refs, body byte-identical to `/`; esbuild router bundle builds with the new lazy import
**Decisions:** (1) `theme.js` stub created here so the Containerfile esbuild stage resolves the router's dynamic import (editor body is task 05); (2) `#nav-theme` copied into the three standalone page headers — required for the one-bar inventory parity (phase-79 did the same for Tokens); (3) `test_wide_column_css.py` 46rem form-column pin updated 2→3 (`.theme-shell`, the phase-59 `.doc-edit-shell` precedent). Test pins extended in place per house convention.
**Next pending task:** `05_theme_editor.md` (theme.js editor: populate, live preview, Save/Reset, WCAG contrast warnings).
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 98%]
.................................... [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 120 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 19 0 100%
app/core/theming.py 30 0 100%
app/core/tokens.py 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 109 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 251 0 100%
--------------------------------------------------
TOTAL 3397 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
Everything is green. Final report:
**Task 05 complete — Theme editor (`theme.js`) + defect fix (CSP was silently killing the pre-paint theme tag in real browsers)**
Implemented:
- `frontend/assets/theme.js` (replaces task-04 stub): admin gate via cached `fetchIsAdmin()`, GET `/api/ui-settings` populate with effective values (failed fetch → error + retry, form kept), live `:root` preview on color `input` (CSSOM — CSP-safe; cleared on save/reset/re-show), Save/Reset §7.4 lifecycle (disable → "Saving…"/"Resetting…" → restore), 422 server detail in `#theme-error` with form kept, role=status results, WCAG 5-pair contrast warnings (`#theme-contrast`, warning-only), `bor:view-refresh` re-load.
- `styles.css`: 3-col desktop / 1-col mobile palette grid, 44px touch swatches, `--err-*` family contrast warning box, `.theme-note` sub-copy. `index.html`: spec'd sub-copy under h1, branding next-load note, WCAG legend on the palette fieldset.
- **Defect fixed (task 02 + phase-82 interaction):** the A1 CSP (`default-src 'self'`, no `style-src`) blocked the inline `<style id="bor-theme">` in every browser — unit tests only checked raw bytes. Fix keeps phase-82 posture maximum-strict: themed HTML pages now carry `A1 + style-src 'self' 'sha256-<exact tag content hash>'` (`theming.theme_csp_hash` set by `CachingMiddleware`; `SecurityHeadersMiddleware` preserves an inner layer's CSP). No `'unsafe-inline'` anywhere; unthemed pages keep A1 verbatim (byte- AND header-identical).
Verification:
- `uv run pytest --no-cov` → **1841 passed** (5 new tests; modified modules theming/caching/security_headers at 100%, TOTAL **99%** >90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- Live Chromium pass: anon gate / admin form + effective populate, live repaint, contrast warn+hide, 422 detail kept, save → fresh-load computed `--brand` = saved value on first paint, anon sees themed HTML, reset → byte-identical (no tag)
No forbidden files touched; dev DB `ui_settings` row left clean; no commits made.
Next pending task: `.agents/phases/todo/91_admin_theme_tab/06_e2e_theme_tab.md`.
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 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 109 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 251 0 100%
--------------------------------------------------
TOTAL 3410 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,22 @@
Everything is green and the working tree is in the expected state (new E2E file untracked; `.agents/` moves are the harness's own from earlier tasks — my target task file is untouched in `todo/`).
## Final report — task `91_admin_theme_tab/06_e2e_theme_tab`
**Implemented**
- `tests/e2e/test_admin_theme_tab.py` (NEW) — the phase's isolated Playwright suite, 5 tests:
1. Admin tab: nav link + gate/form state, 11 inputs show effective defaults (8 built-in hexes parsed from `styles.css` `:root` in-test; template strings from `Settings` class-field defaults), §7.4 "Saving…" lifecycle (deterministic via a held-PUT route intercept, the archive-upload house pattern), "Theme saved." (role=status), re-populate, DB row pin, warning-only contrast on the saved non-AA palette
2. Pre-paint proof: raw served HTML (httpx + admin & fresh-anonymous `page.content()`) carries exactly one `<style id="bor-theme">` immediately before `</head>` with all 8 saved hexes; computed `:root` vars = saved hexes at load; CSP `style-src 'self' 'sha256-…'` asserted; B4 strings (name/placeholder/footer) via the brand.js boot fetch
3. Gate + 403s: anonymous sees `#theme-gate` (`?next=/theme.html`), form hidden, nav hidden, PUT/GET 403; token user (phase-79 gate login) PUT 403 + nav hidden
4. Reset: "Resetting…" → "Reset to the built-in theme.", 11 defaults re-populated, no tag served, and reset-deployment bytes == row-less deployment bytes (byte-identical proof)
5. Contrast warning: `--ink` `#101010` lists "--ink on --bg: 1.0:1 — needs 4.5:1" (role=alert) on picker input; Save still succeeds; Reset restores + hides
- House fixtures: module-scoped `app_server` (branding vars pinned to code defaults — phase-61/62 leak-guard extended to `BOR_APP_NAME` — `BOR_GIT_SOURCES` forced empty), autouse clean (TRUNCATE `ui_settings`, `e2e-%` token delete, before+after)
**Results**
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → 5 passed (run 3×, deterministic)
- `uv run pytest --cov=app --cov-report=term-missing` → 1841 passed, `app/` TOTAL 99% (phase-91 files all 100%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- Regression: `tests/e2e/test_ui_customization.py` → 4 passed; `rg "BOR_THEME|themes/" app/ frontend/ Containerfile` → only the excepted doc-history comment citation
**Decisions** — task-suggested indigo palette fails exactly one WCAG pair (bg on brand 3.0:1) — pinned in test 1 as the warning-only proof; no `app/` code added (task is E2E-only).
**Next pending task:** none — task 06 was the last task of phase 91.
@@ -0,0 +1,92 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 86%]
........................................................................ [ 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 109 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 251 0 100%
--------------------------------------------------
TOTAL 3410 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.413).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+3 -1
View File
@@ -7,7 +7,9 @@ BOR_ENVIRONMENT=development
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
# BOR_INPUT_PLACEHOLDER=Ask me anything… # composer placeholder, chat page (phase 62)
# BOR_FOOTER_TEXT=Powered by self-hosted models # footer line on every page (phase 62)
# BOR_THEME= # filename under frontend/assets/themes/ (e.g. indigo.css) — overrides the built-in palette; empty = built-in (phase 62)
# (Phase 91: the retired CSS-file theme env var is gone — the colors
# are set from the admin Theme tab, /theme.html; a leftover value in
# a local .env is ignored.)
# BOR_LOG_LEVEL=INFO
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
-1
View File
@@ -24,7 +24,6 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp -r ./assets/themes /out/assets/themes \
&& cp ./assets/favicon.svg /out/assets/favicon.svg \
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
+8 -6
View File
@@ -403,7 +403,6 @@ asset references of the known pages in flight.
| `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) |
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder |
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page |
| `BOR_THEME` | *(empty)* | Filename under `frontend/assets/themes/` (e.g. `indigo.css`) — a `:root` palette override |
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
@@ -440,11 +439,14 @@ Every identity string is an env var: `BOR_APP_NAME` (display name),
`BOR_FOOTER_TEXT` (footer line) — all served by `GET /api/config` and
applied by `assets/brand.js` at boot.
Color themes are plain CSS variable overrides: write a `:root` block in
`frontend/assets/themes/` and point `BOR_THEME` at the filename. The server
refuses a malformed `BOR_THEME` at startup; a missing file degrades to the
built-in palette. Leave everything unset and the app renders the defaults
byte-identically.
The colors are set from the admin **Theme tab** (`/theme.html`,
admin-only — phase 91): the 8 identity colors plus the three strings
above are edited with text fields and color pickers, persisted in the
`ui_settings` table, and injected into every served page as an inline
`<style>` BEFORE first paint (no red flash, no pop-in). The old
CSS-file theming is retired — a leftover `BOR_THEME` line in a
deployment's `.env` is simply ignored. Leave everything unset and the
app renders the built-in dark-tech palette byte-identically.
---
+56
View File
@@ -0,0 +1,56 @@
"""ui_settings: single-row UI settings (phase 91)
Revision ID: 0014
Revises: 0013
Create Date: 2026-09-09
Phase 91 (admin Theme tab: the owner sets the app name, input
placeholder, footer text and the 8 identity colors with text fields
and color pickers — one additive, fully reversible table, A13):
* ``ui_settings`` — ONE row (PK ``id``, the row is always id 1): the
phase-91 admin Theme tab persists its values here. The 3 strings
(``app_name`` / ``input_placeholder`` / ``footer_text``, VARCHAR(300))
and the 8 identity colors (``bg`` / ``surface`` / ``ink`` /
``ink_soft`` / ``line`` / ``brand`` / ``brand_soft`` / ``brand_ink``,
VARCHAR(7) ``#rrggbb``) are ALL nullable — NULL = default (the env
value for the strings, the built-in palette for the colors, B1).
The row is created only by the PUT upsert (no seed, no server
default — a missing row means "defaults", the byte-identical
contract).
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0014"
down_revision = "0013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ui_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("app_name", sa.String(length=300), nullable=True),
sa.Column("input_placeholder", sa.String(length=300), nullable=True),
sa.Column("footer_text", sa.String(length=300), nullable=True),
sa.Column("bg", sa.String(length=7), nullable=True),
sa.Column("surface", sa.String(length=7), nullable=True),
sa.Column("ink", sa.String(length=7), nullable=True),
sa.Column("ink_soft", sa.String(length=7), nullable=True),
sa.Column("line", sa.String(length=7), nullable=True),
sa.Column("brand", sa.String(length=7), nullable=True),
sa.Column("brand_soft", sa.String(length=7), nullable=True),
sa.Column("brand_ink", sa.String(length=7), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
def downgrade() -> None:
# Safe order: the table is the only 0014 artefact — dropping it
# leaves 0013's schema byte-identical (A13, fully reversible).
op.drop_table("ui_settings")
+35 -12
View File
@@ -1,12 +1,27 @@
"""Public app metadata (display name + version) for the frontend brand
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
phase-62 UI customization strings (composer placeholder, footer line,
theme file name)."""
phase-62 UI customization strings (composer placeholder, footer line).
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
wins when set, env is the fallback), resolved by the SAME
:func:`app.core.theming.effective_settings` resolver the
``/api/ui-settings`` API uses, so the brand layer and the tab can never
disagree. The route opens a short-lived session (the sync-endpoint
house pattern — the route is sync, matching the middleware world).
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
deleted with the mechanism — the five keys below are the entire
contract (the colors never rode this endpoint; the server injects
them pre-paint, :mod:`app.core.theming`).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.config import Settings, get_settings
from app.core import theming
from app.db import SessionLocal
router = APIRouter(tags=["config"])
@@ -15,17 +30,25 @@ router = APIRouter(tags=["config"])
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39) +
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
customization keys (``input_placeholder``, ``footer_text``,
``theme``) — all display strings, the SAME boot fetch (no new
network surface) and the same public posture as ``app_name``
(no secrets). Values are passed through verbatim: the frontend
brand layer treats an empty string as "keep the template default"
(the unset => byte-identical contract)."""
customization keys (``input_placeholder``, ``footer_text``) — all
display strings, the SAME boot fetch (no new network surface) and
the same public posture as ``app_name`` (no secrets). Phase 91:
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
the env values — DB-over-env, B1); the frontend brand layer treats
an empty string as "keep the template default" (the unset =>
byte-identical contract). Phase 91 (task 03): the retired
CSS-file theming's ``theme`` key is gone — the five keys are the
entire response."""
db = SessionLocal()
try:
effective = theming.effective_settings(db, settings)
finally:
db.close()
return {
"app_name": settings.app_name,
"app_name": effective["app_name"],
"version": settings.app_version,
"docs_repo_configured": settings.docs_configured,
"input_placeholder": settings.input_placeholder,
"footer_text": settings.footer_text,
"theme": settings.theme,
"input_placeholder": effective["input_placeholder"],
"footer_text": effective["footer_text"],
}
+137
View File
@@ -0,0 +1,137 @@
"""UI settings admin API (phase 91, task 01).
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 8
identity colors. 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).
Routes (under ``/api`` via the ``main`` registration):
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
env strings + the built-in palette, so a fresh tab shows the live
theme. Creates nothing.
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
string is trimmed (empty → NULL, >300 → 422 naming the field), each
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
naming the field), and — the owner-locked normalization — a color
equal to its built-in is stored as NULL, so "save the defaults"
leaves the row empty and the served HTML stays byte-identical (the
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
update-or-insert); a concurrent PUT is single-admin — last writer
wins. Returns the new effective values.
"""
from __future__ import annotations
import re
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.core import theming
from app.core.auth import require_admin
from app.db import get_db
from app.models import UiSettings
from app.schemas import UiSettingsIn, UiSettingsOut
router = APIRouter(
prefix="/ui-settings",
tags=["ui-settings"],
dependencies=[Depends(require_admin)], # phase 91: the Theme tab is admin-only
)
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
#: lowercased on store so the stored/tagged hex is canonical).
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
_MAX_STRING_LEN = 300
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
"""Trim the 3 display strings: empty after the trim → ``None``
(the clear operation), >300 chars after the trim → 422 naming the
field (the house fixed-detail style — the detail never varies by
value beyond naming the field)."""
values: dict[str, str | None] = {}
for field in theming.STRING_FIELDS:
raw = getattr(payload, field)
if raw is None:
values[field] = None
continue
value = raw.strip()
if len(value) > _MAX_STRING_LEN:
raise HTTPException(
status_code=422, detail=f"{field} is too long (max 300)"
)
values[field] = value or None
return values
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
"""Validate + normalize the 8 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)."""
values: dict[str, str | None] = {}
for field in theming.COLOR_FIELDS:
raw = getattr(payload, field)
if raw is None:
values[field] = None
continue
if _HEX_COLOR.fullmatch(raw) is None:
raise HTTPException(
status_code=422, detail=f"{field} must be a #rrggbb hex color"
)
value = raw.lower()
values[field] = None if value == theming.BUILTIN_COLORS[field] else value
return values
@router.get("", response_model=UiSettingsOut)
def get_ui_settings(
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
"""The effective UI settings — DB-over-env / DB-over-built-in (B1).
Reads only: a missing row means "defaults" (the env strings + the
built-in palette), so a fresh deployment's tab shows the live theme
with an empty row, and nothing is ever upserted by a read.
"""
return UiSettingsOut(**theming.effective_settings(db, settings))
@router.put("", response_model=UiSettingsOut)
def update_ui_settings(
payload: UiSettingsIn,
settings: Settings = Depends(get_settings), # noqa: B008
db: Session = Depends(get_db), # noqa: B008
) -> UiSettingsOut:
"""Replace the single row with the body's 11 values (validated and
normalized — see the module docstring), then report the new
effective values.
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
``default=1`` supplies the PK on the insert). Concurrency is
single-admin (one owner, one tab) — the last writer wins and no
lock is taken: a lost race just means the other admin's PUT is the
effective one.
"""
strings = _validate_strings(payload)
colors = _validate_colors(payload)
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
if row is None:
row = UiSettings(id=1)
db.add(row)
for field in theming.STRING_FIELDS:
setattr(row, field, strings[field])
for field in theming.COLOR_FIELDS:
setattr(row, field, colors[field])
db.commit()
return UiSettingsOut(**theming.effective_settings(db, settings))
+3 -25
View File
@@ -48,12 +48,11 @@ class Settings(BaseSettings):
# --- UI customization (phase 62, TODO L3) ---
# Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
# (Phase 91, task 03: the retired CSS-file theme env var is gone —
# the admin Theme tab is the only theming surface; a leftover value
# in a deployment's .env is simply ignored.)
input_placeholder: str = "Ask me anything…"
footer_text: str = "Powered by self-hosted models"
#: Theme file NAME under frontend/assets/themes/ (e.g. "indigo.css");
#: empty = the built-in dark-tech palette. Validated: bare filename
#: only — no paths, no ".." (no-CDN: served from the static dir).
theme: str = ""
# --- Database (PostgreSQL 17 + pgvector) ---
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
@@ -238,27 +237,6 @@ class Settings(BaseSettings):
#: separate from ``sources_dir`` (the source checkouts).
docs_work_dir: str = "~/bor-docs"
@field_validator("theme", mode="after")
@classmethod
def _theme_bare_css_filename(cls, v: str) -> str:
r"""Phase 62 (A5): the theme is a FILE NAME under
``frontend/assets/themes/``, served from the static dir
(no-CDN) — so only a bare lowercase ``.css`` filename is legal
(``^[a-z0-9_-]+\.css$``). Anything else (a path, ``..``,
uppercase, a missing extension) is a typo that would silently
404 at runtime — fail loudly at startup instead, naming the
offending value and the allowed shape (the phase-56 fail-loud
house style)."""
if v == "":
return v # empty = the built-in dark-tech palette
if re.fullmatch(r"[a-z0-9_-]+\.css", v) is None:
raise ValueError(
"theme must be a bare .css filename under "
"frontend/assets/themes/ (lowercase letters/digits/"
f"'_'/'-', e.g. 'indigo.css') — got {v!r}"
)
return v
@field_validator("import_extensions")
@classmethod
def _import_extensions_known(cls, v: str) -> str:
+75 -2
View File
@@ -28,6 +28,36 @@ outbound validators. The asymmetry is deliberate: a 304 on
a 304 on an HTML page is never safe — the served body depends on the
process token, which the validator ignores.
Phase 91 (task 02) — the pre-paint theme tag: in the SAME rewrite
branch, AFTER the ``?v=<token>`` asset rewrite, the effective
``ui_settings`` row (task 01's :func:`app.core.theming.effective_settings`
resolver — one short-lived session per response, NO process cache: the
owner changes the theme at runtime from the admin tab, so the next
request must see it without a restart, and a single-row SELECT is
negligible at homelab page traffic) is rendered as an inline
``<style id="bor-theme">:root{…}</style>`` and inserted immediately
BEFORE the first ``</head>`` (:func:`app.core.theming.inject_theme`),
so a themed deployment paints its palette on the FIRST paint — no red
flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` —
the identity no-op — and serves the EXACT pre-phase-91 rewrite-only
bytes (the byte-identical contract, B4); a DB blip (or a pre-migration
boot) is the same no-op, the page never breaks. The asset rewrite is
untouched, and ``/api/*`` / ``/assets/*`` still pass through
byte-identical.
Phase 91 (task 05, defect fix) — the CSP extension: the phase-82
policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the
inline tag in every real browser, so a themed HTML page's response
also carries ``style-src 'self' 'sha256-<hash>'`` appended to the A1
string, where ``<hash>`` is the CSP3 hash of the EXACT tag content
(:func:`app.core.theming.theme_csp_hash`) — the current theme is the
only inline style ever permitted (no ``'unsafe-inline'``; a different
palette or any other inline style is still blocked). The untagged
response keeps the plain A1 string (the outer
:class:`~app.core.security_headers.SecurityHeadersMiddleware`
preserves a CSP an inner layer has already set), and no non-HTML
response ever gets the extension.
The token is computed **once per process** (``functools.cache``, i.e.
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
when a new commit lands (git path) or the frontend tree's mtimes/sizes
@@ -61,6 +91,9 @@ from starlette.requests import Request
from starlette.responses import Response
from app.config import get_settings
from app.core import theming
from app.core.security_headers import CSP
from app.db import SessionLocal
logger = logging.getLogger("app")
@@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = (
"/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
# phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v=
@@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware):
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
return response
# Phase 91 (task 02): the pre-paint theme tag. One short-lived
# session per response (the sync-endpoint house pattern from
# app/db.py — the middleware world is sync); NO process cache —
# the theme changes at runtime from the admin tab, so the next
# request must see it without a restart. A DB blip (or a
# pre-migration boot) must never break the page: fall back to
# ``tag == ""`` (the built-in palette) and keep the no-cache
# contract (loadHealth house style).
tag = ""
try:
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
db = SessionLocal()
try:
effective = theming.effective_settings(db)
finally:
db.close()
tag = theming.theme_style_tag(
{key: effective[key] for key in theming.COLOR_FIELDS}
)
except Exception:
logger.exception(
"cache busting: theme read failed for %s — serving without the theme tag",
path,
)
tag = ""
try:
new_body = theming.inject_theme(
rewrite_asset_refs(body.decode("utf-8"), token), tag
).encode("utf-8")
except Exception:
# The body IS buffered — re-serve the ORIGINAL bytes so a
# rewrite hiccup never loses the page.
@@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware):
headers=_no_cache_headers(response),
)
headers = _no_cache_headers(response)
if tag:
# Phase 91 (task 05): the inline tag needs a style-src
# exemption or the phase-82 CSP blocks it in the browser —
# the strictest one: a sha256 hash of the EXACT tag content
# (theming.theme_csp_hash), appended to the A1 string. The
# outer SecurityHeadersMiddleware preserves this (it only
# fills in a missing CSP); the untagged page keeps A1
# verbatim — byte- AND header-identical to pre-phase-91.
headers["Content-Security-Policy"] = (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
return Response(
content=new_body,
status_code=response.status_code,
headers=_no_cache_headers(response),
headers=headers,
)
+15 -1
View File
@@ -55,7 +55,16 @@ class SecurityHeadersMiddleware:
Adds exactly three headers to every HTTP response:
* ``Content-Security-Policy``: the strict same-origin policy above
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04);
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04) —
EXCEPT when an inner layer has already set one: the phase-91
(task 05) pre-paint theme tag is an inline ``<style>`` that the
A1 policy would block in the browser, so the caching middleware
publishes, on themed HTML pages only, the A1 string with
``style-src 'self' 'sha256-<tag-content-hash>'`` appended (the
current theme is the only inline style ever permitted — no
``'unsafe-inline'``). A pre-existing CSP is that inner layer's
deliberate one and is preserved; every other response (including
every untagged page) gets the plain A1 string.
* ``X-Frame-Options: DENY`` — legacy no-framing fallback;
* ``X-Content-Type-Options: nosniff`` — MIME-confusion belt.
@@ -73,6 +82,11 @@ class SecurityHeadersMiddleware:
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(scope=message)
# Phase 91 (task 05): preserve a CSP an inner layer set
# (the caching middleware's theme-extended policy — see
# the class docstring); the A1 string covers every
# response without one.
if "content-security-policy" not in headers:
headers["Content-Security-Policy"] = CSP
headers["X-Frame-Options"] = "DENY"
headers["X-Content-Type-Options"] = "nosniff"
+207
View File
@@ -0,0 +1,207 @@
"""The built-in identity palette + the effective UI-settings resolver
(phase 91).
Single source of the built-in **identity** palette. Phase 62's
custom-CSS-file theming (an env var named a drop-in ``:root`` override
stylesheet that ``brand.js`` linked AFTER the boot fetch — the "red
first, then pop" the owner saw) is retired in this phase: task 03
deleted the env var, the example-stylesheet directory, and the link
insertion, and the admin Theme tab is now the only theming surface.
The contract that directory's authoring guide carried is re-homed here
(built-in table, the five contrast pairs, the never-white-on-brand
trap — see below), and the 8 variables + built-in values are the
authoritative table (the unit drift test parses
``frontend/assets/styles.css``'s ``:root`` and asserts equality, so
the two can never silently diverge).
The **8 identity variables** (bare names, README order) and their
built-in values (from ``frontend/assets/styles.css`` ``:root``):
=================== ========== =================================================
Variable Built-in Role
=================== ========== =================================================
``bg`` ``#0f0a0a`` page background (text on it: ``ink``)
``surface`` ``#1a0f0f`` cards, panels, code blocks (text: ``ink``)
``ink`` ``#f0e6e6`` primary text
``ink_soft`` ``#b8a8a8`` secondary text (5.1:1 on ``surface``)
``line`` ``#2d1a1a`` decorative 1px borders (no contrast duty)
``brand`` ``#f43f5e`` brand accent — buttons, links (text ON
it is the DARK ``bg`` ink)
``brand_soft`` ``#2d0a0a`` brand-tinted surface (chips, hover washes)
``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 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.
Effective-value resolution (:func:`effective_settings`) — the DB-over-
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
color column to the built-in. ONE resolver is used by BOTH
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
layer), so the tab and the running UI can never disagree.
"""
from __future__ import annotations
import base64
import hashlib
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import UiSettings
#: The 8 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.
BUILTIN_COLORS: dict[str, str] = {
"bg": "#0f0a0a",
"surface": "#1a0f0f",
"ink": "#f0e6e6",
"ink_soft": "#b8a8a8",
"line": "#2d1a1a",
"brand": "#f43f5e",
"brand_soft": "#2d0a0a",
"brand_ink": "#fca5a5",
}
#: The 8 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).
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
#: The 3 display strings the ``ui_settings`` row carries — env fallback
#: (B1: unlike the colors, the env vars stay the strings' default).
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
def effective_settings(
session: Session, settings: Settings | None = None
) -> dict[str, str]:
"""Resolve the EFFECTIVE UI settings — DB-over-env / DB-over-built-in.
Reads the single ``ui_settings`` row (id 1) and merges it over the
defaults, column by column:
* **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 8 :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).
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 11 keys.
"""
if settings is None:
settings = get_settings()
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
effective: dict[str, str] = {}
for field in STRING_FIELDS:
value = getattr(row, field, None) if row is not None else None
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
for key in COLOR_FIELDS:
value = getattr(row, key, None) if row is not None else None
effective[key] = value if value is not None else BUILTIN_COLORS[key]
return effective
def theme_style_tag(colors: dict[str, str]) -> str:
"""The pre-paint inline theme tag (task 02's injection input).
``""`` 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 ``<style>`` tag anywhere.
Otherwise one ``<style id="bor-theme">`` tag with ALL 8 variables in
:data:`COLOR_FIELDS` order (the non-overridden ones repeat their
built-in value — the tag is a complete ``:root`` override, so the
page never mixes partial palettes)::
<style id="bor-theme">:root{--bg:#0f0a0a;…;--brand-ink:#fca5a5}</style>
Pure function of its input — :func:`inject_theme` places it before
the first ``</head>`` of every served HTML page (the phase-91
pre-paint injection), so the themed deployment renders its palette
on the FIRST paint (no red flash, no pop-in).
"""
if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS):
return ""
declarations = "".join(
f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS
)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
def inject_theme(html: str, tag: str) -> str:
"""Insert ``tag`` immediately BEFORE the first ``</head>`` of
``html`` — the pure half of the phase-91 pre-paint injection.
The :class:`~app.core.caching.CachingMiddleware` (task 02) calls
this on every known HTML page's rewritten body, so the helper stays
pure (no DB, no app) and unit-testable on its own. Identity rules —
the byte-identical contract (B4, owner-locked 2026-09-09):
* ``tag == ""`` (an unset or "defaults saved" deployment —
:func:`theme_style_tag` returns exactly that) → ``html`` is
returned EXACTLY as passed in, byte for byte;
* no ``</head>`` occurrence → unchanged (nothing to anchor to);
* ``id="bor-theme"`` already present → unchanged (defensive
idempotence — the static files never contain the id, and one
body can never reach the helper twice, but the guarantee is free
for a pure function).
Otherwise the tag is placed with a leading newline (readable HTML)
immediately before the FIRST ``</head>`` — the browser meets the
complete ``:root`` override before it applies any stylesheet, so
the palette is live on the first paint.
"""
if not tag or "</head>" not in html or 'id="bor-theme"' in html:
return html
index = html.index("</head>")
return html[:index] + "\n" + tag + html[index:]
def theme_csp_hash(tag: str) -> str:
"""The CSP3 ``sha256-`` source expression for an inline theme tag.
Phase 91 (task 05 defect fix): the phase-82 CSP (A1 —
``default-src 'self'`` with no explicit ``style-src``) BLOCKS the
inline ``<style id="bor-theme">`` tag in every real browser
(``style-src`` falls back to ``default-src 'self'``), so the
pre-paint injection would be dead bytes in the served HTML. The
fix is the strictest one that works: the hashing source expression
of the tag's EXACT content (CSP3 §13.4 — the character data between
the tags; the rendered content carries no leading/trailing
whitespace, so no stripping applies). The caching middleware
publishes it on themed HTML pages only, as ``style-src 'self'
'sha256-…'`` appended to the A1 string — the current theme is the
only inline style ever permitted, and a different palette (or any
other inline style) is still blocked. No blanket
``'unsafe-inline'`` — the A1 posture holds everywhere else. Returns
``""`` for an empty tag (an unset/defaults deployment keeps the
plain A1 policy — the byte- AND header-identical contract).
"""
if not tag:
return ""
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
digest = hashlib.sha256(content.encode("utf-8")).digest()
return "sha256-" + base64.b64encode(digest).decode("ascii")
+6
View File
@@ -40,6 +40,7 @@ from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
from app.api.sync import router as sync_router
from app.api.tokens import router as tokens_router
from app.api.ui_settings import router as ui_settings_router
from app.config import get_settings
from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching
@@ -122,6 +123,10 @@ def create_app() -> FastAPI:
# Phase 79: the admin token surface (create/list/revoke) — admin-only
# (router-wide require_admin; a token USER stays 403 here, task 03).
app.include_router(tokens_router, prefix="/api")
# Phase 91 (task 01): the admin UI-settings surface (GET/PUT the
# single ui_settings row — the Theme tab's persistence) — admin-only
# (router-wide require_admin; anonymous AND token users stay 403).
app.include_router(ui_settings_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page.
@@ -156,6 +161,7 @@ def create_app() -> FastAPI:
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the Tokens view
"/theme.html", # phase 91 task 04: the Theme view (shell route)
),
)
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
+43
View File
@@ -57,6 +57,12 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
on ``POST /api/token-auth`` (task 03 — the only
request that presents the token; the in-app gate
re-sends the cached token on every page load).
* ``ui_settings`` — single-row UI settings (phase 91): the admin
Theme tab's app name, input placeholder, footer
text and the 8 identity colors, one row
(``id = 1``); every column NULL = "use the
default" (env value for the strings, the built-in
palette for the colors — task 01).
"""
from __future__ import annotations
@@ -384,3 +390,40 @@ class ApiToken(Base):
#: (enforced immediately on the holder's next request); NULL while
#: active.
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class UiSettings(Base):
"""Single-row UI settings (phase 91, task 01).
The admin Theme tab (``/theme.html``, tasks 04/05) persists everything
the ``BOR_`` env vars and the retired custom-CSS theming supported in
ONE row (``id = 1`` — the single row is always id 1; ``GET`` creates
nothing, ``PUT`` upserts). The NULL = default rule (B1, owner-locked
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 eight identity
colors (B1: no env fallback for colors). :func:`app.core.theming.
effective_settings` resolves the effective 11 values both the
``GET /api/ui-settings`` and ``GET /api/config`` endpoints serve.
"""
__tablename__ = "ui_settings"
#: The single row is always id 1 (the ``kb_overview`` / ``sources_meta``
#: id=1 precedent — Python-side default; the migration carries no
#: server default because the row is created only by the PUT upsert).
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
# --- Strings (NULL/empty = "use the env default" — B1) ---
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
# --- The 8 identity colors (NULL = the built-in — B1), #rrggbb ---
bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
surface: Mapped[str | None] = mapped_column(String(7), nullable=True)
ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
ink_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
line: Mapped[str | None] = mapped_column(String(7), nullable=True)
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)
+53
View File
@@ -812,3 +812,56 @@ class TokenAuthRequest(BaseModel):
"""
token: str
class UiSettingsIn(BaseModel):
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
replacement of the single ``ui_settings`` row.
Every field is ``str | None`` — present = a new value (strings are
trimmed; empty after the trim is the CLEAR operation, stored as
NULL; colors must be ``#rrggbb`` and are lowercased on store),
``null``/absent = "back to the default" (stored as NULL — the Reset
button's all-null PUT is exactly the "defaults" operation). The
API layer runs the trim/length/hex validation so the 422 details
name the offending field (the house fixed-detail style); the
built-in→NULL normalization (a color equal to its built-in is
stored as NULL — "save the defaults" must leave the row empty, the
no-op injection contract) happens there too, next to the palette
it normalizes against.
"""
app_name: str | None = None
input_placeholder: str | None = None
footer_text: str | None = None
bg: str | None = None
surface: str | None = None
ink: str | None = None
ink_soft: str | None = None
line: str | None = None
brand: str | None = None
brand_soft: str | None = None
brand_ink: str | None = None
class UiSettingsOut(BaseModel):
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
phase 91, task 01).
All 11 values, 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.
"""
app_name: str
input_placeholder: str
footer_text: str
bg: str
surface: str
ink: str
ink_soft: str
line: str
brand: str
brand_soft: str
brand_ink: str
+20 -64
View File
@@ -32,9 +32,9 @@
* 4. an attribute pass — the aria-label / placeholder / meta
* content attributes containing the literal (the #messages
* aria-label, the input label, the meta descriptions);
* 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the three UI-customization
* keys, applied in this same .then, AFTER the app_name
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
* settled config also carries the two UI-customization
* string keys, applied in this same .then, AFTER the app_name
* passes and INDEPENDENT of them (they apply even when the
* name is the default/empty). Each empty value is a no-op —
* an unset deployment stays byte-identical:
@@ -43,30 +43,22 @@
* no-ops via the null guard);
* 6. footer_text — non-empty → every .footer-text node's
* textContent (all 9 pages, the phase-61 hook; an
* operator string can't inject markup via textContent);
* 7. theme — non-empty → a <link rel="stylesheet"> inserted
* IMMEDIATELY AFTER the styles.css link (the theme's
* :root overrides win by cascade order). The styles.css
* finder matches the RAW attribute path with any query/
* fragment stripped — the phase-33/54 cache-busting
* middleware serves the HTML with the asset refs rewritten
* to "…/styles.css?v=<token>", and el.href (the absolute
* URL) would never end with "styles.css" once versioned.
* The filename is validated server-side (a bare *.css
* name — no path can reach here via /api/config); a
* MISSING file degrades to the built-in theme (onerror →
* console.warn — A5, the page never breaks). Guarded by
* #theme-override: never inserted twice.
* operator string can't inject markup via textContent).
* Phase 91 (task 03): color theming is no longer a brand.js
* job — the retired CSS-file theme link (the old step 7) is
* deleted with its env var; the SERVER now injects the
* effective palette inline before first paint
* (app/core/theming.py), so this layer keeps the text swaps
* only.
* • fetch failure / empty name → the default stays + console.warn
* (the loadHealth house style: progressive enhancement, the page
* never breaks).
*
* No-op property: with the customization env vars (BOR_APP_NAME,
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) unset, /api/config
* answers with the template defaults themselves — the name IS the
* literal, the placeholder and footer are the phase-61 copy (re-setting
* them is invisible), the theme is empty (the link is skipped) — so an
* unset deployment renders byte-identical.
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers
* with the template defaults themselves — the name IS the literal, the
* placeholder and footer are the phase-61 copy (re-setting them is
* invisible) — so an unset deployment renders byte-identical.
*/
/* The synchronous default — set BEFORE any fetch, so module scripts
@@ -187,11 +179,12 @@ function applyBrand() {
}
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
// config also carries the three customization keys — applied here,
// INDEPENDENT of the app_name block above (they apply even when
// the name is the default/empty). Each empty value is a no-op, so
// an unset deployment stays byte-identical (no attribute or
// element touched, no second network call).
// config also carries the two customization STRING keys — applied
// here, INDEPENDENT of the app_name block above (they apply even
// when the name is the default/empty). Each empty value is a
// no-op, so an unset deployment stays byte-identical (no attribute
// or element touched, no second network call). Color theming is
// server-side since phase 91 (task 03) — not this layer's job.
const placeholder =
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
if (placeholder) {
@@ -214,43 +207,6 @@ function applyBrand() {
el.textContent = footerText;
});
}
const themeName = typeof cfg?.theme === "string" ? cfg.theme : "";
if (themeName) {
// 7. The theme stylesheet — a <link> inserted IMMEDIATELY AFTER
// the existing styles.css link, so the theme's :root
// overrides win by cascade order. The filename is validated
// server-side (task 01: a bare *.css name) — no path input
// can reach here via /api/config. Guarded by #theme-override:
// never applied twice (the loadHealth house style — never
// break the page, never double-apply). A missing file
// degrades to the built-in theme (A5): the onerror warns,
// nothing else.
if (!document.getElementById("theme-override")) {
// Phase 33/54: the served HTML may carry the cache-bust query
// (?v=<token>) on the asset ref — match on the RAW attribute
// path with query/fragment stripped, never on el.href (the
// absolute URL, which would include the token).
const stylesLink = Array.from(
document.querySelectorAll('link[rel="stylesheet"]'),
).find((el) => {
const ref = (el.getAttribute("href") || "").split(/[?#]/)[0];
return ref.endsWith("styles.css");
});
if (stylesLink) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/assets/themes/" + themeName;
link.id = "theme-override";
link.onerror = () =>
console.warn(
"brand: theme " + themeName +
" did not load — the built-in theme stands.",
);
stylesLink.insertAdjacentElement("afterend", link);
}
}
}
});
}
+7
View File
@@ -219,6 +219,13 @@ export async function initSharedHeader() {
// pages) is a no-op. A token user (role "user") never sees it.
const navTokens = document.querySelector("#nav-tokens");
if (navTokens) navTokens.hidden = !admin;
// Phase 91 (task 04): the Theme nav link (the shell's seventh view —
// the phase-34 one-bar contract ships it on every page's nav) —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the Tokens link above. Null-safe: a page without the link is a
// no-op. A token user (role "user") never sees it.
const navTheme = document.querySelector("#nav-theme");
if (navTheme) navTheme.hidden = !admin;
// Phase 34: the steering panel (phase 15) is module-owned. The
// navbar #steering-toggle was removed at owner request (2026-08-28)
// — the panel ships hidden and is only kept fresh. Admin: refresh
+11 -4
View File
@@ -1,7 +1,7 @@
/* Brain of Reese — shell router (phase 76, task 01).
*
* The five navbar views are views of ONE HTML shell (index.html), not
* five documents: this module makes a navbar click a CLIENT-SIDE view
* The seven navbar views are views of ONE HTML shell (index.html), not
* seven documents: this module makes a navbar click a CLIENT-SIDE view
* switch — history.pushState + show/hide — never a document load, so
* the in-flight chat stream in the hidden view keeps streaming
* through any switch and completes when the user returns to Chat.
@@ -62,8 +62,10 @@
* (module — this file). No CDN, no framework, no bundler dependency:
* a plain ES module whose dynamic imports (./tuning.js, task 01;
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03;
* ./tokens.js in phase 79 task 06) resolve relatively in dev and are
* inlined by the Containerfile's esbuild stage in the image.
* ./tokens.js in phase 79 task 06; ./theme.js in phase 91 task 04 —
* the wiring lands there, the editor fills it in task 05) resolve
* relatively in dev and are inlined by the Containerfile's esbuild
* stage in the image.
*/
/* ---------- the view map (pathname → view name) ----------
@@ -79,6 +81,7 @@ const VIEW = {
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
};
/* The nav-link href the router stamps active for each view (the
@@ -90,6 +93,7 @@ const VIEW_PATH = {
"git-sources": "/git-sources.html",
history: "/history.html",
tokens: "/tokens.html",
theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only)
};
/* The lazy view modules — ONLY the non-chat views (chat needs no
@@ -103,6 +107,7 @@ const VIEW_MODULES = {
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
history: () => import("./history.js"), // phase 76 task 03
tokens: () => import("./tokens.js"), // phase 79 task 06
theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor)
};
/* Per-view document.head values, carried over from the old pages'
@@ -116,6 +121,7 @@ const TITLES = {
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
history: "Saved chats · Brain of Reese", // old history.html <title>
tokens: "Access tokens · Brain of Reese",
theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new
};
const DESCRIPTIONS = {
chat:
@@ -128,6 +134,7 @@ const DESCRIPTIONS = {
history:
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
tokens: "Generate and revoke the API tokens that let people use the app.",
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
};
/* The brand-resolved display name (phase 39 — brand.js is the single
+186
View File
@@ -2951,6 +2951,185 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-style: italic;
}
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
The shell's seventh view (#view-theme): the admin palette + branding
editor (task 05). The centered 46rem column (the .tuning-shell
language — this is a form view, the tuning-view width pattern), the
form card (the #tune-form language: surface fill, --line hairline,
radius, shadow), the fieldset groups (Branding / Palette) with the
.theme-note sub-copy, the 3-column (desktop) / 1-column (mobile)
palette grid with the color swatch inputs sized for touch (44px —
the label above is the second tap affordance), and the Save/Reset
row (Save = the brand pill family, --bg ink on --brand 5.2:1 AA;
Reset = the ghost button family, --line border; the §7.4
disabled-while-in-flight look is the :disabled pair below). The
feedback lines: error/result are the house role=status/alert line
languages; #theme-contrast (the WCAG warning) uses the --err-*
STATE family — states are semantic colors, never themed from the
tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible
via the global 3px outline rule. No CDN, system fonts. */
.theme-shell {
max-width: 46rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The static form — one card holding the two fieldset groups + the
actions row (the #tune-form card language). */
#theme-form {
display: flex;
flex-direction: column;
gap: 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1rem 1.1rem 1.1rem;
}
/* The fieldset groups: reset the UA border (the card IS the group's
frame), the legend rides the group's title line (the
.tuning-panel-title language — ink, 1rem, 700). */
.theme-group {
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.theme-group-title {
padding: 0;
font-size: 1rem;
font-weight: 700;
color: var(--ink);
}
/* The branding text inputs: the #token-label language (surface is
already the card fill — transparent field, --line hairline, ≥44px
target, the global :focus-visible ring). */
#theme-form fieldset label {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-soft);
}
#theme-form fieldset input[type="text"] {
width: 100%;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
/* The palette note under the branding legend (task 05): the strings
apply on the next page load (B4) — the live preview covers the
palette only. */
.theme-note {
margin: 0;
font-size: 0.8rem;
line-height: 1.35;
color: var(--ink-soft);
}
/* The color-input grid (task 05): the 8 swatches in 3 columns
(3 + 3 + 2 rows) — the cell is a .theme-color (label above the
swatch); 1 column at the mobile breakpoint (below). */
.theme-colors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.6rem 0.9rem;
}
.theme-color {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.theme-color label {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-soft);
line-height: 1.25;
}
.theme-color input[type="color"] {
/* Touch-sized swatch (task 05): 44px tall (the WCAG 2.5.8 target —
the label above is the second tap affordance). */
inline-size: 56px;
block-size: 44px;
padding: 3px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg);
cursor: pointer;
}
/* Save (primary) + Reset (secondary): the pill + ghost families
(.history-refresh / .token-revoke languages — the global
:focus-visible ring applies, no button-scoped focus override). */
.theme-actions {
display: flex;
gap: 0.6rem;
margin-top: 0.2rem;
}
#theme-save {
min-height: 44px;
padding: 0.4rem 1.2rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 (never white on brand) */
font: inherit;
font-weight: 700;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
#theme-save:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
#theme-save:disabled { opacity: 0.6; cursor: wait; }
.theme-reset {
min-height: 44px;
padding: 0.4rem 1rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.theme-reset:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.theme-reset:disabled { opacity: 0.6; cursor: wait; }
/* The three feedback lines (task 05): error (role=alert — the
server's 422 detail) and result (role=status) are the house line
languages; min-height holds the layout so a line never reflows the
form. #theme-contrast (the WCAG warning, task 05) takes the
.tuning-error box treatment in the --err-* STATE family — states
are semantic colors, never themed from the tab (B3): a failing
pair reads as a warning and the box is warning-only (Save is never
disabled by it). */
.theme-error,
.theme-result,
.theme-contrast {
display: block;
margin: 0;
min-height: 1.2em;
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
.theme-error { color: var(--err-ink); }
.theme-result { color: var(--ok-ink); }
.theme-contrast {
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
}
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
@@ -3979,6 +4158,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#token-once-copy { width: 100%; }
.tokens-actions-cell { white-space: normal; }
.tokens-actions { flex-wrap: wrap; }
/* Phase 91 task 05: the Theme form squeezes — the color grid drops
to 1 column (the label + swatch pairs keep their ≥44px targets)
and the actions row stacks (Save full width, Reset under it). */
.theme-colors { grid-template-columns: 1fr; }
.theme-actions { flex-direction: column; align-items: stretch; }
#theme-save { width: 100%; }
.theme-reset { width: 100%; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell keeps its base 46rem column (the >=1500px 92rem override
+413
View File
@@ -0,0 +1,413 @@
/* Brain of Reese — Theme view module (phase 91, task 05): the admin
* palette + branding editor.
*
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
* shape): the router (assets/router.js) lazy-imports this module on
* FIRST show of #view-theme and calls mount(root) ONCE (mount-once,
* hide-forever — root is the view's <section id="view-theme">, every
* DOM lookup scoped to it). The initSharedHeader() call never happens
* here: in the shell the shared header boots exactly once, via the
* chat module (app.js) at shell boot.
*
* What the editor does:
*
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
* header.js exports, zero extra requests): admin hides #theme-gate
* and reveals #theme-content; anonymous / token-user keeps the
* 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 11 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 8
* color pickers the value is written straight onto <html> as an
* inline custom property, so the WHOLE page repaints (every view,
* the header) while the owner is picking. Text fields have NO page
* effect: the 3 strings keep the brand.js runtime application
* (owner-locked B4) — they apply via the /api/config boot fetch on
* the NEXT page load, and the sub-copy says so. On every
* successful save, on Reset, and on a re-show refresh all 8
* overrides are removed (removeProperty) so the page reflects the
* served (injected) theme, never stale preview state.
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
* PUT /api/ui-settings with the 11 form values (a cleared/empty
* text field → null; colors always their current hex — the
* server's built-in→NULL normalization keeps the row empty when
* the owner saves the defaults) → 200: #theme-result "Theme
* saved." (role=status), refetch + re-populate (canonical state),
* clear the preview overrides, re-check the contrast pairs →
* re-enable + restore the label (the finally — a click can never
* leave a button stuck). 422: #theme-error carries the SERVER
* detail (it names the offending field), the form is KEPT (the
* owner fixes + retries); any other non-2xx: the fixed error line;
* a network error: the "is the app reachable?" line.
* • Reset — the same lifecycle ("Resetting…") with all 11 values
* null (the API's documented "defaults" operation) → #theme-result
* "Reset to the built-in theme." → refetch + re-populate (the
* env/built-in defaults) + clear the preview overrides.
* • WCAG contrast (the 00_phase design's five pairs — the pairs the
* layout actually pairs, see app/core/theming.py's docstring):
* ink on bg, ink on surface, ink-soft on surface, bg on brand
* (the text on brand buttons is the dark background ink — never
* white on brand), brand-ink on surface. Evaluated on every color
* `input` and after every load/save over the CURRENT form values,
* via WCAG relative luminance (sRGB → linear → L). Any pair under
* 4.5:1 is listed in #theme-contrast (role=alert) as "--ink on
* --bg: 3.2:1 — needs 4.5:1"; all pass → the warning hides.
* WARNING-ONLY: it never disables Save (the owner's homelab
* palette — the built-in stays AA, so the default deployment is
* warning-free).
* • re-show — the phase-77 hook: a user-initiated re-show of this
* already-mounted view makes the router dispatch bor:view-refresh
* on the section — re-run the load then (the tab always shows the
* settled server state when re-shown) and clear the preview
* overrides (the page paints the served theme, not a stale pick).
* Armed only in the ADMIN branch, after the whoami gate passes:
* anonymous shows the gate and never fetches.
*
* Every value is rendered with textContent / input.value — this file
* never builds HTML (the XSS-safe-by-construction house rule).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const gateEl = root.querySelector("#theme-gate");
const contentEl = root.querySelector("#theme-content");
const saveBtn = root.querySelector("#theme-save");
const resetBtn = root.querySelector("#theme-reset");
const errorEl = root.querySelector("#theme-error");
const resultEl = root.querySelector("#theme-result");
const contrastEl = root.querySelector("#theme-contrast");
const SAVE_LABEL = "Save theme";
const RESET_LABEL = "Reset to defaults";
/* The 11 form fields, in the form's order: `field` is the API key
(the input's name attribute), `id` the E2E-stable element id,
`kind` how the value is read for a PUT — a string field that is
empty after the trim sends null (the server stores NULL = "use
the default"); a color field always sends its current #rrggbb
(the server's built-in→NULL normalization keeps the row empty
when the owner saves the defaults). */
const FIELDS = [
{ field: "app_name", id: "theme-app-name", kind: "string" },
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
{ field: "footer_text", id: "theme-footer", kind: "string" },
{ field: "bg", id: "theme-bg", kind: "color" },
{ field: "surface", id: "theme-surface", kind: "color" },
{ field: "ink", id: "theme-ink", kind: "color" },
{ field: "ink_soft", id: "theme-ink-soft", kind: "color" },
{ field: "line", id: "theme-line", kind: "color" },
{ field: "brand", id: "theme-brand", kind: "color" },
{ field: "brand_soft", id: "theme-brand-soft", kind: "color" },
{ field: "brand_ink", id: "theme-brand-ink", kind: "color" },
];
const inputs = {};
for (const f of FIELDS) inputs[f.field] = root.querySelector("#" + f.id);
const isHex = (v) => typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v);
const cssVar = (field) => "--" + field.replace(/_/g, "-");
/* ---------- WCAG contrast (the five pairs) ----------
* Relative luminance per WCAG 2.1: each sRGB channel is linearized
* (the 0.04045 threshold) then weighted (0.2126 / 0.7152 / 0.0722);
* the ratio is (L_lighter + 0.05) / (L_darker + 0.05). The five
* pairs (foreground, background) are exactly the ones the layout
* pairs — app/core/theming.py's docstring is the authoritative
* table. */
function channelLuminance(channel) {
const s = channel / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function relLuminance(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return (
0.2126 * channelLuminance(r) +
0.7152 * channelLuminance(g) +
0.0722 * channelLuminance(b)
);
}
function contrastRatio(fgHex, bgHex) {
const a = relLuminance(fgHex);
const b = relLuminance(bgHex);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
const AA_MIN = 4.5; // WCAG 2.1 AA for normal-size text (AGENTS.md rule 5)
const PAIRS = [
["ink", "bg"],
["ink", "surface"],
["ink_soft", "surface"],
["bg", "brand"],
["brand_ink", "surface"],
];
/* Re-evaluate the five pairs over the CURRENT form values. Any pair
under 4.5:1 is listed in #theme-contrast (one line per failing
pair, " · "-joined — textContent, never HTML); all pass → the
warning hides. A pair whose input is not a valid hex (defensive —
the color inputs always are) is skipped. WARNING-ONLY: this never
touches Save (the owner can still save a failing palette). */
function updateContrast() {
if (!contrastEl) return;
const failures = [];
for (const [fg, bg] of PAIRS) {
const fgHex = inputs[fg] ? inputs[fg].value : "";
const bgHex = inputs[bg] ? inputs[bg].value : "";
if (!isHex(fgHex) || !isHex(bgHex)) continue;
const ratio = contrastRatio(fgHex, bgHex);
if (ratio < AA_MIN) {
failures.push(
`${cssVar(fg)} on ${cssVar(bg)}: ${ratio.toFixed(1)}:1 — needs 4.5:1`,
);
}
}
if (failures.length) {
contrastEl.textContent = failures.join(" · ");
contrastEl.hidden = false;
} else {
contrastEl.textContent = "";
contrastEl.hidden = true;
}
}
/* ---------- feedback lines (never stale, §7.4) ---------- */
function clearError() {
if (!errorEl) return;
errorEl.textContent = "";
errorEl.hidden = true;
}
function showError(message) {
if (!errorEl) return;
errorEl.textContent = message; // textContent — the server detail is data
errorEl.hidden = false;
}
function showResult(message) {
if (!resultEl) return;
resultEl.textContent = message; // role=status announces it
resultEl.hidden = false;
}
/* FastAPI error bodies: a string detail (the house 422s — the detail
names the offending field) or the validation-error array (the
first entry's msg). Same extraction as tuning.js. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* ---------- live preview (colors only — B4) ----------
* The value lands on <html> as an inline custom property: the
* inline style beats the served <style id="bor-theme"> :root, so
* the whole page repaints live while the owner is picking. An empty
* value removes the override (the page falls back to the served
* theme). Text fields write NOTHING here — the strings apply via
* brand.js on the next page load (the sub-copy says so). */
function previewColor(field, value) {
if (value) {
document.documentElement.style.setProperty(cssVar(field), value);
} else {
document.documentElement.style.removeProperty(cssVar(field));
}
}
/* Drop all 8 preview overrides so the page paints the served
(injected) theme — the "never stale" half of the contract: after
a save / reset / re-show the page shows what the server serves,
not a pick that was never (or no longer) saved. */
function clearPreview() {
for (const f of FIELDS) {
if (f.kind === "color") {
document.documentElement.style.removeProperty(cssVar(f.field));
}
}
}
/* ---------- load / populate (effective values) ---------- */
function populate(settings) {
for (const f of FIELDS) {
const input = inputs[f.field];
const value = settings[f.field];
if (input && typeof value === "string" && value) input.value = value;
}
}
/* GET /api/ui-settings → populate the 11 inputs with the EFFECTIVE
values (the tab always shows the live theme — env defaults when
the row is empty) and re-check the five pairs (a SAVED palette
can itself fail AA — the warning then tracks it). A failed fetch
keeps the static form + shows #theme-error with a retry (the
loadHealth house style — never a blanked panel). Returns true
when the values are settled. */
async function loadSettings() {
clearError();
let r;
try {
r = await fetch("/api/ui-settings");
} catch {
showError("Couldn't load the theme — is the app reachable?");
return false;
}
if (!r.ok) {
showError("Couldn't load the theme — try again.");
return false;
}
let settings;
try {
settings = await r.json();
} catch {
showError("Couldn't load the theme — try again.");
return false;
}
populate(settings);
updateContrast();
return true;
}
/* ---------- the PUT (Save + Reset share it) ---------- */
function collectBody() {
const body = {};
for (const f of FIELDS) {
const input = inputs[f.field];
const value = input ? input.value : "";
if (f.kind === "string") {
body[f.field] = value.trim() || null; // cleared field → null
} else {
body[f.field] = isHex(value) ? value : null; // colors: their hex
}
}
return body;
}
function setBusy(busy) {
if (saveBtn) saveBtn.disabled = busy;
if (resetBtn) resetBtn.disabled = busy;
}
/* One action at a time: both buttons are disabled while either PUT
is in flight (a double-fire would race the row). */
async function putSettings(body, busyLabel, button) {
setBusy(true);
if (button) button.textContent = busyLabel;
clearError();
try {
const r = await fetch("/api/ui-settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) {
return { ok: true };
}
/* 422 → the server detail (it names the field); any other
non-2xx → the fixed line. The form is KEPT either way. */
const detail =
r.status === 422
? await apiDetail(r, "Couldn't save the theme — try again.")
: "Couldn't save the theme — try again.";
return { ok: false, detail };
} catch {
return {
ok: false,
detail: "Couldn't save the theme — is the app reachable?",
};
} finally {
setBusy(false);
if (button) button.textContent = button === saveBtn ? SAVE_LABEL : RESET_LABEL;
}
}
async function saveTheme() {
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
if (!outcome.ok) {
showError(outcome.detail);
return;
}
showResult("Theme saved."); // role=status
await loadSettings(); // refetch + re-populate (canonical state)
clearPreview(); // the page paints the served theme, not the pick
}
async function resetTheme() {
const body = {};
for (const f of FIELDS) body[f.field] = null; // all null = the defaults
const outcome = await putSettings(body, "Resetting…", resetBtn);
if (!outcome.ok) {
showError(outcome.detail);
return;
}
showResult("Reset to the built-in theme."); // role=status
await loadSettings(); // the env / built-in defaults, re-rendered
clearPreview(); // the page paints the served theme again
}
/* ---------- view boot (phase 91 task 05) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
* header uses (zero extra requests). Anonymous / token-user: the
* gate in, the content out — and NO /api/ui-settings request at all
* (the router 403s them — the #tokens-gate contract). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true;
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
/* Bindings — armed BEFORE the first load: a fast owner can start
picking while the GET is still out; the preview writes are
idempotent and the settled load re-populates afterwards. Color
inputs drive the live preview + the contrast re-check; text
inputs drive neither (B4 — the strings apply on the next page
load, the sub-copy says so). */
for (const f of FIELDS) {
const input = inputs[f.field];
if (!input || f.kind !== "color") continue;
input.addEventListener("input", () => {
previewColor(f.field, input.value);
updateContrast();
});
}
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
if (resetBtn) resetBtn.addEventListener("click", () => void resetTheme());
/* Phase 77 (the re-show refresh contract): a user-initiated re-show
of this already-mounted view makes the router dispatch
bor:view-refresh on the section — re-run the load then (the tab
always shows the settled server state when re-shown) and drop the
preview overrides (the page paints the served theme, not a pick
left behind from before the switch). Armed ONLY here, after the
whoami gate passed: anonymous shows the gate and never fetches. */
root.addEventListener("bor:view-refresh", () => {
void loadSettings().then((settled) => {
if (settled) clearPreview();
});
});
await loadSettings(); // the effective values — the live theme
}
-77
View File
@@ -1,77 +0,0 @@
# Themes — authoring guide (phase 62)
A theme is a small CSS file that overrides the `:root` palette variables.
That is the entire mechanism — no component CSS is theme-aware, every
color in the app reads a `--*` variable, so a later stylesheet wins by
cascade order.
## How a theme loads
1. Set `BOR_THEME=<file>` (a bare FILENAME, e.g. `BOR_THEME=indigo.css`).
`app/config.py` validates it at startup — anything not matching
`^[a-z0-9_-]+\.css$` (a path, `..`, uppercase, a missing extension)
refuses to boot, naming the value (the phase-56 fail-loud house
style).
2. The value rides the existing boot fetch: `GET /api/config` →
`frontend/assets/brand.js` inserts
`<link rel="stylesheet" href="/assets/themes/<file>">` IMMEDIATELY
AFTER the `styles.css` link — later wins the cascade.
3. A theme file MISSING at runtime (typo past the validator, or the file
deleted after the image was built) degrades to the built-in theme —
`brand.js` warns in the console, the page never breaks (the
loadHealth house style, A5).
4. UNSET (`BOR_THEME` empty) ⇒ no link is inserted at all — the
deployment renders byte-identical to the built-in dark-tech palette.
Loading is opt-in via the env var, never by directory scanning.
## The variables
A theme overrides the **8 identity variables** in a single `:root` block.
Built-in values (from `frontend/assets/styles.css`) for reference:
| Variable | Built-in | Role |
| -------------- | ---------- | ----------------------------------------------------------- |
| `--bg` | `#0f0a0a` | page background (text on it: `--ink`) |
| `--surface` | `#1a0f0f` | cards, panels, code blocks (text on it: `--ink`) |
| `--ink` | `#f0e6e6` | primary text |
| `--ink-soft` | `#b8a8a8` | secondary text (5.1:1 on `--surface`) |
| `--line` | `#2d1a1a` | decorative 1px borders (no contrast obligation) |
| `--brand` | `#f43f5e` | brand accent — buttons, links (text ON it is `--bg`) |
| `--brand-soft` | `#2d0a0a` | brand-tinted surface (chips, hover washes) |
| `--brand-ink` | `#fca5a5` | brand-tinted text (9.0:1 on `--surface`) |
The **semantic families are deliberately NOT identity** — do not
override them: `--accent-*` (deflection amber), `--ok-*` (success
green), `--err-*` (error red) encode *states*, and they are already AA
in the built-in theme. A theme that keeps them stays honest: your
indigo app still tells success from error.
## Rules
- **Filename:** `^[a-z0-9_-]+\.css$` — lowercase, bare filename, in this
directory. The server validator rejects anything else at startup
(naming the value), so keep the env var and the filename in lockstep.
- **One `:root` block.** No selectors, no `@media`, no other
declarations — the file overrides variables and nothing else (the
cascade does the rest). `indigo.css` is the reference shape.
- **Every text/background pair ≥ 4.5:1** (AGENTS.md rule 5, WCAG 2.1
AA). The pairs that matter: `--ink` on `--bg` and on `--surface`,
`--ink-soft` on `--surface`, `--bg` on `--brand` (the text on brand
buttons is the DARK background ink — that is the pattern), and
`--brand-ink` on `--surface`.
- **Never white-on-brand.** The built-in documents the trap: white on
`#f43f5e` is 3.7:1 — it fails. Pick a `--brand` whose luminance
carries the dark `--bg` ink at ≥ 4.5:1 (indigo.css: 6.5:1).
- Keep `--line` close to `--surface` (a 1px step, not a wall) — the
layout reads by surfaces, not borders.
## Deployment
- **Dev:** works immediately — the file is served from the static dir
(`frontend/`, `BOR_STATIC_DIR`), so drop the file in, set
`BOR_THEME`, restart uvicorn.
- **Container:** rebuild the image. Stage 1 ships the WHOLE directory
(`cp -r ./assets/themes /out/assets/themes` — no per-file esbuild), so
a new or edited theme file needs **no Containerfile change** (A7):
whatever is in `frontend/assets/themes/` at build time is what the
image serves at `/assets/themes/…`.
-17
View File
@@ -1,17 +0,0 @@
/* Phase 62 example theme — dark indigo/slate.
Overrides the :root identity palette from styles.css; every
text/background pair meets WCAG 2.1 AA (>= 4.5:1):
ink on bg 15.8:1 · ink on surface 14.7:1 · ink-soft on surface 8.3:1
dark bg ink on brand 6.5:1 · brand-ink on surface 12.0:1.
Semantic families (accent/ok/err) inherit the built-in theme.
Load with BOR_THEME=indigo.css (authoring guide: themes/README.md). */
:root {
--bg: #0a0e1a;
--surface: #111726;
--ink: #e6e9f0;
--ink-soft: #a8b0c8;
--line: #232c44;
--brand: #818cf8;
--brand-soft: #1a1f38;
--brand-ink: #c7d2fe;
}
+7
View File
@@ -65,6 +65,13 @@
page — test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the phase-34 one-bar
contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+177
View File
@@ -54,6 +54,12 @@
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the shell's seventh
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
@@ -943,6 +949,177 @@
</div>
</section>
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
folded view (the phase-76 fold pattern, the phase-79 Tokens
view as the most recent precedent): the admin-only palette +
branding editor. /theme.html serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. The CSS-file theming (BOR_THEME, phase 62) is
retired (task 03): the effective theme is injected into every
served page's <head> server-side (app/core/caching.py +
app/core/theming.py), so it paints on the FIRST paint — no
red flash, no pop-in. SHIPS hidden (anonymous-safe — the gate
is what anonymous sees; theme.js reveals the content for
admin only). The hidden + inert pair is the WCAG contract:
a hidden view must not receive focus or keyboard traversal
(AGENTS.md rule 5). Mounted lazily — assets/router.js imports
theme.js on first show only (mount-once, hide-forever; the
editor lands in task 05). The form is STATIC markup (the
E2E-stable-selectors house convention) — the onsubmit
binding + live preview + Save/Reset lifecycle land in task 05
(theme.js); there is no real submit (every button is
type="button"). -->
<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">
<div class="container theme-shell">
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
#sources-gate pattern (phase 16) and the same .sources-gate
visual language: the palette + branding is what the login
locks (B5, owner-locked 2026-09-09). Visible for
anonymous, hidden for the admin (theme.js). The gate's
Sign in returns to the Theme view (the header's ?next=
convention; the static href is the no-JS fallback). -->
<section class="sources-gate" id="theme-gate" aria-labelledby="theme-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="theme-gate-title">Sign in to change the theme</h2>
<p class="sources-gate-sub">
The palette and branding are admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
</section>
<!-- Phase 91 (task 04): the editor — SHIPS hidden
(anonymous-safe; the gate is what anonymous sees). theme.js
reveals it once the cached whoami says admin (the
#git-sources-content pattern). -->
<div id="theme-content" hidden>
<div class="page-head">
<h1>Theme</h1>
<p class="page-sub">
Changes preview live as you pick;
<strong>Save theme</strong> bakes the palette into every
page it is served on — it applies on the first paint, no
pop-in.
</p>
</div>
<!-- Static form skeleton (E2E-stable selectors; task 05 wires
the bindings — effective-value populate, the live preview,
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
in #theme-contrast). No real submit: both buttons are
type="button"; maxlength=300 mirrors the server's 300-char
limit (the server re-validates — 422 naming the field).
The color inputs ship the BUILT-IN values (app/core/
theming.py BUILTIN_COLORS) — task 05 re-populates them
with the EFFECTIVE values on mount. -->
<form id="theme-form" novalidate>
<fieldset class="theme-group">
<legend class="theme-group-title">Branding</legend>
<!-- B4 (owner-locked): the strings keep the brand.js
runtime application — they apply via the /api/config
boot fetch on the NEXT page load; the live preview
covers the palette only. An empty field restores the
default (the env value). -->
<p class="theme-note">
These strings apply on the next page load — the live
preview covers the palette only. Leaving a field empty
restores its default.
</p>
<label for="theme-app-name">App name</label>
<input
id="theme-app-name"
name="app_name"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-placeholder">Chat input placeholder</label>
<input
id="theme-placeholder"
name="input_placeholder"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-footer">Footer line</label>
<input
id="theme-footer"
name="footer_text"
type="text"
maxlength="300"
autocomplete="off"
>
</fieldset>
<fieldset class="theme-group">
<!-- The five text/background pairs are checked against
WCAG 2.1 AA (4.5:1) as the owner picks (theme.js —
the app/core/theming.py docstring is the authoritative
pair table); failures list in #theme-contrast as a
warning and never block a save. -->
<legend class="theme-group-title">
Palette — five pairs checked against WCAG 2.1 AA (4.5:1)
</legend>
<div class="theme-colors">
<div class="theme-color">
<label for="theme-bg">Background (--bg)</label>
<input id="theme-bg" name="bg" type="color" value="#0f0a0a">
</div>
<div class="theme-color">
<label for="theme-surface">Surface (--surface)</label>
<input id="theme-surface" name="surface" type="color" value="#1a0f0f">
</div>
<div class="theme-color">
<label for="theme-ink">Text (--ink)</label>
<input id="theme-ink" name="ink" type="color" value="#f0e6e6">
</div>
<div class="theme-color">
<label for="theme-ink-soft">Secondary text (--ink-soft)</label>
<input id="theme-ink-soft" name="ink_soft" type="color" value="#b8a8a8">
</div>
<div class="theme-color">
<label for="theme-line">Border (--line)</label>
<input id="theme-line" name="line" type="color" value="#2d1a1a">
</div>
<div class="theme-color">
<label for="theme-brand">Brand accent (--brand) — buttons, links</label>
<input id="theme-brand" name="brand" type="color" value="#f43f5e">
</div>
<div class="theme-color">
<label for="theme-brand-soft">Brand tint (--brand-soft)</label>
<input id="theme-brand-soft" name="brand_soft" type="color" value="#2d0a0a">
</div>
<div class="theme-color">
<label for="theme-brand-ink">Brand text (--brand-ink)</label>
<input id="theme-brand-ink" name="brand_ink" type="color" value="#fca5a5">
</div>
</div>
</fieldset>
<!-- Save (primary) + Reset to defaults (secondary). Both
type="button" (no real submit); task 05 runs the §7.4
never-stale lifecycle ("Saving…" while the PUT is out,
re-enabled on success AND failure). -->
<div class="theme-actions">
<button type="button" id="theme-save">Save theme</button>
<button type="button" class="theme-reset" id="theme-reset">Reset to defaults</button>
</div>
</form>
<!-- task 05 owns all three lines: #theme-error (the server's
422 detail — the fields are kept on failure), #theme-result
("Theme saved." / "Reset to the built-in theme."),
#theme-contrast (the WCAG warnings for the five pairs —
warning-only, the owner can still save). -->
<p class="theme-error" id="theme-error" role="alert" hidden></p>
<p class="theme-result" id="theme-result" role="status" aria-live="polite" hidden></p>
<p class="theme-contrast" id="theme-contrast" role="alert" hidden></p>
</div>
</div>
</section>
</main>
<!-- Phase 79 (task 05): the in-app token gate — a body-level
+7
View File
@@ -58,6 +58,13 @@
page — test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the phase-34 one-bar
contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+7
View File
@@ -59,6 +59,13 @@
page — test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the phase-34 one-bar
contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+4 -3
View File
@@ -40,12 +40,13 @@ os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"]
# Phase 62: the same leak class for the new UI customization settings —
# an operator's local ``.env`` may legitimately carry
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and
# the default-metadata pins must see the code defaults (derived from
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``, and the
# default-metadata pins must see the code defaults (derived from
# the class fields, same pattern as the suggestions line above).
# (Phase 91, task 03: the retired CSS-file theme env var no longer
# exists — nothing to pin.)
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
+5 -6
View File
@@ -123,14 +123,13 @@ def app_server(mock_llm: int) -> Iterator[str]:
)
# Phase 62: the same leak class for the new UI customization
# settings — an operator's local (gitignored) ``.env`` may
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
# / ``BOR_THEME``, and the byte-identical default contract (task
# 05's ``test_default_server_is_byte_identical``) must see the code
# defaults (derived from the class fields, never drifts from
# ``app/config.py``).
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` /
# ``BOR_FOOTER_TEXT``, and the byte-identical default contract
# must see the code defaults (derived from the class fields, never
# drifts from ``app/config.py``). (Phase 91, task 03: the retired
# CSS-file theme env var no longer exists — nothing to pin.)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+733
View File
@@ -0,0 +1,733 @@
"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and
fields, the pre-paint theme, the admin gate, and the reset.
Source: ``TODO.md`` L4 — "Custom theming isn't really working. The
page loads red first and then the theme 'pops' into view, replacing
words and colors in an obvious way. Remove the custom css file
theming. Create a new admin tab that allows the user to change
everything the env var and custom css currently supports but with
buttons and color pickers. Theme should load immediately, not pop in
after the page load."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
11 inputs show the effective defaults (the 3 template strings +
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
IN-TEST — the suite can never drift from the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
the inputs re-populate to the saved values; the persisted row is
the one saved; and the saved non-AA palette lists its failing
pair in ``#theme-contrast`` without blocking the save
(warning-only).
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
id="bor-theme">`` with all 8 vars = the saved hexes, placed
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
stylesheet application). The 3 strings stay on the brand.js boot
fetch (the B4 split: colors pre-paint, strings via the fetch).
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
anonymous meets ``#theme-gate`` (sign-in link
``?next=/theme.html``) with ``#theme-content`` hidden and the
nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user
(the phase-79 gate login) 403s the PUT too and never sees the
nav link (B5: admin-only, like Tuning/Tokens).
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
11 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
pair(s) with the ratio in ``#theme-contrast`` (role=alert) as
soon as the picker moves; Save still succeeds (warning-only);
Reset restores the AA built-ins and hides the warning (the
suite's final state is clean).
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).
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 (an operator's local ``.env`` may carry the owner's
name/placeholder/footer, and "the effective strings start at the
template defaults" must hold regardless — the phase-61/62
leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
not render as env rows in this suite's app).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette (task 06): a full non-built-in indigo set —
# 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",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
FAILING_INK = "#101010"
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
COLOR_INPUT_IDS: dict[str, str] = {
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
}
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 8 built-in identity hexes 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)."""
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 8 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'<style id="bor-theme">:root{{{declarations}}}</style>'
# ---------------------------------------------------------------------------
# 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 the shared conftest
server applies to its two string vars; this one pins all three,
including ``BOR_APP_NAME``, which the shared server leaves to the
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
``.env``'s git repo must not render as env rows in this
suite's app)."""
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) — no chat turn is
# ever sent in this suite, but the app boots with the same shape.
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
)
# The branding vars: "unset" = the template defaults (the code
# defaults, derived from the class fields — the local ``.env`` may
# carry the owner's values, and this suite's assertions need the
# TEMPLATE defaults, not the owner's).
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
# ---------------------------------------------------------------------------
# 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_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
"""Intercept ``PUT /api/ui-settings`` and hold it for
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
while it is held, the page's fetch is guaranteed pending, so the
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
labels) is observable deterministically — a localhost PUT
settles in milliseconds, so without the hold the window is a
race. GETs (the load + the save's refetch) pass straight
through."""
def handle(route: Route) -> None:
if route.request.method == "PUT":
time.sleep(hold_s)
route.continue_()
page.route("**/api/ui-settings", handle)
def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 11 inputs: the 3 text fields (``strings``, default
the E2E set) + the 8 color pickers (``palette``)."""
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"])
page.fill("#theme-footer", text_values["footer_text"])
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 11 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"])
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
all 8 vars = the given hexes, placed IMMEDIATELY before
``</head>`` (``inject_theme``'s exact placement: the tag ends
exactly where ``</head>`` begins and carries the injector's
single leading newline)."""
tag = _expected_tag(colors)
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
start = raw.index(tag)
head = raw.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""The first-paint proof: all 8 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 (admin): the form, the effective defaults, the §7.4 save
# ---------------------------------------------------------------------------
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
# The admin header contract on this page: the ship-hidden "Theme"
# nav link is revealed (header.js, role === "admin") and marks
# the current page (the router's single-writer nav stamp).
expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000)
expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page")
expect(page.locator("#sign-out-btn")).to_be_visible()
# The gate is hidden for the admin and the form is revealed
# (theme.js's whoami branch — the #git-sources-content pattern).
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 11 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 8 built-in hexes 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
# real form — the PUT held so the §7.4 in-flight state is
# observable deterministically.
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
# In-flight: BOTH buttons disabled (one action at a time),
# the primary relabeled "Saving…" (never stale).
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-save")).to_have_text("Saving…")
expect(page.locator("#theme-reset")).to_be_disabled()
# Settled: the role=status confirmation + the restored
# lifecycle (re-enabled, original label).
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
expect(page.locator("#theme-save")).to_have_text("Save theme")
expect(page.locator("#theme-save")).to_be_enabled()
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# 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 11 values
# — every palette color differs from its 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"
assert row.app_name == APP_NAME
assert row.input_placeholder == PLACEHOLDER
assert row.footer_text == FOOTER
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
# The saved palette fails ONE of the five 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).
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible()
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
# + the computed palette at load (the no-pop-in proof), the B4
# strings via the boot fetch
# ---------------------------------------------------------------------------
def test_saved_theme_is_pre_paint_for_everyone(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
# The admin saves the theme (the API seed — test 1 owns the UI
# save path).
_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 8 vars = the saved
# hexes, immediately before </head> (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
# The phase-91 CSP extension: the inline tag is permitted in a
# real browser only via the strict sha256 source expression
# (style-src 'self' 'sha256-…' appended to the A1 string — no
# 'unsafe-inline').
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# The admin's browser: the same tag in the served document, and
# the computed custom properties equal the saved hexes at load
# (the inline tag precedes every stylesheet application — the
# first paint IS the themed paint).
page.goto(app_url + "/")
_assert_raw_tag(page.content(), PALETTE)
_wait_theme_computed(page, PALETTE)
# A FRESH anonymous context (no auth anywhere): the same inline
# tag + computed values — the theme is for EVERYONE, not just
# the admin who set it.
anon_ctx: BrowserContext | None = None
try:
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + "/")
_assert_raw_tag(anon.content(), PALETTE)
_wait_theme_computed(anon, PALETTE)
# The B4 split: the 3 strings are NOT pre-paint — they apply
# post-fetch via the /api/config boot fetch (brand.js) on the
# anonymous page too: the name (header brand + window
# global), the placeholder, and the footer line.
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
# ---------------------------------------------------------------------------
# 3. The gate + the 403s: anonymous sees the gate (never the form),
# the API 403s anonymous AND token users, the nav link is admin-only
# ---------------------------------------------------------------------------
def test_anonymous_and_token_user_are_walled(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# --- anonymous: the gate, the hidden form, the hidden nav link ---
page.goto(app_url + "/theme.html")
# Phase 79: an anonymous visitor meets the in-app token gate on
# the shell — #main is inert behind it…
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
assert page.evaluate("() => document.getElementById('main').inert") is True
# …and the Theme view's OWN gate (the exact #sources-gate
# pattern) is the view's visible surface: the sign-in link
# returns to the Theme view (?next=/theme.html)…
expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000)
expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute(
"href", "/login.html?next=/theme.html"
)
# …while the form stays locked away (theme.js's non-admin
# branch) and the admin-only nav link is hidden.
expect(page.locator("#theme-content")).to_be_hidden()
expect(page.locator("#nav-theme")).to_be_hidden()
expect(page.locator("#sign-in-link")).to_be_visible()
# The API agrees from the context's own (empty) cookies: GET AND
# PUT are 403 "admin only" (the whole router sits behind
# require_admin — anonymous first).
anon_put = page.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}"
anon_get = page.evaluate(
"() => fetch('/api/ui-settings').then((r) => r.status)"
)
assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}"
# --- a token user: the SAME wall (B5: admin-only, like
# Tuning/Tokens) ---
login(page, app_url, next="/")
r = httpx.post(
f"{app_url}/api/tokens",
json={"label": "e2e-theme-wall"},
cookies=_cookies(page),
timeout=10,
)
assert r.status_code == 201, r.text
token = r.json()["token"]
user_ctx: BrowserContext | None = None
try:
user_ctx = browser.new_context()
user = user_ctx.new_page()
user.set_default_timeout(30_000)
login_with_token(user, app_url, token)
# The nav link is hidden on their shell (role "user" — the
# header reveals the admin links only for role === "admin")…
expect(user.locator("#nav-theme")).to_be_hidden()
# …and the API 403s their own session (authenticated, just
# not an admin — 403, never 401).
put_status = user.evaluate(
"""async () => (await fetch('/api/ui-settings', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({brand: '#4f46e5'}),
})).status"""
)
assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}"
finally:
if user_ctx is not None:
user_ctx.close()
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
def test_reset_restores_the_builtin_byte_identical(
page: Page, app_url: str, db_ready: None
) -> None:
defaults = _template_defaults()
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The form settles on the effective defaults (the row-less
# state — the autouse clean truncated the row).
_expect_form_values(page, defaults, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
_release_theme_puts(page)
# …the theme is live server-side (the pre-reset baseline):
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
# Reset to defaults: the §7.4 lifecycle again, with the all-null
# PUT (the API's documented "defaults" operation).
_hold_theme_puts(page)
try:
page.click("#theme-reset")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# The form re-populates to the 11 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).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The served HTML is back to the built-in: NO theme tag anywhere
# (the all-NULL row is the no-op)…
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the computed --brand is the stylesheet's built-in again.
page.goto(app_url + "/")
_wait_theme_computed(page, builtin)
# 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"
)
# ---------------------------------------------------------------------------
# 5. The WCAG contrast warning: listed with the ratio on the picker's
# input event, never blocks the save, hidden again after the reset
# ---------------------------------------------------------------------------
def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
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).
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
# 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.
page.fill("#theme-ink", FAILING_INK)
contrast = page.locator("#theme-contrast")
expect(contrast).to_have_attribute("role", "alert")
expect(contrast).to_be_visible(timeout=15_000)
expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1")
expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1")
# WARNING-ONLY: Save is never disabled by the warning (the
# owner's homelab palette — the built-in stays AA, so the
# default deployment is warning-free).
assert page.locator("#theme-save").is_enabled()
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
# The saved palette still fails the pairs — the warning
# tracks the SAVED state (the save's refetch re-checks it).
expect(contrast).to_be_visible()
finally:
_release_theme_puts(page)
# Restore: Reset clears the failing pick (the suite's final
# state is clean) and the warning hides with the AA built-ins.
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(contrast).to_be_hidden()
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
-1
View File
@@ -331,7 +331,6 @@ def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
env["BOR_THEME"] = _Settings.model_fields["theme"].default
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
+9 -6
View File
@@ -148,25 +148,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
# Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here. Phase 62 (task 01): the
# endpoint grew to six keys — this suite's instances carry no
# UI-customization overrides, so the three new keys are their
# defaults.
# endpoint grew with the UI-customization keys; phase 91
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
# the five keys below are the entire contract (this suite's
# instances carry no UI-customization overrides, so the string
# keys are their defaults).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it) — and its key set
# grew with the endpoint (phase 62).
# tracks the endpoint contract (five keys after phase 91,
# task 03).
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
assert r2.status_code == 200
r2_body = r2.json()
assert set(r2_body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert r2_body["app_name"] == DEFAULT_NAME
+49 -143
View File
@@ -1,50 +1,53 @@
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
"""Phase 62 E2E (Playwright): UI customization — placeholder + footer.
Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese,
but I want anyone to be able to deploy it with their name… custom
message-input placeholder, custom footer-inner text, custom color
themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``,
``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5).
``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's
color-theming half — the CSS-file theme env var and its brand.js
``<link>`` insertion are gone; the admin Theme tab (phases 91,
tasks 04–06) is the only theming surface now, with its own dedicated
E2E suite.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_ui_customization.py -v --no-cov
Contract under test:
* an instance booted with ALL THREE customization vars set shows the
custom look end-to-end: the ``GET /api/config`` overrides, the chat
composer placeholder (``#message-input``), the footer line on multiple
pages (``.footer-text``), and the computed ``:root --brand`` from the
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
(the indigo example theme, ``--brand: #818cf8``);
* an instance booted with BOTH customization vars set shows the custom
look end-to-end: the ``GET /api/config`` overrides (now the five-key
set — the retired theming's ``theme`` key is gone), the chat composer
placeholder (``#message-input``), and the footer line on multiple
pages (``.footer-text``);
* with NOTHING set the shared conftest server is byte-identical to the
phase-39/61 no-op contract: the default placeholder, the default
footer, NO theme link, the built-in ``--brand: #f43f5e``;
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
naming the value — the phase-56 fail-loud style, proven end-to-end
via a real boot attempt, not just the validator unit test.
footer, the built-in ``--brand: #f43f5e``;
* the app NAME stays the default on the custom instance (this suite
does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's
job); the response's ``app_name`` key is the EFFECTIVE value (phase
91: DB-over-env — for an env-only deployment, the env string itself).
Determinism note: this story needs a SECOND app instance — the shared
conftest server keeps the defaults (every other suite's
placeholder/footer/palette assertions depend on it), so ``custom_server``
boots the same env block the phase-39 brand suite's ``testy_server``
boots (same DB, the mock-LLM base URL, the admin auth, the static dir,
the mock-calibrated threshold) with exactly three changes: port
the mock-calibrated threshold) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
collide) and the three env overrides. Every assertion is settled-state:
collide) and the two env overrides. Every assertion is settled-state:
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
fetch (the three keys are applied asynchronously, in the SAME fetch's
settled ``.then`` — no second network call). The one absence assertion
(no ``#theme-override`` on the default server) first waits for
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
fetch (the two string keys are applied asynchronously, in the SAME
fetch's settled ``.then`` — no second network call).
Test → contract mapping (Playwright Mapping Rule):
1. ``test_config_serves_the_overrides``
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
2. ``test_chat_page_shows_custom_placeholder_and_footer``
3. ``test_footer_text_applies_on_other_pages``
4. ``test_default_server_is_byte_identical``
5. ``test_malformed_theme_refuses_startup``
"""
from __future__ import annotations
import os
@@ -67,13 +70,10 @@ from e2e.conftest import (
CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision
CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}"
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
# The three overrides (task 05) — the whole story:
# The two overrides (phase 62, task 05) — the surviving story legs:
CUSTOM_PLACEHOLDER = "Ask the archive…"
CUSTOM_FOOTER = "Custom footer line"
CUSTOM_THEME = "indigo.css"
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
# The phase-39/61 no-op contract on the shared default server:
DEFAULT_NAME = "Brain of Reese"
@@ -84,7 +84,7 @@ BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand
@pytest.fixture(scope="session")
def custom_server(mock_llm: int) -> Iterator[str]:
"""A SECOND app instance, booted with all three customization
"""A SECOND app instance, booted with both customization string
overrides.
The shared conftest ``app_server`` keeps the defaults (every other
@@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
this fixture copies the phase-39 brand suite's ``testy_server`` env
block verbatim (same DB, the mock-LLM base URL,
``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``,
``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port
``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
three env overrides below. Started after ``mock_llm`` is available
two env overrides below. Started after ``mock_llm`` is available
(its fixture dependency).
"""
env = dict(os.environ)
@@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
# 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
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs:
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
env["BOR_THEME"] = CUSTOM_THEME
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"],
@@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]:
proc.kill()
def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
"""Wait for the brand layer's boot fetch to settle.
The three customization keys are applied asynchronously, in the
settled ``/api/config`` promise's ``.then`` — absence assertions
(no ``#theme-override``) must not race that fetch. The promise
NEVER rejects (the brand.js contract), so its resolution means the
DOM pass has already run: ``applyBrand`` registered its callback on
the same promise at page load, before this wait's callback, and
promise callbacks run in registration order."""
page.wait_for_function(
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
timeout=timeout,
)
def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
"""Retrying computed ``:root --brand`` equality. Custom properties
return the SPECIFIED token from ``getComputedStyle`` (no color
normalization), so the string compare is stable: ``#818cf8`` is
exactly what indigo.css declares, ``#f43f5e`` exactly what
styles.css declares (the built-in)."""
normalization), so the string compare is stable: ``#f43f5e`` is
exactly what styles.css declares (the built-in — the page the
shared default server serves, with no ui_settings row, carries no
inline theme tag and the stylesheet value stands)."""
page.wait_for_function(
"""(expected) =>
getComputedStyle(document.documentElement)
@@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
# ---------------------------------------------------------------------------
# 1. The endpoint the brand layer reads — the three overrides, the
# six-key set, and the theme file served from the dev static dir
# 1. The endpoint the brand layer reads — the two overrides, the
# five-key set (the retired theming's theme key is gone)
# ---------------------------------------------------------------------------
@@ -180,33 +164,27 @@ def test_config_serves_the_overrides(custom_server: str) -> None:
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
assert r.status_code == 200
body = r.json()
# The six-key set (the phase-39/59/62 endpoint contract) with the
# three customization overrides — the app NAME stays the default
# (this suite does not re-test BOR_APP_NAME; that is the phase-39
# suite's job).
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
# task 03: the retired CSS-file theming's ``theme`` key is gone)
# with the two customization overrides — the app NAME stays the
# default (this suite does not re-test BOR_APP_NAME; that is the
# phase-39 suite's job).
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == DEFAULT_NAME
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
assert body["footer_text"] == CUSTOM_FOOTER
assert body["theme"] == CUSTOM_THEME
# Served in dev from the static dir (the no-CDN rule): the theme
# file the boot fetch names is reachable at its served path, and
# it is the indigo example (its --brand is the E2E's theme proof).
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
assert r2.status_code == 200
assert f"--brand: {INDIGO_BRAND}" in r2.text
# ---------------------------------------------------------------------------
# 2. The chat page — placeholder, footer, the theme link + effect
# 2. The chat page — placeholder + footer (the theme legs are retired:
# colors are injected pre-paint server-side, phase 91 task 02)
# ---------------------------------------------------------------------------
def test_chat_page_shows_custom_placeholder_footer_theme(
def test_chat_page_shows_custom_placeholder_and_footer(
page: Page, custom_server: str
) -> None:
page.goto(custom_server + "/")
@@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme(
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# 7. The theme link in <head> — rel=stylesheet, the served path.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# And it takes effect: the computed :root --brand is the indigo
# value (the built-in #f43f5e means the theme never loaded).
expect_brand_var(page, INDIGO_BRAND)
# ---------------------------------------------------------------------------
@@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
expect(page.locator(".footer-text").first).to_have_text(
CUSTOM_FOOTER, timeout=15_000
)
# The theme link rides in <head> on every page too.
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
)
# ---------------------------------------------------------------------------
@@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
def test_default_server_is_byte_identical(page: Page, app_server: str) -> None:
page.goto(app_server + "/")
# Settle the boot fetch BEFORE the absence assertion — it must not
# race the (absent) theme-link insertion.
wait_for_brand_settled(page)
# The phase-39/61 no-op contract: the template defaults stand.
# The phase-39/61 no-op contract: the template defaults stand
# (positive assertions on the static HTML — the brand layer's
# no-op paths touch nothing when the env vars are unset).
expect(page.locator("#message-input")).to_have_attribute(
"placeholder", DEFAULT_PLACEHOLDER
)
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
# With BOR_THEME unset the brand layer inserts NO theme link:
assert page.locator("#theme-override").count() == 0, (
"with BOR_THEME unset the brand layer must NOT insert a theme "
"link (the byte-identical no-op contract)"
)
# The built-in dark-tech palette stands.
# The built-in dark-tech palette stands: with no ui_settings row
# the server injects no inline theme tag (phase 91 task 02's
# no-op), so the stylesheet's --brand is the computed value.
expect_brand_var(page, BUILTIN_BRAND)
# ---------------------------------------------------------------------------
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
# ---------------------------------------------------------------------------
def test_malformed_theme_refuses_startup() -> None:
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
shape the A5 lock names as illegal) kills startup with the value
NAMED on stderr (the phase-56 fail-loud house style), proven
end-to-end via a real uvicorn boot attempt: the process exits
non-zero within the timeout without ever starting to listen.
``app.main`` builds its settings at import time
(``settings = get_settings()``), so the validator fires during the
ASGI app import — before admin auth, before the port binds."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The whole point: a malformed theme value.
env["BOR_THEME"] = "../evil.css"
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
try:
proc.wait(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
pytest.fail(
"the app kept running with BOR_THEME='../evil.css' — a "
"malformed theme must refuse startup, not silently 404"
)
assert proc.returncode != 0, (
"the malformed BOR_THEME must make uvicorn exit non-zero"
)
stderr = proc.stderr.read() if proc.stderr else ""
# Fail-loud names the offending value (phase-56 house style):
assert "'../evil.css'" in stderr, (
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
)
assert "theme must be a bare .css filename" in stderr
+54 -18
View File
@@ -9,6 +9,14 @@ Phase 80 note: the suggestions pins are the exception — the chips are
the last 3 questions asked once any are saved, so the env-override
pin (the override is the SEED) needs an empty ``saved_chats``;
the full state matrix lives in ``test_suggestions_api.py``.
Phase 91 (task 01) note: the ``/api/config`` pins are now DB-backed —
the three UI strings are the EFFECTIVE values (the ``ui_settings`` row
over the env values, B1), resolved through a short-lived session, so
the pins take the ``db`` fixture (skip when the stack is down) and
start from an empty ``ui_settings`` table (the env-only-deployment
state; the DB-over-env behaviour itself is pinned in
test_ui_settings_api.py).
"""
from __future__ import annotations
@@ -22,6 +30,14 @@ from app.config import get_settings
from tests.conftest import ADMIN_PASSWORD
def _clear_ui_settings(db: Session) -> None:
"""The env-only-deployment state for the /api/config pins: no
ui_settings row, so the effective strings are the env values
(phase 91, task 01)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def test_health_reports_ok(client) -> None:
r = client.get("/api/health")
assert r.status_code == 200
@@ -31,34 +47,41 @@ def test_health_reports_ok(client) -> None:
assert body["version"]
def test_config_returns_default_app_metadata(client) -> None:
"""GET /api/config is public (anonymous) and returns exactly six
def test_config_returns_default_app_metadata(client, db: Session) -> None:
"""GET /api/config is public (anonymous) and returns exactly five
keys — the phase-39 app metadata, the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
and the phase-62 UI customization strings (composer placeholder,
footer line, theme file name)."""
footer line). Phase 91: with an empty ui_settings table the
effective strings are the env defaults (B1 — DB-over-env, the row
absent here); the retired CSS-file theming's ``theme`` key is gone
(task 03 — the five keys are the entire contract)."""
_clear_ui_settings(db)
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
# Phase 62: UNSET => the phase-61 neutral copy stands (the
# byte-identical contract); an empty theme = the built-in palette.
# byte-identical contract).
assert body["input_placeholder"] == "Ask me anything…"
assert body["footer_text"] == "Powered by self-hosted models"
assert body["theme"] == ""
def test_config_follows_overridden_app_name(client) -> None:
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME)."""
def test_config_follows_overridden_app_name(client, db: Session) -> None:
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).
Phase 91: the override is the ENV side of the DB-over-env resolver —
with an empty ui_settings row the effective app_name is the
overridden env value."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy"
)
@@ -68,7 +91,7 @@ def test_config_follows_overridden_app_name(client) -> None:
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0"
@@ -77,18 +100,21 @@ def test_config_follows_overridden_app_name(client) -> None:
fastapi_app.dependency_overrides.clear()
def test_config_serves_ui_customization_overrides(client) -> None:
"""Phase 62: the three UI customization keys mirror Settings
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` /
``BOR_THEME``) verbatim — the values the frontend brand layer
applies at boot, so this dict is the whole contract."""
def test_config_serves_ui_customization_overrides(client, db: Session) -> None:
"""Phase 62: the UI customization string keys mirror Settings
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``) — the
values the frontend brand layer applies at boot, so this dict is
the whole contract. Phase 91: placeholder + footer are the
EFFECTIVE strings — the env overrides win with an empty
ui_settings row (B1); the retired theming's ``theme`` key is gone
(task 03)."""
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
input_placeholder="Ask the vault…",
footer_text="Powered by my own models",
theme="indigo.css",
)
try:
r = client.get("/api/config")
@@ -96,16 +122,15 @@ def test_config_serves_ui_customization_overrides(client) -> None:
body = r.json()
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
assert body["theme"] == "indigo.css"
finally:
fastapi_app.dependency_overrides.clear()
def test_config_docs_flag_tracks_settings(client) -> None:
def test_config_docs_flag_tracks_settings(client, db: Session) -> None:
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
``settings.docs_configured`` — a real bool (never a truthy string)
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
@@ -113,6 +138,7 @@ def test_config_docs_flag_tracks_settings(client) -> None:
from app.config import Settings
from app.main import app as fastapi_app
_clear_ui_settings(db)
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy",
docs_repo="/srv/docs-repo",
@@ -206,6 +232,10 @@ def test_suggestions_honors_bor_suggestions_env_override(
# shell-body marker (the Tokens view section is inside the
# shell; the per-view title is client-side now).
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
# Phase 91 (task 04): /theme.html is a SHELL route too — the
# shell-body marker (the Theme view section is inside the
# shell; the per-view title is client-side now).
("/theme.html", 'id="view-theme"'), # phase 91: shell route
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
],
)
@@ -246,6 +276,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
["/sources.html", "/document.html", "/login.html", "/tuning.html",
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
"/theme.html", # phase 91 task 04: + Theme (shell route)
"/shared.html"], # phase 51: + the anonymous shared page
)
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
@@ -271,6 +302,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
# phase 91 task 04: the seventh view — there was never a
# standalone theme.html, so "old_title" is the router's
# client-side title: the pin asserts the shell never carries
# the per-view title statically (the router writes it).
("/theme.html", 'id="view-theme"', "Theme · Brain of Reese"), # phase 91 task 04
],
)
def test_shell_routes_serve_the_shell_no_cache_versioned(
@@ -87,6 +87,7 @@ SHELL_BACKED_PAGES = {
"/git-sources.html": "index.html", # phase 76 task 02
"/history.html": "index.html", # phase 76 task 03
"/tokens.html": "index.html", # phase 79 task 06
"/theme.html": "index.html", # phase 91 task 04
}
+193
View File
@@ -0,0 +1,193 @@
"""Integration: migration 0014 (ui_settings) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0012.py`` (information_schema assertions on the state
the migration must leave). The tests target revision ``0014``
explicitly so later migrations cannot break them:
* upgrade 0013 → 0014 → the ``ui_settings`` table exists with the full
column contract (``id`` INTEGER PK; the 3 strings VARCHAR(300) NULL;
the 8 identity colors VARCHAR(7) NULL — NULL = default, B1); no
server defaults anywhere (a missing row means "defaults");
* an inserted id-1 row round-trips its values (the PUT upsert's shape);
* downgrade to 0013 → the table is gone (A13 — reversible), the rest of
the schema (e.g. ``api_tokens.token_hash``) survives;
* upgrade back to 0014 → the table is 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
@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 _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
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, *, app_name: str | None, brand: str | None) -> None:
"""Insert the single row (the PUT upsert's shape) with two values set
and the rest NULL — the NULL = default state the resolver merges."""
db.execute(
text(
"INSERT INTO ui_settings (id, app_name, brand) VALUES (1, :n, :b)"
),
{"n": app_name, "b": brand},
)
db.commit()
def _delete_row(db: Session) -> None:
db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
db.commit()
def test_upgrade_to_0014_adds_ui_settings(db: Session, alembic: Config) -> None:
"""Upgrade 0013 → 0014: the table exists with the full column
contract (the Integer PK, the 3 strings VARCHAR(300) NULL, the 8
colors VARCHAR(7) NULL — no server defaults anywhere: a missing row
means "defaults"); the table is absent at 0013."""
command.downgrade(alembic, "0013") # start from the pre-0014 state
assert _version(db) == "0013"
assert not _table_exists(db, "ui_settings"), "ui_settings must be absent at 0013"
command.upgrade(alembic, "0014")
assert _version(db) == "0014", "alembic_version must be at 0014"
assert _table_exists(db, "ui_settings"), "ui_settings must exist at 0014"
id_col = _column(db, "ui_settings", "id")
assert id_col is not None, "ui_settings.id is missing"
assert id_col[0] == "integer", "ui_settings.id must be INTEGER"
assert id_col[1] == "NO", "ui_settings.id must be NOT NULL (PK)"
for name in ("app_name", "input_placeholder", "footer_text"):
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 (env default, B1)"
assert col[2] is None, f"ui_settings.{name} must have no server default"
assert col[3] == 300, f"ui_settings.{name} must be String(300)"
for name in ("bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink"):
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"
def test_inserted_id_1_row_round_trips_values(db: Session, alembic: Config) -> None:
"""At 0014, the single row (id 1, the PUT upsert's shape) round-trips
its set values verbatim and keeps the unset columns NULL."""
command.upgrade(alembic, "head")
_insert_row(db, app_name="Brain of Testy", brand="#818cf8")
try:
row = db.execute(
text(
"SELECT id, app_name, input_placeholder, footer_text, brand"
" 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] == "Brain of Testy", "app_name must round-trip verbatim"
assert row[2] is None, "input_placeholder must stay NULL (the default)"
assert row[3] is None, "footer_text must stay NULL (the default)"
assert row[4] == "#818cf8", "brand must round-trip verbatim"
finally:
_delete_row(db)
def test_downgrade_to_0013_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0013: the table is gone (A13 — reversible) while the
rest of the schema survives."""
command.downgrade(alembic, "0013")
assert _version(db) == "0013"
assert not _table_exists(db, "ui_settings"), "ui_settings must be dropped"
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"
)
ignore_col = _column(db, "git_sources", "ignore_paths")
assert ignore_col is not None and ignore_col[0] == "jsonb", (
"git_sources.ignore_paths must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0013, then upgrade back to 0014: the table is back
with the column contract intact."""
command.downgrade(alembic, "0013")
command.upgrade(alembic, "0014")
assert _version(db) == "0014", "round-trip upgrade must land at 0014"
assert _table_exists(db, "ui_settings"), "ui_settings must be back"
id_col = _column(db, "ui_settings", "id")
assert id_col is not None and id_col[0] == "integer", (
"id must be INTEGER after the round-trip"
)
brand = _column(db, "ui_settings", "brand")
assert brand is not None and brand[1] == "YES", (
"brand must be VARCHAR NULL after the round-trip"
)
assert brand[3] == 7, "brand must be String(7) after the round-trip"
+52 -2
View File
@@ -30,8 +30,12 @@ from __future__ import annotations
import httpx
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core import theming
from app.core.security_headers import CSP
from app.models import UiSettings
#: The exact owner-approved A1 policy string (phase 82). The constant is
#: the single source of truth; the unit suite additionally pins that the
@@ -54,9 +58,14 @@ def _assert_security_headers(response: httpx.Response) -> None:
)
def test_page_carries_all_three_headers(client: TestClient) -> None:
def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None:
"""``GET /`` (the shell page) — 200 + all three headers, CSP exactly
the A1 string."""
the A1 string. The ``ui_settings`` row is cleared first (phase 91,
task 05: a themed page carries the A1 string EXTENDED with the
style-src hash — the plain-A1 pin is the UNTHAMED page's
contract, and the dev database must not leak a theme into it)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
response = client.get("/")
assert response.status_code == 200
_assert_security_headers(response)
@@ -102,3 +111,44 @@ def test_caching_rewrite_still_runs_under_headers_middleware(client: TestClient)
"the ?v=<token> asset rewrite no longer runs — the outermost "
"security-header middleware altered or swallowed the body"
)
def test_themed_page_carries_a1_plus_style_src_theme_hash(
client: TestClient, db: Session
) -> None:
"""Phase 91 (task 05, defect fix): the A1 CSP would BLOCK the
inline ``<style id="bor-theme">`` pre-paint tag in every real
browser (``style-src`` falls back to ``default-src 'self'``) — so a
THemed HTML page carries the A1 string EXTENDED with
``style-src 'self' 'sha256-<hash>'``, the CSP3 hash of the exact
tag content: the current theme is the only inline style ever
permitted, no blanket ``'unsafe-inline'``, a different palette is
still blocked. The unthemed page keeps the plain A1 string (no
exemption for a tag that is not served). Pinned against the real
app (the unit suite pins the two middleware halves in isolation).
"""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
try:
db.add(UiSettings(id=1, brand="#818cf8"))
db.commit()
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
tag = theming.theme_style_tag(colors)
response = client.get("/")
assert response.status_code == 200
assert tag in response.text # the themed page serves the tag
assert response.headers["content-security-policy"] == (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
assert "unsafe-inline" not in response.headers["content-security-policy"]
# The other two phase-82 headers ride along, unchanged.
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["x-content-type-options"] == "nosniff"
finally:
db.execute(text("DELETE FROM ui_settings"))
db.commit()
# The UNthemed page after the row is gone: plain A1, no tag.
response = client.get("/")
assert response.headers["content-security-policy"] == CSP
assert "bor-theme" not in response.text
+182
View File
@@ -0,0 +1,182 @@
"""Integration: the admin UI-settings gate + the /api/config effective
strings (phase 91, task 01).
The auth + public-contract half of task 01, driven through the real app
(TestClient keeps the cookie jar — the house ``test_auth_api`` /
``test_tokens_api`` admin-login pattern):
* the admin gate — ``GET /api/ui-settings`` and ``PUT`` are 403
``admin only`` for anonymous callers AND for a signed-in token USER
(role ``"user"`` — the router-wide ``require_admin`` closes the
surface on every method, the phase-79 token matrix contract), 200 for
the admin on both;
* ``/api/config`` effective strings (B1: DB-over-env) — an env-only
deployment (no ``ui_settings`` row) returns the env strings; after an
admin PUT, the ANONYMOUS ``/api/config`` returns the DB strings;
* the five-key /api/config contract: the retired CSS-file theming's
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
and the two effective strings are the entire response.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.main import app as fastapi_app
from tests.conftest import ADMIN_PASSWORD
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""Both touched tables are global state: the single ui_settings row
and the api_tokens the token-user test creates (the house
TRUNCATE/DELETE reset pattern)."""
db.execute(text("DELETE FROM ui_settings"))
db.execute(text("TRUNCATE api_tokens"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.execute(text("TRUNCATE api_tokens"))
db.commit()
def _admin_client() -> TestClient:
"""A fresh client signed in as the admin (the ``_admin_client``
pattern from test_auth_api.py)."""
c = TestClient(fastapi_app)
r = c.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return c
def test_anonymous_get_and_put_403(client: TestClient) -> None:
"""Router-level ``require_admin``: both routes are 403 ``admin only``
for the unsigned-in caller (one fixed detail — no enumeration)."""
r = client.get("/api/ui-settings")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.put("/api/ui-settings", json={"app_name": "nope"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_token_user_get_and_put_403(client: TestClient) -> None:
"""A signed-in token USER (role ``"user"``) is NOT the admin: the
Theme tab's surface is closed to them on both methods (the
phase-79 token matrix contract — only the admin themes the
deployment)."""
admin = _admin_client()
r = admin.post("/api/tokens", json={"label": "alice"})
assert r.status_code == 201, r.text
token = r.json()["token"]
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
r = client.get("/api/ui-settings")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.put("/api/ui-settings", json={"brand": "#123456"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
"""The admin passes the gate on both methods: GET reports the
effective defaults (row missing), PUT persists + reports the new
effective values, and a follow-up GET reads them back."""
client.post("/api/login", json={"password": ADMIN_PASSWORD})
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 body["app_name"] == get_settings().app_name
assert {k: body[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
r = client.put(
"/api/ui-settings",
json={"app_name": "Reese Brain", "brand": "#818cf8"},
)
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["brand"] == "#818cf8"
r = client.get("/api/ui-settings")
assert r.status_code == 200
assert r.json()["app_name"] == "Reese Brain"
assert r.json()["brand"] == "#818cf8"
# Untouched fields stay at their defaults (DB-over-env / -built-in).
assert r.json()["footer_text"] == get_settings().footer_text
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
def _config_keys() -> set[str]:
"""The /api/config key set after task 03: the five phase-39/59/62
keys — the retired CSS-file theming's ``theme`` key is gone."""
return {"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text"}
def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None:
"""B1 with an empty ui_settings table: /api/config serves the ENV
strings (the code defaults — conftest pins them) and the key set is
the five-key contract (the retired theming's ``theme`` key is gone
— task 03)."""
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == get_settings().app_name
assert body["input_placeholder"] == get_settings().input_placeholder
assert body["footer_text"] == get_settings().footer_text
def test_api_config_carries_no_theme_key(client: TestClient) -> None:
"""Phase 91 (task 03): the retired CSS-file theming left NO trace
in the endpoint — the response has no ``theme`` key at all (an
env-only deployment and a themed one answer with the same keys; the
colors are injected pre-paint, they never ride this fetch)."""
r = client.get("/api/config")
assert r.status_code == 200
assert "theme" not in r.json()
def test_api_config_returns_db_strings_after_admin_put(
client: TestClient, db: Session
) -> None:
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
(the frontend's boot fetch — no admin needed) serves the DB strings
over the env values; the untouched fields keep the env values; the
five-key set is unchanged (the retired ``theme`` key is absent)."""
admin = _admin_client()
r = admin.put(
"/api/ui-settings",
json={
"app_name": "Brain of Testy",
"input_placeholder": "Ask the vault…",
"footer_text": "Powered by my own models",
},
)
assert r.status_code == 200, r.text
r = client.get("/api/config")
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
body = r.json()
assert set(body) == _config_keys()
assert body["app_name"] == "Brain of Testy"
assert body["input_placeholder"] == "Ask the vault…"
assert body["footer_text"] == "Powered by my own models"
# The colors never ride /api/config (the pre-paint injection is
# task 02; brand.js's surface is the five keys).
assert "theme" not in body
assert body["version"] == get_settings().app_version
+163
View File
@@ -28,12 +28,17 @@ import pytest
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
import app.core.caching as caching
from app.config import Settings
from app.core import theming
from app.core.caching import asset_version, rewrite_asset_refs
from app.core.security_headers import CSP
from app.models import UiSettings
TOKEN = "abc123"
@@ -212,6 +217,7 @@ def test_html_pages_include_history() -> None:
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
@@ -718,3 +724,160 @@ def test_assets_path_keeps_validators_and_304(
assert r304.status_code == 304 # versioned-URL 304s stay safe
assert r304.content == b""
assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL
# ---------------------------------------------------------------------------
# Phase 91 (task 02): the pre-paint inline theme tag
# ---------------------------------------------------------------------------
#
# The middleware's rewrite branch now ALSO builds the theme tag from the
# effective ``ui_settings`` row (task 01's resolver — one short-lived
# session per response, no process cache) and inserts it before the
# first ``</head>``. Unset/defaults → ``tag == ""`` → the served bytes
# are EXACTLY the phase-33/54 rewrite-only output (B4's byte-identical
# contract); a DB blip is the same no-op (the page never breaks).
def _theme_page(name: str) -> str:
"""The ``text/html`` body the fixture routes below serve."""
return (
f"<html><head><title>{name}</title>"
'<link rel="stylesheet" href="/assets/styles.css">'
f"</head><body><main>{name}</main></body></html>"
)
def _theme_page_app() -> FastAPI:
"""A bare app with the middleware: the shell page at ``/`` plus a
second known page (``/document.html``) and the phase-51 dynamic
``/shared/<token>`` route (the prefix branch) — every served
``text/html`` with one versionable asset ref."""
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return _theme_page("index")
@app.get("/document.html", response_class=HTMLResponse)
def document() -> str:
return _theme_page("document")
@app.get("/shared/{token}", response_class=HTMLResponse)
def shared(token: str) -> str:
return _theme_page(f"shared-{token}")
caching.configure_caching(app)
return app
def test_middleware_unset_page_is_byte_identical_to_rewrite_only(db: Session) -> None:
"""THE byte-identical contract (B4): with NO ``ui_settings`` row the
served body is EXACTLY the phase-33/54 rewrite-only output — not a
single byte differs, no ``#bor-theme`` anywhere."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
client = TestClient(_theme_page_app())
token = caching.asset_version()
for path, name in (("/", "index"), ("/document.html", "document")):
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
expected = caching.rewrite_asset_refs(_theme_page(name), token)
assert r.content == expected.encode("utf-8") # byte-identical
assert "bor-theme" not in r.text
# No tag → no style-src exemption: the response carries no CSP
# of its own (this bare app has no security-headers layer), so
# the outer middleware's plain A1 string stands untouched.
assert "content-security-policy" not in r.headers
def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> None:
"""A ``ui_settings`` row with ONE changed color: every HTML page —
``/``, the non-shell ``/document.html``, and the dynamic
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
order and the changed value; the ``?v=`` asset rewrite still applies
alongside."""
db.execute(text("DELETE FROM ui_settings"))
db.add(UiSettings(id=1, brand="#818cf8"))
db.commit()
try:
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8" # one changed color, rest built-in
tag = theming.theme_style_tag(colors)
client = TestClient(_theme_page_app())
token = caching.asset_version()
shared_token = uuid.uuid4().hex
for path in ("/", "/document.html", f"/shared/{shared_token}"):
r = client.get(path)
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
# Exactly one tag …
assert r.text.count('id="bor-theme"') == 1
# … with a leading newline immediately before the first </head>
# (nothing between the tag and the close).
assert "\n" + tag + "</head>" in r.text
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
# All 8 vars, COLOR_FIELDS order, the changed value present.
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
assert declared is not None
names = re.findall(r"--([a-z-]+):", declared.group(1))
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
assert "--brand:#818cf8;" in r.text
# Phase 91 (task 05): the inline tag is blocked by the
# phase-82 CSP in a real browser unless this response also
# carries the style-src exemption — the A1 string plus a
# sha256 hash of the EXACT tag content (the current theme
# is the only inline style ever permitted; no
# 'unsafe-inline').
assert r.headers["content-security-policy"] == (
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
)
assert "unsafe-inline" not in r.headers["content-security-policy"]
# The phase-33/54 asset rewrite is untouched and applies too.
assert f'href="/assets/styles.css?v={token}"' in r.text
finally:
db.execute(text("DELETE FROM ui_settings"))
db.commit()
@pytest.mark.parametrize(
("what",),
[("session",), ("resolver",)],
ids=["session-open-fails", "resolver-fails"],
)
def test_middleware_db_failure_serves_page_without_tag(
monkeypatch: pytest.MonkeyPatch, what: str
) -> None:
"""A DB blip must NEVER break the page (loadHealth house style):
whether the session fails to open or the row read raises, the page
still 200s with the byte-identical rewrite-only body (no tag) and
the no-cache contract intact — a pre-migration boot is the same
path."""
if what == "session":
def _boom_session() -> object:
raise RuntimeError("db down")
monkeypatch.setattr(caching, "SessionLocal", _boom_session)
else:
def _boom_resolver(session: object) -> dict[str, str]:
raise RuntimeError("select failed")
monkeypatch.setattr(caching.theming, "effective_settings", _boom_resolver)
client = TestClient(_theme_page_app())
r = client.get("/")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
token = caching.asset_version()
assert r.content == caching.rewrite_asset_refs(
_theme_page("index"), token
).encode("utf-8")
assert "bor-theme" not in r.text
# The DB-failure fallback is the UNSET shape: no tag, no style-src
# exemption (the plain A1 policy stands — the page degrades to the
# built-in palette, never to an inline-style exemption for a tag
# that is not there).
assert "content-security-policy" not in r.headers
+7 -52
View File
@@ -481,70 +481,25 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
phase-61 copy is the DEFAULT (composer placeholder + footer line),
and an empty theme = the built-in dark-tech palette."""
phase-61 copy is the DEFAULT (composer placeholder + footer line).
Phase 91 (task 03): the retired CSS-file theme env var is gone —
``Settings`` no longer has a theme field at all (a leftover value
in a deployment's .env is ignored, not a boot failure)."""
s = _settings()
assert s.input_placeholder == "Ask me anything…"
assert s.footer_text == "Powered by self-hosted models"
assert s.theme == ""
assert "theme" not in type(s).model_fields
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
"""The three settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
"""The two string settings honor their ``BOR_`` env vars
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
placeholder/footer accept any string (empty is legal — the brand
layer then keeps the template default)."""
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
monkeypatch.setenv("BOR_THEME", "indigo.css")
s = _settings()
assert s.input_placeholder == "Ask the vault…"
assert s.footer_text == "Powered by my own models"
assert s.theme == "indigo.css"
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
assert _settings().input_placeholder == "" # empty stands
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
``.css`` filename (the ``indigo.css`` example) is the only
non-empty shape — dashes/underscores/digits are legal tokens."""
assert _settings().theme == "" # "" passes
assert _settings(theme="indigo.css").theme == "indigo.css"
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
@pytest.mark.parametrize(
("bad", "match"),
[
# uppercase — the shape is lowercase-only
("Indigo.css", "Indigo.css"),
# path escape — a theme is a filename, never a path
("../evil.css", r"\.\./evil\.css"),
("a/b.css", r"a/b\.css"),
("/abs.css", r"got '/abs\.css'"),
# a missing extension is not a theme file
("indigo", r"got 'indigo'"), # must not match the example text
],
)
def test_theme_validator_rejects_malformed_naming_the_value(
bad: str,
match: str,
) -> None:
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
runtime — the rejection names the offending value (the phase-56
fail-loud house style) alongside the allowed shape."""
with pytest.raises(ValidationError, match=match):
_settings(theme=bad)
def test_bor_theme_env_malformed_fails_startup_naming_value(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The startup path: a malformed ``BOR_THEME`` in the environment
fails Settings construction loudly (the app builds its settings at
import time, so this is a refused boot), naming the value — the
E2E boots-check lands in task 05."""
monkeypatch.setenv("BOR_THEME", "../evil.css")
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
_settings()
+13 -25
View File
@@ -99,14 +99,16 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
/api/config answer also drives the three customization keys — the
#message-input placeholder, every .footer-text node, and the theme
stylesheet link (inserted right after the styles.css link, guarded
by #theme-override, degrading with a console.warn on 404 — A5).
/api/config answer also drives the two customization STRING keys —
the #message-input placeholder and every .footer-text node. Phase
91 (task 03): the retired CSS-file theme link (the old step 7)
and its id-guard / styles.css-finder / onerror-degrade machinery
are GONE — color theming is server-side inline injection
(app/core/theming.py), never a brand.js DOM write.
No second network call: the keys ride the existing boot fetch."""
js = _text(BRAND_JS)
assert js.count('= fetch("/api/config"') == 1, (
"the three keys must ride the existing boot fetch — no new call"
"the keys must ride the existing boot fetch — no new call"
)
# 5. The composer placeholder (chat page only — the null guard
# no-ops on every other page).
@@ -116,28 +118,14 @@ def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
# textContent: an operator string can't inject markup.
assert 'document.querySelectorAll(".footer-text")' in js
assert "footer_text" in js
# 7. The theme link: /assets/themes/<name>, inserted right after
# the styles.css link, tagged #theme-override (the idempotency
# guard), with the A5 degradation warn.
assert '"/assets/themes/"' in js
assert 'link.id = "theme-override"' in js
assert 'getElementById("theme-override")' in js
assert 'insertAdjacentElement("afterend", link)' in js
assert "link.onerror" in js
assert '"brand: theme " + themeName' in js
# The styles.css finder must survive the phase-33/54 cache-bust
# rewrite: the SERVED HTML carries the asset ref with a
# ?v=<token> query (and el.href is the absolute URL), so the match
# has to run on the RAW attribute path with query/fragment
# stripped — el.href.endsWith(…) would silently skip the insertion
# (the theme never applied; found by the task-05 E2E).
assert 'getAttribute("href")' in js
assert "split(/[?#]/)[0]" in js
assert 'endsWith("styles.css")' in js
assert "el.href.endsWith" not in js
# Phase 91 (task 03): the retired theme-link machinery is absent —
# no theme key read, no link insertion (the whole step-7 block
# lived inside the themeName guard — themeName gone, block gone).
assert "themeName" not in js
assert 'insertAdjacentElement' not in js
# The empty-skip no-op contract: each key is guarded before any
# DOM write, so an unset deployment stays byte-identical.
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
for guard in ("if (placeholder) {", "if (footerText) {"):
assert guard in js, (
f"an empty value must skip its application ({guard})"
)
+173 -3
View File
@@ -104,8 +104,8 @@ def test_view_map_covers_the_shell_paths() -> None:
"""The VIEW map is pathname → view name: the shell's own two URLs
("/" and "/index.html") are the chat view, plus one entry per
folded view (tasks 01–03: tuning, rag, git-sources, history;
phase 79 task 06: tokens — all five non-chat navbar views are
in)."""
phase 79 task 06: tokens; phase 91 task 04: theme — all six
non-chat navbar views are in)."""
js = _js()
view_start = js.find("const VIEW = {")
assert view_start != -1, "the VIEW map must exist"
@@ -123,8 +123,11 @@ def test_view_map_covers_the_shell_paths() -> None:
assert '"/tokens.html": "tokens"' in view_body, (
"phase 79 task 06 folds the Tokens view into the shell"
)
assert '"/theme.html": "theme"' in view_body, (
"phase 91 task 04 folds the Theme view into the shell"
)
# The view names are the #view-<name> section slugs in index.html.
for name in ("chat", "tuning", "history", "tokens"):
for name in ("chat", "tuning", "history", "tokens", "theme"):
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
@@ -218,6 +221,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
assert 'tokens: () => import("./tokens.js")' in mods_body, (
"the Tokens view module is lazy-imported on first show"
)
assert 'theme: () => import("./theme.js")' in mods_body, (
"the Theme view module is lazy-imported on first show (phase 91)"
)
assert '"chat"' not in mods_body, "the chat view has no lazy module"
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
@@ -275,6 +281,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
assert "Saved chats — every conversation is saved automatically, one click back." in js
assert 'tokens: "Access tokens · Brain of Reese"' in js
assert "Generate and revoke the API tokens that let people use the app." in js
assert 'theme: "Theme · Brain of Reese"' in js
assert (
"Set the palette and branding — the theme is baked into every served page, "
"live on the first paint."
) in js
# The brand composition (phase 39's window.BOR_BRAND, read at
# write time — never a hardcoded stamp).
assert 'window.BOR_BRAND || "Brain of Reese"' in js
@@ -351,6 +362,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
tokens_link = tokens_match.group(0)
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
# The Theme nav link (phase 91 task 04) ships hidden (admin-only)
# and UNstamped too — the router is the single writer of the active
# state, and a token user (role "user") must never see the link
# (header.js reveals it for admin only).
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
assert theme_match, "the shell must carry the #nav-theme nav link"
theme_link = theme_match.group(0)
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
assert "is-active" not in theme_link, "no static active stamp on the Theme link"
# ---------- phase 76 task 04: the header is shell-owned ----------
@@ -869,3 +889,153 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
# The Actions column header is visually-hidden (the row buttons
# carry their own aria-labels — the history-table convention).
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
def test_theme_view_scaffold_in_the_shell() -> None:
"""Phase 91 task 04: the shell carries the #view-theme section —
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
its Sign in returning to the Theme view via ?next=/theme.html) and
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 8 labeled type=color palette inputs
(the 8 identity variables, 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
(role=alert) — all ship hidden. The editor behavior (populate,
live preview, Save/Reset, the contrast warnings) lands in task 05;
this pin keeps the E2E-stable skeleton from drifting."""
html = _html()
view = html.find('<section class="view" id="view-theme"')
assert view != -1, "the #view-theme section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
body = html[view:main_end]
# The gate: the exact #sources-gate pattern (class + ship-hidden +
# its ?next= returning to the Theme view — the no-JS fallback).
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
assert 'class="sources-gate"' in gate.group(0), (
"the gate reuses the .sources-gate visual language"
)
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
assert 'href="/login.html?next=/theme.html"' in body, (
"the gate's Sign in returns to the Theme view (no-JS fallback)"
)
# The content ships hidden (theme.js reveals it for admin only —
# the #git-sources-content pattern).
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
assert content and "hidden" in content.group(0), (
"#theme-content must ship hidden (anonymous-safe)"
)
# The static form skeleton (the E2E-stable-selectors house
# convention): the 3 labeled branding text inputs (maxlength=300)
# and the 8 labeled type=color palette inputs (the 8 identity
# variables — one per theming.COLOR_FIELDS field).
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
"the #theme-form must be STATIC markup in the shell"
)
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
), f"missing the visible label for #{field_id}"
assert re.search(
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
), f"#{field_id} must be a text input with maxlength=300"
for field_id in (
"theme-bg",
"theme-surface",
"theme-ink",
"theme-ink-soft",
"theme-line",
"theme-brand",
"theme-brand-soft",
"theme-brand-ink",
):
assert re.search(
rf'<label[^>]*for="{field_id}"[^>]*>', body
), f"missing the visible label for #{field_id}"
assert re.search(
rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body
), f"#{field_id} must be a type=color input"
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
assert save and 'type="button"' in save.group(0), (
"#theme-save must be a type=button (no real submit)"
)
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
assert reset and 'type="button"' in reset.group(0), (
"#theme-reset must be a type=button (no real submit)"
)
assert "Save theme" in body, "the Save button's label"
assert "Reset to defaults" in body, "the Reset button's label"
# The three task-05 feedback lines, all ship hidden.
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
def test_theme_nav_link_ships_on_every_page_header() -> None:
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
ships on every page (test_nav_consistency pins the header inventory
PARITY across the shell pages, the document viewer, and the login
page), so #nav-theme (ship-hidden, admin-only) must be in the
#app-nav of EVERY header-bearing page: the shell + document.html +
login.html + shared.html. The doc-edit flow page ships the reduced
header (no admin links at all) and is out of the contract."""
for page in (
FRONTEND / "index.html",
FRONTEND / "document.html",
FRONTEND / "login.html",
FRONTEND / "shared.html",
):
text = page.read_text(encoding="utf-8")
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
link = match.group(0)
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
assert "hidden" in link, (
f"{page.name}: #nav-theme ships hidden (admin-only)"
)
assert "is-active" not in link, (
f"{page.name}: no static active stamp on the Theme link"
)
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
the same ship-hidden / reveal-for-admin contract as the other
admin-only links: the two-line reveal (`hidden = !admin`) sits in
initSharedHeader, null-safe (a page without the link is a no-op),
and the gate is the `admin` flag (role === "admin") — a token user
(role "user") never sees the link."""
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
fn = header_js.find("export async function initSharedHeader")
assert fn != -1, "initSharedHeader must exist"
body = header_js[fn:]
lookup = body.find('document.querySelector("#nav-theme")')
assert lookup != -1, "header.js must look up #nav-theme"
reveal = body.find("navTheme.hidden = !admin")
assert 0 <= lookup < reveal, (
"the reveal must be the two-line pattern: null-safe lookup, "
"then hidden = !admin (the admin flag — role === \"admin\")"
)
# The lookup + reveal sit AFTER the whoami resolution (the admin
# flag exists only once fetchWhoami has settled).
whoami = body.find("const whoami = await fetchWhoami()")
admin_flag = body.find('const admin = whoami.role === "admin"')
assert 0 <= whoami < admin_flag < lookup, (
"the reveal keys off the resolved admin flag"
)
+27
View File
@@ -18,6 +18,33 @@ def test_all_tables_registered() -> None:
assert "documents" in tables
assert "chunks" in tables
assert "query_log" in tables
assert "ui_settings" in tables # phase 91: the single-row UI settings
def test_ui_settings_single_row_nullable_contract() -> None:
"""Phase 91: 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 8 identity 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"]
assert set(settings_table.c.keys()) == {
"id", "app_name", "input_placeholder", "footer_text",
"bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink",
}
pk = settings_table.c["id"]
assert pk.primary_key is True, "ui_settings.id must be the PK"
assert pk.default is not None, "id needs the Python-side default=1"
for name in ("app_name", "input_placeholder", "footer_text"):
col = settings_table.c[name]
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",
"brand", "brand_soft", "brand_ink"):
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"
def test_chunks_embedding_is_vector_768() -> None:
+5 -3
View File
@@ -51,11 +51,13 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
s = _settings()
body = app_config(s)
# Phase 62 (task 01): the response grew to the six-key set — the
# phase-62 UI customization keys ride the SAME endpoint.
# Phase 62 (task 01): the response grew to the phase-62 UI
# customization keys; phase 91 (task 03) deleted the retired
# CSS-file theming's ``theme`` key — the five keys below are the
# entire endpoint contract.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
"input_placeholder", "footer_text",
}
assert body["docs_repo_configured"] is s.docs_configured
assert body["docs_repo_configured"] is False
+26
View File
@@ -149,6 +149,32 @@ def test_404_shaped_response_carries_all_three_headers() -> None:
assert body_msg["body"] == b"not found"
def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
"""Phase 91 (task 05): the caching middleware publishes, on themed
HTML pages only, the A1 string EXTENDED with a ``style-src`` sha256
hash for the inline theme tag (the A1 policy would block the tag in
every real browser). A CSP an inner layer has already set is that
layer's deliberate one and must survive the outer middleware —
while the other two headers are still added."""
themed = (
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
)
wrapped = SecurityHeadersMiddleware(
_plain_app(
200,
b"<html></html>",
headers=[[b"content-security-policy", themed.encode("ascii")]],
)
)
sent = _drive(wrapped, _http_scope())
start = sent[0]
assert _header(start, "content-security-policy") == themed # not clobbered
assert _header(start, "x-frame-options") == "DENY"
assert _header(start, "x-content-type-options") == "nosniff"
# ---------------------------------------------------------------------------
# The SSE streaming passthrough pin
# ---------------------------------------------------------------------------
-195
View File
@@ -1,195 +0,0 @@
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
the Containerfile line that ships it (A7).
No Python logic exists for this task — the mechanism lives in
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
drop-in stylesheet. Like the other frontend-adjacent unit files, this
module pins the assets as text, so a silent regression (a theme file
gaining a selector, a declaration drifting, the Containerfile line
vanishing) is caught without a browser. The browser-visible layer
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
``tests/e2e/test_ui_customization.py`` (task 05).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
FRONTEND = REPO_ROOT / "frontend"
THEMES = FRONTEND / "assets" / "themes"
INDIGO = THEMES / "indigo.css"
GUIDE = THEMES / "README.md"
CONTAINERFILE = REPO_ROOT / "Containerfile"
#: The 8 identity variables a theme may override — and the EXACT set
#: indigo.css ships (the semantic families accent/ok/err are states,
#: not identity: a theme that overrides them stops being honest).
IDENTITY_VARS = (
"--bg",
"--surface",
"--ink",
"--ink-soft",
"--line",
"--brand",
"--brand-soft",
"--brand-ink",
)
INDIGO_VALUES: dict[str, str] = {
"--bg": "#0a0e1a",
"--surface": "#111726",
"--ink": "#e6e9f0",
"--ink-soft": "#a8b0c8",
"--line": "#232c44",
"--brand": "#818cf8",
"--brand-soft": "#1a1f38",
"--brand-ink": "#c7d2fe",
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _strip_comments(css: str) -> str:
"""Drop ``/* … */`` comments — the pins assert against declarations,
not prose."""
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
def _declarations(css: str) -> dict[str, str]:
"""The ``--name: value`` declarations of the (single) ``:root``
block, in file order."""
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
def test_example_theme_files_exist() -> None:
"""The example theme and its authoring guide ship in the static
dir (served at /assets/themes/… in dev AND in the image)."""
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
"""The whole file is ONE ``:root`` block (the cascade is the entire
mechanism): after stripping comments the first non-whitespace
content is ``:root``, and no other rule, selector, or declaration
exists anywhere in the file."""
css = _strip_comments(_text(INDIGO))
assert css.lstrip().startswith(":root"), (
"indigo.css must start with the :root block (after its header "
"comment) — nothing may precede it"
)
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
"indigo.css must be exactly one :root block — no selectors, "
"no @media, no nested or extra rules"
)
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
"""EXACTLY the 8 identity overrides with the locked values — no
other declarations (a 9th declaration here would be the theme
reaching past the palette), and the semantic families
(accent/ok/err) must be untouched (they encode states)."""
decls = _declarations(_strip_comments(_text(INDIGO)))
assert set(decls) == set(IDENTITY_VARS), (
f"indigo.css must override exactly the 8 identity variables, got "
f"{sorted(decls)}"
)
for name in IDENTITY_VARS:
assert decls[name].strip() == INDIGO_VALUES[name], (
f"{name} drifted from the locked value "
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
)
for family in ("--accent-", "--ok-", "--err-"):
assert not any(k.startswith(family) for k in decls), (
f"semantic {family}* variables must stay the built-in "
f"theme (they encode states)"
)
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
"""The five identity text/background pairs, computed from the file's
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
(text on brand buttons is --bg, never white — the built-in's
documented 3.7:1 trap), brand-ink on surface."""
decls = _declarations(_strip_comments(_text(INDIGO)))
def lum(hexcolor: str) -> float:
h = hexcolor.lstrip("#")
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
lin = [
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in chans
]
r, g, b = lin
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def ratio(fg: str, bg: str) -> float:
l1, l2 = lum(fg), lum(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
pairs = (
("ink on bg", decls["--ink"], decls["--bg"]),
("ink on surface", decls["--ink"], decls["--surface"]),
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
("bg ink on brand", decls["--bg"], decls["--brand"]),
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
)
for name, fg, bg in pairs:
r = ratio(fg, bg)
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
def test_containerfile_ships_the_whole_themes_directory() -> None:
"""A7: stage 1 copies the WHOLE themes directory (no per-file
esbuild — a future theme file needs no Containerfile edit), and it
does so AFTER the styles.css minify line (so the served /assets/
tree is complete before the pages cp)."""
cf = _text(CONTAINERFILE)
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
lines = stage1.splitlines()
cp_idxs = [
i
for i, ln in enumerate(lines)
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
]
assert len(cp_idxs) == 1, (
"stage 1 must ship the themes directory with exactly one "
"'cp -r ./assets/themes /out/assets/themes' line"
)
styles_idxs = [
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
]
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
assert cp_idxs[0] > styles_idxs[0], (
"the themes cp must come AFTER the styles.css minify line"
)
cp_line = lines[cp_idxs[0]]
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
"A7: the themes directory is copied verbatim — no per-file "
"esbuild minify"
)
def test_authoring_guide_pins_the_contract() -> None:
"""The guide documents the load path (BOR_THEME → /api/config →
brand.js link after styles.css), the filename validator regex, the
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
and the A7 rebuild story (a new file needs no Containerfile edit)."""
guide = _text(GUIDE)
for marker in (
"BOR_THEME",
"/api/config",
"styles.css",
r"^[a-z0-9_-]+\.css$",
"4.5:1",
"white-on-brand",
"cp -r ./assets/themes /out/assets/themes",
):
assert marker in guide, f"themes/README.md must document {marker!r}"
for var in IDENTITY_VARS:
assert var in guide, f"the variable table must list {var}"
+310
View File
@@ -0,0 +1,310 @@
"""Unit: the built-in identity palette + the effective-settings resolver
(phase 91, task 01).
Covers ``app/core/theming.py`` — the single source of the built-in
identity palette (re-homed from the retired phase-62 CSS-file themes'
authoring guide before task 03 deleted it) and the DB-over-env /
DB-over-built-in resolver shared by ``/api/ui-settings`` and
``/api/config``:
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 built-ins must equal the
values parsed straight out of ``frontend/assets/styles.css``'s
``:root`` block, so the Python palette and the stylesheet can never
silently diverge;
* ``theme_style_tag`` — the byte-identical contract (all built-in →
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
order, lowercased hex);
* ``effective_settings`` — missing row → env strings + built-ins; a DB
row's set columns win; an empty-string DB string falls back to env
(the resolver treats "" as unset, B1). House DB-test pattern (the
``test_tokens`` precedent): the real compose Postgres, skipped with
clear instructions when the stack is not up.
"""
from __future__ import annotations
import base64
import hashlib
import re
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.core import theming
from app.models import UiSettings
REPO_ROOT = Path(__file__).resolve().parents[2]
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
def _delete_row() -> Any:
from sqlalchemy import delete
return delete(UiSettings).where(UiSettings.id == 1)
def _root_declarations() -> dict[str, str]:
"""The ``--name: value`` declarations of styles.css's (first)
``:root`` block, comments stripped, in file order."""
css = STYLES_CSS.read_text(encoding="utf-8")
match = re.search(r":root\s*\{", css)
assert match is not None, "styles.css must have a :root block"
block = css[match.end() : css.index("}", match.end())]
block = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
def test_builtin_colors_match_styles_css_root() -> None:
"""The drift guard: every built-in equals the stylesheet's ``:root``
value for the same variable (and ``BUILTIN_COLORS`` names exactly
the 8 identity variables — no more, no fewer)."""
decls = _root_declarations()
builtin_names = set(theming.BUILTIN_COLORS)
assert builtin_names == {
"bg", "surface", "ink", "ink_soft", "line",
"brand", "brand_soft", "brand_ink",
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
for name, value in theming.BUILTIN_COLORS.items():
css_name = f"--{name.replace('_', '-')}"
assert css_name in decls, f"styles.css :root is missing {css_name}"
assert decls[css_name].strip() == value, (
f"{css_name} drifted: BUILTIN_COLORS has {value!r}, "
f"styles.css has {decls[css_name].strip()!r}"
)
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
order the resolver, the API, and the tag renderer all rely on."""
assert theming.COLOR_FIELDS == (
"bg", "surface", "ink", "ink_soft",
"line", "brand", "brand_soft", "brand_ink",
)
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
def _env_settings() -> Settings:
"""An explicit env source (the resolver's optional ``settings``
parameter) — deterministic values, independent of the local ``.env``
(the ``/api/config`` env pins in test_api.py own the env-file
behaviour; this unit module only needs stable fallbacks)."""
return Settings(
app_name="Env Name",
input_placeholder="Env placeholder…",
footer_text="Env footer",
)
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
"""A missing row (GET creates nothing) means "defaults": the env
strings + the built-in palette, all 11 keys."""
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
assert row is None, "the test starts from a row-missing state"
effective = theming.effective_settings(db, _env_settings())
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert effective["app_name"] == "Env Name"
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
def test_effective_db_row_wins_column_by_column(db: Session) -> None:
"""Set columns win, unset columns fall back — per column, so a
partial row (only ``bg`` set) mixes the DB color with the built-ins
and the env strings."""
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["bg"] == "#111111" # DB wins
assert effective["app_name"] == "DB Name" # DB wins
# Unset columns: env strings + the built-in colors.
assert effective["input_placeholder"] == "Env placeholder…"
assert effective["footer_text"] == "Env footer"
for key in theming.COLOR_FIELDS:
if key != "bg":
assert effective[key] == theming.BUILTIN_COLORS[key]
finally:
db.execute(_delete_row())
db.commit()
def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None:
"""B1: an EMPTY string in the DB is unset — the resolver falls back
to the env value (a hand-edited row with '' can't blank the UI).
Colors: ``None`` → the built-in (an empty color is impossible through
the API — the hex validator — the resolver's not-None rule covers
the hand-edited edge by returning whatever the row holds)."""
db.add(UiSettings(id=1, app_name=""))
db.commit()
try:
effective = theming.effective_settings(db, _env_settings())
assert effective["app_name"] == "Env Name" # "" → env fallback
assert effective["footer_text"] == "Env footer"
assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in
finally:
db.execute(_delete_row())
db.commit()
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
"""``settings=None`` (the design's call shape) resolves the env
fallback from the cached :func:`app.config.get_settings` — the
values it reports must be real ``str``s for all 11 keys."""
from app.config import get_settings
effective = theming.effective_settings(db)
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
assert effective["app_name"] == get_settings().app_name
for field in theming.STRING_FIELDS:
assert isinstance(effective[field], str) and effective[field]
assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS)
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
# ---------- theme_style_tag (pure) ----------
def test_theme_style_tag_all_builtins_is_empty_string() -> None:
"""The byte-identical contract: an unset (or "defaults saved")
deployment serves NO tag — exactly the pre-phase-91 HTML."""
assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == ""
# The tag is PURE string-equality: uppercase hex is NOT the built-in
# (the API's lowercasing-before-store is what makes stored hex
# canonical — pin the renderer's own contract here).
colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()}
assert theming.theme_style_tag(colors) != ""
def test_theme_style_tag_one_changed_carries_all_eight_in_order() -> None:
"""A single non-built-in color still emits ALL 8 variables, in
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
tag = theming.theme_style_tag(colors)
assert tag == (
'<style id="bor-theme">:root{'
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"}</style>"
)
# The changed value lands under the dashed CSS name…
assert "--brand:#818cf8;" in tag
# …and the underscored field (ink_soft) renders as --ink-soft.
assert "--ink-soft:#b8a8a8;" in tag
assert "--ink_soft" not in tag
def test_theme_style_tag_multiple_changed() -> None:
"""Two changed colors: both values present, the rest built-in, order
unchanged (the tag is a complete :root override — the page never
mixes a partial palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["bg"] = "#0a0e1a"
colors["brand_ink"] = "#c7d2fe"
tag = theming.theme_style_tag(colors)
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
assert "--brand-ink:#c7d2fe;" in tag
assert tag.endswith("}</style>")
# The order of the 8 dashed names is the COLOR_FIELDS order.
names = re.findall(r"--([a-z-]+):", tag)
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
# ---------- inject_theme (pure — task 02's injection helper) ----------
_HEAD_HTML = "<html><head><title>t</title></head><body><p>b</p></body></html>"
def test_inject_theme_empty_tag_is_identity() -> None:
"""``tag == ""`` (the unset / "defaults saved" deployment — what
``theme_style_tag" returns for all-built-in colors) → the html is
returned EXACTLY as passed in, byte for byte (B4's no-op
contract)."""
assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML
# Whitespace is NOT an empty tag — a real tag is always inserted.
assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML
def test_inject_theme_missing_head_is_identity() -> None:
"""No ``</head>`` occurrence → unchanged (nothing to anchor to);
the empty string (no ``</head>`` either) is identity too."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><body>no head</body></html>"
assert theming.inject_theme(html, tag) == html
assert theming.inject_theme("", tag) == ""
def test_inject_theme_exact_placement_before_first_head_close() -> None:
"""The tag lands with a leading newline immediately BEFORE the
first ``</head>`` — nothing between the tag and the close, nothing
moved after it."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
html = "<html><head><title>t</title></head><body>after</body></html>"
assert theming.inject_theme(html, tag) == (
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
)
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
# FIRST occurrence wins (the one that closes the real head).
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
assert theming.inject_theme(html2, tag) == (
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
)
def test_inject_theme_double_injection_is_idempotent() -> None:
"""The defensive idempotence rule keys on the id: once a
``id="bor-theme"`` tag is present the helper is the identity — the
page can never carry two theme tags, even for a different tag."""
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
once = theming.inject_theme(_HEAD_HTML, tag)
assert once.count('id="bor-theme"') == 1
assert theming.inject_theme(once, tag) == once
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
assert theming.inject_theme(once, other) == once
# ---------------------------------------------------------------------------
# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the
# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag
# in every real browser; the caching middleware publishes the hash as a
# style-src exemption on themed HTML pages only).
# ---------------------------------------------------------------------------
def test_theme_csp_hash_empty_tag_is_empty_string() -> None:
"""No tag (unset/defaults deployment) → no hash — the plain A1
policy stands and the header stays byte-identical to pre-91."""
assert theming.theme_csp_hash("") == ""
def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None:
"""CSP3 §13.4: the hash covers the character data BETWEEN the tags
(the ``:root{…}`` declarations — the rendered content carries no
leading/trailing whitespace, so no stripping applies), base64
after SHA-256, ``sha256-`` prefixed."""
tag = '<style id="bor-theme">:root{--bg:#111111}</style>'
expected = "sha256-" + base64.b64encode(
hashlib.sha256(b":root{--bg:#111111}").digest()
).decode("ascii")
assert theming.theme_csp_hash(tag) == expected
def test_theme_csp_hash_changes_with_the_palette() -> None:
"""A different palette → a different hash: the browser keeps
blocking the OLD tag once the theme changes (the exemption always
matches exactly the served bytes, never a stale palette)."""
colors = dict(theming.BUILTIN_COLORS)
colors["brand"] = "#818cf8"
first = theming.theme_csp_hash(theming.theme_style_tag(colors))
colors["brand"] = "#22c55e"
second = theming.theme_csp_hash(theming.theme_style_tag(colors))
assert first
assert first != second
assert first.startswith("sha256-")
assert second.startswith("sha256-")
+193
View File
@@ -0,0 +1,193 @@
"""Unit: the admin UI-settings API (phase 91, task 01).
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
contract and the GET/PUT persistence on the single ``ui_settings`` row:
* PUT validation — the 422s NAME the offending field (fixed details):
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
prefix, 3-digit shorthand, 8 hex chars, missing ``#``);
* normalization — colors are lowercased on store; a color EQUAL to its
built-in is stored as NULL (the owner-locked rule: "save the defaults"
must leave the row empty — the no-op injection contract); an empty /
whitespace-only string is the clear operation (NULL);
* GET — the effective merge (a partial row reports the DB values over
the env/built-in defaults);
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
same row (one row, always id 1).
House pattern (the ``test_tokens_api`` precedent): the real app via
TestClient (cookie jar = the house admin-login fixture) against the real
compose Postgres; the single row is global state, so an autouse fixture
resets it around every test.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.models import UiSettings
ALL_NULL_BODY: dict[str, str | None] = {
"app_name": None, "input_placeholder": None, "footer_text": None,
"bg": None, "surface": None, "ink": None, "ink_soft": None,
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
}
@pytest.fixture(autouse=True)
def clean_ui_settings(db: Session) -> Iterator[None]:
"""ui_settings holds ONE row of global state: reset it around every
test (the ``clean_tokens`` house pattern, DELETE — the row is
created only by the PUT upsert, so "absent" is the natural
pristine state)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def _row(db: Session) -> UiSettings | None:
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
def test_put_too_long_string_422_names_the_field(
admin_client: TestClient, db: Session
) -> None:
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
that field; a rejected PUT half-writes nothing; exactly 300 still
passes (the column is VARCHAR(300))."""
for field in theming.STRING_FIELDS:
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
assert r.status_code == 422, (field, r.text)
assert r.json()["detail"] == f"{field} is too long (max 300)"
# A whitespace-padded 301 is still 301 after the trim…
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
assert r.status_code == 422, (field, r.text)
# No rejected PUT created the row — the upsert runs after validation.
assert _row(db) is None
# Exactly 300 passes — stored, trimmed.
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
assert r.status_code == 200, r.text
assert r.json()["app_name"] == "y" * 300
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
"""Each of the 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
without ``#``, a named color, and the empty string (the color clear
operation is ``null``, not ``""``)."""
for field in theming.COLOR_FIELDS:
for bad in ("fff", "#ff", "ff00aa", "#12345678", "red"):
r = admin_client.put("/api/ui-settings", json={field: bad})
assert r.status_code == 422, (field, bad, r.text)
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
def test_put_lowercases_colors_on_store(
admin_client: TestClient, db: Session
) -> None:
"""Uppercase hex passes the validator and is stored LOWERCASE — the
canonical form the tag renderer and the drift comparison rely on."""
r = admin_client.put("/api/ui-settings", json={"brand": "#818CF8"})
assert r.status_code == 200, r.text
assert r.json()["brand"] == "#818cf8"
row = _row(db)
assert row is not None
assert row.brand == "#818cf8" # the stored column, not just the response
def test_put_built_in_color_is_stored_as_null(
admin_client: TestClient, db: Session
) -> None:
"""The owner-locked normalization: a color equal to its built-in is
stored as NULL — PUTting the whole built-in palette (with one value
in uppercase, proving the compare happens AFTER the lowercase)
leaves the row COMPLETELY empty: "save the defaults" must keep an
unset deployment byte-identical (the no-op injection contract)."""
body = dict(ALL_NULL_BODY)
for key, value in theming.BUILTIN_COLORS.items():
body[key] = value.upper() if key == "brand" else value
r = admin_client.put("/api/ui-settings", json=body)
assert r.status_code == 200, r.text
# The response is the effective values — still the built-ins…
for key in theming.COLOR_FIELDS:
assert r.json()[key] == theming.BUILTIN_COLORS[key]
# …and the row itself is empty (the upsert created a row of NULLs).
row = _row(db)
assert row is not None, "the PUT upsert creates the id-1 row"
assert row.id == 1
for field in (*theming.STRING_FIELDS, *theming.COLOR_FIELDS):
assert getattr(row, field) is None, f"{field} must be stored as NULL"
def test_put_empty_string_is_the_clear_operation(
admin_client: TestClient, db: Session
) -> None:
"""A whitespace-only (or empty) string trims to empty → NULL — the
clear operation, not a 422 and not a stored blank: the effective
value falls back to the env default."""
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
assert r.status_code == 200, r.text
row = _row(db)
assert row is not None
assert row.app_name is None
assert row.footer_text is None
# The response reports the effective (env) fallback, not "".
assert r.json()["app_name"] == get_settings().app_name
assert r.json()["footer_text"] == get_settings().footer_text
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
"""GET reports the DB values over the defaults, column by column: a
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
built-ins and the env strings — all 11 keys, no nulls."""
db.add(UiSettings(id=1, bg="#123456"))
db.commit()
r = admin_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 body["bg"] == "#123456" # the DB value wins
for key in theming.COLOR_FIELDS:
if key != "bg":
assert body[key] == theming.BUILTIN_COLORS[key]
assert body["app_name"] == get_settings().app_name
assert body["input_placeholder"] == get_settings().input_placeholder
assert body["footer_text"] == get_settings().footer_text
def test_upsert_creates_then_updates_the_id_1_row(
admin_client: TestClient, db: Session
) -> None:
"""The first PUT creates the id-1 row; the second updates the SAME
row (still exactly one row, still id 1 — the single-row contract)."""
r1 = admin_client.put(
"/api/ui-settings", json={"brand": "#123abc", "app_name": "First"}
)
assert r1.status_code == 200, r1.text
row = _row(db)
assert row is not None and row.id == 1
assert row.brand == "#123abc"
assert row.app_name == "First"
r2 = admin_client.put(
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
)
assert r2.status_code == 200, r2.text
assert r2.json()["brand"] == "#abcdef"
assert r2.json()["input_placeholder"] == "Second"
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
rows = db.execute(select(UiSettings)).scalars().all()
assert len(rows) == 1, "the upsert must never create a second row"
assert rows[0].id == 1
assert rows[0].brand == "#abcdef" # updated, not appended
assert rows[0].input_placeholder == "Second" # the new string landed
assert rows[0].app_name is None # absent in the second body → NULL
+10 -7
View File
@@ -168,20 +168,23 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27) and
literal max-width: 46rem: .tuning-shell (phase 27),
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form). Every reading column rides the token (the
--chat-column base declaration is the other non-rule occurrence
of 46rem)."""
stretch the form), and .theme-shell (phase 91 task 04 — the
admin Theme editor is a form column too: the palette grid +
fieldsets must never ride the wide-desktop doubling). Every
reading column rides the token (the --chat-column base
declaration is the other non-rule occurrence of 46rem)."""
css = _css()
assert css.count("max-width: 46rem") == 2, (
"only the form columns (.tuning-shell, .doc-edit-shell) may "
"keep a literal max-width: 46rem"
assert css.count("max-width: 46rem") == 3, (
"only the form columns (.tuning-shell, .doc-edit-shell, "
".theme-shell) may keep a literal max-width: 46rem"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None: