Commit Graph
117 Commits
Author SHA1 Message Date
tiennm99andGitHub 6992de06e2 chore: add dependabot config (#7) 2026-05-23 10:48:53 +07:00
tiennm99 cbb20a5860 fix(ci): remove duplicate pnpm version spec conflicting with packageManager 2026-05-13 10:54:58 +07:00
tiennm99 25847faf8e chore(ci): bump node to 24 2026-05-13 10:52:19 +07:00
tiennm99 0485e09f68 chore: migrate from npm to pnpm 2026-05-13 10:21:00 +07:00
tiennm99 874710c3ef docs: write substantive README 2026-05-11 20:17:09 +07:00
tiennm99 6c9433c02e docs(plans): record Play Store rollout sequence + session progress 2026-05-10 03:01:35 +07:00
tiennm99 07bbefee0b feat(ci): optional Play Store auto-publish on release tags
Adds a gated step in release.yml that uploads the signed AAB to the
Google Play Console internal track when PLAY_SERVICE_ACCOUNT_JSON is
configured. No-op if the secret is missing, so existing tag releases
keep working unchanged.

Default track is 'internal' for safety; change to alpha/beta/production
once trusted. Promotion can also be done via Play Console UI.

First Play Store upload must still be manual (Google policy).
2026-05-10 02:57:35 +07:00
tiennm99 f842deb46c feat(android): make APK installable on BlueStacks and other emulators
- Declare touchscreen/faketouch/screen.portrait/screen.landscape as
  uses-feature required=false so emulators (BlueStacks reports faketouch)
  and Play Store device filters do not exclude the app.
- Drop explicit webContentsDebuggingEnabled override so Capacitor
  auto-enables WebView inspection in debug builds (chrome://inspect over
  ADB) and disables in release.
- Document the install + troubleshoot path in README.
2026-05-10 02:45:26 +07:00
tiennm99 df1bcdced1 fix(ci): bump JDK to 21 (Capacitor 8 compiles with sourceCompatibility 21) 2026-05-10 02:11:32 +07:00
tiennm99 b2c839c902 fix(ci): bump Node to 22 (Capacitor 8 CLI requires >=22) 2026-05-10 02:05:06 +07:00
tiennm99 7840aa419d feat: migrate to Capacitor wrapper for fully-offline Android
Replace the native Kotlin/Compose port with a Capacitor 8 wrapper around
the upstream loto SvelteKit PWA. Loto is consumed as a git submodule and
its static build is bundled into the APK at sync time. The wrapper has
no INTERNET permission, so fully offline is enforced rather than
conventional.

- loto/ submodule pinned to dfb1c1e
- android/ scaffolded by cap add android; signing wired via env vars
- All 184 voice MP3s + service worker bundled in assets/public/
- CI checkouts submodule, runs npm build, then Gradle inside android/
- Native port preserved in git history at e7fb3d0 and 8b8d46e
2026-05-10 02:03:16 +07:00
tiennm99 0d3d822b36 feat(seo): expand meta tags + PWA manifest for GitHub Pages
- app.html: add format-detection=telephone=no so mobile browsers
  stop auto-linking 2-digit bingo numbers as tel: links.
- app.html: rewrite description to mention Bingo 90, Vietnamese
  voice, offline play; add application-name + apple-mobile-web-app-title.
- app.html: add Open Graph (og:type, locale, site_name, title,
  description, url, image) + Twitter summary card. URLs hardcoded
  to https://tiennm99.github.io/loto/ to match the GH Pages target.
- app.html: add explicit <link rel="icon"> alongside apple-touch-icon.
- manifest.webmanifest: add stable id "/loto/", expanded description,
  and categories ["games", "entertainment"].
2026-05-10 00:37:10 +07:00
tiennm99 4d89366deb chore(plans): sweep shipped plan folders and orphaned reports
Apply the policy stated in plans/todo.md ("all prior plan folders
have been deleted") that had drifted: remove three completed plan
folders (auto-call-countdown, both-mode-state-consistency,
switch-deploy-to-github-pages) and the reports tied to them.

Also remove pre-shipping audits that referenced the now-deleted
Cloudflare _headers / CSP setup, plus the just-actioned
cloudflare-legacy cleanup audit. Keep the evergreen Lô Tô rules
researcher report.

Refresh todo.md hand-off header to reflect the GitHub Pages target.
2026-05-10 00:36:54 +07:00
tiennm99 777b2dbaec refactor: drop dead code left over from Cloudflare era
- Remove vietnamese-number.js + test: build-time Python script
  (generate-audio.py) is the sole spec for pre-baked MP3s; runtime
  no longer reads the JS module.
- Remove resetMaster() export: only ever called from tests; inline
  the two-line body at test sites.
- Update Python docstring to drop the JS-mirror reference.
- Rewrite stale "CF Pages root" comment in svelte.config.js.
2026-05-10 00:36:39 +07:00
tiennm99 8fc3ec6ebd ci(deploy): switch from Cloudflare Pages to GitHub Pages
Migrate build and deployment pipeline from Cloudflare Pages to GitHub Pages.
Adds production-ready deploy workflow in deploy-github-pages.yml with proper
artifact handling. Removes Cloudflare-specific tooling: wrangler config, _headers,
_redirects, and CSP hash injection scripts (no longer needed with GitHub Pages
static hosting). Updates package.json build scripts and all project documentation
to reflect new deployment target and simplified architecture.
2026-05-09 23:25:06 +07:00
tiennm99 a06cbd2675 fix(settings): hide "Báo Chờ / Kinh" toggle in master mode
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.
2026-04-30 21:53:25 +07:00
tiennm99 74a235a401 revert(player): bring back the gradient cross-slash style
The SVG-line replacement from the gradient-cleanup refactor (commit
635a4c3) 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
2026-04-30 21:48:50 +07:00
tiennm99 dbed8aa664 chore(plans): add waiting-cell research + brainstorm reports 2026-04-30 21:41:50 +07:00
tiennm99 02d492c29c 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.
2026-04-30 21:41:39 +07:00
tiennm99 3d988e1fc4 refactor(active-tab): rename tab-lock to active-tab for clarity
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.
2026-04-30 21:34:06 +07:00
tiennm99 64e496b777 fix(tab-lock): formalize banner copy
Drop casual loanword 'Tap', use neutral formal Vietnamese for the freeze banner shown to all users (not just internal team).
2026-04-30 21:25:30 +07:00
tiennm99 7a4a4e0450 chore(plans): add voice + multi-tab brainstorm report 2026-04-30 21:22:31 +07:00
tiennm99 2f4efd1cab feat(tab-lock): single-tab guard via BroadcastChannel
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.
2026-04-30 21:22:23 +07:00
tiennm99 9e9c68bff6 fix(voice): suppress "Chờ {số}" in both mode
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.
2026-04-30 21:22:18 +07:00
tiennm99 dbf93e29dd chore(plans): add both-mode consistency plan + audit reports 2026-04-30 21:10:58 +07:00
tiennm99 64a2f7b515 refactor(state): replace call-bus with shared master-store for both-mode
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.
2026-04-30 21:10:51 +07:00
tiennm99 2b6a8c91f1 fix(master): break effect_update_depth_exceeded loop in countdown
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).
2026-04-30 19:47:22 +07:00
tiennm99 1e89137fa6 chore(plans): add countdown indicator plan + review report 2026-04-30 19:32:09 +07:00
tiennm99 7eb96808b0 feat(master): add auto-call countdown indicator
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)
2026-04-30 19:32:02 +07:00
tiennm99 7f5b4bac95 chore: add Apache-2.0 license 2026-04-29 21:33:26 +07:00
tiennm99 04e4cf7a41 chore: add Apache-2.0 license 2026-04-29 21:33:25 +07:00
tiennm99 e80087c339 chore(plans): record 260429-1511 upstream-sync plan + reports
Plan directory and recon/code-review reports for the 6-commit
upstream sync from tiennm99/loto @ 2fb35f2.
2026-04-29 16:26:23 +07:00
tiennm99 593c270211 refactor: KinhModal title color to solid amber (palette refactor)
- Remove gradient brush from "Kinh!" title, use solid BrandAmber600.
- Align with solid-color palette refactor (phase 03).
- Update button and text colors to new palette.

Upstream: a60ea08
2026-04-29 16:25:01 +07:00
tiennm99 f1bc58a6df chore: refactor master/player UI components for consistency
- Minor UI alignment and spacing adjustments across master panel and
  player board screens.
- Improve code consistency and readability in Compose components.
- Update project todo list with implementation notes.

Upstream: a60ea08 (sync completion)
2026-04-29 16:24:49 +07:00
tiennm99 60df1dd7d0 feat: per-mode voice-master hint copy in settings
- Add mode-specific voice hint messages (player vs master mode).
- Update VoiceToggles to show contextual hint based on current mode.
- Improve UX clarity in settings sheet.

Upstream: a60ea08
2026-04-29 16:24:38 +07:00
tiennm99 11825315e0 refactor: solid-color palette refactor (BrandSky, removal of gradients)
- Replace BrandPink/BrandIndigo with BrandSky variants (light/dark).
- Add BrandRose600, BrandAmber600, BrandEmerald600.
- Add BackgroundCream for light mode background.
- Switch light theme background from white to BackgroundCream.
- Keep wordmark gradient (only intentional gradient in brand).

Upstream: a60ea08
2026-04-29 16:24:27 +07:00
tiennm99 793788c78c feat: section "Chờ" ring on label with pulse animation
- Add sectionHasWaiting state to track which sections have rows one cell
  from bingo (in Chờ state).
- Render amber ring + 1.5s pulse on section labels when section has waiting.
- Skip animation if accessibility motion is disabled.
- Add BrandAmberLight color for the ring.

Upstream: a60ea08
2026-04-29 16:24:16 +07:00
tiennm99 ec0a8567dd feat: confetti celebration tier rule and emoji set
- Fire confetti on 2nd bingo OR 1st bingo when another row is in Chờ
  (waiting for one number). Replaces old ≥3 threshold that rarely fired.
- Add Vietnamese hội-chợ emoji: 🥢 🎋 🏮 (chopsticks, bamboo, lantern)
- Per-particle size jitter (1.5–2.4x) for visual interest

Upstream: a60ea08
2026-04-29 16:24:05 +07:00
tiennm99 435a57a02b refactor(ui): drop gradients for clean solid-color palette
Single-accent system: rose-600 (player + UI), amber-600 (host),
emerald-600 (draw), sky-600 (master low-half). Replaces every text
gradient, button gradient, radial bg-glow, hatch-stripe divider, and
the diagonal cell slash (now an SVG <line> with non-scaling stroke).
Updates design-guidelines.md to match.
2026-04-29 14:53:08 +07:00
tiennm99 5b647e76ac chore(plans): delete shipped plan folders, inline residual TODOs
All implemented plan folders removed (current backlog + 8 archived).
PWA verification checklist preserved inline in todo.md as the only
remaining post-deploy work.
2026-04-28 14:11:22 +07:00
tiennm99 0f4c616b41 chore(plans): refresh todo.md after 260428-0927 plan ships
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.
2026-04-28 11:43:43 +07:00
tiennm99 918fb1cda9 chore(pwa): tighten audio cache eviction (30d → 7d, purgeOnQuotaError)
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.
2026-04-28 11:10:51 +07:00
tiennm99 3fd02c14a2 ci(csp): replace 'unsafe-inline' with sha256 hash at build time
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.
2026-04-28 11:09:32 +07:00
tiennm99 a81695d982 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.
2026-04-28 11:05:43 +07:00
tiennm99 f1b4c3256c feat(settings): clearer "both" glyph + sticky title/footer on mobile
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.
2026-04-28 11:00:23 +07:00
tiennm99 a6c10aa67f ci: add inline-script guard for built index.html
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.
2026-04-28 10:55:43 +07:00
tiennm99 36142de6c5 chore(plans): add todo-backlog implementation plan
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.
2026-04-28 10:03:54 +07:00
tiennm99 e745631ea7 refactor(player-board): extract auto-tick into pure helper + tests
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.
2026-04-28 10:03:47 +07:00
tiennm99 aabd1373a2 chore(plans): archive completed plans + add next-session todo
- 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.
2026-04-27 21:13:33 +07:00
tiennm99 8f7546c20a fix: address pass-2 review findings (PWA + CSP + copy)
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
2026-04-27 20:53:53 +07:00