Add the web app manifest, icons and service worker via vite-plugin-pwa
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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+5227
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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 |
@@ -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()
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "WebWorker"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
+19
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user