#!/usr/bin/env node // Writes the icon packs of the Store source: store/x/icons//.icons.json and preview.svg. // wb-line, wb-classic, wb-aero, wb-pixel the packs bundled in the app (apps/desktop/src/inject/packs/index.ts), // published under the SAME ids: installing one changes nothing // retro-98, retro-xp the retro packs (apps/desktop/src/inject/packs/retro.ts), Store only // item.json (category, version, tags, license, credits) is written by hand and left alone. // Run it after changing a pack, then `node scripts/build-store.mjs`. `node scripts/check-store.mjs` fails // when a bundled pack and its Store file differ. // // node scripts/build-store-icons.mjs [--check] import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { glyphSvg } from '../waybetter/packages/runtime/src/themeIcons.ts'; import { BUILTIN_PACKS, packDocument } from '../apps/desktop/src/inject/packs/index.ts'; import { RETRO_PACKS, retroDocument } from '../apps/desktop/src/inject/packs/retro.ts'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const STORE_ICON_IDS = [...new Set([...BUILTIN_PACKS.map(pack => pack.id), ...RETRO_PACKS.map(pack => pack.id)])]; /** The pack file as the Store publishes it. */ export const packFile = id => `${JSON.stringify(packDocument(id) ?? retroDocument(id), null, 1)}\n`; const esc = value => String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]); const num = n => +n.toFixed(2); // ---- preview ------------------------------------------------------------------------------ const RAIL = ['home', 'explore', 'notifications', 'messages', 'grok', 'bookmarks', 'lists', 'communities', 'profile', 'more']; const ACTIONS = ['reply', 'repost', 'repost.on', 'like', 'like.on', 'views', 'bookmark', 'bookmark.on', 'share', 'media', 'emoji', 'schedule']; const RAIL_LABELS = [38, 52, 70, 58, 34, 66, 40, 78, 50, 36]; /** One glyph placed at (x, y) in a box of `size`, `ink` for single-path glyphs. Gradients get unique ids. */ function place(glyph, style, x, y, size, ink, uid) { if (!glyph) return ''; const scale = size / 24; const open = ``; if ('d' in glyph) { const attrs = glyph.stroke ? `fill="none" stroke="${ink}" stroke-width="${style.stroke}" stroke-linecap="${style.cap}" stroke-linejoin="${style.join}"` : `fill="${ink}"${glyph.rule ? ' fill-rule="evenodd"' : ''}`; return `${open}`; } if ('layers' in glyph) { const defs = []; const paths = glyph.layers.map((layer, index) => { let fill = layer.fill; if (typeof fill === 'object') { const id = `${uid}-${index}`; defs.push(``); fill = `url(#${id})`; } return ``; }); return `${open}${defs.length ? `${defs.join('')}` : ''}${paths.join('')}`; } return ''; } const FONT = "'Segoe UI', Tahoma, Arial, sans-serif"; const THEMES = { line: { bg: '#0f1419', ink: '#e7e9ea', muted: '#2f3b44', title: '#ffffff', sub: '#8b98a5', rounded: 12 }, classic: { bg: '#e8eef4', ink: '#2b3f55', muted: '#b9c6d3', title: '#1b2a3a', sub: '#5c6f82', rounded: 12 }, aero: { bg: ['#d8efff', '#7fc3f2'], ink: '#0b3a66', muted: '#ffffff', mutedOpacity: 0.55, title: '#0b3a66', sub: '#2f6a9e', rounded: 16, glass: true }, win98: { bg: '#008080', ink: '#000000', muted: '#808080', title: '#ffffff', sub: '#000000', window: '98' }, winxp: { bg: ['#4a8be6', '#2f9a3a'], ink: '#143c8c', muted: '#a9b8d4', title: '#ffffff', sub: '#143c8c', window: 'xp' }, }; const THEME_OF = { 'wb-line': 'line', 'wb-classic': 'classic', 'wb-aero': 'aero', 'wb-pixel': 'win98', 'retro-98': 'win98', 'retro-xp': 'winxp' }; function frame(theme, x, y, w, h, title, uid) { if (theme.window === '98') { return `` + `${esc(title)}` + `` + ``; } if (theme.window === 'xp') { return `` + `` + `${esc(title)}` + `` + ``; } return ``; } /** A 640x400 preview of a pack: the left navigation drawn with it, and the action bar. */ export function iconsPreviewSvg(pack) { const theme = THEMES[THEME_OF[pack.id] ?? 'line']; const W = 640, H = 400; const uid = pack.id.replace(/[^a-z0-9]/g, ''); const crisp = theme.window === '98' ? ' shape-rendering="crispEdges"' : ''; const gradient = Array.isArray(theme.bg); const out = []; out.push(``); out.push(`${esc(pack.name)}`); out.push(`${gradient ? `` : ''}`); out.push(``); const windowed = Boolean(theme.window); // Left rail panel. const railX = windowed ? 24 : 28, railY = windowed ? 22 : 22, railW = 222, railH = 356; out.push(frame(theme, railX, railY, railW, railH, 'Home', `${uid}r`)); const top = railY + (windowed ? 40 : 16); const pitch = windowed ? 31 : 33, icon = windowed ? 26 : 28; RAIL.forEach((name, index) => { const y = top + index * pitch; out.push(place(pack.glyphs[name], pack.style, railX + 18, y, icon, theme.ink, `${uid}n${index}`)); out.push(``); }); // Right panel: the name and the action bar. const panX = 270, panY = windowed ? 22 : 22, panW = 346, panH = 356; out.push(frame(theme, panX, panY, panW, panH, pack.name, `${uid}p`)); const textTop = panY + (windowed ? 62 : 52); out.push(`${esc(pack.name)}`); out.push(`Icon pack · ${Object.keys(pack.glyphs).length} icons · ${esc(pack.license)}`); ACTIONS.forEach((name, index) => { const col = index % 4, row = Math.floor(index / 4); out.push(place(pack.glyphs[name], pack.style, panX + 28 + col * 78, textTop + 50 + row * 78, 46, theme.ink, `${uid}a${index}`)); }); out.push(''); return out.join(''); } // ---- writing ------------------------------------------------------------------------------ function main() { const check = process.argv.includes('--check'); let drift = 0; for (const id of STORE_ICON_IDS) { const pack = [...BUILTIN_PACKS, ...RETRO_PACKS].find(item => item.id === id); const dir = join(ROOT, 'store/x/icons', id); const files = { [`${id}.icons.json`]: packFile(id), 'preview.svg': iconsPreviewSvg(pack) }; for (const [name, text] of Object.entries(files)) { const path = join(dir, name); if (check) { if (!existsSync(path) || readFileSync(path, 'utf8') !== text) { drift += 1; console.error(`store/x/icons/${id}/${name} is out of date: node scripts/build-store-icons.mjs`); } } else { mkdirSync(dir, { recursive: true }); writeFileSync(path, text); } } } if (check) process.exit(drift ? 1 : 0); console.log(`store icons: ${STORE_ICON_IDS.join(', ')} → store/x/icons/`); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();