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