installable PWA + branded app icons (0.40.0)

- web app manifest: name, standalone display, theme, app shortcuts
  (Browse/Booth/Mansion), maskable + any icons
- branded gem app icons (192/512/apple-touch) + refreshed favicon.svg
- service worker: network-first navigations (deploys never go stale),
  stale-while-revalidate for hashed assets, hard /api bypass, offline shell
- registered only on production amerc.ai hosts (dev HMR untouched)
- nginx: serve .webmanifest as application/manifest+json (all vhosts)
- verified live: installable criteria met, SW controls page, 0 console errors
This commit is contained in:
artheru 2026-06-10 06:17:16 +08:00
parent c8f6d0f892
commit 4ad339a94f
9 changed files with 96 additions and 8 deletions

View File

@ -4,8 +4,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0e0a14" /> <meta name="theme-color" content="#0e0a14" />
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." /> <meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="0.39.0-docs" /> <meta name="version" content="0.40.0-pwa" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="apple-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="amerc" />
<meta name="mobile-web-app-capable" content="yes" />
<!-- Open Graph / social share --> <!-- Open Graph / social share -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="amerc" /> <meta property="og:site_name" content="amerc" />

BIN
public/app-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/app-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -1,7 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="10" fill="#170704"/> <defs>
<path d="M12 48h40l-6-30H18z" fill="#8b4a22"/> <radialGradient id="glow" cx="50%" cy="42%" r="55%">
<path d="M20 18h24l4 22H16z" fill="#321207"/> <stop offset="0%" stop-color="#28b4ff" stop-opacity="0.55"/>
<path d="M27 10h10l9 9-14 14-14-14z" fill="#42dcff"/> <stop offset="100%" stop-color="#28b4ff" stop-opacity="0"/>
<path d="M22 47c4-11 16-11 20 0" fill="none" stroke="#f7bc5d" stroke-width="5" stroke-linecap="round"/> </radialGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#160d22"/>
<circle cx="32" cy="29" r="22" fill="url(#glow)"/>
<polygon points="32,12 32,32 14,32" fill="#5adcff"/>
<polygon points="32,12 50,32 32,32" fill="#3cbef0"/>
<polygon points="14,32 32,32 32,52" fill="#1488d0"/>
<polygon points="32,32 50,32 32,52" fill="#105aa0"/>
<polygon points="32,12 22,18 32,22" fill="#cdf5ff"/>
<polygon points="32,12 50,32 32,52 14,32" fill="none" stroke="#f3d27a" stroke-width="2.2" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 824 B

View File

@ -0,0 +1,23 @@
{
"name": "amerc — agent mercenary tavern",
"short_name": "amerc",
"description": "Hire, run, publish, and deliver AI agents across software boundaries. No infrastructure, just skills.",
"id": "/",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0e0a14",
"theme_color": "#0e0a14",
"categories": ["productivity", "developer", "business"],
"icons": [
{ "src": "/app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"shortcuts": [
{ "name": "Browse Agents", "short_name": "Browse", "url": "/#/agents" },
{ "name": "My Booth", "short_name": "Booth", "url": "/#/booth" },
{ "name": "The Mansion", "short_name": "Mansion", "url": "/#/mansion" }
]
}

43
public/sw.js Normal file
View File

@ -0,0 +1,43 @@
/* amerc service worker offline app shell + asset caching.
Strategy: navigations = network-first (deploys never go stale), hashed
assets = stale-while-revalidate, /api = always bypass (auth + dynamic). */
const CACHE = 'amerc-v1';
const SHELL = '/index.html';
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll([SHELL, '/favicon.svg', '/app-192.png'])).catch(() => {}));
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
const url = new URL(req.url);
// Only handle same-origin GET; never touch the API or cross-origin (relays, iframes).
if (req.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api')) return;
// Navigations: network-first, fall back to cached shell when offline.
if (req.mode === 'navigate') {
e.respondWith(
fetch(req).then((res) => { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(SHELL, copy)); return res; })
.catch(() => caches.match(SHELL).then((m) => m || caches.match(req)))
);
return;
}
// Static assets: serve from cache fast, refresh in the background.
e.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req).then((res) => {
if (res && res.status === 200 && res.type === 'basic') { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(req, copy)); }
return res;
}).catch(() => cached);
return cached || network;
})
);
});

View File

@ -7,7 +7,7 @@ import Account from './Account.jsx';
import TavernGame from './TavernGame.jsx'; import TavernGame from './TavernGame.jsx';
import { api } from './api.js'; import { api } from './api.js';
const RELEASE = '0.39.0-docs'; const RELEASE = '0.40.0-pwa';
const NAV = [ const NAV = [
{ id: 'home', label: 'Tavern' }, { id: 'home', label: 'Tavern' },

View File

@ -19,3 +19,9 @@ createRoot(document.getElementById('root')).render(
</Suspense> </Suspense>
</React.StrictMode>, </React.StrictMode>,
); );
// Progressive Web App: register the service worker on production amerc.ai hosts only
// (skip the Vite dev server so HMR keeps working).
if ('serviceWorker' in navigator && host.endsWith('amerc.ai')) {
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}); });
}