fix(icons): match portrait placement and join contrasting borders

This commit is contained in:
ethernet
2026-09-13 22:51:55 -04:00
parent 209a43335c
commit da9f6d2dcd
2 changed files with 146 additions and 24 deletions
+69 -21
View File
@@ -18,8 +18,8 @@ Sources of truth — two axes, composed per target:
assets/backgrounds/squircle-mac-dark.svg mac HIG grid
The master SVGs (assets/icon-master.svg light, assets/icon-master-dark.svg
dark) are GENERATED artifacts — squircle background + girl nested into the
824px HIG content safe zone. The light master drives every squircle target;
dark) are GENERATED artifacts — squircle background + scaled girl artwork.
The light master drives every squircle target;
the dark master drives the dark-appearance targets. macOS is the exception:
its icns targets render from an in-memory mac master that puts the same
squircle on Apple's 824x824 (r=185.4) grid — centered in 1024 with 100px
@@ -31,10 +31,10 @@ and a seven-character SHA badge. The girl and tile geometry do not change.
Only apps/desktop outputs use this identity. Website, bootstrap, dashboard,
and the shared master SVGs retain the default brand.
The girl is nested via its art bbox as viewBox, so it always lands centered in
the box (824 safe zone for squircles / height-fitted for the marks) without
distortion. The girl art corners sit ~185px from the squircle arc centers vs
the 245px radius, so no art touches the rounded corners on any OS mask.
The girl's position and uniform scale are registered to the reference artwork.
She renders in front of the border, clipped only to the outer rounded silhouette.
Only her bottom sliver extends to the border; the fitted face and hair stay fixed.
Standalone wordmarks remain centered and have no border.
GENERATED OUTPUTS ARE NOT COMMITTED. Everything this script writes is
gitignored and regenerated on demand by the consuming pipelines (website
@@ -93,6 +93,7 @@ import io
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from PIL import Image
@@ -108,17 +109,16 @@ except ImportError:
# The nous dark background (#0d1117) — fixed dark tile/background everywhere.
DARK_HEX = "#0d1117"
DARK_RGB = (13, 17, 23)
BORDER_FRACTION = 0.0407747197
# Girl placement per background: (x, y, w, h) in that background's coordinate
# space. Full-bleed squircles put the girl in the 824px HIG content safe zone
# (centered, 100px pad on a 1024 canvas); the mac-grid squircle is itself 824
# on 1024, so the girl box scales by 824/1024 to keep the same relative size
# inside the shape. Marks reuse the full-bleed squircles.
# Portrait boxes fitted to the reference at equal visible tile width, with
# uniform scaling about the tile center followed by an up-left translation.
# Keep their y coordinate: bottom anchoring would undo the registration.
GIRL_BOXES = {
"squircle-light.svg": (100, 100, 824, 824),
"squircle-dark.svg": (100, 100, 824, 824),
"squircle-mac-light.svg": (180.5, 180.5, 663, 663),
"squircle-mac-dark.svg": (180.5, 180.5, 663, 663),
"squircle-light.svg": (72.149433, 104.703674, 872.767801, 872.767801),
"squircle-dark.svg": (72.149433, 104.703674, 872.767801, 872.767801),
"squircle-mac-light.svg": (157.949166, 184.039504, 702.522501, 702.522501),
"squircle-mac-dark.svg": (157.949166, 184.039504, 702.522501, 702.522501),
}
# The brand-kit SVG canvas (both girl svgs share this viewBox).
GIRL_VIEWBOX = 5487.0615
@@ -235,13 +235,17 @@ def girl_bbox(art: IconArt, girl: str) -> tuple[float, float, float, float]:
return art.bboxes[girl]
def girl_layer(art: IconArt, girl: str, box: tuple[float, float, float, float]) -> str:
def girl_layer(
art: IconArt, girl: str, box: tuple[float, float, float, float],
*, align: str = "xMidYMid",
) -> str:
"""Nested-svg layer: girl art (bbox as viewBox) placed into `box` — the
box's aspect is preserved via 'meet', so the girl never distorts."""
bx, by, bw, bh = girl_bbox(art, girl)
x, y, w, h = box
return (
f'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}">\n'
f'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}" '
f'preserveAspectRatio="{align} meet">\n'
f" {girl_path(art, girl)}\n"
" </svg>"
)
@@ -253,11 +257,13 @@ def background_inner(art: IconArt, name: str) -> tuple[str, int, int]:
if art.colors:
text = text.replace('fill="#ffffff"', f'fill="{art.colors[0]}"')
text = text.replace(f'fill="{DARK_HEX}"', f'fill="{art.colors[1]}"')
m = re.search(r'<svg\b[^>]*viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"[^>]*>', text)
root = ET.fromstring(text)
m = re.fullmatch(r"0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)", root.get("viewBox", ""))
assert m, f"cannot parse viewBox of {name}"
w, h = float(m.group(1)), float(m.group(2))
inner = re.sub(r"^.*?>\s*", "", text, count=1, flags=re.S)
inner = re.sub(r"\s*</svg>\s*$", "", inner, flags=re.S)
# Editor exports include XML declarations and root-scoped namespaces.
# Parse away the prolog and retain child namespaces when embedding.
inner = "".join(ET.tostring(child, encoding="unicode") for child in root)
return inner, int(w), int(h)
@@ -304,12 +310,54 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str:
coordinate space (resvg scales to whatever output size is requested, so
the composition is size-agnostic — no manual box scaling)."""
inner, w, h = background_inner(art, bg)
background = ET.fromstring(f"<g>{inner}</g>")
tile = background.find("{http://www.w3.org/2000/svg}rect")
assert tile is not None, f"no background rectangle in {bg}"
geometry = {key: float(tile.attrib[key]) for key in ("x", "y", "width", "height", "rx")}
thickness = geometry["width"] * BORDER_FRACTION
# An inward stroke keeps the outer platform geometry unchanged. Subtracting
# the same inset from rx (not scaling rx) keeps the corner thickness uniform.
inset = {"x": 1, "y": 1, "width": -2, "height": -2, "rx": -1}
silhouette = ET.Element("rect", {key: str(value) for key, value in geometry.items()})
for key, value in geometry.items():
tile.set(key, str(value + inset[key] * thickness / 2))
tile.set("stroke", "#000000" if girl == "black" else "#ffffff")
tile.set("stroke-width", str(thickness))
inner = "".join(ET.tostring(child, encoding="unicode") for child in background)
clip = ET.tostring(silhouette, encoding="unicode")
box = GIRL_BOXES[bg]
portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax"))
x, y, portrait_width, portrait_height = box
# Extrude a thin slice just above the bottom contour behind the artwork.
# Sampling above its antialiased tips avoids stretching transparent padding.
strip_height = 1
strip_top = y + portrait_height - portrait_height * 0.01
join_bottom = geometry["y"] + geometry["height"] - thickness + 4
stretch = (join_bottom - strip_top) / strip_height
bx, by, bw, bh = girl_bbox(art, girl)
scale = min(portrait_width / bw, portrait_height / bh)
tx = x + (portrait_width - bw * scale) / 2 - bx * scale
ty = y + portrait_height - bh * scale - by * scale
# Flatten the viewport transform: very thin nested SVG viewBoxes can be
# culled by the renderer at small icon sizes, silently losing the extension.
extension_path = ET.fromstring(girl_path(art, girl))
extension_path.attrib.pop("id", None)
join_clip = (
f'<clipPath id="icon-join"><rect x="{x}" y="{strip_top}" '
f'width="{portrait_width}" height="{join_bottom - strip_top}"/></clipPath>'
)
extension = (
'<g clip-path="url(#icon-join)">'
f'<g transform="matrix({scale} 0 0 {scale * stretch} {tx} {stretch * ty + (1 - stretch) * strip_top})">'
f'{ET.tostring(extension_path, encoding="unicode")}</g></g>'
)
badge = f" {commit_layer(art.commit, bg)}\n" if art.commit else ""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">\n'
f' <defs><clipPath id="icon-silhouette">{clip}</clipPath>{join_clip}</defs>\n'
f" {inner.strip()}\n"
f" {girl_layer(art, girl, GIRL_BOXES[bg])}\n"
f"{badge}"
f' <g clip-path="url(#icon-silhouette)">{extension}{ET.tostring(portrait, encoding="unicode")}</g>\n'
"</svg>\n"
)
+77 -3
View File
@@ -2,6 +2,7 @@
import importlib.util
import io
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from types import ModuleType, SimpleNamespace
@@ -10,7 +11,8 @@ from PIL import Image
@pytest.mark.parametrize("bom", [b"", b"\xef\xbb\xbf"])
def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, bom):
@pytest.mark.parametrize("editor_export", [False, True])
def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch, bom, editor_export):
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
script = Path(__file__).resolve().parents[2] / "scripts/generate_icons.py"
spec = importlib.util.spec_from_file_location("icon_readers_under_test", script)
@@ -19,11 +21,20 @@ def test_svg_readers_accept_bom_without_rewriting_assets(tmp_path, monkeypatch,
spec.loader.exec_module(module)
path = tmp_path / "art.svg"
element = '<path d="M0 0 L1 1" aria-label="café 東京"/>'
raw = bom + f'<svg viewBox="0 0 20 30">{element}</svg>'.encode("utf-8")
declaration = '<?xml version="1.0" encoding="UTF-8"?>\n' if editor_export else ""
namespaces = ' xmlns="http://www.w3.org/2000/svg" xmlns:editor="urn:editor"'
metadata = '<editor:namedview editor:zoom="1"/>' if editor_export else ""
document = f'<svg{namespaces} viewBox="0 0 20 30">{metadata}{element}</svg>'
raw = bom + (declaration + document).encode("utf-8")
path.write_bytes(raw)
art = SimpleNamespace(girls={"black": path}, paths={}, backgrounds=tmp_path, colors=None)
assert module.girl_path(art, "black") == element
assert module.background_inner(art, path.name) == (element, 20, 30)
inner, width, height = module.background_inner(art, path.name)
composed = ET.fromstring(f'<svg xmlns="http://www.w3.org/2000/svg">{inner}</svg>')
assert (width, height) == (20, 30)
assert [ET.tostring(child) for child in composed] == [
ET.tostring(child) for child in ET.fromstring(document)
]
assert path.read_bytes() == raw
path.write_bytes(bom + b"<svg/>")
art.paths.clear()
@@ -75,3 +86,66 @@ def test_write_status_includes_every_target(tmp_path, monkeypatch, capsys, failu
output = capsys.readouterr().out
assert "last.png: PNG (2, 2)" in output
assert ("FAILED" in output) is (failure is not None)
@pytest.mark.parametrize("platform", ["", "mac-"])
@pytest.mark.parametrize("appearance,girl", [("light", "black"), ("dark", "white")])
@pytest.mark.parametrize("colors", [None, ("#f5cc32", "#443808"), ("#e34850", "#4a1117")])
def test_icon_portrait_overlays_border_inside_outer_silhouette(monkeypatch, platform, appearance, girl, colors):
monkeypatch.setitem(sys.modules, "resvg_py", ModuleType("resvg_py"))
source = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("icon_geometry_under_test", source / "scripts/generate_icons.py")
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
art = SimpleNamespace(
backgrounds=source / "assets/backgrounds", colors=colors, commit="0123456",
bboxes={girl: (0, 0, 100, 90)}, paths={girl: '<path d="M0 0H100V90H0Z"/>'},
)
name = f"squircle-{platform}{appearance}.svg"
ns = {"svg": "http://www.w3.org/2000/svg"}
original = ET.parse(art.backgrounds / name).find("svg:rect", ns)
result = ET.fromstring(module.compose_svg(art, girl, name))
tile = result.find("svg:rect", ns)
assert original is not None and tile is not None
x, y, width, height, radius = (float(original.attrib[key]) for key in ("x", "y", "width", "height", "rx"))
thickness = width * module.BORDER_FRACTION
assert float(tile.attrib["stroke-width"]) == pytest.approx(thickness)
assert tile.get("stroke") == ("#000000" if appearance == "light" else "#ffffff")
assert float(tile.attrib["x"]) - thickness / 2 == pytest.approx(x)
assert float(tile.attrib["y"]) - thickness / 2 == pytest.approx(y)
assert float(tile.attrib["width"]) + thickness == pytest.approx(width)
assert float(tile.attrib["height"]) + thickness == pytest.approx(height)
assert float(tile.attrib["rx"]) + thickness / 2 == pytest.approx(radius)
expected_fill = (colors or ("#ffffff", module.DARK_HEX))[appearance == "dark"]
assert tile.get("fill") == expected_fill
clip = result.find("svg:defs/svg:clipPath/svg:rect", ns)
assert clip is not None
assert tuple(float(clip.attrib[key]) for key in ("x", "y", "width", "height", "rx")) == (
x, y, width, height, radius,
)
group = result[-1]
portrait = group[-1]
assert portrait is not None and group is not None
assert portrait.get("preserveAspectRatio") == "xMidYMax meet"
assert tuple(float(portrait.attrib[key]) for key in ("x", "y", "width", "height")) == module.GIRL_BOXES[name]
clip_path = result.find("svg:defs/svg:clipPath", ns)
assert clip_path is not None
assert group.get("clip-path") == f"url(#{clip_path.attrib['id']})"
extension = group[0]
assert extension is not portrait
join_clip = result.find("svg:defs/svg:clipPath[@id='icon-join']/svg:rect", ns)
assert join_clip is not None
strip_x, strip_y, strip_width, strip_height = (
float(join_clip.attrib[key]) for key in ("x", "y", "width", "height")
)
px, py, pw, ph = module.GIRL_BOXES[name]
assert (strip_x, strip_width) == (px, pw)
assert 0 < py + ph - strip_y < ph / 50
assert strip_y + strip_height > y + height - thickness
assert strip_y + strip_height < y + height
assert extension.get("clip-path") == "url(#icon-join)"
extension_ink = extension.find("svg:g/svg:path", ns)
portrait_ink = portrait.find("svg:path", ns)
assert extension_ink is not None and portrait_ink is not None
assert extension_ink.get("d") == portrait_ink.get("d")