Files
ApiCore/ApiCore-Frontend/scripts/generate-icons.mjs
T
Nick.RosenloecherandClaude Opus 5.5 2395576c88 feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)
- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt
  any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und
  favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf.
- manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background.
- Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein
  fehlendes Icon hält das Vorladen der Seiten nicht auf.
- Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne
  Sicherheits-Header, Punkte maskiert.
- BrandMark-Deckel mittig (linke Kante 18.8).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:51:41 +02:00

233 lines
8.6 KiB
JavaScript

// 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(`<path\\b[^>]*\\bid="${id}"[^>]*/>`))?.[0];
const d = element?.match(/\bd="([^"]+)"/)?.[1];
if (!element || !d) {
throw new Error(`${MASTER}: no <path id="${id}" d="…"/>`);
}
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 `<g transform="translate(${offset} ${offset}) scale(${scale})">${content}</g>`;
}
function svgDocument(body, size) {
const dimensions = size ? ` width="${size}" height="${size}"` : "";
return `<svg xmlns="http://www.w3.org/2000/svg"${dimensions} viewBox="0 0 ${GRID} ${GRID}">${body}</svg>\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: `<rect width="${GRID}" height="${GRID}" rx="${radius}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
apple: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
maskable: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${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 (
`<rect width="${GRID}" height="${GRID}" fill="${WAX}"/>` +
`<g transform="translate(${offset} ${offset}) scale(${scale})" fill="none" stroke="${INK}" ` +
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</g>`
);
}
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<string[]>} 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;
});
}