// App icons from the master mark (spec 12.2): `npm run icons`. // Reads public/brand/apicore-mark.svg and writes the PNG/SVG/ICO files listed // in ICON_FILES. Idempotent: the same input gives byte-identical files, and a // file whose bytes would not change is not rewritten. The output is committed, // so the Docker image never runs this script. import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const MASTER = "public/brand/apicore-mark.svg"; // Spec 12.1: dark ink oklch(0.24 0.035 55) behind the cell, light wax for shortcuts. const INK = "#2c1b0f"; const WAX = "#f9d280"; const WHITE = "#ffffff"; // Every variant is drawn on a 512 grid; the 64 grid of the mark is scaled into it. const GRID = 512; const MARK_GRID = 64; // Share of the edge the 64 box of the mark takes. "any": the cell is 70 % high. // Maskable/monochrome: the cell (stroke included) stays inside the safe circle // of 40 % radius and below 66 % of the edge (spec 12.2): 0.68 gives 32 % / 64 %. const MARK_BOX = { any: 0.8, maskable: 0.68, favicon: 1 }; // Spec 12.2: "Ecken 22 % gerundet". const ANY_CORNER_RADIUS = 0.22; // Shortcut icons: the 24 px area icon takes half the edge, inside any launcher mask. const SHORTCUT_ICON_SHARE = 0.5; const LUCIDE_GRID = 24; /** Shortcut icon per manifest shortcut: the lucide icon of its area (spec 11). */ export const SHORTCUT_ICONS = [ { file: "public/icons/shortcut-inspection-96.png", area: "inspections", lucide: "clipboard-check" }, { file: "public/icons/shortcut-varroa-96.png", area: "varroa", lucide: "bug" }, { file: "public/icons/shortcut-today-96.png", area: "dashboard", lucide: "layout-dashboard" }, ]; const SHORTCUT_SIZE = 96; export const ICON_FILES = [ "public/icon.svg", "public/icons/icon-192.png", "public/icons/icon-512.png", "public/icons/maskable-192.png", "public/icons/maskable-512.png", "public/icons/monochrome-512.png", ...SHORTCUT_ICONS.map((shortcut) => shortcut.file), "src/app/apple-icon.png", "src/app/favicon.ico", ]; const FAVICON_SIZES = [16, 32, 48]; const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true, palette: false }; /** * The `d` of the cell and the lid in the master SVG. * @param {string} svg * @returns {{ cell: string, lid: string }} */ export function readMasterPaths(svg) { return { cell: pathElement(svg, "cell").d, lid: pathElement(svg, "lid").d }; } /** * @param {string} svg * @param {string} id * @returns {{ element: string, d: string }} */ function pathElement(svg, id) { const element = svg.match(new RegExp(`]*\\bid="${id}"[^>]*/>`))?.[0]; const d = element?.match(/\bd="([^"]+)"/)?.[1]; if (!element || !d) { throw new Error(`${MASTER}: no `); } return { element, d }; } const round = (value) => Number(value.toFixed(4)); /** The mark scaled into the 512 grid, centred, taking `box` of the edge. */ function markGroup(content, box) { const scale = round((box * GRID) / MARK_GRID); const offset = round((GRID - box * GRID) / 2); return `${content}`; } function svgDocument(body, size) { const dimensions = size ? ` width="${size}" height="${size}"` : ""; return `${body}\n`; } /** The SVG bodies of every mark variant, from the master. */ function variants(master) { const cell = pathElement(master, "cell"); const lid = pathElement(master, "lid"); const withoutId = (element) => element.replace(/\sid="[^"]*"/, ""); const colouredMark = `${withoutId(cell.element)}${withoutId(lid.element)}`; const silhouette = withoutId(cell.element) .replace(/\bfill="[^"]*"/, `fill="${WHITE}"`) .replace(/\bstroke="[^"]*"/, `stroke="${WHITE}"`); const radius = round(ANY_CORNER_RADIUS * GRID); return { any: `${markGroup(colouredMark, MARK_BOX.any)}`, apple: `${markGroup(colouredMark, MARK_BOX.any)}`, maskable: `${markGroup(colouredMark, MARK_BOX.maskable)}`, monochrome: markGroup(silhouette, MARK_BOX.maskable), favicon: markGroup(colouredMark, MARK_BOX.favicon), }; } /** Lucide icon node (`[tag, attrs][]`) of an icon name, from the installed lucide-react. */ async function lucideNode(name) { const icon = await import(`lucide-react/dist/esm/icons/${name}.mjs`); return icon.__iconNode; } function lucideMarkup(node) { return node .map(([tag, attrs]) => { const attributes = Object.entries(attrs) .filter(([key]) => key !== "key") .map(([key, value]) => `${key}="${value}"`) .join(" "); return `<${tag} ${attributes}/>`; }) .join(""); } /** Area icon in ink on wax, full bleed (spec 12.2). */ function shortcutSvg(markup) { const scale = round((SHORTCUT_ICON_SHARE * GRID) / LUCIDE_GRID); const offset = round((GRID - SHORTCUT_ICON_SHARE * GRID) / 2); return ( `` + `${markup}` ); } function renderPng(body, size) { return sharp(Buffer.from(svgDocument(body, size))).png(PNG_OPTIONS).toBuffer(); } /** * ICO container with PNG entries (sharp writes no ICO): ICONDIR, one * ICONDIRENTRY per image, then the PNG data. * @param {{ size: number, png: Buffer }[]} images * @returns {Buffer} */ export function buildIco(images) { const HEADER = 6; const ENTRY = 16; const header = Buffer.alloc(HEADER); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(images.length, 4); let offset = HEADER + ENTRY * images.length; const entries = images.map(({ size, png }) => { const entry = Buffer.alloc(ENTRY); // One byte each: 256 is written as 0. entry.writeUInt8(size >= 256 ? 0 : size, 0); entry.writeUInt8(size >= 256 ? 0 : size, 1); entry.writeUInt8(0, 2); entry.writeUInt8(0, 3); entry.writeUInt16LE(1, 4); entry.writeUInt16LE(32, 6); entry.writeUInt32LE(png.length, 8); entry.writeUInt32LE(offset, 12); offset += png.length; return entry; }); return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]); } function writeIfChanged(file, content) { mkdirSync(path.dirname(file), { recursive: true }); let current = null; try { current = readFileSync(file); } catch { // Not there yet. } if (!current || !current.equals(content)) { writeFileSync(file, content); } } /** * Renders every icon into `outRoot` (same relative paths as in the app). * @param {{ root?: string, outRoot?: string }} [options] * @returns {Promise} the files, relative to `outRoot` */ export async function generateIcons({ root = ROOT, outRoot = root } = {}) { const master = readFileSync(path.join(root, MASTER), "utf8"); const bodies = variants(master); const outputs = new Map(); outputs.set("public/icon.svg", Buffer.from(svgDocument(bodies.any))); for (const size of [192, 512]) { outputs.set(`public/icons/icon-${size}.png`, await renderPng(bodies.any, size)); outputs.set(`public/icons/maskable-${size}.png`, await renderPng(bodies.maskable, size)); } outputs.set("public/icons/monochrome-512.png", await renderPng(bodies.monochrome, 512)); for (const shortcut of SHORTCUT_ICONS) { const markup = lucideMarkup(await lucideNode(shortcut.lucide)); outputs.set(shortcut.file, await renderPng(shortcutSvg(markup), SHORTCUT_SIZE)); } outputs.set("src/app/apple-icon.png", await renderPng(bodies.apple, 180)); const favicons = []; for (const size of FAVICON_SIZES) { favicons.push({ size, png: await renderPng(bodies.favicon, size) }); } outputs.set("src/app/favicon.ico", buildIco(favicons)); const files = ICON_FILES.filter((file) => outputs.has(file)); for (const file of files) { writeIfChanged(path.join(outRoot, ...file.split("/")), outputs.get(file)); } return files; } const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isCli) { generateIcons() .then((files) => { process.stdout.write(`${files.join("\n")}\n`); }) .catch((error) => { process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); process.exitCode = 1; }); }