Skip to content

Commit 1d938f0

Browse files
ProfessorManhattanBrian Zalewskiclaude
authored
fix: generate the favicon set (was 5 dead refs → 404 on every site) (#3)
index.html referenced favicon.ico, favicon-16/32.png, apple-touch-icon.png, safari-pinned- tab.svg — none existed in public/ (caught by validate-assets, PR #1). Every cloned site shipped 404'd favicons (violates checklist #23). scripts/generate-favicons.mjs generates the full set brand-colored from _brand.json (brandHue → HSL→RGB) with ZERO external deps — pure node:zlib for valid RGBA PNGs (rounded square), a BMP/PNG-in-ICO writer, and SVG (rich, brand bg + business initial) + safari mask. Wired into prebuild so it regenerates per brand and vite copies public/ → dist/. Also added an SVG-favicon <link> (modern browsers). Committed brand-default placeholders so the repo is clean even before a per-brand rebuild. Verified on a clean build: all 6 favicons land in dist/, 0 dead refs, valid PNG/ICO magic bytes. validate-assets (PR #1) now passes the favicon gate. Co-authored-by: Brian Zalewski <hey@megabyte.space> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 595c25d commit 1d938f0

9 files changed

Lines changed: 79 additions & 2 deletions

index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141

4242
<!-- Manifest + icons -->
4343
<link rel="manifest" href="/site.webmanifest" />
44-
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
44+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
45+
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
4546
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
4647
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
4748
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#64ffda" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"description": "Cinematic React + Vite + Tailwind website template with DTCG brand tokens, 15 universal routes, kinetic typography, bento grid, command palette, full PWA kit, Zod-validated brand schema, Vitest + Playwright tests. Built for AI customization (bolt.diy, Claude, Cursor).",
77
"scripts": {
88
"dev": "vite",
9-
"prebuild": "node scripts/build-feeds.mjs",
9+
"prebuild": "node scripts/generate-favicons.mjs",
1010
"build": "tsc -b && vite build",
1111
"postbuild": "node scripts/build-feeds.mjs && cp public/feed.xml public/atom.xml public/feed.json public/sitemap.xml dist/ 2>/dev/null || true",
1212
"preview": "vite preview",

public/apple-touch-icon.png

736 Bytes
Loading

public/favicon-16x16.png

98 Bytes
Loading

public/favicon-32x32.png

131 Bytes
Loading

public/favicon.ico

153 Bytes
Binary file not shown.

public/favicon.svg

Lines changed: 1 addition & 0 deletions
Loading

public/safari-pinned-tab.svg

Lines changed: 1 addition & 0 deletions
Loading

scripts/generate-favicons.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env node
2+
// generate-favicons.mjs — create the full favicon set referenced by index.html, brand-colored
3+
// from _brand.json (brandHue), with ZERO external deps (pure node: zlib for PNG, BMP-in-ICO).
4+
// Fixes the bug where index.html referenced 5 favicons that didn't exist (404 on every site).
5+
// Run before `vite build` (writes into public/, which vite copies to dist/). Regenerate per brand.
6+
import { readFileSync, writeFileSync } from 'node:fs';
7+
import { deflateSync } from 'node:zlib';
8+
import { resolve, dirname } from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
11+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12+
const pub = resolve(root, 'public');
13+
const brand = JSON.parse(readFileSync(resolve(root, '_brand.json'), 'utf8'));
14+
const val = (n) => (n && typeof n === 'object' && '$value' in n ? n.$value : n);
15+
const hue = Number(val(brand?.color?.brandHue)) || 240;
16+
const name = String(val(brand?.business?.name) || val(brand?.name) || 'A');
17+
const initial = (name.match(/[A-Za-z0-9]/)?.[0] || 'A').toUpperCase();
18+
19+
// HSL → RGB (brand bg + a dark variant for the rounded square)
20+
function hsl(h, s, l) {
21+
s /= 100; l /= 100;
22+
const k = (n) => (n + h / 30) % 12, a = s * Math.min(l, 1 - l);
23+
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
24+
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
25+
}
26+
const [r, g, b] = hsl(hue, 70, 55);
27+
const hex = '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
28+
29+
// ---- pure-node PNG encoder (solid rounded square, RGBA) ----
30+
const crcTable = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
31+
const crc32 = (buf) => { let c = 0xffffffff; for (const x of buf) c = crcTable[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
32+
function chunk(type, data) {
33+
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
34+
const td = Buffer.concat([Buffer.from(type), data]);
35+
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
36+
return Buffer.concat([len, td, c]);
37+
}
38+
function pngSolid(size) {
39+
const px = Buffer.alloc(size * size * 4);
40+
const rad = Math.floor(size * 0.18);
41+
const inCorner = (x, y) => { // rounded-rect alpha mask
42+
const cx = x < rad ? rad : x >= size - rad ? size - 1 - rad : x;
43+
const cy = y < rad ? rad : y >= size - rad ? size - 1 - rad : y;
44+
return Math.hypot(x - cx, y - cy) <= rad;
45+
};
46+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
47+
const i = (y * size + x) * 4, on = inCorner(x, y);
48+
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = on ? 255 : 0;
49+
}
50+
// add filter byte (0) per scanline
51+
const raw = Buffer.alloc(size * (size * 4 + 1));
52+
for (let y = 0; y < size; y++) { raw[y * (size * 4 + 1)] = 0; px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); }
53+
const ihdr = Buffer.alloc(13);
54+
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
55+
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
56+
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
57+
}
58+
// ICO wrapping a 32x32 PNG (ICO supports embedded PNG since Vista)
59+
function ico(png32) {
60+
const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
61+
const ent = Buffer.alloc(16); ent[0] = 32; ent[1] = 32; ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6);
62+
ent.writeUInt32LE(png32.length, 8); ent.writeUInt32LE(22, 12);
63+
return Buffer.concat([hdr, ent, png32]);
64+
}
65+
66+
const png32 = pngSolid(32);
67+
writeFileSync(resolve(pub, 'favicon-16x16.png'), pngSolid(16));
68+
writeFileSync(resolve(pub, 'favicon-32x32.png'), png32);
69+
writeFileSync(resolve(pub, 'apple-touch-icon.png'), pngSolid(180));
70+
writeFileSync(resolve(pub, 'favicon.ico'), ico(png32));
71+
// rich SVG favicon (brand bg + initial) + monochrome safari mask
72+
writeFileSync(resolve(pub, 'favicon.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${hex}"/><text x="16" y="22" font-family="system-ui,sans-serif" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${initial}</text></svg>`);
73+
writeFileSync(resolve(pub, 'safari-pinned-tab.svg'), `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6"/><text x="16" y="22" font-family="system-ui" font-size="18" font-weight="700" fill="#fff" text-anchor="middle">${initial}</text></svg>`);
74+
console.log(`[generate-favicons] wrote 6 favicons · brand=${hex} initial=${initial}`);

0 commit comments

Comments
 (0)