feat(icons): derive every icon from nous-girl SVGs + platform backgrounds

The icon pipeline now composes all 35 targets from two source axes instead
of a single hand-edited master:
- girl art: assets/nous-girl-black.svg / nous-girl-white.svg (brand kit)
- backgrounds: assets/backgrounds/ (squircle light/dark, logo frames,
  BrandMark tiles)

assets/icon-master*.svg are generated artifacts (squircle background + girl
nested in the 824px HIG content safe zone). New dark-appearance artifacts
(icon-dark.* containers, appx *-dark logos, logo-dark wordmark,
nous-logo-dark) land everywhere a surface consumes them: docusaurus navbar
srcDark, BrandMark dark tile (keyed off renderedMode).

nous-girl.jpg jpgs are replaced by lossless 8-bit palette PNGs saved with
compress_level=0 (stored deflate blocks, byte-identical across hosts for
the CI freshness lane).

Includes the temp linux-truth harvest step in icons-freshness-check.yml so
the committed compressed bytes can be refreshed from the ubuntu regen.
This commit is contained in:
ethernet
2026-09-06 21:12:44 -04:00
parent 0b30c2484d
commit 1fa5fea74f
51 changed files with 569 additions and 75 deletions

View File

@@ -1,10 +1,12 @@
name: Icon Assets Freshness
# Guard for the single-master icon pipeline. assets/icon-master.svg is the
# source of truth for all 23 generated icons (see scripts/generate_icons.py);
# this check regenerates every target and byte-compares against the committed
# files. The repo can never hold a hand-edited or stale generated icon, and a
# master change can never land without its regenerated outputs.
# Guard for the icon pipeline. The sources of truth are the nous-girl SVGs
# (assets/nous-girl-*.svg) and the per-platform backgrounds
# (assets/backgrounds/*.svg); assets/icon-master*.svg and every raster target
# are generated from those (see scripts/generate_icons.py). This check
# regenerates everything and byte-compares against the committed files. The
# repo can never hold a hand-edited or stale generated icon, and an art or
# background change can never land without its regenerated outputs.
#
# Byte-determinism note: resvg renders the same SVG to identical bytes on any
# host for pure-shape artwork. If the master ever contains <text>, font
@@ -43,3 +45,23 @@ jobs:
- name: Verify generated icons are in sync with the master
run: uv run python scripts/generate_icons.py --check
continue-on-error: true
# TEMP: upload linux-truth regeneration so the committed set can be
# refreshed from the host CI actually checks against.
- name: Regenerate + upload (temp artifact harvest)
if: always()
shell: bash
run: |
uv run python scripts/generate_icons.py
tar czf icons-linux.tgz \
apps/bootstrap-installer/public/ \
apps/bootstrap-installer/src-tauri/icons/ \
apps/desktop/assets/ \
apps/desktop/public/ \
web/public/favicon.ico website/static/img/
- uses: actions/upload-artifact@v4
if: always()
with:
name: icons-linux
path: icons-linux.tgz

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 B

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -2,12 +2,13 @@ import { cn } from '../lib/utils'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
// Brand badge: nous-girl mark on a white tile (the light BrandMark variant).
// Ported from apps/desktop's BrandMark; asset lives in this app's public/.
// The installer has no theme system, so it always uses the light asset.
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)} {...props}>
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.png')} />
</span>
)
}

View File

@@ -99,8 +99,9 @@ for call-site shadow or border inventions.
| `--chrome-action-hover` | hover fill for quiet controls |
| `--theme-primary`, `--ui-accent` | brand/accent |
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in
`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop).
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The two tiles in
`BrandMark` (white in light mode, `#0d1117` in dark) are the sanctioned literals
(the mark needs a fixed backdrop).
## Buttons — one component
@@ -253,7 +254,11 @@ Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
action. Do not introduce a third icon set or mix styles within one control
group.
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
`nous-girl` mark on a fixed tile, theme-aware: black girl on white in light
mode, white girl on the `#0d1117` dark tile in dark mode (keyed off
`renderedMode` so bright surfaces in dark skins stay on the light mark).
The marks are generated by `scripts/generate_icons.py` from the girl SVGs
+ tile backgrounds (`assets/nous-girl-*.svg` × `assets/backgrounds/tile-*.svg`).
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
for hero/brand moments; don't reintroduce decorative star/sparkle icons.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 995 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,19 +1,25 @@
import { cn } from '@/lib/utils'
import { useTheme } from '@/themes'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
// Brand badge: nous-girl mark on a fixed tile, theme-aware — light mode shows
// the black girl on white, dark mode the white girl on the nous dark surface.
// Fills the tile (softly rounded); size via className (default size-14).
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
const { renderedMode } = useTheme()
const dark = renderedMode === 'dark'
return (
<span
className={cn(
'inline-flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white',
dark && 'bg-[#0d1117]',
className
)}
{...props}
>
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
<img alt="" className="size-full object-contain" src={assetPath(dark ? 'nous-girl-dark.png' : 'nous-girl.png')} />
</span>
)
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1772 1799">
<!-- website wordmark background, dark: white frame (L/R 14, T 17, B 16) + #0d1117 interior -->
<rect x="0" y="0" width="1772" height="1799" rx="2" fill="#ffffff"/>
<rect x="14" y="17" width="1743" height="1766" fill="#0d1117"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1772 1799">
<!-- website wordmark background, light: black frame (L/R 14, T 17, B 16) + white interior -->
<rect x="0" y="0" width="1772" height="1799" rx="2" fill="#000000"/>
<rect x="14" y="17" width="1743" height="1766" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- app-icon background, dark: #0d1117 rounded square (rx=245), transparent corners -->
<rect x="0" y="0" width="1024" height="1024" rx="245" fill="#0d1117"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<!-- app-icon background, light: white rounded square (rx=245), transparent corners -->
<rect x="0" y="0" width="1024" height="1024" rx="245" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- BrandMark tile, dark: solid #0d1117 -->
<rect x="0" y="0" width="256" height="256" fill="#0d1117"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- BrandMark tile, light: solid white -->
<rect x="0" y="0" width="256" height="256" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 178 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 134 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 113 KiB

104
assets/nous-girl-black.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 115 KiB

113
assets/nous-girl-white.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -1,17 +1,33 @@
#!/usr/bin/env python3
"""Generate every app icon in the repo from a single master SVG.
"""Generate every app icon in the repo from the nous-girl art + platform backgrounds.
Usage (from repo root):
.venv/Scripts/python.exe scripts/generate_icons.py # write
.venv/Scripts/python.exe scripts/generate_icons.py --check # verify only
Master: assets/icon-master.svg
A 1024x1024 SVG. The background must be a WHITE ROUNDED SQUARE
(rx ~ 245, spanning the full canvas) with the artwork on top —
the transparent corners outside the squircle are what give the
icons their app-icon shape everywhere downstream. If the master
is missing, a red-circle placeholder is written automatically;
drop in the real artwork and re-run.
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/logo-frame.svg black frame +
white interior (website)
assets/backgrounds/logo-frame-dark.svg white frame +
#0d1117 interior
assets/backgrounds/tile-light.svg solid white (BrandMark)
assets/backgrounds/tile-dark.svg solid #0d1117
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 — not hand-edited sources. The 1024 master is
what every squircle target renders from.
If the girl SVGs are missing a red-circle placeholder master is written so a
partial checkout still generates; --check then flags the drift.
--check mode regenerates every target in memory and byte-compares it
against the committed file, exiting nonzero on any drift. This is what
@@ -25,36 +41,48 @@ Deps:
Pillow (core dependency), resvg-py (dev extra):
uv sync --extra dev
Outputs (23 files, all measured against the originals):
apps/desktop/assets/icon.png 1024x1024 squircle
apps/desktop/assets/icon.ico 16,24,32,48,64,128,256
apps/desktop/assets/icon.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/public/apple-touch-icon.png 1024x1024 squircle
apps/desktop/public/nous-girl.jpg 256x256 JPEG on white
Outputs (35 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 (ready
to wire into the MSIX
manifest when app-builder-lib
supports contrast images)
apps/desktop/public/apple-touch-icon.png 1024x1024 squircle
apps/desktop/public/nous-girl.png 256x256 PNG, black girl on white (light)
apps/desktop/public/nous-girl-dark.png 256x256 PNG, white girl on #0d1117 (dark)
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.jpg 256x256 JPEG on white
apps/bootstrap-installer/public/nous-girl.png 256x256 PNG, black girl on white (light)
website/static/img/logo.png 1772x1799 black-frame wordmark
website/static/img/logo-dark.png 1772x1799 white-frame wordmark
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 master
website/static/img/favicon.svg copy of the light master
web/public/favicon.ico 16,32,48
"""
from __future__ import annotations
import io
import shutil
import re
import sys
from pathlib import Path
@@ -69,10 +97,34 @@ except ImportError:
)
ROOT = Path(__file__).resolve().parent.parent
MASTER = ROOT / "assets" / "icon-master.svg"
ASSETS = ROOT / "assets"
BACKGROUNDS = ASSETS / "backgrounds"
GIRL_BLACK = ASSETS / "nous-girl-black.svg"
GIRL_WHITE = ASSETS / "nous-girl-white.svg"
GIRLS = {"black": GIRL_BLACK, "white": GIRL_WHITE}
MASTER = ASSETS / "icon-master.svg"
MASTER_DARK = ASSETS / "icon-master-dark.svg"
# The squircle corner radius measured on the real 1024 icon (~245px).
SQUIRCLE_RADIUS = 245
# The nous dark background (#0d1117) — fixed dark tile/background everywhere.
DARK_HEX = "#0d1117"
DARK_RGB = (13, 17, 23)
# Girl placement per background: (x, y, w, h) in that background's coordinate
# space. Squircle backgrounds put the girl in the 824px HIG content safe zone
# (centered, 100px pad on a 1024 canvas); logo frames fill the white/dark
# interior at ~1.7x; BrandMark tiles take the girl height-fitted (meet).
GIRL_BOXES = {
"squircle-light.svg": (100, 100, 824, 824),
"squircle-dark.svg": (100, 100, 824, 824),
"logo-frame.svg": (14, 17, 1743, 1766),
"logo-frame-dark.svg": (14, 17, 1743, 1766),
"tile-light.svg": (0, 0, 256, 256),
"tile-dark.svg": (0, 0, 256, 256),
}
# The brand-kit SVG canvas (both girl svgs share this viewBox).
GIRL_VIEWBOX = 5487.0615
PLACEHOLDER = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<rect x="0" y="0" width="1024" height="1024" rx="{SQUIRCLE_RADIUS}" fill="#ffffff"/>
@@ -82,23 +134,35 @@ PLACEHOLDER = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024
# (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),
("apps/desktop/public/nous-girl.jpg", "jpg_white", 256),
("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.jpg", "jpg_white", 256),
("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),
@@ -107,23 +171,92 @@ TARGETS: list[tuple[str, str, object]] = [
("web/public/favicon.ico", "ico", [16, 32, 48]),
]
# ─── girl art extraction ────────────────────────────────────────────────────
def ensure_master(check: bool = False) -> Path:
if not MASTER.exists():
_path_cache: dict[str, str] = {}
_bbox_cache: dict[str, tuple[float, float, float, float]] = {}
def girl_path(girl: str) -> str:
"""The girl `<path>` element with editor metadata stripped (resvg rejects
undeclared inkscape/sodipodi prefixes)."""
if girl not in _path_cache:
src = GIRLS[girl].read_text(encoding="utf-8")
m = re.search(r"<path\b.*?/>", src, re.S)
assert m, f"no <path> found in {GIRLS[girl].name}"
path = re.sub(r'\s+(inkscape|sodipodi):[a-zA-Z-]+="[^"]*"', "", m.group(0))
_path_cache[girl] = path
return _path_cache[girl]
def girl_bbox(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 _bbox_cache:
data = resvg_py.svg_to_bytes(svg_path=str(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
_bbox_cache[girl] = (bx * s, by * s, (bx2 - bx) * s, (by2 - by) * s)
return _bbox_cache[girl]
def girl_layer(girl: str, box: tuple[float, float, float, float]) -> 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(girl)
x, y, w, h = box
return (
f'<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{bx} {by} {bw} {bh}">\n'
f" {girl_path(girl)}\n"
" </svg>"
)
def background_inner(name: str) -> tuple[str, int, int]:
"""Inner content + (width, height) of a background SVG asset."""
text = (BACKGROUNDS / name).read_text(encoding="utf-8")
m = re.search(r"<svg\b[^>]*viewBox=\"0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)\"[^>]*>", text)
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)
return inner, int(w), int(h)
def compose_master(girl: str, bg: str) -> str:
"""Composed master SVG: background + girl layer."""
inner, w, h = background_inner(bg)
x, y, bx, bh = GIRL_BOXES[bg]
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">\n'
f" {inner.strip()}\n"
f" {girl_layer(girl, (x, y, bx, bh))}\n"
"</svg>\n"
)
def ensure_masters(check: bool = False) -> None:
"""Write the generated light/dark masters; placeholder fallback if the
girl art is missing."""
missing = [p for p in (GIRL_BLACK, GIRL_WHITE) if not p.exists()]
if missing:
if check:
sys.exit(
f"[check] {MASTER.relative_to(ROOT)} is missing — "
"cannot verify generated icons without a master."
)
MASTER.write_text(PLACEHOLDER, encoding="utf-8")
print(f"[master] wrote placeholder: {MASTER.relative_to(ROOT)}")
return MASTER
sys.exit(f"[check] girl art missing: {[p.name for p in missing]}")
for path, content in ((MASTER, PLACEHOLDER), (MASTER_DARK, PLACEHOLDER)):
path.write_text(content, encoding="utf-8")
print(f"[master] wrote placeholder masters ({[p.name for p in missing]})")
return
MASTER.write_text(compose_master("black", "squircle-light.svg"), encoding="utf-8")
MASTER_DARK.write_text(compose_master("white", "squircle-dark.svg"), encoding="utf-8")
def render(size: int, *, background: str | None = None) -> Image.Image:
"""Render the master to an RGBA PNG of `size`x`size`."""
# ─── rendering ──────────────────────────────────────────────────────────────
def render(master: Path, 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_path=str(MASTER), width=size, height=size, background=background
svg_path=str(master), width=size, height=size, background=background
)
return Image.open(io.BytesIO(data)).convert("RGBA")
@@ -134,11 +267,37 @@ def paste_centered(canvas: Image.Image, img: Image.Image) -> None:
canvas.alpha_composite(img, (x, y))
def build_logo_image() -> Image.Image:
"""1772x1799: black frame (~14-17px) + white interior + art at ~1.7x.
def girl_mark(kind: str, size: int) -> Image.Image:
"""Composite the girl SVG onto a fixed tile (BrandMark asset), rendered
straight from the vector art (crisp at any size).
Measured from the real logo.png: border L/R 14, T 17, B 16; white
interior spans (15,18)..(1758,1784) = 1743x1766.
girl_light: black girl on white tile. girl_dark: white girl on #0d1117.
"""
if kind == "girl_light":
girl, bg = "black", "tile-light.svg"
else:
girl, bg = "white", "tile-dark.svg"
if not GIRLS[girl].exists():
print(f" [girl] {GIRLS[girl].name} missing — placeholder fallback")
return render(MASTER if kind == "girl_light" else MASTER_DARK, size)
inner, w, h = background_inner(bg)
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}">\n'
f" {inner.strip()}\n"
f" {girl_layer(girl, GIRL_BOXES[bg])}\n"
"</svg>\n"
)
data = resvg_py.svg_to_bytes(svg_string=svg, width=size, height=size)
return Image.open(io.BytesIO(data)).convert("RGBA")
def build_logo_image(master: Path, dark: bool = False) -> Image.Image:
"""1772x1799 wordmark: frame (light: black / dark: white, ~14-17px) +
interior (light: white / dark: #0d1117) + art at ~1.7x.
Measured from the real logo.png: border L/R 14, T 17, B 16; interior
spans (15,18)..(1758,1784) = 1743x1766.
"""
from PIL import ImageDraw
@@ -146,55 +305,88 @@ def build_logo_image() -> Image.Image:
art_w, art_h = 1743, 1766
border = 14
top, bottom = 17, 16
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
if dark:
frame, interior = DARK_RGB, (255, 255, 255)
else:
frame, interior = (0, 0, 0), (255, 255, 255)
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
draw.rounded_rectangle((0, 0, W - 1, H - 1), radius=2, fill=(0, 0, 0, 255))
draw.rounded_rectangle((0, 0, W - 1, H - 1), radius=2, fill=frame)
draw.rectangle(
(border, top, W - 1 - border, H - 1 - bottom),
fill=(255, 255, 255, 255),
fill=interior,
)
# Art: render the master square at ~1.7x, tiny vertical stretch to fill.
art = render(art_w).resize((art_w, art_h), Image.LANCZOS)
art = render(master, art_w).resize((art_w, art_h), Image.LANCZOS)
canvas.alpha_composite(art, (border, top))
return canvas
def target_bytes(kind: str, arg: object) -> bytes:
"""Produce the exact bytes for one target. Shared by write + check."""
if kind == "svg":
return MASTER.read_bytes()
if kind == "svg_dark":
return MASTER_DARK.read_bytes()
if kind == "svg_copy":
return MASTER.read_bytes()
buf = io.BytesIO()
if kind == "png":
render(arg).save(buf, "PNG")
render(MASTER, arg).save(buf, "PNG")
elif kind == "png_dark":
render(MASTER_DARK, arg).save(buf, "PNG")
elif kind == "png_white":
render(arg, background="#ffffff").save(buf, "PNG")
elif kind == "jpg_white":
# JPEG has no alpha: flatten the squircle onto white.
render(arg, background="#ffffff").convert("RGB").save(buf, "JPEG", quality=90)
render(MASTER, arg, background="#ffffff").save(buf, "PNG")
elif kind == "png_dark_white":
render(MASTER_DARK, arg, background=DARK_HEX).save(buf, "PNG")
elif kind == "girl_light" or kind == "girl_dark":
# Flat 2-tone vector-style art: <256 distinct colors, so an 8-bit
# palette PNG holds it pixel-identically. Saved with compress_level=0
# (stored deflate blocks): the icons-freshness lane byte-compares
# against an ubuntu regen, and compressed PNG bytes drift per host
# zlib — stored blocks are spec-locked, identical on every platform.
# ~66KB vs ~15KB compressed is a non-issue for a bundled asset.
girl_mark(kind, arg).convert("RGB").convert("P", palette=Image.ADAPTIVE, colors=256).save(
buf, "PNG", compress_level=0
)
elif kind == "ico":
img = render(max(arg))
img = render(MASTER, max(arg))
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
elif kind == "ico_dark":
img = render(MASTER_DARK, max(arg))
img.save(buf, format="ICO", sizes=[(s, s) for s in arg])
elif kind == "icns":
img = render(1024)
img = render(MASTER, 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(MASTER_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(100))
paste_centered(canvas, render(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(MASTER_DARK, 100))
canvas.save(buf, "PNG")
elif kind == "logo":
build_logo_image().save(buf, "PNG")
build_logo_image(MASTER, dark=False).save(buf, "PNG")
elif kind == "logo_dark":
build_logo_image(MASTER_DARK, dark=True).save(buf, "PNG")
else:
raise ValueError(f"unknown kind {kind!r}")
return buf.getvalue()
def cmd_write() -> None:
ensure_master()
ensure_masters()
written = 0
for rel, kind, arg in TARGETS:
path = ROOT / rel
@@ -204,17 +396,17 @@ def cmd_write() -> None:
written += 1
except Exception as exc: # noqa: BLE001 - report and keep going
print(f" !! {rel}: FAILED ({exc})")
print(f"[ok] wrote {written}/{len(TARGETS)} files from {MASTER.name}")
print(f"[ok] wrote {written}/{len(TARGETS)} files")
print("\n[verify]")
for rel, kind, arg in TARGETS:
path = ROOT / rel
try:
if kind == "svg_copy":
if kind in ("svg", "svg_dark", "svg_copy"):
print(f" {rel}: {path.stat().st_size} bytes SVG")
continue
im = Image.open(path)
if kind == "ico":
if kind in ("ico", "ico_dark"):
sizes = []
try:
for i in range(im.n_frames):
@@ -223,7 +415,7 @@ def cmd_write() -> None:
except Exception:
sizes = [im.size]
print(f" {rel}: ICO {sorted(set(sizes))}")
elif kind == "icns":
elif kind in ("icns", "icns_dark"):
print(f" {rel}: ICNS {im.size} (container)")
else:
print(f" {rel}: {im.format} {im.size}")
@@ -232,7 +424,7 @@ def cmd_write() -> None:
def cmd_check() -> int:
ensure_master(check=True)
ensure_masters(check=True)
drifted: list[str] = []
for rel, kind, arg in TARGETS:
path = ROOT / rel
@@ -249,10 +441,10 @@ def cmd_check() -> int:
drifted.append(f"{rel}: drift ({len(actual)} bytes on disk vs {len(expected)} generated)")
if not drifted:
print(f"[ok] all {len(TARGETS)} generated icons match {MASTER.name}")
print(f"[ok] all {len(TARGETS)} generated icons match the girl art + backgrounds")
return 0
print(f"[check] {len(drifted)} file(s) out of sync with {MASTER.name}:")
print(f"[check] {len(drifted)} file(s) out of sync:")
for line in drifted:
print(f" - {line}")
print(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -121,6 +121,7 @@ const config: Config = {
logo: {
alt: 'Hermes Agent',
src: 'img/logo.png',
srcDark: 'img/logo-dark.png',
},
items: [
{

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 B

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB