Files
backspace/scripts/gen-icons.mjs
T
Jannis Braun cfe4fd80c4 fix(icons): render small favicons from 3D raster to kill white tab-border
The flat app-icon.svg's gradient B mark has a bright (#fff) sheen that runs
to the badge perimeter with no dark separation. At favicon sizes (16/32px)
that edge anti-aliases into a white halo that reads as a border around the
icon — visible in Safari browser tabs, and the same defect in the small
Windows .ico / Linux launcher reps that also rendered from the flat SVG.

The committed 3D raster masters (used by every >=128px output already) frame
the mark in a dark surround and stay clean down to 16px. Set RASTER_THRESHOLD
0 so all app-icon sizes route through the raster path; the flat SVG is kept
as a gated source, re-enablable only with a corrected flat mark. Regenerated
favicons + small desktop reps; output remains byte-deterministic. Updated the
generator header/comments, README source matrix, and the dated icon spec.
2026-07-01 00:58:51 +02:00

373 lines
16 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/{app-icon.svg, app-icon-x{1,2,3}.png, mark.svg,
* mark-mono-dark.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.
*
* APP-ICON RENDERING: every app-icon output sources from the 3D raster
* PNGs (x1=149 / x2=294 / x3=440 / 1024), routed by closest-fit (smallest
* source ≥ target) to minimise resampling, then masked to a rounded-square
* silhouette (22 %·side ≈ Apple's macOS template radius). The flat
* app-icon.svg is retained as a source but no longer rendered: at favicon
* sizes its gradient mark halos into a white perimeter border (see
* RASTER_THRESHOLD). Tray icons and the PWA maskable inner remain SVG-only.
*
* 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, existsSync, statSync } 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'),
appIconPngX1: join(ROOT, 'assets/brand/app-icon-x1.png'),
appIconPngX2: join(ROOT, 'assets/brand/app-icon-x2.png'),
appIconPngX3: join(ROOT, 'assets/brand/app-icon-x3.png'),
appIconPng1024: join(ROOT, 'assets/brand/app-icon-1024.png'),
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;
// Every app-icon size renders from the 3D raster PNG sources; nothing
// renders from the flat app-icon.svg. Set to 128 originally on the theory
// that flat geometry reads crisper than the 3D render at favicon sizes —
// but the flat mark's gradient sheen runs bright (#fff) to the badge
// perimeter with no dark separation, so at 16/32 px it anti-aliases into a
// white halo that reads as a border around the icon (reported in Safari
// browser tabs; same defect in small Windows .ico / Linux launcher reps).
// The committed 3D render frames the mark in a dark surround and stays
// clean down to 16 px. Lanczos downscale from the 149 px @1x source is
// sharp's standard high-quality resampler; the slight softness vs. a flat
// vector render is the correct trade against the halo. Kept as a gate (not
// hard-removed) so the SVG path can be re-enabled if a corrected flat mark
// — one whose sheen doesn't reach the perimeter — is ever supplied.
const RASTER_THRESHOLD = 0;
// Rounded-square corner radius as a fraction of the side length. 0.22
// matches the existing app-icon.svg geometry (rx=32.42 on a 147.46 viewBox
// = 21.99 %) and sits within Apple's macOS app-icon template ratio
// (~22.37 % on the 824×824 grid) — both produce visually identical
// rounding at typical icon sizes.
const SQUIRCLE_RADIUS_RATIO = 0.22;
// Multi-resolution PNG sources for the 3D-rendered app icon. The x1/x2/x3
// variants are the user-supplied @1x/@2x/@3x exports of the same render;
// the 1024 variant is the full-resolution master. Each is independently
// sampled at its native DPI rather than downscaled from a single master.
// We pick the smallest source whose native dimension is ≥ the target
// output size: minimises resampling distance (closer source resolution
// → cleaner result), and the 1024 variant ensures every target ≤1024
// is a downscale (no upscale anywhere, including the 1024 Linux output
// and the .icns synthesis input).
const APP_ICON_PNG_SOURCES = [
{ key: 'appIconPngX1', size: 149 },
{ key: 'appIconPngX2', size: 294 },
{ key: 'appIconPngX3', size: 440 },
{ key: 'appIconPng1024', size: 1024 },
];
// ---- helpers ----
const loadSvg = (path) => readFileSync(path);
const loadPng = (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();
}
function pickAppIconPngSource(sources, target) {
// Smallest source ≥ target; if every source is smaller, use the largest.
for (const s of APP_ICON_PNG_SOURCES) {
if (s.size >= target) return sources[s.key];
}
return sources[APP_ICON_PNG_SOURCES[APP_ICON_PNG_SOURCES.length - 1].key];
}
async function renderAppIconPngFromRaster(sources, size) {
const raster = pickAppIconPngSource(sources, size);
// fit: 'cover' is safe — every PNG source is square, so cover/contain
// produce identical pixels but cover avoids gratuitous transparent
// padding logic if a future variant ships non-square.
// kernel: lanczos3 is sharp's standard high-quality resampler for both
// up- and down-scaling; chosen explicitly for byte-stable determinism
// across sharp versions that change the default kernel.
const resized = await sharp(raster)
.resize(size, size, { fit: 'cover', kernel: sharp.kernel.lanczos3 })
.png({ compressionLevel: 9, palette: false })
.toBuffer();
// Squircle mask: clip corners to transparent so launchers / docks /
// homescreens that render the icon as-is produce the rounded silhouette
// they expect, instead of a hard-edged square. Apple's macOS template
// ratio is ~22.37 %; we use 22 % to match the geometry of the existing
// app-icon.svg (rx=32.42/147.46 ≈ 21.99 %).
const r = Math.round(size * SQUIRCLE_RADIUS_RATIO);
const mask = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect x="0" y="0" width="${size}" height="${size}" rx="${r}" ry="${r}" fill="#fff"/></svg>`,
);
return sharp(resized)
.composite([{ input: mask, blend: 'dest-in' }])
.png({ compressionLevel: 9, palette: false })
.toBuffer();
}
async function renderAppIcon(sources, size) {
// Hybrid: raster ≥ threshold, vector below it. See RASTER_THRESHOLD.
if (size >= RASTER_THRESHOLD) {
return renderAppIconPngFromRaster(sources, size);
}
return renderPng(sources.appIcon, size);
}
async function writePng(path, svg, size) {
mkdirSync(dirname(path), { recursive: true });
const buf = await renderPng(svg, size);
writeFileSync(path, buf);
}
async function writeAppIconPng(path, sources, size) {
mkdirSync(dirname(path), { recursive: true });
const buf = await renderAppIcon(sources, 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 writeAppIconIco(path, sources, sizes) {
// Every pixel size routes through renderAppIcon (3D raster, squircle-
// masked) so the whole .ico — taskbar/Properties small reps through the
// Alt+Tab / explorer large reps — shares one faithful render. Windows
// auto-picks the closest size for the active DPI. (Small reps were SVG-
// sourced until the flat mark's perimeter halo forced the raster switch;
// see RASTER_THRESHOLD.)
mkdirSync(dirname(path), { recursive: true });
const buffers = await Promise.all(sizes.map((s) => renderAppIcon(sources, s)));
const ico = await pngToIco(buffers);
writeFileSync(path, ico);
}
async function writeAppIconIcns(path, sources) {
// png2icons.createICNS takes a single 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). We feed it the 1024 raster output (designed
// 3D render, squircle-masked) and accept that the synthesised 16/32 reps
// are downsampled from raster rather than re-rendered from SVG. macOS
// surfaces .icns reps mostly at ≥128 (Dock, Mission Control, Launchpad)
// — the only place the small-rep softness shows is Finder column view,
// a worthwhile trade for a single-file .icns build that matches every
// other app-icon consumer's design intent.
mkdirSync(dirname(path), { recursive: true });
const src = await renderAppIcon(sources, 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 writeCenteredMarkPng(path, markSvg, canvas, scale, bgHex) {
// Compose: square canvas + bare mark centred at scale × canvas.
// bgHex = hex string → opaque background (PWA maskable: survives
// Android launcher masks like Samsung One UI's
// full circle, Pixel's squircle).
// bgHex = null/undefined → transparent canvas (in-app slots whose
// container provides the visual frame, e.g.
// SpaceSidebar's 40×40 squircle tile).
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({ compressionLevel: 9, palette: false })
.toBuffer();
mkdirSync(dirname(path), { recursive: true });
const composed = await sharp({
create: {
width: canvas,
height: canvas,
channels: 4,
background: bgHex ?? { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: inner, gravity: 'center' }])
.png({ compressionLevel: 9, palette: false })
.toBuffer();
writeFileSync(path, composed);
}
// ---- main ----
async function main() {
// Spec: refuse to run if any source SVG is missing — fail loudly, not on
// a downstream sharp error with a cryptic ENOENT.
for (const [, path] of Object.entries(SRC)) {
if (!existsSync(path)) {
throw new Error(
`Missing source SVG: ${relative(ROOT, path)} — copy from Artworks-Backspace/SVG/`,
);
}
}
const appIcon = loadSvg(SRC.appIcon);
const mark = loadSvg(SRC.mark);
const markMonoDark = loadSvg(SRC.markMonoDark);
const appIconSources = {
appIcon,
appIconPngX1: loadPng(SRC.appIconPngX1),
appIconPngX2: loadPng(SRC.appIconPngX2),
appIconPngX3: loadPng(SRC.appIconPngX3),
appIconPng1024: loadPng(SRC.appIconPng1024),
};
const written = [];
const trace = (label, path, info) =>
written.push({
label,
info,
bytes: statSync(path).size,
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 writeAppIconPng(out, appIconSources, s);
trace('linux-png', out, `${s}x${s} (${s >= RASTER_THRESHOLD ? 'raster' : 'svg'})`);
}
await writeAppIconPng(join(DESKTOP_BUILD, 'icon.png'), appIconSources, 512);
trace('build-icon', join(DESKTOP_BUILD, 'icon.png'), '512x512 (raster)');
await writeAppIconIcns(join(DESKTOP_BUILD, 'icon.icns'), appIconSources);
trace('mac-icns', join(DESKTOP_BUILD, 'icon.icns'), '10-rep iconset (raster)');
await writeAppIconIco(
join(DESKTOP_BUILD, 'icon.ico'),
appIconSources,
[16, 24, 32, 48, 64, 128, 256],
);
trace('win-ico', join(DESKTOP_BUILD, 'icon.ico'), '7 sizes (raster)');
// --- 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 writeAppIconPng(join(WEB_ICONS, 'favicon-16.png'), appIconSources, 16);
trace('favicon-16', join(WEB_ICONS, 'favicon-16.png'), '16 (raster)');
await writeAppIconPng(join(WEB_ICONS, 'favicon-32.png'), appIconSources, 32);
trace('favicon-32', join(WEB_ICONS, 'favicon-32.png'), '32 (raster)');
await writeAppIconPng(join(WEB_ICONS, 'apple-touch-icon.png'), appIconSources, 180);
trace('apple-touch', join(WEB_ICONS, 'apple-touch-icon.png'), '180 (raster)');
await writeAppIconPng(join(WEB_ICONS, 'icon-192.png'), appIconSources, 192);
trace('pwa-192', join(WEB_ICONS, 'icon-192.png'), '192 (raster)');
await writeAppIconPng(join(WEB_ICONS, 'icon-512.png'), appIconSources, 512);
trace('pwa-512', join(WEB_ICONS, 'icon-512.png'), '512 (raster)');
await writeCenteredMarkPng(
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})`);
// Logo for the SpaceSidebar home tile: routes through the standard
// app-icon hybrid path (raster ≥128, squircle-masked) — same designed
// 3D render as desktop launcher / dock / homescreen, just sized for
// the sidebar slot. The squircle's 22 %-radius transparent corners are
// fully contained by the sidebar's own CSS mask (`rounded-[20px → 13px]`
// on a 40×40 tile = 50 % → 32.5 % radius — both more aggressive than
// 22 %), so no transparent gaps show against the `#1a1a23` surface.
// The new raster's vignetted dark-gradient corners visually replace the
// legacy `#1d1d1b → #000000` SVG fill swap that was needed to make the
// flat badge read as an intentional dark tile rather than a warm-grey
// rectangle — the 3D render carries its own dark surround.
await writeAppIconPng(join(WEB_ICONS, 'logo.png'), appIconSources, 256);
trace('in-app-logo', join(WEB_ICONS, 'logo.png'), '256 (raster, sidebar tile)');
// --- Summary ---
const fmtBytes = (n) => {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
console.log('\nGenerated icons:');
console.log(' ' + 'kind'.padEnd(14) + 'info'.padEnd(30) + 'size'.padStart(10) + ' path');
console.log(' ' + '----'.padEnd(14) + '----'.padEnd(30) + '----'.padStart(10) + ' ----');
for (const r of written) {
console.log(' ' + r.label.padEnd(14) + r.info.padEnd(30) + fmtBytes(r.bytes).padStart(10) + ' ' + r.path);
}
const totalBytes = written.reduce((sum, r) => sum + r.bytes, 0);
console.log(`\n${written.length} files written, ${fmtBytes(totalBytes)} total.`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});