pwa-configuration

/home/avalon/.hermes/skills/software-development/pwa-configuration/SKILL.md · raw

PWA Configuration

Use this when Alex asks to make an app installable, mobile-home-screen friendly, standalone, or PWA-enabled. Also use during new VPS app builds that should behave like Jungle Studio Mobile, Video Story, HD Prism, or Hermes Workspace.

Supporting references

Core rules

  1. Do not put nginx Basic Auth in front of a PWA app shell. iOS/Android standalone launches do not reliably carry browser Basic Auth credentials and can white-screen before the app loads. Keep the HTML/JS/CSS/manifest/icon routes public and use app-level auth/session/bearer protection for sensitive screens and APIs. If the app still returns 401 at / without browser Basic credentials, the PWA conversion is incomplete even if the manifest and service worker are valid.
  2. Do not trust browser-tab localStorage/onboarding state to exist in an installed PWA. iOS standalone storage can be isolated from Safari. If a PWA uses localStorage flags such as onboarding-complete, add a boot-time authenticated backend probe (with credentials: 'same-origin' and cache: 'no-store') that can mark the app ready when the backend is healthy, rather than stranding the installed app in onboarding/setup.
  3. Use a no-stale-cache service worker unless offline behavior is explicitly requested. Alex values seeing new deploys immediately. Prefer a network-only/no-cache SW that enables installability but does not cache bundles. Exception: for app launchers/icon grids with many small repeated images, cache only immutable optimized icon/thumb assets (for example /icons/optimized/*) while keeping HTML, JS, CSS, apps.json, status.json, manifest.json, and sw.js network-fresh/no-cache.
  4. Serve sw.js and manifest.json with no-cache headers for both Express/static servers and nginx-only static sites. Put Express routes before static middleware; for nginx-only apps, add exact-match location = /sw.js and location = /manifest.json blocks before the catch-all/root location.
  5. Use one branded app icon everywhere: source icon in the app repo, manifest icons, apple-touch-icon, favicon/shortcut icon, and the launcher.apps.poofc.com card.
  6. Mobile-first verification matters: verify HTTPS, manifest JSON, icons, service worker registration, and standalone-safe layout on a real phone/incognito when feasible.
  7. Treat sudo availability as a runtime fact, not a memory fact. If nginx header edits are needed, run sudo -n true first. If it still says a password is required, ask Alex for the actual password or a local credential location before attempting /etc/nginx edits; do not infer the password from “you have sudo password,” memory, or old notes.

App icon workflow

  1. Use the configured GPT Image workflow to create a square 1024×1024 branded source icon. A design/image subagent may be used to write or refine the icon prompt, but the final art should come from GPT Image by default. For new VPS apps, this is not optional: the icon must be reusable by the app PWA, apple-touch-icon, favicon, and the central launcher grid. Do not use quick Pillow/programmatic placeholder tiles as the final icon unless Alex explicitly asks for a temporary placeholder or GPT Image is unavailable and the result is clearly labeled temporary. The icon should read like a single iOS home-screen icon: one rounded-square composition, no icon-inside-icon, no stacked/double tile, no labels/text, and no status dots.
  2. Save it in the app repo, usually public/app-icon.png.
  3. Generate at least: - public/icon-512.png - public/icon-192.png - public/apple-touch-icon.png (180×180) - optional public/favicon.png / favicon.ico
  4. For icon grids/launchers, do not serve dozens of 512px PNGs directly into 78–92px tiles. Generate responsive WebP thumbnails (for example 96px and 192px) under a stable path such as icons/optimized/<slug>-96.webp and icons/optimized/<slug>-192.webp; use srcset, sizes, loading="lazy", decoding="async", and width/height attributes. Keep the original 512px PNG as fallback or inspection-sheet detail.
  5. Use mask-safe padding so the icon works with Android adaptive masks.
  6. Update the launcher card/grid to use the same icon if the launcher supports image icons; otherwise use a close emoji only as a temporary fallback. For Alex's canonical launcher, the expected shape is a phone-style icon grid: add/update /home/avalon/apps/app-launcher/icons/<slug>.png and the launcher metadata (apps.json or its generated inventory source), not just a text link.

Example resize command:

python3 - <<'PY'
from PIL import Image
src = Image.open('public/app-icon.png').convert('RGBA')
for size, out in [(512,'public/icon-512.png'), (192,'public/icon-192.png'), (180,'public/apple-touch-icon.png')]:
    src.resize((size, size), Image.LANCZOS).save(out)
PY

Example launcher/grid thumbnail generator:

python3 - <<'PY'
from pathlib import Path
from PIL import Image
root = Path('icons')
out = root / 'optimized'
out.mkdir(exist_ok=True)
for src in sorted(root.glob('*.png')):
    im = Image.open(src).convert('RGBA')
    for size, quality in [(96, 78), (192, 82)]:
        dest = out / f'{src.stem}-{size}.webp'
        im.resize((size, size), Image.Resampling.LANCZOS).save(dest, 'WEBP', quality=quality, method=6)
PY

Use in HTML:

<img
  class="tile"
  src="/icons/optimized/app-96.webp"
  srcset="/icons/optimized/app-96.webp 96w, /icons/optimized/app-192.webp 192w, /icons/app.png 512w"
  sizes="(min-width: 640px) 92px, 78px"
  width="92"
  height="92"
  loading="lazy"
  decoding="async"
  alt=""
>

Minimal Vite/React PWA files

public/manifest.json:

{
  "name": "App Name",
  "short_name": "App",
  "description": "Installable app description.",
  "id": "/?app=app-name",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "display_override": ["window-controls-overlay", "standalone", "browser"],
  "orientation": "any",
  "background_color": "#0A0E1A",
  "theme_color": "#0A0E1A",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ]
}

public/sw.js network-only pattern:

self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then((names) => Promise.all(names.map((name) => caches.delete(name))))
      .then(() => self.clients.claim()),
  )
})
self.addEventListener('fetch', () => {
  // Intentionally do not call event.respondWith(); all requests stay network/server controlled.
})

Launcher/icon-grid variant — cache only optimized thumbnails, not the shell/data:

const ICON_CACHE = 'app-icons-v1'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then((names) => Promise.all(names.filter((name) => name !== ICON_CACHE).map((name) => caches.delete(name))))
      .then(() => self.clients.claim()),
  )
})
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  if (event.request.method === 'GET' && url.origin === self.location.origin && url.pathname.startsWith('/icons/optimized/')) {
    event.respondWith(
      caches.open(ICON_CACHE).then(async (cache) => {
        const cached = await cache.match(event.request)
        if (cached) return cached
        const response = await fetch(event.request)
        if (response.ok) cache.put(event.request, response.clone())
        return response
      }),
    )
  }
})

When registering the SW, preserve the icon cache if the page still clears old caches:

if (window.caches?.keys) {
  caches.keys()
    .then((names) => names.filter((name) => name !== 'app-icons-v1').forEach((name) => caches.delete(name)))
    .catch(() => {})
}
navigator.serviceWorker.register('/sw.js', { scope: '/' })
  .then((registration) => registration.update().catch(() => {}))
  .catch(console.warn)

HTML/head requirements:

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

Client registration:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    caches?.keys?.().then((names) => names.forEach((name) => caches.delete(name))).catch(() => {})
    navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(console.warn)
  })
}

Express no-cache routes

Add before express.static:

app.get('/sw.js', (_req, res) => {
  res.set({
    'Content-Type': 'application/javascript; charset=utf-8',
    'Cache-Control': 'no-cache, no-store, must-revalidate',
    'Service-Worker-Allowed': '/',
  })
  res.sendFile(path.join(distOrPublicDir, 'sw.js'))
})

app.get('/manifest.json', (_req, res) => {
  res.set({
    'Content-Type': 'application/manifest+json; charset=utf-8',
    'Cache-Control': 'no-cache, no-store, must-revalidate',
  })
  res.sendFile(path.join(distOrPublicDir, 'manifest.json'))
})

For Nginx-only/static apps, add exact-match location blocks before the generic location / block. Prefer alias for exact file locations instead of root + try_files; the root/try_files form can internally redirect into the generic static location and silently drop the intended headers. Example for the launcher-style static root:

location = /manifest.json {
    alias /home/avalon/apps/APP/manifest.json;
    default_type application/manifest+json;
    add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}

location = /sw.js {
    alias /home/avalon/apps/APP/sw.js;
    default_type application/javascript;
    add_header Cache-Control "no-cache, no-store, must-revalidate" always;
    add_header Service-Worker-Allowed "/" always;
}

location / {
    root /home/avalon/apps/APP;
    index index.html;
    try_files $uri $uri/ /index.html;
}

After editing nginx, always run sudo nginx -t before sudo systemctl reload nginx. If sudo is not currently usable, still complete the repo/static-file PWA work, verify the routes return 200, and report the exact remaining root-only header change.

Layout and touch-interaction pitfalls

Testing command pitfall

When a package script is shaped like "test": "vitest run", running pnpm test -- path/to/test.ts can still execute the whole suite in some projects/toolchains. For targeted PWA guard tests, prefer direct Vitest invocation:

pnpm exec vitest run src/routes/-root-runtime-guards.test.ts

Verification checklist

curl -I https://APP.apps.poofc.com/manifest.json
curl -I https://APP.apps.poofc.com/sw.js
curl -s https://APP.apps.poofc.com/manifest.json | jq .
curl -I https://APP.apps.poofc.com/icon-512.png

Then verify: