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.references/hermes-mobile-pwa-bff.md — mobile admin PWA/BFF pattern for using local-only backends such as Hermes dashboard/gateway from iPhone without publicly exposing the raw service port.references/hermes-mobile-update-and-icon-lessons.md — Hermes Mobile lessons for stale installed-PWA updates, generated app icons, and safely absorbing App Launcher data before retiring it.references/hermes-mobile-batch-icon-regeneration.md — batch workflow for regenerating many Hermes Mobile launcher icons, using cache-busting filenames, backups, contact-sheet review, and public URL verification.references/mobile-pwa-session-auth.md — authenticated PWA session pattern: HTTP-only cookies plus persistent server-side session store so login survives PM2 restarts without client-side password storage.401 at / without browser Basic credentials, the PWA conversion is incomplete even if the manifest and service worker are valid.express-session on the default MemoryStore when Alex expects "enter password once" behavior: PM2 restarts/deploys erase in-memory sessions and cause protected API calls to return 401 unauthorized while the mounted React UI still appears logged in. Use SQLite/Redis/Postgres or a small JSON-file store for single-user internal tools, with HttpOnly, SameSite=Lax, Secure cookies and a rolling/appropriate max age. See references/mobile-pwa-session-auth.md./api/* routes with session auth; the BFF calls 127.0.0.1 services or CLIs server-side. After any temporary remote-desktop/backend test, rebind the raw service to 127.0.0.1 and verify the public IP:port no longer responds.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. If Alex says an installed PWA is not showing changes, treat this as a PWA freshness bug first: verify //fallback HTML has no-cache headers, register sw.js with a build/version query, call registration.update(), delete old caches on activate, and add a visible build/version marker while debugging.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 SVG, Pillow/programmatic, emoji-tile, or other 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. If a session previously shipped a placeholder icon, replace it with an image-generated icon before calling the app polished. 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.
- When Alex wants apps to look like an iPhone home screen, render each app as just the rounded-square icon plus label below, not a card with description/status. Use a fixed square frame (about 64–76px mobile, 76–92px desktop), display:grid; place-items:center; overflow:hidden, and image width:100%; height:100%; object-fit:cover; object-position:center so generated icons read centered even when the source image has uneven padding. Labels should be short, centered, and clamped to 1–2 lines.
- If many app icons show black edges, off-center subjects, or a smaller rounded tile inside the icon, treat it as a source-icon problem, not just CSS. A display-side object-fit: cover plus mild scale can help only mildly padded sources; for baked-in icon-inside-icon artifacts, regenerate full-bleed source icons.
- For installed PWAs, prefer cache-busting icon filenames when changing launcher icons that Alex needs to see immediately: write icons/<slug>-regen.png (or similar), update metadata to point at it, and keep a backup such as <slug>.original-before-regenerated-batch.png. Replacing the old <slug>.png alone can be hidden by static/PWA cache.
- After a batch regeneration, create a contact sheet for visual review and HEAD-request every new public icon URL before calling the update complete. See references/hermes-mobile-batch-icon-regeneration.md for the Hermes Mobile batch pattern./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.viewport-fit=cover + apple-mobile-web-app-status-bar-style=black-translucent, the app must reserve the top safe area on the root shell. Do not later override it away with .app{padding-top:0} or a sticky header at top:0; this makes the OS status bar cover controls. Durable shell pattern: define --safe-top: env(safe-area-inset-top,0px) and set .app{padding-top:max(12px,var(--safe-top))} while keeping sticky headers inside that padded shell.AppHeader rendered directly under the root layout), apply the safe-area class/rule to every header render branch (logged-in and logged-out). A sidebar may already be safe-area padded while the hamburger remains hidden if the sticky app header lacks it. Prefer a reusable rule such as .pwa-safe-top { padding-top: max(env(safe-area-inset-top, 0px), 0px); } and add it to the sticky header class list.safe-area-inset-top. Browser desktop will report env(safe-area-inset-top) as 0px, so a zero computed padding in desktop automation is not evidence the PWA fix is absent.viewport-fit=cover + translucent iOS status bar, put the drawer close button inside a container padded with max(base, calc(env(safe-area-inset-top, 0px) + base)); do not rely on ordinary padding: 16px at the top of fixed sidebars because iPhone standalone mode can place the status bar over the close control.h-dvh flex shell + fixed/flex tab bar + separate bottom spacer + padding-bottom: env(safe-area-inset-bottom), which creates a large white gap above the home indicator. Another failure is a floating pill nav (left/right:8px; bottom:calc(8px + env(...))) when the product wants app-like chrome. Use one strategy only: either the tab bar owns the inset or the shell owns it. For docked app chrome, use position:fixed; left:0; right:0; bottom:0; width:100vw; padding-bottom:calc(base + env(safe-area-inset-bottom)), remove side/bottom offsets, and add matching root content padding so scrollable content is not hidden under the tab bar..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, background_color, and 192/512 icons.<meta name="theme-color"> and manifest.json theme_color/background_color; it is easy to update the HTML shell while leaving install/splash chrome on the old color.sw.js returns 200 with no-cache headers.launcher.apps.poofc.com includes/updates the app card using the branded icon/link.