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.
This commit is contained in:
Jannis Braun
2026-04-27 14:20:34 +02:00
parent 17dd12fe48
commit bdfd38bc90
2 changed files with 248 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# Icon Generator
Regenerates every brand artefact from `assets/brand/*.svg`:
- macOS `.icns`, Windows `.ico`, Linux per-size PNGs (`packages/desktop/build/`)
- Tray icons for all three platforms (`packages/desktop/resources/`)
- Web favicons, PWA manifest icons, maskable, in-app `logo.png` (`packages/web/public/icons/`)
## When to run
After changing any file under `assets/brand/`, **or** after bumping `sharp`,
`png-to-ico`, or `png2icons` in the root `package.json`. Commit the diff in
the same PR.
```bash
pnpm gen-icons
git status # review which files changed
git add -A
git commit -m "chore: regenerate brand icons"
```
## Determinism
Output is byte-stable for a given lockfile. The same SVGs in produce the
same PNG/ICO/ICNS bytes out, every time, on every OS — Sharp uses resvg
internally and writes deterministic PNGs, png-to-ico and png2icons don't
embed timestamps.
**Caveat:** byte-stability is *not* guaranteed across version bumps of
the three encoder deps. After a Renovate / dependabot PR upgrades any of
them, run `pnpm gen-icons` once and commit the resulting diff inside the
same PR. That follow-up commit isn't an artwork change — it's encoder
output drift, and gating it inside the dep PR keeps the artwork-change
git history clean.
## Source SVGs
| Brand source | Drives |
|---------------------------------|-----------------------------------------------|
| `assets/brand/app-icon.svg` | Every full app icon, all favicons, PWA |
| `assets/brand/mark.svg` | Win/Linux tray, in-app logo, PWA maskable |
| `assets/brand/mark-mono-dark.svg` | macOS menu-bar template (alpha + black) |
`Artworks-Backspace/` is the design archive — never read by this script.
+204
View File
@@ -0,0 +1,204 @@
#!/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);
});