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>
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 310 KiB |
@@ -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()));
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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} />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||