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]>
This commit is contained in:
1 parent
fee84c7a6b
commit
2395576c88
23 files changed
+632
-67
No files matched your search
@@ -0,0 +1,232 @@
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user