Skip to content

Commit 5f63e28

Browse files
ralyodioclaude
andcommitted
fix(pwa): give maskable icons a real background and safe zone
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 <noreply@anthropic.com>
1 parent 5f9daca commit 5f63e28

5 files changed

Lines changed: 174 additions & 12 deletions

File tree

2.22 KB
Loading
6.32 KB
Loading

public/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"purpose": "any"
4747
},
4848
{
49-
"src": "/icons/android-chrome-192x192.png",
49+
"src": "/icons/icon-maskable-192x192.png",
5050
"sizes": "192x192",
5151
"type": "image/png",
5252
"purpose": "maskable"
@@ -68,7 +68,7 @@
6868
"purpose": "any"
6969
},
7070
{
71-
"src": "/icons/android-chrome-512x512.png",
71+
"src": "/icons/icon-maskable-512x512.png",
7272
"sizes": "512x512",
7373
"type": "image/png",
7474
"purpose": "maskable"

scripts/generate-icons.js

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,86 @@ const ICON_SIZES = [
6363
const SVG_PATH = './public/favicon.svg';
6464
const ICONS_DIR = './public/icons';
6565

66+
// Maskable icons are a separate family from the ones above, not a re-label of
67+
// them. Android applies a platform mask (circle, squircle, teardrop...) and
68+
// guarantees only the central circle of 80% diameter survives, so a maskable
69+
// icon needs two things the `any` icons must NOT have: an opaque background,
70+
// and the artwork pulled inside that safe circle. The manifest used to point
71+
// both purposes at the same full-bleed transparent file, which meant Android
72+
// cropped 4.3% of the logo and showed the mask through the transparent pixels.
73+
const MASKABLE_SIZES = [192, 512];
74+
75+
// The spec's safe circle has radius 0.40 of the icon width. Targeting 0.39
76+
// leaves a hair of slack: scaling to exactly 0.40 lands antialiased edge
77+
// pixels a fraction over the line once the artwork is downscaled to integer
78+
// dimensions, which the generated-icon test then flags.
79+
const SAFE_ZONE_RATIO = 0.39;
80+
81+
// Matches manifest.json's `background_color`, so the installed icon and the
82+
// splash screen it launches into share a background.
83+
const MASKABLE_BACKGROUND = { r: 255, g: 255, b: 255, alpha: 1 };
84+
85+
/**
86+
* Distance from the centre of `buffer` to its farthest non-transparent pixel.
87+
*
88+
* Scaling by this rather than by the bounding box matters because the artwork
89+
* is an irregular glyph: its bounding-box corners are empty, so the box-based
90+
* rule would shrink it further than the mask actually requires.
91+
*/
92+
async function contentRadius(buffer) {
93+
const { data, info } = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
94+
const { width, height, channels } = info;
95+
const cx = width / 2;
96+
const cy = height / 2;
97+
let maxRadius = 0;
98+
99+
for (let y = 0; y < height; y++) {
100+
for (let x = 0; x < width; x++) {
101+
if (data[(y * width + x) * channels + 3] < 16) continue;
102+
const radius = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);
103+
if (radius > maxRadius) maxRadius = radius;
104+
}
105+
}
106+
107+
return maxRadius;
108+
}
109+
110+
/**
111+
* Render one maskable icon: trim the artwork to its opaque bounds, scale it so
112+
* nothing escapes the safe circle, centre it, and flatten onto a solid colour.
113+
*
114+
* Trimming also re-centres the glyph — it sits off-centre in favicon.svg's
115+
* viewBox (82px of padding on the left, 50px on the right), which a plain
116+
* resize preserves and the mask then crops unevenly.
117+
*/
118+
async function generateMaskableIcon(svgBuffer, size) {
119+
// Render at 2x so the trim finds precise edges before anything is downscaled.
120+
const rendered = await sharp(svgBuffer, { density: 600 })
121+
.resize(size * 2, size * 2, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
122+
.png()
123+
.toBuffer();
124+
125+
const trimmed = await sharp(rendered).trim({ threshold: 1 }).png().toBuffer();
126+
const { width, height } = await sharp(trimmed).metadata();
127+
const scale = (SAFE_ZONE_RATIO * size) / (await contentRadius(trimmed));
128+
const artWidth = Math.max(1, Math.round(width * scale));
129+
const artHeight = Math.max(1, Math.round(height * scale));
130+
131+
const art = await sharp(trimmed).resize(artWidth, artHeight, { fit: 'fill' }).png().toBuffer();
132+
133+
return sharp({
134+
create: { width: size, height: size, channels: 4, background: MASKABLE_BACKGROUND }
135+
})
136+
.composite([{
137+
input: art,
138+
left: Math.round((size - artWidth) / 2),
139+
top: Math.round((size - artHeight) / 2)
140+
}])
141+
.flatten({ background: MASKABLE_BACKGROUND })
142+
.png({ quality: 95, compressionLevel: 9 })
143+
.toBuffer();
144+
}
145+
66146
async function generateIcons() {
67147
try {
68148
console.log('🎨 Generating PNG icons from SVG...');
@@ -81,14 +161,10 @@ async function generateIcons() {
81161
const w = width ?? size;
82162
const h = height ?? size;
83163

84-
// `background` only paints the letterbox `fit: 'contain'` adds, and
85-
// favicon.svg is square, so for every square icon here it paints nothing.
86-
// The old `needsSolidBackground` branch that set an opaque background for
87-
// PWA icons was therefore a no-op — the committed icons have always had
88-
// transparent pixels. Giving the manifest's maskable 192/512 icons a
89-
// genuinely opaque background needs `.flatten()` plus safe-zone padding,
90-
// which changes how the installed icon looks; that's a deliberate design
91-
// change rather than something to fold into the generator silently.
164+
// These stay transparent on purpose. `background` only paints the
165+
// letterbox `fit: 'contain'` adds, and favicon.svg is square, so it
166+
// paints nothing here — which is right for `purpose: "any"`, favicons
167+
// and Windows tiles. Only the maskable family below is flattened.
92168
await sharp(svgBuffer)
93169
.resize(w, h, {
94170
fit: 'contain',
@@ -103,10 +179,17 @@ async function generateIcons() {
103179
console.log(`✅ Generated ${name} (${w}x${h})`);
104180
}
105181

182+
for (const size of MASKABLE_SIZES) {
183+
const name = `icon-maskable-${size}x${size}.png`;
184+
const buffer = await generateMaskableIcon(svgBuffer, size);
185+
await fs.writeFile(path.join(ICONS_DIR, name), buffer);
186+
console.log(`✅ Generated ${name} (${size}x${size}, opaque, safe-zone padded)`);
187+
}
188+
106189
// favicon-16x16 / favicon-32x32 are part of ICON_SIZES above now; the old
107190
// extra pass wrote them to static/favicon-{16,32}.png, a path nothing reads.
108191
console.log('\n🎉 Icon generation complete!');
109-
console.log(`📁 Generated ${ICON_SIZES.length} PNG icons in ${ICONS_DIR}/`);
192+
console.log(`📁 Generated ${ICON_SIZES.length + MASKABLE_SIZES.length} PNG icons in ${ICONS_DIR}/`);
110193

111194
} catch (error) {
112195
console.error('❌ Error generating icons:', error);

tests/pwa-installability.test.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { describe, it, expect } from 'vitest';
1212
import { existsSync, readFileSync } from 'node:fs';
1313
import { resolve } from 'node:path';
14+
import sharp from 'sharp';
1415

1516
const ROOT = resolve(process.cwd());
1617
const readText = (p) => readFileSync(resolve(ROOT, p), 'utf8');
@@ -127,7 +128,15 @@ describe('PWA installability', () => {
127128
expect(installer).not.toMatch(/\.\/static\//);
128129
});
129130

130-
const generated = [...generator.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]);
131+
// ICON_SIZES entries carry a literal `name:`; the maskable family is built
132+
// from MASKABLE_SIZES with a templated filename, so collect both.
133+
const maskableSizes = [
134+
...(generator.match(/MASKABLE_SIZES\s*=\s*\[([^\]]*)\]/)?.[1] ?? '').matchAll(/\d+/g)
135+
].map((m) => `icon-maskable-${m[0]}x${m[0]}.png`);
136+
const generated = [
137+
...[...generator.matchAll(/name:\s*'([^']+)'/g)].map((m) => m[1]),
138+
...maskableSizes
139+
];
131140

132141
it('regenerates every icon the manifest points at', () => {
133142
for (const icon of manifest.icons) {
@@ -154,4 +163,74 @@ describe('PWA installability', () => {
154163
}
155164
});
156165
});
166+
167+
// Android masks these to a circle/squircle/teardrop of its choosing and only
168+
// guarantees the central circle of 80% diameter survives. Both properties
169+
// below were violated when the manifest aimed `purpose: "maskable"` at the
170+
// same full-bleed transparent PNGs it used for `purpose: "any"`.
171+
describe('maskable icons', () => {
172+
const manifest = JSON.parse(readText('public/manifest.json'));
173+
const maskable = manifest.icons.filter((icon) =>
174+
String(icon.purpose ?? '').split(/\s+/).includes('maskable')
175+
);
176+
177+
it('does not reuse an `any` icon for `maskable`', () => {
178+
const anySources = new Set(
179+
manifest.icons
180+
.filter((icon) => !String(icon.purpose ?? '').split(/\s+/).includes('maskable'))
181+
.map((icon) => icon.src)
182+
);
183+
184+
expect(maskable.length).toBeGreaterThan(0);
185+
for (const icon of maskable) {
186+
expect(anySources.has(icon.src)).toBe(false);
187+
}
188+
});
189+
190+
it.each(maskable.map((icon) => icon.src))('%s is fully opaque', async (src) => {
191+
const { data, info } = await sharp(resolve(ROOT, 'public', src.replace(/^\//, '')))
192+
.ensureAlpha()
193+
.raw()
194+
.toBuffer({ resolveWithObject: true });
195+
196+
let minAlpha = 255;
197+
for (let i = 3; i < data.length; i += info.channels) {
198+
if (data[i] < minAlpha) minAlpha = data[i];
199+
}
200+
201+
// A transparent maskable icon lets the platform mask show through, so the
202+
// launcher draws the logo over bare wallpaper instead of a solid tile.
203+
expect(minAlpha).toBe(255);
204+
});
205+
206+
it.each(maskable.map((icon) => icon.src))('%s keeps its artwork inside the safe zone', async (src) => {
207+
const { data, info } = await sharp(resolve(ROOT, 'public', src.replace(/^\//, '')))
208+
.ensureAlpha()
209+
.raw()
210+
.toBuffer({ resolveWithObject: true });
211+
const { width, height, channels } = info;
212+
213+
// Corner pixel is background by construction; anything differing from it
214+
// is artwork that the mask could clip.
215+
const bg = [data[0], data[1], data[2]];
216+
const cx = width / 2;
217+
const cy = height / 2;
218+
const safeRadius = 0.4 * width;
219+
let maxRadius = 0;
220+
221+
for (let y = 0; y < height; y++) {
222+
for (let x = 0; x < width; x++) {
223+
const i = (y * width + x) * channels;
224+
const delta =
225+
Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]);
226+
if (delta < 24) continue;
227+
const radius = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);
228+
if (radius > maxRadius) maxRadius = radius;
229+
}
230+
}
231+
232+
expect(maxRadius).toBeGreaterThan(0);
233+
expect(maxRadius).toBeLessThanOrEqual(safeRadius);
234+
});
235+
});
157236
});

0 commit comments

Comments
 (0)