phase: 177_themed_favicon
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user