From 0bc27f2897465d541bedcb433b059a206bf2e9ff Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 27 Apr 2026 01:26:21 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20big=20bundle=20=E2=80=94=20settings?= =?UTF-8?q?=20(theme/master/auto),=20purple=20default,=20mobile=20fit,=20m?= =?UTF-8?q?aster=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- web/docs/code-standards.md | 9 +- web/docs/codebase-summary.md | 32 +- web/docs/development-roadmap.md | 17 +- web/docs/project-overview-pdr.md | 22 +- web/docs/system-architecture.md | 30 +- .../phase-01-settings-expansion.md | 124 ++++++++ .../phase-02-theme-system.md | 135 +++++++++ .../phase-03-mobile-and-header-footer.md | 123 ++++++++ .../phase-04-extract-master-panel.md | 89 ++++++ .../phase-05-master-mode-integration.md | 101 +++++++ .../phase-06-auto-call.md | 133 ++++++++ .../plan.md | 80 +++++ ...ode-reviewer-260427-0113-session-review.md | 176 +++++++++++ .../tester-260427-0113-test-validation.md | 283 ++++++++++++++++++ web/src/app.css | 35 ++- web/src/lib/MasterPanel.svelte | 223 ++++++++++++++ web/src/lib/PageFooter.svelte | 29 ++ web/src/lib/PlayerBoard.svelte | 27 +- web/src/lib/SettingsButton.svelte | 174 +++++++++-- web/src/lib/settings-store.svelte.js | 109 +++++-- web/src/lib/settings-store.test.js | 198 +++++++++++- web/src/routes/+page.svelte | 62 ++-- web/src/routes/master/+page.svelte | 270 +---------------- 23 files changed, 2056 insertions(+), 425 deletions(-) create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-01-settings-expansion.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-02-theme-system.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-03-mobile-and-header-footer.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-04-extract-master-panel.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-05-master-mode-integration.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-06-auto-call.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/plan.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/reports/code-reviewer-260427-0113-session-review.md create mode 100644 web/plans/260427-0100-master-merge-theme-auto-mobile-fit/reports/tester-260427-0113-test-validation.md create mode 100644 web/src/lib/MasterPanel.svelte create mode 100644 web/src/lib/PageFooter.svelte diff --git a/web/docs/code-standards.md b/web/docs/code-standards.md index afbb20a..f72e2e6 100644 --- a/web/docs/code-standards.md +++ b/web/docs/code-standards.md @@ -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 `` 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) diff --git a/web/docs/codebase-summary.md b/web/docs/codebase-summary.md index 7067312..8eec0a3 100644 --- a/web/docs/codebase-summary.md +++ b/web/docs/codebase-summary.md @@ -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 `` 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) diff --git a/web/docs/development-roadmap.md b/web/docs/development-roadmap.md index 36e8c3b..3632c07 100644 --- a/web/docs/development-roadmap.md +++ b/web/docs/development-roadmap.md @@ -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) diff --git a/web/docs/project-overview-pdr.md b/web/docs/project-overview-pdr.md index 6cdc058..0e331db 100644 --- a/web/docs/project-overview-pdr.md +++ b/web/docs/project-overview-pdr.md @@ -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 ``. +- [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) diff --git a/web/docs/system-architecture.md b/web/docs/system-architecture.md index 9b2218a..d76f557 100644 --- a/web/docs/system-architecture.md +++ b/web/docs/system-architecture.md @@ -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 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) diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-01-settings-expansion.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-01-settings-expansion.md new file mode 100644 index 0000000..b86c8f3 --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-01-settings-expansion.md @@ -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`. diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-02-theme-system.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-02-theme-system.md new file mode 100644 index 0000000..f4b2f4b --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-02-theme-system.md @@ -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 `` 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 ``. +- `"dark"` → add `dark` class to ``. +- `"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 +
+ Giao diện +
+ {#each [["auto","Tự động"],["light","Sáng"],["dark","Tối"]] as [v, label]} + + {/each} +
+
+``` + +`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 `` 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 ``. 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). diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-03-mobile-and-header-footer.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-03-mobile-and-header-footer.md new file mode 100644 index 0000000..e2f34a4 --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-03-mobile-and-header-footer.md @@ -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 miti99 with ] +``` +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 ``. 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 `

`, instructions toggle/panel, "Trang quản trò" link. Mount `` after ``. Tighten page padding. | +| `src/routes/master/+page.svelte` | Mount `` 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 +

+

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)

+

+ Made by + miti99 + with + + + +

+
+ ``` +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 `` block. +3. **/+page.svelte**: + - Remove `let showInstructions = $state(false)`, the tagline `

`, 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 `` after ``. + - Adjust wrapper to `px-2 py-4 sm:px-3 sm:py-12`. +4. **/master/+page.svelte**: import + mount ``. (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). diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-04-extract-master-panel.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-04-extract-master-panel.md new file mode 100644 index 0000000..f71e3a4 --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-04-extract-master-panel.md @@ -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`: +- ` + +

+
+
+
+

Quản trò

+ ← Về trang người chơi +
+ + +
+
+``` + +(Phase 5 will further re-shape this route.) + +## Implementation Steps + +1. Create `src/lib/MasterPanel.svelte`. Copy: + - Entire ` + + + + {#if settings.masterMode} +
+

+ Quản trò +

+ +
+ {/if} + + + ``` + +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 +
+ Chế độ quản trò +

+ Hiện bảng quản trò bên dưới bảng người chơi +

+ +
+ ``` + +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. diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-06-auto-call.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-06-auto-call.md new file mode 100644 index 0000000..4f57591 --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/phase-06-auto-call.md @@ -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} + + {:else if state && state.remaining.length > 0} + + {/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 `` 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". diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/plan.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/plan.md new file mode 100644 index 0000000..b7b9d35 --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/plan.md @@ -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 `` + 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 diff --git a/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/reports/code-reviewer-260427-0113-session-review.md b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/reports/code-reviewer-260427-0113-session-review.md new file mode 100644 index 0000000..fc0a95a --- /dev/null +++ b/web/plans/260427-0100-master-merge-theme-auto-mobile-fit/reports/code-reviewer-260427-0113-session-review.md @@ -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 `` is wrapped by `