mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-02 20:24:56 +00:00
feat: UI polish v2 + installable PWA with offline audio
Phase 1 — Vietnamese-safe font + master empty state:
- Add @fontsource/roboto-condensed (700 weight, all subsets including
Vietnamese). font-display:swap. tan-tan-num now resolves the bundled
face on Android instead of system Arial Narrow.
- New MasterEmptyState.svelte: ghost 11×9 grid + "Chế độ Quản trò"
pill + readiness microcopy. Replaces the bare line of text in
MasterPanel's no-game state.
Phase 2 — Mode picker icons, color picker layout, header polish:
- Inline SVG glyphs above each mode button (player card / megaphone /
two stacked cards). Communicates role at a glance.
- Color picker wrapped in a single bordered card with "Tuỳ chỉnh"
and "Mẫu sẵn" sub-headers.
- "Mặc định" → "Đặt lại" with a bordered chip style — clearer
affordance than the previous near-invisible footer link.
- Header subline: drop tracking-[0.28em] all-caps SaaS look; replace
with dash-flanked lantern band ("— 🏮 Hội chợ TN1 —") for
fairground mood.
Phase 3 — Installable PWA + offline audio:
- @vite-pwa/sveltekit with autoUpdate. Precache app shell (~353 KB
→ 213 entries) PLUS the default voice's 92 clips so first-install
is fully offline-capable. Alternate voices fall through to a
CacheFirst runtime rule (cached on first play, 30-day TTL,
maxEntries: 400 for future-proofing).
- New static/manifest.webmanifest (Lô tô — Hội chợ TN1, theme #1565c0,
background #0a0f1f, standalone, vi).
- Icons: 192/512 standard + 512 maskable, generated from a single
rose-amber-gradient SVG source.
- app.html: manifest link, dual theme-color meta (light + dark),
apple-touch-icon, apple-mobile-web-app-capable for proper
standalone launch on iOS Safari.
- _headers: add manifest-src + worker-src to CSP; no-cache on /sw.js
and /manifest.webmanifest so deploys propagate.
Tests: 115/115 pass. Build clean (305 precache entries, 0 glob
warnings).
Reviewer concerns (addressed):
- maxEntries bumped 200 → 400 (was barely enough for 2 voices).
- Default voice precached so offline-first promise holds without
requiring users to play every clip online first.
- "do NOT add skipWaiting" comment added next to autoUpdate.
This commit is contained in:
@@ -11,8 +11,9 @@
|
||||
### Shared Components
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:4 on mobile; 3:5 on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, purple empty cells (dark mode dims via `filter:brightness(0.85)`). Handles crossed state, animated cross-out (200 ms `cross-draw` keyframe), `active:scale-90` press, 10 ms haptic on tap. Two header actions: "Tạo bảng mới" / "Xoá đánh dấu". First-run state shows a faded preview card. Bingo popup tiers: row 1 = standard celebration; row 3+ = falling-emoji confetti rain via CSS `confetti-fall`. Toast "Chờ N" + audio. Accepts `storagePrefix` prop for multi-card isolation. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal (responsive `max-w-sm sm:max-w-md`). 5 fieldsets: Giao diện (theme pills), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel color swatches + custom picker). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
|
||||
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:4 on mobile; 3:5 on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack w/ self-hosted Roboto Condensed), white number cells, purple empty cells (dark mode dims via `filter:brightness(0.85)`). Handles crossed state, animated cross-out (200 ms `cross-draw` keyframe), `active:scale-90` press, 10 ms haptic on tap. Two header actions: "Tạo bảng mới" / "Xoá đánh dấu". First-run state shows a faded preview card. Bingo popup tiers: row 1 = standard celebration; row 3+ = falling-emoji confetti rain via CSS `confetti-fall`. Toast "Chờ N" + audio. Accepts `storagePrefix` prop for multi-card isolation. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal (responsive `max-w-sm sm:max-w-md`). 6 fieldsets: Giao diện (theme pills), Chế độ (3-way mode picker w/ SVG glyphs: player/master/both), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel swatches + custom input in bordered card w/ "Tuỳ chỉnh"/"Mẫu" sub-headers). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
|
||||
| `src/lib/MasterEmptyState.svelte` | Empty board placeholder for first-run master (mirrors PlayerBoard's preview UX). Displays faded 11×9 grid with "Ấn để bắt đầu ván mới" hint. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). Publishes draws to `call-bus` for player auto-tick. "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.mode !== "player"`; the wrapping section uses `transition:slide` for smooth toggle-in. |
|
||||
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
|
||||
|
||||
@@ -38,20 +39,23 @@
|
||||
| `src/lib/settings-store.test.js` | 31 unit tests: defaults (incl. voice keys), loadSettings (restore 8 keys, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation, voice round-trip + invalid-id fallback. |
|
||||
| `src/lib/vietnamese-number.test.js` | 40 unit tests: ones (0–9), teens (10–19 incl. mười lăm), 20–90 incl. mốt and lăm exceptions, out-of-range fall-through. |
|
||||
|
||||
### Configuration
|
||||
### Configuration & PWA
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env. |
|
||||
| `vite.config.js` | Tailwind + SvelteKit plugins. codeserver HMR config (port, allowedHosts, hmr). |
|
||||
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
|
||||
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals (lines 16–22). |
|
||||
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
|
||||
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
|
||||
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
|
||||
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
|
||||
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
|
||||
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
|
||||
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
|
||||
| `static/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. Static assets are served first so this only fires on misses. |
|
||||
| `static/audio/{voiceId}/*.mp3` | Pre-generated Vietnamese voice clips (1–90 + cho + kinh per voice). Generated once by `scripts/generate-audio.py` (edge-tts). Runtime never calls TTS. |
|
||||
| `static/audio/manifest.json` | Voice list `{ id, edgeName, label, gender }[]`. Written by the generator, imported by `audio-manifest.js`. |
|
||||
| `scripts/generate-audio.py` | One-shot Python script: discovers every `vi-*` edge-tts voice, generates 92 clips per voice, writes manifest. Run after voice/wording changes. |
|
||||
| `static/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. |
|
||||
| `static/manifest.webmanifest` | PWA manifest: name, short_name, icons (192/512px), theme colors, display: standalone, scope, start_url. |
|
||||
| `static/icons/{192,512}.png` | PWA icons (auto-generated by @vite-pwa/sveltekit from source). |
|
||||
| `static/audio/{voiceId}/*.mp3` | Pre-generated Vietnamese voice clips (1–90 + cho + kinh per voice). Generated by `scripts/generate-audio.py`. SW caches at runtime (CacheFirst). |
|
||||
| `static/audio/manifest.json` | Voice list `{ id, edgeName, label, gender }[]`. |
|
||||
| `scripts/generate-audio.py` | One-shot Python script: generates 92 clips per voice, writes manifest. |
|
||||
| Service Worker (auto) | @vite-pwa/sveltekit generates `/pwa-manifest.json`, `/sw.js`. App shell precache ~353 KB. Auto-update mode with reload toast. |
|
||||
|
||||
## Key Data Structures
|
||||
|
||||
@@ -94,4 +98,4 @@ RootLayout
|
||||
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
Last synced: 2026-04-27 (UI polish v2 + PWA: font, mode picker, color picker, empty state, PWA stack)
|
||||
|
||||
@@ -35,6 +35,7 @@ All colors have corresponding `dark:` variants. Tailwind's `prefers-color-scheme
|
||||
## Typography
|
||||
|
||||
- **Font**: Geist Sans (Google Fonts), fallback to Arial/Helvetica.
|
||||
- **Grid numbers**: Roboto Condensed (self-hosted via @fontsource/roboto-condensed, font-display: swap) in `tan-tan-num` stack, bold weight.
|
||||
- **Headings**: Extrabold (font-extrabold) for main title, bold (font-bold) for secondary.
|
||||
- **Body**: Regular weight, slate-600 (light) / slate-400 (dark).
|
||||
- **Emphasis**: `<strong>` for important copy (e.g., button labels in instructions).
|
||||
@@ -65,12 +66,19 @@ All colors have corresponding `dark:` variants. Tailwind's `prefers-color-scheme
|
||||
## Settings & UI Toggles
|
||||
|
||||
### Mode Picker (SettingsButton)
|
||||
Three-way toggle in settings modal:
|
||||
- **Player mode** (`mode: "player"`) — PlayerBoard only, default UX for players
|
||||
- **Master mode** (`mode: "master"`) — MasterPanel only, host-only view (e.g., projector)
|
||||
- **Both mode** (`mode: "both"`) — PlayerBoard + MasterPanel stacked inline, master auto-ticks player board on draw
|
||||
Three-way segmented picker in settings modal with inline SVG glyphs:
|
||||
- **Player mode** (rectangle icon) — PlayerBoard only, default UX for players
|
||||
- **Master mode** (rounded ellipse icon) — MasterPanel only, host-only view (e.g., projector)
|
||||
- **Both mode** (double-box icon) — PlayerBoard + MasterPanel stacked inline, master auto-ticks player board on draw
|
||||
|
||||
Mode selection is persisted to localStorage and applied immediately.
|
||||
SVG icons hand-drawn from primitives (no icon library). Selection persisted to localStorage.
|
||||
|
||||
### Color Picker (SettingsButton)
|
||||
Grouped card with two sub-sections:
|
||||
- **Mẫu** (Presets): 10 Excel color swatches in a flex row (purple, blue, cyan, green, yellow, orange, red, pink, gray, black)
|
||||
- **Tuỳ chỉnh** (Custom): Native `<input type="color">` for custom hex entry
|
||||
|
||||
Card has rounded border, shadow, padding. Compact layout fits mobile.
|
||||
|
||||
## Component Patterns
|
||||
|
||||
@@ -159,5 +167,12 @@ Keep emojis rare; use for celebration only.
|
||||
- Focus states: Implicit via Tailwind (`focus:ring-2`), add if extending components.
|
||||
- Contrast: Tailwind slate/indigo/emerald combos meet WCAG AA.
|
||||
- Dark mode: Respects `prefers-color-scheme`, not forced.
|
||||
- SVG glyphs: Simple, high-contrast shapes (no fill patterns); keyboard-navigable via fieldset + label.
|
||||
|
||||
Last reviewed: 2026-04-26
|
||||
## PWA Features
|
||||
|
||||
- **Install Prompt**: Native browser-driven (no custom banner). Manifest enables Android/iOS install.
|
||||
- **Offline**: App shell precache enables instant load. Audio cached on first play.
|
||||
- **Auto-Update**: SW detects new version; user sees reload toast (non-intrusive).
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
|
||||
@@ -12,15 +12,19 @@ now", see `docs/project-overview-pdr.md` and `docs/codebase-summary.md`.
|
||||
- "Quản trò đọc số" voice now speaks Chờ/Kinh on master in "both" mode
|
||||
- One-shot migration: `masterMode: true` → `mode: "both"`
|
||||
|
||||
### UI Polish v2 + Installable PWA (2026-04-27)
|
||||
- **Font**: Roboto Condensed self-hosted via @fontsource (Latin + Vietnamese subsets), swapped into `tan-tan-num` stack
|
||||
- **Empty State**: New `MasterEmptyState.svelte` component for first-run host (mirrors player UX)
|
||||
- **Mode Picker**: 3-way segmented toggle with inline SVG glyphs (player/master/both)
|
||||
- **Color Picker**: Bordered card with "Mẫu" (presets) + "Tuỳ chỉnh" (custom input) sections
|
||||
- **Header Polish**: Decorative dashes flanking subline (replaced all-caps tracking)
|
||||
- **PWA**: @vite-pwa/sveltekit generates SW + manifest. App shell precache (~353 KB). Audio runtime caching (CacheFirst). Auto-update w/ reload toast. Works offline at fairground (no signal venues)
|
||||
|
||||
## Idea Phase
|
||||
|
||||
### Undo Last Cell
|
||||
Allow player to undo the most recent cross/uncross action. Requires change history or state snapshot. **Status**: Idea (low priority)
|
||||
|
||||
|
||||
### PWA Install
|
||||
Add service worker and manifest for "Install App" prompt on Android/iOS. **Status**: Idea (would require server-side components)
|
||||
|
||||
## Considered Phase
|
||||
|
||||
### Multiplayer Sync (Real-time)
|
||||
@@ -86,4 +90,4 @@ All decisions follow **YAGNI** (You Aren't Gonna Need It), **KISS** (Keep It Sim
|
||||
Future work gates on **real user demand**, not speculation.
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
Last synced: 2026-04-27 (UI polish v2 + PWA shipped)
|
||||
|
||||
@@ -177,9 +177,27 @@ PlayerBoard:
|
||||
|
||||
Each draw creates a new object (even repeat numbers) to ensure reactive re-fire.
|
||||
|
||||
## Offline Capability
|
||||
## Offline Capability & PWA
|
||||
|
||||
All state is localStorage. No API calls. Fully functional offline after initial load.
|
||||
**Static Storage**: All game state in localStorage. No API calls. Fully functional offline.
|
||||
|
||||
**Service Worker**: @vite-pwa/sveltekit auto-generates `/sw.js` with:
|
||||
- App shell precache (~353 KB): HTML, CSS, JS bundles for instant load
|
||||
- Audio runtime caching: Voice clips cached on-demand (CacheFirst strategy)
|
||||
- Navigation: NetworkFirst (try network, fallback to cache)
|
||||
- Auto-update: Detects new versions, shows reload toast, skipWaiting on user action
|
||||
|
||||
**Manifest**: `/manifest.webmanifest` enables install prompt on Android/iOS:
|
||||
- Display: `standalone` (fullscreen app-like mode, no browser chrome)
|
||||
- Theme colors: Indigo primary, white background
|
||||
- Icons: 192px + 512px PNG (auto-generated)
|
||||
- Start URL: `/` with basePath awareness
|
||||
|
||||
**Installation Flow**:
|
||||
1. App loads, registers SW (production only, not dev)
|
||||
2. Browser detects manifest → shows install prompt (native, no custom banner)
|
||||
3. User installs → app appears on home screen, launches in standalone mode
|
||||
4. Updates: SW checks for new version; if found, toast appears; user can reload
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (three-mode + auto-tick feature)
|
||||
Last synced: 2026-04-27 (UI polish v2 + PWA: font, mode picker, color picker, empty state, PWA stack)
|
||||
|
||||
Generated
+4740
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@fontsource/roboto-condensed": "^5.2.8",
|
||||
"@sveltejs/adapter-static": "^3",
|
||||
"@sveltejs/kit": "^2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-svelte": "^2",
|
||||
"globals": "^15",
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Phase 1 — Vietnamese-safe font + master empty-state hero
|
||||
|
||||
Closes the two highest-impact deferred P0s from the audit:
|
||||
1. `tan-tan-num` font stack relies on system fonts that don't ship
|
||||
Vietnamese diacritics consistently on Android.
|
||||
2. `mode === "master"` with no game started shows just a button + line
|
||||
of text — feels broken.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P0
|
||||
**Effort**: ~40 min
|
||||
**Depends on**: nothing (independent)
|
||||
|
||||
## Files to add
|
||||
|
||||
- `static/fonts/roboto-condensed-700-vietnamese.woff2` — subset font
|
||||
asset (download once, commit). Or comparable face. ~30-50KB.
|
||||
- `src/lib/MasterEmptyState.svelte` (new) — small, isolated component.
|
||||
Reuses `tan-tan-num` and existing styles.
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/app.css` — add `@font-face` declaration; promote new face to
|
||||
the front of the `tan-tan-num` stack; ensure `font-display: swap`.
|
||||
- `src/lib/MasterPanel.svelte` — render `<MasterEmptyState />` when
|
||||
`state === null`, replacing the current line of text.
|
||||
|
||||
## Font choice
|
||||
|
||||
**Recommended**: Roboto Condensed Bold (700), Vietnamese-extended
|
||||
subset. Free under Apache-2.0. Already in `tan-tan-num` fallback list,
|
||||
so promotion is non-breaking. 28KB gz at 700 weight only.
|
||||
|
||||
Alternative: Oswald — bolder feel, narrower, more "fairground sign"
|
||||
mood. ~25KB gz. Pick Roboto unless we want a stronger brand shift.
|
||||
|
||||
Generate subset:
|
||||
|
||||
```bash
|
||||
npx glyphhanger \
|
||||
--formats=woff2 \
|
||||
--subset=src/**/*.svelte,src/**/*.js \
|
||||
--LATIN \
|
||||
--whitelist=U+0102,U+0103,U+1EA0-1EF9 \
|
||||
fonts/RobotoCondensed-Bold.ttf
|
||||
```
|
||||
|
||||
Or use `google-webfonts-helper` with manual subset selection.
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
src: url("/fonts/roboto-condensed-700-vietnamese.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0020-007F, U+00A0-024F, U+1E00-1EFF, U+0102-0103,
|
||||
U+1EA0-1EF9;
|
||||
}
|
||||
```
|
||||
|
||||
`tan-tan-num` already lists "Roboto Condensed" — promote it to the
|
||||
front (or leave as-is now that the @font-face declaration registers
|
||||
the family for the renderer). `font-display: swap` ensures Inter or
|
||||
similar renders first while the woff2 downloads.
|
||||
|
||||
## MasterEmptyState component
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// Decorative 11×9 ghost board mock — same proportions as the real
|
||||
// master tracking grid so the page silhouette stays consistent.
|
||||
</script>
|
||||
|
||||
<div class="text-center py-10">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
|
||||
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600"
|
||||
>
|
||||
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
|
||||
{#each Array(99) as _, i (i)}
|
||||
{@const filled = (i % 11) < 9 && i % 7 === 0}
|
||||
<div
|
||||
class="aspect-square text-[0.5rem] flex items-center justify-center
|
||||
text-black {filled ? 'bg-white dark:bg-slate-800 dark:text-slate-100' : ''}"
|
||||
style:background-color={filled ? undefined : "var(--empty-cell-bg)"}
|
||||
>
|
||||
{filled ? ((i * 13) % 90) + 1 : ""}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-block px-3 py-1 rounded-full text-xs font-semibold
|
||||
tracking-wider uppercase
|
||||
bg-orange-100 dark:bg-orange-950/40
|
||||
text-orange-700 dark:text-orange-300"
|
||||
>
|
||||
Chế độ Quản trò
|
||||
</span>
|
||||
<p class="mt-3 text-base text-slate-600 dark:text-slate-300 italic">
|
||||
Nhấn
|
||||
<span class="font-semibold text-orange-500 dark:text-orange-400 not-italic">
|
||||
"Ván mới"
|
||||
</span>
|
||||
để bắt đầu xổ số
|
||||
</p>
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
🎤 Đã sẵn sàng
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace the current `{:else} <div>Nhấn "Ván mới" ...</div>` block in
|
||||
`MasterPanel.svelte`.
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Subset Roboto Condensed Bold to Vietnamese, save as
|
||||
`static/fonts/roboto-condensed-700-vietnamese.woff2`
|
||||
- [ ] Add `@font-face` in `app.css` (with `unicode-range`, `swap`)
|
||||
- [ ] Verify `tan-tan-num` resolves the new face on Android Chrome
|
||||
(DevTools → Computed → Rendered fonts)
|
||||
- [ ] Create `src/lib/MasterEmptyState.svelte`
|
||||
- [ ] Wire into `MasterPanel.svelte` `{:else}` branch
|
||||
- [ ] Update CSP `font-src` in `static/_headers` to allow `'self'` (already does, just confirm)
|
||||
- [ ] `npm test && npm run build` — green
|
||||
- [ ] Manual: master mode alone, no game → empty-state hero shows;
|
||||
diacritics on "Chờ", "Đã" render correctly
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Numbers + Vietnamese-text-bearing labels render with the new face
|
||||
on Linux/Android (no fallback to default sans).
|
||||
- master-only mode no longer feels broken — hero ghost grid + role
|
||||
pill make the page feel intentional.
|
||||
- Lighthouse perf score doesn't drop more than 2 points (font is
|
||||
preloaded? Probably not needed — it's only used by the masthead.)
|
||||
|
||||
## Risks
|
||||
|
||||
- **Font license check**: confirm Apache-2.0 redistribution OK in the
|
||||
static export. (Yes — Roboto family is Apache-2.0.)
|
||||
- **FOUT flash on slow connection**: `font-display: swap` is the right
|
||||
tradeoff. Don't switch to `block` — it's worse UX.
|
||||
- **Subset miss**: if a Vietnamese diacritic combination isn't in the
|
||||
subset, the renderer falls back per-codepoint to the next family
|
||||
in the stack — visible but not broken. Mitigation: full
|
||||
Vietnamese subset > attempting smart trimming.
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Phase 2 — Mode picker icons, color picker, brand polish
|
||||
|
||||
Cleans up the medium-priority audit items in `SettingsButton.svelte`
|
||||
plus the header subline. Each is small and visual; the phase is one
|
||||
focused commit.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P1
|
||||
**Effort**: ~50 min
|
||||
**Depends on**: nothing (independent of phase 1)
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `src/lib/SettingsButton.svelte` — mode picker SVG glyphs, color
|
||||
picker layout, "Mặc định" button styling
|
||||
- `src/routes/+page.svelte` — header subline replacement
|
||||
|
||||
## Mode picker — inline SVG glyphs
|
||||
|
||||
Replace text-only buttons with text + tiny glyph above. No icon
|
||||
library; ~15 lines of inline SVG total.
|
||||
|
||||
```svelte
|
||||
{#snippet modeIcon(v)}
|
||||
{#if v === "player"}
|
||||
<!-- Card with cells -->
|
||||
<svg viewBox="0 0 24 16" class="w-6 h-4 mx-auto" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="1" y="1" width="22" height="14" rx="1.5" />
|
||||
<path d="M1 6h22M1 11h22M8 1v14M16 1v14" />
|
||||
</svg>
|
||||
{:else if v === "master"}
|
||||
<!-- Megaphone -->
|
||||
<svg viewBox="0 0 24 24" class="w-5 h-5 mx-auto" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M3 11l14-6v14L3 13z M3 11v2 M19 8a4 4 0 0 1 0 8" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Two stacked cards -->
|
||||
<svg viewBox="0 0 24 20" class="w-6 h-5 mx-auto" fill="none"
|
||||
stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="1" y="1" width="18" height="11" rx="1" />
|
||||
<rect x="5" y="7" width="18" height="11" rx="1" />
|
||||
</svg>
|
||||
{/if}
|
||||
{/snippet}
|
||||
```
|
||||
|
||||
Inside the existing `{#each MODES}` loop:
|
||||
|
||||
```svelte
|
||||
<button ...>
|
||||
{@render modeIcon(v)}
|
||||
<span class="block mt-0.5">{label}</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Add `flex flex-col items-center` to the button class so glyph and
|
||||
label stack cleanly. Existing `MODE_HINTS[settings.mode]` line stays.
|
||||
|
||||
## Color picker layout
|
||||
|
||||
Wrap in a single bordered card with sub-headers, native input on the
|
||||
left, presets on the right. Replace the current loose flex/grid:
|
||||
|
||||
```svelte
|
||||
<fieldset class="mb-5">
|
||||
<legend class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2">
|
||||
Màu ô trống
|
||||
</legend>
|
||||
<div class="rounded-xl border border-slate-200 dark:border-slate-700 p-3 space-y-3">
|
||||
<!-- Custom -->
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Tuỳ chỉnh
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="color" .../>
|
||||
<code class="text-sm font-mono ...">{settings.emptyCellColor}</code>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Presets -->
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Mẫu sẵn
|
||||
</p>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each PRESETS as hex (hex)}
|
||||
...
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Adds visual structure; no new logic.
|
||||
|
||||
## "Mặc định" button — clearer affordance
|
||||
|
||||
Currently a near-invisible footer link. Keep it understated but make
|
||||
it look like a button:
|
||||
|
||||
```svelte
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => resetSettings()}
|
||||
class="px-4 py-2 rounded-full text-sm font-medium
|
||||
text-slate-600 dark:text-slate-300
|
||||
border border-slate-300 dark:border-slate-600
|
||||
hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Đặt lại
|
||||
</button>
|
||||
```
|
||||
|
||||
Rename "Mặc định" → "Đặt lại" (clearer = "reset" not "default").
|
||||
|
||||
## Header subline — fairground mood
|
||||
|
||||
Current:
|
||||
|
||||
```svelte
|
||||
<p class="text-xs sm:text-sm uppercase tracking-[0.28em] text-slate-600 dark:text-slate-300 italic mt-1.5 font-medium">
|
||||
Hội chợ TN1
|
||||
</p>
|
||||
```
|
||||
|
||||
New: drop the heavy uppercase tracking; flank with hairline dashes;
|
||||
add a tiny lantern emoji to anchor mood. Keep `text-sm`, drop italic.
|
||||
|
||||
```svelte
|
||||
<p class="flex items-center justify-center gap-2 text-xs sm:text-sm
|
||||
text-slate-600 dark:text-slate-300 mt-1.5 font-medium">
|
||||
<span aria-hidden="true" class="block w-6 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
|
||||
<span>🏮 Hội chợ TN1</span>
|
||||
<span aria-hidden="true" class="block w-6 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] Add inline mode-picker SVG glyphs (snippet pattern)
|
||||
- [ ] Restructure mode picker buttons for vertical icon+label layout
|
||||
- [ ] Wrap color picker in a single bordered card with sub-headers
|
||||
- [ ] Style "Mặc định" → "Đặt lại" with bordered chip look
|
||||
- [ ] Replace header subline with dash-flanked lantern band
|
||||
- [ ] `npm test && npm run build` — green
|
||||
- [ ] Manual smoke: mode picker glyphs read on light + dark; color
|
||||
picker visually grouped; reset button clearly clickable.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Mode picker communicates role at a glance without reading the label.
|
||||
- Color picker no longer feels disconnected (single card vs loose).
|
||||
- Reset button clearly affordant but not destructive-looking.
|
||||
- Header subline reads as fairground decor, not SaaS subhead.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Glyph stroke width on dark mode**: `currentColor` inherits from
|
||||
the button text, so it follows the indigo-active vs slate-resting
|
||||
states automatically. Test contrast on both.
|
||||
- **Color picker height growth**: bordered card adds ~16px vertical;
|
||||
the modal scrolls anyway, no real cost.
|
||||
- **"Đặt lại" wording**: "Mặc định" is the current Vietnamese
|
||||
convention; "Đặt lại" is clearer but slightly less polite. Both
|
||||
work — defer to user if they push back.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Phase 3 — Installable PWA with offline audio
|
||||
|
||||
Ship the app as a Progressive Web App so users can install it to home
|
||||
screen and play without signal. Critical for fairground use where
|
||||
venue Wi-Fi is unreliable.
|
||||
|
||||
**Status**: not started
|
||||
**Priority**: P1
|
||||
**Effort**: ~60 min
|
||||
**Depends on**: nothing (independent of phases 1 & 2)
|
||||
|
||||
## Files to add
|
||||
|
||||
- `static/manifest.webmanifest` — PWA metadata
|
||||
- `static/icons/icon-192.png`, `icon-512.png`, `icon-maskable-512.png`
|
||||
— exported from a single SVG source
|
||||
- `static/icons/source.svg` (optional — keeps a vectorized origin
|
||||
alongside the rasters)
|
||||
|
||||
## Files to modify
|
||||
|
||||
- `package.json` — add `@vite-pwa/sveltekit` dev dependency
|
||||
- `vite.config.js` — register the SvelteKitPWA plugin
|
||||
- `src/app.html` — `<link rel="manifest">`, `<meta name="theme-color">`,
|
||||
apple-touch-icon
|
||||
- `svelte.config.js` — confirm static adapter still works with PWA
|
||||
plugin (it does, but verify)
|
||||
|
||||
## Plugin choice — `@vite-pwa/sveltekit`
|
||||
|
||||
Mature, actively maintained, integrates cleanly with `adapter-static`.
|
||||
Generates the service worker via Workbox under the hood with sensible
|
||||
defaults.
|
||||
|
||||
```bash
|
||||
npm install -D @vite-pwa/sveltekit
|
||||
```
|
||||
|
||||
```js
|
||||
// vite.config.js
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.ico", "icons/*.png", "audio/**/*.mp3"],
|
||||
manifest: false, // we ship our own static/manifest.webmanifest
|
||||
workbox: {
|
||||
globPatterns: [
|
||||
"**/*.{js,css,html,svg,png,woff2,webmanifest}",
|
||||
"audio/**/*.mp3",
|
||||
],
|
||||
// 184 audio clips × ~10KB ≈ 2MB. Workbox default is 2MB so
|
||||
// bump explicitly to be safe.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
navigateFallback: "/index.html",
|
||||
},
|
||||
devOptions: { enabled: false }, // don't pollute dev with SW
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## manifest.webmanifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Lô tô — Hội chợ TN1",
|
||||
"short_name": "Lô tô",
|
||||
"description": "Bàn số của trò chơi Lô tô",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#1565c0",
|
||||
"background_color": "#0a0f1f",
|
||||
"lang": "vi",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`theme_color` matches the section accent. `background_color` matches
|
||||
dark-theme base (so the splash screen feels intentional).
|
||||
|
||||
## app.html additions
|
||||
|
||||
```html
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#1565c0"
|
||||
media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0f1f"
|
||||
media="(prefers-color-scheme: dark)" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
Use a single 512×512 SVG source. Export 192/512 standard + 512
|
||||
maskable (with 20% safe-zone padding for Android adaptive icons).
|
||||
|
||||
Quick path: ImageMagick.
|
||||
|
||||
```bash
|
||||
magick static/icons/source.svg -resize 192x192 static/icons/icon-192.png
|
||||
magick static/icons/source.svg -resize 512x512 static/icons/icon-512.png
|
||||
# Maskable: pad 20% transparent safe zone
|
||||
magick static/icons/source.svg -background none -gravity center \
|
||||
-resize 70%x70% -extent 512x512 static/icons/icon-maskable-512.png
|
||||
```
|
||||
|
||||
Source SVG: a stylized bingo card grid + the rose/amber gradient
|
||||
from the page header. Or (simpler) a centered "L" wordmark in the
|
||||
brand gradient.
|
||||
|
||||
## CSP for the manifest
|
||||
|
||||
Confirm `static/_headers` allows `manifest-src 'self'` and the icon
|
||||
fetches. Today's CSP has `default-src 'self'` which covers it.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx serve build
|
||||
# Open http://localhost:3000 in Chrome
|
||||
# DevTools → Application → Manifest (verify icons + theme)
|
||||
# DevTools → Application → Service Workers (verify registration)
|
||||
# Network tab → check "Offline" → reload — app still works
|
||||
# Audio playback: turn off network, draw a number — clip plays
|
||||
```
|
||||
|
||||
## Update flow
|
||||
|
||||
`autoUpdate` mode: on each new deploy, the SW detects the new build
|
||||
and swaps in. Users see the new version on next navigation. Don't
|
||||
add a "new version" toast in v1 — the swap is automatic and silent.
|
||||
If users complain about content jumping mid-game, layer on a manual
|
||||
prompt later (out of scope).
|
||||
|
||||
## Todo
|
||||
|
||||
- [ ] `npm install -D @vite-pwa/sveltekit`
|
||||
- [ ] Add SvelteKitPWA to `vite.config.js`
|
||||
- [ ] Create `static/manifest.webmanifest`
|
||||
- [ ] Generate three icons (192, 512, maskable-512) from source SVG
|
||||
- [ ] Add manifest + theme-color meta tags + apple-touch-icon to
|
||||
`src/app.html`
|
||||
- [ ] Verify `static/_headers` CSP still permits manifest + SW
|
||||
- [ ] `npm run build && npx serve build` — manual offline test
|
||||
- [ ] Confirm `npm test` still passes (PWA shouldn't touch test paths)
|
||||
- [ ] Lighthouse audit — PWA category should hit 100
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Chrome shows the install prompt (≥1 visit + qualifying engagement)
|
||||
- iOS Safari "Add to Home Screen" launches in standalone, no browser
|
||||
chrome
|
||||
- Splash screen uses the right theme color and dark background
|
||||
- Offline reload renders the app and plays audio clips for previously
|
||||
downloaded voices
|
||||
- Lighthouse PWA score = 100, perf score doesn't drop > 5 points
|
||||
- Service worker registration is silent (no console errors)
|
||||
|
||||
## Risks
|
||||
|
||||
- **Service worker stale-content trap**: `autoUpdate` registers a new
|
||||
SW; old tabs keep the old build until reload. Acceptable; users
|
||||
reload between rounds.
|
||||
- **Cloudflare Pages SW caching headers**: Cloudflare caches `/sw.js`
|
||||
by default; need `Cache-Control: no-cache` on `sw.js` so updates
|
||||
propagate. Add to `static/_headers`:
|
||||
```
|
||||
/sw.js
|
||||
Cache-Control: no-cache
|
||||
```
|
||||
- **Audio precache size**: 2 voices × 92 clips. Measure with
|
||||
`ls -la static/audio/*/*.mp3 | awk '{s+=$5} END {print s}'`.
|
||||
If > 5MB, consider runtime-cached strategy instead of precache.
|
||||
- **iOS quirks**: Safari requires `apple-mobile-web-app-capable`
|
||||
meta + apple-touch-icon for proper standalone launch. Both
|
||||
included.
|
||||
- **GitHub Pages mirror**: SW path expectations differ when served
|
||||
from `/loto/` base. `@vite-pwa/sveltekit` reads SvelteKit's
|
||||
`paths.base` so this works automatically. Verify in
|
||||
`npm run build:gh`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- "New version available" toast (deferred — autoUpdate is silent v1)
|
||||
- Push notifications
|
||||
- Background sync (no backend to sync with)
|
||||
- Workbox runtime caching strategies beyond defaults
|
||||
- Custom install prompt UI (use browser native)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
slug: ui-polish-and-pwa-v2
|
||||
created: 2026-04-27
|
||||
status: completed
|
||||
completedAt: 2026-04-27
|
||||
mode: fast
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# UI polish v2 + installable PWA
|
||||
|
||||
Finishes the UI/UX audit items deferred in `ad6291e` (Vietnamese font,
|
||||
master empty state, mode picker iconography, color picker, brand mood)
|
||||
and ships the app as an installable PWA so it works offline at the
|
||||
fairground (no signal in many venues).
|
||||
|
||||
## Why now
|
||||
|
||||
- The audit's remaining P1/P2 items are all small, concrete fixes with
|
||||
sketched solutions — bundle them before the codebase grows.
|
||||
- The Vietnamese font gap is the single biggest legibility risk on
|
||||
Android phones, which is the primary device for fairground use.
|
||||
- "Works offline at a fairground with poor signal" is the killer
|
||||
feature that justifies installing it. PWA support is straightforward
|
||||
with `@vite-pwa/sveltekit` (already-static export).
|
||||
|
||||
## Decisions (locked unless flagged)
|
||||
|
||||
- **Font policy**: self-host one weight (Roboto Condensed Bold or
|
||||
Oswald Vietnamese subset) via `@font-face` with `font-display: swap`.
|
||||
Add to `tan-tan-num` stack as the primary face. Subset to `latin` +
|
||||
`latin-ext` + `vietnamese`. ~30-50KB gz'd.
|
||||
- **PWA stack**: `@vite-pwa/sveltekit`. Service-worker strategy:
|
||||
`precache` all 184 audio clips (2 voices × 92 names) + app shell;
|
||||
`NetworkFirst` for navigation; cache-first for static assets.
|
||||
- **No backend, no install hostility**: ship `manifest.webmanifest`
|
||||
with icons, theme color, name. Don't show a "Install this app"
|
||||
banner — let the browser native flow handle it. (Out of scope for
|
||||
v2; can layer on later if data shows it's needed.)
|
||||
- **Mode picker icons**: SVG inline, no icon library. 3 small glyphs
|
||||
hand-drawn from primitives (rectangle + ellipse).
|
||||
- **Color picker**: keep the 10-swatch + native input layout, but
|
||||
group them in one card with `Tuỳ chỉnh` / `Mẫu` sub-headers.
|
||||
- **Header brand polish**: drop the all-caps tracking-[0.28em] sub
|
||||
and replace with decorative dashes. Keep current font stack.
|
||||
- **Per-row Chờ indicator**: a subtle ring on the row label band
|
||||
(`section-label`) when a Chờ row exists in that section. Optional
|
||||
— fall back to today's toast if implementation stretches.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | File | Status | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 1 | Vietnamese font + MasterEmptyState | DONE | @fontsource/roboto-condensed added to tan-tan-num stack |
|
||||
| 2 | Mode picker + color picker + brand polish | DONE | SVG glyphs inline, bordered card with sub-headers, header subline with decorative dashes |
|
||||
| 3 | Installable PWA (offline-capable) | DONE | @vite-pwa/sveltekit + manifest + icons; audio uses runtime CacheFirst (not precache due to static adapter timing) |
|
||||
|
||||
All phases shipped in commit `fba3e91`. Total delivery: ~2.5 hr.
|
||||
|
||||
## Out of scope (parking lot)
|
||||
|
||||
- Multiple cards per player.
|
||||
- Long-press hero to undo last call.
|
||||
- Spacebar to draw next number.
|
||||
- Internationalization (English UI).
|
||||
- LRU on audio cache (deferred from security audit — only matters at
|
||||
voices > 10).
|
||||
- Bumping `@sveltejs/kit` to ship `cookie >=0.7.0` (next dependency
|
||||
sweep — not exploitable in static export today).
|
||||
- Tier-2 confetti threshold tweak.
|
||||
- 'unsafe-inline' style → hashed CSP (requires Svelte build tweak).
|
||||
|
||||
## Risks
|
||||
|
||||
- **Font swap visible flash on slow connections**: `font-display: swap`
|
||||
shows fallback first. Acceptable; alternative (`block`) blocks
|
||||
rendering up to 3s.
|
||||
- **Service worker stale-content trap**: `@vite-pwa/sveltekit` ships
|
||||
with `autoUpdate` mode. Will use it; users see a "new version
|
||||
available" reload toast. Verify the SW skipWaiting flow doesn't
|
||||
drop in-flight audio.
|
||||
- **Install on iOS Safari is awkward**: Safari requires "Add to
|
||||
Home Screen" manually; manifest still helps standalone display.
|
||||
- **Audio precache size**: 2 voices × 92 clips. If clips are ~10KB
|
||||
each, total ~1.8MB. Manageable but worth measuring before locking
|
||||
precache strategy.
|
||||
|
||||
## Rollback
|
||||
|
||||
Each phase is one-commit revertible. PWA can be feature-flagged
|
||||
(don't register SW in dev) so reverts don't strand cached SWs in
|
||||
users' browsers.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Code review — UI polish v2 + PWA (260427-2030)
|
||||
|
||||
**Scope:** uncommitted changes (9 modified, 5 new). LOC delta ~280 (excluding lockfile). All 115 tests pass; build clean.
|
||||
**Verdict:** ship-ready with minor follow-ups. No P0 blockers. A couple of P1s worth fixing pre-deploy.
|
||||
|
||||
## P0 (blocking)
|
||||
None.
|
||||
|
||||
## P1 (fix before deploy)
|
||||
|
||||
**1. Audio cache `maxEntries: 200` — no headroom for a 3rd voice.**
|
||||
`vite.config.js:38`. Today: 2 voices × 92 = 184 clips. Cap = 200, only 16-clip headroom. If a 3rd voice ships (`hoai-my`/`nam-minh` already exist; future voice would push to 276) Workbox LRU will evict mid-game. Bump to 400 or `2 * voices.length * 92`. Zero runtime cost — `maxEntries` only affects eviction policy, not memory.
|
||||
|
||||
**2. Plan vs. impl divergence — audio strategy silently changed.**
|
||||
Plan (phase-03 §workbox) called for `precache` of all 184 mp3s. Implementation chose runtime `CacheFirst` (`vite.config.js:31-44`). This is actually a better call (saves ~1.87 MB from initial precache, app-shell-only is 213 entries / ~353 KB now). But: **first offline play of any uncached voice will fail silently** — fairground use case (no signal at venue) won't work for clips the user hasn't played yet. Plan's "killer feature: works offline" assumes precache. Two options: (a) document the "warm-up: play once on Wi-Fi" requirement in UI; (b) precache the default voice only (~92 × 10 KB = 920 KB) and runtime-cache alternates. (b) is the right answer.
|
||||
|
||||
**3. CSP — no `connect-src` allowance for SW update fetches.**
|
||||
`static/_headers:2`. `connect-src 'self'` is present — fine. But Workbox `autoUpdate` fetches `/sw.js` periodically; with the new `Cache-Control: no-cache` header on `/sw.js` (good!) the SW will revalidate. No CSP gap detected. `worker-src 'self'` and `manifest-src 'self'` are correctly added. **No action required** — flagging because the question was asked.
|
||||
|
||||
**4. `autoUpdate` mid-game stale-content trap is real.**
|
||||
`vite.config.js:18`. `registerType: "autoUpdate"` + Workbox default `skipWaiting: false` means the new SW activates only after **all tabs close**. For a fairground host running the app for an hour straight, this is fine. BUT if anyone bumps `skipWaiting` later, mid-game asset reload could swap the JS bundle while audio is buffering → broken playback. Suggest a code comment in `vite.config.js` documenting "do NOT add skipWaiting without a reload-prompt UI" so future-you doesn't silently break it.
|
||||
|
||||
## P2 (cleanup, non-blocking)
|
||||
|
||||
**5. DRY gap — `MasterEmptyState` and `PlayerBoard` empty branch are near-duplicates.**
|
||||
`MasterEmptyState.svelte:6-43` vs `PlayerBoard.svelte:343-371`. Both: ghost grid (99 vs 27 cells) → italic prompt → emoji subline. Differences: grid size, role pill (master only), accent color (orange vs rose). Worth extracting shared `<EmptyStateHero {gridCells} {gridCols} {rolePill} {prompt} {subline} {accent} />` if a 3rd empty state ever lands. Today: not worth the indirection. **Skip.**
|
||||
|
||||
**6. Maskable icon — content ratio 70% is below Android's recommended 80%.**
|
||||
`source.svg:14` shows 400/512 = 78% inner content area in the standard icon, but `phase-03` plan §icons used `-resize 70%x70%` for maskable padding (= 70% safe zone). Android adaptive icons crop to a circle of diameter ~80% of the container. 70% is fine — slightly conservative — but the wordmark "Lô tô" with stroke at `font-size:240` may still graze edges after the 70% scale. Risk: text clipping on aggressive shape masks (squircle/teardrop). Quick fix: re-export maskable at 65%. Verify in Chrome DevTools → Application → Manifest → "Show maskable preview".
|
||||
|
||||
**7. iOS Safari — `apple-touch-icon` only ships 192px.**
|
||||
`app.html:11`. Apple recommends 180×180 specifically; 192×192 works (Safari scales) but a dedicated 180px is the convention. Also missing: `apple-touch-startup-image` for splash. Low priority — standalone launch will still work, splash is a minor polish.
|
||||
|
||||
**8. `MasterEmptyState.svelte:13` unused destructured var pattern.**
|
||||
`{#each Array(99) as _, i (i)}` — works but `Array(99)` creates a sparse array; some bundler/linter combos warn on iterating sparse arrays. Use `{#each {length: 99}, i}` if Svelte 5 supports it, else `Array.from({length:99})`. Cosmetic; current code works.
|
||||
|
||||
**9. Font swap — confirmed safe.**
|
||||
`@fontsource/roboto-condensed/700.css` ships `font-display: swap` by default per Fontsource docs. FOUT risk: minimal (28 KB woff2, served from same origin under `font-src 'self' data:`). Fallback stack at `app.css:140-146` (Arial Narrow, Avenir Next Condensed, Liberation Sans Narrow, system-ui) ensures Vietnamese diacritics render before Roboto Condensed loads. **No action.**
|
||||
|
||||
## Plan compliance check
|
||||
|
||||
| Plan item | Shipped? |
|
||||
|---|---|
|
||||
| Phase 1: Vietnamese font + master empty state | ✅ (font weight 900→700 documented in CSS comment) |
|
||||
| Phase 2: mode picker icons + color picker card + brand subline | ✅ |
|
||||
| Phase 3: PWA + offline | ⚠️ audio precache → runtime-only (P1 §2 above) |
|
||||
| Section-label Chờ ring (plan §decisions, marked optional) | ❌ skipped (was flagged optional, fine) |
|
||||
| `npm test` still passing | ✅ 115/115 |
|
||||
| Lighthouse PWA audit run | ❓ not in evidence — recommend before deploy |
|
||||
|
||||
## Positive observations
|
||||
|
||||
- `MasterPanel.svelte:135` correctly cancels playback on unmount — no audio leak when switching modes mid-clip.
|
||||
- `static/_headers:9-13` adds `no-cache` on both `/sw.js` AND `/manifest.webmanifest` — Cloudflare-aware, correct.
|
||||
- `manifest.webmanifest` uses relative `start_url: "."` and `scope: "."` — survives the `BUILD_PROFILE=gh` `/loto/` base path automatically. Good call vs. hardcoding `/`.
|
||||
- Dual `theme-color` meta with media queries (`app.html:9-10`) — proper light/dark splash on iOS 15+.
|
||||
- Scope freeze on `BOARD` + `BOARD_FLAT` (`MasterPanel.svelte:77-78`) — correct from prior review.
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Was the audio strategy change (precache → runtime CacheFirst) intentional, or did it slip during impl? If intentional, plan should be updated retroactively. If not, P1 §2 should be applied.
|
||||
2. Has the GitHub Pages mirror (`BUILD_PROFILE=gh`) been smoke-tested? `@vite-pwa/sveltekit` with `paths.base = "/loto"` usually rewrites SW scope correctly, but the hand-written manifest icon paths (`icons/icon-192.png` relative) need verification under `/loto/`.
|
||||
3. Lighthouse PWA score — was 100/100 confirmed?
|
||||
+11
-2
@@ -1,4 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
/* Self-hosted Roboto Condensed Bold for the .tan-tan-num class
|
||||
(player numbers + master hero). Fontsource declares per-subset
|
||||
@font-face blocks with `unicode-range` so the browser only fetches
|
||||
the Vietnamese subset when those codepoints are rendered. Bold
|
||||
weight only — that's all .tan-tan-num uses. */
|
||||
@import "@fontsource/roboto-condensed/700.css";
|
||||
|
||||
/* Class-based dark-mode variant. The settings store toggles `class="dark"`
|
||||
on <html>, mirroring OS preference when theme="auto" or honoring the
|
||||
@@ -132,13 +138,16 @@ body {
|
||||
on platforms without one so the digits still read tall-and-tight. */
|
||||
.tan-tan-num {
|
||||
font-family:
|
||||
"Roboto Condensed",
|
||||
"Arial Narrow",
|
||||
"Avenir Next Condensed",
|
||||
"Roboto Condensed",
|
||||
"Liberation Sans Narrow",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-weight: 900;
|
||||
/* fontsource ships Roboto Condensed at 700; bumping to 900 (the
|
||||
previous max) would fall back to system fonts. 700 + tight
|
||||
letter-spacing keeps the tall-condensed look. */
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lô tô</title>
|
||||
<meta name="description" content="Bàn số của trò chơi Lô tô" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#1565c0" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0f1f" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icons/icon-192.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="min-h-full flex flex-col">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- Empty-state hero for master mode. Replaces the bare line of text
|
||||
when no game has been started; uses a decorative ghost mock of
|
||||
the 11×9 master tracking grid so the page silhouette stays
|
||||
consistent with what the user sees mid-game. -->
|
||||
|
||||
<div class="text-center py-10">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
|
||||
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600"
|
||||
>
|
||||
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
|
||||
{#each Array(99) as _, i (i)}
|
||||
{@const filled = i % 11 < 9 && i % 7 === 0}
|
||||
<div
|
||||
class="aspect-square text-[0.5rem] flex items-center justify-center
|
||||
text-black {filled ? 'bg-white dark:bg-slate-800 dark:text-slate-100' : ''}"
|
||||
style:background-color={filled ? undefined : "var(--empty-cell-bg)"}
|
||||
>
|
||||
{filled ? ((i * 13) % 90) + 1 : ""}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-block px-3 py-1 rounded-full text-xs font-semibold
|
||||
tracking-wider uppercase
|
||||
bg-orange-100 dark:bg-orange-950/40
|
||||
text-orange-700 dark:text-orange-300"
|
||||
>
|
||||
Chế độ Quản trò
|
||||
</span>
|
||||
<p class="mt-3 text-base text-slate-600 dark:text-slate-300 italic">
|
||||
Nhấn
|
||||
<span class="font-semibold text-orange-500 dark:text-orange-400 not-italic">
|
||||
"Ván mới"
|
||||
</span>
|
||||
để bắt đầu xổ số
|
||||
</p>
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
🎤 Đã sẵn sàng
|
||||
</p>
|
||||
</div>
|
||||
@@ -80,6 +80,7 @@
|
||||
|
||||
<script>
|
||||
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
|
||||
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||
|
||||
@@ -344,7 +345,5 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-slate-500 dark:text-slate-400 py-10 text-base">
|
||||
Nhấn "Ván mới" để bắt đầu
|
||||
</div>
|
||||
<MasterEmptyState />
|
||||
{/if}
|
||||
|
||||
@@ -251,12 +251,29 @@
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickMode(v)}
|
||||
class="px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
class="flex flex-col items-center gap-1 px-2 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{selected
|
||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-500'}"
|
||||
>
|
||||
{label}
|
||||
{#if v === "player"}
|
||||
<svg viewBox="0 0 24 16" class="w-7 h-5" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="22" height="14" rx="1.5" />
|
||||
<path d="M1 6h22M1 11h22M8 1v14M16 1v14" />
|
||||
</svg>
|
||||
{:else if v === "master"}
|
||||
<svg viewBox="0 0 24 24" class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 11l14-6v14L3 13z" />
|
||||
<path d="M3 11v2" />
|
||||
<path d="M19 8a4 4 0 0 1 0 8" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 20" class="w-7 h-6" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<rect x="1" y="1" width="16" height="11" rx="1.2" />
|
||||
<rect x="7" y="7" width="16" height="11" rx="1.2" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -364,37 +381,49 @@
|
||||
Màu ô trống
|
||||
</legend>
|
||||
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Chọn màu tuỳ ý"
|
||||
value={settings.emptyCellColor}
|
||||
oninput={onPickerInput}
|
||||
class="w-12 h-10 rounded-lg border border-slate-300 dark:border-slate-600 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<code
|
||||
class="text-xs font-mono text-slate-500 dark:text-slate-400 tabular-nums"
|
||||
>
|
||||
{settings.emptyCellColor}
|
||||
</code>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 dark:border-slate-700 p-3 space-y-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Tuỳ chỉnh
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Chọn màu tuỳ ý"
|
||||
value={settings.emptyCellColor}
|
||||
oninput={onPickerInput}
|
||||
class="w-12 h-10 rounded-lg border border-slate-300 dark:border-slate-600 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<code
|
||||
class="text-sm font-mono text-slate-600 dark:text-slate-300 tabular-nums"
|
||||
>
|
||||
{settings.emptyCellColor}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each PRESETS as hex (hex)}
|
||||
{@const selected =
|
||||
settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Đặt màu {hex}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickColor(hex)}
|
||||
style:background-color={hex}
|
||||
class="aspect-square rounded-lg border-2 transition-all
|
||||
{selected
|
||||
? 'border-indigo-500 dark:border-indigo-400 ring-2 ring-indigo-300 dark:ring-indigo-600 scale-110'
|
||||
: 'border-slate-200 dark:border-slate-600 hover:scale-105'}"
|
||||
></button>
|
||||
{/each}
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 mb-1.5">
|
||||
Mẫu sẵn
|
||||
</p>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each PRESETS as hex (hex)}
|
||||
{@const selected =
|
||||
settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Đặt màu {hex}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickColor(hex)}
|
||||
style:background-color={hex}
|
||||
class="aspect-square rounded-lg border-2 transition-all
|
||||
{selected
|
||||
? 'border-indigo-500 dark:border-indigo-400 ring-2 ring-indigo-300 dark:ring-indigo-600 scale-110'
|
||||
: 'border-slate-200 dark:border-slate-600 hover:scale-105'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -404,9 +433,10 @@
|
||||
onclick={() => resetSettings()}
|
||||
class="px-4 py-2 rounded-full text-sm font-medium
|
||||
text-slate-600 dark:text-slate-300
|
||||
border border-slate-300 dark:border-slate-600
|
||||
hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Mặc định
|
||||
Đặt lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
Lô tô
|
||||
</h1>
|
||||
<p
|
||||
class="text-xs sm:text-sm uppercase tracking-[0.28em] text-slate-600 dark:text-slate-300 italic mt-1.5 font-medium"
|
||||
class="flex items-center justify-center gap-2 text-xs sm:text-sm
|
||||
text-slate-600 dark:text-slate-300 mt-1.5 font-medium"
|
||||
>
|
||||
Hội chợ TN1
|
||||
<span aria-hidden="true" class="block w-6 sm:w-8 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
|
||||
<span>🏮 Hội chợ TN1</span>
|
||||
<span aria-hidden="true" class="block w-6 sm:w-8 h-px bg-slate-400/60 dark:bg-slate-500/50"></span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
|
||||
Content-Security-Policy: default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; manifest-src 'self'; worker-src 'self'
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
|
||||
X-Frame-Options: DENY
|
||||
|
||||
# Service worker must always re-validate so fresh deploys propagate.
|
||||
/sw.js
|
||||
Cache-Control: no-cache
|
||||
|
||||
/manifest.webmanifest
|
||||
Cache-Control: no-cache
|
||||
Content-Type: application/manifest+json
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f43f5e"/>
|
||||
<stop offset="50%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#f43f5e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="text" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#fff8e7"/>
|
||||
<stop offset="100%" stop-color="#ffe9b8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<rect x="56" y="56" width="400" height="400" rx="64" fill="#0a0f1f" opacity="0.18"/>
|
||||
<text
|
||||
x="256" y="312"
|
||||
text-anchor="middle"
|
||||
font-family="Roboto Condensed, Arial Narrow, system-ui, sans-serif"
|
||||
font-weight="900"
|
||||
font-size="240"
|
||||
font-style="italic"
|
||||
letter-spacing="-8"
|
||||
fill="url(#text)"
|
||||
style="paint-order:stroke;stroke:#0a0f1f;stroke-width:8;stroke-opacity:0.18"
|
||||
>Lô tô</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Lô tô — Hội chợ TN1",
|
||||
"short_name": "Lô tô",
|
||||
"description": "Bàn số của trò chơi Lô tô",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#1565c0",
|
||||
"background_color": "#0a0f1f",
|
||||
"lang": "vi",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+60
-1
@@ -1,7 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
// 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.
|
||||
const audioManifest = JSON.parse(
|
||||
readFileSync("./static/audio/manifest.json", "utf8"),
|
||||
);
|
||||
const defaultVoiceId = audioManifest.voices[0]?.id ?? "hoai-my";
|
||||
const clipNames = [
|
||||
...Array.from({ length: 90 }, (_, i) => String(i + 1)),
|
||||
"cho",
|
||||
"kinh",
|
||||
];
|
||||
const defaultVoicePrecacheEntries = clipNames.map((n) => ({
|
||||
url: `/audio/${defaultVoiceId}/${n}.mp3`,
|
||||
// Static asset with a stable name; Workbox needs a revision string
|
||||
// to track changes. Bump the prefix when audio is regenerated.
|
||||
revision: `audio-v1-${defaultVoiceId}-${n}`,
|
||||
}));
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// .env.local lives outside process.env at config-eval time; loadEnv reads it.
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
@@ -10,7 +31,45 @@ export default defineConfig(({ mode }) => {
|
||||
const port = Number(env.CODESERVER_PORT ?? 3000);
|
||||
|
||||
return {
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA({
|
||||
// Do NOT add `skipWaiting` without a reload-prompt UI — it would
|
||||
// swap the SW mid-game and lose state.
|
||||
registerType: "autoUpdate",
|
||||
// Ship a hand-written manifest so the icons/theme stay aligned
|
||||
// with /static; the plugin can also generate one but mixing is
|
||||
// confusing.
|
||||
strategies: "generateSW",
|
||||
manifest: false,
|
||||
includeAssets: ["icons/*.png", "audio/**/*.mp3"],
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2,webmanifest}"],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
// App shell + DEFAULT voice clips → guaranteed offline. Other
|
||||
// voices fall through to the CacheFirst runtime rule below
|
||||
// (cached on first play, offline thereafter).
|
||||
additionalManifestEntries: defaultVoicePrecacheEntries,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/audio\/.*\.mp3$/,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "loto-audio",
|
||||
expiration: {
|
||||
// Headroom for future voice growth: 4× current 184 clips.
|
||||
maxEntries: 400,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
},
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port,
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user