diff --git a/TODO.md b/TODO.md
index 7172734..15afca2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -71,6 +71,15 @@ hand if any is wanted in the closed test. See
### PWA install verification (manual, post-deploy)
+**Priority after the SvelteKit 3 upgrade (2026-10-12).** Before the
+upgrade, the Pages build's `sw.js` precached `/` and bound navigations to
+`/`. Both point at the origin root, which returns 404 on Pages, so the
+service worker most likely never installed there. The new build (plain
+`vite-plugin-pwa`, replacing `@vite-pwa/sveltekit`, which breaks under
+Kit 3) uses `/loto/`. On Pages, confirm that the service worker installs,
+that the app works offline, and that the update banner appears after the
+next deploy.
+
Needs production deploy on GitHub Pages + physical Android Chrome +
iOS Safari. No code; verification only.
@@ -153,14 +162,6 @@ iOS Safari. No code; verification only.
so every draw re-runs all of them. If profiling shows jank, the cheap
fix is a `MasterCell(num, order, isLast)` composable so only the two
changed cells recompose per draw.
-- **`web/package.json` overrides** (re-checked 2026-10-12).
- `cookie`: still needed — Kit 2.70.x still declares `cookie ^0.6.0`,
- below the 0.7.0 fix. Kit 3.0.1 (now `latest`) declares `^2.0.1`, so
- the override goes away with a Kit 3 migration (a major upgrade, not
- done). `serialize-javascript`: dropped 2026-10-12 — redundant — the consumer
- (`@rollup/plugin-terser`, via `workbox-build`) declares `^7.0.3`, which
- already resolves to 7.1.x; the `^7.0.5` override only guards a lockfile
- that pins 7.0.3/7.0.4.
- **Voice list growth.** If we add voices > 2 (esp. > 10), revisit
the precache strategy — currently we precache only the default
voice. The 7d runtime cache covers the rest, but cold-start cost
diff --git a/web/README.md b/web/README.md
index 7958d0a..6cee044 100644
--- a/web/README.md
+++ b/web/README.md
@@ -56,6 +56,8 @@ npm run dev # dev server at http://localhost:5173
| `npm run preview` | Preview production build locally |
| `npm test` | Run unit tests |
| `npm run lint` | ESLint check |
+| `npm run check` | svelte-check type check (JSDoc) |
+| `npm run verify:pwa` | Check the service worker in `build/` (after a build, same `BUILD_PROFILE`) |
---
@@ -97,6 +99,37 @@ scripts/
```
Game state lives entirely in Svelte 5 rune stores; no server round-trips.
+
+### Build config
+
+- **No `svelte.config.js`.** SvelteKit 3 takes its options (adapter-static,
+ `paths.base` from `base-path.js`) as arguments to the `sveltekit()` plugin
+ in `vite.config.js`.
+- **`#lib/*` imports.** `src/lib` is reached through the `imports` field in
+ `package.json` (`import { settings } from "#lib/settings-store.svelte.js"`),
+ always with the file extension. `jsconfig.json` extends `$app/tsconfig`.
+- **Base-path-safe URLs.** Static files go through `asset()` from
+ `$app/paths` (no leading `/`: `asset("audio/…")`), so they resolve under
+ both `""` and `/loto`.
+
+### PWA
+
+The service worker comes from
+[vite-plugin-pwa](https://vite-pwa-org.netlify.app/) (`generateSW`,
+`registerType: "prompt"`); the manifest is the hand-written
+`static/manifest.webmanifest`. `+layout.svelte` registers it through
+`virtual:pwa-register` and wires `onNeedRefresh` to the update banner in
+`src/lib/update-prompt.svelte.js`. It precaches the app shell plus the default
+voice; other voices are cached on first play.
+
+`@vite-pwa/sveltekit` is not used: it generates `sw.js` when Vite's SSR build
+closes, which assumed SvelteKit 2's build order. SvelteKit 3 builds SSR first,
+then the client, then prerenders, all inside its `buildApp` hook, so
+`vite.config.js` drops vite-plugin-pwa's own build step and calls its
+`generateSW()` from a `buildApp` hook that runs after SvelteKit's and before
+the adapter copies `.svelte-kit/output` into `build/`.
+`npm run verify:pwa` checks the result after a build (run it with the same
+`BUILD_PROFILE`).
Audio manifest is generated once at build prep time — the app reads a JSON manifest
on load and plays the appropriate clip for each drawn number.
diff --git a/web/base-path.js b/web/base-path.js
index f494cd9..f10178e 100644
--- a/web/base-path.js
+++ b/web/base-path.js
@@ -1,9 +1,9 @@
/**
- * 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`),
+ * SvelteKit `paths.base` resolution, shared between the `sveltekit()`
+ * plugin options (page routing / prerendering) and the PWA precache
+ * manifest, both in `vite.config.js`. 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:
@@ -16,9 +16,14 @@
*/
import { loadEnv } from "vite";
-/** @returns {string} */
+/**
+ * SvelteKit validates the shape (leading `/`, no trailing `/`) when it loads
+ * its config, so a malformed NEXT_BASE_PATH fails the build there.
+ * @returns {"" | `/${string}`}
+ */
export function resolveBase() {
- if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
+ if (process.env.NEXT_BASE_PATH != null)
+ return /** @type {"" | `/${string}`} */ (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(
diff --git a/web/docs/code-standards.md b/web/docs/code-standards.md
index 1442933..f59ecd7 100644
--- a/web/docs/code-standards.md
+++ b/web/docs/code-standards.md
@@ -127,19 +127,19 @@ function randomANumberInRow(weights) {
## Internal Navigation
-- Use `import { base } from '$app/paths'` for internal links/asset URLs to preserve basePath across deployments.
-- Example: `` resolves on both root (`""`) and subpath (`/loto`) builds.
+- Use `asset()` (static files) and `resolve()` (routes) from `$app/paths` to preserve basePath across deployments. Their arguments don't start with `/` (route IDs passed to `resolve()` are the exception).
+- Example: `` resolves on both root (`""`) and subpath (`/loto`) builds.
## Import Organization
-1. SvelteKit imports (`$app/paths`, `$lib/...`)
+1. SvelteKit and app-library imports (`$app/paths`, `#lib/...`, always with the file extension)
2. Third-party imports
3. Local component/utility imports
```js
-import { base } from "$app/paths";
-import { generateGrid, isRowComplete } from "$lib/game-logic.js";
-import PlayerBoard from "$lib/PlayerBoard.svelte";
+import { asset } from "$app/paths";
+import { generateGrid, isRowComplete } from "#lib/game-logic.js";
+import PlayerBoard from "#lib/PlayerBoard.svelte";
```
## Component Patterns
diff --git a/web/docs/codebase-summary.md b/web/docs/codebase-summary.md
index 8e94640..149c9d5 100644
--- a/web/docs/codebase-summary.md
+++ b/web/docs/codebase-summary.md
@@ -26,7 +26,7 @@
| `src/lib/active-tab.svelte.js` | Single-active-tab coordinator via `BroadcastChannel`. New tab broadcasts `claim`; old tab sets `activeTab.inactive = true`. `+layout.svelte` mounts `watchActiveTab()` on boot and renders a fullscreen overlay banner ("Phiên Lô tô đang chạy ở tab khác. Nhấn để tiếp tục tại đây.") when inactive — nhấn calls `claimActiveTab()` which broadcasts back, inactivating the other tab in turn. Soft coordination only (cooperating tabs); no-op in browsers without `BroadcastChannel` (legacy iOS Safari ≤15.4). Prevents double auto-call intervals, double localStorage writers, and overlapping audio across tabs. |
| `src/lib/player-auto-cross.js` | Pure `applyMasterCalls({grid, crossed, called, lastHandledIndex, manualUnticks, mode})`. Cursor-by-index dedup (vs the retired `at`-timestamp model), so any caller can pass `lastHandledIndex: 0` to replay master's full history (used by player regen + "Xoá đánh dấu" in both mode). Manual unticks suppress re-cross on replay. |
| `src/lib/vietnamese-number.js` | `numberToVietnamese(n)` — pure utility mapping 0..90 to spoken Vietnamese, with tonal exceptions (15 → "mười lăm", 21 → "hai mươi mốt", 25 → "hai mươi lăm"). Out-of-range falls back to `String(n)`. |
-| `src/lib/voice.js` | Bundled-MP3 playback. Exports `playNumber(n)`, `playWaiting(n)` (sequences cho + N), `playBingo()`, `cancelPlayback()`. Lazy `