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.
references/hermes-workspace-pwa.md — session-specific retrofit notes for Hermes Workspace, including the no-cache SW implementation, targeted test command, fork/PR workflow, and nginx Basic Auth blocker.references/static-launcher-pwa.md — notes from converting the nginx-served app launcher into a PWA, including icon assets, static-route verification, and the sudo/nginx header blocker.references/launcher-touch-gestures.md — movement-safe long-press inspection and pull-to-refresh pattern for the launcher/PWA icon grid.401 at / without browser Basic credentials, the PWA conversion is incomplete even if the manifest and service worker are valid.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./icons/optimized/*) while keeping HTML, JS, CSS, apps.json, status.json, manifest.json, and sw.js network-fresh/no-cache.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.apple-touch-icon, favicon/shortcut icon, and the launcher.apps.poofc.com card.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.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.public/app-icon.png.public/icon-512.png
- public/icon-192.png
- public/apple-touch-icon.png (180×180)
- optional public/favicon.png / favicon.icoicons/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./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=""
>
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)
})
}
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.
min-height: 100dvh/h-dvh plus safe-area padding.position: fixed; bottom: 0; left: 0; right: 0; with padding-bottom: env(safe-area-inset-bottom) or equivalent Tailwind classes..env template and tell the operator to place real secrets only in uncommitted server-side env files."moduleResolution": "Bundler" in tsconfig.json; "Node"/node10 can now fail under TS 6 deprecation checks. Install @types/react and @types/react-dom, and add src/vite-env.d.ts for CSS/module imports before treating tsc --noEmit failures as app logic bugs.NODE_ENV=production in Vite .env files; Vite warns that production NODE_ENV is unsupported there. Keep NODE_ENV=production in PM2/start environment and reserve Vite .env for actual app configuration/secrets.steps array unless all render paths are bounds-checked. A successful async submit can otherwise crash only at the final transition in minified production builds (for example steps[step].validate() when step === steps.length), presenting to users as a blank screen or instant reset. Prefer explicit status: 'form' | 'submitting' | 'success' | 'error', or clamp currentStep and disable form validation outside form steps.pointerdown, but cancel it on pointermove beyond a small threshold (about 10px), pointercancel, pointerleave, and page scroll; otherwise normal thumb scrolling opens endless inspect sheets on mobile. Use a longer threshold than an accidental touch (about 650ms) and only open the app on pointerup when no movement/inspection occurred.window.scrollY === 0), use non-passive touchmove only while actively pulling, and provide a visible state (Pull to refresh → Release to refresh → Refreshing…). For no-cache/static PWAs, refresh should update the service worker registration, refetch dynamic JSON/status data, then reload the page so the latest HTML/JS is definitely used.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
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:
display: standalone, start_url, scope, theme_color, and 192/512 icons.sw.js returns 200 with no-cache headers.launcher.apps.poofc.com includes/updates the app card using the branded icon/link.