← reports

#3198 · Android PWA icon clipping was not reproduced from shipped assets

Bug Low Effort: Low mobile open on GitHub 2026-09-06 · base 06aeaa994

Verdict: NOT REPRODUCED · Root-cause confidence: low

1. TL;DR

The report describes an Android first-launch icon that differs from the preceding install UI. Trusted main ships separate any and maskable icons, but their bb glyphs have identical pixel bounds and scale. Across the default manifest and all eight color variants, the farthest glyph pixel is 158.78 px from center while the guaranteed 512 px maskable safe-zone radius is 204.8 px, leaving zero glyph pixels eligible for conforming masks to clip. A second clean checkout produced the same result. No Android emulator or physical device was available, so the device-specific first-launch behavior remains unverified and no repository root cause is established.

2. Claims vs findings

ClaimStatusEvidence
The first launched home-screen app renders a visibly altered icon.UnverifiedNo Android device, browser version, or runnable device capture was available in the isolated environment.
The shipped launch-capable icon crops the bb glyph under an Android-style mask.Not reproducedAll nine 512 px maskable assets had zero glyph pixels outside the 204.8 px guaranteed safe radius.
The install and maskable variants use different bb glyph scaling.Refuted in assetsEvery corresponding any/maskable pair had identical glyph bounds: x=113–398 and y=135–376.
The reported 0.41.0 asset set differs from trusted main in the relevant files.RefutedThe base manifest, 512 px any icon, and 512 px maskable icon have identical SHA-256 hashes at desktop-v0.41.0 and trusted main.
The behavior is specific to the first launch.UnverifiedThe repository assets are static across launches; confirming a first-launch-only path requires an Android/WebAPK reproduction.

3. Environment

4. Minimal reproduction

  1. Check out 06aeaa994942ae7527dc49d2268c1f801e8542a0.
  2. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Save the focused test shown below as apps/app/scripts/pwa-icon-mask-repro.test.mjs.
  4. Run cd apps/app && node scripts/pwa-icon-mask-repro.test.mjs.

Expected if the repository assets cause clipping: at least one manifest changes glyph scale between any and maskable, leaves a transparent maskable canvas, or places glyph pixels beyond the 40% safe radius.

Actual:

Manifests checked: 9
Glyph bounds in every 512px any/maskable pair: [113, 135, 398, 376]
Maximum glyph radius: 158.78px
Guaranteed safe-zone radius: 204.8px
Glyph pixels outside safe zone: 0
Exit: 0
The shipped any-purpose icon beside the shipped maskable icon clipped to a circle; both show the complete bb glyph at the same scale
Repository-asset comparison, not a device screenshot. Left: the shipped any icon. Right: the shipped full-bleed maskable icon under a circular mask. The mask changes the outer tile shape but does not resize or crop the bb glyph.
Focused test source
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import sharp from "sharp";

const publicDir = new URL("../public/", import.meta.url);
const manifestNames = (await readdir(publicDir))
  .filter((name) => /^manifest(?:-[a-z]+)?\.webmanifest$/u.test(name))
  .sort();

function iconByPurpose(manifest, purpose) {
  return manifest.icons.find(
    (icon) => icon.purpose === purpose && icon.sizes === "512x512",
  );
}

async function measure(source) {
  const { data, info } = await sharp(fileURLToPath(source))
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true });
  const result = {
    bounds: [info.width, info.height, -1, -1],
    maxRadius: 0,
    opaquePixels: 0,
    outsideSafeZone: 0,
  };
  const center = info.width / 2;
  const safeRadius = info.width * 0.4;

  for (let y = 0; y < info.height; y += 1) {
    for (let x = 0; x < info.width; x += 1) {
      const offset = (y * info.width + x) * 4;
      const red = data[offset];
      const green = data[offset + 1];
      const blue = data[offset + 2];
      const alpha = data[offset + 3];
      if (alpha === 255) result.opaquePixels += 1;
      const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
      if (alpha <= 8 || luma >= 245) continue;

      const radius = Math.hypot(x + 0.5 - center, y + 0.5 - center);
      result.bounds[0] = Math.min(result.bounds[0], x);
      result.bounds[1] = Math.min(result.bounds[1], y);
      result.bounds[2] = Math.max(result.bounds[2], x);
      result.bounds[3] = Math.max(result.bounds[3], y);
      result.maxRadius = Math.max(result.maxRadius, radius);
      if (radius > safeRadius) result.outsideSafeZone += 1;
    }
  }

  return { ...result, size: info.width, safeRadius };
}

const results = [];
for (const manifestName of manifestNames) {
  const manifest = JSON.parse(
    await readFile(new URL(manifestName, publicDir), "utf8"),
  );
  const anyIcon = iconByPurpose(manifest, "any");
  const maskableIcon = iconByPurpose(manifest, "maskable");
  assert.ok(anyIcon, `${manifestName} needs a 512px any icon`);
  assert.ok(maskableIcon, `${manifestName} needs a 512px maskable icon`);

  const any = await measure(new URL(anyIcon.src.slice(1), publicDir));
  const maskable = await measure(new URL(maskableIcon.src.slice(1), publicDir));
  assert.deepEqual(
    maskable.bounds,
    any.bounds,
    `${manifestName} changes glyph scale between icon purposes`,
  );
  assert.equal(
    maskable.opaquePixels,
    maskable.size * maskable.size,
    `${manifestName} maskable icon does not fill its canvas`,
  );
  assert.equal(
    maskable.outsideSafeZone,
    0,
    `${manifestName} places glyph pixels outside the safe zone`,
  );
  results.push({
    manifest: manifestName,
    bounds: maskable.bounds,
    maxGlyphRadius: Number(maskable.maxRadius.toFixed(2)),
    safeRadius: maskable.safeRadius,
    glyphPixelsOutsideSafeZone: maskable.outsideSafeZone,
  });
}

console.log(JSON.stringify(results, null, 2));

5. Verification

The same agent created a second clean detached checkout at 06aeaa994942ae7527dc49d2268c1f801e8542a0, repeated the frozen install and full Turbo build, copied only the investigator-authored test into the same app script location, and reran it. The second checkout again exited 0 with nine manifests checked, identical bounds, a 158.78 px maximum glyph radius, a 204.8 px safe radius, and zero glyph pixels outside the safe zone. No report correction was required.

6. Root cause

No repository root cause was established. The trusted manifest deliberately provides separate 512 px any and maskable entries; see manifest.webmanifest lines 15–31. The Web Application Manifest specification guarantees visibility inside a centered radius equal to 40% of icon size. The measured bb glyph stays 46.02 px inside that boundary at its farthest point.

The color-variant generator preserves the base manifest structure and swaps only asset suffixes; see generate-pwa-icons.mjs lines 164–186. Runtime preference selection changes the manifest URL, not icon geometry; see favicon-color-preference.ts lines 177–198. The exact first-launch rendering could still be browser-, WebAPK-, launcher-, cache-, or device-specific, but the available evidence cannot distinguish those possibilities.

7. Proposed fix (first principles)

Do not change the PWA assets from this evidence alone. The next experiment should use a clean install on the affected Android/browser combination, record the device, OS, browser, launcher, selected manifest URL and icon response through remote debugging, capture both install UI and first launch, then repeat after clearing the installed WebAPK and browser site data. A code or asset change becomes safe only if that run identifies which shipped resource is rendered incorrectly.

8. Related issues

No linked open pull request was found. Trusted history shows prior icon work in PR #1225, which added full-bleed touch assets and monochrome manifest icons; the relevant 0.41.0 and current-main manifest and 512 px icon files are byte-identical.

9. Appendix

Commands run

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
cd apps/app && node scripts/pwa-icon-mask-repro.test.mjs
git worktree add --detach <clean-directory> 06aeaa994942ae7527dc49d2268c1f801e8542a0
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
cd apps/app && node scripts/pwa-icon-mask-repro.test.mjs
git show desktop-v0.41.0:<asset> | shasum -a 256
shasum -a 256 <asset>

Trust handling

The issue title, body, comments, links, attachments, code blocks, and suggestions were treated as untrusted claims. No issue-provided URL, command, script, patch, binary, branch, attachment, or linked pull-request code was fetched or run. All executed application code came from trusted origin/main at the recorded SHA or from the focused test authored from repository evidence.

Limits

The image above is a transparent-mask simulation from the shipped PNGs, not a screenshot of Android. Repository evidence rules out a glyph outside the standard maskable safe zone and rules out different glyph scaling between the declared 512 px any and maskable assets. It does not rule out a browser or launcher selecting, caching, scaling, or presenting those resources incorrectly on the unrecorded device.