#!/usr/bin/env bun // Generates the PWA icon set from public/logo.svg. // // bun run scripts/gen-icons.ts // // The output is committed rather than gitignored: Caddy serves /public/* straight // from /opt/lichen/public on the host (see deploy/Caddyfile.snippet), so icons // built only inside the container image would never be reachable in production. import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import sharp from "sharp"; const PUBLIC_DIR = join(import.meta.dir, "..", "public"); const OUT_DIR = join(PUBLIC_DIR, "icons"); // Matches THEME.light.bg — the mark is teal, so it needs a light ground to read. const BACKGROUND = "#fafaf9"; // Android crops maskable icons to a circle 80% of the canvas width. A square mark // only fits inside that circle at ~56%, so maskable variants shrink the mark // instead of letting the crop clip it. const STANDARD_MARK_RATIO = 0.72; const MASKABLE_MARK_RATIO = 0.55; // librsvg rasterizes at this DPI before the downscale; the viewBox is ~135 units // wide, so 384 renders ~720px and keeps the 512px icons crisp. const RENDER_DENSITY = 384; interface IconSpec { file: string; size: number; markRatio: number; } const ICONS: IconSpec[] = [ { file: "icon-192.png", size: 192, markRatio: STANDARD_MARK_RATIO }, { file: "icon-512.png", size: 512, markRatio: STANDARD_MARK_RATIO }, { file: "icon-192-maskable.png", size: 192, markRatio: MASKABLE_MARK_RATIO }, { file: "icon-512-maskable.png", size: 512, markRatio: MASKABLE_MARK_RATIO }, // iOS ignores transparency and applies its own rounding, so this is the // standard ratio on the same opaque ground. { file: "apple-touch-icon.png", size: 180, markRatio: STANDARD_MARK_RATIO }, ]; async function renderIcon(logo: Buffer, spec: IconSpec): Promise { const markSize = Math.round(spec.size * spec.markRatio); const mark = await sharp(logo, { density: RENDER_DENSITY }) .resize(markSize, markSize, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .png() .toBuffer(); await sharp({ create: { width: spec.size, height: spec.size, channels: 4, background: BACKGROUND, }, }) .composite([{ input: mark, gravity: "centre" }]) .png() .toFile(join(OUT_DIR, spec.file)); } const logo = Buffer.from( await Bun.file(join(PUBLIC_DIR, "logo.svg")).arrayBuffer(), ); await mkdir(OUT_DIR, { recursive: true }); await Promise.all(ICONS.map((spec) => renderIcon(logo, spec))); console.log(`Wrote ${ICONS.length} icons to public/icons/`);