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:
tiennm99 committed 2026-04-27 09:38:18 +07:00
1 parent 0b6c41faad
commit d77d4a5652
14 files changed
+897 -66

No files matched your search

+4 -4
View File
@@ -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 |
+10 -3
View File
@@ -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
View File
@@ -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;
}
+25 -5
View File
@@ -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'}"
+61 -6
View File
@@ -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}
+40 -45
View File
@@ -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
View File
@@ -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>