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 */
1117import { 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
107195const outDir = join ( dirname ( fileURLToPath ( import . meta. url ) ) , '..' , 'public' )
108196mkdirSync ( outDir , { recursive : true } )
109197
110198const 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