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] ## [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. 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 ## [0.21.2] — 2026-05-08
Patch release for the post-v0.21.1 polish work. Three through- Patch release for the post-v0.21.1 polish work. Three through-
+120 -77
View File
@@ -1,46 +1,73 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.1 cut and tagged at `daa655a`**, **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.1 is a patch release for the post-v0.21.0 work: closes v0.21.2 is a patch release for the post-v0.21.1 polish work:
Resume-prompt Options A (app icon — runtime `Window::icon` plus extends accessibility (full HC chrome rollout across 8 surfaces;
the 9-size PNG hierarchy) and F (high-contrast + reduce-motion splash reduce-motion gating on scanline + cursor pulse), adds a
accessibility modes — Settings flags wired through engine and floating MOVE chip above the destination card during replay
UI). Plus a card-visual iteration cycle that moved through three playback, and lights up the first real consumer of
states (v0.21.0 Terminal pink/gray → brief 4-colour-deck `ToastVariant::Error` (a "Invalid move" toast as the third leg
experiment → traditional 2-colour Microsoft-Solitaire-on-dark-mode of the existing audio + visual rejection-feedback stool).
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).
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 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
`daa655a`; 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.1 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:** **1192 passing / 0 failing** across the workspace - **Tests:** **1207 passing / 0 failing** across the workspace
(net +8 from v0.21.0's 1184 baseline). Detail in (net +12 from the v0.21.2 cut: 8 from Toast Warning wiring;
`CHANGELOG.md` § [0.21.1] § Stats. 4 from the radial-rim HC truth-table).
- **Tags on origin:** `v0.9.0` through `v0.21.1`. v0.21.1 is on - **Tags on origin:** `v0.9.0` through `v0.21.2`. v0.21.2 is on
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on `f23df3b`; v0.21.1 stays on `daa655a`; v0.21.0 stays on
`41a009a`. `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 - **`279e23d` — Toast Warning variant wired.** First in-engine
work since the cut would land here as commit narratives; for consumer of `ToastVariant::Warning`: a 4 s amber-bordered
the v0.21.1 contents themselves, see `CHANGELOG.md` § [0.21.1]. 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 ## 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 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
@@ -203,19 +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.1 is tagged at daa655a (cut 2026-05-08, a Branch: master. v0.21.2 is tagged at f23df3b (cut 2026-05-08, a
patch release rolling up app-icon, accessibility modes, and the patch release rolling up accessibility extensions, replay polish,
card-visual iteration cycle that closed Resume-prompt Options A and the first real `ToastVariant::Error` consumer). v0.21.1 stays
and F). v0.21.0 stays at 04f9bf9. Working tree clean. See at daa655a, v0.21.0 at 04f9bf9. Working tree clean. Post-cut
CHANGELOG.md § [0.21.1] for full detail of what shipped in the work shipped: Toast Warning variant (`279e23d`) and the HC
patch release. 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 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): READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — this file 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 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
@@ -235,29 +287,16 @@ DECISION TO ASK THE PLAYER FIRST:
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.
(Was Resume-prompt B before the post-v0.21.1 menu trim.) B. Replay-overlay screen-takeover redesign — multi-session
B. Replay-overlay extensions — either the floating `MOVE N/M` work: move-log scroller, mini-tableau preview, WIN MOVE
chip above the focused card (smaller, cross-plugin; needs marker on the scrub bar (needs new `Replay::win_move_index`
cursor → card-position plumbing in `card_plugin`) or the field), playback controls. The smaller floating-MOVE-chip
full screen-takeover redesign (multi-session: move-log piece of B already shipped in v0.21.2 (`2fb2d63`).
scroll, mini tableau preview, WIN MOVE marker, data-layer C. Phase 8 (sync) — local storage scaffolding, self-hosted
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
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).
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: WORKFLOW NOTES:
- Use the system git config (already correct). - Use the system git config (already correct).
@@ -281,5 +320,9 @@ WORKFLOW NOTES:
a "this does X" doc comment, verify the code actually does a "this does X" doc comment, verify the code actually does
X and add a test if not. Two layers, two checks. 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::challenge_plugin::ChallengeAdvancedEvent;
use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent}; use crate::daily_challenge_plugin::{DailyChallengeCompletedEvent, DailyGoalAnnouncementEvent};
use crate::events::{ use crate::events::{
AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, XpAwardedEvent, AchievementUnlockedEvent, GameWonEvent, InfoToastEvent, MoveRejectedEvent, WarningToastEvent,
XpAwardedEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -164,6 +165,7 @@ impl Plugin for AnimationPlugin {
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<WarningToastEvent>()
.add_message::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
@@ -186,6 +188,7 @@ impl Plugin for AnimationPlugin {
handle_auto_complete_toast, handle_auto_complete_toast,
handle_xp_awarded_toast, handle_xp_awarded_toast,
handle_move_rejected_toast, handle_move_rejected_toast,
handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (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. /// 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
+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)]
+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)));
+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);
}
} }
+1
View File
@@ -372,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)));