public/favicon.ico shipped with no transparency: all three frames declared a 32-bit alpha channel and then carried alpha=255 on every one of their 256/1024/ 2304 pixels, ground opaque cream rgba(250,247,242,255). Pouya's read-through finding, confirmed by parsing the ICO container directly. The render source carries true alpha (2,272,386 transparent px, 20,795 partial), so this is an export, not a mask derived from the cream ground — R13's harder branch did not fire and R13 is unchanged on its own terms. New scripts/icons.mjs + npm run icons re-derives the icon from the committed master: asserts the source is still the documented crop (R14), verifies a candidate file and renames on success so a rejected build cannot replace a good favicon, and runs a boundary-colour halo test. Composition is unchanged — ink bbox and pixel count identical at all three sizes. apple-touch-icon.png is byte-identical and stays opaque cream deliberately; the reason lives in docs/reference/brand-assets.md §The icon set, with the bar and a pointer in BaseLayout.astro, docs/06 and R13. docs/06: the read-through is ticked, and the cutover callout drops to ONE blocker — Q60's waiting period. Two adversarial review rounds, 15 findings, all resolved, none declined; stopped at two per D19. claims-auditor correctly deferred to cutover per D20. Gates on the committed bytes, exit status read: check 0, build 0 (23 pages), check:claims 0, check:intake 0, og:proof 0, lint 0, lighthouse 0 (worst of 23 99/100/100/100). Nothing deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
/**
|
|
* 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}`);
|