commit ce980a2a1f1abdb4cd564f564675cb62e1ac3b38
parent bb562b42eae19c78e1010c997eb17f39362fdcaf
Author: finwo <finwo@pm.me>
Date: Sat, 26 Sep 2026 21:38:35 +0200
Draw the conductor mark and ship it as the icon set
Diffstat:
13 files changed, 446 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
@@ -251,6 +251,13 @@ container image builds it in its own stage:
npm run build
```
+The icon is geometry rather than an image, drawn at each size it ships in.
+Rerun after changing `util/icon.js`, then rebuild:
+
+```sh
+npm run icon
+```
+
With docker available the suite starts what it needs and cleans up after
itself:
diff --git a/package.json b/package.json
@@ -10,6 +10,7 @@
"worker": "node src/worker/agent.js",
"migrate": "node src/lib/db/migrate-cli.js",
"build": "node web/build.cjs",
+ "icon": "node util/icon.js",
"test": "node --test \"test/**/*.test.js\""
},
"dependencies": {
diff --git a/test/icon.test.js b/test/icon.test.js
@@ -0,0 +1,175 @@
+// test/icon.test.js - the mark's geometry, and the png it encodes to
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import zlib from 'node:zlib';
+import { DEFAULTS, GRID, mark, png, raster, svg } from '../util/icon.js';
+
+function parse(buf) {
+ assert.deepEqual([...buf.subarray(0, 8)], [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ const chunks = [];
+ let p = 8;
+ while (p < buf.length) {
+ const len = buf.readUInt32BE(p);
+ const type = buf.toString('ascii', p + 4, p + 8);
+ const data = buf.subarray(p + 8, p + 8 + len);
+ const stored = buf.readUInt32BE(p + 8 + len);
+ chunks.push({ type, data, stored, covered: buf.subarray(p + 4, p + 8 + len) });
+ p += len + 12;
+ }
+ const ihdr = chunks.find(c => c.type === 'IHDR');
+ return {
+ chunks,
+ width: ihdr.data.readUInt32BE(0),
+ height: ihdr.data.readUInt32BE(4),
+ depth: ihdr.data[8],
+ colour: ihdr.data[9],
+ pixels: zlib.inflateSync(Buffer.concat(chunks.filter(c => c.type === 'IDAT').map(c => c.data))),
+ };
+}
+
+// Greyscale+alpha, so a row is one filter byte then two bytes a pixel.
+function pixel(img, x, y) {
+ const row = y * (1 + img.width * 2) + 1;
+ return { grey: img.pixels[row + x * 2], alpha: img.pixels[row + x * 2 + 1] };
+}
+
+test('encodes a png every size it is asked for', () => {
+ for (const size of [32, 180, 512]) {
+ const img = parse(png(size, raster(size)));
+ assert.equal(img.width, size);
+ assert.equal(img.height, size);
+ assert.equal(img.depth, 8);
+ assert.equal(img.colour, 4);
+ assert.equal(img.pixels.length, size * (1 + size * 2));
+ assert.equal(img.chunks.at(-1).type, 'IEND');
+ }
+});
+
+test('every chunk carries its own checksum', () => {
+ const table = new Int32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[n] = c;
+ }
+ for (const chunk of parse(png(32, raster(32))).chunks) {
+ let c = -1;
+ for (const byte of chunk.covered) c = table[(c ^ byte) & 0xff] ^ (c >>> 8);
+ assert.equal((c ^ -1) >>> 0, chunk.stored, `${chunk.type} checksum`);
+ }
+});
+
+test('the background stays transparent and the ink keeps its value', () => {
+ const img = parse(png(64, raster(64), 0x1c));
+ for (const [x, y] of [[0, 0], [63, 0], [0, 63], [63, 63]]) {
+ assert.equal(pixel(img, x, y).alpha, 0, `corner ${x},${y}`);
+ }
+ assert.equal(pixel(img, 32, 32).alpha, 0, 'the bowl is hollow');
+ const left = pixel(img, 12, 32);
+ assert.equal(left.alpha, 255, 'the left limb is solid');
+ assert.equal(left.grey, 0x1c);
+});
+
+test('a background composites the mark onto an opaque tile', () => {
+ const img = parse(png(64, raster(64), 0x1c, 0xf6));
+ for (let y = 0; y < 64; y++) {
+ for (let x = 0; x < 64; x++) assert.equal(pixel(img, x, y).alpha, 255);
+ }
+ assert.equal(pixel(img, 0, 0).grey, 0xf6);
+ assert.equal(pixel(img, 12, 32).grey, 0x1c);
+});
+
+test('the mark is symmetric about the horizontal axis', () => {
+ const size = 256;
+ const cov = raster(size, mark());
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ assert.equal(cov[y * size + x], cov[(size - 1 - y) * size + x], `${x},${y}`);
+ }
+ }
+});
+
+test('the break is cut to the inner corner and the limbs end balanced', () => {
+ const { outer, stroke, stretch } = DEFAULTS;
+ const K = 1.45;
+ const inner = outer - stroke;
+ const shape = mark();
+ const gapEdge = inner * (K - 1) + stretch;
+ const trim = (stroke * (2 - K)) / 2;
+
+ // Probes sit 4 units off every edge: (inner, gapEdge) is the corner itself,
+ // where the diagonal lands on a float that rounds either way.
+ assert.ok(shape(inner + 4, gapEdge + 4), 'limb present just past the corner');
+ assert.ok(!shape(inner - 8, gapEdge + 4), 'the hole is not filled in');
+ assert.ok(!shape(inner + 4, gapEdge - 4), 'the break is open below it');
+
+ assert.ok(shape(outer - trim - 4, gapEdge + 4), 'limb present just inside the slice');
+ assert.ok(!shape(outer - trim + 4, gapEdge + 4), 'nothing survives the slice');
+
+ const horizontal = outer - trim - inner;
+ const vertical = outer * (K - 1) + stretch + trim - gapEdge;
+ assert.ok(Math.abs(horizontal - vertical) < 1e-9, `${horizontal} vs ${vertical}`);
+ assert.ok(Math.abs(horizontal - (stroke * K) / 2) < 1e-9);
+});
+
+test('the bar clears the break and reaches past the sliced limbs', () => {
+ const { start, reach, stroke, outer } = DEFAULTS;
+ const shape = mark();
+ assert.ok(!shape(start - 2, 0), 'nothing before the bar starts');
+ assert.ok(shape(start + 2, 0), 'the bar starts where it says');
+ assert.ok(shape(reach - 2, 0), 'the bar runs to its reach');
+ assert.ok(!shape(reach + 2, 0), 'and stops there');
+ assert.ok(reach > outer, 'the bar overshoots the ring');
+ assert.ok(shape(reach - 2, stroke / 2 - 2), 'bar is a full stroke thick');
+ assert.ok(!shape(reach - 2, stroke / 2 + 2));
+});
+
+// The svg restates the geometry independently, so it can drift from the
+// raster without either looking wrong on its own.
+test('the svg draws the same shape as the raster', () => {
+ const doc = svg();
+ const nums = (re) => [...doc.match(re)[0].matchAll(/-?[\d.]+/g)].map(Number);
+ const polys = [...doc.matchAll(/<polygon points="([^"]+)"/g)]
+ .map(m => m[1].split(' ').map(p => p.split(',').map(Number)));
+ const rects = [...doc.matchAll(/<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
+ .map(m => m.slice(1, 5).map(Number));
+ assert.equal(polys.length, 2);
+ assert.equal(rects.length, 3);
+
+ const inside = (poly, x, y) => {
+ let hit = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const [xi, yi] = poly[i], [xj, yj] = poly[j];
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
+ }
+ return hit;
+ };
+ const within = ([rx, ry, rw, rh], x, y) => x >= rx && x < rx + rw && y >= ry && y < ry + rh;
+
+ const fromSvg = (x, y) => {
+ let on = inside(polys[0], x, y) && !inside(polys[1], x, y);
+ if (within(rects[0], x, y)) on = false;
+ if (within(rects[1], x, y)) on = false;
+ if (within(rects[2], x, y)) on = true;
+ return on;
+ };
+
+ const shape = mark();
+ let checked = 0;
+ for (let y = 4; y < GRID; y += 7) {
+ for (let x = 4; x < GRID; x += 7) {
+ // Half-unit offsets keep the probes off the shared edges, where the
+ // polygon rule and the predicate are each free to round either way.
+ const px = x + 0.5, py = y + 0.5;
+ assert.equal(fromSvg(px, py), shape(px - GRID / 2, py - GRID / 2), `${px},${py}`);
+ checked++;
+ }
+ }
+ assert.ok(checked > 20000, `only probed ${checked} points`);
+});
+
+test('the same options always encode to the same bytes', () => {
+ assert.deepEqual(png(64, raster(64)), png(64, raster(64)));
+ assert.equal(svg(), svg());
+});
diff --git a/util/icon.js b/util/icon.js
@@ -0,0 +1,224 @@
+// util/icon.js - draws the conductor mark, writes web/src/assets
+
+import fs from 'node:fs';
+import path from 'node:path';
+import zlib from 'node:zlib';
+import { fileURLToPath } from 'node:url';
+
+export const GRID = 1024;
+
+// Square clipped by a diamond, as in the cq mark.
+const K = 1.45;
+
+// Design units, measured from the centre.
+export const DEFAULTS = {
+ outer: 360,
+ stroke: 112,
+ start: 32,
+ reach: 440,
+ stretch: 32,
+};
+
+// Break cut to the inner octagon's corner, so no sliver of limb survives it.
+function breakHeight(inner, stretch) {
+ return 2 * (inner * (K - 1) + stretch);
+}
+
+// Slice leaving the limbs' horizontal face (stroke-d) and vertical face
+// (stroke*(K-1)+d) equal, both at stroke*K/2.
+function limbTrim(stroke) {
+ return (stroke * (2 - K)) / 2;
+}
+
+function octagon(r) {
+ const limit = r * K;
+ return (x, y) => {
+ const ax = Math.abs(x), ay = Math.abs(y);
+ return ax <= r && ay <= r && ax + ay <= limit;
+ };
+}
+
+// Clockwise from the upper right flat. Every corner sits off the axis, so
+// the stretch displaces all eight.
+function corners(r, stretch) {
+ const c = r * K - r;
+ const t = c + stretch, s = r + stretch;
+ return [[r, -t], [c, -s], [-c, -s], [-r, -t], [-r, t], [-c, s], [c, s], [r, t]];
+}
+
+// Ring, stretch, break, slice, bar. Stretching after the cuts would drag
+// them out of true; the bar goes last so the slice does not shorten it.
+export function mark(opts = {}) {
+ const { outer, stroke, start, reach, stretch } = { ...DEFAULTS, ...opts };
+ const inner = outer - stroke;
+ const gap = opts.gap ?? breakHeight(inner, stretch);
+ const trim = opts.trim ?? limbTrim(stroke);
+ const ring = octagon(outer);
+ const hole = octagon(inner);
+ return (x, y) => {
+ // Pulled apart at the seam; the limbs are uniform there, so the
+ // duplicated band reads as a taller octagon rather than a tear.
+ const yy = y > 0 ? y - stretch : y + stretch;
+ let on = ring(x, yy) && !hole(x, yy);
+ if (x >= 0 && Math.abs(y) <= gap / 2) on = false;
+ if (x > outer - trim) on = false;
+ if (x >= start && x <= reach && Math.abs(y) <= stroke / 2) on = true;
+ return on;
+ };
+}
+
+// Small icons need the antialiasing, large ones are nearly all interior.
+function sampling(size) {
+ return Math.min(16, Math.max(2, Math.round(512 / size)));
+}
+
+// Coverage per pixel, 0..255.
+export function raster(size, shape = mark(), samples = sampling(size)) {
+ const out = new Uint8Array(size * size);
+ const scale = GRID / size;
+ const step = 1 / samples;
+ const total = samples * samples;
+ for (let py = 0; py < size; py++) {
+ for (let px = 0; px < size; px++) {
+ let hit = 0;
+ for (let sy = 0; sy < samples; sy++) {
+ const y = (py + (sy + 0.5) * step) * scale - GRID / 2;
+ for (let sx = 0; sx < samples; sx++) {
+ const x = (px + (sx + 0.5) * step) * scale - GRID / 2;
+ if (shape(x, y)) hit++;
+ }
+ }
+ out[py * size + px] = Math.round((hit * 255) / total);
+ }
+ }
+ return out;
+}
+
+const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+
+const CRC = (() => {
+ const table = new Int32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[n] = c;
+ }
+ return table;
+})();
+
+function crc32(buf) {
+ let c = -1;
+ for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ -1) >>> 0;
+}
+
+function chunk(type, data) {
+ const head = Buffer.alloc(8);
+ head.writeUInt32BE(data.length, 0);
+ head.write(type, 4, 'ascii');
+ const crc = Buffer.alloc(4);
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
+ return Buffer.concat([head, data, crc]);
+}
+
+// Colour type 4, greyscale with alpha. A null bg stays transparent, a value
+// composites against it.
+export function png(size, coverage, ink = 0x00, bg = null) {
+ const raw = Buffer.alloc(size * (1 + size * 2));
+ let p = 0;
+ for (let y = 0; y < size; y++) {
+ raw[p++] = 0;
+ for (let x = 0; x < size; x++) {
+ const a = coverage[y * size + x];
+ raw[p++] = bg === null ? ink : Math.round((ink * a + bg * (255 - a)) / 255);
+ raw[p++] = bg === null ? a : 0xff;
+ }
+ }
+ const ihdr = Buffer.alloc(13);
+ ihdr.writeUInt32BE(size, 0);
+ ihdr.writeUInt32BE(size, 4);
+ ihdr[8] = 8;
+ ihdr[9] = 4;
+ return Buffer.concat([
+ SIGNATURE,
+ chunk('IHDR', ihdr),
+ chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
+ chunk('IEND', Buffer.alloc(0)),
+ ]);
+}
+
+export function svg(opts = {}) {
+ const { outer, stroke, start, reach, stretch } = { ...DEFAULTS, ...opts };
+ const inner = outer - stroke;
+ const gap = opts.gap ?? breakHeight(inner, stretch);
+ const trim = opts.trim ?? limbTrim(stroke);
+ const c = GRID / 2;
+ const n = (v) => +v.toFixed(3);
+ const points = (r) => corners(r, stretch).map(([x, y]) => `${n(c + x)},${n(c + y)}`).join(' ');
+ // Same steps, same order as mark().
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID} ${GRID}" width="${GRID}" height="${GRID}">
+ <style>:root{color:#1c1f23}@media(prefers-color-scheme:dark){:root{color:#e7e9ea}}</style>
+ <mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="${GRID}" height="${GRID}">
+ <polygon points="${points(outer)}" fill="#fff"/>
+ <polygon points="${points(inner)}" fill="#000"/>
+ <rect x="${c}" y="${n(c - gap / 2)}" width="${c}" height="${n(gap)}" fill="#000"/>
+ <rect x="${n(c + outer - trim)}" y="0" width="${c}" height="${GRID}" fill="#000"/>
+ <rect x="${n(c + start)}" y="${n(c - stroke / 2)}" width="${n(reach - start)}" height="${n(stroke)}" fill="#fff"/>
+ </mask>
+ <rect width="${GRID}" height="${GRID}" fill="currentColor" mask="url(#m)"/>
+</svg>
+`;
+}
+
+// The palette's fg and bg, as greyscale.
+const INK = { dark: 0x1c, light: 0xe9 };
+const TILE = 0xf6;
+
+const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/src/assets');
+
+const MANIFEST = {
+ name: 'conductor',
+ short_name: 'conductor',
+ // Relative to the manifest's url, so a sub-path deployment resolves to it.
+ start_url: '../',
+ scope: '../',
+ display: 'standalone',
+ background_color: '#f6f7f9',
+ theme_color: '#1c1f23',
+ icons: [
+ { src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
+ { src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
+ ],
+};
+
+function main() {
+ fs.mkdirSync(OUT, { recursive: true });
+ const shape = mark();
+ const written = [];
+ const put = (name, data) => {
+ fs.writeFileSync(path.join(OUT, name), data);
+ written.push([name, data.length]);
+ };
+
+ put('icon.svg', svg());
+ put('manifest.json', `${JSON.stringify(MANIFEST, null, 2)}\n`);
+
+ const small = raster(32, shape);
+ put('favicon-32.png', png(32, small, INK.dark));
+ put('favicon-32-dark.png', png(32, small, INK.light));
+
+ // Opaque: iOS composites a transparent touch icon onto black.
+ for (const [name, size] of [
+ ['apple-touch-icon.png', 180],
+ ['icon-192.png', 192],
+ ['icon-512.png', 512],
+ ['icon-1024.png', 1024],
+ ]) put(name, png(size, raster(size, shape), INK.dark, TILE));
+
+ const pad = Math.max(...written.map(([n]) => n.length));
+ for (const [name, bytes] of written) {
+ process.stdout.write(`${name.padEnd(pad)} ${String(bytes).padStart(7)} bytes\n`);
+ }
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
diff --git a/web/build.cjs b/web/build.cjs
@@ -61,9 +61,8 @@ esbuild
.build(config)
.then(async () => {
try { fs.mkdirSync('./dist/assets'); } catch { /* empty */ }
- // Nothing in src/assets yet, and cpy throws on a glob that matches none.
- // await cpy(__dirname + '/src/assets/*', __dirname + '/dist/assets');
- // await cpy(__dirname + '/src/assets/**/*', __dirname + '/dist/assets');
+ // Already rendered at every size by util/icon.js, so only copied here.
+ await cpy(__dirname + '/src/assets/**/*', __dirname + '/dist/assets');
// try { fs.mkdirSync('./dist/AppModule'); } catch { /* empty */ }
fs.copyFileSync(__dirname + `/src/global.css`, config.outdir + `/global.css`);
for(const name of Object.keys(entryPoints)) {
@@ -82,11 +81,11 @@ esbuild
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0">
<title>conductor</title>
- <!-- <link rel="icon" href="/assets/favicon.ico"> -->
- <!-- <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png"> -->
- <!-- <link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192x192.png"> -->
- <!-- <link rel="icon" type="image/png" sizes="512x512" href="/assets/icon-512x512.png"> -->
- <!-- <link rel="manifest" href="/assets/manifest.json"/> -->
+ <link rel="icon" type="image/svg+xml" href="assets/icon.svg">
+ <link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32-dark.png" media="(prefers-color-scheme: dark)">
+ <link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
+ <link rel="manifest" href="assets/manifest.json"/>
<!-- <link rel="stylesheet" href="/assets/fonts/karla/karla.css"/> -->
${styles.map(name => `<link rel="stylesheet" href="${name}"/>`).join('\n ')}
</head>
@@ -96,23 +95,6 @@ esbuild
</html>
`);
- // Build favicons
- // await new Promise(done => {
- // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=32:32 -sws_flags area ${config.outdir}/assets/favicon.ico`, done);
- // });
- // await new Promise(done => {
- // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=180:180 -sws_flags area ${config.outdir}/assets/apple-touch-icon.png`, done);
- // });
-
- // Build app icons
- // const manifest = require(__dirname+'/dist/assets/manifest.json');
- // for(const format of manifest.icons) {
- // const scale = format.sizes.split('x').join(':');
- // await new Promise(done => {
- // exec(`ffmpeg -n -i ${config.outdir}/assets/icon.png -vf scale=${scale} -sws_flags area ${config.outdir}${format.src}`, done);
- // });
- // }
-
// const files = await new Promise(done => {
// exec(`find ${config.outdir} -type f`, (err,stdout, stderr) => {
// done(stdout
diff --git a/web/src/assets/apple-touch-icon.png b/web/src/assets/apple-touch-icon.png
Binary files differ.
diff --git a/web/src/assets/favicon-32-dark.png b/web/src/assets/favicon-32-dark.png
Binary files differ.
diff --git a/web/src/assets/favicon-32.png b/web/src/assets/favicon-32.png
Binary files differ.
diff --git a/web/src/assets/icon-1024.png b/web/src/assets/icon-1024.png
Binary files differ.
diff --git a/web/src/assets/icon-192.png b/web/src/assets/icon-192.png
Binary files differ.
diff --git a/web/src/assets/icon-512.png b/web/src/assets/icon-512.png
Binary files differ.
diff --git a/web/src/assets/icon.svg b/web/src/assets/icon.svg
@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
+ <style>:root{color:#1c1f23}@media(prefers-color-scheme:dark){:root{color:#e7e9ea}}</style>
+ <mask id="m" maskUnits="userSpaceOnUse" x="0" y="0" width="1024" height="1024">
+ <polygon points="872,318 674,120 350,120 152,318 152,706 350,904 674,904 872,706" fill="#fff"/>
+ <polygon points="760,368.4 623.6,232 400.4,232 264,368.4 264,655.6 400.4,792 623.6,792 760,655.6" fill="#000"/>
+ <rect x="512" y="368.4" width="512" height="287.2" fill="#000"/>
+ <rect x="841.2" y="0" width="512" height="1024" fill="#000"/>
+ <rect x="544" y="456" width="408" height="112" fill="#fff"/>
+ </mask>
+ <rect width="1024" height="1024" fill="currentColor" mask="url(#m)"/>
+</svg>
diff --git a/web/src/assets/manifest.json b/web/src/assets/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "conductor",
+ "short_name": "conductor",
+ "start_url": "../",
+ "scope": "../",
+ "display": "standalone",
+ "background_color": "#f6f7f9",
+ "theme_color": "#1c1f23",
+ "icons": [
+ {
+ "src": "icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}