feat(ui): installable offline PWA (F5) #31

Merged
developer merged 2 commits from feature/ui-finalize-f5-pwa into development 2026-05-22 14:02:40 +00:00
17 changed files with 409 additions and 2 deletions
+6
View File
@@ -98,6 +98,12 @@ jobs:
working-directory: ui/frontend working-directory: ui/frontend
run: pnpm exec playwright test run: pnpm exec playwright test
- name: Run PWA tests
# Builds + previews the production bundle (the service worker only
# precaches a real build) and checks manifest / SW / offline.
working-directory: ui/frontend
run: pnpm test:pwa
- name: Upload Playwright report on failure - name: Upload Playwright report on failure
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+17 -1
View File
@@ -120,7 +120,23 @@ Acceptance: every server error → a translated actionable message;
consistent empty/loading/error states; the selected planet is visually consistent empty/loading/error states; the selected planet is visually
marked. marked.
## F5 — PWA (was Phase 33) ## F5 — PWA (was Phase 33) — done
Installable, offline-tolerant PWA on SvelteKit's native service worker
(`src/service-worker.ts`): a version-keyed cache precaches the shell,
build artefacts (incl. `core.wasm`), and static files; `activate` purges
old caches; the gateway is never intercepted. Adds
`static/manifest.webmanifest`, a generated placeholder icon set
(`scripts/gen-pwa-icons.mjs`, dependency-free), and the manifest /
theme-color / apple-touch tags in `app.html`. Gated by Playwright against
a production preview (`pnpm test:pwa`, wired into `ui-test`): manifest +
installable icons, SW registration + single version-keyed cache, and
offline shell load. Docs: `ui/docs/pwa-strategy.md`.
**Acceptance correction:** "Lighthouse PWA ≥ 90" is dropped — Lighthouse
removed the PWA category in v12 (current line 13.x), so it is no longer a
valid gate; the Playwright checks verify the same install/offline
behaviour directly.
Goal: the web build is installable and offline-tolerant. Depends on F6 Goal: the web build is installable and offline-tolerant. Depends on F6
(wasm in CI) so the service worker caches a freshly-built artefact. (wasm in CI) so the service worker caches a freshly-built artefact.
+2
View File
@@ -31,6 +31,8 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in
bridge, with the live function surface and parity rules. bridge, with the live function surface and parity rules.
- [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to - [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to
`core.wasm` with TinyGo. `core.wasm` with TinyGo.
- [pwa-strategy.md](pwa-strategy.md) — the installable/offline PWA: the
native service worker, manifest, icons, and the Playwright PWA gate.
- [testing.md](testing.md) — the UI test layers (Vitest + Playwright). - [testing.md](testing.md) — the UI test layers (Vitest + Playwright).
## Auth & lobby ## Auth & lobby
+46
View File
@@ -0,0 +1,46 @@
# PWA strategy
The web client is an installable, offline-tolerant PWA. It uses
SvelteKit's native service worker (no Workbox) so there is no extra
build dependency and the cache logic stays explicit.
## Pieces
- [`src/service-worker.ts`](../frontend/src/service-worker.ts) — the
worker. SvelteKit registers it automatically in the production build.
It precaches the app shell (`/`), the build artefacts (JS/CSS +
`core.wasm`), and the static files under a **version-keyed** cache
(`galaxy-cache-<version>`, `version` from `$service-worker`). On
`activate` it deletes every other cache, so a new deploy never serves
stale code. Strategy: cache-first for the version-keyed build/files;
network-first with cache fallback for everything else; the cached
shell answers navigations when fully offline. The gateway (cross-
origin) is never intercepted — it is always live network.
- [`static/manifest.webmanifest`](../frontend/static/manifest.webmanifest)
— name, `standalone` display, `start_url`/`scope` `/`, dark
`theme_color`/`background_color`, and the icon set.
- [`static/icons/`](../frontend/static/icons/) — `192`/`512` (`any`),
a `512` `maskable`, and a `180` apple-touch icon. They are placeholder
artwork generated from `static/favicon.svg` by
[`scripts/gen-pwa-icons.mjs`](../frontend/scripts/gen-pwa-icons.mjs)
(a dependency-free pure-Node PNG encoder); swap in real artwork at the
same paths and the manifest is unchanged.
- [`src/app.html`](../frontend/src/app.html) — the manifest link, the
apple-touch-icon link, and light/dark `theme-color` metas matching the
design tokens.
## Testing
PWA behaviour is gated by Playwright against a **production preview**
build ([`playwright.pwa.config.ts`](../frontend/playwright.pwa.config.ts)
+ [`tests/pwa/pwa.spec.ts`](../frontend/tests/pwa/pwa.spec.ts), run by
`pnpm test:pwa` in CI): the manifest is linked with installable icons,
the service worker registers and controls the page under exactly one
version-keyed cache, and the app shell loads offline from that cache.
The spec needs a real build because `$service-worker`'s `build` list is
empty under `vite dev`.
Lighthouse is intentionally **not** used: its PWA category was removed
in Lighthouse 12 (the current line is 13.x), so "Lighthouse PWA ≥ 90" is
no longer a meaningful gate. The Playwright checks above verify the same
install/offline behaviour directly.
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:pwa": "playwright test --config playwright.pwa.config.ts"
}, },
"dependencies": { "dependencies": {
"flatbuffers": "^25.9.23", "flatbuffers": "^25.9.23",
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
import { FIXTURE_PUBLIC_KEY_RAW_BASE64 } from "./tests/e2e/fixtures/gateway-key";
// The service worker only precaches a real production build (the
// `$service-worker` `build` list is empty under `vite dev`), so the PWA
// spec runs against `vite preview` of an actual build — a separate config
// from the dev-server e2e suite. Chromium only: service-worker + Cache
// Storage + offline emulation are the surface under test, and WebKit's
// SW support in headless CI is unreliable.
export default defineConfig({
testDir: "tests/pwa",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["list"]],
use: {
baseURL: "http://localhost:4173",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "pnpm build && pnpm preview --port 4173",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: {
VITE_GATEWAY_RESPONSE_PUBLIC_KEY: FIXTURE_PUBLIC_KEY_RAW_BASE64,
},
},
});
+101
View File
@@ -0,0 +1,101 @@
// Generates the PWA icon set from the project's galaxy mark, with no
// image dependency (a tiny pure-Node RGBA PNG encoder over zlib). The
// mark mirrors `static/favicon.svg`: a dark disc with an amber core,
// plus an amber orbit ring. Re-run after changing the palette/mark:
//
// node scripts/gen-pwa-icons.mjs
//
// Outputs into static/icons/. The committed PNGs are placeholders; swap
// in real artwork at the same paths/sizes and the manifest is unchanged.
import { deflateSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
const BG = [31, 41, 55]; // #1f2937
const AMBER = [251, 191, 36]; // #fbbf24
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
function encodePng(size, rgba) {
const stride = size * 4;
const raw = Buffer.alloc((stride + 1) * size);
for (let y = 0; y < size; y++) {
raw[y * (stride + 1)] = 0; // filter: none
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type RGBA
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
// `fullBleed` fills the whole square opaquely (maskable / apple-touch);
// otherwise the disc is drawn on transparency with content in the safe
// zone untouched.
function draw(size, fullBleed) {
const buf = Buffer.alloc(size * size * 4);
const c = size / 2;
const discR = size * 0.46;
const ringR = fullBleed ? size * 0.3 : size * 0.34;
const ringW = size * 0.04;
const coreR = fullBleed ? size * 0.09 : size * 0.1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const d = Math.hypot(x + 0.5 - c, y + 0.5 - c);
let col = null;
if (fullBleed) col = BG;
else if (d <= discR) col = BG;
if (col !== null) {
if (Math.abs(d - ringR) <= ringW / 2) col = AMBER;
if (d <= coreR) col = AMBER;
buf[i] = col[0];
buf[i + 1] = col[1];
buf[i + 2] = col[2];
buf[i + 3] = 255;
}
}
}
return buf;
}
mkdirSync(new URL("../static/icons/", import.meta.url), { recursive: true });
const targets = [
["icon-192.png", 192, false],
["icon-512.png", 512, false],
["icon-maskable-512.png", 512, true],
["apple-touch-icon-180.png", 180, true],
];
for (const [name, size, fullBleed] of targets) {
const path = new URL(`../static/icons/${name}`, import.meta.url);
writeFileSync(path, encodePng(size, draw(size, fullBleed)));
console.log("wrote", name, `${size}x${size}`);
}
+15
View File
@@ -3,6 +3,21 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link
rel="apple-touch-icon"
href="%sveltekit.assets%/icons/apple-touch-icon-180.png"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#0a0e1a"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#f3f5fb"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Galaxy</title> <title>Galaxy</title>
<style> <style>
+7
View File
@@ -4,6 +4,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { dev } from "$app/environment";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte"; import { eventStream } from "../api/events.svelte";
@@ -21,6 +22,12 @@
onMount(() => { onMount(() => {
void session.init(); void session.init();
// Production-only service-worker registration (auto-register is off
// in svelte.config.js) so `vite dev` and the dev-server e2e suite
// run without the worker intercepting requests.
if (!dev && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("/service-worker.js");
}
return () => { return () => {
eventStream.stop(); eventStream.stop();
streamSessionId = null; streamSessionId = null;
+75
View File
@@ -0,0 +1,75 @@
/// <reference types="@sveltejs/kit" />
/// <reference lib="webworker" />
// Native SvelteKit service worker (no Workbox). It precaches the app
// shell, the build artefacts (JS/CSS + core.wasm), and the static files
// under a version-keyed cache, so the installed PWA loads offline and a
// new deploy (new `version`) drops the stale cache instead of serving
// old code. The gateway is never intercepted — it is always live network.
//
// SvelteKit registers this worker automatically in the production build.
import { build, files, version } from "$service-worker";
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE = `galaxy-cache-${version}`;
// "/" is the SPA shell (adapter-static fallback); precaching it makes the
// start_url load offline.
const PRECACHE = ["/", ...build, ...files];
sw.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(PRECACHE))
.then(() => sw.skipWaiting()),
);
});
sw.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) await caches.delete(key);
}
await sw.clients.claim();
})(),
);
});
sw.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Cross-origin (the gateway) is always live network — never cached.
if (url.origin !== sw.location.origin) return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE);
// Version-keyed build/files: cache-first (content-hashed/immutable).
if (PRECACHE.includes(url.pathname)) {
const hit = await cache.match(url.pathname);
if (hit) return hit;
}
// Everything else: network-first, falling back to the cache, and
// for a navigation to the cached app shell when fully offline.
try {
const response = await fetch(request);
if (response.ok && response.type === "basic") {
cache.put(request, response.clone());
}
return response;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
if (request.mode === "navigate") {
const shell = await cache.match("/");
if (shell) return shell;
}
throw err;
}
})(),
);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+32
View File
@@ -0,0 +1,32 @@
{
"name": "Galaxy",
"short_name": "Galaxy",
"description": "Galaxy — a turn-based space strategy game.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0e1a",
"theme_color": "#0a0e1a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+6
View File
@@ -11,5 +11,11 @@ export default {
fallback: "index.html", fallback: "index.html",
strict: true, strict: true,
}), }),
serviceWorker: {
// Registered manually in the root layout for production only.
// SvelteKit's auto-registration also runs under `vite dev`, where
// the worker would intercept and cache the dev-server e2e suite.
register: false,
},
}, },
}; };
+69
View File
@@ -0,0 +1,69 @@
// F5 — PWA behaviour against a production preview build (see
// playwright.pwa.config.ts). Covers the manifest, service-worker
// registration, offline-from-cache load, and the version-keyed cache
// (a new deploy's `version` makes a new cache and `activate` drops the
// old one — verified here as "exactly one galaxy cache, version-keyed").
import { expect, test } from "@playwright/test";
test.describe("PWA", () => {
test("links a web manifest with installable icons", async ({ page }) => {
await page.goto("/login");
const href = await page
.locator('head link[rel="manifest"]')
.getAttribute("href");
expect(href).toMatch(/manifest\.webmanifest$/);
const manifest = await page.request
.get(href!)
.then((r) => r.json());
expect(manifest.name).toBe("Galaxy");
expect(manifest.start_url).toBe("/");
expect(manifest.display).toBe("standalone");
const sizes = (manifest.icons as { sizes: string }[]).map((i) => i.sizes);
expect(sizes).toContain("192x192");
expect(sizes).toContain("512x512");
const purposes = (manifest.icons as { purpose?: string }[]).map(
(i) => i.purpose,
);
expect(purposes).toContain("maskable");
});
test("registers a service worker that controls the page", async ({ page }) => {
await page.goto("/login");
await page.waitForFunction(
() => navigator.serviceWorker.controller !== null,
null,
{ timeout: 20_000 },
);
const cacheNames: string[] = await page.evaluate(() => caches.keys());
const galaxy = cacheNames.filter((n) => n.startsWith("galaxy-cache-"));
// Exactly one, version-keyed cache (old versions purged on activate).
expect(galaxy).toHaveLength(1);
expect(galaxy[0]).toMatch(/^galaxy-cache-.+/);
});
test("serves the app shell offline from the cache", async ({
page,
context,
}) => {
await page.goto("/login");
await page.waitForFunction(
() => navigator.serviceWorker.controller !== null,
null,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await context.setOffline(true);
try {
await page.reload();
// The cached shell boots offline — the login main region renders.
await expect(page.locator("#main-content")).toBeVisible({
timeout: 15_000,
});
} finally {
await context.setOffline(false);
}
});
});