From 5f63e28de7d8ed4b30967a2040aaeb4753db76cc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 11 Aug 2026 03:25:39 +0000 Subject: [PATCH] fix(pwa): give maskable icons a real background and safe zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest pointed `purpose: "maskable"` at the same full-bleed, transparent PNGs it used for `purpose: "any"`. Those are different contracts: Android applies its own mask (circle, squircle, teardrop) and only guarantees the central circle of 80% diameter survives. Two consequences, both measured against the committed 512px icon: - 4.3% of the logo's opaque pixels sit outside the safe zone, so the swoosh tips get clipped. Content reached 0.527 of the icon width from centre against a 0.400 budget. - Every pixel outside the glyph is transparent, so the launcher draws the logo straight onto the wallpaper instead of a solid tile. Generate a dedicated maskable family instead. The artwork is trimmed to its opaque bounds (which also re-centres it — it sits 82px from the left of favicon.svg's viewBox and 50px from the right), scaled so its farthest opaque pixel lands inside the safe circle, then flattened onto the manifest's own background_color so icon and splash screen agree. Scaling by farthest-pixel radius rather than by the bounding box matters: the glyph is irregular, so its bounding-box corners are empty and the box rule would shrink it more than the mask requires. The `any`, favicon and Windows-tile families keep their transparency — that is correct for those, and only the maskable pair is flattened. Tests assert the two properties on the actual pixels, not just the manifest wiring; all five fail if the manifest is pointed back at the android-chrome files. Co-Authored-By: Claude Opus 5 --- public/icons/icon-maskable-192x192.png | Bin 0 -> 2274 bytes public/icons/icon-maskable-512x512.png | Bin 0 -> 6471 bytes public/manifest.json | 4 +- scripts/generate-icons.js | 101 ++++++++++++++++++++++--- tests/pwa-installability.test.js | 81 +++++++++++++++++++- 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 public/icons/icon-maskable-192x192.png create mode 100644 public/icons/icon-maskable-512x512.png diff --git a/public/icons/icon-maskable-192x192.png b/public/icons/icon-maskable-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..ff504fc7148c2f083eb6ce7896da5587f0a70424 GIT binary patch literal 2274 zcma);`9ISS0LDLawP9aG&We$gqe$*+mTPVrxyn^z5>t|M&5_$2GpjaZY$uU~ z3<&7G>DFBM-w9s7;=v39L^QaLn(UZ5*h8U<5G}y^sSnxk9yB9m#1I1#C96S-xSuad zTn0sHB6X(kHV}E6;5;8+hn)MKE=&@C~u@`gLtI`BoCW+rF9 zj5V8)(d;Y$pPu$tf+Y>==VM$uEEAIDX4AeU+_ViqLgk`cVcFA4+Ua=rv&o${wS?FV z?+OpY-Q44ui2&z#t{^Q8dR6BMXPLj6>RgFmldUAP30#EzQ#Uc=V_m7X2X!@{4U?pz zARvLx18AnT{gO+liCJY%&lq07t9!r-pp1NlNLlO84Xxsz6+(?PeW8^z(!^UJ_Qhj4 zM;_-L3|1qWTn$%Fq0k17yK}FiN`&QtU$aZ%XZQ8w6VLvBUiG^LH`);|EivZ{?%~tP z6^5f-lyNuo#<`-kBwyy97E9&L56bKO)^G{!LP5fmtN*m%>V}<&PI)QiFtvJUQ}lz zubf__!`>7+Dr_I;X0_xRaE7av>`Y1T(*efEgVeH*PNW*?$V|vc7?n|Pm*9PwctW5o z3#+!rJlD8scM0)wl*fxkAv4EOEDrH{HscA#lzMU7JpR{0Lc*ZgU3j5~h6upKt?u3! zMcN_|3fL2pcI<92z&`SvOTm1D!_@-&I4o7XF-tJ`n>YN|SRB05Hjk562Z&BslC^%o(m!pDlWewmsM5ix zn-@eCOyp=l^1;|*R*l({*)da76;M`y%jeaqL7NPuzEk~C+jm+wVy?TREV|no3n63t zVi4?((ugyoZ1?uTfta5@_zm!QBy zb_$t=UyEPdWNfO(C<3-?`=UeNLNmanC1K zwSO;tTX!D=3G543vmr?1F~T(u@}E*jj4*M_o!%9pok-R?ormdxdtSK3aJRVt_6eQ+ z*%W@Kj~!EFj^Yu>fM%MSP+r`G47;qVz?AzG(&_G((25I92~rn(uMfgw9*BMz>g$pU%QsEtp}4r7?E1(P8`3A|bJT1{ocW}L4T4)hVN@Se z0z>K5H-5U0jEKN}MtiH^_d9kUG_=b<;j)g3B)#Lks7%p}V*3|y(}o`U9IJYpDQIJA zlL*SXlyK77ab0@xH0cYQ?#99JgN3vb^fJ-h+d3!L-NKp2jV#R@CQaA{hph=K#kk8K zTn>zGkyX(qt%BF0Et0ishBAzNXs!!;l-1w9)t*Gn7H};ymr9 zd$oT2ZdSFayX#doD^x750$l3BRh4KVH}h6K2aK}AXBF<06}-jZaK-d5>Uu9-t0{Ng zq@wrAWz`;5eAX;>7RWp7Du(dfIN^qv5kHV1Z|=<>GgkLwJ@b4!q;ULa4(=NhslIV0 zCdd;!JvGgLvTB4f96dm4-%##=>nZA`*_V_ai?{>EFU->o^lJe4FA+BQi?}S?Vuyd%)>NF|kDKP0?9)%H*1dxrT=6qv{-}xyV8(&#dMA1bZu_26_fSU zGApzE`s-!G3xoHQ;!gZfADM}177aN4w$)KKV_lOS4zT@CQLI3R$9Q>qj-*=F9R7X} N0BLAxP>*nn`WJI)FZTcd literal 0 HcmV?d00001 diff --git a/public/icons/icon-maskable-512x512.png b/public/icons/icon-maskable-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..b695b63323d4939e4fecd0c7069d4efc72cea069 GIT binary patch literal 6471 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 CME%eJ literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json index d2cf57a9..1eb3d6b9 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -46,7 +46,7 @@ "purpose": "any" }, { - "src": "/icons/android-chrome-192x192.png", + "src": "/icons/icon-maskable-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" @@ -68,7 +68,7 @@ "purpose": "any" }, { - "src": "/icons/android-chrome-512x512.png", + "src": "/icons/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js index 918ae330..50ba33ed 100644 --- a/scripts/generate-icons.js +++ b/scripts/generate-icons.js @@ -63,6 +63,86 @@ const ICON_SIZES = [ const SVG_PATH = './public/favicon.svg'; const ICONS_DIR = './public/icons'; +// Maskable icons are a separate family from the ones above, not a re-label of +// them. Android applies a platform mask (circle, squircle, teardrop...) and +// guarantees only the central circle of 80% diameter survives, so a maskable +// icon needs two things the `any` icons must NOT have: an opaque background, +// and the artwork pulled inside that safe circle. The manifest used to point +// both purposes at the same full-bleed transparent file, which meant Android +// cropped 4.3% of the logo and showed the mask through the transparent pixels. +const MASKABLE_SIZES = [192, 512]; + +// The spec's safe circle has radius 0.40 of the icon width. Targeting 0.39 +// leaves a hair of slack: scaling to exactly 0.40 lands antialiased edge +// pixels a fraction over the line once the artwork is downscaled to integer +// dimensions, which the generated-icon test then flags. +const SAFE_ZONE_RATIO = 0.39; + +// Matches manifest.json's `background_color`, so the installed icon and the +// splash screen it launches into share a background. +const MASKABLE_BACKGROUND = { r: 255, g: 255, b: 255, alpha: 1 }; + +/** + * Distance from the centre of `buffer` to its farthest non-transparent pixel. + * + * Scaling by this rather than by the bounding box matters because the artwork + * is an irregular glyph: its bounding-box corners are empty, so the box-based + * rule would shrink it further than the mask actually requires. + */ +async function contentRadius(buffer) { + const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const { width, height, channels } = info; + const cx = width / 2; + const cy = height / 2; + let maxRadius = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * channels + 3] < 16) continue; + const radius = Math.hypot(x + 0.5 - cx, y + 0.5 - cy); + if (radius > maxRadius) maxRadius = radius; + } + } + + return maxRadius; +} + +/** + * Render one maskable icon: trim the artwork to its opaque bounds, scale it so + * nothing escapes the safe circle, centre it, and flatten onto a solid colour. + * + * Trimming also re-centres the glyph — it sits off-centre in favicon.svg's + * viewBox (82px of padding on the left, 50px on the right), which a plain + * resize preserves and the mask then crops unevenly. + */ +async function generateMaskableIcon(svgBuffer, size) { + // Render at 2x so the trim finds precise edges before anything is downscaled. + const rendered = await sharp(svgBuffer, { density: 600 }) + .resize(size * 2, size * 2, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer(); + + const trimmed = await sharp(rendered).trim({ threshold: 1 }).png().toBuffer(); + const { width, height } = await sharp(trimmed).metadata(); + const scale = (SAFE_ZONE_RATIO * size) / (await contentRadius(trimmed)); + const artWidth = Math.max(1, Math.round(width * scale)); + const artHeight = Math.max(1, Math.round(height * scale)); + + const art = await sharp(trimmed).resize(artWidth, artHeight, { fit: 'fill' }).png().toBuffer(); + + return sharp({ + create: { width: size, height: size, channels: 4, background: MASKABLE_BACKGROUND } + }) + .composite([{ + input: art, + left: Math.round((size - artWidth) / 2), + top: Math.round((size - artHeight) / 2) + }]) + .flatten({ background: MASKABLE_BACKGROUND }) + .png({ quality: 95, compressionLevel: 9 }) + .toBuffer(); +} + async function generateIcons() { try { console.log('šŸŽØ Generating PNG icons from SVG...'); @@ -81,14 +161,10 @@ async function generateIcons() { const w = width ?? size; const h = height ?? size; - // `background` only paints the letterbox `fit: 'contain'` adds, and - // favicon.svg is square, so for every square icon here it paints nothing. - // The old `needsSolidBackground` branch that set an opaque background for - // PWA icons was therefore a no-op — the committed icons have always had - // transparent pixels. Giving the manifest's maskable 192/512 icons a - // genuinely opaque background needs `.flatten()` plus safe-zone padding, - // which changes how the installed icon looks; that's a deliberate design - // change rather than something to fold into the generator silently. + // These stay transparent on purpose. `background` only paints the + // letterbox `fit: 'contain'` adds, and favicon.svg is square, so it + // paints nothing here — which is right for `purpose: "any"`, favicons + // and Windows tiles. Only the maskable family below is flattened. await sharp(svgBuffer) .resize(w, h, { fit: 'contain', @@ -103,10 +179,17 @@ async function generateIcons() { console.log(`āœ… Generated ${name} (${w}x${h})`); } + for (const size of MASKABLE_SIZES) { + const name = `icon-maskable-${size}x${size}.png`; + const buffer = await generateMaskableIcon(svgBuffer, size); + await fs.writeFile(path.join(ICONS_DIR, name), buffer); + console.log(`āœ… Generated ${name} (${size}x${size}, opaque, safe-zone padded)`); + } + // favicon-16x16 / favicon-32x32 are part of ICON_SIZES above now; the old // extra pass wrote them to static/favicon-{16,32}.png, a path nothing reads. console.log('\nšŸŽ‰ Icon generation complete!'); - console.log(`šŸ“ Generated ${ICON_SIZES.length} PNG icons in ${ICONS_DIR}/`); + console.log(`šŸ“ Generated ${ICON_SIZES.length + MASKABLE_SIZES.length} PNG icons in ${ICONS_DIR}/`); } catch (error) { console.error('āŒ Error generating icons:', error); diff --git a/tests/pwa-installability.test.js b/tests/pwa-installability.test.js index de6dc23a..005e78de 100644 --- a/tests/pwa-installability.test.js +++ b/tests/pwa-installability.test.js @@ -11,6 +11,7 @@ import { describe, it, expect } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import sharp from 'sharp'; const ROOT = resolve(process.cwd()); const readText = (p) => readFileSync(resolve(ROOT, p), 'utf8'); @@ -127,7 +128,15 @@ describe('PWA installability', () => { expect(installer).not.toMatch(/\.\/static\//); }); - const generated = [...generator.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]); + // ICON_SIZES entries carry a literal `name:`; the maskable family is built + // from MASKABLE_SIZES with a templated filename, so collect both. + const maskableSizes = [ + ...(generator.match(/MASKABLE_SIZES\s*=\s*\[([^\]]*)\]/)?.[1] ?? '').matchAll(/\d+/g) + ].map((m) => `icon-maskable-${m[0]}x${m[0]}.png`); + const generated = [ + ...[...generator.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]), + ...maskableSizes + ]; it('regenerates every icon the manifest points at', () => { for (const icon of manifest.icons) { @@ -154,4 +163,74 @@ describe('PWA installability', () => { } }); }); + + // Android masks these to a circle/squircle/teardrop of its choosing and only + // guarantees the central circle of 80% diameter survives. Both properties + // below were violated when the manifest aimed `purpose: "maskable"` at the + // same full-bleed transparent PNGs it used for `purpose: "any"`. + describe('maskable icons', () => { + const manifest = JSON.parse(readText('public/manifest.json')); + const maskable = manifest.icons.filter((icon) => + String(icon.purpose ?? '').split(/\s+/).includes('maskable') + ); + + it('does not reuse an `any` icon for `maskable`', () => { + const anySources = new Set( + manifest.icons + .filter((icon) => !String(icon.purpose ?? '').split(/\s+/).includes('maskable')) + .map((icon) => icon.src) + ); + + expect(maskable.length).toBeGreaterThan(0); + for (const icon of maskable) { + expect(anySources.has(icon.src)).toBe(false); + } + }); + + it.each(maskable.map((icon) => icon.src))('%s is fully opaque', async (src) => { + const { data, info } = await sharp(resolve(ROOT, 'public', src.replace(/^\//, ''))) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + let minAlpha = 255; + for (let i = 3; i < data.length; i += info.channels) { + if (data[i] < minAlpha) minAlpha = data[i]; + } + + // A transparent maskable icon lets the platform mask show through, so the + // launcher draws the logo over bare wallpaper instead of a solid tile. + expect(minAlpha).toBe(255); + }); + + it.each(maskable.map((icon) => icon.src))('%s keeps its artwork inside the safe zone', async (src) => { + const { data, info } = await sharp(resolve(ROOT, 'public', src.replace(/^\//, ''))) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + const { width, height, channels } = info; + + // Corner pixel is background by construction; anything differing from it + // is artwork that the mask could clip. + const bg = [data[0], data[1], data[2]]; + const cx = width / 2; + const cy = height / 2; + const safeRadius = 0.4 * width; + let maxRadius = 0; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * channels; + const delta = + Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]); + if (delta < 24) continue; + const radius = Math.hypot(x + 0.5 - cx, y + 0.5 - cy); + if (radius > maxRadius) maxRadius = radius; + } + } + + expect(maxRadius).toBeGreaterThan(0); + expect(maxRadius).toBeLessThanOrEqual(safeRadius); + }); + }); });