icons: one mark in, every size out, and a place to serve them

Three surfaces were showing generated identicons: the forge org avatar
(which is what Discord unfurls), the GitHub org avatar, and the browser
tab, because the app shipped no favicon at all.

make_icons.py turns one SVG or PNG into the seven sizes plus a
multi-resolution favicon.ico. The server now serves /favicon.ico and
/icons/*.png from icons/ when they exist, and index.html asks for them,
so dropping the real mark into icons/ is the whole integration.

Co-developed-with: CLU <clu@binaryranch.com>
This commit is contained in:
Christian Sass 2026-10-21 13:35:45 -05:00
parent 9cdbb43394
commit 52885cad78
3 changed files with 104 additions and 0 deletions

View file

@ -4,6 +4,12 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vitals — workload map</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/icons/icon-180.png">
<meta property="og:image" content="/icons/icon-512.png">
<meta property="og:title" content="Vitals — workload map">
<meta property="og:description" content="Group processes by human identity, then cross the browser boundary and give every tab its own CPU number.">
<meta name="twitter:card" content="summary_large_image">
<style>
:root{
--bg:#0d1013; --panel:#12161a; --panel2:#171c21; --line:#232a31;

View file

@ -744,6 +744,19 @@ class Handler(BaseHTTPRequestHandler):
return self._send(f.read(), "text/html; charset=utf-8")
except OSError:
return self._send("index.html missing", "text/plain", 500)
if u.path == "/favicon.ico":
path = os.path.join(HERE, "icons", "favicon.ico")
if os.path.exists(path):
with open(path, "rb") as f:
return self._send(f.read(), "image/x-icon")
return self._send("no icon yet", "text/plain", 404)
if u.path.startswith("/icons/"):
name = os.path.basename(u.path) # never trust the path
path = os.path.join(HERE, "icons", name)
if name.endswith(".png") and os.path.exists(path):
with open(path, "rb") as f:
return self._send(f.read(), "image/png")
return self._send("no icon yet", "text/plain", 404)
if u.path == "/api/snapshot":
if DEMO["data"]:
snap = dict(DEMO["data"]["snapshot"])

85
tools/make_icons.py Normal file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
make_icons.py — turn one source mark into every icon size the surfaces need.
One file in, the full set out, because the alternative is discovering three
weeks later that the forge unfurl, the GitHub avatar, and the browser tab are
all still showing generated identicons.
python3 tools/make_icons.py brand/mark.svg
python3 tools/make_icons.py brand/mark.png --out-dir icons
Produces, in the output directory:
icon-16.png icon-32.png icon-48.png icon-180.png icon-256.png
icon-512.png icon-1024.png favicon.ico (16/32/48 multi-size)
Where each one goes:
1024 + 512 store listings, social cards, press kit
512 Forgejo org avatar, GitHub org avatar
180 apple-touch-icon (iOS home screen)
16/32/48 favicon.ico, browser tabs, bookmarks
"""
import argparse
import os
import shutil
import subprocess
import sys
from PIL import Image
RESAMPLE = getattr(Image, "Resampling", Image).LANCZOS
SIZES = [16, 32, 48, 180, 256, 512, 1024]
ICO_SIZES = [16, 32, 48, 64]
def load_square(path, size=1024):
"""Return a square RGBA image, rendering SVG at high resolution first."""
ext = os.path.splitext(path)[1].lower()
tmp = None
if ext == ".svg":
tmp = f"/tmp/.icon-src-{os.getpid()}.png"
if shutil.which("inkscape"):
subprocess.run(["inkscape", path, "--export-type=png", f"--export-filename={tmp}",
f"--export-width={size}", f"--export-height={size}"],
check=True, capture_output=True)
else:
subprocess.run(["convert", "-background", "none", "-density", "600", path,
"-resize", f"{size}x{size}", tmp], check=True, capture_output=True)
path = tmp
img = Image.open(path).convert("RGBA")
if tmp:
os.unlink(tmp)
side = max(img.size)
canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0))
canvas.paste(img, ((side - img.width) // 2, (side - img.height) // 2), img)
return canvas.resize((size, size), RESAMPLE)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source", help="square-ish .svg or .png/.jpg mark")
ap.add_argument("--out-dir", default="icons")
args = ap.parse_args()
if not os.path.exists(args.source):
sys.exit(f"no such file: {args.source}")
master = load_square(args.source)
os.makedirs(args.out_dir, exist_ok=True)
for size in SIZES:
out = os.path.join(args.out_dir, f"icon-{size}.png")
master.resize((size, size), RESAMPLE).save(out)
print(f" {out}")
ico_path = os.path.join(args.out_dir, "favicon.ico")
master.resize((256, 256), RESAMPLE).save(ico_path, sizes=[(s, s) for s in ICO_SIZES])
print(f" {ico_path} ({', '.join(str(s) for s in ICO_SIZES)})")
print(f"\n{len(SIZES) + 1} files in {args.out_dir}/")
print("upload icon-512.png as the org avatar on both forges; the rest ship with the app")
if __name__ == "__main__":
main()