mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 08:15:46 +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:
1 parent
ad6291e924
commit
f7db20c13a
24 files changed
+5756
-68
No files matched your search
+16
-12
@@ -11,8 +11,9 @@
|
|||||||
### Shared Components
|
### Shared Components
|
||||||
| File | Purpose |
|
| 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/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`). 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/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/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 `/`. |
|
| `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/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. |
|
| `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 |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env. |
|
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
|
||||||
| `vite.config.js` | Tailwind + SvelteKit plugins. codeserver HMR config (port, allowedHosts, hmr). |
|
| `vite.config.js` | Tailwind + SvelteKit + PWA 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. |
|
| `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 (lines 16–22). |
|
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
|
||||||
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
|
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
|
||||||
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
|
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
|
||||||
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
|
| `.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/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. |
|
||||||
| `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/manifest.webmanifest` | PWA manifest: name, short_name, icons (192/512px), theme colors, display: standalone, scope, start_url. |
|
||||||
| `static/audio/manifest.json` | Voice list `{ id, edgeName, label, gender }[]`. Written by the generator, imported by `audio-manifest.js`. |
|
| `static/icons/{192,512}.png` | PWA icons (auto-generated by @vite-pwa/sveltekit from source). |
|
||||||
| `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/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
|
## Key Data Structures
|
||||||
|
|
||||||
@@ -94,4 +98,4 @@ RootLayout
|
|||||||
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
|
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
|
||||||
|
|
||||||
Last reviewed: 2026-04-27
|
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
|
## Typography
|
||||||
|
|
||||||
- **Font**: Geist Sans (Google Fonts), fallback to Arial/Helvetica.
|
- **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.
|
- **Headings**: Extrabold (font-extrabold) for main title, bold (font-bold) for secondary.
|
||||||
- **Body**: Regular weight, slate-600 (light) / slate-400 (dark).
|
- **Body**: Regular weight, slate-600 (light) / slate-400 (dark).
|
||||||
- **Emphasis**: `<strong>` for important copy (e.g., button labels in instructions).
|
- **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
|
## Settings & UI Toggles
|
||||||
|
|
||||||
### Mode Picker (SettingsButton)
|
### Mode Picker (SettingsButton)
|
||||||
Three-way toggle in settings modal:
|
Three-way segmented picker in settings modal with inline SVG glyphs:
|
||||||
- **Player mode** (`mode: "player"`) — PlayerBoard only, default UX for players
|
- **Player mode** (rectangle icon) — PlayerBoard only, default UX for players
|
||||||
- **Master mode** (`mode: "master"`) — MasterPanel only, host-only view (e.g., projector)
|
- **Master mode** (rounded ellipse icon) — MasterPanel only, host-only view (e.g., projector)
|
||||||
- **Both mode** (`mode: "both"`) — PlayerBoard + MasterPanel stacked inline, master auto-ticks player board on draw
|
- **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
|
## 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.
|
- Focus states: Implicit via Tailwind (`focus:ring-2`), add if extending components.
|
||||||
- Contrast: Tailwind slate/indigo/emerald combos meet WCAG AA.
|
- Contrast: Tailwind slate/indigo/emerald combos meet WCAG AA.
|
||||||
- Dark mode: Respects `prefers-color-scheme`, not forced.
|
- 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
|
- "Quản trò đọc số" voice now speaks Chờ/Kinh on master in "both" mode
|
||||||
- One-shot migration: `masterMode: true` → `mode: "both"`
|
- 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
|
## Idea Phase
|
||||||
|
|
||||||
### Undo Last Cell
|
### Undo Last Cell
|
||||||
Allow player to undo the most recent cross/uncross action. Requires change history or state snapshot. **Status**: Idea (low priority)
|
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
|
## Considered Phase
|
||||||
|
|
||||||
### Multiplayer Sync (Real-time)
|
### 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.
|
Future work gates on **real user demand**, not speculation.
|
||||||
|
|
||||||
Last reviewed: 2026-04-27
|
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.
|
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 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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9",
|
"@eslint/js": "^9",
|
||||||
|
"@fontsource/roboto-condensed": "^5.2.8",
|
||||||
"@sveltejs/adapter-static": "^3",
|
"@sveltejs/adapter-static": "^3",
|
||||||
"@sveltejs/kit": "^2",
|
"@sveltejs/kit": "^2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6",
|
"@sveltejs/vite-plugin-svelte": "^6",
|
||||||
"@tailwindcss/vite": "^4",
|
"@tailwindcss/vite": "^4",
|
||||||
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-plugin-svelte": "^2",
|
"eslint-plugin-svelte": "^2",
|
||||||
"globals": "^15",
|
"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
@@ -1,4 +1,10 @@
|
|||||||
@import "tailwindcss";
|
@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"`
|
/* Class-based dark-mode variant. The settings store toggles `class="dark"`
|
||||||
on <html>, mirroring OS preference when theme="auto" or honoring the
|
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. */
|
on platforms without one so the digits still read tall-and-tight. */
|
||||||
.tan-tan-num {
|
.tan-tan-num {
|
||||||
font-family:
|
font-family:
|
||||||
|
"Roboto Condensed",
|
||||||
"Arial Narrow",
|
"Arial Narrow",
|
||||||
"Avenir Next Condensed",
|
"Avenir Next Condensed",
|
||||||
"Roboto Condensed",
|
|
||||||
"Liberation Sans Narrow",
|
"Liberation Sans Narrow",
|
||||||
system-ui,
|
system-ui,
|
||||||
sans-serif;
|
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;
|
letter-spacing: -0.04em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Lô tô</title>
|
<title>Lô tô</title>
|
||||||
<meta name="description" content="Bàn số của trò chơi Lô tô" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-full flex flex-col">
|
<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>
|
<script>
|
||||||
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
|
import { broadcastDraw, resetBus } from "$lib/call-bus.svelte.js";
|
||||||
|
import MasterEmptyState from "$lib/MasterEmptyState.svelte";
|
||||||
import { settings } from "$lib/settings-store.svelte.js";
|
import { settings } from "$lib/settings-store.svelte.js";
|
||||||
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
import { cancelPlayback, playNumber } from "$lib/voice.js";
|
||||||
|
|
||||||
@@ -344,7 +345,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-center text-slate-500 dark:text-slate-400 py-10 text-base">
|
<MasterEmptyState />
|
||||||
Nhấn "Ván mới" để bắt đầu
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
@@ -251,12 +251,29 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onclick={() => pickMode(v)}
|
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
|
{selected
|
||||||
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-300'
|
? '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'}"
|
: '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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,37 +381,49 @@
|
|||||||
Màu ô trống
|
Màu ô trống
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="rounded-xl border border-slate-200 dark:border-slate-700 p-3 space-y-3">
|
||||||
<input
|
<div>
|
||||||
type="color"
|
<p class="text-xs uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 mb-1.5">
|
||||||
aria-label="Chọn màu tuỳ ý"
|
Tuỳ chỉnh
|
||||||
value={settings.emptyCellColor}
|
</p>
|
||||||
oninput={onPickerInput}
|
<div class="flex items-center gap-3">
|
||||||
class="w-12 h-10 rounded-lg border border-slate-300 dark:border-slate-600 cursor-pointer bg-transparent"
|
<input
|
||||||
/>
|
type="color"
|
||||||
<code
|
aria-label="Chọn màu tuỳ ý"
|
||||||
class="text-xs font-mono text-slate-500 dark:text-slate-400 tabular-nums"
|
value={settings.emptyCellColor}
|
||||||
>
|
oninput={onPickerInput}
|
||||||
{settings.emptyCellColor}
|
class="w-12 h-10 rounded-lg border border-slate-300 dark:border-slate-600 cursor-pointer bg-transparent"
|
||||||
</code>
|
/>
|
||||||
</div>
|
<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">
|
<div>
|
||||||
{#each PRESETS as hex (hex)}
|
<p class="text-xs uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400 mb-1.5">
|
||||||
{@const selected =
|
Mẫu sẵn
|
||||||
settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
</p>
|
||||||
<button
|
<div class="grid grid-cols-5 gap-2">
|
||||||
type="button"
|
{#each PRESETS as hex (hex)}
|
||||||
aria-label="Đặt màu {hex}"
|
{@const selected =
|
||||||
aria-pressed={selected}
|
settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
||||||
onclick={() => pickColor(hex)}
|
<button
|
||||||
style:background-color={hex}
|
type="button"
|
||||||
class="aspect-square rounded-lg border-2 transition-all
|
aria-label="Đặt màu {hex}"
|
||||||
{selected
|
aria-pressed={selected}
|
||||||
? 'border-indigo-500 dark:border-indigo-400 ring-2 ring-indigo-300 dark:ring-indigo-600 scale-110'
|
onclick={() => pickColor(hex)}
|
||||||
: 'border-slate-200 dark:border-slate-600 hover:scale-105'}"
|
style:background-color={hex}
|
||||||
></button>
|
class="aspect-square rounded-lg border-2 transition-all
|
||||||
{/each}
|
{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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@@ -404,9 +433,10 @@
|
|||||||
onclick={() => resetSettings()}
|
onclick={() => resetSettings()}
|
||||||
class="px-4 py-2 rounded-full text-sm font-medium
|
class="px-4 py-2 rounded-full text-sm font-medium
|
||||||
text-slate-600 dark:text-slate-300
|
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"
|
hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
Mặc định
|
Đặt lại
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -24,9 +24,12 @@
|
|||||||
Lô tô
|
Lô tô
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<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>
|
</p>
|
||||||
</header>
|
</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
|
X-Content-Type-Options: nosniff
|
||||||
Referrer-Policy: strict-origin-when-cross-origin
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
|
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
|
||||||
X-Frame-Options: DENY
|
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 { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||||
import { defineConfig, loadEnv } from "vite";
|
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 }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
// .env.local lives outside process.env at config-eval time; loadEnv reads it.
|
// .env.local lives outside process.env at config-eval time; loadEnv reads it.
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
@@ -10,7 +31,45 @@ export default defineConfig(({ mode }) => {
|
|||||||
const port = Number(env.CODESERVER_PORT ?? 3000);
|
const port = Number(env.CODESERVER_PORT ?? 3000);
|
||||||
|
|
||||||
return {
|
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: {
|
server: {
|
||||||
port,
|
port,
|
||||||
host: true,
|
host: true,
|
||||||
|
|||||||
Reference in new issue
Block a user