diff --git a/README.md b/README.md
index 73abcfe..6a77f45 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# Mina Habits
+The production app is installable as a PWA, with an offline reconnect screen and
+user-controlled updates. Habit data and changes require a connection. See
+[PWA behavior and verification](docs/PWA.md).
+
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The React Router
client includes a homepage for signed-out visitors and a personal habit dashboard.
diff --git a/build.ts b/build.ts
index 808a90a..742171e 100644
--- a/build.ts
+++ b/build.ts
@@ -1,6 +1,7 @@
import tailwind from "bun-plugin-tailwind";
import { rm, mkdir, copyFile } from "node:fs/promises";
import path from "node:path";
+import { buildPwa } from "./scripts/build-pwa";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
@@ -36,3 +37,5 @@ await mkdir(path.join(outdir, "fonts/browser"), { recursive: true });
for (const file of ["InstrumentSerif-Regular", "InstrumentSerif-Italic", "NotoSans-Regular", "NotoSans-Medium", "NotoSans-SemiBold"]) {
await copyFile(`src/assets/fonts/${file}.woff2`, path.join(outdir, `fonts/browser/${file}.woff2`));
}
+
+await buildPwa(outdir, result.outputs);
diff --git a/bun-env.d.ts b/bun-env.d.ts
index 6d4416c..ff7c49a 100644
--- a/bun-env.d.ts
+++ b/bun-env.d.ts
@@ -9,6 +9,10 @@ declare module "*.svg" {
}
declare module "*.css" {}
+declare module "*.webmanifest" {
+ const content: string;
+ export default content;
+}
declare module "*.png" {
const path: string;
export default path;
diff --git a/bun.lock b/bun.lock
index d7c92bb..0ccf7cc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -26,6 +26,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
+ "playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2",
},
@@ -290,6 +291,10 @@
"lucide-react": ["lucide-react@1.40.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg=="],
+ "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
+
+ "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
+
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
diff --git a/docs/PWA.md b/docs/PWA.md
new file mode 100644
index 0000000..18a0f91
--- /dev/null
+++ b/docs/PWA.md
@@ -0,0 +1,103 @@
+# Progressive web app
+
+Mina Habits has an installable production build with a public offline app shell.
+Its manifest supplies a stable app identity and root scope, standalone display,
+192px and 512px icons, a separate maskable icon, app colors and descriptions.
+The HTML includes an Apple touch icon, mobile metadata, theme color and safe-area
+support. Browser chrome follows the selected light or dark theme.
+
+Install from **Install app** in the footer. Supporting browsers show their native
+install prompt. Other browsers receive instructions for Add to Home Screen or
+Add to Dock. Installed windows hide the install action.
+
+## Offline behavior
+
+After one successful online visit and service-worker installation, the application
+shell, fonts, icons and lazy browser chunks are available offline. Opening or
+reloading the app offline shows a reconnect screen instead of the browser error
+page. Nested routes also resolve correctly. Accounts retry on reconnection and
+periodically while a failed connection is visible, including when the browser
+incorrectly reports that it is online. An account request has a 10-second timeout;
+page navigations fall back to the cached shell after four seconds.
+
+**Account access, habit changes, sign-in, exports and Discord actions require a
+connection.** Existing dashboard content can remain visible in an open window,
+but no account responses are persisted for offline use and no mutations are
+queued or replayed. Failed saves must be retried by the user. This is an offline
+fallback, not offline habit editing or background synchronization.
+
+All `/api` traffic, including OAuth callback navigations, bypasses the service
+worker. API responses have `Cache-Control: no-store`. Only explicitly public
+build assets enter Cache Storage; server JavaScript, source maps, account data,
+exports and third-party responses are excluded. Clearing site data removes the
+public offline files; it does not remove account records from the server.
+
+## Build and updates
+
+`bun run build` runs `scripts/build-pwa.ts` after Bun emits the browser and server
+bundles. It derives a release identifier from the worker source and public asset
+contents, copies a version-specific public HTML shell, and emits `dist/pwa/sw.js`
+with an explicit precache list. A failed precache download prevents installation
+of that release. The stable `/sw.js` route is served as JavaScript with
+`Cache-Control: no-cache` and root service-worker scope.
+
+Updates wait until existing windows close or the user chooses **Update app**,
+then **Reload and update**. The confirmation tells the user to save unfinished
+changes. Only the accepting window reloads; other windows retain their drafts
+and receive their own update action. Old static caches remain available for their
+lazy chunks. A subsequent activation with no open app windows removes obsolete
+Mina Habits caches, leaving unrelated caches untouched. Checking for updates on
+return to the app, reconnection and hourly supports long-lived installed windows.
+
+The source manifest is `src/pwa/manifest.webmanifest`. Bun fingerprints its HTML
+link; an explicit `/manifest.webmanifest` endpoint serves the same document. The
+manifest's stable `id` keeps app identity independent of its asset filename.
+
+Install icons reuse `docs/design/mina-habits-icon.png`; regenerate their exact
+sizes with `bun scripts/pwa-icons.ts`. The maskable derivative adds opaque padding
+to keep the monogram inside the central safe zone. Existing favicon and Apple
+icons are preserved.
+
+## Serving requirements
+
+Use the normal production build/start or Docker workflow at the root of an HTTPS
+origin. Local testing may use `http://localhost` or `http://127.0.0.1`. The current
+production configuration already requires an HTTPS origin outside loopback and
+uses Caddy for TLS. No new deployment environment variables are required.
+
+Service-worker registration is deliberately disabled in development/HMR mode;
+run the production smoke check to verify caching. Deploy the entire build
+atomically, including `dist/pwa`, rather than uploading individual browser files.
+Do not apply immutable HTTP caching to `/sw.js` or add a proxy cache for `/api`.
+
+PWA installation support and its interface vary by browser. Push notifications,
+background sync and app-store packaging are optional capabilities, not
+installation requirements. This implementation follows the current
+[MDN installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
+and the documented
+[service-worker lifecycle](https://web.dev/articles/service-worker-lifecycle).
+
+## Verification
+
+```sh
+bun run typecheck
+bun test
+bun x playwright install chromium
+bun run test:pwa
+bun run test:smoke
+```
+
+`test:pwa` copies the production build into a temporary directory, starts it with
+a disposable SQLite database and no Discord credentials, then runs Chromium.
+It verifies manifest parsing and Chrome installability errors, icon dimensions,
+MIME/cache headers, first installation, offline startup and nested/lazy routes,
+reconnection, responsive layouts, theme color, failed-update recovery, a two-window
+update with an unsaved draft, API-cache exclusion, sign-out and mobile install
+instructions. Screenshots are written to `.cache/pwa/`. Temporary data and the
+server are removed when the check finishes. Worker unit tests cover routing,
+installation failure and cache cleanup.
+
+Before a public release, also exercise installation and standalone relaunch on a
+physical iPhone/iPad and Android device over the production HTTPS origin. Desktop
+Chromium checks and mobile emulation do not establish native OS installation or
+Safari behavior. The implementation does not claim a universal PWA certification.
diff --git a/package.json b/package.json
index 29ef2a0..e2d0dc2 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts",
"test:coverage": "bun test --coverage",
"test:smoke": "bun run build && bun scripts/api-smoke.ts",
+ "test:pwa": "bun run build && bun scripts/pwa-smoke.ts",
"db:backup": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/backup.ts",
"db:restore": "bun scripts/restore.ts",
"monitor": "bun --env-file=.env --env-file=.env.production scripts/monitor.ts"
@@ -40,6 +41,7 @@
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"happy-dom": "^20.14.0",
+ "playwright": "^1.63.0",
"tailwindcss": "^4.1.11",
"typescript": "^7.0.2"
}
diff --git a/scripts/build-pwa.ts b/scripts/build-pwa.ts
new file mode 100644
index 0000000..050b079
--- /dev/null
+++ b/scripts/build-pwa.ts
@@ -0,0 +1,27 @@
+import { createHash } from "node:crypto";
+import { copyFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+import { manifest } from "../src/pwa/manifest";
+
+export async function buildPwa(outdir: string, outputs: readonly { path: string }[]) {
+ const directory = path.join(outdir, "pwa");
+ await mkdir(directory, { recursive: true });
+ for (const icon of manifest.icons) await copyFile(`src/pwa/public/${path.basename(icon.src)}`, path.join(directory, path.basename(icon.src)));
+ await Bun.write(path.join(outdir, "manifest.webmanifest"), JSON.stringify(manifest, null, 2));
+ // Only browser assets emitted by Bun; index.js is the private server bundle.
+ const files = outputs.map(output => path.relative(outdir, output.path)).filter(file =>
+ file !== "index.js" && /\.(js|css|png|svg|woff2|webmanifest)$/.test(file));
+ files.push(...manifest.icons.map(icon => icon.src.slice(1)), "manifest.webmanifest");
+ for await (const file of new Bun.Glob("fonts/browser/*.woff2").scan(outdir)) files.push(file);
+ files.sort();
+ const template = await Bun.file("src/pwa/sw.js").text();
+ const html = await Bun.file(path.join(outdir, "index.html")).text();
+ const hash = createHash("sha256").update(template).update(html);
+ for (const file of files) hash.update(file).update(await Bun.file(path.join(outdir, file)).arrayBuffer().then(Buffer.from));
+ const version = hash.digest("hex").slice(0, 16);
+ const shell = `/pwa/shell-${version}.html`;
+ await Bun.write(path.join(outdir, shell.slice(1)), html);
+ const config = { version, shell, assets: [...files.map(file => `/${file}`), shell] };
+ await Bun.write(path.join(directory, "sw.js"), template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))));
+ console.log(` PWA ${version}: ${config.assets.length} public assets`);
+}
diff --git a/scripts/pwa-icons.ts b/scripts/pwa-icons.ts
new file mode 100644
index 0000000..4242e95
--- /dev/null
+++ b/scripts/pwa-icons.ts
@@ -0,0 +1,17 @@
+import { createCanvas, loadImage } from "@napi-rs/canvas";
+import { mkdir } from "node:fs/promises";
+
+// Package the approved master at exact platform sizes; do not regenerate the artwork.
+const master = await loadImage("docs/design/mina-habits-icon.png");
+await mkdir("src/pwa/public", { recursive: true });
+for (const [name, size, scale] of [
+ ["icon-192", 192, 1], ["icon-512", 512, 1], ["icon-maskable-512", 512, 0.7],
+] as const) {
+ const canvas = createCanvas(size, size);
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = "#000000";
+ ctx.fillRect(0, 0, size, size);
+ const inset = size * (1 - scale) / 2;
+ ctx.drawImage(master, inset, inset, size * scale, size * scale);
+ await Bun.write(`src/pwa/public/${name}.png`, canvas.toBuffer("image/png"));
+}
diff --git a/scripts/pwa-smoke.ts b/scripts/pwa-smoke.ts
new file mode 100644
index 0000000..d084cde
--- /dev/null
+++ b/scripts/pwa-smoke.ts
@@ -0,0 +1,194 @@
+import assert from "node:assert/strict";
+import { cp, mkdtemp, mkdir, rm, symlink } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { chromium } from "playwright";
+import { Database } from "bun:sqlite";
+import { hashToken } from "../src/auth";
+
+// Exercise a copied production bundle and disposable account. No live data or Discord calls.
+const directory = await mkdtemp(path.join(tmpdir(), "minabot-pwa-"));
+const project = process.cwd();
+const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
+const port = probe.port!;
+probe.stop(true);
+const origin = `http://127.0.0.1:${port}`;
+const databasePath = path.join(directory, "test.sqlite");
+const env = { ...process.env, NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), APP_ORIGIN: origin,
+ DATABASE_PATH: databasePath, BACKUP_ENABLED: "false", DISCORD_CLIENT_ID: "", DISCORD_CLIENT_SECRET: "", DISCORD_PUBLIC_KEY: "",
+ DISCORD_BOT_TOKEN: "", DISCORD_SHARING_CHANNEL_ID: "", AUTH_COOKIE_SECRET: "pwa-test-only-secret-at-least-32-characters" };
+let server: ReturnType | undefined;
+let browser: Awaited> | undefined;
+const screenshots = path.resolve(".cache/pwa");
+try {
+ await cp("dist", path.join(directory, "dist"), { recursive: true });
+ await symlink(path.join(project, "node_modules"), path.join(directory, "node_modules"), "dir");
+ const migration = Bun.spawn([process.execPath, "scripts/migrate.ts"], { env, stdout: "pipe", stderr: "pipe" });
+ assert.equal(await migration.exited, 0, "Production migrations failed");
+ const sqlite = new Database(databasePath);
+ const now = Date.now();
+ const token = "p".repeat(43);
+ sqlite.query("INSERT INTO users (id, discord_id, username, timezone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run("pwa", "pwa", "PWA Test", "UTC", now, now);
+ sqlite.query("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(hashToken(token), "pwa", now, now + 3600000);
+ sqlite.close();
+ server = Bun.spawn([process.execPath, "index.js"], { cwd: path.join(directory, "dist"), env, stdout: "pipe", stderr: "pipe" });
+ let healthy = false;
+ for (let attempt = 0; attempt < 100; attempt++) {
+ try { if ((await fetch(`${origin}/api/health`)).ok) { healthy = true; break; } } catch { /* Starting. */ }
+ if (server.exitCode !== null) throw new Error(await new Response(server.stderr as ReadableStream).text());
+ await Bun.sleep(50);
+ }
+ assert(healthy, "Production server failed to start");
+ const swResponse = await fetch(`${origin}/sw.js`);
+ assert.equal(swResponse.headers.get("service-worker-allowed"), "/");
+ assert.equal(swResponse.headers.get("cache-control"), "no-cache");
+ assert.match(swResponse.headers.get("content-type")!, /javascript/);
+ assert.equal((await fetch(`${origin}/pwa/index.js`)).status, 404);
+ assert.equal((await fetch(`${origin}/pwa/missing.png`)).status, 404);
+ assert.equal((await fetch(`${origin}/pwa/icon-192.png`, { method: "POST" })).status, 405);
+ assert.equal((await fetch(`${origin}/api/me`)).headers.get("cache-control"), "no-store");
+
+ browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
+ const page = await context.newPage();
+ const pageErrors: string[] = [];
+ page.on("pageerror", error => pageErrors.push(error.message));
+ await page.goto(origin);
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ await page.waitForFunction(() => Boolean(navigator.serviceWorker.controller));
+ assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0, "First install must not announce an update");
+ const cdp = await context.newCDPSession(page);
+ const manifestResult = await cdp.send("Page.getAppManifest");
+ assert.equal(manifestResult.errors.length, 0, JSON.stringify(manifestResult.errors));
+ const manifest = JSON.parse(manifestResult.data!);
+ assert.equal(manifest.id, "/");
+ assert.equal(manifest.display, "standalone");
+ assert.equal(manifest.scope, "/");
+ for (const icon of manifest.icons) {
+ const response = await fetch(new URL(icon.src, origin));
+ assert.match(response.headers.get("content-type")!, /image\/png/);
+ const png = Buffer.from(await response.arrayBuffer());
+ assert.equal(`${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`, icon.sizes);
+ }
+ const installability = await cdp.send("Page.getInstallabilityErrors");
+ assert.deepEqual(installability.installabilityErrors, []);
+ console.log("PASS Chromium installability, manifest, icon sizes, MIME types and HTTP cache policy");
+ await mkdir(screenshots, { recursive: true });
+ await page.screenshot({ path: path.join(screenshots, "desktop.png"), fullPage: true });
+ await page.getByRole("radio", { name: "Dark", exact: true }).check();
+ assert.equal(await page.locator('meta[name="theme-color"]').getAttribute("content"), "#191e1b");
+ await page.getByRole("radio", { name: "Light", exact: true }).check();
+
+ await context.setOffline(true);
+ await page.goto(`${origin}/a/deep/link`);
+ await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
+ assert(await page.getByText("We can’t connect to Mina Habits.", { exact: false }).isVisible());
+ await page.setViewportSize({ width: 375, height: 812 });
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Offline mobile layout overflows");
+ await page.screenshot({ path: path.join(screenshots, "offline-mobile.png"), fullPage: true });
+ await page.goto(`${origin}/design-system`);
+ await page.getByRole("heading", { name: /design system/i }).first().waitFor();
+ await context.setOffline(false);
+ await page.goto(origin);
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ await context.setOffline(true);
+ await page.reload();
+ await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
+ await context.setOffline(false);
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Online mobile layout overflows");
+ await page.screenshot({ path: path.join(screenshots, "mobile.png"), fullPage: true });
+ console.log("PASS offline cold start, nested routes, lazy routes, mobile layouts, theme and automatic reconnection");
+
+ // Keep an unsaved draft in another open window while applying an update here.
+ const other = await context.newPage();
+ await other.goto(origin);
+ const draft = other.getByRole("textbox", { name: "Total glasses of water", exact: true });
+ await draft.fill("6");
+ const workerPath = path.join(directory, "dist/pwa/sw.js");
+ const original = await Bun.file(workerPath).text();
+ const configMatch = original.match(/const CONFIG = JSON.parse\((.+)\);/)!;
+ const config = JSON.parse(JSON.parse(configMatch[1]!));
+ // A broken release must leave the active worker and existing offline assets usable.
+ const broken = { ...config, version: `${config.version}-broken`, assets: [...config.assets, "/pwa/missing.png"] };
+ await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(broken))});`));
+ await page.evaluate(async () => {
+ const registration = (await navigator.serviceWorker.getRegistration())!;
+ await new Promise((resolve, reject) => {
+ registration.addEventListener("updatefound", () => {
+ const worker = registration.installing!;
+ worker.addEventListener("statechange", () => { if (worker.state === "redundant") resolve(); });
+ }, { once: true });
+ void registration.update().catch(reject);
+ });
+ });
+ assert.equal(await page.getByRole("button", { name: "Update app", exact: true }).count(), 0);
+ await context.setOffline(true);
+ await page.reload();
+ await page.getByRole("heading", { name: "A moment to reconnect." }).waitFor();
+ await context.setOffline(false);
+ await page.getByRole("button", { name: "Try again", exact: true }).click();
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ config.version += "-qa";
+ await Bun.write(workerPath, original.replace(configMatch[0], `const CONFIG = JSON.parse(${JSON.stringify(JSON.stringify(config))});`));
+ await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration())!.update(); });
+ await page.getByRole("button", { name: "Update app", exact: true }).waitFor();
+ await page.getByRole("button", { name: "Update app", exact: true }).click();
+ await page.getByRole("button", { name: "Later", exact: true }).click();
+ assert.equal(await draft.inputValue(), "6");
+ await page.getByRole("button", { name: "Update app", exact: true }).click();
+ await Promise.all([page.waitForEvent("load"), page.getByRole("button", { name: "Reload and update", exact: true }).click()]);
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ assert.equal(await draft.inputValue(), "6", "Updating another window discarded its draft");
+ await other.getByRole("button", { name: "Update app", exact: true }).waitFor();
+ const keys = await page.evaluate(() => caches.keys());
+ assert(keys.some(key => key.endsWith("-qa")) && keys.length >= 2, "Old window assets were not retained");
+ console.log("PASS failed-update recovery, update waiting, deferred reload and preservation of another window’s draft");
+
+ await context.addCookies([{ name: "minabot_session", value: token, url: origin }]);
+ await page.reload();
+ await page.getByRole("button", { name: "Sign out", exact: true }).waitFor();
+ const privateResponse = await page.evaluate(async () => ({ status: (await fetch("/api/me")).status }));
+ assert.equal(privateResponse.status, 200);
+ await context.setOffline(true);
+ assert.equal(await page.evaluate(async () => { try { await fetch("/api/me"); return true; } catch { return false; } }), false, "Account response was cached");
+ await context.setOffline(false);
+ await page.getByRole("button", { name: "Sign out", exact: true }).click();
+ await page.getByRole("heading", { name: /Small steps/ }).waitFor();
+ const cacheUrls = await page.evaluate(async () => {
+ const urls: string[] = [];
+ for (const key of await caches.keys()) for (const request of await (await caches.open(key)).keys()) urls.push(new URL(request.url).pathname);
+ return urls;
+ });
+ assert(!cacheUrls.some(url => url.startsWith("/api") || url.endsWith(".map") || url === "/index.js"), "Private files entered Cache Storage");
+ assert.deepEqual(pageErrors, []);
+ console.log("PASS authenticated API bypass, sign-out privacy and no uncaught browser errors");
+ const manualContext = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
+ await manualContext.addInitScript(() => {
+ window.addEventListener("beforeinstallprompt", event => { event.preventDefault(); event.stopImmediatePropagation(); });
+ });
+ const manual = await manualContext.newPage();
+ await manual.goto(origin);
+ await manual.getByRole("button", { name: "Install app", exact: true }).click();
+ await manual.getByRole("dialog", { name: "Keep Mina Habits close." }).waitFor();
+ assert(await manual.getByText("iPhone or iPad:", { exact: false }).isVisible());
+ assert(await manual.evaluate(() => document.documentElement.scrollWidth <= innerWidth), "Install dialog overflows");
+ await manual.screenshot({ path: path.join(screenshots, "install-mobile.png"), fullPage: true });
+ await manual.getByRole("button", { name: "Got it", exact: true }).click();
+ await manual.evaluate(() => window.dispatchEvent(new Event("appinstalled")));
+ assert.equal(await manual.getByRole("button", { name: "Install app", exact: true }).count(), 0);
+ await manualContext.close();
+ console.log("PASS mobile installation guidance and installed-state controls");
+ console.log(`Screenshots: ${screenshots}`);
+} catch (error) {
+ const page = browser?.contexts()[0]?.pages()[0];
+ if (page) {
+ console.error("Browser at failure:", await page.locator("body").innerText());
+ await page.screenshot({ path: path.join(screenshots, "failure.png"), fullPage: true });
+ }
+ throw error;
+} finally {
+ await browser?.close();
+ if (server) { server.kill(); await server.exited; }
+ await rm(directory, { recursive: true, force: true });
+}
diff --git a/src/api.ts b/src/api.ts
index 77afff3..d28e53e 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -18,6 +18,8 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
app.use("*", async (c, next) => {
const requestId = crypto.randomUUID();
c.set("requestId", requestId); c.header("X-Request-ID", requestId);
+ // Accounts, calendars, exports and OAuth responses must never enter HTTP caches.
+ c.header("Cache-Control", "no-store");
await next();
});
const auth = createAuth(db, config, request, now);
diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx
index 24d3976..0fa7163 100644
--- a/src/components/AuthProvider.tsx
+++ b/src/components/AuthProvider.tsx
@@ -11,6 +11,7 @@ const authErrors: Record = {
};
function signIn() {
+ if (navigator.onLine === false) return;
let timezone = "UTC";
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ }
window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`);
@@ -21,6 +22,7 @@ type AuthState = {
loading: boolean;
busy: boolean;
accountError: string;
+ connectionError: boolean;
error: string;
signIn: () => void;
signOut: () => Promise;
@@ -34,6 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [accountError, setAccountError] = useState("");
+ const [connectionError, setConnectionError] = useState(false);
const [actionError, setActionError] = useState("");
const [attempt, setAttempt] = useState(0);
const { search } = useLocation();
@@ -42,15 +45,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
+ let reachedServer = false;
try {
- const response = await fetch("/api/me", { signal: controller.signal });
+ setAccountError("");
+ setConnectionError(false);
+ const response = await fetch("/api/me", { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]) });
+ reachedServer = true;
if (controller.signal.aborted) return;
if (response.status === 401) { setUser(null); return; }
if (!response.ok) throw new Error("Could not load your account. Please try again.");
const account: PublicUser = await response.json();
if (!controller.signal.aborted) setUser(account);
} catch (error) {
- if (!controller.signal.aborted) setAccountError(error instanceof Error ? error.message : "Could not load your account.");
+ if (!controller.signal.aborted) {
+ setConnectionError(!reachedServer);
+ setAccountError(!reachedServer ? "Could not connect to Mina Habits. Check your connection and try again." : error instanceof Error ? error.message : "Could not load your account.");
+ }
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -59,6 +69,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => controller.abort();
}, [attempt]);
+ useEffect(() => {
+ const reconnect = () => { if (accountError) retry(); };
+ const check = () => { if (connectionError && navigator.onLine !== false && document.visibilityState === "visible") retry(); };
+ const timer = window.setInterval(check, 15_000);
+ window.addEventListener("online", reconnect);
+ window.addEventListener("focus", check);
+ return () => { window.clearInterval(timer); window.removeEventListener("online", reconnect); window.removeEventListener("focus", check); };
+ }, [accountError, connectionError]);
+
function retry() {
setAccountError("");
setLoading(true);
@@ -69,6 +88,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setBusy(true);
setActionError("");
try {
+ if (navigator.onLine === false) throw new Error("Reconnect to sign out.");
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Could not sign out. Please try again.");
setUser(null);
@@ -78,7 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
return (
-
+
{children}
);
diff --git a/src/components/PwaControls.tsx b/src/components/PwaControls.tsx
new file mode 100644
index 0000000..c2fbfb8
--- /dev/null
+++ b/src/components/PwaControls.tsx
@@ -0,0 +1,43 @@
+import { useState } from "react";
+import { applyUpdate, installApp, useOnline, usePwa } from "../pwa/client";
+import { Button } from "./design-system/primitives";
+import { Modal } from "./design-system/Modal";
+
+export function InstallApp() {
+ const { enabled, installed, installPrompt } = usePwa();
+ const [help, setHelp] = useState(false);
+ if (!enabled || installed) return null;
+ return <>
+
+ {help && setHelp(false)}>
+
+
Open Mina Habits from your home screen or dock.
+
iPhone or iPad: open the Share menu, then choose Add to Home Screen.
+
Mac Safari: choose File → Add to Dock.
+
Chrome or Edge: use the install icon in the address bar, or Install app in the browser menu.
+
Installation options depend on your browser. Your habits still need a connection to load and save.
+
+
+ }
+ >;
+}
+
+export function PwaStatus() {
+ const online = useOnline();
+ const { updateReady, error } = usePwa();
+ const [confirmUpdate, setConfirmUpdate] = useState(false);
+ return <>
+ {!online && You’re offline. Reconnect to load or save your habits. Changes are not queued.
}
+ {error && {error}
}
+ {updateReady &&
+ A new version of Mina Habits is ready.
+
+
}
+ {confirmUpdate && setConfirmUpdate(false)}>
+
+
This window will reload. Save any unfinished changes first.
+
+
+ }
+ >;
+}
diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx
index 62cda6d..6c2c880 100644
--- a/src/components/ThemeProvider.tsx
+++ b/src/components/ThemeProvider.tsx
@@ -13,6 +13,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", theme === "dark" ? "#191e1b" : "#ffffff");
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
diff --git a/src/components/design-system/PageLayout.tsx b/src/components/design-system/PageLayout.tsx
index 7decf1f..d7821d3 100644
--- a/src/components/design-system/PageLayout.tsx
+++ b/src/components/design-system/PageLayout.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
+import { InstallApp, PwaStatus } from "../PwaControls";
/** The existing design-system page shell, shared without page-specific styling. */
export function PageLayout({
@@ -34,10 +35,12 @@ export function PageLayout({
{header}
+
{children}
-
+
Try it below ↓
Sign in with your Discord account to save your habits.
@@ -230,14 +234,16 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
// Refresh after returning to the page and across the account's local midnight.
useEffect(() => {
const refresh = () => {
- if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
+ if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
setAttempt((value) => value + 1);
};
const timer = window.setInterval(refresh, 60_000);
window.addEventListener("focus", refresh);
+ window.addEventListener("online", refresh);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", refresh);
+ window.removeEventListener("online", refresh);
};
}, []);
diff --git a/src/pwa/assets.ts b/src/pwa/assets.ts
new file mode 100644
index 0000000..bb215e7
--- /dev/null
+++ b/src/pwa/assets.ts
@@ -0,0 +1,26 @@
+import path from "node:path";
+import { manifest } from "./manifest";
+
+/** Serve only public PWA files. Never expose the server bundle or source maps. */
+export function pwaAsset(request: Request): Response {
+ const pathname = new URL(request.url).pathname;
+ const headers = { "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" };
+ if (!["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 405, headers: { ...headers, Allow: "GET, HEAD" } });
+ if (pathname === "/manifest.webmanifest") {
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(manifest), { headers: { ...headers, "Content-Type": "application/manifest+json" } });
+ }
+ const production = process.env.NODE_ENV === "production";
+ const filename = pathname.slice("/pwa/".length);
+ const icon = /^icon-(192|512|maskable-512)\.png$/.test(filename);
+ const shell = production && /^shell-[a-f0-9]{16}\.html$/.test(filename);
+ if (pathname !== "/sw.js" && !icon && !shell) return new Response(null, { status: 404, headers });
+ if (pathname === "/sw.js" && !production) return new Response(null, { status: 404, headers });
+ const file = Bun.file(path.resolve(production ? "pwa" : "src/pwa/public", pathname === "/sw.js" ? "sw.js" : filename));
+ if (!file.size) return new Response(null, { status: 404, headers });
+ return new Response(request.method === "HEAD" ? null : file, { headers: {
+ ...headers,
+ "Content-Type": icon ? "image/png" : shell ? "text/html; charset=utf-8" : "text/javascript; charset=utf-8",
+ ...(pathname === "/sw.js" ? { "Service-Worker-Allowed": "/" } : {}),
+ ...(shell ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
+ } });
+}
diff --git a/src/pwa/client.ts b/src/pwa/client.ts
new file mode 100644
index 0000000..b1c39a7
--- /dev/null
+++ b/src/pwa/client.ts
@@ -0,0 +1,90 @@
+import { useSyncExternalStore } from "react";
+
+type InstallPrompt = Event & {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+};
+type PwaState = { enabled: boolean; installed: boolean; installPrompt: InstallPrompt | null; updateReady: boolean; error: string };
+let state: PwaState = { enabled: false, installed: false, installPrompt: null, updateReady: false, error: "" };
+const listeners = new Set<() => void>();
+let registration: ServiceWorkerRegistration | undefined;
+let reloading = false;
+let started = false;
+const publish = (change: Partial) => {
+ state = { ...state, ...change };
+ listeners.forEach(listener => listener());
+};
+const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
+export const usePwa = () => useSyncExternalStore(subscribe, () => state, () => state);
+const subscribeOnline = (listener: () => void) => {
+ window.addEventListener("online", listener);
+ window.addEventListener("offline", listener);
+ return () => { window.removeEventListener("online", listener); window.removeEventListener("offline", listener); };
+};
+export const useOnline = () => useSyncExternalStore(subscribeOnline, () => navigator.onLine !== false, () => true);
+
+/** Called once before React renders so early install events cannot be missed. */
+export function startPwa() {
+ if (started || !window.isSecureContext || !("serviceWorker" in navigator)) return;
+ started = true;
+ const standalone = window.matchMedia("(display-mode: standalone)");
+ const installed = () => standalone.matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
+ publish({ enabled: true, installed: installed() });
+ standalone.addEventListener("change", () => publish({ installed: installed() }));
+ window.addEventListener("beforeinstallprompt", event => {
+ event.preventDefault();
+ publish({ installPrompt: event as InstallPrompt });
+ });
+ window.addEventListener("appinstalled", () => publish({ installed: true, installPrompt: null }));
+ let controller = navigator.serviceWorker.controller;
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (reloading) window.location.reload();
+ else if (controller) publish({ updateReady: true });
+ controller = navigator.serviceWorker.controller;
+ });
+ const register = async () => {
+ try {
+ registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
+ publish({ updateReady: Boolean(registration.waiting) });
+ const observeInstall = () => {
+ const worker = registration?.installing;
+ worker?.addEventListener("statechange", () => {
+ if (worker.state === "installed") publish({ updateReady: Boolean(navigator.serviceWorker.controller), error: "" });
+ if (worker.state === "redundant" && !navigator.serviceWorker.controller)
+ publish({ error: "Offline setup didn’t finish. Reconnect and reload to try again." });
+ });
+ };
+ registration.addEventListener("updatefound", observeInstall);
+ observeInstall();
+ const check = () => {
+ if (navigator.onLine && document.visibilityState === "visible") void registration?.update().catch(() => {});
+ };
+ document.addEventListener("visibilitychange", check);
+ window.addEventListener("online", check);
+ window.setInterval(check, 60 * 60 * 1000);
+ } catch {
+ publish({ error: "Offline setup is unavailable. Reconnect and reload to try again." });
+ }
+ };
+ if (document.readyState === "complete") void register();
+ else window.addEventListener("load", () => { void register(); }, { once: true });
+}
+
+export async function installApp(): Promise {
+ const prompt = state.installPrompt;
+ if (!prompt) return false;
+ publish({ installPrompt: null });
+ try {
+ await prompt.prompt();
+ await prompt.userChoice;
+ } catch { publish({ error: "Could not open installation. Try your browser’s install menu." }); }
+ return true;
+}
+
+export function applyUpdate() {
+ if (!state.updateReady) return;
+ if (registration?.waiting) {
+ reloading = true;
+ registration.waiting.postMessage({ type: "ACTIVATE_UPDATE" });
+ } else window.location.reload();
+}
diff --git a/src/pwa/manifest.ts b/src/pwa/manifest.ts
new file mode 100644
index 0000000..baa3f8a
--- /dev/null
+++ b/src/pwa/manifest.ts
@@ -0,0 +1,9 @@
+import source from "./manifest.webmanifest" with { type: "text" };
+
+export const manifest = JSON.parse(source) as {
+ id: string; name: string; short_name: string; description: string;
+ lang: string; dir: string; start_url: string; scope: string; display: string;
+ background_color: string; theme_color: string; categories: string[];
+ prefer_related_applications: boolean;
+ icons: { src: string; sizes: string; type: string; purpose: string }[];
+};
diff --git a/src/pwa/manifest.webmanifest b/src/pwa/manifest.webmanifest
new file mode 100644
index 0000000..7fe8726
--- /dev/null
+++ b/src/pwa/manifest.webmanifest
@@ -0,0 +1,38 @@
+{
+ "id": "/",
+ "name": "Mina Habits",
+ "short_name": "Mina Habits",
+ "description": "A quiet place to track your habits and see your progress.",
+ "lang": "en",
+ "dir": "ltr",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "categories": [
+ "productivity",
+ "lifestyle"
+ ],
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "/pwa/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/pwa/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/pwa/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/pwa/public/icon-192.png b/src/pwa/public/icon-192.png
new file mode 100644
index 0000000..0c2a33d
Binary files /dev/null and b/src/pwa/public/icon-192.png differ
diff --git a/src/pwa/public/icon-512.png b/src/pwa/public/icon-512.png
new file mode 100644
index 0000000..2f65958
Binary files /dev/null and b/src/pwa/public/icon-512.png differ
diff --git a/src/pwa/public/icon-maskable-512.png b/src/pwa/public/icon-maskable-512.png
new file mode 100644
index 0000000..8f8c9fd
Binary files /dev/null and b/src/pwa/public/icon-maskable-512.png differ
diff --git a/src/pwa/sw.js b/src/pwa/sw.js
new file mode 100644
index 0000000..f7164a2
--- /dev/null
+++ b/src/pwa/sw.js
@@ -0,0 +1,66 @@
+/* Build injects a content-derived version and an explicit public-asset allowlist. */
+const CONFIG = JSON.parse("__PWA_CONFIG__");
+const PREFIX = "mina-habits-static-";
+const CACHE = PREFIX + CONFIG.version;
+const ASSETS = new Set(CONFIG.assets);
+
+self.addEventListener("install", event => {
+ event.waitUntil((async () => {
+ const cache = await caches.open(CACHE);
+ // addAll is atomic: a failed download cannot activate a partial release.
+ await cache.addAll(CONFIG.assets.map(url => new Request(url, { cache: "reload", credentials: "omit" })));
+ })());
+});
+
+self.addEventListener("activate", event => {
+ event.waitUntil((async () => {
+ // A user can accept an update while other windows still hold older code.
+ // Preserve their lazy chunks until a later activation with no open windows.
+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
+ if (windows.length === 0) {
+ await Promise.all((await caches.keys()).filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)));
+ }
+ await self.clients.claim();
+ })());
+});
+
+self.addEventListener("message", event => {
+ if (event.data?.type === "ACTIVATE_UPDATE") event.waitUntil(self.skipWaiting());
+});
+
+async function cachedAsset(request) {
+ const current = await caches.open(CACHE);
+ const cached = await current.match(request);
+ if (cached) return cached;
+ // Old windows can still request fingerprinted chunks after an accepted update.
+ for (const key of await caches.keys()) {
+ if (key.startsWith(PREFIX) && key !== CACHE) {
+ const response = await (await caches.open(key)).match(request);
+ if (response) return response;
+ }
+ }
+ return fetch(request);
+}
+
+async function navigate(request) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 4000);
+ try {
+ const response = await fetch(request, { signal: controller.signal });
+ if (response.ok) return response;
+ } catch { /* Unreachable server or offline: use the public shell. */ }
+ finally { clearTimeout(timeout); }
+ return (await (await caches.open(CACHE)).match(CONFIG.shell)) || Response.error();
+}
+
+self.addEventListener("fetch", event => {
+ const request = event.request;
+ const url = new URL(request.url);
+ // In particular: no account data, OAuth callbacks, exports, writes or third-party responses.
+ if (request.method !== "GET" || url.origin !== self.location.origin || /^\/api(?:\/|$)/.test(url.pathname)) return;
+ if (request.mode === "navigate") {
+ event.respondWith(navigate(request));
+ } else if (ASSETS.has(url.pathname) || /^\/chunk-[\w-]+\.js$/.test(url.pathname)) {
+ event.respondWith(cachedAsset(request));
+ }
+});
diff --git a/src/pwa/sw.test.ts b/src/pwa/sw.test.ts
new file mode 100644
index 0000000..f26a108
--- /dev/null
+++ b/src/pwa/sw.test.ts
@@ -0,0 +1,99 @@
+import { expect, test } from "bun:test";
+import { runInNewContext } from "node:vm";
+
+const template = await Bun.file(new URL("./sw.js", import.meta.url)).text();
+const origin = "https://mina.example";
+const cacheName = "mina-habits-static-test";
+
+function worker({ offline = false, windows = 0, failInstall = false } = {}) {
+ const events = new Map void>();
+ const stores = new Map>([
+ [cacheName, new Map([[`${origin}/pwa/shell-test.html`, new Response("offline shell")]])],
+ ["mina-habits-static-old", new Map([[`${origin}/chunk-old.js`, new Response("old chunk")]])],
+ ["unrelated-cache", new Map()],
+ ]);
+ const deleted: string[] = [];
+ const fetched: string[] = [];
+ let skipped = false;
+ let claimed = false;
+ let installed: Request[] = [];
+ const requestOptions: RequestInit[] = [];
+ const url = (input: Request | string) => new URL(typeof input === "string" ? input : input.url, origin).href;
+ const config = { version: "test", shell: "/pwa/shell-test.html", assets: ["/chunk-new.js", "/pwa/shell-test.html"] };
+ runInNewContext(template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))), {
+ self: {
+ location: { origin }, addEventListener: (type: string, handler: (event: any) => void) => events.set(type, handler),
+ skipWaiting: async () => { skipped = true; },
+ clients: { matchAll: async () => Array.from({ length: windows }, () => ({})), claim: async () => { claimed = true; } },
+ },
+ caches: {
+ open: async (name: string) => ({
+ match: async (request: Request | string) => stores.get(name)?.get(url(request))?.clone(),
+ addAll: async (requests: Request[]) => { if (failInstall) throw new Error("download failed"); installed = requests; },
+ }),
+ keys: async () => [...stores.keys()],
+ delete: async (name: string) => { deleted.push(name); return stores.delete(name); },
+ },
+ fetch: async (request: Request) => { fetched.push(request.url); if (offline) throw new TypeError("offline"); return new Response("network"); },
+ Request: class extends Request { constructor(input: string, init?: RequestInit) { super(url(input), init); requestOptions.push(init ?? {}); } },
+ Response, Set, URL, AbortController, setTimeout, clearTimeout,
+ });
+ async function lifecycle(type: string, data?: unknown) {
+ let promise: Promise | undefined;
+ events.get(type)!({ data, waitUntil: (value: Promise) => { promise = value; } });
+ await promise;
+ }
+ function request(pathname: string, method = "GET", mode = "cors") {
+ let response: Promise | undefined;
+ events.get("fetch")!({ request: { url: new URL(pathname, origin).href, method, mode }, respondWith: (value: Promise) => { response = value; } });
+ return response;
+ }
+ return { request, lifecycle, deleted, fetched, requestOptions, get skipped() { return skipped; }, get claimed() { return claimed; }, get installed() { return installed; } };
+}
+
+test("service worker excludes private API and OAuth navigations, writes and other origins", () => {
+ const sw = worker();
+ for (const route of ["/api", "/api/me", "/api/account/export", "/api/auth/discord/callback?code=private"]) {
+ expect(sw.request(route, "GET", "navigate")).toBeUndefined();
+ }
+ expect(sw.request("/chunk-new.js", "POST")).toBeUndefined();
+ expect(sw.request("https://discord.com/api/me")).toBeUndefined();
+ expect(sw.request("/private.json")).toBeUndefined();
+ expect(sw.request("/index.js")).toBeUndefined();
+ expect(sw.request("/chunk-new.js.map")).toBeUndefined();
+});
+
+test("network navigation is fresh while offline navigation uses only the public shell", async () => {
+ expect(await (await worker().request("/", "GET", "navigate"))!.text()).toBe("network");
+ const sw = worker({ offline: true });
+ expect(await (await sw.request("/a/deep/link", "GET", "navigate"))!.text()).toBe("offline shell");
+});
+
+test("older windows can load their cached lazy chunks after an update", async () => {
+ const sw = worker({ offline: true });
+ expect(await (await sw.request("/chunk-old.js"))!.text()).toBe("old chunk");
+ expect(sw.fetched).toEqual([]);
+});
+
+test("install excludes credentials and waits for every public asset without forcing activation", async () => {
+ const sw = worker();
+ await sw.lifecycle("install");
+ expect(sw.installed).toHaveLength(2);
+ expect(sw.requestOptions.every(options => options.credentials === "omit" && options.cache === "reload")).toBe(true);
+ expect(sw.skipped).toBe(false);
+ await expect(worker({ failInstall: true }).lifecycle("install")).rejects.toThrow("download failed");
+});
+
+test("activation preserves old windows and cleans only its own obsolete caches when windows close", async () => {
+ const occupied = worker({ windows: 2 });
+ await occupied.lifecycle("activate");
+ expect(occupied.deleted).toEqual([]);
+ expect(occupied.claimed).toBe(true);
+ const empty = worker();
+ await empty.lifecycle("activate");
+ expect(empty.deleted).toEqual(["mina-habits-static-old"]);
+ await empty.lifecycle("message", { type: "unrelated" });
+ expect(empty.skipped).toBe(false);
+ await empty.lifecycle("message", { type: "ACTIVATE_UPDATE" });
+ expect(empty.skipped).toBe(true);
+});
diff --git a/styles/design-system.css b/styles/design-system.css
index ac3aed5..9eeb246 100644
--- a/styles/design-system.css
+++ b/styles/design-system.css
@@ -52,6 +52,8 @@
position: relative;
margin: -8px;
min-height: 100vh;
+ min-height: 100dvh;
+ padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
color: var(--ds-ink);
background: var(--ds-paper);
@apply type-body;
@@ -770,6 +772,7 @@
border-top: 1px solid var(--ds-rule);
padding: 28px 0 40px;
display: flex;
+ flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 20px;