feat(kez-chat/web): make the SPA installable as a PWA

Now installable on iOS (Safari → Share → Add to Home Screen) and
Android/desktop Chrome (install prompt or Settings → Install app).
Launches in standalone mode with a dark theme color matching the
lock-icon palette.

Stack:
  • vite-plugin-pwa with workbox in generateSW mode, registerType
    'autoUpdate' — new SW activates on next page load, no upgrade prompt
    (chat needs to stay fresh).
  • @vite-pwa/assets-generator for icon variants from a single SVG.
    Source kez-icon.svg = dark squircle (#111827) + amber key glyph,
    drawn inside the 80% maskable safe zone.

Caching:
  • Precaches the SPA shell (~635 KB inc. the zstd WASM, well under
    the 5 MB per-file cap).
  • runtimeCaching 'NetworkOnly' for /v1/* — never cache authenticated
    chat data; every poll must hit the network.
  • navigateFallback to index.html so /messages, /claims, /dashboard
    survive a refresh while offline. The /v1/, /internal/, /.well-known/
    paths are explicitly denylisted from this fallback.

Meta tags (index.html):
  • <link rel="manifest"> + theme-color for Android Chrome.
  • apple-touch-icon-180x180 + apple-mobile-web-app-* meta for iOS,
    including status-bar-style=black-translucent so the dark header
    flows into the notch area in standalone.
  • viewport-fit=cover so safe-area-inset works on notched devices.

Generated artifacts committed under web/public/:
  kez-icon.svg, pwa-{64,192,512}.png, maskable-icon-512x512.png,
  apple-touch-icon-180x180.png, favicon.ico.

Verified live: /manifest.webmanifest serves application/manifest+json,
/sw.js serves text/javascript, all icons return 200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-25 22:23:14 -06:00
parent 7e9dc0773a
commit 6c0f5e2fd5
12 changed files with 5653 additions and 5 deletions

View File

@ -2,9 +2,24 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>kez-chat</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🔑</text></svg>" />
<meta name="description" content="End-to-end encrypted chat on top of KEZ — portable cross-app identity." />
<!-- Browser tab + Android Chrome favicon -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="/kez-icon.svg" />
<!-- iOS Add-to-Home-Screen icon + status-bar styling -->
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<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="kez-chat" />
<!-- Web App Manifest (generated by vite-plugin-pwa) + Android theme color -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#111827" />
</head>
<body>
<div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@ -24,10 +24,12 @@
"@tailwindcss/vite": "^4.0.0",
"@tsconfig/svelte": "^5.0.0",
"@types/node": "^22.0.0",
"@vite-pwa/assets-generator": "^1.0.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vite-plugin-pwa": "^1.3.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Solid background. 20% padding around the glyph leaves the safe
area for Android "maskable" icons (radial crops, squircle, etc.). -->
<rect width="512" height="512" rx="96" ry="96" fill="#111827"/>
<!-- A simple key shape: round bow + rectangular shaft + two teeth.
Drawn at the center; bounding box ~280px wide, well inside the 80%
safe zone (~410px diameter). -->
<g transform="translate(106 156)" fill="none" stroke="#fbbf24" stroke-width="28" stroke-linecap="round" stroke-linejoin="round">
<!-- Bow (ring) -->
<circle cx="80" cy="100" r="64"/>
<!-- Shaft (rounded line from the bow to the right edge) -->
<line x1="144" y1="100" x2="300" y2="100"/>
<!-- Two teeth on the bottom of the shaft -->
<line x1="240" y1="100" x2="240" y2="140"/>
<line x1="280" y1="100" x2="280" y2="156"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 926 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1,10 @@
import {
defineConfig,
minimal2023Preset as preset,
} from "@vite-pwa/assets-generator/config";
export default defineConfig({
headLinkOptions: { preset: "2023" },
preset,
images: ["public/kez-icon.svg"],
});

View File

@ -1,9 +1,66 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [svelte(), tailwindcss()],
plugins: [
svelte(),
tailwindcss(),
VitePWA({
// Auto-update: a new SW activates on next page load. No "click to
// update" prompt — chat needs to stay fresh and we don't want users
// stuck on an old build.
registerType: "autoUpdate",
injectRegister: "auto",
manifest: {
name: "kez-chat",
short_name: "kez-chat",
description:
"End-to-end encrypted chat on top of KEZ — portable cross-app identity.",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#111827",
theme_color: "#111827",
categories: ["social", "communication"],
icons: [
{ src: "pwa-64x64.png", sizes: "64x64", type: "image/png" },
{ src: "pwa-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "pwa-512x512.png", sizes: "512x512", type: "image/png" },
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
// Precache the SPA shell. Chat data is fetched live from /v1/*
// and we DON'T want it cached — see runtimeCaching below.
globPatterns: ["**/*.{js,css,html,svg,png,ico,wasm}"],
// zstd wasm is ~350 KB; raise the per-file cap.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
// Same-origin navigation requests fall back to the SPA shell so
// /messages, /claims, etc. work after a refresh while offline.
navigateFallback: "index.html",
navigateFallbackDenylist: [/^\/v1\//, /^\/internal\//, /^\/\.well-known\//],
runtimeCaching: [
{
// Never cache API responses — they're authenticated + dynamic.
urlPattern: /\/v1\//,
handler: "NetworkOnly",
},
],
navigationPreload: true,
cleanupOutdatedCaches: true,
},
devOptions: {
enabled: false,
},
}),
],
server: {
// For dev: proxy API calls to the locally-running chat-server.
// The deployed SPA (served by the same chat-server) doesn't need this.