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
+188 -1
View File
@@ -32,12 +32,23 @@ frontend sources and asserts on their mechanisms):
``BUILTIN_COLORS``), never a third hardcoded palette copy;
* the "State colors" fieldset sits AFTER the palette fieldset (the
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
import re
from pathlib import Path
from typing import TypedDict
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
else:
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
``test_builtin_colors_match_styles_css_root`` (UNCHANGED — this
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
import base64
import hashlib
import re
import xml.etree.ElementTree as ET
from pathlib import Path
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"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