Master-only mode never mounts PlayerBoard, so no Chờ/Kinh detection
fires — the toggle does nothing. Hide both it and its child
"Đọc thêm số đang chờ" sub-toggle in master mode to avoid the dead
control. Visible in player and both modes as before.
The SVG-line replacement from the gradient-cleanup refactor (commit
2fb35f2) drew the slash on the main diagonal (top-left → bottom-right)
and stopped at the inset:6px box edge — visually disconnected from the
cell vertices. The original CSS gradient stripe goes corner-to-corner
of the inset box on the anti-diagonal (the natural slash direction
for Vietnamese lô tô), with a clip-path reveal animation that reads
as "drawing" the slash.
- Restore .cell-crossed / .cell-crossed-win pseudo classes
- Replace the inline <svg> markup with a single <span> + class
- Restore cross-draw keyframes (clip-path inset reveal)
- prefers-reduced-motion: keep slash drawn, drop the reveal animation
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.
The "lock" framing was misleading — there's no OS-level mutex; this is
a soft coordinator that decides which tab is the active one. Rename
makes the role obvious at the import site.
- file: tab-lock.svelte.js → active-tab.svelte.js (+ test)
- export: tabLock → activeTab
- field: .frozen → .inactive (positive form: this tab is inactive)
- fn: startTabLock → watchActiveTab
- fn: reclaimTab → claimActiveTab
- BroadcastChannel name: loto_tab_lock → loto_active_tab
No behavior change. Banner copy already updated separately.
Two tabs of the app on the same origin both running auto-call would
double-draw, double-write `loto_master`, and overlap audio. New tab
now broadcasts a claim; old tab freezes itself with a fullscreen
overlay ("Loto đã mở ở tab khác. Tap để chuyển về tab này.") and the
user can tap to take it back — handover broadcasts a fresh claim,
freezing the other tab in turn.
Mounted in +layout.svelte's onMount so the cleanup closes the channel
on HMR / route changes. No-op in legacy iOS Safari (≤15.4) without
BroadcastChannel — silently falls through to the prior behavior.
Master is the audio owner in both mode and is already calling numbers
aloud, so the player-side "Chờ N" announcement that fires right after
a master call confused listeners — they couldn't tell which number was
the active draw vs the awaited row.
- voice.js playWaiting: gate `speakNumber` on `mode !== "both"`
- SettingsButton: hide the "Đọc thêm số đang chờ" toggle in both mode
The bare-word "Chờ" still plays so the host knows a row is one cell
away. Setting persists across mode toggles — the gate is at the call
site, not in storage.
Single-slot bus carried only the latest draw, so any state event off-bus
(player regen, master "Ván mới", reload, mode toggle, throttled tab)
silently lost history. Symptom the host hit: regenerating the player
board mid-game wiped all prior auto-crosses.
- master-store.svelte.js: lifted {called, remaining} out of MasterPanel
into shared reactive $state, persisted to loto_master, hydrated once
in +layout.svelte's onMount so panels mount with consistent state
- player-auto-cross.js: new applyMasterCalls helper using cursor-by-index
(vs the retired Date.now-based at timestamp), so callers can pass
lastHandledIndex: 0 to replay master's full history on demand
- PlayerBoard:
- Reads masterState.called directly; cursor advances strictly
- manualUnticks Set tracks user-initiated unticks of called numbers,
suppressing re-cross on replay; persisted to loto_manualUnticks
- "Tạo bảng mới" replays masterState.called onto fresh grid (in both)
- "Xoá đánh dấu" clears + immediately replays in both mode
- Master "Ván mới" detected by called length transitioning >0 → 0,
force-clears player crossed + manualUnticks (locked product call)
- Killed call-bus.svelte.js + auto-tick.js and their tests; helper
surface is fully covered by master-store.test.js (9) and
player-auto-cross.test.js (10), plus 6 new manualUnticks cases in
game-logic.test.js (134 tests passing, was 123 before this refactor)
Targets findings F1, F2, F4, F6, F7, F8, F10 from the 2026-04-30
both-mode consistency audit. F9 (voice ownership) and #20 (multi-tab)
remain out of scope — separate plans to follow.
Two $effects were reading state they also wrote, turning each into its
own dependency:
- AutoCountdown reset effect: `now = tickStart` read tickStart after
writing it. Use a local snapshot so neither rune is read post-write.
- MasterPanel auto-call effect: `tickCount++` read tickCount. Drop the
bump here — AutoCountdown's reset effect already re-baselines on the
rising edge of `running` and on `duration` change, so re-arms remain
covered. handleDrawNext keeps its per-draw bump (not in effect scope).
Visible countdown ring + seconds number above the hero token while
auto-call runs. Host now sees exactly when the next number fires
instead of staring at a static caption.
- AutoCountdown.svelte: pure visual component, props-driven, rAF loop,
SVG ring via stroke-dashoffset, prefers-reduced-motion fallback
- MasterPanel.svelte: tickCount $state bumped per draw + on every
(re-)arm of the auto-call $effect (covers speed-slider mid-run)
All implemented plan folders removed (current backlog + 8 archived).
PWA verification checklist preserved inline in todo.md as the only
remaining post-deploy work.
8 of 9 phases shipped (only manual PWA verification remains). Rewrite
the hand-off list to reflect current state: residual UX polish,
upstream-blocked tech debt, parking-lot features, and a "Recently
shipped" tail listing the 8 phases delivered today.
Workbox's CacheFirst rule for /audio/*.mp3 used a 30-day age-based
TTL. Approximate LRU by dropping to 7d — a voice clip stays cached as
long as it's played at least once a week, otherwise it falls out and
re-fetches on next play. Each clip is <200KB and same-origin so the
re-fetch cost is negligible. Also flip purgeOnQuotaError so the
runtime cache yields first under storage pressure.
Default voice precache (vite.config.js additionalManifestEntries)
unaffected — it's a separate workbox flow with its own revision.
Postbuild script computes SHA-256 of every inline <script> in
build/index.html and rewrites build/_headers — replacing the
script-src 'unsafe-inline' relaxation with the matching hashes. The
hash regenerates per build (SvelteKit bootstrap embeds a per-build
registration call) so the script must run on every build; chain it
into both `npm run build` and `build:gh`.
verify-build extended to assert build/_headers script-src no longer
contains 'unsafe-inline', so the inject step's output is enforced in
CI. style-src 'unsafe-inline' stays — Svelte's `style:` directives
emit inline attributes that hashes can't cover.
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.
Two small UX fixes in the settings modal:
1. Mode picker "Cả hai" glyph was two stacked rectangles — read as
"windows" rather than the two roles being combined. Replace with a
mini player-grid + mini megaphone side-by-side so the glyph mirrors
the two single-mode glyphs concretely.
2. On iPhone SE the modal body pushes the title and the action row off
screen, so the close button needs scrolling-back to reach. Make the
title block sticky at top and the action row sticky at bottom
inside the existing scroll container; move the panel padding from
the outer wrapper into per-section padding so the sticky bands can
span edge-to-edge with matching backgrounds.
SvelteKit emits one inline bootstrap <script> in build/index.html and
the CSP in static/_headers is relaxed to `script-src 'unsafe-inline'`
to admit it. If a SvelteKit upgrade adds another inline block, the
relaxation no longer matches reality and the new block could ship
unhashed.
`npm run verify:build` reads build/index.html, counts inline scripts
(no `src=`), and fails when count > EXPECTED_INLINE (1). New GH
Actions workflow runs test + build + verify on push/PR to main.
Mutation-tested locally: setting EXPECTED_INLINE=0 fails as expected,
restored to 1 passes.
9-phase plan covering the highest-leverage items from `plans/todo.md`
plus tech-debt and UX polish. YAGNI cuts: GhostBoardPreview (rule of
three not met), upstream-blocked override removals, parking-lot
features. Phase 1 (auto-tick test) marked completed.
Pull the bus-driven auto-tick effect body out of PlayerBoard.svelte
into `src/lib/auto-tick.js` so the dedup-by-`at` invariant — the one
that already caught a P0 — is unit-testable without mounting Svelte.
The effect is now a thin wrapper that calls `processAutoTick()` and
applies the returned `{crossed, lastHandledAt, changed}`.
8 vitest cases cover NEW draw, dedup on same `at`, re-cross after
manual untick, mode=master/player ignored (timestamp still advances),
off-board number, null lastDraw, and null grid.
- Move all 8 completed plans (260426-* and 260427-*) into
plans/archive/ to keep the active plans/ dir uncluttered.
- Add plans/todo.md as a hand-off list for next session: highest
leverage items first (auto-tick integration test, CI inline-script
smoke check, PWA install verification), then UX polish queued from
pass-2 reviews, tech-debt items, and a parking-lot of new features.
P0:
- CSP `script-src` was 'self' only, but SvelteKit's static export
emits a small inline bootstrap script. Without 'unsafe-inline' the
entire app silently fails under Cloudflare Pages CSP enforcement.
Verified by inspecting the built index.html.
- manifest `background_color` was the dark base (#0a0f1f); for the
~50% of users on light mode that gave a dark splash flash on every
install/launch. Switch to #f8fafc to match the default light theme.
- <title> bare "Lô tô" mismatched manifest name "Lô tô — Hội chợ TN1";
align both to the same string so OS install prompt + browser tab
match.
Medium:
- Audio runtime cache `cacheableResponse.statuses` was [0, 200].
Audio is same-origin, so opaque (0) responses can never legitimately
appear; tightening to [200] removes a CDN-poisoning replay window.
- Voice hint copy: "Đọc số đã xổ + báo Chờ/Kinh khi ở Cả hai" was
shown in master-only mode too, where the hint is wrong (no player
board → no Chờ/Kinh). Split copy per mode.
Cosmetic:
- Drop `includeAssets: ["icons/*.png", "audio/**/*.mp3"]` — both are
already in static/, so the option was a no-op.
- Replace `defaultVoiceId` fallback `"hoai-my"` with a hard read; the
manifest is committed and authoritative — duplicate fallbacks just
invite drift if the manifest ever rotates.
Verified: npm test 115/115; npm run build clean (305 precache entries,
no glob warnings); npm audit 0 vulnerabilities.
Reports: plans/reports/{code-reviewer,ui-ux-designer,security}-260427-2047-pass2-full.md
`@vite-pwa/sveltekit` pulled in `serialize-javascript@6.0.2` via
workbox-build → @rollup/plugin-terser, which is flagged for High RCE
(GHSA-5c6j-r48x-rmvq) and Medium DoS (GHSA-qj8w-gfj5-8c6v). Both fix
to >=7.0.5.
Also opportunistically bump `cookie` past 0.7.0 (Low GHSA-pxg6-pf52-xh8x)
via @sveltejs/kit — was the last-remaining Dependabot Low.
Build-time tooling only — neither package ships to the browser. Pinned
via npm `overrides` rather than `audit fix --force` because the latter
would downgrade @vite-pwa/sveltekit to 0.0.1 (breaking).
npm audit: 0 vulnerabilities. Tests 115/115. Build clean.
Phase 1 — Vietnamese-safe font + master empty state:
- Add @fontsource/roboto-condensed (700 weight, all subsets including
Vietnamese). font-display:swap. tan-tan-num now resolves the bundled
face on Android instead of system Arial Narrow.
- New MasterEmptyState.svelte: ghost 11×9 grid + "Chế độ Quản trò"
pill + readiness microcopy. Replaces the bare line of text in
MasterPanel's no-game state.
Phase 2 — Mode picker icons, color picker layout, header polish:
- Inline SVG glyphs above each mode button (player card / megaphone /
two stacked cards). Communicates role at a glance.
- Color picker wrapped in a single bordered card with "Tuỳ chỉnh"
and "Mẫu sẵn" sub-headers.
- "Mặc định" → "Đặt lại" with a bordered chip style — clearer
affordance than the previous near-invisible footer link.
- Header subline: drop tracking-[0.28em] all-caps SaaS look; replace
with dash-flanked lantern band ("— 🏮 Hội chợ TN1 —") for
fairground mood.
Phase 3 — Installable PWA + offline audio:
- @vite-pwa/sveltekit with autoUpdate. Precache app shell (~353 KB
→ 213 entries) PLUS the default voice's 92 clips so first-install
is fully offline-capable. Alternate voices fall through to a
CacheFirst runtime rule (cached on first play, 30-day TTL,
maxEntries: 400 for future-proofing).
- New static/manifest.webmanifest (Lô tô — Hội chợ TN1, theme #1565c0,
background #0a0f1f, standalone, vi).
- Icons: 192/512 standard + 512 maskable, generated from a single
rose-amber-gradient SVG source.
- app.html: manifest link, dual theme-color meta (light + dark),
apple-touch-icon, apple-mobile-web-app-capable for proper
standalone launch on iOS Safari.
- _headers: add manifest-src + worker-src to CSP; no-cache on /sw.js
and /manifest.webmanifest so deploys propagate.
Tests: 115/115 pass. Build clean (305 precache entries, 0 glob
warnings).
Reviewer concerns (addressed):
- maxEntries bumped 200 → 400 (was barely enough for 2 voices).
- Default voice precached so offline-first promise holds without
requiring users to play every clip online first.
- "do NOT add skipWaiting" comment added next to autoUpdate.
P0:
- PlayerBoard auto-tick: track lastDrawn.at non-reactively so the
effect re-firing on crossed/grid changes (manual untick, clear,
regen) no longer re-marks the latest drawn number.
- Toast moved above the grid — was overlaying middle cells for 5s.
- Bingo modal Escape now uses a window listener (matches settings
modal pattern); the inline onkeydown rarely fired.
- Dark winning-row contrast: bg-emerald-900/60 + text-emerald-200
to clear WCAG AA. New .cell-crossed-win class flips the slash to
emerald so completed rows read as "win" not "marked off".
- master-only mode: keep "Quản trò" h2 visible so the page has
context when no player board sits above.
P1:
- Reset bus on PlayerBoard handleClear/handleGenerate.
- Master mode picker now shows a per-mode hint line.
- Hide "Quản trò đọc số" toggle entirely in mode=player (no effect
there). Indent auto-call slider with the same nested-border
treatment as voice waiting.
- Hero number scales smaller on ≤375px (w-32 / border-6).
- aria-live=polite on hero (was assertive — informational not urgent).
- MasterPanel auto-stop early-return without redundant write.
- Drop in-card "Made by miti99" — duplicates the page footer.
- Replace biased Math.random sort shuffle with Fisher-Yates in
randomNumbersInCol.
- Reduced-motion gates: vibrate, smooth scroll, all keyframe
animations short-circuit when prefers-reduced-motion: reduce.
- History pill border-2 (was border-3, cramped 88s).
Security:
- static/_headers: CSP, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy, X-Frame-Options for Cloudflare Pages.
- safeParse / loadSettings / MasterPanel.loadState: payload-size cap
before JSON.parse + reviver strips __proto__ and constructor keys
as defense-in-depth.
- voice.clipUrl: encodeURIComponent on voice id and clip name.
Tests: +findUncrossedCell unit tests (covers the auto-tick path
that exposed the P0), +voice.js cancellation/chaining tests
(playWaiting honours voiceWaitingNumber). 115/115 pass.
Skipped (need new assets / deeper scope):
- Vietnamese-supporting condensed @font-face (needs font upload).
- Full master empty-state hero illustration.
- Confetti emoji variety, color picker redesign, mode picker icons,
brand-mood overhaul of the header subline.
- Improve section accent/divider color theming in app.css
- Enhance dark mode glow and body→html background layering
- Increase footer text size for better mobile legibility (text-xs → text-sm)
- Gate voiceWaitingNumber announcements behind new setting flag
- Add `mode` setting to replace legacy `masterMode` with migration path
- Implement 3-button display mode picker (player-only, master-only, both)
- Auto-increment master when card drawn via call-bus event system
- Add voice hint on settings button, gate auto-call on mode changes
- Broadcast draw events from MasterPanel, reset call-bus on new game
- Broadened announce condition to cover both modes
- New call-bus.svelte.js module for event coordination
- Update settings-store tests to cover mode migration
- Update codebase docs for mode setting and call-bus architecture
1. Settings gear was unclickable because H1's `drop-shadow` filter
created a stacking context that painted the H1 over the absolutely-
positioned trigger. Bump gear wrapper to z-10.
(src/routes/+page.svelte)
2. Switch rows in settings had a dead zone — `<label>` wrapped a
`<span role=switch>` (not a labelable element), so taps on the
text fell through. Move role/click/keydown to the wrapping div
so the whole row toggles. Pill stays decorative.
(src/lib/SettingsButton.svelte)
3. Empty cells used `dark:[filter:brightness(...)]` which creates a
per-cell stacking context — same hazard class as bug #1. Replace
with a pointer-events-none overlay span (`hidden dark:block`).
(src/lib/PlayerBoard.svelte)
4. Audio cache never evicted on voice swap, leaking ~92 Audio
elements per change over a session. New `clearAudioCache()` in
voice.js, called from `pickVoice` when the id actually changes.
(src/lib/voice.js, src/lib/SettingsButton.svelte)
Tests 98/98 pass; build clean.
- Run scripts/generate-audio.py to produce 184 MP3 clips (92 each
for vi-VN-HoaiMyNeural and vi-VN-NamMinhNeural), ~2.2 MB total.
- Cap edge-tts concurrency at 4 with 4-attempt exponential retry on
NoAudioReceived — earlier all-at-once gather() hit the upstream
rate limit and bailed mid-voice.
- .gitignore: add .venv/ + __pycache__/ for the local generator venv.
Speak the called number on master draw, "Chờ N" when a row is one
away, and "Kinh" on bingo. No runtime TTS API — clips are
pre-generated by `scripts/generate-audio.py` (free edge-tts) and
shipped as static MP3s under `static/audio/{voiceId}/`.
- src/lib/vietnamese-number.js + test (40 cases): tonal exceptions
mười lăm / hai mươi mốt / hai mươi lăm
- src/lib/voice.js: lazy <audio> cache, token-based cancellation,
cho+number sequencer, on-unmount cleanup
- src/lib/audio-manifest.js: re-exports static/audio/manifest.json
- scripts/generate-audio.py: discovers every vi-* edge-tts voice,
writes 92 clips per voice + manifest.json
- static/audio/manifest.json: placeholder until user runs the script
- src/lib/settings-store: +voiceEnabledMaster/voiceEnabledPlayer/voice
with per-key validators
- src/lib/SettingsButton: new "Âm thanh" fieldset (toggles + voice
picker rendered from manifest)
- MasterPanel.handleDrawNext: playNumber(next) + cancel on new game
- PlayerBoard $effect: playWaiting/playBingo beside toast/popup;
cancel on regenerate / clear
To materialize the MP3s on first install:
pip install edge-tts
python3 scripts/generate-audio.py
Tests: 98 pass (40 number + 31 settings + 27 game-logic).
Soft visual constraint: no row has cols n, n+1, n+2 all filled.
Implementation = constraint-aware per-row picker (uniformly samples
triple-free completions of the forced+candidate set) + whole-grid
rejection sampling (up to 200 attempts). Hard invariants (5 per row,
5 per col, ascending column values) are never sacrificed; if the soft
constraint can't be met, the generator returns the best attempt.
- src/lib/game-logic.js: hasThreeInARow, combinations,
pickFilledColsOnce, pickFilledCols rejection wrapper
- src/lib/game-logic.test.js: 300-trial strict assertion
- docs/codebase-summary.md, project-overview-pdr.md: note the rule
Lets the player wipe all crossed cells on the current card without
generating a new grid. Shown next to "Tạo bảng mới" only when a grid
exists. Confirms before clearing if any cell is marked.
- src/lib/PlayerBoard.svelte: handleClear + secondary outline button
- docs/codebase-summary.md, project-overview-pdr.md: reflect new action
The previous rule "/* / 301" matched / itself even though docs say
static assets take precedence — empirically CF still bounced and
caused ERR_TOO_MANY_REDIRECTS on loto.miti99.com.
Replace with two safer rules:
/master* / 301 (true URL redirect for the known orphan)
/* /index.html 200 (SPA fallback — URL stays, homepage shows)
The 200 rewrite cannot create a redirect loop. Static assets at /,
/_app/*, /favicon.* are served before rules fire.
Previous commit misread "remove master board" — restore the 11x9
ones-digit-aligned tracking grid (with circular tokens + draw-order
overlay) inside MasterPanel; remove the host's own player card
("Bảng của quản trò") instead. The host can use the player board
above it; no need for a duplicate card.
Delete /master route entirely (single-page app now). Cloudflare
Pages redirect added via static/_redirects: any unknown path
301-redirects to /, so old /master bookmarks land on the homepage.
Static assets are served first so the rule only fires on misses.
Storage keys loto_master_card_grid / loto_master_card_crossed are
no longer written but old saved data stays in users' localStorage
(harmless leftover).
Settings expansion: 4 new keys (theme auto/light/dark, masterMode,
autoCallEnabled, autoCallSpeed 1-10) with per-key validators that
preserve old saved data. Default empty-cell color flipped from Tân
Tân blue to Excel Standard Purple #7030A0; preset palette swapped
to Office's 10 standard colors (5x2 grid). 15 new tests, 53 total.
Theme system: Tailwind v4 @variant dark (.dark *); applyTheme()
toggles <html class="dark"> based on settings.theme; auto mode
mirrors prefers-color-scheme via matchMedia listener (cleanly torn
down when switching modes). Existing dark-mode CSS converted from
@media to :where(.dark) selectors.
Mobile fit: PlayerBoard cells aspect-square on mobile, sm:aspect-[3/5]
desktop. Number text scales text-base sm:text-2xl md:text-3xl. Page
padding tightened (px-2 py-4 sm:px-3 sm:py-12). Container bumped
max-w-lg to max-w-2xl.
Header / footer: removed instructions toggle and "Trang quản trò"
link from player page. New PageFooter.svelte (tagline + Made by
miti99 with [SVG heart] link); also duplicated in PlayerBoard's
closing section-label band per request. Heart is inline SVG (red),
not emoji.
Master mode: extracted everything from /master route into reusable
MasterPanel.svelte. /master route slimmed to header + MasterPanel
+ footer. / mounts MasterPanel conditionally when settings.masterMode.
Storage prefixes unchanged.
Master tracking grid removed: per request, the 11x9 ones-digit
master board is gone. Host still gets controls, "Số vừa xổ" hero,
draw history list, and their own player card. Player card is enough.
Auto-call: single $effect lifecycle keyed on (autoRunning,
settings.autoCallSpeed, settings.autoCallEnabled). Setup / clear
setInterval cleanly across speed changes, master-mode toggle off,
component unmount, and "user disabled auto" mid-run. Button toggles
"Xổ số" -> "Bắt đầu / Dừng". Speed slider in Settings (only visible
when master mode is on). aria-label / aria-valuetext on slider.
Code-review nice-to-fixes applied: folded the two MasterPanel $effects
into one, removed muddled onkeydown on the SettingsButton modal
backdrop, added slider a11y attrs.
Docs: PDR / codebase-summary / system-architecture / development-roadmap
/ code-standards all synced to the new state.
The attribution lives inside the closing section-label band now (with
hatch flanks on both sides) instead of as a separate page footer.
Link to miti99.com kept clickable, with hover styling to differentiate
it from surrounding label text. Drops the duplicated standalone
<footer> from / and /master.
Each label now renders as: [hatch fill] [padding] [LABEL] [padding]
[hatch fill]. Flex layout with ::before/::after pseudo-elements
filling the remaining width. Matches the BAMBOORAFT physical card's
✚✚✚✚ TÂN TÂN ✚✚✚✚ band; 12px horizontal padding around the text
keeps the label readable.
Card now framed only on left + right by the cross-hatch. Top edge is
the first "Tân Tân" section label and the bottom edge gets a closing
"Tân Tân tốt nhất" label, mirroring the physical sheet's printed
header / footer slogans.
Each cell now has a thin slate border on all 4 sides (slate-400/50 in
light, slate-600/40 in dark). Adjacent cells double up to a slightly
heavier 1-2px line — matches the visible grid lines on the physical
BAMBOORAFT card.
The BAMBOORAFT physical sheet has the ✚✚✚ blue border running
around the entire card edge, not just between mini-cards. Adds
matching top/bottom/left/right frames using the existing
.section-divider plus a new .section-divider-vertical. Drops the
thin slate outer border (replaced by the hatch frame) and the
per-cell border-r/border-b lines (the white-on-blue contrast already
delineates cells, matching the flat physical-paper look). Outer
wrapper rounded-md instead of rounded-2xl to feel more like
a printed card edge.
All three places where called numbers appear (hero, tracking grid
token, history list) — digit color now follows the ring: pink for
1–49, emerald for 50–90. Drops the rose-700 fallback that was
mismatched against both rings.