diff --git a/assets/brand/app-icon-1024.png b/assets/brand/app-icon-1024.png new file mode 100644 index 00000000..cde5ab5d Binary files /dev/null and b/assets/brand/app-icon-1024.png differ diff --git a/assets/brand/app-icon-x1.png b/assets/brand/app-icon-x1.png new file mode 100644 index 00000000..5d9acb7f Binary files /dev/null and b/assets/brand/app-icon-x1.png differ diff --git a/assets/brand/app-icon-x2.png b/assets/brand/app-icon-x2.png new file mode 100644 index 00000000..5551eca1 Binary files /dev/null and b/assets/brand/app-icon-x2.png differ diff --git a/assets/brand/app-icon-x3.png b/assets/brand/app-icon-x3.png new file mode 100644 index 00000000..d445814a Binary files /dev/null and b/assets/brand/app-icon-x3.png differ diff --git a/deploy.sh b/deploy.sh index 7eb4e83e..a715c9d5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 ───────────────────────────────────────── diff --git a/packages/desktop/build/icon.icns b/packages/desktop/build/icon.icns index d407d2ff..3a7ea90d 100644 Binary files a/packages/desktop/build/icon.icns and b/packages/desktop/build/icon.icns differ diff --git a/packages/desktop/build/icon.ico b/packages/desktop/build/icon.ico index 8c003cb7..3a803cf6 100644 Binary files a/packages/desktop/build/icon.ico and b/packages/desktop/build/icon.ico differ diff --git a/packages/desktop/build/icon.png b/packages/desktop/build/icon.png index b97223d9..5ad038bd 100644 Binary files a/packages/desktop/build/icon.png and b/packages/desktop/build/icon.png differ diff --git a/packages/desktop/build/icons/1024x1024.png b/packages/desktop/build/icons/1024x1024.png index 803b064e..a670e29d 100644 Binary files a/packages/desktop/build/icons/1024x1024.png and b/packages/desktop/build/icons/1024x1024.png differ diff --git a/packages/desktop/build/icons/128x128.png b/packages/desktop/build/icons/128x128.png index 3d66363a..73fe02fa 100644 Binary files a/packages/desktop/build/icons/128x128.png and b/packages/desktop/build/icons/128x128.png differ diff --git a/packages/desktop/build/icons/256x256.png b/packages/desktop/build/icons/256x256.png index 4b9415ca..71989079 100644 Binary files a/packages/desktop/build/icons/256x256.png and b/packages/desktop/build/icons/256x256.png differ diff --git a/packages/desktop/build/icons/512x512.png b/packages/desktop/build/icons/512x512.png index b97223d9..5ad038bd 100644 Binary files a/packages/desktop/build/icons/512x512.png and b/packages/desktop/build/icons/512x512.png differ diff --git a/packages/web/public/icons/apple-touch-icon.png b/packages/web/public/icons/apple-touch-icon.png index ef9c3d8a..5380556e 100644 Binary files a/packages/web/public/icons/apple-touch-icon.png and b/packages/web/public/icons/apple-touch-icon.png differ diff --git a/packages/web/public/icons/icon-192.png b/packages/web/public/icons/icon-192.png index b8a573e9..42b57e06 100644 Binary files a/packages/web/public/icons/icon-192.png and b/packages/web/public/icons/icon-192.png differ diff --git a/packages/web/public/icons/icon-512.png b/packages/web/public/icons/icon-512.png index b97223d9..5ad038bd 100644 Binary files a/packages/web/public/icons/icon-512.png and b/packages/web/public/icons/icon-512.png differ diff --git a/packages/web/public/icons/logo.png b/packages/web/public/icons/logo.png index 52eeb5fe..71989079 100644 Binary files a/packages/web/public/icons/logo.png and b/packages/web/public/icons/logo.png differ diff --git a/scripts/gen-icons.README.md b/scripts/gen-icons.README.md index 113c431e..b9455bfe 100644 --- a/scripts/gen-icons.README.md +++ b/scripts/gen-icons.README.md @@ -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 -| 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/mark.svg` | Win/Linux tray, PWA maskable inner mark | -| `assets/brand/mark-mono-dark.svg` | macOS menu-bar template (alpha + black) | +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` | 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. diff --git a/scripts/gen-icons.mjs b/scripts/gen-icons.mjs index 7b5b0cb9..13e8e439 100755 --- a/scripts/gen-icons.mjs +++ b/scripts/gen-icons.mjs @@ -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 @@ -31,9 +40,13 @@ 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'), + 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'); @@ -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( + ``, + ); + 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) => {