#!/usr/bin/env node /** * Generates frontend/icons/icon-192.png and icon-512.png for the PWA manifest. * Same vermilion play-button design as appicon.png — no image libraries needed. * * Usage: node scripts/make-pwa-icons.js */ 'use strict'; const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); // ---- PNG encoder (same approach as make-icon.js) ---- 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 tb = Buffer.from(type, 'ascii'); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([tb, data])), 0); return Buffer.concat([len, tb, data, crc]); } function makePng(SIZE) { const px = Buffer.alloc(SIZE * SIZE * 4); function set(x, y, r, g, b, a = 255) { const i = (y * SIZE + x) * 4; px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; } // Rounded-rectangle background (vermilion gradient) const radius = Math.round(SIZE * 0.22); for (let y = 0; y < SIZE; y++) { const t = y / SIZE; const r = Math.round(0xff + (0xd4 - 0xff) * t); const g = Math.round(0x6a + (0x32 - 0x6a) * t); const b = Math.round(0x52 + (0x1d - 0x52) * t); for (let x = 0; x < SIZE; x++) { // Simple corner rounding via distance check const dx = Math.max(0, Math.abs(x - SIZE / 2) - (SIZE / 2 - radius)); const dy = Math.max(0, Math.abs(y - SIZE / 2) - (SIZE / 2 - radius)); if (dx * dx + dy * dy > radius * radius) { set(x, y, 0, 0, 0, 0); // transparent outside rounded rect } else { set(x, y, r, g, b); } } } // White play triangle, centered const scale = SIZE / 512; const ax = Math.round(196 * scale), ay = Math.round(150 * scale); const bx = Math.round(196 * scale), by = Math.round(362 * scale); const cx = Math.round(384 * scale), cy = Math.round(256 * scale); function edge(px1, py1, px2, py2, x, y) { return (x - px1) * (py2 - py1) - (y - py1) * (px2 - px1); } const yMin = Math.round(120 * scale), yMax = Math.round(392 * scale); const xMin = Math.round(170 * scale), xMax = Math.round(400 * scale); for (let y = yMin; y < yMax; y++) { for (let x = xMin; x < xMax; x++) { const w0 = edge(bx, by, cx, cy, x, y); const w1 = edge(cx, cy, ax, ay, x, y); const w2 = edge(ax, ay, bx, by, x, y); if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) { // Only paint if inside the rounded-rect background const cur = (y * SIZE + x) * 4; if (px[cur + 3] > 0) { px[cur] = 255; px[cur + 1] = 255; px[cur + 2] = 255; px[cur + 3] = 255; } } } } // Encode to PNG const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE); for (let y = 0; y < SIZE; y++) { raw[y * (SIZE * 4 + 1)] = 0; px.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4); } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(SIZE, 0); ihdr.writeUInt32BE(SIZE, 4); ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 6 })), chunk('IEND', Buffer.alloc(0)), ]); } const outDir = path.join(__dirname, '..', 'frontend', 'icons'); fs.mkdirSync(outDir, { recursive: true }); for (const size of [192, 512]) { const out = path.join(outDir, `icon-${size}.png`); const data = makePng(size); fs.writeFileSync(out, data); console.log(`Wrote ${out} (${size}×${size}, ${data.length} bytes)`); } console.log('Done — PWA icons ready in frontend/icons/');