feat(player): center "Chờ N" chip + pulse the awaited cell

The toast above the card was disconnected from where the user actually
looks (the 9×9 grid). Two-part redesign per /ck:research + /ck:brainstorm:

- Chip moved to the vertical center of the card with bg-amber-500/75 +
  backdrop-blur-sm + pointer-events-none on the wrapper. Taps still
  pass through to the cells underneath; auto-hide stays at 5s.
- Persistent 1.6s breathing pulse on the cell holding the awaited
  number — amber inset ring + soft outer glow, dimmed in
  prefers-reduced-motion to a static ring. Material 3-style cadence
  picked over scale-bounce because the grid is dense and bouncing
  cells would smudge neighbours.

Pulses scale to multiple simultaneous waiting rows (Set-keyed by
"row,col"). aria-label gains "đang chờ" so screen readers pick it up.
This commit is contained in:
2026-04-30 21:41:39 +07:00
parent 9d2699cf96
commit c4e103cd58
2 changed files with 52 additions and 6 deletions
+25
View File
@@ -107,6 +107,31 @@ body {
.section-label-waiting { animation: none; }
}
/* Per-cell waiting pulse — applied to the specific board cell that holds
the awaited number for a "Chờ" row. Amber inset ring + outer glow
breathe at 1.6s (Material 3 standard pulse cadence) to attract the
eye without fatigue over a long round. */
.cell-waiting {
animation: cell-waiting-pulse 1.6s ease-in-out infinite;
}
@keyframes cell-waiting-pulse {
0%, 100% {
box-shadow:
inset 0 0 0 3px rgb(245 158 11 / 0.45);
}
50% {
box-shadow:
inset 0 0 0 3px rgb(245 158 11 / 0.95),
0 0 8px 2px rgb(245 158 11 / 0.5);
}
}
@media (prefers-reduced-motion: reduce) {
.cell-waiting {
animation: none;
box-shadow: inset 0 0 0 3px rgb(245 158 11 / 0.7);
}
}
/* 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. */
+27 -6
View File
@@ -76,6 +76,24 @@
: []
);
// "row,col" keys of cells holding the awaited number for any waiting
// row. Drives the per-cell pulse animation so the user can spot which
// number to find without reading text. Multiple rows may be waiting
// simultaneously — each gets its own pulsing cell.
const waitingCells = $derived.by(() => {
/** @type {Set<string>} */
const set = new Set();
if (!grid || crossed.length === 0) return set;
for (let r = 0; r < grid.length; r++) {
if (rowCompleteness[r]) continue;
const num = getWaitingNumber(grid, crossed, r);
if (num === null) continue;
const c = grid[r].indexOf(num);
if (c >= 0) set.add(`${r},${c}`);
}
return set;
});
function dismissToast() {
toast = null;
if (toastTimer) {
@@ -400,6 +418,7 @@
{@const hasNumber = num > 0}
{@const isCrossed = hasNumber && !!crossed[row]?.[col]}
{@const rowComplete = hasNumber && rowCompleteness[row]}
{@const isWaitingCell = hasNumber && waitingCells.has(`${row},${col}`)}
{#if !hasNumber}
<!-- Empty cell. Dark-mode dim is an overlay div, not a CSS
@@ -418,7 +437,7 @@
{:else}
<button
type="button"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}{isWaitingCell ? ', đang chờ' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="tan-tan-num relative flex items-center justify-center
@@ -427,6 +446,7 @@
border border-slate-400/50 dark:border-slate-600/40
transition-all select-none cursor-pointer active:scale-90
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-rose-400
{isWaitingCell ? 'cell-waiting' : ''}
{isCrossed
? rowComplete
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-700 dark:text-emerald-200'
@@ -464,19 +484,20 @@
</div>
{#if toast}
<!-- Anchor toast ABOVE the grid (not centered over playable cells)
so it announces without blocking row taps. Pointer-events
still routed to the dismiss button only. -->
<!-- Centered overlay over the card. Lower opacity + pointer-events
routing keeps cell taps unblocked while the chip is visible.
Cell-pulse on the awaited number is the persistent visual
anchor; this chip is the textual fresh-event cue. -->
<div
role="status"
aria-live="polite"
class="absolute left-1/2 -translate-x-1/2 -top-3 sm:-top-4 z-10 pointer-events-none"
class="absolute inset-0 z-10 flex items-center justify-center pointer-events-none"
>
<button
type="button"
onclick={dismissToast}
aria-label="Đóng thông báo"
class="pointer-events-auto px-5 py-2.5 rounded-2xl bg-amber-500/95 dark:bg-amber-600/95 text-white text-lg sm:text-xl font-black shadow-xl animate-toast"
class="pointer-events-auto px-6 py-3 rounded-2xl bg-amber-500/75 dark:bg-amber-600/75 text-white text-2xl sm:text-3xl font-black shadow-2xl animate-toast backdrop-blur-sm"
>
{toast}
</button>