chore(agent): phase roadmap from TODO.md (177 themed favicon)
LLM-Generated: true
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
# Phase 177 — The favicon's colors follow the effective theme palette
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "We need the favicon to change colors to match the theme."
|
||||||
|
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||||
|
**Context:** `frontend/assets/favicon.svg` (the current static art — a hexagon + center dot + 4 spokes whose three hardcoded colors map 1:1 onto the built-in palette: `#1a0f0f` = `surface`, `#f43f5e` = `brand`, `#fca5a5` = `brand_ink`), the five templates' L8 `<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">` (`frontend/index.html`, `frontend/document.html`, `frontend/login.html`, `frontend/shared.html`, `frontend/doc-edit.html`), `app/core/theming.py` (`BUILTIN_COLORS`, `COLOR_FIELDS`, `effective_settings()` — the DB-over-env/built-in resolver with NO process cache, `theme_style_tag()` + `inject_theme()` — the pre-paint tag pattern, `theme_csp_hash()`), `app/core/caching.py` (`CachingMiddleware`'s per-response short-lived-session theme read with the never-break DB-blip fallback; `/favicon.svg` is neither in `HTML_PAGES` nor under `/assets/` → passes through untouched), `app/main.py` `create_app()` (router includes end ~L199; the static catch-all mount at L252 — routes-first), `app/core/security_headers.py` (CSP `default-src 'self'; … img-src 'self' data:` — the phase-123 carve-out that makes a `data:` favicon URI legal; CSP3 governs `link[rel=icon]` by `img-src`), `frontend/assets/theme.js` (the admin Theme tab — `previewColor()` live preview, the `input` bindings, `saveTheme()`/`resetTheme()` + the `bor:view-refresh` re-show handler), `tests/unit/test_theming.py` (the static pin set incl. `test_builtin_colors_match_styles_css_root`), `tests/unit/test_theme_frontend.py` (the JS static-pin pattern), `tests/e2e/test_theme_save_and_coverage.py` + `test_theme_semantic_completion.py` (the admin theme-tab E2E pattern: `e2e/auth_helpers.login(page, app_url, next="/theme.html")`, the `#theme-*` inputs), AGENTS rules 4/6/8/9/10, PLAN §7.4 (the never-stale feedback contract).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The browser-tab favicon must carry the EFFECTIVE theme palette (built-in or the owner's saved one), painted on first load like everything else — no red hexagon on a graphite theme. The favicon is served by the app from the same resolver that paints the page (`effective_settings`), and the admin Theme tab's live (unsaved) picks repaint it too (owner-confirmed A4).
|
||||||
|
|
||||||
|
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||||
|
- **D1 — the pure renderer (task 01, A1).** `theming.favicon_svg(colors: dict[str, str]) -> str` — the SAME artwork (viewBox `0 0 64 64`, the hexagon path, the r=6.5 center circle, the 4 spokes, all stroke widths/caps unchanged) with only the three color roles swapped: hexagon `fill` → `surface`, hexagon `stroke` + circle `fill` → `brand`, spokes `stroke` → `brand_ink`. The input is the full 20-value effective dict (mirroring `theme_style_tag`'s self-contained signature; the function reads its three keys). **Byte-identical contract:** for the built-in palette the output is EXACTLY the current static file's `<svg>` element (lowercase hex, same attribute order) — pinned by a 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.
|
||||||
|
- **D2 — serving (task 02, A2/A3).** `GET /favicon.svg` (+ HEAD for static-file parity) — a no-prefix route in `create_app()` after the router includes and BEFORE the static catch-all mount (routes-first, the phase-51 shared-page pattern). Sync handler: one short-lived `SessionLocal()` session (the `caching.py` house pattern — NO process cache, so a runtime theme change is visible on the next request), `effective_settings()` → `favicon_svg()`; ANY exception → `logger.exception` + `favicon_svg(BUILTIN_COLORS)` (200, never 500 — the theme-tag's never-break contract, `caching.py`'s DB-blip pattern). Response: `media_type="image/svg+xml"`, `Cache-Control: no-cache` (the pages are no-cache; the palette changes at runtime, so the browser must revalidate — the route is NOT under `/assets/`, so no version token; freshness is revalidation, not busting). Deliberately OUTSIDE `/api/*`: no per-response byte-budget line (AGENTS rule 10 is `/api/*`-only), no auth dependency (the favicon is chrome, like the login page). `CachingMiddleware` passes it through untouched (neither an HTML page nor an asset); `SecurityHeadersMiddleware` still adds the CSP — no change needed (`img-src 'self' data:` covers the D4 preview).
|
||||||
|
- **D3 — the templates + the static file's deletion (task 03, A2).** All five L8 links → `href="/favicon.svg"`; `frontend/assets/favicon.svg` is **deleted** — the route is the single source (the built-in bytes stay pinned by D1's golden constant, so nothing is lost). The new href is not under `/assets/`, so `CachingMiddleware`'s `?v=` rewrite regex can never touch it (a pin keeps this explicit — the link stays bare).
|
||||||
|
- **D4 — the Theme tab live preview (task 04, A4 — owner-confirmed 2026-10-13).** `theme.js` gains a pure `themeFaviconDataUri(surface, brand, brandInk)` — the CLIENT-SIDE MIRROR of `theming.favicon_svg` (same artwork, same roles; a unit pin keeps the two from diverging — the `PAIRS`-mirror house pattern). The existing per-color `input` binding ALSO updates `link[rel="icon"].href` to the `data:` URI whenever one of the three driving fields (`surface`/`brand`/`brand_ink`) changes (rebuilt from the three inputs' current values; the other 14 picks never touch it). **Restore to `"/favicon.svg"`** on: Save success (after `applyServedTheme`), Reset success, the Reset-failure `clearPreview()` path, and the `bor:view-refresh` re-show — after every restore the SETTLED palette (re-read per request, no process cache) governs. CSP note (commented in the code): `data:` is legal for the favicon via the phase-123 `img-src 'self' data:` carve-out (CSP3: `link[rel=icon]` is governed by `img-src`; the carve-out is image-scoped — `data:` never becomes a script/style/fetch source).
|
||||||
|
- **NOT touched:** `CachingMiddleware`, the `#bor-theme` tag injection + CSP hash, the 8 semantic state colors (they are NOT in the artwork — a favicon has no state meaning), `app/` outside `theming.py`/`main.py`, `brand.js`, the other templates/assets, the `theme.js` save/reset/contrast machinery beyond the additions above.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `176_mobile_settings_value_width` (todo) — the immediately preceding phase (Protocol B ordering; no functional dependency).
|
||||||
|
- `92_theme_save_and_coverage` (complete) — the served-theme sync + the `#theme-*` input contract the D4 additions bind to.
|
||||||
|
- `91_admin_theme_tab` (complete) — the Theme tab's live-preview mechanism (`previewColor`) and the `bor:view-refresh` re-show hook.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_favicon_svg_renderer.md` — `theming.favicon_svg()`: the pure palette→artwork renderer, byte-identical for the built-in palette (golden pin) — A1.
|
||||||
|
2. `02_favicon_route.md` — `GET /favicon.svg` (+ HEAD) in `create_app()`: effective palette, built-in fallback on DB failure (200, never 500), `image/svg+xml` + `no-cache` — A2/A3.
|
||||||
|
3. `03_template_link_swap.md` — the five template links → `/favicon.svg`; `frontend/assets/favicon.svg` deleted (single source).
|
||||||
|
4. `04_theme_tab_live_preview.md` — the Theme tab's live picks repaint the favicon (the CSP-legal `data:` mirror, A4); Save/Reset/re-show restore the route URL; the dedicated E2E suite + the atomic commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- **Unit:** `tests/unit/test_theming.py` (task 01): the golden byte-identical pin, the themed-role pin (the three hexes in their exact roles), the geometry-invariance pin (only the 4 color attributes differ); `tests/unit/test_theme_frontend.py` (task 04): the `data:` mechanism pin (the three driving field names, the exact `/favicon.svg` restore, the restore wiring).
|
||||||
|
- **Integration:** `tests/integration/test_favicon_route.py` (tasks 02–03): the three route states (default = golden bytes, themed = effective colors, DB failure = built-in fallback 200), the CSP header presence, the five template link pins (bare `/favicon.svg`, no `?v=`, no `/assets/favicon.svg` anywhere in the served HTML), the deleted file's 404.
|
||||||
|
- **E2E (AGENTS rule 4, task 04):** `tests/e2e/test_themed_favicon.py`, run **in isolation** — the full contract in a real browser (default/themed route bytes, the pre-paint tag non-regression, the live-preview `data:` repaint, the restore contract, zero external requests — rule 6).
|
||||||
|
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`); **lint/types:** `uv run ruff check . && uv run pyright`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `GET /favicon.svg` on a default (no ui_settings row) deployment is 200 `image/svg+xml` with `Cache-Control: no-cache` and a body byte-identical to the current static art (the golden pin, the integration test).
|
||||||
|
- [ ] With a saved non-default palette (distinct `surface`/`brand`/`brand_ink`), the route's SVG carries those three hexes in their exact roles, and the served pages STILL inject the `#bor-theme` tag (the phase-91 pin stays green — no regression to the tag/CSP contract).
|
||||||
|
- [ ] A DB failure at request time serves the built-in art with 200 (never 500).
|
||||||
|
- [ ] All five templates link `href="/favicon.svg"` (bare — no `?v=`), `/assets/favicon.svg` is 404 (the file is deleted), and the favicon actually renders in the browser (the E2E).
|
||||||
|
- [ ] In the admin Theme tab, a live (unsaved) pick of `surface`/`brand`/`brand_ink` repaints the favicon via the `data:` URI; Save / Reset / re-show restore `href="/favicon.svg"` with the settled palette governing (the E2E).
|
||||||
|
- [ ] `uv run pytest` green, `app/` coverage >90%, ruff + pyright clean.
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` green **in isolation**; `test_theme_save_and_coverage.py` + `test_theme_semantic_completion.py` (phases 92/93) green in isolation (no theme-tab regression).
|
||||||
|
- [ ] ONE atomic Conventional Commits commit (AGENTS rule 8, `--no-gpg-sign`) covering the phase + this directory + the cleared TODO.md.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Task 01 — The pure favicon renderer in `theming.py`
|
||||||
|
|
||||||
|
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||||
|
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A pure, unit-testable renderer that produces the favicon artwork from the effective palette — the same art as today's static `frontend/assets/favicon.svg`, byte-identical for the built-in palette.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/core/theming.py` — add `favicon_svg(colors: dict[str, str]) -> str` next to `theme_style_tag()`. The output is EXACTLY the `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">…</svg>` element (no comment header — the static file's comment is file packaging, not art). The artwork is unchanged: the hexagon path (`d="M32 4 55 18v28L32 60 9 46V18Z"`, `stroke-width="4"`, `stroke-linejoin="round"`), the center `<circle cx="32" cy="32" r="6.5">`, the spokes path (`d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5"`, `stroke-width="3"`, `stroke-linecap="round"`). Only the three color roles swap, per the A1 mapping: hexagon `fill` ← `colors["surface"]`, hexagon `stroke` ← `colors["brand"]`, circle `fill` ← `colors["brand"]`, spokes `stroke` ← `colors["brand_ink"]`. The input is the full 20-value effective dict (mirroring `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 `effective_settings`/`BUILTIN_COLORS` contract guarantees the values). Docstring: the A1 role table, the byte-identical contract, and the "no state colors" note (the 8 semantic state colors are not in the artwork — a favicon has no state meaning).
|
||||||
|
- ASSUMPTION: A1 (owner-confirmed) — the color mapping is hexagon fill→`surface`, hexagon stroke + center dot→`brand`, spokes→`brand_ink`; only these three identity colors drive the favicon.
|
||||||
|
2. `tests/unit/test_theming.py` — extend the pin set:
|
||||||
|
- `test_favicon_svg_builtins_is_byte_identical_to_the_static_art` — `favicon_svg(BUILTIN_COLORS)` equals a GOLDEN constant holding the current static file's `<svg>` element bytes. During this task's execution, verify the golden constant against the current `frontend/assets/favicon.svg` file (a one-time sanity check — the file is deleted in task 03, so the golden constant, not the file, is the durable contract).
|
||||||
|
- `test_favicon_svg_themed_carries_the_three_roles` — a palette with distinct `surface`/`brand`/`brand_ink` (e.g. `#102030` / `#4f46e5` / `#a5b4fc`) plus the other 14 built-ins: the three hexes appear in their exact attribute roles (assert by parsing the attributes, not by substring), and none of the other 14 hexes appears.
|
||||||
|
- `test_favicon_svg_geometry_is_invariant` — 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).
|
||||||
|
3. One-time sanity: diff the golden constant against `frontend/assets/favicon.svg`'s `<svg>` element and log the result (evidence for the hand-off; not a persistent test).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the three pins above (`tests/unit/test_theming.py`), run with `uv run pytest tests/unit/test_theming.py -v`.
|
||||||
|
- Coverage: **>90%** on this task's new code (a pure function — the pins drive it to ~100%).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/unit/test_theming.py -v` green (new + existing pins).
|
||||||
|
- [ ] The one-time golden-vs-file sanity check passes (logged).
|
||||||
|
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||||
|
- [ ] No behavior change in completed work (a new function with no existing call site).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Task 02 — Serve the themed favicon: `GET /favicon.svg`
|
||||||
|
|
||||||
|
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||||
|
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The favicon over the wire: a route that renders the artwork from the EFFECTIVE palette on every request (no process cache — a runtime theme change is visible on the next request), with the never-break DB-blip fallback.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/main.py` — in `create_app()`, after the router includes (ending at the `chats_shared_page_router` line ~L200) and BEFORE the static catch-all mount (`app.mount("/", …)` at L252, routes-first): register `app.api_route("/favicon.svg", methods=["GET", "HEAD"], include_in_schema=False)` (HEAD for static-file parity, the phase-76 `_shell_routes` parity idiom). The handler is a sync `def` (the house pattern for short-lived-session reads):
|
||||||
|
- open a short-lived `SessionLocal()` session (imported from `app.db`, the `caching.py` middleware's exact pattern — NO process cache, `try/finally` close);
|
||||||
|
- `effective = theming.effective_settings(db)` → `svg = theming.favicon_svg(effective)`;
|
||||||
|
- ANY exception (DB blip, pre-migration boot): `logger.exception("favicon: theme read failed — serving the built-in favicon")` and `svg = theming.favicon_svg(theming.BUILTIN_COLORS)` — **200, never 500** (the theme-tag's never-break contract, `caching.py`'s DB-blip pattern);
|
||||||
|
- return `Response(svg, media_type="image/svg+xml", headers={"Cache-Control": "no-cache"})`.
|
||||||
|
- Commented rationale in the code (house style): (a) the route is 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); (b) `CachingMiddleware` passes it through untouched (neither in `HTML_PAGES` nor under `/assets/`) → the route's own `no-cache` is the freshness contract (the palette changes at runtime; the page is no-cache too, so the browser revalidates together with it); (c) `SecurityHeadersMiddleware` still adds the CSP — no change needed (`img-src 'self' data:` covers task 04's `data:` preview).
|
||||||
|
- ASSUMPTION: A2 (owner-confirmed) — serving shape: a new dynamic route `GET /favicon.svg` replaces the static file (task 03 deletes it).
|
||||||
|
- ASSUMPTION: A3 (owner-confirmed) — fault tolerance: a DB failure / pre-migration boot serves the built-in palette with 200 (never 500).
|
||||||
|
2. `tests/integration/test_favicon_route.py` — NEW, via the house `TestClient` fixture (follow the `tests/integration/test_ui_settings.py` app/db fixture pattern):
|
||||||
|
- **default state** (no `ui_settings` row): `GET /favicon.svg` → 200, `content-type: image/svg+xml`, `cache-control: no-cache`, body byte-identical to the golden art constant (import it from `tests/unit/test_theming.py` — one source), and the response carries the CSP header (the `SecurityHeadersMiddleware` pass-through).
|
||||||
|
- **themed state**: a `ui_settings` row (id 1) with distinct `surface`/`brand`/`brand_ink` (e.g. `#102030` / `#4f46e5` / `#a5b4fc`): the body carries the three hexes in their roles (attribute-parsed, same helper idiom as the unit pins) and none of the other 14; a second request after updating the row reflects the new palette immediately (no process cache — the runtime-change contract).
|
||||||
|
- **DB-failure state**: patch `effective_settings` (or `SessionLocal`) to raise → 200 + the built-in golden bytes (never 500).
|
||||||
|
- **HEAD parity**: `HEAD /favicon.svg` → 200, the same headers, empty body.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the four states above (`tests/integration/test_favicon_route.py`), run with `uv run pytest tests/integration/test_favicon_route.py -v`.
|
||||||
|
- Coverage: **>90%** on this task's new/modified code (the handler's success + fallback branches both driven).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/integration/test_favicon_route.py -v` green (all four states).
|
||||||
|
- [ ] The route wins over the static mount (a file named `favicon.svg` in the static dir could not shadow it — the routes-first position holds).
|
||||||
|
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||||
|
- [ ] No behavior change in completed work (a new route only; `/assets/favicon.svg` still serves until task 03 deletes it).
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Task 03 — Point all five templates at the route; delete the static file
|
||||||
|
|
||||||
|
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||||
|
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Every page asks for the themed favicon from the route, and the static file stops existing — the route is the single source (the built-in bytes stay pinned by task 01's golden constant, so nothing is lost).
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/index.html`, `frontend/document.html`, `frontend/login.html`, `frontend/shared.html`, `frontend/doc-edit.html` — L8 in each file: `<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">` → `<link rel="icon" type="image/svg+xml" href="/favicon.svg">`. Nothing else in any template changes.
|
||||||
|
2. Delete `frontend/assets/favicon.svg` (A2, owner-confirmed — the single-source decision; the one-time golden-vs-file sanity was done in task 01, so the deletion loses no bytes: the golden constant in `tests/unit/test_theming.py` is the durable contract).
|
||||||
|
3. `tests/integration/test_favicon_route.py` — extend with the template pins:
|
||||||
|
- each of the five served page bodies (`/`, `/document.html`, `/login.html`, `/shared.html`, `/doc-edit.html` via `TestClient`) contains `<link rel="icon" type="image/svg+xml" href="/favicon.svg">` and does NOT contain `/assets/favicon.svg`;
|
||||||
|
- the favicon link in the served HTML 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);
|
||||||
|
- `GET /assets/favicon.svg` → 404 (the file is gone);
|
||||||
|
- a tree scan of `frontend/` (the test walks the directory, the house idiom) asserts zero remaining references to `/assets/favicon.svg`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Integration: the template pins above, run with `uv run pytest tests/integration/test_favicon_route.py -v`.
|
||||||
|
- Coverage: **>90%** on `app/` (no new `app/` code in this task — the gate must stay green, not drop).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] All five templates link `href="/favicon.svg"`; the tree scan finds zero `/assets/favicon.svg` references.
|
||||||
|
- [ ] `GET /assets/favicon.svg` is 404; `GET /favicon.svg` still serves the golden bytes (the route is the single source now).
|
||||||
|
- [ ] In a running app, a browser tab loads the favicon from `/favicon.svg` (final browser proof in task 04's E2E).
|
||||||
|
- [ ] Full test suite green; `app/` coverage >90%; ruff + pyright clean.
|
||||||
|
- [ ] No behavior change in completed work (the favicon art is byte-identical on a default deployment — the golden pin holds).
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Task 04 — Theme-tab live preview + the dedicated E2E suite + the commit
|
||||||
|
|
||||||
|
**Phase:** `177_themed_favicon` · **Source:** `TODO.md:3` — "We need the favicon to change colors to match the theme."
|
||||||
|
**Story:** n/a (TODO-derived, owner roadmap confirmation 2026-10-13)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The admin Theme tab's live (unsaved) picks repaint the favicon too (owner-confirmed A4), the settled palette always wins after Save/Reset/re-show — and the phase's dedicated Playwright suite proves the whole contract in a real browser.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/theme.js` —
|
||||||
|
- a pure helper `themeFaviconDataUri(surface, brand, brandInk)` → `"data:image/svg+xml," + encodeURIComponent(svg)`, where `svg` is the CLIENT-SIDE MIRROR of `theming.favicon_svg` (the same artwork, the same A1 roles: hexagon fill ← `surface`, hexagon stroke + circle fill ← `brand`, spokes stroke ← `brand_ink`). Guard with the existing `isHex` idiom: a non-hex input returns `null` (the link is left untouched — the color inputs are always valid hex, the guard is belt-and-braces; 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).
|
||||||
|
- in the existing per-color `input` bindings (the live-preview loop): when the field is one of the three driving fields (`surface`, `brand`, `brand_ink`), ALSO set the `link[rel="icon"]` element's `href` to `themeFaviconDataUri(<the three inputs' current values>)` (rebuilt, not patched — a `surface` pick repaints with the current `brand`/`brand_ink` picks). The other 14 picks never touch the link.
|
||||||
|
- **restore to `"/favicon.svg"`** in the four settle points: `saveTheme()` success (after `applyServedTheme(settings)`), `resetTheme()` success (after the reconcile), the `resetTheme()` failure path (where `clearPreview()` runs — the picks are stale), and the `bor:view-refresh` re-show handler (after its `applyServedTheme`). After every restore the SETTLED palette governs — the route re-reads the DB per request (no process cache), so the restored URL is always fresh; the page is `no-cache`, so the browser revalidates the favicon with it.
|
||||||
|
- CSP comment (house style, per D4): `data:` is legal for the favicon via the phase-123 `img-src 'self' data:` carve-out (CSP3 governs `link[rel=icon]` by `img-src`; the carve-out is image-scoped — `data:` never becomes a script/style/fetch source).
|
||||||
|
- file-header docstring: extend with the favicon-preview paragraph (the mechanism, the four restore points, the CSP note — the theme.js header documents every behavior).
|
||||||
|
- ASSUMPTION: A4 (owner-confirmed 2026-10-13) — the live (unsaved) picks repaint the favicon via the CSP-legal `data:` URI; the settled palette always wins after Save/Reset/re-show. (The simpler alternative — saved palette only, no `data:` — was considered and declined.)
|
||||||
|
2. `tests/unit/test_theme_frontend.py` — extend the static pin set (the house JS-pin idiom — the pins parse `theme.js`'s source):
|
||||||
|
- the `data:image/svg+xml` URI mechanism is present (the literal prefix, `encodeURIComponent`);
|
||||||
|
- the favicon helper reads EXACTLY the three field names `surface`/`brand`/`brand_ink` (the driving-set pin — a fourth field or a missing one fails the pin);
|
||||||
|
- the restore string is EXACTLY `"/favicon.svg"` and is wired to all four settle points (save success, reset success, reset-failure `clearPreview` path, `bor:view-refresh`);
|
||||||
|
- the mirror's artwork constants match the server's (the viewBox + both path `d` strings + circle `r` — the divergence guard, the `PAIRS`-mirror house pattern).
|
||||||
|
3. `tests/e2e/test_themed_favicon.py` — NEW, the phase's dedicated Playwright suite (AGENTS rule 4), run in isolation, following the `test_theme_save_and_coverage.py` fixture idiom (`app_server`/`app_url`/`db_ready`, `e2e/auth_helpers.login(page, app_url, next="/theme.html")`, the `_clean` ui_settings reset):
|
||||||
|
- **default state:** `GET /favicon.svg` → 200 + the golden art bytes (fetched in the test); the five pages' HTML reference `/favicon.svg`; `/assets/favicon.svg` → 404;
|
||||||
|
- **themed state:** admin login → `PUT /api/ui-settings` with a distinct palette (e.g. `surface #102030`, `brand #4f46e5`, `brand_ink #a5b4fc`): the route's SVG carries the three hexes in their roles; a fresh page load STILL injects `#bor-theme` with the expected content (the phase-91 non-regression pin — the tag/CSP contract is untouched);
|
||||||
|
- **live preview:** admin opens `/theme.html` (the `#view-theme` view), sets the `#theme-surface` / `#theme-brand` / `#theme-brand-ink` inputs (the `input` event, the house `_fill_theme_form` idiom) → `link[rel="icon"]`'s href starts with `data:image/svg+xml` and, decoded, carries the three picked hexes in their roles; a non-driving pick (e.g. `#theme-bg`) does not change the href;
|
||||||
|
- **restore contract:** click **Save theme** → the href is EXACTLY `/favicon.svg` and the route now serves the saved palette's bytes; click **Reset to defaults** → the href is `/favicon.svg` and the route serves the built-in golden bytes; re-showing the tab (the `bor:view-refresh` path) restores the href too;
|
||||||
|
- **zero external requests** across the whole suite (AGENTS rule 6 — the house E2E assertion idiom: a `page.on("request")` collector asserting every URL is the local origin / a `data:` URI).
|
||||||
|
4. The atomic commit (AGENTS rule 8): ONE Conventional Commits commit, e.g. `feat(theming): serve the favicon from the effective theme palette`, `--no-gpg-sign` — covering the `app/` + `frontend/` + `tests/` changes, this phase directory (`.agents/` is tracked), and the cleared `TODO.md`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit: the JS static pins (`tests/unit/test_theme_frontend.py`), run with `uv run pytest tests/unit/test_theme_frontend.py -v`.
|
||||||
|
- E2E: `tests/e2e/test_themed_favicon.py` run **in isolation** — `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov`.
|
||||||
|
- Coverage: **>90%** on `app/` (the JS is gated by the pins + the browser E2E — the house idiom); ruff + pyright clean.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `uv run pytest tests/unit/test_theme_frontend.py -v` green (new pins).
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_themed_favicon.py -v --no-cov` green **in isolation** (all six contract blocks).
|
||||||
|
- [ ] `test_theme_save_and_coverage.py` (phase 92) + `test_theme_semantic_completion.py` (phase 93) green in isolation (no Theme-tab regression).
|
||||||
|
- [ ] `uv run pytest` green, `app/` coverage >90% (`uv run pytest --cov=app --cov-report=term-missing`), `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] The atomic commit is landed (Conventional Commits, `--no-gpg-sign`, `.agents/` + the cleared `TODO.md` included).
|
||||||
Reference in New Issue
Block a user