Files
hermes-agent/scripts/generate_icons.py
Hermes Agent b24b8149dd fix(icons): give the dev Dock its own mac-grid png
Linux uses apple-touch-icon.png as the window icon and Nix requires it to
match the full-bleed launcher icon, so keep it full-bleed and point the
dev-only app.dock.setIcon at assets/icon-mac.png instead.
2026-09-26 18:10:54 -05:00

666 lines
31 KiB
Python

#!/usr/bin/env python3
"""Generate every app icon in the repo from the nous-girl art + platform backgrounds.
Usage (from repo root):
node scripts/generate-icons.mjs # write
node scripts/generate-icons.mjs --check # verify structure
Sources of truth — two axes, composed per target:
Girl art (vector): assets/nous-girl-black.svg (black positive space)
assets/nous-girl-white.svg (white positive space)
straight from the Nous brand kit (Inkscape exports,
5487^2 viewBox, one path each).
Backgrounds (per platform surface, light/dark):
assets/backgrounds/squircle-light.svg white rounded
assets/backgrounds/squircle-dark.svg #0d1117 rounded
assets/backgrounds/squircle-mac-light.svg mac HIG grid
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 + 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
margins — so the icon matches the size of Apple-template neighbors.
Desktop build identity comes from HERMES_PAYLOAD_TAG / HERMES_BUILD_COMMIT:
Canary uses yellow/dark-yellow backgrounds. Commit builds use red/dark-red
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'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 nodes near her bottom edge extend to the border; the fitted face and hair stay fixed.
Standalone wordmarks remain centered and have no border.
GENERATED OUTPUTS ARE COMMITTED. Regular builds and installs consume them and
never render; flavored release bundles (canary/commit) render to a product dir.
icons-freshness-check.yml regenerates, runs --check, and fails on any diff.
Rendering: resvg (resvg-py) for SVG -> PNG fidelity at every size.
Containers: Pillow for multi-size .ico and .icns.
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):
assets/icon-master.svg generated light master
assets/icon-master-dark.svg generated dark master
apps/desktop/assets/icon.png 1024x1024 squircle (light)
apps/desktop/assets/icon.ico 16,24,32,48,64,128,256
apps/desktop/assets/icon.icns 16..1024 (real ICNS)
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/appx/Wide310x150Logo.png 310x150, squircle 100 centered
apps/desktop/assets/appx/StoreLogo.png 50x50 squircle
apps/desktop/assets/appx/Square44x44Logo.png 44x44 squircle
apps/desktop/assets/appx/Square150x150Logo.png 150x150 squircle
apps/desktop/assets/appx/*-dark.png dark-appearance logos
apps/desktop/public/apple-touch-icon.png 1024x1024 squircle
apps/desktop/public/nous-girl.png 256x256 squircle, black girl (light mark)
apps/desktop/public/nous-girl-dark.png 256x256 squircle, white girl (dark mark)
apps/bootstrap-installer/src-tauri/icons/32x32.png 32x32
apps/bootstrap-installer/src-tauri/icons/128x128.png 128x128
apps/bootstrap-installer/src-tauri/icons/128x128@2x.png 256x256
apps/bootstrap-installer/src-tauri/icons/icon.ico 16,32,64,128,256
apps/bootstrap-installer/src-tauri/icons/icon.icns 16..1024
apps/bootstrap-installer/public/nous-girl.png 256x256 squircle mark (light)
website/static/img/logo.png 1772x1799 girl alone, transparent (light)
website/static/img/logo-dark.png 1772x1799 girl alone, transparent (dark)
website/static/img/nous-logo.png 150x150 on white (opaque)
website/static/img/nous-logo-dark.png 150x150 on #0d1117 (opaque)
website/static/img/favicon-16x16.png 16x16
website/static/img/favicon-32x32.png 32x32
website/static/img/apple-touch-icon.png 180x180
website/static/img/favicon.ico 16,32,48
website/static/img/favicon.svg copy of the light master
web/public/favicon.ico 16,32,48
"""
from __future__ import annotations
import argparse
import io
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from PIL import Image
try:
import resvg_py
except ImportError:
sys.exit(
"resvg-py is missing: run the generator with a Hermes runtime interpreter\n"
" (HERMES_PYTHON=<hermes venv python> node scripts/generate-icons.mjs)"
)
# Copy of hermes_cli.update_channel._CANARY_TAG_RE: builders run this renderer
# on the runtime dependencies without the application package installed
# (Docker, bundles). tests/scripts/test_icon_flavors.py pins it to the canonical one.
_CANARY_TAG_RE = re.compile(
r"^v(?:0|[1-9]\d{0,2})\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
r"\+canary\.20\d{6}T\d{6}Z$"
)
# The nous dark background (#0d1117) — fixed dark tile/background everywhere.
DARK_HEX = "#0d1117"
DARK_RGB = (13, 17, 23)
BORDER_FRACTION = 0.0407747197
# 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": (72.149433, 104.703674, 872.767801, 872.767801),
"squircle-dark.svg": (72.149433, 104.703674, 872.767801, 872.767801),
# Mac: the girl scaled 1.12x about the plate center; the plate stays on the
# 824 grid, but a white tile with a ring reads small beside full-color peers.
"squircle-mac-light.svg": (122.43, 144.84, 786.83, 786.83),
"squircle-mac-dark.svg": (122.43, 144.84, 786.83, 786.83),
}
# The brand-kit SVG canvas (both girl svgs share this viewBox).
GIRL_VIEWBOX = 5487.0615
# Target sizes for --check's structural verification: relpath -> (format, size)
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/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)),
"apps/desktop/assets/appx/StoreLogo-dark.png": ("PNG", (50, 50)),
"apps/desktop/assets/appx/Square44x44Logo.png": ("PNG", (44, 44)),
"apps/desktop/assets/appx/Square44x44Logo-dark.png": ("PNG", (44, 44)),
"apps/desktop/assets/appx/Square150x150Logo.png": ("PNG", (150, 150)),
"apps/desktop/assets/appx/Square150x150Logo-dark.png": ("PNG", (150, 150)),
"apps/desktop/public/apple-touch-icon.png": ("PNG", (1024, 1024)),
"apps/desktop/public/nous-girl.png": ("PNG", (256, 256)),
"apps/desktop/public/nous-girl-dark.png": ("PNG", (256, 256)),
"apps/bootstrap-installer/src-tauri/icons/32x32.png": ("PNG", (32, 32)),
"apps/bootstrap-installer/src-tauri/icons/128x128.png": ("PNG", (128, 128)),
"apps/bootstrap-installer/src-tauri/icons/128x128@2x.png": ("PNG", (256, 256)),
"apps/bootstrap-installer/public/nous-girl.png": ("PNG", (256, 256)),
"website/static/img/logo.png": ("PNG", (1772, 1799)),
"website/static/img/logo-dark.png": ("PNG", (1772, 1799)),
"website/static/img/nous-logo.png": ("PNG", (150, 150)),
"website/static/img/nous-logo-dark.png": ("PNG", (150, 150)),
"website/static/img/favicon-16x16.png": ("PNG", (16, 16)),
"website/static/img/favicon-32x32.png": ("PNG", (32, 32)),
"website/static/img/apple-touch-icon.png": ("PNG", (180, 180)),
}
# (relpath, kind, arg)
TARGETS: list[tuple[str, str, object]] = [
("assets/icon-master.svg", "svg", None),
("assets/icon-master-dark.svg", "svg_dark", None),
("apps/desktop/assets/icon.png", "png", 1024),
("apps/desktop/assets/icon.ico", "ico", [16, 24, 32, 48, 64, 128, 256]),
("apps/desktop/assets/icon.icns", "icns", None),
("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/appx/Wide310x150Logo.png", "wide", (310, 150)),
("apps/desktop/assets/appx/StoreLogo.png", "png", 50),
("apps/desktop/assets/appx/Square44x44Logo.png", "png", 44),
("apps/desktop/assets/appx/Square150x150Logo.png", "png", 150),
("apps/desktop/assets/appx/Wide310x150Logo-dark.png", "wide_dark", (310, 150)),
("apps/desktop/assets/appx/StoreLogo-dark.png", "png_dark", 50),
("apps/desktop/assets/appx/Square44x44Logo-dark.png", "png_dark", 44),
("apps/desktop/assets/appx/Square150x150Logo-dark.png", "png_dark", 150),
("apps/desktop/public/apple-touch-icon.png", "png", 1024),
# The dev-run Dock icon (app.dock.setIcon): same mac grid as the icns.
("apps/desktop/assets/icon-mac.png", "png_mac", 1024),
("apps/desktop/public/nous-girl.png", "girl_light", 256),
("apps/desktop/public/nous-girl-dark.png", "girl_dark", 256),
("apps/bootstrap-installer/src-tauri/icons/32x32.png", "png", 32),
("apps/bootstrap-installer/src-tauri/icons/128x128.png", "png", 128),
("apps/bootstrap-installer/src-tauri/icons/128x128@2x.png", "png", 256),
("apps/bootstrap-installer/src-tauri/icons/icon.ico", "ico", [16, 32, 64, 128, 256]),
("apps/bootstrap-installer/src-tauri/icons/icon.icns", "icns", None),
("apps/bootstrap-installer/public/nous-girl.png", "girl_light", 256),
("website/static/img/logo.png", "logo", None),
("website/static/img/logo-dark.png", "logo_dark", None),
("website/static/img/nous-logo.png", "png_white", 150),
("website/static/img/nous-logo-dark.png", "png_dark_white", 150),
("website/static/img/favicon-16x16.png", "png", 16),
("website/static/img/favicon-32x32.png", "png", 32),
("website/static/img/apple-touch-icon.png", "png", 180),
("website/static/img/favicon.ico", "ico", [16, 32, 48]),
("website/static/img/favicon.svg", "svg_copy", None),
("web/public/favicon.ico", "ico", [16, 32, 48]),
]
# ─── girl art extraction ────────────────────────────────────────────────────
class IconArt:
"""One generation's rendering inputs and caches; never writes to source."""
def __init__(self, source: Path, *, colors: tuple[str, str] | None = None, commit: str = ""):
assets = source / "assets"
self.colors = colors
self.commit = commit
self.girls = {color: assets / f"nous-girl-{color}.svg" for color in ("black", "white")}
self.backgrounds = assets / "backgrounds"
self.paths: dict[str, str] = {}
self.bboxes: dict[str, tuple[float, float, float, float]] = {}
self.master = compose_svg(self, "black", "squircle-light.svg")
self.master_dark = compose_svg(self, "white", "squircle-dark.svg")
# macOS icons sit on Apple's 824-on-1024 grid, not the full-bleed
# squircle: same art, mac-grid backgrounds, icns targets only.
self.master_mac = compose_svg(self, "black", "squircle-mac-light.svg")
self.master_mac_dark = compose_svg(self, "white", "squircle-mac-dark.svg")
def girl_path(art: IconArt, girl: str) -> str:
"""The girl `<path>` element with editor metadata stripped (resvg rejects
undeclared inkscape/sodipodi prefixes)."""
if girl not in art.paths:
src = art.girls[girl].read_text(encoding="utf-8-sig")
m = re.search(r"<path\b.*?/>", src, re.S)
assert m, f"no <path> found in {art.girls[girl].name}"
path = re.sub(r'\s+(inkscape|sodipodi):[a-zA-Z-]+="[^"]*"', "", m.group(0))
art.paths[girl] = path
return art.paths[girl]
def girl_bbox(art: IconArt, girl: str) -> tuple[float, float, float, float]:
"""Art bounding box in the girl SVG's coordinate space, measured by
rendering once and taking the alpha bbox (robust to art changes)."""
if girl not in art.bboxes:
data = resvg_py.svg_to_bytes(svg_path=str(art.girls[girl]), width=512, height=512)
im = Image.open(io.BytesIO(data))
bx, by, bx2, by2 = im.getchannel("A").point(lambda v: 255 if v > 0 else 0).getbbox()
s = GIRL_VIEWBOX / 512.0
art.bboxes[girl] = (bx * s, by * s, (bx2 - bx) * s, (by2 - by) * s)
return art.bboxes[girl]
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}" '
f'preserveAspectRatio="{align} meet">\n'
f" {girl_path(art, girl)}\n"
" </svg>"
)
def background_inner(art: IconArt, name: str) -> tuple[str, int, int]:
"""Inner content + (width, height) of a background SVG asset."""
text = (art.backgrounds / name).read_text(encoding="utf-8-sig")
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]}"')
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))
# 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)
# Five-by-seven lowercase hexadecimal glyphs, one five-bit row at a time.
# Vector cells keep release builds deterministic without any installed fonts.
HEX_GLYPHS = {
"0": (14, 17, 19, 21, 25, 17, 14),
"1": (4, 12, 4, 4, 4, 4, 14),
"2": (14, 17, 1, 2, 4, 8, 31),
"3": (30, 1, 1, 14, 1, 1, 30),
"4": (2, 6, 10, 18, 31, 2, 2),
"5": (31, 16, 16, 30, 1, 1, 30),
"6": (14, 16, 16, 30, 17, 17, 14),
"7": (31, 1, 2, 4, 8, 8, 8),
"8": (14, 17, 17, 14, 17, 17, 14),
"9": (14, 17, 17, 15, 1, 1, 14),
"a": (0, 0, 14, 1, 15, 17, 15),
"b": (16, 16, 30, 17, 17, 17, 30),
"c": (0, 0, 14, 16, 16, 17, 14),
"d": (1, 1, 15, 17, 17, 17, 15),
"e": (0, 0, 14, 17, 31, 16, 14),
"f": (6, 9, 8, 28, 8, 8, 8),
}
def commit_layer(commit: str, bg: str) -> str:
cells = []
for index, char in enumerate(commit[:7]):
for row, bits in enumerate(HEX_GLYPHS[char]):
for col in range(5):
if bits & (1 << (4 - col)):
x, y = 184 + (index * 6 + col) * 16, 48 + row * 16
cells.append(f"M{x} {y}h16v16h-16z")
# The badge follows the tile's mac HIG inset, never the outer canvas.
transform = f' transform="translate(100 100) scale({824 / 1024})"' if "-mac-" in bg else ""
return (
f'<g{transform}><rect x="160" y="28" width="704" height="152" rx="24" fill="#29090c"/>'
f'<path fill="#ffffff" d="{"".join(cells)}"/></g>'
)
def drag_bottom_nodes(path: ET.Element, *, cutoff: float, band: float, distance: float) -> None:
"""Drag lower nodes and curve handles, tapering to zero above the bottom band."""
y_scale, y_offset = 1.0, 0.0
transform = path.get("transform")
if transform:
matrix = re.fullmatch(r"matrix\(([^)]+)\)", transform)
if matrix is None:
raise ValueError("bottom node edits require an axis-aligned matrix")
_, b, c, y_scale, _, y_offset = map(float, re.split(r"[\s,]+", matrix[1].strip()))
if b != 0 or c != 0 or y_scale <= 0:
raise ValueError("bottom node edits require an upright axis-aligned matrix")
# The brand exports use explicit absolute M/L/C commands. Reject other
# commands rather than silently corrupting relative coordinates or arcs.
tokens = re.findall(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?", path.attrib["d"])
counts = {"M": 2, "L": 2, "C": 6, "z": 0, "Z": 0}
index = 0
while index < len(tokens):
command = tokens[index]
if command not in counts:
raise ValueError("bottom node edits require explicit absolute M/L/C commands")
count = counts[command]
if index + count >= len(tokens):
raise ValueError("incomplete SVG path command")
for offset in range(2, count + 1, 2):
token_index = index + offset
raw_y = float(tokens[token_index])
amount = min(1.0, max(0.0, (raw_y * y_scale + y_offset - cutoff) / band))
if amount:
weight = amount * amount * (3 - 2 * amount)
tokens[token_index] = f"{raw_y + distance * weight / y_scale:.12g}"
index += count + 1
path.set("d", " ".join(tokens))
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
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"))
_, 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")
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></defs>\n'
f" {inner.strip()}\n"
f"{badge}"
f' <g clip-path="url(#icon-silhouette)">{ET.tostring(portrait, encoding="unicode")}</g>\n'
"</svg>\n"
)
# ─── rendering ──────────────────────────────────────────────────────────────
def render(master: str, size: int, *, background: str | None = None) -> Image.Image:
"""Render a master to an RGBA PNG of `size`x`size`."""
data = resvg_py.svg_to_bytes(
svg_string=master, width=size, height=size, background=background
)
return Image.open(io.BytesIO(data)).convert("RGBA")
def render_svg(svg: str, size: int | tuple[int, int]) -> Image.Image:
if isinstance(size, int):
w = h = size
else:
w, h = size
data = resvg_py.svg_to_bytes(svg_string=svg, width=w, height=h)
return Image.open(io.BytesIO(data)).convert("RGBA")
def paste_centered(canvas: Image.Image, img: Image.Image) -> None:
x = (canvas.width - img.width) // 2
y = (canvas.height - img.height) // 2
canvas.alpha_composite(img, (x, y))
def save_png(img: Image.Image, buf: io.BytesIO) -> None:
"""Save with alpha preserved. Flat art quantizes losslessly to an 8-bit
palette (tRNS per-index alpha keeps the AA edges), so use that when the
palette round-trips pixel-identically; fall back to RGBA otherwise."""
if img.mode != "RGBA":
img.convert("RGBA").save(buf, "PNG", optimize=True)
return
quantized = img.quantize(colors=256, method=Image.FASTOCTREE, dither=Image.NONE)
if quantized.convert("RGBA").tobytes() == img.tobytes():
quantized.save(buf, "PNG", optimize=True)
else:
img.save(buf, "PNG", optimize=True)
def girl_mark(art: IconArt, kind: str, size: int) -> Image.Image:
"""The girl in the app-icon squircle (BrandMark asset) — the mark IS the
icon shape. girl_light: black girl on white squircle. girl_dark: white
girl on #0d1117 squircle."""
if kind == "girl_light":
girl, bg = "black", "squircle-light.svg"
else:
girl, bg = "white", "squircle-dark.svg"
return render_svg(compose_svg(art, girl, bg), size)
def build_logo_image(art: IconArt, dark: bool = False) -> Image.Image:
"""1772x1799 wordmark: the girl alone on transparency (no frame), centered.
Light = black girl, dark = white girl — the consuming surface's background
(navbar light/dark) shows through."""
W, H = 1772, 1799
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}">\n {girl_layer(art, "white" if dark else "black", (0, 0, W, H))}\n</svg>\n'
return render_svg(svg, (W, H))
def target_bytes(art: IconArt, kind: str, arg: object) -> bytes:
"""Produce the exact bytes for one target. Shared by write + check."""
if kind == "svg":
return art.master.encode("utf-8")
if kind == "svg_dark":
return art.master_dark.encode("utf-8")
if kind == "svg_copy":
return art.master.encode("utf-8")
buf = io.BytesIO()
if kind == "png":
save_png(render(art.master, arg), buf)
elif kind == "png_mac":
save_png(render(art.master_mac, arg), buf)
elif kind == "png_dark":
save_png(render(art.master_dark, arg), buf)
elif kind == "png_white":
render(art.master, arg, background="#ffffff").convert("RGB").save(buf, "PNG", optimize=True)
elif kind == "png_dark_white":
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 == "ico":
img = render(art.master, max(arg))
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
elif kind == "ico_dark":
img = render(art.master_dark, max(arg))
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
elif kind == "icns":
img = render(art.master_mac, 1024)
frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)]
img.save(buf, format="ICNS", append_images=frames[1:])
elif kind == "icns_dark":
img = render(art.master_mac_dark, 1024)
frames = [img.resize((s, s), Image.LANCZOS) for s in (16, 32, 64, 128, 256, 512, 1024)]
img.save(buf, format="ICNS", append_images=frames[1:])
elif kind == "wide":
w, h = arg
canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
paste_centered(canvas, render(art.master, 100))
canvas.save(buf, "PNG")
elif kind == "wide_dark":
w, h = arg
canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
paste_centered(canvas, render(art.master_dark, 100))
canvas.save(buf, "PNG")
elif kind == "logo":
build_logo_image(art, dark=False).save(buf, "PNG")
elif kind == "logo_dark":
build_logo_image(art, dark=True).save(buf, "PNG")
else:
raise ValueError(f"unknown kind {kind!r}")
return buf.getvalue()
def build_art(source: Path) -> tuple[IconArt, IconArt]:
"""Only desktop outputs carry build identity. Shared branding stays stable."""
art = IconArt(source)
tag = os.environ.get("HERMES_PAYLOAD_TAG", "")
commit = os.environ.get("HERMES_BUILD_COMMIT", "")
if commit:
if tag:
raise ValueError("Commit builds cannot also select HERMES_PAYLOAD_TAG")
if not re.fullmatch(r"[a-f0-9]{40}", commit):
raise ValueError("HERMES_BUILD_COMMIT requires an exact full 40-character SHA")
return art, IconArt(source, colors=("#e34850", "#4a1117"), commit=commit)
if _CANARY_TAG_RE.match(tag.strip()):
return art, IconArt(source, colors=("#f5cc32", "#443808"))
return art, art
def cmd_write(source: Path, out: Path) -> int:
"""Return failure when any target cannot be generated or verified."""
art, desktop_art = build_art(source)
written = 0
failures = 0
for rel, kind, arg in TARGETS:
path = out / rel
try:
path.parent.mkdir(parents=True, exist_ok=True)
selected = desktop_art if rel.startswith("apps/desktop/") else art
path.write_bytes(target_bytes(selected, kind, arg))
written += 1
except Exception as exc: # noqa: BLE001 - report all, then fail
failures += 1
print(f" !! {rel}: FAILED ({exc})")
print(f"[write] wrote {written}/{len(TARGETS)} files")
print("\n[verify]")
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")
continue
im = Image.open(path)
if kind in ("ico", "ico_dark"):
sizes = []
try:
for i in range(im.n_frames):
im.seek(i)
sizes.append(im.size)
except Exception:
sizes = [im.size]
print(f" {rel}: ICO {sorted(set(sizes))}")
elif kind in ("icns", "icns_dark"):
print(f" {rel}: ICNS {im.size} (container)")
else:
print(f" {rel}: {im.format} {im.size}")
except Exception as exc:
failures += 1
print(f" {rel}: VERIFY FAILED ({exc})")
return int(failures > 0)
def cmd_check(source: Path, out: Path) -> int:
"""Structural verification: regenerate every target in memory and assert
the invariants that actually matter (there are no committed bytes to
byte-compare — outputs are generated on demand)."""
art, desktop_art = build_art(source)
problems: list[str] = []
# every target must generate without error
for rel, kind, arg in TARGETS:
try:
selected = desktop_art if rel.startswith("apps/desktop/") else art
target_bytes(selected, kind, arg)
except Exception as exc: # noqa: BLE001
problems.append(f"{rel}: REGENERATE FAILED ({exc})")
# PNG targets must have the expected format + size
for rel, (fmt, size) in CHECK_SIZES.items():
path = out / rel
if not path.exists():
problems.append(f"{rel}: MISSING (expected generated file)")
continue
try:
im = Image.open(path)
if im.format != fmt or im.size != size:
problems.append(f"{rel}: got {im.format} {im.size}, expected {fmt} {size}")
except Exception as exc: # noqa: BLE001
problems.append(f"{rel}: UNREADABLE ({exc})")
# squircles must keep transparent corners (alpha extrema include 0)
for rel in (
"apps/desktop/assets/icon.png",
"apps/desktop/assets/icon-dark.png",
"apps/desktop/public/nous-girl.png",
"apps/desktop/public/nous-girl-dark.png",
"apps/desktop/public/apple-touch-icon.png",
):
path = out / rel
if not path.exists():
continue
alpha = Image.open(path).convert("RGBA").getchannel("A")
lo, hi = alpha.getextrema()
if lo != 0 or hi != 255:
problems.append(f"{rel}: alpha extrema {alpha.getextrema()}, expected (0, 255) transparent corners")
# containers must have the right frame sets (parse ICO headers directly —
# PIL's ICO n_frames is unreliable across versions)
def ico_sizes(path: Path) -> list[int]:
data = path.read_bytes()
count = int.from_bytes(data[4:6], "little")
sizes = []
for i in range(count):
entry = data[6 + i * 16 : 6 + (i + 1) * 16]
w = entry[0] or 256
h = entry[1] or 256
sizes.append(w)
return sorted(set(sizes))
for rel, sizes in (
("apps/desktop/assets/icon.ico", [16, 24, 32, 48, 64, 128, 256]),
("apps/desktop/assets/icon-dark.ico", [16, 24, 32, 48, 64, 128, 256]),
("apps/bootstrap-installer/src-tauri/icons/icon.ico", [16, 32, 64, 128, 256]),
):
path = out / rel
if not path.exists():
problems.append(f"{rel}: MISSING")
continue
try:
got = ico_sizes(path)
if got != sizes:
problems.append(f"{rel}: ICO frames {got}, expected {sizes}")
except Exception as exc: # noqa: BLE001
problems.append(f"{rel}: UNREADABLE ({exc})")
if not problems:
print(f"[ok] all {len(TARGETS)} targets generate and pass structural checks")
return 0
print(f"[check] {len(problems)} problem(s):")
for line in problems:
print(f" - {line}")
return 1
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument("--out", type=Path, help="Output root (defaults to source for developer builds)")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
source = args.source.resolve()
out = (args.out or source).resolve()
command = cmd_check if args.check else cmd_write
sys.exit(command(source, out))
if __name__ == "__main__":
main()