`WindowInsets.getInsets(systemBars())` returns physical pixels (e.g. 84 px
on a 2.625× Pixel 7) but both Bevy's `Val::Px` (UI layer) and the world-
space layout coordinate system use logical pixels. Dividing by
`window.scale_factor()` before applying gives the correct 32 dp offset.
- `safe_area.rs::apply_safe_area_anchors`: query `Window`, divide `insets.top`
by `scale_factor()` before writing `Val::Px(base_top + top_logical)`.
- `layout.rs::compute_layout`: new `safe_area_top: f32` parameter (logical px)
subtracts from the vertical budget (`card_width_height_based`) and from
`top_y` so both card sizing and pile positioning honour the status-bar band.
- `table_plugin.rs`: `setup_table` and `on_window_resized` now read
`SafeAreaInsets` and divide by scale before passing `safe_area_top` to
`compute_layout`. New `on_safe_area_changed` system fires a synthetic
`WindowResized` when insets arrive (~frame 2-3 on Android) so the full
resize pipeline (layout → pile markers → card snap) re-runs automatically.
- All test call-sites updated with `, 0.0` safe_area_top (desktop/no inset).
- Two regression tests added: shift amount equals `safe_area_top` exactly;
horizontal layout is unaffected by vertical inset.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reqwest/hyper-util's GaiResolver calls tokio::runtime::Handle::current()
which panics with "no reactor running" when driven by Bevy's
AsyncComputeTaskPool (async-executor, not Tokio). Fixed all three spawn
sites in sync_plugin.rs (start_pull, handle_manual_sync_request,
push_replay_on_win) and the push_on_exit fallback by wrapping each HTTP
future in tokio::runtime::Builder::new_current_thread().enable_all().
Also fixes a clippy type_complexity warning in hud_plugin.rs by
extracting HudScoreFont / HudMovesFont / HudTimeFont type aliases for
the update_hud_typography query parameters.
Closes P4 AVD JNI bridge test: keystore JNI verified working on
Android 14 x86_64 AVD (load_access_token returned NotFound correctly);
clipboard JNI compiled and linked, runtime test deferred to a real-device
session with a won game and active sync server.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the final two P2 Android playability items:
1. HUD typography — new `update_hud_typography` system fires on
`WindowResized` and adjusts Tier-1 font sizes: below 480 logical px
Score drops HEADLINE(26)→BODY_LG(18) and Moves/Timer drop
BODY_LG(18)→CAPTION(11), so all three fit in the 180dp HUD column
on a 360dp phone without wrapping.
2. Orientation lock — `[package.metadata.android.application.activity]`
with `orientation = "portrait"` in solitaire_app/Cargo.toml; cargo-apk
maps this to `android:screenOrientation="portrait"` in the generated
AndroidManifest.xml.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Touch screens have no right mouse button, so right-click radial was
inaccessible on Android. New system radial_open_on_long_press counts
up while a touch is held on a face-up card without crossing the drag
threshold; after 0.5 s it transitions RightClickRadialState to Active,
which the existing visual overlay and destination-ring infrastructure
then renders unchanged.
Three supporting changes to wire up the touch-driven confirm path:
- radial_track_cursor: falls back to the first active Touches position
when cursor_world returns None, so the hover ring tracks a sliding
held finger on Android.
- radial_handle_release_or_cancel: confirms on Touches::iter_just_released
(finger lift) in addition to right-mouse release. Cancels on
Touches::iter_just_canceled. No new event reader — uses the Touches
resource which is already in scope after the track_cursor addition.
- handle_double_tap: skips when the radial is active. Guards the
narrow edge case where the finger lifts on the exact same frame
as the 0.5 s long-press threshold fires; prevents a spurious
double-tap move from racing with the radial confirm.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two improvements to drag responsiveness on Android:
1. Guard start_drag against touch-simulated mouse presses.
start_drag (mouse path) now bails when Touches::iter_just_pressed()
finds an active touch, so touch_start_drag always owns drag state on
touch-screen devices. Without the guard, Bevy/Winit versions that
synthesise MouseButton::Left from the primary touch would have the
mouse drag path claim drag state first (start_drag runs before
touch_start_drag in the system chain), leaving the card tracked via
cursor_world instead of the Touches resource.
2. Lower mobile drag commit threshold 10 px → 8 px.
Matches Android ViewConfiguration.getScaledTouchSlop() exactly.
Smaller threshold reduces the snap-to-finger displacement at commit
and makes drag feel more immediate.
Hardware confirmation (verify no stutter, tune if needed) remains a
manual step recorded in PLAYABILITY_TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When handle_double_tap recognises a double-tap and fires MoveRequestEvent,
the moved card(s) are immediately tinted STATE_SUCCESS (lime #acc267) with
a 0.35 s HintHighlight so the player sees visual confirmation before the
card animation begins.
- Priority 1 (single top card): flashes that card only.
- Priority 2 (whole face-up stack): flashes every card in drag.cards.
Reuses the existing tick_hint_highlight cleanup path (restores sprite
to WHITE when timer expires) so no new system or component is needed.
The flash duration (0.35 s) slightly outlasts a typical card animation
(~0.3 s), giving the tint a brief moment at the destination before clearing.
Marks P1 "Double-tap auto-move visible feedback" as closed in
PLAYABILITY_TODO (hardware trigger-verification still manual).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On a 360 dp portrait phone the card width is set by the 9-column
horizontal packing (360/9 = 40 dp); the fixed 0.25 fan fraction then
places the worst-case 13-card column in the top ~44 % of the screen,
leaving the bottom 56 % empty black.
`compute_layout` now solves for the fan fraction that exactly uses the
available vertical space below the tableau row:
ideal = avail / (12 * card_height)
On height-limited (desktop) windows ideal ≈ 0.25 and the clamp to the
minimum keeps existing behaviour. On width-limited (portrait phone)
windows the fan expands — ≈ 0.84 at 360 × 800 dp — stretching the
tableau to fill the screen.
Both `tableau_fan_frac` and `tableau_facedown_fan_frac` (scaled
proportionally) are stored on the `Layout` struct. `card_plugin` and
`input_plugin` read from the struct so rendering and hit-testing stay
in sync at every viewport size.
Three new regression tests:
- portrait phone expands fan_frac beyond desktop minimum
- expanded fan fits inside phone viewport (no overflow)
- desktop fan_frac stays at minimum 0.25
Closes P1 "Portrait-first card spacing" in PLAYABILITY_TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Action buttons sized to text + 8 px padding made "Undo" end up
~46 x 33 px — fine for a mouse but at the threshold of a finger.
Adds `min_width: 48 px` and `min_height: 48 px` to the button
Node so every button meets Material's 48 dp thumb-target guideline.
Applied universally; the floor is a no-op for buttons whose
content already exceeds 48 px on either axis (Menu, Modes,
New Game, Pause, Help all clear 48 px wide; height was the
binding constraint at ~33 px).
Closes P1 #2 of docs/android/PLAYABILITY_TODO.md. Engine tests
pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The U / Esc / F1 / N caption chips next to the HUD action buttons
are meaningless on a touch device and visibly clutter the
narrow-viewport action row (visible as "Esc A [] N" in the v0.22.3
screenshot). `spawn_action_button` now rebinds `hotkey` to `None`
under `#[cfg(target_os = "android")]` so the chip-spawn branch is
skipped on touch builds.
Menu / Modes chevrons are unaffected — they indicate dropdown
behaviour and still apply on touch. Other hint surfaces
(onboarding, pause modal Esc hint, mode-card chips, replay
footer, modal toggle chips, help screen) live behind navigation
and are tracked as a P3 sweep in PLAYABILITY_TODO.md.
Closes P1 #1 of docs/android/PLAYABILITY_TODO.md. 855 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a
component-wise floor: any window smaller than MIN_WINDOW on either
axis gets clamped up. The previous floor of 800x600 was set with
desktop in mind, but on Android the OS-provided window size is the
device resolution (~360 dp wide on a typical phone) and the clamp
silently re-laid the board for an 800 dp width.
Side effect: total grid width (9 * card_width) became ~800 px on a
360 dp viewport, so the leftmost foundation x-position fell past
-180 and the rightmost tableau pile past +180 — both clipped at
the visible edges, matching the v0.22.3 hardware screenshot.
Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone
(~360x640), so every real device flows through compute_layout
unclamped. The floor is preserved as a sentinel against degenerate
windows (Bevy can briefly report 0-size during startup or after
minimisation on some compositors). Desktop's "minimum supported
playable size" is enforced separately via WindowResizeConstraints
in solitaire_app.
Updates `layout_below_minimum_clamps_to_minimum` to use values
below the new floor, and adds a new regression test
`phone_portrait_layout_fits_horizontally` that asserts all 13
piles fit inside a 360 x 800 dp viewport.
Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests
pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The v0.22.3 hardware screenshot showed the 6-button action row
(~510 px when laid out) overflowing into a 360 dp viewport from
the right anchor, with Menu and Undo clipped off-screen left and
Pause/Help/Modes/New_Game overlapping the left HUD column's
Score / Moves / Timer text.
Cap both clusters at `max_width: 50 %` so on mobile each takes
half the viewport (~180 px) and on desktop the cap is wider than
either cluster's natural width so the existing single-line
layout is preserved.
- Action button row: adds `flex_wrap: Wrap`, `row_gap`, and
`justify_content: FlexEnd` so the row breaks to multiple
right-aligned lines instead of clipping. 6 buttons become 2-3
lines of 2-3 buttons.
- HUD column tier rows: add `flex_wrap: Wrap` and `row_gap` to
the shared `row_node` helper so a long Mode/Challenge/Draw-cycle
combo soft-wraps onto two lines instead of pushing into the
action button column.
Closes P0 #2 of docs/android/PLAYABILITY_TODO.md. All 854 engine
tests pass; clippy clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.
UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.
Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).
Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two cfg(android) issues hidden from Linux CI:
- android_clipboard.rs: JValue was imported but never used (JValueOwned
covers all call sites). Removed to satisfy -D unused-imports.
- stats_plugin.rs: both arms of the clipboard match now return () via
explicit block+semicolon, resolving the type mismatch that pinged-pong
between runs due to bidirectional match-arm type inference.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Err arm in stats_plugin.rs had a trailing semicolon on
toast.write(...) making it return () while the Ok arm returned
MessageId<InfoToastEvent>. Only caught on Android because the block is
cfg(target_os = "android") gated; the Linux CI never compiled it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DifficultyLevel (Easy/Medium/Hard/Expert/Grandmaster/Random) to
solitaire_core::game_state alongside GameMode::Difficulty(DifficultyLevel).
Five seed catalogs (40 seeds each) are pre-verified by the new
gen_difficulty_seeds binary using tiered solver budgets (1K–200K moves).
DifficultyPlugin resolves StartDifficultyRequestEvent → catalog seed →
NewGameRequestEvent; Random uses a system-time seed and bypasses the
winnable-only filter. The home overlay gets an expandable Difficulty section
between Draw Mode and the mode grid; last-played tier persists in Settings.
Difficulty wins pool into Classic stats. 5 unit tests in difficulty_plugin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the informational "Share link: {url}" toast on Android with a
real clipboard write via ClipboardManager JNI. Falls back to the old
toast on JNI error so the user can still copy the URL manually.
Adds `jni = "0.21"` (default-features = false) as a workspace dep;
`jni 0.21.1` was already in Cargo.lock as a transitive dep so no new
packages are fetched.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
F11 fullscreen toggle only makes sense on desktop; Android windows are
always full-screen. Gates the fn and the MonitorSelection/WindowMode
imports with #[cfg(not(target_os = "android"))] to keep clippy clean
on the Android target. The add_systems call is extracted as a separate
statement so #[cfg] can annotate it (cannot appear mid-chain).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a numeric-input modal (PlayBySeedPlugin) that lets the player type
a decimal seed and receive an instant solver-verified verdict before the
hand is dealt. A new HomeMode::PlayBySeed card surfaces it in the home
overlay, matched by the StartPlayBySeedRequestEvent carrier.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors handle_double_click for the touch pipeline. A double-tap on a
face-up card fires MoveRequestEvent to the best legal destination using
the same priority order (foundation first, tableau second; stack move
as priority 2 when the tapped card is a stack base).
Implementation:
- handle_double_tap reads TouchPhase::Ended events. When
drag.active_touch_id is set and drag.committed is false, the touch
ended without crossing the drag threshold = pure tap. The top card ID
from drag.cards is used as the tracking key.
- DOUBLE_TAP_WINDOW = 0.5s (wider than DOUBLE_CLICK_WINDOW = 0.35s;
touch screens have higher input latency; pinned by a const-assert test).
- System is inserted between touch_follow_drag and touch_end_drag in
the .chain() so drag state is readable before touch_end_drag clears it.
- touch_end_drag's uncommitted-tap cleanup path still fires after
handle_double_tap — the drag.clear() + StateChangedEvent are
harmless in sequence with a MoveRequestEvent already queued.
1 new test (1283 total): double_tap_window_is_wider_than_double_click_window
(compile-time const assert).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire the long-dormant ReplayPrevButton / ReplaySelectorCaption /
ReplayNextButton / ReplaySelectorDetail spawn site that was missing
since v0.19.0. The click handler and repaint systems already existed;
this commit adds the actual UI nodes so players can step through all
stored replays (up to REPLAY_HISTORY_CAP) instead of always watching
the most recent win.
Also fix an assertion-on-constant clippy lint in the replay_overlay
dim-layer z-order test (const { assert!() } form required).
1282 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HighContrastBackground gains an optional hc_color field so sites can
specify a domain-specific HC variant rather than always bumping to
BORDER_SUBTLE_HC (gray). with_default() fills hc_color = BORDER_SUBTLE_HC
preserving all existing behaviour; new with_hc(default, hc) lets callers
specify both ends. update_high_contrast_backgrounds reads marker.hc_color
instead of the hardcoded constant.
STATE_SUCCESS_HC (#c8e862, L≈0.73) added to ui_theme — a brighter lime
that maintains the success hue while standing out from bumped notch
ticks (BORDER_SUBTLE_HC gray, L≈0.60) under HC mode.
WIN MOVE marker now carries HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC): lime stays lime under HC instead of turning gray.
Unit test pins both the default and hc color fields on the spawned marker.
1276 tests pass / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The three middle scrub-bar labels (25%, 50%, 75%) previously had their
left edge anchored at the notch percentage, making them read as
"starting after" the notch. Apply the CSS translateX(-50%) pattern for
Bevy 0.18 UI: give each middle label a fixed-width container
(SCRUB_LABEL_CENTER_WIDTH = 36px), offset the container's left edge by
-width/2 via margin.left, and add Justify::Center so the text renders
centred within the container. The container's centre then coincides with
the notch line at the chosen percentage.
Endpoints (0%, 100%) keep their flush-left / flush-right anchoring
unchanged. 1275 tests pass / 0 failing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black)
at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer
darkens the entire card world so the replay chrome (banner at z=55,
move-log panel at z=55) reads clearly against the scene without
obscuring card positions — matching the mockup's "Game Peek Band at
50% opacity" spec. Bevy's UI/world compositor means no changes to
card_plugin are needed: UI nodes always render above world-space sprites
regardless of Transform.z values.
The dim layer carries no Interaction component (purely visual; pointer
events pass through). Despawned alongside the banner and move-log panel
in `react_to_state_change` when the replay ends.
Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus
two new tests: lifecycle (spawn/despawn mirrors floating chip pattern)
and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned).
1275 tests pass / 0 failing. Closes the last major B-2 sub-piece.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
→ 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Player feedback after the border-drop fix did NOT close the
"gray corners" complaint: "I do not see anything change." The
border was a real artifact, but the *visible* gray came from a
different source.
Root cause: pile markers are 8%-alpha-white sprites sized to
the card area, sitting at `Z_PILE_MARKER = -1.0` beneath every
card. Composited against the dark play surface, the marker's
effective colour is ≈`#272727` — visibly gray. When a card
(rounded corners, opaque body) sits on top, the marker's
rectangular fill bleeds through the 4 small triangular regions
where the card's rounded corner curves cut away from the card's
bounding rectangle. That bleed-through is the "gray L" the
player saw at each card corner.
Fix: hide pile-marker sprites for any pile that has a card on
top. New `sync_pile_marker_visibility` system runs each Update
tick, guarded by `game.is_changed()` so the work skips on idle
frames. Iterates `(&PileMarker, &mut Visibility)` and sets
`Hidden` for occupied piles, `Inherited` for empty.
This implements the *documented* invariant declared in the
module-level doc comment ("Pile markers ... remain visible only
where a pile is empty") that was previously not enforced —
markers always rendered. Strictly speaking this is a
documentation-vs-implementation drift fix, not a behaviour
change.
### Why the border-drop fix didn't address this
The border drop changed the SVG stroke and removed *one* source
of corner artifacts (anti-aliased red/near-white stroke fading
through gray). It correctly drifted 52 face hashes. But the
visible gray at corners came from a *different* layer — the
pile-marker sprite *behind* the card, not the card stroke
itself. Right test target, wrong visible-artifact target.
Two layers, two fixes; this commit closes the second.
### Test
New `pile_markers_hide_when_pile_is_occupied` pins the
post-deal state: 8 markers hidden (stock + 7 tableau), 5
markers visible (waste + 4 foundations). 1192 passing
(+1 from prior 1191).
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Player feedback after the 2-colour revert: "I do not like the
grey corners on the cards." The visible artifact was anti-
aliasing physics — the 1 px suit-coloured stroke (red for
hearts/diamonds, near-white for clubs/spades) faded through
gray pixels into the dark play surface at each rounded corner,
producing a visible "gray sliver" at the four arcs of every
card.
Fix: drop the stroke entirely. The card body fill defines the
shape against the play surface; the 5-unit brightness gap
between `#1a1a1a` body and `#151515` surface is enough to read
as a card edge without an explicit stroke. Anti-aliasing on a
fill-only rounded rect blends `#1a1a1a → #151515` over a few
pixels — barely perceptible compared to the
`stroke → transparent` gradient that produced the artifact.
### Changes
- `card_face_svg.rs`: removed `stroke="{colour}" stroke-width="2"`
from the card body rect. Reverted the 1 px stroke inset back
to `(x=0, y=0, width=256, height=384)` since there's no
longer a stroke to keep inside the pixmap. Module-level
comment updated to document the reasoning.
- `design-system.md` § Game Cards line 225 updated: "Border:
1px solid in suit color" → "Border: none." with the
artifact rationale recorded as audit trail.
- `card_face_svg_pin.rs` rebaselined: all 52 face hashes drift
(every card's perimeter pixels changed); 5 back hashes
unchanged.
Workspace clippy + cargo test --workspace clean. 1191 passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per player feedback after the brief 4-colour-deck experiment:
"can we make the card suit colors the same as a regular
solitaire game would." Reverts the 4-colour split (`62b61cc`)
and bumps both 2-colour hues to read more like a real
Microsoft-Solitaire-on-dark-mode deck.
### Constants
- `RED_SUIT_COLOUR`: `#fb9fb1` (Terminal pink, then briefly
hearts-only) → `#e35353` (saturated red). More chromatic, less
pastel; reads as "the red suit" rather than "a Terminal-
themed pink." Visually distinct from `ACCENT_PRIMARY`
`#a54242` (the brick-red CTA accent) so chrome and suit don't
collapse to the same hue.
- `BLACK_SUIT_COLOUR`: `#d0d0d0` (matched `TEXT_PRIMARY`) →
`#e8e8e8` (near-white). Bumped slightly brighter so it reads
as a chromatic-neutral counterpart to the new saturated red,
not as "the same gray as body text." `TEXT_PRIMARY_HC`
(`#f5f5f5`) is still brighter for the high-contrast boost
path.
- `RED_SUIT_COLOUR_HC`: `#ff8aa0` (pinkish boost matching the
v0.21.0 pink default) → `#ff6868` (brighter saturated red).
Now reads as "more chromatic" than the new default red, not
"less saturated."
- `DIAMOND_SUIT_COLOUR` and `CLUB_SUIT_COLOUR` deleted — the
4-colour split is gone, hearts/diamonds re-pair under
`RED_SUIT_COLOUR` and clubs/spades under
`BLACK_SUIT_COLOUR`.
### `card_face_svg.rs`
- Module-level constants collapse from four (`SUIT_HEART` /
`SUIT_DIAMOND` / `SUIT_CLUB` / `SUIT_SPADE`) back to two
(`SUIT_RED` / `SUIT_DARK`) at the new saturated-red /
near-white values.
- `suit_paint()` reverts to the 2-colour pairing: hearts
filled-red, diamonds outlined-red, spades filled-near-white,
clubs outlined-near-white. Filled-vs-outlined glyph
differentiation stays the always-on CBM fallback.
### `card_plugin.rs`
- `text_colour()` reverts to a `card.suit.is_red()`
bifurcation. Comment block updated to reflect the new
truth table: red suits → saturated red (or CBM lime / HC
brighter red); dark suits → near-white (or HC brighter
near-white).
### Tests
Test block restructured back to the pre-4-colour shape: two
red/black pairing tests instead of one 4-colour distinctness
test. CBM/HC compose tests retuned to the 2-colour world (red
suits compose, dark suits compose; no separate diamonds-immune
or clubs-immune cases). 1191 passing / 0 failing — net 0 from
the prior commit (3 tests removed: the 4-colour distinctness
test + the diamonds/clubs-immune test; 2 tests added back: the
red-pairing + dark-pairing tests; existing tests amended to
new colour assumptions).
### `card_face_svg_pin`
All 52 face hashes drift (every suit's colour shifted); 5 back
hashes unchanged. Surgical rebaseline.
### `design-system.md`
§Suit Colors retitled "Two-color traditional pairing", table
updated with the new hex values, CBM section text simplified
back to red→lime swap on both red suits.
Workspace clippy clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>