Add the web app manifest, icons and service worker via vite-plugin-pwa
CI / ci (push) Successful in 1m34s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m36s

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>
This commit is contained in:
2026-10-04 16:12:18 -04:00
co-authored by Claude Opus 5.5
parent d80012a821
commit 46cc7500de
14 changed files with 5488 additions and 4 deletions
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5a358c" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>MealQuest</title>
</head>
<body>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MealQuest",
"short_name": "MealQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#5a358c",
"background_color": "#f9f6f1",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+5227
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
@@ -29,6 +29,7 @@
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+59
View File
@@ -0,0 +1,59 @@
"""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()
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment node
// Builds the app with the production config and serves the result, so the
// assertions cover what ships in dist: the web manifest, the service worker,
// every icon, and the service worker registration that only builds include.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
import tokens from './styles/tokens.css?raw'
const outDir = 'node_modules/.tmp/pwa-test-dist'
const icons = [
{ path: '/icons/icon-192.png', size: 192 },
{ path: '/icons/icon-512.png', size: 512 },
{ path: '/icons/icon-maskable-512.png', size: 512 },
{ path: '/apple-touch-icon.png', size: 180 },
]
function token(name: string): string {
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
if (!value) throw new Error(`${name} is not defined in tokens.css`)
return value
}
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
// signature, the chunk length and the chunk type.
function pngSize(bytes: Uint8Array): { width: number; height: number } {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
describe('production build', () => {
let server: PreviewServer
let origin: string
async function get(path: string): Promise<Response> {
const response = await fetch(new URL(path, origin))
expect(response.status, path).toBe(200)
return response
}
beforeAll(async () => {
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
server = await preview({
logLevel: 'error',
build: { outDir },
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
})
const url = server.resolvedUrls?.local[0]
if (!url) throw new Error('preview server did not report a URL')
origin = url
}, 60_000)
afterAll(async () => {
await server?.close()
})
it('emits manifest.webmanifest with the MealQuest identity', async () => {
const manifest = await (await get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({
name: 'MealQuest',
short_name: 'MealQuest',
start_url: '/',
scope: '/',
display: 'standalone',
// --color-accent and --color-bg hold the manifest colours; hex colours
// may not be written outside tokens.css.
theme_color: token('--color-accent'),
background_color: token('--color-bg'),
})
expect(manifest.icons).toEqual([
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
])
})
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
expect(pngSize(bytes)).toEqual({ width: size, height: size })
})
it('emits sw.js with the precache list injected', async () => {
const sw = await (await get('/sw.js')).text()
expect(sw).not.toContain('self.__WB_MANIFEST')
expect(sw).toContain('"url":"index.html"')
for (const { path } of icons.slice(0, 3)) {
expect(sw).toContain(`"url":"${path.slice(1)}"`)
}
})
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
const html = await (await get('/')).text()
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
})
it('registers /sw.js at scope / from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
const register = await (await get('/registerSW.js')).text()
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
})
})
describe('dev server', () => {
let server: ViteDevServer
beforeAll(async () => {
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
})
afterAll(async () => {
await server?.close()
})
it('does not register a service worker', async () => {
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
expect(html).not.toContain('registerSW')
expect(html).not.toContain('serviceWorker')
expect(html).not.toContain('rel="manifest"')
})
})
+17
View File
@@ -0,0 +1,17 @@
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
// the build's precache list, and installing caches every entry. Serving from
// that cache, expiring old entries and the update flow belong to the runtime
// caching strategies, which are not built yet.
export {}
declare const self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
}
const precacheName = 'mealquest-precache'
const precacheURLs = self.__WB_MANIFEST.map((entry) => entry.url)
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(precacheName).then((cache) => cache.addAll(precacheURLs)))
})
+3 -1
View File
@@ -13,9 +13,11 @@
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"exclude": []
}
+19 -1
View File
@@ -1,10 +1,28 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
import manifest from './manifest.json' with { type: 'json' }
const backend = 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
// The plugin writes manifest.webmanifest and sw.js, links the manifest
// from index.html and adds the registerSW.js script. It does both only
// in production builds: dev options stay off, so neither `vite` nor
// Vitest registers a service worker.
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: 'script',
// The web manifest lives in manifest.json: its theme and background
// colours are --color-accent and --color-bg from src/styles/tokens.css,
// and hex colours may not be written in TypeScript.
manifest: manifest as Partial<ManifestOptions>,
}),
],
server: {
proxy: {
'/api': backend,