phase: 91_admin_theme_tab
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:
@@ -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`
|
||||
+92
@@ -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
|
||||
+18
@@ -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`
|
||||
+92
@@ -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
|
||||
+12
@@ -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`
|
||||
+92
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +82,12 @@ class SecurityHeadersMiddleware:
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
headers["Content-Security-Policy"] = CSP
|
||||
# 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"
|
||||
await send(message)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/…`.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"])
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}"
|
||||
@@ -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-")
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user