- 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]>
233 lines
8.6 KiB
JavaScript
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;
|
|
});
|
|
}
|