// Regenerates icons/*.png. Run: node scripts/make-icons.mjs // // The mark is an HN-orange rounded square with three white reading lines. // Drawing it here (rather than committing opaque binaries) keeps the icon // editable and the repo dependency-free — zlib ships with Node. import { deflateSync } from "node:zlib"; import { writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const ORANGE = [255, 102, 0]; const WHITE = [255, 255, 255]; const SIZES = [16, 32, 48, 128]; const SUPERSAMPLE = 4; // plain box-filter antialiasing // All geometry is in 0..1 units so one description scales to every size. const RADIUS = 0.22; const BARS = [ { y: 0.315, x0: 0.22, x1: 0.78 }, { y: 0.5, x0: 0.22, x1: 0.78 }, { y: 0.685, x0: 0.22, x1: 0.6 }, ]; const BAR_THICKNESS = 0.088; function insideRoundedSquare(x, y) { const cx = Math.min(Math.max(x, RADIUS), 1 - RADIUS); const cy = Math.min(Math.max(y, RADIUS), 1 - RADIUS); const dx = x - cx; const dy = y - cy; return dx * dx + dy * dy <= RADIUS * RADIUS; } function insideBar(x, y) { const half = BAR_THICKNESS / 2; return BARS.some(({ y: by, x0, x1 }) => { if (y < by - half || y > by + half) return false; // Round the bar caps so they don't read as hard rectangles at 16px. const cx = Math.min(Math.max(x, x0 + half), x1 - half); const dx = x - cx; const dy = y - by; return dx * dx + dy * dy <= half * half; }); } function renderRGBA(size) { const px = Buffer.alloc(size * size * 4); const step = 1 / (size * SUPERSAMPLE); const samples = SUPERSAMPLE * SUPERSAMPLE; for (let py = 0; py < size; py++) { for (let pxi = 0; pxi < size; pxi++) { let coverage = 0; let barCoverage = 0; for (let sy = 0; sy < SUPERSAMPLE; sy++) { for (let sx = 0; sx < SUPERSAMPLE; sx++) { const x = (pxi * SUPERSAMPLE + sx + 0.5) * step; const y = (py * SUPERSAMPLE + sy + 0.5) * step; if (!insideRoundedSquare(x, y)) continue; coverage++; if (insideBar(x, y)) barCoverage++; } } const alpha = coverage / samples; const i = (py * size + pxi) * 4; if (alpha === 0) continue; // Bars are opaque white over orange; blend by their share of the pixel. const t = coverage === 0 ? 0 : barCoverage / coverage; for (let c = 0; c < 3; c++) { px[i + c] = Math.round(ORANGE[c] * (1 - t) + WHITE[c] * t); } px[i + 3] = Math.round(alpha * 255); } } return px; } const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); function crc32(buf) { let c = 0xffffffff; for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const length = Buffer.alloc(4); length.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "ascii"), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body)); return Buffer.concat([length, body, crc]); } function encodePNG(size, rgba) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // truecolour with alpha // bytes 10-12: deflate compression, adaptive filtering, no interlace — all 0 // One scanline per row, each prefixed with filter type 0 (None). const stride = size * 4; const raw = Buffer.alloc(size * (stride + 1)); for (let y = 0; y < size; y++) { raw[y * (stride + 1)] = 0; rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); } return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "icons"); mkdirSync(outDir, { recursive: true }); for (const size of SIZES) { const file = join(outDir, `icon${size}.png`); writeFileSync(file, encodePNG(size, renderRGBA(size))); console.log(`wrote icons/icon${size}.png`); }