mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-14 16:17:43 +00:00
fix: address full-project review findings (P0/P1/P2 + security)
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.
This commit is contained in:
1 parent
c9b6c52bc0
commit
ad6291e924
14 files changed
+606
-97
No files matched your search
@@ -0,0 +1,77 @@
|
||||
# Code review — full project (260427-1151)
|
||||
|
||||
Scope: `src/` SvelteKit + Svelte 5. Skip `node_modules`/`.svelte-kit`/`build`.
|
||||
Focus: runes correctness, bus/state coordination, voice cancellation, edge cases, JSDoc, tests, anti-patterns.
|
||||
Overall: code is clean, well-commented, JSDoc-typed throughout. One real auto-tick re-mark bug; rest are minor.
|
||||
|
||||
## P0 — blocking
|
||||
|
||||
**1. Auto-tick re-marks cells the user cleared / unticked / regenerated** — `src/lib/PlayerBoard.svelte:136-151`.
|
||||
The auto-tick `$effect` reads `bus.lastDrawn` AND `crossed` AND `grid` reactively. So it re-fires when `crossed` changes (manual untick, "Xoá đánh dấu", "Tạo bảng mới") even though no new draw happened. With `bus.lastDrawn` still set, it scans for that number and re-marks it. Reproductions in "both" mode:
|
||||
- Master draws 42. User taps 42 to untoggle → effect re-runs, sees `!crossed[r][c]`, re-marks. User cannot ever untick the latest draw.
|
||||
- User taps "Xoá đánh dấu" → all marks clear → effect re-fires → 42 re-marked instantly.
|
||||
- User taps "Tạo bảng mới" → new `crossed` matrix → effect re-fires → 42 marked on the new card.
|
||||
Fixes: track `lastDrawn.at` (or the bus reference) in a non-reactive ref and early-exit when unchanged; OR call `resetBus()` from `handleClear`/`handleGenerate` AND skip the effect on the very first run after generate. Same hazard exists conceptually with the comment "auto-tick can't fight manual taps" — comment is wrong.
|
||||
|
||||
## P1 — high
|
||||
|
||||
**2. `handleClear` / `handleGenerate` don't reset `bus.lastDrawn`** — same file, lines 153-177. Even after fixing #1, the bus stays sticky across player-card resets. Master `handleNewGame` does reset it (`MasterPanel:139`); player resets should too in "both" mode (or at minimum, snapshot `lastDrawn.at` so post-clear effect knows to ignore stale draws).
|
||||
|
||||
**3. `handleClear` doesn't reset `congratsRow`/`celebrationTier` and leaves `showCongrats`** — `PlayerBoard.svelte:167-177` sets `showCongrats = false` but if a celebration was mid-animation the state is fine. However `congratsRow`/`celebrationTier` aren't reset; harmless until next bingo overwrites. P2.
|
||||
|
||||
**4. Multiple-row simultaneous bingo silently swallowed** — `PlayerBoard.svelte:102-112`. Pass 1 `break`s after first new completion, but pass 2 won't surface another. Not reachable today (one cell-toggle = one row at most). But auto-tick + cross-row coincidence + future logic changes could expose it. Add a short queue or track "pending celebrations" if you ever batch-mark.
|
||||
|
||||
**5. Bingo modal Escape only works when backdrop has focus** — `PlayerBoard.svelte:393-402`. `onkeydown={onModalKeydown}` is on the backdrop `<button>`; opening the modal doesn't auto-focus it, so Escape often doesn't fire. Settings modal solved this with a window listener (`SettingsButton.svelte:107-115`); replicate.
|
||||
|
||||
**6. MasterPanel auto-stop branch sets state during effect synchronously** — `MasterPanel.svelte:99-103`. `if (!autoRunning || !settings.autoCallEnabled) { autoRunning = false; return; }` writes the same value when `autoRunning` already false; harmless but produces a redundant effect re-run. Minor correctness — early-return without write when already false.
|
||||
|
||||
**7. `loadSettings` is not idempotent for theme listener if called twice without DOM teardown** — `settings-store.svelte.js:88-110`. `applyTheme` defensively clears prior listener via module-scoped `mql/mqlListener`, but module is shared across HMR boundaries. In production fine; in dev hot-reload the previous module's listener can leak. Not a prod concern. P2.
|
||||
|
||||
## P2 — medium
|
||||
|
||||
- **Smooth-scroll-on-draw ignores `prefers-reduced-motion`** — `MasterPanel.svelte:127-128`. Auto mode at 1s/draw forces page jumps. Gate on `matchMedia('(prefers-reduced-motion: reduce)')`.
|
||||
- **`navigator.vibrate(10)` on every cell click** — `PlayerBoard.svelte:184-186`. No reduced-motion gate; minor.
|
||||
- **`toastTimer` not cleared on component unmount** — leaks if PlayerBoard unmounts while toast pending. Wrap in `$effect(() => () => dismissToast())`.
|
||||
- **`onModalKeydown` only handles Escape** — settings modal uses window listener pattern; bingo modal should match for consistency.
|
||||
- **`CONFETTI` placement is deterministic** — `(i*8.3 + (i%3)*11) % 100` — fine, but visually clusters. Cosmetic.
|
||||
- **`generateGrid` uses `Math.random()` based shuffle** — `game-logic.js:31-34` does `arr.sort(() => 0.5 - Math.random())` which is biased. Replace with Fisher-Yates (already used in `pickFilledColsOnce:106-109` and `MasterPanel.svelte:35-38`). Bias is small but real and breaks the "uniform pick" assumption.
|
||||
- **`scrollOnNextDraw` is a plain `let` in a Svelte 5 component** — works because the gating effect is triggered by `lastCalled`, but reads non-reactively. Document or move into ref-style helper.
|
||||
- **Settings modal lacks focus trap and focus restoration** — clicking gear, then Escape, returns focus to body, not the gear button. Minor a11y.
|
||||
- **`storagePrefix` prop on PlayerBoard is unused (always default)** — `PlayerBoard.svelte:22`. YAGNI hook. If keeping, document it; otherwise drop.
|
||||
|
||||
## P3 — low / nits
|
||||
|
||||
- `celebrationTier` typed `1 | 2` but only `>= 3` switches to 2; type and threshold name mismatch — fine, but could be `'normal' | 'big'`.
|
||||
- `BOARD_FLAT` is computed at module load in `<script module>` — good, just note it persists across HMR.
|
||||
- `audio-manifest.js:23` `DEFAULT_VOICE = VOICES[0]?.id ?? "hoai-my"` — fallback string isn't validated against `VOICE_IDS` if manifest is empty; impossible today but fragile.
|
||||
- `cancelPlayback` resets `currentTime` after `pause()` — order is fine, but the cached element keeps `onended`/`onerror` nulled; next `playClip` reattaches. OK.
|
||||
|
||||
## Tests
|
||||
Coverage strong on `game-logic`, `settings-store`, `vietnamese-number`, `call-bus`. No vacuous assertions seen. Gaps:
|
||||
- No integration test for the auto-tick bus → PlayerBoard `crossed` flow (would have caught P0 #1).
|
||||
- No test for `MasterPanel` auto-call interval lifecycle (start/stop/speed-change re-arm).
|
||||
- No test for `voice.js` token cancellation (pure logic — easy to add).
|
||||
- `generateGrid` rejection sampling tested for "no triple" but not for the fallback path (return `last` after 200 attempts).
|
||||
|
||||
## Positives
|
||||
- Clean module boundaries; `game-logic` pure.
|
||||
- `safeParse` pattern for localStorage is solid.
|
||||
- `playClip` token/resolver dance handles cancellation correctly.
|
||||
- Theme `auto` listener cleanup is proper.
|
||||
- JSDoc coverage near 100%; types tight.
|
||||
- Migration path (`masterMode` → `mode: "both"`) preserved with tests.
|
||||
|
||||
## Recommended fix order
|
||||
1. P0 #1 — track `lastDrawn.at` non-reactively in PlayerBoard; only auto-tick when it changes.
|
||||
2. P1 #2 — call `resetBus()` from player resets (or rely on #1 fix making it moot).
|
||||
3. P1 #5 — window-level Escape on bingo modal.
|
||||
4. P2 — Fisher-Yates in `randomNumbersInCol`; reduced-motion gates.
|
||||
5. Add integration test covering auto-tick + manual-untick interaction.
|
||||
|
||||
## Unresolved questions
|
||||
- Is `storagePrefix` prop intended for a future feature (e.g. master also tracks their own card) or YAGNI?
|
||||
- For the auto-tick ergonomics: is "user untoggles latest draw" actually a desired flow, or is the auto-tick supposed to be authoritative? Affects the chosen fix for P0 #1.
|
||||
- Should `voiceEnabledMaster` driving the player Chờ/Kinh in "both" mode be made a separate setting? Current double-meaning (read in `PlayerBoard.svelte:97-99`) is non-obvious.
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** One P0 (auto-tick re-marks cleared cells), 6 P1, several P2 cosmetic/a11y. Code quality high overall.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Security & Reliability Audit — Lô tô
|
||||
|
||||
Date: 2026-04-27 | Scope: SvelteKit static export, no backend.
|
||||
Method: STRIDE + OWASP Top 10, manual review.
|
||||
Files scanned: 11 (`src/**`), build/deploy configs, `npm audit`.
|
||||
|
||||
## Summary
|
||||
- 0 Critical, 0 High, 3 Medium, 4 Low, 5 Informational.
|
||||
- No outbound network calls; no `{@html}`, `innerHTML`, `eval`, `fetch`, `XHR`, `WebSocket`, `sendBeacon` anywhere in `src/`. Privacy-clean.
|
||||
- All user input flows through validators; static export served with redirects only.
|
||||
|
||||
## Findings
|
||||
|
||||
### Medium
|
||||
|
||||
**M1 — No CSP / security headers configured.**
|
||||
`static/_redirects` only handles SPA fallback; no `_headers` file for Cloudflare Pages, no headers in GitHub Pages action. Missing `Content-Security-Policy`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, `X-Frame-Options`. With no CSP, defense-in-depth against future XSS or third-party-origin embedding is absent. Recommend adding `static/_headers` with restrictive CSP (`default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`). `'unsafe-inline'` for style is needed because Svelte emits inline `style:` attributes. (file: `static/_redirects`)
|
||||
|
||||
**M2 — `loadSettings` does not reject `__proto__` / `constructor` keys before destructuring.**
|
||||
`settings-store.svelte.js:125` does `JSON.parse(raw) ?? {}` then reads named properties. JSON.parse of `{"__proto__":{"polluted":1}}` does NOT pollute `Object.prototype` (modern JS treats `__proto__` from JSON as an own property), so this is theoretically safe today, but the validators read named props directly off `parsed` without `Object.hasOwn` checks. If future code ever uses spread or `Object.assign(target, parsed)` the surface grows. Same pattern in `game-logic.js:171` (`safeParse`). Defense-in-depth: parse with `JSON.parse(raw, (k,v) => k === "__proto__" ? undefined : v)` or check `Object.hasOwn`. (files: `src/lib/settings-store.svelte.js:121-153`, `src/lib/game-logic.js:168-176`)
|
||||
|
||||
**M3 — Auto-call interval lower bound borderline; no oversize-payload guard on localStorage reads.**
|
||||
`autoCallSpeed` validated 1-10s — fine. But `loadGrid` / `loadCrossedState` accept arbitrarily large strings up to localStorage's per-origin quota (~5-10MB) before `JSON.parse`. A poisoned origin (browser extension, shared device) could store a megabyte-scale string; parse stalls UI on mount. Add a length cap (`raw.length > 50_000` reject) before parse. Same applies to `loto_settings` and `loto_master`. (files: `src/lib/game-logic.js:222-228, 246-252`, `src/lib/settings-store.svelte.js:123`, `src/lib/MasterPanel.svelte:51-59`)
|
||||
|
||||
### Low
|
||||
|
||||
**L1 — `clipUrl` builds path from `settings.voice` only; relies entirely on `validVoiceId` allowlist.**
|
||||
`voice.js:38-40` interpolates `settings.voice` into a URL. `validVoiceId` (settings-store.svelte.js:67) checks `VOICE_IDS.has(v)` — Set of strings from the bundled manifest. Path-traversal blocked because allowlist values are static slugs. If `settings.voice` is ever set without going through validation (e.g. direct mutation in a future feature), `..%2F` could escape. Add a defensive `encodeURIComponent` on `name` and `settings.voice` in `clipUrl` for resilience. Clip `name` is currently always internal (`String(n)`, `"cho"`, `"kinh"`) — safe. (file: `src/lib/voice.js:38-40`)
|
||||
|
||||
**L2 — Audio cache unbounded growth.**
|
||||
`voice.js:11` keeps every constructed `<audio>` in a `Map` keyed by URL. Per voice = 92 entries (1-90 + cho + kinh). `clearAudioCache` only fires on voice change. With 2 voices today this is fine; if voices grow past ~10 the cache holds ~1000 audio elements. Add LRU cap or drop cache when document hides for >N min. (file: `src/lib/voice.js:11-49`)
|
||||
|
||||
**L3 — `crypto.getRandomValues` not used for grid / call shuffles.**
|
||||
`game-logic.js:31, 104, 107` and `MasterPanel.svelte:35-37` use `Math.random` for shuffle and combination selection. Game has no stake / no fairness guarantees so practical impact is zero, but `Math.random()` is V8's xorshift128+, not cryptographically random; `arr.sort(() => 0.5 - Math.random())` in `randomNumbersInCol` is also a known-biased shuffle (use Fisher-Yates throughout). Information only — replace with Fisher-Yates for bias correctness, not security. (files: `src/lib/game-logic.js:31`, `src/lib/MasterPanel.svelte:35-39`)
|
||||
|
||||
**L4 — `npm audit` reports 3 Low vulns (transitive `cookie` <0.7.0 via @sveltejs/kit).**
|
||||
Static export — runtime never parses cookies, so the OOB-character issue (CWE-74) is not exploitable here. Bump SvelteKit to a release shipping `cookie` ≥0.7.0 next dependency sweep.
|
||||
|
||||
### Informational
|
||||
|
||||
**I1 — `confirm()` race vs auto-call.**
|
||||
`MasterPanel.svelte:134` shows native `confirm()` for "Ván mới". `setInterval` callback runs while modal is open (browser-dependent — Chrome pauses, Safari may not). On rapid double-click of "Ván mới" while autoRunning, state could mutate mid-confirmation. Low practical impact. (file: `src/lib/MasterPanel.svelte:99-113, 133-140`)
|
||||
|
||||
**I2 — `bus.lastDrawn` never cleared after `bus.lastDrawn` set, only on `resetBus()`.**
|
||||
If master mode toggles off/on with `mode === "both"` after a draw, `PlayerBoard`'s auto-tick effect re-fires on remount with the stale draw and may double-mark. Effect already has the `!crossed[r][c]` guard so it's idempotent — but a stricter design clears `bus` on mode flip. (file: `src/lib/call-bus.svelte.js`, `src/lib/PlayerBoard.svelte:136-151`)
|
||||
|
||||
**I3 — `<a href="https://miti99.com" target="_blank" rel="noopener noreferrer">` is correctly hardened.** Both occurrences (`PlayerBoard.svelte:295`, `PageFooter.svelte`) include `noopener noreferrer`. No reverse-tabnabbing risk.
|
||||
|
||||
**I4 — `.env.local` committed to repo via tracked file but only contains non-secret hostnames.**
|
||||
`.env.local` (in working tree, excluded by `.gitignore` for future) holds `CODESERVER_HOST=codeserver.sg.miti99.com`. Not a credential, but reveals dev host. Confirm gitignore excludes it (it does — `.env*.local`). No remediation needed if not in git history. Verify with `git log --all -- .env.local`.
|
||||
|
||||
**I5 — GitHub Pages action interpolates `DEST` into HTML via shell heredoc.**
|
||||
`.github/workflows/deploy-github-pages.yml:24-50` builds redirect HTML. `DEST` is hardcoded; if it ever becomes a workflow input, the heredoc would shell-interpolate without escaping. Currently safe.
|
||||
|
||||
## Trust-boundary verification
|
||||
- `--empty-cell-bg` injection (M2 from spec): regex `/^#[0-9a-fA-F]{6}$/` is **tight** — anchored, length-fixed, no whitespace. Cannot escape into `;color:red;` or url() injection. PASS.
|
||||
- localStorage poisoning: type validators present for every key; falls back to default on mismatch. The remaining gap is M2 above (proto keys / payload size). PASS with caveat.
|
||||
- Voice path traversal: allowlist enforced; clip names internal-only. PASS.
|
||||
- Resource exhaustion: auto-call clamped 1-10s, `setInterval` cleared on dependency change. Audio cache addressed in L2.
|
||||
- Privacy: `grep -rn "fetch\|XMLHttpRequest\|sendBeacon\|WebSocket\|EventSource" src/` returns 0 hits. PASS.
|
||||
- Build/deploy secrets: none in `wrangler.toml`, `svelte.config.js`, `.env.example`. `.env.local` in tree but only non-secret hostnames.
|
||||
|
||||
## Unresolved questions
|
||||
1. Is `'unsafe-inline'` style acceptable for the M1 CSP, or should Svelte be configured to extract `style:` directives into hashed inline blocks?
|
||||
2. Will the voice list grow beyond 2 entries (affects L2 priority)?
|
||||
3. Is `.env.local` in any historical git commit, or only working tree? (run `git log --all --oneline -- .env.local`)
|
||||
4. Cloudflare Pages serves `_headers` — is GitHub Pages mirror also expected to enforce the same headers, or is the GitHub deploy redirect-only (so headers don't matter for that origin)?
|
||||
@@ -0,0 +1,118 @@
|
||||
# Lô tô — Full UI/UX Audit (260427)
|
||||
|
||||
Stack: SvelteKit + Tailwind 4. Mobile-first, single-page. Audit scope: app.css, +page.svelte, PlayerBoard, MasterPanel, SettingsButton, PageFooter.
|
||||
|
||||
Severity: P0 = breaks UX/a11y, P1 = noticeable polish/clarity, P2 = nice-to-have.
|
||||
|
||||
---
|
||||
|
||||
## P0
|
||||
|
||||
### 1. Master-only mode looks abandoned on first load
|
||||
**Where:** `+page.svelte:37-52`, `MasterPanel.svelte:323-327`.
|
||||
Mode=master + no game started = page shows only "Ván mới" button + "Nhấn 'Ván mới' để bắt đầu" text. No header context, no preview, no hero. Feels like a broken page, not "the master tool".
|
||||
**Fix sketch:** add empty-state hero (decorative 11x9 ghost board mock like PlayerBoard's preview, or a "Chuẩn bị xổ số" illustration). Surface a "Đang ở chế độ Quản trò" pill near top so the role is explicit when no player board sits above.
|
||||
|
||||
### 2. `cell-crossed` red diagonal — contrast on green winning row
|
||||
**Where:** `app.css:184-196`, `PlayerBoard.svelte:281`.
|
||||
Winning row uses `bg-emerald-100`/`text-emerald-700` + `#ef4444` diagonal. Red-on-mint is OK, but the `text-emerald-300` dark variant + red slash + dim emerald-900/40 bg = ~2.5:1 text contrast in dark. Below WCAG AA.
|
||||
**Fix sketch:** for dark winning cells, lift text to `emerald-200` and bump bg to `emerald-900/60`; or use a lighter slash color (rose-300) on dark.
|
||||
|
||||
### 3. `tan-tan-num` font stack omits Vietnamese-safe fallback
|
||||
**Where:** `app.css:131-142`. "Arial Narrow", "Avenir Next Condensed", "Roboto Condensed" — none are guaranteed to ship Vietnamese diacritics consistently on Android (esp. older WebView). Player numbers won't render diacritics, but the same class is reused for fallback rendering and any future label. Still, Roboto Condensed isn't loaded — relies on system. On Linux/Android without it, you get default sans, defeating the "tall + tight" aesthetic.
|
||||
**Fix sketch:** load a self-hosted Vietnamese-supporting condensed display face (e.g. Bebas Neue Vie, Oswald subset latin-ext+vietnamese) via @font-face; keep current as fallback.
|
||||
|
||||
### 4. Toast overlays board content — blocks the cell underneath
|
||||
**Where:** `PlayerBoard.svelte:321-336`. Toast is `absolute inset-0` flex-centered, pointer-events-none on wrapper but the button itself eats clicks for ~5s. Lands smack on the middle row.
|
||||
**Fix sketch:** anchor toast to top-edge or below grid (translate-y above the grid) — never centered over playable cells.
|
||||
|
||||
---
|
||||
|
||||
## P1
|
||||
|
||||
### 5. Three-mode picker has no icon/visual cue
|
||||
**Where:** `SettingsButton.svelte:241-256`. "Người chơi / Quản trò / Cả hai" are text-only pills. Discoverability OK once modal opens but doesn't communicate role at a glance. "Cả hai" is ambiguous (both *what*?).
|
||||
**Fix sketch:** add a tiny glyph above each label (player card icon / megaphone / split). Add a 1-line description under the active selection ("Hiển thị bảng người chơi" etc.) so users know *what changes*.
|
||||
|
||||
### 6. Light-mode `--section-band-bg` (`#e3f2fd 70%`) over amber radial = muddy edge
|
||||
**Where:** `app.css:24, 13-17`. Cool blue band on warm amber glow at top → visible color clash on the topmost section label.
|
||||
**Fix sketch:** either warm the band tint (cream or rose tinge) or darken the section accent slightly so band reads on either glow. Test against amber and indigo variants.
|
||||
|
||||
### 7. Master hero number circle — fixed `w-40 h-40` clips on 320px screens with border-[8px]
|
||||
**Where:** `MasterPanel.svelte:220-237`. 160px circle + 16px ring + page padding (`px-2`=8) + `flex flex-col items-center` = fits, but the called-history scrolls awkwardly because hero never collapses. On scroll-into-view, header gets pushed off, host loses orientation.
|
||||
**Fix sketch:** sticky top mini-pill of last-called when hero is scrolled offscreen. Or shrink hero to `w-32 h-32` ≤375px.
|
||||
|
||||
### 8. Auto-call sub-fieldset density unclear
|
||||
**Where:** `SettingsButton.svelte:260-294`. Slider sits inside the same fieldset as the toggle, no visual separation, label "Tốc độ" floats. When toggle off, fieldset shrinks abruptly with no transition — jarring.
|
||||
**Fix sketch:** indent slider with left border (mirror voice-waiting nesting at line 319). Add slide transition. Add tick labels at 1s/5s/10s.
|
||||
|
||||
### 9. Voice nesting reads: "Quản trò đọc số" + sub-text "Đọc số đã xổ + báo Chờ/Kinh khi ở Cả hai"
|
||||
**Where:** `SettingsButton.svelte:307-314`. The hint text only renders when mode≠player. So in mode=player, "Quản trò đọc số" toggle is shown with no explanation — confusing because there is no master visible.
|
||||
**Fix sketch:** hide the master voice toggle entirely in mode=player (it has no effect there) or add a hint that explains it's for "Cả hai" mode.
|
||||
|
||||
### 10. Color-picker: presets are 5×2 grid, hex code is tiny + no swatch label
|
||||
**Where:** `SettingsButton.svelte:373-389`. 10 color squares with no name tooltip. Selected state is a 110% scale + indigo ring — but the *current custom* hex shown next to the native picker doesn't update its visual when a preset is clicked (it does, but the `<input type="color">` and `<code>` block are visually disconnected from the preset row).
|
||||
**Fix sketch:** wrap picker + hex + presets in one card with a "Tuỳ chỉnh" / "Mẫu sẵn" sub-divide. Show hex *inside* the selected swatch overlay.
|
||||
|
||||
### 11. Settings modal: "Mặc định" button is barely visible (slate-600 text, no border)
|
||||
**Where:** `SettingsButton.svelte:392-401`. Reset is a destructive-ish action. Currently looks like a footer link, easily missed or accidentally hit.
|
||||
**Fix sketch:** ghost-button style with subtle border, or move under a divider line with smaller "Đặt lại tất cả" text + confirm dialog.
|
||||
|
||||
### 12. Header brand "Lô tô" + sub "Hội chợ TN1" — sub is muted slate, italic, all-caps tracking-[0.28em]
|
||||
**Where:** `+page.svelte:26-30`. Reads more "fintech subhead" than fairground. Off-brand for festive.
|
||||
**Fix sketch:** swap to a hand-drawn or display Vietnamese script font for sub; or wrap in decorative dashes/dots ("· Hội chợ TN1 ·"). Lower tracking (.18em) and add a tiny string-light or paper-lantern emoji.
|
||||
|
||||
### 13. "Kinh!" celebration modal — purple/pink gradient, generic
|
||||
**Where:** `PlayerBoard.svelte:389-439`. Confetti overlay only triggers on tier 2 (3+ bingos). First bingo modal is plain-ish for a fairground game. No row number callout in an oversized way.
|
||||
**Fix sketch:** for first bingo use a single confetti burst (not the rain), and make `Hàng X` huge (text-5xl) — that's the actual win info.
|
||||
|
||||
---
|
||||
|
||||
## P2
|
||||
|
||||
### 14. Footer dual cross-hatch label "Made by miti99" — visually heavy near grid bottom
|
||||
**Where:** `PlayerBoard.svelte:291-314`. Reuses `.section-label` (which has flanking ✚✚✚). Then `PageFooter.svelte` adds a *second* "Made by miti99" line. Duplicate attribution.
|
||||
**Fix sketch:** drop the in-card credit (it duplicates the footer); keep just the cross-hatch decorative band with no text, or replace with "TN1 · Lô tô" branding.
|
||||
|
||||
### 15. Confetti emoji set "🎊✨🎉🥳" — same chunkiness, all sit at 2rem
|
||||
**Where:** `PlayerBoard.svelte:34, 175-182`. Visual variety low; no rotation in fall start.
|
||||
**Fix sketch:** add Vietnamese-flavored bits: 🥢 🎋 🏮 (lantern!) and randomize size 1.5–2.5rem.
|
||||
|
||||
### 16. Master called-history pills — pink/green border with cream fill, OK contrast but identical shape to hero
|
||||
**Where:** `MasterPanel.svelte:253-268`. Mini repeats hero styling. `tabular-nums` good. But `border-[3px]` on 36px pill = ~31px usable. Numbers like 88 cramped.
|
||||
**Fix sketch:** drop border to 2px on pills; reserve 3px-ring treatment for hero only.
|
||||
|
||||
### 17. `cell-crossed` slash uses fixed `#ef4444` red — same on green winning bg as on red losing bg
|
||||
**Where:** `app.css:184-196`. Green winning row deserves a green or gold slash to communicate "complete" not "marked".
|
||||
**Fix sketch:** add a `.cell-crossed-win` modifier with emerald slash, applied when `rowComplete`.
|
||||
|
||||
### 18. `aria-live="assertive"` on hero number
|
||||
**Where:** `MasterPanel.svelte:218`. SR users get every draw spammed assertively. Should be polite — assertive is reserved for warnings.
|
||||
**Fix sketch:** `aria-live="polite"` is sufficient; lastCalled change is informational not urgent.
|
||||
|
||||
### 19. Reduced-motion not respected
|
||||
**Where:** `app.css:144-182`. confetti-fall, bounce-slow, spin-slow, pop-in, toast — none gated by `@media (prefers-reduced-motion: reduce)`.
|
||||
**Fix sketch:** wrap animation declarations in a media query that disables transforms/opacity loops, falling back to instant fade-in.
|
||||
|
||||
### 20. Empty cells use a single solid color across light & dark
|
||||
**Where:** `app.css:20`, `PlayerBoard.svelte:259-266`. Default `#7030A0` purple over near-black `#050813` is fine but on light theme the same purple looks office-clipart-ish next to amber glow. The `bg-black/15` overlay in dark is a band-aid.
|
||||
**Fix sketch:** allow per-theme cell color or auto-shift the saved color toward a more festive Tân Tân red/blue when theme=light and color=default.
|
||||
|
||||
### 21. No reset/clear keyboard shortcut, no swipe gestures on mobile
|
||||
**Where:** general. Power user pain — tapping "Xổ số" 90 times.
|
||||
**Fix sketch:** spacebar to draw next; long-press hero to undo last call. Nice-to-have only.
|
||||
|
||||
---
|
||||
|
||||
## Unresolved questions
|
||||
|
||||
1. Is the rose lower-glow in dark mode visible on tall pages? It's `at 50% 110%` but `background-attachment: fixed` — confirm intent: glow should track the viewport bottom, not the document.
|
||||
2. Default mode is `"player"` — is that final? "Cả hai" might be the natural at-a-table default. Check telemetry/intent.
|
||||
3. Is the in-card "Made by miti99" supposed to live there alongside footer attribution, or was it left over from before PageFooter existed?
|
||||
4. Tier-2 confetti threshold = 3+ bingos per session. Is this per-card or per-page? Code says `celebratedRows.size` on a 9-row card — only triggers if 3 of 9 rows complete, which is rare in a single round.
|
||||
5. Is there appetite for a per-row column highlight when "Chờ" toast fires (point at the row that's waiting)?
|
||||
|
||||
---
|
||||
|
||||
**Status:** DONE
|
||||
**Summary:** 21 findings — 4 P0 (master empty state, dark winning-cell contrast, font Vietnamese fallback, toast blocks cells), 9 P1 (mode picker discoverability, modal density, brand mood, hero scaling), 8 P2.
|
||||
+28
-1
@@ -21,7 +21,9 @@
|
||||
/* Section accent — used by both the label text and the cross-hatch in
|
||||
dividers/label flanks, so they always read as one unified band. */
|
||||
--section-accent: #1565c0;
|
||||
--section-band-bg: rgb(227 242 253 / 0.7);
|
||||
/* Pale blue band with a hint of warmth so it doesn't fight the
|
||||
amber top-glow at the topmost section label. */
|
||||
--section-band-bg: rgb(232 240 250 / 0.78);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -194,3 +196,28 @@ body {
|
||||
pointer-events: none;
|
||||
animation: cross-draw 200ms ease-out;
|
||||
}
|
||||
/* Winning-row slash uses emerald instead of red so a completed row
|
||||
reads as "win" rather than "marked off". */
|
||||
.cell-crossed-win::after {
|
||||
background-image: linear-gradient(
|
||||
to bottom right,
|
||||
transparent calc(50% - 1.5px),
|
||||
#10b981,
|
||||
transparent calc(50% + 1.5px)
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-fade-in,
|
||||
.animate-pop-in,
|
||||
.animate-bounce-slow,
|
||||
.animate-spin-slow,
|
||||
.animate-spin-slow-reverse,
|
||||
.animate-toast,
|
||||
.confetti,
|
||||
.cell-crossed::after {
|
||||
animation: none !important;
|
||||
}
|
||||
/* Keep the crossed slash visible (no draw animation, but still drawn). */
|
||||
.cell-crossed::after { clip-path: none; }
|
||||
}
|
||||
@@ -48,11 +48,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard cap so a poisoned origin can't stall the UI on mount.
|
||||
* 90-number called list serializes to ~500 bytes; 16 KB has 30× headroom. */
|
||||
const MAX_STORAGE_BYTES = 16_384;
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data);
|
||||
if (!data || data.length > MAX_STORAGE_BYTES) return null;
|
||||
const parsed = JSON.parse(data, (k, v) =>
|
||||
k === "__proto__" || k === "constructor" ? undefined : v,
|
||||
);
|
||||
// Minimal shape check — Array.isArray on both halves is enough
|
||||
// for all callers; deeper validation isn't worth the complexity.
|
||||
if (
|
||||
!parsed ||
|
||||
!Array.isArray(parsed.called) ||
|
||||
!Array.isArray(parsed.remaining)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -97,7 +113,8 @@
|
||||
// "user disabled auto setting mid-run" auto-stop as part of the same
|
||||
// dependency tracking.
|
||||
$effect(() => {
|
||||
if (!autoRunning || !settings.autoCallEnabled) {
|
||||
if (!autoRunning) return;
|
||||
if (!settings.autoCallEnabled) {
|
||||
autoRunning = false;
|
||||
return;
|
||||
}
|
||||
@@ -124,8 +141,14 @@
|
||||
$effect(() => {
|
||||
if (lastCalled !== null && heroEl && scrollOnNextDraw) {
|
||||
scrollOnNextDraw = false;
|
||||
const reduceMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
requestAnimationFrame(() =>
|
||||
heroEl?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
||||
heroEl?.scrollIntoView({
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
block: "center",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -215,11 +238,11 @@
|
||||
<div
|
||||
bind:this={heroEl}
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="w-40 h-40 sm:w-56 sm:h-56 rounded-full
|
||||
class="w-32 h-32 sm:w-56 sm:h-56 rounded-full
|
||||
bg-amber-50 dark:bg-amber-100
|
||||
border-[8px] sm:border-[10px]
|
||||
border-[6px] sm:border-[10px]
|
||||
flex items-center justify-center
|
||||
shadow-xl scroll-mt-4
|
||||
{lastIsLow
|
||||
@@ -227,7 +250,7 @@
|
||||
: 'border-emerald-500 dark:border-emerald-400 shadow-emerald-500/30'}"
|
||||
>
|
||||
<span
|
||||
class="text-7xl sm:text-8xl font-black tabular-nums
|
||||
class="text-6xl sm:text-8xl font-black tabular-nums
|
||||
{lastIsLow
|
||||
? 'text-pink-500 dark:text-pink-400'
|
||||
: 'text-emerald-500 dark:text-emerald-400'}"
|
||||
@@ -256,7 +279,7 @@
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10
|
||||
text-base sm:text-lg font-black tabular-nums rounded-full
|
||||
border-[3px] bg-amber-50 dark:bg-amber-100
|
||||
border-2 bg-amber-50 dark:bg-amber-100
|
||||
{isLow
|
||||
? 'border-pink-500 dark:border-pink-400 text-pink-600 dark:text-pink-500'
|
||||
: 'border-emerald-500 dark:border-emerald-400 text-emerald-600 dark:text-emerald-500'}"
|
||||
|
||||
+76
-68
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { bus } from "$lib/call-bus.svelte.js";
|
||||
import { bus, resetBus } from "$lib/call-bus.svelte.js";
|
||||
import {
|
||||
findUncrossedCell,
|
||||
generateGrid,
|
||||
getWaitingNumber,
|
||||
isRowComplete,
|
||||
@@ -12,14 +13,14 @@
|
||||
import { settings } from "$lib/settings-store.svelte.js";
|
||||
import { cancelPlayback, playBingo, playWaiting } from "$lib/voice.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {string} [storagePrefix] localStorage key prefix; allows multiple
|
||||
* independent boards (e.g. user vs master)
|
||||
*/
|
||||
const STORAGE_PREFIX = "loto";
|
||||
|
||||
/** @type {Props} */
|
||||
let { storagePrefix = "loto" } = $props();
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
let grid = $state(/** @type {number[][] | null} */ (null));
|
||||
let crossed = $state(/** @type {boolean[][]} */ ([]));
|
||||
@@ -38,6 +39,10 @@
|
||||
let toastTimer = null;
|
||||
const celebratedRows = new Set();
|
||||
const notifiedWaitingRows = new Set();
|
||||
// Last bus draw we acted on. Compared against bus.lastDrawn.at so the
|
||||
// auto-tick effect only fires on a NEW draw — re-runs caused by
|
||||
// crossed/grid changes (manual untick, clear, regen) skip cleanly.
|
||||
let lastHandledDrawAt = 0;
|
||||
|
||||
// Memoized per-row completeness — avoid 81×/render isRowComplete calls
|
||||
const rowCompleteness = $derived(
|
||||
@@ -63,14 +68,14 @@
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Initial load from localStorage. Re-runs if storagePrefix changes.
|
||||
// Initial load from localStorage. Re-runs if STORAGE_PREFIX changes.
|
||||
$effect(() => {
|
||||
const savedGrid = loadGrid(storagePrefix);
|
||||
const savedGrid = loadGrid(STORAGE_PREFIX);
|
||||
if (!savedGrid) return;
|
||||
|
||||
grid = savedGrid;
|
||||
const savedCrossed =
|
||||
loadCrossedState(storagePrefix) ??
|
||||
loadCrossedState(STORAGE_PREFIX) ??
|
||||
savedGrid.map((row) => row.map(() => false));
|
||||
crossed = savedCrossed;
|
||||
|
||||
@@ -85,7 +90,7 @@
|
||||
|
||||
// Persist crossed state on change
|
||||
$effect(() => {
|
||||
if (crossed.length > 0) saveCrossedState(crossed, storagePrefix);
|
||||
if (crossed.length > 0) saveCrossedState(crossed, STORAGE_PREFIX);
|
||||
});
|
||||
|
||||
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
|
||||
@@ -125,29 +130,44 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Stop any in-flight clip on unmount so audio doesn't outlive the board.
|
||||
$effect(() => () => cancelPlayback());
|
||||
// Stop any in-flight clip + dismiss any pending toast on unmount so
|
||||
// audio and timers don't outlive the board.
|
||||
$effect(() => () => {
|
||||
cancelPlayback();
|
||||
dismissToast();
|
||||
});
|
||||
|
||||
// Auto-tick on master draw (only in "both" mode). Set-only — never
|
||||
// un-marks an already-crossed cell, so manual taps and auto-tick
|
||||
// can't fight each other. Forward-only: only marks the most recent
|
||||
// draw, doesn't backfill prior calls when mode flips to "both"
|
||||
// mid-game (use the master's "Ván mới" if you need a fresh state).
|
||||
// Bingo modal: window-level Escape so it works regardless of which
|
||||
// element holds focus (the inline backdrop button rarely does).
|
||||
$effect(() => {
|
||||
if (!showCongrats) return;
|
||||
/** @param {KeyboardEvent} e */
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") showCongrats = false;
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
// Auto-tick on master draw (only in "both" mode). The effect tracks
|
||||
// `bus.lastDrawn` reactively, but `grid`/`crossed` are read inside a
|
||||
// peek — we only want to fire when a NEW draw arrives, not when the
|
||||
// user manually unticks, clears, or regenerates. Comparing
|
||||
// `drawn.at` against the last-handled timestamp blocks re-marks.
|
||||
$effect(() => {
|
||||
const drawn = bus.lastDrawn;
|
||||
if (!drawn) return;
|
||||
if (drawn.at === lastHandledDrawAt) return;
|
||||
lastHandledDrawAt = drawn.at;
|
||||
if (settings.mode !== "both") return;
|
||||
if (!grid || crossed.length === 0) return;
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === drawn.num && !crossed[r][c]) {
|
||||
crossed = crossed.map((row, ri) =>
|
||||
ri === r ? row.map((v, ci) => (ci === c ? true : v)) : row,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const target = findUncrossedCell(grid, crossed, drawn.num);
|
||||
if (!target) return;
|
||||
crossed = crossed.map((row, ri) =>
|
||||
ri === target.row
|
||||
? row.map((v, ci) => (ci === target.col ? true : v))
|
||||
: row,
|
||||
);
|
||||
});
|
||||
|
||||
function handleGenerate() {
|
||||
@@ -157,11 +177,13 @@
|
||||
const newCrossed = newGrid.map((row) => row.map(() => false));
|
||||
grid = newGrid;
|
||||
crossed = newCrossed;
|
||||
saveGrid(newGrid, storagePrefix);
|
||||
saveCrossedState(newCrossed, storagePrefix);
|
||||
saveGrid(newGrid, STORAGE_PREFIX);
|
||||
saveCrossedState(newCrossed, STORAGE_PREFIX);
|
||||
celebratedRows.clear();
|
||||
notifiedWaitingRows.clear();
|
||||
dismissToast();
|
||||
showCongrats = false;
|
||||
resetBus();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
@@ -174,6 +196,7 @@
|
||||
notifiedWaitingRows.clear();
|
||||
dismissToast();
|
||||
showCongrats = false;
|
||||
resetBus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +204,11 @@
|
||||
* @param {number} col
|
||||
*/
|
||||
function handleCellClick(row, col) {
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
navigator.vibrate &&
|
||||
!prefersReducedMotion()
|
||||
) {
|
||||
navigator.vibrate(10);
|
||||
}
|
||||
crossed = crossed.map((r, ri) =>
|
||||
@@ -189,11 +216,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
function onModalKeydown(e) {
|
||||
if (e.key === "Escape") showCongrats = false;
|
||||
}
|
||||
|
||||
// Tân Tân physical card: 3 stacked 3x9 mini-cards with these labels
|
||||
// (top → bottom). Underlying 9x9 data is unchanged; this is purely
|
||||
// visual segmentation.
|
||||
@@ -278,7 +300,7 @@
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
{isCrossed
|
||||
? rowComplete
|
||||
? 'cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300'
|
||||
? 'cell-crossed cell-crossed-win bg-emerald-100 dark:bg-emerald-900/60 text-emerald-700 dark:text-emerald-200'
|
||||
: 'cell-crossed bg-red-50 dark:bg-red-950/40 text-red-700 dark:text-red-300'
|
||||
: 'bg-white dark:bg-slate-800 text-black dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400'}"
|
||||
>
|
||||
@@ -288,30 +310,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="section-label">
|
||||
<span class="flex items-center gap-1">
|
||||
Made by
|
||||
<a
|
||||
href="https://miti99.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
miti99
|
||||
</a>
|
||||
with
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label="trái tim"
|
||||
class="inline w-3.5 h-3.5 text-red-500"
|
||||
>
|
||||
<path
|
||||
d="M12 21s-7-4.35-9.5-8.5C.5 8.5 3 4 7 4c2 0 3.5 1 5 3 1.5-2 3-3 5-3 4 0 6.5 4.5 4.5 8.5C19 16.65 12 21 12 21z"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Decorative bottom band (matches the section-label hatch).
|
||||
Attribution lives in the page footer, not here, to avoid
|
||||
duplicating "Made by miti99". -->
|
||||
<div class="section-label" aria-hidden="true"></div>
|
||||
</div>
|
||||
<!-- Right frame -->
|
||||
<div class="section-divider-vertical" aria-hidden="true"></div>
|
||||
@@ -319,16 +321,19 @@
|
||||
</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. -->
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
|
||||
class="absolute left-1/2 -translate-x-1/2 -top-3 sm:-top-4 z-10 pointer-events-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={dismissToast}
|
||||
aria-label="Đóng thông báo"
|
||||
class="pointer-events-auto px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast"
|
||||
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"
|
||||
>
|
||||
{toast}
|
||||
</button>
|
||||
@@ -397,7 +402,6 @@
|
||||
type="button"
|
||||
aria-label="Đóng"
|
||||
onclick={() => (showCongrats = false)}
|
||||
onkeydown={onModalKeydown}
|
||||
class="absolute inset-0 cursor-default"
|
||||
></button>
|
||||
<div
|
||||
@@ -415,12 +419,16 @@
|
||||
|
||||
<h2
|
||||
id="congrats-title"
|
||||
class="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
|
||||
class="mt-6 text-4xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
|
||||
>
|
||||
Kinh!
|
||||
</h2>
|
||||
<p class="mt-3 text-lg text-slate-700 dark:text-slate-200">
|
||||
Hàng <span class="font-bold text-pink-500">{congratsRow}</span> đã đầy đủ!
|
||||
<p class="mt-3 text-base text-slate-700 dark:text-slate-200">
|
||||
Hàng
|
||||
<span class="block text-5xl sm:text-6xl font-black text-pink-500 dark:text-pink-400 my-1 tabular-nums">
|
||||
{congratsRow}
|
||||
</span>
|
||||
đã đầy đủ!
|
||||
</p>
|
||||
<p class="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
Hãy hô to "Kinh!" 🎶
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
["both", "Cả hai"],
|
||||
]);
|
||||
|
||||
const MODE_HINTS = /** @type {const} */ ({
|
||||
player: "Chỉ bảng người chơi",
|
||||
master: "Chỉ bảng quản trò (xổ số, theo dõi)",
|
||||
both: "Cả hai bảng — quản trò tự đánh dấu cho người chơi",
|
||||
});
|
||||
|
||||
/** @param {string} hex */
|
||||
function pickColor(hex) {
|
||||
settings.emptyCellColor = hex;
|
||||
@@ -254,6 +260,9 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-2 italic">
|
||||
{MODE_HINTS[settings.mode]}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Auto-call (only relevant when master panel is visible) -->
|
||||
@@ -271,7 +280,7 @@
|
||||
{@render switchRow("Tự xổ số", settings.autoCallEnabled, toggleAuto)}
|
||||
</div>
|
||||
{#if settings.autoCallEnabled}
|
||||
<label class="block">
|
||||
<label class="block mt-2 pl-3 border-l-2 border-slate-200 dark:border-slate-600">
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">
|
||||
Tốc độ: <strong class="tabular-nums"
|
||||
>{settings.autoCallSpeed}</strong
|
||||
@@ -304,14 +313,14 @@
|
||||
Đọc số bằng tiếng Việt
|
||||
</p>
|
||||
|
||||
<div class="mb-2">
|
||||
{@render switchRow("Quản trò đọc số", settings.voiceEnabledMaster, toggleVoiceMaster)}
|
||||
{#if settings.mode !== "player"}
|
||||
{#if settings.mode !== "player"}
|
||||
<div class="mb-2">
|
||||
{@render switchRow("Quản trò đọc số", settings.voiceEnabledMaster, toggleVoiceMaster)}
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1.5 px-1">
|
||||
Đọc số đã xổ + báo Chờ/Kinh khi ở "Cả hai".
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render switchRow("Báo Chờ / Kinh", settings.voiceEnabledPlayer, toggleVoicePlayer)}
|
||||
|
||||
|
||||
+39
-3
@@ -28,7 +28,11 @@ const NUM_PER_ROW = 5;
|
||||
*/
|
||||
function randomNumbersInCol(num, col) {
|
||||
const arr = [...NUM_IN_COL[col]];
|
||||
arr.sort(() => 0.5 - Math.random());
|
||||
// Fisher-Yates — `arr.sort(() => 0.5 - Math.random())` is biased.
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
// Pick `num` at random, then return them ascending so they sit
|
||||
// top-to-bottom in the column (lô tô hội chợ convention).
|
||||
return arr.slice(0, num).sort((a, b) => a - b);
|
||||
@@ -159,16 +163,25 @@ export function generateGrid() {
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** Hard cap on stored JSON length to keep a poisoned origin (browser
|
||||
* extension, shared device) from stalling the UI on mount. A full 9×9
|
||||
* number matrix serializes to ~250 bytes; 32 KB leaves >100× headroom. */
|
||||
const MAX_STORAGE_BYTES = 32_768;
|
||||
|
||||
/**
|
||||
* Parse JSON and validate its shape with a runtime guard. Returns null on
|
||||
* either parse failure or validation failure.
|
||||
* size-cap, parse, or validation failure. The reviver strips
|
||||
* `__proto__` keys as defense-in-depth against future spread/assign use.
|
||||
* @param {string | null} raw
|
||||
* @param {(v: any) => boolean} validate
|
||||
*/
|
||||
function safeParse(raw, validate) {
|
||||
if (!raw) return null;
|
||||
if (raw.length > MAX_STORAGE_BYTES) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw, (k, v) =>
|
||||
k === "__proto__" || k === "constructor" ? undefined : v,
|
||||
);
|
||||
return validate(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -251,6 +264,29 @@ export function loadCrossedState(prefix = "loto") {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the first non-crossed cell holding `num` on the grid.
|
||||
* Returns `null` when the number is absent or already crossed everywhere.
|
||||
* Used by the master→player auto-tick path.
|
||||
*
|
||||
* @param {number[][]} grid
|
||||
* @param {boolean[][]} crossed
|
||||
* @param {number} num
|
||||
* @returns {{ row: number, col: number } | null}
|
||||
*/
|
||||
export function findUncrossedCell(grid, crossed, num) {
|
||||
if (num <= 0) return null;
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
const row = grid[r];
|
||||
for (let c = 0; c < row.length; c++) {
|
||||
if (row[c] === num && !crossed[r]?.[c]) {
|
||||
return { row: r, col: c };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a row has all its numbers crossed (and has at least one number).
|
||||
* @param {number[][]} grid
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
findUncrossedCell,
|
||||
generateGrid,
|
||||
getWaitingNumber,
|
||||
isRowComplete,
|
||||
@@ -256,3 +257,40 @@ describe("getWaitingNumber", () => {
|
||||
expect(getWaitingNumber(grid, crossed, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUncrossedCell", () => {
|
||||
const grid = [
|
||||
[0, 11, 0, 33, 0, 55, 0, 77, 88],
|
||||
[1, 0, 22, 0, 44, 0, 66, 0, 89],
|
||||
[0, 12, 23, 0, 0, 56, 0, 78, 0],
|
||||
];
|
||||
|
||||
it("returns coords of the matching cell when uncrossed", () => {
|
||||
const crossed = grid.map((row) => row.map(() => false));
|
||||
expect(findUncrossedCell(grid, crossed, 33)).toEqual({ row: 0, col: 3 });
|
||||
expect(findUncrossedCell(grid, crossed, 1)).toEqual({ row: 1, col: 0 });
|
||||
});
|
||||
|
||||
it("returns null when the number isn't on the card", () => {
|
||||
const crossed = grid.map((row) => row.map(() => false));
|
||||
expect(findUncrossedCell(grid, crossed, 99)).toBeNull();
|
||||
expect(findUncrossedCell(grid, crossed, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the cell is already crossed", () => {
|
||||
const crossed = grid.map((row) => row.map(() => false));
|
||||
crossed[0][3] = true; // 33 already marked
|
||||
expect(findUncrossedCell(grid, crossed, 33)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores empty (0) cells even when num=0", () => {
|
||||
const crossed = grid.map((row) => row.map(() => false));
|
||||
expect(findUncrossedCell(grid, crossed, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("scans rows top-down, columns left-right", () => {
|
||||
// Both row 0 col 1 and row 2 col 1 hold 11/12 — first match wins.
|
||||
expect(findUncrossedCell(grid, grid.map((row) => row.map(() => false)), 11))
|
||||
.toEqual({ row: 0, col: 1 });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ const STORAGE_KEY = "loto_settings";
|
||||
const HEX6 = /^#[0-9a-fA-F]{6}$/;
|
||||
const VALID_THEMES = /** @type {const} */ (["auto", "light", "dark"]);
|
||||
const VALID_MODES = /** @type {const} */ (["player", "master", "both"]);
|
||||
/** Hard cap on stored settings JSON to keep poisoned origins from
|
||||
* stalling the UI on mount. Real settings serialize to ~200 bytes. */
|
||||
const MAX_STORAGE_BYTES = 8_192;
|
||||
|
||||
export const DEFAULT_SETTINGS = Object.freeze({
|
||||
/** Excel "Standard Color: Purple". */
|
||||
@@ -121,8 +124,12 @@ function applyAll() {
|
||||
export function loadSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) ?? {};
|
||||
if (raw && raw.length <= MAX_STORAGE_BYTES) {
|
||||
// Strip `__proto__`/`constructor` as defense-in-depth in case
|
||||
// future code spreads/assigns the parsed object.
|
||||
const parsed = JSON.parse(raw, (k, v) =>
|
||||
k === "__proto__" || k === "constructor" ? undefined : v,
|
||||
) ?? {};
|
||||
settings.emptyCellColor =
|
||||
validColor(parsed.emptyCellColor) ?? DEFAULT_SETTINGS.emptyCellColor;
|
||||
settings.theme =
|
||||
|
||||
+3
-1
@@ -36,7 +36,9 @@ function getAudio(url) {
|
||||
|
||||
/** @param {string} name — clip basename without extension */
|
||||
function clipUrl(name) {
|
||||
return `${base}/audio/${settings.voice}/${name}.mp3`;
|
||||
// `settings.voice` is allowlist-validated and `name` is internal-only,
|
||||
// but encode anyway as defense-in-depth in case either ever escapes.
|
||||
return `${base}/audio/${encodeURIComponent(settings.voice)}/${encodeURIComponent(name)}.mp3`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { settings } from "./settings-store.svelte.js";
|
||||
import { cancelPlayback, clearAudioCache, playNumber, playWaiting } from "./voice.js";
|
||||
|
||||
// Track every Audio instance the module constructs so we can drive
|
||||
// onended/onerror by hand and verify cancelPlayback's bookkeeping.
|
||||
/** @type {any[]} */
|
||||
let audios = [];
|
||||
|
||||
class FakeAudio {
|
||||
/** @param {string} url */
|
||||
constructor(url) {
|
||||
this.src = url;
|
||||
this.preload = "";
|
||||
this.currentTime = 0;
|
||||
this.onended = null;
|
||||
this.onerror = null;
|
||||
this.paused = true;
|
||||
audios.push(this);
|
||||
}
|
||||
play() {
|
||||
this.paused = false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
pause() {
|
||||
this.paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
audios = [];
|
||||
vi.stubGlobal("Audio", /** @type {any} */ (FakeAudio));
|
||||
// Reset cache + active state so each test starts cleanly.
|
||||
clearAudioCache();
|
||||
settings.voice = "test-voice";
|
||||
settings.voiceWaitingNumber = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cancelPlayback();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("voice — playback cancellation", () => {
|
||||
it("cancelPlayback pauses and rewinds the active clip", () => {
|
||||
playNumber(7);
|
||||
const a = audios[audios.length - 1];
|
||||
a.currentTime = 0.42;
|
||||
a.paused = false;
|
||||
cancelPlayback();
|
||||
expect(a.paused).toBe(true);
|
||||
expect(a.currentTime).toBe(0);
|
||||
expect(a.onended).toBeNull();
|
||||
expect(a.onerror).toBeNull();
|
||||
});
|
||||
|
||||
it("a second playNumber cancels the first", () => {
|
||||
playNumber(3);
|
||||
const first = audios[audios.length - 1];
|
||||
playNumber(5);
|
||||
const second = audios[audios.length - 1];
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.paused).toBe(true);
|
||||
expect(second.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("playWaiting plays only 'cho' when voiceWaitingNumber is off", async () => {
|
||||
settings.voiceWaitingNumber = false;
|
||||
playWaiting(42);
|
||||
// Let the async chain schedule + start the cho clip.
|
||||
await Promise.resolve();
|
||||
expect(audios.length).toBe(1);
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
audios[0].onended?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// Number clip should NOT be queued.
|
||||
expect(audios.length).toBe(1);
|
||||
});
|
||||
|
||||
it("playWaiting chains 'cho' → number when voiceWaitingNumber is on", async () => {
|
||||
settings.voiceWaitingNumber = true;
|
||||
playWaiting(42);
|
||||
await Promise.resolve();
|
||||
expect(audios[0].src).toMatch(/\/cho\.mp3$/);
|
||||
audios[0].onended?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(audios.length).toBe(2);
|
||||
expect(audios[1].src).toMatch(/\/42\.mp3$/);
|
||||
});
|
||||
});
|
||||
@@ -40,13 +40,11 @@
|
||||
aria-label="Bảng quản trò"
|
||||
transition:slide={{ duration: 250 }}
|
||||
>
|
||||
{#if settings.mode === "both"}
|
||||
<h2
|
||||
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>
|
||||
{/if}
|
||||
<h2
|
||||
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>
|
||||
<MasterPanel />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
|
||||
X-Frame-Options: DENY
|
||||
Reference in new issue
Block a user