// Generates the PWA icon set from the project's galaxy mark, with no // image dependency (a tiny pure-Node RGBA PNG encoder over zlib). The // mark mirrors `static/favicon.svg`: a dark disc with an amber core, // plus an amber orbit ring. Re-run after changing the palette/mark: // // node scripts/gen-pwa-icons.mjs // // Outputs into static/icons/. The committed PNGs are placeholders; swap // in real artwork at the same paths/sizes and the manifest is unchanged. import { deflateSync } from "node:zlib"; import { mkdirSync, writeFileSync } from "node:fs"; const BG = [31, 41, 55]; // #1f2937 const AMBER = [251, 191, 36]; // #fbbf24 const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); const body = Buffer.concat([Buffer.from(type, "latin1"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([len, body, crc]); } function encodePng(size, rgba) { const stride = size * 4; const raw = Buffer.alloc((stride + 1) * size); for (let y = 0; y < size; y++) { raw[y * (stride + 1)] = 0; // filter: none rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride); } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // colour type RGBA const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); return Buffer.concat([ sig, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } // `fullBleed` fills the whole square opaquely (maskable / apple-touch); // otherwise the disc is drawn on transparency with content in the safe // zone untouched. function draw(size, fullBleed) { const buf = Buffer.alloc(size * size * 4); const c = size / 2; const discR = size * 0.46; const ringR = fullBleed ? size * 0.3 : size * 0.34; const ringW = size * 0.04; const coreR = fullBleed ? size * 0.09 : size * 0.1; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const d = Math.hypot(x + 0.5 - c, y + 0.5 - c); let col = null; if (fullBleed) col = BG; else if (d <= discR) col = BG; if (col !== null) { if (Math.abs(d - ringR) <= ringW / 2) col = AMBER; if (d <= coreR) col = AMBER; buf[i] = col[0]; buf[i + 1] = col[1]; buf[i + 2] = col[2]; buf[i + 3] = 255; } } } return buf; } mkdirSync(new URL("../static/icons/", import.meta.url), { recursive: true }); const targets = [ ["icon-192.png", 192, false], ["icon-512.png", 512, false], ["icon-maskable-512.png", 512, true], ["apple-touch-icon-180.png", 180, true], ]; for (const [name, size, fullBleed] of targets) { const path = new URL(`../static/icons/${name}`, import.meta.url); writeFileSync(path, encodePng(size, draw(size, fullBleed))); console.log("wrote", name, `${size}x${size}`); }