Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/icons/icon-maskable-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-maskable-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
101 changes: 92 additions & 9 deletions scripts/generate-icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -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...');
Expand All @@ -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',
Expand All @@ -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);
Expand Down
81 changes: 80 additions & 1 deletion tests/pwa-installability.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
});
});
});
Loading