conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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:
MREADME.md | 7+++++++
Mpackage.json | 1+
Atest/icon.test.js | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Autil/icon.js | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/build.cjs | 32+++++++-------------------------
Aweb/src/assets/apple-touch-icon.png | 0
Aweb/src/assets/favicon-32-dark.png | 0
Aweb/src/assets/favicon-32.png | 0
Aweb/src/assets/icon-1024.png | 0
Aweb/src/assets/icon-192.png | 0
Aweb/src/assets/icon-512.png | 0
Aweb/src/assets/icon.svg | 11+++++++++++
Aweb/src/assets/manifest.json | 21+++++++++++++++++++++
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" + } + ] +}