feat(settings): empty-cell color picker with persisted store

Adds a gear button + modal on /, /master with a native color picker
and 8 preset swatches for the empty-cell background. The selected
color repaints both the player card AND the master tracking grid via
the --empty-cell-bg CSS variable. Default brown matches the physical
Minh Tân paper card.

settings-store.svelte.js: Svelte 5 rune-based reactive store, state
hydrated on layout mount, persisted to localStorage key loto_settings.
Validates hex (#rrggbb regex) before applying — keeps the CSS sink
safe and ignores corrupt or shorthand-3-digit payloads.

SettingsButton.svelte: gear icon, modal with picker + presets + reset
+ close, escape-to-close via window keydown listener (the trigger
button retains focus on open, so a dialog-scoped handler would miss).

eslint.config.mjs: declares Svelte 5 rune globals so .svelte.js stores
lint clean.

Docs synced: PDR, codebase-summary, system-architecture, roadmap.
This commit is contained in:
tiennm99 committed 2026-04-27 00:13:39 +07:00
1 parent 947585bfaf
commit 839afd9201
10 files changed
+307 -24

No files matched your search

+13 -4
View File
@@ -12,25 +12,33 @@
### Shared Components
| File | Purpose |
|------|---------|
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. |
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Minh Tân / Loại đặc biệt / Tấn tài tấn lộc). 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. |
### 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`. |
### 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. |
### 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. |
### Configuration
| File | Purpose |
|------|---------|
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env. |
| `vite.config.js` | Tailwind + SvelteKit plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite. Scripts: dev, dev:codeserver, build, build:gh, lint. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals (lines 16–22). |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
@@ -50,6 +58,7 @@
| `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" }`. |
## Component Hierarchy
@@ -76,4 +85,4 @@ RootLayout
| `handleCellClick()` | PlayerBoard.svelte | Toggle crossed[row][col]. |
| `saveGrid()` / `loadGrid()` | game-logic.js | localStorage with prefix-based keys. |
Last reviewed: 2026-04-26
Last reviewed: 2026-04-27
+11 -10
View File
@@ -13,11 +13,17 @@ The app is fully functional for core gameplay (Lô tô hội chợ Tân Tân var
- Host number drawing from 1–90 deck
- 11×9 last-digit-aligned master board with draw-order overlay for fast
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`
- 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
## Idea Phase
@@ -49,15 +55,9 @@ Internationalization (English, Chinese, etc.). Requires extraction of all Vietna
### Undo/Redo System
Full undo/redo stack with history navigation. Adds complexity to state management. **Status**: Considered (YAGNI for now)
## Testing (Planned but Unstarted)
## Testing
### Unit Tests
- Game logic: `generateGrid()`, `isRowComplete()`, `getWaitingNumber()`
- localStorage helpers: `saveGrid()`, `loadGrid()`, etc.
**Tech**: Vitest (Vite's native test runner)
### Component Tests
### Component Tests (Planned)
- PlayerBoard with mocked localStorage, $state/$derived verification
- Master page with different game states
@@ -96,10 +96,11 @@ European Bingo 90 patterns. **Out of scope** — Tân Tân uses single-line "Kin
All decisions follow **YAGNI** (You Aren't Gonna Need It), **KISS** (Keep It Simple), **DRY** (Don't Repeat Yourself):
- No multiplayer sync → adds server dependency, breaks static export (Cloudflare Pages)
- No i18n → Vietnamese-only community, localizing adds complexity
- No testing → small codebase (<300 LOC), manual testing covers critical paths; add when code grows
- Unit tests implemented → critical paths (constraints, persistence, settings) now have automated coverage
- Component/E2E tests deferred → small codebase, manual testing sufficient for UI flows
- No undo/redo → simple game, mistakes are play experience
- Svelte 5 runes → cleaner reactivity than hooks, smaller mental model
Future work gates on **real user demand**, not speculation.
Last reviewed: 2026-04-26
Last reviewed: 2026-04-27
+8
View File
@@ -15,6 +15,9 @@ That format is intentionally out of scope.
### In scope (Lô tô hội chợ Tân Tân)
- **Card**: 9 rows × 9 columns. Exactly **5 numbers per row** AND exactly
**5 numbers per column** (45 numbers per card; 36 blanks).
- **Visual layout**: card displayed as 3 stacked 3×9 mini-cards with
traditional separator labels — *Minh Tân*, *Loại đặc biệt*,
*Tấn tài tấn lộc* — matching the physical Minh Tân paper sheet.
- **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.
@@ -24,6 +27,9 @@ That format is intentionally out of scope.
- **Waiting state**: when a row needs only 1 number, app announces **"Chờ N"**.
- **Host (quản trò)** draws numbers from a shuffled 1–90 deck, tracks them on
a master board, 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).
Persisted to `loto_settings`.
### Out of scope
- 3×9 / 15-number Bingo 90 / Tombola tickets (European format).
@@ -85,6 +91,8 @@ State is entirely client-side. Each page/card instance uses a unique localStorag
- [x] Dark mode support (Tailwind dark classes).
- [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.
## Visual Language
+8 -6
View File
@@ -3,19 +3,21 @@
## Page Flow
```
Entry
Entry (+layout.svelte)
├─ onMount: loadSettings() — restore emptyCellColor from loto_settings,
│ apply to --empty-cell-bg CSS variable before pages render
│
├─ / (Player Page)
│ ├─ Load loto_grid, loto_crossed from localStorage
│ ├─ Display 9×9 grid
│ ├─ Display 9×9 grid (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
│
└─ /master (Host Page)
├─ Load loto_master (called/remaining)
├─ Display 11×9 master board (numbers 1–90, aligned by ones-digit;
│ col 0 = 1–9, col 8 = 80–90; each called cell shows draw order)
├─ Draw button shows next called number (highlighted in red on board)
├─ 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
```
@@ -131,4 +133,4 @@ Files that are client-only:
All state is localStorage. No API calls. Fully functional offline after initial load.
Last reviewed: 2026-04-26
Last reviewed: 2026-04-27
+12 -1
View File
@@ -9,7 +9,18 @@ export default [
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: { ...globals.browser, ...globals.node },
globals: {
...globals.browser,
...globals.node,
// Svelte 5 runes — usable inside .svelte and .svelte.js files.
$state: "readonly",
$derived: "readonly",
$effect: "readonly",
$props: "readonly",
$bindable: "readonly",
$inspect: "readonly",
$host: "readonly",
},
},
},
{
+174
View File
@@ -0,0 +1,174 @@
<script>
import {
DEFAULT_SETTINGS,
resetSettings,
saveSettings,
settings,
} from "$lib/settings-store.svelte.js";
let open = $state(false);
// Curated swatches: brown (default Minh Tân), warm cream, slate,
// amber, emerald, indigo, plum, deep pink — covers the common asks
// (paper-feel, neutral, vivid).
const PRESETS = [
DEFAULT_SETTINGS.emptyCellColor,
"#f5e6c8",
"#475569",
"#f59e0b",
"#10b981",
"#6366f1",
"#9333ea",
"#e11d48",
];
/** @param {string} hex */
function pick(hex) {
settings.emptyCellColor = hex;
saveSettings();
}
function onPickerInput(/** @type {Event} */ e) {
const v = /** @type {HTMLInputElement} */ (e.currentTarget).value;
settings.emptyCellColor = v;
saveSettings();
}
function close() {
open = false;
}
// Escape-to-close while open. Window listener avoids relying on focus
// staying inside the modal — the dialog div may not receive keydown
// when the trigger button still holds focus on initial open.
$effect(() => {
if (!open) return;
/** @param {KeyboardEvent} e */
const onKey = (e) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
</script>
<button
type="button"
aria-label="Cài đặt"
title="Cài đặt"
onclick={() => (open = true)}
class="inline-flex items-center justify-center w-9 h-9 rounded-full
text-slate-500 dark:text-slate-400
hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors
focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
{#if open}
<div
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
>
<!-- Backdrop: click-to-close, hidden from a11y tree (the "Xong" button is the labelled close). -->
<div
aria-hidden="true"
onclick={close}
onkeydown={(e) => e.key === "Enter" && close()}
role="presentation"
class="absolute inset-0"
></div>
<div
class="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
>
<h2
id="settings-title"
class="text-xl font-bold text-slate-800 dark:text-slate-100 mb-1"
>
Cài đặt
</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5">
Tuỳ chỉnh giao diện bảng lô tô
</p>
<fieldset class="mb-5">
<legend
class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-2"
>
Màu ô trống
</legend>
<div class="flex items-center gap-3 mb-3">
<input
type="color"
aria-label="Chọn màu tuỳ ý"
value={settings.emptyCellColor}
oninput={onPickerInput}
class="w-12 h-10 rounded-lg border border-slate-300 dark:border-slate-600 cursor-pointer bg-transparent"
/>
<code
class="text-xs font-mono text-slate-500 dark:text-slate-400 tabular-nums"
>
{settings.emptyCellColor}
</code>
</div>
<div class="grid grid-cols-8 gap-2">
{#each PRESETS as hex (hex)}
{@const selected = settings.emptyCellColor.toLowerCase() === hex.toLowerCase()}
<button
type="button"
aria-label="Đặt màu {hex}"
aria-pressed={selected}
onclick={() => pick(hex)}
style:background-color={hex}
class="aspect-square rounded-lg border-2 transition-all
{selected
? 'border-indigo-500 dark:border-indigo-400 ring-2 ring-indigo-300 dark:ring-indigo-600 scale-110'
: 'border-slate-200 dark:border-slate-600 hover:scale-105'}"
></button>
{/each}
</div>
</fieldset>
<div class="flex justify-between gap-2">
<button
type="button"
onclick={() => resetSettings()}
class="px-4 py-2 rounded-full text-sm font-medium
text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
Mặc định
</button>
<button
type="button"
onclick={close}
class="px-6 py-2 rounded-full text-sm font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Xong
</button>
</div>
</div>
</div>
{/if}
+62
View File
@@ -0,0 +1,62 @@
/**
* Global UI settings, persisted to localStorage and reactive via Svelte 5
* runes. Currently only `emptyCellColor` (paints blank cells across both
* the player card and the master tracking grid). Shape is intentionally
* an object so future keys can be added without breaking persistence.
*
* @module lib/settings-store
*/
const STORAGE_KEY = "loto_settings";
const HEX6 = /^#[0-9a-fA-F]{6}$/;
export const DEFAULT_SETTINGS = Object.freeze({
/** Brown — matches a Minh Tân paper card. */
emptyCellColor: "#7a4a2b",
});
export const settings = $state({ ...DEFAULT_SETTINGS });
/** @param {unknown} v */
function isValidColor(v) {
return typeof v === "string" && HEX6.test(v);
}
function applyToDom() {
if (typeof document === "undefined") return;
document.documentElement.style.setProperty(
"--empty-cell-bg",
settings.emptyCellColor,
);
}
export function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
applyToDom();
return;
}
const parsed = JSON.parse(raw);
if (parsed && isValidColor(parsed.emptyCellColor)) {
settings.emptyCellColor = parsed.emptyCellColor;
}
} catch {
/* private mode / quota / corrupt JSON — fall back to defaults */
}
applyToDom();
}
export function saveSettings() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...settings }));
} catch {
/* see loadSettings */
}
applyToDom();
}
export function resetSettings() {
settings.emptyCellColor = DEFAULT_SETTINGS.emptyCellColor;
saveSettings();
}
+7
View File
@@ -1,7 +1,14 @@
<script>
import { onMount } from "svelte";
import "../app.css";
import { loadSettings } from "$lib/settings-store.svelte.js";
let { children } = $props();
// Hydrate settings once so the empty-cell color is applied before
// either page renders its grid. onMount (not $effect) makes the
// single-shot intent explicit.
onMount(loadSettings);
</script>
{@render children()}
+5 -1
View File
@@ -1,6 +1,7 @@
<script>
import { base } from "$app/paths";
import PlayerBoard from "$lib/PlayerBoard.svelte";
import SettingsButton from "$lib/SettingsButton.svelte";
let showInstructions = $state(false);
</script>
@@ -8,7 +9,10 @@
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-lg">
<!-- Header -->
<header class="text-center mb-8">
<header class="relative text-center mb-8">
<div class="absolute right-0 top-0">
<SettingsButton />
</div>
<h1
class="text-4xl sm:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent"
>
+7 -2
View File
@@ -70,6 +70,7 @@
<script>
import { base } from "$app/paths";
import PlayerBoard from "$lib/PlayerBoard.svelte";
import SettingsButton from "$lib/SettingsButton.svelte";
let state = $state(/** @type {{called: number[], remaining: number[]} | null} */ (null));
let lastCalled = $state(/** @type {number | null} */ (null));
@@ -114,7 +115,10 @@
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-2xl">
<!-- Header -->
<header class="text-center mb-6">
<header class="relative text-center mb-6">
<div class="absolute right-0 top-0">
<SettingsButton />
</div>
<h1
class="text-3xl sm:text-4xl font-extrabold tracking-tight bg-gradient-to-r from-orange-500 to-red-500 bg-clip-text text-transparent"
>
@@ -210,6 +214,7 @@
{@const isCalled = order !== undefined}
{@const isLast = isCalled && num === lastCalled}
<div
style:background-color={hasNumber ? null : "var(--empty-cell-bg)"}
class="relative flex items-center justify-center
aspect-square text-sm sm:text-base font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
@@ -220,7 +225,7 @@
? 'bg-red-500 dark:bg-red-600 text-white ring-2 ring-red-300 dark:ring-red-400 ring-inset z-10'
: 'bg-orange-500 dark:bg-orange-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-900/60'}"
: ''}"
>
{hasNumber ? num : ""}
{#if isCalled}