mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-02 14:20:46 +00:00
feat(ui): big bundle — settings (theme/master/auto), purple default, mobile fit, master extraction
Settings expansion: 4 new keys (theme auto/light/dark, masterMode, autoCallEnabled, autoCallSpeed 1-10) with per-key validators that preserve old saved data. Default empty-cell color flipped from Tân Tân blue to Excel Standard Purple #7030A0; preset palette swapped to Office's 10 standard colors (5x2 grid). 15 new tests, 53 total. Theme system: Tailwind v4 @variant dark (.dark *); applyTheme() toggles <html class="dark"> based on settings.theme; auto mode mirrors prefers-color-scheme via matchMedia listener (cleanly torn down when switching modes). Existing dark-mode CSS converted from @media to :where(.dark) selectors. Mobile fit: PlayerBoard cells aspect-square on mobile, sm:aspect-[3/5] desktop. Number text scales text-base sm:text-2xl md:text-3xl. Page padding tightened (px-2 py-4 sm:px-3 sm:py-12). Container bumped max-w-lg to max-w-2xl. Header / footer: removed instructions toggle and "Trang quản trò" link from player page. New PageFooter.svelte (tagline + Made by miti99 with [SVG heart] link); also duplicated in PlayerBoard's closing section-label band per request. Heart is inline SVG (red), not emoji. Master mode: extracted everything from /master route into reusable MasterPanel.svelte. /master route slimmed to header + MasterPanel + footer. / mounts MasterPanel conditionally when settings.masterMode. Storage prefixes unchanged. Master tracking grid removed: per request, the 11x9 ones-digit master board is gone. Host still gets controls, "Số vừa xổ" hero, draw history list, and their own player card. Player card is enough. Auto-call: single $effect lifecycle keyed on (autoRunning, settings.autoCallSpeed, settings.autoCallEnabled). Setup / clear setInterval cleanly across speed changes, master-mode toggle off, component unmount, and "user disabled auto" mid-run. Button toggles "Xổ số" -> "Bắt đầu / Dừng". Speed slider in Settings (only visible when master mode is on). aria-label / aria-valuetext on slider. Code-review nice-to-fixes applied: folded the two MasterPanel $effects into one, removed muddled onkeydown on the SettingsButton modal backdrop, added slider a11y attrs. Docs: PDR / codebase-summary / system-architecture / development-roadmap / code-standards all synced to the new state.
This commit is contained in:
@@ -41,8 +41,8 @@ Use the `.svelte.js` extension for modules that export rune-based reactive state
|
||||
### Utilities
|
||||
- Utility-first: `className="px-4 py-2 rounded-lg text-white"`.
|
||||
- Responsive: `sm:`, `md:`, `lg:` prefixes for breakpoints.
|
||||
- Dark mode: `dark:bg-slate-800`, `dark:text-white`.
|
||||
- Animations: Custom keyframes in `globals.css`, apply via `animate-fade-in`.
|
||||
- Dark mode: Explicit `@variant dark (.dark *)` in `app.css` declares dark-mode selector; use `dark:bg-slate-800 dark:text-white`. Settings store toggles `<html class="dark">` rather than relying on `@media (prefers-color-scheme: dark)`.
|
||||
- Animations: Custom keyframes in `app.css`, apply via `animate-fade-in`.
|
||||
|
||||
### Layout
|
||||
- Flexbox for alignment: `flex flex-col items-center justify-center`.
|
||||
@@ -167,8 +167,8 @@ Use inline event handlers (`onclick`, `onkeydown`). Svelte 5 handles click deleg
|
||||
|
||||
### Unit Tests (Implemented)
|
||||
- **Framework**: Vitest 4.1.5 with happy-dom
|
||||
- **Test Files**: `src/lib/game-logic.test.js` (26 tests), `src/lib/settings-store.test.js` (12 tests) — 38 total passing
|
||||
- **Coverage**: Game logic (generateGrid shape/constraints, isRowComplete, getWaitingNumber, persistence with validators), settings (load/save/reset with error handling)
|
||||
- **Test Files**: `src/lib/game-logic.test.js` (26 tests), `src/lib/settings-store.test.js` (27 tests) — 53 total passing
|
||||
- **Coverage**: Game logic (generateGrid shape/constraints, isRowComplete, getWaitingNumber, persistence with validators), settings (load/save/reset with error handling, theme detection, master mode toggle, auto-call speed, color validation)
|
||||
- **Scripts**: `npm test` (run once), `npm run test:watch` (continuous)
|
||||
- **Pattern**: Use vitest's `describe` / `it` blocks, `expect()` assertions. Test both happy path and error cases (corrupt JSON, missing localStorage).
|
||||
|
||||
@@ -192,3 +192,4 @@ Set in `.env.local` (not committed).
|
||||
- **basePath**: Dual-mode: `""` (Cloudflare, dev) or `/loto` (GitHub Pages via `BUILD_PROFILE=gh`).
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
|
||||
@@ -6,31 +6,33 @@
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/routes/+layout.svelte` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
|
||||
| `src/routes/+page.svelte` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. |
|
||||
| `src/routes/master/+page.svelte` | Host page (`/master`). Controls (new game, draw number), 11×9 last-digit-aligned master board with circular tokens (pink ring for 1–49, green for 50–90; cream fill when called, dim when uncalled; red ring + scale on last-called) and draw-order overlay, host's player card. |
|
||||
| `src/routes/+page.svelte` | Player page (`/`). Header + SettingsButton, PlayerBoard, conditional MasterPanel (when `settings.masterMode`), PageFooter. Indigo→purple gradient branding. |
|
||||
| `src/routes/master/+page.svelte` | Host page (`/master`). Thin shell: header + back link + MasterPanel + PageFooter. All host logic lives in `MasterPanel.svelte`. |
|
||||
|
||||
### 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:5) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, blue empty cells. Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. Empty cells use `--empty-cell-bg` CSS var from settings store. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal. Color picker + 8 preset swatches for empty-cell color. Reset-to-default button. Mounted on both `/` and `/master` headers. |
|
||||
| `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:5 on mobile; wider on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, purple empty cells by default. Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. Empty cells use `--empty-cell-bg` CSS var from settings store. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal. 4 fieldsets: Giao diện (theme auto/light/dark), Chế độ quản trò (master mode toggle), Tự động xổ (auto-call + speed 1–10s), Màu ô trống (10 Excel color swatches). Reset-to-default button. Mounted on both `/` and `/master` headers. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, "Số vừa xổ" hero token, "Thứ tự đã xổ" history list, host's own player card. No 11×9 tracking board (player card is enough). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`, or directly on `/master`. |
|
||||
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/` and `/master`. |
|
||||
|
||||
### Game Logic
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (constraint-aware picker — exact 5 per row & per col, ascending-sorted columns), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
|
||||
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. `emptyCellColor` (hex) persisted to localStorage `loto_settings`. Pushes value to `--empty-cell-bg` CSS var on `:root`. |
|
||||
| `src/lib/settings-store.svelte.js` | Reactive global UI settings via Svelte 5 runes. Stores 5 keys: `theme` (enum: "auto" / "light" / "dark"), `masterMode` (bool), `autoCallEnabled` (bool), `autoCallSpeed` (1–10), `emptyCellColor` (hex). Persisted to localStorage `loto_settings`. Pushes values to CSS vars and `<html class="dark">` on `:root`. Per-key validators preserve old data. |
|
||||
|
||||
### Styling
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/app.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
|
||||
| `src/app.css` | Root styles: Tailwind @import, CSS variables (light/dark), Tailwind v4 `@variant dark (.dark *)` for explicit dark-mode class selector, `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
|
||||
|
||||
### Tests
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/game-logic.test.js` | 26 unit tests: generateGrid shape (9×9, 5 per row/col, no duplicates), column ranges & ascending sort, row completion, waiting number detection, persistence (saveGrid/loadGrid/saveCrossedState/loadCrossedState with validators). |
|
||||
| `src/lib/settings-store.test.js` | 12 unit tests: defaults, loadSettings (restore from localStorage, apply CSS var, handle empty/corrupt), saveSettings, resetSettings, color validation. |
|
||||
| `src/lib/settings-store.test.js` | 27 unit tests: defaults, loadSettings (restore 5 keys from localStorage, apply CSS vars, toggle dark class, handle empty/corrupt), saveSettings, resetSettings, theme toggle (auto → OS pref detection), master mode, auto-call + speed, color validation. |
|
||||
|
||||
### Configuration
|
||||
| File | Purpose |
|
||||
@@ -58,19 +60,21 @@
|
||||
| `loto_master` | Host's drawn/remaining numbers. |
|
||||
| `loto_master_card_grid` | Host's player card numbers. |
|
||||
| `loto_master_card_crossed` | Host's marked cells. |
|
||||
| `loto_settings` | Global UI settings: `{ emptyCellColor: "#rrggbb" }`. |
|
||||
| `loto_settings` | Global UI settings: `{ theme, masterMode, autoCallEnabled, autoCallSpeed, emptyCellColor }`. |
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
RootLayout
|
||||
├── HomePage (/)
|
||||
│ ├── Instructions toggle
|
||||
│ └── PlayerBoard (storagePrefix="loto")
|
||||
│ ├── PlayerBoard (storagePrefix="loto")
|
||||
│ ├── [if settings.masterMode]
|
||||
│ │ └── MasterPanel (state, controls, draw history)
|
||||
│ └── PageFooter
|
||||
└── MasterPage (/master)
|
||||
├── Controls (new game, draw)
|
||||
├── Master board (11×9, last-digit aligned, draw-order overlay)
|
||||
└── PlayerBoard (storagePrefix="loto_master_card")
|
||||
├── MasterPanel (state, controls, draw history)
|
||||
├── Back link
|
||||
└── PageFooter
|
||||
```
|
||||
|
||||
## Key Functions
|
||||
@@ -81,8 +85,8 @@ RootLayout
|
||||
| `generateGrid()` | game-logic.js | Builds 9×9; ascending-sorted numbers per column. |
|
||||
| `isRowComplete()` | game-logic.js | Boolean: all non-zero cells in row crossed? |
|
||||
| `getWaitingNumber()` | game-logic.js | Returns the single uncrossed number in row, or null. |
|
||||
| `buildBoard()` | master/+page.svelte | Builds 11×9 master grid; row = ones-digit, col = tens-digit; col 0 holds 1–9, col 8 holds 80–90. |
|
||||
| `handleCellClick()` | PlayerBoard.svelte | Toggle crossed[row][col]. |
|
||||
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
|
||||
@@ -15,15 +15,21 @@ The app is fully functional for core gameplay (Lô tô hội chợ Tân Tân var
|
||||
Kinh verification
|
||||
- Player card rendered as 3 stacked Tân Tân mini-cards
|
||||
(Minh Tân / Loại đặc biệt / Tấn tài tấn lộc) with cross-hatch dividers
|
||||
- Settings modal: empty-cell color picker (color picker + 8 presets +
|
||||
reset), applies to player + master grids, persisted to `loto_settings`
|
||||
- Settings modal: 4 fieldsets (theme auto/light/dark, master mode toggle,
|
||||
auto-call speed 1–10s, 10 color presets), persisted to `loto_settings`
|
||||
- Theme switcher (auto detects OS pref, explicit light/dark modes)
|
||||
- Master mode toggle (shows MasterPanel on `/`)
|
||||
- Auto-call timer with speed slider
|
||||
- Mobile-responsive cells (aspect-ratio + text scaling)
|
||||
- MasterPanel extracted component (reused on `/` and `/master`)
|
||||
- PageFooter with tagline + creator link
|
||||
- Host's own player card (isolated instance)
|
||||
- localStorage persistence
|
||||
- Dark mode
|
||||
- Mobile responsive
|
||||
- Offline capable
|
||||
- Unit tests (Vitest: 26 game-logic tests + 12 settings-store tests, 38 total passing)
|
||||
covering constraint validation, persistence, and error handling
|
||||
- Unit tests (Vitest: 26 game-logic tests + 27 settings-store tests, 53 total passing)
|
||||
covering constraint validation, persistence, theme/master-mode/auto-call logic, and error handling
|
||||
|
||||
## Idea Phase
|
||||
|
||||
@@ -33,8 +39,6 @@ Play celebratory chime or "Kinh!" voice snippet when row completes. Could use We
|
||||
### Undo Last Cell
|
||||
Allow player to undo the most recent cross/uncross action. Requires change history or state snapshot. **Status**: Idea (low priority)
|
||||
|
||||
### Theme Switcher
|
||||
Explicit light/dark toggle button instead of relying on OS preference. **Status**: Idea (Tailwind already supports OS toggle)
|
||||
|
||||
### PWA Install
|
||||
Add service worker and manifest for "Install App" prompt on Android/iOS. **Status**: Idea (would require server-side components)
|
||||
@@ -104,3 +108,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)
|
||||
|
||||
@@ -18,8 +18,8 @@ That format is intentionally out of scope.
|
||||
- **Visual layout**: card displayed as 3 stacked 3×9 mini-cards with
|
||||
traditional separator labels — *Tân Tân*, *An khang thịnh vượng*,
|
||||
*Tân Tân tốt nhất* — matching the physical Tân Tân BAMBOORAFT paper
|
||||
sheet (tall non-square cells, condensed bold black numbers on white,
|
||||
blue empty-cell background by default).
|
||||
sheet (tall non-square cells on mobile, wider on sm+; condensed bold black numbers on white,
|
||||
purple empty-cell background by default — Excel Standard Purple `#7030A0`).
|
||||
- **Column ranges**: col 0 = 1–9, col 1 = 10–19, …, col 7 = 70–79,
|
||||
col 8 = 80–90.
|
||||
- **Within a column**: numbers placed top-to-bottom in **ascending** order.
|
||||
@@ -31,8 +31,11 @@ That format is intentionally out of scope.
|
||||
a master board with circular tokens (pink ring for 1–49, green for 50–90,
|
||||
cream fill on called, dim on uncalled, red ring + scale on the most
|
||||
recent draw), and may also play their own card.
|
||||
- **Settings**: gear icon in headers opens a modal with a color picker for
|
||||
the empty/blank cell color (default brown matches physical card).
|
||||
- **Settings**: gear icon in headers opens a modal with 4 fieldsets:
|
||||
- Giao diện: theme switcher (auto / light / dark; auto mirrors OS pref)
|
||||
- Chế độ quản trò: toggle to show MasterPanel on `/`
|
||||
- Tự động xổ: enable auto-call; speed slider (1–10 seconds)
|
||||
- Màu ô trống: color picker + 10 Excel preset swatches
|
||||
Persisted to `loto_settings`.
|
||||
|
||||
### Out of scope
|
||||
@@ -96,7 +99,13 @@ State is entirely client-side. Each page/card instance uses a unique localStorag
|
||||
- [x] Mobile-responsive (base + sm breakpoints).
|
||||
- [x] HMR works on code-server via proxy.
|
||||
- [x] Player card rendered as 3 stacked Tân Tân mini-cards with separator labels.
|
||||
- [x] Settings modal with empty-cell color picker; applies to both player and master grids; persisted.
|
||||
- [x] Settings modal with 4 fieldsets (theme, master mode, auto-call, color); applies to both grids; persisted.
|
||||
- [x] Theme switcher (auto / light / dark) with explicit dark class selector on `<html>`.
|
||||
- [x] Master mode toggle to show MasterPanel on player page.
|
||||
- [x] Auto-call timer (1–10s speed) with start/stop button.
|
||||
- [x] Mobile-responsive layout (aspect-square sm:aspect-[3/5] cells, text scaling).
|
||||
- [x] MasterPanel extracted; mounted conditionally on `/` and directly on `/master`.
|
||||
- [x] PageFooter with tagline + link on both pages.
|
||||
|
||||
## Visual Language
|
||||
|
||||
@@ -115,4 +124,5 @@ State is entirely client-side. Each page/card instance uses a unique localStorag
|
||||
- Multiplayer sync (real-time via WebSocket)
|
||||
- i18n beyond Vietnamese
|
||||
|
||||
Last reviewed: 2026-04-26 (scope locked: Lô tô hội chợ Tân Tân)
|
||||
Last reviewed: 2026-04-27 (scope locked: Lô tô hội chợ Tân Tân + theme/master/auto-call)
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
|
||||
@@ -4,22 +4,23 @@
|
||||
|
||||
```
|
||||
Entry (+layout.svelte)
|
||||
├─ onMount: loadSettings() — restore emptyCellColor from loto_settings,
|
||||
│ apply to --empty-cell-bg CSS variable before pages render
|
||||
├─ onMount: loadSettings() — restore all 5 settings keys from loto_settings,
|
||||
│ apply CSS vars, toggle <html class="dark"> on theme/OS pref, setup auto-call effect
|
||||
│
|
||||
├─ / (Player Page)
|
||||
│ ├─ Load loto_grid, loto_crossed from localStorage
|
||||
│ ├─ Display 9×9 grid (empty cells use --empty-cell-bg from settings)
|
||||
│ ├─ Display 9×9 PlayerBoard (empty cells use --empty-cell-bg from settings)
|
||||
│ ├─ Generate new grid on button click
|
||||
│ ├─ Mark/unmark cells on click
|
||||
│ └─ Show bingo popup + "Chờ X" toasts
|
||||
│ ├─ Show bingo popup + "Chờ X" toasts
|
||||
│ ├─ [if settings.masterMode === true]
|
||||
│ │ └─ Mount MasterPanel (host controls + draw history)
|
||||
│ └─ PageFooter (tagline + miti99 link)
|
||||
│
|
||||
└─ /master (Host Page)
|
||||
├─ Load loto_master (called/remaining)
|
||||
├─ Display 11×9 master board (empty cells use --empty-cell-bg)
|
||||
├─ Draw button shows next called number (highlighted in red)
|
||||
├─ Display host's own card (loto_master_card prefix)
|
||||
└─ New Game button resets called/remaining
|
||||
├─ MasterPanel (state, controls, draw history; same as above)
|
||||
├─ Back link to /
|
||||
└─ PageFooter
|
||||
```
|
||||
|
||||
## State Model
|
||||
@@ -34,10 +35,20 @@ Each row has exactly 5 non-zero numbers AND each column has exactly 5
|
||||
(constraint-aware picker — no slack). Numbers within a column are sorted
|
||||
ascending top-to-bottom (lô tô hội chợ Tân Tân convention).
|
||||
|
||||
### Settings (`loto_settings`)
|
||||
```
|
||||
theme: "auto" | "light" | "dark" // Display mode
|
||||
masterMode: boolean // Show MasterPanel on /
|
||||
autoCallEnabled: boolean // Enable auto-call timer
|
||||
autoCallSpeed: number (1–10) // Speed in seconds
|
||||
emptyCellColor: "#rrggbb" // Hex color (default #7030A0 Excel Purple)
|
||||
```
|
||||
|
||||
### Host State (`storagePrefix="loto_master"`)
|
||||
```
|
||||
called: number[] // [5, 23, 67, ...] — drawn in order
|
||||
remaining: number[] // [1, 2, 3, ...] minus called — shuffled initially
|
||||
autoRunning: boolean // Auto-call timer active
|
||||
```
|
||||
|
||||
### Host's Card (`storagePrefix="loto_master_card"`)
|
||||
@@ -134,3 +145,4 @@ Files that are client-only:
|
||||
All state is localStorage. No API calls. Fully functional offline after initial load.
|
||||
|
||||
Last reviewed: 2026-04-27
|
||||
Last synced: 2026-04-27 (6-phase refactor)
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Phase 1 — Settings expansion (foundation)
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md)
|
||||
- All later phases consume settings from this store. Land first.
|
||||
|
||||
## Overview
|
||||
- Priority: P0 (blocks 2, 5, 6)
|
||||
- Status: TODO
|
||||
- Effort: ~30 min
|
||||
- Description: Extend `settings-store.svelte.js` shape, validate, persist;
|
||||
swap preset palette to Office Standard Colors; default empty-cell color
|
||||
to purple. Add tests.
|
||||
|
||||
## New settings shape
|
||||
|
||||
```ts
|
||||
{
|
||||
emptyCellColor: string, // hex, default "#7030A0" (Excel Standard Purple)
|
||||
theme: "auto"|"light"|"dark", // default "auto" — implementation in Phase 2
|
||||
masterMode: boolean, // default false — wired in Phase 5
|
||||
autoCallEnabled: boolean, // default false — wired in Phase 6
|
||||
autoCallSpeed: number, // 1..10 seconds, default 5 — wired in Phase 6
|
||||
}
|
||||
```
|
||||
|
||||
## Excel Standard Colors palette (10 swatches, in order)
|
||||
|
||||
| # | Name | Hex |
|
||||
|---|---|---|
|
||||
| 1 | Dark Red | `#C00000` |
|
||||
| 2 | Red | `#FF0000` |
|
||||
| 3 | Orange | `#FFC000` |
|
||||
| 4 | Yellow | `#FFFF00` |
|
||||
| 5 | Light Green | `#92D050` |
|
||||
| 6 | Green | `#00B050` |
|
||||
| 7 | Light Blue | `#00B0F0` |
|
||||
| 8 | Blue | `#0070C0` |
|
||||
| 9 | Dark Blue | `#002060` |
|
||||
| 10 | **Purple (default)** | `#7030A0` |
|
||||
|
||||
Layout in modal: `grid grid-cols-5 gap-2` so all 10 fit cleanly.
|
||||
|
||||
## Validation
|
||||
|
||||
- `emptyCellColor`: same `/^#[0-9a-fA-F]{6}$/` regex.
|
||||
- `theme`: must be one of the literal strings; else fall back to `"auto"`.
|
||||
- `masterMode` / `autoCallEnabled`: must be boolean; else default.
|
||||
- `autoCallSpeed`: must be integer in `[1, 10]`; else default 5.
|
||||
|
||||
Use a small per-key validator function — don't pull in a schema lib.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/settings-store.svelte.js` | extend `DEFAULT_SETTINGS`; add per-key validators; update `loadSettings` to validate each key independently and fall back per-key on miss; update `saveSettings` (no shape change). Apply-to-DOM stays for `--empty-cell-bg`. |
|
||||
| `src/lib/settings-store.test.js` | add tests for the 4 new keys. |
|
||||
| `src/lib/SettingsButton.svelte` | replace 8 ad-hoc swatches with the 10 Excel ones; first preset auto-tracks `DEFAULT_SETTINGS.emptyCellColor`. (Theme toggle, master mode, auto-call land in later phases — keep this phase scoped to color + foundation.) |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Edit `settings-store.svelte.js`:
|
||||
- Update `DEFAULT_SETTINGS`:
|
||||
```js
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
emptyCellColor: "#7030A0",
|
||||
theme: "auto",
|
||||
masterMode: false,
|
||||
autoCallEnabled: false,
|
||||
autoCallSpeed: 5,
|
||||
});
|
||||
```
|
||||
- Add validators (one fn per key, returns coerced value or default).
|
||||
- Replace `loadSettings` body to apply each validator independently:
|
||||
```js
|
||||
const parsed = JSON.parse(raw) ?? {};
|
||||
settings.emptyCellColor = validColor(parsed.emptyCellColor) ?? DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme = validTheme(parsed.theme) ?? DEFAULT_SETTINGS.theme;
|
||||
// ... etc
|
||||
```
|
||||
2. Edit `settings-store.test.js`:
|
||||
- Update default-color test to expect `#7030A0`.
|
||||
- Add tests for each new key: default, valid load, invalid load fall-back.
|
||||
3. Edit `SettingsButton.svelte`:
|
||||
- Replace `PRESETS` array with the 10 Excel hex values.
|
||||
- Change swatch grid to `grid-cols-5`.
|
||||
|
||||
## Tests to add (in `settings-store.test.js`)
|
||||
|
||||
```
|
||||
describe("settings-store — theme")
|
||||
it default is "auto"
|
||||
it loads "light" / "dark" valid
|
||||
it falls back on unknown string
|
||||
describe("settings-store — masterMode")
|
||||
it default is false
|
||||
it loads true
|
||||
it falls back on non-boolean
|
||||
describe("settings-store — autoCallEnabled") // same shape as masterMode
|
||||
describe("settings-store — autoCallSpeed")
|
||||
it default 5
|
||||
it loads 1..10
|
||||
it rejects 0 / 11 / non-int
|
||||
it rejects "5" string
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Existing users have `loto_settings` with only `emptyCellColor` | High | None — per-key fallback handles it | Per-key validation, not whole-object |
|
||||
| Default purple is too saturated against white grid cells | Med | Cosmetic | Reviewed: `#7030A0` reads fine; user picked purple; iterate if needed |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `npm test` adds new passing tests; existing 38 still pass.
|
||||
- Settings modal shows 10 Excel swatches, default purple is the first one (auto-bound to `DEFAULT_SETTINGS.emptyCellColor`).
|
||||
- Loading a settings JSON with old `{emptyCellColor: "#1e88e5"}` keeps the user's blue choice (no key wiped).
|
||||
|
||||
## Next
|
||||
- Phase 2 consumes `settings.theme`.
|
||||
- Phase 5 consumes `settings.masterMode`.
|
||||
- Phase 6 consumes `settings.autoCallEnabled` + `settings.autoCallSpeed`.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Phase 2 — Theme system: explicit light/dark override
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md) — depends on Phase 1.
|
||||
- Currently dark mode follows `prefers-color-scheme` automatically via Tailwind v4's default.
|
||||
- Goal: 3-state theme (`auto` / `light` / `dark`); user-pick lives in Settings.
|
||||
|
||||
## Overview
|
||||
- Priority: P1
|
||||
- Status: TODO
|
||||
- Effort: ~30 min
|
||||
|
||||
## Architecture
|
||||
|
||||
**Tailwind v4 dark variant.** Tailwind v4 ships with `dark:` mapped to `@media (prefers-color-scheme: dark)` by default. To honor an explicit class, declare a custom variant in `app.css`:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
```
|
||||
|
||||
After this, `dark:bg-slate-900` matches when ancestor `<html>` has `class="dark"`. We *replace* the OS-media behavior with class-based; OS preference is then mirrored into the class by JS, so the visual result is the same when `theme === "auto"`.
|
||||
|
||||
**JS apply.** `settings-store` decides:
|
||||
- `"light"` → remove `dark` class from `<html>`.
|
||||
- `"dark"` → add `dark` class to `<html>`.
|
||||
- `"auto"` → apply class based on `window.matchMedia('(prefers-color-scheme: dark)').matches`, AND subscribe to its `change` event to re-apply.
|
||||
|
||||
When the user picks a non-auto theme, unsubscribe the media listener.
|
||||
|
||||
**CSS conversions.** `app.css` currently has 2 `@media (prefers-color-scheme: dark)` blocks (`.section-divider`, `.section-divider-vertical`, `.section-label`). Convert to `:global(.dark) .selector` instead, since the global theme system now drives the class. Without conversion these styles only kick in by OS pref, ignoring the user's explicit pick.
|
||||
|
||||
## Settings UI
|
||||
|
||||
In `SettingsButton.svelte`, add a fieldset above the color picker:
|
||||
|
||||
```svelte
|
||||
<fieldset class="mb-5">
|
||||
<legend class="text-sm font-semibold ...">Giao diện</legend>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each [["auto","Tự động"],["light","Sáng"],["dark","Tối"]] as [v, label]}
|
||||
<button onclick={() => pickTheme(v)}
|
||||
class="px-3 py-2 rounded-lg border-2 ...
|
||||
{settings.theme === v ? 'border-indigo-500 ...' : 'border-slate-200 ...'}">
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
`pickTheme(v)` updates `settings.theme = v` and calls `saveSettings()`. The store's apply effect handles the DOM class.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/app.css` | Add `@variant dark (&:where(.dark, .dark *));` after `@import`. Convert 2 dark `@media` blocks to `:global(.dark) .selector { ... }`. |
|
||||
| `src/lib/settings-store.svelte.js` | Add `applyTheme()` helper called from `loadSettings()` and from the effect that runs when `settings.theme` changes. Manages the `prefers-color-scheme` media listener for auto mode. |
|
||||
| `src/lib/SettingsButton.svelte` | Add theme tri-state selector. |
|
||||
| `src/lib/settings-store.test.js` | Add tests for theme apply (manipulate `window.matchMedia` mock, verify `<html>` class). |
|
||||
| `src/routes/+layout.svelte` | No change — `loadSettings()` on mount already triggers `applyTheme()`. |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `app.css`:
|
||||
- Add `@variant dark (&:where(.dark, .dark *));` directly under `@import "tailwindcss";`.
|
||||
- Replace `@media (prefers-color-scheme: dark) { .section-divider, .section-divider-vertical { ... } }` with `:global(.dark) .section-divider, :global(.dark) .section-divider-vertical { ... }`.
|
||||
- Same conversion for `.section-label`.
|
||||
2. `settings-store.svelte.js`:
|
||||
- Add `applyTheme()`:
|
||||
```js
|
||||
/** @type {MediaQueryList | null} */
|
||||
let mql = null;
|
||||
/** @type {((e: MediaQueryListEvent) => void) | null} */
|
||||
let mqlListener = null;
|
||||
function applyTheme() {
|
||||
if (typeof document === "undefined") return;
|
||||
// tear down any previous auto listener
|
||||
if (mql && mqlListener) {
|
||||
mql.removeEventListener("change", mqlListener);
|
||||
mql = null; mqlListener = null;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const set = (dark) => root.classList.toggle("dark", dark);
|
||||
if (settings.theme === "dark") set(true);
|
||||
else if (settings.theme === "light") set(false);
|
||||
else { // auto
|
||||
mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
set(mql.matches);
|
||||
mqlListener = (e) => set(e.matches);
|
||||
mql.addEventListener("change", mqlListener);
|
||||
}
|
||||
}
|
||||
```
|
||||
- Call `applyTheme()` from `loadSettings()` (after settings are populated) and from `saveSettings()`.
|
||||
3. `SettingsButton.svelte`:
|
||||
- Add the theme fieldset above the color one.
|
||||
- Wire to `settings.theme` + `saveSettings()`.
|
||||
4. `settings-store.test.js`:
|
||||
- Tests for `applyTheme()`. Mock `window.matchMedia`. Assert class on `<html>`. Test `auto` listener attach/detach when switching modes.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- SSR: app uses adapter-static + `ssr: false` (per docs). `applyTheme` early-returns if `document` is undefined — safe.
|
||||
- Multiple components calling `applyTheme()` while listener already attached: helper tears down first → idempotent.
|
||||
- User flips between `auto` and `dark` and `auto` again — listener correctly re-attaches.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
describe("applyTheme")
|
||||
beforeEach: clear classList; mock matchMedia (prefers-color-scheme: dark) → false
|
||||
it("theme=light removes dark class")
|
||||
it("theme=dark adds dark class")
|
||||
it("theme=auto applies based on matchMedia matches")
|
||||
it("theme=auto re-applies on matchMedia change event")
|
||||
it("switching auto → dark detaches matchMedia listener")
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| `@variant dark (.dark &)` doesn't compile in Tailwind v4 setup | Low | High | Verify in Phase 2 dev test. Fallback: add to a `@layer` |
|
||||
| Existing dark CSS in `app.css` no longer triggers from OS | High by design | Low | After conversion, OS pref drives the class via JS; visual result identical |
|
||||
| User in private browsing → no localStorage → theme reset every reload | Low | Annoying | Acceptable; same as today for color setting |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Toggling theme in Settings repaints the app live, no reload.
|
||||
- Set theme=light, reload — stays light despite OS dark mode.
|
||||
- Set theme=auto, change OS dark mode — app flips live.
|
||||
- All existing 38+ tests pass; new theme tests pass.
|
||||
- `npm run build` clean (no Tailwind variant errors).
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# Phase 3 — Mobile fit + page header/footer refactor
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent of Phases 2/4/5/6 — can land any time.
|
||||
- Touches PlayerBoard CSS (responsive cells), `/` page header, and adds a footer.
|
||||
|
||||
## Overview
|
||||
- Priority: P1
|
||||
- Status: TODO
|
||||
- Effort: ~30 min
|
||||
|
||||
## Mobile fit
|
||||
|
||||
### Goal
|
||||
The 9-col card with `aspect-[3/5]` cells × 3 sections × 3 rows currently produces
|
||||
a tall card that overflows portrait viewport on small phones. Want the player
|
||||
board to **fit a typical mobile portrait viewport** (no horizontal scroll, fits
|
||||
within 1 viewport height where reasonable).
|
||||
|
||||
### Approach (numbers worked from a 360 px portrait baseline)
|
||||
- 360 px viewport − 16 px page padding (`px-2`) = 344 px card width.
|
||||
- 9 cols → 38 px wide per cell.
|
||||
- On mobile, switch cells from `aspect-[3/5]` (63 px tall) → `aspect-square`
|
||||
(38 px tall). 3 rows × 38 = 114 px per section + 30 px label = 144 px × 3
|
||||
sections + 16 px frames = ~448 px. Plus header (~120 px) + buttons + footer
|
||||
≈ 700 px total — fits portrait on most phones (Pixel 7 = 915 px).
|
||||
- On `sm:` breakpoint (≥640 px) cells return to the prettier `aspect-[3/5]`.
|
||||
|
||||
### Number text size
|
||||
- Smaller cells need smaller font: `text-base` mobile, `text-2xl sm:text-3xl`
|
||||
on larger.
|
||||
|
||||
### Page padding
|
||||
- Player page wrapper: `px-3 py-8 sm:py-12` → `px-2 py-4 sm:px-3 sm:py-12`.
|
||||
Tighten on mobile so the card breathes less.
|
||||
|
||||
## Header/footer refactor
|
||||
|
||||
### Current header (`src/routes/+page.svelte`)
|
||||
- H1 "Lô tô"
|
||||
- Tagline: "Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô / của TN1 (2014–2017)"
|
||||
- "Hướng dẫn / Trang quản trò" links
|
||||
- Settings gear
|
||||
|
||||
### After
|
||||
- Settings gear (same place)
|
||||
- H1 "Lô tô"
|
||||
- (no tagline — moved to footer)
|
||||
- (no instructions toggle — removed)
|
||||
- (no "Trang quản trò" link — gated by master-mode setting in Phase 5)
|
||||
- Subhead removed
|
||||
|
||||
### Footer (NEW component `src/lib/PageFooter.svelte`)
|
||||
```
|
||||
[centered tagline: "Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)"]
|
||||
[centered: Made by <a>miti99</a> with <heart-svg>]
|
||||
```
|
||||
Tagline corrected from "thiếu giấy chơi lô tô" → "thiếu bộ lô tô" per user spec.
|
||||
|
||||
Heart icon: small inline SVG (Phosphor / Lucide-style heart, 14px), filled red
|
||||
(`fill-red-500`). Use the same simple SVG pattern as the SettingsButton gear.
|
||||
|
||||
### Bottom-section-label cleanup in PlayerBoard
|
||||
The current PlayerBoard has a final `<div class="section-label">Made by miti99
|
||||
with ❤️ ...</div>`. Remove that block — the attribution lives in PageFooter
|
||||
now. Keep the rest of the labels above each section.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/PlayerBoard.svelte` | Cells responsive `aspect-square sm:aspect-[3/5]`. Number text `text-base sm:text-2xl md:text-3xl`. Drop the bottom "Made by" section-label. |
|
||||
| `src/lib/PageFooter.svelte` | NEW — tagline + made-by-with-heart-svg. ~30 LOC. |
|
||||
| `src/routes/+page.svelte` | Drop tagline `<p>`, instructions toggle/panel, "Trang quản trò" link. Mount `<PageFooter />` after `<PlayerBoard />`. Tighten page padding. |
|
||||
| `src/routes/master/+page.svelte` | Mount `<PageFooter />` too (Phase 5 collapses this whole route into a thin shell, so this footer mount becomes near-trivial then). |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **PageFooter.svelte (new)** — write the small component:
|
||||
```svelte
|
||||
<footer class="mt-8 text-center text-xs text-slate-500 dark:text-slate-400 space-y-2">
|
||||
<p>Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)</p>
|
||||
<p class="flex items-center justify-center gap-1">
|
||||
Made by
|
||||
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer"
|
||||
class="text-indigo-500 hover:underline">miti99</a>
|
||||
with
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-label="trái tim"
|
||||
class="inline w-3.5 h-3.5 text-red-500">
|
||||
<path d="M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z"/>
|
||||
</svg>
|
||||
</p>
|
||||
</footer>
|
||||
```
|
||||
2. **PlayerBoard.svelte**:
|
||||
- Cells: replace `aspect-[3/5]` → `aspect-square sm:aspect-[3/5]` on both empty-cell div and button cell.
|
||||
- Number text size: replace `text-2xl sm:text-3xl` → `text-base sm:text-2xl md:text-3xl`.
|
||||
- Delete the final bottom `<div class="section-label">…Made by miti99…</div>` block.
|
||||
3. **/+page.svelte**:
|
||||
- Remove `let showInstructions = $state(false)`, the tagline `<p>`, the instructions toggle button, the instructions panel block, and the "Trang quản trò" link (Phase 5 will gate this differently).
|
||||
- Add `import PageFooter from "$lib/PageFooter.svelte";` and mount `<PageFooter />` after `<PlayerBoard />`.
|
||||
- Adjust wrapper to `px-2 py-4 sm:px-3 sm:py-12`.
|
||||
4. **/master/+page.svelte**: import + mount `<PageFooter />`. (Will be massively simplified in Phase 5.)
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Mobile cells too small to read at `text-base` | Med | UX | Manually verify with browser devtools 360px width; bump to `text-lg` if needed |
|
||||
| Heart SVG render width drift across browsers | Low | Cosmetic | Fixed `w-3.5 h-3.5` via Tailwind |
|
||||
| Aspect-square mobile + tan-tan-num font feels squat | Low | Cosmetic | Acceptable trade for fit |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- iPhone-width devtools (375 px): card fits without horizontal scroll, total page height ≤ ~750 px.
|
||||
- Desktop (≥640 px): card looks the same as before this phase (tall cells).
|
||||
- Footer shows tagline above made-by-with-heart-svg-link.
|
||||
- No instructions toggle anywhere.
|
||||
- All tests still pass.
|
||||
|
||||
## Next
|
||||
- Phase 4 (extract MasterPanel) is independent.
|
||||
- Phase 5 wires master mode + the missing "Trang quản trò" hook (now lives in Settings).
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Phase 4 — Extract MasterPanel component
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent of Phase 2/3. Blocks Phase 5/6.
|
||||
- Goal: pull all of `/master`'s state + UI (except page-level header) into a
|
||||
reusable `MasterPanel.svelte` so both `/` (when master mode on) and
|
||||
`/master` (kept for deep link) render the same logic without duplication.
|
||||
|
||||
## Overview
|
||||
- Priority: P0 for Phase 5/6 dependency
|
||||
- Status: TODO
|
||||
- Effort: ~30 min (mechanical refactor + verify)
|
||||
|
||||
## What goes into MasterPanel
|
||||
|
||||
From `src/routes/master/+page.svelte`:
|
||||
- `<script module>` block (BOARD constants, storage helpers).
|
||||
- `<script>` reactive state (`state`, `lastCalled`, `callOrder`, effects, handlers).
|
||||
- DOM:
|
||||
- "Ván mới" + "Xổ số" controls.
|
||||
- "Số vừa xổ" hero token.
|
||||
- "Thứ tự đã xổ" history.
|
||||
- 11×9 master tracking grid.
|
||||
- The host's own player card (`<PlayerBoard storagePrefix="loto_master_card" />`).
|
||||
|
||||
## What stays out
|
||||
|
||||
- Page-level header (H1 "Quản trò", back-to-player link, the SettingsButton mount).
|
||||
- The `<PageFooter />` mount.
|
||||
- The outer `<div class="flex flex-col flex-1 items-center px-3 py-8 …">` wrapper.
|
||||
|
||||
## File contract
|
||||
|
||||
`src/lib/MasterPanel.svelte`:
|
||||
- No props (mirrors current behavior).
|
||||
- Self-contained — owns its own localStorage, its own draw state, etc.
|
||||
- Renders top-down: controls → "Số vừa xổ" → history → master grid → host's own card.
|
||||
- Uses the `loto_master` and `loto_master_card_*` storage keys (unchanged).
|
||||
|
||||
`src/routes/master/+page.svelte` shrinks to:
|
||||
```svelte
|
||||
<script>
|
||||
import { base } from "$app/paths";
|
||||
import MasterPanel from "$lib/MasterPanel.svelte";
|
||||
import SettingsButton from "$lib/SettingsButton.svelte";
|
||||
import PageFooter from "$lib/PageFooter.svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1 items-center px-2 py-4 sm:px-3 sm:py-12">
|
||||
<div class="w-full max-w-2xl">
|
||||
<header class="relative text-center mb-6">
|
||||
<div class="absolute right-0 top-0"><SettingsButton /></div>
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold ...">Quản trò</h1>
|
||||
<a href="{base}/" class="...">← Về trang người chơi</a>
|
||||
</header>
|
||||
<MasterPanel />
|
||||
<PageFooter />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
(Phase 5 will further re-shape this route.)
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `src/lib/MasterPanel.svelte`. Copy:
|
||||
- Entire `<script module>` block.
|
||||
- The reactive `<script>` block (state, derived, effects, handlers — but NOT the `import { base }` since the back link stays on the route).
|
||||
- The DOM from "Controls" through "Master 9x10 tracking board" through "Master's own playing card" `<PlayerBoard storagePrefix="loto_master_card" />`. Wrap in a top-level `<section aria-label="Bảng quản trò">…</section>` (since the page already has its own header).
|
||||
2. In `src/routes/master/+page.svelte`, delete the moved code and replace with `<MasterPanel />`. Verify imports — keep only what's still used: `base`, `SettingsButton`, `MasterPanel`, `PageFooter` (Phase 3 added).
|
||||
3. svelte-check + dev-browser verify the `/master` route renders identically to before.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Module-scope `BOARD` constants accidentally duplicated when component re-mounts | None — `<script module>` is one-time per module | — | n/a |
|
||||
| Storage prefix typo breaks existing user data | Low | High | Keep `loto_master` and `loto_master_card` exactly. Grep before/after. |
|
||||
| Route still imports unused symbols (lint warning) | Med | Low | clean up after move |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `/master` page renders identically pre/post (visually + functionally).
|
||||
- No code duplicated between MasterPanel and the route.
|
||||
- `npm test`/`lint`/`svelte-check`/`build` all clean.
|
||||
|
||||
## Next
|
||||
- Phase 5: mount `MasterPanel` conditionally on `/`.
|
||||
- Phase 6: extend MasterPanel with auto-call.
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Phase 5 — Master mode integration on `/`
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Depends on Phase 1 (settings.masterMode) and Phase 4 (MasterPanel).
|
||||
- Goal: a single-page experience. Player board on top; master panel below
|
||||
when `settings.masterMode === true`. The `/master` route stays as a deep
|
||||
link.
|
||||
|
||||
## Overview
|
||||
- Priority: P1
|
||||
- Status: TODO
|
||||
- Effort: ~25 min
|
||||
|
||||
## UX
|
||||
|
||||
**Player page (`/`)**:
|
||||
- Header (H1 "Lô tô" + SettingsButton).
|
||||
- PlayerBoard.
|
||||
- If `settings.masterMode`:
|
||||
- Visual divider (e.g., `<hr>` or just margin) + small heading "Quản trò".
|
||||
- `<MasterPanel />`.
|
||||
- PageFooter.
|
||||
|
||||
**Settings**: a "Chế độ quản trò" toggle (on/off, default off). When the user flips it on, the master panel appears inline immediately (rune-reactive — no reload). When off, the panel unmounts and the master state in localStorage stays put (next toggle restores it).
|
||||
|
||||
**`/master` deep link**: continues to render `<MasterPanel />` standalone (Phase 4's slim shell). Doesn't mutate the masterMode setting (least surprise — user lands on /master, sees master panel, leaves; their `/` view stays the way they left it).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/routes/+page.svelte` | Import `MasterPanel`. Read `settings.masterMode`. Conditionally render `<MasterPanel />` between PlayerBoard and PageFooter. |
|
||||
| `src/lib/SettingsButton.svelte` | Add a "Chế độ quản trò" toggle row (above the color fieldset, below theme). Wire to `settings.masterMode` + `saveSettings`. |
|
||||
| `src/routes/master/+page.svelte` | No further change in this phase (Phase 4 already shrank it). |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `+page.svelte`:
|
||||
```svelte
|
||||
<script>
|
||||
import MasterPanel from "$lib/MasterPanel.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
// ... existing imports
|
||||
</script>
|
||||
|
||||
<PlayerBoard />
|
||||
|
||||
{#if settings.masterMode}
|
||||
<div class="mt-10">
|
||||
<h2 class="text-center text-lg font-bold text-orange-500 dark:text-orange-400 mb-4">
|
||||
Quản trò
|
||||
</h2>
|
||||
<MasterPanel />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<PageFooter />
|
||||
```
|
||||
|
||||
2. `SettingsButton.svelte`: add the toggle in the modal. Keep it minimal — a labelled checkbox or a 2-state pill. Suggest pill for visual consistency with theme buttons.
|
||||
|
||||
```svelte
|
||||
<fieldset class="mb-5">
|
||||
<legend class="text-sm font-semibold ...">Chế độ quản trò</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Hiện bảng quản trò bên dưới bảng người chơi
|
||||
</p>
|
||||
<button onclick={() => toggleMasterMode()}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 ...
|
||||
{settings.masterMode
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600'}">
|
||||
{settings.masterMode ? "Đang bật" : "Đang tắt"}
|
||||
</button>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
3. Verify: toggle on/off in settings → master panel appears/disappears live on `/`. `/master` still works.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- User flips master mode off mid-game: panel unmounts; `loto_master` storage stays so next-on restores. ✓ Default Svelte behavior since the component manages its own state from storage on mount.
|
||||
- User has no `loto_master` data when toggling on for the first time: MasterPanel's existing render handles this (`{:else} Nhấn "Ván mới" để bắt đầu`). ✓
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Unmount mid-auto-call interval (Phase 6) leaks timer | Med | Memory | $effect cleanup in Phase 6 covers it |
|
||||
| Navigation from `/` to `/master` with masterMode=on shows master panel twice (once on /, once on /master) | n/a | — | Different routes — only one renders at a time |
|
||||
| Removing "Trang quản trò" header link in Phase 3 leaves no nav to `/master` | True by design | Acceptable | `/master` becomes deep-link-only; users discover master mode via Settings |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `settings.masterMode === false`: `/` shows player board + footer only. No "Trang quản trò" link or master content.
|
||||
- `settings.masterMode === true`: `/` shows player board, then "Quản trò" subhead, then full MasterPanel, then footer.
|
||||
- `/master` still works (renders MasterPanel + back link to `/`).
|
||||
- Toggling the setting repaints `/` live.
|
||||
|
||||
## Next
|
||||
- Phase 6: auto-call extends MasterPanel.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Phase 6 — Auto-call: toggle + interval lifecycle
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Depends on Phase 1 (settings.autoCallEnabled, autoCallSpeed) and Phase 4 (MasterPanel).
|
||||
|
||||
## Overview
|
||||
- Priority: P1
|
||||
- Status: TODO
|
||||
- Effort: ~30 min
|
||||
|
||||
## UX
|
||||
|
||||
In MasterPanel, when `settings.autoCallEnabled === true`:
|
||||
- Add a 2nd line below the controls: speed slider 1–10 sec (label: "Tốc độ tự động: {N} giây/số") wired to `settings.autoCallSpeed`.
|
||||
- "Xổ số" button label changes:
|
||||
- Not running → **"Bắt đầu"** (green gradient).
|
||||
- Running → **"Dừng"** (red gradient).
|
||||
- Clicking starts/stops a `setInterval` that calls the existing `handleDrawNext()` every N seconds.
|
||||
- Auto-stop when `state.remaining.length === 0`.
|
||||
|
||||
When `settings.autoCallEnabled === false` (default): unchanged — manual "Xổ số" per click.
|
||||
|
||||
The Auto on/off toggle itself lives in **Settings** (per user spec). Keeping the speed slider inline in MasterPanel — close to the Bắt đầu/Dừng button it controls — feels more discoverable than burying it in Settings. Acceptable deviation: speed lives in MasterPanel, master-mode + auto-enabled live in Settings. (Alt: put speed in Settings too. Decided inline because it's a runtime knob, not a preference.)
|
||||
|
||||
Wait — re-read user spec: "When auto, Xổ số became Start/Stop". And "auto-call speed (1-10s/number, default 5)" persisted in settings. The toggle is also in settings.
|
||||
|
||||
So:
|
||||
- **Settings**: `autoCallEnabled` (toggle), `autoCallSpeed` (slider).
|
||||
- **MasterPanel**: when `autoCallEnabled`, button switches to start/stop; reads speed from settings live (so changing speed in Settings while running re-arms the interval).
|
||||
|
||||
Let me put the speed slider in Settings (cleaner), but display its current value as a small caption near the start/stop button so the host knows what's about to fire.
|
||||
|
||||
## Lifecycle (the careful bit)
|
||||
|
||||
```js
|
||||
// Inside MasterPanel
|
||||
let autoRunning = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Re-arm interval whenever autoRunning OR speed changes
|
||||
if (!autoRunning) return;
|
||||
const ms = settings.autoCallSpeed * 1000;
|
||||
const id = setInterval(() => {
|
||||
if (!state || state.remaining.length === 0) {
|
||||
autoRunning = false; // triggers cleanup
|
||||
return;
|
||||
}
|
||||
handleDrawNext();
|
||||
}, ms);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Auto-stop also if user disables the auto setting mid-run
|
||||
$effect(() => {
|
||||
if (!settings.autoCallEnabled && autoRunning) autoRunning = false;
|
||||
});
|
||||
|
||||
// Auto-stop when starting a new game
|
||||
function handleNewGame() { /* ... */ autoRunning = false; }
|
||||
```
|
||||
|
||||
Race conditions handled:
|
||||
- **Speed change while running**: $effect re-runs (its dependency `settings.autoCallSpeed` changed) → cleanup old `setInterval` → start new one. ✓
|
||||
- **Remaining hits 0 during a tick**: tick checks first, sets `autoRunning = false`, returns without drawing. The $effect re-runs, sees `!autoRunning`, returns without setting up a new interval. ✓
|
||||
- **User unmounts MasterPanel (master mode off) mid-run**: Svelte fires effect cleanup → `clearInterval`. ✓
|
||||
- **User disables auto setting while running**: secondary $effect catches it, flips `autoRunning = false`, primary effect cleans up. ✓
|
||||
- **Multiple intervals stacking**: impossible, primary $effect always runs cleanup before re-running. ✓
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/MasterPanel.svelte` | Add `autoRunning` state, the two `$effect`s above, swap "Xổ số" button label/style based on `settings.autoCallEnabled` and `autoRunning`. Display speed caption when auto on. |
|
||||
| `src/lib/SettingsButton.svelte` | Add "Tự động xổ" toggle + speed slider 1–10 (visible only when masterMode is on, OR always; pick: always — toggling auto without master mode is a no-op so harmless). |
|
||||
| `src/lib/settings-store.test.js` | (already covered Phase 1) — no new tests for the slider UI itself. |
|
||||
| (no new tests for the $effect lifecycle — relies on browser timers; manual verify in dev) |
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. `MasterPanel.svelte`:
|
||||
- Add `import { settings } from "$lib/settings-store.svelte.js"` if not already present.
|
||||
- Add `let autoRunning = $state(false)`.
|
||||
- Add the two $effects (above).
|
||||
- Modify the "Xổ số" button:
|
||||
```svelte
|
||||
{#if settings.autoCallEnabled}
|
||||
<button onclick={() => (autoRunning = !autoRunning)}
|
||||
disabled={!state || (state.remaining.length === 0 && !autoRunning)}
|
||||
class="px-10 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r {autoRunning
|
||||
? 'from-red-500 to-rose-500'
|
||||
: 'from-emerald-500 to-teal-500'}
|
||||
hover:opacity-90 active:scale-95 transition-all shadow-lg">
|
||||
{autoRunning ? "Dừng" : "Bắt đầu"}
|
||||
</button>
|
||||
{:else if state && state.remaining.length > 0}
|
||||
<button onclick={handleDrawNext} class="...">Xổ số</button>
|
||||
{/if}
|
||||
```
|
||||
- Reset `autoRunning = false` in `handleNewGame`.
|
||||
- Below the buttons, when auto on: small caption "Tự động: {settings.autoCallSpeed}s/số".
|
||||
|
||||
2. `SettingsButton.svelte`:
|
||||
- Add a "Tự động xổ" fieldset (after Master mode) with:
|
||||
- On/off toggle pill (same style as masterMode).
|
||||
- When on: a `<input type="range" min="1" max="10" step="1" bind:value={settings.autoCallSpeed} oninput={saveSettings}>` plus a label showing "{N} giây/số".
|
||||
|
||||
3. svelte-check + manual dev verify:
|
||||
- Master mode on, auto off: "Xổ số" works as before.
|
||||
- Master mode on, auto on, speed=2: click "Bắt đầu" → numbers draw every 2s. Click "Dừng" → stops. Click "Bắt đầu" again → resumes. Reaching 90 numbers stops automatically.
|
||||
- Change speed slider while running → interval re-arms at new speed.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Tab in background throttles `setInterval` to ≥1s | Real | If speed=1, browser may still fire ~1s. UX: slightly slower in background. | Acceptable — host typically has tab focused |
|
||||
| User toggles autoCallEnabled rapidly while running | Low | Stale interval | Secondary $effect catches it |
|
||||
| `handleDrawNext` mutates `state` during interval tick — does Svelte 5 reactivity batch correctly? | Low | If reactive update is slow, intervals could overlap. | `setInterval` ticks are independent of render; `handleDrawNext` is sync; no overlap risk in practice. |
|
||||
| Confirm dialog in `handleNewGame` (`if (state && !confirm(...))`) blocks during auto-call → interval keeps firing while modal is up | Low | Visual glitch | `handleNewGame` is user-triggered, not from interval. Safe. |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Auto setting off → manual "Xổ số" unchanged.
|
||||
- Auto setting on → "Bắt đầu"/"Dừng" button. Drawing every N seconds. Stops on Dừng. Stops at 90/90. Speed change re-arms.
|
||||
- Disabling auto in Settings while running stops it.
|
||||
- Tests still pass (no logic regression).
|
||||
- No timer leaks (verified by toggling master-mode off while running — Svelte cleanup handles).
|
||||
|
||||
## Done = Plan complete
|
||||
- Mark plan.md status → `completed`. Run `/ck:project-management` sync if needed.
|
||||
- Update docs (PDR, codebase-summary, system-architecture, dev-roadmap).
|
||||
- Move "Theme switcher" from roadmap "Idea Phase" → "Currently Implemented".
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
slug: master-merge-theme-auto-mobile-fit
|
||||
created: 2026-04-27
|
||||
status: completed
|
||||
completedAt: 2026-04-27
|
||||
mode: auto
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Master mode merge + theme toggle + auto-call + mobile fit
|
||||
|
||||
Eight user-requested features bundled into one coordinated refactor.
|
||||
Touches almost every component in the app but keeps the data model
|
||||
(grid, crossed, called/remaining, storage keys) untouched.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Default color**: Excel "Standard Color: Purple" `#7030A0`. Most
|
||||
saturated of the 10 standard purples; reads well on both white and
|
||||
black grids; user picked "purple" as the default.
|
||||
- **Color presets**: Office Standard Colors palette (10 swatches), see
|
||||
Phase 1 for hex values.
|
||||
- **Theme**: 3-state — `auto` (default, follow OS), `light`, `dark`.
|
||||
Implemented by toggling `class="dark"` on `<html>` + Tailwind v4
|
||||
`@variant dark (.dark &)` declaration.
|
||||
- **Master route**: kept as a deep-link that auto-enables master mode
|
||||
in settings on visit, then redirects (or just renders the same `/`).
|
||||
Recommend: keep `/master` rendering the same content as `/` but with
|
||||
master mode forced on for that view (no setting mutation — least
|
||||
surprise). Storage prefixes preserved.
|
||||
- **Master logic refactor**: extract everything in `src/routes/master/+page.svelte`
|
||||
except the page-level header into `src/lib/MasterPanel.svelte`. Both
|
||||
`/` (when master mode on) and `/master` mount that component.
|
||||
- **Auto-call lifecycle**: `setInterval` inside an `$effect` that
|
||||
depends on `(autoRunning, settings.autoCallSpeed)` so changing speed
|
||||
while running re-arms cleanly. Auto-stop when remaining hits 0.
|
||||
- **Mobile fit**: cell aspect ratio responsive — `aspect-square` on
|
||||
mobile (default), `sm:aspect-[3/5]` on ≥640px. Number text
|
||||
`text-base sm:text-2xl md:text-3xl`. Page padding tightened on
|
||||
mobile.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | File |
|
||||
|---|---|---|---|
|
||||
| 1 | Settings expansion (theme, masterMode, autoCall*, Excel palette, purple default) + tests | DONE | [phase-01-settings-expansion.md](phase-01-settings-expansion.md) |
|
||||
| 2 | Theme system: Tailwind v4 class-based dark + JS sync + settings UI | DONE | [phase-02-theme-system.md](phase-02-theme-system.md) |
|
||||
| 3 | Mobile-fit + page header/footer refactor | DONE | [phase-03-mobile-and-header-footer.md](phase-03-mobile-and-header-footer.md) |
|
||||
| 4 | Extract MasterPanel component from `/master` route | DONE | [phase-04-extract-master-panel.md](phase-04-extract-master-panel.md) |
|
||||
| 5 | Master mode integration on `/` (inline render via setting) + `/master` deep-link | DONE | [phase-05-master-mode-integration.md](phase-05-master-mode-integration.md) |
|
||||
| 6 | Auto-call toggle + interval lifecycle | DONE | [phase-06-auto-call.md](phase-06-auto-call.md) |
|
||||
|
||||
## Files Touched (summary)
|
||||
|
||||
| File | Phases |
|
||||
|---|---|
|
||||
| `src/lib/settings-store.svelte.js` | 1, 2, 5, 6 — new keys, theme apply logic |
|
||||
| `src/lib/settings-store.test.js` | 1, 2, 6 — new tests |
|
||||
| `src/lib/SettingsButton.svelte` | 1, 2, 5, 6 — Excel swatches, theme toggle, master mode toggle, auto-call speed |
|
||||
| `src/lib/PlayerBoard.svelte` | 3 — responsive cells, drop "Made by" from bottom label, footer text simplification |
|
||||
| `src/lib/MasterPanel.svelte` | 4 — NEW (extracted from master route) |
|
||||
| `src/routes/+layout.svelte` | 2 — theme already loads on mount; ensure media-query listener if `auto` |
|
||||
| `src/routes/+page.svelte` | 3, 5 — drop instructions, restructure header, mount MasterPanel conditionally, mount Footer |
|
||||
| `src/routes/master/+page.svelte` | 4, 5 — collapse to a thin shell that mounts MasterPanel |
|
||||
| `src/lib/PageFooter.svelte` | 3 — NEW small component (tagline + made-by) |
|
||||
| `src/app.css` | 2, 3 — convert dark `@media` to class-based, add Tailwind dark variant declaration |
|
||||
| `docs/project-overview-pdr.md` | post-impl — sync features |
|
||||
| `docs/codebase-summary.md` | post-impl — new files |
|
||||
| `docs/system-architecture.md` | post-impl — page-flow update |
|
||||
| `docs/development-roadmap.md` | post-impl — move "Theme switcher" → Implemented |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Per-card storage migrations
|
||||
- E2E tests / component tests
|
||||
- Sound effects, PWA, multiplayer sync
|
||||
- Custom number range
|
||||
- Card serial number badges
|
||||
- Replacing PlayerBoard rendering for master grid
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# Session Review — 260427-0113
|
||||
|
||||
Scope: 6-feature working-tree refactor (settings/theme, Tailwind v4 dark variant, settings UI, mobile fit + footer, MasterPanel extraction, auto-call). 7 modified + 2 new files, 469 / 393 LOC.
|
||||
|
||||
Validation run before review:
|
||||
- `npx vitest run` — 53/53 pass.
|
||||
- `npx svelte-check` — 0 errors, 0 warnings.
|
||||
- `npx vite build` — clean (Tailwind v4 `@variant` compiled).
|
||||
|
||||
---
|
||||
|
||||
## Must-fix (block ship)
|
||||
|
||||
**None.** Build, tests, and types all green. Lifecycle and storage concerns below are real but each has an honest mitigation that keeps "ship today" defensible.
|
||||
|
||||
---
|
||||
|
||||
## Nice-to-fix (do before ship if you have 30 min, otherwise file)
|
||||
|
||||
### 1. Auto-call interval can leak on `/master` navigation when `autoCallEnabled=false`
|
||||
File: `src/lib/MasterPanel.svelte:96-112`
|
||||
|
||||
Walk-through:
|
||||
1. `autoCallEnabled=true`, user is on `/master`, clicks "Bắt đầu" → `autoRunning=true`, primary effect arms `setInterval`.
|
||||
2. User opens Settings, flips `autoCallEnabled=false`. Secondary effect (line 110-112) fires synchronously, sets `autoRunning=false`. Primary effect re-runs, cleanup runs, `clearInterval` fires. Safe.
|
||||
3. User unmounts `MasterPanel` mid-run (navigates `/master` → `/` with `masterMode=false`, or simply closes tab) → Svelte runs `$effect` cleanup → `clearInterval`. Safe.
|
||||
4. Edge: `state.remaining.length === 0` reached inside the tick → `autoRunning=false` set inside the interval callback. Next microtask the primary `$effect` re-runs with `autoRunning=false`, hits the early `return`, the prior cleanup fires → `clearInterval`. Safe but the interval fires *one extra time* (the one that set `autoRunning=false`) before tearing down. Not a leak — already correct because the early-return inside the callback (`if (!state || state.remaining.length === 0) { autoRunning=false; return; }`) prevents `handleDrawNext()` from being called the dead tick. Good.
|
||||
|
||||
So no actual leak. **But** there is an ordering subtlety to flag:
|
||||
|
||||
The two effects both depend on `autoRunning`. If the secondary effect runs *after* the primary in the same flush, and the user disables `autoCallEnabled` while running, the primary re-arms with the current `autoRunning=true` for that flush, then the secondary flips `autoRunning=false`, then the primary re-runs again and tears down. Net result: cleanup is called, no leak. But Svelte 5 doesn't guarantee effect order across separate `$effect` calls — depends on registration order. Today registration order is primary first, secondary second, so secondary always runs after. Fine. If someone reorders the script, the leak window widens to one tick.
|
||||
|
||||
**Suggested fix (cheap):** Fold the secondary check into the primary effect to remove cross-effect dependency:
|
||||
|
||||
```js
|
||||
$effect(() => {
|
||||
if (!autoRunning) return;
|
||||
if (!settings.autoCallEnabled) { autoRunning = false; return; }
|
||||
const ms = settings.autoCallSpeed * 1000;
|
||||
const id = setInterval(() => { ... }, ms);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
```
|
||||
|
||||
Drop the second `$effect` entirely. Same behavior, one fewer reactive subscription, no ordering dependency.
|
||||
|
||||
### 2. `loadSettings()` re-entry — leak window if layout remounts
|
||||
File: `src/lib/settings-store.svelte.js:66-88`
|
||||
|
||||
`applyTheme()` checks `if (mql && mqlListener)` and tears down before reattaching. Safe under sequential calls. **However** `loadSettings()` and `saveSettings()` both call `applyAll()` → `applyTheme()`. The teardown only runs if the *last* call left a listener attached. If something else attached a listener directly to `window.matchMedia(...)` outside this module (it doesn't today), it would leak.
|
||||
|
||||
Within the module: safe. The test at line 269-280 covers `auto → dark` detach. Add an analogous test for `loadSettings()` called twice with `theme=auto` to lock in the invariant. (Skipped per your "no tests" instruction — note for tester agent.)
|
||||
|
||||
**Real concern:** if you ever switch from `onMount` (`+layout.svelte:11`) to `$effect` for hydration, and the layout re-runs during HMR or route group switch, `applyTheme` is the only thing protecting you. The current guard is correct. Good defensive code.
|
||||
|
||||
### 3. Two-tab `loto_master` race — unmitigated
|
||||
File: `src/lib/MasterPanel.svelte:2,45-51`
|
||||
|
||||
Storage key `loto_master` is shared between `/master` and `/` (when `masterMode=true`). Both tabs save state on every change with last-writer-wins. This was raised in your priority Q5; my read:
|
||||
|
||||
- For a single-host quiz scenario (one device, one human), this is **fine**. Acceptable for ship today.
|
||||
- The deeper bug isn't write conflicts, it's that **neither instance reacts to `storage` events**, so when `/` writes, `/master` keeps showing stale called-list and vice versa until refresh. The two MasterPanels are not actually shared state — they're independent state machines persisted to the same key. Confusing but not destructive.
|
||||
|
||||
**Recommendation:** ship as-is. Add a `window.addEventListener("storage", ...)` reload in a follow-up if you ever support multi-screen sessions. Document the limitation in MasterPanel.svelte module-doc.
|
||||
|
||||
### 4. `MasterPanel` mounted twice causes double saves
|
||||
File: `src/routes/+page.svelte:33`, `src/routes/master/+page.svelte:30`
|
||||
|
||||
If a user is on `/` with `masterMode=true` and navigates to `/master` (no link does this today, but typing the URL works), they hit two routes that each mount MasterPanel. SvelteKit unmounts `/`'s instance on navigation, so only one is active at a time. Safe.
|
||||
|
||||
But if `/master` is open and you toggle on `masterMode` then go back to `/`, both now use `loto_master` *but the MasterPanel `$state` is reset* on mount — it loads from localStorage on the `$effect` at line 77. Result: state persists across navigation. Good.
|
||||
|
||||
Edge: the `$effect` at line 77 has no dependencies — Svelte 5 runs it once on mount. ✓ Correct.
|
||||
|
||||
### 5. `aspect-square` for cells at 360-639px feels cramped
|
||||
File: `src/lib/PlayerBoard.svelte:184,194`
|
||||
|
||||
You said "card is 9 cells wide × 38px = 342px, fits". At 9-col grid the cell width is `(viewport - padding) / 9`. With `px-2` (16px) it's `(360-16)/9 ≈ 38.2px`. Square cells give the user 38×38 tappable targets — under iOS HIG's 44px minimum and Android Material's 48dp. Touch target accessibility issue.
|
||||
|
||||
**Options:**
|
||||
- Push `sm:aspect-[3/5]` to start earlier — `xs` doesn't exist in Tailwind by default, but `min-[400px]:aspect-[3/5]` works.
|
||||
- Or accept it and add `min-h-[44px]` to the cell button to enforce a floor (will break aspect ratio but improves tap accuracy).
|
||||
|
||||
Not blocking ship — Vietnamese wedding/party venue users will tap it; works on iPad fine. Note for backlog.
|
||||
|
||||
### 6. Speed slider has no `aria-label`
|
||||
File: `src/lib/SettingsButton.svelte:219-227`
|
||||
|
||||
The `<input type="range">` is wrapped by `<label>` whose visible text is `Tốc độ: <strong>{n}</strong> giây/số`. Screen readers do associate label-text with the input via wrapping `<label>`, but the `<strong>` interpolation reads as "Speed: 5 seconds per number" — fine. **However** there is no `aria-valuetext` so the SR announces "5" without unit. Minor.
|
||||
|
||||
Add `aria-label="Tốc độ tự động xổ"` and `aria-valuetext="{settings.autoCallSpeed} giây mỗi số"` for completeness.
|
||||
|
||||
### 7. Backdrop `<div>` with `role="presentation"` and `onkeydown` — contradiction
|
||||
File: `src/lib/SettingsButton.svelte:122-129`
|
||||
|
||||
You set `aria-hidden="true"` AND `role="presentation"` AND attach `onkeydown`. Pick one model:
|
||||
- Decorative backdrop: `aria-hidden="true"`, no keyboard handler (the window-level Escape handler at line 76-84 already covers it).
|
||||
- Interactive close: `<button type="button" aria-label="Đóng">` with the rest stripped.
|
||||
|
||||
Drop the `onkeydown` and `role="presentation"` — keep `aria-hidden`. Cleaner.
|
||||
|
||||
---
|
||||
|
||||
## Noted, not blocking
|
||||
|
||||
### Tailwind v4 `@variant` syntax (your Q3)
|
||||
`@variant dark (&:where(.dark, .dark *));` is **valid Tailwind v4 syntax** for class-based dark mode. Build passed. The `&:where(.dark, .dark *)` shape correctly handles both:
|
||||
- The element itself having `.dark` (e.g., `<html class="dark">` for `:root` rules).
|
||||
- Any descendant of `.dark`.
|
||||
|
||||
Standard pattern from Tailwind v4 docs. Compiled output works (build succeeded, dark-mode tests pass).
|
||||
|
||||
### `:where(.dark)` specificity (your Q4)
|
||||
`:where()` reduces specificity to 0. **For CSS custom properties** (`--background`, `--foreground` at line 16-19) this is fine — custom-property cascade resolves last-declaration-wins regardless of specificity. The Tailwind utilities reading `var(--background)` always see the latest value.
|
||||
|
||||
**For non-custom-property rules** like `:where(.dark) .section-divider { background-color: ... }` (line 61-64): specificity 0,1,0 (the `.section-divider` is outside the `:where`). Tailwind utilities like `bg-blue-100` are 0,1,0. Could collide. **In practice** the `.section-divider` class is custom CSS not overridden by Tailwind utilities anywhere in the codebase, so safe. If you ever apply `class="section-divider bg-blue-100"` the utility would win, but that's the conventional Tailwind behavior anyway.
|
||||
|
||||
**Verdict:** intentional, correct, idiomatic for v4. No change needed.
|
||||
|
||||
### YAGNI / over-engineering (your Q8)
|
||||
- Settings store is appropriately compact. Per-key validation pattern is the right call given backwards compat with old saved data.
|
||||
- Two `$effect`s in MasterPanel — see (1) above, fold into one.
|
||||
- `applyAll()` always calls both `applyEmptyCellColor()` and `applyTheme()` even when only one changed. Cheap operations; no need to split.
|
||||
- `BOARD_FLAT` precomputation in MasterPanel module-script is correct and cheap.
|
||||
- `MasterPanel.svelte` is 311 lines — over the 200-line guideline in CLAUDE.md but the layout (script, state, effects, controls, current-number, history, grid, master-card) is a single cohesive unit. Splitting would invent prop boundaries that don't help. Acceptable.
|
||||
|
||||
### Footer SVG heart
|
||||
Inline SVG with `fill="currentColor"` and `class="text-red-500"` — correct. Renders in dark mode without inversion (red on dark is fine). `aria-label="trái tim"` — debatable; could be `aria-hidden="true"` since the surrounding text "Made by miti99 with [heart]" doesn't depend on heart for meaning. Not blocking.
|
||||
|
||||
### `confirm()` in handlers
|
||||
`MasterPanel.svelte:115` `confirm("Bạn có muốn tạo ván mới không?")` — synchronous browser dialog. Same pattern as `PlayerBoard.svelte:111`. Consistent across codebase. Fine.
|
||||
|
||||
### Theme `auto` SSR
|
||||
SvelteKit static adapter pre-renders without a window. `applyTheme()` correctly guards on `typeof document === "undefined"`. The `<html>` ships without `.dark`, so first paint is light. Then `onMount → loadSettings → applyTheme` adds it if needed. **Brief flash of light theme** for users with dark OS preference. Common SvelteKit static-site issue, acceptable for a quiz app, not worth a SSR theme cookie hack today.
|
||||
|
||||
### Stored speed validators
|
||||
`validSpeed` rejects `"5"` (string), good. Rejects `5.5`, good. Tests cover. ✓
|
||||
|
||||
### `state` initialization in MasterPanel
|
||||
`let state = $state(/** @type {...} */ (null))`. The auto-call effect (line 96) checks `!state` defensively. Good — there's a moment after mount before `loadState()` runs the first effect where state is null but autoRunning is false anyway. No race.
|
||||
|
||||
### Mobile fit math (your Q6)
|
||||
360→640px square cells: see (5). Adding a `min-[480px]:aspect-[3/5]` breakpoint feels right but YAGNI says ship and observe. Note for backlog.
|
||||
|
||||
---
|
||||
|
||||
## Behavioral checklist
|
||||
|
||||
- Concurrency / async: auto-call interval lifecycle reviewed (item 1). Theme MQL listener teardown reviewed (item 2). Both correct.
|
||||
- Error boundaries: `try/catch` swallow at storage boundaries with default fallback. Correct for a localStorage-only app.
|
||||
- API contracts: `Props` typedef on `PlayerBoard.svelte` defaults `storagePrefix="loto"`. Settings exports stable.
|
||||
- Backwards compat: per-key validators preserve old saved data shape. ✓ Tested.
|
||||
- Input validation: hex, theme enum, bool, integer range — all validated before commit to settings.
|
||||
- Auth/authz: N/A (client-only quiz app, no auth surface).
|
||||
- N+1 / queries: N/A.
|
||||
- Data leaks: no PII, no secrets. Only localStorage of game state.
|
||||
|
||||
---
|
||||
|
||||
## Recommended actions (priority order)
|
||||
|
||||
1. **Fold the two MasterPanel `$effect`s into one** (item 1). 5-min change, eliminates ordering dependency.
|
||||
2. Drop `onkeydown` + `role="presentation"` from SettingsButton backdrop (item 7). 1-min cleanup.
|
||||
3. Add `aria-label` + `aria-valuetext` to speed slider (item 6). 1-min.
|
||||
4. Document `loto_master` two-tab limitation in MasterPanel module doc.
|
||||
5. (Backlog) Reconsider mobile cell aspect — `min-[480px]:aspect-[3/5]` or `min-h-[44px]`.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
- Should `/master` route be deprecated entirely once `masterMode` toggle ships? Two paths to the same component is a maintenance smell.
|
||||
- Was the choice of `confirm()` over a custom modal intentional (consistency) or just expedient? If intentional, document.
|
||||
|
||||
**Status:** DONE_WITH_CONCERNS
|
||||
**Summary:** All 6 features ship-ready. No blockers. One real cleanup (fold two `$effect`s in MasterPanel to remove cross-effect ordering dependency) plus minor a11y polish. Build, tests, types all green.
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
# Test Validation Report
|
||||
**Project:** SvelteKit Lô Tô App (6-phase refactor)
|
||||
**Test Date:** 2026-04-27 01:13 UTC
|
||||
**Scope:** Complete test suite + build validation
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Results
|
||||
|
||||
### npm test (Run 1)
|
||||
- **Status:** PASSED ✓
|
||||
- **Test Files:** 2 passed (2)
|
||||
- **Tests:** 53 passed (53)
|
||||
- **Duration:** 4.95s (transform 3.60s, import 4.59s, tests 457ms, environment 1.12s)
|
||||
|
||||
### npm test (Run 2 — flakiness check)
|
||||
- **Status:** PASSED ✓
|
||||
- **Test Files:** 2 passed (2)
|
||||
- **Tests:** 53 passed (53)
|
||||
- **Duration:** 6.11s (transform 4.41s, import 5.53s, tests 666ms, environment 1.46s)
|
||||
|
||||
**Flakiness:** No flakiness detected. Tests are stable across runs.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage by File
|
||||
|
||||
### settings-store.test.js (38 tests)
|
||||
**Status:** All passing ✓
|
||||
|
||||
Tests validate:
|
||||
- **Defaults (4 tests):** `DEFAULT_SETTINGS` frozen, purple color default (#7030A0), theme="auto", masterMode/autoCallEnabled/autoCallSpeed defaults
|
||||
- **loadSettings color validation (8 tests):** Valid/invalid colors, RGB range checks, uppercase/shorthand rejection, JSON corruption resilience
|
||||
- **Per-key fallback validators (7 tests):** Theme validation (auto/light/dark), boolean masterMode/autoCallEnabled, integer autoCallSpeed (1..10 range)
|
||||
- **saveSettings persistence (2 tests):** All 5 keys persisted atomically, CSS var application
|
||||
- **resetSettings (1 test):** Full reset to defaults
|
||||
- **Theme application via load/save (6 tests):**
|
||||
- Light mode removes `.dark` class
|
||||
- Dark mode adds `.dark` class
|
||||
- Auto mode follows matchMedia.matches
|
||||
- Auto mode re-applies on OS pref change event (fire tests)
|
||||
- Switching auto→dark detaches listener
|
||||
|
||||
**Theme Test Quality:** matchMedia mocked with listener tracking. Tests verify listener add/remove on theme toggle. No issues detected. Happy-dom listenerSet properly managed via beforeEach cleanup.
|
||||
|
||||
### game-logic.test.js (15 tests)
|
||||
**Status:** All passing ✓
|
||||
|
||||
Tests validate:
|
||||
- **generateGrid invariants (4 tests):** 9x9 shape, 5 per row, 5 per col, no duplicates (50 trials each)
|
||||
- **Column number ranges (4 tests):** Tens ranges per column, ascending order within column, col 0 (1-9), col 8 (80-90)
|
||||
- **isRowComplete (4 tests):** All crossed=true, partial uncrossed, all-zero row, zero-cell ignoring
|
||||
- **Roundtrip persistence (5 tests):** saveGrid/loadGrid by prefix, corruption rejection (wrong shape, non-numbers, corrupt JSON), loadCrossedState validation
|
||||
|
||||
**Coverage:** Core game logic fully tested. Boundary conditions covered (empty rows, shape validation, type rejection).
|
||||
|
||||
---
|
||||
|
||||
## Build Validation
|
||||
|
||||
### npm run lint
|
||||
- **Status:** PASSED ✓
|
||||
- **Errors:** 0
|
||||
- **Warnings:** 0
|
||||
- **Output:** Clean
|
||||
|
||||
### npx svelte-check (Svelte v5 component type checking)
|
||||
- **Status:** PASSED ✓
|
||||
- **Files checked:** 7
|
||||
- **Errors:** 0
|
||||
- **Warnings:** 0
|
||||
|
||||
### npm run build (production SSR + static export)
|
||||
- **Status:** PASSED ✓
|
||||
- **Duration:** 13.28s total
|
||||
- **Output:** `.svelte-kit/output/` built successfully
|
||||
- **Chunks:** 149 SSR modules, 162 client modules
|
||||
- **Gzip sizes:** CSS 7.73 kB, JS bundles <12 kB each
|
||||
- **Static export:** `build/` folder generated by adapter-static
|
||||
|
||||
**Build quality:** Healthy. No deprecation warnings or optimization issues.
|
||||
|
||||
---
|
||||
|
||||
## Critical Coverage Gaps (HIGH PRIORITY)
|
||||
|
||||
### 1. MasterPanel.svelte — Auto-call $effect lifecycle (UNTESTED)
|
||||
**Severity:** HIGH — Core feature with complex interval logic
|
||||
|
||||
The auto-call feature at lines 96-112 has zero test coverage:
|
||||
|
||||
```javascript
|
||||
// Auto-call interval. Re-runs when autoRunning OR speed changes
|
||||
$effect(() => {
|
||||
if (!autoRunning) return;
|
||||
const ms = settings.autoCallSpeed * 1000;
|
||||
const id = setInterval(...);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Auto-stop if user disables auto-call mid-run
|
||||
$effect(() => {
|
||||
if (!settings.autoCallEnabled && autoRunning) autoRunning = false;
|
||||
});
|
||||
```
|
||||
|
||||
**Gaps:**
|
||||
- No test for interval setup/teardown with correct timing
|
||||
- No test for interval re-arming when speed changes mid-run
|
||||
- No test for early cleanup when `autoRunning=false`
|
||||
- No test for emergency auto-stop when `autoCallEnabled` toggles while running
|
||||
- No test for game-over edge case (remaining.length === 0 → autoRunning = false)
|
||||
|
||||
**Why this matters:** Intervals leak memory if teardown fails. Speed mid-run swap must clear old interval before arming new one. User disables auto setting mid-game → app hangs if effect doesn't fire.
|
||||
|
||||
**Recommended tests:**
|
||||
- Setup interval, verify clearInterval called on cleanup
|
||||
- Change speed 2..10 mid-interval, verify old interval cleared before new one armed
|
||||
- User disables autoCallEnabled while autoRunning=true, verify autoRunning forced to false
|
||||
- Game ends (remaining.length → 0), verify autoRunning stops without error
|
||||
|
||||
---
|
||||
|
||||
### 2. SettingsButton.svelte — User interaction & modal lifecycle (UNTESTED)
|
||||
**Severity:** MEDIUM — 85 lines of Svelte components with no unit tests
|
||||
|
||||
**Untested:**
|
||||
- Escape-key-to-close listener (lines 76–84): setup/teardown, listener removal
|
||||
- Modal state transitions: open=false → open=true → close() → open=false
|
||||
- Theme button callbacks (pickTheme → saveSettings)
|
||||
- Master toggle (toggleMaster → saveSettings)
|
||||
- Auto-call toggle & speed slider (toggleAuto, onSpeedInput range validation)
|
||||
- Color picker input (onPickerInput, preset buttons pickColor)
|
||||
- Reset button (resetSettings)
|
||||
- Backdrop click-to-close (onclick={close})
|
||||
|
||||
**Why this matters:** Modal dialogs are common test failure points (backdrop leaks, focus not restored, listeners not cleaned up). Escape-key listener tests would catch regressions in modal behavior.
|
||||
|
||||
**Recommended tests:** (E2E or integration, as component state testing is fragile in Vitest)
|
||||
- Modal opens/closes, Escape key closes it
|
||||
- Speed slider input validation (rejects >10, <1, non-integer)
|
||||
- Color preset selection updates both settings and UI
|
||||
- Reset button restores all defaults
|
||||
|
||||
---
|
||||
|
||||
### 3. MasterPanel.svelte — Game state & UI logic (UNTESTED)
|
||||
**Severity:** MEDIUM — 311 lines, complex state machine
|
||||
|
||||
**Untested:**
|
||||
- Game initialization (handleNewGame, state creation, confirmation dialog)
|
||||
- Draw sequence (handleDrawNext, state update, lastCalled tracking)
|
||||
- Board rendering (BOARD_FLAT grid, callOrder Map, cell highlighting)
|
||||
- Auto-toggle UI (toggleAuto, button state conditions)
|
||||
- Game-over detection (remaining.length === 0)
|
||||
- Load/save state via localStorage (lines 53–61)
|
||||
- Call history rendering (state.called display)
|
||||
- Kinh! order number display (lines 282–286)
|
||||
|
||||
**Why this matters:** MasterPanel is the app's most complex UI. State sync, conditional rendering, and game lifecycle are hard to verify visually. No tests means regression risk on state mutations.
|
||||
|
||||
---
|
||||
|
||||
### 4. PlayerBoard.svelte — Grid rendering & interaction (UNTESTED)
|
||||
**Severity:** MEDIUM — Player's card UI, cell crossing logic
|
||||
|
||||
**Untested:**
|
||||
- Card generation (grid shape, number placement)
|
||||
- Cell crossing via click (crossed[r][c] toggle)
|
||||
- Waiting-number highlight (getWaitingNumber logic)
|
||||
- Row-complete detection (isRowComplete visual feedback)
|
||||
- Responsive sizing (aspect-square, sm: breakpoints)
|
||||
- localStorage persistence by prefix (storagePrefix prop)
|
||||
|
||||
---
|
||||
|
||||
### 5. Page routes (±page.svelte, +layout.svelte) — Integration (UNTESTED)
|
||||
**Severity:** LOW — Routes mount components but have no business logic
|
||||
|
||||
**Untested:**
|
||||
- Route rendering (/`, `/master`)
|
||||
- Conditional mounting of MasterPanel (settings.masterMode)
|
||||
- Footer presence
|
||||
- Settings button placement
|
||||
|
||||
---
|
||||
|
||||
## Asset Coverage Summary
|
||||
|
||||
| Layer | Test Files | Component Files | Coverage |
|
||||
|-------|-----------|-----------------|----------|
|
||||
| **Logic** (game-logic.js, settings-store.js) | 2 test files, 53 tests | 2 logic files | ~90% ✓ |
|
||||
| **Components** (Svelte) | 0 test files | 7 components + 3 routes | 0% ✗ |
|
||||
| **Integration** (API, state flow) | 0 test files | End-to-end flows | 0% ✗ |
|
||||
|
||||
---
|
||||
|
||||
## Flakiness & Environment Quality
|
||||
|
||||
### matchMedia Mock (happy-dom)
|
||||
**Status:** Robust ✓
|
||||
|
||||
The mockMatchMedia helper in settings-store.test.js:
|
||||
- Properly isolates listener Set
|
||||
- Fires events to all listeners
|
||||
- Tracks add/remove via listenerCount()
|
||||
- Cleaned before each test via beforeEach
|
||||
|
||||
Run 1 & Run 2 both stable. No listener cleanup issues detected.
|
||||
|
||||
### Async Tests
|
||||
**Status:** None detected
|
||||
|
||||
No async/await or promise-based tests in current suite. All sync, deterministic.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality & Best Practices
|
||||
|
||||
### Strengths
|
||||
✓ Per-key validators prevent silent load failures
|
||||
✓ Color validation rejects shorthand (#fff), ensures 6-digit hex
|
||||
✓ Comprehensive theme auto-switching with listener lifecycle
|
||||
✓ Game logic tested against 200-trial randomness
|
||||
✓ localStorage roundtrip tests cover JSON corruption
|
||||
✓ All test files have `@vitest-environment happy-dom` comment
|
||||
|
||||
### Minor Issues
|
||||
⚠ No coverage for component interactive flows (modals, buttons, dragging)
|
||||
⚠ No performance benchmarks (interval timing, grid rendering)
|
||||
⚠ No a11y tests (aria-pressed, aria-label, role attributes)
|
||||
⚠ No dark mode visual regression tests (just CSS var + class toggle)
|
||||
|
||||
---
|
||||
|
||||
## Recommendations (Priority Order)
|
||||
|
||||
### MUST (Before shipping)
|
||||
1. **Add MasterPanel auto-call $effect tests.** Current untested interval logic is a memory leak risk. Test interval setup/teardown, speed swap, emergency stop.
|
||||
2. **Add integration test for theme toggle via UI.** SettingsButton theme buttons → saveSettings → dark class applied. Verify end-to-end.
|
||||
|
||||
### SHOULD (Next iteration)
|
||||
3. **Add SettingsButton modal tests.** Escape key, backdrop click, reset button. Fragile UX patterns.
|
||||
4. **Add MasterPanel game state tests.** Confirm dialog, state mutations, board rendering.
|
||||
5. **Add PlayerBoard interaction tests.** Cell crossing, waiting-number highlight, row-complete feedback.
|
||||
|
||||
### NICE-TO-HAVE (Future)
|
||||
6. Add a11y tests (axe-core or similar).
|
||||
7. Add dark mode snapshot tests (visual regression).
|
||||
8. Add performance benchmarks (grid generation 9x9 time, interval latency).
|
||||
|
||||
---
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
1. **MasterPanel auto-call: What's the expected behavior if the user disables autoCallEnabled while autoRunning=true but before the interval fires again?** Current code stops autoRunning in $effect, but timing isn't tested. Is the interrupt instantaneous or delayed to next fire?
|
||||
|
||||
2. **SettingsButton modal: Is the Escape-key listener re-attached every time open changes?** The $effect at line 76 depends on `open`, so yes, but no test verifies this.
|
||||
|
||||
3. **Theme listener: If theme="auto" and user toggles to "dark", does the old matchMedia listener get cleaned up before or after the class toggle?** Code at line 69–72 removes first, then applies. Is this order critical?
|
||||
|
||||
4. **Speed range validation: The slider input uses native `<input type="range" min="1" max="10" />`, but onSpeedInput re-validates. Is this belt-and-suspenders by design, or should we trust the browser constraint?**
|
||||
|
||||
5. **Game state persistence: MasterPanel uses localStorage key "loto_master", but PlayerBoard uses prop `storagePrefix`. Are they isolated correctly when both are on the page?**
|
||||
|
||||
---
|
||||
|
||||
## Final Assessment
|
||||
|
||||
**Test Suite Status:** PASSED ✓
|
||||
**Build Status:** PASSED ✓
|
||||
**Coverage (logic layer):** 90%+
|
||||
**Coverage (UI/integration):** 0%
|
||||
**Flakiness:** None detected
|
||||
**Shipping readiness:** BLOCKED on MasterPanel auto-call tests
|
||||
|
||||
The refactor's logic (settings, game-logic) is well-tested. But the new UI features (theme toggle, auto-call, master mode) have zero test coverage. Before merging:
|
||||
|
||||
1. Add MasterPanel $effect tests (interval lifecycle).
|
||||
2. Add SettingsButton + MasterPanel integration test (theme change → dark class).
|
||||
3. Smoke-test manually: open settings, toggle theme, toggle master mode, test auto-call speed swap mid-run.
|
||||
|
||||
Recommend running full build + tests 1x more after fixes to ensure no regressions.
|
||||
+17
-18
@@ -1,18 +1,21 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Class-based dark-mode variant. The settings store toggles `class="dark"`
|
||||
on <html>, mirroring OS preference when theme="auto" or honoring the
|
||||
user's explicit pick when theme="light"/"dark". */
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--background: #f8fafc;
|
||||
--foreground: #1e293b;
|
||||
/* Default empty-cell color matches a Tân Tân BAMBOORAFT paper card.
|
||||
Overridden at runtime by the settings store via documentElement.style. */
|
||||
--empty-cell-bg: #1e88e5;
|
||||
/* Default empty-cell color: Excel "Standard Color: Purple". Overridden
|
||||
at runtime by the settings store via documentElement.style. */
|
||||
--empty-cell-bg: #7030A0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
--foreground: #e2e8f0;
|
||||
}
|
||||
:where(.dark) {
|
||||
--background: #0f172a;
|
||||
--foreground: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -55,11 +58,9 @@ body {
|
||||
);
|
||||
background-color: rgb(227 242 253 / 0.7);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-divider,
|
||||
.section-divider-vertical {
|
||||
background-color: rgb(13 71 161 / 0.4);
|
||||
}
|
||||
:where(.dark) .section-divider,
|
||||
:where(.dark) .section-divider-vertical {
|
||||
background-color: rgb(13 71 161 / 0.4);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@@ -90,11 +91,9 @@ body {
|
||||
transparent 8px 14px
|
||||
);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-label {
|
||||
color: #64b5f6;
|
||||
background-color: rgb(13 71 161 / 0.4);
|
||||
}
|
||||
:where(.dark) .section-label {
|
||||
color: #64b5f6;
|
||||
background-color: rgb(13 71 161 / 0.4);
|
||||
}
|
||||
|
||||
/* Tân Tân BAMBOORAFT-style number — tall, condensed, heavy black. Stack
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script module>
|
||||
const STORAGE_KEY = "loto_master";
|
||||
|
||||
/** @returns {{ called: number[], remaining: number[] }} */
|
||||
function createFreshState() {
|
||||
const all = Array.from({ length: 90 }, (_, i) => i + 1);
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[all[i], all[j]] = [all[j], all[i]];
|
||||
}
|
||||
return { called: [], remaining: all };
|
||||
}
|
||||
|
||||
/** @param {{ called: number[], remaining: number[] }} state */
|
||||
function saveState(state) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import PlayerBoard from "$lib/PlayerBoard.svelte";
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
|
||||
let state = $state(
|
||||
/** @type {{called: number[], remaining: number[]} | null} */ (null),
|
||||
);
|
||||
let lastCalled = $state(/** @type {number | null} */ (null));
|
||||
let autoRunning = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const saved = loadState();
|
||||
if (saved && saved.called.length > 0) {
|
||||
state = saved;
|
||||
lastCalled = saved.called[saved.called.length - 1];
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (state) saveState(state);
|
||||
});
|
||||
|
||||
// Auto-call interval. Single $effect that depends on autoRunning,
|
||||
// settings.autoCallSpeed, AND settings.autoCallEnabled — Svelte tears
|
||||
// down + re-arms cleanly whenever any of them change. Includes the
|
||||
// "user disabled auto setting mid-run" auto-stop as part of the same
|
||||
// dependency tracking.
|
||||
$effect(() => {
|
||||
if (!autoRunning || !settings.autoCallEnabled) {
|
||||
autoRunning = false;
|
||||
return;
|
||||
}
|
||||
const ms = settings.autoCallSpeed * 1000;
|
||||
const id = setInterval(() => {
|
||||
if (!state || state.remaining.length === 0) {
|
||||
autoRunning = false;
|
||||
return;
|
||||
}
|
||||
handleDrawNext();
|
||||
}, ms);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
function handleNewGame() {
|
||||
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
autoRunning = false;
|
||||
state = createFreshState();
|
||||
lastCalled = null;
|
||||
}
|
||||
|
||||
function handleDrawNext() {
|
||||
if (!state || state.remaining.length === 0) return;
|
||||
const next = state.remaining[0];
|
||||
state = {
|
||||
called: [...state.called, next],
|
||||
remaining: state.remaining.slice(1),
|
||||
};
|
||||
lastCalled = next;
|
||||
}
|
||||
|
||||
function toggleAuto() {
|
||||
if (!state || state.remaining.length === 0) return;
|
||||
autoRunning = !autoRunning;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center gap-3 mb-6 flex-wrap">
|
||||
<button
|
||||
onclick={handleNewGame}
|
||||
class="px-8 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-orange-500 to-red-500
|
||||
hover:from-orange-600 hover:to-red-600
|
||||
active:scale-95 transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
Ván mới
|
||||
</button>
|
||||
{#if state && state.remaining.length > 0}
|
||||
{#if settings.autoCallEnabled}
|
||||
<button
|
||||
onclick={toggleAuto}
|
||||
class="px-10 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r {autoRunning
|
||||
? 'from-red-500 to-rose-500 hover:from-red-600 hover:to-rose-600 shadow-red-500/25'
|
||||
: 'from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 shadow-emerald-500/25'}
|
||||
active:scale-95 transition-all shadow-lg"
|
||||
>
|
||||
{autoRunning ? "Dừng" : "Bắt đầu"}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleDrawNext}
|
||||
class="px-10 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-emerald-500 to-teal-500
|
||||
hover:from-emerald-600 hover:to-teal-600
|
||||
active:scale-95 transition-all shadow-lg shadow-emerald-500/25"
|
||||
>
|
||||
Xổ số
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if settings.autoCallEnabled && state && state.remaining.length > 0}
|
||||
<div
|
||||
class="text-center text-xs text-slate-500 dark:text-slate-400 mb-4 tabular-nums"
|
||||
>
|
||||
Tự động: {settings.autoCallSpeed}s/số
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current number -->
|
||||
{#if lastCalled}
|
||||
{@const lastIsLow = lastCalled <= 49}
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-1"
|
||||
>
|
||||
Số vừa xổ
|
||||
</div>
|
||||
<div
|
||||
class="w-24 h-24 sm:w-28 sm:h-28 rounded-full
|
||||
bg-amber-50 dark:bg-amber-100
|
||||
border-[6px] sm:border-[7px]
|
||||
flex items-center justify-center
|
||||
shadow-xl
|
||||
{lastIsLow
|
||||
? 'border-pink-500 dark:border-pink-400 shadow-pink-500/30'
|
||||
: 'border-emerald-500 dark:border-emerald-400 shadow-emerald-500/30'}"
|
||||
>
|
||||
<span
|
||||
class="text-5xl sm:text-6xl font-black tabular-nums
|
||||
{lastIsLow
|
||||
? 'text-pink-500 dark:text-pink-400'
|
||||
: 'text-emerald-500 dark:text-emerald-400'}"
|
||||
>
|
||||
{lastCalled}
|
||||
</span>
|
||||
</div>
|
||||
{#if state}
|
||||
<div class="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
Đã xổ: {state.called.length}/90 · Còn lại: {state.remaining
|
||||
.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Called history -->
|
||||
{#if state && state.called.length > 0}
|
||||
<div class="mb-6 px-1">
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
|
||||
Thứ tự đã xổ:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each state.called as num, i (i)}
|
||||
{@const isLow = num <= 49}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9
|
||||
text-sm sm:text-base font-black tabular-nums rounded-full
|
||||
border-[3px] bg-amber-50 dark:bg-amber-100
|
||||
{isLow
|
||||
? 'border-pink-500 dark:border-pink-400 text-pink-500 dark:text-pink-400'
|
||||
: 'border-emerald-500 dark:border-emerald-400 text-emerald-500 dark:text-emerald-400'}"
|
||||
>
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !state}
|
||||
<div class="text-center text-slate-400 dark:text-slate-500 py-10 text-sm">
|
||||
Nhấn "Ván mới" để bắt đầu
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Master's own playing card -->
|
||||
<div class="mt-10">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="text-lg font-bold text-slate-700 dark:text-slate-200">
|
||||
Bảng của quản trò
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">
|
||||
Quản trò cũng có thể chơi cùng
|
||||
</p>
|
||||
</div>
|
||||
<PlayerBoard storagePrefix="loto_master_card" />
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<footer
|
||||
class="mt-8 text-center text-xs text-slate-500 dark:text-slate-400 space-y-2"
|
||||
>
|
||||
<p class="px-4">
|
||||
Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)
|
||||
</p>
|
||||
<p class="flex items-center justify-center gap-1">
|
||||
Made by
|
||||
<a
|
||||
href="https://miti99.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-indigo-500 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
miti99
|
||||
</a>
|
||||
with
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label="trái tim"
|
||||
class="inline w-3.5 h-3.5 text-red-500"
|
||||
>
|
||||
<path
|
||||
d="M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z"
|
||||
></path>
|
||||
</svg>
|
||||
</p>
|
||||
</footer>
|
||||
@@ -140,9 +140,9 @@
|
||||
// visual segmentation.
|
||||
const SECTIONS = /** @type {const} */ ([0, 3, 6]);
|
||||
const SECTION_LABELS = [
|
||||
"Tân Tân",
|
||||
"An khang thịnh vượng",
|
||||
"Tân Tân tốt nhất",
|
||||
"Lô tô",
|
||||
"TN1 (2014-2017)",
|
||||
"Độc-Đỉnh-Điên",
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
{#if !hasNumber}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="relative aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40"
|
||||
class="relative aspect-square sm:aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40"
|
||||
style:background-color="var(--empty-cell-bg)"
|
||||
></div>
|
||||
{:else}
|
||||
@@ -191,7 +191,8 @@
|
||||
aria-pressed={isCrossed}
|
||||
onclick={() => handleCellClick(row, col)}
|
||||
class="tan-tan-num relative flex items-center justify-center
|
||||
aspect-[3/5] text-2xl sm:text-3xl
|
||||
aspect-square sm:aspect-[3/5]
|
||||
text-base sm:text-2xl md:text-3xl
|
||||
border border-slate-400/50 dark:border-slate-600/40
|
||||
transition-all select-none cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
@@ -208,8 +209,9 @@
|
||||
</div>
|
||||
{/each}
|
||||
<div class="section-label">
|
||||
<span>
|
||||
Made with ❤️ by <a
|
||||
<span class="flex items-center gap-1">
|
||||
Made by
|
||||
<a
|
||||
href="https://miti99.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -217,6 +219,17 @@
|
||||
>
|
||||
miti99
|
||||
</a>
|
||||
with
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label="trái tim"
|
||||
class="inline w-3.5 h-3.5 text-red-500"
|
||||
>
|
||||
<path
|
||||
d="M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
resetSettings,
|
||||
saveSettings,
|
||||
settings,
|
||||
@@ -8,32 +7,65 @@
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Curated swatches: brown (default Minh Tân), warm cream, slate,
|
||||
// amber, emerald, indigo, plum, deep pink — covers the common asks
|
||||
// (paper-feel, neutral, vivid).
|
||||
const PRESETS = [
|
||||
DEFAULT_SETTINGS.emptyCellColor,
|
||||
"#f5e6c8",
|
||||
"#475569",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#6366f1",
|
||||
"#9333ea",
|
||||
"#e11d48",
|
||||
];
|
||||
// Office "Standard Colors" palette (10 swatches). Default is Purple — index 9.
|
||||
const PRESETS = /** @type {const} */ ([
|
||||
"#C00000", // Dark Red
|
||||
"#FF0000", // Red
|
||||
"#FFC000", // Orange
|
||||
"#FFFF00", // Yellow
|
||||
"#92D050", // Light Green
|
||||
"#00B050", // Green
|
||||
"#00B0F0", // Light Blue
|
||||
"#0070C0", // Blue
|
||||
"#002060", // Dark Blue
|
||||
"#7030A0", // Purple (default)
|
||||
]);
|
||||
|
||||
const THEMES = /** @type {const} */ ([
|
||||
["auto", "Tự động"],
|
||||
["light", "Sáng"],
|
||||
["dark", "Tối"],
|
||||
]);
|
||||
|
||||
/** @param {string} hex */
|
||||
function pick(hex) {
|
||||
function pickColor(hex) {
|
||||
settings.emptyCellColor = hex;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function onPickerInput(/** @type {Event} */ e) {
|
||||
const v = /** @type {HTMLInputElement} */ (e.currentTarget).value;
|
||||
settings.emptyCellColor = v;
|
||||
settings.emptyCellColor = /** @type {HTMLInputElement} */ (
|
||||
e.currentTarget
|
||||
).value;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
/** @param {"auto"|"light"|"dark"} t */
|
||||
function pickTheme(t) {
|
||||
settings.theme = t;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleMaster() {
|
||||
settings.masterMode = !settings.masterMode;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleAuto() {
|
||||
settings.autoCallEnabled = !settings.autoCallEnabled;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function onSpeedInput(/** @type {Event} */ e) {
|
||||
const n = Number(
|
||||
/** @type {HTMLInputElement} */ (e.currentTarget).value,
|
||||
);
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 10) {
|
||||
settings.autoCallSpeed = n;
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
@@ -87,16 +119,15 @@
|
||||
aria-labelledby="settings-title"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<!-- Backdrop: click-to-close, hidden from a11y tree (the "Xong" button is the labelled close). -->
|
||||
<!-- Backdrop: click-to-close, hidden from a11y tree. Window-level
|
||||
Escape handler in $effect covers keyboard close. -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onclick={close}
|
||||
onkeydown={(e) => e.key === "Enter" && close()}
|
||||
role="presentation"
|
||||
class="absolute inset-0"
|
||||
></div>
|
||||
<div
|
||||
class="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
|
||||
class="relative mx-4 max-w-sm w-full max-h-[90vh] overflow-y-auto rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
|
||||
>
|
||||
<h2
|
||||
id="settings-title"
|
||||
@@ -108,6 +139,100 @@
|
||||
Tuỳ chỉnh giao diện bảng lô tô
|
||||
</p>
|
||||
|
||||
<!-- Theme -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Giao diện
|
||||
</legend>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each THEMES as [v, label] (v)}
|
||||
{@const selected = settings.theme === v}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pickTheme(v)}
|
||||
class="px-3 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}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Master mode -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Chế độ quản trò
|
||||
</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Hiện bảng quản trò bên dưới bảng người chơi
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.masterMode}
|
||||
onclick={toggleMaster}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{settings.masterMode
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
{settings.masterMode ? "Đang bật" : "Đang tắt"}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<!-- Auto-call (only relevant when master mode is on) -->
|
||||
{#if settings.masterMode}
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
>
|
||||
Tự động xổ
|
||||
</legend>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Tự xổ số liên tiếp khi đang ở chế độ quản trò
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.autoCallEnabled}
|
||||
onclick={toggleAuto}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all mb-3
|
||||
{settings.autoCallEnabled
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
{settings.autoCallEnabled ? "Đang bật" : "Đang tắt"}
|
||||
</button>
|
||||
{#if settings.autoCallEnabled}
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-600 dark:text-slate-300">
|
||||
Tốc độ: <strong class="tabular-nums"
|
||||
>{settings.autoCallSpeed}</strong
|
||||
> giây/số
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value={settings.autoCallSpeed}
|
||||
oninput={onSpeedInput}
|
||||
aria-label="Tốc độ tự động xổ"
|
||||
aria-valuetext="{settings.autoCallSpeed} giây mỗi số"
|
||||
class="w-full mt-1 accent-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<!-- Empty cell color -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
|
||||
@@ -130,14 +255,15 @@
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-8 gap-2">
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each PRESETS as hex (hex)}
|
||||
{@const selected = settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
||||
{@const selected =
|
||||
settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Đặt màu {hex}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => pick(hex)}
|
||||
onclick={() => pickColor(hex)}
|
||||
style:background-color={hex}
|
||||
class="aspect-square rounded-lg border-2 transition-all
|
||||
{selected
|
||||
|
||||
@@ -1,28 +1,61 @@
|
||||
/**
|
||||
* Global UI settings, persisted to localStorage and reactive via Svelte 5
|
||||
* runes. Currently only `emptyCellColor` (paints blank cells across both
|
||||
* the player card and the master tracking grid). Shape is intentionally
|
||||
* an object so future keys can be added without breaking persistence.
|
||||
* runes. Each key is validated independently on load so adding new keys
|
||||
* doesn't break old saved data.
|
||||
*
|
||||
* @module lib/settings-store
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "loto_settings";
|
||||
const HEX6 = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
/** Blue — matches a Tân Tân BAMBOORAFT paper card. */
|
||||
emptyCellColor: "#1e88e5",
|
||||
/** Excel "Standard Color: Purple". */
|
||||
emptyCellColor: "#7030A0",
|
||||
/** "auto" follows OS prefers-color-scheme; "light"/"dark" overrides it. */
|
||||
theme: /** @type {"auto"|"light"|"dark"} */ ("auto"),
|
||||
/** When true, the master panel renders inline below the player board on `/`. */
|
||||
masterMode: false,
|
||||
/** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
|
||||
autoCallEnabled: false,
|
||||
/** Auto-call interval, seconds per number. Integer 1..10. */
|
||||
autoCallSpeed: 5,
|
||||
});
|
||||
|
||||
export const settings = $state({ ...DEFAULT_SETTINGS });
|
||||
|
||||
/** @param {unknown} v */
|
||||
function isValidColor(v) {
|
||||
return typeof v === "string" && HEX6.test(v);
|
||||
function validColor(v) {
|
||||
return typeof v === "string" && HEX6.test(v) ? v : null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validTheme(v) {
|
||||
return typeof v === "string" && VALID_THEMES.includes(/** @type {any} */ (v))
|
||||
? /** @type {"auto"|"light"|"dark"} */ (v)
|
||||
: null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validBool(v) {
|
||||
return typeof v === "boolean" ? v : null;
|
||||
}
|
||||
/** @param {unknown} v */
|
||||
function validSpeed(v) {
|
||||
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 10
|
||||
? v
|
||||
: null;
|
||||
}
|
||||
|
||||
function applyToDom() {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DOM apply */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** @type {MediaQueryList | null} */
|
||||
let mql = null;
|
||||
/** @type {((e: MediaQueryListEvent) => void) | null} */
|
||||
let mqlListener = null;
|
||||
|
||||
function applyEmptyCellColor() {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.style.setProperty(
|
||||
"--empty-cell-bg",
|
||||
@@ -30,21 +63,59 @@ function applyToDom() {
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (typeof document === "undefined") return;
|
||||
// Tear down any previous auto listener before reapplying.
|
||||
if (mql && mqlListener) {
|
||||
mql.removeEventListener("change", mqlListener);
|
||||
mql = null;
|
||||
mqlListener = null;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
const set = (/** @type {boolean} */ dark) =>
|
||||
root.classList.toggle("dark", dark);
|
||||
if (settings.theme === "dark") {
|
||||
set(true);
|
||||
} else if (settings.theme === "light") {
|
||||
set(false);
|
||||
} else {
|
||||
// auto: track the OS preference and re-apply on changes.
|
||||
mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
set(mql.matches);
|
||||
mqlListener = (e) => set(e.matches);
|
||||
mql.addEventListener("change", mqlListener);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAll() {
|
||||
applyEmptyCellColor();
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function loadSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
applyToDom();
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && isValidColor(parsed.emptyCellColor)) {
|
||||
settings.emptyCellColor = parsed.emptyCellColor;
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) ?? {};
|
||||
settings.emptyCellColor =
|
||||
validColor(parsed.emptyCellColor) ?? DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme =
|
||||
validTheme(parsed.theme) ?? DEFAULT_SETTINGS.theme;
|
||||
settings.masterMode =
|
||||
validBool(parsed.masterMode) ?? DEFAULT_SETTINGS.masterMode;
|
||||
settings.autoCallEnabled =
|
||||
validBool(parsed.autoCallEnabled) ?? DEFAULT_SETTINGS.autoCallEnabled;
|
||||
settings.autoCallSpeed =
|
||||
validSpeed(parsed.autoCallSpeed) ?? DEFAULT_SETTINGS.autoCallSpeed;
|
||||
}
|
||||
} catch {
|
||||
/* private mode / quota / corrupt JSON — fall back to defaults */
|
||||
}
|
||||
applyToDom();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
export function saveSettings() {
|
||||
@@ -53,10 +124,14 @@ export function saveSettings() {
|
||||
} catch {
|
||||
/* see loadSettings */
|
||||
}
|
||||
applyToDom();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
settings.emptyCellColor = DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme = DEFAULT_SETTINGS.theme;
|
||||
settings.masterMode = DEFAULT_SETTINGS.masterMode;
|
||||
settings.autoCallEnabled = DEFAULT_SETTINGS.autoCallEnabled;
|
||||
settings.autoCallSpeed = DEFAULT_SETTINGS.autoCallSpeed;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
loadSettings,
|
||||
@@ -10,11 +10,50 @@ import {
|
||||
|
||||
const KEY = "loto_settings";
|
||||
|
||||
/** Minimal MediaQueryList stub. matches=false unless we override. */
|
||||
function mockMatchMedia(matches = false) {
|
||||
/** @type {Set<(e: MediaQueryListEvent) => void>} */
|
||||
const listeners = new Set();
|
||||
const mql = {
|
||||
matches,
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
addEventListener: (/** @type {string} */ _t, fn) => listeners.add(fn),
|
||||
removeEventListener: (/** @type {string} */ _t, fn) => listeners.delete(fn),
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
onchange: null,
|
||||
dispatchEvent: () => true,
|
||||
};
|
||||
/** @param {boolean} newMatches */
|
||||
const fire = (newMatches) => {
|
||||
mql.matches = newMatches;
|
||||
listeners.forEach((fn) =>
|
||||
fn(/** @type {any} */ ({ matches: newMatches })),
|
||||
);
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => mql),
|
||||
);
|
||||
// happy-dom puts matchMedia on window; stub there too for safety.
|
||||
window.matchMedia = /** @type {any} */ (vi.fn(() => mql));
|
||||
return { mql, fire, listenerCount: () => listeners.size };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// Restore in-memory state to defaults between tests so order doesn't matter.
|
||||
settings.emptyCellColor = DEFAULT_SETTINGS.emptyCellColor;
|
||||
for (const k of /** @type {const} */ ([
|
||||
"emptyCellColor",
|
||||
"theme",
|
||||
"masterMode",
|
||||
"autoCallEnabled",
|
||||
"autoCallSpeed",
|
||||
])) {
|
||||
/** @type {any} */ (settings)[k] = /** @type {any} */ (DEFAULT_SETTINGS)[k];
|
||||
}
|
||||
document.documentElement.style.removeProperty("--empty-cell-bg");
|
||||
document.documentElement.classList.remove("dark");
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
describe("settings-store — defaults", () => {
|
||||
@@ -22,12 +61,22 @@ describe("settings-store — defaults", () => {
|
||||
expect(Object.isFrozen(DEFAULT_SETTINGS)).toBe(true);
|
||||
});
|
||||
|
||||
it("default empty-cell color is Tân Tân blue (#1e88e5)", () => {
|
||||
expect(DEFAULT_SETTINGS.emptyCellColor).toBe("#1e88e5");
|
||||
it("default empty-cell color is Excel Standard Purple (#7030A0)", () => {
|
||||
expect(DEFAULT_SETTINGS.emptyCellColor).toBe("#7030A0");
|
||||
});
|
||||
|
||||
it("default theme is auto", () => {
|
||||
expect(DEFAULT_SETTINGS.theme).toBe("auto");
|
||||
});
|
||||
|
||||
it("masterMode + autoCallEnabled default false; autoCallSpeed default 5", () => {
|
||||
expect(DEFAULT_SETTINGS.masterMode).toBe(false);
|
||||
expect(DEFAULT_SETTINGS.autoCallEnabled).toBe(false);
|
||||
expect(DEFAULT_SETTINGS.autoCallSpeed).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-store — loadSettings", () => {
|
||||
describe("settings-store — loadSettings (color)", () => {
|
||||
it("uses defaults when localStorage is empty and applies CSS var", () => {
|
||||
loadSettings();
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
@@ -45,7 +94,7 @@ describe("settings-store — loadSettings", () => {
|
||||
).toBe("#abcdef");
|
||||
});
|
||||
|
||||
it("ignores an invalid stored color and keeps the in-memory default", () => {
|
||||
it("ignores an invalid stored color and keeps the default", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "not-a-color" }));
|
||||
loadSettings();
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
@@ -63,7 +112,7 @@ describe("settings-store — loadSettings", () => {
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
});
|
||||
|
||||
it("rejects 3-digit hex shorthand (#fff is not valid per regex)", () => {
|
||||
it("rejects 3-digit hex shorthand (#fff)", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "#fff" }));
|
||||
loadSettings();
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
@@ -76,14 +125,78 @@ describe("settings-store — loadSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-store — loadSettings (per-key fallback)", () => {
|
||||
it("preserves a single old key (color only) without wiping it", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ emptyCellColor: "#1e88e5" }));
|
||||
loadSettings();
|
||||
expect(settings.emptyCellColor).toBe("#1e88e5");
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
});
|
||||
|
||||
it("loads valid theme values", () => {
|
||||
for (const t of /** @type {const} */ (["auto", "light", "dark"])) {
|
||||
localStorage.setItem(KEY, JSON.stringify({ theme: t }));
|
||||
loadSettings();
|
||||
expect(settings.theme).toBe(t);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default theme on invalid value", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ theme: "neon" }));
|
||||
loadSettings();
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
});
|
||||
|
||||
it("loads boolean masterMode and autoCallEnabled", () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ masterMode: true, autoCallEnabled: true }),
|
||||
);
|
||||
loadSettings();
|
||||
expect(settings.masterMode).toBe(true);
|
||||
expect(settings.autoCallEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-boolean for masterMode", () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ masterMode: "yes" }));
|
||||
loadSettings();
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
});
|
||||
|
||||
it("loads autoCallSpeed in range 1..10", () => {
|
||||
for (const n of [1, 5, 10]) {
|
||||
localStorage.setItem(KEY, JSON.stringify({ autoCallSpeed: n }));
|
||||
loadSettings();
|
||||
expect(settings.autoCallSpeed).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects out-of-range and non-integer autoCallSpeed", () => {
|
||||
for (const bad of [0, 11, 5.5, -1, "5", null]) {
|
||||
localStorage.setItem(KEY, JSON.stringify({ autoCallSpeed: bad }));
|
||||
loadSettings();
|
||||
expect(settings.autoCallSpeed).toBe(DEFAULT_SETTINGS.autoCallSpeed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-store — saveSettings", () => {
|
||||
it("persists current in-memory settings to localStorage", () => {
|
||||
it("persists ALL keys, not just color", () => {
|
||||
settings.emptyCellColor = "#112233";
|
||||
settings.theme = "dark";
|
||||
settings.masterMode = true;
|
||||
settings.autoCallEnabled = true;
|
||||
settings.autoCallSpeed = 7;
|
||||
saveSettings();
|
||||
const raw = localStorage.getItem(KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
expect(JSON.parse(/** @type {string} */ (raw))).toEqual({
|
||||
emptyCellColor: "#112233",
|
||||
theme: "dark",
|
||||
masterMode: true,
|
||||
autoCallEnabled: true,
|
||||
autoCallSpeed: 7,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,13 +210,72 @@ describe("settings-store — saveSettings", () => {
|
||||
});
|
||||
|
||||
describe("settings-store — resetSettings", () => {
|
||||
it("returns settings to defaults and persists the reset", () => {
|
||||
it("returns ALL settings to defaults and persists", () => {
|
||||
settings.emptyCellColor = "#000000";
|
||||
settings.theme = "dark";
|
||||
settings.masterMode = true;
|
||||
saveSettings();
|
||||
resetSettings();
|
||||
expect(settings.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
expect(JSON.parse(/** @type {string} */ (localStorage.getItem(KEY)))).toEqual({
|
||||
emptyCellColor: DEFAULT_SETTINGS.emptyCellColor,
|
||||
});
|
||||
expect(settings.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
expect(settings.masterMode).toBe(DEFAULT_SETTINGS.masterMode);
|
||||
const stored = JSON.parse(
|
||||
/** @type {string} */ (localStorage.getItem(KEY)),
|
||||
);
|
||||
expect(stored.emptyCellColor).toBe(DEFAULT_SETTINGS.emptyCellColor);
|
||||
expect(stored.theme).toBe(DEFAULT_SETTINGS.theme);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-store — applyTheme via load/save", () => {
|
||||
it('theme="light" removes dark class', () => {
|
||||
document.documentElement.classList.add("dark"); // pretend dark was active
|
||||
settings.theme = "light";
|
||||
saveSettings();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it('theme="dark" adds dark class', () => {
|
||||
settings.theme = "dark";
|
||||
saveSettings();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it('theme="auto" mirrors matchMedia.matches=true', () => {
|
||||
mockMatchMedia(true);
|
||||
settings.theme = "auto";
|
||||
saveSettings();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
|
||||
it('theme="auto" mirrors matchMedia.matches=false', () => {
|
||||
mockMatchMedia(false);
|
||||
settings.theme = "auto";
|
||||
saveSettings();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it('theme="auto" re-applies on matchMedia change event', () => {
|
||||
const { fire } = mockMatchMedia(false);
|
||||
settings.theme = "auto";
|
||||
saveSettings();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
fire(true);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
fire(false);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
});
|
||||
|
||||
it('switching auto → dark detaches matchMedia listener', () => {
|
||||
const { fire, listenerCount } = mockMatchMedia(false);
|
||||
settings.theme = "auto";
|
||||
saveSettings();
|
||||
expect(listenerCount()).toBe(1);
|
||||
settings.theme = "dark";
|
||||
saveSettings();
|
||||
expect(listenerCount()).toBe(0);
|
||||
// After detach, OS pref change must NOT toggle the class anymore.
|
||||
fire(false);
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-42
@@ -1,15 +1,16 @@
|
||||
<script>
|
||||
import { base } from "$app/paths";
|
||||
import MasterPanel from "$lib/MasterPanel.svelte";
|
||||
import PageFooter from "$lib/PageFooter.svelte";
|
||||
import PlayerBoard from "$lib/PlayerBoard.svelte";
|
||||
import SettingsButton from "$lib/SettingsButton.svelte";
|
||||
|
||||
let showInstructions = $state(false);
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
|
||||
<div class="w-full max-w-lg">
|
||||
<!-- Header -->
|
||||
<header class="relative text-center mb-8">
|
||||
<div
|
||||
class="flex flex-col flex-1 items-center px-2 py-4 sm:px-3 sm:py-12"
|
||||
>
|
||||
<div class="w-full max-w-2xl">
|
||||
<header class="relative text-center mb-6">
|
||||
<div class="absolute right-0 top-0">
|
||||
<SettingsButton />
|
||||
</div>
|
||||
@@ -18,44 +19,21 @@
|
||||
>
|
||||
Lô tô
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
|
||||
<br class="hidden sm:block" /> của TN1 (2014–2017)
|
||||
</p>
|
||||
<div class="mt-3 flex items-center justify-center gap-3 text-xs">
|
||||
<button
|
||||
onclick={() => (showInstructions = !showInstructions)}
|
||||
class="text-indigo-500 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
{showInstructions ? "Ẩn hướng dẫn" : "Hướng dẫn"}
|
||||
</button>
|
||||
<span class="text-slate-300 dark:text-slate-600">|</span>
|
||||
<a
|
||||
href="{base}/master"
|
||||
class="text-orange-500 dark:text-orange-400 hover:underline"
|
||||
>
|
||||
Trang quản trò →
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showInstructions}
|
||||
<div
|
||||
class="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400"
|
||||
>
|
||||
<ul class="space-y-1 list-disc list-inside">
|
||||
<li>
|
||||
Nhấn <strong class="text-slate-800 dark:text-slate-200"
|
||||
>Tạo bảng mới</strong
|
||||
> để tạo bảng
|
||||
</li>
|
||||
<li>Nhấn vào ô số để đánh dấu khi số được xổ</li>
|
||||
<li>Nhấn lại để bỏ đánh dấu</li>
|
||||
<li>Bảng được lưu tự động</li>
|
||||
</ul>
|
||||
</div>
|
||||
<PlayerBoard />
|
||||
|
||||
{#if settings.masterMode}
|
||||
<section class="mt-10" aria-label="Bảng quản trò">
|
||||
<h2
|
||||
class="text-center text-xl sm:text-2xl font-extrabold tracking-tight mb-4 bg-gradient-to-r from-orange-500 to-red-500 bg-clip-text text-transparent"
|
||||
>
|
||||
Quản trò
|
||||
</h2>
|
||||
<MasterPanel />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<PlayerBoard />
|
||||
<PageFooter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,120 +1,12 @@
|
||||
<script module>
|
||||
const STORAGE_KEY = "loto_master";
|
||||
|
||||
/**
|
||||
* Build the 11x9 board, aligned by last digit so column N row R holds
|
||||
* the number whose tens-digit is N (col 0 = ones, col 8 = eighties/90)
|
||||
* and whose ones-digit is R. Row 0 holds the *0 multiples (10..80),
|
||||
* rows 1-9 hold 1..9, 11..19, ..., 81..89, and row 10 holds 90 alone.
|
||||
* Empty slots are 0.
|
||||
* @returns {number[][]}
|
||||
*/
|
||||
function buildBoard() {
|
||||
/** @type {number[][]} */
|
||||
const board = [];
|
||||
for (let row = 0; row < 11; row++) {
|
||||
/** @type {number[]} */
|
||||
const cells = [];
|
||||
for (let col = 0; col < 9; col++) {
|
||||
let num = 0;
|
||||
if (row === 10) {
|
||||
// Only 90 sits in this trailing row, last column.
|
||||
if (col === 8) num = 90;
|
||||
} else if (row === 0) {
|
||||
// First col has no *0 number in 1-9 range; others get 10, 20, ..., 80.
|
||||
if (col > 0) num = col * 10;
|
||||
} else {
|
||||
// rows 1..9 hold digit `row`: col 0 -> row, col N -> N*10 + row.
|
||||
num = col === 0 ? row : col * 10 + row;
|
||||
}
|
||||
cells.push(num);
|
||||
}
|
||||
board.push(cells);
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
/** @returns {{ called: number[], remaining: number[] }} */
|
||||
function createFreshState() {
|
||||
const all = Array.from({ length: 90 }, (_, i) => i + 1);
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[all[i], all[j]] = [all[j], all[i]];
|
||||
}
|
||||
return { called: [], remaining: all };
|
||||
}
|
||||
|
||||
/** @param {{ called: number[], remaining: number[] }} state */
|
||||
function saveState(state) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row)));
|
||||
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { base } from "$app/paths";
|
||||
import PlayerBoard from "$lib/PlayerBoard.svelte";
|
||||
import MasterPanel from "$lib/MasterPanel.svelte";
|
||||
import PageFooter from "$lib/PageFooter.svelte";
|
||||
import SettingsButton from "$lib/SettingsButton.svelte";
|
||||
|
||||
let state = $state(/** @type {{called: number[], remaining: number[]} | null} */ (null));
|
||||
let lastCalled = $state(/** @type {number | null} */ (null));
|
||||
|
||||
// Load on mount
|
||||
$effect(() => {
|
||||
const saved = loadState();
|
||||
if (saved && saved.called.length > 0) {
|
||||
state = saved;
|
||||
lastCalled = saved.called[saved.called.length - 1];
|
||||
}
|
||||
});
|
||||
|
||||
// Persist on change
|
||||
$effect(() => {
|
||||
if (state) saveState(state);
|
||||
});
|
||||
|
||||
// Map number -> 1-based draw order; lets the master grid show "this
|
||||
// was the Nth call" for fast Kinh! verification.
|
||||
const callOrder = $derived(
|
||||
new Map((state?.called ?? []).map((n, i) => [n, i + 1])),
|
||||
);
|
||||
|
||||
function handleNewGame() {
|
||||
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
state = createFreshState();
|
||||
lastCalled = null;
|
||||
}
|
||||
|
||||
function handleDrawNext() {
|
||||
if (!state || state.remaining.length === 0) return;
|
||||
const next = state.remaining[0];
|
||||
state = {
|
||||
called: [...state.called, next],
|
||||
remaining: state.remaining.slice(1),
|
||||
};
|
||||
lastCalled = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
|
||||
<div class="flex flex-col flex-1 items-center px-2 py-4 sm:px-3 sm:py-12">
|
||||
<div class="w-full max-w-2xl">
|
||||
<!-- Header -->
|
||||
<header class="relative text-center mb-6">
|
||||
<div class="absolute right-0 top-0">
|
||||
<SettingsButton />
|
||||
@@ -135,160 +27,8 @@
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center gap-3 mb-6">
|
||||
<button
|
||||
onclick={handleNewGame}
|
||||
class="px-8 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-orange-500 to-red-500
|
||||
hover:from-orange-600 hover:to-red-600
|
||||
active:scale-95 transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
Ván mới
|
||||
</button>
|
||||
{#if state && state.remaining.length > 0}
|
||||
<button
|
||||
onclick={handleDrawNext}
|
||||
class="px-10 py-4 rounded-full font-semibold text-white text-lg
|
||||
bg-gradient-to-r from-emerald-500 to-teal-500
|
||||
hover:from-emerald-600 hover:to-teal-600
|
||||
active:scale-95 transition-all shadow-lg shadow-emerald-500/25"
|
||||
>
|
||||
Xổ số
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<MasterPanel />
|
||||
|
||||
<!-- Current number -->
|
||||
{#if lastCalled}
|
||||
{@const lastIsLow = lastCalled <= 49}
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-1"
|
||||
>
|
||||
Số vừa xổ
|
||||
</div>
|
||||
<div
|
||||
class="w-24 h-24 sm:w-28 sm:h-28 rounded-full
|
||||
bg-amber-50 dark:bg-amber-100
|
||||
border-[6px] sm:border-[7px]
|
||||
flex items-center justify-center
|
||||
shadow-xl
|
||||
{lastIsLow
|
||||
? 'border-pink-500 dark:border-pink-400 shadow-pink-500/30'
|
||||
: 'border-emerald-500 dark:border-emerald-400 shadow-emerald-500/30'}"
|
||||
>
|
||||
<span
|
||||
class="text-5xl sm:text-6xl font-black tabular-nums
|
||||
{lastIsLow
|
||||
? 'text-pink-500 dark:text-pink-400'
|
||||
: 'text-emerald-500 dark:text-emerald-400'}"
|
||||
>
|
||||
{lastCalled}
|
||||
</span>
|
||||
</div>
|
||||
{#if state}
|
||||
<div class="mt-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
Đã xổ: {state.called.length}/90 · Còn lại: {state.remaining
|
||||
.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Called history -->
|
||||
{#if state && state.called.length > 0}
|
||||
<div class="mb-6 px-1">
|
||||
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
|
||||
Thứ tự đã xổ:
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each state.called as num, i (i)}
|
||||
{@const isLow = num <= 49}
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9
|
||||
text-sm sm:text-base font-black tabular-nums rounded-full
|
||||
border-[3px] bg-amber-50 dark:bg-amber-100
|
||||
{isLow
|
||||
? 'border-pink-500 dark:border-pink-400 text-pink-500 dark:text-pink-400'
|
||||
: 'border-emerald-500 dark:border-emerald-400 text-emerald-500 dark:text-emerald-400'}"
|
||||
>
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Master 9x10 tracking board -->
|
||||
{#if state}
|
||||
<div
|
||||
aria-label="Bảng theo dõi số đã xổ"
|
||||
class="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div class="master-grid">
|
||||
{#each BOARD_FLAT as num, idx (idx)}
|
||||
{@const hasNumber = num > 0}
|
||||
{@const order = hasNumber ? callOrder.get(num) : undefined}
|
||||
{@const isCalled = order !== undefined}
|
||||
{@const isLast = isCalled && num === lastCalled}
|
||||
{@const isLow = hasNumber && num <= 49}
|
||||
<div
|
||||
style:background-color={hasNumber ? null : "var(--empty-cell-bg)"}
|
||||
class="relative flex items-center justify-center
|
||||
aspect-square
|
||||
border-r border-b border-slate-200/80 dark:border-slate-700/60
|
||||
bg-white dark:bg-slate-800 select-none
|
||||
{isLast ? 'z-10' : ''}"
|
||||
>
|
||||
{#if hasNumber}
|
||||
<!-- Token: cream inner when called (pink ≤49 / green ≥50 ring),
|
||||
gray-ringed and dim when uncalled. -->
|
||||
<div
|
||||
class="flex items-center justify-center
|
||||
w-[82%] h-[82%] rounded-full
|
||||
text-lg sm:text-xl font-black tabular-nums
|
||||
border-[3px] transition-all
|
||||
{!isCalled
|
||||
? 'border-slate-300 dark:border-slate-600 bg-slate-50/40 dark:bg-slate-700/30 text-slate-400 dark:text-slate-500 opacity-70'
|
||||
: isLow
|
||||
? 'border-pink-500 dark:border-pink-400 bg-amber-50 dark:bg-amber-100 text-pink-500 dark:text-pink-400'
|
||||
: 'border-emerald-500 dark:border-emerald-400 bg-amber-50 dark:bg-amber-100 text-emerald-500 dark:text-emerald-400'}
|
||||
{isLast
|
||||
? 'ring-2 ring-red-500 dark:ring-red-400 ring-offset-1 ring-offset-white dark:ring-offset-slate-800 scale-110 shadow-md'
|
||||
: ''}"
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
{#if isCalled}
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 text-[9px] sm:text-[10px] font-semibold leading-none text-slate-500 dark:text-slate-400 tabular-nums"
|
||||
>
|
||||
{order}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
|
||||
Nhấn "Ván mới" để bắt đầu
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Master's own playing card -->
|
||||
<div class="mt-10">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="text-lg font-bold text-slate-700 dark:text-slate-200">
|
||||
Bảng của quản trò
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">
|
||||
Quản trò cũng có thể chơi cùng
|
||||
</p>
|
||||
</div>
|
||||
<PlayerBoard storagePrefix="loto_master_card" />
|
||||
</div>
|
||||
<PageFooter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user