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
+65 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
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.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.ui_settings import router as ui_settings_router
from app.config import get_settings, invalidate_settings
from app.core import theming
from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching
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_overlay import enabled as settings_db_enabled
from app.core.settings_overlay import load_overrides, register_invalidator
from app.db import SessionLocal
configure_logging()
configure_debugging()
@@ -96,6 +98,55 @@ def _shell_routes(app: FastAPI, static_dir: Path, paths: tuple[str, ...]) -> Non
)(_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:
# Fail loud BEFORE serving anything (phase 16): missing
# 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_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)
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
# Added after the session middleware, so it wraps the whole app