chore(brand): refresh app icons + add brand source artwork

Replaces desktop and web icon binaries with the new brand mark, ships
PNG raster exports under assets/brand/ alongside the existing SVG
sources, adds Alternative Styles artwork variants, and updates the
icon generation script and System Prompt doc. deploy.sh excludes
the new local-only artifacts (*.rtfd, assets/brand, electronbuild.sh,
multi-platform-roadmap.md) so they don't get rsync'd to the live
boxes. Drops ARCHITECTURE_AUDIT.md (obsolete).
This commit is contained in:
Jannis Braun
2026-05-04 00:48:11 +02:00
parent c7eb14d38e
commit 52b5f8e2b1
18 changed files with 184 additions and 47 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+4
View File
@@ -49,8 +49,12 @@ EXCLUDES=(
--exclude='outdated trash'
--exclude='docs/superpowers'
--exclude='*.rtf'
--exclude='*.rtfd'
--exclude='ARCHITECTURE_AUDIT.md'
--exclude='Backspace-design-prototype.html'
--exclude='assets/brand'
--exclude='electronbuild.sh'
--exclude='multi-platform-roadmap.md'
)
# ── Deploy function ─────────────────────────────────────────
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+12 -4
View File
@@ -1,6 +1,6 @@
# Icon Generator
Regenerates every brand artefact from `assets/brand/*.svg`:
Regenerates every brand artefact from sources in `assets/brand/`:
- macOS `.icns`, Windows `.ico`, Linux per-size PNGs (`packages/desktop/build/`)
- Tray icons for all three platforms (`packages/desktop/resources/`)
@@ -35,12 +35,20 @@ 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
## Sources
App-icon outputs use a hybrid pipeline: SVG <128 px (crisp pixel-grid alignment, where 3D detail wouldn't read), 3D-rendered raster ≥128 px (the designed render shows). The closest-fit picker selects the smallest raster source whose native dimension is ≥ the target output size, so every output is a downscale — no upscale anywhere.
| Brand source | Drives |
|---------------------------------|-----------------------------------------------|
| `assets/brand/app-icon.svg` | Every full app icon, all favicons, PWA, in-app `logo.png` (with `#1d1d1b → #000` bg swap for sidebar contrast) |
|------------------------------------|-------------------------------------------------------------------------------------|
| `assets/brand/app-icon.svg` | App-icon outputs <128 px (favicons 16/32, small Linux launcher reps 16/32/48/64, small `.ico` reps 16/24/32/48/64) |
| `assets/brand/app-icon-x1.png` (149) | App-icon outputs ≥128 and ≤149 (Linux 128, Windows `.ico` 128 rep) |
| `assets/brand/app-icon-x2.png` (294) | App-icon outputs >149 and ≤294 (Linux 256, Windows `.ico` 256 rep, apple-touch 180, PWA 192, in-app `logo.png` 256) |
| `assets/brand/app-icon-x3.png` (440) | App-icon outputs >294 and ≤440 (currently unused — reserved for future intermediate targets) |
| `assets/brand/app-icon-1024.png` | App-icon outputs >440 (Linux 512, Linux 1024, `build/icon.png` 512, PWA 512, `.icns` synthesis input 1024) |
| `assets/brand/mark.svg` | Win/Linux tray, PWA maskable inner mark |
| `assets/brand/mark-mono-dark.svg` | macOS menu-bar template (alpha + black) |
After raster resize, every app-icon raster output is masked with a 22 %-radius rounded-square (matches Apple's macOS template ratio and the existing `app-icon.svg` geometry of `rx=32.42 / 147.46 ≈ 21.99 %`) so launchers/docks/homescreens that render the icon as-is produce the rounded silhouette they expect.
`Artworks-Backspace/` is the design archive — never read by this script.
+162 -37
View File
@@ -2,7 +2,8 @@
/**
* Backspace icon generator
*
* Reads from assets/brand/*.svg and writes the entire desktop + web icon set:
* 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)
@@ -13,6 +14,14 @@
*
* Run via `pnpm gen-icons` after artwork changes; commit the diff.
*
* APP-ICON HYBRID RENDERING: app-icon outputs ≥128 px source from the 3D
* raster PNGs (x1=149 / x2=294 / x3=440), routed by closest-fit (smallest
* source ≥ target) to minimise resampling, then masked to a rounded-square
* silhouette (22 %·side ≈ Apple's macOS template radius). Outputs <128 px
* source from app-icon.svg — flat geometry stays crisp at favicon sizes
* where the 3D detail wouldn't read anyway. 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
@@ -32,6 +41,10 @@ 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'),
};
@@ -51,9 +64,42 @@ const MASKABLE_BG = '#1d1d1b';
// is fine and keeps output stable across all target sizes.
const SVG_DENSITY = 1200;
// Sizes at or above this threshold render from the 3D raster PNG sources;
// smaller targets render from app-icon.svg. 128 picked because:
// - dock / launcher / homescreen / PWA tile thumbnails are all ≥128 px,
// where the 3D-rendered design intent reads;
// - <128 (favicons, small Linux launcher slots, small .ico subset) reads
// better from flat SVG — crisp pixel grid alignment, and the 3D detail
// wouldn't be visible at that size regardless.
const RASTER_THRESHOLD = 128;
// 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
@@ -68,12 +114,61 @@ async function renderPng(svg, size) {
.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)));
@@ -81,12 +176,30 @@ async function writeIco(path, svg, sizes) {
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.
async function writeAppIconIco(path, sources, sizes) {
// Mixed-source .ico: each pixel size routes through renderAppIcon, so
// 16/24/32/48/64 come from the SVG and 128/256 from the raster — Windows
// auto-picks the closest size for the active DPI, getting flat-crisp
// small variants for taskbar/Properties and the designed render at the
// larger sizes that Alt+Tab and explorer thumbnails use.
mkdirSync(dirname(path), { recursive: true });
const src = await renderPng(svg, 1024);
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);
@@ -140,6 +253,14 @@ async function main() {
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({
@@ -153,18 +274,22 @@ async function main() {
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 writeAppIconPng(out, appIconSources, s);
trace('linux-png', out, `${s}x${s} (${s >= RASTER_THRESHOLD ? 'raster' : 'svg'})`);
}
await writePng(join(DESKTOP_BUILD, 'icon.png'), appIcon, 512);
trace('build-icon', join(DESKTOP_BUILD, 'icon.png'), '512x512');
await writeAppIconPng(join(DESKTOP_BUILD, 'icon.png'), appIconSources, 512);
trace('build-icon', join(DESKTOP_BUILD, 'icon.png'), '512x512 (raster)');
await writeIcns(join(DESKTOP_BUILD, 'icon.icns'), appIcon);
trace('mac-icns', join(DESKTOP_BUILD, 'icon.icns'), '10-rep iconset');
await writeAppIconIcns(join(DESKTOP_BUILD, 'icon.icns'), appIconSources);
trace('mac-icns', join(DESKTOP_BUILD, 'icon.icns'), '10-rep iconset (raster)');
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');
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 (svg<128, raster≥128)');
// --- Desktop: tray ---
await writePng(join(DESKTOP_RES, 'tray-iconTemplate.png'), markMonoDark, 22);
@@ -180,20 +305,20 @@ async function main() {
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 writeAppIconPng(join(WEB_ICONS, 'favicon-16.png'), appIconSources, 16);
trace('favicon-16', join(WEB_ICONS, 'favicon-16.png'), '16 (svg)');
await writePng(join(WEB_ICONS, 'favicon-32.png'), appIcon, 32);
trace('favicon-32', join(WEB_ICONS, 'favicon-32.png'), '32');
await writeAppIconPng(join(WEB_ICONS, 'favicon-32.png'), appIconSources, 32);
trace('favicon-32', join(WEB_ICONS, 'favicon-32.png'), '32 (svg)');
await writePng(join(WEB_ICONS, 'apple-touch-icon.png'), appIcon, 180);
trace('apple-touch', join(WEB_ICONS, 'apple-touch-icon.png'), '180');
await writeAppIconPng(join(WEB_ICONS, 'apple-touch-icon.png'), appIconSources, 180);
trace('apple-touch', join(WEB_ICONS, 'apple-touch-icon.png'), '180 (raster)');
await writePng(join(WEB_ICONS, 'icon-192.png'), appIcon, 192);
trace('pwa-192', join(WEB_ICONS, 'icon-192.png'), '192');
await writeAppIconPng(join(WEB_ICONS, 'icon-192.png'), appIconSources, 192);
trace('pwa-192', join(WEB_ICONS, 'icon-192.png'), '192 (raster)');
await writePng(join(WEB_ICONS, 'icon-512.png'), appIcon, 512);
trace('pwa-512', join(WEB_ICONS, 'icon-512.png'), '512');
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'),
@@ -204,19 +329,19 @@ async function main() {
);
trace('pwa-maskable', join(WEB_ICONS, 'icon-maskable-512.png'), `512 (60% mark on ${MASKABLE_BG})`);
// Logo for the SpaceSidebar home tile: full Element 1 badge with the
// bg fill swapped from #1d1d1b → #000000. The badge IS the brand
// identity at small sizes (full mark mass + own internal padding),
// and pure black against the sidebar's #1a1a23 reads as deliberately
// darker (intentional dark tile) rather than as a warm/cool mismatch
// (the original #1d1d1b looked like an off-grey rectangle). The
// sidebar's overflow-hidden + rounded-[20px → 13px] morph clips the
// badge cleanly because the bg is fully opaque.
const logoSvg = Buffer.from(
appIcon.toString('utf8').replace(/#1d1d1b/gi, '#000000'),
);
await writePng(join(WEB_ICONS, 'logo.png'), logoSvg, 256);
trace('in-app-logo', join(WEB_ICONS, 'logo.png'), '256 (full badge, #000)');
// 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) => {