Use the HEOS mark, in white, for the header and the icon

The word HEOS at the top of the panel becomes the mark itself, and the
home-screen icons are rendered from the same file so there is one place
to change it. All white on transparent: the panel is dark, and iOS lays
a transparent apple-touch-icon over black.

make_icons.py no longer draws its own glyph -- it rasterises logo.svg
with headless Chromium, which is heavier than the old arithmetic but the
only way to render real paths without a system SVG library. The PNGs are
committed, so it only has to run when the logo changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 21:45:33 +02:00
co-authored by Claude Opus 5
parent 1a1fcc84d6
commit aa1b734b3a
7 changed files with 62 additions and 75 deletions
+50 -72
View File
@@ -1,88 +1,66 @@
#!/usr/bin/env python3
"""Draw the home-screen icon, with no image library to install.
"""Render static/logo.svg into the home-screen icons.
python3 tools/make_icons.py
Writes static/icon-180.png (what iOS uses for the home screen) and
static/icon-512.png (Android / the web manifest). Re-run it if you want
different colours -- they are the two constants below.
Writes static/icon-180.png (what iOS uses on the home screen) and
static/icon-512.png (Android / the web manifest): the white mark on a
transparent background, which iOS lays over black on the home screen.
The PNGs are committed, so this only needs running if the logo changes.
It rasterises with headless Chromium, which is a heavy thing to install
for one job -- if you have librsvg to hand, this does the same:
rsvg-convert -w 512 -h 512 static/logo.svg -o static/icon-512.png
Otherwise: pip install playwright && playwright install chromium
"""
import math
import struct
import zlib
import sys
from pathlib import Path
TOP = (0x1D, 0x27, 0x3C) # background gradient, top
BOTTOM = (0x0A, 0x0D, 0x14) # background gradient, bottom
GLYPH = (0xEE, 0xF2, 0xF9) # the wave itself
STATIC = Path(__file__).resolve().parent.parent / "static"
SAMPLES = 3 # supersampling per axis, for smooth edges
LOGO = STATIC / "logo.svg"
SIZES = (180, 512)
MARGIN = 0.14 # breathing room, so iOS's rounded mask never clips it
PAGE = """<!doctype html>
<style>
html, body {{ margin: 0; background: transparent; }}
body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }}
img {{ width: {inner}px; height: {inner}px; object-fit: contain; }}
</style>
<img src="logo.svg">
"""
def coverage(x: float, y: float, size: float) -> float:
"""How much of the glyph covers this point: a dot plus three arcs
opening to the right, i.e. the usual 'sound coming out' mark."""
cx, cy = 0.33 * size, 0.5 * size
dx, dy = x - cx, y - cy
distance = math.hypot(dx, dy)
def main():
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit("needs playwright: pip install playwright && playwright install chromium")
if distance <= 0.075 * size:
return 1.0
if not LOGO.exists():
sys.exit(f"no logo at {LOGO}")
angle = abs(math.degrees(math.atan2(dy, dx)))
if angle > 50:
return 0.0
half = 0.024 * size
for radius in (0.17, 0.27, 0.37):
if abs(distance - radius * size) <= half:
return 1.0
return 0.0
def render(size: int) -> bytes:
rows = bytearray()
step = 1.0 / SAMPLES
for py in range(size):
rows.append(0) # PNG filter type 0 for this scanline
mix = py / max(1, size - 1)
background = tuple(
round(TOP[i] + (BOTTOM[i] - TOP[i]) * mix) for i in range(3)
)
for px in range(size):
hits = 0
for sy in range(SAMPLES):
for sx in range(SAMPLES):
if coverage(px + (sx + 0.5) * step, py + (sy + 0.5) * step, size):
hits += 1
alpha = hits / (SAMPLES * SAMPLES)
if alpha == 0:
rows.extend(background)
else:
rows.extend(
round(background[i] + (GLYPH[i] - background[i]) * alpha) for i in range(3)
)
return bytes(rows)
def write_png(path: Path, size: int, raw: bytes):
def chunk(kind: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + kind + data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit truecolour
path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
scratch = STATIC / "_icon.html"
try:
with sync_playwright() as p:
browser = p.chromium.launch(args=["--no-sandbox"])
for size in SIZES:
inner = round(size * (1 - 2 * MARGIN))
scratch.write_text(PAGE.format(size=size, inner=inner))
page = browser.new_page(viewport={"width": size, "height": size})
page.goto(scratch.as_uri())
page.wait_for_timeout(120) # let the SVG paint
target = STATIC / f"icon-{size}.png"
page.screenshot(path=target, omit_background=True)
page.close()
print(f"wrote {target} ({target.stat().st_size} bytes)")
browser.close()
finally:
scratch.unlink(missing_ok=True)
if __name__ == "__main__":
for size in (180, 512):
target = STATIC / f"icon-{size}.png"
write_png(target, size, render(size))
print(f"wrote {target} ({target.stat().st_size} bytes)")
main()