/** * Regenerates `public/favicon.ico` from the committed brand master. * * LOCAL ONLY, like `bio:pdf`. Not wired into `npm run build` or either deploy * path — the icons are committed artefacts and this is what re-derives them. * * Writes the favicon ONLY. `apple-touch-icon.png` is deliberately not touched * and must stay opaque — `docs/reference/brand-assets.md` §The icon set. */ import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; const root = fileURLToPath(new URL('..', import.meta.url)); const MARK = `${root}src/assets/brand/sml-infinity-mark.png`; const MASTER = `${root}src/assets/brand/sml-infinity-mark-master.png`; const OUT = `${root}public/favicon.ico`; /** Sizes carried in the container, ascending — the order BaseLayout declares. */ const SIZES = [16, 32, 48]; /** * The mark spans 7/8 of the canvas and is centred on both axes. Not a taste * decision at this point: it is the composition already shipping, measured off * the previous icon at all three sizes (14/16, 28/32, 42/48) and off the touch * icon (158/180). Regenerating for alpha must not also restyle the mark. */ const INK_FRACTION = 7 / 8; /** `--cream` — the ground the previous icon was matted against. */ const CREAM = [250, 247, 242]; const die = (msg) => { rmSync(`${OUT}.candidate`, { force: true }); console.error(`icons: ${msg}`); process.exit(1); }; /** * R14 — the icon must be traceable to the artwork in this repository, not to a * file on someone's disk. The render source is a tight crop of the master, so * assert it still IS that crop before deriving anything from it. */ async function assertProvenance() { const mark = await sharp(MARK).metadata(); const crop = { left: 159, top: 646, width: 2668, height: 1704 }; if (mark.width !== crop.width || mark.height !== crop.height) { die( `render source is ${mark.width}x${mark.height}, expected ${crop.width}x${crop.height}`, ); } const [a, b] = await Promise.all([ sharp(MASTER).extract(crop).raw().toBuffer(), sharp(MARK).raw().toBuffer(), ]); if (!a.equals(b)) die('render source is no longer the documented crop of the master'); console.log( `provenance: ${crop.width}x${crop.height} at (${crop.left},${crop.top}) of the master — identical`, ); } /** * The whole point of the regeneration. A source without alpha would mean * deriving a mask from the cream ground, which is a different and lossier job — * so fail rather than silently ship a matted icon again. */ async function loadMark() { const meta = await sharp(MARK).metadata(); if (!meta.hasAlpha) die( `${MARK} has no alpha channel — cannot export a transparent icon from it`, ); const { data, info } = await sharp(MARK) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); let transparent = 0; for (let p = 3; p < data.length; p += 4) if (data[p] === 0) transparent++; if (transparent === 0) die(`${MARK} declares alpha but every pixel is opaque`); console.log( `source: ${info.width}x${info.height} alpha, ${transparent} fully transparent px`, ); } /** * Resize onto a TRANSPARENT canvas. sharp premultiplies around the resample, so * the ribbon's anti-aliased edge blends toward its own colour rather than * toward the RGB sitting under alpha 0 — that is the cream halo this change * exists to remove, and it would come straight back with a matted background. */ async function frame(size) { const w = Math.round(size * INK_FRACTION); const png = await sharp(MARK) .resize({ width: w, kernel: 'lanczos3', fit: 'inside', withoutEnlargement: false, }) .toBuffer(); const { height: h } = await sharp(png).metadata(); if (h > size) die(`size ${size}: mark is ${w}x${h}, taller than the canvas`); const left = Math.round((size - w) / 2); const top = Math.round((size - h) / 2); const out = await sharp({ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }) .composite([{ input: png, left, top }]) .png({ compressionLevel: 9, effort: 10, palette: false }) .toBuffer(); console.log( ` ${size}x${size}: mark ${w}x${h} at (${left},${top}), ${out.length} B`, ); return out; } /** ICO container: 6-byte header, one 16-byte directory entry per frame, then the PNG payloads. */ function buildIco(frames) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserved header.writeUInt16LE(1, 2); // type 1 = icon header.writeUInt16LE(frames.length, 4); const dir = Buffer.alloc(16 * frames.length); let offset = header.length + dir.length; frames.forEach(({ size, png }, i) => { const e = i * 16; dir[e] = size; // width — a byte; 0 would mean 256, which SIZES never is dir[e + 1] = size; // height dir[e + 2] = 0; // palette size — 0 for truecolour dir[e + 3] = 0; // reserved dir.writeUInt16LE(1, e + 4); // colour planes dir.writeUInt16LE(32, e + 6); // bits per pixel dir.writeUInt32LE(png.length, e + 8); dir.writeUInt32LE(offset, e + 12); offset += png.length; }); return Buffer.concat([header, dir, ...frames.map((f) => f.png)]); } /** * Re-read the container FROM DISK and decode each frame, rather than inspecting * the buffers we just built — a check that reads its own inputs proves nothing. * (It is still `sharp` decoding `sharp`'s output, so it is not a second * instrument. The independent reads are in `docs/reference/brand-assets.md`.) */ async function verify(path) { const buf = readFileSync(path); const count = buf.readUInt16LE(4); if (count !== SIZES.length) die(`container declares ${count} frames, expected ${SIZES.length}`); for (let i = 0; i < count; i++) { const e = 6 + i * 16; const size = buf[e]; const len = buf.readUInt32LE(e + 8); const off = buf.readUInt32LE(e + 12); if (off + len > buf.length) die(`frame ${i}: range ${off}+${len} exceeds ${buf.length} B`); const { data, info } = await sharp(buf.subarray(off, off + len)) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); if (info.width !== size || info.height !== size) die(`frame ${i}: decoded ${info.width}x${info.height}, dir says ${size}`); const corners = [ [0, 0], [size - 1, 0], [0, size - 1], [size - 1, size - 1], ]; for (const [x, y] of corners) { const a = data[(y * size + x) * 4 + 3]; if (a !== 0) die(`frame ${size}: corner (${x},${y}) has alpha ${a}, expected 0`); } /* * THE CORNER AND TRANSPARENCY CHECKS CANNOT SEE A CREAM HALO. A frame whose * edge was matted against cream and then had its background knocked out has * clear corners, transparent pixels and opaque pixels, and passes every one * of them. What distinguishes it is the colour the edge blends TOWARD. * * ⚠️ AND IT IS THE BOUNDARY, NOT THE PARTIAL-ALPHA PIXELS. A first version * of this guard inspected only pixels at 0 < alpha < 255 and MISSED a * purpose-built haloed fixture entirely, because a knockout sets alpha per * pixel and leaves NO partial alpha at all — 0 such pixels in the fixture. * A guard that cannot see the defect it is named for is worse than none. * * So: take every painted pixel that touches a fully transparent one, and * measure how many sit near cream. Measured on this artwork — correct * frames 1 / 2 / 5 of 61 / 146 / 258 boundary pixels (1.4-1.9%); the haloed * fixture 33 of 115 (28.7%). The gate is 10%, roughly 5x clear of both. */ const NEAR_CREAM = 20; const HALO_SHARE = 0.1; const alphaAt = (x, y) => x < 0 || y < 0 || x >= size || y >= size ? 0 : data[(y * size + x) * 4 + 3]; let clear = 0; let ink = 0; let boundary = 0; let boundaryNearCream = 0; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; const a = data[i + 3]; if (a === 0) { clear++; continue; } if (a === 255) ink++; const onEdge = alphaAt(x - 1, y) === 0 || alphaAt(x + 1, y) === 0 || alphaAt(x, y - 1) === 0 || alphaAt(x, y + 1) === 0; if (!onEdge) continue; boundary++; const d = Math.max( Math.abs(data[i] - CREAM[0]), Math.abs(data[i + 1] - CREAM[1]), Math.abs(data[i + 2] - CREAM[2]), ); if (d <= NEAR_CREAM) boundaryNearCream++; } } const share = boundary === 0 ? 0 : boundaryNearCream / boundary; if (clear === 0) die(`frame ${size}: no transparent pixels — the ground is fully matted`); if (ink === 0) die(`frame ${size}: no opaque pixels — the mark did not render`); if (boundary === 0) die(`frame ${size}: no boundary pixels — cannot test the edge colour`); if (share > HALO_SHARE) die( `frame ${size}: ${boundaryNearCream} of ${boundary} boundary pixels ` + `(${(share * 100).toFixed(1)}%) sit within ${NEAR_CREAM} of cream — ` + `the edge was matted against the ground before the ground was removed`, ); console.log( ` ${size}x${size}: ${clear} transparent, ${ink} opaque, corners clear, ` + `edge ${(share * 100).toFixed(1)}% near cream`, ); } console.log(`verified ${path} (${buf.length} B)`); } await assertProvenance(); await loadMark(); console.log('frames:'); const frames = []; for (const size of SIZES) frames.push({ size, png: await frame(size) }); /* * Verify a CANDIDATE file, then move it into place. Writing OUT first and * verifying after would mean a failing check exits 1 having already replaced a * good favicon with the one it just rejected — and nothing downstream re-checks, * because this script is deliberately outside the build and both deploy paths. */ const candidate = `${OUT}.candidate`; writeFileSync(candidate, buildIco(frames)); console.log('verify:'); try { await verify(candidate); } catch (err) { rmSync(candidate, { force: true }); throw err; } renameSync(candidate, OUT); console.log(`wrote ${OUT}`);