fix(web): service-worker registration, base-path handling, PWA verification, multi-tab protocol, update prompt, focus trap, iOS audio unlock, checkJs fixes

This commit is contained in:
tiennm99 committed 2026-09-01 00:11:29 +07:00
1 parent 75bcbfeff0
commit 0593672afd
26 files changed
+1241 -222

No files matched your search

+34
View File
@@ -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 "";
}
+1 -1
View File
@@ -2,6 +2,6 @@
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false
"checkJs": true
}
}
+104 -112
View File
@@ -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",
+3
View File
@@ -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"
+96
View File
@@ -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.`,
);
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite-plugin-pwa/client" />
// 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 {};
+38
View File
@@ -9,6 +9,44 @@
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="format-detection" content="telephone=no" />
<!-- Blocking pre-paint theme script. `loadSettings()` in +layout.svelte
(client-only, runs in onMount) is the authoritative pass and keeps
its own validation; this duplicate, defensively-guarded read only
has to get the FIRST paint right so dark-mode users don't see a
full-page light flash before hydration. Runs before app.css's
<link> (injected by %sveltekit.head% further down) so `.dark`/the
custom property are already set by the time styles apply. -->
<script>
(function () {
try {
var raw = localStorage.getItem("loto_settings");
var parsed = raw ? JSON.parse(raw) : null;
var theme =
parsed && typeof parsed.theme === "string" ? parsed.theme : "auto";
var dark =
theme === "dark" ||
(theme !== "light" &&
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
var color =
parsed &&
typeof parsed.emptyCellColor === "string" &&
/^#[0-9a-fA-F]{6}$/.test(parsed.emptyCellColor)
? parsed.emptyCellColor
: null;
if (color) {
document.documentElement.style.setProperty(
"--empty-cell-bg",
color,
);
}
} catch (e) {
/* private mode / corrupt JSON — fall back to light + default
color, same as loadSettings()'s own catch. */
}
})();
</script>
<title>Lô tô — Hội chợ TN1</title>
<meta
name="description"
+13 -3
View File
@@ -41,7 +41,7 @@
} from "$lib/master-store.svelte.js";
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
import { settings } from "$lib/settings-store.svelte.js";
import { cancelPlayback, playNumber } from "$lib/voice.js";
import { cancelPlayback, playNumber, unlockAudio } from "$lib/voice.js";
import { setWakeLock } from "$lib/wake-lock.js";
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
@@ -67,10 +67,13 @@
// against an empty `masterState.called` and replay the back-history
// when MasterPanel later loads.
$effect(() => {
// 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 <audio> 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;
}
</script>
+54 -22
View File
@@ -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,7 +520,11 @@
<!-- Centered overlay over the card. Lower opacity + pointer-events
routing keeps cell taps unblocked while the chip is visible.
Cell-pulse on the awaited number is the persistent visual
anchor; this chip is the textual fresh-event cue. -->
anchor; this chip is the textual fresh-event cue.
{#key toastId} forces a full remount on every showToast() call
so the 5s CSS fade-out always restarts from a fresh node — see
toastId's declaration for why a reused node was a real bug. -->
{#key toastId}
<div
role="status"
aria-live="polite"
@@ -509,6 +539,7 @@
{toast}
</button>
</div>
{/key}
{/if}
</div>
{: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"
>
<button
+7 -1
View File
@@ -1,5 +1,6 @@
<script>
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"
>
<!-- Backdrop: click-to-close, hidden from a11y tree. Window-level
@@ -261,7 +263,11 @@
</div>
</fieldset>
{#snippet switchRow(label, isOn, onToggle)}
{#snippet switchRow(
/** @type {string} */ label,
/** @type {boolean} */ isOn,
/** @type {() => void} */ onToggle,
)}
<!-- Whole row is the switch — role/tabindex/click here, not on the
inner pill, so tapping the label text also toggles. -->
<div
+42 -5
View File
@@ -27,6 +27,24 @@ export const activeTab = $state({
/** @type {BroadcastChannel | null} */
let bc = null;
/**
* `Date.now()` of this tab's most recent claim (mount or {@link claimActiveTab}).
* Used to resolve simultaneous claims: whichever tab claimed last stays
* active, so two tabs opened in the same instant don't both freeze each
* other. Equal timestamps (clock-resolution collisions) tie-break on
* {@link TAB_ID} so exactly one tab wins deterministically.
* @type {number}
*/
let myClaimTs = 0;
/**
* Broadcast a claim carrying this tab's id and current claim timestamp.
*/
function postClaim() {
myClaimTs = Date.now();
if (bc) bc.postMessage({ type: "claim", id: TAB_ID, ts: myClaimTs });
}
/**
* Open the channel, listen for peer claims, and announce that THIS tab
* is now the active one. Returns a cleanup that closes the channel.
@@ -37,11 +55,29 @@ export function watchActiveTab() {
if (bc) return () => {};
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();
}
+206 -3
View File
@@ -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) ?? [];
const name = this.name;
const sender = this;
queueMicrotask(() => {
const peers = FakeBC.channels.get(name) ?? [];
for (const p of peers) {
if (p === this || p.closed || !p.onmessage) continue;
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();
});
});
+97
View File
@@ -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: `<div role="dialog" aria-modal="true" use:focusTrap>…</div>`.
* 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();
},
};
}
+109
View File
@@ -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("");
});
});
+10
View File
@@ -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;
}
}
+50
View File
@@ -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);
});
});
});
+2
View File
@@ -12,7 +12,9 @@ function firePopState() {
window.dispatchEvent(new Event("popstate"));
}
/** @type {ReturnType<typeof vi.spyOn>} */
let back;
/** @type {ReturnType<typeof vi.spyOn>} */
let pushState;
beforeEach(() => {
+26 -4
View File
@@ -23,13 +23,34 @@ 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({
/**
* 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: /** @type {"auto"|"light"|"dark"} */ ("auto"),
theme: "auto",
/** Which panels are visible: player only, master only, or both inline. */
mode: /** @type {"player"|"master"|"both"} */ ("player"),
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. */
@@ -44,7 +65,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
voice: DEFAULT_VOICE,
/** Multiplier on board number size; one of BOARD_TEXT_SCALES. */
boardTextScale: 1,
});
}),
);
export const settings = $state({ ...DEFAULT_SETTINGS });
+8 -2
View File
@@ -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,
+52
View File
@@ -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<void>) | 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<void>} 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;
}
+52
View File
@@ -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);
});
});
+53 -15
View File
@@ -7,8 +7,22 @@
import { base } from "$app/paths";
import { settings } from "$lib/settings-store.svelte.js";
/** @type {Map<string, HTMLAudioElement>} */
const cache = new Map();
/**
* Single reusable <audio> 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 `<audio>` 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 <audio> 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<void>}
*/
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);
});
}
+65 -27
View File
@@ -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 <audio> 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$/);
});
});
+84 -1
View File
@@ -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();
}
</script>
{#if activeTab.inactive}
<button
type="button"
onclick={claimActiveTab}
onclick={reclaimTab}
class="fixed inset-0 z-50 flex flex-col items-center justify-center
bg-slate-900/95 text-white text-center px-6
cursor-pointer focus:outline-none"
@@ -41,4 +87,41 @@
</button>
{:else}
{@render children()}
{#if updatePrompt.visible}
<!-- Small corner banner, never a full-screen overlay — must never sit
over the board or the master "Số vừa xổ" hero mid-round. Reload
only ever happens from the explicit "Tải lại" tap (see
lib/update-prompt.svelte.js's module doc for why). -->
<div
role="status"
aria-live="polite"
class="fixed z-40 bottom-4 inset-x-4 sm:inset-x-auto sm:right-4 sm:left-auto sm:max-w-xs
flex items-center gap-3 rounded-2xl bg-white dark:bg-slate-800
border border-slate-200 dark:border-slate-600
px-4 py-3 shadow-2xl animate-fade-in"
>
<p class="flex-1 text-sm font-semibold text-slate-800 dark:text-slate-100">
Có bản mới. Tải lại?
</p>
<button
type="button"
onclick={applyUpdate}
class="shrink-0 px-4 py-1.5 rounded-full font-semibold text-white text-sm
bg-rose-600 hover:bg-rose-700 active:scale-95 transition-all shadow-md"
>
Tải lại
</button>
<button
type="button"
onclick={dismissUpdate}
aria-label="Đóng thông báo cập nhật"
class="shrink-0 px-2 py-1.5 rounded-full text-sm font-medium
text-slate-500 dark:text-slate-400
hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
>
Để sau
</button>
</div>
{/if}
{/if}
+1 -22
View File
@@ -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(),
+17 -4
View File
@@ -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.