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:
2026-04-27 20:35:59 +07:00
parent f7cbb6e1c7
commit f899b09631
24 changed files with 5756 additions and 68 deletions
+16 -12
View File
@@ -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 (09), teens (1019 incl. mười lăm), 2090 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 1622). |
| `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 (190 + 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 (190 + 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)
+21 -6
View File
@@ -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
+9 -5
View File
@@ -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)
+21 -3
View File
@@ -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)
+4740
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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",
@@ -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.
@@ -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
View File
@@ -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;
}
+6
View File
@@ -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">
+43
View File
@@ -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>
+2 -3
View File
@@ -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}
+63 -33
View File
@@ -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"
+5 -2
View File
@@ -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
View File
@@ -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

+26
View File
@@ -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

+30
View File
@@ -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
View File
@@ -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,