feat: the apps' icons as one family, drawn by one tool (ADR 0031)

tool/app_icons.py at the root replaces Learn's and Todo's scripts: each
app's Lucide glyph (lucide-static 1.50.0) drawn by librsvg, fading from
cobalt through the colours ADR 0031 names, then every platform's
launcher icon through flutter_launcher_icons, Windows' .ico with every
size, and Learn's snap icon.

Learn's icon becomes the graduation cap, its window's on Linux too, with
Lucide's notice in its licence list; the kingfisher stays the emblem on
the rail and the launch screen. Todo's becomes list-todo, Train's bike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 10:42:47 +01:00
commit ece9ce9697
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""Makes Halkyon Todo's icons: Lucide's `list-checks` in the brand's blues (cobalt
0xFF3C57E5 into sky 0xFF179FD9, top left to bottom right, as the first start's
other tile draws its globe) on a white square, 1024 x 1024 pixels
(halkyon_todo_flutter/assets/icon/halkyon_todo_icon.png); the launch screen and
launchers round its corners themselves. Then, as Learn's tool/app_icons.py does:
- through flutter_launcher_icons, configured in the app's pubspec: Android's
adaptive icon and its older square one, iOS's, macOS's (the rounded tile on
Apple's grid, 824 of 1024 pixels) and Windows' (the rounded tile);
- Windows' .ico once more, with every size Windows shows (16 to 256 pixels).
The icon comes from lucide-static at a pinned release (LUCIDE below), fetched from
jsDelivr, with Lucide's licence (ISC), which the app registers in its licence list
(halkyon_todo_flutter/assets/icon/LICENSE-lucide.txt). No SVG renderer is needed:
the icon's paths are straight strokes, which this draws itself with Pillow, round at
their ends and joins as Lucide's are; a path with other commands fails loudly.
Run from apps/todo with Python 3 and Pillow (python3-pil on Ubuntu), the app's
dependencies fetched:
python3 -I tool/app_icons.py
"""
import math
import re
import subprocess
import urllib.request
from pathlib import Path
from PIL import Image, ImageDraw
LUCIDE = "1.50.0"
ICON = "list-checks"
CDN = f"https://cdn.jsdelivr.net/npm/lucide-static@{LUCIDE}"
APP = Path("halkyon_todo_flutter")
SOURCE = APP / "assets/icon/halkyon_todo_icon.png"
LICENSE = APP / "assets/icon/LICENSE-lucide.txt"
DERIVED = APP / "build/icons"
WINDOWS_ICON = APP / "windows/runner/resources/app_icon.ico"
IOS_PROJECT = APP / "ios/Runner.xcodeproj/project.pbxproj"
SIZE = 1024
SUPERSAMPLING = 4
# The 24-unit box Lucide draws in takes this share of the square, centred: well
# inside the circle Android's adaptive icons keep (66 %).
GLYPH = 0.6
COBALT = (0x3C, 0x57, 0xE5)
SKY = (0x17, 0x9F, 0xD9)
# The corners the launch screen and launchers round to (Learn's).
RADIUS = 142.222 / 512
MACOS_CANVAS = 1024
MACOS_TILE = 824
WINDOWS_SIZES = [16, 20, 24, 32, 40, 48, 64, 128, 256]
def fetch(name: str) -> str:
with urllib.request.urlopen(f"{CDN}/{name}", timeout=30) as response:
return response.read().decode("utf-8")
def polylines(path: str) -> list[list[tuple[float, float]]]:
"""The straight strokes of an SVG path's data, in its own units."""
tokens = re.findall(r"[A-Za-z]|-?\d*\.?\d+(?:e-?\d+)?", path)
lines: list[list[tuple[float, float]]] = []
x = y = 0.0
command = ""
i = 0
while i < len(tokens):
if tokens[i].isalpha():
command = tokens[i]
i += 1
if command in "Zz":
lines[-1].append(lines[-1][0])
continue
if command in "Mm":
dx, dy = float(tokens[i]), float(tokens[i + 1])
i += 2
x, y = (x + dx, y + dy) if command == "m" else (dx, dy)
lines.append([(x, y)])
# Further pairs after a move are lines to, as SVG has it.
command = "l" if command == "m" else "L"
elif command in "Ll":
dx, dy = float(tokens[i]), float(tokens[i + 1])
i += 2
x, y = (x + dx, y + dy) if command == "l" else (dx, dy)
lines[-1].append((x, y))
elif command in "Hh":
dx = float(tokens[i])
i += 1
x = x + dx if command == "h" else dx
lines[-1].append((x, y))
elif command in "Vv":
dy = float(tokens[i])
i += 1
y = y + dy if command == "v" else dy
lines[-1].append((x, y))
else:
raise SystemExit(f"{ICON}: the path command {command!r} isn't drawn here.")
return lines
def icon(svg: str) -> Image.Image:
"""The icon on white, SIZE pixels square."""
if f"lucide-static v{LUCIDE}" not in svg:
raise SystemExit(f"Not lucide-static {LUCIDE}'s {ICON}.")
if 'viewBox="0 0 24 24"' not in svg or 'stroke-width="2"' not in svg:
raise SystemExit(f"{ICON} isn't drawn in Lucide's 24 units at width 2.")
large = SIZE * SUPERSAMPLING
unit = large * GLYPH / 24
offset = large * (1 - GLYPH) / 2
width = 2 * unit
mask = Image.new("L", (large, large), 0)
draw = ImageDraw.Draw(mask)
half = width / 2
for data in re.findall(r'<path d="([^"]+)"', svg):
for line in polylines(data):
points = [(offset + px * unit, offset + py * unit) for px, py in line]
# Each segment as the exact band around it, so its edges meet the
# discs at its ends without a step.
for (x0, y0), (x1, y1) in zip(points, points[1:]):
length = math.hypot(x1 - x0, y1 - y0)
nx, ny = -(y1 - y0) / length * half, (x1 - x0) / length * half
draw.polygon(
[(x0 + nx, y0 + ny), (x1 + nx, y1 + ny),
(x1 - nx, y1 - ny), (x0 - nx, y0 - ny)],
fill=255,
)
# Round ends and joins: a disc at every point.
for cx, cy in points:
draw.ellipse((cx - half, cy - half, cx + half, cy + half), fill=255)
# Cobalt into sky across Lucide's box, top left to bottom right: worked out
# small and scaled up, which leaves a straight gradient as it is.
small = 256
gradient = Image.new("RGB", (small, small))
pixels = gradient.load()
start = small * (1 - GLYPH) / 2
span = small * GLYPH * 2
for gy in range(small):
for gx in range(small):
t = min(max(((gx - start) + (gy - start)) / span, 0.0), 1.0)
pixels[gx, gy] = tuple(
round(a + (b - a) * t) for a, b in zip(COBALT, SKY)
)
gradient = gradient.resize((large, large), Image.BILINEAR)
tile = Image.new("RGB", (large, large), (255, 255, 255))
tile.paste(gradient, mask=mask)
return tile.resize((SIZE, SIZE), Image.LANCZOS)
def rounded(source: Image.Image, size: int) -> Image.Image:
"""[source] at [size] pixels, its corners rounded off to transparency."""
picture = source.convert("RGBA").resize((size, size), Image.LANCZOS)
large = size * SUPERSAMPLING
mask = Image.new("L", (large, large), 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, large - 1, large - 1), radius=RADIUS * large, fill=255
)
picture.putalpha(mask.resize((size, size), Image.LANCZOS))
return picture
def main() -> None:
svg = fetch(f"icons/{ICON}.svg")
source = icon(svg)
SOURCE.parent.mkdir(parents=True, exist_ok=True)
source.save(SOURCE, optimize=True)
print(f"Wrote {SOURCE}.")
LICENSE.write_text(fetch("LICENSE"), encoding="utf-8")
print(f"Wrote {LICENSE}.")
DERIVED.mkdir(parents=True, exist_ok=True)
rounded(source, 1024).save(DERIVED / "rounded.png")
macos = Image.new("RGBA", (MACOS_CANVAS, MACOS_CANVAS), (0, 0, 0, 0))
margin = (MACOS_CANVAS - MACOS_TILE) // 2
macos.alpha_composite(rounded(source, MACOS_TILE), (margin, margin))
macos.save(DERIVED / "macos.png")
# flutter_launcher_icons rewrites the iOS project's settings; they stay as
# they are.
ios_project = IOS_PROJECT.read_bytes()
subprocess.run(["dart", "run", "flutter_launcher_icons"], cwd=APP, check=True)
IOS_PROJECT.write_bytes(ios_project)
rounded(source, 256).save(
WINDOWS_ICON, format="ICO", sizes=[(size, size) for size in WINDOWS_SIZES]
)
print(f"Wrote {WINDOWS_ICON} with {len(WINDOWS_SIZES)} sizes.")
if __name__ == "__main__":
main()