From bdfd38bc905635f957932d36af08ba344df9a32a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:20:34 +0200 Subject: [PATCH] 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. --- scripts/gen-icons.README.md | 44 ++++++++ scripts/gen-icons.mjs | 204 ++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 scripts/gen-icons.README.md create mode 100755 scripts/gen-icons.mjs diff --git a/scripts/gen-icons.README.md b/scripts/gen-icons.README.md new file mode 100644 index 00000000..d0871b2f --- /dev/null +++ b/scripts/gen-icons.README.md @@ -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. diff --git a/scripts/gen-icons.mjs b/scripts/gen-icons.mjs new file mode 100755 index 00000000..f0e9f476 --- /dev/null +++ b/scripts/gen-icons.mjs @@ -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); +});