feat(player-board): persistent Chờ ring + livelier confetti

Three player-board polish tweaks:

1. Per-section Chờ indicator. Each section's label band glows amber
   while any of its 3 rows is one cell from bingo. Uses the same amber
   as the toast so colour stays consistent. Animation respects
   prefers-reduced-motion.

2. Confetti threshold drops from 3+ bingos to: 2nd bingo OR 1st bingo
   while another row is in Chờ. The old threshold rarely fired on a
   9-row card so most wins felt under-celebrated.

3. Confetti emoji set adds 🥢 🎋 🏮 (chopsticks, bamboo, lantern) for
   hội-chợ flavour, plus per-piece size jitter (1.5–2.4rem) via a
   --size CSS variable.
This commit is contained in:
tiennm99 committed 2026-04-28 11:05:43 +07:00
1 parent 2b74b2e2e9
commit a60ea08f37
5 files changed
+67 -8

No files matched your search

@@ -1,9 +1,10 @@
--- ---
name: Per-row "Chờ" indicator name: Per-row "Chờ" indicator
phase: 5 phase: 5
status: todo status: completed
priority: medium priority: medium
effort: 1h effort: 1h
completed: 2026-04-28
--- ---
# Phase 5 — Per-row "Chờ" visual indicator # Phase 5 — Per-row "Chờ" visual indicator
@@ -1,9 +1,10 @@
--- ---
name: Confetti polish name: Confetti polish
phase: 6 phase: 6
status: todo status: completed
priority: low priority: low
effort: 30m effort: 30m
completed: 2026-04-28
--- ---
# Phase 6 — Confetti polish # Phase 6 — Confetti polish
@@ -21,8 +21,8 @@ YAGNI — parking-lot features and upstream-blocked items skipped.
| 2 | CI inline-script guard ✅ | `phase-02-ci-inline-script-guard.md` | | 2 | CI inline-script guard ✅ | `phase-02-ci-inline-script-guard.md` |
| 3 | Mode picker glyph redesign ✅ | `phase-03-mode-picker-glyphs.md` | | 3 | Mode picker glyph redesign ✅ | `phase-03-mode-picker-glyphs.md` |
| 4 | Settings modal sticky on small screens ✅ | `phase-04-settings-modal-sticky.md` | | 4 | Settings modal sticky on small screens ✅ | `phase-04-settings-modal-sticky.md` |
| 5 | Per-row "Chờ" indicator | `phase-05-cho-row-indicator.md` | | 5 | Per-row "Chờ" indicator ✅ | `phase-05-cho-row-indicator.md` |
| 6 | Confetti polish (threshold + variety) | `phase-06-confetti-polish.md` | | 6 | Confetti polish (threshold + variety) ✅ | `phase-06-confetti-polish.md` |
| 7 | Strict CSP via hashed inline script | `phase-07-strict-csp-hashed.md` | | 7 | Strict CSP via hashed inline script | `phase-07-strict-csp-hashed.md` |
| 8 | Audio cache LRU rule | `phase-08-audio-cache-lru.md` | | 8 | Audio cache LRU rule | `phase-08-audio-cache-lru.md` |
| 9 | PWA install verification checklist | `phase-09-pwa-verify-install.md` | | 9 | PWA install verification checklist | `phase-09-pwa-verify-install.md` |
+19 -1
View File
@@ -133,6 +133,24 @@ body {
); );
} }
/* Persistent visual cue when any of the section's 3 rows is in "Chờ"
(one cell from bingo). Amber matches the toast — `bg-amber-500/95`
in PlayerBoard — so users associate the same colour with the same
meaning. Inset ring so the label band keeps its natural width. */
.section-label-waiting {
box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.6);
animation: section-pulse 2.4s ease-in-out infinite;
}
@keyframes section-pulse {
0%, 100% { box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.45); }
50% { box-shadow: inset 0 0 0 2px rgb(245 158 11 / 0.85); }
}
@media (prefers-reduced-motion: reduce) {
.section-label-waiting { animation: none; }
}
/* Tân Tân BAMBOORAFT-style number — tall, condensed, heavy black. Stack /* Tân Tân BAMBOORAFT-style number — tall, condensed, heavy black. Stack
prefers a system condensed face; falls back to negative letter-spacing prefers a system condensed face; falls back to negative letter-spacing
on platforms without one so the digits still read tall-and-tight. */ on platforms without one so the digits still read tall-and-tight. */
@@ -187,7 +205,7 @@ body {
position: absolute; position: absolute;
top: 0; top: 0;
left: var(--x); left: var(--x);
font-size: 2rem; font-size: var(--size, 2rem);
animation: confetti-fall 1.6s ease-in var(--delay) forwards; animation: confetti-fall 1.6s ease-in var(--delay) forwards;
will-change: transform, opacity; will-change: transform, opacity;
} }
+42 -3
View File
@@ -32,7 +32,9 @@
// 12 confetti emoji indices. Stable per-render — values don't matter, // 12 confetti emoji indices. Stable per-render — values don't matter,
// only the count drives the {#each}. // only the count drives the {#each}.
const CONFETTI = Array.from({ length: 12 }, (_, i) => i); const CONFETTI = Array.from({ length: 12 }, (_, i) => i);
const CONFETTI_EMOJI = ["🎊", "✨", "🎉", "🥳"]; // Hội-chợ flavour added (lantern, bamboo, chopsticks) so the
// celebration set reads as Vietnamese fair, not generic party.
const CONFETTI_EMOJI = ["🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮"];
// Plain refs (not reactive) // Plain refs (not reactive)
/** @type {ReturnType<typeof setTimeout> | null} */ /** @type {ReturnType<typeof setTimeout> | null} */
@@ -51,6 +53,18 @@
: [] : []
); );
// Per-row "Chờ" flag — one cell away AND not already complete. Reuses
// rowCompleteness so we don't re-walk the row twice.
const waitingRows = $derived(
grid && crossed.length
? grid.map(
(_, r) =>
!rowCompleteness[r] &&
getWaitingNumber(grid, crossed, r) !== null,
)
: []
);
function dismissToast() { function dismissToast() {
toast = null; toast = null;
if (toastTimer) { if (toastTimer) {
@@ -110,7 +124,19 @@
notifiedWaitingRows.add(i); notifiedWaitingRows.add(i);
congratsRow = i + 1; congratsRow = i + 1;
showCongrats = true; showCongrats = true;
celebrationTier = celebratedRows.size >= 3 ? 2 : 1; // Tier 2 confetti: 2nd bingo, OR 1st bingo while another row
// is one cell away. The previous "3+ bingos" threshold rarely
// fired on a 9-row card so most wins felt under-celebrated.
const hasActiveCho = grid.some(
(_, r) =>
!celebratedRows.has(r) &&
getWaitingNumber(grid, crossed, r) !== null,
);
celebrationTier =
celebratedRows.size >= 2 ||
(celebratedRows.size >= 1 && hasActiveCho)
? 2
: 1;
if (announce) playBingo(); if (announce) playBingo();
break; break;
} }
@@ -219,6 +245,14 @@
"TN1 (2014-2017)", "TN1 (2014-2017)",
"Độc-Đỉnh-Điên", "Độc-Đỉnh-Điên",
]; ];
// Persistent ring on the section band when any of its 3 rows is
// waiting on a single number. Reduces reliance on the 5s toast.
const sectionHasWaiting = $derived(
SECTIONS.map((startRow) =>
waitingRows.slice(startRow, startRow + 3).some(Boolean)
)
);
</script> </script>
<div class="flex flex-wrap justify-center gap-3 mb-6"> <div class="flex flex-wrap justify-center gap-3 mb-6">
@@ -257,7 +291,11 @@
<div class="section-divider-vertical" aria-hidden="true"></div> <div class="section-divider-vertical" aria-hidden="true"></div>
<div class="flex-1"> <div class="flex-1">
{#each SECTIONS as startRow, sectionIdx (sectionIdx)} {#each SECTIONS as startRow, sectionIdx (sectionIdx)}
<div class="section-label">{SECTION_LABELS[sectionIdx]}</div> <div
class="section-label {sectionHasWaiting[sectionIdx] ? 'section-label-waiting' : ''}"
>
{SECTION_LABELS[sectionIdx]}
</div>
<div class="loto-grid"> <div class="loto-grid">
{#each grid.slice(startRow, startRow + 3).flat() as num, idx (idx)} {#each grid.slice(startRow, startRow + 3).flat() as num, idx (idx)}
{@const row = startRow + Math.floor(idx / 9)} {@const row = startRow + Math.floor(idx / 9)}
@@ -378,6 +416,7 @@
style:--x="{(i * 8.3 + (i % 3) * 11) % 100}%" style:--x="{(i * 8.3 + (i % 3) * 11) % 100}%"
style:--delay="{(i * 37) % 400}ms" style:--delay="{(i * 37) % 400}ms"
style:--rot="{(i * 67) % 360}deg" style:--rot="{(i * 67) % 360}deg"
style:--size="{(1.5 + ((i * 13) % 11) / 10).toFixed(2)}rem"
> >
{CONFETTI_EMOJI[i % CONFETTI_EMOJI.length]} {CONFETTI_EMOJI[i % CONFETTI_EMOJI.length]}
</span> </span>