PWA Installability

SkillDev tools

Use when the user asks to make a site installable, turn it into a PWA, add a service worker, add a web app manifest, enable "add to home screen", or support offline. Also use when adding favicon/app icons, iOS home-screen support (apple-mobile-web-app meta tags), or verifying PWA installability.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the PWA Installability skill

What this skill tells your AI

The instructions your AI receives, as published by spinspire/pocketbase-sveltekit-starter in .agents/skills/pwa/SKILL.md and read by ahel’s review.

Make a web app installable and iOS-friendly with a minimal service worker. Default posture is network-only (no caching) unless the app needs offline — pick a strategy deliberately, never by default.

When to Use

  • "Make it installable / a PWA"
  • "Add a service worker" / "add a web manifest" / "add to home screen"
  • "Works offline?" or "works on iPhone?"

The Three Pillars

  1. HTTPS — required for service workers (localhost exempt in dev).
  2. Web app manifest — name, icons, start_url, display → makes it installable.
  3. Service worker — background script; active SW + valid manifest unlocks the install prompt.

Quick Reference

AssetWhere it goesNotes
manifest.webmanifestsite root (static/ for SvelteKit)name, short_name, start_url, scope, display standalone, theme/background color, icons 192+512 + maskable
favicon-32/48/64.pngrootbrowser tab icons; also apple-touch-icon.png 180px for iOS
icon-192.png, icon-512.pngrootrequired by Chrome for install
icon-maskable-512.pngrootfull-bleed background, content in center 80% safe zone
sw.jsroot (static/ for SvelteKit)register from client code; served at site root for correct scope
offline.html (optional)rootfallback for failed navigations if offline support is wanted

Service Worker

Default: network-only (no caching)

self.addEventListener('install', () => {
	self.skipWaiting();
});

self.addEventListener('activate', (event) => {
	event.waitUntil(clients.claim());
});

self.addEventListener('fetch', (event) => {
	event.respondWith(fetch(event.request));
});

This makes the app installable and always-fresh. Use for apps where data lives server-side / in a spreadsheet and serving stale data is wrong.

Offline fallback (add only if offline support is requested)

const CACHE = 'capstone-v1';

self.addEventListener('install', (event) => {
	event.waitUntil(
		caches.open(CACHE).then((c) => c.addAll(['/', '/offline.html', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']))
	);
	self.skipWaiting();
});

self.addEventListener('activate', (event) => {
	event.waitUntil(
		caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => clients.claim())
	);
});

self.addEventListener('fetch', (event) => {
	const url = new URL(event.request.url);
	if (event.request.mode === 'navigate') {
		event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html')));
		return;
	}
	if (url.origin === location.origin && ['css', 'js', 'svg', 'png', 'jpg', 'woff2'].some((ext) => url.pathname.endsWith('.' + ext))) {
		event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request).then((res) => { const clone = res.clone(); caches.open(CACHE).then((c) => c.put(event.request, clone)); return res; })));
		return;
	}
	event.respondWith(fetch(event.request));
});

Caching strategy decision table

StrategyBehaviorUse for
Network onlyAlways hit network, never cacheAuth, live/volatile data, spreadsheets, anything where stale is wrong. Default.
Cache firstServe from cache, update in backgroundStatic assets: css/js, fonts, images
Network firstTry network, fall back to cacheHTML pages, API reads where offline read > error
Stale while revalidateServe stale instantly, refresh cacheSemi-static content, lists

When caching, version the cache name and delete old caches in activate. Never cache POST or auth'd API responses.

Registration (client side)

Register on mount, not at parse time, so it never delays first paint:

if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('SW registration failed', err));
}

In SvelteKit, put this in +layout.svelte onMount. The SW must be at the site root (/sw.js) — static/sw.js ships there via adapter-static.

Manifest

{
  "name": "Capstone — Construction business operations",
  "short_name": "Capstone",
  "description": "One-line description.",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#faf9f6",
  "theme_color": "#1f2933",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Link it in the HTML head: <link rel="manifest" href="/manifest.webmanifest" />.

Icons from source images

  • Source should be square (e.g. 768×768). Crop/resize with ImageMagick:
    magick src.png -resize 192x192 icon-192.png
    magick src.png -resize 512x512 icon-512.png
    magick -size 512x512 xc:"#1f2933" \( src.png -resize 60% +repage \) -gravity center -composite icon-maskable-512.png
    
  • Maskable icons need a full-bleed background with the logo at ~60% inside the center safe zone.

iOS support (Safari / Add to Home Screen)

iOS ignores theme_color and needs legacy meta tags:

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="AppName" />
<meta name="theme-color" content="#1f2933" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />

Caveats:

  • iOS has no beforeinstallprompt — users install via Share → Add to Home Screen.
  • iOS may evict service workers after ~7 days of inactivity (Intelligent Tracking Prevention) — expect re-registration on next visit.

Pre-ship audit

  1. bun run check (or project typecheck) and build pass.
  2. Start dev server, then verify with agent-browser:
    • manifest + SW reachable: curl -s -o /dev/null -w "%{http_code}" localhost:5173/manifest.webmanifest → 200
    • all icon assets return 200 (favicon-32/48/64, apple-touch-icon, icon-192/512, icon-maskable-512, sw.js)
    • SW registered + active:
      agent-browser eval "navigator.serviceWorker.getRegistration().then(r => ({ scope: r?.scope, active: r?.active?.state }))"
      
    • links present in head: rel=icon, rel=apple-touch-icon, rel=manifest, meta[name=theme-color]
  3. Production requires HTTPS (Cloudflare Pages et al. provide it). Local dev on http://localhost is exempt for SW registration.
  4. Optional: run Lighthouse PWA audit in Chrome DevTools.

Common Mistakes

  • Registering SW in <head> or at module top-level — delays first paint; use onMount.
  • Caching auth'd or volatile API responses — serves stale or broken data. Keep those network-only.
  • SW not at root/sw.js only controls the scope beneath it; a SW in /js/sw.js won't control /.
  • Skipping maskable icon — Android adaptive icons will crop the logo without one.
  • Cache name never versioned / old caches never deleted — disk fills and stale assets linger.
  • Forgetting iOS meta tags — app opens in Safari tab instead of standalone, wrong status bar.

Signals

GitHub stars
507
Forks
76
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
pwa
Source
github.com/spinspire/pocketbase-sveltekit-starter