/** * PWA icon generator — writes simple solid-background PNGs (192 & 512 px) * with a hand-rolled PNG encoder (no image dependencies, D-001). The glyph — * a ring on the app's dark background — stays well inside the maskable safe * zone, so the icons declare `purpose: "any maskable"` in the manifest. * * Run directly (`bun scripts/generate-icons.ts`) or via `ensureIcons()` from * src/index.ts (dev) and scripts/build.ts (production). */ import { deflateSync } from "node:zlib"; import { join } from "node:path"; export const ICON_SIZES = [192, 512] as const; // App theme (src/index.css / manifest.webmanifest). const BACKGROUND: readonly [number, number, number] = [0x16, 0x18, 0x1d]; const ACCENT: readonly [number, number, number] = [0x7a, 0xa2, 0xf7]; // --- Minimal PNG encoding (8-bit RGBA, filter 0) ------------------------- const CRC_TABLE = new Uint32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(bytes: Uint8Array): number { let crc = 0xffffffff; for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function pngChunk(type: string, data: Uint8Array): Uint8Array { const chunk = new Uint8Array(12 + data.length); const view = new DataView(chunk.buffer); view.setUint32(0, data.length); for (let i = 0; i < 4; i++) chunk[4 + i] = type.charCodeAt(i); chunk.set(data, 8); view.setUint32(8 + data.length, crc32(chunk.subarray(4, 8 + data.length))); return chunk; } /** Encode an RGBA pixel buffer as a PNG file. */ function encodePng(size: number, rgba: Uint8Array): Uint8Array { const ihdr = new Uint8Array(13); const ihdrView = new DataView(ihdr.buffer); ihdrView.setUint32(0, size); ihdrView.setUint32(4, size); ihdr[8] = 8; // bit depth ihdr[9] = 6; // color type: RGBA // compression 0, filter 0, interlace 0 // Scanlines, each prefixed with filter byte 0. const raw = new Uint8Array(size * (size * 4 + 1)); for (let y = 0; y < size; y++) { raw.set(rgba.subarray(y * size * 4, (y + 1) * size * 4), y * (size * 4 + 1) + 1); } const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const idat = new Uint8Array(deflateSync(raw)); return Uint8Array.from([ ...signature, ...pngChunk("IHDR", ihdr), ...pngChunk("IDAT", idat), ...pngChunk("IEND", new Uint8Array(0)), ]); } // --- Icon artwork --------------------------------------------------------- /** 0→1 coverage across a ~1px band, for cheap antialiasing. */ function edge(distance: number): number { return Math.min(1, Math.max(0, distance + 0.5)); } export function encodeIcon(size: number): Uint8Array { const rgba = new Uint8Array(size * size * 4); const center = (size - 1) / 2; const outer = size * 0.3; const inner = size * 0.17; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const d = Math.hypot(x - center, y - center); // Ring: inside outer radius but outside inner radius. const cover = edge(outer - d) * edge(d - inner); const offset = (y * size + x) * 4; for (let c = 0; c < 3; c++) { rgba[offset + c] = Math.round( BACKGROUND[c]! + (ACCENT[c]! - BACKGROUND[c]!) * cover, ); } rgba[offset + 3] = 255; // opaque — maskable icons need a full background } } return encodePng(size, rgba); } /** Directory the manifest's icon URLs are served from in dev. */ export const ICONS_DIR = join(import.meta.dir, "../src/pwa/icons"); /** Generate any missing icons into `dir` (idempotent). */ export async function ensureIcons(dir: string = ICONS_DIR): Promise { for (const size of ICON_SIZES) { const path = join(dir, `icon-${size}.png`); if (await Bun.file(path).exists()) continue; await Bun.write(path, encodeIcon(size)); console.log(`Wrote ${path}`); } } if (import.meta.main) { await ensureIcons(); }