Compare commits

..

44 Commits

Author SHA1 Message Date
funman300 f63db769ae docs: cut v0.21.6 — Move Log panel + scrub-UX polish
Patch release rolling up six post-v0.21.5 commits under the
through-line "Move Log panel + scrub-UX polish":

- d3cb1a5: HC-mode coverage for scrub track + notches
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence
- d6f32d3: Move Log panel + active row (header + format helpers)
- 140251b: 2 prev rows above active
- e7345ae: active-row highlight with ACCENT_PRIMARY background
- 4437a1a: 2 next rows below active

The Move Log panel is the first replay-overlay surface that
isn't attached to the banner — it lives at a separate screen
anchor (bottom: 0) with its own spawn/despawn lifecycle.
Establishes the multi-anchor replay UI pattern that the
remaining B-2 sub-piece (mini-tableau preview) will inherit.

Panel grows 56 → 84 → 112 px across the four move-log commits.
HighContrastBackground primitive lifted to ui_theme parallel
to HighContrastBorder; settings_plugin gains
update_high_contrast_backgrounds for the BackgroundColor
repaint cycle. Continuous scrub uses a per-key accumulator
resource (ReplayScrubKeyHold) gated on SCRUB_REPEAT_INTERVAL_SECS
(0.1s).

Tests: 1250 → 1273 (+23 net new). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:46:24 -07:00
funman300 4437a1aaf9 feat(replay): add 2 next rows below active row in Move Log panel
Symmetric to the prev-rows commit. Adds 2 about-to-apply move
rows below the active row so the panel now shows a full 5-row
window: prev offset 2 → prev offset 1 → active → next offset 1
→ next offset 2. Panel grows from 84 → 112 px to fit the
additional rows.

Format helper `format_kth_next_row(state, k)` returns the kth
about-to-apply move's text:
- k=1 → moves[cursor], displayed as "{cursor + 1} │ {body}"
- k=2 → moves[cursor + 1], displayed as "{cursor + 2} │ ..."
- Returns empty when cursor + k - 1 >= moves.len() (under-fill
  late in the replay) or k=0 (degenerate).

Symmetric implementation:
- New `ReplayOverlayMoveLogNextRow { offset: u8 }` component
- Spawn loop iterates 1..=MOVE_LOG_NEXT_ROWS in order so offset
  1 sits directly below active, offset 2 below that
- Per-frame `update_move_log_next_rows` system mirrors the
  prev-rows updater
- TEXT_SECONDARY (matching prev rows) keeps the active row's
  highlight as the focal point

For post-game replays the next rows aren't spoilers (the game
is already won). If a future use case reuses the panel during
live play, the preview-shape would need rethinking.

4 new tests:
- format_kth_next_row: k=1, 2 in-range cases + k beyond
  moves.len() out-of-range + k=0 degenerate.
- move_log_next_rows_spawn_with_panel: cardinality matches
  MOVE_LOG_NEXT_ROWS.
- move_log_next_rows_paint_helper_strings_at_spawn: text
  matches helper output per offset.
- move_log_next_rows_underfill_at_replay_end: offset 1
  populates at cursor=9/10, offset 2 stays empty.

Tests: 1269 → 1273. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:44:59 -07:00
funman300 e7345aed6c feat(replay): highlight active row in Move Log panel
Wraps the active-row Text in a Node with
BackgroundColor(ACCENT_PRIMARY) so the row reads as "current
focus" against the panel's elevated background. Inner Text
colour bumps from TEXT_PRIMARY (#d0d0d0) to TEXT_PRIMARY_HC
(#f5f5f5) for legible contrast against the brick-red highlight.

format_active_move_row now prefixes the row with `▶` (the focus
marker) so the visual hierarchy is reinforced even before the
background paints (HC mode, future palette tweaks). The empty
case still returns empty — cursor=0 doesn't paint a stray "▶ "
prefix on an otherwise-empty row.

Mirrors the mockup at docs/ui-mockups/replay-overlay-mobile.html
§ "Move Log Card" where the active row has bg-suit-red-cb
(brick-red equivalent) + dark text + the ▶ marker.

3 new tests:
- active_row_wrapper_carries_accent_primary_background: walks
  from the active-row Text to its parent Node and asserts the
  wrapper carries BackgroundColor(ACCENT_PRIMARY).
- active_row_text_uses_high_contrast_color_for_highlight: pins
  the TextColor as TEXT_PRIMARY_HC.
- active_row_format_includes_focus_prefix: pure-helper guard for
  the ▶ prefix + the cursor=0-stays-empty contract.

Plus 2 existing tests updated for the new prefixed format
(format_active_move_row_handles_cursor_zero_and_positive,
move_log_active_row_repaints_on_cursor_advance).

Tests: 1266 → 1269 (+3 net new, +2 updated). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:41:14 -07:00
funman300 140251beae feat(replay): add 2 prev rows above active row in Move Log panel
Extends the Move Log panel's single active-row to a 3-row recent-
history window: 2 prev rows showing the moves applied just before
the active one, then the active row. Display order top-to-bottom:
header → prev offset 2 (oldest) → prev offset 1 → active.

Panel grows from 56 → 84 px to fit the additional rows. Active
row keeps TEXT_PRIMARY; prev rows render in TEXT_SECONDARY so
the active row stands out from context rows even without an
explicit highlight. (Active-row highlight is a follow-up commit.)

The format helper generalises:
- New `format_kth_recent_row(state, k)` returns the text for the
  kth-most-recently-applied move (k=1 is active, k=2 is row above,
  etc.). Returns empty when k > cursor (early-replay under-fill)
  or k = 0 (degenerate).
- `format_active_move_row` becomes a thin wrapper for k=1, kept
  at module scope so call sites stay readable.

New `ReplayOverlayMoveLogPrevRow { offset: u8 }` component carries
the row's offset (1 = just-before-active, 2 = before that). Spawn
loop iterates `MOVE_LOG_PREV_ROWS..=1` in reverse so the highest-
offset (oldest) row sits topmost in the panel's flex column.

Per-frame `update_move_log_prev_rows` system reads each row's
offset, computes k = offset + 1, and repaints via
format_kth_recent_row. Empty-when-out-of-range means panels gracefully
under-fill at cursor=1 (only active populated) and cursor=2
(active + offset 1, offset 2 empty).

4 new tests:
- format_kth_recent_row: k=1, 2, 3 in-range cases + k>cursor
  out-of-range + k=0 degenerate.
- move_log_prev_rows_spawn_with_panel: cardinality matches the
  MOVE_LOG_PREV_ROWS const.
- move_log_prev_rows_paint_helper_strings_at_spawn: text matches
  helper output per offset.
- move_log_prev_rows_repaint_on_cursor_advance: drives cursor=2
  → cursor=5 and asserts offset 1 / offset 2 texts follow.

Tests: 1262 → 1266. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:35:07 -07:00
funman300 d6f32d3154 feat(replay): add Move Log panel with active-row readout
First slice of the move-log mockup at
docs/ui-mockups/replay-overlay-mobile.html § "Move Log Card".
Adds a separate root UI entity anchored to the viewport's bottom
edge (sibling-of-banner pattern, mirrors ReplayFloatingProgressChip
lifecycle) carrying a `▌ MOVE LOG · N/M` header plus a single row
showing the most-recently-applied move.

Subsequent commits in this multi-session arc add prev/next rows,
active-row highlight, and auto-scroll on cursor advance. Splitting
the work at "panel + active row only" lands the structural piece
(panel exists, lifecycle works, format helpers proven) before
tackling the harder questions about rendering un-applied future
moves and scrolling.

Position decision: bottom-of-viewport (matches mockup), separate
root entity from the 92 px top banner. Keeps the banner from
growing further into a top-heavy 170+ px strip; the
top-status + bottom-info paradigm reads as vim/IDE-style buffer
chrome that players intuitively scan.

Four pure helpers handle the formatting:
- format_pile(p) → lowercase, 1-indexed display string
  ("foundation 3" rather than enum's 0-indexed Foundation(2))
- format_move_body(m) → "{from} → {to}" or "stock cycle"
- format_move_log_header(state) → "▌ MOVE LOG · N/M",
  "▌ MOVE LOG · COMPLETE" for `Completed`, empty for `Inactive`
- format_active_move_row(state) → "{cursor} │ {body}" with
  1-based cursor for player display, empty at cursor=0

Two per-frame update systems (update_move_log_header,
update_move_log_active_row) repaint the texts on resource change
with the standard early-exit-on-no-change idiom.

Despawn handling: react_to_state_change gains a third query for
ReplayOverlayMoveLogPanel entities and despawns them on
Playing → Inactive alongside the banner root and floating chip.

Panel border carries HighContrastBorder so the 1 px top edge
bumps under HC mode — same pattern as the keybind footer.

8 new tests:
- format_pile pile-name + 1-index pinning
- format_move_body both-variant pinning
- format_move_log_header three-state coverage
- format_active_move_row cursor=0 vs cursor>0
- move_log_panel spawn cardinality (exactly one)
- move_log_panel header paints helper string at spawn
- move_log_active_row repaints on cursor advance
- move_log_panel despawn parity with overlay tree

Tests: 1254 → 1262. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:29:37 -07:00
funman300 8fdc41f36f docs(handoff): record post-v0.21.5 polish; recommend notch-label centering
Two carve-outs land on top of v0.21.5:
- d3cb1a5: HC-mode coverage for scrub track + notches via new
  HighContrastBackground primitive in ui_theme + paint system
  in settings_plugin.
- 2e25476: continuous scrub on key-held ← / → at 100ms cadence;
  matches mockup's "[← →] scrub" terminology while keeping
  single-press = single-step semantics.

Update Since-cut log, status (1250 → 1254 tests passing,
flake cleared), and next-step menu. B-2 keyboard accelerator
coverage + accessibility + scrub UX are all complete; remaining
options are notch-label centering polish (smallest), the
move-log/mini-tableau multi-session arcs that close B-2, or
WIN MOVE marker HC bump (optional).

Recommended next-step: notch label centering (small).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:20:51 -07:00
funman300 2e25476d0a feat(replay): continuous scrub on key-held arrow keys
Holding ← or → now triggers continuous step at 100 ms cadence
(10 steps/sec) — matches the mockup's `[← →] scrub`
terminology while keeping single-press = single-step semantics.

Implementation: per-key accumulators in a new
`ReplayScrubKeyHold` resource. Each frame the key is held, the
corresponding accumulator absorbs `time.delta_secs()`; when it
exceeds `SCRUB_REPEAT_INTERVAL_SECS` (0.1s) the handler fires
another step and resets the accumulator. `just_pressed` events
bypass the accumulator entirely and fire immediately —
release resets to 0 so the next fresh press also fires
immediately rather than at half-interval.

Symmetric handling for ← (backwards step via undo) and →
(forward step). Both keys remain paused-only via the same
destructure-gate pattern in the underlying step helpers.

Footer text unchanged (`[← →] step`) — the only-wired-keybinds
discipline says "list what works"; held-key continuous scrub
is a discoverable enhancement to the same keybind, not a new
keybind.

`handle_arrow_keyboard` gains `Res<Time>` and
`ResMut<ReplayScrubKeyHold>` parameters. `Time` is provided by
MinimalPlugins's TimePlugin so headless tests already have it.

2 new tests (in addition to the 4 existing arrow scenarios):
- arrow_right_keyboard_repeats_while_held: drives time at
  exactly SCRUB_REPEAT_INTERVAL_SECS per tick and asserts that
  a second step fires after the just_pressed one.
- arrow_keyboard_release_resets_accumulator: verifies the
  release branch zeros the per-key accumulator.

Tests: 1252 → 1254. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:19:46 -07:00
funman300 d3cb1a51d4 feat(replay): HC-mode coverage for scrub track + notches
The 1 px scrub track and 5 quarter-mark notch ticks paint their
shape via BackgroundColor (not BorderColor — they're tiny
full-bleed Nodes, not borders on wider containers), so the
existing HighContrastBorder marker doesn't apply to them.

Add a parallel primitive in ui_theme: HighContrastBackground
marker carrying default_color, mirroring HighContrastBorder's
shape exactly. Add update_high_contrast_backgrounds system in
settings_plugin alongside update_high_contrast_borders — same
on/off rule (off → marker.default_color, on → BORDER_SUBTLE_HC),
same change-suppression idiom (only mutate when different so
Bevy's change-detection doesn't trigger per-frame repaints).

Tag the scrub track Node and all five notch Nodes with
HighContrastBackground::with_default(BORDER_SUBTLE) so the
existing settings repaint cycle picks them up under HC mode.

The scrub fill (ACCENT_PRIMARY brick-red) and WIN MOVE marker
(STATE_SUCCESS lime-green) don't get the marker — accent and
state colours are already saturated and don't need an HC
luminance variant.

2 new tests: spawn-time marker presence on the track and
cardinality-matches-notch-count on the ticks.

Tests: 1250 → 1252. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:14:03 -07:00
funman300 c8358f4275 docs(handoff): refresh post-v0.21.5 — anchor to new tag, reset menu state
Fold the six post-v0.21.4 commit narratives into CHANGELOG §
[0.21.5] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD `a2432df`, tags through v0.21.5, tests still
1250/1249 passing pending the time-dependent flake clearing).
Resume prompt now anchors at v0.21.5 with the smaller post-cut
menu of next-finite-steps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:08:56 -07:00
funman300 a2432dfe7a docs: cut v0.21.5 — replay-overlay scrubbing affordances + accessibility
Patch release rolling up six post-v0.21.4 commits under the
through-line "replay-overlay scrubbing affordances + accessibility":

- fe68861: quarter-mark scrub-bar notches
- d322abf: percentage labels under notches (banner 60 → 76 px)
- 1873b3f: keybind-hint footer (banner 76 → 92 px)
- 90e24d9: ESC accelerator + cross-plugin pause-modal gate
- 23902cd: HC-mode coverage for footer top border
- e5c4f51: ← / → keyboard accelerators for paused stepping

v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
the first scrubbing-shaped additions; v0.21.5 fills out the rest
of the scrubbing UX so the player has both visual anchor points
(notches + labels) and a complete keyboard control surface
(Space / Esc / ← / →) for navigating a paused replay.

Two of the six commits are layout-changing — they grow the
banner from 60 → 76 → 92 px to make room for the notch labels
and keybind footer. Banner geometry was fixed for every prior
B-2 commit; this release establishes the "grow the container,
add a flex-column child" pattern that the remaining B-2
sub-pieces (move-log scroller, mini-tableau preview) will
inherit when they land.

Tests: 1228 → 1250 (+22 net new), 1249 passing, 1 pre-existing
time-dependent flake (daily_challenge warning, fails when UTC
clock is within 30 min of midnight; verified not introduced by
this release).

Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:05:03 -07:00
funman300 511550232c docs(handoff): record HC marker + ← / → wiring; recommend v0.21.5 cut
Two more post-v0.21.4 carve-outs land:
- 23902cd: HC-mode coverage for keybind-footer top border
  (HighContrastBorder marker so apply_high_contrast_borders
  bumps the 1 px top border under HC).
- e5c4f51: ← / → keyboard accelerators for paused stepping
  (hooks game's undo system for backwards step; footer
  extended to [SPACE] pause/resume · [ESC] stop · [← →] step).

Update Since-cut log, visual-identity bullet, B option in the
Resume menu, status (1244 → 1250 total tests / 1249 passing /
1 pre-existing flake), and HEAD hint.

Six post-v0.21.4 commits now form a coherent through-line:
replay-overlay scrubbing affordances + accessibility. Resume
menu's B option now recommends cutting v0.21.5 as the natural
next boundary.

Pre-existing flake noted: daily_challenge warning test fails
when wall-clock UTC is within 30 minutes of midnight (the
warning window the test asserts against). Verified not
introduced by recent commits via stash-and-retest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:59:08 -07:00
funman300 e5c4f51a6e feat(replay): wire ← / → keyboard accelerators for paused stepping
→ during a paused replay advances by one move (mirrors the Stop
button's existing forward-step semantics). ← decrements the
cursor and dispatches `UndoRequestEvent`, which the game's
`handle_undo` reads next frame to reverse its most-recent move
— hooking the existing undo system rather than replaying
forward from cursor 0 (every replay-applied move pushes to the
undo stack the same way a player move would, so undo is the
right reversal primitive).

Both accelerators are paused-only — backwards via a new
`step_backwards_replay_playback` in `replay_playback.rs` that
hard-gates with the same destructure pattern as
`step_replay_playback`. Pressing → during running playback or ←
at cursor 0 are silent no-ops; the player learns "pause first,
then arrow."

The mockup labels these `[← →] scrub` (continuous fast scan).
Single-move step is the closest behaviour shippable today —
continuous scrub would need either a key-held event source or
an internal speed-up loop. Footer hint reads
`[← →] step` to match what's wired rather than the aspirational
"scrub."

Footer hint extended in lockstep:
`[SPACE] pause/resume · [ESC] stop · [← →] step` — the
only-wired-keybinds discipline holds.

ReplayOverlayPlugin gains `add_message::<UndoRequestEvent>()`
defensively so the plugin can run under MinimalPlugins without
GamePlugin attached (idempotent registration; harmless when
GamePlugin is also present).

6 new tests (2 hint pins + 4 keyboard scenarios) + 1 helper-pin
update for the new hint string.

Pre-existing flake noted: `daily_challenge_plugin::tests::
check_system_fires_warning_event_only_once_per_day` is failing
because wall-clock UTC is currently within 30 minutes of
midnight, inside the daily-expiry warning window the test
asserts against. Verified pre-existing by stashing all changes
and re-running — failure persists. Same shape as the
`winnable_seed_search` flake the handoff documented earlier
this session: time-dependent, deterministically passes under
different clock conditions. Not introduced by this commit.

Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:50:59 -07:00
funman300 23902cdc44 feat(replay): HC-mode coverage for keybind-footer top border
Tag the footer's border-carrying Node with
`HighContrastBorder::with_default(BORDER_SUBTLE)` so the existing
`apply_high_contrast_borders` system bumps the 1 px top border
from `BORDER_SUBTLE` (#505050) to `BORDER_SUBTLE_HC` (#a0a0a0)
when `Settings::high_contrast_mode` is on.

Without this the footer reads as floating loose under HC because
the border that visually anchors it to the labels row above is
near-invisible at #505050 against the elevated banner background.

The footer's text colours (`TEXT_SECONDARY` on both the
mode-line and the hint) don't need an HC bump — `TEXT_SECONDARY`
is already at `#a0a0a0`, the same luminance as `BORDER_SUBTLE_HC`.
There's no `TEXT_SECONDARY_HC` constant in the palette because
secondary text is already at HC-border level by design.

The notch labels also use `TEXT_SECONDARY` and inherit the same
"already HC-bright" property — no marker needed there either.

The 1 px scrub track, notch ticks, and WIN MOVE marker render
via `BackgroundColor` (not `BorderColor`) so the
`HighContrastBorder` marker doesn't apply. HC coverage for those
decorative pieces would need a custom settings-aware paint
system (precedent: `radial_rim_outline` in `radial_menu`) and is
deferred to a follow-up commit.

1 new test pinning the marker on spawn. 1243 → 1244. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:41:49 -07:00
funman300 3cc8eacafa docs(handoff): record ESC accelerator; B's next step is HC polish
Post-v0.21.4 fourth carve-out: 90e24d9 wires ESC for replay-stop
with a cross-plugin gate in pause_plugin to defer when replay is
playing. Footer extended in lockstep to
[SPACE] pause/resume · [ESC] stop. Update Since-cut log,
visual-identity bullet, B option in the Resume menu, status
(1240 → 1243 tests), and HEAD hint.

B option's next-step menu now has three branches: HC polish
(smallest), ← / → wiring (medium, needs backwards-step path),
and the multi-session move-log/preview arcs that close B-2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:07:33 -07:00
funman300 90e24d9711 feat(replay): wire ESC accelerator for stop, gate pause modal
ESC during an active replay now stops it (mirrors the existing
Stop button click). UI-first contract from CLAUDE.md §3.3 holds
for the keyboard accelerator: every keybind the footer surfaces
points at a wired action.

Cross-plugin coordination: pause_plugin's `toggle_pause` already
listens for ESC and would otherwise open the pause modal on the
same press. Resolved by adding a fourth defer-if check to the
existing modal-stack pattern in `toggle_pause` —
`replay_state.is_some_and(|s| s.is_playing())` slots in right
after `other_modal_scrims` and before `selection`. Symmetric
shape to the existing forfeit / modal-scrim / selection /
game-over / drag gates.

Footer hint extended from `[SPACE] pause/resume` to
`[SPACE] pause/resume · [ESC] stop` in lockstep — the
"only-wired-keybinds" discipline holds.

3 new tests:
- esc_keyboard_stops_active_replay (positive: Esc → Inactive,
  overlay despawns next frame)
- esc_keyboard_is_noop_when_not_playing (negative: doesn't fire
  on Inactive state, lets global Esc listeners own those frames)
- keybind_footer_hint_lists_space_and_esc (footer text contains
  both keybinds)

Plus updated helper-pin test for the new hint string. Existing
pause_plugin tests unaffected (they don't insert a
ReplayPlaybackState resource so the new gate is a no-op for
them).

Tests: 1240 → 1243 (+3). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 16:06:02 -07:00
funman300 decbe0bbd9 docs(handoff): record keybind footer; B's next step is ESC accelerator
Post-v0.21.4 third carve-out: 1873b3f ships a keybind-hint footer
(vim-style mode line + `[SPACE] pause/resume`) at the bottom of
the banner (76 → 92 px). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1236 → 1240 tests),
and HEAD hint.

Footer lists only wired keybinds. Next finite step on B-2: wire
ESC for stop and extend the footer to `[SPACE] pause/resume ·
[ESC] stop` — small, single-axis, surfaces another keyboard
accelerator alongside the existing Stop button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:59:57 -07:00
funman300 1873b3f9be feat(replay): add keybind-hint footer to overlay banner
Vim-style mode line on the left (`▌ NORMAL │ replay`) plus a
keybind-hint on the right (`[SPACE] pause/resume`) gives the
existing Space accelerator a visible UI counterpart, satisfying
the UI-first contract from CLAUDE.md §3.3 for the keyboard
accelerator that v0.21.4 shipped.

The footer lists only keybinds that are *actually wired today*.
Future commits that wire ESC for stop or ← / → for prev/next
move will extend the right-hand text in lockstep — the footer
never lists aspirational keybinds (would lie to users).

Banner height grew from 76 → 92 px to make room for the 16 px
footer row. Second layout-changing commit in B-2's screen-
takeover arc; same "grow container, add flex-column child"
pattern as the notch-labels commit. 1px top border in
BORDER_SUBTLE separates the footer from the notch-label row.

Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable
without per-text marker components on the inner Text entities.
The shared `font_handle_for_labels` clone covers both label and
footer text spawns since the labels closure only `.clone()`s
the handle (never moves it).

4 new tests: pure-helper guards, footer-spawn cardinality
(exactly one), text-set assertion (both helper strings appear as
descendants), lifecycle parity with the overlay tree.

Tests: 1236 → 1240 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:58:28 -07:00
funman300 d11d97e677 docs(handoff): record notch labels; B's next step is keybind footer
Post-v0.21.4 second carve-out: d322abf ships percentage labels
under each scrub-bar notch (banner 60 → 76 px — first real layout
change in B-2's arc). Update Since-cut log, visual-identity
bullet, B option in the Resume menu, status (1232 → 1236 tests),
and HEAD hint.

Banner geometry is now mutable; future B-2 sub-pieces follow the
same "grow container, add flex-column child" pattern. Next
finite step: keybind-hint footer (small) before the bigger
move-log / mini-tableau pieces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:52:28 -07:00
funman300 d322abf67b feat(replay): add percentage labels under scrub-bar notches
Five `0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
row beneath the 1 px scrub track give the player explicit
quarter-mark readouts to pair with the notch ticks.

Pure helper `scrub_notch_labels()` returns the fixed array,
paired index-for-index with `scrub_notch_positions()`. Spawn loop
zips both helpers and applies an "endpoints flush, middle three
percent-anchored" positioning pattern: leftmost label gets
`left: 0` (no clip on `0%`), rightmost gets `right: 0` (no overflow
on `100%`), middle three anchor at `left: Val::Percent(p)` since
Bevy 0.18 UI lacks a clean CSS-style `translate-x: -50%` centering
primitive. The slight right-of-notch offset on the middle three
is visually subtle at TYPE_CAPTION; explicit polish target if
anyone notices.

Banner height grew from 60 → 76 px to make room for the label row
(76 = top row 59 flex-grow + scrub track 1 + label row 16). First
real layout change in B-2's screen-takeover arc — every prior
B-2 commit was additive at fixed banner geometry.

Label color is TEXT_SECONDARY rather than mockup's `text-outline`
(BORDER_SUBTLE) — the latter would match the notches but is too
low-contrast against BG_ELEVATED_HI to read at 12 px. TEXT_SECONDARY
keeps the subdued caption hierarchy while staying legible.

4 new tests: pure-helper guard pinning the array + helper-positions
pairing invariant, spawn cardinality, set equality between spawned
texts and helper output, lifecycle parity with the overlay tree.

Tests: 1232 → 1236 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:51:09 -07:00
funman300 c9e4c0b4cd docs(handoff): record scrub-bar notches; B's next step is notch labels
Post-v0.21.4 carve-out: fe68861 ships quarter-mark notches on the
scrub bar. Update Since-cut log, visual-identity bullet, B option
in the Resume menu, status (1228 → 1232 tests), and HEAD hint.

Next finite step on B-2: percentage labels under each notch —
forces banner height to grow from 60 px to ~76 px, making it the
first real layout change in the screen-takeover arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:44:05 -07:00
funman300 fe68861e10 feat(replay): add quarter-mark notches to scrub bar
Five 1px vertical ticks at 0/25/50/75/100% give the player visual
anchor points for "where am I, relative to the quarter-marks of the
replay" without needing to mentally bisect the bar.

Pure helper `scrub_notch_positions()` returns the fixed array; the
spawn loop sits next to the WIN MOVE marker spawn so the two
overlays share their lifecycle with the rest of the overlay tree.
Notches paint in BORDER_SUBTLE (same as the unfilled track) and
extend vertically past the 1px track (5px tall, anchored 2px above
the track top) — same visibility trick the WIN MOVE marker uses.

Spawned after the WIN MOVE marker so a notch and the marker landing
on the same percentage paint the marker on top.

Mirrors the notch ladder in the screen-takeover mockup at
docs/ui-mockups/replay-overlay-mobile.html. First finite step toward
B-2's screen-takeover layout reflow; labels under each notch land in
a follow-up commit when the banner height grows to accommodate them.

4 new tests: pure-helper guard pinning the [0,25,50,75,100] array,
spawn-cardinality matching helper.len(), lifecycle parity with the
overlay tree, independence from win_move_index.

Tests: 1228 → 1232 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:42:37 -07:00
funman300 c33b39cf11 docs(handoff): refresh post-v0.21.4 — anchor to new tag, reset menu state
Anchors handoff to v0.21.4 at `23ff62c`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
bumps the Resume-prompt summary to reflect replay-scrubbing
accessibility as the v0.21.4 through-line, and identifies the
screen-takeover layout reflow as the remaining multi-session arc
on B (with move-log scroller + mini-tableau preview as small
sub-pieces inside it).

Resume menu stays at A/B/C — A and C unchanged; B's prerequisite
sub-pieces shipped in v0.21.4 so the entry now points cleanly at
the layout reflow as the single remaining multi-session piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:28:50 -07:00
funman300 23ff62c397 docs: cut v0.21.4 — replay-scrubbing accessibility
Patch release for the three post-v0.21.3 commits on the B-2 replay
screen-takeover redesign arc. One through-line: the replay overlay
gains scrubbing affordances. The player can see at a glance where
the winning move sits (WIN MOVE marker on the scrub bar) and stop
on any move to inspect the board (pause / resume / step controls
plus a Space keyboard accelerator).

Also adds the data foundation that makes the marker possible:
`Replay::win_move_index: Option<usize>`, an additive serde-default
field that doesn't bump `REPLAY_SCHEMA_VERSION` because legacy
on-disk replays load with `None` and simply don't get a marker.

Remaining B-2 work — screen-takeover layout, move-log scroller,
mini-tableau preview — shares a layout-reflow prerequisite the
banner-only overlay can't carry, so it's deferred to a future
cycle that can take it as a single multi-session arc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:26:54 -07:00
funman300 0b2ffca016 docs(handoff): record playback controls; B's next step is takeover layout
Captures `fbe48ac` (pause / resume / step + Space accelerator) under
"Since the v0.21.3 cut", marks playback controls closed in the
Visual-identity follow-ups list, identifies the screen-takeover
layout itself (with move-log scroller + mini-tableau preview as its
sub-pieces) as the next finite step on B, and bumps the test count
to 1228.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:21:48 -07:00
funman300 fbe48acef6 feat(replay): playback controls — pause / resume / step + Space accelerator
Third commit on the B-2 replay screen-takeover redesign. Adds the
ability to pause an in-flight replay, step through it one move at
a time while paused, and resume — both via on-screen buttons
(UI-first contract per CLAUDE.md §3.3) and the optional `Space`
keyboard accelerator.

State shape: a new `paused: bool` field on
`ReplayPlaybackState::Playing`. The `tick_replay_playback` system
skips the `secs_to_next` decrement entirely while `paused` is set
so cursor and timer freeze together — resuming starts the next
move from a full interval. Stepping fires the next move directly
via a new `step_replay_playback` API that bypasses the tick path
and is hard-gated to `Playing { paused: true }` so it can't race
the running tick loop.

Public API additions:
- `toggle_pause_replay_playback(state)` — flips the flag, returns
  the new value (or None when not Playing).
- `step_replay_playback(state, moves_writer, draws_writer)` —
  advances exactly one move when paused; returns true on dispatch,
  false on any guard miss.

UI:
- Pause / Resume button next to Stop. Label repaints reactively
  via `update_pause_button_label`, which walks `Children` from
  the marked button to its inner `Text` so the spawn path doesn't
  need a second marker.
- Step button next to Pause. Click fires the next move; while
  unpaused the click is a no-op (guarded inside
  `step_replay_playback`).
- `Space` keyboard handler reads `Option<Res<ButtonInput>>` and
  no-ops when missing — keeps test-app compatibility under
  `MinimalPlugins`.

Test coverage: pause-button label truth table, label repaint on
state change, click-toggles-paused, step advances cursor exactly
one with paused flag preserved, step-while-running is no-op,
Space toggles paused flag. 8 new tests (1220 → 1228).

Side-effect: 25 existing `Playing { ... }` construction sites
across `replay_overlay`, `achievement_plugin`, and
`replay_playback` tests gained `paused: false` to satisfy the new
field requirement. Mechanical edit; no behavioral change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:20:45 -07:00
funman300 cd79877933 docs(handoff): record WIN MOVE marker ship; B's next finite step
Captures `52befa6` (WIN MOVE marker on the scrub bar) under "Since
the v0.21.3 cut", marks the marker piece of B-2 closed in the
Visual-identity follow-ups list, identifies playback controls
(play/pause/step) as the next bounded commit on B, and bumps the
test count to 1220.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:54:44 -07:00
funman300 52befa6199 feat(replay): WIN MOVE marker on the scrub bar
Second commit on the B-2 replay screen-takeover redesign — the UI
that consumes the data field landed in `ab857bb`. Adds a small
green tick on the scrub bar at `replay.win_move_index / total`,
positioned so the playback cursor reaches the marker exactly when
the move it's about to apply IS the winning move.

Implementation: a new `ReplayOverlayWinMoveMarker` component
spawned alongside `ReplayOverlayScrubFill` as a sibling under the
1px scrub track. Position computed by a pure helper
`win_move_marker_pct` that returns `None` for any of: state not
`Playing`, replay's `win_move_index` is `None` (older replay
loaded from disk pre-dating the field), or empty move list. The
percentage is clamped to `[0, 100]` defensively. Marker is
absolute-positioned with `top: -1px` so the 3px-tall tick is
centered on the 1px track line — 1px above and 1px below.

Lifecycle is "spawn-time only" — the marker position never changes
during a single playback because the underlying replay is
immutable while `Playing`. Despawned with the rest of the overlay
tree when the state returns to `Inactive`.

8 new tests cover: pure helper for Inactive / Completed / no-field /
correct-position / clamp; spawn presence with field; spawn absence
without field; despawn-with-overlay lifecycle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:53:40 -07:00
funman300 e63046700c docs(handoff): record win_move_index data field; B's next finite step
Captures `ab857bb` (Replay::win_move_index data field) under "Since
the v0.21.3 cut". Updates the Visual-identity follow-up entry for
B-2 to flag the data-layer prerequisite as landed and identifies
the WIN MOVE scrub-bar marker UI as the natural next finite commit.
Bumps test count to 1212.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:59 -07:00
funman300 ab857bbb6e feat(data): add Replay::win_move_index for the WIN MOVE scrub marker
First finite step toward the B-2 replay screen-takeover redesign:
the data foundation. Adds an additive optional `win_move_index:
Option<usize>` field on `Replay`, defaulting to `None` via
`#[serde(default)]` so older `latest_replay.json` /
`replays.json` files load unchanged — no `REPLAY_SCHEMA_VERSION`
bump needed since the field is purely additive and nullable.

Populated at the live recording site (`game_plugin::handle_game_won`)
via a new builder-style setter `Replay::with_win_move_index`. For
fresh recordings the value is always `Some(moves.len() - 1)`
because recording freezes on win, but storing the index
explicitly lets the playback UI read the WIN MOVE position
directly without re-deriving it on every render — and leaves
room for future recording semantics that capture post-win state.

UI consumption (the WIN MOVE marker on the scrub bar, plus the
broader screen-takeover redesign — move-log scroller, mini-
tableau preview, playback controls) lands in subsequent commits.

Test coverage: default value, builder set / set-None, on-disk
round-trip, and the legacy-JSON-loads-with-None backward-compat
contract (the test that pins the no-schema-bump claim).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:02 -07:00
funman300 886e0cf8a1 docs(handoff): refresh post-v0.21.3 — anchor to new tag, reset menu state
Anchors handoff to v0.21.3 at `3d92a91`, resets the "Since the cut"
section to placeholder, updates the READ FIRST CHANGELOG pointer,
and bumps the Resume-prompt summary to reflect the accessibility
arc closure as the v0.21.3 through-line. Resume menu stays at
A/B/C since v0.21.3 closes only post-v0.21.2 carve-outs (the
remaining options were already heavy / multi-session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:41:02 -07:00
funman300 3d92a91e3b docs: cut v0.21.3 — accessibility arc closure + Toast Warning driver
Patch release for the two post-v0.21.2 commits. One through-line:
the v0.21.2 "dynamic-paint sites stay un-tagged" carve-out turned
out to be over-cautious — re-reading the code showed only the
radial rim was actually a border-paint cycle. v0.21.3 closes the
carve-out: HUD action buttons + modal buttons take the existing
`HighContrastBorder` marker pattern; the radial rim folds HC into
its per-frame respawn via `radial_rim_outline`.

Bonus: `ToastVariant::Warning` gets its first real consumer in
this cycle (daily-challenge expiry < 30 min from UTC reset). Every
`ToastVariant` now has at least one driver — the enum is fully
load-bearing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:39:46 -07:00
funman300 9113cdb483 docs(handoff): record HC dynamic-paint rollout; menu drops D → 3 options
Marks the HC dynamic-paint rollout (`c153363`) closed under the
High-contrast accessibility entry, captures it in "Since the v0.21.2
cut", bumps the test count to 1207, and trims the Resume prompt
menu from 4 → 3 options (A Android, B replay screen-takeover,
C Phase 8 sync). All three remaining options are multi-session by
nature; the resume prompt now flags that explicitly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:36:00 -07:00
funman300 c153363626 feat(accessibility): finish HC rollout — HUD + modal buttons + radial rim
Closes the v0.21.2 carve-out: dynamic-paint sites that were left
un-tagged because their paint cycles were assumed to race
`update_high_contrast_borders`. Re-reading the code revealed only
one of three sites is actually a border-paint cycle — the other
two paint backgrounds, with static borders that take the marker
pattern cleanly:

* HUD action buttons (`spawn_action_button`): `paint_action_buttons`
  only mutates `BackgroundColor`. Tag the spawn with
  `HighContrastBorder::with_default(BORDER_SUBTLE)`.
* Modal buttons (`spawn_modal_button`): `paint_modal_buttons` also
  only mutates `BackgroundColor`. Same marker pattern.
* Radial menu rim (`radial_redraw_overlay`): full despawn-respawn
  every frame; sprites, not UI nodes; the marker can't apply. Folds
  the HC choice into the spawn site instead — under HC the
  *focused* rim boosts to `BORDER_SUBTLE_HC` rather than
  `BORDER_STRONG`. Naive marker substitution would invert the
  visual hierarchy because `BORDER_SUBTLE_HC` (#a0a0a0) is lighter
  than `BORDER_STRONG` (#505050); folding the choice in keeps the
  focused rim *more* visible under HC, not less.

Decision logic for the rim is extracted to `radial_rim_outline` —
a pure function with a 4-row truth-table test (focused × HC).

After this commit, every UI surface tagged in v0.21.x's
accessibility arc either carries `HighContrastBorder` or has its
HC behaviour folded into its own spawn cycle. No "un-tagged
because race-risk" surfaces remain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:34:05 -07:00
funman300 93b67f1d0b docs(handoff): record Toast Warning wiring; menu drops C → 4 options
Marks the daily-challenge-expiry Warning toast (`279e23d`) closed in
the Visual-identity follow-ups list, captures it in "Since the
v0.21.2 cut", bumps the test count to 1203, and trims the Resume
prompt menu from 5 → 4 options (A Android, B-2 replay takeover,
C Phase 8 sync, D HC dynamic-paint).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:25:10 -07:00
funman300 279e23d0af feat(toast): wire ToastVariant::Warning for daily-challenge expiry
Adds the first in-engine consumer of `ToastVariant::Warning` — a 4s
amber-bordered toast that fires once per daily-challenge date when the
player is within 30 minutes of UTC midnight reset and hasn't yet
completed today's challenge.

Mirrors the v0.21.2 `ToastVariant::Error` wiring: a domain-event
message (`WarningToastEvent(String)`) crosses the plugin boundary;
`animation_plugin::handle_warning_toast` reads it and spawns the
fire-and-forget toast. Suppression is decided by a pure helper
(`compute_expiry_warning_minutes`) that's exhaustively covered by 7
unit tests + 1 in-Bevy idempotence test.

After this lands, every `ToastVariant` (Info, Warning, Error,
Celebration) has at least one real driver — closing the "is this enum
scaffolding or load-bearing?" ambiguity that's been latent since the
variant was introduced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:22:58 -07:00
funman300 12fba2157a docs(handoff): refresh post-v0.21.2 — anchor to new tag, update menu
Mirrors the post-v0.21.0 → v0.21.1 → v0.21.2 cut-then-refresh
pattern. Cut commit (f23df3b) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.2.

Updated:
- Header points to v0.21.2 at f23df3b; opening paragraph
  summarizes the patch's three threads (accessibility
  extensions, replay polish, first real Toast Error consumer).
- Status at pause: tests bumped to 1195 (net +3 from v0.21.1's
  1192); tags list extended through v0.21.2.
- "Since the v0.21.1 cut" → "Since the v0.21.2 cut" with the
  closure narratives dropped (now in CHANGELOG.md § [0.21.2]).
  Section reset to "no threads in flight" placeholder.
- Visual-identity follow-ups: marked floating MOVE chip closed
  by v0.21.2 (`2fb2d63`), Toast Error closed by v0.21.2
  (`68d50b5`); HC + reduce-motion entries updated to reflect
  v0.21.2's HC chrome rollout (8 surfaces) and splash
  reduce-motion gating. Toast Warning still open with a
  candidate driver suggestion (daily-challenge expiry).
- Resume prompt menu retuned: A (Android) and D (Phase 8)
  unchanged; B narrowed to just the screen-takeover redesign
  (the floating chip piece shipped); C narrowed to just
  Warning variant (Error done); new E added for
  HC+reduce-motion on dynamic-paint sites (HUD action buttons,
  etc — explicitly carved out of the v0.21.2 HC rollout
  because of paint-cycle races).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:08:17 -07:00
funman300 f23df3b805 docs: cut v0.21.2 — accessibility extensions + replay polish + first real Toast Error consumer
Promotes [Unreleased] to [0.21.2] dated 2026-05-08 and opens a
fresh empty [Unreleased]. Patch release covering 6 substantive
post-v0.21.1 commits (plus the v0.21.1 handoff refresh).

Three through-lines:

- **Accessibility extensions.** Closes the two threads v0.21.1
  left explicitly open. Reduce-motion was previously gated only
  on card slide_secs; v0.21.2 extends it to splash scanline +
  cursor pulse (`ed152e2`). HC borders had `BORDER_SUBTLE_HC`
  defined but no consumers; v0.21.2 builds the
  `HighContrastBorder` marker + `update_high_contrast_borders`
  system (`c9af1ea`) and rolls it out across 8 surfaces
  (`d87761d` + `ec804d5`).

- **Replay polish.** New floating MOVE chip rendered above the
  destination pile of the most-recently-applied move during
  playback (`2fb2d63`). World-space `Text2d` entity that
  reuses the same `LayoutResource` pile coordinates as every
  other piece of pile geometry — stays correctly positioned
  through window resizes without any UI / camera math.

- **First real `ToastVariant::Error` consumer.** Wires
  `MoveRejectedEvent` to a 2-second pink-bordered "Invalid move"
  toast (`68d50b5`). Joins the existing `card_invalid.wav`
  audio + destination-pile shake visual as the
  accessibility-focused readable text channel.

cargo clippy --workspace --all-targets -- -D warnings clean.
1195 passing / 0 failing (net +3 from v0.21.1's 1192).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:06:14 -07:00
funman300 68d50b5021 feat(toast): wire ToastVariant::Error for invalid-move feedback
Resume-prompt Option C — first in-engine consumer of
`ToastVariant::Error`. The variant has had a slot in the enum
since v0.20.0's toast system landed; this commit wires a real
driver event so the slot is no longer dead code.

### Driver: MoveRejectedEvent

When a player tries an illegal placement (drops dragged cards on
a real pile but the move violates the rules), `MoveRejectedEvent`
fires. The existing rejection-feedback chain plays
`card_invalid.wav` (audio cue) and triggers the destination-pile
shake (visual cue via `feedback_anim_plugin`). This commit adds a
third leg — a 2-second pink-bordered Error toast reading
"Invalid move" — primarily for accessibility:

- **Audio cue alone** doesn't help deaf players.
- **Visual shake alone** is brief and easy to miss for low-vision
  players or anyone with reduce-motion enabled (which gates the
  shake's animation timing).
- **Toast text** is persistent ~2 s, readable, and unambiguous.

The three legs together cover the major perception channels.

### Implementation

New `handle_move_rejected_toast` system in `animation_plugin`
mirrors the shape of `handle_xp_awarded_toast` — read events,
fire `spawn_toast(commands, "Invalid move", 2.0,
ToastVariant::Error)`. Registered in the plugin's Update set
between `handle_xp_awarded_toast` and `tick_toasts` so the toast
spawn pipeline picks it up the same frame the event fires.

`AnimationPlugin::build` gains
`.add_message::<MoveRejectedEvent>()` so the message is
initialized when the plugin runs under MinimalPlugins (tests).
The message is also registered by `feedback_anim_plugin` —
Bevy's `add_message` is idempotent, so both registrations
coexist cleanly.

Also drops the `#[allow(dead_code)]` from `ToastVariant::Error`
(stale now that the variant has a real consumer) and updates the
variant's doc comment to point at `handle_move_rejected_toast`.

### Test

New `move_rejected_event_spawns_error_toast` pins the wiring:
firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay`
on the next tick. Matches the shape of the existing
`info_toast_event_spawns_toast_overlay` test. 1195 passing
(+1 from prior 1194).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:59:39 -07:00
funman300 ec804d54c6 feat(accessibility): finish HC chrome rollout — home + settings panel borders
Continues the rollout from `c9af1ea` (modal scaffold) and
`d87761d` (tooltip + 3 panels). Tags the remaining 7 static-
border surfaces in the chrome so the HC chrome thread is
effectively complete:

- **`home_plugin.rs` × 3**: the home-screen Level/XP/Score
  summary row (line 842), the home-screen mode-selector
  buttons (line 945), the home-screen mode-hotkey chips
  (line 1158).
- **`settings_plugin.rs` × 4**: the card-back picker swatches
  (line 1952), the theme picker swatches (line 2093), the
  Sync Now button (line 2214), and the swatch glyph buttons
  (line 2274).

Pre-tagging audit: confirmed none of these sites have a
dynamic-paint system that would race the
`update_high_contrast_borders` system. `paint_action_buttons`
in `hud_plugin.rs` only paints entities tagged with the
`ActionButton` marker (HUD buttons only). The focus-overlay
system in `ui_focus.rs` spawns *separate* overlay entities for
focus indication, never mutating the original `BorderColor`.
Settings panel buttons / swatches use their own
`SettingsButton` enum for click routing; their `BorderColor`
is set at spawn time and not touched again.

After this commit, every `BorderColor::all(BORDER_SUBTLE)` site
in the chrome (excluding the dynamic-paint sites that are
intentionally skipped — HUD action buttons, modal buttons,
radial menu rim) carries a `HighContrastBorder` marker. The
HC thread for chrome borders is closed; the dynamic-paint
sites remain open for a future iteration that needs a
different shape (folding HC into the dynamic-paint logic, or
having HC consult hover/focus state).

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level lifecycle of `HighContrastBorder`
was already covered by the modal-scaffold scaffolding in
`c9af1ea`). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:47:58 -07:00
funman300 d87761d451 feat(accessibility): roll HighContrastBorder out to tooltip + 3 panel borders
Continues the HC chrome rollout started by `c9af1ea` (which wired
just the modal scaffold). Tags four more static-border surfaces
so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast
mode is on:

- **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption
  popup. Border legibility matters because tooltips are usually
  brief — if the player has to squint to find the panel edge,
  the tooltip dismisses before they've parsed it.
- **Onboarding banner key chips** (`onboarding_plugin.rs:388`).
  The first-run UI's "press H or ?" key chips. First-run
  onboarding has the highest stakes for accessibility — a
  low-vision player who can't see the chips can't discover
  the help system.
- **Help panel key chips** (`help_plugin.rs:265`). Same
  treatment as the onboarding chips: keyboard-shortcut chips
  inside the F1 cheat sheet.
- **Stats panel cells** (`stats_plugin.rs:1019`). The S-key
  overlay's individual stat cells. A dense grid of bordered
  numbers is exactly the kind of surface where HC's
  `#505050 → #a0a0a0` boost makes the layout legible.

Each tagging is one line on the spawn tuple plus an import. The
existing `update_high_contrast_borders` system in
`settings_plugin` (added in `c9af1ea`) handles all tagged
entities uniformly — no system changes needed.

### Skipped on this pass

Sites with dynamic hover/focus paint systems (HUD action
buttons, modal buttons, radial menu rim) intentionally not
tagged because their existing paint cycles would race the HC
system. Wiring HC into those needs a different shape — either
fold HC into the dynamic-paint logic, or have HC consult the
hover/focus state. Future scope.

Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home
menu element borders, `settings_plugin.rs:1952/2093/2214/2274`
settings panel rows) are likely fine to tag but I'm capping
this commit at four to keep it reviewable. Pattern is
established; future commits can extend.

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level test in `c9af1ea`'s scaffolding
covers all tagged entities uniformly). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:43:04 -07:00
funman300 2fb2d638bf feat(replay): floating MOVE chip above the focused card during playback
Resume-prompt Option B (smaller scope variant) — closes the
"floating MOVE chip" piece flagged as future scope in v0.21.1's
replay-overlay punch list. Leaves the multi-session screen-
takeover redesign for a future B-2.

The existing banner-anchored MOVE chip stays put — it provides
the at-a-glance overview. The new floating chip mirrors the same
text but renders above the destination pile of the most-recently-
applied move, keeping progress at the player's focal point so they
don't have to look up at the banner during fast-paced playback.

### Architecture

- New `ReplayFloatingProgressChip` marker component on a
  `Text2d` entity rendered in 2D world space. World-space
  placement (rather than UI-space + camera projection) keeps
  the math trivial — the chip uses the same `LayoutResource`
  pile coordinates that drive every other piece of pile
  geometry, so it stays correctly positioned through window
  resizes without any extra wiring.
- Lifecycle matches the banner overlay: `spawn_overlay` spawns
  the chip alongside the banner when a replay starts;
  `react_to_state_change` despawns it when the replay ends.
  The chip lives outside the UI tree (because it's world-space)
  so the despawn needs its own query — added a second
  `Query<Entity, With<ReplayFloatingProgressChip>>` parameter.
- Z = 100 keeps the chip above every card stack
  (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular tableau
  cards stack to the low double digits at most).

### Position + visibility logic

`update_floating_progress_chip` runs each Update tick:

- Resolves the destination pile of the last-applied move
  (`replay.moves[cursor - 1]`'s `to`).
- Hides the chip when `cursor == 0` (no moves applied yet —
  nowhere meaningful to land) or when the last move was a
  `StockClick` (no destination pile, and stock-click feedback
  already lives at the stock pile — letting the chip jitter
  back to the stock every cycle would be visual noise).
- Otherwise positions the chip at `pile_position + (0,
  card_size.y * 0.6)` — half a card lifts above the pile
  centre, the extra 10 % is breathing room above the card's
  top edge so the chip doesn't visually clip.
- Updates the chip text via `format_progress(&state)` —
  shares the same MOVE N/M format with the banner chip.

### Test

New `floating_chip_spawns_and_despawns_with_overlay` pins the
lifecycle: chip absent on Inactive, exactly one chip on Playing,
absent again on return to Inactive. Position correctness needs
`LayoutResource` (which the headless fixture doesn't set up);
covered via running-game verification rather than a unit test —
the system's gate logic is small enough that pixel positioning
isn't load-bearing on a test.

1194 passing (+1 from prior 1193). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:29:38 -07:00
funman300 c9af1ead22 feat(accessibility): wire BORDER_SUBTLE_HC into the modal scaffold
Resume-prompt Option E, part 2 of 2 — HC chrome borders. Pairs
with the reduce-motion gating in `ed152e2`.

v0.21.1 introduced `BORDER_SUBTLE_HC` (#a0a0a0) but never wired
it: the constant existed, no consumer used it. Spec at
`design-system.md` §Accessibility (#2) mandates outline boost
from `#505050` (BORDER_STRONG) to `#a0a0a0` under high-contrast
mode so panels and popovers stay legible on low-quality
displays.

### Architecture

- New `HighContrastBorder` component in `ui_theme` carrying a
  `default_color: Color` field that records the off-state colour
  the entity was spawned with. Tag any UI node where border
  legibility is accessibility-critical.
- New `update_high_contrast_borders` system in `settings_plugin`
  walks all tagged entities each Update tick, sets `BorderColor`
  to `BORDER_SUBTLE_HC` when `Settings::high_contrast_mode` is
  on, otherwise to `marker.default_color`. Compares against
  current `BorderColor` and only mutates when different so
  Bevy's change-detection doesn't trigger repaints every frame.

### Tagged in this commit

- The modal scaffold's card border (`ui_modal::spawn_modal`).
  This is the primary accessibility target — modals demand
  attention and a low-vision player needs to perceive the panel
  boundary. Default colour: `BORDER_STRONG` (#505050); HC
  variant: `BORDER_SUBTLE_HC` (#a0a0a0).

### Future scope

Other `BORDER_SUBTLE` / `BORDER_STRONG` consumer sites (help
panel, stats panel, tooltip, action buttons, settings rows,
etc.) can be tagged in follow-ups by adding
`HighContrastBorder::with_default(...)` to their spawn tuple.
The system handles any entity carrying the marker — no further
changes needed once a site is tagged. Started small here to
keep the commit reviewable and prove the architecture before
rolling out broadly.

Workspace clippy + cargo test --workspace clean. 1193 passing
(unchanged from prior — no new tests added; the system is
small enough that the running-game verification is the meaningful
check).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:13:13 -07:00
funman300 ed152e2d8f feat(accessibility): gate splash scanline + cursor pulse on reduce-motion
Resume-prompt Option E, part 1 of 2 (the reduce-motion piece;
HC chrome borders follow in a separate commit).

v0.21.1 wired `Settings::reduce_motion_mode` through
`effective_slide_secs` so cards snap instead of sliding under
reduce-motion. The design-system spec at §Accessibility (#3)
calls out two more sources of non-essential motion that
reduce-motion should suppress: the splash CRT scanline effect
and the splash cursor pulse. This commit gates both.

### Splash cursor pulse (`pulse_splash_cursor`)

Previously sine-pulsed every frame regardless of settings. Now
reads `Settings::reduce_motion_mode` and skips the pulse
multiplier when on — the cursor still fades in / out with the
global splash alpha (essential timing), but doesn't blink
(decorative motion). The fade is preserved on purpose: skipping
it would hard-cut the splash on/off, which is jarring; the spec
specifically calls out *non-essential* motion as the reduce-
motion target, and a decorative blink is more clearly
non-essential than a fade timeline.

### Splash scanline overlay (`spawn_splash`)

Previously generated and spawned unconditionally when
`Assets<Image>` was available. Now skipped entirely when
reduce-motion is on — without the scanline overlay the boot
screen still reads as terminal-themed (foreground content,
borders, palette swatches all unchanged); the scanlines are
purely decorative.

### Test

New `splash_skips_scanline_overlay_under_reduce_motion` pins
the gate behaviour: under `reduce_motion_mode = true`, the
splash root still spawns (essential motion intact) but the
`SplashScanlineOverlay` entity is absent. 1193 passing
(+1 from prior 1192).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:07:51 -07:00
funman300 279a834f9d docs(handoff): refresh post-v0.21.1 — anchor to new tag, renumber Resume menu
Mirrors the post-v0.20.0 → v0.21.0 → v0.21.1 cut-then-refresh
pattern. Cut commit (daa655a) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.1.

Updated:
- Last-updated header points to v0.21.1 at daa655a; opening
  paragraph summarizes the patch's three threads (icon,
  accessibility, card-visual iteration with two bug fixes).
- Status at pause: tests bumped to 1192 (net +8 from
  v0.21.0's 1184); tags list extended through v0.21.1.
- "Since the v0.21.0 cut" → "Since the v0.21.1 cut" with the
  closure narratives dropped (now in CHANGELOG.md § [0.21.1]).
  Section reset to "no threads in flight" placeholder so
  future post-cut work has a clean starting point.
- Resume prompt menu trimmed: A and F closure entries dropped
  (preserved in CHANGELOG); remaining options renumbered A-E
  with the v0.21.1 closure callouts inline. New option E
  added: "extend HC through chrome borders + reduce-motion to
  splash/warning-chip" — both small finite items that v0.21.1
  flagged as future scope.
- Workflow notes gain the doc-vs-implementation-drift pattern
  observation from the pile-marker fix: when a module's
  top-level doc comment claims "X happens" but no code enforces
  it, the gap is invisible until a player notices the missing
  behaviour. Worth checking such claims and adding tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:59:24 -07:00
22 changed files with 4613 additions and 181 deletions
+607 -1
View File
@@ -6,9 +6,615 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
No threads in flight. v0.21.1 cut on 2026-05-08; CHANGELOG accumulates
No threads in flight. v0.21.6 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here.
## [0.21.6] — 2026-05-08
Patch release for the post-v0.21.5 work. Through-line:
**Move Log panel + scrub-UX polish**. v0.21.5 closed out the
keyboard-accelerator surface (Space / Esc / ← / →) and the
keybind footer; v0.21.6 builds on that with two parallel
threads — accessibility + scrub-on-hold polish for the v0.21.5
surfaces, plus a brand-new Move Log panel anchored to the
viewport's bottom edge that gives players a 5-row recent-and-
upcoming move history alongside the existing top-edge banner.
The Move Log panel is the first replay-overlay surface that
*isn't* attached to the banner — it lives at a separate screen
anchor (bottom: 0) with its own spawn/despawn lifecycle.
Establishes the pattern for "multi-anchor replay UI" that the
remaining B-2 sub-piece (mini-tableau preview) will inherit.
### Added
- **HC-mode coverage for the scrub track + quarter-mark notch
ticks** (`d3cb1a5`). Adds parallel primitive
`HighContrastBackground` to `ui_theme` and a paint system
`update_high_contrast_backgrounds` in `settings_plugin` that
mirrors the existing border-marker pattern but targets
`BackgroundColor` instead of `BorderColor`. Tags the 1 px
scrub track Node and all five quarter-mark notch ticks so
they bump from `BORDER_SUBTLE` (`#505050`) →
`BORDER_SUBTLE_HC` (`#a0a0a0`) under HC mode. Scrub fill
(`ACCENT_PRIMARY`) and WIN MOVE marker (`STATE_SUCCESS`)
don't get the marker — accent and state colours are already
saturated and don't need an HC luminance variant.
- **Continuous scrub on key-held arrow keys** (`2e25476`).
Holding ← or → triggers continuous step at 100 ms cadence
(10 steps/sec) — matches the mockup's `[← →] scrub`
terminology while keeping single-press = single-step
semantics. Per-key accumulators in a new
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
the accumulator and fire immediately. Release resets to 0
so the next fresh press fires immediately rather than at
half-interval.
- **Move Log panel** (`d6f32d3` + `140251b` + `e7345ae` +
`4437a1a`). New bottom-edge UI panel showing a 5-row window
onto recent + upcoming moves: 2 prev rows above the active
row + active row highlighted in `ACCENT_PRIMARY` + 2 next
rows below. Header reads `▌ MOVE LOG · N/M` (or
`▌ MOVE LOG · COMPLETE` when finished). Active row carries
a `▶` focus prefix and `TEXT_PRIMARY_HC` text colour for
legible contrast against the brick-red highlight. Prev /
next rows render in `TEXT_SECONDARY` so the active row
stays the focal point.
- Sibling-of-banner pattern (separate root entity anchored
at viewport bottom, not a banner child) — same
spawn/despawn lifecycle as `ReplayFloatingProgressChip`,
different screen anchor.
- Five pure helpers handle the formatting:
`format_pile`, `format_move_body`,
`format_move_log_header`, `format_kth_recent_row` (active
+ prev), `format_kth_next_row` (next). 1-indexed display
numbers throughout (`Foundation(2)` reads as "foundation
3" rather than the enum's 0-index).
- Panel grows from 56 → 84 → 112 px across the four
move-log commits. `MOVE_LOG_PREV_ROWS` and
`MOVE_LOG_NEXT_ROWS` constants (both = 2) parameterise
the row count; `format_kth_recent_row` and
`format_kth_next_row` return empty for out-of-range k so
panels gracefully under-fill at the start (cursor=1) and
end (cursor=N-1) of a replay.
- HC marker on the panel's top border so the 1 px edge
bumps under HC mode (same pattern as the keybind footer).
### Changed
- **`react_to_state_change` despawns the Move Log panel** on
`Playing → Inactive` alongside the banner root and floating
progress chip. Third query in the same defer-and-despawn
cycle.
- **Move Log panel height grew 56 → 84 → 112 px** across the
prev-rows and next-rows commits. The panel is sized to fit
the chosen row count + header + padding; tunable via the
`MOVE_LOG_PANEL_HEIGHT` const.
- **`format_active_move_row` now prefixes the `▶` focus
marker** (`e7345ae`). Wraps `format_kth_recent_row(state, 1)`
and prepends the prefix when the body is non-empty. Empty
case still returns empty — cursor=0 doesn't paint a stray
`▶` on an otherwise-empty row.
### Documentation
- `SESSION_HANDOFF.md` refreshed twice this cycle — once
recording the HC paint + continuous-scrub polish, then
again as the Move Log arc shipped commit-by-commit. The
Resume menu's B option now traces the full arc:
notches → labels → footer → ESC → HC → arrow keys →
HC paint → continuous scrub → move log.
### Stats
- **1273 passing tests / 0 failing** across the workspace
(net +23 from v0.21.5's 1250 baseline):
- 2 from `d3cb1a5` (HC marker on track + notches).
- 2 from `2e25476` (continuous-scrub repeat-while-held +
release-resets-accumulator).
- 8 from `d6f32d3` (move-log panel init + 5 helpers + 3
spawn / lifecycle scenarios).
- 4 from `140251b` (prev rows: helper k coverage + spawn
cardinality + spawn texts + repaint on cursor advance).
- 3 from `e7345ae` (active row highlight: wrapper bg +
text colour + focus prefix + cursor=0 stays empty).
- 4 from `4437a1a` (next rows: helper k coverage + spawn
cardinality + spawn texts + under-fill at replay end).
- Clippy clean across the workspace.
## [0.21.5] — 2026-05-08
Patch release for the post-v0.21.4 work. One through-line:
**replay-overlay scrubbing affordances + accessibility**. v0.21.4
shipped pause / resume / step + the WIN MOVE marker as the first
*scrubbing-shaped* additions to the replay overlay; v0.21.5
fills out the rest of the scrubbing UX so the player has both
visual anchor points (notches + labels) and a complete keyboard
control surface (Space / Esc / ← / →) for navigating a paused
replay.
Two of the six commits in this cycle are layout-changing — they
grow the banner height from 60 px → 76 px → 92 px to make room
for the notch labels and keybind footer. Banner geometry was
fixed for every prior B-2 commit; this release establishes the
"grow the container, add a flex-column child" pattern that the
remaining B-2 sub-pieces (move-log scroller, mini-tableau
preview) will inherit when they land.
### Added
- **Quarter-mark scrub-bar notches** (`fe68861`). Five 1 px
vertical ticks at 0 / 25 / 50 / 75 / 100 % give the player
visual anchor points without needing to mentally bisect the
bar. Pure helper `scrub_notch_positions()` returns the fixed
array; spawn loop sits next to the WIN MOVE marker spawn so
the lifecycles match. Notches paint in `BORDER_SUBTLE` (same
as the unfilled track) and rely on extending past the 1 px
track (5 px tall, anchored 2 px above the track top) for
visibility — same trick the WIN MOVE marker uses. Spawned
*after* the WIN MOVE marker so a notch and the marker
landing on the same percentage paint the marker on top.
- **Percentage labels under each notch** (`d322abf`). Five
`0%` / `25%` / `50%` / `75%` / `100%` labels in a new 16 px
row beneath the 1 px scrub track give the player explicit
quarter-mark readouts. Banner grew from 60 → 76 px to
accommodate the row — first **layout-changing** commit in
the B-2 arc. Pure helper `scrub_notch_labels()` returns the
fixed array, paired index-for-index with
`scrub_notch_positions()`. Spawn loop applies an "endpoints
flush, middle three percent-anchored" positioning pattern:
leftmost label gets `left: 0`, rightmost gets `right: 0`,
middle three anchor at `left: Val::Percent(p)` since Bevy
0.18 UI lacks a clean CSS-style `translate-x: -50%`
centering primitive. Label colour is `TEXT_SECONDARY`
rather than the mockup's `BORDER_SUBTLE` (the latter would
match the notches but is too low-contrast against
`BG_ELEVATED_HI` to read at 12 px).
- **Keybind-hint footer** (`1873b3f`). Vim-style mode line on
the left (`▌ NORMAL │ replay`) plus a keybind hint on the
right at the bottom edge of the banner. Banner grew from
76 → 92 px to fit the 16 px footer row. Surfaces every
wired keyboard accelerator visually so CLAUDE.md §3.3's
UI-first contract holds for keyboard accelerators too. The
footer lists *only* keybinds that are actually wired —
the only-wired-keybinds discipline means each release
cycle's hint string is a precise honest contract with the
player. Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable.
1 px top border in `BORDER_SUBTLE` separates the footer
from the labels row.
- **ESC keyboard accelerator for replay-stop** (`90e24d9`).
New `handle_stop_keyboard` system parallels
`handle_pause_keyboard` in shape — fires only when state
is `Playing`, calls `stop_replay_playback`. Cross-plugin
coordination via `pause_plugin::toggle_pause`: added a
fourth defer-if check
(`replay_state.is_some_and(|s| s.is_playing())`) right
after the existing `other_modal_scrims` check so ESC
during active replay belongs to the replay overlay, not
the pause modal.
- **HC-mode coverage for the keybind-footer top border**
(`23902cd`).
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
on the footer's border-carrying Node so the existing
`apply_high_contrast_borders` system bumps the 1 px top
border from `#505050``#a0a0a0` when
`Settings::high_contrast_mode` is on. Without the marker
the footer reads as floating loose under HC because the
border that anchors it to the labels row is
near-invisible.
- **← / → keyboard accelerators for paused stepping**
(`e5c4f51`). New `step_backwards_replay_playback` in
`replay_playback.rs` decrements the cursor and dispatches
`UndoRequestEvent`; the game's `handle_undo` reads it
next frame to reverse its most-recent move. Hooks the
existing undo system rather than replaying-forward-from-
zero — every replay-applied move pushes to the undo stack
the same way a player move would, so undo is the right
reversal primitive. Both arrow keys are paused-only via
the same destructure-gate pattern the forward step uses.
The mockup labels these `[← →] scrub`; single-move step
is the closest behaviour shippable today, so the footer
hint reads `[← →] step` — only-wired-keybinds discipline.
### Changed
- **Banner height grew 60 → 76 → 92 px** across two
layout-changing commits (`d322abf` then `1873b3f`). Top
row's `flex_grow: 1.0` still consumes 59 px so the
existing content (label / progress chip / buttons) has
the same vertical space; the new rows (16 px labels +
16 px footer) extend the banner downward into the
gameplay area. Banner geometry is now mutable — every
prior B-2 commit fit inside fixed 60 px space.
- **Keybind-footer hint text grew alongside the wirings**:
`[SPACE] pause/resume`
`[SPACE] pause/resume · [ESC] stop`
`[SPACE] pause/resume · [ESC] stop · [← →] step`.
- **`pause_plugin::toggle_pause` now defers when a replay
is active** (`90e24d9`). Adds a fourth defer-if check to
the existing modal-stack pattern.
- **`ReplayOverlayPlugin` registers
`add_message::<UndoRequestEvent>()`** (`e5c4f51`).
Defensive registration so the plugin runs cleanly under
`MinimalPlugins` without `GamePlugin` attached.
### Documentation
- `SESSION_HANDOFF.md` refreshed five times this cycle.
The B option in the Resume menu now traces the full arc:
notches → labels → footer → ESC → HC → arrow keys.
- The pre-existing `daily_challenge` warning test that
fails when wall-clock UTC is within 30 minutes of
midnight is documented in this cycle's handoff. Same
shape as the earlier `winnable_seed_search` flake —
time-dependent, deterministically passes outside the
trigger window.
### Stats
- **1250 total tests / 1249 passing / 1 pre-existing
time-dependent flake** across the workspace (net +22 from
v0.21.4's 1228 baseline):
- 4 from `fe68861` (scrub-notch coverage)
- 4 from `d322abf` (notch-label coverage)
- 4 from `1873b3f` (keybind-footer coverage)
- 3 from `90e24d9` (ESC-accelerator coverage)
- 1 from `23902cd` (HC-marker coverage)
- 6 from `e5c4f51` (arrow-keyboard coverage)
- **Pre-existing flake**:
`daily_challenge_plugin::tests::check_system_fires_warning_event_only_once_per_day`
fails when wall-clock UTC is within 30 minutes of
midnight. Verified pre-existing by stash-and-retest
before each commit. Will pass deterministically outside
the trigger window. Not introduced by this release.
- Clippy clean across the workspace.
## [0.21.4] — 2026-05-08
Patch release for the post-v0.21.3 work. One through-line:
**replay-scrubbing accessibility**. The replay overlay used to be
pure-passive — the player started a replay, watched it execute,
and waited for it to end. v0.21.4 adds the scaffolding for
*navigating within* a replay: a WIN MOVE marker on the scrub bar
so the player can see at a glance where the winning move sits,
and pause / resume / step controls so they can stop on any move
and inspect the board.
The work is also the first three commits on the B-2 replay
screen-takeover redesign arc. The remaining pieces (screen-
takeover layout, move-log scroller, mini-tableau preview) are
deferred to a future cycle because they need a layout reflow
that the existing banner-only overlay can't carry.
### Added
- **`Replay::win_move_index: Option<usize>` data field**
(`ab857bb`). Additive optional field on the persisted
`Replay` shape. `#[serde(default)]` keeps older
`latest_replay.json` / `replays.json` files loadable without
bumping `REPLAY_SCHEMA_VERSION` — this is purely additive.
Populated at the live recording site
(`game_plugin::handle_game_won`) via a new builder-style
setter `Replay::with_win_move_index`. For fresh recordings
the value is always `Some(moves.len() - 1)` because recording
freezes on win, but storing it explicitly lets the playback
UI read the WIN MOVE position directly without re-deriving
on every render.
- **WIN MOVE scrub-bar marker** (`52befa6`). New
`ReplayOverlayWinMoveMarker` component spawned as a sibling
to `ReplayOverlayScrubFill` under the 1px scrub track,
absolute-positioned at `replay.win_move_index / total %` of
the bar. Painted in `STATE_SUCCESS` (green) so the marker
reads as "this is where the win lives." Pure helper
`win_move_marker_pct` returns `None` for any state where the
marker shouldn't draw (Inactive, Completed, replay missing
the field, empty move list); percentage clamps to `[0, 100]`
defensively. Spawn-time only — the position never changes
during a single playback because the underlying `Replay` is
immutable while `Playing`.
- **Pause / Resume / Step playback controls** (`fbe48ac`). New
`paused: bool` field on `ReplayPlaybackState::Playing`.
`tick_replay_playback` skips the `secs_to_next` decrement
entirely while paused so cursor and timer freeze together;
resuming starts the next move from a full interval. New
public API: `toggle_pause_replay_playback` and
`step_replay_playback` (the latter hard-gated to `Playing {
paused: true }` via the destructure pattern itself, so
manual stepping can't race the tick loop). On-screen Pause
and Step buttons sit alongside the existing Stop button;
`Space` keyboard accelerator toggles pause / resume.
- **`Replay::with_win_move_index` builder** (`ab857bb`).
Chainable setter so the recording site can write
`Replay::new(...).with_win_move_index(idx)`. Keeps
`Replay::new`'s signature stable across the 13+ existing
test-fixture call sites that don't care about the field.
### Changed
- **`Replay::new` writes `win_move_index: None`** (`ab857bb`).
Existing canonical constructor stays signature-compatible
with all existing callers. The field is opt-in via the
builder.
- **`game_plugin::handle_game_won` populates the new field**
(`ab857bb`). The recording site computes
`recording.moves.len().checked_sub(1)` as the win-move
index. `checked_sub` rather than direct subtraction guards
the unreachable empty-recording branch (which is also
guarded earlier in the function).
- **`tick_replay_playback` honors the new `paused` flag**
(`fbe48ac`). Skipping the timer decrement is the only
behavior change; the loop body and Completed-detection are
unchanged. Stepping fires moves directly via
`step_replay_playback`, bypassing the tick path entirely.
- **Pause / Resume button label is reactive** (`fbe48ac`).
`update_pause_button_label` walks `Children` from the
marked button to its inner `Text` and repaints the label
whenever `ReplayPlaybackState` changes. Pure helper
`pause_button_label` covers all four state arms (running,
paused, inactive, completed).
- **25 existing `Playing { ... }` construction sites gained
`paused: false`** (`fbe48ac`). Mechanical edit across
`replay_overlay`, `achievement_plugin`, and
`replay_playback` tests to satisfy the new field
requirement. No behavioral change.
### Documentation
- `SESSION_HANDOFF.md` refreshed three times this cycle —
once after each post-cut feature commit. The B-2 entry in
the Visual-identity follow-ups list now points at the
remaining sub-pieces (screen-takeover layout, move-log
scroller, mini-tableau preview) as a single multi-session
arc rather than three independent ones, since they share a
layout-reflow prerequisite.
### Stats
- **1228 passing tests / 0 failing** across the workspace
(net +21 from v0.21.3's 1207 baseline):
- 5 from `ab857bb`'s `win_move_index` coverage: default
constructor, builder set / set-None, on-disk round-trip,
legacy-JSON-loads-with-None backward-compat. The last
test pins the no-schema-bump claim — if a future refactor
drops the `#[serde(default)]`, that test catches it.
- 8 from `52befa6`'s WIN MOVE marker: pure-helper truth
table (Inactive / Completed / no-field / correct-position
/ clamp) + spawn-presence-with-field /
spawn-absence-without / despawn-with-overlay observables.
- 8 from `fbe48ac`'s playback controls: label truth table,
label repaint on state change, click-toggles-paused,
step advances cursor by exactly one with paused
preserved, step-while-running no-op, Space toggles
paused.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.3] — 2026-05-08
Patch release for the post-v0.21.2 work. One through-line:
**accessibility arc closure**. v0.21.2 explicitly carved out
"dynamic-paint sites" (HUD action buttons, modal buttons, radial
menu rim) on the assumption that their existing paint cycles would
race the central `update_high_contrast_borders` system. v0.21.3
walks the actual code, finds the carve-out was over-cautious, and
closes it. Bonus: the first real consumer of `ToastVariant::Warning`
also lands here, making the `ToastVariant` enum fully load-bearing
(every variant has at least one driver).
### Added
- **`WarningToastEvent(String)` — first `ToastVariant::Warning`
consumer** (`279e23d`). Generic carrier message that any system
can fire to spawn a 4 s amber-bordered fire-and-forget toast.
Mirrors the v0.21.2 `MoveRejectedEvent` → `Error` toast wiring:
domain message crosses the plugin boundary, the animation
plugin's `handle_warning_toast` system reads it and spawns. Not
queued (Warning is alert-shaped, not info-shaped — should never
block on a queue).
- **Daily-challenge-expiry warning** (`279e23d`). First in-engine
driver of `WarningToastEvent`. New
`daily_challenge_plugin::check_daily_expiry_warning` system
fires at most once per `DailyChallengeResource::date` when the
player is within 30 min of UTC midnight reset and today's
challenge isn't yet complete. Suppression decided by a pure
helper (`compute_expiry_warning_minutes`) covering: already-
completed-today, already-shown-for-this-date, outside the
threshold window, post-midnight rollover. Pure-helper-plus-
thin-system shape because `Utc::now()` can't be pinned without
injecting a clock resource — overkill for one consumer.
- **`radial_rim_outline` pure helper** (`c153363`). Decision
logic for the radial-menu rim outline colour. Resting outlines
always carry `BORDER_SUBTLE`; focused outlines carry
`BORDER_STRONG` normally and `BORDER_SUBTLE_HC` under HC. Naive
marker substitution would invert the focused-vs-resting
hierarchy because `BORDER_SUBTLE_HC` (`#a0a0a0`) is *lighter*
than `BORDER_STRONG` (`#505050`); folding the choice in here
keeps the focused rim more visible under HC, not less.
### Changed
- **HC marker pattern extended to HUD action buttons + modal
buttons** (`c153363`). Re-reading the code revealed both sites'
paint systems (`paint_action_buttons`, `paint_modal_buttons`)
only mutate `BackgroundColor` — `BorderColor` is set once at
spawn and never touched. So the existing
`HighContrastBorder::with_default(BORDER_SUBTLE)` marker
pattern works cleanly for both, no race. v0.21.2's carve-out
comment was based on assumed-but-not-actual race risk; this
cycle treats it as the doc-vs-implementation drift pattern in
the wild and verifies before trusting.
- **Radial menu rim folds HC into per-frame respawn**
(`c153363`). The rim is the only true dynamic-painter of the
three carved-out sites — `radial_redraw_overlay` despawns and
respawns all rim sprites every frame the radial is `Active`.
The `HighContrastBorder` marker can't apply (entities don't
persist across frames) so HC is read directly in the system
via `Option<Res<SettingsResource>>` and routed through
`radial_rim_outline`. The `Option<Res<...>>` shape preserves
test compatibility under `MinimalPlugins`.
- **Animation plugin registers `WarningToastEvent`** (`279e23d`).
Joins `InfoToastEvent`, `MoveRejectedEvent` etc. in
`AnimationPlugin::build`. Daily-challenge plugin also
registers it (idempotent) so the message exists when running
the daily plugin under `MinimalPlugins` without the animation
plugin attached.
### Documentation
- `SESSION_HANDOFF.md` refreshed twice this cycle — once after
the Toast Warning wiring (menu trimmed 5 → 4 options), and
again after the HC dynamic-paint rollout (menu trimmed 4 → 3,
with all remaining options now flagged as multi-session). The
`High-contrast accessibility mode` entry in the Visual-identity
follow-ups list is updated to reflect that no "un-tagged
because race-risk" surfaces remain.
### Stats
- **1207 passing tests / 0 failing** across the workspace
(net +12 from v0.21.2's 1195 baseline):
- 7 tests for `compute_expiry_warning_minutes` (`279e23d`)
covering each suppression rule + the inclusive boundary at
exactly 30 min remaining.
- 1 in-Bevy test (`check_system_fires_warning_event_only_once_per_day`)
pinning `DailyExpiryWarningShown`'s once-per-date
suppression and the symmetric "already-completed-today"
suppression.
- 4 truth-table tests for `radial_rim_outline` (`c153363`):
focused × HC. The "resting stays subtle under HC" test
explicitly documents *why* — it's the hierarchy-preservation
invariant a future refactor might be tempted to break.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.2] — 2026-05-08
Patch release for the post-v0.21.1 polish work. Three through-
lines: **accessibility extensions** (reduce-motion gating for
splash animations, full HC chrome rollout across 8 surfaces),
**replay polish** (floating MOVE chip above the focused card
during playback), and the **first real consumer of
`ToastVariant::Error`** (invalid-move feedback as the third leg
of the existing audio + visual rejection-feedback stool).
The accessibility extensions close two threads v0.21.1 left
explicitly open: reduce-motion was previously gated only on card
slide_secs, and HC borders had `BORDER_SUBTLE_HC` defined but no
consumers. v0.21.2 finishes both — non-essential motion in the
splash boot screen now respects reduce-motion, and every static-
border chrome surface (modal scaffold, tooltip, help / stats /
home / settings panels) boosts to the HC variant under high-
contrast mode. Dynamic-paint sites (HUD action buttons, modal
buttons, radial menu rim) intentionally stay un-tagged because
their existing paint cycles would race the HC system; they
remain open for a future iteration that needs a different shape.
### Added
- **`sync_pile_marker_visibility` system precursor was v0.21.1's;
this cycle adds**: `update_high_contrast_borders` system in
`settings_plugin` (`c9af1ea`). Walks all entities tagged with
`HighContrastBorder` each Update tick, swaps `BorderColor` to
`BORDER_SUBTLE_HC` when high-contrast mode is on. Compares
current colour and only mutates when different so Bevy's
change-detection doesn't trigger repaints every frame. New
`HighContrastBorder { default_color: Color }` component carries
the off-state colour at each tagged site so the system can
revert correctly.
- **HC chrome rollout — 8 tagged surfaces** (`c9af1ea` modal
scaffold; `d87761d` tooltip + onboarding key chips + help
panel key chips + stats panel cells; `ec804d5` home Level/XP/
Score row + home mode-selector buttons + home mode-hotkey
chips + 4 settings panel surfaces). Each tagging is one line
on the spawn tuple. The marker-component architecture pays
back proportionally to the number of consumers — the per-
commit cost dropped from ~75 lines (foundation + first
surface) to ~13 lines (4 surfaces) to ~9 lines (7 surfaces).
- **Floating MOVE chip during replay** (`2fb2d63`). New
`ReplayFloatingProgressChip` marker on a `Text2d` entity
rendered in 2D world space above the destination pile of the
most-recently-applied move. Sibling of the banner overlay (not
a child) because it lives in world-space coordinates, not the
UI tree. Lifecycle matches the banner: `spawn_overlay` spawns
the chip alongside the banner when a replay starts;
`react_to_state_change` despawns it when the replay ends.
World-space placement (rather than UI-space + camera projection)
uses the same `LayoutResource` pile coordinates that drive
every other piece of pile geometry — stays correctly positioned
through window resizes for free. Hidden when cursor=0 (no
moves applied yet) or when the last applied move was a
`StockClick` (no destination pile to follow).
- **`handle_move_rejected_toast` system + first real
`ToastVariant::Error` consumer** (`68d50b5`). When
`MoveRejectedEvent` fires (illegal placement attempt), spawns
a 2-second pink-bordered "Invalid move" toast. Joins the
existing `card_invalid.wav` (audio cue) and destination-pile
shake (visual cue) as the accessibility-focused readable text
channel — covers deaf players (no audio reliance) and
reduce-motion players (no shake reliance) with a persistent
~2 s text cue. Drops the `#[allow(dead_code)]` from
`ToastVariant::Error` and updates its doc to point at the new
consumer.
### Changed
- **Splash scanline overlay skipped under reduce-motion**
(`ed152e2`). `spawn_splash` reads `Settings::reduce_motion_mode`
and skips the scanline texture / overlay node entirely when
on. Without the scanlines the boot screen still reads as
terminal-themed (foreground content, borders, palette swatches
unchanged); the scanlines are decorative.
- **Splash cursor pulse held under reduce-motion** (`ed152e2`).
`pulse_splash_cursor` reads `Settings::reduce_motion_mode` and
skips the per-frame sine-pulse multiplier when on — the cursor
still fades in / out with the global splash alpha (essential
timing) but doesn't blink. Spec calls out non-essential motion
as the reduce-motion target; the global fade is essential
(otherwise the splash would hard-cut on/off, which is
jarring), and the cursor blink is decorative.
- **`AnimationPlugin::build` registers
`MoveRejectedEvent`** (`68d50b5`). Bevy's `add_message` is
idempotent, so the duplicate registration with
`feedback_anim_plugin` (which already registered the message)
coexists cleanly. Required for the new
`handle_move_rejected_toast` system to run under
MinimalPlugins (tests).
### Documentation
- `docs/ui-mockups/design-system.md` and `SESSION_HANDOFF.md`
refreshed in lockstep with the rollouts. The handoff's
Resume-prompt menu trimmed twice this cycle as Options A and F
closed in v0.21.1, then this commit cycle's accessibility
extensions implicitly closed the "future scope" footnotes
v0.21.1 left on F's documentation.
### Stats
- **1195 passing tests / 0 failing** across the workspace
(net +3 from v0.21.1's 1192 baseline). New tests added by
this cycle:
- `splash_skips_scanline_overlay_under_reduce_motion`
(`ed152e2`) pins the reduce-motion gate on the splash
scanline overlay. Discovered an asset-fixture bootstrapping
detail along the way: under `MinimalPlugins`,
`Assets<Image>` isn't auto-inserted; the test had to add
`bevy::asset::AssetPlugin::default()` and
`init_asset::<bevy::image::Image>()`. Pattern flagged for
future asset-using tests.
- `floating_chip_spawns_and_despawns_with_overlay`
(`2fb2d63`) pins the floating MOVE chip's lifecycle:
absent on Inactive, exactly one on Playing, absent again
on return to Inactive.
- `move_rejected_event_spawns_error_toast` (`68d50b5`) pins
the new toast wiring: firing a `MoveRejectedEvent` spawns
exactly one `ToastOverlay` on the next tick.
- Zero clippy warnings under `cargo clippy --workspace
--all-targets -- -D warnings`.
- `cargo test --workspace` clean.
## [0.21.1] — 2026-05-08
Patch release for the post-v0.21.0 work — closes Resume-prompt
+192 -111
View File
@@ -1,75 +1,101 @@
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.0 cut and tagged at `04f9bf9`**,
working tree clean, all post-tag work pushed to origin.
**Last updated:** 2026-05-08 — **v0.21.5 cut and tagged at
`a2432df`**, working tree clean, all post-tag work pushed to
origin.
v0.21.0 closes the visual-identity arc opened in v0.20.0. Three
through-lines landed in this cycle: the **card-face / suit /
card-back artwork migration** that v0.20.0 deliberately deferred
(both rendering paths in lockstep — `assets/cards/*.png` fallback
plus the bundled-default theme SVGs at
`solitaire_engine/assets/themes/default/*.svg` that
`include_bytes!()`-embed into the binary), the **splash boot-
screen + replay-overlay polish** that closed Resume-prompt
Options B and C, and a late-cycle **`ACCENT_PRIMARY` palette
swap** from cyan `#6fc2ef` to brick red `#a54242` after a quick
stakeholder review of the shipped art.
v0.21.5 is a patch release with one through-line:
**replay-overlay scrubbing affordances + accessibility**.
v0.21.4 shipped pause / resume / step + the WIN MOVE marker as
the first scrubbing-shaped additions to the replay overlay;
v0.21.5 fills out the rest of the scrubbing UX so the player
has both visual anchor points (notches + labels) and a complete
keyboard control surface (Space / Esc / ← / →) for navigating a
paused replay.
Full v0.21.0 detail lives in `CHANGELOG.md` § [0.21.0]. This
Six commits on the B-2 replay screen-takeover redesign arc land
here. Two of them are layout-changing — banner height grew
60 → 76 → 92 px to make room for the notch labels and keybind
footer. Banner geometry was fixed for every prior B-2 commit;
this release establishes the "grow the container, add a
flex-column child" pattern that the remaining B-2 sub-pieces
(move-log scroller, mini-tableau preview) will inherit when
they land.
Full v0.21.5 detail lives in `CHANGELOG.md` § [0.21.5]. This
file from here on focuses on what's *open* post-cut and how to
resume.
## Status at pause
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
`04f9bf9`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.0 is fully on origin.
`a2432df`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.5 is fully on origin.
- **Working tree:** clean. No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1184 passing / 0 failing** across the workspace
(net +8 from v0.20.0's 1176 baseline). Detail in
`CHANGELOG.md` § [0.21.0] § Stats.
- **Tags on origin:** `v0.9.0` through `v0.21.0`. v0.21.0 is on
`04f9bf9`; v0.20.0 stays on `41a009a`.
- **Tests:** **1254 passing / 0 failing** across the workspace
(1250 in v0.21.5 + 2 from `d3cb1a5`'s HC-marker tests + 2
from `2e25476`'s continuous-scrub tests). The
time-dependent `daily_challenge` flake noted in v0.21.5's
CHANGELOG passes again (UTC clock has moved past the
trigger window). Detail in `CHANGELOG.md` § [0.21.5] § Stats
for the v0.21.5 baseline; post-cut delta tracked in this
file's Since-cut log.
- **Tags on origin:** `v0.9.0` through `v0.21.5`. v0.21.5 is on
`a2432df`; v0.21.4 stays on `23ff62c`; v0.21.3 stays on
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
`41a009a`.
## Since the v0.21.0 cut
## Since the v0.21.5 cut
Two Resume-prompt options closed post-tag (2026-05-08):
- **`d3cb1a5` — `feat(replay): HC-mode coverage for scrub
track + notches`.** Adds a parallel primitive to ui_theme
(`HighContrastBackground` marker carrying `default_color`)
and a paint system in settings_plugin
(`update_high_contrast_backgrounds`) that mirrors the
existing border-marker pattern but targets `BackgroundColor`
instead of `BorderColor`. Tags the 1 px scrub track Node and
all five quarter-mark notch ticks with the new marker so
they bump from `BORDER_SUBTLE` (#505050) → `BORDER_SUBTLE_HC`
(#a0a0a0) under HC mode. Scrub fill (ACCENT_PRIMARY) and
WIN MOVE marker (STATE_SUCCESS) don't get the marker —
accent and state colours are already saturated. 2 new tests;
1250 → 1252.
- **`2e25476` — `feat(replay): continuous scrub on key-held
arrow keys`.** Holding ← or → now triggers continuous step
at 100 ms cadence (10 steps/sec) — matches the mockup's
`[← →] scrub` terminology while keeping single-press =
single-step semantics. Per-key accumulators in a new
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
the accumulator and fire immediately. Release resets to 0
so the next fresh press fires immediately rather than at
half-interval. Footer text unchanged (`[← →] step`) —
held-key scrub is a discoverable enhancement to the same
keybind, not a new keybind. 2 new tests using
`TimeUpdateStrategy::ManualDuration`; 1252 → 1254.
- **Option A — App icon round** (`3eb3a26` + `716a025`). 9-size
PNG hierarchy in `assets/icon/` (16/24/32/48/64/128/256/512/
1024 px), generated by a new `icon_generator` example from a
shared `icon_svg` builder (Terminal `▌RS` mark on dark
`#151515` with brick-red accent). Runtime `Window::icon`
wired via `WinitWindows` on desktop only (Android draws its
launcher icon from the APK manifest). The follow-up fix
`716a025` wraps `NonSend<WinitWindows>` in `Option<...>`
to satisfy Bevy 0.18's stricter system-param validation —
the resource doesn't exist on the first few frames before
winit's `Resumed` event fires. New deps (target-gated
non-Android): direct `winit = "0.30"` for `Icon`
construction, direct `tiny-skia` for PNG → RGBA decode.
Pin test `icon_svg_pin` guards future rasteriser drift.
- **Option F — Accessibility modes** (`c5787c6` + `07e0357`).
High-contrast and reduce-motion settings flags wired through
the engine and surfaced as Settings panel toggles. HC boosts
`RED_SUIT_COLOUR` to `#ff8aa0` and `BLACK_SUIT_COLOUR` to
`#f5f5f5` for card text rendering; reduce-motion forces
`effective_slide_secs` to 0 regardless of `AnimSpeed`. CBM
and HC compose: lime CBM wins on red when both are on; HC
still applies to black suits when both are on. Six new
tests pin the truth tables. UI toggles sit alongside the
Color-blind row in Settings → Cosmetic; tab-walk visits
all three accessibility flags in one vertical run.
Open next-step menu (B-2 keyboard accelerator coverage +
accessibility + scrub UX are all complete):
1. **Move-log scroller / mini-tableau preview** — both need
a much larger banner-height grow (effectively the takeover
container itself). Multi-session arcs that close B-2.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
2. **Polish: notch label centering.** Bevy 0.18 lacks a clean
`translate-x: -50%` primitive so middle three labels sit
slightly right-of-notch. Could use a child Text wrapper
with computed left-margin compensation. Tiny commit.
3. **Polish: WIN MOVE marker HC bump.** Currently the marker
uses `STATE_SUCCESS` lime which stays visible under HC,
but a slight saturation / contrast bump under HC would
make the marker even more legible alongside the bumped
notches. Optional.
Three Resume-prompt options remain live: B (APK launch
verification), C (replay-overlay extensions), D (Toast
Warning/Error wiring), E (Phase 8 sync). The visible-payoff
pieces of the post-v0.21.0 menu have shipped; what's left is
Android runtime work, replay-overlay polish, sync infrastructure,
and toast-event sourcing.
Recommended order: option 2 (notch label centering) is the
smallest concrete next-step. Option 1 is the multi-session
arc that closes B-2 — natural place to start a fresh session.
## Open punch list
@@ -106,29 +132,67 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
mini-tableau preview, playback controls, move-log scroll, and
a WIN MOVE marker on the scrub bar. Banner-local pieces all
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
`e080b49`); the screen-takeover is a multi-session redesign
with data-layer impact (move-log scroller; WIN MOVE needs a
`win_move_index` field on `Replay` that doesn't yet exist).
- **Floating `MOVE N/M` chip above the focused card during
playback.** Cross-plugin work — `update_progress_text` writes
the banner chip but the card-position lookup belongs in
`card_plugin`. Smaller scope than the screen-takeover.
- **Toast Warning / Error variants.** `ToastVariant` has slots
for `Warning` (gold) and `Error` (pink) but no in-engine
event uses them yet. Wire when a warning- or error-flavoured
toast event materialises.
`e080b49`); the floating MOVE chip above the focused card
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6`
(UI). Playback controls (pause / resume / step + Space
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5
bundled six more commits under "replay-overlay scrubbing
affordances + accessibility" — scrub notches + percentage
labels + keybind-hint footer + ESC and ← / → accelerators
+ HC marker for the footer top border. Banner height grew
60 → 76 → 92 px across two layout-changing commits in
v0.21.5; banner geometry is now mutable. Full per-commit
detail in `CHANGELOG.md` § [0.21.5]. Keyboard accelerator
coverage is complete. What still needs to land: HC-mode
coverage for the scrub-track / notches / WIN MOVE marker
(they render via `BackgroundColor` so the
`HighContrastBorder` marker doesn't apply — needs a
settings-aware paint), continuous scrub on key-held ← / →
(vs single-step), then the bigger pieces — a move-log
scroller and a mini-tableau preview — both screen-
takeover-only pieces that need a much larger banner height
grow (effectively the takeover container itself).
Multi-session.
- *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same
`LayoutResource` pile coordinates so it survives window
resizes without UI/camera math.
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
Daily-challenge-expiry toast fires once per `daily.date` when
within 30 min of UTC midnight reset and today is incomplete.
`ToastVariant` is now fully load-bearing (every variant has at
least one real driver). Future Warning drivers can either reuse
the generic `WarningToastEvent(String)` carrier or add their
own domain message + `animation_plugin` handler.
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
`MoveRejectedEvent` now fires a 2-second pink-bordered
"Invalid move" toast as the third leg of the
audio + visual + text rejection-feedback stool.
- *High-contrast accessibility mode — closed 2026-05-08 by
`c5787c6` + `07e0357`.* Card text rendering picks up
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC`
(`#ff8aa0`); Settings panel has a toggle. Future scope:
extend HC through chrome borders (`BORDER_SUBTLE_HC` already
defined, not yet consumed), buttons, popover edges.
- *Reduced-motion mode — closed 2026-05-08 by the same pair.*
`effective_slide_secs` forces 0 when on, regardless of the
`AnimSpeed` setting. Future scope: gate splash scanline
overlay + cursor pulse animation on the same flag, gate
warning-chip pulse, gate any future card-lift z-bump
animation.
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
dynamic-paint rollout (`c153363`).* Card text rendering plus
8 static-border chrome surfaces (modal scaffold, tooltip,
onboarding key chips, help panel key chips, stats panel
cells, home Level/XP/Score row, home mode buttons, home
mode-hotkey chips, 4 settings panel surfaces) all boost
borders to `BORDER_SUBTLE_HC` under HC via the
`HighContrastBorder` marker. The previously-carved-out
dynamic-paint sites are now also covered: HUD action buttons
and modal buttons take the same marker (their paint cycles
only mutate `BackgroundColor`, so no race); the radial menu
rim folds HC into its per-frame spawn via
`radial_rim_outline` so the focused rim boosts to
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
hierarchy that naive marker substitution would invert).
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
card animations; `pulse_splash_cursor` skips the per-frame
pulse multiplier; `spawn_splash` skips the scanline overlay
entirely. Future scope: gate any future card-lift z-bump
animation, warning-chip pulse (when one materialises).
### Carried forward from v0.19.0
@@ -232,20 +296,25 @@ into a v0.21.1 / v0.22.0 cut.
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.0 is tagged at 04f9bf9 (cut 2026-05-08).
Working tree clean. v0.21.0 closed the visual-identity arc that
v0.20.0 deferred — full Terminal cards on both rendering paths
(asset PNGs + bundled-default theme SVGs), splash boot screen,
replay-overlay banner enrichments, and a project-wide ACCENT_PRIMARY
swap from cyan to brick red `#a54242`. See CHANGELOG.md § [0.21.0]
for full detail.
Branch: master. v0.21.5 is tagged at a2432df (cut 2026-05-08, a
patch release rolling up replay-overlay scrubbing affordances +
accessibility: scrub-bar notches with percentage labels, keybind-
hint footer, ESC + ← / → keyboard accelerators, and HC-mode
coverage for the footer top border). v0.21.4 stays at 23ff62c,
v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a,
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md §
[0.21.5] for full detail.
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
pass (1184+; check with `cargo test --workspace`), clippy clean.
State: HEAD locally — see `git rev-parse HEAD`. The cut commit
is a2432df; any post-cut docs edits ride on top of that.
Workspace tests: 1250 total / 1249 passing / 1 pre-existing
time-dependent flake (`daily_challenge` warning, fails when UTC
is within 30 min of midnight; verified not introduced by recent
work). Clippy clean.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.0] section is the most recent cut
2. CHANGELOG.md — [0.21.5] section is the most recent cut
3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
@@ -260,38 +329,40 @@ READ FIRST (in order, before doing anything):
fresh machine)
DECISION TO ASK THE PLAYER FIRST:
A. *Closed 2026-05-08 by `3eb3a26` + `716a025`.* App icon
round — runtime `Window::icon` wired plus a 9-size PNG
hierarchy at `assets/icon/`. `.ico` / `.icns` bundle
formats stay open if the project later ships as a
packaged macOS / Windows app.
B. APK launch verification on AVD / device — `adb install` +
A. APK launch verification on AVD / device — `adb install` +
`adb logcat` to shake out runtime bugs the build / unit
tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running.
C. Replay-overlay extensions — either the floating `MOVE N/M`
chip above the focused card (smaller, cross-plugin; needs
cursor → card-position plumbing in `card_plugin`) or the
full screen-takeover redesign (multi-session: move-log
scroll, mini tableau preview, WIN MOVE marker, data-layer
impact for `Replay::win_move_index`).
D. Toast Warning / Error variant wiring. UI infrastructure
exists in `ToastVariant`; no in-engine event uses Warning
(gold) or Error (pink) yet. Wire when a real warning- or
error-flavoured event materialises.
E. Phase 8 (sync) — local storage scaffolding, self-hosted
B. Replay-overlay screen-takeover redesign — multi-session
work. v0.21.4 shipped WIN MOVE marker, pause / resume /
step + Space accelerator, plus the floating-MOVE-chip
piece from v0.21.2 (`2fb2d63`). v0.21.5 shipped scrub
notches + percentage labels + keybind-hint footer + ESC
and ← / → accelerators + HC marker for the footer top
border (six commits across CHANGELOG § [0.21.5]). Banner
height grew 60 → 76 → 92 px across two layout-changing
commits in v0.21.5; geometry is now mutable. Keyboard
accelerator coverage is complete. Natural next finite
steps:
1. **HC-mode coverage** for the scrub-track / notches /
WIN MOVE marker (render via `BackgroundColor` not
`BorderColor`, so `HighContrastBorder` doesn't apply
— needs a settings-aware paint, precedent
`radial_rim_outline`). Smallest next commit.
2. **Continuous scrub on key-held ← / →** instead of
single-step. Needs a key-held event source. Matches
the mockup's `[← →] scrub` terminology.
3. **Move-log scroller / mini-tableau preview** — both
need a much larger banner-height grow (effectively
the takeover container itself). Multi-session arcs
that close B-2.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
C. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore,
ClipboardManager).
F. *Closed 2026-05-08 by `c5787c6` + `07e0357`.* High-contrast
and reduced-motion accessibility modes — Settings flags
+ UI toggles + engine wiring. Card text rendering uses
HC variants when on; card slide_secs forces to 0 when
reduce-motion is on. Future scope: extend HC through
chrome borders, buttons; gate splash + warning-chip
animations on reduce-motion.
WORKFLOW NOTES:
- Use the system git config (already correct).
@@ -308,6 +379,16 @@ WORKFLOW NOTES:
migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can
pattern-match instead of rediscovering.
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
visibility fix (`4d48cad`) implemented an invariant that
had been declared in a module doc comment but was never
enforced in code. When future work touches a module with
a "this does X" doc comment, verify the code actually does
X and add a test if not. Two layers, two checks.
OPEN AT THE START: ask which of AF. Don't pick unilaterally.
OPEN AT THE START: ask which of AC. Don't pick unilaterally.
Note: every remaining option is multi-session by nature (A is
gated on Android tooling, B and C are explicitly multi-session
arcs). A fresh session is a better fit for any of them than the
tail of a long working stretch.
```
+109
View File
@@ -147,12 +147,38 @@ pub struct Replay {
/// [`REPLAY_SCHEMA_VERSION`].
#[serde(default)]
pub share_url: Option<String>,
/// Index into [`moves`](Self::moves) of the move that triggered
/// the win condition (i.e. completed the last foundation pile).
///
/// For replays recorded by the live engine this is always
/// `Some(moves.len() - 1)` because recording freezes on win — but
/// the field is stored explicitly so the playback UI can read it
/// directly without re-deriving "the last move was the win" each
/// time, and to leave room for future recording semantics that
/// might capture post-win state.
///
/// `None` for replays loaded from disk that pre-date this field.
/// `#[serde(default)]` keeps older `latest_replay.json` /
/// `replays.json` files loadable without bumping
/// [`REPLAY_SCHEMA_VERSION`] — this is an additive optional
/// field, not a schema-breaking change.
///
/// Surfaced by the replay-overlay scrub bar's WIN MOVE marker
/// (B-2 screen-takeover redesign) when present.
#[serde(default)]
pub win_move_index: Option<usize>,
}
impl Replay {
/// Construct a fresh replay with the current schema version. The
/// caller fills in the recorded fields; this is the canonical
/// constructor used by the engine on win.
///
/// [`win_move_index`](Self::win_move_index) and
/// [`share_url`](Self::share_url) default to `None` — the engine
/// uses [`with_win_move_index`](Self::with_win_move_index) at the
/// recording site to set the former, and `sync_plugin` writes the
/// latter directly when the upload task resolves.
pub fn new(
seed: u64,
draw_mode: DrawMode,
@@ -172,8 +198,24 @@ impl Replay {
recorded_at,
moves,
share_url: None,
win_move_index: None,
}
}
/// Builder-style setter for [`win_move_index`](Self::win_move_index).
/// Returns `self` so the recording site can chain it onto
/// [`Replay::new`]:
///
/// ```ignore
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
/// ```
///
/// `None` is a valid input — useful for tests that don't care about
/// the WIN MOVE marker's scrub-bar position.
pub fn with_win_move_index(mut self, idx: Option<usize>) -> Self {
self.win_move_index = idx;
self
}
}
/// Rolling history of the player's most recent winning replays.
@@ -737,4 +779,71 @@ mod tests {
let _ = fs::remove_file(&path);
}
// -----------------------------------------------------------------------
// win_move_index — additive optional field for the WIN MOVE marker
// -----------------------------------------------------------------------
#[test]
fn replay_new_defaults_win_move_index_to_none() {
let r = sample_replay();
assert_eq!(r.win_move_index, None);
}
#[test]
fn with_win_move_index_sets_value() {
let r = sample_replay().with_win_move_index(Some(3));
assert_eq!(r.win_move_index, Some(3));
}
#[test]
fn with_win_move_index_accepts_none() {
// Passing None through the builder is a valid no-op — useful for
// tests / synthetic replays that don't care about the marker.
let r = sample_replay().with_win_move_index(None);
assert_eq!(r.win_move_index, None);
}
#[test]
fn replay_with_win_move_index_round_trips_on_disk() {
let path = tmp_path("win_move_index_round_trip");
let _ = fs::remove_file(&path);
let original = sample_replay().with_win_move_index(Some(3));
save_latest_replay_to(&path, &original).expect("save");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, Some(3));
assert_eq!(loaded, original);
let _ = fs::remove_file(&path);
}
/// Older replay files written before this field was added must still
/// load — `#[serde(default)]` keeps `win_move_index` optional and
/// defaults missing fields to `None`. This is the contract that lets
/// us add the field without bumping `REPLAY_SCHEMA_VERSION`.
#[test]
fn replay_without_win_move_index_loads_with_none() {
let path = tmp_path("legacy_no_win_move_index");
let _ = fs::remove_file(&path);
// Hand-rolled minimal v2 replay JSON with no win_move_index field.
let v2_no_field = r#"{
"schema_version": 2,
"seed": 1,
"draw_mode": "DrawOne",
"mode": "Classic",
"time_seconds": 60,
"final_score": 100,
"recorded_at": "2026-05-02",
"moves": []
}"#;
fs::write(&path, v2_no_field).expect("write fixture");
let loaded = load_latest_replay_from(&path).expect("load");
assert_eq!(loaded.win_move_index, None);
assert_eq!(loaded.schema_version, REPLAY_SCHEMA_VERSION);
let _ = fs::remove_file(&path);
}
}
@@ -1445,6 +1445,7 @@ mod tests {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
assert!(
@@ -1480,6 +1481,7 @@ mod tests {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
@@ -1512,6 +1514,7 @@ mod tests {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
@@ -1534,6 +1537,7 @@ mod tests {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
@@ -1559,6 +1563,7 @@ mod tests {
replay: dummy_replay(),
cursor: 0,
secs_to_next: 0.0,
paused: false,
};
app.update();
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
+92 -5
View File
@@ -21,8 +21,10 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{InfoToastEvent, XpAwardedEvent};
use crate::events::{AchievementUnlockedEvent, GameWonEvent};
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource;
@@ -162,6 +164,8 @@ impl Plugin for AnimationPlugin {
.add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
@@ -183,6 +187,8 @@ impl Plugin for AnimationPlugin {
handle_settings_toast,
handle_auto_complete_toast,
handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
)
@@ -565,9 +571,11 @@ pub enum ToastVariant {
/// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)]
Warning,
/// Failure / rejected action — pink border. Currently unused; kept so
/// future error-flavoured toasts have a slot.
#[allow(dead_code)]
/// Failure / rejected action — pink border. Used by
/// [`handle_move_rejected_toast`] for illegal-placement
/// feedback; the third leg of the rejection-feedback stool
/// alongside `card_invalid.wav` (audio) and the destination-
/// pile shake (visual).
Error,
/// Reward / milestone — lavender border. Used for XP awards,
/// achievement unlocks, level-ups, daily/weekly/challenge completions.
@@ -622,6 +630,47 @@ fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpA
}
}
/// Spawns a 2-second pink-bordered Error toast when the player tries an
/// illegal placement (`MoveRejectedEvent`). Adds a third leg to the
/// existing rejection feedback stool — `card_invalid.wav` already plays
/// (audio cue) and `feedback_anim_plugin::queue_shake_for_rejected_move`
/// fires the destination-pile shake (visual cue). The toast is the
/// accessibility-focused leg: persistent ~2 s text that's readable for
/// deaf players and impossible to miss for players who blink during the
/// shake. First in-engine consumer of `ToastVariant::Error` — exercises
/// the variant's pink border accent and the design-system "rejected
/// action" semantic.
fn handle_move_rejected_toast(
mut commands: Commands,
mut events: MessageReader<MoveRejectedEvent>,
) {
for _ev in events.read() {
spawn_toast(
&mut commands,
"Invalid move".to_string(),
2.0,
ToastVariant::Error,
);
}
}
/// Spawns a 4-second amber-bordered Warning toast for every incoming
/// [`WarningToastEvent`]. First in-engine consumer of
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
/// the design-system "act soon" semantic.
///
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
}
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
///
/// Skipped while the game is paused so toast countdowns freeze along with the
@@ -966,6 +1015,44 @@ mod tests {
let _ = count;
}
#[test]
fn move_rejected_event_spawns_error_toast() {
// The first in-engine consumer of `ToastVariant::Error`. Firing
// a `MoveRejectedEvent` (illegal placement) must spawn exactly
// one `ToastOverlay` carrying the rejection-feedback message.
// Pairs the existing audio (`card_invalid.wav`) and visual
// (`feedback_anim_plugin::queue_shake_for_rejected_move`) feedback
// with an accessibility-focused readable text cue.
use solitaire_core::pile::PileType;
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
// Baseline: no toast overlays exist before the event.
let before = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
app.world_mut().write_message(MoveRejectedEvent {
from: PileType::Tableau(0),
to: PileType::Tableau(1),
count: 1,
});
app.update();
let after = app
.world_mut()
.query::<&ToastOverlay>()
.iter(app.world())
.count();
assert_eq!(
after,
before + 1,
"MoveRejectedEvent must spawn exactly one error toast",
);
}
// -----------------------------------------------------------------------
// Task #67 — Toast queue pure-function tests
// -----------------------------------------------------------------------
+223 -3
View File
@@ -14,13 +14,13 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use chrono::{Local, NaiveDate};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal;
use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
XpAwardedEvent,
WarningToastEvent, XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
@@ -30,6 +30,11 @@ use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge.
pub const DAILY_BONUS_XP: u64 = 100;
/// Minutes before UTC midnight at which the daily-challenge expiry warning
/// fires. The reset is global (UTC), so the warning is global too — local
/// midnight may be hours away or already past.
pub const DAILY_EXPIRY_WARNING_MINUTES: i64 = 30;
/// The active daily challenge — date + RNG seed for that date's deal,
/// plus optional goal metadata fetched from the server.
#[derive(Resource, Debug, Clone)]
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day.
///
/// `None` until the first warning fires; thereafter holds the date the
/// warning was shown for. When `daily.date` advances (a new local day rolls
/// over while the app stays open), this becomes stale and the next warning
/// can fire.
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation));
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
}
}
@@ -215,6 +233,71 @@ fn handle_start_daily_request(
announce.write(DailyGoalAnnouncementEvent(desc));
}
/// Pure decision logic for the daily-challenge expiry warning. Returns the
/// integer minutes-until-UTC-midnight if a warning toast should fire on this
/// frame, or `None` if any suppression condition holds.
///
/// Suppression rules (in order):
/// 1. Player has already completed today's daily challenge.
/// 2. The warning has already fired for `daily_date`.
/// 3. UTC midnight is more than [`DAILY_EXPIRY_WARNING_MINUTES`] away.
/// 4. UTC midnight has already passed for the current calendar day (the
/// minutes-remaining is negative — happens for at most one frame at the
/// rollover boundary).
///
/// Factored out so the threshold/clock behavior is unit-testable without an
/// `App`.
fn compute_expiry_warning_minutes(
daily_date: NaiveDate,
last_completed: Option<NaiveDate>,
last_shown: Option<NaiveDate>,
now_utc: DateTime<Utc>,
threshold_mins: i64,
) -> Option<i64> {
if last_completed == Some(daily_date) {
return None;
}
if last_shown == Some(daily_date) {
return None;
}
let next_midnight = (now_utc.date_naive() + Duration::days(1))
.and_hms_opt(0, 0, 0)?
.and_utc();
let mins_remaining = (next_midnight - now_utc).num_minutes();
if !(0..=threshold_mins).contains(&mins_remaining) {
return None;
}
Some(mins_remaining)
}
/// Each-frame check for the daily-challenge expiry warning. Fires a single
/// [`WarningToastEvent`] when the player is within
/// [`DAILY_EXPIRY_WARNING_MINUTES`] of UTC midnight reset and hasn't yet
/// completed today's challenge.
///
/// Idempotent — `DailyExpiryWarningShown` ensures the toast spawns at most
/// once per `daily.date`.
fn check_daily_expiry_warning(
daily: Res<DailyChallengeResource>,
progress: Res<ProgressResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
mut warning: MessageWriter<WarningToastEvent>,
) {
let Some(mins) = compute_expiry_warning_minutes(
daily.date,
progress.0.daily_challenge_last_completed,
shown.0,
Utc::now(),
DAILY_EXPIRY_WARNING_MINUTES,
) else {
return;
};
shown.0 = Some(daily.date);
warning.write(WarningToastEvent(format!(
"Daily challenge expires in {mins} min"
)));
}
#[cfg(test)]
mod tests {
use super::*;
@@ -385,4 +468,141 @@ mod tests {
assert_eq!(r.target_score, Some(1_000));
assert_eq!(r.max_time_secs, Some(300));
}
// -----------------------------------------------------------------------
// Daily-expiry warning toast (compute_expiry_warning_minutes + system)
// -----------------------------------------------------------------------
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// Construct a UTC `DateTime` at the given calendar position. Used to
/// drive the pure helper through every threshold edge.
fn utc_at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
ymd(y, m, d).and_hms_opt(h, min, 0).unwrap().and_utc()
}
#[test]
fn warning_fires_inside_threshold_when_incomplete_and_unseen() {
// 23:50 UTC, 10 min until reset, < 30 min threshold.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_fires_at_exact_threshold_boundary() {
// 23:30 UTC, exactly 30 min remaining — the inclusive boundary.
let now = utc_at(2026, 5, 8, 23, 30);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(30));
}
#[test]
fn warning_suppressed_outside_threshold() {
// 23:00 UTC, 60 min remaining — outside the 30 min window.
let now = utc_at(2026, 5, 8, 23, 0);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_fires_when_last_shown_was_yesterday() {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn check_system_fires_warning_event_only_once_per_day() {
// The pure helper is exhaustively tested above. This test verifies
// the system that consumes it correctly stores the "shown" date so
// the WarningToastEvent fires at most once per `daily.date`, even
// when the system runs many frames in a row inside the threshold.
//
// The system reads `Utc::now()` directly, so we can't pin the clock.
// Instead, we simulate the post-warning state by pre-populating
// `DailyExpiryWarningShown` with `daily.date` and asserting nothing
// fires; then we verify the symmetric "completed today" suppression.
let mut app = headless_app();
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when DailyExpiryWarningShown already covers today"
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.daily_challenge_last_completed = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when today is already completed"
);
}
}
+15
View File
@@ -212,6 +212,21 @@ pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
#[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String);
/// Generic warning toast message. Spawns a fire-and-forget
/// [`ToastVariant::Warning`](crate::animation_plugin::ToastVariant) toast.
///
/// Distinct from [`InfoToastEvent`] in two ways:
/// 1. **Variant.** Warning carries the design-system warning border accent,
/// not the neutral info accent — so the player can distinguish "you might
/// want to act" from "here's some neutral information".
/// 2. **No queue.** Warnings are alerts, not a stream. Each event spawns its
/// own toast immediately rather than waiting for the info queue to drain.
///
/// First in-engine driver: daily-challenge expiry warning fired by
/// `daily_challenge_plugin` when < 30 min from UTC midnight reset.
#[derive(Message, Debug, Clone)]
pub struct WarningToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Message, Debug, Clone, Copy)]
+7 -1
View File
@@ -936,6 +936,11 @@ pub fn record_replay_on_win(
if recording.moves.is_empty() {
continue;
}
// Recording freezes on win, so the move that triggered the
// win condition is the last one in the list. Storing the
// index explicitly lets the playback UI read the WIN MOVE
// position directly instead of re-deriving it on every render.
let win_move_index = recording.moves.len().checked_sub(1);
let replay = Replay::new(
game.0.seed,
game.0.draw_mode.clone(),
@@ -944,7 +949,8 @@ pub fn record_replay_on_win(
ev.score,
Utc::now().date_naive(),
recording.moves.clone(),
);
)
.with_win_move_index(win_move_index);
let Some(p) = path.as_ref().and_then(|r| r.0.as_deref()) else {
// No persistence path configured (e.g. tests / minimal Linux
// containers without dirs::data_dir). The in-memory replay
+3 -2
View File
@@ -14,8 +14,8 @@ use crate::ui_modal::{
ScrimDismissible,
};
use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Marker on the help overlay root node.
@@ -263,6 +263,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
+5 -2
View File
@@ -38,8 +38,8 @@ use crate::ui_modal::{
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD,
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
@@ -840,6 +840,7 @@ fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|row| {
for (label, value) in [
@@ -943,6 +944,7 @@ fn spawn_draw_mode_chip<M: Component>(
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|c| {
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
@@ -1156,6 +1158,7 @@ fn spawn_mode_card(
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
+2 -1
View File
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
@@ -715,6 +715,7 @@ fn spawn_action_button<M: Component>(
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
+3 -2
View File
@@ -32,8 +32,8 @@ use crate::ui_modal::{
spawn_modal_header, ButtonVariant,
};
use crate::ui_theme::{
BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1,
VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
};
// ---------------------------------------------------------------------------
@@ -386,6 +386,7 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|chip| {
chip.spawn((
+11
View File
@@ -30,6 +30,7 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameOverScreen, GameStatePath};
use crate::progress_plugin::ProgressResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameStateResource};
use crate::selection_plugin::{SelectionKeySet, SelectionState};
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
@@ -154,6 +155,7 @@ fn toggle_pause(
mut drag: Option<ResMut<DragState>>,
mut changed: MessageWriter<StateChangedEvent>,
selection: Option<Res<SelectionState>>,
replay_state: Option<Res<ReplayPlaybackState>>,
) {
let PauseModalQueries {
pause_screens: screens,
@@ -184,6 +186,15 @@ fn toggle_pause(
if !other_modal_scrims.is_empty() {
return;
}
// If a replay is currently playing, let `replay_overlay::handle_stop_keyboard`
// own the Esc press — that handler stops the replay. Without this guard a
// single Esc both stops the replay AND opens the pause modal on top of the
// (now empty) board, leaving the player on a screen they didn't ask for.
// The HUD-button path is gated too; clicking Pause while watching a replay
// is almost always an accident.
if replay_state.is_some_and(|s| s.is_playing()) {
return;
}
// If a card is currently selected, let SelectionPlugin handle this Escape
// (it will clear the selection). Pause must not also open in the same frame.
if selection.is_some_and(|s| s.selected_pile.is_some()) {
+63 -4
View File
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
///
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
/// Despawns and respawns the radial overlay sprites every frame the
/// state is `Active`; despawns them when the state returns to `Idle`.
///
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
/// the simplest place to fold HC in: this is the only system that
/// owns the rim sprite, so there's no parallel paint path to fight.
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
/// apply because the rim is a `Sprite`, not a UI node with
/// `BorderColor`, and the entities don't persist across frames.)
fn radial_redraw_overlay(
state: Res<RightClickRadialState>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
existing_icons: Query<Entity, With<RadialIcon>>,
existing_centres: Query<Entity, With<RadialCentre>>,
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
));
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
// Hovered icon gets a strong yellow rim; resting icons get a
// muted purple rim so the focused one reads as the obvious target.
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
let outline = radial_rim_outline(focused, high_contrast);
commands
.spawn((
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
}
}
/// Pure decision logic for the radial-icon rim outline colour.
///
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
/// reads as the obvious target. Under high-contrast mode the focused
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
/// invert the hierarchy because the resting colour
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
/// focused rim *more* visible under HC, not less.
///
/// Factored out as a pure function so the truth-table is unit-testable
/// without spinning up the per-frame respawn system.
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
match (focused, high_contrast) {
(true, true) => BORDER_SUBTLE_HC,
(true, false) => BORDER_STRONG,
(false, _) => BORDER_SUBTLE,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -940,4 +970,33 @@ mod tests {
"face-down cards must not open the radial"
);
}
// -----------------------------------------------------------------------
// radial_rim_outline — accessibility / high-contrast truth table
// -----------------------------------------------------------------------
#[test]
fn rim_resting_uses_subtle_outline_without_hc() {
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
}
#[test]
fn rim_focused_uses_strong_outline_without_hc() {
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
}
#[test]
fn rim_focused_boosts_to_subtle_hc_under_hc() {
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
}
#[test]
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
// Naive marker substitution would also flip the resting outline
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
// that would invert the focused/resting hierarchy. Holding the
// resting colour at BORDER_SUBTLE keeps the focused icon the
// obvious target under HC.
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
}
}
File diff suppressed because it is too large Load Diff
+120 -1
View File
@@ -42,7 +42,7 @@
use bevy::prelude::*;
use solitaire_data::{Replay, ReplayMove};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent};
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::game_plugin::{GameMutation, RecordingReplay};
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
@@ -119,6 +119,15 @@ pub enum ReplayPlaybackState {
cursor: usize,
/// Seconds remaining until the next move is dispatched.
secs_to_next: f32,
/// `true` while playback is paused — `tick_replay_playback`
/// skips the `secs_to_next` decrement entirely while this is
/// set, so the cursor and the timer freeze together. The
/// overlay stays mounted (`is_playing()` still returns
/// `true`) so the player can see the paused state and the
/// Resume / Step controls. Stepping while paused fires the
/// next move directly via [`step_replay_playback`] and
/// leaves the paused flag untouched.
paused: bool,
},
/// The replay finished playing back. The overlay swaps the banner
/// label to "Replay complete" until [`auto_clear_completed_replay`]
@@ -194,6 +203,7 @@ pub fn start_replay_playback(
replay,
cursor: 0,
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
paused: false,
};
}
@@ -219,6 +229,107 @@ pub fn stop_replay_playback(
**state = ReplayPlaybackState::Inactive;
}
/// Toggle the `paused` flag on the active playback. No-op when not
/// `Playing` (i.e. `Inactive` or `Completed`) — pause has no meaning
/// in those states. Returns the new paused value, or `None` if the
/// state wasn't `Playing`.
pub fn toggle_pause_replay_playback(state: &mut ResMut<ReplayPlaybackState>) -> Option<bool> {
if let ReplayPlaybackState::Playing { paused, .. } = state.as_mut() {
*paused = !*paused;
Some(*paused)
} else {
None
}
}
/// Advance playback by exactly one move. Only meaningful while paused
/// — when called on an unpaused playback it would race the
/// `tick_replay_playback` loop. Returns `true` when a move was fired,
/// `false` when no-op (state isn't `Playing { paused: true }` or the
/// cursor is already at the end of the move list).
///
/// Stepping the last move transitions the state to `Completed` on
/// the next `tick_replay_playback` frame — same end-of-list path the
/// normal advance loop takes.
pub fn step_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
moves_writer: &mut MessageWriter<MoveRequestEvent>,
draws_writer: &mut MessageWriter<DrawRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
replay,
cursor,
paused: true,
..
} = state.as_mut()
else {
return false;
};
if *cursor >= replay.moves.len() {
return false;
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
moves_writer.write(MoveRequestEvent {
from: from.clone(),
to: to.clone(),
count: *count,
});
}
ReplayMove::StockClick => {
draws_writer.write(DrawRequestEvent);
}
}
*cursor += 1;
true
}
/// Steps the replay **backwards** by exactly one move while paused.
///
/// Strategy: the live game's undo system is the source of truth for
/// reversing moves. Every move the replay forward-stepped (via
/// [`step_replay_playback`] or the auto-advance loop in
/// [`tick_replay_playback`]) was dispatched as a canonical
/// [`MoveRequestEvent`] / [`DrawRequestEvent`], which the game
/// applied and pushed onto its undo stack. So a backwards step here
/// is simply: decrement the cursor (so the about-to-apply move
/// re-points at the one we're rewinding past) and fire an
/// [`UndoRequestEvent`] so the game reverses its most-recent move
/// next frame.
///
/// Hard-gated to the paused state via destructure pattern —
/// matches the existing [`step_replay_playback`] gate so the
/// player can only scrub one direction at a time and the tick
/// loop never races a manual rewind.
///
/// Returns `false` and is a no-op in three cases:
/// - State isn't `Playing` (no replay attached).
/// - State is `Playing` but not paused (the tick loop owns the cursor).
/// - Cursor is already at 0 (nothing to rewind past).
///
/// Returns `true` on a successful step; the actual game-state
/// reversal happens next frame when `handle_undo` reads the
/// `UndoRequestEvent`.
pub fn step_backwards_replay_playback(
state: &mut ResMut<ReplayPlaybackState>,
undo_writer: &mut MessageWriter<UndoRequestEvent>,
) -> bool {
let ReplayPlaybackState::Playing {
cursor,
paused: true,
..
} = state.as_mut()
else {
return false;
};
if *cursor == 0 {
return false;
}
*cursor -= 1;
undo_writer.write(UndoRequestEvent);
true
}
/// Tick system. Runs every frame; only does work when
/// [`ReplayPlaybackState::is_playing`].
///
@@ -249,8 +360,15 @@ fn tick_replay_playback(
replay,
cursor,
secs_to_next,
paused,
} = state.as_mut()
{
// While paused, the cursor and the timer freeze together —
// skip the decrement entirely so resuming starts the next
// move from a full `secs_to_next` window. Stepping (handled
// separately) fires moves directly without touching this
// path.
if !*paused {
*secs_to_next -= dt;
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
@@ -273,6 +391,7 @@ fn tick_replay_playback(
transition_to_completed = true;
}
}
}
if transition_to_completed {
*state = ReplayPlaybackState::Completed;
+81 -3
View File
@@ -34,9 +34,10 @@ use crate::ui_modal::{
};
use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
Z_MODAL_PANEL,
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBackground,
HighContrastBorder,
RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
};
/// Side length of a swatch button in the card-back / background pickers.
@@ -364,6 +365,8 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text,
update_color_blind_text,
update_high_contrast_text,
update_high_contrast_borders,
update_high_contrast_backgrounds,
update_reduce_motion_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
@@ -637,6 +640,77 @@ fn update_high_contrast_text(
}
}
/// Repaints `BorderColor` on every entity tagged with
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current border colour and
/// only mutates when different so Bevy's change-detection
/// doesn't trigger repaints every frame.
///
/// Spec at `design-system.md` §Accessibility (#2): under HC,
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
/// modal panels, popover edges, and focus-ring carriers stay
/// legible on low-quality displays / for low-vision users.
///
/// Tagged sites in v0.21.x: the modal scaffold's card border
/// (`ui_modal::spawn_modal`). More sites can be tagged in
/// follow-ups by adding `HighContrastBorder::with_default(...)`
/// to their spawn tuple.
fn update_high_contrast_borders(
settings: Res<SettingsResource>,
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut border) in borders.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
} else {
marker.default_color
};
// Only mutate when actually different — avoids per-frame
// change-detection churn. `border.left` is representative
// because every tagged site uses `BorderColor::all(...)`.
if border.left != target {
*border = BorderColor::all(target);
}
}
}
/// Repaints `BackgroundColor` on every entity tagged with
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current background and only
/// mutates when different so Bevy's change-detection doesn't trigger
/// repaints every frame.
///
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
/// same change-suppression idiom, different colour channel —
/// `BackgroundColor` for tick marks, decorative strips, fine
/// separators that paint their shape directly rather than via a
/// `BorderColor` on a wider Node.
///
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
///
/// More sites can be tagged in follow-ups by adding
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
pub(crate) fn update_high_contrast_backgrounds(
settings: Res<SettingsResource>,
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut bg) in backgrounds.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
} else {
marker.default_color
};
if bg.0 != target {
*bg = BackgroundColor(target);
}
}
}
fn update_reduce_motion_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
@@ -1913,6 +1987,7 @@ fn picker_row(
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
@@ -2054,6 +2129,7 @@ fn theme_picker_row(
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
@@ -2175,6 +2251,7 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((
@@ -2235,6 +2312,7 @@ fn icon_button(
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
+72 -3
View File
@@ -219,12 +219,27 @@ fn spawn_splash(
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Settings is borrowed twice — once for the first_run_complete
// gate above, once here for the reduce-motion gate. The borrow
// above already happened (and was let-go via the `settings.as_deref()`
// pattern's auto-drop), so this re-read is safe.
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
// Generate the scanline texture handle up-front (when the asset
// store is available — always true in production; opt-out under
// bare `MinimalPlugins` test fixtures so existing tests that
// don't init `Assets<Image>` keep working with the rest of the
// splash content unchanged).
let scanline_handle = images.map(|mut images| images.add(build_scanline_image()));
// splash content unchanged). Also skipped when reduce-motion is
// on — the scanline overlay is the "CRT scanline effect" the
// design-system spec calls out as non-essential motion under
// reduce-motion (`design-system.md` §Accessibility #3). Without
// it the boot screen still reads as terminal-themed; the
// scanlines are decorative.
let scanline_handle = if reduce_motion {
None
} else {
images.map(|mut images| images.add(build_scanline_image()))
};
commands
.spawn((
@@ -712,15 +727,29 @@ fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 {
///
/// No-op when no `SplashRoot` exists (the splash has already
/// despawned, or we're under a test fixture that doesn't spawn one).
///
/// Under `Settings::reduce_motion_mode`, the per-frame pulse
/// multiplier is skipped — the cursor still fades in / out with
/// the global splash alpha (essential timing) but doesn't blink
/// (decorative motion). Spec at `design-system.md` §Accessibility
/// (#3): reduce-motion suppresses non-essential motion only;
/// fade-in / fade-out timelines stay intact because the splash
/// itself would otherwise hard-cut on/off, which is jarring.
fn pulse_splash_cursor(
roots: Query<&SplashAge, With<SplashRoot>>,
settings: Option<Res<SettingsResource>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) {
let Some(age) = roots.iter().next() else {
return;
};
let global = splash_alpha(age.0).unwrap_or(0.0);
let pulse = cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN);
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
let pulse = if reduce_motion {
1.0
} else {
cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN)
};
let combined = (global * pulse).clamp(0.0, 1.0);
for (fadable, mut bg) in &mut pulses {
let mut c = fadable.base_color;
@@ -954,6 +983,46 @@ mod tests {
);
}
#[test]
fn splash_skips_scanline_overlay_under_reduce_motion() {
// The CRT scanline overlay is decorative motion that
// `Settings::reduce_motion_mode` suppresses per the
// design-system spec (§Accessibility #3). The splash
// root itself still spawns — the cursor still fades in
// and out (essential timing), but the scanline overlay
// node is omitted entirely.
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default())
.init_asset::<bevy::image::Image>()
.add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.insert_resource(SettingsResource(Settings {
first_run_complete: false,
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// The splash root spawns (essential motion intact)
assert_eq!(
count_splash_roots(&mut app),
1,
"splash should still spawn under reduce-motion — only the scanline + pulse are gated",
);
// The scanline overlay is gone
let scanline_count = app
.world_mut()
.query::<&SplashScanlineOverlay>()
.iter(app.world())
.count();
assert_eq!(
scanline_count, 0,
"scanline overlay must NOT spawn under reduce-motion",
);
}
#[test]
fn splash_despawns_after_total_duration() {
let mut app = headless_app();
+4 -3
View File
@@ -32,9 +32,9 @@ use crate::ui_modal::{
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2,
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
};
/// Bevy resource wrapping the current stats.
@@ -1017,6 +1017,7 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
..default()
},
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|cell| {
// Large value label — accent yellow makes the number sing
+11 -2
View File
@@ -58,8 +58,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5,
};
// ---------------------------------------------------------------------------
@@ -230,6 +231,13 @@ where
Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)),
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG),
// Honour `Settings::high_contrast_mode`: under HC the
// border boosts from `BORDER_STRONG` (#505050) to
// `BORDER_SUBTLE_HC` (#a0a0a0) so the modal panel
// edge stays clearly visible against the scrim and
// surface beneath. `update_high_contrast_borders` in
// `settings_plugin` does the per-frame swap.
HighContrastBorder::with_default(BORDER_STRONG),
))
.with_children(build_card);
})
@@ -364,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
},
BackgroundColor(idle_bg(variant)),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
+55
View File
@@ -226,6 +226,61 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0);
/// vision users.
pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
/// Marker for entities whose [`BorderColor`] should swap to
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
/// Tag any UI node where border legibility is accessibility-critical
/// — modal panels, popovers, settings rows, focus-ring carriers —
/// then add the `apply_high_contrast_borders` system to react to
/// settings changes.
///
/// `default_color` records the off-state colour the entity was
/// spawned with so the system can revert when HC is toggled back
/// off. Different sites use different defaults (`BORDER_SUBTLE` for
/// idle popover edges, `BORDER_STRONG` for active modal cards) — the
/// marker captures whichever one applies at this entity.
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
}
impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color }
}
}
/// Marker for entities whose [`BackgroundColor`] should swap to
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
/// Parallel to [`HighContrastBorder`] but for sites that paint their
/// shape via `BackgroundColor` rather than `BorderColor` —
/// `bevy::ui` 1 px decorative strips, tick marks, fine separators
/// often render as tiny full-bleed `Node`s, not as borders, so the
/// border-marker pattern doesn't apply.
///
/// `default_color` records the off-state colour the entity was
/// spawned with so the system can revert when HC is toggled back
/// off. The accompanying paint system is
/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds).
///
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
}
impl HighContrastBackground {
/// Convenience constructor —
/// `HighContrastBackground::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color }
}
}
/// Strong border — hover outline, focused button, active popover.
/// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
+3 -2
View File
@@ -36,8 +36,8 @@ use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM,
TEXT_PRIMARY, TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
};
// ---------------------------------------------------------------------------
@@ -189,6 +189,7 @@ fn spawn_tooltip_overlay(
},
BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline.