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
+
+```
+
+`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 `
Made by miti99
+with ❤️ ...
`. 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
+
+ ```
+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 `
`, 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`:
+- `
+
+
+```
+
+(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
+
+ ```
+
+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 `