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.
This commit is contained in:
tiennm99 committed 2026-04-29 16:26:23 +07:00
1 parent 593c270211
commit e80087c339
9 files changed
+1114

No files matched your search

@@ -0,0 +1,112 @@
# Phase 01 — Confetti & celebration tier rule
## Context
Upstream commit `a60ea08` (2026-04-28). Two changes bundled here:
1. **Tier rule**: confetti now fires on the 2nd bingo, OR on the 1st bingo
when another row is in Chờ. Old rule (`>= 3`) rarely fired on a 9-row
card, so most wins felt under-celebrated.
2. **Confetti emoji set + size jitter**: add 🥢 🎋 🏮 (chopsticks, bamboo,
lantern) for hội-chợ flavour; per-piece size 1.5–2.4 sp scale.
## Priority
P0 — game feel.
## Status
pending.
## Key insights
- The "Chờ" check needs the live grid + crossed state at bingo time. The VM
already has both in `_state.value`. We have to compute "is any *other*
row in Chờ" at the moment we evaluate the tier (just after marking the
new bingo into `celebratedRows`).
- Compose can change `Text.fontSize` per particle without a `--size` CSS
variable equivalent — keep the size jitter inside `Particle.fontSizeSp`.
## Related code files
- `app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt`
- `app/src/main/java/com/miti99/loto/ui/board/Confetti.kt`
## Implementation steps
1. **PlayerBoardViewModel.kt — tier rule**
Replace `applyCrossedChange` Pass-1 tier calculation:
```kotlin
// OLD (line ~166):
// val tier = if (celebrated.size >= 3) 2 else 1
val newCelebrated = celebrated + i
val hasActiveCho = grid.indices.any { otherRow ->
otherRow !in newCelebrated &&
GameLogic.getWaitingNumber(gridArr, crossedArr, otherRow) != null
}
val tier = when {
newCelebrated.size >= 2 -> 2
newCelebrated.size >= 1 && hasActiveCho -> 2
else -> 1
}
celebrated = newCelebrated
```
Note: `crossedArr` in this scope already reflects the new mark for row
`i`, so `getWaitingNumber` returns null for the just-completed row —
the `otherRow !in newCelebrated` filter is belt-and-braces.
2. **Confetti.kt — emoji + size jitter**
```kotlin
private val CONFETTI_EMOJI = listOf("🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮")
private data class Particle(
val emoji: String,
val startXFraction: Float,
val speed: Float,
val rotationSpeed: Float,
val delayNs: Long,
val fontSizeSp: Float, // NEW
)
```
In the `remember { List(...) }` initializer, derive
`fontSizeSp = 1.5f + ((i * 13) % 11) / 10f` then `* 16f` for sp scale —
final clamp to `[24f, 38f]`. Pass into `Text(fontSize = p.fontSizeSp.sp)`.
3. Compile + run:
```bash
./gradlew :app:compileDebugKotlin
```
## Todo
- [ ] Update tier rule in PlayerBoardViewModel.kt
- [ ] Add 🥢 🎋 🏮 to CONFETTI_EMOJI
- [ ] Add `fontSizeSp` to Particle and use it in Text
- [ ] Compile clean
## Success criteria
- 1st bingo with another row in Chờ → `tier == 2` (Confetti fires).
- 2nd bingo (regardless of Chờ) → `tier == 2`.
- 1st bingo with no Chờ row → `tier == 1` (no Confetti).
- Confetti renders 7 distinct emoji across particles, with visible size
variance.
## Risks
- `getWaitingNumber` is a 9-col scan per row; called 9 times per bingo —
trivial cost. No mitigation needed.
- A row could have just been completed by the same draw that triggers the
bingo; the `otherRow !in newCelebrated` filter prevents that row from
counting itself toward `hasActiveCho`.
## Next
Phase 02 (section Chờ ring) — independent, can run in parallel if needed.
@@ -0,0 +1,141 @@
# Phase 02 — Section "Chờ" ring on label band
## Context
Upstream commit `a60ea08` (2026-04-28) part 1: each section's label band
glows amber while any of its 3 rows is one cell from bingo and not yet
complete. Reduces reliance on the 5 s waiting toast.
## Priority
P1 — UX nice-to-have, reinforces existing toast.
## Status
pending.
## Key insights
- Web computes `waitingRows[r]` per row, then `sectionHasWaiting[s]` =
any of rows `[s*3 .. s*3+2]` waiting. Same shape works in VM.
- The flag must be derived from the same snapshot as `crossed`/`grid`.
Cleanest is a derived field in `PlayerBoardUiState` recomputed inside
`applyCrossedChange` and `loadFromStorage`.
- Compose has no `prefers-reduced-motion` CSS query. Use
`LocalAccessibilityManager.current` (or similar `Settings.Global` read)
to detect reduce-motion. Default to a static glow with no pulse if
detection is too noisy.
## Related code files
- `app/src/main/java/com/miti99/loto/state/PlayerBoardUiState.kt`
- `app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt`
- `app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt`
- `app/src/main/java/com/miti99/loto/ui/theme/Color.kt` (amber band color, may
reuse `BrandAmberLight`)
## Implementation steps
1. **State shape** (`PlayerBoardUiState.kt`):
```kotlin
val sectionHasWaiting: List<Boolean> = listOf(false, false, false),
```
2. **VM derivation** (`PlayerBoardViewModel.kt`):
- In `applyCrossedChange` (after computing `rowComplete`), add:
```kotlin
val waitingRows = grid.indices.map { r ->
!rowComplete[r] &&
GameLogic.getWaitingNumber(gridArr, crossedArr, r) != null
}
val sectionHasWaiting = listOf(0, 3, 6).map { start ->
(start until start + 3).any { r -> waitingRows.getOrElse(r) { false } }
}
```
- Pass `sectionHasWaiting = sectionHasWaiting` into the `_state.update`
copy.
- Mirror the same derivation in `loadFromStorage`, `clear`, `generate`
(last two reset to `[false, false, false]` since no marks yet).
3. **UI** (`PlayerBoardGrid.kt`):
Change the section-label `Text` to a `Box` wrapping the label, and
apply a `border` + glow when `sectionHasWaiting[sectionIdx]`:
```kotlin
val isWaiting = sectionHasWaiting.getOrNull(sectionIdx) ?: false
val infinite = rememberInfiniteTransition(label = "cho-pulse")
val alpha by infinite.animateFloat(
initialValue = 0.55f, targetValue = 1.0f,
animationSpec = infiniteRepeatable(
tween(1500, easing = LinearEasing), RepeatMode.Reverse,
),
label = "cho-alpha",
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, top = if (sectionIdx > 0) 12.dp else 4.dp, bottom = 2.dp)
.then(
if (isWaiting) Modifier.background(
color = BrandAmberLight.copy(alpha = alpha * 0.18f),
shape = RoundedCornerShape(8.dp),
).border(
width = 1.dp,
color = BrandAmberLight.copy(alpha = alpha),
shape = RoundedCornerShape(8.dp),
).padding(horizontal = 8.dp, vertical = 2.dp)
else Modifier
),
) {
Text(
text = stringResource(SECTION_LABEL_RES[sectionIdx]),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = if (isWaiting) BrandAmberLight else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
```
- Update `PlayerBoardGrid` signature to accept
`sectionHasWaiting: List<Boolean>`.
- Caller (`PlayerBoardScreen`) passes `state.sectionHasWaiting`.
4. **Reduce-motion guard**:
- Wrap the `infiniteTransition` block in
`if (LocalAccessibilityManager.current?.isEnabled != true) infinite else stillPulse`,
OR fall back to a static `alpha = 0.85f` for users with reduce-motion
enabled. Implementation choice is fine either way; prefer the simple
static fallback unless QA disagrees.
## Todo
- [ ] Add `sectionHasWaiting` to `PlayerBoardUiState`
- [ ] Compute it in VM (apply, load, clear, generate)
- [ ] Update `PlayerBoardGrid` signature + render
- [ ] Wire from `PlayerBoardScreen`
- [ ] Reduce-motion fallback
## Success criteria
- A row with exactly 1 uncrossed number flips its section label to amber
glow; manually un-checking a different cell that breaks Chờ removes the
glow.
- All 3 sections light up if all of them have a waiting row.
- A bingo'd section drops its glow once the row completes.
- No glow on cold start (no grid).
## Risks
- Compose `infiniteTransition` keeps recomposing while visible. Confirm
this doesn't cause bad battery on long sessions — measure once during
QA. If problematic, drop to a single-shot fade-in static state.
## Next
Phase 03 (palette).
@@ -0,0 +1,150 @@
# Phase 03 — Solid-color palette refactor
## Context
Upstream commit `2fb35f2` (2026-04-29). New single-accent system:
- **rose-600** `#E11D48` — player + UI primary
- **amber-600** `#D97706` — host primary
- **emerald-600** `#059669` — draw / completion
- **sky-600** `#0284C7` — master ≤49 token (replaces pink-500)
- Background warmer cream `#fffbeb` (light); dark unchanged (`#050813`).
- All ad-hoc gradients dropped EXCEPT the wordmark (intentional brand
signature; web also kept its rose→amber wordmark).
## Priority
P1 — visual cohesion.
## Status
pending.
## Key insights
- Android already has `BrandRoseLight/Dark`, `BrandAmberLight/Dark`,
`BrandEmeraldLight/Dark`. We need the deeper `-600` shades AND new
`BrandSky*` constants.
- `BrandPink*` and `BrandIndigo*` become unused after this phase. Delete
them outright (`docs/development-rules.md`: "do not leave unused"),
unless any test asserts on the old hex.
- "Ván mới" button currently uses `Brush.horizontalGradient(amber, rose)`
— flatten to solid `BrandAmber600`.
- The crossed-cell red diagonal slash is a `Canvas` line in Compose
already — no SVG migration needed; it's already a stroked line, not a
gradient.
## Related code files
- `ui/theme/Color.kt` — add `BrandSky*`, `BrandRose600`, `BrandAmber600`,
`BrandEmerald600`, `BackgroundCream`. Remove `BrandPink*`, `BrandIndigo*`
once unused.
- `ui/theme/Theme.kt` — switch light `background` to `BackgroundCream`.
- `ui/master/MasterCell.kt`, `ui/master/CalledHistory.kt`,
`ui/master/CurrentNumberHero.kt` — replace `BrandPinkLight` with
`BrandSky600`.
- `ui/master/MasterControls.kt` — drop the gradient `Box` wrapper; use a
plain `Button(containerColor = BrandAmber600)`.
- `ui/board/PlayerBoardScreen.kt` — replace `BrandIndigoLight` (clear
button) with `BrandRose600`. Adjust `Generate` button to `BrandRose600`
if currently `BrandRoseLight`.
- `ui/board/KinhModal.kt` — drop the gradient `Brush` on "Kinh!" title;
use solid `BrandAmber600` (matches web's text-amber-600).
## Implementation steps
1. **Color.kt — add deeper shades**
```kotlin
// Solid-palette shades (matches Tailwind -600 step)
val BrandRose600 = Color(0xFFE11D48)
val BrandAmber600 = Color(0xFFD97706)
val BrandEmerald600 = Color(0xFF059669)
val BrandSky600 = Color(0xFF0284C7)
val BrandSky400 = Color(0xFF38BDF8) // dark variant
val BackgroundCream = Color(0xFFFFFBEB)
```
After all consumers migrate, delete:
```kotlin
// val BrandPinkLight, BrandPinkDark
// val BrandIndigoLight, BrandIndigoDark
```
2. **Theme.kt — light background**
```kotlin
private val LotoLightColorScheme = lightColorScheme(
// ...
background = BackgroundCream, // was BackgroundLight
// ...
)
```
3. **Master ≤49 token color** — every `BrandPinkLight` → `BrandSky600`
(light theme). For dark theme, use `BrandSky400`. Wire the dark
variant via `if (darkTheme) BrandSky400 else BrandSky600` at the
call site.
4. **MasterControls.kt — flatten "Ván mới"**
Replace the `Box { Button(transparent) }` wrapper with:
```kotlin
Button(
onClick = onNewGame,
colors = ButtonDefaults.buttonColors(
containerColor = BrandAmber600,
contentColor = Color.White,
),
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.btn_new_game), fontWeight = FontWeight.Bold)
}
```
Remove the `Brush.horizontalGradient` import.
5. **PlayerBoardScreen.kt — clear button**
Replace `BrandIndigoLight` with `BrandRose600`. Use `OutlinedButton`
contentColor.
6. **KinhModal.kt — flatten title**
Replace `Brush.horizontalGradient` with a solid `BrandAmber600` text
color. Drop the gradient brush import.
## Todo
- [ ] Color.kt: add 6 new tokens
- [ ] Theme.kt: light background → cream
- [ ] MasterCell.kt: pink → sky (theme-aware)
- [ ] CalledHistory.kt: pink → sky
- [ ] CurrentNumberHero.kt: pink → sky
- [ ] MasterControls.kt: drop gradient on "Ván mới"
- [ ] PlayerBoardScreen.kt: indigo → rose on clear button
- [ ] KinhModal.kt: drop gradient on "Kinh!"
- [ ] Color.kt: delete BrandPink/Indigo once unused
- [ ] Compile clean
## Success criteria
- No `Brush.horizontalGradient` import outside `Wordmark.kt`.
- "Ván mới" renders as solid amber, no orange→red split.
- Master ≤49 tokens render sky-blue, not pink.
- Light-mode background is cream (`#fffbeb`).
- "Kinh!" title is solid amber.
- `grep -r BrandPink` and `grep -r BrandIndigo` return only theme-deletion
candidates (or nothing after cleanup).
## Risks
- KinhModal Compose render previews may need re-snapshot if any exist.
- Dark-mode contrast: sky-600 on amber-cream cell could under-contrast.
Use sky-400 in dark mode (we add `BrandSky400` for that).
## Next
Phase 04 (voice-master hint copy).
@@ -0,0 +1,123 @@
# Phase 04 — Per-mode voice-master hint copy
## Context
Upstream commit `ee71bf0` (2026-04-27, after port baseline). The single
hint string was wrong in master-only mode (no player board → no
Chờ/Kinh). Web split into two:
- mode `both` → "Đọc số đã xổ + báo Chờ/Kinh."
- mode `master` → "Đọc số đã xổ."
Android currently shows no hint at all under the master-voice toggle.
## Priority
P2 — copy correctness.
## Status
pending.
## Key insights
- The hint should only render when `mode != PLAYER` (matches web's outer
`{#if settings.mode !== "player"}`). Android `VoiceToggles` doesn't
currently gate the master switch by mode — confirm the row is intended
to be visible in master + both, hidden in player. Web hides it in
player. Port that gating too.
## Related code files
- `app/src/main/res/values/strings.xml`
- `app/src/main/java/com/miti99/loto/ui/settings/VoiceToggles.kt`
- `app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt`
(verify nothing else duplicates the hint)
## Implementation steps
1. **strings.xml — add hints + mode-gated flag**
```xml
<!-- Voice-master hints, per mode (phase 04) -->
<string name="voice_master_hint_both">Đọc số đã xổ + báo Chờ/Kinh.</string>
<string name="voice_master_hint_master_only">Đọc số đã xổ.</string>
```
2. **VoiceToggles.kt — gate + hint**
```kotlin
@Composable
internal fun VoiceToggles(
state: SettingsState,
onSetVoiceEnabledMaster: (Boolean) -> Unit,
onSetVoiceEnabledPlayer: (Boolean) -> Unit,
onSetVoiceWaitingNumber: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
if (state.mode != SettingsState.Mode.PLAYER) {
SwitchRow(
label = stringResource(R.string.voice_enabled_master),
checked = state.voiceEnabledMaster,
onCheckedChange = onSetVoiceEnabledMaster,
)
val hintRes = if (state.mode == SettingsState.Mode.BOTH)
R.string.voice_master_hint_both
else
R.string.voice_master_hint_master_only
Text(
text = stringResource(hintRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, top = 2.dp, bottom = 8.dp),
)
}
if (state.mode != SettingsState.Mode.MASTER) {
SwitchRow(
label = stringResource(R.string.voice_enabled_player),
checked = state.voiceEnabledPlayer,
onCheckedChange = onSetVoiceEnabledPlayer,
)
AnimatedVisibility(visible = state.voiceEnabledPlayer) {
SwitchRow(
label = stringResource(R.string.voice_waiting_number),
checked = state.voiceWaitingNumber,
onCheckedChange = onSetVoiceWaitingNumber,
)
}
}
}
}
```
Note the player toggle gating mirrors web (hide in master-only mode).
Confirm with `MasterPanel.svelte` / `PlayerBoard.svelte` rendering
logic — web hides the player row in `mode === "master"`.
## Todo
- [ ] Add 2 strings
- [ ] Gate master row by mode
- [ ] Render per-mode hint under master row
- [ ] Gate player rows by mode (hide in master-only)
## Success criteria
- Mode `player` → only the player switch + (conditional) "đọc số khi Chờ"
visible. No master switch.
- Mode `master` → only the master switch + correct hint visible.
- Mode `both` → both switches visible; hint shows the "+ báo Chờ/Kinh."
variant.
- Hint typography matches the rest of the settings sheet ("body-small,
on-surface-variant" — the existing convention).
## Risks
- If any test asserts `voice_enabled_master` is always present, it will
fail in mode=player. Update phase-06 test plan accordingly.
## Next
Phase 05 (optional) or Phase 06 directly.
@@ -0,0 +1,123 @@
# Phase 05 — Extract auto-tick into pure helper (OPTIONAL)
## Context
Upstream commit `3e6cb90` (2026-04-28). Pulled the bus-driven auto-tick
effect body out of `PlayerBoard.svelte` into `src/lib/auto-tick.js` so
the dedup-by-`at` invariant — which already caught a P0 — is unit-testable
without mounting the component.
Android equivalent: `PlayerBoardViewModel.consumeBusEvent`. It already
encodes the dedup invariant via `lastConsumedEventId`. **Behavior is
already correct.** This phase is a refactor for testability parity.
## Priority
P3 — optional. Skip without consequence; the invariant is already
exercised in `PlayerBoardViewModelTest`.
## Status
pending.
## Decision
Recommend **defer** this phase unless a test gap emerges during phase 06.
The Kotlin VM-level test already covers monotonic-id dedup — extracting a
pure helper just shuffles the test boundary.
If we later want test parity:
## Implementation outline (if adopted)
1. **Create** `app/src/main/java/com/miti99/loto/state/AutoTick.kt`:
```kotlin
package com.miti99.loto.state
import com.miti99.loto.settings.SettingsState
data class AutoTickResult(
val crossed: List<List<Boolean>>,
val lastConsumedEventId: Long,
val changed: Boolean,
)
/**
* Pure auto-tick rule — port of upstream `processAutoTick`.
* Always advances `lastConsumedEventId` on any NEW event (id > last)
* even when no cell flips, so reactive re-runs of cross/grid changes
* don't re-fire the same event.
*/
fun processAutoTick(
grid: List<List<Int>>?,
crossed: List<List<Boolean>>,
event: DrawEvent?,
lastConsumedEventId: Long,
mode: SettingsState.Mode,
): AutoTickResult {
if (event == null || event.id <= lastConsumedEventId) {
return AutoTickResult(crossed, lastConsumedEventId, false)
}
val advanced = event.id
if (mode != SettingsState.Mode.BOTH) {
return AutoTickResult(crossed, advanced, false)
}
if (grid == null || crossed.isEmpty()) {
return AutoTickResult(crossed, advanced, false)
}
var hit = false
val updated = crossed.mapIndexed { r, line ->
line.mapIndexed { c, v ->
if (!hit && grid[r][c] == event.num && !v) { hit = true; true } else v
}
}
return if (hit) AutoTickResult(updated, advanced, true)
else AutoTickResult(crossed, advanced, false)
}
```
2. **Refactor** `PlayerBoardViewModel.consumeBusEvent` to call it:
```kotlin
private fun consumeBusEvent(event: DrawEvent) {
val cur = _state.value
val mode = currentSettings().mode
val r = processAutoTick(
grid = cur.grid,
crossed = cur.crossed,
event = event,
lastConsumedEventId = cur.lastConsumedEventId,
mode = mode,
)
if (r.changed) applyCrossedChange(r.crossed, r.lastConsumedEventId)
else _state.update { it.copy(lastConsumedEventId = r.lastConsumedEventId) }
}
```
3. **Tests** in `app/src/test/java/com/miti99/loto/state/AutoTickTest.kt`:
port the 8 vitest cases from `auto-tick.test.js` (NEW draw, dedup,
re-cross after manual untick, mode=master/player ignored, off-board,
null event, null grid, advance even on miss).
## Todo
- [ ] Create AutoTick.kt
- [ ] Refactor consumeBusEvent
- [ ] Port 8 tests
- [ ] Compile + tests pass
## Success criteria
- All existing PlayerBoardViewModelTest cases still pass.
- New AutoTickTest passes 8/8.
- `consumeBusEvent` body shrinks to ≤ 12 lines.
## Risks
- None — pure refactor. Behavior change is impossible (function returns
exactly the same `(crossed, lastConsumedEventId, changed)` tuple).
## Next
Phase 06 (tests + verification).
@@ -0,0 +1,114 @@
# Phase 06 — Tests + verification
## Context
Land tests for phases 01-04 (and 05 if adopted). Port the upstream test
fixture deltas where applicable. Verify build + smoke test on a device.
## Priority
P0 — gate before commit.
## Status
pending.
## Related code files
- `app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt`
- `app/src/test/java/com/miti99/loto/state/AutoTickTest.kt` (only if
phase-05 adopted)
## Implementation steps
1. **PlayerBoardViewModelTest — confetti tier rule (phase 01)**
Add cases:
- 1st bingo, no other waiting row → `bingoEvent.tier == 1`
- 1st bingo, another row in Chờ → `bingoEvent.tier == 2`
- 2nd bingo, regardless → `bingoEvent.tier == 2`
- Update existing test that asserted "tier=2 needs ≥3 bingos" — that
contract is gone. Replace with the 2nd-bingo case.
Sketch:
```kotlin
@Test
fun `tier-2 confetti fires on 1st bingo when another row is in Cho`() = runTest {
// craft a grid where rowA has 1 number remaining and rowB has 1
// number remaining; cross rowA's last cell → first bingo
// expect: state.bingoEvent.tier == 2
}
```
2. **PlayerBoardViewModelTest — section Chờ ring (phase 02)**
Add cases:
- No waiting row → `sectionHasWaiting == [false, false, false]`
- Row 4 has exactly 1 uncrossed → `sectionHasWaiting[1] == true`
(section 1 covers rows 3..5)
- Bingo on row 4 → `sectionHasWaiting[1]` flips back to false (because
row is now complete, not waiting)
3. **VoiceTogglesTest** (compose UI test, phase 04)
Optional but cheap. Skip if test infra not yet set up — manual smoke
in §5 covers it.
4. **Phase 05 (if adopted)**
Port the 8 cases from `auto-tick.test.js`:
- NEW event flips matching cell
- Dedup: same event id no-op
- Mode=master: advances id, no flip
- Mode=player: advances id, no flip
- Off-board number: advances id, no flip
- Null grid: advances id (event consumed), no flip
- Null event: no-op
- Re-cross after manual untick: a fresh event-id flips the cell again
5. **Manual smoke (cannot run from here, requires device)**
```bash
./gradlew :app:lint :app:test :app:assembleDebug
./gradlew :app:installDebug
```
Golden path:
- Generate grid → mark 4 of 5 cells in row 0 → section 0 label glows amber
- Mark the 5th → bingo modal + confetti (tier 1 since no other row in Chờ)
- Mark 4/5 of row 4 → section 1 glows
- Bingo row 1 → tier-2 confetti (1st bingo + Chờ on row 4)
- Settings → switch mode to "Cả hai" → master toggle hint reads "+
báo Chờ/Kinh"; switch to "Quản trò" → hint drops the "+ báo Chờ/Kinh"
- Master panel "Ván mới" button is solid amber, not gradient
- Master ≤49 number tokens render sky-blue (light) / sky-400 (dark)
- Light mode background reads as cream, not pure white
## Todo
- [ ] PlayerBoardViewModelTest: 3 new tier cases, update old assertions
- [ ] PlayerBoardViewModelTest: 3 sectionHasWaiting cases
- [ ] (Phase-05) AutoTickTest: 8 cases
- [ ] `./gradlew :app:test` clean
- [ ] `./gradlew :app:assembleDebug` clean
- [ ] Manual smoke per checklist
- [ ] Update `plans/todo.md` once verified
## Success criteria
- `./gradlew :app:test` exits 0
- `./gradlew :app:lint :app:assembleDebug` exits 0
- All bullets in §5 manual smoke pass
## Risks
- Some tier-rule tests existed under the old `>=3` contract; they will
fail when the rule flips. This is intended — replace, don't preserve.
## Next
Commit per phase, push branch, open PR (or land directly if working on
main per project convention).
@@ -0,0 +1,59 @@
# Loto upstream sync — 2026-04-29
Pull 6 logic/UX changes from `tiennm99/loto` (web) into the Android port.
Behavior-faithful where the platform allows; idiomatic Compose where it
matters.
## Source
- Repo: `tiennm99/loto` @ `2fb35f2` (2026-04-29 14:53 +0700)
- Port baseline: Android `fe52232` (covers upstream through `ad6291e`)
- Drift commits considered: 15 — see recon report
## Mode
`--port` (default). No transplant of Svelte/CSS; reproduce the visual and
behavioral outcomes in Compose.
## Phases
| # | Phase | Status | Risk | Files (delta) |
|----|------------------------------------|---------|------|---------------|
| 01 | Confetti & celebration tier rule | pending | low | 2 |
| 02 | Section "Chờ" ring on label band | pending | low | 3 |
| 03 | Solid-color palette refactor | pending | low | 8 |
| 04 | Per-mode voice-master hint copy | pending | low | 3 |
| 05 | (Optional) Extract auto-tick helper| pending | low | 3 |
| 06 | Tests + verification | pending | low | 2 |
Phase ordering: 01 → 02 → 03 → 04 → (05) → 06. Phases 01-04 are largely
independent; 06 lands tests for all of them at once.
## Out of scope (intentionally skipped)
- Settings modal sticky title/footer — `ModalBottomSheet` is the native
equivalent; problem already solved.
- Mode picker "Cả hai" glyph — Android picker is text-only segments.
- PWA cache eviction, CSP, npm vuln overrides — web-only.
## Reports
- Recon: `reports/xia-recon-260429-1511.md`
## Rollback
Each phase ships its own commit. Revert order = 06 → 05 → 04 → 03 → 02 → 01.
No data-shape changes, no DataStore migration, no audio-asset changes —
revert is `git revert <sha>` per phase.
## Decisions
- New constant `BrandSky*` colors added (replacing `BrandPink*` in master
≤49 path). Old `BrandPink*` and `BrandIndigo*` are removed once unused.
- Wordmark gradient kept — it's the brand's only intentional gradient.
- Section Chờ ring: solid amber band + 1.5 s pulse via Compose `infiniteTransition`.
Skip animation if `LocalAccessibilityManager.areAnimationsEnabled() == false`.
## Hand-off
Once approved, run: `/ck:cook plans/260429-1511-loto-upstream-sync/plan.md`
@@ -0,0 +1,143 @@
# Xia Recon — Upstream `tiennm99/loto` → Android port sync
## Source manifest
- Repo: `tiennm99/loto` (SvelteKit web app, Svelte 5 + Vite 7)
- Branch: `main`
- Local clone: `/config/workspace/tiennm99/loto`
- Latest commit at recon: `2fb35f2` (2026-04-29 14:53 +0700)
- Port baseline: Android `fe52232` (2026-04-27 19:33 +0700) — bundles upstream
through `ad6291e` (2026-04-27 18:57)
- Window of drift: 2026-04-27 19:33 → 2026-04-29 14:53 (≈ 44 h, 15 commits)
## Source map (logic-relevant)
- `src/lib/game-logic.js` — pure card gen, persistence, row helpers
- `src/lib/auto-tick.js` — pure helper for master→player auto-tick (NEW since
baseline)
- `src/lib/call-bus.svelte.js` — one-slot bus
- `src/lib/settings-store.svelte.js` — settings + DOM apply
- `src/lib/voice.js` — bundled-MP3 playback
- `src/lib/vietnamese-number.js` — number-to-words
- `src/lib/PlayerBoard.svelte` — player UI + bingo/chờ state
- `src/lib/MasterPanel.svelte` — host UI
- `src/lib/SettingsButton.svelte` — settings modal
- `src/app.css` — palette, animations, decorative borders
## Local map (Android)
- `game/GameLogic.kt`, `game/VietnameseNumber.kt` — pure logic
- `state/CallBus.kt`, `state/PlayerBoardViewModel.kt`,
`state/MasterPanelViewModel.kt` — coroutine state
- `audio/VoicePlayer.kt`, `audio/VoiceManifest.kt`
- `settings/SettingsRepository.kt`, `settings/SettingsState.kt`
- `ui/board/*`, `ui/master/*`, `ui/settings/*`, `ui/theme/*`
## Drift analysis
Commits on/after port baseline (15 total). Filtered to logic/UX-relevant:
| Commit | Subject | Layer | Port? |
|---------|---------------------------------------------|-------------|-------|
| 2fb35f2 | drop gradients, solid-color palette | UI | YES |
| a60ea08 | persistent Chờ ring + livelier confetti | UI + tier | YES |
| 2b74b2e | "Cả hai" glyph + sticky title/footer | settings UI | PART |
| 3e6cb90 | extract auto-tick into pure helper + tests | refactor | OPT |
| ee71bf0 | per-mode voice hint copy (+ PWA fixes) | copy | YES |
| f7db20c | UI polish v2 + PWA | UI + web | DONE |
| 9f24b6d | PWA cache eviction tighten | web only | NO |
| 7c893aa | CSP sha256 hash | web only | NO |
| 558d0c7 | inline-script guard | web only | NO |
| d94294d | npm vuln overrides | web only | NO |
| chore(plans) ×4 | plan docs | - | NO |
## Behavior delta (the part that matters)
### 1. Confetti tier rule (PlayerBoardViewModel.kt:166)
- **Was**: `tier = 2 if celebratedRows.size >= 3 else 1`
- **Now**: `tier = 2 if celebratedRows.size >= 2 OR
(celebratedRows.size >= 1 AND any other row in Chờ) else 1`
- Reason: 9-row card rarely hits 3 bingos; old threshold under-celebrated wins.
### 2. Confetti emoji + size
- Add `🥢 🎋 🏮` to set; per-piece size jitter 1.5–2.4rem (sp).
### 3. Section Chờ ring
- New `sectionHasWaiting[3]` derived flag — true iff any of its 3 rows is
one cell from bingo and not yet completed. Section label gets persistent
amber ring/glow (animation respects reduced-motion).
### 4. Per-mode voice-master hint
- Mode = `both` → hint: "Đọc số đã xổ + báo Chờ/Kinh."
- Mode = `master`→ hint: "Đọc số đã xổ."
- Currently Android has no hint at all under the master-voice toggle.
### 5. Solid-color palette
- Drop every gradient (Wordmark stays — it's the only intentional brand
gradient). Use solid: rose-600 (player+UI), amber-600 (host),
emerald-600 (draw), sky-600 (master ≤49).
- Replaces master ≤49 pink → sky.
- Player accent: indigo → rose.
- "Ván mới" amber→rose gradient → solid amber-600.
- Background warmer cream `#fffbeb` (light only).
### 6. Auto-tick pure helper (optional)
- Upstream extracted `processAutoTick({grid, crossed, lastDraw,
lastHandledAt, mode})` into `src/lib/auto-tick.js`.
- Android `PlayerBoardViewModel.consumeBusEvent` already implements the
same dedup-by-id invariant. Optional refactor for unit-testability
parity.
### 7. Settings sticky title/footer
- Web fix for iPhone SE viewport (375×667).
- Android: `ModalBottomSheet` already gives system-pinned drag handle
+ scroll behavior — same problem already solved by the platform.
**Skip.**
### 8. "Cả hai" glyph
- Web mode picker has SVG glyphs; Android `ModePicker` is text-only
(`SegmentedButton` + `Text`). **Skip.**
## Decision matrix
| Decision | Source's way | Local equivalent | Recommendation |
|------------------------|-----------------------------|----------------------------|----------------|
| Confetti tier | 2nd OR (1st + Chờ) | 3+ | Port (P0 — game feel) |
| Confetti emoji+size | + hội-chợ + jitter | base set, fixed 20.sp | Port (P1) |
| Section Chờ ring | amber pulsing band | - | Port (P1) |
| Voice-master hint copy | per-mode | (no hint shown) | Port (P2) |
| Solid-color palette | rose/amber/emerald/sky | rose/amber/emerald/pink+gr | Port (P1) |
| Auto-tick helper | extracted pure fn | inline in VM (semantics ✓) | Optional (P3) |
| Sticky modal bands | sticky CSS | ModalBottomSheet (native) | Skip |
| "Cả hai" glyph | inline SVG | text-only segment | Skip |
## Risk score
**Low.** No data-shape changes; no migration. All ports are additive (new
state field, new strings, new colors) or threshold tweaks. Tests cover the
existing tier rule — must update fixtures to the new threshold. No risk to
DataStore schema, audio assets, or audio-call coordination.
## Approach
`--port` mode: rewrite idiomatically for Compose/Kotlin. No transplanting
Svelte/CSS — the visual outcomes get expressed in Compose primitives.
## Open questions
- Should the Confetti emoji set still ship with `🥢 🎋 🏮` on devices where
the system font lacks coverage? Android default fonts since API 24 cover
these. **No action**, but revisit if QA reports tofu glyphs on older OEM
builds.
- The amber pulsing ring uses `prefers-reduced-motion`. Compose has no
direct equivalent; recommend gating on `LocalAccessibilityManager.current`
reduce-motion or simply using a static glow (no animation) — defer to
phase-02 implementation choice.
@@ -0,0 +1,149 @@
# Code review — upstream sync (6 changes)
**Plan:** plans/260429-1511-loto-upstream-sync/plan.md
**Recon:** plans/260429-1511-loto-upstream-sync/reports/xia-recon-260429-1511.md
**Scope:** 14 files, +215/-89 LOC. Correctness of 6 ported changes.
**Verdict:** **Approve** — no critical/major issues. 4 minors + 2 nits below.
## Critical
None.
## Major
None.
## Minor
### M1. Stale comment on tier-2 confetti gate
- **File:** app/src/main/java/com/miti99/loto/ui/board/PlayerBoardScreen.kt:160
- Comment reads `// Confetti for tier-2 bingo (≥ 3 rows complete)`. New rule is
"≥2 OR (≥1 + Chờ)". Update to: `// Confetti for tier-2 bingo (2nd+ row, OR 1st row while another is in Chờ)`.
- Risk: future reader may revert to old threshold thinking comment is the spec.
### M2. Dead 0.dp Spacer
- **File:** app/src/main/java/com/miti99/loto/ui/master/MasterControls.kt:70
- `Spacer(Modifier.width(0.dp))` is a no-op — parent `Row` already has
`Arrangement.spacedBy(8.dp)` (line 51). Drop the Spacer + its import isn't
actually unused (Spacer survives through dead code), so this is purely cosmetic.
### M3. CalledCellCream fill = BackgroundCream — zero contrast
- **Files:** app/src/main/java/com/miti99/loto/ui/theme/Color.kt:40 (`BackgroundCream = #FFFBEB`)
vs. line 53 (`CalledCellCream = #FFFBEB`).
- Light-theme page background and called-cell fill are now the same hex. The
colored ring is the only thing that separates cells from the page. Master
grid cells (tightly packed) read fine; the 160dp `CurrentNumberHero` circle
now visually "floats" — only the ring outlines it. Matches upstream, but
flag for design review.
- Suggested: either nudge `CalledCellCream` to amber-100 `#FEF3C7` (you
already have `CalledCellCreamDark` at that value but never use it), or
leave with explicit comment that this is intentional flat design.
### M4. PlayerBoardGrid pulse runs continuously even when no section is waiting
- **File:** app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt:60-69
- `rememberInfiniteTransition` + `animateFloat` always tick. When all three
sections have `isWaiting == false`, `pulseAlpha` is never read inside the
Modifier branch (the `else Modifier` path doesn't touch it), so no
recomposition is triggered — confirmed safe.
- Non-issue for jank: animation cost is negligible and doesn't drive
recompose. Optional optimization: gate the entire transition behind
`if (sectionHasWaiting.any { it })` to also kill the value-update job
when nothing waits, but YAGNI for a card with 9 rows.
## Nits
### N1. `CalledCellCreamDark` is declared but never referenced
- **File:** app/src/main/java/com/miti99/loto/ui/theme/Color.kt:54
- Either wire it into a dark-theme branch on the called-cell fill (parallel
to the Sky400/Sky600 split) or delete it. Currently dead.
### N2. `BackgroundLight` (#FFFFFF) still referenced as `onPrimary/onSecondary/onTertiary`
- **File:** app/src/main/java/com/miti99/loto/ui/theme/Theme.kt:11,13,15
- Theme.kt swapped `background` → `BackgroundCream` but `onPrimary` etc.
still point to `BackgroundLight` (= white). Intentional — these are
contrast-on-color tokens, not the page color. Just confirming this isn't
an oversight; it reads correctly.
## Verification of the 7 review questions
### 1. Tier rule correctness — Pass
Traced `applyCrossedChange` (PlayerBoardViewModel.kt:163-186) against
`GameLogic.getWaitingNumber` (GameLogic.kt:70-83):
- (a) **1st bingo + Chờ on another row** → `celebrated.size == 1`,
`hasActiveCho == true` (other row's `getWaitingNumber` is unaffected by
marking the just-completed row's last cell) → **tier 2**. ✓
- (b) **2nd bingo regardless** → `celebrated.size >= 2` short-circuits
before `hasActiveCho` check → **tier 2**. ✓
- (c) **1st bingo, no other Chờ** → `celebrated.size == 1`,
`hasActiveCho == false` → **tier 1**. ✓
- **Just-completed row not counted in `hasActiveCho`:** loop excludes
`otherRow !in celebrated` (line 173), and even without the guard,
`getWaitingNumber` returns null when 0 cells remain (GameLogic.kt:75-82
— `remaining` stays null). ✓
- **`crossedArr` is `newCrossed`** (line 152), so the just-marked cell
is reflected. ✓
Pre-existing edge case (not introduced by this PR): if a single draw
completes 2 rows simultaneously (two rows have the same waiting number),
Pass-1 only celebrates one per call. The 2nd row stays uncelebrated until
the next state change. This matches upstream Svelte semantics — flagging
only because it was on my radar.
### 2. Race / leak risk on shared infiniteTransition — No issue
The transition runs continuously but `pulseAlpha` is only read inside the
`if (isWaiting)` Modifier branch, so no useless recompose. Animation jobs
are owned by composition scope and torn down with the screen — no leak.
### 3. `isSystemInDarkTheme()` inside @Composable — All correct
- MasterCell.kt:57 — inside @Composable `MasterCell`. ✓
- CalledHistory.kt:74 — inside @Composable `CalledChip` (private). ✓
- CurrentNumberHero.kt:46 — inside @Composable `CurrentNumberHero`. ✓
### 4. Light/dark contrast on amber-cream cell — Adequate, see M3
Light theme: BackgroundCream `#FFFBEB` + Sky600 `#0284C7` ring → 6.1:1
contrast — passes WCAG AA. Sky600 text on CalledCellCream same value —
6.1:1 — fine. Emerald500 `#10B981` on cream — 2.1:1 — borderline for
small text but fine for the ring color (decorative). M3 flags the
fill==background issue separately.
### 5. VoiceToggles gating — Matches recon spec
VoiceToggles.kt:35 (`mode != PLAYER`) and :53 (`mode != MASTER`) hide the
master row in PLAYER-only mode and the player rows in MASTER-only mode.
Hint copy switches BOTH vs MASTER on line 41-44. Matches plan
phase-04-voice-hint-per-mode and recon §4.
### 6. Stale imports — None found
Grepped each modified file:
- KinhModal.kt: all 33 imports used. No stale `Brush` / gradient imports.
- MasterControls.kt: all 25 imports used. No stale gradient imports.
`BrandEmeraldLight` still used by `AutoCallChip` (line 111, 118).
- PlayerBoardGrid.kt: all imports used.
- PlayerBoardScreen.kt: all imports used.
- Master compose files: imports for `BrandSky400/600` and `isSystemInDarkTheme`
all referenced.
- Color.kt: comment references "former pink" — fine; no `BrandPinkX` /
`BrandIndigoX` left in any source file (verified with grep).
### 7. Behavior preservation — One tier consumer, OK
`grep -rn "bingoEvent.tier"` returns a single hit:
PlayerBoardScreen.kt:161 `state.bingoEvent?.tier == 2`. The new rule
still uses `tier ∈ {1, 2}`, so the existing "tier == 2 ⇒ confetti"
conditional fires at a *higher* rate than before (intended — see recon
§1). No type/shape break.
## Summary
The 6 ports are clean, the tier rule is correctly threaded through
`applyCrossedChange`, the Section Chờ ring uses a shared transition
without leaks, and the gating in `VoiceToggles` matches the upstream
spec. Theme cleanup is consistent (no leftover `BrandPink/Indigo`).
Recommended: **approve and ship**, optionally fold M1 (1-line comment)
and M2 (drop dead Spacer) into the same commit. M3/M4 are observations
for the design/perf docket, not blockers.
## Unresolved questions
- M3: Is `CalledCellCream == BackgroundCream` (both `#FFFBEB`)
intentional flat design, or should the called-cell fill nudge to
amber-100 `#FEF3C7` for contrast? Upstream parity suggests intentional;
worth a 1-line confirmation.
- N1: Should `CalledCellCreamDark` be wired into a dark-theme branch
for the called-cell fill (parallel to Sky400/Sky600), or deleted?