Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d92a91e3b | |||
| 9113cdb483 | |||
| c153363626 | |||
| 93b67f1d0b | |||
| 279e23d0af | |||
| 12fba2157a | |||
| f23df3b805 | |||
| 68d50b5021 | |||
| ec804d54c6 | |||
| d87761d451 | |||
| 2fb2d638bf | |||
| c9af1ead22 | |||
| ed152e2d8f | |||
| 279a834f9d |
+226
-1
@@ -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
@@ -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 A–F. Don't pick unilaterally.
|
OPEN AT THE START: ask which of A–C. 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,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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user