| 1 | // Calma's marks (Paul, 2026-09-29): the Oficina mark is the Fanned sweep |
| 2 | // through every app's colour; each app's icon is its own colour, with a |
| 3 | // soft glow of lighter shades of that one hue behind its glyph. |
| 4 | // |
| 5 | // icons.json is the source: each app's hue (which must match estilo's |
| 6 | // theme ring, theme/apps.go; brand_test checks) and its glyph, drawn on a |
| 7 | // 100-unit grid with "{strong}" where the app's deep tone goes. |
| 8 | // |
| 9 | // Three shapes of every icon: |
| 10 | // svg/<id>.svg the web icon: Calma's squircle, transparent corners |
| 11 | // square/<id>.svg full-bleed and opaque: the Apple touch icon, and the |
| 12 | // iPhone app icon (iOS applies its own mask) |
| 13 | // mac/<id>.svg the Mac app icon: the squircle on Apple's icon grid |
| 14 | // (824 of 1024), with its shadow |
| 15 | // `--render` also writes PNGs: png/<id>-{16,32,48,192,512,1024}.png from |
| 16 | // the web icon, png/<id>-180.png from the square one, and |
| 17 | // native/<id>/{ios-1024,macos-1024}.png and native/<id>/AppIcon.icns, for |
| 18 | // the apps with a native client. |
| 19 | import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'; |
| 20 | import { fileURLToPath } from 'node:url'; |
| 21 | import { execFileSync } from 'node:child_process'; |
| 22 | import { createHash } from 'node:crypto'; |
| 23 | |
| 24 | const root = new URL('./', import.meta.url); |
| 25 | const icons = JSON.parse(await readFile(new URL('icons.json', root), 'utf8')); |
| 26 | const apps = icons.filter(i => i.hue !== null); |
| 27 | // The apps with a native client today (distribute.py's NATIVE). Add an |
| 28 | // app here when it gets one. |
| 29 | const NATIVE = ['calls', 'docs', 'sheets', 'calendar', 'memoria']; |
| 30 | |
| 31 | // OKLCH to sRGB hex, taking chroma down until the colour fits sRGB: the |
| 32 | // same conversion as estilo's theme.Hex, so an icon and its app's accent |
| 33 | // agree to the digit. |
| 34 | function hex(L, C, h) { |
| 35 | const lin = c => { |
| 36 | const rad = h * Math.PI / 180, a = c * Math.cos(rad), b = c * Math.sin(rad); |
| 37 | const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; |
| 38 | const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; |
| 39 | const s = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3; |
| 40 | return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, |
| 41 | -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, |
| 42 | -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s]; |
| 43 | }; |
| 44 | while (C > 0 && !lin(C).every(v => v >= -1e-4 && v <= 1 + 1e-4)) C -= 0.002; |
| 45 | 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); }; |
| 46 | return '#' + lin(Math.max(C, 0)).map(v => enc(v).toString(16).padStart(2, '0')).join(''); |
| 47 | } |
| 48 | |
| 49 | // Calma's squircle: a superellipse (n=5) on the 100-unit grid, 4 to 96. |
| 50 | const SQ = (() => { |
| 51 | const pts = []; |
| 52 | for (let i = 0; i < 120; i++) { |
| 53 | const t = 2 * Math.PI * i / 120, c = Math.cos(t), s = Math.sin(t); |
| 54 | const x = 50 + 46 * Math.sign(c) * Math.abs(c) ** (2 / 5), y = 50 + 46 * Math.sign(s) * Math.abs(s) ** (2 / 5); |
| 55 | pts.push(`${i ? 'L' : 'M'}${x.toFixed(2)} ${y.toFixed(2)}`); |
| 56 | } |
| 57 | return pts.join('') + 'Z'; |
| 58 | })(); |
| 59 | |
| 60 | // A seeded stream, so an app's glow is the same on every run. |
| 61 | function stream(seed) { |
| 62 | const d = createHash('sha256').update(seed).digest(); |
| 63 | let a = d.readUInt32LE(0), b = d.readUInt32LE(4), c = d.readUInt32LE(8), n = d.readUInt32LE(12); |
| 64 | return () => { // sfc32 |
| 65 | a |= 0; b |= 0; c |= 0; n |= 0; |
| 66 | 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; |
| 67 | return (t >>> 0) / 4294967296; |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | // The glow: a small curtain of lighter shades of the app's one hue, |
| 72 | // blurred, behind the glyph. Seeded by the app's id, so no two apps' |
| 73 | // glows are the same shape. |
| 74 | function glow(app) { |
| 75 | const r = stream('icon:' + app.id), u = (lo, hi) => lo + r() * (hi - lo); |
| 76 | const ph = u(0, 2 * Math.PI); |
| 77 | let rays = ''; |
| 78 | for (let x = 4; x < 96; x += u(1.1, 2.3)) { |
| 79 | const edge = 50 - 11 * Math.sin((x - 4) / 92 * Math.PI) + 3 * Math.sin(x / 8.5 + ph); |
| 80 | const len = Math.max(8, 20 + 8 * Math.sin(x * .9 + ph) + 6 * Math.sin(x * .31 + 2 * ph)); |
| 81 | rays += `<rect x="${x.toFixed(2)}" y="${(edge - len).toFixed(1)}" width="${u(.6, 1.3).toFixed(2)}" height="${(len + 3).toFixed(1)}" fill="${hex(u(.80, .93), u(.07, .115), app.hue + u(-5, 5))}"/>`; |
| 82 | } |
| 83 | return `<g filter="url(#h)" opacity=".7">${rays}</g>`; |
| 84 | } |
| 85 | |
| 86 | // The Oficina mark's sweep: from the bottom-left corner, across 118°, |
| 87 | // through every app's hue in ring order. |
| 88 | function fanned() { |
| 89 | const ring = [...apps].sort((a, b) => a.hue - b.hue); |
| 90 | const start = ring.findIndex(a => a.id === 'fichas'); |
| 91 | const hues = [...ring.slice(start), ...ring.slice(0, start)].map(a => a.hue); |
| 92 | for (let i = 1; i < hues.length; i++) while (hues[i] < hues[i - 1]) hues[i] += 360; |
| 93 | hues.push(hues[0] + 360); |
| 94 | const at = f => { const k = f * (hues.length - 1), i = Math.floor(k); return (hues[i] + (hues[i + 1] - hues[i]) * (k - i)) % 360; }; |
| 95 | let out = ''; |
| 96 | const n = 144, cx = 4, cy = 104, R = 160, a0 = -104, span = 118; |
| 97 | for (let i = 0; i < n; i++) { |
| 98 | const t0 = (a0 + span * i / n) * Math.PI / 180, t1 = (a0 + span * (i + 1.08) / n) * Math.PI / 180; |
| 99 | out += `<path d="M${cx} ${cy}L${(cx + R * Math.cos(t0)).toFixed(2)} ${(cy + R * Math.sin(t0)).toFixed(2)}L${(cx + R * Math.cos(t1)).toFixed(2)} ${(cy + R * Math.sin(t1)).toFixed(2)}Z" fill="${hex(.65, .145, at(i / n))}"/>`; |
| 100 | } |
| 101 | return `<g filter="url(#b)">${out}</g>` + |
| 102 | '<rect width="100" height="100" fill="url(#c)"/><rect width="100" height="100" fill="url(#l)"/>'; |
| 103 | } |
| 104 | |
| 105 | const SHEEN = '<radialGradient id="c"><stop offset="0" stop-color="#fff" stop-opacity=".24"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></radialGradient>' + |
| 106 | '<linearGradient id="l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".18"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/></linearGradient>'; |
| 107 | |
| 108 | // One icon's artwork on the 100-unit grid, in the web ("squircle") or |
| 109 | // full-bleed ("square") shape. |
| 110 | function art(item, shape) { |
| 111 | const outline = shape === 'square' ? '<rect width="100" height="100"/>' : `<path d="${SQ}"/>`; |
| 112 | const rim = shape === 'square' ? '' : `<path d="${SQ}" fill="none" stroke="#fff" stroke-opacity=".18" stroke-width="1"/>`; |
| 113 | if (item.hue === null) { |
| 114 | return `<defs><clipPath id="s">${outline}</clipPath>${SHEEN}<filter id="b" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="3"/></filter></defs>` + |
| 115 | `<g clip-path="url(#s)">${fanned()}</g>${item.glyph}`; |
| 116 | } |
| 117 | const top = hex(.64, .14, item.hue), tone = hex(.56, .15, item.hue), strong = hex(.49, .15, item.hue); |
| 118 | return `<defs><clipPath id="s">${outline}</clipPath>` + |
| 119 | `<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="1" stop-color="${tone}"/></linearGradient>` + |
| 120 | '<filter id="h" x="-40%" y="-60%" width="180%" height="220%"><feGaussianBlur stdDeviation="5.5"/></filter></defs>' + |
| 121 | `<g clip-path="url(#s)"><rect width="100" height="100" fill="url(#g)"/>${glow(item)}</g>${rim}` + |
| 122 | item.glyph.replaceAll('{strong}', strong); |
| 123 | } |
| 124 | |
| 125 | const doc = (item, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 100 100" role="img" aria-label="${item.name}"><title>${item.name}</title>${body}</svg>\n`; |
| 126 | const web = item => doc(item, art(item, 'squircle')); |
| 127 | const square = item => doc(item, art(item, 'square')); |
| 128 | // Apple's grid: the icon body is 824 of 1024, centred, with a soft shadow |
| 129 | // beneath. The squircle spans 92 units, so it scales by 80.47/92. |
| 130 | const mac = item => doc(item, '<defs><filter id="d" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="1.2" stdDeviation="1.3" flood-color="#141821" flood-opacity=".32"/></filter></defs>' + |
| 131 | `<g filter="url(#d)"><svg x="6.26" y="6.26" width="87.47" height="87.47" viewBox="0 0 100 100">${art(item, 'squircle')}</svg></g>`); |
| 132 | |
| 133 | for (const dir of ['svg', 'square', 'mac']) await mkdir(new URL(dir + '/', root), { recursive: true }); |
| 134 | for (const item of icons) { |
| 135 | await writeFile(new URL(`svg/${item.id}.svg`, root), web(item)); |
| 136 | await writeFile(new URL(`square/${item.id}.svg`, root), square(item)); |
| 137 | await writeFile(new URL(`mac/${item.id}.svg`, root), mac(item)); |
| 138 | } |
| 139 | |
| 140 | // The family, at size, on light and dark. |
| 141 | const cell = (item, x, y, size) => `<svg x="${x}" y="${y}" width="${size}" height="${size}" viewBox="0 0 100 100">${art(item, 'squircle').replaceAll('id="', `id="${item.id}${size}`).replaceAll('url(#', `url(#${item.id}${size}`)}</svg>`; |
| 142 | let sheet = '<rect width="1600" height="620" fill="#F5F6F8"/><rect y="310" width="1600" height="310" fill="#15171C"/><g font-family="Onest, system-ui, sans-serif">'; |
| 143 | icons.forEach((item, i) => { |
| 144 | const x = 60 + i * 152; |
| 145 | for (const [band, ink] of [[0, '#262A33'], [310, '#ECEEF2']]) { |
| 146 | sheet += cell(item, x, band + 40, 120); |
| 147 | sheet += `<text x="${x + 60}" y="${band + 190}" font-size="17" font-weight="600" fill="${ink}" text-anchor="middle">${item.name}</text>`; |
| 148 | let sx = x + 6; |
| 149 | for (const size of [48, 32, 16]) { sheet += cell(item, sx, band + 215 + (48 - size), size); sx += size + 8; } |
| 150 | } |
| 151 | }); |
| 152 | sheet += '</g>'; |
| 153 | await writeFile(new URL('preview.svg', root), `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="620" viewBox="0 0 1600 620" role="img" aria-label="Oficina app icons"><title>Oficina app icons</title>${sheet}</svg>\n`); |
| 154 | console.log(`Generated ${icons.length} icons in three shapes, and preview.svg`); |
| 155 | |
| 156 | if (process.argv.includes('--render')) { |
| 157 | const { chromium } = await import(process.env.OFICINA_PLAYWRIGHT || 'playwright'); |
| 158 | const browser = await chromium.launch({ headless: true, executablePath: process.env.OFICINA_CHROMIUM }); |
| 159 | try { |
| 160 | const page = await browser.newPage({ viewport: { width: 1600, height: 620 }, deviceScaleFactor: 1 }); |
| 161 | await page.goto(new URL('preview.svg', root).href); |
| 162 | await page.screenshot({ path: fileURLToPath(new URL('preview.png', root)) }); |
| 163 | const draw = (svg, size, alpha) => page.evaluate(async ({ svg, size, alpha }) => { |
| 164 | const img = new Image(); |
| 165 | img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); |
| 166 | await img.decode(); |
| 167 | const canvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); |
| 168 | canvas.width = canvas.height = size; |
| 169 | canvas.getContext('2d', { alpha }).drawImage(img, 0, 0, size, size); |
| 170 | return canvas.toDataURL('image/png').split(',')[1]; |
| 171 | }, { svg, size, alpha }); |
| 172 | await mkdir(new URL('png/', root), { recursive: true }); |
| 173 | for (const item of icons) { |
| 174 | for (const size of [16, 32, 48, 192, 512, 1024]) { |
| 175 | await writeFile(new URL(`png/${item.id}-${size}.png`, root), Buffer.from(await draw(web(item), size, true), 'base64')); |
| 176 | } |
| 177 | await writeFile(new URL(`png/${item.id}-180.png`, root), Buffer.from(await draw(square(item), 180, false), 'base64')); |
| 178 | if (!NATIVE.includes(item.id)) continue; |
| 179 | const native = new URL(`native/${item.id}/`, root); |
| 180 | await mkdir(native, { recursive: true }); |
| 181 | // iPhone: full bleed, and no alpha channel at all. |
| 182 | const ios = execFileSync('go', ['run', fileURLToPath(new URL('opaque-png.go', root))], { |
| 183 | input: Buffer.from(await draw(square(item), 1024, false), 'base64'), maxBuffer: 8 << 20, |
| 184 | }); |
| 185 | if (ios[25] !== 2) throw new Error('iPhone PNG must omit its alpha channel'); |
| 186 | await writeFile(new URL('ios-1024.png', native), ios); |
| 187 | for (const size of [16, 32, 64, 128, 256, 512, 1024]) { |
| 188 | await writeFile(new URL(`macos-${size}.png`, native), Buffer.from(await draw(mac(item), size, true), 'base64')); |
| 189 | } |
| 190 | // The Finder's .icns, from the same Mac PNGs (macOS's iconutil). |
| 191 | const set = new URL(`AppIcon.iconset/`, native); |
| 192 | await mkdir(set, { recursive: true }); |
| 193 | for (const [name, size] of [['16x16', 16], ['16x16@2x', 32], ['32x32', 32], ['32x32@2x', 64], ['128x128', 128], |
| 194 | ['128x128@2x', 256], ['256x256', 256], ['256x256@2x', 512], ['512x512', 512], ['512x512@2x', 1024]]) { |
| 195 | await writeFile(new URL(`icon_${name}.png`, set), await readFile(new URL(`macos-${size}.png`, native))); |
| 196 | } |
| 197 | execFileSync('iconutil', ['-c', 'icns', fileURLToPath(set), '-o', fileURLToPath(new URL('AppIcon.icns', native))]); |
| 198 | await rm(set, { recursive: true }); |
| 199 | // Keep what distribute.py copies; the .icns carries the other sizes. |
| 200 | for (const size of [16, 32, 64, 128, 256, 512]) await rm(new URL(`macos-${size}.png`, native)); |
| 201 | } |
| 202 | console.log('Rendered preview.png, web PNGs, and iPhone and Mac icons for the native apps'); |
| 203 | } finally { |
| 204 | await browser.close(); |
| 205 | } |
| 206 | } |
| 207 | |