oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

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.
19import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
20import { fileURLToPath } from 'node:url';
21import { execFileSync } from 'node:child_process';
22import { createHash } from 'node:crypto';
23
24const root = new URL('./', import.meta.url);
25const icons = JSON.parse(await readFile(new URL('icons.json', root), 'utf8'));
26const 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.
29const 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.
34function 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.
50const 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.
61function 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.
74function 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.
88function 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
105const 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.
110function 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
125const 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`;
126const web = item => doc(item, art(item, 'squircle'));
127const 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.
130const 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
133for (const dir of ['svg', 'square', 'mac']) await mkdir(new URL(dir + '/', root), { recursive: true });
134for (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.
141const 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>`;
142let 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">';
143icons.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});
152sheet += '</g>';
153await 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`);
154console.log(`Generated ${icons.length} icons in three shapes, and preview.svg`);
155
156if (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