Compare commits

...

14 Commits

Author SHA1 Message Date
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
17 changed files with 1094 additions and 143 deletions
+226 -1
View File
@@ -6,9 +6,234 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
No threads in flight. v0.21.1 cut on 2026-05-08; CHANGELOG accumulates No threads in flight. v0.21.3 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here. the next cycle here.
## [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 ## [0.21.1] — 2026-05-08
Patch release for the post-v0.21.0 work — closes Resume-prompt Patch release for the post-v0.21.0 work — closes Resume-prompt
+125 -110
View File
@@ -1,75 +1,73 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.0 cut and tagged at `04f9bf9`**, **Last updated:** 2026-05-08 — v0.21.2 cut and tagged at `f23df3b`;
working tree clean, all post-tag work pushed to origin. post-cut work shipped: Toast Warning (`279e23d`) and the HC
dynamic-paint rollout (`c153363`). Working tree clean, all
post-tag work pushed to origin.
v0.21.0 closes the visual-identity arc opened in v0.20.0. Three v0.21.2 is a patch release for the post-v0.21.1 polish work:
through-lines landed in this cycle: the **card-face / suit / extends accessibility (full HC chrome rollout across 8 surfaces;
card-back artwork migration** that v0.20.0 deliberately deferred splash reduce-motion gating on scanline + cursor pulse), adds a
(both rendering paths in lockstep — `assets/cards/*.png` fallback floating MOVE chip above the destination card during replay
plus the bundled-default theme SVGs at playback, and lights up the first real consumer of
`solitaire_engine/assets/themes/default/*.svg` that `ToastVariant::Error` (a "Invalid move" toast as the third leg
`include_bytes!()`-embed into the binary), the **splash boot- of the existing audio + visual rejection-feedback stool).
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.
Full v0.21.0 detail lives in `CHANGELOG.md` § [0.21.0]. This Full v0.21.2 detail lives in `CHANGELOG.md` § [0.21.2]. This
file from here on focuses on what's *open* post-cut and how to file from here on focuses on what's *open* post-cut and how to
resume. resume.
## Status at pause ## Status at pause
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is - **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
`04f9bf9`; any post-cut docs edits ride on top of that. `f23df3b`; post-cut work (`279e23d` Toast Warning, `c153363`
- **HEAD on origin:** matches local. v0.21.0 is fully on origin. HC dynamic-paint rollout) rides on top of that.
- **HEAD on origin:** matches local. v0.21.2 is fully on origin.
- **Working tree:** clean. No WIP outstanding. - **Working tree:** clean. No WIP outstanding.
- **`artwork/` directory:** still untracked. Intentional. - **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` - **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean. clean.
- **Tests:** **1184 passing / 0 failing** across the workspace - **Tests:** **1207 passing / 0 failing** across the workspace
(net +8 from v0.20.0's 1176 baseline). Detail in (net +12 from the v0.21.2 cut: 8 from Toast Warning wiring;
`CHANGELOG.md` § [0.21.0] § Stats. 4 from the radial-rim HC truth-table).
- **Tags on origin:** `v0.9.0` through `v0.21.0`. v0.21.0 is on - **Tags on origin:** `v0.9.0` through `v0.21.2`. v0.21.2 is on
`f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
`04f9bf9`; v0.20.0 stays on `41a009a`. `04f9bf9`; v0.20.0 stays on `41a009a`.
## Since the v0.21.0 cut ## Since the v0.21.2 cut
Two Resume-prompt options closed post-tag (2026-05-08): - **`279e23d` — Toast Warning variant wired.** First in-engine
consumer of `ToastVariant::Warning`: a 4 s amber-bordered
toast that fires once per daily-challenge date when the
player is within 30 min of UTC midnight reset and hasn't yet
completed today's challenge. Mirrors the v0.21.2 Toast Error
pattern — a domain message (`WarningToastEvent(String)`) is
the contract between the daily plugin and the animation
plugin's spawn handler. Suppression decided by a pure helper
(`compute_expiry_warning_minutes`) that's exhaustively tested
without an `App`. After this commit every `ToastVariant`
(Info / Warning / Error / Celebration) has at least one real
driver — the variant enum is fully load-bearing.
- **`c153363` — HC rollout to the dynamic-paint sites.** Closes
the v0.21.2 carve-out. Re-reading the code revealed only one
of three "dynamic-paint" sites was actually a border-paint
cycle — HUD action buttons and modal buttons paint
*backgrounds* dynamically with static borders, so they take
the existing `HighContrastBorder` marker pattern cleanly. The
radial menu rim is the only true dynamic-painter (full
per-frame respawn of `Sprite` entities); HC is folded into
the spawn there with a pure helper (`radial_rim_outline`)
that boosts the *focused* rim to `BORDER_SUBTLE_HC` under HC
rather than `BORDER_STRONG` — naive marker substitution would
invert the focused-vs-resting hierarchy because
`BORDER_SUBTLE_HC` (#a0a0a0) is lighter than `BORDER_STRONG`
(#505050). After this commit, every UI surface in the v0.21.x
accessibility arc either carries the marker or has HC folded
into its own spawn cycle. No "un-tagged because race-risk"
surfaces remain.
- **Option A — App icon round** (`3eb3a26` + `716a025`). 9-size For the v0.21.2 contents themselves, see `CHANGELOG.md` §
PNG hierarchy in `assets/icon/` (16/24/32/48/64/128/256/512/ [0.21.2].
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.
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.
## Open punch list ## Open punch list
@@ -106,29 +104,51 @@ palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
mini-tableau preview, playback controls, move-log scroll, and mini-tableau preview, playback controls, move-log scroll, and
a WIN MOVE marker on the scrub bar. Banner-local pieces all a WIN MOVE marker on the scrub bar. Banner-local pieces all
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` + shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
`e080b49`); the screen-takeover is a multi-session redesign `e080b49`); the floating MOVE chip above the focused card
with data-layer impact (move-log scroller; WIN MOVE needs a shipped in v0.21.2 (`2fb2d63`). The screen-takeover is a
`win_move_index` field on `Replay` that doesn't yet exist). multi-session redesign with data-layer impact — needs a new
- **Floating `MOVE N/M` chip above the focused card during `win_move_index: Option<usize>` field on `Replay` (currently
playback.** Cross-plugin work — `update_progress_text` writes unimplemented), a move-log scroller, and a mini-tableau
the banner chip but the card-position lookup belongs in preview.
`card_plugin`. Smaller scope than the screen-takeover. - *Floating `MOVE N/M` chip above the focused card during
- **Toast Warning / Error variants.** `ToastVariant` has slots playback — closed 2026-05-08 by `2fb2d63`.* World-space
for `Warning` (gold) and `Error` (pink) but no in-engine `Text2d` entity sibling to the banner overlay; uses the same
event uses them yet. Wire when a warning- or error-flavoured `LayoutResource` pile coordinates so it survives window
toast event materialises. 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 - *High-contrast accessibility mode — closed 2026-05-08 by
`c5787c6` + `07e0357`.* Card text rendering picks up `c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC` rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
(`#ff8aa0`); Settings panel has a toggle. Future scope: dynamic-paint rollout (`c153363`).* Card text rendering plus
extend HC through chrome borders (`BORDER_SUBTLE_HC` already 8 static-border chrome surfaces (modal scaffold, tooltip,
defined, not yet consumed), buttons, popover edges. onboarding key chips, help panel key chips, stats panel
- *Reduced-motion mode — closed 2026-05-08 by the same pair.* cells, home Level/XP/Score row, home mode buttons, home
`effective_slide_secs` forces 0 when on, regardless of the mode-hotkey chips, 4 settings panel surfaces) all boost
`AnimSpeed` setting. Future scope: gate splash scanline borders to `BORDER_SUBTLE_HC` under HC via the
overlay + cursor pulse animation on the same flag, gate `HighContrastBorder` marker. The previously-carved-out
warning-chip pulse, gate any future card-lift z-bump dynamic-paint sites are now also covered: HUD action buttons
animation. 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 ### Carried forward from v0.19.0
@@ -232,20 +252,22 @@ into a v0.21.1 / v0.22.0 cut.
``` ```
You are a senior Rust + Bevy developer working on Solitaire Quest. You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>. Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.0 is tagged at 04f9bf9 (cut 2026-05-08). Branch: master. v0.21.2 is tagged at f23df3b (cut 2026-05-08, a
Working tree clean. v0.21.0 closed the visual-identity arc that patch release rolling up accessibility extensions, replay polish,
v0.20.0 deferred — full Terminal cards on both rendering paths and the first real `ToastVariant::Error` consumer). v0.21.1 stays
(asset PNGs + bundled-default theme SVGs), splash boot screen, at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut
replay-overlay banner enrichments, and a project-wide ACCENT_PRIMARY work shipped: Toast Warning variant (`279e23d`) and the HC
swap from cyan to brick red `#a54242`. See CHANGELOG.md § [0.21.0] dynamic-paint rollout (`c153363`) — accessibility arc is fully
for full detail. closed, every `ToastVariant` has at least one real driver. See
CHANGELOG.md § [0.21.2] + the "Since the v0.21.2 cut" section
above for full detail.
State: HEAD locally — see `git rev-parse HEAD`. All workspace tests State: HEAD locally — see `git rev-parse HEAD`. All workspace tests
pass (1184+; check with `cargo test --workspace`), clippy clean. pass (1207+; check with `cargo test --workspace`), clippy clean.
READ FIRST (in order, before doing anything): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file 1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.0] section is the most recent cut 2. CHANGELOG.md — [0.21.2] section is the most recent cut
3. CLAUDE.md — unified-3.0 rule set 3. CLAUDE.md — unified-3.0 rule set
4. CLAUDE_SPEC.md — formal architecture spec 4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow 5. ARCHITECTURE.md — crate responsibilities + data flow
@@ -260,38 +282,21 @@ READ FIRST (in order, before doing anything):
fresh machine) fresh machine)
DECISION TO ASK THE PLAYER FIRST: DECISION TO ASK THE PLAYER FIRST:
A. *Closed 2026-05-08 by `3eb3a26` + `716a025`.* App icon A. APK launch verification on AVD / device — `adb install` +
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` +
`adb logcat` to shake out runtime bugs the build / unit `adb logcat` to shake out runtime bugs the build / unit
tests can't catch. Likely surfaces JNI ClipboardManager tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running. scope; needs an Android device or emulator running.
C. Replay-overlay extensions — either the floating `MOVE N/M` B. Replay-overlay screen-takeover redesign — multi-session
chip above the focused card (smaller, cross-plugin; needs work: move-log scroller, mini-tableau preview, WIN MOVE
cursor → card-position plumbing in `card_plugin`) or the marker on the scrub bar (needs new `Replay::win_move_index`
full screen-takeover redesign (multi-session: move-log field), playback controls. The smaller floating-MOVE-chip
scroll, mini tableau preview, WIN MOVE marker, data-layer piece of B already shipped in v0.21.2 (`2fb2d63`).
impact for `Replay::win_move_index`). C. Phase 8 (sync) — local storage scaffolding, self-hosted
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
Axum server, `SolitaireServerClient` impl, GPGS stub Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore, up several Phase Android dependencies (Keystore,
ClipboardManager). 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: WORKFLOW NOTES:
- Use the system git config (already correct). - Use the system git config (already correct).
@@ -308,6 +313,16 @@ WORKFLOW NOTES:
migration walked past this" follow-ups that all matched migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can this shape — codified here so future similar work can
pattern-match instead of rediscovering. 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.
``` ```
+92 -5
View File
@@ -21,8 +21,10 @@ use crate::card_animation::{sample_curve, CardAnimation, MotionCurve};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent; use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{InfoToastEvent, XpAwardedEvent}; use crate::events::{
use crate::events::{AchievementUnlockedEvent, GameWonEvent}; AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
@@ -162,6 +164,8 @@ impl Plugin for AnimationPlugin {
.add_message::<ChallengeAdvancedEvent>() .add_message::<ChallengeAdvancedEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
@@ -183,6 +187,8 @@ impl Plugin for AnimationPlugin {
handle_settings_toast, handle_settings_toast,
handle_auto_complete_toast, handle_auto_complete_toast,
handle_xp_awarded_toast, handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
) )
@@ -565,9 +571,11 @@ pub enum ToastVariant {
/// event; kept so future warning-flavoured toasts have a slot. /// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)] #[allow(dead_code)]
Warning, Warning,
/// Failure / rejected action — pink border. Currently unused; kept so /// Failure / rejected action — pink border. Used by
/// future error-flavoured toasts have a slot. /// [`handle_move_rejected_toast`] for illegal-placement
#[allow(dead_code)] /// feedback; the third leg of the rejection-feedback stool
/// alongside `card_invalid.wav` (audio) and the destination-
/// pile shake (visual).
Error, Error,
/// Reward / milestone — lavender border. Used for XP awards, /// Reward / milestone — lavender border. Used for XP awards,
/// achievement unlocks, level-ups, daily/weekly/challenge completions. /// 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. /// 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 /// Skipped while the game is paused so toast countdowns freeze along with the
@@ -966,6 +1015,44 @@ mod tests {
let _ = count; 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 // Task #67 — Toast queue pure-function tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+223 -3
View File
@@ -14,13 +14,13 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; 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_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
use crate::events::{ use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent, GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
XpAwardedEvent, WarningToastEvent, XpAwardedEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate}; 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. /// Bonus XP awarded for completing today's daily challenge.
pub const DAILY_BONUS_XP: u64 = 100; 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, /// The active daily challenge — date + RNG seed for that date's deal,
/// plus optional goal metadata fetched from the server. /// plus optional goal metadata fetched from the server.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>); 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. /// 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. /// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin; pub struct DailyChallengePlugin;
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today()) app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>() .init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.add_message::<DailyChallengeCompletedEvent>() .add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>() .add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>() .add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge) .add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge) .add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight // record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame. // ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate)) .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)); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -385,4 +468,141 @@ mod tests {
assert_eq!(r.target_score, Some(1_000)); assert_eq!(r.target_score, Some(1_000));
assert_eq!(r.max_time_secs, Some(300)); 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)] #[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String); 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 /// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade. /// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Message, Debug, Clone, Copy)] #[derive(Message, Debug, Clone, Copy)]
+3 -2
View File
@@ -14,8 +14,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Marker on the help overlay root node. /// Marker on the help overlay root node.
@@ -263,6 +263,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+5 -2
View File
@@ -38,8 +38,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, 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, 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), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|row| { .with_children(|row| {
for (label, value) in [ for (label, value) in [
@@ -943,6 +944,7 @@ fn spawn_draw_mode_chip<M: Component>(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|c| { .with_children(|c| {
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg))); c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
@@ -1156,6 +1158,7 @@ fn spawn_mode_card(
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+2 -1
View File
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT; use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, 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, 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, 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, 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), BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); 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, spawn_modal_header, ButtonVariant,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING, 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() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+63 -4
View File
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource}; use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource}; 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. /// 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 /// Despawns and respawns the radial overlay sprites every frame the
/// state is `Active`; despawns them when the state returns to `Idle`. /// 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( fn radial_redraw_overlay(
state: Res<RightClickRadialState>, state: Res<RightClickRadialState>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands, mut commands: Commands,
existing_icons: Query<Entity, With<RadialIcon>>, existing_icons: Query<Entity, With<RadialIcon>>,
existing_centres: Query<Entity, With<RadialCentre>>, 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), 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() { for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i); let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 }; let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY }; let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
// Hovered icon gets a strong yellow rim; resting icons get a let outline = radial_rim_outline(focused, high_contrast);
// muted purple rim so the focused one reads as the obvious target.
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
commands commands
.spawn(( .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 // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -940,4 +970,33 @@ mod tests {
"face-down cards must not open the radial" "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);
}
} }
+177
View File
@@ -27,7 +27,9 @@ use bevy::prelude::*;
use chrono::Datelike; use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::layout::LayoutResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -88,6 +90,21 @@ pub struct ReplayOverlayBannerText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayProgressText; pub struct ReplayOverlayProgressText;
/// Marker on the **floating** progress chip — a 2D world-space text
/// entity rendered above the destination pile of the most-recently-
/// applied move. Sits independently of the banner overlay (which
/// lives in the UI tree and never moves) so the player can see
/// progress without breaking eye contact with the focal card.
///
/// Lifecycle matches the banner overlay: spawned by `spawn_overlay`
/// when a replay starts, despawned by `react_to_state_change` when
/// it ends. Position updated each frame by
/// `update_floating_progress_chip`. Hidden when cursor=0 (no moves
/// applied yet) or the last applied move was a `StockClick` (no
/// destination pile to follow).
#[derive(Component, Debug)]
pub struct ReplayFloatingProgressChip;
/// Marker on the right-hand "Stop" button. Click handler queries for this /// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed` /// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen. /// transition is seen.
@@ -149,6 +166,7 @@ impl Plugin for ReplayOverlayPlugin {
react_to_state_change, react_to_state_change,
update_banner_label, update_banner_label,
update_progress_text, update_progress_text,
update_floating_progress_chip,
update_scrub_fill, update_scrub_fill,
handle_stop_button, handle_stop_button,
) )
@@ -170,6 +188,7 @@ fn react_to_state_change(
mut commands: Commands, mut commands: Commands,
state: Res<ReplayPlaybackState>, state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>, existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if !state.is_changed() { if !state.is_changed() {
@@ -185,6 +204,13 @@ fn react_to_state_change(
for entity in &existing { for entity in &existing {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
// Floating chip lives outside the UI tree (world-space
// entity), so the banner-root despawn doesn't reach it.
// Despawn separately on the same state transition so both
// disappear together when the replay ends.
for entity in &floating_chips {
commands.entity(entity).despawn();
}
} }
// The `should_be_visible && already_spawned` branch is a no-op here — // The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label // the per-frame text update systems below repaint the banner label
@@ -200,6 +226,11 @@ fn spawn_overlay(
state: &ReplayPlaybackState, state: &ReplayPlaybackState,
) { ) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Clone for the floating chip spawn that runs *after* the
// banner's `.with_children(|banner| { ... })` closure consumes
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone();
let banner_label = if state.is_completed() { let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
@@ -365,6 +396,30 @@ fn spawn_overlay(
)); ));
}); });
}); });
// Floating progress chip — a 2D world-space `Text2d` rendered
// above the destination pile of the most-recently-applied move.
// Sibling of (not child of) the banner overlay because it lives
// in world-space coordinates, not the UI tree. Spawned hidden;
// `update_floating_progress_chip` shows + positions it on the
// first frame the cursor advances past 0. Lifecycle matches
// the banner overlay — `react_to_state_change` despawns both
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
// High Z keeps the chip above every card stack
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
// stack to the low double digits at most).
Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden,
));
} }
/// Pure helper — returns the scrub-fill width as a percentage of the /// Pure helper — returns the scrub-fill width as a percentage of the
@@ -425,6 +480,78 @@ fn update_progress_text(
} }
} }
/// Repositions the floating progress chip above the destination
/// pile of the most-recently-applied move and repaints its text.
///
/// The chip is hidden when:
/// - the cursor is at 0 (no moves applied yet — chip would have
/// nowhere meaningful to land), OR
/// - the most-recently-applied move was a `StockClick` (no
/// destination pile — stock-click feedback already lives at
/// the stock pile and we don't want the chip to jitter back
/// to the stock pile every cycle).
///
/// When visible, the chip's world-space `Transform.translation`
/// is set to the destination pile's centre plus a fixed upward
/// offset (`card_size.y * 0.6`) so the chip floats just above
/// the top edge of the card. World-space placement (rather than
/// UI-space + camera projection) keeps the math trivial and means
/// the chip stays correctly positioned through window resizes
/// without any extra wiring — `LayoutResource` already drives
/// every other piece of pile geometry.
fn update_floating_progress_chip(
state: Res<ReplayPlaybackState>,
layout: Option<Res<LayoutResource>>,
mut chips: Query<
(&mut Transform, &mut Visibility, &mut Text2d),
With<ReplayFloatingProgressChip>,
>,
) {
let Some(layout) = layout else {
return;
};
// Resolve the destination pile of the last-applied move (if
// any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(to.clone()),
ReplayMove::StockClick => None,
}
}
_ => None,
};
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| layout.0.pile_positions.get(p).copied())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {
*visibility = Visibility::Hidden;
}
return;
};
// Position above the destination pile by ~60 % of a card
// height. Half a card lifts above the centre, the extra 10 %
// is breathing room above the top edge so the chip doesn't
// visually clip the card.
let above = Vec2::new(0.0, layout.0.card_size.y * 0.6);
let target = (world_pos + above).extend(100.0);
let label = format_progress(&state);
for (mut transform, mut visibility, mut text2d) in chips.iter_mut() {
transform.translation = target;
*visibility = Visibility::Inherited;
if **text2d != label {
**text2d = label.clone();
}
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress. /// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay /// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the /// already early-exits when nothing moved, so an idle replay leaves the
@@ -668,6 +795,56 @@ mod tests {
); );
} }
/// Lifecycle: the floating progress chip spawns alongside the
/// banner overlay when playback starts, and despawns when
/// playback ends. (Position correctness needs `LayoutResource`,
/// which isn't set up in this headless fixture; the lifecycle
/// test below is what's load-bearing for the spawn/despawn
/// pairing.)
#[test]
fn floating_chip_spawns_and_despawns_with_overlay() {
let mut app = headless_app();
// Inactive → no chip.
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
0,
"no floating chip while playback is Inactive",
);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
1,
"floating chip must spawn when playback starts",
);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
0,
"floating chip must despawn when playback ends",
);
}
/// Manually flipping the resource back to `Inactive` (e.g. via the /// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay /// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input. /// down without any further input.
+44 -3
View File
@@ -34,9 +34,9 @@ use crate::ui_modal::{
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
Z_MODAL_PANEL, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Side length of a swatch button in the card-back / background pickers. /// Side length of a swatch button in the card-back / background pickers.
@@ -364,6 +364,7 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text, update_color_blind_text,
update_high_contrast_text, update_high_contrast_text,
update_high_contrast_borders,
update_reduce_motion_text, update_reduce_motion_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
@@ -637,6 +638,42 @@ 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);
}
}
}
fn update_reduce_motion_text( fn update_reduce_motion_text(
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>, mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
@@ -1913,6 +1950,7 @@ fn picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
@@ -2054,6 +2092,7 @@ fn theme_picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref()); spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
@@ -2175,6 +2214,7 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -2235,6 +2275,7 @@ fn icon_button(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY))); 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(); 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 // Generate the scanline texture handle up-front (when the asset
// store is available — always true in production; opt-out under // store is available — always true in production; opt-out under
// bare `MinimalPlugins` test fixtures so existing tests that // bare `MinimalPlugins` test fixtures so existing tests that
// don't init `Assets<Image>` keep working with the rest of the // don't init `Assets<Image>` keep working with the rest of the
// splash content unchanged). // splash content unchanged). Also skipped when reduce-motion is
let scanline_handle = images.map(|mut images| images.add(build_scanline_image())); // 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 commands
.spawn(( .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 /// No-op when no `SplashRoot` exists (the splash has already
/// despawned, or we're under a test fixture that doesn't spawn one). /// 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( fn pulse_splash_cursor(
roots: Query<&SplashAge, With<SplashRoot>>, roots: Query<&SplashAge, With<SplashRoot>>,
settings: Option<Res<SettingsResource>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>, mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) { ) {
let Some(age) = roots.iter().next() else { let Some(age) = roots.iter().next() else {
return; return;
}; };
let global = splash_alpha(age.0).unwrap_or(0.0); 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); let combined = (global * pulse).clamp(0.0, 1.0);
for (fadable, mut bg) in &mut pulses { for (fadable, mut bg) in &mut pulses {
let mut c = fadable.base_color; 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] #[test]
fn splash_despawns_after_total_duration() { fn splash_despawns_after_total_duration() {
let mut app = headless_app(); let mut app = headless_app();
+4 -3
View File
@@ -32,9 +32,9 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -1017,6 +1017,7 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|cell| { .with_children(|cell| {
// Large value label — accent yellow makes the number sing // 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::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, 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, 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, HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, 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)), Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)),
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG), 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); .with_children(build_card);
}) })
@@ -364,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
}, },
BackgroundColor(idle_bg(variant)), BackgroundColor(idle_bg(variant)),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.into()), font_label, TextColor(label_color))); b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));
+26
View File
@@ -226,6 +226,32 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0);
/// vision users. /// vision users.
pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0); 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 }
}
}
/// Strong border — hover outline, focused button, active popover. /// Strong border — hover outline, focused button, active popover.
/// `outline` from the design system. `#505050`. /// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0); 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::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP, TEXT_PRIMARY, TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -189,6 +189,7 @@ fn spawn_tooltip_overlay(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
Visibility::Hidden, Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element // Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline. // is never occluded by the focus outline.