feat(icons): macOS 26 layered icon whose border follows Apple's mask
macOS 26 clips every app icon to its own squircle and shows legacy .icns art inside a glass plate. Our border is a stroke on the tile's rounded rect, so on 26 the ring sits inside a grey rim and thins toward the corners where Apple's curve and ours diverge. Add an Icon Composer package (apps/desktop/assets/icon.icon) of canvas-filling layers the system masks itself: a border band whose outer edge is the canvas (the mask supplies the outline) and whose inner edge is Apple's mask offset inward by the brand border thickness, so the band is the same width everywhere along the visible outline; and the girl (+ commit badge) as a second layer. Build flavours recolour the fill in icon.json only. Apple's mask is a circular arc flanked by two cubic easing segments (the Figma corner-smoothing construction). Fitted to macOS 26.6.2's own render of a system icon on the 824-on-1024 grid: radius 214px, smoothing 0.645, rms 0.32px / worst 0.81px; a superellipse cannot get under 0.54px rms. The .icns targets are unchanged: Apple's own 26-era .icns still sit on the 824 grid, and macOS <= 15 keeps showing that artwork. The new contract test probes the border layer 640 points along the mask: opaque at thickness-1.5px, transparent at thickness+1.5px. Rendering the layers with the old r=185.4 corner violates it at 1448 probe points.
This commit is contained in:
BIN
apps/desktop/assets/icon.icon/Assets/art-dark.png
Normal file
BIN
apps/desktop/assets/icon.icon/Assets/art-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/desktop/assets/icon.icon/Assets/art-light.png
Normal file
BIN
apps/desktop/assets/icon.icon/Assets/art-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/desktop/assets/icon.icon/Assets/border-dark.png
Normal file
BIN
apps/desktop/assets/icon.icon/Assets/border-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/desktop/assets/icon.icon/Assets/border-light.png
Normal file
BIN
apps/desktop/assets/icon.icon/Assets/border-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
65
apps/desktop/assets/icon.icon/icon.json
Normal file
65
apps/desktop/assets/icon.icon/icon.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"fill-specializations": [
|
||||
{
|
||||
"value": {
|
||||
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance": "dark",
|
||||
"value": {
|
||||
"solid": "srgb:0.05098,0.06667,0.09020,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"lighting": "individual",
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "none",
|
||||
"opacity": 0
|
||||
},
|
||||
"blur-material": null,
|
||||
"layers": [
|
||||
{
|
||||
"name": "border",
|
||||
"image-name": "border-light.png",
|
||||
"image-name-specializations": [
|
||||
{
|
||||
"value": "border-light.png"
|
||||
},
|
||||
{
|
||||
"appearance": "dark",
|
||||
"value": "border-dark.png"
|
||||
}
|
||||
],
|
||||
"glass": false,
|
||||
"hidden": false
|
||||
},
|
||||
{
|
||||
"name": "art",
|
||||
"image-name": "art-light.png",
|
||||
"image-name-specializations": [
|
||||
{
|
||||
"value": "art-light.png"
|
||||
},
|
||||
{
|
||||
"appearance": "dark",
|
||||
"value": "art-dark.png"
|
||||
}
|
||||
],
|
||||
"glass": false,
|
||||
"hidden": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ Sources of truth — two axes, composed per target:
|
||||
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
|
||||
margins — so the icon matches the size of Apple-template neighbors.
|
||||
macOS 26 additionally gets apps/desktop/assets/icon.icon, an Icon Composer
|
||||
package of canvas-filling layers (border band + girl, light/dark) that the
|
||||
system masks into its own squircle; electron-builder compiles it to
|
||||
Assets.car next to the .icns, which macOS <= 15 keeps showing.
|
||||
|
||||
Desktop build identity comes from HERMES_PAYLOAD_TAG / HERMES_BUILD_COMMIT:
|
||||
Canary uses yellow/dark-yellow backgrounds. Commit builds use red/dark-red
|
||||
@@ -47,7 +51,7 @@ Dependencies:
|
||||
Pillow and resvg-py are core runtime dependencies; run this file with a
|
||||
Hermes runtime interpreter (scripts/generate-icons.mjs uses HERMES_PYTHON).
|
||||
|
||||
Outputs (30 files):
|
||||
Outputs (40 files):
|
||||
assets/icon-master.svg generated light master
|
||||
assets/icon-master-dark.svg generated dark master
|
||||
apps/desktop/assets/icon.png 1024x1024 squircle (light)
|
||||
@@ -56,6 +60,9 @@ Outputs (30 files):
|
||||
apps/desktop/assets/icon-dark.png 1024x1024 squircle (dark)
|
||||
apps/desktop/assets/icon-dark.ico 16,24,32,48,64,128,256
|
||||
apps/desktop/assets/icon-dark.icns 16..1024 (real ICNS)
|
||||
apps/desktop/assets/icon.icon/icon.json Icon Composer manifest (macOS 26)
|
||||
apps/desktop/assets/icon.icon/Assets/border-*.png 1024 border band, light/dark ink
|
||||
apps/desktop/assets/icon.icon/Assets/art-*.png 1024 girl (+ commit badge), light/dark
|
||||
apps/desktop/assets/appx/Wide310x150Logo.png 310x150, squircle 100 centered
|
||||
apps/desktop/assets/appx/StoreLogo.png 50x50 squircle
|
||||
apps/desktop/assets/appx/Square44x44Logo.png 44x44 squircle
|
||||
@@ -86,6 +93,8 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -133,6 +142,10 @@ GIRL_VIEWBOX = 5487.0615
|
||||
CHECK_SIZES: dict[str, tuple[str, tuple[int, int]]] = {
|
||||
"apps/desktop/assets/icon.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/icon-dark.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/icon.icon/Assets/border-light.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/icon.icon/Assets/border-dark.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/icon.icon/Assets/art-light.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/icon.icon/Assets/art-dark.png": ("PNG", (1024, 1024)),
|
||||
"apps/desktop/assets/appx/Wide310x150Logo.png": ("PNG", (310, 150)),
|
||||
"apps/desktop/assets/appx/Wide310x150Logo-dark.png": ("PNG", (310, 150)),
|
||||
"apps/desktop/assets/appx/StoreLogo.png": ("PNG", (50, 50)),
|
||||
@@ -167,6 +180,11 @@ TARGETS: list[tuple[str, str, object]] = [
|
||||
("apps/desktop/assets/icon-dark.png", "png_dark", 1024),
|
||||
("apps/desktop/assets/icon-dark.ico", "ico_dark", [16, 24, 32, 48, 64, 128, 256]),
|
||||
("apps/desktop/assets/icon-dark.icns", "icns_dark", None),
|
||||
("apps/desktop/assets/icon.icon/icon.json", "icon_manifest", None),
|
||||
("apps/desktop/assets/icon.icon/Assets/border-light.png", "icon_border", "#000000"),
|
||||
("apps/desktop/assets/icon.icon/Assets/border-dark.png", "icon_border", "#ffffff"),
|
||||
("apps/desktop/assets/icon.icon/Assets/art-light.png", "icon_art", "black"),
|
||||
("apps/desktop/assets/icon.icon/Assets/art-dark.png", "icon_art", "white"),
|
||||
("apps/desktop/assets/appx/Wide310x150Logo.png", "wide", (310, 150)),
|
||||
("apps/desktop/assets/appx/StoreLogo.png", "png", 50),
|
||||
("apps/desktop/assets/appx/Square44x44Logo.png", "png", 44),
|
||||
@@ -348,6 +366,23 @@ def drag_bottom_nodes(path: ET.Element, *, cutoff: float, band: float, distance:
|
||||
path.set("d", " ".join(tokens))
|
||||
|
||||
|
||||
def portrait_layer(art: IconArt, girl: str, bg: str, join_bottom: float) -> ET.Element:
|
||||
"""The registered girl with her lowest nodes dragged down to `join_bottom`
|
||||
(the border's inner edge) so hair meets the ring instead of floating."""
|
||||
box = GIRL_BOXES[bg]
|
||||
portrait = ET.fromstring(girl_layer(art, girl, box, align="xMidYMax"))
|
||||
_, y, portrait_width, portrait_height = box
|
||||
_, by, bw, bh = girl_bbox(art, girl)
|
||||
scale = min(portrait_width / bw, portrait_height / bh)
|
||||
drag_bottom_nodes(
|
||||
portrait[0], cutoff=by + bh * 0.97, band=bh * 0.02,
|
||||
distance=max(0.0, join_bottom - (y + portrait_height)) / scale,
|
||||
)
|
||||
# Keep the fitted viewBox fixed, but let edited nodes reach into the border.
|
||||
portrait.set("overflow", "visible")
|
||||
return portrait
|
||||
|
||||
|
||||
def compose_svg(art: IconArt, girl: str, bg: str) -> str:
|
||||
"""Full svg text: background + girl layer, in the background's native
|
||||
coordinate space (resvg scales to whatever output size is requested, so
|
||||
@@ -368,18 +403,7 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str:
|
||||
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"))
|
||||
_, y, portrait_width, portrait_height = box
|
||||
_, by, bw, bh = girl_bbox(art, girl)
|
||||
scale = min(portrait_width / bw, portrait_height / bh)
|
||||
join_bottom = geometry["y"] + geometry["height"] - thickness + 10
|
||||
drag_bottom_nodes(
|
||||
portrait[0], cutoff=by + bh * 0.97, band=bh * 0.02,
|
||||
distance=max(0.0, join_bottom - (y + portrait_height)) / scale,
|
||||
)
|
||||
# Keep the fitted viewBox fixed, but let edited nodes reach into the border.
|
||||
portrait.set("overflow", "visible")
|
||||
portrait = portrait_layer(art, girl, bg, geometry["y"] + geometry["height"] - thickness + 10)
|
||||
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'
|
||||
@@ -391,6 +415,182 @@ def compose_svg(art: IconArt, girl: str, bg: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
# ─── macOS 26 layered icon (.icon → Assets.car) ─────────────────────────────
|
||||
#
|
||||
# macOS 26 clips every app icon to its own mask and shows legacy .icns art
|
||||
# inside a glass plate, so a ring drawn around our tile no longer follows the
|
||||
# visible outline. The .icon package gives the system canvas-filling layers
|
||||
# to mask itself: the border layer is a band whose OUTER edge is the canvas
|
||||
# (the system's mask becomes the outline) and whose INNER edge is the mask
|
||||
# offset inward by the border thickness — constant thickness by construction.
|
||||
#
|
||||
# The mask is Apple's smoothed rounded square: a circular arc flanked by two
|
||||
# cubic easing segments (the Figma corner-smoothing construction). Fitted to
|
||||
# macOS 26.6.2's own render of a system icon on the 824-on-1024 grid:
|
||||
# radius 214px, smoothing 0.645 (rms 0.32px, worst 0.81px). Layers are
|
||||
# placed on that grid by the system, so in layer coordinates the mask fills
|
||||
# the canvas.
|
||||
MAC_MASK_RADIUS_FRACTION = 214.0 / 824.0
|
||||
MAC_MASK_SMOOTHING = 0.645
|
||||
ICON_CANVAS = 1024
|
||||
|
||||
|
||||
def _cubic(p0, p1, p2, p3, t: float) -> tuple[float, float]:
|
||||
u = 1.0 - t
|
||||
return (u ** 3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t ** 3 * p3[0],
|
||||
u ** 3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t ** 3 * p3[1])
|
||||
|
||||
|
||||
def _mask_corner(side: float, samples: int) -> list[tuple[float, float]]:
|
||||
"""Top-left corner with the tile corner at the origin, running clockwise
|
||||
from the left edge (0, p) around to the top edge (p, 0)."""
|
||||
radius = side * MAC_MASK_RADIUS_FRACTION
|
||||
smoothing = MAC_MASK_SMOOTHING
|
||||
p = min((1 + smoothing) * radius, side / 2)
|
||||
arc_measure = 90 * (1 - smoothing)
|
||||
arc_len = math.sin(math.radians(arc_measure / 2)) * radius * math.sqrt(2)
|
||||
angle_alpha = (90 - arc_measure) / 2
|
||||
p3p4 = radius * math.tan(math.radians(angle_alpha / 2))
|
||||
angle_beta = 45 * smoothing
|
||||
c = p3p4 * math.cos(math.radians(angle_beta))
|
||||
d = c * math.tan(math.radians(angle_beta))
|
||||
b = (p - arc_len - c - d) / 3
|
||||
a = 2 * b
|
||||
# Trace the construction's top-right corner (corner at the origin, x <= 0):
|
||||
# easing cubic, circular arc, easing cubic.
|
||||
pts: list[tuple[float, float]] = []
|
||||
p0 = (-p, 0.0)
|
||||
p1, p2, p3 = (p0[0] + a, 0.0), (p0[0] + a + b, 0.0), (p0[0] + a + b + c, d)
|
||||
for i in range(samples):
|
||||
pts.append(_cubic(p0, p1, p2, p3, i / samples))
|
||||
length = math.hypot(c, d)
|
||||
nx, ny = -d / length, c / length
|
||||
cx, cy = p3[0] + nx * radius, p3[1] + ny * radius
|
||||
start = math.atan2(p3[1] - cy, p3[0] - cx)
|
||||
for i in range(samples):
|
||||
angle = start + math.radians(arc_measure) * i / samples
|
||||
pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
|
||||
p4 = (p3[0] + arc_len, p3[1] + arc_len)
|
||||
p5, p6, p7 = (p4[0] + d, p4[1] + c), (p4[0] + d, p4[1] + b + c), (0.0, p)
|
||||
for i in range(samples + 1):
|
||||
pts.append(_cubic(p4, p5, p6, p7, i / samples))
|
||||
# Mirror into the top-left corner and reverse so the run is clockwise.
|
||||
return [(-x, y) for x, y in reversed(pts)]
|
||||
|
||||
|
||||
def mac_mask_outline(side: float, samples: int = 96) -> list[tuple[float, float]]:
|
||||
"""Closed clockwise outline of the macOS 26 icon mask for a tile of `side`."""
|
||||
tl = _mask_corner(side, samples)
|
||||
tr = [(side - y, x) for x, y in tl]
|
||||
br = [(side - x, side - y) for x, y in tl]
|
||||
bl = [(y, side - x) for x, y in tl]
|
||||
outline: list[tuple[float, float]] = []
|
||||
for point in tl + tr + br + bl:
|
||||
if not outline or math.hypot(point[0] - outline[-1][0], point[1] - outline[-1][1]) > 1e-9:
|
||||
outline.append(point)
|
||||
return outline
|
||||
|
||||
|
||||
def offset_inward(points: list[tuple[float, float]], distance: float) -> list[tuple[float, float]]:
|
||||
"""Parallel curve `distance` inside a convex closed outline."""
|
||||
count = len(points)
|
||||
cx = sum(x for x, _ in points) / count
|
||||
cy = sum(y for _, y in points) / count
|
||||
result = []
|
||||
for i, (x, y) in enumerate(points):
|
||||
px, py = points[i - 1]
|
||||
qx, qy = points[(i + 1) % count]
|
||||
tx, ty = qx - px, qy - py
|
||||
length = math.hypot(tx, ty) or 1.0
|
||||
nx, ny = -ty / length, tx / length
|
||||
if (cx - x) * nx + (cy - y) * ny < 0:
|
||||
nx, ny = -nx, -ny
|
||||
result.append((x + nx * distance, y + ny * distance))
|
||||
return result
|
||||
|
||||
|
||||
def closed_path(points: list[tuple[float, float]]) -> str:
|
||||
"""SVG path through the points as a closed Catmull-Rom spline (cubic Beziers)."""
|
||||
count = len(points)
|
||||
parts = [f"M{points[0][0]:.3f} {points[0][1]:.3f}"]
|
||||
for i in range(count):
|
||||
p0, p1 = points[(i - 1) % count], points[i]
|
||||
p2, p3 = points[(i + 1) % count], points[(i + 2) % count]
|
||||
c1 = (p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0)
|
||||
c2 = (p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0)
|
||||
parts.append(f"C{c1[0]:.3f} {c1[1]:.3f} {c2[0]:.3f} {c2[1]:.3f} {p2[0]:.3f} {p2[1]:.3f}")
|
||||
parts.append("Z")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def icon_border_svg(ink: str) -> str:
|
||||
"""Border layer: the canvas minus the mask's inward offset (even-odd)."""
|
||||
thickness = ICON_CANVAS * BORDER_FRACTION
|
||||
inner = closed_path(offset_inward(mac_mask_outline(float(ICON_CANVAS)), thickness))
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {ICON_CANVAS} {ICON_CANVAS}">\n'
|
||||
f' <path d="M0 0H{ICON_CANVAS}V{ICON_CANVAS}H0Z {inner}" fill="{ink}" fill-rule="evenodd"/>\n'
|
||||
"</svg>\n"
|
||||
)
|
||||
|
||||
|
||||
def icon_art_svg(art: IconArt, girl: str) -> str:
|
||||
"""Art layer: the girl registered as on the canvas-filling squircle, joined
|
||||
to the border band; the commit badge rides along for commit builds."""
|
||||
thickness = ICON_CANVAS * BORDER_FRACTION
|
||||
bg = "squircle-light.svg" # canvas-filling registration; the system supplies the grid
|
||||
portrait = portrait_layer(art, girl, bg, ICON_CANVAS - thickness + 10)
|
||||
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 {ICON_CANVAS} {ICON_CANVAS}">\n'
|
||||
f"{badge}"
|
||||
f" {ET.tostring(portrait, encoding='unicode')}\n"
|
||||
"</svg>\n"
|
||||
)
|
||||
|
||||
|
||||
def icon_color(hex_color: str) -> str:
|
||||
"""Icon Composer colour literal for an opaque sRGB hex colour."""
|
||||
value = hex_color.lstrip("#")
|
||||
r, g, b = (int(value[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
|
||||
return f"srgb:{r:.5f},{g:.5f},{b:.5f},1.00000"
|
||||
|
||||
|
||||
def icon_manifest(art: IconArt) -> str:
|
||||
"""icon.json: flat brand fill (flavoured for canary/commit builds), a
|
||||
border layer and an art layer, each with a dark-appearance variant."""
|
||||
light, dark = art.colors or ("#ffffff", DARK_HEX)
|
||||
|
||||
def layer(name: str) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"image-name": f"{name}-light.png",
|
||||
"image-name-specializations": [
|
||||
{"value": f"{name}-light.png"},
|
||||
{"appearance": "dark", "value": f"{name}-dark.png"},
|
||||
],
|
||||
"glass": False,
|
||||
"hidden": False,
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"fill-specializations": [
|
||||
{"value": {"solid": icon_color(light)}},
|
||||
{"appearance": "dark", "value": {"solid": icon_color(dark)}},
|
||||
],
|
||||
"groups": [{
|
||||
"lighting": "individual",
|
||||
"specular": False,
|
||||
"translucency": {"enabled": False, "value": 0},
|
||||
"shadow": {"kind": "none", "opacity": 0},
|
||||
"blur-material": None,
|
||||
"layers": [layer("border"), layer("art")],
|
||||
}],
|
||||
"supported-platforms": {"squares": "shared"},
|
||||
}
|
||||
return json.dumps(manifest, indent=2) + "\n"
|
||||
|
||||
|
||||
# ─── rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
def render(master: str, size: int, *, background: str | None = None) -> Image.Image:
|
||||
@@ -458,6 +658,8 @@ def target_bytes(art: IconArt, kind: str, arg: object) -> bytes:
|
||||
return art.master_dark.encode("utf-8")
|
||||
if kind == "svg_copy":
|
||||
return art.master.encode("utf-8")
|
||||
if kind == "icon_manifest":
|
||||
return icon_manifest(art).encode("utf-8")
|
||||
|
||||
buf = io.BytesIO()
|
||||
if kind == "png":
|
||||
@@ -472,6 +674,10 @@ def target_bytes(art: IconArt, kind: str, arg: object) -> bytes:
|
||||
render(art.master_dark, arg, background=DARK_HEX).convert("RGB").save(buf, "PNG", optimize=True)
|
||||
elif kind in ("girl_light", "girl_dark"):
|
||||
save_png(girl_mark(art, kind, arg), buf)
|
||||
elif kind == "icon_border":
|
||||
save_png(render_svg(icon_border_svg(arg), ICON_CANVAS), buf)
|
||||
elif kind == "icon_art":
|
||||
save_png(render_svg(icon_art_svg(art, arg), ICON_CANVAS), buf)
|
||||
elif kind == "ico":
|
||||
img = render(art.master, max(arg))
|
||||
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
|
||||
@@ -542,8 +748,8 @@ def cmd_write(source: Path, out: Path) -> int:
|
||||
for rel, kind, arg in TARGETS:
|
||||
path = out / rel
|
||||
try:
|
||||
if kind in ("svg", "svg_dark", "svg_copy"):
|
||||
print(f" {rel}: {path.stat().st_size} bytes SVG")
|
||||
if kind in ("svg", "svg_dark", "svg_copy", "icon_manifest"):
|
||||
print(f" {rel}: {path.stat().st_size} bytes {'JSON' if kind == 'icon_manifest' else 'SVG'}")
|
||||
continue
|
||||
im = Image.open(path)
|
||||
if kind in ("ico", "ico_dark"):
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LAYERED_ICON = "icon.icon" # apps/desktop/assets/icon.icon: the macOS 26 Icon Composer package
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -140,8 +141,8 @@ def test_canary_changes_only_desktop_background_preserving_art_and_native_geomet
|
||||
stable = generate("v1.2.3")
|
||||
canary = generate("v1.2.3+canary.20260911T010203Z")
|
||||
for path in (stable / "apps/desktop").rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if not path.is_file() or LAYERED_ICON in path.parts:
|
||||
continue # the layered macOS icon carries its flavor in icon.json, tested below
|
||||
original_frames = list(frames(path))
|
||||
canary_frames = list(frames(canary / path.relative_to(stable)))
|
||||
assert len(original_frames) == len(canary_frames)
|
||||
@@ -168,6 +169,8 @@ def test_commit_icons_are_red_and_print_only_the_actual_seven_digit_prefix(gener
|
||||
continue
|
||||
rel = path.relative_to(first)
|
||||
assert path.read_bytes() == (same_prefix / rel).read_bytes(), rel
|
||||
if LAYERED_ICON in path.parts:
|
||||
continue # the layered macOS icon carries its flavor in icon.json, tested below
|
||||
first_frames = list(frames(path))
|
||||
other_frames = list(frames(changed / rel))
|
||||
stable_frames = list(frames(stable / rel))
|
||||
@@ -213,6 +216,80 @@ def test_commit_icons_are_red_and_print_only_the_actual_seven_digit_prefix(gener
|
||||
assert_unbranded_outputs(stable, first)
|
||||
|
||||
|
||||
def load_generator(monkeypatch):
|
||||
import importlib.util
|
||||
import types
|
||||
|
||||
monkeypatch.setitem(sys.modules, "resvg_py", types.ModuleType("resvg_py"))
|
||||
spec = importlib.util.spec_from_file_location("generate_icons", ROOT / "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)
|
||||
return module
|
||||
|
||||
|
||||
def manifest_fills(package):
|
||||
"""(light, dark) fill colours of an Icon Composer package as 0..1 RGB tuples."""
|
||||
import json
|
||||
|
||||
manifest = json.loads((package / "icon.json").read_text(encoding="utf-8"))
|
||||
fills = {}
|
||||
for spec in manifest["fill-specializations"]:
|
||||
prefix, _, channels = spec["value"]["solid"].partition(":")
|
||||
assert prefix == "srgb"
|
||||
fills[spec.get("appearance", "light")] = tuple(float(v) for v in channels.split(","))[:3]
|
||||
layers = [layer for group in manifest["groups"] for layer in group["layers"]]
|
||||
referenced = {layer["image-name"] for layer in layers} | {
|
||||
spec["value"] for layer in layers for spec in layer["image-name-specializations"]}
|
||||
assert referenced == {p.name for p in (package / "Assets").iterdir()}, "every layer image is referenced, none dangle"
|
||||
return fills["light"], fills["dark"]
|
||||
|
||||
|
||||
def test_layered_macos_icon_border_hugs_the_system_mask_and_flavor_stays_in_the_fill(generate, monkeypatch):
|
||||
"""macOS 26 masks the layers itself: the border layer must be a band of the
|
||||
brand thickness measured perpendicular to Apple's mask everywhere along it,
|
||||
reaching the canvas edge so the mask supplies the outline. Build flavors
|
||||
recolour the fill in icon.json only; the ring and the girl never change."""
|
||||
module = load_generator(monkeypatch)
|
||||
stable = generate("v1.2.3") / "apps/desktop/assets" / LAYERED_ICON
|
||||
canary = generate("v1.2.3+canary.20260911T010203Z") / "apps/desktop/assets" / LAYERED_ICON
|
||||
commit = generate(commit="0123456" + "a" * 33) / "apps/desktop/assets" / LAYERED_ICON
|
||||
|
||||
canvas = module.ICON_CANVAS
|
||||
thickness = canvas * module.BORDER_FRACTION
|
||||
outline = module.mac_mask_outline(float(canvas), 160)
|
||||
for name, ink in (("border-light.png", (0, 0, 0)), ("border-dark.png", (255, 255, 255))):
|
||||
layer = Image.open(stable / "Assets" / name).convert("RGBA")
|
||||
assert layer.size == (canvas, canvas)
|
||||
assert layer.getpixel((0, 0))[3] == 255 and layer.getpixel((canvas - 1, canvas - 1))[3] == 255
|
||||
for (xi, yi), (xo, yo) in zip(module.offset_inward(outline, thickness - 1.5),
|
||||
module.offset_inward(outline, thickness + 1.5), strict=True):
|
||||
inside = layer.getpixel((int(xi), int(yi)))
|
||||
assert inside[3] >= 250 and inside[:3] == ink, (name, xi, yi, inside)
|
||||
assert layer.getpixel((int(xo), int(yo)))[3] <= 5, (name, xo, yo)
|
||||
|
||||
for name in ("border-light.png", "border-dark.png", "art-light.png", "art-dark.png"):
|
||||
assert (stable / "Assets" / name).read_bytes() == (canary / "Assets" / name).read_bytes(), name
|
||||
for name in ("border-light.png", "border-dark.png"):
|
||||
assert (stable / "Assets" / name).read_bytes() == (commit / "Assets" / name).read_bytes(), name
|
||||
for name in ("art-light.png", "art-dark.png"):
|
||||
# The commit badge is the only difference, and it lives in the top quarter.
|
||||
plain = Image.open(stable / "Assets" / name).convert("RGBA")
|
||||
badged = Image.open(commit / "Assets" / name).convert("RGBA")
|
||||
changed = ImageChops.difference(plain, badged).convert("L").getbbox()
|
||||
assert changed is not None and changed[3] <= canvas * 0.25 + 3, (name, changed)
|
||||
|
||||
light, dark = manifest_fills(stable)
|
||||
assert light == (1.0, 1.0, 1.0) and max(dark) < 0.1
|
||||
for package, low, high in ((canary, 0.10, 0.18), (commit, -0.05, 0.05)):
|
||||
light, dark = manifest_fills(package)
|
||||
for fill, dark_fill in ((light, False), (dark, True)):
|
||||
hue, saturation, value = colorsys.rgb_to_hsv(*fill)
|
||||
hue = hue - 1 if hue > 0.5 else hue # red straddles the hue wrap
|
||||
assert low < hue < high and saturation > 0.6, (package, fill)
|
||||
assert (value < 0.4) if dark_fill else (value > 0.8), (package, fill)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag,commit", [
|
||||
("", "abcdef0"), ("", "a" * 41), ("", "A" * 40),
|
||||
("", "a" * 39 + "g"), ("", "a" * 40 + "\n"),
|
||||
|
||||
Reference in New Issue
Block a user