#!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json /** * Generate tessera site icons (favicon + PWA) from a single source glyph. * Outputs to public/icons. Run after brand mark or palette changes: * * npx tsx scripts/build-icons.ts */ import { mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; const ROOT = resolve(fileURLToPath(import.meta.url), "../.."); const OUT = resolve(ROOT, "public/icons"); const BG = "#221f21"; const FG = "#c89738"; type Variant = { contentScale: number; strokeScale: number; strokeLinecap: "round" | "butt" | "square"; }; const FULL: Variant = { contentScale: 0.82, strokeScale: 1, strokeLinecap: "round" }; const TINY: Variant = { contentScale: 0.92, strokeScale: 1.45, strokeLinecap: "butt" }; // Lucide ShieldCheck lives in a 24x24 viewBox; visible content is ~16x20 centered on (12,12). const GLYPH_W = 16; const GLYPH_H = 20; function buildSvg(size: number, v: Variant, transparent = false): string { const scale = (size * v.contentScale) / Math.max(GLYPH_W, GLYPH_H); const tx = size / 2 - 12 * scale; const ty = size / 2 - 12 * scale; const stroke = 2 * v.strokeScale; const bgRect = transparent ? "" : ` `; return ` ${bgRect} `; } function resizeSvgString(svg: string, newSize: number): string { return svg.replace(/width="\d+"\s+height="\d+"/, `width="${newSize}" height="${newSize}"`); } async function renderPng(svg: string, size: number, opts: { alpha?: boolean } = {}): Promise { const supersample = size < 64 ? 4 : 2; const source = resizeSvgString(svg, size * supersample); let pipeline = sharp(Buffer.from(source)).resize(size, size, { kernel: sharp.kernel.lanczos3, }); if (opts.alpha === false) { pipeline = pipeline.flatten({ background: BG }); } return pipeline.png({ compressionLevel: 9 }).toBuffer(); } function buildIco(pngs: Buffer[]): Buffer { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(pngs.length, 4); const entries = Buffer.alloc(16 * pngs.length); const sizes = [16, 32, 48]; let offset = header.length + entries.length; pngs.forEach((png, i) => { const size = sizes[i]; const base = i * 16; entries.writeUInt8(size === 256 ? 0 : size, base + 0); entries.writeUInt8(size === 256 ? 0 : size, base + 1); entries.writeUInt8(0, base + 2); entries.writeUInt8(0, base + 3); entries.writeUInt16LE(1, base + 4); entries.writeUInt16LE(32, base + 6); entries.writeUInt32LE(png.length, base + 8); entries.writeUInt32LE(offset, base + 12); offset += png.length; }); return Buffer.concat([header, entries, ...pngs]); } async function main() { mkdirSync(OUT, { recursive: true }); const canonicalSvg = buildSvg(512, FULL, true); writeFileSync(resolve(OUT, "icon.svg"), canonicalSvg); const targets: Array<{ name: string; size: number; variant: Variant; alpha: boolean }> = [ { name: "favicon-16x16.png", size: 16, variant: TINY, alpha: true }, { name: "favicon-32x32.png", size: 32, variant: FULL, alpha: true }, { name: "apple-touch-icon.png", size: 180, variant: FULL, alpha: false }, { name: "oauth-logo-120.png", size: 120, variant: FULL, alpha: false }, ]; for (const t of targets) { const svg = buildSvg(t.size, t.variant, t.alpha); const png = await renderPng(svg, t.size, { alpha: t.alpha }); writeFileSync(resolve(OUT, t.name), png); console.log(`wrote ${t.name} (${png.length} bytes)`); } const icoSizes: Array<{ size: number; variant: Variant }> = [ { size: 16, variant: TINY }, { size: 32, variant: FULL }, { size: 48, variant: FULL }, ]; const icoPngs = await Promise.all( icoSizes.map(({ size, variant }) => renderPng(buildSvg(size, variant, true), size, { alpha: true })), ); const ico = buildIco(icoPngs); writeFileSync(resolve(OUT, "favicon.ico"), ico); console.log(`wrote favicon.ico (${ico.length} bytes, ${icoPngs.length} sizes)`); } main().catch((err) => { console.error(err); process.exit(1); });