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:
2026-04-28 11:05:43 +07:00
parent 2b74b2e2e9
commit a60ea08f37
5 changed files with 67 additions and 8 deletions
@@ -1,9 +1,10 @@
---
name: Per-row "Chờ" indicator
phase: 5
status: todo
status: completed
priority: medium
effort: 1h
completed: 2026-04-28
---
# Phase 5 — Per-row "Chờ" visual indicator
@@ -1,9 +1,10 @@
---
name: Confetti polish
phase: 6
status: todo
status: completed
priority: low
effort: 30m
completed: 2026-04-28
---
# 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` |
| 3 | Mode picker glyph redesign ✅ | `phase-03-mode-picker-glyphs.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` |
| 6 | Confetti polish (threshold + variety) | `phase-06-confetti-polish.md` |
| 5 | Per-row "Chờ" indicator | `phase-05-cho-row-indicator.md` |
| 6 | Confetti polish (threshold + variety) | `phase-06-confetti-polish.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` |
| 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
prefers a system condensed face; falls back to negative letter-spacing
on platforms without one so the digits still read tall-and-tight. */
@@ -187,7 +205,7 @@ body {
position: absolute;
top: 0;
left: var(--x);
font-size: 2rem;
font-size: var(--size, 2rem);
animation: confetti-fall 1.6s ease-in var(--delay) forwards;
will-change: transform, opacity;
}
+42 -3
View File
@@ -32,7 +32,9 @@
// 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 = ["🎊", "✨", "🎉", "🥳"];
// 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)
/** @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() {
toast = null;
if (toastTimer) {
@@ -110,7 +124,19 @@
notifiedWaitingRows.add(i);
congratsRow = i + 1;
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();
break;
}
@@ -219,6 +245,14 @@
"TN1 (2014-2017)",
"Độ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>
<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="flex-1">
{#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">
{#each grid.slice(startRow, startRow + 3).flat() as num, idx (idx)}
{@const row = startRow + Math.floor(idx / 9)}
@@ -378,6 +416,7 @@
style:--x="{(i * 8.3 + (i % 3) * 11) % 100}%"
style:--delay="{(i * 37) % 400}ms"
style:--rot="{(i * 67) % 360}deg"
style:--size="{(1.5 + ((i * 13) % 11) / 10).toFixed(2)}rem"
>
{CONFETTI_EMOJI[i % CONFETTI_EMOJI.length]}
</span>