phase: 177_themed_favicon
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 12s

Everything is verified. Final report:

## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)

**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.

**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed

**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).

**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.

**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.

**Next pending phase:** none — 177 was the last; TODO.md is cleared.
This commit is contained in:
2026-10-11 15:33:39 -04:00
parent dd119d534a
commit 36eddce672
40 changed files with 2351 additions and 16 deletions
@@ -0,0 +1,20 @@
Everything is verified. Final report:
## Phase 177 — Themed favicon: completion pass (all tasks pre-done, verified end-to-end)
**Verified (no code fixes needed — implementation matched the design D1–D4 exactly):**
- `theming.favicon_svg()` pure renderer + golden byte-identical pin; `GET/HEAD /favicon.svg` route (no-cache, `image/svg+xml`, built-in 200 fallback on DB blip, routes-first before static mount); 5 templates → bare `href="/favicon.svg"`, static file deleted (Containerfile cp removed); Theme-tab `data:` live preview with 4 restore points; JS↔server mirror pins.
**Tests / lint / coverage (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 4161 passed, `app/` 99% (>90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) → 6 passed
- `test_theme_save_and_coverage.py` (92) + `test_theme_semantic_completion.py` (93), isolated → 3 + 8 passed
**Completion criteria:** all met — default golden bytes/headers; themed roles + `#bor-theme` non-regression; DB-failure 200 fallback; 5 bare links + `/assets/favicon.svg` 404 + browser rendering; live-preview data: repaint + Save/Reset/re-show restore; gates green; E2E suites green in isolation. Commit: harness commits atomically (overrides AGENTS rule 8 — no git commands run).
**Documentation:** README.md verified consistent in full (the phase's favicon paragraph is accurate; all commands, env vars, routes, ports, scripts cross-checked) — no changes needed. AGENTS.md verified consistent — no changes needed.
**Notable:** `image-1.png` (owner's TODO screenshot, referenced by phase 176's overview) sits untracked at the repo root — pre-existing, explicitly out of scope per the phase-176 report; left untouched.
**Next pending phase:** none — 177 was the last; TODO.md is cleared.
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6467 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,26 @@
Everything is complete and verified. Final status against the task's completion criteria:
- ✅ `tests/unit/test_theming.py` green (25 tests: 22 existing + 3 new pins)
- ✅ One-time golden-vs-file sanity check: PASS (logged — 341 bytes, byte-identical)
- ✅ Full suite green (4141 passed), `app/` coverage 99% (>90%), `theming.py` 100%
- ✅ ruff + pyright clean
- ✅ No behavior change in completed work (new function, zero existing call sites)
## Report
**Implemented (task 01, A1):**
- `app/core/theming.py`: `favicon_svg(colors) -> str` — pure palette→artwork renderer, same art as the static favicon (hexagon + dot + 4 spokes, `viewBox 0 0 64 64`, all geometry/widths/caps unchanged), only the three A1 color roles swapped (hexagon fill→`surface`, hexagon stroke + dot→`brand`, spokes→`brand_ink`); docstring carries the role table, byte-identical contract, and the "no state colors" note; placed next to `theme_style_tag()`
- `tests/unit/test_theming.py`: three new pins — golden byte-identical (`FAVICON_GOLDEN` = the static file's 341-byte `<svg>` element, no comment header), themed three-roles (asserted via `ElementTree` attribute parsing + none of the other 14 built-in hexes present), geometry invariance (mask-the-4-color-attrs comparison, exactly 4 masked)
- Docstring module header extended with the Phase 177 entry
**Verification (exact commands):**
- `uv run pytest tests/unit/test_theming.py -v` → 25 passed
- One-time sanity (logged): `FAVICON_GOLDEN == file element == favicon_svg(BUILTIN_COLORS)` → True (341 bytes)
- `uv run pytest --cov=app --cov-report=term-missing` → 4141 passed, TOTAL 99% (`app/core/theming.py` 100%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
**Docs:** no doc impact — the task adds a pure function with no user-visible surface (no route/env/commands yet); README/AGENTS.md carry no favicon references that go stale (the static file stays until task 03).
**Decisions:** none — followed D1 exactly; the golden constant is the durable contract per the phase (file deleted in task 03).
**Next pending task:** `02_favicon_route.md` — `GET /favicon.svg` (+ HEAD) in `create_app()` (A2/A3).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
..................................... [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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 91 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6453 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
Everything is green. Final verification of the task's completion criteria is done — all four states plus the routes-first pin are covered by the 7 integration tests, the full suite passes, and the docs are updated.
## Report
**Implemented**
- `app/main.py`: `_favicon()` sync handler + `app.api_route("/favicon.svg", methods=["GET","HEAD"], include_in_schema=False)` registered after the router includes, before the static catch-all mount (routes-first). Per request: short-lived `SessionLocal()` → `theming.effective_settings()` → `theming.favicon_svg()`; ANY exception → `logger.exception` + built-in fallback (200, never 500). Response: `image/svg+xml` + `Cache-Control: no-cache`. Commented rationale: outside `/api/*` (no byte-budget line, no auth — chrome like the login page), `CachingMiddleware` passes it untouched (not in `HTML_PAGES`, not under `/assets/` → no `?v=` rewrite), CSP covers task 04's `data:` preview.
- `tests/integration/test_favicon_route.py`: 7 tests — default state (200 + golden bytes via `FAVICON_GOLDEN` imported from `tests/unit/test_theming.py` + CSP header with the `img-src 'self' data:` carve-out), themed state (3 hexes in exact roles, parsed via ET — unit-pin idiom; none of the other 14 built-ins), no-process-cache (row update visible on the next request, old hexes gone), DB-failure (patched `effective_settings` raising → 200 + golden + logged fallback line), HEAD parity (same headers, empty body), routes-first (a `favicon.svg` with shadow bytes in the static dir cannot shadow the route), public/no-auth.
**Gates (all green)**
- `uv run pytest tests/integration/test_favicon_route.py -v` → 7 passed
- `uv run pytest --cov=app --cov-report=term-missing` → 4148 passed, `app/` **99%** (>90% ✓), `app/main.py` 100%
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
**Docs**: README "Customizing the look" — one paragraph on `GET /favicon.svg` (effective palette per request, no restart; built-in art on DB blip). AGENTS.md: no changes needed (no new conventions/env/commands).
**Decisions**: patched `theming.effective_settings` (module attribute the handler calls) for the DB-blip test — exercises the exact handler branch; shadow-test creates/deletes `frontend/favicon.svg` with `try/finally` cleanup. No live-infrastructure changes (repo-side only). `/assets/favicon.svg` still serves (task 03 deletes it).
**Next pending task**: `03_template_link_swap.md`
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6467 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,13 @@
Task 03 complete — everything is green.
**Report**
- **Implemented**
- All five templates (`index.html`, `document.html`, `login.html`, `shared.html`, `doc-edit.html`) L8 link swapped to `<link rel="icon" type="image/svg+xml" href="/favicon.svg">` — nothing else touched in any template
- `frontend/assets/favicon.svg` deleted (route is now the single source; bytes stay pinned by task 01's `FAVICON_GOLDEN`)
- `tests/integration/test_favicon_route.py` extended with the task-03 pins: 5 parametrized served-page tests (exact bare link present, no `/assets/favicon.svg`, no `?v=` against regex drift), deleted-file test (on-disk gone + `GET /assets/favicon.svg` 404 + `GET /favicon.svg` still golden), `frontend/` tree scan (house `rglob` idiom) asserting zero `/assets/favicon.svg` references
- **Defect fixed:** the `Containerfile` stage-1 `cp ./assets/favicon.svg` would have broken the production image build once the file was deleted — removed the line (the hermetic `test_containerfile_assets.py` pins stay green)
- **Tests/lint/coverage:** `uv run pytest tests/integration/test_favicon_route.py -v` → 14 passed; `uv run pytest --cov=app --cov-report=term-missing` → 4155 passed, `app/` 99%; `uv run ruff check .` clean; `uv run pyright` 0 errors; targeted `test_containerfile_assets`/`test_caching_revalidation`/`test_api` → 66 passed
- **Docs:** no changes needed — README's "Customizing the look" (task 02) already describes the route and carries no static-file reference; AGENTS.md has no favicon mention
- **Decisions:** Containerfile `cp` removal is a direct consequence of the single-source deletion (no phase-design conflict — it's not in the "NOT touched" list and leaving it broke the build); live browser proof deferred to task 04's E2E per the task file; no live-infrastructure changes
- **Next pending task:** `.agents/phases/todo/177_themed_favicon/04_theme_tab_live_preview.md`
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6467 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,18 @@
**Report — Phase 177, Task 04 (theme-tab live preview + dedicated E2E + commit)**
**Implemented**
- `theme.js`: pure `themeFaviconDataUri()` (client-side mirror of `theming.favicon_svg`, `isHex` guard → `null` leaves the link untouched), `FAVICON_DRIVERS` gate in the per-color `input` binding (rebuilt from the three inputs' current values; the other 14 picks never touch the link), `restoreFavicon()` → `"/favicon.svg"` wired to all four settle points (Save success, Reset success, Reset-failure `clearPreview()` path, `bor:view-refresh` re-show); CSP (phase-123 carve-out) + mechanism documented in the file-header docstring
- `tests/unit/test_theme_frontend.py`: 7 new pins (mechanism, guard, exactly-three driving set, mirror artwork constants vs server source, exact `/favicon.svg` restore, four-point wiring + count pin)
- `tests/e2e/test_themed_favicon.py`: new isolated Playwright suite — all six contract blocks + zero-external/CSP guard, following the phase-92 fixture idiom
**Results**
- `uv run pytest tests/unit/test_theme_frontend.py -v` — 13 passed
- `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` (isolated) — 6/6 passed
- `test_theme_save_and_coverage.py` — 3/3; `test_theme_semantic_completion.py` — 8/8 (isolated, no regression)
- `uv run pytest --cov=app` — 4161 passed, `app/` 99% (>90%); `uv run ruff check . && uv run pyright` — clean
**Docs** — README.md: one sentence added to "Customizing the look" (live picks repaint the favicon via `data:` URI; Save/Reset/re-show restore the route URL). AGENTS.md: no impact.
**Decisions/deviations** — headless Chromium never fetches favicons (probe-verified: zero request events, not even the initial `/favicon.svg`), so the CSP-legality proof is the negative guard (no failed `data:` request, no CSP console violation) plus the href state + decoded-art assertions. Commit deferred to the harness (one atomic per phase, per protocol).
**Next pending task** — none; task 04 is the phase's final task (phase 177 complete).
@@ -0,0 +1,136 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 288 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
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 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6467 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
-1
View File
@@ -24,7 +24,6 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./assets/favicon.svg /out/assets/favicon.svg \
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/ && cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
# ---------- Stage 2: python dependencies ---------- # ---------- Stage 2: python dependencies ----------
+9 -1
View File
@@ -1275,7 +1275,15 @@ admin-only — phase 91): the 17 palette colors (the 9 identity colors plus
the 8 semantic state colors — success/error/accent families) and the the 8 semantic state colors — success/error/accent families) and the
three strings above are edited with text fields and color pickers, three strings above are edited with text fields and color pickers,
persisted in the `ui_settings` table, and injected into every served page 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 as an inline `<style>` BEFORE first paint (no red flash, no pop-in). The
browser-tab **favicon** follows the same effective palette: `GET
/favicon.svg` renders the hexagon art from the saved colors on every
request (no process cache — a theme change is visible on the next
request, no restart) and degrades to the built-in art with 200 on a
DB blip (never 500). In the Theme tab, a live (unsaved) pick of
`surface` / `brand` / `brand_ink` repaints the favicon in real time
(a CSP-legal `data:` URI), and Save / Reset / re-show restore the
`/favicon.svg` route — the settled palette always wins. The old
CSS-file theming is retired — a leftover `BOR_THEME` line in a CSS-file theming is retired — a leftover `BOR_THEME` line in a
deployment's `.env` is simply ignored. Leave everything unset and the deployment's `.env` is simply ignored. Leave everything unset and the
app renders the built-in dark-tech palette byte-identically. app renders the built-in dark-tech palette byte-identically.
+49
View File
@@ -177,6 +177,55 @@ def effective_settings(
return effective return effective
def favicon_svg(colors: dict[str, str]) -> str:
"""Render the browser-tab favicon artwork from the effective palette.
Phase 177 (task 01, A1 — owner-confirmed): the pure half of the
themed favicon. ``GET /favicon.svg`` (task 02) serves this, and
``frontend/assets/theme.js``'s ``themeFaviconDataUri`` (task 04)
is the client-side mirror — the two must NEVER diverge (the
unit pins here keep them aligned). The artwork is the SAME art as
the pre-phase static ``frontend/assets/favicon.svg`` (a hexagon +
center dot + 4 spokes, ``viewBox 0 0 64 64``); only the THREE
color roles swap, per the A1 mapping:
=========================== ===================
SVG attribute Palette role
=========================== ===================
hexagon path ``fill`` ``surface``
hexagon path ``stroke`` ``brand``
center circle ``fill`` ``brand``
spokes path ``stroke`` ``brand_ink``
=========================== ===================
**Byte-identical contract:** for the built-in palette (the three
role keys carrying their :data:`BUILTIN_COLORS` values) the output
is EXACTLY the static file's ``<svg>`` element — lowercase hex,
same attribute order (pinned by the golden constant in
``tests/unit/test_theming.py``; the static file's comment header
is file packaging, not art, and is NOT part of the contract).
The input is the full 20-value effective dict (mirroring
:func:`theme_style_tag`'s self-contained signature); the function
reads its three keys. Pure: dict in → str out (no DB, no app, no
defensive branches — the :func:`effective_settings` /
:data:`BUILTIN_COLORS` contract guarantees the values are
canonical lowercase 6-hex).
**No state colors:** the 8 semantic state colors are NOT in the
artwork — a favicon has no state meaning (the state families
encode *states* on the page; the tab icon is identity only).
"""
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
f'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="{colors["surface"]}"'
f' stroke="{colors["brand"]}" stroke-width="4" stroke-linejoin="round"/>'
f'<circle cx="32" cy="32" r="6.5" fill="{colors["brand"]}"/>'
f'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5"'
f' stroke="{colors["brand_ink"]}" stroke-width="3" stroke-linecap="round"/>'
"</svg>"
)
def theme_style_tag(colors: dict[str, str]) -> str: def theme_style_tag(colors: dict[str, str]) -> str:
"""The pre-paint inline theme tag (task 02's injection input). """The pre-paint inline theme tag (task 02's injection input).
+65 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import FileResponse from starlette.responses import FileResponse, Response
from app.api.admin_settings import router as admin_settings_router from app.api.admin_settings import router as admin_settings_router
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
@@ -45,6 +45,7 @@ from app.api.sync import router as sync_router
from app.api.tokens import router as tokens_router from app.api.tokens import router as tokens_router
from app.api.ui_settings import router as ui_settings_router from app.api.ui_settings import router as ui_settings_router
from app.config import get_settings, invalidate_settings from app.config import get_settings, invalidate_settings
from app.core import theming
from app.core.auth import ensure_admin_configured from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching from app.core.caching import configure_caching
from app.core.compression import SelectiveGZipMiddleware from app.core.compression import SelectiveGZipMiddleware
@@ -55,6 +56,7 @@ from app.core.security_headers import SecurityHeadersMiddleware
from app.core.settings_migrate import migrate_legacy_docs_creds from app.core.settings_migrate import migrate_legacy_docs_creds
from app.core.settings_overlay import enabled as settings_db_enabled from app.core.settings_overlay import enabled as settings_db_enabled
from app.core.settings_overlay import load_overrides, register_invalidator from app.core.settings_overlay import load_overrides, register_invalidator
from app.db import SessionLocal
configure_logging() configure_logging()
configure_debugging() configure_debugging()
@@ -96,6 +98,55 @@ def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> Non
)(_shell_view) )(_shell_view)
def _favicon() -> Response:
"""Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
favicon over the wire.
Renders :func:`app.core.theming.favicon_svg` from the EFFECTIVE
palette on EVERY request — one short-lived ``SessionLocal()``
session, the ``caching.py`` middleware's house pattern: NO process
cache, so a runtime theme change (the admin Theme tab's Save) is
visible on the next request without a restart (a single-row SELECT
is negligible at homelab traffic). ANY exception (a DB blip, a
pre-migration boot) falls back to the BUILT-IN palette with 200 —
never 500 (the theme-tag's never-break contract, ``caching.py``'s
DB-blip fallback): the tab icon degrades to the built-in art, the
app never errors on it.
Deliberately OUTSIDE ``/api/*``: no per-response byte-budget line
(AGENTS rule 10 is ``/api/*``-only) and NO auth dependency — the
favicon is chrome, public like the login page.
``CachingMiddleware`` passes the response through untouched
(``/favicon.svg`` is neither in ``HTML_PAGES`` nor under
``/assets/``) → the route's own ``Cache-Control: no-cache`` is the
freshness contract (the palette changes at runtime; the pages are
no-cache too, so the browser revalidates the favicon together with
them — freshness is revalidation, not a ``?v=`` bust). The route is
not under ``/assets/``, so its ``<link>`` ref can never be touched
by the ``?v=`` rewrite. ``SecurityHeadersMiddleware`` still adds the
CSP — no change needed (the phase-123 ``img-src 'self' data:``
carve-out covers task 04's ``data:`` preview; CSP3 governs
``link[rel=icon]`` by ``img-src``).
"""
try:
db = SessionLocal()
try:
effective = theming.effective_settings(db)
finally:
db.close()
svg = theming.favicon_svg(effective)
except Exception:
logger.exception(
"favicon: theme read failed — serving the built-in favicon"
)
svg = theming.favicon_svg(theming.BUILTIN_COLORS)
return Response(
svg,
media_type="image/svg+xml",
headers={"Cache-Control": "no-cache"},
)
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Fail loud BEFORE serving anything (phase 16): missing # Fail loud BEFORE serving anything (phase 16): missing
# BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the # BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
@@ -189,6 +240,19 @@ def create_app() -> FastAPI:
app.include_router(chats_public_router, prefix="/api") app.include_router(chats_public_router, prefix="/api")
app.include_router(chats_shared_page_router) # no prefix — /shared/<token> app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
# Phase 177 (task 02, D2 — A2/A3 owner-confirmed): the themed
# favicon route — registered AFTER the router includes and BEFORE
# the static catch-all mount (routes-first, the phase-51/76
# pattern): a file named favicon.svg in the static dir could never
# shadow it. HEAD joins GET for static-file parity (the
# phase-76 ``_shell_routes`` parity idiom — the pre-phase static
# file answered both methods, the dynamic route keeps that).
# See the handler docstring for the never-break DB-blip fallback
# and the /api/*-out rationale.
app.api_route(
"/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False
)(_favicon)
# Cache busting (phase 33): the five HTML pages revalidate (no-cache) # Cache busting (phase 33): the five HTML pages revalidate (no-cache)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year. # with ?v=<token> asset refs; /assets/* becomes immutable for a year.
# Added after the session middleware, so it wraps the whole app # Added after the session middleware, so it wraps the whole app
-7
View File
@@ -1,7 +0,0 @@
<!-- Favicon — served statically (AGENTS.md rule 6) instead of a
data: URI: the phase-82 strict CSP (default-src 'self') blocks
data: images, and the audit's "no external hosts" verification
had missed this one. Byte-identical artwork, extracted from the
former <link rel="icon" href="data:image/svg+xml,…"> (2026-09-08,
phase 82 task 03 — the E2E CSP gate caught the violation). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 757 B

+106
View File
@@ -30,6 +30,42 @@
* effect: the 3 strings keep the brand.js runtime application * effect: the 3 strings keep the brand.js runtime application
* (owner-locked B4) — they apply via the /api/config boot fetch on * (owner-locked B4) — they apply via the /api/config boot fetch on
* the NEXT page load, and the sub-copy says so. * the NEXT page load, and the sub-copy says so.
* • favicon live preview (phase 177, A4 — owner-confirmed
* 2026-10-13) — a pick of one of the THREE driving fields
* (``surface`` / ``brand`` / ``brand_ink`` — the artwork's
* color roles: hexagon fill, hexagon stroke + center dot,
* 4 spokes) ALSO repaints the tab icon: the
* ``link[rel="icon"]`` href is set to
* ``themeFaviconDataUri(...)`` — a ``data:image/svg+xml,``
* URI (``encodeURIComponent``-ed) whose SVG is the
* CLIENT-SIDE MIRROR of ``app.core.theming.favicon_svg``
* (the same artwork, the same roles —
* ``tests/unit/test_theme_frontend.py`` keeps the two from
* diverging: the artwork constants are pinned against the
* server source, the ``PAIRS``-mirror house pattern).
* Rebuilt, not patched: a ``surface`` pick repaints with the
* CURRENT ``brand`` / ``brand_ink`` picks (the helper reads
* the three inputs' values). The ``isHex`` guard (belt-
* and-braces — the color inputs are always valid hex, the
* inputs are the ONLY source of the values, so there is no
* XSS path; the house "never build HTML" rule binds the DOM,
* the URI is built from constants + validated hexes) returns
* ``null`` on a non-hex input and the link is left
* untouched. The other 14 picks never touch the link.
* RESTORE TO THE ROUTE — the settled palette always wins: on
* every SETTLED read (Save success, after the
* ``applyServedTheme`` reconcile; Reset success, after the
* reconcile; the Reset-failure ``clearPreview()`` path — the
* picks are stale; the ``bor:view-refresh`` re-show, after
* its ``applyServedTheme``) the href goes back to
* ``"/favicon.svg"`` — the route re-reads the DB per request
* (NO process cache), so the restored URL is always fresh,
* and the page is ``no-cache``, so the browser revalidates
* the favicon with it. CSP (phase 123, per D4): ``data:`` is
* legal for the favicon via the ``img-src 'self' data:``
* carve-out — CSP3 governs ``link[rel=icon]`` by
* ``img-src``, and the carve-out is image-scoped (``data:``
* never becomes a script/style/fetch source).
* • served-theme sync (phase 92, defect 1) — the phase-91 defect: * • served-theme sync (phase 92, defect 1) — the phase-91 defect:
* Save/Reset removed the preview overrides, and the page then * Save/Reset removed the preview overrides, and the page then
* fell back to the <style id="bor-theme"> tag baked into THIS * fell back to the <style id="bor-theme"> tag baked into THIS
@@ -124,6 +160,10 @@ export async function mount(root) {
const errorEl = root.querySelector("#theme-error"); const errorEl = root.querySelector("#theme-error");
const resultEl = root.querySelector("#theme-result"); const resultEl = root.querySelector("#theme-result");
const contrastEl = root.querySelector("#theme-contrast"); const contrastEl = root.querySelector("#theme-contrast");
/* The tab icon's link (a document-level element, not in the
view — the favicon live preview writes its href; phase 177,
A4). */
const faviconLink = document.querySelector('link[rel="icon"]');
const SAVE_LABEL = "Save theme"; const SAVE_LABEL = "Save theme";
const RESET_LABEL = "Reset to defaults"; const RESET_LABEL = "Reset to defaults";
@@ -306,6 +346,53 @@ export async function mount(root) {
} }
} }
/* ---------- favicon live preview (phase 177 — A4) ----------
* The tab icon is the CLIENT-SIDE MIRROR of the server's
* favicon: app/core/theming.py's favicon_svg is the
* authoritative renderer — the SAME artwork (a hexagon +
* center dot + 4 spokes, viewBox 0 0 64 64), the SAME three
* color roles (hexagon fill ← surface, hexagon stroke +
* circle fill ← brand, spokes stroke ← brand_ink). The two
* must never diverge: tests/unit/test_theme_frontend.py
* pins this file's artwork constants (the viewBox, both path
* d-strings, the circle r) against the server's source (the
* PAIRS-mirror house pattern). CSP (phase 123, per D4):
* data: is legal for the favicon via the img-src 'self'
* data: carve-out — CSP3 governs link[rel="icon"] by
* img-src, and the carve-out is image-scoped (data: never
* becomes a script / style / fetch source). */
const FAVICON_DRIVERS = ["surface", "brand", "brand_ink"];
/* The data: URI for the three driving values — the pure half
of the favicon live preview. The isHex guard (belt-and-
braces: the color inputs are always valid hex, the inputs
are the ONLY source of the values, so there is no XSS path
— the house "never build HTML" rule binds the DOM, the URI
is built from constants + validated hexes) returns null on
a non-hex input: the link is then left untouched. */
function themeFaviconDataUri(surface, brand, brandInk) {
if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) return null;
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
+ '<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="' + surface
+ '" stroke="' + brand + '" stroke-width="4" stroke-linejoin="round"/>'
+ '<circle cx="32" cy="32" r="6.5" fill="' + brand + '"/>'
+ '<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="'
+ brandInk + '" stroke-width="3" stroke-linecap="round"/>'
+ "</svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* The settled palette always wins (A4): after every SETTLED
read (Save success, Reset success, the Reset-failure
clearPreview path, the bor:view-refresh re-show) the href
goes back to the route — which re-reads the DB per request
(NO process cache, so it is always fresh) and whose
no-cache makes the browser revalidate the favicon with it. */
function restoreFavicon() {
if (faviconLink) faviconLink.href = "/favicon.svg";
}
/* ---------- served-theme sync (phase 92, defect 1) ---------- /* ---------- served-theme sync (phase 92, defect 1) ----------
* The phase-91 defect: after a Save/Reset the preview overrides * The phase-91 defect: after a Save/Reset the preview overrides
* were removed and the page fell back to the <style id="bor-theme"> * were removed and the page fell back to the <style id="bor-theme">
@@ -500,6 +587,7 @@ export async function mount(root) {
const settings = await loadSettings(); // refetch + re-populate const settings = await loadSettings(); // refetch + re-populate
if (settings) { if (settings) {
applyServedTheme(settings); // reconcile the open document applyServedTheme(settings); // reconcile the open document
restoreFavicon(); // the settled palette governs the tab icon
} }
/* A failed refetch keeps the current <html> overrides on purpose: /* A failed refetch keeps the current <html> overrides on purpose:
the PUT body came from these very inputs, so they ARE the saved the PUT body came from these very inputs, so they ARE the saved
@@ -518,8 +606,10 @@ export async function mount(root) {
const settings = await loadSettings(); // the env / built-in defaults const settings = await loadSettings(); // the env / built-in defaults
if (settings) { if (settings) {
applyServedTheme(settings); // tag removed + overrides dropped applyServedTheme(settings); // tag removed + overrides dropped
restoreFavicon(); // the settled palette governs the tab icon
} else { } else {
clearPreview(); // the reset landed — the picks are stale clearPreview(); // the reset landed — the picks are stale
restoreFavicon(); // stale picks — back to the route URL
} }
} }
@@ -562,6 +652,21 @@ export async function mount(root) {
input.addEventListener("input", () => { input.addEventListener("input", () => {
previewColor(f.field, input.value); previewColor(f.field, input.value);
updateContrast(); updateContrast();
/* The favicon live preview (phase 177 — A4): ONLY the
three driving fields repaint the tab icon; the other 14
picks never touch the link. Rebuilt, not patched — the
helper reads the three inputs' CURRENT values, so a
surface pick repaints with the current brand /
brand_ink picks. A null URI (a non-hex value,
defensive) leaves the link untouched. */
if (FAVICON_DRIVERS.includes(f.field)) {
const uri = themeFaviconDataUri(
inputs.surface.value,
inputs.brand.value,
inputs.brand_ink.value,
);
if (uri && faviconLink) faviconLink.href = uri;
}
}); });
} }
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme()); if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
@@ -577,6 +682,7 @@ export async function mount(root) {
root.addEventListener("bor:view-refresh", () => { root.addEventListener("bor:view-refresh", () => {
void loadSettings().then((settings) => { void loadSettings().then((settings) => {
if (settings) applyServedTheme(settings); if (settings) applyServedTheme(settings);
restoreFavicon(); // the settled palette governs the tab icon
}); });
}); });
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only)."> <meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
<title>Edit doc · Brain of Reese</title> <title>Edit doc · Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Read a document indexed in Brain of Reese."> <meta name="description" content="Read a document indexed in Brain of Reese.">
<title>Document · Brain of Reese</title> <title>Document · Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Ask anything about your indexed documents — every answer cites the exact doc."> <meta name="description" content="Ask anything about your indexed documents — every answer cites the exact doc.">
<title>Brain of Reese</title> <title>Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning."> <meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
<title>Sign in · Brain of Reese</title> <title>Sign in · Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="A shared Brain of Reese conversation — read-only."> <meta name="description" content="A shared Brain of Reese conversation — read-only.">
<title>Shared conversation · Brain of Reese</title> <title>Shared conversation · Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
+701
View File
@@ -0,0 +1,701 @@
"""Phase 177 E2E (Playwright): the tab icon carries the effective
palette — the route, the pre-paint tag, the live preview, the
restore contract.
Source: TODO.md L3 — "We need the favicon to change colors to
match the theme." (owner roadmap confirmation 2026-10-13, A1–A4.)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_default_state_serves_the_golden_route`` — the default
(no ``ui_settings`` row) deployment: ``GET /favicon.svg`` is 200
``image/svg+xml`` + ``no-cache`` with the body byte-identical
to the golden art (``FAVICON_GOLDEN`` from
``tests.unit.test_theming`` — ONE source); the five pages'
served HTML reference the route (``href="/favicon.svg"`` —
bare, never ``/assets/favicon.svg``); the deleted static file
is 404.
2. ``test_themed_state_serves_the_effective_palette`` — with a
saved palette (the 3 driving colors distinct): the route's SVG
carries the three hexes in their EXACT roles (attribute-parsed,
never substring) and is byte-equal to the server renderer's
output; a fresh page load STILL injects the ``#bor-theme`` tag
immediately before ``</head>`` with the full 17-var content +
the phase-91 CSP hash (the tag/CSP contract is untouched by the
favicon work), and the browser's first paint is the themed
paint (the computed ``--surface`` / ``--brand``).
3. ``test_live_preview_repaints_the_favicon`` — an admin's live
(unsaved) pick of the three driving fields sets
``link[rel="icon"]`` to a ``data:image/svg+xml`` URI that,
decoded, carries the three picked hexes in their exact roles
(attribute-parsed). Headless Chromium never fetches favicons
(probe-verified — zero request events, not even for the
initial ``/favicon.svg``), so the suite pins the href state +
the decoded art as the repaint contract and guards the CSP
path NEGATIVELY: no CSP violation is ever reported against a
``data:`` URI (the phase-123 ``img-src 'self' data:``
carve-out makes a ``data:`` favicon legal — CSP3 governs
``link[rel=icon]`` by ``img-src``; a blocked fetch would
surface as a ``requestfailed`` with a CSP error or a console
violation, which the suite asserts does not happen). A
non-driving pick (``#theme-bg``) never touches the href.
4. ``test_save_restores_the_route_url`` — Save: the href is
EXACTLY ``/favicon.svg`` and the route now serves the saved
palette's bytes (the settled palette governs).
5. ``test_reset_restores_the_route_url`` — after a Save, Reset:
the href is ``/favicon.svg`` and the route serves the built-in
golden bytes.
6. ``test_view_refresh_restores_the_route_url`` — a user-initiated
re-show (the router's ``bor:view-refresh`` dispatch — the
phase-77 hook) restores the href after a live pick.
Zero external requests across the whole suite (AGENTS rule 6 — the
house E2E assertion idiom): a ``page.on("request")`` collector
asserts every http(s) URL is the local origin (a ``data:`` URI is
exempt by design — the phase-123 carve-out).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware + the favicon route read for
EVERY request — a leftover themed row would repaint other suites'
pages and icons) 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). The phase-92 file
(test_theme_save_and_coverage.py) is the copy source — the two
modules' scaffolds stay in lockstep so a future conftest refactor
touches both at once.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from urllib.parse import unquote
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.core import theming
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from tests.unit.test_theming import FAVICON_GOLDEN
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The three driving fields (A1 — the artwork's color roles: hexagon
# fill, hexagon stroke + center dot, 4 spokes) with E2E values
# distinct from every built-in.
DRIVING: dict[str, str] = {
"surface": "#102030",
"brand": "#4f46e5",
"brand_ink": "#a5b4fc",
}
#: The E2E-stable color-input ids (the form's own markup — the
#: static E2E-stable-selectors house convention).
INPUT_IDS = {
field: f"#theme-{field.replace('_', '-')}" for field in theming.COLOR_FIELDS
}
#: The five templates whose served HTML must reference the route.
PAGES = ("/", "/document.html", "/login.html", "/shared.html", "/doc-edit.html")
#: The data: URI prefix the live preview sets (the phase-123
#: img-src 'self' data: carve-out makes it CSP-legal).
DATA_PREFIX = "data:image/svg+xml,"
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(
f"--{k.replace('_', '-')}:{colors[k]};" for k in theming.COLOR_FIELDS
)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
def _expected_favicon_bytes(colors: dict[str, str]) -> bytes:
"""The route's body for a palette: the SERVER renderer's output
(imported — ONE source, never a test-side mirror of the art)."""
return theming.favicon_svg(colors).encode("utf-8")
def _attr(element: ET.Element, name: str) -> str:
"""An SVG attribute, asserted non-null (a missing role attribute
is a test failure, not a parseable value)."""
value = element.get(name)
assert value is not None, f"missing the {name} attribute"
return value
def _parse_roles(svg: str) -> dict[str, str]:
"""The three color roles, ATTRIBUTE-parsed (the unit pin's
idiom — never substring): the hexagon's fill (surface), the
hexagon's stroke (brand), the center circle's fill (brand),
the spokes' stroke (brand_ink)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg)
assert root.tag == f"{ns}svg", root.tag
hexagon, circle, spokes = list(root)
return {
"surface": _attr(hexagon, "fill"),
"brand": _attr(hexagon, "stroke"),
"brand_circle": _attr(circle, "fill"),
"brand_ink": _attr(spokes, "stroke"),
}
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 _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate
is the last visible step — the app-name input carries the
effective default (the static markup ships it empty, so only a
settled ``loadSettings`` can have written it)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _favicon_href(page: Page) -> str:
"""The tab icon's current href (the attribute's raw state)."""
return page.evaluate(
"() => document.querySelector('link[rel=icon]').getAttribute('href')"
)
def _wait_data_href(page: Page) -> None:
"""The href is a ``data:`` URI (the live preview has landed)."""
page.wait_for_function(
f"() => (document.querySelector('link[rel=icon]')"
f".getAttribute('href') || '').startsWith('{DATA_PREFIX}')",
timeout=15_000,
)
def _wait_route_href(page: Page) -> None:
"""The href is EXACTLY the route URL (the restore has landed —
the settled palette governs)."""
page.wait_for_function(
"() => (document.querySelector('link[rel=icon]')"
'.getAttribute(\'href\') || \'\') === \'/favicon.svg\'',
timeout=15_000,
)
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the 3 driving colors
(the rest null → the built-ins — the route's bytes need only
the three colors in the artwork)."""
body: dict[str, object] = {}
for field in theming.STRING_FIELDS:
body[field] = None
for field in theming.COLOR_FIELDS:
body[field] = None
body.update(DRIVING)
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# 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 + the favicon route read it for
EVERY request — a leftover themed row would repaint other
suites' pages and icons) 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 _zero_external(
page: Page,
) -> tuple[list[str], list[str], list[str], list[str]]:
"""AGENTS rule 6 (the house E2E assertion idiom): every http(s)
request is the local origin; ``data:`` URIs are exempt by
design (the phase-123 ``img-src 'self' data:`` carve-out).
Also collects the CSP-failure signals: a failed ``data:``
fetch (a blocked fetch carries a CSP error in its failure
message) and console CSP violations ("Refused to …" / "CSP"
against a ``data:`` URI). Returns (externals, data_requests,
failed_data, csp_violations). Headless Chromium never fetches
favicons (probe-verified), so ``data_requests`` is expected to
stay empty — the negative assertions (``failed_data`` +
``csp_violations``) are the CSP guard: a future change that
blocks the ``data:`` URI surfaces here, and a real
(non-headless) browser run additionally sees the fetch in
``data_requests``."""
externals: list[str] = []
data_requests: list[str] = []
failed_data: list[str] = []
csp_violations: list[str] = []
def _watch(request: object) -> None:
url = getattr(request, "url", "")
if url.startswith("data:"):
data_requests.append(url)
return
if url.startswith(("http://", "https://")) and "127.0.0.1" not in url:
externals.append(url)
def _watch_failed(request: object) -> None:
url = getattr(request, "url", "")
failure = getattr(request, "failure", None)
if url.startswith("data:") and failure:
failed_data.append(str(failure))
def _watch_console(message: object) -> None:
msg = getattr(message, "text", "")
if ("CSP" in msg or "Refused to" in msg) and "data:" in msg:
csp_violations.append(msg)
page.on("request", _watch)
page.on("requestfailed", _watch_failed)
page.on("console", _watch_console)
return externals, data_requests, failed_data, csp_violations
def _assert_zero_external(
externals: list[str], failed_data: list[str], csp_violations: list[str]
) -> None:
"""The suite's tail assertion: zero external requests, no
``data:`` URI CSP-blocked (a blocked fetch carries a CSP error
in its failure message), and no CSP violation reported
against a ``data:`` URI (the phase-123 carve-out holds)."""
assert not externals, f"external requests: {externals}"
assert not failed_data, f"data: URIs blocked (CSP?): {failed_data}"
assert not csp_violations, f"CSP violations: {csp_violations}"
# ---------------------------------------------------------------------------
# 1. Default state: the golden route, the five page references, the
# deleted static file
# ---------------------------------------------------------------------------
def test_default_state_serves_the_golden_route(app_url: str, db_ready: None) -> None:
"""The default (no ``ui_settings`` row) deployment: the route
is byte-identical to the golden art (imported — ONE source),
with the svg media type + ``no-cache``; the five pages' served
HTML reference the route (bare — no ``?v=``, never
``/assets/favicon.svg``); the deleted static file is 404."""
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.headers["content-type"].startswith("image/svg+xml")
assert r.headers["cache-control"] == "no-cache"
assert r.content == FAVICON_GOLDEN.encode("utf-8")
for path in PAGES:
page = httpx.get(app_url + path, timeout=10)
assert page.status_code == 200, path
assert (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
in page.text
), f"{path} must reference the themed route"
assert "/assets/favicon.svg" not in page.text, (
f"{path} must never reference the deleted static file"
)
assert "favicon.svg?v=" not in page.text, (
f"{path} must keep the favicon link bare (no ?v=)"
)
gone = httpx.get(app_url + "/assets/favicon.svg", timeout=10)
assert gone.status_code == 404
# ---------------------------------------------------------------------------
# 2. Themed state: the route carries the effective palette; the
# pre-paint tag + the first paint are untouched (phase 91)
# ---------------------------------------------------------------------------
def test_themed_state_serves_the_effective_palette(
page: Page, app_url: str, db_ready: None
) -> None:
"""With a saved palette (the 3 driving colors distinct): the
route's SVG carries the three hexes in their EXACT roles
(attribute-parsed) and is byte-equal to the server renderer;
a fresh page load STILL injects the ``#bor-theme`` tag
(17-var content, immediately before ``</head>`` + the phase-91
CSP hash — the tag/CSP contract is untouched by the favicon
work) and the browser's first paint is the themed paint."""
page.set_default_timeout(30_000)
login(page, app_url)
_seed_theme_via_api(app_url, _cookies(page))
# The route: the three hexes in their exact roles…
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
roles = _parse_roles(r.text)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# …and byte-equal to the server renderer's output (ONE source).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
assert r.content == _expected_favicon_bytes(effective)
# The pre-paint tag non-regression (phase 91): the RAW served
# HTML carries the 17-var tag immediately before ``</head>``…
tag = _expected_tag(effective)
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
assert r.text.count(tag) == 1, "the 17-var tag must be injected exactly once"
start = r.text.index(tag)
assert start + len(tag) == r.text.index("</head>"), (
"the tag must end exactly where </head> begins"
)
# …with the phase-91 CSP hash (the favicon work never touched
# the tag/CSP contract).
assert "style-src 'self' 'sha256-" in r.headers.get(
"content-security-policy", ""
), "the themed page must carry the phase-91 CSP hash"
# A fresh page load: the first paint IS the themed paint.
page.goto(app_url + "/")
page.wait_for_function(
"""(d) => {
const cs = getComputedStyle(document.documentElement);
return cs.getPropertyValue('--surface').trim() === d.surface
&& cs.getPropertyValue('--brand').trim() === d.brand;
}""",
arg=DRIVING,
timeout=15_000,
)
# ---------------------------------------------------------------------------
# 3. Live preview: an unsaved pick repaints the favicon via the
# CSP-legal data: URI; a non-driving pick never touches it
# ---------------------------------------------------------------------------
def test_live_preview_repaints_the_favicon(
page: Page, app_url: str, db_ready: None
) -> None:
"""An admin's live (unsaved) pick of the three driving fields
sets ``link[rel="icon"]`` to a ``data:`` URI that, decoded,
carries the three picked hexes in their exact roles; the
browser FETCHES the ``data:`` URI (the phase-123 carve-out —
a blocked fetch would be a ``requestfailed`` with a CSP error);
a non-driving pick (``#theme-bg``) never touches the href."""
page.set_default_timeout(30_000)
externals, _data_requests, failed_data, csp_violations = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A clean start: the href is the route (no row → the golden
# art served by the route).
assert _favicon_href(page) == "/favicon.svg"
# Set the three driving fields (page.fill fires the input event
# — the house _fill_theme_form idiom).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# The href is a data: URI, and — decoded — carries the three
# picked hexes in their exact roles (attribute-parsed).
href = _favicon_href(page)
assert href.startswith(DATA_PREFIX)
svg = unquote(href[len(DATA_PREFIX):])
roles = _parse_roles(svg)
assert roles["surface"] == DRIVING["surface"]
assert roles["brand"] == DRIVING["brand"]
assert roles["brand_circle"] == DRIVING["brand"]
assert roles["brand_ink"] == DRIVING["brand_ink"]
# A non-driving pick never touches the href.
page.fill(INPUT_IDS["bg"], "#000000")
assert _favicon_href(page) == href, "a non-driving pick must not touch the href"
# The CSP guard (negative — headless never fetches favicons):
# no failed data: fetch, no CSP violation against the URI.
_assert_zero_external(externals, failed_data, csp_violations)
# ---------------------------------------------------------------------------
# 4. Restore: Save — the href is EXACTLY /favicon.svg and the route
# serves the saved palette's bytes (the settled palette governs)
# ---------------------------------------------------------------------------
def test_save_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, Save: the §7.4 lifecycle lands and the
href is EXACTLY ``/favicon.svg``; the route now serves the
saved palette's bytes (the settled palette governs — the route
re-reads the DB per request, no process cache)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick first — so the save has a data: href to restore.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# Save through the real form.
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: the route serves the saved
# palette's bytes (the PUT saved all 20 form values — the 3
# driving colors + the 14 built-ins (→ NULL) + the 3 template
# strings; the effective palette is the 3 + the built-ins).
effective = {**theming.BUILTIN_COLORS, **DRIVING}
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == _expected_favicon_bytes(effective)
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 5. Restore: Reset — the href is /favicon.svg and the route serves
# the built-in golden bytes
# ---------------------------------------------------------------------------
def test_reset_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a Save (the route is themed), a fresh live pick, then
Reset: the §7.4 lifecycle lands and the href is
``/favicon.svg``; the route serves the built-in golden bytes
(the reset nulled the row — the built-ins govern)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# Save first — the route is themed (the reset then has a real
# state to settle back from).
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
_wait_route_href(page)
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.content == _expected_favicon_bytes(
{**theming.BUILTIN_COLORS, **DRIVING}
)
# A fresh live pick — the href is the data: one the reset
# restores (a distinct value, so the repaint is guaranteed).
page.fill(INPUT_IDS["surface"], "#0a1525")
_wait_data_href(page)
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
# API's documented "defaults" operation).
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
# The restore: the href is EXACTLY the route URL…
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# …and the route serves the built-in golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)
# ---------------------------------------------------------------------------
# 6. Restore: the bor:view-refresh re-show (the phase-77 hook)
# ---------------------------------------------------------------------------
def test_view_refresh_restores_the_route_url(
page: Page, app_url: str, db_ready: None
) -> None:
"""After a live pick, a user-initiated re-show (leave the Theme
view, come back — the router dispatches ``bor:view-refresh``
on the already-mounted section): the handler re-runs the load,
reconciles, and the href is restored to ``/favicon.svg`` (the
settled palette governs — in this test's row-less state, the
golden bytes)."""
page.set_default_timeout(30_000)
externals, _d, failed_data, csp = _zero_external(page)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
_wait_mount_settled(page)
# A live pick — the href is the data: one the re-show restores.
for field in ("surface", "brand", "brand_ink"):
page.fill(INPUT_IDS[field], DRIVING[field])
_wait_data_href(page)
# A user-initiated re-show: away (the RAG view — a real view
# switch), then back — the router's phase-77 hook dispatches
# bor:view-refresh on the mounted Theme section.
page.click("#nav-sources")
expect(page.locator("#view-rag")).to_be_visible(timeout=15_000)
page.click("#nav-theme")
expect(page.locator("#view-theme")).to_be_visible(timeout=15_000)
# The restore: the href is EXACTLY the route URL.
_wait_route_href(page)
assert _favicon_href(page) == "/favicon.svg"
# The settled palette governs: no row was saved, so the route
# still serves the golden bytes.
r = httpx.get(f"{app_url}/favicon.svg", timeout=10)
assert r.status_code == 200
assert r.content == FAVICON_GOLDEN.encode("utf-8")
_assert_zero_external(externals, failed_data, csp)
+335
View File
@@ -0,0 +1,335 @@
"""Integration: the themed favicon route — ``GET /favicon.svg`` (phase
177, task 02, D2; A2/A3 owner-confirmed) + the template pins (task 03,
D3 — the route is the SINGLE source; the static file is deleted).
The route's contract, driven through the real app (TestClient + the
house ``db`` fixture — the ``test_ui_settings_api`` pattern):
* **default state** (no ``ui_settings`` row): 200 ``image/svg+xml`` +
``Cache-Control: no-cache``, the body byte-identical to the golden
art (``FAVICON_GOLDEN`` from ``tests.unit.test_theming`` — ONE
source, so the route and the renderer can never serve different
bytes), the CSP header present (the ``SecurityHeadersMiddleware``
pass-through — no new header wiring was needed);
* **themed state**: a set row's ``surface``/``brand``/``brand_ink``
land in their exact SVG attribute roles (asserted by PARSING the
attributes — the ``test_theming`` unit-pin idiom, not substring) and
none of the other 14 built-ins appears in the body;
* **no process cache**: a row update is visible on the very next
request — the palette is re-read per request (the runtime-change
contract; a theme change never needs a restart to reach the tab
icon);
* **DB-failure state**: ``effective_settings`` raising (a DB blip / a
pre-migration boot) → 200 with the built-in golden bytes (NEVER 500
— the never-break contract) + the fallback log line;
* **HEAD parity**: the same headers as GET, an empty body (the
phase-76 static-file parity idiom — the pre-phase static file
answered both methods);
* **routes-first**: a file named ``favicon.svg`` in the static dir
could NOT shadow the route (the registration position holds);
* **task 03 (template pins)**: all five served page bodies link the
EXACT bare ``<link … href="/favicon.svg">`` (NO ``?v=`` — the href
is not under ``/assets/``, so ``CachingMiddleware``'s
``_ASSET_REF_RE`` can never touch it; the pin keeps that contract
explicit against a future regex drift) and contain no
``/assets/favicon.svg`` reference; ``GET /assets/favicon.svg``
404s (the file is deleted); a tree scan of ``frontend/`` finds zero
remaining ``/assets/favicon.svg`` references.
The route is deliberately OUTSIDE ``/api/*`` (no per-response
byte-budget line — AGENTS rule 10 is ``/api/*``-only) and carries NO
auth dependency (the favicon is chrome — public like the login page):
every test below is the anonymous ``client``.
Real Postgres (``podman compose up -d db``); no LLM involved.
Requires: podman compose up -d db
"""
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text, update
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import theming
from app.models import UiSettings
from tests.unit.test_theming import FAVICON_GOLDEN
# Phase 177 (task 03, D3): the template pins — every page links the
# BARE route; the static file is gone.
#: The EXACT favicon link tag every template must carry (bare — no
#: ``?v=``: the href is not under ``/assets/``).
FAVICON_LINK_TAG = (
'<link rel="icon" type="image/svg+xml" href="/favicon.svg">'
)
#: The five L8 pages — the shell + the four standalone pages.
PAGE_PATHS: tuple[str, ...] = (
"/",
"/document.html",
"/login.html",
"/shared.html",
"/doc-edit.html",
)
#: The frontend tree root (the house tree-scan idiom).
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""The single ui_settings row is global state — wiped so every
test starts from the state it asserts (the house DELETE reset
pattern, ``test_ui_settings_api``)."""
db.execute(text("DELETE FROM ui_settings"))
db.commit()
yield
db.execute(text("DELETE FROM ui_settings"))
db.commit()
def _assert_roles(
svg_text: str, *, surface: str, brand: str, brand_ink: str
) -> None:
"""The three hexes in their EXACT SVG attribute roles — asserted by
PARSING the attributes (the ``test_theming`` unit-pin idiom, not
substring) — the geometry invariants along the way (the hexagon
``d`` path, the three element order), and none of the other 14
built-in hexes anywhere in the body (a themed favicon must not
leak a built-in color)."""
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(svg_text)
assert root.tag == f"{ns}svg"
hexagon, circle, spokes = list(root)
assert hexagon.tag == f"{ns}path"
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
assert hexagon.get("fill") == surface
assert hexagon.get("stroke") == brand
assert circle.tag == f"{ns}circle"
assert circle.get("fill") == brand
assert spokes.tag == f"{ns}path"
assert spokes.get("stroke") == brand_ink
for key, value in theming.BUILTIN_COLORS.items():
if value not in (surface, brand, brand_ink):
assert value not in svg_text, (
f"built-in {key} ({value}) must not appear in a themed favicon"
)
def test_default_state_serves_the_golden_bytes(
client: TestClient, db: Session
) -> None:
"""No ui_settings row: the BUILT-IN palette renders — 200
``image/svg+xml`` + ``Cache-Control: no-cache``, the body
byte-identical to the golden art (the D1 contract through the
wire), and the CSP header is present on the response (the
``SecurityHeadersMiddleware`` pass-through — no header change was
needed; the phase-123 ``img-src 'self' data:`` carve-out is part
of the policy that covers task 04's ``data:`` preview)."""
assert (
db.execute(text("SELECT count(*) FROM ui_settings")).scalar() == 0
), "the test starts from a row-missing state"
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert "content-security-policy" in r.headers
assert "default-src 'self'" in r.headers["content-security-policy"]
assert "img-src 'self' data:" in r.headers["content-security-policy"]
def test_themed_state_carries_the_three_roles(
client: TestClient, db: Session
) -> None:
"""A set row (distinct ``surface``/``brand``/``brand_ink``): the
three hexes land in their exact SVG attribute roles (parsed, not
substring) and none of the other 14 built-ins appears in the
body; the header contract (no-cache + content-type) is unchanged
by the palette."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
_assert_roles(
r.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
def test_themed_state_is_live_no_process_cache(
client: TestClient, db: Session
) -> None:
"""The runtime-change contract (D2): the palette is re-read per
request — NO process cache, so a row update is visible on the very
next request (a theme change never needs a restart to reach the
tab icon), and the OLD palette is gone from the body (nothing
stale is served)."""
db.add(
UiSettings(
id=1, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
)
db.commit()
r1 = client.get("/favicon.svg")
assert r1.status_code == 200
_assert_roles(
r1.text, surface="#102030", brand="#4f46e5", brand_ink="#a5b4fc"
)
db.execute(
update(UiSettings)
.where(UiSettings.id == 1)
.values(surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd")
)
db.commit()
r2 = client.get("/favicon.svg")
assert r2.status_code == 200
_assert_roles(
r2.text, surface="#203040", brand="#0ea5e9", brand_ink="#bae6fd"
)
for old in ("#102030", "#4f46e5", "#a5b4fc"):
assert old not in r2.text
def test_db_failure_serves_the_builtin_art_with_200(
client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A DB blip / pre-migration boot (``effective_settings`` raising):
the handler's fallback serves the BUILT-IN golden bytes with 200
(NEVER 500 — the never-break contract) and logs the failure via
``logger.exception`` (the ``caching.py`` DB-blip pattern)."""
def _boom(_session: Session) -> dict[str, str]:
raise RuntimeError("db blip (test)")
monkeypatch.setattr(theming, "effective_settings", _boom)
with caplog.at_level(logging.ERROR, logger="app"):
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert r.text == FAVICON_GOLDEN
assert any(
"favicon: theme read failed" in record.message for record in caplog.records
), "the fallback path must log the failure (logger.exception)"
def test_head_parity_matches_get(client: TestClient, db: Session) -> None:
"""``HEAD /favicon.svg``: 200 with the SAME headers as GET
(content-type, no-cache, the CSP pass-through) and an empty body
(the phase-76 static-file parity idiom — the pre-phase static
file answered both methods, the dynamic route keeps that)."""
r = client.head("/favicon.svg")
assert r.status_code == 200
assert r.headers["content-type"] == "image/svg+xml"
assert r.headers["cache-control"] == "no-cache"
assert "content-security-policy" in r.headers
assert r.content == b""
def test_route_wins_over_the_static_mount(client: TestClient) -> None:
"""Routes-first: a file named ``favicon.svg`` in the static dir
could NOT shadow the route — the file is created with
SHADOW-ATTEMPT bytes (and deleted again in the ``finally``), and
the response is still the golden art (the registration position —
after the router includes, before the static catch-all mount —
holds)."""
shadow = Path(get_settings().static_dir).resolve() / "favicon.svg"
assert not shadow.exists(), (
"the test must start without a static-dir favicon.svg"
)
shadow.write_text("SHADOW ATTEMPT", encoding="utf-8")
try:
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
assert "SHADOW ATTEMPT" not in r.text
finally:
shadow.unlink()
def test_route_is_public_no_auth_dependency(client: TestClient) -> None:
"""The favicon is chrome — public like the login page: the
anonymous (unsigned-in) client gets 200, and the route carries no
``/api/`` prefix (deliberately outside ``/api/*`` — no
per-response byte-budget line, AGENTS rule 10's scope)."""
assert client.get("/api/whoami").json() == {
"authenticated": False,
"role": "anonymous",
}
r = client.get("/favicon.svg")
assert r.status_code == 200
# ---------- Phase 177 (task 03, D3): the template pins ----------
@pytest.mark.parametrize("path", PAGE_PATHS)
def test_served_page_links_the_bare_route(
client: TestClient, path: str
) -> None:
"""Each of the five served page bodies carries the EXACT bare
favicon link (task 03, D3 — ``href="/favicon.svg"``) and contains
NO ``/assets/favicon.svg`` reference of any kind. The link stays
BARE — no ``?v=`` (the href is not under ``/assets/``, so
``CachingMiddleware``'s ``_ASSET_REF_RE`` can never touch it;
the pin keeps that contract explicit against a future regex
drift)."""
r = client.get(path)
assert r.status_code == 200
assert FAVICON_LINK_TAG in r.text, f"{path}: favicon link pin missing"
assert "/assets/favicon.svg" not in r.text
assert "/favicon.svg?v=" not in r.text, (
f"{path}: the favicon link must stay bare (no ?v= version token)"
)
def test_the_static_favicon_file_is_gone(client: TestClient) -> None:
"""The single-source decision (task 03, D3): ``frontend/assets/
favicon.svg`` no longer exists — on disk AND over the wire (the
static catch-all 404s it). Nothing is lost: the built-in bytes
stay pinned by ``FAVICON_GOLDEN`` in ``test_theming`` (task 01's
golden constant is the durable contract), and ``GET
/favicon.svg`` still serves the golden art (the route is the
single source now)."""
assert not (FRONTEND / "assets" / "favicon.svg").exists()
r = client.get("/assets/favicon.svg")
assert r.status_code == 404
r = client.get("/favicon.svg")
assert r.status_code == 200
assert r.text == FAVICON_GOLDEN
def test_tree_scan_has_zero_assets_favicon_references() -> None:
"""A tree scan of ``frontend/`` (the house idiom — every file,
markup + scripts + styles, comments included): ZERO remaining
references to ``/assets/favicon.svg`` (the deletion is complete —
no template, script, or stylesheet still asks for the retired
URL)."""
files = [
p
for p in sorted(FRONTEND.rglob("*"))
if p.is_file()
]
assert files, "frontend/ contains no files — test is blind"
for path in files:
assert "/assets/favicon.svg" not in path.read_text(encoding="utf-8"), (
f"/assets/favicon.svg survived in {path.relative_to(FRONTEND)}"
)
+188 -1
View File
@@ -32,12 +32,23 @@ frontend sources and asserts on their mechanisms):
``BUILTIN_COLORS``), never a third hardcoded palette copy; ``BUILTIN_COLORS``), never a third hardcoded palette copy;
* the "State colors" fieldset sits AFTER the palette fieldset (the * the "State colors" fieldset sits AFTER the palette fieldset (the
task-02 form shape), and the palette legend's "five pairs" copy is task-02 form shape), and the palette legend's "five pairs" copy is
the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept). the "eight pairs" copy (the WCAG 2.1 AA (4.5:1) wording kept);
* phase 177 (task 04, A4) — the favicon live preview: the
``data:image/svg+xml,`` + ``encodeURIComponent`` mechanism, the
``isHex`` guard (a non-hex input returns ``null`` — the link is
left untouched), the EXACTLY-three driving fields (the binding's
gate + the helper call), the mirror's artwork constants pinned
against ``app/core/theming.py``'s ``favicon_svg`` source (the
divergence guard), and the ``/favicon.svg`` restore wired to the
four settle points (Save success, Reset success, the
Reset-failure ``clearPreview()`` path, the
``bor:view-refresh`` re-show — and to no other point).
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import Path from pathlib import Path
from typing import TypedDict
from app.core import theming from app.core import theming
@@ -276,3 +287,179 @@ def test_state_colors_fieldset_after_the_palette() -> None:
assert field_id not in palette_body assert field_id not in palette_body
else: else:
assert field_id in palette_body assert field_id in palette_body
# ---------- theme.js: the favicon live preview (phase 177 — A4) ----------
# The admin Theme tab's live (unsaved) picks of the THREE driving
# fields (``surface`` / ``brand`` / ``brand_ink``) repaint the tab
# icon via a CSP-legal ``data:`` URI — the CLIENT-SIDE MIRROR of
# ``app.core.theming.favicon_svg`` (the live behavior is E2E-gated
# in ``tests/e2e/test_themed_favicon.py``; here the source pins
# keep the mirror from diverging — the ``PAIRS``-mirror house
# pattern). The settled palette always wins: the four settle points
# (Save success, Reset success, the Reset-failure ``clearPreview()``
# path, the ``bor:view-refresh`` re-show) restore the href to
# ``/favicon.svg`` (the route re-reads the DB per request — no
# process cache — so the restored URL is always fresh).
THEMING_PY = Path(__file__).resolve().parents[2] / "app" / "core" / "theming.py"
def _favicon_helper(js: str) -> str:
"""The ``themeFaviconDataUri`` function (from the signature to
the ``restoreFavicon`` definition — the helper's layout is
pinned by the pins below, so the slice is stable)."""
start = js.index("function themeFaviconDataUri(")
end = js.index("function restoreFavicon()", start)
return js[start:end]
def test_favicon_data_uri_mechanism_present() -> None:
"""The live preview builds a CSP-legal ``data:`` URI: the
literal ``data:image/svg+xml,`` prefix +
``encodeURIComponent`` — the phase-123 ``img-src 'self'
data:`` carve-out makes a ``data:`` favicon legal (CSP3
governs ``link[rel=icon]`` by ``img-src``; the carve-out is
image-scoped — ``data:`` never becomes a script/style/fetch
source). The URI is built from constants + validated hexes —
the house "never build HTML" rule binds the DOM, and this
file's URI carries no DOM-inserted HTML."""
helper = _favicon_helper(_js())
assert '"data:image/svg+xml,"' in helper
assert "encodeURIComponent(" in helper
def test_favicon_helper_guards_non_hex_inputs() -> None:
"""The ``isHex`` guard (belt-and-braces — the color inputs are
always valid hex, the inputs are the ONLY source of the
values, so there is no XSS path): a non-hex input returns
``null``, and the link is set ONLY for a non-null URI (a
``null`` URI leaves it untouched)."""
helper = _favicon_helper(_js())
assert (
"if (!isHex(surface) || !isHex(brand) || !isHex(brandInk)) "
"return null;" in helper
)
assert "if (uri && faviconLink) faviconLink.href = uri;" in _js()
def test_favicon_driving_set_is_exactly_the_three_fields() -> None:
"""The helper reads EXACTLY the three driving fields (``
surface`` / ``brand`` / ``brand_ink`` — the artwork's color
roles: hexagon fill, hexagon stroke + center dot, 4 spokes) —
a fourth field, or a missing one, fails the pin (the other 14
picks never touch the link; the 8 semantic state colors are
NOT in the artwork — a favicon has no state meaning). The
binding's gate (``FAVICON_DRIVERS``) is the same set, in the
artwork's role order."""
js = _js()
start = js.index("const FAVICON_DRIVERS = [")
end = js.index("]", start)
drivers = re.findall(r'"([a-z_]+)"', js[start:end])
assert drivers == ["surface", "brand", "brand_ink"]
# The live set: the helper call reads EXACTLY those three
# inputs' CURRENT values (rebuilt, not patched — a surface
# pick repaints with the current brand / brand_ink picks).
# The live call (the helper definition precedes it — search past
# the restore function's definition to land on the call site).
call = js.index("themeFaviconDataUri(", js.index("function restoreFavicon()"))
close = js.index(")", call + len("themeFaviconDataUri("))
names = re.findall(r"inputs\.(\w+)\.value", js[call:close])
assert names == ["surface", "brand", "brand_ink"]
def test_favicon_mirror_matches_the_server_artwork() -> None:
"""The client-side mirror's artwork constants match the
SERVER's (``app/core/theming.py``'s ``favicon_svg`` source —
the ``PAIRS``-mirror house pattern, the divergence guard):
the ``viewBox``, BOTH path ``d``-strings (the hexagon, the
spokes), and the circle ``r`` are extracted from each side's
source and compared — a change on either side fails the pin."""
js = _js()
py = THEMING_PY.read_text(encoding="utf-8")
py_start = py.index("def favicon_svg(")
py_end = py.index("\ndef ", py_start)
py_art = py[py_start:py_end]
class _ArtConstants(TypedDict):
viewBox: str
r: str
d: list[str]
def _art_constants(text: str) -> _ArtConstants:
viewbox = re.search(r'viewBox="([^"]+)"', text)
circle_r = re.search(r'r="([^"]+)"', text)
assert viewbox is not None, "the viewBox constant is missing"
assert circle_r is not None, "the circle r constant is missing"
return {
"viewBox": viewbox.group(1),
"r": circle_r.group(1),
"d": re.findall(r'd="([^"]+)"', text),
}
js_constants = _art_constants(_favicon_helper(js))
py_constants = _art_constants(py_art)
assert js_constants == py_constants, (
f"the client mirror diverged from the server: {js_constants} "
f"vs {py_constants}"
)
# Sanity: BOTH path d-strings (hexagon + spokes) are present.
assert len(js_constants["d"]) == 2
assert js_constants["viewBox"] == "0 0 64 64"
def test_favicon_restore_is_the_route_url() -> None:
"""The restore sends the href back to EXACTLY
``/favicon.svg`` — the themed route (the settled palette
always wins; the route re-reads the DB per request, so the
restored URL is always fresh)."""
js = _js()
start = js.index("function restoreFavicon()")
end = js.index("}\n", start)
body = js[start:end]
assert re.findall(r'href\s*=\s*"([^"]*)"', body) == ["/favicon.svg"]
def test_favicon_restore_wired_to_the_four_settle_points() -> None:
"""The restore is wired to ALL four settle points — and to NO
other point (the count pin: the one definition + EXACTLY four
call sites):
* ``saveTheme()`` success — AFTER the
``applyServedTheme`` reconcile (in the refetch-success
branch — a failed refetch keeps the ``data:`` preview on
purpose: the picks ARE the saved palette);
* ``resetTheme()`` success — AFTER the reconcile;
* the ``resetTheme()`` FAILURE path — AFTER
``clearPreview()`` (the picks are stale);
* the ``bor:view-refresh`` re-show — AFTER its
``applyServedTheme``.
"""
js = _js()
assert js.count("restoreFavicon()") == 5 # definition + 4 call sites
def region(start_marker: str, end_marker: str) -> str:
s = js.index(start_marker)
e = js.index(end_marker, s + len(start_marker))
return js[s:e]
# Save success: after the reconcile.
save = region("async function saveTheme", "async function resetTheme")
assert save.count("restoreFavicon()") == 1
assert save.index("applyServedTheme(settings)") < save.index("restoreFavicon()")
# Reset: the success branch (after the reconcile) AND the
# failure path (after clearPreview — the picks are stale).
reset = region(
"async function resetTheme", 'root.addEventListener("bor:view-refresh"'
)
assert reset.count("restoreFavicon()") == 2
first = reset.index("restoreFavicon()")
second = reset.index("restoreFavicon()", first + 1)
assert reset.index("applyServedTheme(settings)") < first
assert reset.index("clearPreview()") < second
# The re-show (the phase-77 hook) — after its reconcile.
refresh = region(
'root.addEventListener("bor:view-refresh"',
"const settings = await loadSettings()",
)
assert refresh.count("restoreFavicon()") == 1
+113
View File
@@ -29,12 +29,21 @@ DB-over-built-in resolver shared by ``/api/ui-settings`` and
ellipsis. The :root 17-variable set itself stays pinned by ellipsis. The :root 17-variable set itself stays pinned by
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this ``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
phase adds no palette variable, B3). phase adds no palette variable, B3).
* Phase 177 (task 01): ``favicon_svg`` — the pure palette→artwork
renderer, byte-identical for the built-in palette (the golden
constant ``FAVICON_GOLDEN`` holds the pre-phase static file's
``<svg>`` element — the file is deleted in task 03, so the
constant is the durable contract); the three color roles per A1
(hexagon fill → ``surface``, hexagon stroke + center dot →
``brand``, spokes → ``brand_ink``); the 8 semantic state colors
are NOT in the artwork (a favicon has no state meaning).
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import re import re
import xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -572,3 +581,107 @@ def test_cell_clip_ellipsis_pin() -> None:
f".cell-clip white-space drifted: {rule.get('white-space')!r} — " f".cell-clip white-space drifted: {rule.get('white-space')!r} — "
f"the ellipsis needs the nowrap" f"the ellipsis needs the nowrap"
) )
# ---------------------------------------------------------------------------
# Phase 177 (task 01): favicon_svg — the pure palette→artwork renderer,
# byte-identical for the built-in palette (A1). The GOLDEN constant is
# the durable contract: it holds the pre-phase static file's ``<svg>``
# element (the file's comment header is file packaging, not art, and is
# NOT part of the contract; the file itself is deleted in task 03).
# ---------------------------------------------------------------------------
#: The pre-phase ``frontend/assets/favicon.svg`` ``<svg>`` element —
#: hexagon + center dot + 4 spokes, ``viewBox 0 0 64 64``, lowercase
#: hex, original attribute order. ``favicon_svg(BUILTIN_COLORS)`` must
#: equal this EXACTLY (341 bytes, single line, no comment header).
FAVICON_GOLDEN = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
'<path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" '
'stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/>'
'<circle cx="32" cy="32" r="6.5" fill="#f43f5e"/>'
'<path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" '
'stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/>'
"</svg>"
)
def _mask_color_attrs(svg_text: str) -> str:
"""The document with its EXACTLY FOUR color attributes (the
``fill="`` / ``stroke="`` values) masked — the rest (geometry,
``d`` paths, circle ``cx/cy/r``, stroke widths, ``linejoin`` /
``linecap``, attribute order) is then comparable byte for byte.
(``stroke-width`` / ``stroke-linejoin`` / ``stroke-linecap`` never
match — they carry ``-`` before the ``="``.)"""
return re.sub(r'(fill|stroke)="#[0-9a-f]{6}"', r'\1="COLOR"', svg_text)
def test_favicon_svg_builtins_is_byte_identical_to_the_static_art() -> None:
"""The byte-identical contract (D1): for the built-in palette the
output is EXACTLY the static file's ``<svg>`` element — lowercase
hex, same attribute order — pinned by the golden constant (the
durable contract; the static file is deleted in task 03, so the
constant, not the file, keeps the bytes alive)."""
out = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
assert out == FAVICON_GOLDEN
# Single-line element, no comment header (the file's comment is
# file packaging, not art), exact extent.
assert "\n" not in out
assert out.startswith('<svg xmlns="http://www.w3.org/2000/svg"')
assert out.endswith("</svg>")
# The three built-in role values land in the golden's roles.
assert 'fill="#1a0f0f"' in out # surface
assert out.count('"#f43f5e"') == 2 # brand (hexagon stroke + circle)
assert 'stroke="#fca5a5"' in out # brand_ink
def test_favicon_svg_themed_carries_the_three_roles() -> None:
"""A palette with distinct ``surface``/``brand``/``brand_ink``
(plus the other 14 built-ins): the three hexes appear in their
exact attribute roles — asserted by PARSING the SVG attributes
(not by substring) — and none of the other 14 built-in hexes
appears anywhere in the output."""
colors = dict(theming.BUILTIN_COLORS)
colors["surface"] = "#102030"
colors["brand"] = "#4f46e5"
colors["brand_ink"] = "#a5b4fc"
out = theming.favicon_svg(colors)
ns = "{http://www.w3.org/2000/svg}"
root = ET.fromstring(out)
assert root.tag == f"{ns}svg"
assert root.get("viewBox") == "0 0 64 64"
hexagon, circle, spokes = list(root)
# Hexagon: fill ← surface, stroke ← brand (geometry unchanged).
assert hexagon.tag == f"{ns}path"
assert hexagon.get("d") == "M32 4 55 18v28L32 60 9 46V18Z"
assert hexagon.get("fill") == "#102030"
assert hexagon.get("stroke") == "#4f46e5"
# Center dot: fill ← brand.
assert circle.tag == f"{ns}circle"
assert circle.get("fill") == "#4f46e5"
# Spokes: stroke ← brand_ink.
assert spokes.tag == f"{ns}path"
assert spokes.get("stroke") == "#a5b4fc"
# None of the other 14 built-in hexes appears in the output.
for key, value in theming.BUILTIN_COLORS.items():
if key not in ("surface", "brand", "brand_ink"):
assert value not in out, (
f"built-in {key} value {value} must not appear in a themed favicon"
)
def test_favicon_svg_geometry_is_invariant() -> None:
"""Geometry invariance: with a themed palette, everything EXCEPT
the four color attributes is byte-equal to the built-in output
(viewBox, both path ``d``s, circle ``cx/cy/r``, stroke widths,
``linejoin``/``linecap``, attribute order — the mask leaves only
the four ``fill="…"`` / ``stroke="…"`` values to differ)."""
colors = dict(theming.BUILTIN_COLORS)
colors["surface"] = "#102030"
colors["brand"] = "#4f46e5"
colors["brand_ink"] = "#a5b4fc"
themed = theming.favicon_svg(colors)
builtin = theming.favicon_svg(dict(theming.BUILTIN_COLORS))
assert _mask_color_attrs(themed) == _mask_color_attrs(builtin)
# Sanity: the mask masked exactly the four color attributes.
assert _mask_color_attrs(builtin).count('"COLOR"') == 4