mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 12:17:00 +00:00
feat(ui): mobile legibility, brand marquee, master focal, dark polish
Six small UI/UX phases shipped together: 1. Mobile legibility — cell aspect 3:4 on mobile (was 1:1), text-lg number, active:scale-90 press, navigator.vibrate(10) on tap, 200ms cross-draw clip-path animation on cell mark. 2. Brand — H1 italic rose-amber gradient with drop-shadow + "Hội chợ Tân Tân" subtitle. First-run state shows a faded preview card + warm welcome line instead of a gray placeholder. 3. Master focal point — "Số vừa xổ" hero scaled w-40/56 with text-7xl/8xl, role=status aria-live=assertive, scrollIntoView on each user-driven draw (gated by scrollOnNextDraw flag so reload doesn't yank the page). Master section uses transition:slide. 4. Settings polish — modal max-w-md on sm+; switchRow snippet replaces 4 boolean buttons with role=switch UI (focus ring, keyboard space/enter); empty cells get dark:[filter:brightness(0.85)_saturate(0.9)] so user's chosen purple isn't neon in dark mode. 5. Celebration tiering — 3rd+ bingo per card triggers a 12-emoji CSS confetti rain at z-[60] above the popup. 6. Docs sync — codebase-summary + pdr. Reviewer fixes applied: confetti z-index above popup; scroll-on-load guarded by interaction flag; switch focus ring. Tests 98/98 pass; build clean.
This commit is contained in:
1 parent
0b6c41faad
commit
d77d4a5652
14 files changed
+897
-66
No files matched your search
@@ -11,9 +11,9 @@
|
||||
### 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 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. Two header actions: "Tạo bảng mới" (regenerate grid) and "Xoá đánh dấu" (clear all marks, keep grid — only shown when a grid exists). 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. 5 fieldsets: Giao diện (theme auto/light/dark), Chế độ quản trò (master mode toggle), Tự động xổ (auto-call + speed 1–10s), Âm thanh (voice toggles for master + player + voice picker from manifest), Màu ô trống (10 Excel color swatches). Reset-to-default button. Mounted on `/`. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, "Số vừa xổ" hero token, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). No host's own player card (the player already has one above). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`. |
|
||||
| `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:4 on mobile; 3:5 on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, purple empty cells (dark mode dims via `filter:brightness(0.85)`). Handles crossed state, animated cross-out (200 ms `cross-draw` keyframe), `active:scale-90` press, 10 ms haptic on tap. Two header actions: "Tạo bảng mới" / "Xoá đánh dấu". First-run state shows a faded preview card. Bingo popup tiers: row 1 = standard celebration; row 3+ = falling-emoji confetti rain via CSS `confetti-fall`. Toast "Chờ N" + audio. Accepts `storagePrefix` prop for multi-card isolation. |
|
||||
| `src/lib/SettingsButton.svelte` | Gear icon + modal (responsive `max-w-sm sm:max-w-md`). 5 fieldsets: Giao diện (theme pills), Chế độ quản trò (switch row), Tự động xổ (switch + speed slider), Âm thanh (two switches + voice picker), Màu ô trống (10 Excel color swatches + custom picker). Boolean toggles use a shared `switchRow` snippet (`role="switch"` + keyboard support). Reset-to-default button. Mounted on `/`. |
|
||||
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, large "Số vừa xổ" hero token (160 px mobile, 224 px sm+) with `aria-live="assertive"` + auto `scrollIntoView` on each new draw, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). No host's own player card (the player already has one above). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`; the wrapping section uses `transition:slide` for smooth toggle-in. |
|
||||
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/`. |
|
||||
|
||||
### Game Logic
|
||||
@@ -28,7 +28,7 @@
|
||||
### Styling
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `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. |
|
||||
| `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, cross-draw 200 ms cell mark, confetti-fall 1.6 s), `.cell-crossed` diagonal, `.confetti` falling-emoji helper. |
|
||||
|
||||
### Tests
|
||||
| File | Purpose |
|
||||
|
||||
@@ -100,6 +100,11 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
|
||||
- [x] Master speaks the called number aloud in Vietnamese (bundled MP3, no runtime TTS API).
|
||||
- [x] Player hears "Chờ N" when a row is one away and "Kinh" on bingo.
|
||||
- [x] Voice picker in settings — switch among every Vietnamese edge-tts voice.
|
||||
- [x] Player numbers comfortably readable on a 360 px viewport.
|
||||
- [x] Cell tap on mobile gives haptic + active-press + animated cross-out feedback.
|
||||
- [x] Master "Số vừa xổ" hero is the page focal point and auto-scrolls into view on each draw.
|
||||
- [x] Cold first paint shows a faded preview card + welcome line, not a gray placeholder.
|
||||
- [x] Bingo celebration tiers: 3rd+ row triggers a CSS confetti rain on top of the popup.
|
||||
- [x] Bingo popup triggers when row is complete, shows row number and "Kinh!" message.
|
||||
- [x] Player may keep marking after a Kinh — no game-end lock.
|
||||
- [x] Toast notifications show "Chờ X" before bingo (one number remaining).
|
||||
@@ -121,11 +126,13 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
|
||||
|
||||
## Visual Language
|
||||
|
||||
- **Player gradient**: indigo → purple (primary brand, positive action).
|
||||
- **Host gradient**: orange → red (higher-stakes, control action).
|
||||
- **Wordmark gradient**: rose → amber → rose, italic + drop-shadow — vintage carnival marquee feel.
|
||||
- **Player accent**: indigo → purple (primary brand, positive action).
|
||||
- **Host accent**: orange (subordinate to wordmark; not gradient).
|
||||
- **Completed rows**: emerald (success indicator).
|
||||
- **Waiting toast**: amber (attention, ephemeral).
|
||||
- **Emojis**: 🎉 ✨ 🎊 🥳 ❤️ (celebration, joy).
|
||||
- **Emojis**: 🎉 ✨ 🎊 🥳 ❤️ (celebration, joy). Confetti rain on 3rd+ bingo per card.
|
||||
- **Haptic**: 10 ms vibration on cell tap (mobile only, where supported).
|
||||
|
||||
## Future Considerations (Not Committed)
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Phase 1 — Mobile legibility (cell sizing, haptic, active, cross-out)
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). No deps. Highest user-facing impact.
|
||||
- Reviewer P0: cell text under ~14px on 360px viewport, tap target
|
||||
~37px (under WCAG 44px), no active-press feedback, no haptic, static
|
||||
cross-out line.
|
||||
|
||||
## Overview
|
||||
- Priority: P0
|
||||
- Status: TODO
|
||||
- Effort: ~20 min
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/PlayerBoard.svelte` | Cell button class — taller aspect on mobile, larger text, `active:scale-90`, `navigator.vibrate(10)` in `handleCellClick` |
|
||||
| `src/app.css` | `@keyframes cross-draw` + apply to `.cell-crossed::after` |
|
||||
|
||||
## Diffs (illustrative)
|
||||
|
||||
```diff
|
||||
/** @param {number} row, @param {number} col */
|
||||
function handleCellClick(row, col) {
|
||||
+ if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
+ navigator.vibrate(10);
|
||||
+ }
|
||||
crossed = crossed.map((r, ri) =>
|
||||
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
|
||||
aria-pressed={isCrossed}
|
||||
onclick={() => handleCellClick(row, col)}
|
||||
class="tan-tan-num relative flex items-center justify-center
|
||||
- aspect-square sm:aspect-[3/5]
|
||||
- text-base sm:text-2xl md:text-3xl
|
||||
+ aspect-[3/4] sm:aspect-[3/5]
|
||||
+ text-lg sm:text-2xl md:text-3xl
|
||||
border border-slate-400/50 dark:border-slate-600/40
|
||||
- transition-all select-none cursor-pointer
|
||||
+ transition-all select-none cursor-pointer active:scale-90
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
{isCrossed ...}"
|
||||
```
|
||||
|
||||
```css
|
||||
/* src/app.css — add near .cell-crossed */
|
||||
@keyframes cross-draw {
|
||||
from { clip-path: inset(0 100% 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
.cell-crossed::after {
|
||||
/* …existing rules… */
|
||||
animation: cross-draw 200ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| iOS Safari ignores `navigator.vibrate` | Guarded `typeof navigator !== "undefined" && navigator.vibrate` — silent no-op |
|
||||
| User uncrosses a cell | `cell-crossed::after` simply unmounts; no inverse animation needed |
|
||||
| Reduced-motion preference | Optional follow-up: wrap `cross-draw` in `@media (prefers-reduced-motion: no-preference)`. Skip in v1 — animation is 200ms and non-essential. |
|
||||
| `aspect-[3/4]` on very small screens | Cells grow ~10px taller; layout still fits because parent caps width |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- On a 360px viewport, a number reads at ≥18px effective.
|
||||
- Tap target ≥40px on mobile (cells are 40×53 px in 9-col grid at 360px).
|
||||
- Tapping a cell on Android Chrome vibrates ~10ms.
|
||||
- Press scale-down is visible on touchstart.
|
||||
- Cross-out diagonal animates in over ~200ms when toggled on.
|
||||
- Existing 98 unit tests still pass.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Taller cells push the bingo popup off-screen on tiny viewports | Already a `fixed inset-0` modal with internal scroll — unaffected |
|
||||
| Haptic abused as a cell-bouncer | One 10ms pulse per click; user can hold-press without retrigger because Svelte uses click events, not touchstart |
|
||||
|
||||
## Next
|
||||
- Phase 2 brand + first-run state.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Phase 2 — Brand H1 marquee + first-run empty preview
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent of Phase 1.
|
||||
- Reviewer P0: H1 reads like a SaaS label; cold first paint shows a
|
||||
gray "Nhấn 'Tạo bảng mới'" placeholder with no personality.
|
||||
|
||||
## Overview
|
||||
- Priority: P0
|
||||
- Status: TODO
|
||||
- Effort: ~20 min
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/routes/+page.svelte` | H1: bigger, italic, warmer gradient, drop-shadow; add subtitle line |
|
||||
| `src/lib/PlayerBoard.svelte` | Replace empty-state placeholder with a faded mini-card preview + warm welcome line |
|
||||
|
||||
## H1 marquee
|
||||
|
||||
```diff
|
||||
- <h1 class="text-4xl sm:text-5xl font-extrabold
|
||||
- bg-gradient-to-r from-indigo-500 to-purple-500
|
||||
- bg-clip-text text-transparent">
|
||||
- Lô tô
|
||||
- </h1>
|
||||
+ <h1 class="text-5xl sm:text-7xl font-black italic tracking-tight
|
||||
+ bg-gradient-to-r from-rose-500 via-amber-500 to-rose-500
|
||||
+ bg-clip-text text-transparent
|
||||
+ drop-shadow-[0_2px_0_rgba(0,0,0,0.15)]">
|
||||
+ Lô tô
|
||||
+ </h1>
|
||||
+ <p class="text-xs sm:text-sm uppercase tracking-[0.3em]
|
||||
+ text-slate-500 dark:text-slate-400 italic mt-1">
|
||||
+ Hội chợ Tân Tân
|
||||
+ </p>
|
||||
```
|
||||
|
||||
## Empty-state preview
|
||||
|
||||
Replace the current placeholder block (`PlayerBoard.svelte` ~line 292):
|
||||
|
||||
```svelte
|
||||
{:else}
|
||||
<div class="text-center py-10">
|
||||
<!-- Faded 3×9 preview row to set expectations -->
|
||||
<div class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
|
||||
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600">
|
||||
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
|
||||
{#each Array(27) as _, i (i)}
|
||||
<div
|
||||
class="aspect-square text-[0.6rem] flex items-center justify-center
|
||||
{i % 3 === 0 ? 'bg-white dark:bg-slate-800' : ''}"
|
||||
style:background-color={i % 3 === 0 ? undefined : 'var(--empty-cell-bg)'}
|
||||
>
|
||||
{i % 3 === 0 ? Math.floor(Math.random() * 90) + 1 : ''}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 italic">
|
||||
Nhấn <span class="font-semibold text-indigo-500 dark:text-indigo-400">"Tạo bảng mới"</span> để bắt đầu chơi
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
🎫 Chúc cả nhà một ván vui vẻ
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
(The randomness on first paint is fine — preview re-renders only on
|
||||
mount; it's decorative, not gameplay.)
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Tailwind purges `from-rose-500 via-amber-500 to-rose-500` | These are static class names — tailwind keeps them |
|
||||
| User dislikes the new gradient | Trivial revert, single block |
|
||||
| Empty preview confuses screen readers | Wrap in `aria-hidden="true"` (already pointer-events-none + decorative) |
|
||||
| First paint shows different random numbers on every visit | Acceptable — purely decorative |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- H1 visually dominates above the fold on mobile (text-5xl ≈ 48px).
|
||||
- "Hội chợ Tân Tân" subtitle reads as a marquee subline.
|
||||
- Cold reload (no `loto_grid` in localStorage) shows a faded preview
|
||||
card + welcome copy, not a gray placeholder.
|
||||
- Dark-mode versions still readable (>=4.5:1 contrast on subtitle).
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `drop-shadow-[…]` arbitrary value drops Safari support pre-15.4 | Acceptable — Safari 15.4 = March 2022; Cloudflare logs would tell us if a meaningful share is on older |
|
||||
| Brand color shift (purple→rose/amber) might feel inconsistent with Excel-purple empty cells | The H1 is the wordmark, the cells are the data — different roles. Reviewer specifically flagged the purple as "generic SaaS" applied to the H1; cells stay purple by user setting. |
|
||||
|
||||
## Next
|
||||
- Phase 3 master focal point.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Phase 3 — Master mode focal point
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent of Phases 1-2.
|
||||
- Reviewer P0: "Số vừa xổ" hero is too small (96px), called number is
|
||||
buried mid-page below the player card. Master mode toggles in
|
||||
abruptly with no transition. No `aria-live` for screen readers.
|
||||
|
||||
## Overview
|
||||
- Priority: P0
|
||||
- Status: TODO
|
||||
- Effort: ~25 min
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/MasterPanel.svelte` | Hero "Số vừa xổ" 2× larger; container has `aria-live`; auto-`scrollIntoView` after each draw |
|
||||
| `src/routes/+page.svelte` | Slide transition on the master section mount |
|
||||
|
||||
## Hero scaling
|
||||
|
||||
In `MasterPanel.svelte` find the "Số vừa xổ" block (~line 184):
|
||||
|
||||
```diff
|
||||
- <div class="...flex items-center justify-center
|
||||
- w-24 h-24 sm:w-28 sm:h-28
|
||||
- rounded-full ring-4 ...">
|
||||
- <span class="text-5xl sm:text-6xl font-black ...">
|
||||
+ <div bind:this={heroEl}
|
||||
+ role="status" aria-live="assertive" aria-atomic="true"
|
||||
+ class="...flex items-center justify-center
|
||||
+ w-40 h-40 sm:w-56 sm:h-56
|
||||
+ rounded-full ring-4 ...
|
||||
+ scroll-mt-4">
|
||||
+ <span class="text-7xl sm:text-8xl font-black ...">
|
||||
{lastCalled}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add the auto-scroll wiring (after the existing `$state` block):
|
||||
|
||||
```js
|
||||
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
|
||||
|
||||
$effect(() => {
|
||||
if (lastCalled !== null && heroEl) {
|
||||
// microtask after DOM patch
|
||||
requestAnimationFrame(() =>
|
||||
heroEl?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Slide-in master section
|
||||
|
||||
In `+page.svelte`:
|
||||
|
||||
```diff
|
||||
+ <script>
|
||||
+ import { slide } from "svelte/transition";
|
||||
+ // …existing imports…
|
||||
+ </script>
|
||||
|
||||
{#if settings.masterMode}
|
||||
- <div class="mt-10">
|
||||
+ <div class="mt-10" transition:slide={{ duration: 250 }}>
|
||||
<h2 class="text-center text-lg font-bold text-orange-500 dark:text-orange-400 mb-4">
|
||||
Quản trò
|
||||
</h2>
|
||||
<MasterPanel />
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
Also drop the gradient on H2 (reviewer P2): keep `text-orange-500` —
|
||||
it stays visually subordinate to H1's rose-amber.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Auto-scroll on every render (e.g., toggling auto-call) | `$effect` dep is `lastCalled` only; toggling auto-call doesn't change it |
|
||||
| Hero `scrollIntoView` competes with toast on player side | Toast is `position: absolute` inside the player area, not affected by window scroll |
|
||||
| Screen reader reads "45" out of context | `aria-live="assertive"` + Vietnamese voice announcement together — redundant for hearing users; SR users get the live region. Acceptable. |
|
||||
| User has reduced motion preference | `behavior: "smooth"` ignores prefers-reduced-motion in some browsers; trade-off accepted in v1. Follow-up: branch on `matchMedia('(prefers-reduced-motion: reduce)').matches`. |
|
||||
| Section unmounts during slide | Svelte's `slide` handles in/out; on toggle off the section collapses cleanly |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- "Số vừa xổ" circle ~160-220px on desktop, ~160px on mobile.
|
||||
- After `Xổ số`, the hero scrolls smoothly into view (block: center).
|
||||
- Screen reader announces each new number.
|
||||
- Toggling master mode on slides the section in over 250ms (no
|
||||
layout jump).
|
||||
- No regression to auto-call interval timing.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `scrollIntoView` triggers on initial load if `lastCalled` was persisted | `loadState` already sets `lastCalled` on mount; the effect runs once but the user expects to land on the hero anyway. Fine. |
|
||||
| Bigger hero pushes the rest of the panel down | Intentional — the hero is the focal point |
|
||||
| Slide transition causes CLS warnings | One-off, only on master toggle; not a Core Web Vitals path |
|
||||
|
||||
## Next
|
||||
- Phase 4 settings polish.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Phase 4 — Settings modal width + dark-mode purple + switch toggles
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent.
|
||||
- Reviewer P1: modal feels cramped on tablet+; empty cells use full-
|
||||
saturation Excel purple in both themes (neon-bright in dark mode);
|
||||
"Đang bật / Đang tắt" reads like state, not action.
|
||||
|
||||
## Overview
|
||||
- Priority: P1
|
||||
- Status: TODO
|
||||
- Effort: ~25 min
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/SettingsButton.svelte` | Modal `max-w-sm` → `max-w-sm sm:max-w-md`; convert master/auto/voice toggles to switch UI |
|
||||
| `src/app.css` | Dark-mode override for `--empty-cell-bg` (or filter on dark) |
|
||||
|
||||
## Modal width
|
||||
|
||||
```diff
|
||||
<div
|
||||
- class="relative mx-4 max-w-sm w-full max-h-[90vh] overflow-y-auto
|
||||
+ class="relative mx-4 max-w-sm sm:max-w-md w-full max-h-[90vh] overflow-y-auto
|
||||
rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
|
||||
>
|
||||
```
|
||||
|
||||
Cell color swatch grid stays 5-col; the extra width gives breathing
|
||||
room without re-flow.
|
||||
|
||||
## Switch UI for boolean toggles
|
||||
|
||||
A small reusable inline pattern (no new component file — this is a
|
||||
two-line snippet replacement, KISS):
|
||||
|
||||
```svelte
|
||||
<!-- Replace the master-mode "Đang bật / Đang tắt" full-width button -->
|
||||
<label class="flex items-center justify-between gap-3 px-3 py-2 rounded-lg
|
||||
border-2 border-slate-200 dark:border-slate-600 cursor-pointer
|
||||
hover:border-slate-300 dark:hover:border-slate-500 transition-colors">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">
|
||||
Hiện bảng quản trò
|
||||
</span>
|
||||
<span
|
||||
role="switch"
|
||||
aria-checked={settings.masterMode}
|
||||
tabindex="0"
|
||||
onclick={toggleMaster}
|
||||
onkeydown={(e) => (e.key === " " || e.key === "Enter") && (e.preventDefault(), toggleMaster())}
|
||||
class="relative inline-block w-10 h-6 rounded-full transition-colors
|
||||
{settings.masterMode
|
||||
? 'bg-emerald-500'
|
||||
: 'bg-slate-300 dark:bg-slate-600'}"
|
||||
>
|
||||
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform
|
||||
{settings.masterMode ? 'translate-x-4' : 'translate-x-0'}"></span>
|
||||
</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
Apply the same pattern to:
|
||||
- Auto-call enable (`autoCallEnabled`)
|
||||
- Voice master (`voiceEnabledMaster`)
|
||||
- Voice player (`voiceEnabledPlayer`)
|
||||
|
||||
Voice picker (radio-style buttons) stays as-is — switch UI doesn't
|
||||
generalize to >2 options.
|
||||
|
||||
## Dark-mode empty-cell desaturation
|
||||
|
||||
In `src/app.css`:
|
||||
|
||||
```diff
|
||||
:root {
|
||||
--empty-cell-bg: #7030A0;
|
||||
}
|
||||
+
|
||||
+ :where(.dark) {
|
||||
+ --empty-cell-bg: #5a2480;
|
||||
+ }
|
||||
```
|
||||
|
||||
Or, if user has a custom hex via the color picker, the override above
|
||||
gets clobbered when they pick. Better: keep user's choice as the
|
||||
source of truth, but apply a `filter: brightness(0.85)` in dark mode
|
||||
on the empty-cell elements.
|
||||
|
||||
Decision: **respect user choice in both modes**. Skip the dark
|
||||
override; user can pick their own color for dark mode if they want.
|
||||
Just dim slightly via filter:
|
||||
|
||||
```css
|
||||
:where(.dark) [style*="--empty-cell-bg"],
|
||||
:where(.dark) [style*="background-color"] {
|
||||
/* Too broad — second selector hits everything. Skip. */
|
||||
}
|
||||
```
|
||||
|
||||
Actually the cleanest fix that doesn't fight user choice:
|
||||
|
||||
```svelte
|
||||
<!-- PlayerBoard.svelte empty cell -->
|
||||
- style:background-color="var(--empty-cell-bg)"
|
||||
+ class="dark:[filter:brightness(0.85)_saturate(0.9)]"
|
||||
+ style:background-color="var(--empty-cell-bg)"
|
||||
```
|
||||
|
||||
This keeps user's color but tones it down ~15% in dark mode.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Switch keyboard activation on iOS VoiceOver | `role="switch"` + `aria-checked` + Space/Enter keydown handler — works |
|
||||
| Dark-mode filter on cells with custom colors close to white | Filter is multiplicative; near-white stays near-white, near-purple goes near-deeper-purple. Acceptable. |
|
||||
| Modal `max-w-md` overflows on landscape phones | `mx-4` gutter still applies; max width is a cap, not a floor |
|
||||
| Hover styles on touch devices stick after tap | Existing pattern in the codebase; no regression |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- On a 768px+ viewport, settings modal feels balanced (not narrow).
|
||||
- Boolean toggles read as switches, not buttons.
|
||||
- In dark mode, default purple `#7030A0` reads as a muted purple, not
|
||||
neon. Custom colors get the same dimming.
|
||||
- All settings keep persisting correctly (Phase 4 of voice plan
|
||||
test-suite still green).
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Switch behavior diverges across keyboard/mouse/touch | Tested keyboard handler; tap-on-label still works because the inner span is the actual switch role |
|
||||
| Custom dark color picked at full saturation looks worse with filter | User can override; trade-off accepted |
|
||||
| Filter on every cell adds GPU layer | 81 cells × 1 filter = no measurable cost on modern devices |
|
||||
|
||||
## Next
|
||||
- Phase 5 celebration tiering.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 5 — Two-tier "Kinh!" celebration
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Independent of earlier phases.
|
||||
- Reviewer P2: bingo on row 1 looks identical to bingo on row 5. The
|
||||
party should escalate.
|
||||
|
||||
## Overview
|
||||
- Priority: P2
|
||||
- Status: TODO
|
||||
- Effort: ~15 min
|
||||
|
||||
## Goal
|
||||
|
||||
First completed row: current celebration (gradient text, 🎉 bounce, ✨🎊 spinners).
|
||||
Third+ completed row: same modal **plus** a CSS confetti burst (8–12
|
||||
emoji spans falling from the top with random `--x` and `--delay`).
|
||||
|
||||
No JS particle library — pure CSS keyframes.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/lib/PlayerBoard.svelte` | Track `celebratedRows.size`; pass `tier` to popup; render confetti layer when tier ≥ 2 |
|
||||
| `src/app.css` | `@keyframes confetti-fall` |
|
||||
|
||||
## Diff (illustrative)
|
||||
|
||||
```diff
|
||||
$effect(() => {
|
||||
if (!grid || crossed.length === 0) return;
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
|
||||
celebratedRows.add(i);
|
||||
notifiedWaitingRows.add(i);
|
||||
congratsRow = i + 1;
|
||||
showCongrats = true;
|
||||
+ celebrationTier = celebratedRows.size >= 3 ? 2 : 1;
|
||||
if (settings.voiceEnabledPlayer) playBingo();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// …pass 2 unchanged…
|
||||
});
|
||||
```
|
||||
|
||||
```svelte
|
||||
let celebrationTier = $state(/** @type {1 | 2} */ (1));
|
||||
```
|
||||
|
||||
In the popup template, after the existing modal block:
|
||||
|
||||
```svelte
|
||||
{#if showCongrats && celebrationTier >= 2}
|
||||
<div aria-hidden="true" class="fixed inset-0 z-40 pointer-events-none overflow-hidden">
|
||||
{#each Array(12) as _, i (i)}
|
||||
<span
|
||||
class="confetti"
|
||||
style:--x="{Math.random() * 100}%"
|
||||
style:--delay="{Math.random() * 400}ms"
|
||||
style:--rot="{Math.random() * 360}deg"
|
||||
>
|
||||
{["🎊", "✨", "🎉", "🥳"][i % 4]}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
```
|
||||
|
||||
```css
|
||||
/* src/app.css */
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translate(0, -10vh) rotate(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translate(0, 110vh) rotate(var(--rot)); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--x);
|
||||
font-size: 2rem;
|
||||
animation: confetti-fall 1.6s ease-in var(--delay) forwards;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| User dismisses popup before confetti finishes | `showCongrats = false` unmounts the confetti layer too — clean |
|
||||
| Player generates a new card mid-celebration | `handleGenerate` already clears `celebratedRows` — tier resets to 1 next bingo |
|
||||
| Reduced motion users | Wrap `confetti` block in `@media (prefers-reduced-motion: no-preference) { … }` (skip in v1, follow-up) |
|
||||
| 12 emoji × random pos = browser perf | 1.6s, 12 spans, GPU layer — trivial. No issue. |
|
||||
|
||||
## Success criteria
|
||||
|
||||
- First bingo: same modal as today, no confetti.
|
||||
- Third bingo (and beyond): same modal **plus** ~12 falling confetti
|
||||
emoji that complete in ~1.6 s.
|
||||
- Tier resets when the player generates a new card.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Confetti behind the modal looks weird | `z-40` for confetti vs `z-50` for modal — modal floats above |
|
||||
| Animation lingers on slow phones | `forwards` keeps final state; opacity 0.8→0 fade still hides it |
|
||||
|
||||
## Next
|
||||
- Phase 6 tests + docs.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Phase 6 — Tests pass + docs sync
|
||||
|
||||
## Context
|
||||
- [plan.md](plan.md). Final phase.
|
||||
|
||||
## Overview
|
||||
- Priority: P2
|
||||
- Status: TODO
|
||||
- Effort: ~10 min
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm test # expect 98/98 still pass — no new tests required
|
||||
npm run build # clean
|
||||
```
|
||||
|
||||
This plan ships no new tests because the changes are visual /
|
||||
interaction polish; existing settings + game-logic + number-words
|
||||
tests remain authoritative.
|
||||
|
||||
## Manual smoke (browser, mobile + desktop)
|
||||
|
||||
| Step | Expected |
|
||||
|---|---|
|
||||
| Cold reload (clear localStorage) | H1 marquee + subtitle visible; preview card faded; warm welcome line |
|
||||
| Click "Tạo bảng mới" | grid renders; cells comfortably tappable on phone |
|
||||
| Tap a cell on Android | 10ms vibration, scale-down press, cross-out diagonal animates in |
|
||||
| Mark a row to bingo | popup + audio (existing behavior) |
|
||||
| Mark second & third row to bingo | popup + confetti rain on 3rd |
|
||||
| Open Settings → toggle master mode | section slides in, no jump |
|
||||
| Master "Xổ số" | giant hero appears, auto-scrolls into view, screen reader announces number |
|
||||
| Toggle dark mode → look at empty cells | purple is muted, not neon |
|
||||
| Settings on tablet (≥640px) | modal `max-w-md`, swatches breathe |
|
||||
| Switch toggles in settings | feel like switches, not buttons |
|
||||
|
||||
## Docs to sync
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `docs/codebase-summary.md` | Update PlayerBoard description (haptic, animated cross-out, tiered celebration). Update SettingsButton (switch UI). Update +page.svelte (marquee H1 + subtitle). Add `@keyframes confetti-fall`, `cross-draw` to app.css description. |
|
||||
| `docs/project-overview-pdr.md` | "Visual Language" section: add wordmark gradient (rose→amber→rose); confetti tier note; haptic feedback. Add 1-2 acceptance items (mobile cell legibility, tiered celebration). |
|
||||
| `docs/development-roadmap.md` | No change (no new ideas, no new completed items belong here). |
|
||||
|
||||
Skip `system-architecture.md`, `code-standards.md`, `deployment-guide.md`.
|
||||
|
||||
## Out of scope (intentionally)
|
||||
|
||||
- New tests for visual changes (Vitest doesn't render, Playwright not
|
||||
set up; visual regression deferred to a future PWA/E2E plan).
|
||||
- Reduced-motion media queries for animations (one-line fix; defer
|
||||
until a real accessibility complaint).
|
||||
- Configurable section labels.
|
||||
- Custom font for the wordmark.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- All 98 unit tests still pass.
|
||||
- `npm run build` clean.
|
||||
- Manual smoke checklist all green.
|
||||
- Docs reflect new visual behavior.
|
||||
- Plan marked completed; tasks #5–#11 from voice plan stay closed,
|
||||
new task chain marked done.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
slug: ui-ux-improvements
|
||||
created: 2026-04-27
|
||||
status: completed
|
||||
completedAt: 2026-04-27
|
||||
mode: fast
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# UI/UX improvements — mobile legibility, brand, master focus, dark mode
|
||||
|
||||
Synthesizes the 12 recommendations from the ui-ux-designer review (see
|
||||
`/config/workspace/tiennm99/loto/plans/reports/` if archived; key
|
||||
findings inline below). Six small phases, ~10–25 min each, no new
|
||||
dependencies, no breaking changes to settings/storage.
|
||||
|
||||
## What this changes (in user terms)
|
||||
|
||||
- Numbers on the player card are bigger and easier to tap on phones.
|
||||
- Cold first paint shows a friendly preview, not a gray placeholder.
|
||||
- Brand wordmark feels like a fairground marquee, not a SaaS header.
|
||||
- When the host enables master mode, the called number becomes a giant
|
||||
hero that auto-scrolls into view; new draws are announced for screen
|
||||
readers.
|
||||
- Dark mode loses the "neon purple" punch on empty cells.
|
||||
- Settings modal feels less cramped on tablet/desktop.
|
||||
- Marking a cell has tactile feedback (haptic + crossout animation +
|
||||
active-press scale).
|
||||
|
||||
## Decisions (locked unless flagged)
|
||||
|
||||
- **Hard invariants stay**: 9×9 grid, 5-per-row + 5-per-col, ascending
|
||||
columns, no-3-consecutive soft rule, all storage keys, all settings
|
||||
keys.
|
||||
- **No new components/library**: continue plain Svelte + Tailwind.
|
||||
- **No new fonts in v1** — H1 marquee uses existing system stack +
|
||||
`italic tracking-tight` + warmer gradient. Adding a Google Font is
|
||||
P2/follow-up.
|
||||
- **No illustration assets** in this plan — open question #2 below.
|
||||
- **Section labels stay personal** ("TN1 (2014-2017)",
|
||||
"Độc-Đỉnh-Điên") — they're the original group's joke and part of the
|
||||
app's identity. Making them configurable is a separate plan.
|
||||
- **Master mode keeps stacking below player**, not full-screen replace
|
||||
— open question #3 below; deferred until a host complains.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | File |
|
||||
|---|---|---|
|
||||
| 1 | Mobile legibility — cell sizing, haptic, active state, animated cross-out | [phase-01-mobile-legibility.md](phase-01-mobile-legibility.md) |
|
||||
| 2 | Brand H1 marquee + first-run empty preview | [phase-02-brand-and-empty-state.md](phase-02-brand-and-empty-state.md) |
|
||||
| 3 | Master mode focal point — giant hero, auto-scroll, slide-in, aria-live | [phase-03-master-focal-point.md](phase-03-master-focal-point.md) |
|
||||
| 4 | Settings modal width + dark-mode purple + switch-style toggles | [phase-04-settings-and-dark-polish.md](phase-04-settings-and-dark-polish.md) |
|
||||
| 5 | Two-tier "Kinh!" celebration (first row vs nth row) | [phase-05-celebration-tiering.md](phase-05-celebration-tiering.md) |
|
||||
| 6 | Tests pass + docs sync | [phase-06-tests-and-docs.md](phase-06-tests-and-docs.md) |
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Phase(s) |
|
||||
|---|---|
|
||||
| `src/lib/PlayerBoard.svelte` | 1, 2, 5 |
|
||||
| `src/lib/MasterPanel.svelte` | 3 |
|
||||
| `src/lib/SettingsButton.svelte` | 4 |
|
||||
| `src/routes/+page.svelte` | 2, 3 |
|
||||
| `src/app.css` | 1, 4 |
|
||||
| `docs/codebase-summary.md`, `docs/project-overview-pdr.md` | 6 |
|
||||
|
||||
## Open questions (deferred — answer at implementation time)
|
||||
|
||||
1. **Section labels** — keep "Lô tô / TN1 (2014-2017) / Độc-Đỉnh-Điên"
|
||||
hard-coded? **Default: yes, keep them.** Configurable labels = a
|
||||
future plan if multiple groups adopt the app.
|
||||
2. **Illustration assets** — add a paper-lantern / dice motif to the
|
||||
header? **Default: skip in v1.** A typographic marquee carries
|
||||
enough personality; revisit if real users say it's bland.
|
||||
3. **Master mode on phone** — should it replace the player view full-
|
||||
screen instead of stacking? **Default: keep stacking** + giant
|
||||
hero + auto-scroll. Revisit if a host complains they can't see the
|
||||
hero while holding their card.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- New fonts (Playfair / Bebas Neue / etc.)
|
||||
- Illustration / icon assets
|
||||
- Configurable section labels
|
||||
- Full-screen master mode toggle for phones
|
||||
- Confetti particle library (Phase 5 uses pure CSS)
|
||||
- PWA, sound effects beyond TTS, multiplayer sync
|
||||
|
||||
## Acceptance summary (user-visible)
|
||||
|
||||
- [ ] On a 360px viewport, a player number is comfortably readable
|
||||
across a typical living room.
|
||||
- [ ] First load shows a card preview + warm welcome line, not a gray
|
||||
placeholder.
|
||||
- [ ] H1 looks like a vintage carnival marquee, not a SaaS header.
|
||||
- [ ] In master mode, the most recently drawn number is the
|
||||
unambiguous focal point even with the player card on screen.
|
||||
- [ ] Switching themes desaturates the purple empty cells in dark
|
||||
mode (no more neon punch).
|
||||
- [ ] Tapping a cell vibrates briefly on mobile, presses inward, and
|
||||
draws the cross-out diagonal in ~200 ms.
|
||||
- [ ] Bingo on row 1 ≠ bingo on row 5 visually (party gets louder).
|
||||
- [ ] All 98 unit tests still pass; `npm run build` clean.
|
||||
+22
@@ -131,6 +131,27 @@ body {
|
||||
.animate-spin-slow-reverse { animation: spin-slow 3s linear infinite reverse; }
|
||||
.animate-toast { animation: toast 5s ease-in-out forwards; }
|
||||
|
||||
@keyframes cross-draw {
|
||||
from { clip-path: inset(0 100% 0 0); }
|
||||
to { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
/* Falling-confetti burst for the 3rd-and-up bingo of a single card. */
|
||||
@keyframes confetti-fall {
|
||||
0% { transform: translateY(-10vh) rotate(0); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translateY(110vh) rotate(var(--rot)); opacity: 0.85; }
|
||||
}
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--x);
|
||||
font-size: 2rem;
|
||||
animation: confetti-fall 1.6s ease-in var(--delay) forwards;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.cell-crossed::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -142,4 +163,5 @@ body {
|
||||
transparent calc(50% + 1.5px)
|
||||
);
|
||||
pointer-events: none;
|
||||
animation: cross-draw 200ms ease-out;
|
||||
}
|
||||
@@ -70,6 +70,7 @@
|
||||
/** @type {{called: number[], remaining: number[]} | null} */ (null),
|
||||
);
|
||||
let lastCalled = $state(/** @type {number | null} */ (null));
|
||||
let heroEl = $state(/** @type {HTMLDivElement | null} */ (null));
|
||||
let autoRunning = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
@@ -114,6 +115,20 @@
|
||||
// toggled off mid-clip) so audio doesn't outlive the panel.
|
||||
$effect(() => () => cancelPlayback());
|
||||
|
||||
// Scroll the "Số vừa xổ" hero into view on each new draw so the host
|
||||
// doesn't have to chase it on a phone. Gated by a user-interaction flag
|
||||
// so reloading with persisted state doesn't yank the page on mount.
|
||||
let scrollOnNextDraw = false;
|
||||
|
||||
$effect(() => {
|
||||
if (lastCalled !== null && heroEl && scrollOnNextDraw) {
|
||||
scrollOnNextDraw = false;
|
||||
requestAnimationFrame(() =>
|
||||
heroEl?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function handleNewGame() {
|
||||
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
|
||||
cancelPlayback();
|
||||
@@ -130,6 +145,7 @@
|
||||
remaining: state.remaining.slice(1),
|
||||
};
|
||||
lastCalled = next;
|
||||
scrollOnNextDraw = true;
|
||||
if (settings.voiceEnabledMaster) playNumber(next);
|
||||
}
|
||||
|
||||
@@ -189,22 +205,26 @@
|
||||
{@const lastIsLow = lastCalled <= 49}
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-1"
|
||||
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-2"
|
||||
>
|
||||
Số vừa xổ
|
||||
</div>
|
||||
<div
|
||||
class="w-24 h-24 sm:w-28 sm:h-28 rounded-full
|
||||
bind:this={heroEl}
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
class="w-40 h-40 sm:w-56 sm:h-56 rounded-full
|
||||
bg-amber-50 dark:bg-amber-100
|
||||
border-[6px] sm:border-[7px]
|
||||
border-[8px] sm:border-[10px]
|
||||
flex items-center justify-center
|
||||
shadow-xl
|
||||
shadow-xl scroll-mt-4
|
||||
{lastIsLow
|
||||
? 'border-pink-500 dark:border-pink-400 shadow-pink-500/30'
|
||||
: 'border-emerald-500 dark:border-emerald-400 shadow-emerald-500/30'}"
|
||||
>
|
||||
<span
|
||||
class="text-5xl sm:text-6xl font-black tabular-nums
|
||||
class="text-7xl sm:text-8xl font-black tabular-nums
|
||||
{lastIsLow
|
||||
? 'text-pink-500 dark:text-pink-400'
|
||||
: 'text-emerald-500 dark:text-emerald-400'}"
|
||||
|
||||
@@ -24,8 +24,14 @@
|
||||
let crossed = $state(/** @type {boolean[][]} */ ([]));
|
||||
let showCongrats = $state(false);
|
||||
let congratsRow = $state(-1);
|
||||
let celebrationTier = $state(/** @type {1 | 2} */ (1));
|
||||
let toast = $state(/** @type {string | null} */ (null));
|
||||
|
||||
// 12 confetti emoji indices. Stable per-render — values don't matter,
|
||||
// only the count drives the {#each}.
|
||||
const CONFETTI = Array.from({ length: 12 }, (_, i) => i);
|
||||
const CONFETTI_EMOJI = ["🎊", "✨", "🎉", "🥳"];
|
||||
|
||||
// Plain refs (not reactive)
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let toastTimer = null;
|
||||
@@ -92,6 +98,7 @@
|
||||
notifiedWaitingRows.add(i);
|
||||
congratsRow = i + 1;
|
||||
showCongrats = true;
|
||||
celebrationTier = celebratedRows.size >= 3 ? 2 : 1;
|
||||
if (settings.voiceEnabledPlayer) playBingo();
|
||||
break;
|
||||
}
|
||||
@@ -145,6 +152,9 @@
|
||||
* @param {number} col
|
||||
*/
|
||||
function handleCellClick(row, col) {
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
navigator.vibrate(10);
|
||||
}
|
||||
crossed = crossed.map((r, ri) =>
|
||||
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r
|
||||
);
|
||||
@@ -214,7 +224,7 @@
|
||||
{#if !hasNumber}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="relative aspect-square sm:aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40"
|
||||
class="relative aspect-[3/4] sm:aspect-[3/5] border border-slate-400/50 dark:border-slate-600/40 dark:[filter:brightness(0.85)_saturate(0.9)]"
|
||||
style:background-color="var(--empty-cell-bg)"
|
||||
></div>
|
||||
{:else}
|
||||
@@ -224,10 +234,10 @@
|
||||
aria-pressed={isCrossed}
|
||||
onclick={() => handleCellClick(row, col)}
|
||||
class="tan-tan-num relative flex items-center justify-center
|
||||
aspect-square sm:aspect-[3/5]
|
||||
text-base sm:text-2xl md:text-3xl
|
||||
aspect-[3/4] sm:aspect-[3/5]
|
||||
text-lg sm:text-2xl md:text-3xl
|
||||
border border-slate-400/50 dark:border-slate-600/40
|
||||
transition-all select-none cursor-pointer
|
||||
transition-all select-none cursor-pointer active:scale-90
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
{isCrossed
|
||||
? rowComplete
|
||||
@@ -289,8 +299,53 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
|
||||
Nhấn "Tạo bảng mới" để bắt đầu chơi
|
||||
<div class="text-center py-10">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="mx-auto max-w-xs opacity-30 pointer-events-none mb-6
|
||||
rounded-md overflow-hidden border border-slate-300 dark:border-slate-600"
|
||||
>
|
||||
<div class="grid grid-cols-9 gap-px bg-slate-300 dark:bg-slate-700">
|
||||
{#each Array(27) as _, i (i)}
|
||||
{@const filled = i % 3 === 0}
|
||||
<div
|
||||
class="aspect-square text-[0.55rem] flex items-center justify-center
|
||||
text-black {filled ? 'bg-white dark:bg-slate-800 dark:text-slate-100' : ''}"
|
||||
style:background-color={filled ? undefined : "var(--empty-cell-bg)"}
|
||||
>
|
||||
{filled ? ((i * 7) % 90) + 1 : ""}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 italic">
|
||||
Nhấn
|
||||
<span class="font-semibold text-rose-500 dark:text-rose-400 not-italic">"Tạo bảng mới"</span>
|
||||
để bắt đầu chơi
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
🎫 Chúc cả nhà một ván vui vẻ
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCongrats && celebrationTier >= 2}
|
||||
<!-- z-[60] sits above the bingo modal (z-50) so confetti reads through
|
||||
the backdrop blur instead of being dimmed by it. -->
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fixed inset-0 z-[60] pointer-events-none overflow-hidden"
|
||||
>
|
||||
{#each CONFETTI as i (i)}
|
||||
<span
|
||||
class="confetti"
|
||||
style:--x="{(i * 8.3 + (i % 3) * 11) % 100}%"
|
||||
style:--delay="{(i * 37) % 400}ms"
|
||||
style:--rot="{(i * 67) % 360}deg"
|
||||
>
|
||||
{CONFETTI_EMOJI[i % CONFETTI_EMOJI.length]}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
class="absolute inset-0"
|
||||
></div>
|
||||
<div
|
||||
class="relative mx-4 max-w-sm w-full max-h-[90vh] overflow-y-auto rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
|
||||
class="relative mx-4 max-w-sm sm:max-w-md w-full max-h-[90vh] overflow-y-auto rounded-3xl bg-white dark:bg-slate-800 p-6 shadow-2xl animate-pop-in"
|
||||
>
|
||||
<h2
|
||||
id="settings-title"
|
||||
@@ -181,6 +181,37 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#snippet switchRow(label, isOn, onToggle)}
|
||||
<label
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 rounded-lg
|
||||
border-2 border-slate-200 dark:border-slate-600 cursor-pointer
|
||||
hover:border-slate-300 dark:hover:border-slate-500 transition-colors"
|
||||
>
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">{label}</span>
|
||||
<span
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
aria-checked={isOn}
|
||||
onclick={onToggle}
|
||||
onkeydown={(/** @type {KeyboardEvent} */ e) => {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
class="relative inline-block w-10 h-6 rounded-full transition-colors flex-none
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-400
|
||||
focus:ring-offset-white dark:focus:ring-offset-slate-800
|
||||
{isOn ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-slate-600'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm transition-transform
|
||||
{isOn ? 'translate-x-4' : 'translate-x-0'}"
|
||||
></span>
|
||||
</span>
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
<!-- Master mode -->
|
||||
<fieldset class="mb-5">
|
||||
<legend
|
||||
@@ -191,17 +222,7 @@
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Hiện bảng quản trò bên dưới bảng người chơi
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.masterMode}
|
||||
onclick={toggleMaster}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{settings.masterMode
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
{settings.masterMode ? "Đang bật" : "Đang tắt"}
|
||||
</button>
|
||||
{@render switchRow("Hiện bảng quản trò", settings.masterMode, toggleMaster)}
|
||||
</fieldset>
|
||||
|
||||
<!-- Auto-call (only relevant when master mode is on) -->
|
||||
@@ -215,17 +236,9 @@
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||
Tự xổ số liên tiếp khi đang ở chế độ quản trò
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.autoCallEnabled}
|
||||
onclick={toggleAuto}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all mb-3
|
||||
{settings.autoCallEnabled
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
{settings.autoCallEnabled ? "Đang bật" : "Đang tắt"}
|
||||
</button>
|
||||
<div class="mb-3">
|
||||
{@render switchRow("Tự xổ số", settings.autoCallEnabled, toggleAuto)}
|
||||
</div>
|
||||
{#if settings.autoCallEnabled}
|
||||
<label class="block">
|
||||
<span class="text-xs text-slate-600 dark:text-slate-300">
|
||||
@@ -260,29 +273,11 @@
|
||||
Đọc số bằng tiếng Việt
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.voiceEnabledMaster}
|
||||
onclick={toggleVoiceMaster}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all mb-2
|
||||
{settings.voiceEnabledMaster
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
Quản trò: {settings.voiceEnabledMaster ? "Bật" : "Tắt"}
|
||||
</button>
|
||||
<div class="mb-2">
|
||||
{@render switchRow("Quản trò đọc số", settings.voiceEnabledMaster, toggleVoiceMaster)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={settings.voiceEnabledPlayer}
|
||||
onclick={toggleVoicePlayer}
|
||||
class="w-full px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all
|
||||
{settings.voiceEnabledPlayer
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400'}"
|
||||
>
|
||||
Người chơi (Chờ / Kinh): {settings.voiceEnabledPlayer ? "Bật" : "Tắt"}
|
||||
</button>
|
||||
{@render switchRow("Báo Chờ / Kinh", settings.voiceEnabledPlayer, toggleVoicePlayer)}
|
||||
|
||||
{#if VOICES.length > 1}
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-3 mb-1">
|
||||
|
||||
+13
-3
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { slide } from "svelte/transition";
|
||||
import MasterPanel from "$lib/MasterPanel.svelte";
|
||||
import PageFooter from "$lib/PageFooter.svelte";
|
||||
import PlayerBoard from "$lib/PlayerBoard.svelte";
|
||||
@@ -15,18 +16,27 @@
|
||||
<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"
|
||||
class="text-5xl sm:text-7xl font-black italic tracking-tight bg-gradient-to-r from-rose-500 via-amber-500 to-rose-500 bg-clip-text text-transparent drop-shadow-[0_2px_0_rgba(0,0,0,0.15)]"
|
||||
>
|
||||
Lô tô
|
||||
</h1>
|
||||
<p
|
||||
class="text-[0.65rem] sm:text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400 italic mt-1"
|
||||
>
|
||||
Hội chợ Tân Tân
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<PlayerBoard />
|
||||
|
||||
{#if settings.masterMode}
|
||||
<section class="mt-10" aria-label="Bảng quản trò">
|
||||
<section
|
||||
class="mt-10"
|
||||
aria-label="Bảng quản trò"
|
||||
transition:slide={{ duration: 250 }}
|
||||
>
|
||||
<h2
|
||||
class="text-center text-xl sm:text-2xl font-extrabold tracking-tight mb-4 bg-gradient-to-r from-orange-500 to-red-500 bg-clip-text text-transparent"
|
||||
class="text-center text-lg sm:text-xl font-bold tracking-wider uppercase mb-4 text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
Quản trò
|
||||
</h2>
|
||||
|
||||
Reference in new issue
Block a user