diff --git a/README.md b/README.md
index 367e458..e77db0e 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ npm run check:release # Check the native distribution
npm run build:reference # Optional reference in dist-reference/
```
-Serve `dist/` through an HTTPS static host, or use `npm run preview` locally. Subdirectory hosting is supported with `npm run build -- --base=/Crystal/`. Use HTTPS from another device; ROM verification and save locking require a secure context.
+Serve `dist/` through an HTTPS static host, or use `npm run preview` locally. Over HTTPS the app can be installed from Savegames (or Safari's Add to Home Screen) and opens offline; select the ROM at each launch. Updates wait until you choose Update. Subdirectory hosting is supported with `npm run build -- --base=/Crystal/`. Use HTTPS from another device; ROM verification and save locking require a secure context.
## Development
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 81fa9de..a508075 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -30,7 +30,7 @@ flowchart LR
| `src/app` | Runtime-independent load/play/pause/backup lifecycle |
| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes |
| `src/ui`, `src/main.ts` | Accessible game tabs, UI composition and DOM dialogue, no ROM offsets |
-| `src/platform` | Browser locks and future native platform adapters |
+| `src/platform` | Browser locks, the installable app (service worker, install, storage) and future native adapters |
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only |
diff --git a/docs/PLAN.md b/docs/PLAN.md
index e44c4ce..6790879 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -235,6 +235,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- "Open in this port" lists the landmarks with at least one map in `NATIVE_MAPS`, so the atlas never suggests travel through unported areas.
- `tests/atlas.test.ts` compares every landmark position with `data/maps/landmarks.asm` and the flypoints with `data/maps/flypoints.asm`.
+## Implemented: the installable web app
+
+- `public/manifest.webmanifest` makes Crystal installable (standalone, relative start URL and scope, so subdirectory hosting works). `scripts/make-icons.mjs` renders the PNG icons, including a maskable one and the Apple touch icon, from the project's own `icon.svg`.
+- The native build emits `sw.js` (`scripts/service-worker.mjs`, a Vite plugin). It precaches exactly the built files under a cache named by their contents, serves them first and passes every other request to the network. ROMs are read from local files and never fetched, so they never reach the cache. The reference build has no worker, and development registers none.
+- Updates wait. A new version installs in the background, and a banner offers Update; the page saves, then the worker takes over and the page reloads. Nothing reloads during play without that choice. Activation deletes older shells.
+- Savegames gains App and Storage. Install uses the browser's prompt where it exists; iPhone and iPad get Safari's Share, Add to Home Screen steps. Storage shows whether saves are persistent and how much is used, and asks for persistence on request (and once at start when installed). A full disk gets its own save error, which points to backups.
+- `check:release` accepts only the four known icons beside the text assets and checks that the worker's list matches the build. Tests run the generated worker against a fake cache, and a Chromium test opens the production build offline.
+- The ROM is still selected at each launch; keeping it is a separate, explicit preference for later.
+
## Implemented: interface settings
- Settings, beside Help, sets the text size (Standard, Large, Largest: 100, 125 and 150 %) and the keyboard key of each button. Every font size in the stylesheet scales with `--text-scale`; the dialogue box scrolls instead of growing past the screen.
@@ -478,7 +487,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
- [x] Scalable text and configurable keyboard controls
- [ ] Fuller accessibility: announcements for every native screen and touch control remapping
- [ ] Save slots with migration, atomic writes and import previews
-- [ ] Installable web app with offline app shell, safe updates and quota handling
+- [x] Installable web app with offline app shell, safe updates and quota handling
- [ ] Acceptance on physical iPhone and Android devices
The first presentation increment replaces the landing page and handheld frame with a restrained Crystal-style interface. Game, Savegames, Map, Pokédex, Link and Friends stay above a viewport-filling native canvas. The renderer draws more world tiles as space grows instead of stretching a 160 × 144 image. The game and Map tab share a live local minimap; the atlas and unsupported tabs remain honest planned states. The linked UI reference could not be inspected through automated access because the site returned a Cloudflare challenge, so exact screenshot matching is not claimed.
diff --git a/index.html b/index.html
index 34ead91..9f7f82f 100644
--- a/index.html
+++ b/index.html
@@ -13,6 +13,11 @@
/>
Crystal · Native web port
+
+
+
+
+
diff --git a/playwright.config.ts b/playwright.config.ts
index 4d2c23b..46b91db 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -10,9 +10,18 @@ export default defineConfig({
use: { ...devices["iPhone 13"], defaultBrowserType: "webkit" },
},
],
- webServer: {
- command: "npm run dev -- --host 127.0.0.1",
- url: "http://127.0.0.1:5173",
- reuseExistingServer: !process.env.CI,
- },
+ webServer: [
+ {
+ command: "npm run dev -- --host 127.0.0.1",
+ url: "http://127.0.0.1:5173",
+ reuseExistingServer: !process.env.CI,
+ },
+ // The production build, for the service worker and offline checks.
+ {
+ command: "npm run build && npx vite preview --host 127.0.0.1 --port 4173 --strictPort",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ ],
});
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..4d3bd7a
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/icon-192.png b/public/icon-192.png
new file mode 100644
index 0000000..dec2403
Binary files /dev/null and b/public/icon-192.png differ
diff --git a/public/icon-512.png b/public/icon-512.png
new file mode 100644
index 0000000..dc7d4f8
Binary files /dev/null and b/public/icon-512.png differ
diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png
new file mode 100644
index 0000000..c31dc1d
Binary files /dev/null and b/public/icon-maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..0779aef
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,23 @@
+{
+ "name": "Crystal · Native web port",
+ "short_name": "Crystal",
+ "description": "A native web port of Pokémon Crystal that reads its graphics and dialogue from your own ROM.",
+ "id": "./",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#102b2a",
+ "theme_color": "#164875",
+ "icons": [
+ { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" },
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" },
+ {
+ "src": "icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs
index 8d50a5d..85e0912 100644
--- a/scripts/check-release.mjs
+++ b/scripts/check-release.mjs
@@ -1,6 +1,13 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
+import { listFiles } from "./service-worker.mjs";
+const ICONS = new Set([
+ "icon-192.png",
+ "icon-512.png",
+ "icon-maskable-512.png",
+ "apple-touch-icon.png",
+]);
let files = 0,
size = 0;
function inspect(directory) {
@@ -10,7 +17,15 @@ function inspect(directory) {
inspect(path);
continue;
}
- if (!/\.(html|js|css|svg)$/.test(name))
+ // The app icons are the only images: rendered from icon.svg, small.
+ if (ICONS.has(name)) {
+ if (statSync(path).size > 32_000)
+ throw new Error(`App icon is unexpectedly large: ${path}`);
+ files++;
+ size += statSync(path).size;
+ continue;
+ }
+ if (!/\.(html|js|css|svg|webmanifest)$/.test(name))
throw new Error(`Unexpected production asset: ${path}`);
if (/reference|vendor|binjgb/i.test(path))
throw new Error(`Reference tool in production: ${path}`);
@@ -25,6 +40,22 @@ function inspect(directory) {
}
}
inspect("dist");
+
+// The service worker precaches exactly the shell: every built file, and
+// nothing that could hold game data.
+const worker = readFileSync("dist/sw.js", "utf8");
+const shell = JSON.parse(/const SHELL = (\[.*\]);/.exec(worker)?.[1] ?? "null");
+if (!Array.isArray(shell)) throw new Error("dist/sw.js has no app shell list.");
+const expected = [
+ "./",
+ ...listFiles("dist")
+ .filter((file) => file !== "sw.js" && file !== "index.html")
+ .map((file) => `./${file}`),
+].sort();
+if (JSON.stringify([...shell].sort()) !== JSON.stringify(expected))
+ throw new Error("The service worker's shell does not match the build.");
+if (shell.some((file) => /\.(gbc?|rom|sav)$|crystal-save/i.test(file)))
+ throw new Error("The service worker would cache game data.");
console.log(
- `Native release verified: ${files} HTML/JS/CSS/SVG files, ${size} bytes; no emulator, ROM, extracted binary assets or reference checkout.`,
+ `Native release verified: ${files} files (HTML, JS, CSS, SVG, manifest, service worker and ${ICONS.size} icons), ${size} bytes; no emulator, ROM, extracted binary assets or reference checkout.`,
);
diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs
new file mode 100644
index 0000000..f12b36a
--- /dev/null
+++ b/scripts/make-icons.mjs
@@ -0,0 +1,36 @@
+// Renders public/icon.svg into the PNG icons the manifest and iOS need.
+// Run after changing the SVG: node scripts/make-icons.mjs
+import { readFileSync } from "node:fs";
+import { chromium } from "@playwright/test";
+
+const svg = readFileSync("public/icon.svg", "utf8");
+// The maskable icon keeps the artwork inside the central safe zone (80 %).
+const icons = [
+ { file: "public/icon-192.png", size: 192, inset: 0 },
+ { file: "public/icon-512.png", size: 512, inset: 0 },
+ {
+ file: "public/icon-maskable-512.png",
+ size: 512,
+ inset: 0.1,
+ background: "#102b2a",
+ },
+ {
+ file: "public/apple-touch-icon.png",
+ size: 180,
+ inset: 0.06,
+ background: "#102b2a",
+ },
+];
+
+const browser = await chromium.launch();
+const page = await browser.newPage();
+for (const { file, size, inset, background } of icons) {
+ await page.setViewportSize({ width: size, height: size });
+ const pad = size * inset;
+ await page.setContent(
+ `