fix: regenerate favicon.ico with a transparent ground; tick Pouya's read-through
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
4735989f0b
commit
67847d94fa
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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}`);
|
||||
Reference in New Issue
Block a user