/** * Renders the Freezmo app icons (PWA + iOS) from the master artwork with * headless Chromium. * Run: node scripts/make-icons.mjs (requires a Playwright Chromium cache) * * Master: assets/app-icon-source.jpeg — a 1024x1024 navy rounded square. * 1. The master is masked at full resolution first (a rounded rect 4px * inside the artwork's own corner radius), so the JPEG's anti-aliased * white edge never survives and small sizes downscale with clean alpha. * 2. "any" icons use that masked master as-is. * Apple touch icons are full-bleed, because iOS paints transparency * black; the artwork is zoomed 20% so its corners fall outside the * square (the master's corners are ~85px, so nothing else is cropped). * The maskable icon keeps the artwork inside the 80% safe circle on a * matching navy field. * Also writes app/favicon.ico, the Next.js file convention for the browser * tab (the larger sizes are declared in app/layout.tsx metadata). */ import { chromium } from "playwright-core"; import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"; const CHROME = process.env.CHROME ?? "/home/martin/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome"; const ROOT = new URL("../", import.meta.url).pathname; const OUT = `${ROOT}public/icons/`; const SOURCE = `${ROOT}assets/app-icon-source.jpeg`; mkdirSync(OUT, { recursive: true }); /** Sampled from the master artwork: background colour, size, corner radius. */ const NAVY = "#101528"; const SIZE = 1024; const CORNER = 253; const ERODE = 4; // keeps the clip inside the artwork's anti-aliased edge const sourceUri = `data:image/jpeg;base64,${readFileSync(SOURCE).toString("base64")}`; const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] }); /* Stage 1: mask the master at full resolution. */ const masterHtml = ` `; const masterPage = await browser.newPage({ viewport: { width: SIZE, height: SIZE }, deviceScaleFactor: 1 }); await masterPage.setContent(masterHtml, { waitUntil: "load" }); const master = await masterPage.screenshot({ omitBackground: true }); await masterPage.close(); const masterUri = `data:image/png;base64,${master.toString("base64")}`; /* Stage 2: render each target from the masked master. */ function pageHtml(size, mode) { const img = mode === "bleed" ? sourceUri : masterUri; return `
`; } const targets = [ // Standard PWA icons: the artwork's rounded square, transparent outside { file: "icon-192.png", size: 192, mode: "rounded" }, { file: "icon-512.png", size: 512, mode: "rounded" }, // Maskable: full-bleed navy field, artwork inside the safe circle { file: "maskable-512.png", size: 512, mode: "maskable" }, // iOS home screen: square, the OS applies its own mask { file: "apple-touch-icon.png", size: 180, mode: "bleed" }, { file: "apple-touch-icon-167.png", size: 167, mode: "bleed" }, { file: "apple-touch-icon-152.png", size: 152, mode: "bleed" }, // Browser tab { file: "favicon-32.png", size: 32, mode: "rounded" }, ]; for (const target of targets) { const page = await browser.newPage({ viewport: { width: target.size, height: target.size }, deviceScaleFactor: 1 }); await page.setContent(pageHtml(target.size, target.mode), { waitUntil: "load" }); await page.screenshot({ path: `${OUT}${target.file}`, omitBackground: true }); await page.close(); console.log(` ✓ ${target.file}`); } /* Next.js file convention: /favicon.ico is the browser-tab icon (the other sizes live in public/icons and are declared in app/layout.tsx metadata). The ICO is a PNG payload in an ICO container, which every browser of consequence reads. */ const favicon = readFileSync(`${OUT}favicon-32.png`); const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserved header.writeUInt16LE(1, 2); // type: icon header.writeUInt16LE(1, 4); // image count const entry = Buffer.alloc(16); entry.writeUInt8(32, 0); // width entry.writeUInt8(32, 1); // height entry.writeUInt16LE(1, 4); // colour planes entry.writeUInt16LE(32, 6); // bits per pixel entry.writeUInt32LE(favicon.length, 8); entry.writeUInt32LE(22, 12); // payload offset writeFileSync(`${ROOT}app/favicon.ico`, Buffer.concat([header, entry, favicon])); console.log(" ✓ app/favicon.ico"); /* iOS splash screens: a light stage with the new mark and the wordmark. */ const badgeUri = `data:image/png;base64,${readFileSync(`${OUT}icon-192.png`).toString("base64")}`; const splash = `
Freezmo
`; const splashTargets = [ [1170, 2532], [1284, 2778], [1179, 2556], [1290, 2796], [750, 1334], [828, 1792], [1536, 2048], [1668, 2388], ]; for (const [width, height] of splashTargets) { const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); await page.setContent(splash, { waitUntil: "load" }); await page.screenshot({ path: `${OUT}splash-${width}x${height}.png` }); await page.close(); console.log(` ✓ splash-${width}x${height}.png`); } await browser.close(); if (!existsSync(`${OUT}icon-512.png`)) process.exit(1); console.log("icons written to public/icons/");