Skip to content

fix(pwa): give maskable icons a real background and safe zone - #239

Merged
ralyodio merged 1 commit into
masterfrom
worktree-maskable-icons
Aug 11, 2026
Merged

fix(pwa): give maskable icons a real background and safe zone#239
ralyodio merged 1 commit into
masterfrom
worktree-maskable-icons

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Follow-up to #236. The manifest declared purpose: "maskable" on the same full-bleed, transparent PNGs it used for purpose: "any" — but those are different contracts. Android applies its own mask (circle, squircle, teardrop) and only guarantees the central circle of 80% diameter survives.

What was wrong

Measured against the committed android-chrome-512x512.png:

measured budget
farthest artwork pixel from centre 0.527 × width 0.400 × width
logo pixels outside the safe zone 4.3% 0
background fully transparent opaque

So the swoosh tips were croppable, and the launcher drew the logo onto bare wallpaper rather than a solid tile.

The fix

A dedicated maskable family — icon-maskable-192x192.png and icon-maskable-512x512.png — rather than re-labelling the existing icons. The generator trims the artwork to its opaque bounds, scales it so its farthest opaque pixel lands inside the safe circle, centres it, and flattens onto the manifest's own background_color (#ffffff) so the icon and the splash screen it launches into share a background.

Two details worth calling out:

  • Trimming also re-centres the glyph. It sits off-centre in favicon.svg's viewBox — 82px of padding on the left against 50px on the right — which a plain resize preserves and the mask then crops unevenly.
  • Scaling by farthest-pixel radius, not by the bounding box. The glyph is irregular, so its bounding-box corners are empty; the box rule would shrink it noticeably more than the mask actually requires.

The any, favicon and Windows-tile families keep their transparency — that is correct for those. Only the maskable pair is flattened.

Verification

Five new tests assert the two properties against the decoded pixels, not the manifest wiring: fully opaque, and all artwork inside 0.4 × width of centre. Pointing the manifest back at the android-chrome files fails all five, so they aren't vacuous.

Full suite: 80 files, 480 tests green.

No existing icon binary is touched — tooling, manifest and two new files only.

Not included

apple-touch-icon-*.png are also transparent (alpha min = 0), and iOS composites those onto black rather than supplying a background. Same class of bug, but fixing it changes how the icon looks on the iOS home screen, so it's left as a separate call.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

96 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 17 | LOW: 76

Severity Rule Location
HIGH secret-generic-credential scripts/supabase-email.sh:21
HIGH secret-generic-credential scripts/supabase-twilio.sh:32
HIGH secret-generic-credential scripts/supabase-twilio.sh:47
MEDIUM manifest-install-lifecycle-script package.json:33
MEDIUM js-shell-exec-interpolation scripts/convert-routes.mjs:9
MEDIUM sql-template-interpolation scripts/fix-constraint-violation.js:74
MEDIUM sql-template-interpolation scripts/fix-constraint-violation.js:108
MEDIUM sql-template-interpolation scripts/fix-constraint-violation.js:153
MEDIUM sql-template-interpolation src/app/api/conversations/delete/route.js:22
MEDIUM sql-template-interpolation src/app/api/keys/reset/route.js:49
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
MEDIUM sql-template-interpolation src/app/api/user/nuclear-delete/route.js:140
MEDIUM sql-template-interpolation src/app/api/user/nuclear-delete/route.js:174
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:138
MEDIUM js-unescaped-html-sink src/app/layout.jsx:142
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:57
LOW secret-generic-credential src/app/api/auth/invite-anon/route.test.js:24
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/profile/update/route.test.js:63
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:47
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:64
LOW secret-generic-credential tests/auth-key-generation.test.js:109
LOW secret-generic-credential tests/auth-key-generation.test.js:110
LOW secret-generic-credential tests/auth-key-generation.test.js:157
LOW secret-generic-credential tests/auth-key-generation.test.js:158
LOW secret-generic-credential tests/auth-key-generation.test.js:207
LOW secret-generic-credential tests/auth-key-generation.test.js:208
LOW secret-generic-credential tests/auth-key-generation.test.js:250
LOW secret-generic-credential tests/auth-key-generation.test.js:251
LOW secret-generic-credential tests/auth-key-generation.test.js:293
LOW secret-generic-credential tests/auth-key-generation.test.js:294
LOW secret-generic-credential tests/auth-key-generation.test.js:340
LOW secret-generic-credential tests/auth-key-generation.test.js:341
LOW secret-generic-credential tests/auth-key-generation.test.js:381
LOW secret-generic-credential tests/auth-key-generation.test.js:382
LOW secret-generic-credential tests/chat-archive.test.js:22
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/gpg-private-key-export.test.js:63
LOW secret-generic-credential tests/gpg-private-key-export.test.js:64
LOW secret-generic-credential tests/nuclear-delete.test.js:19
LOW secret-generic-credential tests/nuclear-delete.test.js:89
LOW secret-generic-credential tests/nuclear-delete.test.js:97
LOW secret-generic-credential tests/password-security-fix-verification.test.js:51
LOW secret-generic-credential tests/password-security-fix-verification.test.js:75

…and 46 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 48d3688 into master Aug 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant