Files
backspace/scripts/gen-icons.mjs
T
Jannis Braun bdfd38bc90 feat(brand): add SVG-driven icon generator (scripts/gen-icons.mjs)
Single Node ESM script. Reads assets/brand/*.svg, writes the full
desktop + web icon set deterministically. Idempotent; safe to re-run.
Companion README documents the version-bump determinism caveat.
2026-04-27 14:20:34 +02:00

205 lines
7.4 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Backspace icon generator
*
* Reads from assets/brand/*.svg and writes the entire desktop + web icon set:
* - macOS .icns (10-rep iconset)
* - Windows .ico (multi-size)
* - Linux per-size PNGs (electron-builder dir mode)
* - macOS menu-bar template + @2x
* - Windows tray .ico (multi-size, DPI-auto)
* - Linux tray PNG (22x22)
* - Web favicons, PWA, in-app brand logo, PWA maskable
*
* Run via `pnpm gen-icons` after artwork changes; commit the diff.
*
* DETERMINISM: byte-stable for a given lockfile only. After bumping
* sharp / png-to-ico / png2icons, expect a follow-up regen+commit in
* the dep-bump PR — that diff isn't an artwork change, just upstream
* encoder differences. See spec
* docs/superpowers/specs/2026-04-27-icon-system-design.md.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join, relative } from 'node:path';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import sharp from 'sharp';
import pngToIco from 'png-to-ico';
import png2icons from 'png2icons';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SRC = {
appIcon: join(ROOT, 'assets/brand/app-icon.svg'),
mark: join(ROOT, 'assets/brand/mark.svg'),
markMonoDark: join(ROOT, 'assets/brand/mark-mono-dark.svg'),
};
const DESKTOP_BUILD = join(ROOT, 'packages/desktop/build');
const DESKTOP_RES = join(ROOT, 'packages/desktop/resources');
const WEB_ICONS = join(ROOT, 'packages/web/public/icons');
// Hex extracted from app-icon.svg's cls-1 fill (the badge background).
// Used as the maskable PWA background so regular and maskable variants
// read as the same brand on Android home screens.
const MASKABLE_BG = '#1d1d1b';
// SVG render density. High enough to produce a clean intermediate for
// the largest target (1024) from the smallest viewBox source (~100px).
// Sharp/libvips downscales with Lanczos, so over-rendering then resizing
// is fine and keeps output stable across all target sizes.
const SVG_DENSITY = 1200;
// ---- helpers ----
const loadSvg = (path) => readFileSync(path);
async function renderPng(svg, size) {
// Render SVG → square PNG at exact target size. fit: 'contain' preserves
// aspect ratio: wide-bbox SVGs (mark, mark-mono-dark) get transparent
// top/bottom padding instead of being stretched square.
return sharp(svg, { density: SVG_DENSITY })
.resize(size, size, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png({ compressionLevel: 9, palette: false })
.toBuffer();
}
async function writePng(path, svg, size) {
mkdirSync(dirname(path), { recursive: true });
const buf = await renderPng(svg, size);
writeFileSync(path, buf);
}
async function writeIco(path, svg, sizes) {
mkdirSync(dirname(path), { recursive: true });
const buffers = await Promise.all(sizes.map((s) => renderPng(svg, s)));
const ico = await pngToIco(buffers);
writeFileSync(path, ico);
}
async function writeIcns(path, svg) {
// png2icons.createICNS takes one high-res PNG and synthesises the full
// 10-rep iconset internally (16/16@2x, 32/32@2x, 128/128@2x, 256/256@2x,
// 512/512@2x). Render at 1024 for full @2x coverage.
mkdirSync(dirname(path), { recursive: true });
const src = await renderPng(svg, 1024);
const icns = png2icons.createICNS(src, png2icons.BICUBIC, 0);
if (!icns) throw new Error(`png2icons.createICNS returned null for ${path}`);
writeFileSync(path, icns);
}
async function writeMaskablePng(path, markSvg, canvas, scale, bgHex) {
// Compose: solid square background + bare mark centred at scale × canvas.
// 60% scale guarantees content survives Android's most aggressive launcher
// masks (Samsung One UI's full circle, Pixel's squircle).
const innerSize = Math.round(canvas * scale);
const inner = await sharp(markSvg, { density: SVG_DENSITY })
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
mkdirSync(dirname(path), { recursive: true });
const composed = await sharp({
create: {
width: canvas,
height: canvas,
channels: 4,
background: bgHex,
},
})
.composite([{ input: inner, gravity: 'center' }])
.png({ compressionLevel: 9, palette: false })
.toBuffer();
writeFileSync(path, composed);
}
// ---- main ----
async function main() {
const appIcon = loadSvg(SRC.appIcon);
const mark = loadSvg(SRC.mark);
const markMonoDark = loadSvg(SRC.markMonoDark);
const written = [];
const trace = (label, path, info) =>
written.push({ label, info, path: relative(ROOT, path) });
// --- Desktop: application icon ---
const linuxSizes = [16, 32, 48, 64, 128, 256, 512, 1024];
for (const s of linuxSizes) {
const out = join(DESKTOP_BUILD, `icons/${s}x${s}.png`);
await writePng(out, appIcon, s);
trace('linux-png', out, `${s}x${s}`);
}
await writePng(join(DESKTOP_BUILD, 'icon.png'), appIcon, 512);
trace('build-icon', join(DESKTOP_BUILD, 'icon.png'), '512x512');
await writeIcns(join(DESKTOP_BUILD, 'icon.icns'), appIcon);
trace('mac-icns', join(DESKTOP_BUILD, 'icon.icns'), '10-rep iconset');
await writeIco(join(DESKTOP_BUILD, 'icon.ico'), appIcon, [16, 24, 32, 48, 64, 128, 256]);
trace('win-ico', join(DESKTOP_BUILD, 'icon.ico'), '7 sizes');
// --- Desktop: tray ---
await writePng(join(DESKTOP_RES, 'tray-iconTemplate.png'), markMonoDark, 22);
trace('tray-mac-1x', join(DESKTOP_RES, 'tray-iconTemplate.png'), '22x22');
await writePng(join(DESKTOP_RES, 'tray-iconTemplate@2x.png'), markMonoDark, 44);
trace('tray-mac-2x', join(DESKTOP_RES, 'tray-iconTemplate@2x.png'), '44x44');
await writeIco(join(DESKTOP_RES, 'tray-icon.ico'), mark, [16, 20, 24, 32, 40, 48]);
trace('tray-win-ico', join(DESKTOP_RES, 'tray-icon.ico'), '6 sizes');
await writePng(join(DESKTOP_RES, 'tray-icon.png'), mark, 22);
trace('tray-linux', join(DESKTOP_RES, 'tray-icon.png'), '22x22');
// --- Web: favicons + PWA + in-app ---
await writePng(join(WEB_ICONS, 'favicon-16.png'), appIcon, 16);
trace('favicon-16', join(WEB_ICONS, 'favicon-16.png'), '16');
await writePng(join(WEB_ICONS, 'favicon-32.png'), appIcon, 32);
trace('favicon-32', join(WEB_ICONS, 'favicon-32.png'), '32');
await writePng(join(WEB_ICONS, 'apple-touch-icon.png'), appIcon, 180);
trace('apple-touch', join(WEB_ICONS, 'apple-touch-icon.png'), '180');
await writePng(join(WEB_ICONS, 'icon-192.png'), appIcon, 192);
trace('pwa-192', join(WEB_ICONS, 'icon-192.png'), '192');
await writePng(join(WEB_ICONS, 'icon-512.png'), appIcon, 512);
trace('pwa-512', join(WEB_ICONS, 'icon-512.png'), '512');
await writeMaskablePng(
join(WEB_ICONS, 'icon-maskable-512.png'),
mark,
512,
0.6,
MASKABLE_BG,
);
trace('pwa-maskable', join(WEB_ICONS, 'icon-maskable-512.png'), `512 (60% mark on ${MASKABLE_BG})`);
await writePng(join(WEB_ICONS, 'logo.png'), mark, 256);
trace('in-app-logo', join(WEB_ICONS, 'logo.png'), '256 (transparent)');
// --- Summary ---
console.log('\nGenerated icons:');
console.log(' ' + 'kind'.padEnd(14) + 'info'.padEnd(30) + 'path');
console.log(' ' + '----'.padEnd(14) + '----'.padEnd(30) + '----');
for (const r of written) {
console.log(' ' + r.label.padEnd(14) + r.info.padEnd(30) + r.path);
}
console.log(`\n${written.length} files written.`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});