diff --git a/web/base-path.js b/web/base-path.js
new file mode 100644
index 0000000..f494cd9
--- /dev/null
+++ b/web/base-path.js
@@ -0,0 +1,34 @@
+/**
+ * SvelteKit `paths.base` resolution, shared between `svelte.config.js`
+ * (page routing / prerendering) and `vite.config.js` (PWA precache
+ * manifest). Both consumers MUST agree on the base or the service
+ * worker's `additionalManifestEntries` end up origin-rooted while the
+ * app itself serves under a sub-path (e.g. GitHub Pages `/loto`),
+ * causing every precached URL to 404 and the SW install to fail.
+ *
+ * Resolution order:
+ * 1. NEXT_BASE_PATH — explicit override (escape hatch)
+ * 2. codeserver dev → /absproxy/{port}
+ * 3. BUILD_PROFILE=gh → /loto
+ * 4. default (local dev / generic static host) → ""
+ *
+ * @module base-path
+ */
+import { loadEnv } from "vite";
+
+/** @returns {string} */
+export function resolveBase() {
+ if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
+ if (process.env.VITE_DEV_PROFILE === "codeserver") {
+ // .env.local lives outside process.env at config-eval time; loadEnv reads it.
+ const env = loadEnv(
+ process.env.NODE_ENV ?? "development",
+ process.cwd(),
+ "",
+ );
+ const port = env.CODESERVER_PORT ?? "3000";
+ return `/absproxy/${port}`;
+ }
+ if (process.env.BUILD_PROFILE === "gh") return "/loto";
+ return "";
+}
diff --git a/web/jsconfig.json b/web/jsconfig.json
index a8ef6e9..e1f6d9c 100644
--- a/web/jsconfig.json
+++ b/web/jsconfig.json
@@ -2,6 +2,6 @@
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
- "checkJs": false
+ "checkJs": true
}
}
diff --git a/web/package-lock.json b/web/package-lock.json
index f8689bf..eabcbd4 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -20,6 +20,7 @@
"globals": "^17",
"happy-dom": "20.11.0",
"svelte": "^5",
+ "svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vitest": "4.1.10"
@@ -1903,9 +1904,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2025,9 +2023,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2045,9 +2040,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2065,9 +2057,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2085,9 +2074,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2105,9 +2091,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2125,9 +2108,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2414,9 +2394,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2431,9 +2408,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2448,9 +2422,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2465,9 +2436,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2482,9 +2450,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2499,9 +2464,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2516,9 +2478,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2533,9 +2492,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2550,9 +2506,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2567,9 +2520,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2584,9 +2534,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2601,9 +2548,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2618,9 +2562,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2780,6 +2721,16 @@
}
}
},
+ "node_modules/@sveltejs/load-config": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
+ "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz",
@@ -2943,9 +2894,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2963,9 +2911,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2983,9 +2928,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3003,9 +2945,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3712,6 +3651,22 @@
"node": ">=18"
}
},
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -5822,9 +5777,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5846,9 +5798,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5869,9 +5818,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5893,9 +5839,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5959,9 +5902,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6071,6 +6011,16 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -6519,6 +6469,20 @@
"node": ">=6"
}
},
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6732,6 +6696,19 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/safe-array-concat": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
@@ -7233,6 +7210,31 @@
"node": ">=18"
}
},
+ "node_modules/svelte-check": {
+ "version": "4.7.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
+ "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "@sveltejs/load-config": "^0.2.3",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
"node_modules/svelte-eslint-parser": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.1.tgz",
@@ -7539,6 +7541,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -7943,9 +7960,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7967,9 +7981,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7991,9 +8002,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -8591,22 +8599,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/yaml": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
- "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
- "extraneous": true,
- "license": "ISC",
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/web/package.json b/web/package.json
index 88fa8ec..b47a598 100644
--- a/web/package.json
+++ b/web/package.json
@@ -10,6 +10,8 @@
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint .",
+ "check": "svelte-check --tsconfig ./jsconfig.json",
+ "verify:pwa": "node scripts/verify-pwa-build.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
@@ -34,6 +36,7 @@
"globals": "^17",
"happy-dom": "20.11.0",
"svelte": "^5",
+ "svelte-check": "^4.7.6",
"tailwindcss": "^4",
"vite": "^8.1.5",
"vitest": "4.1.10"
diff --git a/web/scripts/verify-pwa-build.mjs b/web/scripts/verify-pwa-build.mjs
new file mode 100644
index 0000000..1813670
--- /dev/null
+++ b/web/scripts/verify-pwa-build.mjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+/**
+ * Build-time regression guard for the PWA layer.
+ *
+ * `adapter-static` renders page HTML during `adapt()`, which runs AFTER the
+ * PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets to inject
+ * its own registration tag into the built HTML, and a plain grep on
+ * `build/index.html` proves nothing either way. `+layout.svelte` registers
+ * the service worker itself via a dynamically-imported `virtual:pwa-register`
+ * chunk instead; this script asserts that wiring survived the build rather
+ * than trusting a future refactor not to quietly drop it.
+ *
+ * Also asserts the audio precache manifest in `build/sw.js` is prefixed
+ * with the SAME base path SvelteKit itself resolved for this build — a
+ * mismatch here 404s every precached clip and fails the SW `install` event
+ * on that origin (this exact bug shipped once already).
+ *
+ * Usage: run AFTER `npm run build` / `npm run build:gh` (see package.json's
+ * `verify:pwa` script), with the same `BUILD_PROFILE`/`NEXT_BASE_PATH` env
+ * the build used.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+import { resolveBase } from "../base-path.js";
+
+const BUILD_DIR = "build";
+let ok = true;
+
+/** @param {string} msg */
+function fail(msg) {
+ console.error(`✗ ${msg}`);
+ ok = false;
+}
+
+/** @param {string} dir */
+function walkJsFiles(dir) {
+ /** @type {string[]} */
+ const files = [];
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) files.push(...walkJsFiles(full));
+ else if (entry.endsWith(".js")) files.push(full);
+ }
+ return files;
+}
+
+// --- sw.js was actually generated ---------------------------------------
+const swPath = join(BUILD_DIR, "sw.js");
+let swSrc = "";
+try {
+ swSrc = readFileSync(swPath, "utf8");
+} catch {
+ fail(`${swPath} not found — did the PWA plugin run?`);
+ process.exit(1);
+}
+
+// --- C1: some built client chunk actually registers it -------------------
+const appDir = join(BUILD_DIR, "_app");
+const jsFiles = walkJsFiles(appDir);
+const registersSW = jsFiles.some((f) => {
+ const src = readFileSync(f, "utf8");
+ return src.includes("serviceWorker") && src.includes("sw.js");
+});
+if (!registersSW) {
+ fail(
+ `No built JS chunk under ${appDir} registers the service worker ` +
+ '(expected a "serviceWorker" + "sw.js" reference reachable from the ' +
+ "virtual:pwa-register import in +layout.svelte).",
+ );
+}
+
+// --- H1: precached audio entries carry the resolved base path -----------
+const base = resolveBase();
+const audioUrls = [...swSrc.matchAll(/"([^"]*\/audio\/[^"]*\.mp3)"/g)].map(
+ (m) => m[1],
+);
+if (audioUrls.length === 0) {
+ fail(`${swPath} has no precached audio entries — expected the default voice's clips.`);
+} else {
+ const badUrls = audioUrls.filter((u) => !u.startsWith(`${base}/audio/`));
+ if (badUrls.length > 0) {
+ fail(
+ `${badUrls.length}/${audioUrls.length} precached audio URL(s) don't ` +
+ `start with "${base}/audio/" (resolved base "${base}") — e.g. "${badUrls[0]}". ` +
+ "additionalManifestEntries in vite.config.js and svelte.config.js's " +
+ "paths.base have drifted apart again.",
+ );
+ }
+}
+
+if (!ok) process.exit(1);
+console.log(
+ `✓ PWA build check passed — base "${base}", ${jsFiles.length} client JS files, ` +
+ `${audioUrls.length} precached audio entries.`,
+);
diff --git a/web/src/app.d.ts b/web/src/app.d.ts
new file mode 100644
index 0000000..b450547
--- /dev/null
+++ b/web/src/app.d.ts
@@ -0,0 +1,17 @@
+///
+
+// See https://svelte.dev/docs/kit/types#app.d.ts for what these interfaces
+// are for. None are used yet — this file's real job right now is the
+// triple-slash reference above, which supplies the `virtual:pwa-register`
+// module types that `+layout.svelte` imports.
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/web/src/app.html b/web/src/app.html
index a93df73..e1ea3ee 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -9,6 +9,44 @@
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
+
+
Lô tô — Hội chợ TN1
{
- // Subscribe to both arrays so any mutation re-saves.
+ // Subscribe to both arrays so any mutation re-saves. Gated on
+ // `hydrated` so a mount can never persist stale in-memory state before
+ // `loadMaster()` has (re-)run — see `claimActiveTab()` in
+ // active-tab.svelte.js for the reclaim-a-frozen-tab case this guards.
masterState.called;
masterState.remaining;
- saveMaster();
+ if (masterState.hydrated) saveMaster();
});
// Map number -> 1-based draw order for fast Kinh! verification.
@@ -150,6 +153,11 @@
}
function handleDrawNext() {
+ // "Xổ số" is a real click — prime the shared element here so
+ // later auto-call playback (fired from a setInterval callback, which
+ // iOS does not treat as a user gesture) is already unlocked. No-op
+ // after the first call.
+ unlockAudio();
const next = drawNext();
if (next === null) return;
scrollOnNextDraw = true;
@@ -159,6 +167,8 @@
function toggleAuto() {
if (masterState.remaining.length === 0) return;
+ // "Bắt đầu" is a real click — see handleDrawNext's comment.
+ unlockAudio();
autoRunning = !autoRunning;
}
diff --git a/web/src/lib/PlayerBoard.svelte b/web/src/lib/PlayerBoard.svelte
index 8d9d7a0..aa37033 100644
--- a/web/src/lib/PlayerBoard.svelte
+++ b/web/src/lib/PlayerBoard.svelte
@@ -12,6 +12,7 @@
saveGrid,
saveManualUnticks,
} from "$lib/game-logic.js";
+ import { focusTrap } from "$lib/focus-trap.js";
import { masterState } from "$lib/master-store.svelte.js";
import { pushOverlay } from "$lib/overlay-history.js";
import { applyMasterCalls } from "$lib/player-auto-cross.js";
@@ -39,6 +40,11 @@
let congratsRow = $state(-1);
let celebrationTier = $state(/** @type {1 | 2} */ (1));
let toast = $state(/** @type {string | null} */ (null));
+ // Bumped on every showToast() call so `{#key toastId}` force-remounts the
+ // toast node — otherwise a 2nd "Chờ" within 5s reuses the same DOM node,
+ // the CSS fade-out animation never restarts, and the node (still
+ // pointer-events-auto) sits invisible-but-clickable over the board.
+ let toastId = $state(0);
// 12 confetti emoji indices. Stable per-render — values don't matter,
// only the count drives the {#each}.
@@ -68,24 +74,29 @@
// player crossed in both mode per locked product decision.
let prevCalledLen = $state(0);
- // Memoized per-row completeness — avoid 81×/render isRowComplete calls
- const rowCompleteness = $derived(
- grid && crossed.length
- ? grid.map((_, r) => isRowComplete(grid, crossed, r))
- : []
- );
+ // Memoized per-row completeness — avoid 81×/render isRowComplete calls.
+ // Reads `grid` into a local `g` first: TS can't carry the null-narrowing
+ // from the ternary condition into the `.map()` callback closure for a
+ // mutable outer binding, even though nothing reassigns `grid` mid-eval.
+ const rowCompleteness = $derived.by(() => {
+ const g = grid;
+ return g && crossed.length
+ ? g.map((_, r) => isRowComplete(g, crossed, r))
+ : [];
+ });
// Per-row "Chờ" flag — one cell away AND not already complete. Reuses
// rowCompleteness so we don't re-walk the row twice.
- const waitingRows = $derived(
- grid && crossed.length
- ? grid.map(
+ const waitingRows = $derived.by(() => {
+ const g = grid;
+ return g && crossed.length
+ ? g.map(
(_, r) =>
!rowCompleteness[r] &&
- getWaitingNumber(grid, crossed, r) !== null,
+ getWaitingNumber(g, crossed, r) !== null,
)
- : []
- );
+ : [];
+ });
// "row,col" keys of cells holding the awaited number for any waiting
// row. Drives the per-cell pulse animation so the user can spot which
@@ -117,6 +128,7 @@
function showToast(msg) {
dismissToast();
toast = msg;
+ toastId++;
toastTimer = setTimeout(() => {
toast = null;
}, 5000);
@@ -160,7 +172,11 @@
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
$effect(() => {
- if (!grid || crossed.length === 0) return;
+ // Captured into `g` so the null-narrowing survives the `.some()`
+ // callback below (TS drops narrowing of a mutable outer binding once
+ // it's referenced inside a nested function expression).
+ const g = grid;
+ if (!g || crossed.length === 0) return;
// The master takes over announcer duties in "both" mode, so its
// voice flag also drives Chờ/Kinh. Solo players keep their own flag.
@@ -168,9 +184,16 @@
settings.voiceEnabledPlayer ||
(settings.voiceEnabledMaster && settings.mode === "both");
+ // Set when pass 1 fires playBingo() this run, so pass 2 knows not to
+ // cut it off — playWaiting()/playBingo() both start with
+ // cancelPlayback(), so an unconditional pass-2 call would silence the
+ // win announcement in the exact "completed one row, one away on
+ // another" run where it matters most.
+ let announcedBingo = false;
+
// Pass 1: at most one bingo popup per render
- for (let i = 0; i < grid.length; i++) {
- if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
+ for (let i = 0; i < g.length; i++) {
+ if (!celebratedRows.has(i) && isRowComplete(g, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
@@ -178,29 +201,32 @@
// Tier 2 confetti: 2nd bingo, OR 1st bingo while another row
// is one cell away. The previous "3+ bingos" threshold rarely
// fired on a 9-row card so most wins felt under-celebrated.
- const hasActiveCho = grid.some(
+ const hasActiveCho = g.some(
(_, r) =>
!celebratedRows.has(r) &&
- getWaitingNumber(grid, crossed, r) !== null,
+ getWaitingNumber(g, crossed, r) !== null,
);
celebrationTier =
celebratedRows.size >= 2 ||
(celebratedRows.size >= 1 && hasActiveCho)
? 2
: 1;
- if (announce) playBingo();
+ if (announce) {
+ playBingo();
+ announcedBingo = true;
+ }
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
- for (let i = 0; i < grid.length; i++) {
+ for (let i = 0; i < g.length; i++) {
if (celebratedRows.has(i)) continue;
- const waitNum = getWaitingNumber(grid, crossed, i);
+ const waitNum = getWaitingNumber(g, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
- if (announce) playWaiting(waitNum);
+ if (announce && !announcedBingo) playWaiting(waitNum);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
@@ -494,21 +520,26 @@
-
-
+ {#key toastId}
+
- {toast}
-
-
+
+ {toast}
+
+
+ {/key}
{/if}
{:else}
@@ -568,6 +599,7 @@
role="dialog"
aria-modal="true"
aria-labelledby="congrats-title"
+ use:focusTrap
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
>
import { VOICES } from "$lib/audio-manifest.js";
+ import { focusTrap } from "$lib/focus-trap.js";
import { pushOverlay } from "$lib/overlay-history.js";
import {
BOARD_TEXT_SCALES,
@@ -177,6 +178,7 @@
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
+ use:focusTrap
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
>
{};
bc = new BroadcastChannel(CHANNEL);
bc.onmessage = (e) => {
- if (e.data?.type === "claim" && e.data.id !== TAB_ID) {
+ if (e.data?.type !== "claim" || e.data.id === TAB_ID) return;
+ // A legacy peer (pre-echo build) broadcasts {type, id} with no `ts`.
+ // Treat that as beating any real timestamp so this tab freezes instead
+ // of silently ignoring it (matches the old always-freeze behavior).
+ const peerTs = typeof e.data.ts === "number" ? e.data.ts : Infinity;
+ // Newest claim wins. Ignore a peer claim that is older than this tab's
+ // own claim; on an exact tie (same millisecond) the lexicographically
+ // greater id wins so exactly one side freezes.
+ const peerWins =
+ peerTs > myClaimTs || (peerTs === myClaimTs && e.data.id > TAB_ID);
+ if (peerWins) {
activeTab.inactive = true;
+ } else if (!activeTab.inactive && bc) {
+ // This tab wins but the peer doesn't know it lost yet — it set
+ // itself active locally before broadcasting. Echo this tab's own
+ // winning claim so the peer re-evaluates and freezes. This can't
+ // loop: the echo carries this tab's unchanged (ts, id), so the peer
+ // strictly loses it and freezes without ever echoing back (a frozen
+ // tab never claims).
+ bc.postMessage({ type: "claim", id: TAB_ID, ts: myClaimTs });
}
};
- bc.postMessage({ type: "claim", id: TAB_ID });
+ postClaim();
return () => {
if (bc) {
bc.close();
@@ -52,10 +88,11 @@ export function watchActiveTab() {
/**
* Take active status back from the inactive banner. Marks this tab
- * active locally and broadcasts a claim — any peer freezes itself in
- * turn so the handover is symmetric.
+ * active locally, records a fresh claim timestamp (so a stale peer claim
+ * can't re-freeze it), and broadcasts the claim — any peer freezes itself
+ * in turn so the handover is symmetric.
*/
export function claimActiveTab() {
activeTab.inactive = false;
- if (bc) bc.postMessage({ type: "claim", id: TAB_ID });
+ postClaim();
}
diff --git a/web/src/lib/active-tab.test.js b/web/src/lib/active-tab.test.js
index 933dd00..89c9a69 100644
--- a/web/src/lib/active-tab.test.js
+++ b/web/src/lib/active-tab.test.js
@@ -9,20 +9,33 @@ class FakeBC {
/** @param {string} name */
constructor(name) {
this.name = name;
+ /** @type {((e: { data: any }) => void) | null} */
this.onmessage = null;
this.closed = false;
const peers = FakeBC.channels.get(name) ?? [];
peers.push(this);
FakeBC.channels.set(name, peers);
}
- /** @param {any} data */
+ /**
+ * Defers delivery to a microtask and re-reads the peer list at delivery
+ * time (not at call time), so two channels opened in the same tick — the
+ * "two tabs mount simultaneously" case under test — both see each other's
+ * claim. This matches real `BroadcastChannel`, which queues a task and
+ * resolves the receiving set when that task runs, not when postMessage
+ * is called.
+ * @param {any} data
+ */
postMessage(data) {
if (this.closed) return;
- const peers = FakeBC.channels.get(this.name) ?? [];
- for (const p of peers) {
- if (p === this || p.closed || !p.onmessage) continue;
- p.onmessage({ data });
- }
+ const name = this.name;
+ const sender = this;
+ queueMicrotask(() => {
+ const peers = FakeBC.channels.get(name) ?? [];
+ for (const p of peers) {
+ if (p === sender || p.closed || !p.onmessage) continue;
+ p.onmessage({ data });
+ }
+ });
}
close() {
this.closed = true;
@@ -34,19 +47,37 @@ class FakeBC {
}
}
+/** Flush FakeBC's queued microtask message deliveries. */
+async function deliverBcMessages() {
+ await Promise.resolve();
+}
+
beforeEach(() => {
FakeBC.channels.clear();
vi.stubGlobal("BroadcastChannel", /** @type {any} */ (FakeBC));
vi.resetModules();
+ // Default: a strictly increasing "clock" so claim order matches call
+ // order without every test having to stub a value. Individual tests
+ // override a single call with `mockReturnValueOnce` when they need an
+ // exact (e.g. equal or stale) timestamp.
+ let clock = 0;
+ vi.spyOn(Date, "now").mockImplementation(() => {
+ clock += 1;
+ return clock;
+ });
});
afterEach(() => {
vi.unstubAllGlobals();
+ vi.restoreAllMocks();
});
describe("active-tab", () => {
it("watchActiveTab returns no-op cleanup when BroadcastChannel is missing", async () => {
vi.stubGlobal("BroadcastChannel", undefined);
+ // @ts-expect-error - "?tag" is a real relative import at runtime (Vite/
+ // Vitest cache-busting for module-singleton isolation between tests);
+ // TS can't resolve the literal query-string specifier.
const mod = await import("./active-tab.svelte.js?nobc");
const cleanup = mod.watchActiveTab();
expect(typeof cleanup).toBe("function");
@@ -55,27 +86,35 @@ describe("active-tab", () => {
});
it("second tab's claim marks the first tab inactive", async () => {
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
const tabA = await import("./active-tab.svelte.js?a");
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
const tabB = await import("./active-tab.svelte.js?b");
tabA.watchActiveTab();
expect(tabA.activeTab.inactive).toBe(false);
tabB.watchActiveTab();
+ await deliverBcMessages();
expect(tabA.activeTab.inactive).toBe(true);
expect(tabB.activeTab.inactive).toBe(false);
});
it("claimActiveTab reactivates self and inactivates the other", async () => {
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
const tabA = await import("./active-tab.svelte.js?ra-a");
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
const tabB = await import("./active-tab.svelte.js?ra-b");
tabA.watchActiveTab();
tabB.watchActiveTab();
+ await deliverBcMessages();
expect(tabA.activeTab.inactive).toBe(true);
tabA.claimActiveTab();
expect(tabA.activeTab.inactive).toBe(false);
+ await deliverBcMessages();
expect(tabB.activeTab.inactive).toBe(true);
});
it("watchActiveTab is idempotent within a single module", async () => {
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
const tab = await import("./active-tab.svelte.js?idem");
const c1 = tab.watchActiveTab();
const c2 = tab.watchActiveTab();
@@ -84,4 +123,168 @@ describe("active-tab", () => {
c1();
c2();
});
+
+ it("simultaneous mount with unequal ts: older claim freezes, newer stays active", async () => {
+ vi.mocked(Date.now).mockReturnValueOnce(1000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabOlder = await import("./active-tab.svelte.js?ts-older");
+ tabOlder.watchActiveTab();
+
+ vi.mocked(Date.now).mockReturnValueOnce(2000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabNewer = await import("./active-tab.svelte.js?ts-newer");
+ tabNewer.watchActiveTab();
+ await deliverBcMessages();
+
+ expect(tabOlder.activeTab.inactive).toBe(true);
+ expect(tabNewer.activeTab.inactive).toBe(false);
+ });
+
+ it("simultaneous mount with equal ts: exactly one tab wins via id tie-break", async () => {
+ const idLow = "11111111-1111-4111-8111-111111111111";
+ const idHigh = "99999999-9999-4999-8999-999999999999";
+
+ vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
+ /** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
+ idLow
+ ),
+ );
+ vi.mocked(Date.now).mockReturnValueOnce(5000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabLow = await import("./active-tab.svelte.js?tie-low");
+ tabLow.watchActiveTab();
+
+ vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
+ /** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
+ idHigh
+ ),
+ );
+ vi.mocked(Date.now).mockReturnValueOnce(5000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabHigh = await import("./active-tab.svelte.js?tie-high");
+ tabHigh.watchActiveTab();
+ await deliverBcMessages();
+
+ // Equal claim ts: the lexicographically greater id wins, regardless of
+ // mount order — both tabs receive each other's claim and independently
+ // reach the same conclusion.
+ expect(tabLow.activeTab.inactive).toBe(true);
+ expect(tabHigh.activeTab.inactive).toBe(false);
+ });
+
+ it("reclaim updates myClaimTs so a stale peer claim can't re-freeze the reclaimed tab", async () => {
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabA = await import("./active-tab.svelte.js?stale-a");
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabB = await import("./active-tab.svelte.js?stale-b");
+ tabA.watchActiveTab();
+ tabB.watchActiveTab();
+ await deliverBcMessages();
+ expect(tabA.activeTab.inactive).toBe(true);
+
+ // A reclaims — this records a fresh, later claim ts and reactivates
+ // immediately.
+ tabA.claimActiveTab();
+ expect(tabA.activeTab.inactive).toBe(false);
+ await deliverBcMessages();
+ expect(tabB.activeTab.inactive).toBe(true);
+
+ // A stale claim from B (older than A's reclaim — e.g. a delayed
+ // message) must not re-freeze A. A ignores it as a loss for B and
+ // echoes its own winning claim back, so B (the stale claimer) freezes
+ // instead of staying active locally — exactly one tab ends up active.
+ vi.mocked(Date.now).mockReturnValueOnce(1);
+ tabB.claimActiveTab();
+ await deliverBcMessages(); // B's stale claim reaches A; A echoes
+ await deliverBcMessages(); // A's echo reaches B; B freezes
+ expect(tabA.activeTab.inactive).toBe(false);
+ expect(tabB.activeTab.inactive).toBe(true);
+ });
+
+ it("a losing claimer is echoed at and freezes instead of leaving both tabs active", async () => {
+ // Reproduces the asymmetric-delivery race: tabA's own initial claim is
+ // flushed (delivered to nobody) before tabB even mounts, so tabB never
+ // sees it — matching real BroadcastChannel, which doesn't replay past
+ // messages to a channel opened later. Both claims then carry the same
+ // ts, so only the id tie-break decides the winner, and only tabA (the
+ // one still open when tabB's claim arrives) evaluates it.
+ const idLow = "11111111-1111-4111-8111-111111111111";
+ const idHigh = "99999999-9999-4999-8999-999999999999";
+
+ vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
+ /** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
+ idHigh
+ ),
+ );
+ vi.mocked(Date.now).mockReturnValueOnce(3000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabA = await import("./active-tab.svelte.js?echo-a");
+ tabA.watchActiveTab();
+ await deliverBcMessages(); // tabA's own claim is flushed with no peers
+
+ vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(
+ /** @type {`${string}-${string}-${string}-${string}-${string}`} */ (
+ idLow
+ ),
+ );
+ vi.mocked(Date.now).mockReturnValueOnce(3000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabB = await import("./active-tab.svelte.js?echo-b");
+ tabB.watchActiveTab();
+ await deliverBcMessages(); // tabB's claim reaches only tabA
+ await deliverBcMessages(); // tabA's echo (it strictly wins the tie) reaches tabB
+
+ // tabA has the greater id, so on the tie it wins and never freezes;
+ // without the echo fix tabB would also stay active (both active).
+ expect(tabA.activeTab.inactive).toBe(false);
+ expect(tabB.activeTab.inactive).toBe(true);
+ });
+
+ it("the echo does not loop: message traffic settles after one echo round", async () => {
+ const postSpy = vi.spyOn(FakeBC.prototype, "postMessage");
+
+ vi.mocked(Date.now).mockReturnValueOnce(2000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabWinner = await import("./active-tab.svelte.js?loop-winner");
+ tabWinner.watchActiveTab();
+ await deliverBcMessages(); // winner's own claim, delivered to nobody yet
+
+ vi.mocked(Date.now).mockReturnValueOnce(1000);
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tabLoser = await import("./active-tab.svelte.js?loop-loser");
+ tabLoser.watchActiveTab();
+ await deliverBcMessages(); // loser's stale claim reaches winner; winner echoes
+ await deliverBcMessages(); // winner's echo reaches loser; loser freezes
+
+ expect(tabWinner.activeTab.inactive).toBe(false);
+ expect(tabLoser.activeTab.inactive).toBe(true);
+
+ // Winner's initial claim + loser's stale claim + one echo from the
+ // winner. The loser never echoes back (it's frozen and always
+ // strictly loses the winner's unchanged ts), so traffic must stop here.
+ const settledCount = postSpy.mock.calls.length;
+ expect(settledCount).toBe(3);
+
+ // Flushing further must not produce any more messages.
+ await deliverBcMessages();
+ await deliverBcMessages();
+ expect(postSpy.mock.calls.length).toBe(settledCount);
+ });
+
+ it("a peer claim with no ts (legacy tab) always wins, matching old always-freeze behavior", async () => {
+ // @ts-expect-error - "?tag" cache-busting import, see the note above.
+ const tab = await import("./active-tab.svelte.js?legacy");
+ tab.watchActiveTab();
+ await deliverBcMessages(); // this tab's own claim, delivered to nobody
+
+ // A pre-fix peer broadcasts {type, id} with no `ts` at all.
+ const legacyPeer = new /** @type {any} */ (BroadcastChannel)(
+ "loto_active_tab",
+ );
+ legacyPeer.postMessage({ type: "claim", id: "legacy-peer" });
+ await deliverBcMessages();
+
+ expect(tab.activeTab.inactive).toBe(true);
+ legacyPeer.close();
+ });
});
diff --git a/web/src/lib/focus-trap.js b/web/src/lib/focus-trap.js
new file mode 100644
index 0000000..198d4a2
--- /dev/null
+++ b/web/src/lib/focus-trap.js
@@ -0,0 +1,97 @@
+/**
+ * Svelte action for a modal `role="dialog" aria-modal="true"` container:
+ * moves focus into the dialog on mount, traps Tab/Shift+Tab among its
+ * focusable descendants, restores focus to whatever was focused before
+ * the dialog opened, and locks body scroll for as long as any trap is
+ * active (reference-counted so two stacked dialogs can't fight over the
+ * lock or restore the wrong `overflow` value).
+ *
+ * Usage: `
…
`.
+ * Escape-to-close is NOT handled here — callers already wire their own
+ * window-level Escape listener (see PlayerBoard/SettingsButton) so the
+ * Android back-gesture sentinel keeps working unchanged.
+ *
+ * @module lib/focus-trap
+ */
+
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+let lockCount = 0;
+let previousBodyOverflow = "";
+
+function lockBodyScroll() {
+ if (lockCount === 0) {
+ previousBodyOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ }
+ lockCount++;
+}
+
+function unlockBodyScroll() {
+ lockCount = Math.max(0, lockCount - 1);
+ if (lockCount === 0) document.body.style.overflow = previousBodyOverflow;
+}
+
+/**
+ * @param {HTMLElement} container
+ * @returns {HTMLElement[]}
+ */
+function focusables(container) {
+ return /** @type {HTMLElement[]} */ (
+ Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
+ (el) => el instanceof HTMLElement && el.offsetParent !== null,
+ )
+ );
+}
+
+/**
+ * @param {HTMLElement} node
+ * @returns {{ destroy(): void }}
+ */
+export function focusTrap(node) {
+ const previouslyFocused =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+
+ if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1");
+ // Focus the container itself rather than a descendant — predictable
+ // regardless of dialog layout, and screen readers still announce
+ // `aria-labelledby`/`aria-label` on focus.
+ node.focus();
+
+ lockBodyScroll();
+
+ /** @param {KeyboardEvent} e */
+ function onKeydown(e) {
+ if (e.key !== "Tab") return;
+ const items = focusables(node);
+ if (items.length === 0) {
+ // Nothing tabbable inside — keep focus pinned on the dialog so Tab
+ // can't escape to the (visually covered but still-in-DOM) page.
+ e.preventDefault();
+ node.focus();
+ return;
+ }
+ const first = items[0];
+ const last = items[items.length - 1];
+ const active = document.activeElement;
+ if (e.shiftKey && (active === first || !node.contains(active))) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && (active === last || !node.contains(active))) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ node.addEventListener("keydown", onKeydown);
+
+ return {
+ destroy() {
+ node.removeEventListener("keydown", onKeydown);
+ unlockBodyScroll();
+ previouslyFocused?.focus();
+ },
+ };
+}
diff --git a/web/src/lib/focus-trap.test.js b/web/src/lib/focus-trap.test.js
new file mode 100644
index 0000000..72d71d7
--- /dev/null
+++ b/web/src/lib/focus-trap.test.js
@@ -0,0 +1,109 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { focusTrap } from "./focus-trap.js";
+
+/** @type {HTMLElement} */
+let outsideButton;
+/** @type {HTMLDivElement} */
+let dialog;
+/** @type {HTMLButtonElement} */
+let first;
+/** @type {HTMLButtonElement} */
+let last;
+
+beforeEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.overflow = "";
+
+ outsideButton = document.createElement("button");
+ outsideButton.textContent = "outside";
+ document.body.appendChild(outsideButton);
+ outsideButton.focus();
+
+ dialog = document.createElement("div");
+ first = document.createElement("button");
+ first.textContent = "first";
+ const middle = document.createElement("button");
+ middle.textContent = "middle";
+ last = document.createElement("button");
+ last.textContent = "last";
+ dialog.append(first, middle, last);
+ document.body.appendChild(dialog);
+});
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ document.body.style.overflow = "";
+});
+
+/** @param {HTMLElement} target @param {boolean} [shiftKey] */
+function tab(target, shiftKey = false) {
+ const event = new KeyboardEvent("keydown", {
+ key: "Tab",
+ shiftKey,
+ cancelable: true,
+ bubbles: true,
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+describe("focus-trap (M4)", () => {
+ it("focuses the dialog container on mount", () => {
+ const trap = focusTrap(dialog);
+ expect(document.activeElement).toBe(dialog);
+ expect(dialog.getAttribute("tabindex")).toBe("-1");
+ trap.destroy();
+ });
+
+ it("wraps Tab from the last focusable back to the first", () => {
+ const trap = focusTrap(dialog);
+ last.focus();
+ const event = tab(dialog);
+ expect(event.defaultPrevented).toBe(true);
+ expect(document.activeElement).toBe(first);
+ trap.destroy();
+ });
+
+ it("wraps Shift+Tab from the first focusable back to the last", () => {
+ const trap = focusTrap(dialog);
+ first.focus();
+ const event = tab(dialog, true);
+ expect(event.defaultPrevented).toBe(true);
+ expect(document.activeElement).toBe(last);
+ trap.destroy();
+ });
+
+ it("locks body scroll while active and restores it on destroy", () => {
+ document.body.style.overflow = "auto";
+ const trap = focusTrap(dialog);
+ expect(document.body.style.overflow).toBe("hidden");
+ trap.destroy();
+ expect(document.body.style.overflow).toBe("auto");
+ });
+
+ it("restores focus to the previously-focused element on destroy", () => {
+ expect(document.activeElement).toBe(outsideButton);
+ const trap = focusTrap(dialog);
+ expect(document.activeElement).toBe(dialog);
+ trap.destroy();
+ expect(document.activeElement).toBe(outsideButton);
+ });
+
+ it("does not fight over the body scroll lock across two stacked traps", () => {
+ const outer = focusTrap(dialog);
+ const dialog2 = document.createElement("div");
+ const btn2 = document.createElement("button");
+ dialog2.appendChild(btn2);
+ document.body.appendChild(dialog2);
+ const inner = focusTrap(dialog2);
+
+ expect(document.body.style.overflow).toBe("hidden");
+ inner.destroy();
+ // Outer trap is still active — lock must stay held.
+ expect(document.body.style.overflow).toBe("hidden");
+ outer.destroy();
+ expect(document.body.style.overflow).toBe("");
+ });
+});
diff --git a/web/src/lib/master-store.svelte.js b/web/src/lib/master-store.svelte.js
index d3af66a..35d00cf 100644
--- a/web/src/lib/master-store.svelte.js
+++ b/web/src/lib/master-store.svelte.js
@@ -19,6 +19,14 @@ export const masterState = $state({
called: [],
/** @type {number[]} */
remaining: [],
+ /**
+ * True once `loadMaster()` has run at least once in this tab's current
+ * active session. Consumers should gate `saveMaster()` on this so a
+ * component that mounts (or remounts after reclaiming a frozen tab)
+ * never persists stale in-memory state over a peer tab's newer writes
+ * before this tab has re-read localStorage.
+ */
+ hydrated: false,
});
function shuffled1to90() {
@@ -56,6 +64,8 @@ export function loadMaster() {
masterState.remaining = parsed.remaining;
} catch {
/* private mode / corrupt JSON — leave defaults */
+ } finally {
+ masterState.hydrated = true;
}
}
diff --git a/web/src/lib/master-store.test.js b/web/src/lib/master-store.test.js
index 765fac3..884e01d 100644
--- a/web/src/lib/master-store.test.js
+++ b/web/src/lib/master-store.test.js
@@ -13,6 +13,7 @@ beforeEach(() => {
localStorage.clear();
masterState.called = [];
masterState.remaining = [];
+ masterState.hydrated = false;
});
describe("master-store", () => {
@@ -85,4 +86,53 @@ describe("master-store", () => {
loadMaster();
expect(masterState.called).toEqual([]);
});
+
+ describe("hydrated flag (reclaim-a-frozen-tab fix, H2)", () => {
+ it("starts false and flips true after the first loadMaster()", () => {
+ expect(masterState.hydrated).toBe(false);
+ loadMaster();
+ expect(masterState.hydrated).toBe(true);
+ });
+
+ it("flips true even when there is nothing to load", () => {
+ // No localStorage entry at all — still counts as "a load happened",
+ // so a consumer's save-effect gate (MasterPanel) is safe to write.
+ loadMaster();
+ expect(masterState.hydrated).toBe(true);
+ });
+
+ it("flips true even when the stored payload is corrupt", () => {
+ localStorage.setItem("loto_master", "{not valid");
+ loadMaster();
+ expect(masterState.hydrated).toBe(true);
+ });
+
+ it("re-loading picks up a peer tab's newer writes instead of this tab's stale copy", () => {
+ // Simulates the reclaim sequence: this tab drew a couple of numbers,
+ // froze (its in-memory masterState is untouched but stale), a peer
+ // tab drew more and persisted them, then this tab reclaims and must
+ // re-read localStorage before anything re-saves.
+ startNewGame();
+ drawNext();
+ drawNext();
+ saveMaster();
+ const staleCalled = [...masterState.called];
+ const thirdDraw = masterState.remaining[0];
+
+ // Peer tab: independently draws one more and persists.
+ const peerCalled = [...staleCalled, thirdDraw];
+ localStorage.setItem(
+ "loto_master",
+ JSON.stringify({
+ called: peerCalled,
+ remaining: masterState.remaining.slice(1),
+ }),
+ );
+
+ // Reclaim: claimActiveTab()'s fix re-hydrates before re-enabling.
+ loadMaster();
+ expect(masterState.called).toEqual(peerCalled);
+ expect(masterState.called).not.toEqual(staleCalled);
+ });
+ });
});
diff --git a/web/src/lib/overlay-history.test.js b/web/src/lib/overlay-history.test.js
index dfb57cb..8b8d6d7 100644
--- a/web/src/lib/overlay-history.test.js
+++ b/web/src/lib/overlay-history.test.js
@@ -12,7 +12,9 @@ function firePopState() {
window.dispatchEvent(new Event("popstate"));
}
+/** @type {ReturnType
} */
let back;
+/** @type {ReturnType} */
let pushState;
beforeEach(() => {
diff --git a/web/src/lib/settings-store.svelte.js b/web/src/lib/settings-store.svelte.js
index fe076ab..235862c 100644
--- a/web/src/lib/settings-store.svelte.js
+++ b/web/src/lib/settings-store.svelte.js
@@ -23,28 +23,50 @@ export const BOARD_TEXT_SCALES = /** @type {const} */ ([0.9, 1, 1.15, 1.3]);
* stalling the UI on mount. Real settings serialize to ~200 bytes. */
const MAX_STORAGE_BYTES = 8_192;
-export const DEFAULT_SETTINGS = Object.freeze({
- /** Excel "Standard Color: Purple". */
- emptyCellColor: "#7030A0",
- /** "auto" follows OS prefers-color-scheme; "light"/"dark" overrides it. */
- theme: /** @type {"auto"|"light"|"dark"} */ ("auto"),
- /** Which panels are visible: player only, master only, or both inline. */
- mode: /** @type {"player"|"master"|"both"} */ ("player"),
- /** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
- autoCallEnabled: false,
- /** Auto-call interval, seconds per number. Integer 1..10. */
- autoCallSpeed: 5,
- /** Speak the called number aloud when master draws. */
- voiceEnabledMaster: true,
- /** Speak "Chờ" / "Kinh" on player events. */
- voiceEnabledPlayer: false,
- /** When voiceEnabledPlayer is on, also speak the awaited number after "Chờ". */
- voiceWaitingNumber: false,
- /** Active voice id; matches an entry in audio manifest. */
- voice: DEFAULT_VOICE,
- /** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
- boardTextScale: 1,
-});
+/**
+ * Field types for `settings`/`DEFAULT_SETTINGS`. Declared explicitly (rather
+ * than left to inference) so `Object.freeze()` below doesn't narrow each
+ * field to its literal default value (e.g. `autoCallSpeed: 5` inferred as
+ * the type `5`, not `number`) — that literal type would otherwise leak into
+ * `settings = $state({ ...DEFAULT_SETTINGS })` and reject every later
+ * assignment of a different, still-valid value for the field.
+ * @typedef {Object} Settings
+ * @property {string} emptyCellColor
+ * @property {"auto"|"light"|"dark"} theme
+ * @property {"player"|"master"|"both"} mode
+ * @property {boolean} autoCallEnabled
+ * @property {number} autoCallSpeed
+ * @property {boolean} voiceEnabledMaster
+ * @property {boolean} voiceEnabledPlayer
+ * @property {boolean} voiceWaitingNumber
+ * @property {string} voice
+ * @property {number} boardTextScale
+ */
+
+export const DEFAULT_SETTINGS = Object.freeze(
+ /** @type {Settings} */ ({
+ /** Excel "Standard Color: Purple". */
+ emptyCellColor: "#7030A0",
+ /** "auto" follows OS prefers-color-scheme; "light"/"dark" overrides it. */
+ theme: "auto",
+ /** Which panels are visible: player only, master only, or both inline. */
+ mode: "player",
+ /** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
+ autoCallEnabled: false,
+ /** Auto-call interval, seconds per number. Integer 1..10. */
+ autoCallSpeed: 5,
+ /** Speak the called number aloud when master draws. */
+ voiceEnabledMaster: true,
+ /** Speak "Chờ" / "Kinh" on player events. */
+ voiceEnabledPlayer: false,
+ /** When voiceEnabledPlayer is on, also speak the awaited number after "Chờ". */
+ voiceWaitingNumber: false,
+ /** Active voice id; matches an entry in audio manifest. */
+ voice: DEFAULT_VOICE,
+ /** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
+ boardTextScale: 1,
+ }),
+);
export const settings = $state({ ...DEFAULT_SETTINGS });
diff --git a/web/src/lib/settings-store.test.js b/web/src/lib/settings-store.test.js
index 36528b8..2f2594a 100644
--- a/web/src/lib/settings-store.test.js
+++ b/web/src/lib/settings-store.test.js
@@ -18,8 +18,14 @@ function mockMatchMedia(matches = false) {
const mql = {
matches,
media: "(prefers-color-scheme: dark)",
- addEventListener: (/** @type {string} */ _t, fn) => listeners.add(fn),
- removeEventListener: (/** @type {string} */ _t, fn) => listeners.delete(fn),
+ addEventListener: (
+ /** @type {string} */ _t,
+ /** @type {(e: MediaQueryListEvent) => void} */ fn,
+ ) => listeners.add(fn),
+ removeEventListener: (
+ /** @type {string} */ _t,
+ /** @type {(e: MediaQueryListEvent) => void} */ fn,
+ ) => listeners.delete(fn),
addListener: () => {},
removeListener: () => {},
onchange: null,
diff --git a/web/src/lib/update-prompt.svelte.js b/web/src/lib/update-prompt.svelte.js
new file mode 100644
index 0000000..ae30e7e
--- /dev/null
+++ b/web/src/lib/update-prompt.svelte.js
@@ -0,0 +1,52 @@
+/**
+ * Update-prompt coordinator for the PWA service worker.
+ *
+ * `vite.config.js` sets `registerType: "prompt"` so vite-plugin-pwa NEVER
+ * force-reloads a live tab when a new build activates — an unattended
+ * reload would drop in-memory round state (`autoRunning`, `showCongrats`,
+ * the auto-call countdown) mid-fairground. Instead `+layout.svelte` calls
+ * `registerSW({ onNeedRefresh: showUpdatePrompt })`, which routes here so a
+ * small dismissible banner can ask the host before reloading. The actual
+ * reload only ever happens from an explicit tap on the banner's button.
+ *
+ * @module lib/update-prompt
+ */
+
+export const updatePrompt = $state({
+ /** True once vite-plugin-pwa's `onNeedRefresh` has fired this session. */
+ visible: false,
+});
+
+/** @type {((reloadPage?: boolean) => Promise) | null} */
+let updateSW = null;
+
+/**
+ * Store the function `registerSW()` returns so `applyUpdate()` can trigger
+ * the actual reload later without `+layout.svelte` holding its own copy.
+ * @param {(reloadPage?: boolean) => Promise} fn
+ */
+export function setUpdateSW(fn) {
+ updateSW = fn;
+}
+
+/** Wired to `registerSW`'s `onNeedRefresh` callback. */
+export function showUpdatePrompt() {
+ updatePrompt.visible = true;
+}
+
+/** Host tapped "Tải lại" — activate the waiting worker and reload. */
+export function applyUpdate() {
+ updatePrompt.visible = false;
+ if (updateSW) void updateSW(true);
+}
+
+/** Host tapped dismiss — keep the current version running for this visit. */
+export function dismissUpdate() {
+ updatePrompt.visible = false;
+}
+
+/** Test-only reset so module-singleton state doesn't leak between cases. */
+export function _resetUpdatePromptForTest() {
+ updatePrompt.visible = false;
+ updateSW = null;
+}
diff --git a/web/src/lib/update-prompt.test.js b/web/src/lib/update-prompt.test.js
new file mode 100644
index 0000000..f052b28
--- /dev/null
+++ b/web/src/lib/update-prompt.test.js
@@ -0,0 +1,52 @@
+// @vitest-environment happy-dom
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ _resetUpdatePromptForTest,
+ applyUpdate,
+ dismissUpdate,
+ setUpdateSW,
+ showUpdatePrompt,
+ updatePrompt,
+} from "./update-prompt.svelte.js";
+
+beforeEach(() => {
+ _resetUpdatePromptForTest();
+});
+
+describe("update-prompt", () => {
+ it("starts hidden", () => {
+ expect(updatePrompt.visible).toBe(false);
+ });
+
+ it("showUpdatePrompt (onNeedRefresh) reveals the banner", () => {
+ showUpdatePrompt();
+ expect(updatePrompt.visible).toBe(true);
+ });
+
+ it("dismissUpdate hides the banner without calling updateSW", () => {
+ const updateSW = vi.fn().mockResolvedValue(undefined);
+ setUpdateSW(updateSW);
+ showUpdatePrompt();
+ dismissUpdate();
+ expect(updatePrompt.visible).toBe(false);
+ expect(updateSW).not.toHaveBeenCalled();
+ });
+
+ it("applyUpdate hides the banner and reloads via updateSW(true)", () => {
+ const updateSW = vi.fn().mockResolvedValue(undefined);
+ setUpdateSW(updateSW);
+ showUpdatePrompt();
+ applyUpdate();
+ expect(updatePrompt.visible).toBe(false);
+ expect(updateSW).toHaveBeenCalledTimes(1);
+ expect(updateSW).toHaveBeenCalledWith(true);
+ });
+
+ it("applyUpdate before registerSW resolves is a safe no-op (no throw)", () => {
+ showUpdatePrompt();
+ expect(() => applyUpdate()).not.toThrow();
+ expect(updatePrompt.visible).toBe(false);
+ });
+});
diff --git a/web/src/lib/voice.js b/web/src/lib/voice.js
index dac42fd..844d9dc 100644
--- a/web/src/lib/voice.js
+++ b/web/src/lib/voice.js
@@ -7,8 +7,22 @@
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
-/** @type {Map} */
-const cache = new Map();
+/**
+ * Single reusable element, `.src` swapped per clip. iOS Safari
+ * only allows programmatic `play()` on an element that was actually
+ * play()'d inside a user gesture; auto-call plays clips from a
+ * `setInterval` callback, which is never a gesture. A single element
+ * unlocked once via `unlockAudio()` (called from the "Bắt đầu"/"Xổ số"
+ * click) stays unlocked for the rest of the page's life — the previous
+ * per-URL-cached-element design created a fresh, never-unlocked element
+ * for every clip and was silent on iOS under auto-call with no
+ * diagnostic (also retained up to 92 `preload="auto"` elements per voice).
+ * @type {HTMLAudioElement | null}
+ */
+let audio = null;
+
+/** True once `unlockAudio()` has primed `audio` inside a user gesture. */
+let unlocked = false;
/** @type {HTMLAudioElement | null} */
let activeClip = null;
@@ -23,15 +37,34 @@ function isBrowser() {
return typeof window !== "undefined" && typeof Audio !== "undefined";
}
-/** @param {string} url */
-function getAudio(url) {
- let a = cache.get(url);
- if (!a) {
- a = new Audio(url);
- a.preload = "auto";
- cache.set(url, a);
+function getAudio() {
+ if (!audio) {
+ audio = new Audio();
+ audio.preload = "auto";
+ }
+ return audio;
+}
+
+/**
+ * Unlock the shared `` element for later programmatic playback on
+ * iOS Safari. MUST be called synchronously from inside a real user
+ * gesture (a click handler) — see the module doc comment on `audio`.
+ * Idempotent and safe to call from every relevant click handler.
+ */
+export function unlockAudio() {
+ if (!isBrowser() || unlocked) return;
+ unlocked = true;
+ const a = getAudio();
+ const played = a.play();
+ if (played && typeof played.then === "function") {
+ played.then(() => a.pause()).catch(() => {
+ /* The priming play() itself may reject (e.g. no src yet) — that's
+ fine, the attempt still happened synchronously inside the
+ gesture, which is what iOS actually checks. */
+ });
+ } else {
+ a.pause();
}
- return a;
}
/** @param {string} name — clip basename without extension */
@@ -42,12 +75,13 @@ function clipUrl(name) {
}
/**
- * Drop all cached elements. Call when the active voice changes
- * so the old voice's clips don't leak across a long session.
+ * Stop and reset the shared element. Kept as its own export (rather than
+ * folded into `cancelPlayback`) so callers that change `settings.voice`
+ * keep a stable "drop anything voice-specific" hook even though there is
+ * now only one element to reset.
*/
export function clearAudioCache() {
cancelPlayback();
- cache.clear();
}
export function cancelPlayback() {
@@ -73,12 +107,12 @@ export function cancelPlayback() {
* Play one clip; resolve when it ends, errors, or is canceled.
* @param {string} url
* @param {symbol} token — caller's session marker; mismatch = canceled
+ * @returns {Promise}
*/
function playClip(url, token) {
return new Promise((resolve) => {
if (!isBrowser() || activeToken !== token) return resolve();
- const a = getAudio(url);
- a.currentTime = 0;
+ const a = getAudio();
activeClip = a;
activeResolver = resolve;
const done = () => {
@@ -90,6 +124,10 @@ function playClip(url, token) {
};
a.onended = done;
a.onerror = done;
+ // Reassigning `.src` (even to the same URL) reloads and resets
+ // playback to 0 — required because this element is reused across
+ // every clip, unlike the old one-element-per-URL cache.
+ a.src = url;
a.play().catch(done);
});
}
diff --git a/web/src/lib/voice.test.js b/web/src/lib/voice.test.js
index f102b76..6e4f3aa 100644
--- a/web/src/lib/voice.test.js
+++ b/web/src/lib/voice.test.js
@@ -1,17 +1,17 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { settings } from "./settings-store.svelte.js";
-import { cancelPlayback, clearAudioCache, playNumber, playWaiting } from "./voice.js";
-// Track every Audio instance the module constructs so we can drive
-// onended/onerror by hand and verify cancelPlayback's bookkeeping.
+// Track every Audio instance the module constructs. Since voice.js now
+// keeps a single reusable element for the module's lifetime (M6
+// fix — one element, `.src` swapped per clip, so it stays iOS-unlocked
+// across every subsequent play()), this array should never grow past 1
+// within a test.
/** @type {any[]} */
let audios = [];
class FakeAudio {
- /** @param {string} url */
- constructor(url) {
- this.src = url;
+ constructor() {
+ this.src = "";
this.preload = "";
this.currentTime = 0;
this.onended = null;
@@ -28,47 +28,58 @@ class FakeAudio {
}
}
-beforeEach(() => {
+/** @type {typeof import("./voice.js")} */
+let voice;
+/** @type {typeof import("./settings-store.svelte.js").settings} */
+let settings;
+
+beforeEach(async () => {
audios = [];
vi.stubGlobal("Audio", /** @type {any} */ (FakeAudio));
- // Reset cache + active state so each test starts cleanly.
- clearAudioCache();
+ // voice.js keeps module-level singleton state (the shared element, the
+ // unlock flag) by design — reset the module registry so each test gets
+ // a fresh instance instead of reusing a prior test's primed element.
+ vi.resetModules();
+ ({ settings } = await import("./settings-store.svelte.js"));
+ voice = await import("./voice.js");
settings.voice = "test-voice";
settings.voiceWaitingNumber = false;
settings.mode = "player";
});
afterEach(() => {
- cancelPlayback();
+ voice.cancelPlayback();
vi.unstubAllGlobals();
});
describe("voice — playback cancellation", () => {
it("cancelPlayback pauses and rewinds the active clip", () => {
- playNumber(7);
+ voice.playNumber(7);
const a = audios[audios.length - 1];
a.currentTime = 0.42;
a.paused = false;
- cancelPlayback();
+ voice.cancelPlayback();
expect(a.paused).toBe(true);
expect(a.currentTime).toBe(0);
expect(a.onended).toBeNull();
expect(a.onerror).toBeNull();
});
- it("a second playNumber cancels the first", () => {
- playNumber(3);
- const first = audios[audios.length - 1];
- playNumber(5);
- const second = audios[audios.length - 1];
- expect(first).not.toBe(second);
- expect(first.paused).toBe(true);
- expect(second.paused).toBe(false);
+ it("a second playNumber cancels the first and reuses the same element", () => {
+ voice.playNumber(3);
+ const a = audios[audios.length - 1];
+ expect(a.src).toMatch(/\/3\.mp3$/);
+ voice.playNumber(5);
+ // Single shared element (M6 fix) — src swapped in place, no 2nd
+ // Audio instance constructed.
+ expect(audios.length).toBe(1);
+ expect(a.src).toMatch(/\/5\.mp3$/);
+ expect(a.paused).toBe(false);
});
it("playWaiting plays only 'cho' when voiceWaitingNumber is off", async () => {
settings.voiceWaitingNumber = false;
- playWaiting(42);
+ voice.playWaiting(42);
// Let the async chain schedule + start the cho clip.
await Promise.resolve();
expect(audios.length).toBe(1);
@@ -76,26 +87,27 @@ describe("voice — playback cancellation", () => {
audios[0].onended?.();
await Promise.resolve();
await Promise.resolve();
- // Number clip should NOT be queued.
+ // Number clip should NOT be queued — src stays on "cho".
expect(audios.length).toBe(1);
+ expect(audios[0].src).toMatch(/\/cho\.mp3$/);
});
it("playWaiting chains 'cho' → number when voiceWaitingNumber is on", async () => {
settings.voiceWaitingNumber = true;
- playWaiting(42);
+ voice.playWaiting(42);
await Promise.resolve();
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
audios[0].onended?.();
await Promise.resolve();
await Promise.resolve();
- expect(audios.length).toBe(2);
- expect(audios[1].src).toMatch(/\/42\.mp3$/);
+ expect(audios.length).toBe(1);
+ expect(audios[0].src).toMatch(/\/42\.mp3$/);
});
it("playWaiting suppresses trailing number in both mode even when flag is on", async () => {
settings.voiceWaitingNumber = true;
settings.mode = "both";
- playWaiting(42);
+ voice.playWaiting(42);
await Promise.resolve();
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
audios[0].onended?.();
@@ -104,5 +116,31 @@ describe("voice — playback cancellation", () => {
// Master is the announcer in both mode — overlapping "Chờ N" right
// after a master call confuses listeners, so we drop it.
expect(audios.length).toBe(1);
+ expect(audios[0].src).toMatch(/\/cho\.mp3$/);
+ });
+});
+
+describe("voice — iOS unlock (M6)", () => {
+ it("unlockAudio primes the shared element exactly once", () => {
+ voice.unlockAudio();
+ expect(audios.length).toBe(1);
+ const primed = audios[0];
+ voice.unlockAudio();
+ // Idempotent — no 2nd element created on a repeat call (e.g. both
+ // "Bắt đầu" and a later "Xổ số" click in the same session).
+ expect(audios.length).toBe(1);
+ expect(audios[0]).toBe(primed);
+ });
+
+ it("playback after unlockAudio reuses the already-primed element", () => {
+ voice.unlockAudio();
+ const primed = audios[0];
+ // Simulates auto-call's setInterval callback, which iOS does not
+ // treat as a user gesture — playback must land on the element that
+ // was unlocked earlier inside a real click, not a fresh one.
+ voice.playNumber(9);
+ expect(audios.length).toBe(1);
+ expect(audios[0]).toBe(primed);
+ expect(audios[0].src).toMatch(/\/9\.mp3$/);
});
});
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index ff5c0c0..9e1952e 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -8,6 +8,13 @@
claimActiveTab,
watchActiveTab,
} from "$lib/active-tab.svelte.js";
+ import {
+ applyUpdate,
+ dismissUpdate,
+ setUpdateSW,
+ showUpdatePrompt,
+ updatePrompt,
+ } from "$lib/update-prompt.svelte.js";
let { children } = $props();
@@ -19,14 +26,53 @@
onMount(() => {
loadSettings();
loadMaster();
+ registerServiceWorker();
return watchActiveTab();
});
+
+ // `adapter-static` renders page HTML during `adapt()`, which runs AFTER
+ // the PWA plugin's `closeBundle` — so `@vite-pwa/sveltekit` never gets a
+ // chance to inject its own registration tag into the built HTML. We have
+ // to call the plugin's registration entry point ourselves. Dynamic import
+ // (not a static one) so this module is never evaluated during SSR/
+ // prerendering — `virtual:pwa-register` reaches into `navigator` and
+ // `workbox-window`, both browser-only.
+ //
+ // `registerType: "prompt"` (vite.config.js) means the new SW installs and
+ // waits rather than force-reloading a live round; `onNeedRefresh` only
+ // flips the banner's visibility (lib/update-prompt.svelte.js) — the
+ // reload itself happens exclusively from that banner's explicit tap.
+ function registerServiceWorker() {
+ if (!import.meta.env.PROD) return;
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator))
+ return;
+ import("virtual:pwa-register")
+ .then(({ registerSW }) => {
+ const updateSW = registerSW({ onNeedRefresh: showUpdatePrompt });
+ setUpdateSW(updateSW);
+ })
+ .catch(() => {
+ /* SW unsupported/blocked (e.g. private mode) — app still works online */
+ });
+ }
+
+ // Reclaiming a frozen tab: while this tab was frozen, the peer tab may
+ // have drawn more numbers and persisted them. Re-read localStorage BEFORE
+ // flipping `inactive` back off, so the children remount with the peer's
+ // authoritative state instead of this tab's stale in-memory snapshot —
+ // otherwise MasterPanel's save effect would immediately roll the round
+ // back to whatever this tab last held.
+ function reclaimTab() {
+ loadSettings();
+ loadMaster();
+ claimActiveTab();
+ }
{#if activeTab.inactive}
{:else}
{@render children()}
+
+ {#if updatePrompt.visible}
+
+
+
+ Có bản mới. Tải lại?
+
+
+ Tải lại
+
+
+ Để sau
+
+
+ {/if}
{/if}
diff --git a/web/svelte.config.js b/web/svelte.config.js
index 44fe2c0..79c5475 100644
--- a/web/svelte.config.js
+++ b/web/svelte.config.js
@@ -1,28 +1,7 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-import { loadEnv } from "vite";
-const profile = process.env.BUILD_PROFILE;
-const isCodeserver = process.env.VITE_DEV_PROFILE === "codeserver";
-
-// Same env-loading concern as vite.config.js — .env.local isn't in
-// process.env at config-eval time. loadEnv reads it.
-const env = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), "");
-
-// basePath resolution:
-// 1. NEXT_BASE_PATH — explicit override (escape hatch)
-// 2. codeserver dev → /absproxy/{port}
-// 3. BUILD_PROFILE=gh → /loto
-// 4. default (local dev / generic static host) → ""
-function resolveBase() {
- if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
- if (isCodeserver) {
- const port = env.CODESERVER_PORT ?? "3000";
- return `/absproxy/${port}`;
- }
- if (profile === "gh") return "/loto";
- return "";
-}
+import { resolveBase } from "./base-path.js";
export default {
preprocess: vitePreprocess(),
diff --git a/web/vite.config.js b/web/vite.config.js
index 2321917..eb154bd 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -4,6 +4,8 @@ import tailwindcss from "@tailwindcss/vite";
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
import { defineConfig, loadEnv } from "vite";
+import { resolveBase } from "./base-path.js";
+
// Precache the default voice's clips so the app is fully offline-capable
// on first install (without bloating the install with every voice).
// Alternate voices fall through to runtime CacheFirst on first play.
@@ -18,8 +20,14 @@ const clipNames = [
"cho",
"kinh",
];
+// `additionalManifestEntries` are passed to workbox-build verbatim — the
+// plugin only base-prefixes entries it generates itself from globs. Without
+// this, the GitHub Pages build (base "/loto") precaches origin-rooted
+// "/audio/..." URLs that 404 under "/loto/", and Workbox aborts the SW
+// `install` event on the first failed precache request.
+const base = resolveBase();
const defaultVoicePrecacheEntries = clipNames.map((n) => ({
- url: `/audio/${defaultVoiceId}/${n}.mp3`,
+ url: `${base}/audio/${defaultVoiceId}/${n}.mp3`,
// Workbox needs a revision string to invalidate stale clips.
// Bump the prefix when audio is regenerated.
revision: `audio-v1-${defaultVoiceId}-${n}`,
@@ -37,9 +45,14 @@ export default defineConfig(({ mode }) => {
tailwindcss(),
sveltekit(),
SvelteKitPWA({
- // Do NOT add `skipWaiting` without a reload-prompt UI — it would
- // swap the SW mid-game and lose state.
- registerType: "autoUpdate",
+ // "prompt" (not "autoUpdate"): a deploy must never force-reload a
+ // live tab mid-round — that would drop in-memory state
+ // (autoRunning, showCongrats, the auto-call countdown). The new SW
+ // installs and waits; `+layout.svelte` registers
+ // `onNeedRefresh: showUpdatePrompt` (see lib/update-prompt.svelte.js)
+ // so the host sees a small "Có bản mới. Tải lại?" banner and the
+ // reload only happens on an explicit tap.
+ registerType: "prompt",
// Ship a hand-written manifest so the icons/theme stay aligned
// with /static; the plugin can also generate one but mixing is
// confusing.