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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 310 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
// Ham Lab service worker: cache hashed assets, never the API, network-first for the shell. // Ham Lab service worker: cache hashed assets, never the API, network-first for the shell.
const CACHE = 'hamlab-v2'; const CACHE = 'hamlab-v3'; // bumped when files under /icons/ change: they are served cache-first
self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => { self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())); e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
+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 { createRequire } from 'node:module'
import { writeFileSync } from 'node:fs' import { writeFileSync } from 'node:fs'
const require = createRequire(import.meta.url) 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 img = await loadImage(new URL('./logo-source.png', import.meta.url).pathname)
const c = createCanvas(size, size), g = c.getContext('2d') const src = createCanvas(img.width, img.height), sg = src.getContext('2d')
g.fillStyle = '#111418' sg.drawImage(img, 0, 0)
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size) const px = sg.getImageData(0, 0, img.width, img.height), d = px.data
const inner = size - pad * 2 // The export left the artwork at alpha 252 and a stray alpha-1 speck outside it: make what
g.fillStyle = '#e3a33b' // is there fully there, what isn't fully gone, and keep the anti-aliased edge between.
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif` let x0 = img.width, y0 = img.height, x1 = 0, y1 = 0
g.textAlign = 'center'; g.textBaseline = 'middle' for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
g.fillText('H', size / 2, size / 2 + inner * 0.03) const i = (y * img.width + x) * 4 + 3
// prompt caret if (d[i] >= 240) d[i] = 255; else if (d[i] < 8) d[i] = 0
g.fillStyle = '#58a6ff' if (d[i] > 128) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y }
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')
} }
writeFileSync('public/icons/icon-192.png', draw(192, {})) sg.putImageData(px, 0, 0)
writeFileSync('public/icons/icon-512.png', draw(512, {})) // a square around the artwork's own centre, as wide as its longer side: never stretched
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 })) const side = Math.max(x1 - x0, y1 - y0) + 1, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {})) const art = createCanvas(side, side)
console.log('icons written') 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`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+1 -1
View File
@@ -17,7 +17,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
return ( return (
<div className="center"> <div className="center">
<form className="login" onSubmit={submit}> <form className="login" onSubmit={submit}>
<img className="login-logo" src="/icons/logo.png" alt="" width={160} height={160} /> <img className="login-logo" src="/icons/logo.png" alt="" width={176} height={176} />
<h1>Ham Lab</h1> <h1>Ham Lab</h1>
<input type="password" autoFocus autoComplete="current-password" placeholder="password" <input type="password" autoFocus autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} /> value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
+3 -1
View File
@@ -16,7 +16,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* login */ /* login */
.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); } .login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); }
.login-logo { width: 160px; height: 160px; align-self: center; border-radius: 50%; margin-bottom: 4px; } /* the logo carries the wordmark now; the heading stays for screen readers and is not drawn twice */
.login h1 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.login-logo { width: 176px; height: 176px; align-self: center; margin-bottom: 4px; } /* the artwork brings its own shape: no circular crop */
.login h1 { text-align: center; margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; } .login h1 { text-align: center; margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } .login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; } .login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }