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 `