Skip to content

Commit bda7e70

Browse files
Kjubikstronkclaude
andcommitted
Redraw the app icon so it survives a home screen
The icon was a flat hot-pink heart filling an ink square. One colour, no depth, and at tab size it read as a smudge. It now gets the treatment the rest of the app already gives that glyph: a lavender ground, the ink halo PixelHeart draws for map pins, a hard `deep` offset shadow with no blur, and an aqua and a white sparkle. Every colour is a palette token; nothing is anti-aliased. Three variants rather than one, because phones do not show a square: - The favicon keeps a hard ink frame. The browser tab is the only place the icon stays square, so it is the only place a frame survives. - The installed icon is full bleed with the sparkles out at the corners, clear of the heart's halo. An iOS squircle keeps both. - The maskable icon drops the sparkles entirely. Android crops it to a circle, and a circle sized to this heart leaves no corner that both clears the glyph and stays in the safe zone — better none than two sliced in half. make-icons.mjs now emits favicon.svg too, so all five files come from one grid instead of the SVG being maintained by hand. Every output size is a whole multiple of the 16px grid, which is why apple-touch-icon is 192 rather than the conventional 180 — 180 would round some cells to 11 device pixels and others to 12. Run it with --preview to see the installed icon behind an iOS-style squircle before shipping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 263a0cc commit bda7e70

7 files changed

Lines changed: 180 additions & 62 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ dev-dist
3232

3333
# Filled-in rules with real UIDs — the committed firestore.rules is a template
3434
firestore.rules.local
35+
icon-preview.png

‎public/apple-touch-icon.png‎

128 Bytes
Loading

‎public/favicon.svg‎

Lines changed: 1 addition & 11 deletions
Loading

‎public/icon-192.png‎

92 Bytes
Loading

‎public/icon-512.png‎

717 Bytes
Loading

‎public/icon-maskable-512.png‎

710 Bytes
Loading

‎scripts/make-icons.mjs‎

Lines changed: 178 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
/**
2-
* Generates the PWA / home-screen icons from the same pixel heart the app uses.
2+
* Generates the favicon and the PWA / home-screen icons from the same pixel
3+
* heart the app uses.
34
*
45
* Written by hand rather than pulled from a library because the image is a
5-
* literal pixel grid — nearest-neighbour scaling of a 9x8 bitmap is a few lines
6-
* of arithmetic, and adding a raster dependency to draw eight squares would be
6+
* literal pixel grid — nearest-neighbour scaling of a bitmap is a few lines of
7+
* arithmetic, and adding a raster dependency to draw a few squares would be
78
* silly. iOS needs PNG for apple-touch-icon, so SVG alone wouldn't do.
89
*
10+
* The heart was a flat pink silhouette on ink, which read as a smudge at tab
11+
* size. It now gets the same treatment the rest of the app gives it: a hard
12+
* offset shadow with no blur, the ink halo PixelHeart already draws for map
13+
* pins, and two sparkles. Every colour is a palette token.
14+
*
915
* node scripts/make-icons.mjs
1016
*/
1117
import { deflateSync } from 'node:zlib'
@@ -24,8 +30,91 @@ const HEART = [
2430
'....X....',
2531
]
2632

27-
const INK = [0x1a, 0x10, 0x33]
28-
const HOT = [0xff, 0x5c, 0xa8]
33+
const INK = '#1A1033'
34+
const CARD = '#FFFDFE'
35+
const HOT = '#FF5CA8'
36+
const DEEP = '#B31E67'
37+
const LAV = '#B8A6FF'
38+
const AQUA = '#5BE0E6'
39+
40+
/** Everything is composed on this grid, then scaled up by whole pixels. */
41+
const N = 16
42+
/** Top-left of the 9x8 heart within the grid. */
43+
const HX = 3
44+
const HY = 4
45+
46+
const lit = []
47+
HEART.forEach((row, y) => [...row].forEach((c, x) => c === 'X' && lit.push([x, y])))
48+
const isLit = new Set(lit.map(([x, y]) => `${x},${y}`))
49+
50+
/**
51+
* @param border edge colour, or null for the maskable icon — Android crops
52+
* that one to a circle, so a frame would be sliced off.
53+
* @param sparkles [x, y, colour] centres of five-pixel plus shapes.
54+
*/
55+
function compose({ border, sparkles }) {
56+
const g = Array.from({ length: N }, () => Array(N).fill(LAV))
57+
58+
if (border) {
59+
for (let i = 0; i < N; i++) {
60+
g[0][i] = border
61+
g[N - 1][i] = border
62+
g[i][0] = border
63+
g[i][N - 1] = border
64+
}
65+
}
66+
67+
// Shadow first, one pixel down and right. Hard edge, no blur — the same
68+
// offset the cards and buttons use.
69+
for (const [x, y] of lit) set(g, x + HX + 1, y + HY + 1, DEEP)
70+
71+
// The ink halo, only over untouched background so it never eats the shadow.
72+
for (const [x, y] of lit) {
73+
for (let dy = -1; dy <= 1; dy++) {
74+
for (let dx = -1; dx <= 1; dx++) {
75+
if (isLit.has(`${x + dx},${y + dy}`)) continue
76+
const px = x + dx + HX
77+
const py = y + dy + HY
78+
if (g[py]?.[px] === LAV) g[py][px] = INK
79+
}
80+
}
81+
}
82+
83+
for (const [x, y] of lit) set(g, x + HX, y + HY, HOT)
84+
85+
for (const [cx, cy, colour] of sparkles) {
86+
for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]) {
87+
set(g, cx + dx, cy + dy, colour)
88+
}
89+
}
90+
return g
91+
}
92+
93+
function set(g, x, y, colour) {
94+
if (g[y]?.[x] !== undefined) g[y][x] = colour
95+
}
96+
97+
/**
98+
* The browser tab is the one place the icon stays a square, so that is the one
99+
* place it gets a frame. Every phone rounds or masks its icons, and a hard
100+
* square border under a rounded mask loses its corners and reads as broken.
101+
*/
102+
const FAVICON = compose({ border: INK, sparkles: [[12, 2, AQUA], [3, 12, CARD]] })
103+
104+
/**
105+
* The installed icon: full bleed, no frame. The sparkles sit in the corners,
106+
* clear of the heart and its halo — pulled any further in and they land on
107+
* the glyph itself. An iOS squircle keeps enough corner for both.
108+
*/
109+
const APP = compose({ border: null, sparkles: [[12, 2, AQUA], [3, 12, CARD]] })
110+
111+
/**
112+
* Android crops this one to a circle, and a circle that fits a heart this size
113+
* has no corner left to put a sparkle in — every position that clears the
114+
* glyph falls outside the safe zone. So it goes without, rather than shipping
115+
* an icon with two sparkles sliced in half.
116+
*/
117+
const MASKABLE = compose({ border: null, sparkles: [] })
29118

30119
// ── PNG encoding ────────────────────────────────────────────────────────────
31120

@@ -50,73 +139,111 @@ function chunk(type, data) {
50139
return Buffer.concat([len, body, crc])
51140
}
52141

53-
function png(width, height, rgb) {
54-
const ihdr = Buffer.alloc(13)
55-
ihdr.writeUInt32BE(width, 0)
56-
ihdr.writeUInt32BE(height, 4)
57-
ihdr[8] = 8 // bit depth
58-
ihdr[9] = 2 // colour type: truecolour RGB
59-
return Buffer.concat([
60-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
61-
chunk('IHDR', ihdr),
62-
chunk('IDAT', deflateSync(rgb, { level: 9 })),
63-
chunk('IEND', Buffer.alloc(0)),
64-
])
65-
}
142+
const rgb = (hex) => [
143+
parseInt(hex.slice(1, 3), 16),
144+
parseInt(hex.slice(3, 5), 16),
145+
parseInt(hex.slice(5, 7), 16),
146+
]
66147

67-
// ── Drawing ─────────────────────────────────────────────────────────────────
148+
/** Every size is a whole multiple of the grid, so no cell comes out uneven. */
149+
function png(grid, size) {
150+
const cell = size / N
151+
if (!Number.isInteger(cell)) throw new Error(`${size} is not a multiple of ${N}`)
68152

69-
/**
70-
* @param size output edge length in px
71-
* @param inset fraction of the canvas left as background around the heart.
72-
* Maskable icons get cropped to a circle on Android, so the
73-
* glyph has to sit inside the safe zone.
74-
*/
75-
function render(size, inset) {
76-
const gw = HEART[0].length
77-
const gh = HEART.length
78-
const cell = Math.floor((size * (1 - inset * 2)) / gw)
79-
const drawW = cell * gw
80-
const drawH = cell * gh
81-
const offX = Math.floor((size - drawW) / 2)
82-
const offY = Math.floor((size - drawH) / 2)
83-
84-
// One filter byte (0 = None) per scanline, then RGB triples.
85153
const stride = size * 3 + 1
86154
const raw = Buffer.alloc(stride * size)
87-
88155
for (let y = 0; y < size; y++) {
89156
const rowStart = y * stride
90-
raw[rowStart] = 0
157+
raw[rowStart] = 0 // filter: None
91158
for (let x = 0; x < size; x++) {
92-
const gx = Math.floor((x - offX) / cell)
93-
const gy = Math.floor((y - offY) / cell)
94-
const lit =
95-
gx >= 0 && gx < gw && gy >= 0 && gy < gh && HEART[gy][gx] === 'X'
96-
const [r, g, b] = lit ? HOT : INK
159+
const [r, g, b] = rgb(grid[(y / cell) | 0][(x / cell) | 0])
97160
const p = rowStart + 1 + x * 3
98161
raw[p] = r
99162
raw[p + 1] = g
100163
raw[p + 2] = b
101164
}
102165
}
103166

104-
return png(size, size, raw)
167+
const ihdr = Buffer.alloc(13)
168+
ihdr.writeUInt32BE(size, 0)
169+
ihdr.writeUInt32BE(size, 4)
170+
ihdr[8] = 8 // bit depth
171+
ihdr[9] = 2 // colour type: truecolour RGB
172+
return Buffer.concat([
173+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
174+
chunk('IHDR', ihdr),
175+
chunk('IDAT', deflateSync(raw, { level: 9 })),
176+
chunk('IEND', Buffer.alloc(0)),
177+
])
178+
}
179+
180+
/** Runs of one colour merge into a single rect, which keeps the file small. */
181+
function svg(grid) {
182+
let rects = ''
183+
grid.forEach((row, y) => {
184+
let x = 0
185+
while (x < N) {
186+
let w = 1
187+
while (x + w < N && row[x + w] === row[x]) w++
188+
rects += `<rect x="${x}" y="${y}" width="${w}" height="1" fill="${row[x]}"/>`
189+
x += w
190+
}
191+
})
192+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${N} ${N}" shape-rendering="crispEdges">${rects}</svg>\n`
105193
}
106194

107195
const outDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public')
108196
mkdirSync(outDir, { recursive: true })
109197

110198
const targets = [
111-
['icon-192.png', 192, 0.16],
112-
['icon-512.png', 512, 0.16],
113-
// Android maskable icons are cropped hard; give the heart more room.
114-
['icon-maskable-512.png', 512, 0.26],
115-
// iOS does not round-crop, so it can be tighter.
116-
['apple-touch-icon.png', 180, 0.14],
199+
['icon-192.png', APP, 192],
200+
['icon-512.png', APP, 512],
201+
['icon-maskable-512.png', MASKABLE, 512],
202+
// 192 rather than the conventional 180: 180 is not a whole multiple of the
203+
// 16px grid, and iOS scales this happily either way.
204+
['apple-touch-icon.png', APP, 192],
117205
]
118206

119-
for (const [name, size, inset] of targets) {
120-
writeFileSync(join(outDir, name), render(size, inset))
207+
for (const [name, grid, size] of targets) {
208+
writeFileSync(join(outDir, name), png(grid, size))
121209
console.log(`wrote public/${name} (${size}x${size})`)
122210
}
211+
writeFileSync(join(outDir, 'favicon.svg'), svg(FAVICON))
212+
console.log('wrote public/favicon.svg')
213+
214+
// Not shipped — a look at the installed icon behind an iOS-style squircle, so
215+
// the corner treatment can be judged before it reaches a home screen.
216+
if (process.argv.includes('--preview')) {
217+
const size = 512
218+
const grid = APP
219+
const cell = size / N
220+
const stride = size * 3 + 1
221+
const raw = Buffer.alloc(stride * size)
222+
const r0 = size / 2
223+
for (let y = 0; y < size; y++) {
224+
raw[y * stride] = 0
225+
for (let x = 0; x < size; x++) {
226+
// Superellipse, the shape iOS actually uses.
227+
const dx = Math.abs(x - r0 + 0.5) / r0
228+
const dy = Math.abs(y - r0 + 0.5) / r0
229+
const inside = Math.pow(dx, 5) + Math.pow(dy, 5) <= 1
230+
const [r, g, b] = inside ? rgb(grid[(y / cell) | 0][(x / cell) | 0]) : [0x22, 0x22, 0x22]
231+
const p = y * stride + 1 + x * 3
232+
raw[p] = r
233+
raw[p + 1] = g
234+
raw[p + 2] = b
235+
}
236+
}
237+
const ihdr = Buffer.alloc(13)
238+
ihdr.writeUInt32BE(size, 0)
239+
ihdr.writeUInt32BE(size, 4)
240+
ihdr[8] = 8
241+
ihdr[9] = 2
242+
writeFileSync(join(outDir, '..', 'icon-preview.png'), Buffer.concat([
243+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
244+
chunk('IHDR', ihdr),
245+
chunk('IDAT', deflateSync(raw, { level: 9 })),
246+
chunk('IEND', Buffer.alloc(0)),
247+
]))
248+
console.log('wrote icon-preview.png (squircle preview, not shipped)')
249+
}

0 commit comments

Comments
 (0)