vite-plugin-pwa runs in injectManifest mode over a minimal src/sw.ts that caches the injected precache list on install. The manifest lives in web/manifest.json (MealQuest, standalone at /, Grape theme over the page background) with 192, 512 and maskable 512 PNG icons and a 180 px apple-touch-icon, all generated from design/logos/logo_v1_2Oct2026.jpg by web/scripts/generate-icons.py. The plugin links the manifest and adds the registerSW.js script only in production builds. src/pwa.test.ts builds and previews the app to assert the shipped manifest, sw.js, icon sizes and registration, and checks that the dev server registers nothing. Maestro task: 58b74786-937a-45a5-850f-7913273beba2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60 lines
2.2 KiB
Python
60 lines
2.2 KiB
Python
"""Generates the PWA icons in web/public from the MealQuest logo.
|
|
|
|
Run from the repository root with Pillow installed:
|
|
|
|
python3 web/scripts/generate-icons.py
|
|
|
|
The logo fills the regular icons edge to edge. The maskable icon shrinks it
|
|
onto the colour of its own parchment, so the banner stays inside
|
|
the circular safe zone (radius 40% of the icon) that launchers may crop to.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageStat
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
|
|
PUBLIC = ROOT / "web" / "public"
|
|
|
|
MASKABLE_SCALE = 0.66
|
|
|
|
|
|
def resized(logo: Image.Image, size: int) -> Image.Image:
|
|
return logo.resize((size, size), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def maskable(logo: Image.Image, size: int) -> Image.Image:
|
|
# The padding is the median colour of the logo's top edge, which is bare
|
|
# parchment; a feathered edge hides the seam.
|
|
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
|
|
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
|
|
inner = round(size * MASKABLE_SCALE)
|
|
offset = (size - inner) // 2
|
|
feather = Image.new("L", (inner, inner), 0)
|
|
margin = inner // 24
|
|
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
|
|
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
|
|
canvas.paste(resized(logo, inner), (offset, offset), feather)
|
|
return canvas
|
|
|
|
|
|
def main() -> None:
|
|
logo = Image.open(SOURCE).convert("RGB")
|
|
if logo.width != logo.height:
|
|
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
|
|
outputs = {
|
|
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
|
|
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
|
|
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
|
|
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
|
|
}
|
|
for path, image in outputs.items():
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(path, optimize=True)
|
|
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|