Compare commits

...

6 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
8 changed files with 545 additions and 87 deletions
+100 -1
View File
@@ -6,9 +6,108 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
No threads in flight. v0.21.2 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.
## [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-
+120 -77
View File
@@ -1,46 +1,73 @@
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.1 cut and tagged at `daa655a`**,
working tree clean, all post-tag work pushed to origin.
**Last updated:** 2026-05-08 — v0.21.2 cut and tagged at `f23df3b`;
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.1 is a patch release for the post-v0.21.0 work: closes
Resume-prompt Options A (app icon — runtime `Window::icon` plus
the 9-size PNG hierarchy) and F (high-contrast + reduce-motion
accessibility modes — Settings flags wired through engine and
UI). Plus a card-visual iteration cycle that moved through three
states (v0.21.0 Terminal pink/gray → brief 4-colour-deck
experiment → traditional 2-colour Microsoft-Solitaire-on-dark-mode
red/near-white) and two visible-bug fixes (suit-coloured border
anti-aliasing artifact at rounded corners, pile-marker
bleed-through producing "gray L" shapes at occupied piles —
the latter implemented the previously-documented-but-not-enforced
"markers visible only at empty piles" invariant).
v0.21.2 is a patch release for the post-v0.21.1 polish work:
extends accessibility (full HC chrome rollout across 8 surfaces;
splash reduce-motion gating on scanline + cursor pulse), adds a
floating MOVE chip above the destination card during replay
playback, and lights up the first real consumer of
`ToastVariant::Error` (a "Invalid move" toast as the third leg
of the existing audio + visual rejection-feedback stool).
Full v0.21.1 detail lives in `CHANGELOG.md` § [0.21.1]. 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
resume.
## Status at pause
- **HEAD locally:** see `git rev-parse HEAD`. The cut commit is
`daa655a`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.1 is fully on origin.
`f23df3b`; post-cut work (`279e23d` Toast Warning, `c153363`
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.
- **`artwork/` directory:** still untracked. Intentional.
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings`
clean.
- **Tests:** **1192 passing / 0 failing** across the workspace
(net +8 from v0.21.0's 1184 baseline). Detail in
`CHANGELOG.md` § [0.21.1] § Stats.
- **Tags on origin:** `v0.9.0` through `v0.21.1`. v0.21.1 is on
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
`41a009a`.
- **Tests:** **1207 passing / 0 failing** across the workspace
(net +12 from the v0.21.2 cut: 8 from Toast Warning wiring;
4 from the radial-rim HC truth-table).
- **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`.
## Since the v0.21.1 cut
## Since the v0.21.2 cut
No threads in flight. Working tree clean as of 2026-05-08. New
work since the cut would land here as commit narratives; for
the v0.21.1 contents themselves, see `CHANGELOG.md` § [0.21.1].
- **`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.
For the v0.21.2 contents themselves, see `CHANGELOG.md` §
[0.21.2].
## Open punch list
@@ -77,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
a WIN MOVE marker on the scrub bar. Banner-local pieces all
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` +
`e080b49`); the screen-takeover is a multi-session redesign
with data-layer impact (move-log scroller; WIN MOVE needs a
`win_move_index` field on `Replay` that doesn't yet exist).
- **Floating `MOVE N/M` chip above the focused card during
playback.** Cross-plugin work — `update_progress_text` writes
the banner chip but the card-position lookup belongs in
`card_plugin`. Smaller scope than the screen-takeover.
- **Toast Warning / Error variants.** `ToastVariant` has slots
for `Warning` (gold) and `Error` (pink) but no in-engine
event uses them yet. Wire when a warning- or error-flavoured
toast event materialises.
`e080b49`); the floating MOVE chip above the focused card
shipped in v0.21.2 (`2fb2d63`). The screen-takeover is a
multi-session redesign with data-layer impact — needs a new
`win_move_index: Option<usize>` field on `Replay` (currently
unimplemented), a move-log scroller, and a mini-tableau
preview.
- *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same
`LayoutResource` pile coordinates so it survives window
resizes without UI/camera math.
- *Toast Warning variant wiring — closed 2026-05-08 by `279e23d`.*
Daily-challenge-expiry toast fires once per `daily.date` when
within 30 min of UTC midnight reset and today is incomplete.
`ToastVariant` is now fully load-bearing (every variant has at
least one real driver). Future Warning drivers can either reuse
the generic `WarningToastEvent(String)` carrier or add their
own domain message + `animation_plugin` handler.
- *Toast Error variant wiring — closed 2026-05-08 by `68d50b5`.*
`MoveRejectedEvent` now fires a 2-second pink-bordered
"Invalid move" toast as the third leg of the
audio + visual + text rejection-feedback stool.
- *High-contrast accessibility mode — closed 2026-05-08 by
`c5787c6` + `07e0357`.* Card text rendering picks up
`TEXT_PRIMARY_HC` (`#f5f5f5`) and `RED_SUIT_COLOUR_HC`
(`#ff8aa0`); Settings panel has a toggle. Future scope:
extend HC through chrome borders (`BORDER_SUBTLE_HC` already
defined, not yet consumed), buttons, popover edges.
- *Reduced-motion mode — closed 2026-05-08 by the same pair.*
`effective_slide_secs` forces 0 when on, regardless of the
`AnimSpeed` setting. Future scope: gate splash scanline
overlay + cursor pulse animation on the same flag, gate
warning-chip pulse, gate any future card-lift z-bump
animation.
`c5787c6` + `07e0357` (engine + UI) + v0.21.2's HC chrome
rollout (`c9af1ea` + `d87761d` + `ec804d5`) + post-cut
dynamic-paint rollout (`c153363`).* Card text rendering plus
8 static-border chrome surfaces (modal scaffold, tooltip,
onboarding key chips, help panel key chips, stats panel
cells, home Level/XP/Score row, home mode buttons, home
mode-hotkey chips, 4 settings panel surfaces) all boost
borders to `BORDER_SUBTLE_HC` under HC via the
`HighContrastBorder` marker. The previously-carved-out
dynamic-paint sites are now also covered: HUD action buttons
and modal buttons take the same marker (their paint cycles
only mutate `BackgroundColor`, so no race); the radial menu
rim folds HC into its per-frame spawn via
`radial_rim_outline` so the focused rim boosts to
`BORDER_SUBTLE_HC` under HC (preserving focused-vs-resting
hierarchy that naive marker substitution would invert).
- *Reduced-motion mode — closed 2026-05-08 by `c5787c6` +
v0.21.2's `ed152e2`.* `effective_slide_secs` forces 0 on
card animations; `pulse_splash_cursor` skips the per-frame
pulse multiplier; `spawn_splash` skips the scanline overlay
entirely. Future scope: gate any future card-lift z-bump
animation, warning-chip pulse (when one materialises).
### Carried forward from v0.19.0
@@ -203,19 +252,22 @@ into a v0.21.1 / v0.22.0 cut.
```
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine>.
Branch: master. v0.21.1 is tagged at daa655a (cut 2026-05-08, a
patch release rolling up app-icon, accessibility modes, and the
card-visual iteration cycle that closed Resume-prompt Options A
and F). v0.21.0 stays at 04f9bf9. Working tree clean. See
CHANGELOG.md § [0.21.1] for full detail of what shipped in the
patch release.
Branch: master. v0.21.2 is tagged at f23df3b (cut 2026-05-08, a
patch release rolling up accessibility extensions, replay polish,
and the first real `ToastVariant::Error` consumer). v0.21.1 stays
at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut
work shipped: Toast Warning variant (`279e23d`) and the HC
dynamic-paint rollout (`c153363`) — accessibility arc is fully
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
pass (1192+; check with `cargo test --workspace`), clippy clean.
pass (1207+; check with `cargo test --workspace`), clippy clean.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file
2. CHANGELOG.md — [0.21.1] 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
4. CLAUDE_SPEC.md — formal architecture spec
5. ARCHITECTURE.md — crate responsibilities + data flow
@@ -235,29 +287,16 @@ DECISION TO ASK THE PLAYER FIRST:
tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running.
(Was Resume-prompt B before the post-v0.21.1 menu trim.)
B. Replay-overlay extensions — either the floating `MOVE N/M`
chip above the focused card (smaller, cross-plugin; needs
cursor → card-position plumbing in `card_plugin`) or the
full screen-takeover redesign (multi-session: move-log
scroll, mini tableau preview, WIN MOVE marker, data-layer
impact for `Replay::win_move_index`).
C. 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.
D. Phase 8 (sync) — local storage scaffolding, self-hosted
B. Replay-overlay screen-takeover redesign — multi-session
work: move-log scroller, mini-tableau preview, WIN MOVE
marker on the scrub bar (needs new `Replay::win_move_index`
field), playback controls. The smaller floating-MOVE-chip
piece of B already shipped in v0.21.2 (`2fb2d63`).
C. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore,
ClipboardManager).
E. Extend high-contrast through chrome — `BORDER_SUBTLE_HC`
was defined in v0.21.1 but isn't yet consumed; popover
edges, button borders, focus rings still use the default
non-HC tokens. Plus reduce-motion still doesn't gate
splash scanline / cursor pulse / warning-chip pulse —
v0.21.1 only gated card slide_secs. Both are small,
finite, half-day scope.
WORKFLOW NOTES:
- Use the system git config (already correct).
@@ -281,5 +320,9 @@ WORKFLOW NOTES:
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 AE. 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.
```
+21 -1
View File
@@ -22,7 +22,8 @@ use crate::card_plugin::CardEntity;
use crate::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent,
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
@@ -164,6 +165,7 @@ impl Plugin for AnimationPlugin {
.add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>()
@@ -186,6 +188,7 @@ impl Plugin for AnimationPlugin {
handle_auto_complete_toast,
handle_xp_awarded_toast,
handle_move_rejected_toast,
handle_warning_toast,
tick_toasts,
(enqueue_toasts, drive_toast_display).chain(),
)
@@ -651,6 +654,23 @@ fn handle_move_rejected_toast(
}
}
/// Spawns a 4-second amber-bordered Warning toast for every incoming
/// [`WarningToastEvent`]. First in-engine consumer of
/// [`ToastVariant::Warning`] — exercises the variant's amber accent and
/// the design-system "act soon" semantic.
///
/// Mirrors [`handle_move_rejected_toast`] but reads a generic carrier
/// event (not a domain-specific one) because Warning has multiple
/// candidate drivers and the call-site knows the message wording.
fn handle_warning_toast(
mut commands: Commands,
mut events: MessageReader<WarningToastEvent>,
) {
for ev in events.read() {
spawn_toast(&mut commands, ev.0.clone(), 4.0, ToastVariant::Warning);
}
}
/// Ticks down `ToastTimer` on each toast and despawns it when the timer expires.
///
/// Skipped while the game is paused so toast countdowns freeze along with the
+223 -3
View File
@@ -14,13 +14,13 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use chrono::{Local, NaiveDate};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
use solitaire_sync::ChallengeGoal;
use crate::events::{
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
XpAwardedEvent,
WarningToastEvent, XpAwardedEvent,
};
use crate::game_plugin::GameMutation;
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
@@ -30,6 +30,11 @@ use crate::sync_plugin::SyncProviderResource;
/// Bonus XP awarded for completing today's daily challenge.
pub const DAILY_BONUS_XP: u64 = 100;
/// Minutes before UTC midnight at which the daily-challenge expiry warning
/// fires. The reset is global (UTC), so the warning is global too — local
/// midnight may be hours away or already past.
pub const DAILY_EXPIRY_WARNING_MINUTES: i64 = 30;
/// The active daily challenge — date + RNG seed for that date's deal,
/// plus optional goal metadata fetched from the server.
#[derive(Resource, Debug, Clone)]
@@ -74,6 +79,16 @@ pub struct DailyChallengeCompletedEvent {
#[derive(Resource, Default)]
struct DailyChallengeTask(Option<Task<Option<ChallengeGoal>>>);
/// Tracks which `DailyChallengeResource::date` the expiry-warning toast has
/// already fired for, so the toast spawns at most once per day.
///
/// `None` until the first warning fires; thereafter holds the date the
/// warning was shown for. When `daily.date` advances (a new local day rolls
/// over while the app stays open), this becomes stale and the next warning
/// can fire.
#[derive(Resource, Default, Debug)]
struct DailyExpiryWarningShown(Option<NaiveDate>);
/// Fetches today's daily challenge seed and goal from the sync server on startup and tracks completion.
/// Fires `DailyChallengeCompletedEvent` when the player wins a matching game.
pub struct DailyChallengePlugin;
@@ -82,18 +97,21 @@ impl Plugin for DailyChallengePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(DailyChallengeResource::for_today())
.init_resource::<DailyChallengeTask>()
.init_resource::<DailyExpiryWarningShown>()
.add_message::<DailyChallengeCompletedEvent>()
.add_message::<DailyGoalAnnouncementEvent>()
.add_message::<GameWonEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>()
.add_systems(Startup, fetch_server_challenge)
.add_systems(Update, poll_server_challenge)
// record/award after the base ProgressUpdate so we don't fight
// ProgressPlugin's add_xp on the same frame.
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
.add_systems(Update, handle_start_daily_request.before(GameMutation));
.add_systems(Update, handle_start_daily_request.before(GameMutation))
.add_systems(Update, check_daily_expiry_warning);
}
}
@@ -215,6 +233,71 @@ fn handle_start_daily_request(
announce.write(DailyGoalAnnouncementEvent(desc));
}
/// Pure decision logic for the daily-challenge expiry warning. Returns the
/// integer minutes-until-UTC-midnight if a warning toast should fire on this
/// frame, or `None` if any suppression condition holds.
///
/// Suppression rules (in order):
/// 1. Player has already completed today's daily challenge.
/// 2. The warning has already fired for `daily_date`.
/// 3. UTC midnight is more than [`DAILY_EXPIRY_WARNING_MINUTES`] away.
/// 4. UTC midnight has already passed for the current calendar day (the
/// minutes-remaining is negative — happens for at most one frame at the
/// rollover boundary).
///
/// Factored out so the threshold/clock behavior is unit-testable without an
/// `App`.
fn compute_expiry_warning_minutes(
daily_date: NaiveDate,
last_completed: Option<NaiveDate>,
last_shown: Option<NaiveDate>,
now_utc: DateTime<Utc>,
threshold_mins: i64,
) -> Option<i64> {
if last_completed == Some(daily_date) {
return None;
}
if last_shown == Some(daily_date) {
return None;
}
let next_midnight = (now_utc.date_naive() + Duration::days(1))
.and_hms_opt(0, 0, 0)?
.and_utc();
let mins_remaining = (next_midnight - now_utc).num_minutes();
if !(0..=threshold_mins).contains(&mins_remaining) {
return None;
}
Some(mins_remaining)
}
/// Each-frame check for the daily-challenge expiry warning. Fires a single
/// [`WarningToastEvent`] when the player is within
/// [`DAILY_EXPIRY_WARNING_MINUTES`] of UTC midnight reset and hasn't yet
/// completed today's challenge.
///
/// Idempotent — `DailyExpiryWarningShown` ensures the toast spawns at most
/// once per `daily.date`.
fn check_daily_expiry_warning(
daily: Res<DailyChallengeResource>,
progress: Res<ProgressResource>,
mut shown: ResMut<DailyExpiryWarningShown>,
mut warning: MessageWriter<WarningToastEvent>,
) {
let Some(mins) = compute_expiry_warning_minutes(
daily.date,
progress.0.daily_challenge_last_completed,
shown.0,
Utc::now(),
DAILY_EXPIRY_WARNING_MINUTES,
) else {
return;
};
shown.0 = Some(daily.date);
warning.write(WarningToastEvent(format!(
"Daily challenge expires in {mins} min"
)));
}
#[cfg(test)]
mod tests {
use super::*;
@@ -385,4 +468,141 @@ mod tests {
assert_eq!(r.target_score, Some(1_000));
assert_eq!(r.max_time_secs, Some(300));
}
// -----------------------------------------------------------------------
// Daily-expiry warning toast (compute_expiry_warning_minutes + system)
// -----------------------------------------------------------------------
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// Construct a UTC `DateTime` at the given calendar position. Used to
/// drive the pure helper through every threshold edge.
fn utc_at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<Utc> {
ymd(y, m, d).and_hms_opt(h, min, 0).unwrap().and_utc()
}
#[test]
fn warning_fires_inside_threshold_when_incomplete_and_unseen() {
// 23:50 UTC, 10 min until reset, < 30 min threshold.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_fires_at_exact_threshold_boundary() {
// 23:30 UTC, exactly 30 min remaining — the inclusive boundary.
let now = utc_at(2026, 5, 8, 23, 30);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, Some(30));
}
#[test]
fn warning_suppressed_outside_threshold() {
// 23:00 UTC, 60 min remaining — outside the 30 min window.
let now = utc_at(2026, 5, 8, 23, 0);
let mins = compute_expiry_warning_minutes(ymd(2026, 5, 8), None, None, now, 30);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_already_completed_today() {
// 23:50 UTC inside threshold, but today is already done.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 8)),
None,
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_suppressed_when_yesterdays_completion_is_stale() {
// Yesterday's completion is irrelevant — we want to warn about today.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
Some(ymd(2026, 5, 7)),
None,
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn warning_suppressed_when_already_shown_for_this_date() {
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 8)),
now,
30,
);
assert_eq!(mins, None);
}
#[test]
fn warning_fires_when_last_shown_was_yesterday() {
// Player kept the app open across a midnight rollover. Stale
// "shown" date doesn't suppress today's warning.
let now = utc_at(2026, 5, 8, 23, 50);
let mins = compute_expiry_warning_minutes(
ymd(2026, 5, 8),
None,
Some(ymd(2026, 5, 7)),
now,
30,
);
assert_eq!(mins, Some(10));
}
#[test]
fn check_system_fires_warning_event_only_once_per_day() {
// The pure helper is exhaustively tested above. This test verifies
// the system that consumes it correctly stores the "shown" date so
// the WarningToastEvent fires at most once per `daily.date`, even
// when the system runs many frames in a row inside the threshold.
//
// The system reads `Utc::now()` directly, so we can't pin the clock.
// Instead, we simulate the post-warning state by pre-populating
// `DailyExpiryWarningShown` with `daily.date` and asserting nothing
// fires; then we verify the symmetric "completed today" suppression.
let mut app = headless_app();
let today = app.world().resource::<DailyChallengeResource>().date;
// Pre-mark warning as already shown for today.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when DailyExpiryWarningShown already covers today"
);
// Reset shown, mark today as completed.
app.world_mut()
.resource_mut::<DailyExpiryWarningShown>()
.0 = None;
app.world_mut()
.resource_mut::<ProgressResource>()
.0
.daily_challenge_last_completed = Some(today);
app.update();
let events = app.world().resource::<Messages<WarningToastEvent>>();
let mut cursor = events.get_cursor();
assert!(
cursor.read(events).next().is_none(),
"no warning fires when today is already completed"
);
}
}
+15
View File
@@ -212,6 +212,21 @@ pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
#[derive(Message, Debug, Clone)]
pub struct InfoToastEvent(pub String);
/// Generic warning toast message. Spawns a fire-and-forget
/// [`ToastVariant::Warning`](crate::animation_plugin::ToastVariant) toast.
///
/// Distinct from [`InfoToastEvent`] in two ways:
/// 1. **Variant.** Warning carries the design-system warning border accent,
/// not the neutral info accent — so the player can distinguish "you might
/// want to act" from "here's some neutral information".
/// 2. **No queue.** Warnings are alerts, not a stream. Each event spawns its
/// own toast immediately rather than waiting for the info queue to drain.
///
/// First in-engine driver: daily-challenge expiry warning fired by
/// `daily_challenge_plugin` when < 30 min from UTC midnight reset.
#[derive(Message, Debug, Clone)]
pub struct WarningToastEvent(pub String);
/// Fired by `ProgressPlugin` immediately after awarding XP for a win so the
/// animation layer can display a "+N XP" toast alongside the win cascade.
#[derive(Message, Debug, Clone, Copy)]
+2 -1
View File
@@ -19,7 +19,7 @@ use crate::settings_plugin::SettingsResource;
use crate::layout::HUD_BAND_HEIGHT;
use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, MOTION_SCORE_PULSE_SECS,
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
@@ -715,6 +715,7 @@ fn spawn_action_button<M: Component>(
},
BackgroundColor(ACTION_BTN_IDLE),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
+63 -4
View File
@@ -56,7 +56,8 @@ use crate::events::MoveRequestEvent;
use crate::layout::{Layout, LayoutResource};
use crate::pause_plugin::PausedResource;
use crate::resources::{DragState, GameStateResource};
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, STATE_SUCCESS};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
///
@@ -533,8 +534,17 @@ fn radial_handle_release_or_cancel(
/// Despawns and respawns the radial overlay sprites every frame the
/// state is `Active`; despawns them when the state returns to `Idle`.
///
/// Reads [`SettingsResource`] so the focused-icon outline can boost to
/// [`BORDER_SUBTLE_HC`] under high-contrast mode. Per-frame respawn is
/// the simplest place to fold HC in: this is the only system that
/// owns the rim sprite, so there's no parallel paint path to fight.
/// ([`HighContrastBorder`](crate::ui_theme::HighContrastBorder) doesn't
/// apply because the rim is a `Sprite`, not a UI node with
/// `BorderColor`, and the entities don't persist across frames.)
fn radial_redraw_overlay(
state: Res<RightClickRadialState>,
settings: Option<Res<SettingsResource>>,
mut commands: Commands,
existing_icons: Query<Entity, With<RadialIcon>>,
existing_centres: Query<Entity, With<RadialCentre>>,
@@ -569,13 +579,12 @@ fn radial_redraw_overlay(
Transform::from_xyz(centre.x, centre.y, Z_RADIAL_MENU + 0.01),
));
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
for (i, (_pile, anchor)) in legal_destinations.iter().enumerate() {
let focused = *hovered_index == Some(i);
let scale = if focused { RADIAL_HOVER_SCALE } else { 1.0 };
let fill = if focused { STATE_SUCCESS } else { ACCENT_PRIMARY };
// Hovered icon gets a strong yellow rim; resting icons get a
// muted purple rim so the focused one reads as the obvious target.
let outline = if focused { BORDER_STRONG } else { BORDER_SUBTLE };
let outline = radial_rim_outline(focused, high_contrast);
commands
.spawn((
@@ -606,6 +615,27 @@ fn radial_redraw_overlay(
}
}
/// Pure decision logic for the radial-icon rim outline colour.
///
/// Resting icons always carry [`BORDER_SUBTLE`] so the focused icon
/// reads as the obvious target. Under high-contrast mode the focused
/// rim boosts to [`BORDER_SUBTLE_HC`] (`#a0a0a0`) instead of
/// [`BORDER_STRONG`] (`#505050`) — naive marker substitution via
/// [`HighContrastBorder`](crate::ui_theme::HighContrastBorder) would
/// invert the hierarchy because the resting colour
/// (`#353535`) is darker than `BORDER_STRONG`. This shape keeps the
/// focused rim *more* visible under HC, not less.
///
/// Factored out as a pure function so the truth-table is unit-testable
/// without spinning up the per-frame respawn system.
fn radial_rim_outline(focused: bool, high_contrast: bool) -> Color {
match (focused, high_contrast) {
(true, true) => BORDER_SUBTLE_HC,
(true, false) => BORDER_STRONG,
(false, _) => BORDER_SUBTLE,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -940,4 +970,33 @@ mod tests {
"face-down cards must not open the radial"
);
}
// -----------------------------------------------------------------------
// radial_rim_outline — accessibility / high-contrast truth table
// -----------------------------------------------------------------------
#[test]
fn rim_resting_uses_subtle_outline_without_hc() {
assert_eq!(radial_rim_outline(false, false), BORDER_SUBTLE);
}
#[test]
fn rim_focused_uses_strong_outline_without_hc() {
assert_eq!(radial_rim_outline(true, false), BORDER_STRONG);
}
#[test]
fn rim_focused_boosts_to_subtle_hc_under_hc() {
assert_eq!(radial_rim_outline(true, true), BORDER_SUBTLE_HC);
}
#[test]
fn rim_resting_stays_subtle_under_hc_to_preserve_hierarchy() {
// Naive marker substitution would also flip the resting outline
// to BORDER_SUBTLE_HC, which is *lighter* than BORDER_STRONG —
// that would invert the focused/resting hierarchy. Holding the
// resting colour at BORDER_SUBTLE keeps the focused icon the
// obvious target under HC.
assert_eq!(radial_rim_outline(false, true), BORDER_SUBTLE);
}
}
+1
View File
@@ -372,6 +372,7 @@ pub fn spawn_modal_button<M: Component>(
},
BackgroundColor(idle_bg(variant)),
BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|b| {
b.spawn((Text::new(label.into()), font_label, TextColor(label_color)));