diff --git a/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-01-three-section-player-board.md b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-01-three-section-player-board.md new file mode 100644 index 0000000..910fa6e --- /dev/null +++ b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-01-three-section-player-board.md @@ -0,0 +1,156 @@ +# Phase 1 — 3-Section Visual Split for PlayerBoard + +## Context + +- Plan: [plan.md](plan.md) +- Reference image: physical Minh Tân lô tô card (3 stacked 3×9 mini-cards on + one sheet, brown empty cells, decorative cross-hatch separators). +- Touches: `src/lib/PlayerBoard.svelte`, `src/app.css`. + +## Overview + +- **Priority**: P1 (visual identity / authenticity) +- **Status**: TODO +- **Effort**: ~30 min +- **Description**: Render the existing 9×9 grid as 3 visually distinct 3-row + sections. Data, generator, click handlers, win detection: unchanged. + +## Key Insights + +- The 9×9 → 3×(3×9) split is **purely visual**. Don't touch + `src/lib/game-logic.js`. Don't change the grid shape, the storage shape, + or `isRowComplete` — they all keep operating on a 9×9 array. +- Tân Tân section labels (top to bottom): **Minh Tân** / **Loại đặc biệt** + / **Tấn tài tấn lộc**. Use these verbatim. +- The decorative cross-hatch border (✚✚✚✚) on the physical card is + ornamental. CSS approximation: a thin orange/red dotted or repeating + cross-pattern border between sections is enough — don't pixel-copy. +- Card serial number badge (e.g., "25" on the physical card) is **out of + scope** per plan. + +## Requirements + +### Functional +- Player card renders as 3 stacked sub-grids: rows 0–2, rows 3–5, rows 6–8. +- A label sits between each pair of sub-grids (and above the first): + `Minh Tân`, `Loại đặc biệt`, `Tấn tài tấn lộc`. +- All click / cross / waiting / Kinh behavior keeps working unchanged. +- Cell crossed style, row-complete style, the `Chờ N` toast, and the Kinh + modal all behave exactly as before. + +### Non-functional +- No layout shift or visual jitter. +- Mobile responsive (stays within `max-w-2xl` container). +- Dark mode parity: separator and labels visible on both themes. +- A11y: each sub-grid keeps `aria-label="Bảng lô tô"` (or section-specific + label like `Bảng lô tô — phần 1`). + +## Architecture + +### Render strategy + +Replace the single `
` with three sibling +`
` blocks, each iterating only its 3 rows. Use +`{#each [0, 3, 6] as startRow, sectionIdx}` and within each section iterate +`grid.slice(startRow, startRow + 3)`. + +The `crossed` lookup must use the **absolute row index** (`startRow + r`), +not the slice-relative index. Same for click handlers and `rowCompleteness`. + +### Section labels + +Plain centered text (`text-center text-xs uppercase tracking-widest text-slate-500`) +between sections, sandwiched by horizontal cross-hatch lines. Use a +repeating linear-gradient or a `.section-divider` utility in `app.css`. + +### Border continuity + +Currently each cell has `border-r border-b`. Within a section this is fine. +Across sections we **don't** want the bottom row of section 1 to look +adjacent to the top row of section 2 — the separator label / divider sits +between them, so the visual gap naturally breaks the grid. + +The outer wrapper (`rounded-2xl overflow-hidden shadow-xl border …`) +currently wraps the whole grid. Two choices: +- **A**: keep one outer wrapper, separators are inside it. +- **B**: each section has its own rounded card. + +**Pick A** — one outer wrapper, internal separators. Closer to the +physical sheet which is one continuous piece of paper. + +## Related Code Files + +### Modify +- `src/lib/PlayerBoard.svelte` + - Replace the single `{#each grid.flat() …}` loop with 3 sectioned loops + (lines ~157–191 in current state). + - Each section iterates `[0, 3, 6][i] .. + 3` and uses absolute row idx + when reading `crossed[row]`, calling `handleCellClick(row, col)`, + reading `rowCompleteness[row]`. + - Add the section header text between sections. +- `src/app.css` + - Add `.section-divider` utility (cross-hatch / dotted horizontal rule). + - Optionally add `.section-label` if Tailwind utility classes don't + cover the styling cleanly. + +### Don't touch +- `src/lib/game-logic.js` +- `src/routes/master/+page.svelte` (master grid stays 11×9) +- `src/routes/+page.svelte` + +## Implementation Steps + +1. In `PlayerBoard.svelte`, extract a `SECTIONS = [0, 3, 6]` constant and a + parallel `SECTION_LABELS = ['Minh Tân', 'Loại đặc biệt', 'Tấn tài tấn lộc']`. +2. Wrap the existing grid block with a flex column. Inside, loop sections: + for each `startRow`, render the label, then a `loto-grid` containing + that section's 3 rows × 9 cols. +3. Inside the inner loop, compute `row = startRow + r`. Pass `row` to + `handleCellClick`, look up `crossed[row]?.[col]`, `rowCompleteness[row]`. +4. Add the `Chờ` toast container *outside* the sectioned grid (it + currently uses `absolute inset-0` of the outer relative wrapper — + keep that wrapper around all 3 sections). +5. In `app.css`, define `.section-divider` (e.g. + `background: repeating-linear-gradient(90deg, transparent 0 8px, theme(colors.orange.400) 8px 10px); height: 6px;`). +6. Run `npx svelte-check`, then test in dev: generate card, mark cells, + trigger Kinh, trigger Chờ. All should work. + +## Todo + +- [ ] Add `SECTIONS` + `SECTION_LABELS` constants to PlayerBoard.svelte +- [ ] Replace flat grid render with 3 sectioned `loto-grid` blocks +- [ ] Verify absolute-row-index math (click, crossed lookup, rowCompleteness) +- [ ] Add label markup between sections +- [ ] Add `.section-divider` style in `app.css` +- [ ] Verify Chờ toast still appears centered over the whole card +- [ ] Verify Kinh modal still triggers +- [ ] Verify dark-mode contrast for labels and dividers +- [ ] Mobile breakpoint check (sm + base) +- [ ] `npx svelte-check` clean + +## Success Criteria + +- Player page shows 3 visually separated 3×9 mini-cards with the 3 labels. +- Generating a new card, marking cells, and winning all behave identically + to before this change. +- No svelte-check warnings. +- Dark mode looks intentional (not just light mode in the dark). + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Off-by-one in absolute row index | Medium | High (broken click handling) | Compute `row = startRow + r` once at the top of the inner each, reuse everywhere. | +| Toast positioning breaks | Low | Low | Toast container stays at the same wrapper level it's at today. | +| Border doubling at section seams | Low | Cosmetic | Last row of each section keeps its own `border-b`; separator visually masks it. | +| `aspect-square` cells distort with new container | Low | Cosmetic | grid-template-columns is still `repeat(9, 1fr)` per section, same width budget per section. | + +## Security Considerations + +None — view-only change. + +## Next Steps + +After this phase: Phase 2 (settings + color picker) plugs the +configurable color into `bg-slate-50 dark:bg-slate-900/60` empty cells +(replaced by a CSS variable). diff --git a/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-02-settings-color-picker.md b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-02-settings-color-picker.md new file mode 100644 index 0000000..d7c4143 --- /dev/null +++ b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/phase-02-settings-color-picker.md @@ -0,0 +1,227 @@ +# Phase 2 — Settings Modal + Empty-Cell Color Picker + +## Context + +- Plan: [plan.md](plan.md) +- Depends on Phase 1 only loosely (works either way; better when Phase 1 + shipped because the section background also picks up the color). +- Touches: new `settings-store.svelte.js` + `SettingsButton.svelte`, + edits in `PlayerBoard.svelte`, `master/+page.svelte`, `+page.svelte`, + `app.css`. + +## Overview + +- **Priority**: P1 (user-requested feature) +- **Status**: TODO +- **Effort**: ~45 min +- **Description**: Gear icon → modal panel with a color picker. Selected + color paints empty/blank cells across player card *and* master tracking + grid. Persists in localStorage. Default: brown (matches physical Tân Tân). + +## Key Insights + +- One **global** color, not per-card. Simpler, matches physical sheet + (one paper color). +- Use **CSS custom property** (`--empty-cell-bg`) on `:root` so a single + store update repaints every empty cell with no per-component prop drilling. +- Settings store goes in `src/lib/settings-store.svelte.js` — the + `.svelte.js` extension lets it use Svelte 5 runes (`$state`) at module + scope. Components import the reactive `settings` object. +- The color picker can be a **native** `` to avoid + pulling in a UI library. Add a few **preset swatches** (brown, slate, + amber, emerald, indigo, neutral white) for one-tap defaults. +- Keep the modal a11y-clean: `role="dialog"`, `aria-modal="true"`, + `Escape` to close, focus trap-ish (focus the close button on open). + +## Requirements + +### Functional +- A gear button (⚙) sits in the player page header (`/`) and master page + header (`/master`). +- Clicking the gear opens a modal panel. +- Modal contains: + - A native color picker (``) bound to the current + color. + - 6 preset swatches as quick-pick buttons. + - A "Reset to default" button. + - A "Close" button. +- Color change is **live** — empty cells repaint as user picks. +- Setting persists in localStorage under key `loto_settings`. +- Default color: a brown matching the physical card (`#7a4a2b` or close). + +### Non-functional +- No new third-party deps. +- Settings store must support adding more keys later (extensible shape). +- Works offline (no network). +- Dark mode: the picker UI itself must be readable in dark mode; the + user-chosen color obviously stays as-is. + +## Architecture + +### Store shape + +```js +// settings-store.svelte.js +export const DEFAULT_SETTINGS = Object.freeze({ + emptyCellColor: '#7a4a2b', // brown — matches physical Minh Tân card +}); + +const STORAGE_KEY = 'loto_settings'; + +export const settings = $state({ ...DEFAULT_SETTINGS }); + +// load + persist helpers +export function loadSettings() { /* read localStorage, merge into settings */ } +export function saveSettings() { /* write current settings */ } +export function resetSettings() { /* reset to DEFAULT_SETTINGS */ } +``` + +A root effect (set up once in `+layout.svelte` or in each page that +imports the store) calls `loadSettings()` on mount and persists on change. +Simpler: do it inside the store module via a top-level `$effect.root` in +the `.svelte.js` file — Svelte 5 supports this. + +### CSS variable wiring + +In `app.css`, define a default: +```css +:root { --empty-cell-bg: #7a4a2b; } +``` + +Bind it from the store at the page level (whichever component first +mounts the store): +```svelte +
...
+``` + +OR set it directly on `document.documentElement` in `loadSettings()` / +on store change via an effect. The latter avoids prop drilling. + +### Empty-cell consumption + +Replace existing empty-cell backgrounds: + +| File | Current | New | +|---|---|---| +| `PlayerBoard.svelte` (empty cell) | `bg-slate-50 dark:bg-slate-900/60` | `style:background-color="var(--empty-cell-bg)"` | +| `master/+page.svelte` (empty cell, `!hasNumber`) | `bg-slate-100 dark:bg-slate-900/60` | same | + +Filled cells keep their existing styles. The chosen color only repaints +**`hasNumber === false`** cells. + +### Settings button placement + +A small floating gear button in the top-right of each page's header +section. Reuse the same `SettingsButton.svelte` component on both pages. + +### Modal + +`SettingsButton.svelte` owns its own `open` state. When open, renders the +modal as a sibling overlay. Same dismiss patterns as the existing Kinh +modal in `PlayerBoard.svelte` (backdrop click + Escape). + +## Related Code Files + +### Create +- `src/lib/settings-store.svelte.js` — rune-based reactive store + + load/save/reset. ~50 LOC. +- `src/lib/SettingsButton.svelte` — gear button + modal + color picker + + 6 preset swatches + reset/close. ~80 LOC. + +### Modify +- `src/lib/PlayerBoard.svelte` — empty-cell background uses CSS var. +- `src/routes/master/+page.svelte` — empty-cell background uses CSS var; + mount `` in header. +- `src/routes/+page.svelte` — mount `` in header. +- `src/app.css` — add `:root { --empty-cell-bg: ...; }` default. + +### Don't touch +- `src/lib/game-logic.js` (no game state changes) + +## Implementation Steps + +1. Create `src/lib/settings-store.svelte.js` with `settings` rune object, + `DEFAULT_SETTINGS`, `loadSettings`, `saveSettings`, `resetSettings`. +2. In the store module, set up a root effect that: + - Reads localStorage on first call. + - Writes localStorage on change. + - Pushes `settings.emptyCellColor` to + `document.documentElement.style.setProperty('--empty-cell-bg', …)`. +3. Add `:root { --empty-cell-bg: #7a4a2b; }` to `src/app.css` as fallback. +4. Create `src/lib/SettingsButton.svelte`: + - `let { } = $props();` (no props) + - Local `open = $state(false)` + - Imports `settings` + `resetSettings` from the store + - Renders gear button; on click toggles `open` + - Modal: ``, + 6 preset swatches as buttons setting `settings.emptyCellColor` directly, + reset button calling `resetSettings()`, close button. + - `Escape` keydown closes modal. +5. In `PlayerBoard.svelte`, change empty-cell background from + `bg-slate-50 dark:bg-slate-900/60` to inline + `style:background-color="var(--empty-cell-bg)"`. +6. In `master/+page.svelte`, do the same for `!hasNumber` cells; also + import and mount `` in the header (next to the + "Về trang người chơi" link). +7. In `+page.svelte`, mount `` in the player page header. +8. Run `npx svelte-check`. +9. Manual test: open gear, change color, see player card + master grid + empty cells update live. Reload page → color persists. +10. Reset button → returns to brown default. + +## Todo + +- [ ] Create `settings-store.svelte.js` with rune store + persistence +- [ ] Create `SettingsButton.svelte` with gear + modal + picker + presets +- [ ] Add CSS var `--empty-cell-bg` default in `app.css` +- [ ] Wire CSS var update from store via effect +- [ ] Update `PlayerBoard.svelte` empty cells to use CSS var +- [ ] Update `master/+page.svelte` empty cells + mount SettingsButton +- [ ] Update `+page.svelte` to mount SettingsButton +- [ ] Test: change color → repaints both boards live +- [ ] Test: reload → persists +- [ ] Test: reset → back to brown +- [ ] Test: Escape closes modal +- [ ] `npx svelte-check` clean +- [ ] Update `docs/codebase-summary.md` (new files, new storage key) +- [ ] Update `docs/project-overview-pdr.md` (note settings feature) + +## Success Criteria + +- Gear icon visible on `/` and `/master`. +- Modal opens, picker bound to current color. +- Picking a color via picker OR swatch repaints empty cells live on both + boards. +- Reload preserves the chosen color. +- Reset button returns to brown default. +- No svelte-check errors. + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `$state` at module scope misuse | Medium | High | Use `.svelte.js` extension; verify with svelte-check; reference Svelte 5 docs if unsure (`docs-seeker` skill / context7). | +| CSS var doesn't apply across light/dark mode cleanly | Low | Cosmetic | Set var on `:root`, no media-query gating. User picked color overrides the brand-default for both modes — that's the user's call. | +| localStorage unavailable (private mode) | Low | Setting just doesn't persist | Wrap reads/writes in try/catch (mirror existing `saveGrid`/`loadGrid` pattern). | +| Race: store loads after first paint, brief flash of fallback color | Low | Cosmetic | Acceptable; default fallback in CSS is the same brown, so no flash for default users. | +| New storage key collides with existing | None | – | `loto_settings` is unused. | + +## Security Considerations + +- `` returns a 7-char `#rrggbb` string, no injection + vector via CSS variable. Still: validate `/^#[0-9a-fA-F]{6}$/` before + applying, fall back to default on mismatch. +- localStorage data is per-origin; no concerns. + +## Next Steps + +After this phase the plan is complete. Optional follow-ups (NOT in this plan): +- Add more settings (font size, sound on Kinh, etc.) — store is already + shaped to allow it. +- Persist user-defined preset swatches. + +## Storage Keys (added) + +| Key | Shape | Purpose | +|---|---|---| +| `loto_settings` | `{ emptyCellColor: "#rrggbb" }` | global UI settings | diff --git a/web/plans/260426-2343-tan-tan-3-section-board-and-settings/plan.md b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/plan.md new file mode 100644 index 0000000..0523f79 --- /dev/null +++ b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/plan.md @@ -0,0 +1,63 @@ +--- +slug: tan-tan-3-section-board-and-settings +created: 2026-04-26 +status: completed +completedAt: 2026-04-26 +mode: fast +blockedBy: [] +blocks: [] +--- + +# Tân Tân 3-Section Player Board + Settings + +Two cosmetic features that make the app look like a real Minh Tân lô tô sheet: + +1. **Visual split** — render the 9×9 player card as 3 stacked 3×9 mini-cards + with traditional Tân Tân separator labels. Data and generator unchanged. +2. **Settings** — gear icon → modal with a color picker for empty cells. + Applies to all boards (player + master tracking grid). Default: brown. + +Reference image: physical "Minh Tân" card from dochoicholon.com — 3 mini-cards +stacked on one sheet, brown empty cells, decorative cross-hatch separators +labeled "Minh Tân" / "Loại đặc biệt" / "Tấn tài tấn lộc". + +## Decisions (locked, do not re-ask) + +- **Layout**: stacked on one page, not paginated. +- **Generator**: unchanged — keeps exact 5/row + 5/col on the full 9×9. +- **Color scope**: setting applies to player card *and* master tracking grid. + +## Phases + +| # | Phase | Status | File | +|---|---|---|---| +| 1 | 3-section visual split for PlayerBoard | DONE | [phase-01-three-section-player-board.md](phase-01-three-section-player-board.md) | +| 2 | Settings modal + empty-cell color picker | DONE | [phase-02-settings-color-picker.md](phase-02-settings-color-picker.md) | + +## Files Touched + +| File | Phase | Why | +|---|---|---| +| `src/lib/PlayerBoard.svelte` | 1, 2 | render 3 sections; consume color setting | +| `src/routes/master/+page.svelte` | 2 | consume color setting for empty cells | +| `src/routes/+page.svelte` | 2 | mount settings button (player page header) | +| `src/lib/settings-store.svelte.js` | 2 | new — rune-based settings store + persistence | +| `src/lib/SettingsButton.svelte` | 2 | new — gear button + modal | +| `src/app.css` | 1, 2 | section separator styles, CSS var for empty color | +| `docs/project-overview-pdr.md` | 1, 2 | document new visual + settings | +| `docs/codebase-summary.md` | 1, 2 | new files in component table | + +## Key Dependencies + +- Phase 2 depends on Phase 1 (so the new section background uses the + configurable color from day one). They can also ship independently — + Phase 2 will simply apply to both flat and section layouts. + +## Out of Scope + +- Per-card color override (one global color for all cards). +- Theme preset bundles. +- Other settings (font, sound, etc.) — settings store will be extensible + but only "empty-cell color" lands now. +- Decorative bg images / paper texture. +- Card serial number badge (the "25" badge in the physical photo). diff --git a/web/plans/260426-2343-tan-tan-3-section-board-and-settings/reports/code-reviewer-260427-0002-session-review.md b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/reports/code-reviewer-260427-0002-session-review.md new file mode 100644 index 0000000..604b8b0 --- /dev/null +++ b/web/plans/260426-2343-tan-tan-3-section-board-and-settings/reports/code-reviewer-260427-0002-session-review.md @@ -0,0 +1,193 @@ +# Code Review — Tân Tân 3-section board + Settings color picker + +**Date:** 2026-04-27 +**Reviewer:** code-reviewer +**Scope:** uncommitted working tree, two features + +--- + +## TL;DR + +Both features are essentially correct and ship-worthy. Row-index math is right. Color validation regex is safe enough for a CSS custom property sink. A handful of small bugs/papercuts — none block ship — and one **must-fix** focus/a11y issue worth 5 minutes before deploy. + +Verdict: **DONE_WITH_CONCERNS** — ship today, fix the must-fix list before merging the PR. + +--- + +## Must-fix (block ship until done) + +### 1. Settings modal: ` - {/if} - {/each} -
+ {#if !hasNumber} + + {:else} + + {/if} + {/each} +
+ {/each} {#if toast}