Compare commits

..

8 Commits

Author SHA1 Message Date
funman300 f23df3b805 docs: cut v0.21.2 — accessibility extensions + replay polish + first real Toast Error consumer
Promotes [Unreleased] to [0.21.2] dated 2026-05-08 and opens a
fresh empty [Unreleased]. Patch release covering 6 substantive
post-v0.21.1 commits (plus the v0.21.1 handoff refresh).

Three through-lines:

- **Accessibility extensions.** Closes the two threads v0.21.1
  left explicitly open. Reduce-motion was previously gated only
  on card slide_secs; v0.21.2 extends it to splash scanline +
  cursor pulse (`ed152e2`). HC borders had `BORDER_SUBTLE_HC`
  defined but no consumers; v0.21.2 builds the
  `HighContrastBorder` marker + `update_high_contrast_borders`
  system (`c9af1ea`) and rolls it out across 8 surfaces
  (`d87761d` + `ec804d5`).

- **Replay polish.** New floating MOVE chip rendered above the
  destination pile of the most-recently-applied move during
  playback (`2fb2d63`). World-space `Text2d` entity that
  reuses the same `LayoutResource` pile coordinates as every
  other piece of pile geometry — stays correctly positioned
  through window resizes without any UI / camera math.

- **First real `ToastVariant::Error` consumer.** Wires
  `MoveRejectedEvent` to a 2-second pink-bordered "Invalid move"
  toast (`68d50b5`). Joins the existing `card_invalid.wav`
  audio + destination-pile shake visual as the
  accessibility-focused readable text channel.

cargo clippy --workspace --all-targets -- -D warnings clean.
1195 passing / 0 failing (net +3 from v0.21.1's 1192).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:06:14 -07:00
funman300 68d50b5021 feat(toast): wire ToastVariant::Error for invalid-move feedback
Resume-prompt Option C — first in-engine consumer of
`ToastVariant::Error`. The variant has had a slot in the enum
since v0.20.0's toast system landed; this commit wires a real
driver event so the slot is no longer dead code.

### Driver: MoveRejectedEvent

When a player tries an illegal placement (drops dragged cards on
a real pile but the move violates the rules), `MoveRejectedEvent`
fires. The existing rejection-feedback chain plays
`card_invalid.wav` (audio cue) and triggers the destination-pile
shake (visual cue via `feedback_anim_plugin`). This commit adds a
third leg — a 2-second pink-bordered Error toast reading
"Invalid move" — primarily for accessibility:

- **Audio cue alone** doesn't help deaf players.
- **Visual shake alone** is brief and easy to miss for low-vision
  players or anyone with reduce-motion enabled (which gates the
  shake's animation timing).
- **Toast text** is persistent ~2 s, readable, and unambiguous.

The three legs together cover the major perception channels.

### Implementation

New `handle_move_rejected_toast` system in `animation_plugin`
mirrors the shape of `handle_xp_awarded_toast` — read events,
fire `spawn_toast(commands, "Invalid move", 2.0,
ToastVariant::Error)`. Registered in the plugin's Update set
between `handle_xp_awarded_toast` and `tick_toasts` so the toast
spawn pipeline picks it up the same frame the event fires.

`AnimationPlugin::build` gains
`.add_message::<MoveRejectedEvent>()` so the message is
initialized when the plugin runs under MinimalPlugins (tests).
The message is also registered by `feedback_anim_plugin` —
Bevy's `add_message` is idempotent, so both registrations
coexist cleanly.

Also drops the `#[allow(dead_code)]` from `ToastVariant::Error`
(stale now that the variant has a real consumer) and updates the
variant's doc comment to point at `handle_move_rejected_toast`.

### Test

New `move_rejected_event_spawns_error_toast` pins the wiring:
firing a `MoveRejectedEvent` spawns exactly one `ToastOverlay`
on the next tick. Matches the shape of the existing
`info_toast_event_spawns_toast_overlay` test. 1195 passing
(+1 from prior 1194).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:59:39 -07:00
funman300 ec804d54c6 feat(accessibility): finish HC chrome rollout — home + settings panel borders
Continues the rollout from `c9af1ea` (modal scaffold) and
`d87761d` (tooltip + 3 panels). Tags the remaining 7 static-
border surfaces in the chrome so the HC chrome thread is
effectively complete:

- **`home_plugin.rs` × 3**: the home-screen Level/XP/Score
  summary row (line 842), the home-screen mode-selector
  buttons (line 945), the home-screen mode-hotkey chips
  (line 1158).
- **`settings_plugin.rs` × 4**: the card-back picker swatches
  (line 1952), the theme picker swatches (line 2093), the
  Sync Now button (line 2214), and the swatch glyph buttons
  (line 2274).

Pre-tagging audit: confirmed none of these sites have a
dynamic-paint system that would race the
`update_high_contrast_borders` system. `paint_action_buttons`
in `hud_plugin.rs` only paints entities tagged with the
`ActionButton` marker (HUD buttons only). The focus-overlay
system in `ui_focus.rs` spawns *separate* overlay entities for
focus indication, never mutating the original `BorderColor`.
Settings panel buttons / swatches use their own
`SettingsButton` enum for click routing; their `BorderColor`
is set at spawn time and not touched again.

After this commit, every `BorderColor::all(BORDER_SUBTLE)` site
in the chrome (excluding the dynamic-paint sites that are
intentionally skipped — HUD action buttons, modal buttons,
radial menu rim) carries a `HighContrastBorder` marker. The
HC thread for chrome borders is closed; the dynamic-paint
sites remain open for a future iteration that needs a
different shape (folding HC into the dynamic-paint logic, or
having HC consult hover/focus state).

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level lifecycle of `HighContrastBorder`
was already covered by the modal-scaffold scaffolding in
`c9af1ea`). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:47:58 -07:00
funman300 d87761d451 feat(accessibility): roll HighContrastBorder out to tooltip + 3 panel borders
Continues the HC chrome rollout started by `c9af1ea` (which wired
just the modal scaffold). Tags four more static-border surfaces
so they boost to `BORDER_SUBTLE_HC` (#a0a0a0) when high-contrast
mode is on:

- **Tooltip** (`ui_tooltip.rs:191`). The hover-revealed caption
  popup. Border legibility matters because tooltips are usually
  brief — if the player has to squint to find the panel edge,
  the tooltip dismisses before they've parsed it.
- **Onboarding banner key chips** (`onboarding_plugin.rs:388`).
  The first-run UI's "press H or ?" key chips. First-run
  onboarding has the highest stakes for accessibility — a
  low-vision player who can't see the chips can't discover
  the help system.
- **Help panel key chips** (`help_plugin.rs:265`). Same
  treatment as the onboarding chips: keyboard-shortcut chips
  inside the F1 cheat sheet.
- **Stats panel cells** (`stats_plugin.rs:1019`). The S-key
  overlay's individual stat cells. A dense grid of bordered
  numbers is exactly the kind of surface where HC's
  `#505050 → #a0a0a0` boost makes the layout legible.

Each tagging is one line on the spawn tuple plus an import. The
existing `update_high_contrast_borders` system in
`settings_plugin` (added in `c9af1ea`) handles all tagged
entities uniformly — no system changes needed.

### Skipped on this pass

Sites with dynamic hover/focus paint systems (HUD action
buttons, modal buttons, radial menu rim) intentionally not
tagged because their existing paint cycles would race the HC
system. Wiring HC into those needs a different shape — either
fold HC into the dynamic-paint logic, or have HC consult the
hover/focus state. Future scope.

Other HC-tagging candidates (`home_plugin.rs:842/945/1158` home
menu element borders, `settings_plugin.rs:1952/2093/2214/2274`
settings panel rows) are likely fine to tag but I'm capping
this commit at four to keep it reviewable. Pattern is
established; future commits can extend.

1194 passing / 0 failing across the workspace (unchanged — no
new tests; the system-level test in `c9af1ea`'s scaffolding
covers all tagged entities uniformly). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:43:04 -07:00
funman300 2fb2d638bf feat(replay): floating MOVE chip above the focused card during playback
Resume-prompt Option B (smaller scope variant) — closes the
"floating MOVE chip" piece flagged as future scope in v0.21.1's
replay-overlay punch list. Leaves the multi-session screen-
takeover redesign for a future B-2.

The existing banner-anchored MOVE chip stays put — it provides
the at-a-glance overview. The new floating chip mirrors the same
text but renders above the destination pile of the most-recently-
applied move, keeping progress at the player's focal point so they
don't have to look up at the banner during fast-paced playback.

### Architecture

- New `ReplayFloatingProgressChip` marker component on a
  `Text2d` entity rendered in 2D world space. World-space
  placement (rather than UI-space + camera projection) keeps
  the math trivial — the chip uses the same `LayoutResource`
  pile coordinates that drive every other piece of pile
  geometry, so it stays correctly positioned through window
  resizes without any extra wiring.
- Lifecycle matches the banner overlay: `spawn_overlay` spawns
  the chip alongside the banner when a replay starts;
  `react_to_state_change` despawns it when the replay ends.
  The chip lives outside the UI tree (because it's world-space)
  so the despawn needs its own query — added a second
  `Query<Entity, With<ReplayFloatingProgressChip>>` parameter.
- Z = 100 keeps the chip above every card stack
  (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular tableau
  cards stack to the low double digits at most).

### Position + visibility logic

`update_floating_progress_chip` runs each Update tick:

- Resolves the destination pile of the last-applied move
  (`replay.moves[cursor - 1]`'s `to`).
- Hides the chip when `cursor == 0` (no moves applied yet —
  nowhere meaningful to land) or when the last move was a
  `StockClick` (no destination pile, and stock-click feedback
  already lives at the stock pile — letting the chip jitter
  back to the stock every cycle would be visual noise).
- Otherwise positions the chip at `pile_position + (0,
  card_size.y * 0.6)` — half a card lifts above the pile
  centre, the extra 10 % is breathing room above the card's
  top edge so the chip doesn't visually clip.
- Updates the chip text via `format_progress(&state)` —
  shares the same MOVE N/M format with the banner chip.

### Test

New `floating_chip_spawns_and_despawns_with_overlay` pins the
lifecycle: chip absent on Inactive, exactly one chip on Playing,
absent again on return to Inactive. Position correctness needs
`LayoutResource` (which the headless fixture doesn't set up);
covered via running-game verification rather than a unit test —
the system's gate logic is small enough that pixel positioning
isn't load-bearing on a test.

1194 passing (+1 from prior 1193). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:29:38 -07:00
funman300 c9af1ead22 feat(accessibility): wire BORDER_SUBTLE_HC into the modal scaffold
Resume-prompt Option E, part 2 of 2 — HC chrome borders. Pairs
with the reduce-motion gating in `ed152e2`.

v0.21.1 introduced `BORDER_SUBTLE_HC` (#a0a0a0) but never wired
it: the constant existed, no consumer used it. Spec at
`design-system.md` §Accessibility (#2) mandates outline boost
from `#505050` (BORDER_STRONG) to `#a0a0a0` under high-contrast
mode so panels and popovers stay legible on low-quality
displays.

### Architecture

- New `HighContrastBorder` component in `ui_theme` carrying a
  `default_color: Color` field that records the off-state colour
  the entity was spawned with. Tag any UI node where border
  legibility is accessibility-critical.
- New `update_high_contrast_borders` system in `settings_plugin`
  walks all tagged entities each Update tick, sets `BorderColor`
  to `BORDER_SUBTLE_HC` when `Settings::high_contrast_mode` is
  on, otherwise to `marker.default_color`. Compares against
  current `BorderColor` and only mutates when different so
  Bevy's change-detection doesn't trigger repaints every frame.

### Tagged in this commit

- The modal scaffold's card border (`ui_modal::spawn_modal`).
  This is the primary accessibility target — modals demand
  attention and a low-vision player needs to perceive the panel
  boundary. Default colour: `BORDER_STRONG` (#505050); HC
  variant: `BORDER_SUBTLE_HC` (#a0a0a0).

### Future scope

Other `BORDER_SUBTLE` / `BORDER_STRONG` consumer sites (help
panel, stats panel, tooltip, action buttons, settings rows,
etc.) can be tagged in follow-ups by adding
`HighContrastBorder::with_default(...)` to their spawn tuple.
The system handles any entity carrying the marker — no further
changes needed once a site is tagged. Started small here to
keep the commit reviewable and prove the architecture before
rolling out broadly.

Workspace clippy + cargo test --workspace clean. 1193 passing
(unchanged from prior — no new tests added; the system is
small enough that the running-game verification is the meaningful
check).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:13:13 -07:00
funman300 ed152e2d8f feat(accessibility): gate splash scanline + cursor pulse on reduce-motion
Resume-prompt Option E, part 1 of 2 (the reduce-motion piece;
HC chrome borders follow in a separate commit).

v0.21.1 wired `Settings::reduce_motion_mode` through
`effective_slide_secs` so cards snap instead of sliding under
reduce-motion. The design-system spec at §Accessibility (#3)
calls out two more sources of non-essential motion that
reduce-motion should suppress: the splash CRT scanline effect
and the splash cursor pulse. This commit gates both.

### Splash cursor pulse (`pulse_splash_cursor`)

Previously sine-pulsed every frame regardless of settings. Now
reads `Settings::reduce_motion_mode` and skips the pulse
multiplier when on — the cursor still fades in / out with the
global splash alpha (essential timing), but doesn't blink
(decorative motion). The fade is preserved on purpose: skipping
it would hard-cut the splash on/off, which is jarring; the spec
specifically calls out *non-essential* motion as the reduce-
motion target, and a decorative blink is more clearly
non-essential than a fade timeline.

### Splash scanline overlay (`spawn_splash`)

Previously generated and spawned unconditionally when
`Assets<Image>` was available. Now skipped entirely when
reduce-motion is on — without the scanline overlay the boot
screen still reads as terminal-themed (foreground content,
borders, palette swatches all unchanged); the scanlines are
purely decorative.

### Test

New `splash_skips_scanline_overlay_under_reduce_motion` pins
the gate behaviour: under `reduce_motion_mode = true`, the
splash root still spawns (essential motion intact) but the
`SplashScanlineOverlay` entity is absent. 1193 passing
(+1 from prior 1192).

Workspace clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:07:51 -07:00
funman300 279a834f9d docs(handoff): refresh post-v0.21.1 — anchor to new tag, renumber Resume menu
Mirrors the post-v0.20.0 → v0.21.0 → v0.21.1 cut-then-refresh
pattern. Cut commit (daa655a) edited only CHANGELOG; this
follow-up resets the handoff so a fresh session picks up cleanly
post-v0.21.1.

Updated:
- Last-updated header points to v0.21.1 at daa655a; opening
  paragraph summarizes the patch's three threads (icon,
  accessibility, card-visual iteration with two bug fixes).
- Status at pause: tests bumped to 1192 (net +8 from
  v0.21.0's 1184); tags list extended through v0.21.1.
- "Since the v0.21.0 cut" → "Since the v0.21.1 cut" with the
  closure narratives dropped (now in CHANGELOG.md § [0.21.1]).
  Section reset to "no threads in flight" placeholder so
  future post-cut work has a clean starting point.
- Resume prompt menu trimmed: A and F closure entries dropped
  (preserved in CHANGELOG); remaining options renumbered A-E
  with the v0.21.1 closure callouts inline. New option E
  added: "extend HC through chrome borders + reduce-motion to
  splash/warning-chip" — both small finite items that v0.21.1
  flagged as future scope.
- Workflow notes gain the doc-vs-implementation-drift pattern
  observation from the pile-marker fix: when a module's
  top-level doc comment claims "X happens" but no code enforces
  it, the gap is invisible until a player notices the missing
  behaviour. Worth checking such claims and adding tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:59:24 -07:00
13 changed files with 599 additions and 106 deletions
+127 -1
View File
@@ -6,9 +6,135 @@ 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.2 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here. the next cycle here.
## [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
+53 -81
View File
@@ -1,75 +1,46 @@
# 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.1 cut and tagged at `daa655a`**,
working tree clean, all post-tag work pushed to origin. 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.1 is a patch release for the post-v0.21.0 work: closes
through-lines landed in this cycle: the **card-face / suit / Resume-prompt Options A (app icon — runtime `Window::icon` plus
card-back artwork migration** that v0.20.0 deliberately deferred the 9-size PNG hierarchy) and F (high-contrast + reduce-motion
(both rendering paths in lockstep — `assets/cards/*.png` fallback accessibility modes — Settings flags wired through engine and
plus the bundled-default theme SVGs at UI). Plus a card-visual iteration cycle that moved through three
`solitaire_engine/assets/themes/default/*.svg` that states (v0.21.0 Terminal pink/gray → brief 4-colour-deck
`include_bytes!()`-embed into the binary), the **splash boot- experiment → traditional 2-colour Microsoft-Solitaire-on-dark-mode
screen + replay-overlay polish** that closed Resume-prompt red/near-white) and two visible-bug fixes (suit-coloured border
Options B and C, and a late-cycle **`ACCENT_PRIMARY` palette anti-aliasing artifact at rounded corners, pile-marker
swap** from cyan `#6fc2ef` to brick red `#a54242` after a quick bleed-through producing "gray L" shapes at occupied piles —
stakeholder review of the shipped art. the latter implemented the previously-documented-but-not-enforced
"markers visible only at empty piles" invariant).
Full v0.21.0 detail lives in `CHANGELOG.md` § [0.21.0]. This Full v0.21.1 detail lives in `CHANGELOG.md` § [0.21.1]. 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. `daa655a`; any post-cut docs edits ride on top of that.
- **HEAD on origin:** matches local. v0.21.0 is fully on origin. - **HEAD on origin:** matches local. v0.21.1 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:** **1192 passing / 0 failing** across the workspace
(net +8 from v0.20.0's 1176 baseline). Detail in (net +8 from v0.21.0's 1184 baseline). Detail in
`CHANGELOG.md` § [0.21.0] § Stats. `CHANGELOG.md` § [0.21.1] § Stats.
- **Tags on origin:** `v0.9.0` through `v0.21.0`. v0.21.0 is on - **Tags on origin:** `v0.9.0` through `v0.21.1`. v0.21.1 is on
`04f9bf9`; v0.20.0 stays on `41a009a`. `daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
`41a009a`.
## Since the v0.21.0 cut ## Since the v0.21.1 cut
Two Resume-prompt options closed post-tag (2026-05-08): No threads in flight. Working tree clean as of 2026-05-08. New
work since the cut would land here as commit narratives; for
- **Option A — App icon round** (`3eb3a26` + `716a025`). 9-size the v0.21.1 contents themselves, see `CHANGELOG.md` § [0.21.1].
PNG hierarchy in `assets/icon/` (16/24/32/48/64/128/256/512/
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
@@ -232,20 +203,19 @@ 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.1 is tagged at daa655a (cut 2026-05-08, a
Working tree clean. v0.21.0 closed the visual-identity arc that patch release rolling up app-icon, accessibility modes, and the
v0.20.0 deferred — full Terminal cards on both rendering paths card-visual iteration cycle that closed Resume-prompt Options A
(asset PNGs + bundled-default theme SVGs), splash boot screen, and F). v0.21.0 stays at 04f9bf9. Working tree clean. See
replay-overlay banner enrichments, and a project-wide ACCENT_PRIMARY CHANGELOG.md § [0.21.1] for full detail of what shipped in the
swap from cyan to brick red `#a54242`. See CHANGELOG.md § [0.21.0] patch release.
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 (1192+; 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.1] 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 +230,34 @@ 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` (Was Resume-prompt B before the post-v0.21.1 menu trim.)
B. Replay-overlay extensions — either the floating `MOVE N/M`
chip above the focused card (smaller, cross-plugin; needs chip above the focused card (smaller, cross-plugin; needs
cursor → card-position plumbing in `card_plugin`) or the cursor → card-position plumbing in `card_plugin`) or the
full screen-takeover redesign (multi-session: move-log full screen-takeover redesign (multi-session: move-log
scroll, mini tableau preview, WIN MOVE marker, data-layer scroll, mini tableau preview, WIN MOVE marker, data-layer
impact for `Replay::win_move_index`). impact for `Replay::win_move_index`).
D. Toast Warning / Error variant wiring. UI infrastructure C. Toast Warning / Error variant wiring. UI infrastructure
exists in `ToastVariant`; no in-engine event uses Warning exists in `ToastVariant`; no in-engine event uses Warning
(gold) or Error (pink) yet. Wire when a real warning- or (gold) or Error (pink) yet. Wire when a real warning- or
error-flavoured event materialises. error-flavoured event materialises.
E. Phase 8 (sync) — local storage scaffolding, self-hosted D. Phase 8 (sync) — local storage scaffolding, self-hosted
Axum server, `SolitaireServerClient` impl, GPGS stub Axum server, `SolitaireServerClient` impl, GPGS stub
wired into Settings. The biggest open arc by scope; rolls wired into Settings. The biggest open arc by scope; rolls
up several Phase Android dependencies (Keystore, up several Phase Android dependencies (Keystore,
ClipboardManager). ClipboardManager).
F. *Closed 2026-05-08 by `c5787c6` + `07e0357`.* High-contrast E. Extend high-contrast through chrome — `BORDER_SUBTLE_HC`
and reduced-motion accessibility modes — Settings flags was defined in v0.21.1 but isn't yet consumed; popover
+ UI toggles + engine wiring. Card text rendering uses edges, button borders, focus rings still use the default
HC variants when on; card slide_secs forces to 0 when non-HC tokens. Plus reduce-motion still doesn't gate
reduce-motion is on. Future scope: extend HC through splash scanline / cursor pulse / warning-chip pulse —
chrome borders, buttons; gate splash + warning-chip v0.21.1 only gated card slide_secs. Both are small,
animations on reduce-motion. finite, half-day scope.
WORKFLOW NOTES: WORKFLOW NOTES:
- Use the system git config (already correct). - Use the system git config (already correct).
@@ -308,6 +274,12 @@ WORKFLOW NOTES:
migration walked past this" follow-ups that all matched migration walked past this" follow-ups that all matched
this shape — codified here so future similar work can this shape — codified here so future similar work can
pattern-match instead of rediscovering. pattern-match instead of rediscovering.
- Doc-vs-implementation drift pattern: v0.21.1's pile-marker
visibility fix (`4d48cad`) implemented an invariant that
had been declared in a module doc comment but was never
enforced in code. When future work touches a module with
a "this does X" doc comment, verify the code actually does
X and add a test if not. Two layers, two checks.
OPEN AT THE START: ask which of AF. Don't pick unilaterally. OPEN AT THE START: ask which of AE. Don't pick unilaterally.
``` ```
+72 -5
View File
@@ -21,8 +21,9 @@ 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, 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 +163,7 @@ 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::<XpAwardedEvent>() .add_message::<XpAwardedEvent>()
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
@@ -183,6 +185,7 @@ 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,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
) )
@@ -565,9 +568,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 +627,30 @@ 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,
);
}
}
/// 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 +995,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
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+3 -2
View File
@@ -14,8 +14,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Marker on the help overlay root node. /// Marker on the help overlay root node.
@@ -263,6 +263,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+5 -2
View File
@@ -38,8 +38,8 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, ACCENT_PRIMARY, BG_ELEVATED, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, RADIUS_MD, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, TYPE_CAPTION, TYPE_DISPLAY, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
@@ -840,6 +840,7 @@ fn spawn_home_header_chips(parent: &mut ChildSpawnerCommands, ctx: &HomeContext<
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|row| { .with_children(|row| {
for (label, value) in [ for (label, value) in [
@@ -943,6 +944,7 @@ fn spawn_draw_mode_chip<M: Component>(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|c| { .with_children(|c| {
c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg))); c.spawn((Text::new(label.to_string()), font.clone(), TextColor(fg)));
@@ -1156,6 +1158,7 @@ fn spawn_mode_card(
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+3 -2
View File
@@ -32,8 +32,8 @@ use crate::ui_modal::{
spawn_modal_header, ButtonVariant, spawn_modal_header, ButtonVariant,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -386,6 +386,7 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|chip| { .with_children(|chip| {
chip.spawn(( chip.spawn((
+177
View File
@@ -27,7 +27,9 @@ use bevy::prelude::*;
use chrono::Datelike; use chrono::Datelike;
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::layout::LayoutResource;
use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState}; use crate::replay_playback::{stop_replay_playback, ReplayPlaybackState};
use solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -88,6 +90,21 @@ pub struct ReplayOverlayBannerText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayProgressText; pub struct ReplayOverlayProgressText;
/// Marker on the **floating** progress chip — a 2D world-space text
/// entity rendered above the destination pile of the most-recently-
/// applied move. Sits independently of the banner overlay (which
/// lives in the UI tree and never moves) so the player can see
/// progress without breaking eye contact with the focal card.
///
/// Lifecycle matches the banner overlay: spawned by `spawn_overlay`
/// when a replay starts, despawned by `react_to_state_change` when
/// it ends. Position updated each frame by
/// `update_floating_progress_chip`. Hidden when cursor=0 (no moves
/// applied yet) or the last applied move was a `StockClick` (no
/// destination pile to follow).
#[derive(Component, Debug)]
pub struct ReplayFloatingProgressChip;
/// Marker on the right-hand "Stop" button. Click handler queries for this /// Marker on the right-hand "Stop" button. Click handler queries for this
/// and calls [`stop_replay_playback`] when an `Interaction::Pressed` /// and calls [`stop_replay_playback`] when an `Interaction::Pressed`
/// transition is seen. /// transition is seen.
@@ -149,6 +166,7 @@ impl Plugin for ReplayOverlayPlugin {
react_to_state_change, react_to_state_change,
update_banner_label, update_banner_label,
update_progress_text, update_progress_text,
update_floating_progress_chip,
update_scrub_fill, update_scrub_fill,
handle_stop_button, handle_stop_button,
) )
@@ -170,6 +188,7 @@ fn react_to_state_change(
mut commands: Commands, mut commands: Commands,
state: Res<ReplayPlaybackState>, state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>, existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if !state.is_changed() { if !state.is_changed() {
@@ -185,6 +204,13 @@ fn react_to_state_change(
for entity in &existing { for entity in &existing {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
// Floating chip lives outside the UI tree (world-space
// entity), so the banner-root despawn doesn't reach it.
// Despawn separately on the same state transition so both
// disappear together when the replay ends.
for entity in &floating_chips {
commands.entity(entity).despawn();
}
} }
// The `should_be_visible && already_spawned` branch is a no-op here — // The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label // the per-frame text update systems below repaint the banner label
@@ -200,6 +226,11 @@ fn spawn_overlay(
state: &ReplayPlaybackState, state: &ReplayPlaybackState,
) { ) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Clone for the floating chip spawn that runs *after* the
// banner's `.with_children(|banner| { ... })` closure consumes
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone();
let banner_label = if state.is_completed() { let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
@@ -365,6 +396,30 @@ fn spawn_overlay(
)); ));
}); });
}); });
// Floating progress chip — a 2D world-space `Text2d` rendered
// above the destination pile of the most-recently-applied move.
// Sibling of (not child of) the banner overlay because it lives
// in world-space coordinates, not the UI tree. Spawned hidden;
// `update_floating_progress_chip` shows + positions it on the
// first frame the cursor advances past 0. Lifecycle matches
// the banner overlay — `react_to_state_change` despawns both
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
// High Z keeps the chip above every card stack
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
// stack to the low double digits at most).
Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden,
));
} }
/// Pure helper — returns the scrub-fill width as a percentage of the /// Pure helper — returns the scrub-fill width as a percentage of the
@@ -425,6 +480,78 @@ fn update_progress_text(
} }
} }
/// Repositions the floating progress chip above the destination
/// pile of the most-recently-applied move and repaints its text.
///
/// The chip is hidden when:
/// - the cursor is at 0 (no moves applied yet — chip would have
/// nowhere meaningful to land), OR
/// - the most-recently-applied move was a `StockClick` (no
/// destination pile — stock-click feedback already lives at
/// the stock pile and we don't want the chip to jitter back
/// to the stock pile every cycle).
///
/// When visible, the chip's world-space `Transform.translation`
/// is set to the destination pile's centre plus a fixed upward
/// offset (`card_size.y * 0.6`) so the chip floats just above
/// the top edge of the card. World-space placement (rather than
/// UI-space + camera projection) keeps the math trivial and means
/// the chip stays correctly positioned through window resizes
/// without any extra wiring — `LayoutResource` already drives
/// every other piece of pile geometry.
fn update_floating_progress_chip(
state: Res<ReplayPlaybackState>,
layout: Option<Res<LayoutResource>>,
mut chips: Query<
(&mut Transform, &mut Visibility, &mut Text2d),
With<ReplayFloatingProgressChip>,
>,
) {
let Some(layout) = layout else {
return;
};
// Resolve the destination pile of the last-applied move (if
// any). `cursor` is the index of the *next* move to apply, so
// the most-recently-applied move sits at `cursor - 1`.
let dest_pile = match state.as_ref() {
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
match &replay.moves[cursor - 1] {
ReplayMove::Move { to, .. } => Some(to.clone()),
ReplayMove::StockClick => None,
}
}
_ => None,
};
let Some(world_pos) = dest_pile
.as_ref()
.and_then(|p| layout.0.pile_positions.get(p).copied())
else {
// Nothing to point at — hide every chip and exit.
for (_, mut visibility, _) in chips.iter_mut() {
*visibility = Visibility::Hidden;
}
return;
};
// Position above the destination pile by ~60 % of a card
// height. Half a card lifts above the centre, the extra 10 %
// is breathing room above the top edge so the chip doesn't
// visually clip the card.
let above = Vec2::new(0.0, layout.0.card_size.y * 0.6);
let target = (world_pos + above).extend(100.0);
let label = format_progress(&state);
for (mut transform, mut visibility, mut text2d) in chips.iter_mut() {
transform.translation = target;
*visibility = Visibility::Inherited;
if **text2d != label {
**text2d = label.clone();
}
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress. /// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay /// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the /// already early-exits when nothing moved, so an idle replay leaves the
@@ -668,6 +795,56 @@ mod tests {
); );
} }
/// Lifecycle: the floating progress chip spawns alongside the
/// banner overlay when playback starts, and despawns when
/// playback ends. (Position correctness needs `LayoutResource`,
/// which isn't set up in this headless fixture; the lifecycle
/// test below is what's load-bearing for the spawn/despawn
/// pairing.)
#[test]
fn floating_chip_spawns_and_despawns_with_overlay() {
let mut app = headless_app();
// Inactive → no chip.
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
0,
"no floating chip while playback is Inactive",
);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
},
);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
1,
"floating chip must spawn when playback starts",
);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayFloatingProgressChip>()
.iter(app.world())
.count(),
0,
"floating chip must despawn when playback ends",
);
}
/// Manually flipping the resource back to `Inactive` (e.g. via the /// Manually flipping the resource back to `Inactive` (e.g. via the
/// playback core's auto-clear after `Completed`) tears the overlay /// playback core's auto-clear after `Completed`) tears the overlay
/// down without any further input. /// down without any further input.
+44 -3
View File
@@ -34,9 +34,9 @@ use crate::ui_modal::{
}; };
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, BORDER_SUBTLE_HC, HighContrastBorder,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
Z_MODAL_PANEL, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
}; };
/// Side length of a swatch button in the card-back / background pickers. /// Side length of a swatch button in the card-back / background pickers.
@@ -364,6 +364,7 @@ impl Plugin for SettingsPlugin {
update_anim_speed_text, update_anim_speed_text,
update_color_blind_text, update_color_blind_text,
update_high_contrast_text, update_high_contrast_text,
update_high_contrast_borders,
update_reduce_motion_text, update_reduce_motion_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
@@ -637,6 +638,42 @@ fn update_high_contrast_text(
} }
} }
/// Repaints `BorderColor` on every entity tagged with
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
/// (`#a0a0a0`). Compares against the current border colour and
/// only mutates when different so Bevy's change-detection
/// doesn't trigger repaints every frame.
///
/// Spec at `design-system.md` §Accessibility (#2): under HC,
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
/// modal panels, popover edges, and focus-ring carriers stay
/// legible on low-quality displays / for low-vision users.
///
/// Tagged sites in v0.21.x: the modal scaffold's card border
/// (`ui_modal::spawn_modal`). More sites can be tagged in
/// follow-ups by adding `HighContrastBorder::with_default(...)`
/// to their spawn tuple.
fn update_high_contrast_borders(
settings: Res<SettingsResource>,
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
) {
let high_contrast = settings.0.high_contrast_mode;
for (marker, mut border) in borders.iter_mut() {
let target = if high_contrast {
BORDER_SUBTLE_HC
} else {
marker.default_color
};
// Only mutate when actually different — avoids per-frame
// change-detection churn. `border.left` is representative
// because every tagged site uses `BorderColor::all(...)`.
if border.left != target {
*border = BorderColor::all(target);
}
}
}
fn update_reduce_motion_text( fn update_reduce_motion_text(
settings: Res<SettingsResource>, settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>, mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
@@ -1913,6 +1950,7 @@ fn picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
@@ -2054,6 +2092,7 @@ fn theme_picker_row(
}, },
BackgroundColor(bg), BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref()); spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
@@ -2175,6 +2214,7 @@ fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Opti
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -2235,6 +2275,7 @@ fn icon_button(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY))); b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
+72 -3
View File
@@ -219,12 +219,27 @@ fn spawn_splash(
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Settings is borrowed twice — once for the first_run_complete
// gate above, once here for the reduce-motion gate. The borrow
// above already happened (and was let-go via the `settings.as_deref()`
// pattern's auto-drop), so this re-read is safe.
let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
// Generate the scanline texture handle up-front (when the asset // Generate the scanline texture handle up-front (when the asset
// store is available — always true in production; opt-out under // store is available — always true in production; opt-out under
// bare `MinimalPlugins` test fixtures so existing tests that // bare `MinimalPlugins` test fixtures so existing tests that
// don't init `Assets<Image>` keep working with the rest of the // don't init `Assets<Image>` keep working with the rest of the
// splash content unchanged). // splash content unchanged). Also skipped when reduce-motion is
let scanline_handle = images.map(|mut images| images.add(build_scanline_image())); // on — the scanline overlay is the "CRT scanline effect" the
// design-system spec calls out as non-essential motion under
// reduce-motion (`design-system.md` §Accessibility #3). Without
// it the boot screen still reads as terminal-themed; the
// scanlines are decorative.
let scanline_handle = if reduce_motion {
None
} else {
images.map(|mut images| images.add(build_scanline_image()))
};
commands commands
.spawn(( .spawn((
@@ -712,15 +727,29 @@ fn cursor_pulse_factor(age: Duration, period: f32, min: f32) -> f32 {
/// ///
/// No-op when no `SplashRoot` exists (the splash has already /// No-op when no `SplashRoot` exists (the splash has already
/// despawned, or we're under a test fixture that doesn't spawn one). /// despawned, or we're under a test fixture that doesn't spawn one).
///
/// Under `Settings::reduce_motion_mode`, the per-frame pulse
/// multiplier is skipped — the cursor still fades in / out with
/// the global splash alpha (essential timing) but doesn't blink
/// (decorative motion). Spec at `design-system.md` §Accessibility
/// (#3): reduce-motion suppresses non-essential motion only;
/// fade-in / fade-out timelines stay intact because the splash
/// itself would otherwise hard-cut on/off, which is jarring.
fn pulse_splash_cursor( fn pulse_splash_cursor(
roots: Query<&SplashAge, With<SplashRoot>>, roots: Query<&SplashAge, With<SplashRoot>>,
settings: Option<Res<SettingsResource>>,
mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>, mut pulses: Query<(&SplashFadableBg, &mut BackgroundColor), With<SplashCursorPulse>>,
) { ) {
let Some(age) = roots.iter().next() else { let Some(age) = roots.iter().next() else {
return; return;
}; };
let global = splash_alpha(age.0).unwrap_or(0.0); let global = splash_alpha(age.0).unwrap_or(0.0);
let pulse = cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN); let reduce_motion = settings.is_some_and(|s| s.0.reduce_motion_mode);
let pulse = if reduce_motion {
1.0
} else {
cursor_pulse_factor(age.0, MOTION_PULSE_PERIOD_SECS, PULSE_ALPHA_MIN)
};
let combined = (global * pulse).clamp(0.0, 1.0); let combined = (global * pulse).clamp(0.0, 1.0);
for (fadable, mut bg) in &mut pulses { for (fadable, mut bg) in &mut pulses {
let mut c = fadable.base_color; let mut c = fadable.base_color;
@@ -954,6 +983,46 @@ mod tests {
); );
} }
#[test]
fn splash_skips_scanline_overlay_under_reduce_motion() {
// The CRT scanline overlay is decorative motion that
// `Settings::reduce_motion_mode` suppresses per the
// design-system spec (§Accessibility #3). The splash
// root itself still spawns — the cursor still fades in
// and out (essential timing), but the scanline overlay
// node is omitted entirely.
use solitaire_data::Settings;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::asset::AssetPlugin::default())
.init_asset::<bevy::image::Image>()
.add_plugins(SplashPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<ButtonInput<MouseButton>>();
app.insert_resource(SettingsResource(Settings {
first_run_complete: false,
reduce_motion_mode: true,
..Settings::default()
}));
app.update();
// The splash root spawns (essential motion intact)
assert_eq!(
count_splash_roots(&mut app),
1,
"splash should still spawn under reduce-motion — only the scanline + pulse are gated",
);
// The scanline overlay is gone
let scanline_count = app
.world_mut()
.query::<&SplashScanlineOverlay>()
.iter(app.world())
.count();
assert_eq!(
scanline_count, 0,
"scanline overlay must NOT spawn under reduce-motion",
);
}
#[test] #[test]
fn splash_despawns_after_total_duration() { fn splash_despawns_after_total_duration() {
let mut app = headless_app(); let mut app = headless_app();
+4 -3
View File
@@ -32,9 +32,9 @@ use crate::ui_modal::{
ScrimDismissible, ScrimDismissible,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, ACCENT_PRIMARY, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION,
VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -1017,6 +1017,7 @@ fn spawn_stat_cell(parent: &mut ChildSpawnerCommands, value: &str, label: &str)
..default() ..default()
}, },
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
)) ))
.with_children(|cell| { .with_children(|cell| {
// Large value label — accent yellow makes the number sing // Large value label — accent yellow makes the number sing
+10 -2
View File
@@ -58,8 +58,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -230,6 +231,13 @@ where
Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)), Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)),
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG), BorderColor::all(BORDER_STRONG),
// Honour `Settings::high_contrast_mode`: under HC the
// border boosts from `BORDER_STRONG` (#505050) to
// `BORDER_SUBTLE_HC` (#a0a0a0) so the modal panel
// edge stays clearly visible against the scrim and
// surface beneath. `update_high_contrast_borders` in
// `settings_plugin` does the per-frame swap.
HighContrastBorder::with_default(BORDER_STRONG),
)) ))
.with_children(build_card); .with_children(build_card);
}) })
+26
View File
@@ -226,6 +226,32 @@ pub const BORDER_SUBTLE: Color = Color::srgba(0.208, 0.208, 0.208, 1.0);
/// vision users. /// vision users.
pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0); pub const BORDER_SUBTLE_HC: Color = Color::srgba(0.627, 0.627, 0.627, 1.0);
/// Marker for entities whose [`BorderColor`] should swap to
/// [`BORDER_SUBTLE_HC`] when `Settings::high_contrast_mode` is on.
/// Tag any UI node where border legibility is accessibility-critical
/// — modal panels, popovers, settings rows, focus-ring carriers —
/// then add the `apply_high_contrast_borders` system to react to
/// settings changes.
///
/// `default_color` records the off-state colour the entity was
/// spawned with so the system can revert when HC is toggled back
/// off. Different sites use different defaults (`BORDER_SUBTLE` for
/// idle popover edges, `BORDER_STRONG` for active modal cards) — the
/// marker captures whichever one applies at this entity.
#[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBorder {
/// Border colour to use when high-contrast mode is *off* — the
/// site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color,
}
impl HighContrastBorder {
/// Convenience constructor — `HighContrastBorder::with_default(BORDER_SUBTLE)`.
pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color }
}
}
/// Strong border — hover outline, focused button, active popover. /// Strong border — hover outline, focused button, active popover.
/// `outline` from the design system. `#505050`. /// `outline` from the design system. `#505050`.
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0); pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
+3 -2
View File
@@ -36,8 +36,8 @@ use bevy::ui::{ComputedNode, UiGlobalTransform};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::settings_plugin::SettingsResource; use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
BG_ELEVATED_HI, BORDER_SUBTLE, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM, TEXT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, MOTION_TOOLTIP_DELAY_SECS, RADIUS_SM,
TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP, TEXT_PRIMARY, TYPE_CAPTION, VAL_SPACE_2, Z_TOOLTIP,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -189,6 +189,7 @@ fn spawn_tooltip_overlay(
}, },
BackgroundColor(BG_ELEVATED_HI), BackgroundColor(BG_ELEVATED_HI),
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
HighContrastBorder::with_default(BORDER_SUBTLE),
Visibility::Hidden, Visibility::Hidden,
// Pin above the focus ring so a tooltip on a focused element // Pin above the focus ring so a tooltip on a focused element
// is never occluded by the focus outline. // is never occluded by the focus outline.