New app icon and login logo: the HamLab penguin

Every icon is rendered from web/scripts/logo-source.png by scripts/icons.mjs: trimmed to
the artwork's own edge, transparent corners for the browser icons, a dark ground from the
artwork's palette for iOS (which paints transparency black) and for Android's maskable
icon, where the whole artwork sits inside the 80% safe circle. The login screen no longer
crops the logo to a circle or repeats the wordmark. Service worker cache bumped so
installed apps fetch the new files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 19:41:47 -07:00
co-authored by Claude Fable 5.1
parent 1d48267d90
commit 3a882e2d84
10 changed files with 68 additions and 24 deletions
+63 -21
View File
@@ -1,26 +1,68 @@
// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
// Renders the PWA icon set and the login logo from scripts/logo-source.png, with node-canvas
// (borrowed from ../../calorie-counter/node_modules). node scripts/icons.mjs
//
// The artwork is a rounded square on a transparent background, a little off-centre in its
// file. It is trimmed to its own edge, then drawn three ways:
// icon-192 / icon-512 / logo the artwork alone, transparent corners, filling the square
// apple-touch-icon iOS paints transparency black and cuts its own, tighter
// corners: a dark ground from the artwork's own palette, the
// artwork inset a little so its neon ring clears that cut
// icon-512-maskable Android may crop to a circle 80% across: the whole artwork
// sits inside that circle, on the same dark ground to the edge
import { createRequire } from 'node:module'
import { writeFileSync } from 'node:fs'
const require = createRequire(import.meta.url)
const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
const { createCanvas, loadImage } = require('/home/core/projects/calorie-counter/node_modules/canvas')
function draw(size, { pad = 0, radius = 0 }) {
const c = createCanvas(size, size), g = c.getContext('2d')
g.fillStyle = '#111418'
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
const inner = size - pad * 2
g.fillStyle = '#e3a33b'
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
g.textAlign = 'center'; g.textBaseline = 'middle'
g.fillText('H', size / 2, size / 2 + inner * 0.03)
// prompt caret
g.fillStyle = '#58a6ff'
g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
return c.toBuffer('image/png')
const img = await loadImage(new URL('./logo-source.png', import.meta.url).pathname)
const src = createCanvas(img.width, img.height), sg = src.getContext('2d')
sg.drawImage(img, 0, 0)
const px = sg.getImageData(0, 0, img.width, img.height), d = px.data
// The export left the artwork at alpha 252 and a stray alpha-1 speck outside it: make what
// is there fully there, what isn't fully gone, and keep the anti-aliased edge between.
let x0 = img.width, y0 = img.height, x1 = 0, y1 = 0
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
const i = (y * img.width + x) * 4 + 3
if (d[i] >= 240) d[i] = 255; else if (d[i] < 8) d[i] = 0
if (d[i] > 128) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y }
}
writeFileSync('public/icons/icon-192.png', draw(192, {}))
writeFileSync('public/icons/icon-512.png', draw(512, {}))
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
console.log('icons written')
sg.putImageData(px, 0, 0)
// a square around the artwork's own centre, as wide as its longer side: never stretched
const side = Math.max(x1 - x0, y1 - y0) + 1, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2
const art = createCanvas(side, side)
art.getContext('2d').drawImage(src, Math.round(cx - side / 2), Math.round(cy - side / 2), side, side, 0, 0, side, side)
// Shrinking six-fold in one step aliases the fine lines; halving until close does not.
function shrink(from, size) {
let cur = from
while (cur.width / 2 >= size) {
const next = createCanvas(Math.ceil(cur.width / 2), Math.ceil(cur.height / 2)), g = next.getContext('2d')
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
g.drawImage(cur, 0, 0, next.width, next.height)
cur = next
}
return cur
}
function draw(size, { scale = 1, ground = false } = {}) {
const c = createCanvas(size, size), g = c.getContext('2d')
if (ground) { // the artwork's own night-green, lit a little behind the middle
const bg = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size * 0.72)
bg.addColorStop(0, '#0b2e2a'); bg.addColorStop(1, '#03110f')
g.fillStyle = bg; g.fillRect(0, 0, size, size)
}
const w = Math.round(size * scale), from = shrink(art, w)
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
g.drawImage(from, (size - w) / 2, (size - w) / 2, w, w)
return c.toBuffer('image/png', { compressionLevel: 9 })
}
const out = {
'icon-192.png': draw(192),
'icon-512.png': draw(512),
'logo.png': draw(480),
'apple-touch-icon.png': draw(180, { scale: 0.95, ground: true }),
'icon-512-maskable.png': draw(512, { scale: 0.68, ground: true }),
}
for (const [name, buf] of Object.entries(out)) { writeFileSync(new URL('../public/icons/' + name, import.meta.url).pathname, buf); console.log(name, Math.round(buf.length / 1024) + ' KB') }
console.log(`artwork ${x1 - x0 + 1}×${y1 - y0 + 1} at ${x0},${y0} → ${side} px square`)