// Calma's marks (Paul, 2026-09-29): the Oficina mark is the Fanned sweep // through every app's colour; each app's icon is its own colour, with a // soft glow of lighter shades of that one hue behind its glyph. // // icons.json is the source: each app's hue (which must match estilo's // theme ring, theme/apps.go; brand_test checks) and its glyph, drawn on a // 100-unit grid with "{strong}" where the app's deep tone goes. // // Three shapes of every icon: // svg/.svg the web icon: Calma's squircle, transparent corners // square/.svg full-bleed and opaque: the Apple touch icon, and the // iPhone app icon (iOS applies its own mask) // mac/.svg the Mac app icon: the squircle on Apple's icon grid // (824 of 1024), with its shadow // `--render` also writes PNGs: png/-{16,32,48,192,512,1024}.png from // the web icon, png/-180.png from the square one, and // native//{ios-1024,macos-1024}.png and native//AppIcon.icns, for // the apps with a native client. import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; const root = new URL('./', import.meta.url); const icons = JSON.parse(await readFile(new URL('icons.json', root), 'utf8')); const apps = icons.filter(i => i.hue !== null); // The apps with a native client today (distribute.py's NATIVE). Add an // app here when it gets one. const NATIVE = ['calls', 'docs', 'sheets', 'calendar', 'memoria']; // OKLCH to sRGB hex, taking chroma down until the colour fits sRGB: the // same conversion as estilo's theme.Hex, so an icon and its app's accent // agree to the digit. function hex(L, C, h) { const lin = c => { const rad = h * Math.PI / 180, a = c * Math.cos(rad), b = c * Math.sin(rad); const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3; return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s]; }; while (C > 0 && !lin(C).every(v => v >= -1e-4 && v <= 1 + 1e-4)) C -= 0.002; const enc = v => { v = Math.max(0, Math.min(1, v)); v = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; return Math.round(v * 255); }; return '#' + lin(Math.max(C, 0)).map(v => enc(v).toString(16).padStart(2, '0')).join(''); } // Calma's squircle: a superellipse (n=5) on the 100-unit grid, 4 to 96. const SQ = (() => { const pts = []; for (let i = 0; i < 120; i++) { const t = 2 * Math.PI * i / 120, c = Math.cos(t), s = Math.sin(t); const x = 50 + 46 * Math.sign(c) * Math.abs(c) ** (2 / 5), y = 50 + 46 * Math.sign(s) * Math.abs(s) ** (2 / 5); pts.push(`${i ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`); } return pts.join('') + 'Z'; })(); // A seeded stream, so an app's glow is the same on every run. function stream(seed) { const d = createHash('sha256').update(seed).digest(); let a = d.readUInt32LE(0), b = d.readUInt32LE(4), c = d.readUInt32LE(8), n = d.readUInt32LE(12); return () => { // sfc32 a |= 0; b |= 0; c |= 0; n |= 0; const t = (a + b | 0) + n | 0; n = n + 1 | 0; a = b ^ b >>> 9; b = c + (c << 3) | 0; c = c << 21 | c >>> 11; c = c + t | 0; return (t >>> 0) / 4294967296; }; } // The glow: a small curtain of lighter shades of the app's one hue, // blurred, behind the glyph. Seeded by the app's id, so no two apps' // glows are the same shape. function glow(app) { const r = stream('icon:' + app.id), u = (lo, hi) => lo + r() * (hi - lo); const ph = u(0, 2 * Math.PI); let rays = ''; for (let x = 4; x < 96; x += u(1.1, 2.3)) { const edge = 50 - 11 * Math.sin((x - 4) / 92 * Math.PI) + 3 * Math.sin(x / 8.5 + ph); const len = Math.max(8, 20 + 8 * Math.sin(x * .9 + ph) + 6 * Math.sin(x * .31 + 2 * ph)); rays += ``; } return `${rays}`; } // The Oficina mark's sweep: from the bottom-left corner, across 118°, // through every app's hue in ring order. function fanned() { const ring = [...apps].sort((a, b) => a.hue - b.hue); const start = ring.findIndex(a => a.id === 'fichas'); const hues = [...ring.slice(start), ...ring.slice(0, start)].map(a => a.hue); for (let i = 1; i < hues.length; i++) while (hues[i] < hues[i - 1]) hues[i] += 360; hues.push(hues[0] + 360); const at = f => { const k = f * (hues.length - 1), i = Math.floor(k); return (hues[i] + (hues[i + 1] - hues[i]) * (k - i)) % 360; }; let out = ''; const n = 144, cx = 4, cy = 104, R = 160, a0 = -104, span = 118; for (let i = 0; i < n; i++) { const t0 = (a0 + span * i / n) * Math.PI / 180, t1 = (a0 + span * (i + 1.08) / n) * Math.PI / 180; out += ``; } return `${out}` + ''; } const SHEEN = '' + ''; // One icon's artwork on the 100-unit grid, in the web ("squircle") or // full-bleed ("square") shape. function art(item, shape) { const outline = shape === 'square' ? '' : ``; const rim = shape === 'square' ? '' : ``; if (item.hue === null) { return `${outline}${SHEEN}` + `${fanned()}${item.glyph}`; } const top = hex(.64, .14, item.hue), tone = hex(.56, .15, item.hue), strong = hex(.49, .15, item.hue); return `${outline}` + `` + '' + `${glow(item)}${rim}` + item.glyph.replaceAll('{strong}', strong); } const doc = (item, body) => `${item.name}${body}\n`; const web = item => doc(item, art(item, 'squircle')); const square = item => doc(item, art(item, 'square')); // Apple's grid: the icon body is 824 of 1024, centred, with a soft shadow // beneath. The squircle spans 92 units, so it scales by 80.47/92. const mac = item => doc(item, '' + `${art(item, 'squircle')}`); for (const dir of ['svg', 'square', 'mac']) await mkdir(new URL(dir + '/', root), { recursive: true }); for (const item of icons) { await writeFile(new URL(`svg/${item.id}.svg`, root), web(item)); await writeFile(new URL(`square/${item.id}.svg`, root), square(item)); await writeFile(new URL(`mac/${item.id}.svg`, root), mac(item)); } // The family, at size, on light and dark. const cell = (item, x, y, size) => `${art(item, 'squircle').replaceAll('id="', `id="${item.id}${size}`).replaceAll('url(#', `url(#${item.id}${size}`)}`; let sheet = ''; icons.forEach((item, i) => { const x = 60 + i * 152; for (const [band, ink] of [[0, '#262A33'], [310, '#ECEEF2']]) { sheet += cell(item, x, band + 40, 120); sheet += `${item.name}`; let sx = x + 6; for (const size of [48, 32, 16]) { sheet += cell(item, sx, band + 215 + (48 - size), size); sx += size + 8; } } }); sheet += ''; await writeFile(new URL('preview.svg', root), `Oficina app icons${sheet}\n`); console.log(`Generated ${icons.length} icons in three shapes, and preview.svg`); if (process.argv.includes('--render')) { const { chromium } = await import(process.env.OFICINA_PLAYWRIGHT || 'playwright'); const browser = await chromium.launch({ headless: true, executablePath: process.env.OFICINA_CHROMIUM }); try { const page = await browser.newPage({ viewport: { width: 1600, height: 620 }, deviceScaleFactor: 1 }); await page.goto(new URL('preview.svg', root).href); await page.screenshot({ path: fileURLToPath(new URL('preview.png', root)) }); const draw = (svg, size, alpha) => page.evaluate(async ({ svg, size, alpha }) => { const img = new Image(); img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); await img.decode(); const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); canvas.width = canvas.height = size; canvas.getContext('2d', { alpha }).drawImage(img, 0, 0, size, size); return canvas.toDataURL('image/png').split(',')[1]; }, { svg, size, alpha }); await mkdir(new URL('png/', root), { recursive: true }); for (const item of icons) { for (const size of [16, 32, 48, 192, 512, 1024]) { await writeFile(new URL(`png/${item.id}-${size}.png`, root), Buffer.from(await draw(web(item), size, true), 'base64')); } await writeFile(new URL(`png/${item.id}-180.png`, root), Buffer.from(await draw(square(item), 180, false), 'base64')); if (!NATIVE.includes(item.id)) continue; const native = new URL(`native/${item.id}/`, root); await mkdir(native, { recursive: true }); // iPhone: full bleed, and no alpha channel at all. const ios = execFileSync('go', ['run', fileURLToPath(new URL('opaque-png.go', root))], { input: Buffer.from(await draw(square(item), 1024, false), 'base64'), maxBuffer: 8 << 20, }); if (ios[25] !== 2) throw new Error('iPhone PNG must omit its alpha channel'); await writeFile(new URL('ios-1024.png', native), ios); for (const size of [16, 32, 64, 128, 256, 512, 1024]) { await writeFile(new URL(`macos-${size}.png`, native), Buffer.from(await draw(mac(item), size, true), 'base64')); } // The Finder's .icns, from the same Mac PNGs (macOS's iconutil). const set = new URL(`AppIcon.iconset/`, native); await mkdir(set, { recursive: true }); for (const [name, size] of [['16x16', 16], ['16x16@2x', 32], ['32x32', 32], ['32x32@2x', 64], ['128x128', 128], ['128x128@2x', 256], ['256x256', 256], ['256x256@2x', 512], ['512x512', 512], ['512x512@2x', 1024]]) { await writeFile(new URL(`icon_${name}.png`, set), await readFile(new URL(`macos-${size}.png`, native))); } execFileSync('iconutil', ['-c', 'icns', fileURLToPath(set), '-o', fileURLToPath(new URL('AppIcon.icns', native))]); await rm(set, { recursive: true }); // Keep what distribute.py copies; the .icns carries the other sizes. for (const size of [16, 32, 64, 128, 256, 512]) await rm(new URL(`macos-${size}.png`, native)); } console.log('Rendered preview.png, web PNGs, and iPhone and Mac icons for the native apps'); } finally { await browser.close(); } }