From 04c7f6e68a8843478b97cc7122aea1cb3ef9c6ec Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 22 May 2026 15:46:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20installable=20offline=20PWA=20?= =?UTF-8?q?=E2=80=94=20service=20worker,=20manifest,=20icons=20(F5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native SvelteKit service worker (src/service-worker.ts): a version-keyed cache precaches the app shell + build artefacts (incl. core.wasm) + static files; activate purges old caches; the gateway is never intercepted; navigations fall back to the cached shell offline. Adds static/manifest.webmanifest, a generated placeholder icon set (scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and manifest / theme-color / apple-touch tags in app.html. Gated by Playwright against a production preview (playwright.pwa.config.ts + tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test): manifest + installable icons, SW registration + a single version-keyed cache, and offline shell load. Lighthouse is not used — its PWA category was removed in v12. Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/ui-test.yaml | 6 ++ ui/PLAN-finalize.md | 18 +++- ui/docs/README.md | 2 + ui/docs/pwa-strategy.md | 46 ++++++++ ui/frontend/package.json | 3 +- ui/frontend/playwright.pwa.config.ts | 31 ++++++ ui/frontend/scripts/gen-pwa-icons.mjs | 101 ++++++++++++++++++ ui/frontend/src/app.html | 15 +++ ui/frontend/src/service-worker.ts | 75 +++++++++++++ .../static/icons/apple-touch-icon-180.png | Bin 0 -> 867 bytes ui/frontend/static/icons/icon-192.png | Bin 0 -> 1148 bytes ui/frontend/static/icons/icon-512.png | Bin 0 -> 4460 bytes .../static/icons/icon-maskable-512.png | Bin 0 -> 3435 bytes ui/frontend/static/manifest.webmanifest | 32 ++++++ ui/frontend/tests/pwa/pwa.spec.ts | 69 ++++++++++++ 15 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 ui/docs/pwa-strategy.md create mode 100644 ui/frontend/playwright.pwa.config.ts create mode 100644 ui/frontend/scripts/gen-pwa-icons.mjs create mode 100644 ui/frontend/src/service-worker.ts create mode 100644 ui/frontend/static/icons/apple-touch-icon-180.png create mode 100644 ui/frontend/static/icons/icon-192.png create mode 100644 ui/frontend/static/icons/icon-512.png create mode 100644 ui/frontend/static/icons/icon-maskable-512.png create mode 100644 ui/frontend/static/manifest.webmanifest create mode 100644 ui/frontend/tests/pwa/pwa.spec.ts diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml index 173b7bd..97e0333 100644 --- a/.gitea/workflows/ui-test.yaml +++ b/.gitea/workflows/ui-test.yaml @@ -98,6 +98,12 @@ jobs: working-directory: ui/frontend 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 if: failure() uses: actions/upload-artifact@v4 diff --git a/ui/PLAN-finalize.md b/ui/PLAN-finalize.md index 8911a7c..0b1a5d0 100644 --- a/ui/PLAN-finalize.md +++ b/ui/PLAN-finalize.md @@ -120,7 +120,23 @@ Acceptance: every server error → a translated actionable message; consistent empty/loading/error states; the selected planet is visually 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 (wasm in CI) so the service worker caches a freshly-built artefact. diff --git a/ui/docs/README.md b/ui/docs/README.md index 626853d..27ab80a 100644 --- a/ui/docs/README.md +++ b/ui/docs/README.md @@ -31,6 +31,8 @@ lives in [`../PLAN.md`](../PLAN.md), the active web finalization in bridge, with the live function surface and parity rules. - [wasm-toolchain.md](wasm-toolchain.md) — building `ui/core` to `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). ## Auth & lobby diff --git a/ui/docs/pwa-strategy.md b/ui/docs/pwa-strategy.md new file mode 100644 index 0000000..acd14b3 --- /dev/null +++ b/ui/docs/pwa-strategy.md @@ -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` 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. diff --git a/ui/frontend/package.json b/ui/frontend/package.json index d4b1341..0cb1a39 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:pwa": "playwright test --config playwright.pwa.config.ts" }, "dependencies": { "flatbuffers": "^25.9.23", diff --git a/ui/frontend/playwright.pwa.config.ts b/ui/frontend/playwright.pwa.config.ts new file mode 100644 index 0000000..6e4e30f --- /dev/null +++ b/ui/frontend/playwright.pwa.config.ts @@ -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, + }, + }, +}); diff --git a/ui/frontend/scripts/gen-pwa-icons.mjs b/ui/frontend/scripts/gen-pwa-icons.mjs new file mode 100644 index 0000000..ab2d183 --- /dev/null +++ b/ui/frontend/scripts/gen-pwa-icons.mjs @@ -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}`); +} diff --git a/ui/frontend/src/app.html b/ui/frontend/src/app.html index c57a03b..7486906 100644 --- a/ui/frontend/src/app.html +++ b/ui/frontend/src/app.html @@ -3,6 +3,21 @@ + + + + Galaxy