Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 c50eaf81f7 feat(replay): add HC bump for WIN MOVE scrub-bar marker; extend HighContrastBackground
HighContrastBackground gains an optional hc_color field so sites can
specify a domain-specific HC variant rather than always bumping to
BORDER_SUBTLE_HC (gray). with_default() fills hc_color = BORDER_SUBTLE_HC
preserving all existing behaviour; new with_hc(default, hc) lets callers
specify both ends. update_high_contrast_backgrounds reads marker.hc_color
instead of the hardcoded constant.

STATE_SUCCESS_HC (#c8e862, L≈0.73) added to ui_theme — a brighter lime
that maintains the success hue while standing out from bumped notch
ticks (BORDER_SUBTLE_HC gray, L≈0.60) under HC mode.

WIN MOVE marker now carries HighContrastBackground::with_hc(STATE_SUCCESS,
STATE_SUCCESS_HC): lime stays lime under HC instead of turning gray.
Unit test pins both the default and hc color fields on the spawned marker.

1276 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:19:00 -07:00
funman300 b44d2777ec fix(replay): centre scrub-bar notch labels on their notch ticks
The three middle scrub-bar labels (25%, 50%, 75%) previously had their
left edge anchored at the notch percentage, making them read as
"starting after" the notch. Apply the CSS translateX(-50%) pattern for
Bevy 0.18 UI: give each middle label a fixed-width container
(SCRUB_LABEL_CENTER_WIDTH = 36px), offset the container's left edge by
-width/2 via margin.left, and add Justify::Center so the text renders
centred within the container. The container's centre then coincides with
the notch line at the chosen percentage.

Endpoints (0%, 100%) keep their flush-left / flush-right anchoring
unchanged. 1275 tests pass / 0 failing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:14:14 -07:00
funman300 52407e7256 docs(handoff): cut v0.21.7 — B-2 replay arc closed; dim layer ships
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:03:32 -07:00
funman300 da3e5423dc feat(replay): add full-screen tableau dim layer for mini-tableau preview
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black)
at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer
darkens the entire card world so the replay chrome (banner at z=55,
move-log panel at z=55) reads clearly against the scene without
obscuring card positions — matching the mockup's "Game Peek Band at
50% opacity" spec. Bevy's UI/world compositor means no changes to
card_plugin are needed: UI nodes always render above world-space sprites
regardless of Transform.z values.

The dim layer carries no Interaction component (purely visual; pointer
events pass through). Despawned alongside the banner and move-log panel
in `react_to_state_change` when the replay ends.

Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus
two new tests: lifecycle (spawn/despawn mirrors floating chip pattern)
and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned).

1275 tests pass / 0 failing. Closes the last major B-2 sub-piece.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:01:22 -07:00
funman300 a1864271de docs(handoff): refresh post-v0.21.6 — anchor to new tag, reset menu state
Fold the six post-v0.21.5 commit narratives into CHANGELOG §
[0.21.6] (now the source of truth for that release's scope).
Reset the Since-cut log to "no threads in flight." Update
status (HEAD f63db76, tags through v0.21.6, tests 1273
passing). Resume prompt now anchors at v0.21.6.

The post-cut menu's main item is now the mini-tableau preview
— the only major B-2 sub-piece left after Move Log panel
shipped. Architectural change (touches card_plugin rendering),
best tackled in a fresh session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:48:51 -07:00
5 changed files with 367 additions and 184 deletions
+31 -1
View File
@@ -6,9 +6,39 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
No threads in flight. v0.21.6 cut on 2026-05-08; CHANGELOG accumulates No threads in flight. v0.21.7 cut on 2026-05-08; CHANGELOG accumulates
the next cycle here. the next cycle here.
## [0.21.7] — 2026-05-08
Patch release closing the last major B-2 sub-piece. Through-line:
**mini-tableau preview dim layer**. The mockup's "Game Peek Band at
50 % opacity" is now implemented as a full-screen UI scrim that darkens
the card world during replay so the chrome (banner + move-log panel)
reads clearly against the scene.
### Added
- **Full-screen tableau dim layer** (`da3e542`). Spawns a
`ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
black) at `Z_REPLAY_DIM = Z_REPLAY_OVERLAY 1 = 54` whenever
a replay starts; despawned alongside the banner and move-log
panel when the replay ends. Bevy's UI/world compositor means
no changes to `card_plugin` are needed — UI nodes always
render above world-space sprites regardless of `Transform.z`.
The dim layer carries no `Interaction` component (purely
visual; pointer events pass through). Adds `Z_REPLAY_DIM`
and `TABLEAU_DIM_ALPHA` constants plus two new tests:
lifecycle (spawn/despawn mirrors the floating-chip pattern)
and z-ordering invariant (`Z_REPLAY_DIM < Z_REPLAY_OVERLAY`
pinned). 1275 tests pass / 0 failing.
### Stats
- Tests: 1275 passing / 0 failing
- Clippy: clean
- Crates touched: `solitaire_engine` (replay_overlay.rs)
## [0.21.6] — 2026-05-08 ## [0.21.6] — 2026-05-08
Patch release for the post-v0.21.5 work. Through-line: Patch release for the post-v0.21.5 work. Through-line:
+79 -147
View File
@@ -1,101 +1,66 @@
# Solitaire Quest — Session Handoff # Solitaire Quest — Session Handoff
**Last updated:** 2026-05-08 — **v0.21.5 cut and tagged at **Last updated:** 2026-05-08 — **v0.21.7 cut and tagged at
`a2432df`**, working tree clean, all post-tag work pushed to `da3e542`**, working tree clean (tag pending push).
origin.
v0.21.5 is a patch release with one through-line: v0.21.7 is a single-commit patch closing the last major B-2
**replay-overlay scrubbing affordances + accessibility**. sub-piece: **mini-tableau preview dim layer**. A full-screen
v0.21.4 shipped pause / resume / step + the WIN MOVE marker as `ReplayTableauDimLayer` UI node (100 % × 100 %, 50 % opacity
the first scrubbing-shaped additions to the replay overlay; black) at `Z_REPLAY_DIM = 54` (one rung below the replay
v0.21.5 fills out the rest of the scrubbing UX so the player chrome at z=55) darkens the card world during replay so the
has both visual anchor points (notches + labels) and a complete banner and move-log panel read clearly against the scene —
keyboard control surface (Space / Esc / ← / →) for navigating a matching the mockup's "Game Peek Band at 50 % opacity" spec
paused replay. without touching `card_plugin`. 13 commits have now shipped
across v0.21.4v0.21.7 on the B-2 replay screen-takeover
arc; every major sub-piece is closed.
Six commits on the B-2 replay screen-takeover redesign arc land Full v0.21.7 detail lives in `CHANGELOG.md` § [0.21.7]. This
here. Two of them are layout-changing — banner height grew
60 → 76 → 92 px to make room for the notch labels and keybind
footer. Banner geometry was fixed for every prior B-2 commit;
this release establishes the "grow the container, add a
flex-column child" pattern that the remaining B-2 sub-pieces
(move-log scroller, mini-tableau preview) will inherit when
they land.
Full v0.21.5 detail lives in `CHANGELOG.md` § [0.21.5]. 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:** `da3e542` (v0.21.7 commit). Tag pending —
`a2432df`; any post-cut docs edits ride on top of that. push with `git tag v0.21.7 da3e542 && git push origin v0.21.7`.
- **HEAD on origin:** matches local. v0.21.5 is fully on origin. - **HEAD on origin:** `f63db76` (v0.21.6). v0.21.7 commit
not pushed yet; a docs-only edit will ride on top before push.
- **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:** **1254 passing / 0 failing** across the workspace - **Tests:** **1275 passing / 0 failing** across the workspace.
(1250 in v0.21.5 + 2 from `d3cb1a5`'s HC-marker tests + 2 Detail in `CHANGELOG.md` § [0.21.7] § Stats.
from `2e25476`'s continuous-scrub tests). The - **Tags on origin:** `v0.9.0` through `v0.21.6`. v0.21.7
time-dependent `daily_challenge` flake noted in v0.21.5's tag exists locally at `da3e542`; push to origin when ready.
CHANGELOG passes again (UTC clock has moved past the
trigger window). Detail in `CHANGELOG.md` § [0.21.5] § Stats
for the v0.21.5 baseline; post-cut delta tracked in this
file's Since-cut log.
- **Tags on origin:** `v0.9.0` through `v0.21.5`. v0.21.5 is on
`a2432df`; v0.21.4 stays on `23ff62c`; v0.21.3 stays on
`3d92a91`; v0.21.2 stays on `f23df3b`; v0.21.1 stays on
`daa655a`; v0.21.0 stays on `04f9bf9`; v0.20.0 stays on
`41a009a`.
## Since the v0.21.5 cut ## Since the v0.21.7 cut
- **`d3cb1a5` — `feat(replay): HC-mode coverage for scrub One commit in flight (not yet pushed to origin): `da3e542`
track + notches`.** Adds a parallel primitive to ui_theme adds the full-screen tableau dim layer. CHANGELOG and
(`HighContrastBackground` marker carrying `default_color`) SESSION_HANDOFF updates ride on top. Push with:
and a paint system in settings_plugin ```
(`update_high_contrast_backgrounds`) that mirrors the git push origin master
existing border-marker pattern but targets `BackgroundColor` git push origin v0.21.7
instead of `BorderColor`. Tags the 1 px scrub track Node and ```
all five quarter-mark notch ticks with the new marker so
they bump from `BORDER_SUBTLE` (#505050) → `BORDER_SUBTLE_HC`
(#a0a0a0) under HC mode. Scrub fill (ACCENT_PRIMARY) and
WIN MOVE marker (STATE_SUCCESS) don't get the marker —
accent and state colours are already saturated. 2 new tests;
1250 → 1252.
- **`2e25476` — `feat(replay): continuous scrub on key-held
arrow keys`.** Holding ← or → now triggers continuous step
at 100 ms cadence (10 steps/sec) — matches the mockup's
`[← →] scrub` terminology while keeping single-press =
single-step semantics. Per-key accumulators in a new
`ReplayScrubKeyHold` resource; `just_pressed` events bypass
the accumulator and fire immediately. Release resets to 0
so the next fresh press fires immediately rather than at
half-interval. Footer text unchanged (`[← →] step`) —
held-key scrub is a discoverable enhancement to the same
keybind, not a new keybind. 2 new tests using
`TimeUpdateStrategy::ManualDuration`; 1252 → 1254.
Open next-step menu (B-2 keyboard accelerator coverage + Open next-step menu (all major B-2 sub-pieces now closed):
accessibility + scrub UX are all complete): 1. **Polish: notch label centering.** Bevy 0.18 lacks a
1. **Move-log scroller / mini-tableau preview** — both need clean `translate-x: -50%` primitive so the middle three
a much larger banner-height grow (effectively the takeover scrub-bar labels sit slightly right-of-notch. Could use a
container itself). Multi-session arcs that close B-2. child Text wrapper with computed left-margin compensation.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`. Tiny commit, requires visual review.
2. **Polish: notch label centering.** Bevy 0.18 lacks a clean 2. **Polish: WIN MOVE marker HC bump.** Currently uses
`translate-x: -50%` primitive so middle three labels sit `STATE_SUCCESS` lime which stays visible under HC, but a
slightly right-of-notch. Could use a child Text wrapper contrast bump under HC would make it even more legible
with computed left-margin compensation. Tiny commit. alongside the bumped notches. Optional.
3. **Polish: WIN MOVE marker HC bump.** Currently the marker 3. **Move Log auto-scroll** — only relevant if the panel's
uses `STATE_SUCCESS` lime which stays visible under HC, row count grows beyond the current 5-row fixed window.
but a slight saturation / contrast bump under HC would Currently the prev-2 / active / next-2 layout fits all
make the marker even more legible alongside the bumped visible content, so auto-scroll is unneeded.
notches. Optional.
Recommended order: option 2 (notch label centering) is the Recommended order: options 1 and 2 are tiny polish commits
smallest concrete next-step. Option 1 is the multi-session that benefit from visual review. Option 3 is a non-starter
arc that closes B-2 — natural place to start a fresh session. unless the panel's row capacity grows.
## Open punch list ## Open punch list
@@ -127,33 +92,18 @@ chrome migration, splash boot screen, replay-overlay banner,
card-face artwork (both rendering paths), and the `ACCENT_PRIMARY` card-face artwork (both rendering paths), and the `ACCENT_PRIMARY`
palette refresh all shipped in v0.20.0 + v0.21.0. What stays open: palette refresh all shipped in v0.20.0 + v0.21.0. What stays open:
- **Replay-overlay screen-takeover redesign.** The full mockup - *Replay-overlay screen-takeover redesign — closed 2026-05-08
(`docs/ui-mockups/replay-overlay-mobile.html`) calls for a across 13 commits (v0.21.4v0.21.7).* The full mockup
mini-tableau preview, playback controls, move-log scroll, and (`docs/ui-mockups/replay-overlay-mobile.html`) has shipped:
a WIN MOVE marker on the scrub bar. Banner-local pieces all banner chrome (v0.21.0), floating MOVE chip (v0.21.2), WIN
shipped in v0.21.0 (`c84d9f4` + `6204db8` + `54005d5` + MOVE scrub-bar marker (post-v0.21.3), playback controls /
`e080b49`); the floating MOVE chip above the focused card Space accelerator (post-v0.21.3), scrub notches + labels +
shipped in v0.21.2 (`2fb2d63`). The WIN MOVE scrub-bar marker keybind footer + ESC / ← / → accelerators + HC border
shipped post-v0.21.3 in `ab857bb` (data field) + `52befa6` (v0.21.5), Move Log panel + HC scrub track + continuous
(UI). Playback controls (pause / resume / step + Space scrub (v0.21.6), and full-screen 50 % opacity dim layer
accelerator) shipped post-v0.21.3 in `fbe48ac`. v0.21.5 (v0.21.7). Every major B-2 sub-piece is now closed. The
bundled six more commits under "replay-overlay scrubbing only remaining items are minor polish: notch-label centering
affordances + accessibility" — scrub notches + percentage and WIN MOVE HC contrast bump (see Open next-step menu).*
labels + keybind-hint footer + ESC and ← / → accelerators
+ HC marker for the footer top border. Banner height grew
60 → 76 → 92 px across two layout-changing commits in
v0.21.5; banner geometry is now mutable. Full per-commit
detail in `CHANGELOG.md` § [0.21.5]. Keyboard accelerator
coverage is complete. What still needs to land: HC-mode
coverage for the scrub-track / notches / WIN MOVE marker
(they render via `BackgroundColor` so the
`HighContrastBorder` marker doesn't apply — needs a
settings-aware paint), continuous scrub on key-held ← / →
(vs single-step), then the bigger pieces — a move-log
scroller and a mini-tableau preview — both screen-
takeover-only pieces that need a much larger banner height
grow (effectively the takeover container itself).
Multi-session.
- *Floating `MOVE N/M` chip above the focused card during - *Floating `MOVE N/M` chip above the focused card during
playback — closed 2026-05-08 by `2fb2d63`.* World-space playback — closed 2026-05-08 by `2fb2d63`.* World-space
`Text2d` entity sibling to the banner overlay; uses the same `Text2d` entity sibling to the banner overlay; uses the same
@@ -296,25 +246,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.5 is tagged at a2432df (cut 2026-05-08, a Branch: master. v0.21.7 is tagged at da3e542 (cut 2026-05-08,
patch release rolling up replay-overlay scrubbing affordances + closes the last major B-2 sub-piece: full-screen tableau dim
accessibility: scrub-bar notches with percentage labels, keybind- layer — 50 % opacity black UI scrim at z=54 that darkens the
hint footer, ESC + ← / → keyboard accelerators, and HC-mode card world during replay so the chrome reads clearly above it).
coverage for the footer top border). v0.21.4 stays at 23ff62c, v0.21.6 stays at f63db76, v0.21.5 at a2432df, v0.21.4 at
v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at daa655a, 23ff62c, v0.21.3 at 3d92a91, v0.21.2 at f23df3b, v0.21.1 at
v0.21.0 at 04f9bf9. Working tree clean. See CHANGELOG.md § daa655a, v0.21.0 at 04f9bf9. Working tree clean (CHANGELOG +
[0.21.5] for full detail. SESSION_HANDOFF docs ride on top of da3e542; push pending).
See CHANGELOG.md § [0.21.7] for full detail.
State: HEAD locally — see `git rev-parse HEAD`. The cut commit State: HEAD locally — see `git rev-parse HEAD`. Workspace
is a2432df; any post-cut docs edits ride on top of that. tests: 1275 passing / 0 failing. Clippy clean.
Workspace tests: 1250 total / 1249 passing / 1 pre-existing
time-dependent flake (`daily_challenge` warning, fails when UTC
is within 30 min of midnight; verified not introduced by recent
work). 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.5] section is the most recent cut 2. CHANGELOG.md — [0.21.6] 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
@@ -334,30 +281,15 @@ DECISION TO ASK THE PLAYER FIRST:
tests can't catch. Likely surfaces JNI ClipboardManager tests can't catch. Likely surfaces JNI ClipboardManager
and Android Keystore stubs that need real bridges. Larger and Android Keystore stubs that need real bridges. Larger
scope; needs an Android device or emulator running. scope; needs an Android device or emulator running.
B. Replay-overlay screen-takeover redesign — multi-session B. Replay-overlay polish (B-2 arc fully closed in v0.21.7).
work. v0.21.4 shipped WIN MOVE marker, pause / resume / All 13 planned sub-pieces shipped. Remaining items are
step + Space accelerator, plus the floating-MOVE-chip minor polish: (a) scrub-bar notch-label centering — middle
piece from v0.21.2 (`2fb2d63`). v0.21.5 shipped scrub three labels sit slightly right-of-notch due to Bevy 0.18
notches + percentage labels + keybind-hint footer + ESC lacking `translate-x: -50%`; tiny commit, needs visual
and ← / → accelerators + HC marker for the footer top review. (b) WIN MOVE marker HC contrast bump — optional
border (six commits across CHANGELOG § [0.21.5]). Banner luminance boost under HC mode. Both are single commits
height grew 60 → 76 → 92 px across two layout-changing requiring visual review; recommend treating as a v0.21.8
commits in v0.21.5; geometry is now mutable. Keyboard polish pass after manual testing.
accelerator coverage is complete. Natural next finite
steps:
1. **HC-mode coverage** for the scrub-track / notches /
WIN MOVE marker (render via `BackgroundColor` not
`BorderColor`, so `HighContrastBorder` doesn't apply
— needs a settings-aware paint, precedent
`radial_rim_outline`). Smallest next commit.
2. **Continuous scrub on key-held ← / →** instead of
single-step. Needs a key-held event source. Matches
the mockup's `[← →] scrub` terminology.
3. **Move-log scroller / mini-tableau preview** — both
need a much larger banner-height grow (effectively
the takeover container itself). Multi-session arcs
that close B-2.
Mockup at `docs/ui-mockups/replay-overlay-mobile.html`.
C. Phase 8 (sync) — local storage scaffolding, self-hosted C. 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
+221 -28
View File
@@ -38,8 +38,8 @@ 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, HighContrastBackground, HighContrastBorder, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
STATE_SUCCESS, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -60,6 +60,23 @@ use crate::ui_theme::{
/// we materialise a separate constant rather than reuse the `f32` value. /// we materialise a separate constant rather than reuse the `f32` value.
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5; pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// `bevy::ui` `ZIndex` for the full-screen tableau dim layer.
///
/// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome
/// (banner + move-log panel) renders clearly on top while the dim scrim
/// darkens the card world beneath it. World-space sprites (cards,
/// badges, drop-target overlays) are always below any UI node regardless
/// of their Transform.z — the dim layer doesn't need to know their z
/// values.
const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1;
/// Alpha for the tableau dim layer — 50 % opacity black. Dark enough
/// to visually separate the gameplay scene from the replay chrome
/// above it; light enough that card positions remain legible through
/// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity"
/// spec in `docs/ui-mockups/replay-overlay-mobile.html`.
const TABLEAU_DIM_ALPHA: f32 = 0.5;
/// Total height of the banner in pixels. Thin enough to leave the /// Total height of the banner in pixels. Thin enough to leave the
/// gameplay surface visible underneath, tall enough to comfortably fit /// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "▌ replay" label stacked above the /// the headline-sized "▌ replay" label stacked above the
@@ -88,6 +105,21 @@ const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0;
/// (12 px) + 4 px breathing room. /// (12 px) + 4 px breathing room.
const KEYBIND_FOOTER_HEIGHT: f32 = 16.0; const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
/// Fixed pixel width of the centred scrub-bar notch-label container.
/// Wide enough to hold the widest label ("100%" at 4 chars) while
/// narrower than the 25 % gap between adjacent notches (≈ banner_w
/// × 0.25; on a 320 px banner that's 80 px). A 36 px container
/// leaves ≥ 44 px of clearance on each side at the narrowest common
/// screen width.
///
/// Container width drives the `margin.left = -width / 2` centering
/// trick: the container's left edge is placed at `left: Percent(pct)`
/// and then shifted left by half its own width, so the container's
/// centre coincides with the notch line. `Justify::Center` then
/// renders the text centred within the container. This is the
/// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI.
const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0;
/// How long a held arrow key waits before firing the next repeat /// How long a held arrow key waits before firing the next repeat
/// step. 100 ms = 10 steps/sec — fast enough to scrub through a /// step. 100 ms = 10 steps/sec — fast enough to scrub through a
/// hundred-move replay in ~10 seconds while held, slow enough that /// hundred-move replay in ~10 seconds while held, slow enough that
@@ -189,6 +221,18 @@ pub struct ReplayPauseButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayStepButton; pub struct ReplayStepButton;
/// Marker on the full-screen tableau dim layer spawned at the start of
/// every replay. The dim layer is a 100 % × 100 % `Node` at
/// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent
/// black `BackgroundColor`. It darkens the card world so the replay
/// chrome reads clearly against it without obscuring card positions.
///
/// Carries no [`Interaction`] component — purely visual; pointer events
/// pass through to the underlying UI and world-space systems.
/// Despawned by `react_to_state_change` when the replay ends.
#[derive(Component, Debug)]
pub struct ReplayTableauDimLayer;
/// Marker on the small caption sitting below the "▌ replay" /// Marker on the small caption sitting below the "▌ replay"
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a /// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
/// replay is playing — a compact, monotonically-increasing identifier /// replay is playing — a compact, monotonically-increasing identifier
@@ -435,6 +479,7 @@ fn react_to_state_change(
existing: Query<Entity, With<ReplayOverlayRoot>>, existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>, floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>, move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if !state.is_changed() { if !state.is_changed() {
@@ -463,6 +508,11 @@ fn react_to_state_change(
for entity in &move_log_panels { for entity in &move_log_panels {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
// Tableau dim layer is also a separate root entity — same
// pattern as the move-log panel.
for entity in &dim_layers {
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
@@ -504,6 +554,27 @@ fn spawn_overlay(
}; };
let progress_label = format_progress(state); let progress_label = format_progress(state);
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
// in the UI stacking context. World-space sprites (cards, badges) are
// always below any UI node, so the dim layer darkens the entire
// gameplay scene without needing to touch card_plugin. No Interaction
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
ZIndex(Z_REPLAY_DIM),
GlobalZIndex(Z_REPLAY_DIM),
));
let banner_bg = Color::srgba( let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red, BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green, BG_ELEVATED_HI.to_srgba().green,
@@ -708,6 +779,11 @@ fn spawn_overlay(
..default() ..default()
}, },
BackgroundColor(STATE_SUCCESS), BackgroundColor(STATE_SUCCESS),
// HC bump: lime → brighter lime so the win
// marker reads clearly above the bumped
// notch ticks (BORDER_SUBTLE_HC gray) under
// high-contrast mode.
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
)); ));
} }
// Fixed quarter-mark notches: five 1px vertical // Fixed quarter-mark notches: five 1px vertical
@@ -766,45 +842,63 @@ fn spawn_overlay(
labels.iter().zip(positions.iter()).enumerate() labels.iter().zip(positions.iter()).enumerate()
{ {
// Endpoints flush to the row's edges; middle // Endpoints flush to the row's edges; middle
// three labels anchor at their percentage. // three labels use the `translateX(-50%)`
// `i == 0` → flush left (`left: 0`), so the // pattern for Bevy 0.18 UI: a fixed-width
// "0%" caption doesn't get clipped at the // container is placed at `left: Percent(pct)`
// left edge. `i == last` → flush right // then shifted left by half its own width via
// (`right: 0`) so "100%" doesn't overflow // `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
// the banner. Bevy 0.18 UI has no clean // `Justify::Center` renders the text centred
// CSS-style `translate-x: -50%` centering, // within the container so the text's visual
// so the middle three labels sit slightly // centre coincides with the notch line.
// right-of-notch — visually subtle at this let (node, justify) = if i == 0 {
// font size; explicit polish target if (
// anyone notices. Node {
let mut node = Node { position_type: PositionType::Absolute,
position_type: PositionType::Absolute, top: Val::Px(2.0),
top: Val::Px(2.0), left: Val::Px(0.0),
..default() ..default()
}; },
if i == 0 { Justify::Left,
node.left = Val::Px(0.0); )
} else if i == labels.len() - 1 { } else if i == labels.len() - 1 {
node.right = Val::Px(0.0); (
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
right: Val::Px(0.0),
..default()
},
Justify::Right,
)
} else { } else {
node.left = Val::Percent(*pct); (
} Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Percent(*pct),
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
margin: UiRect {
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
..default()
},
..default()
},
Justify::Center,
)
};
row.spawn(( row.spawn((
ReplayOverlayScrubNotchLabel, ReplayOverlayScrubNotchLabel,
node, node,
Text::new(*label), Text::new(*label),
TextLayout::new_with_justify(justify),
TextFont { TextFont {
font: font_handle_for_labels.clone(), font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}, },
// The mockup's `text-outline` (BORDER_SUBTLE)
// would match the notches but reads as too
// low-contrast against `BG_ELEVATED_HI` for
// the labels to actually be legible at 12 px.
// TEXT_SECONDARY keeps the subdued visual // TEXT_SECONDARY keeps the subdued visual
// hierarchy (caption, not headline) while // hierarchy (caption, not headline) while
// staying readable. // staying readable against BG_ELEVATED_HI.
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
} }
@@ -2260,6 +2354,44 @@ mod tests {
); );
} }
/// The WIN MOVE marker carries `HighContrastBackground::with_hc(
/// STATE_SUCCESS, STATE_SUCCESS_HC)` so the lime bumps to brighter
/// lime under HC mode rather than to a neutral gray. Pin the
/// presence of the marker so a future refactor can't accidentally
/// drop it and silently regress HC legibility.
#[test]
fn win_move_marker_carries_hc_background_marker() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8).with_win_move_index(Some(7)),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&HighContrastBackground, With<ReplayOverlayWinMoveMarker>>();
let marker = q
.iter(app.world())
.next()
.expect("WIN MOVE marker must carry HighContrastBackground");
assert_eq!(
marker.default_color,
STATE_SUCCESS,
"default colour must be STATE_SUCCESS"
);
assert_eq!(
marker.hc_color,
STATE_SUCCESS_HC,
"HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)"
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour // scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -3798,4 +3930,65 @@ mod tests {
other => panic!("expected Playing, got {other:?}"), other => panic!("expected Playing, got {other:?}"),
} }
} }
/// The tableau dim layer spawns alongside the banner when playback
/// starts and despawns when the replay ends. Mirrors
/// `floating_chip_spawns_and_despawns_with_overlay` for the dim layer.
#[test]
fn dim_layer_spawns_and_despawns_with_overlay() {
let mut app = headless_app();
// Inactive → no dim layer yet.
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayTableauDimLayer>()
.iter(app.world())
.count(),
0,
"no dim layer while playback is Inactive",
);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(5),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayTableauDimLayer>()
.iter(app.world())
.count(),
1,
"dim layer must spawn when playback starts",
);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
app.world_mut()
.query::<&ReplayTableauDimLayer>()
.iter(app.world())
.count(),
0,
"dim layer must despawn when playback ends",
);
}
/// The dim layer is a full-screen node (100 % × 100 %) at a lower
/// z-index than the replay chrome (z = Z_REPLAY_DIM < Z_REPLAY_OVERLAY).
/// Lock the z-ordering so a future refactor of the z constants can't
/// silently flip the intended stacking.
#[test]
fn dim_layer_z_is_below_replay_chrome() {
assert!(
Z_REPLAY_DIM < Z_REPLAY_OVERLAY,
"dim layer (z={Z_REPLAY_DIM}) must be below replay chrome (z={Z_REPLAY_OVERLAY})",
);
}
} }
+1 -1
View File
@@ -701,7 +701,7 @@ pub(crate) fn update_high_contrast_backgrounds(
let high_contrast = settings.0.high_contrast_mode; let high_contrast = settings.0.high_contrast_mode;
for (marker, mut bg) in backgrounds.iter_mut() { for (marker, mut bg) in backgrounds.iter_mut() {
let target = if high_contrast { let target = if high_contrast {
BORDER_SUBTLE_HC marker.hc_color
} else { } else {
marker.default_color marker.default_color
}; };
+35 -7
View File
@@ -93,6 +93,13 @@ pub const ACCENT_SECONDARY: Color = Color::srgb(0.882, 0.639, 0.933);
/// from base16-eighties. `#acc267`. /// from base16-eighties. `#acc267`.
pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404); pub const STATE_SUCCESS: Color = Color::srgb(0.675, 0.761, 0.404);
/// High-contrast variant of [`STATE_SUCCESS`] — `#c8e862`. Brighter
/// lime that maintains the success hue while lifting luminance from
/// ~0.51 → ~0.73 so the WIN MOVE scrub-bar marker stands out from
/// the bumped notch ticks (`BORDER_SUBTLE_HC` `#a0a0a0`, L≈0.60) in
/// high-contrast mode.
pub const STATE_SUCCESS_HC: Color = Color::srgb(0.784, 0.910, 0.384);
/// Warning — penalty signal, daily-seed expiry countdown, sync-pending /// Warning — penalty signal, daily-seed expiry countdown, sync-pending
/// status. Gold from base16-eighties. **Both** Undo and Recycle /// status. Gold from base16-eighties. **Both** Undo and Recycle
/// counters use this when non-zero. `#ddb26f`. /// counters use this when non-zero. `#ddb26f`.
@@ -260,24 +267,45 @@ impl HighContrastBorder {
/// often render as tiny full-bleed `Node`s, not as borders, so the /// often render as tiny full-bleed `Node`s, not as borders, so the
/// border-marker pattern doesn't apply. /// border-marker pattern doesn't apply.
/// ///
/// `default_color` records the off-state colour the entity was /// `default_color` records the off-state colour; `hc_color` the on-
/// spawned with so the system can revert when HC is toggled back /// state colour. [`with_default`] fills `hc_color` with
/// off. The accompanying paint system is /// [`BORDER_SUBTLE_HC`] so the 90 % of sites that just need the
/// [`update_high_contrast_backgrounds`](crate::settings_plugin::update_high_contrast_backgrounds). /// standard subtle-border bump can continue using a one-argument
/// constructor. [`with_hc`] overrides the HC colour for the rare
/// site (currently only the WIN MOVE scrub-bar marker) that needs a
/// domain-specific HC variant (`STATE_SUCCESS_HC` instead of a gray).
/// ///
/// [`with_default`]: HighContrastBackground::with_default
/// [`with_hc`]: HighContrastBackground::with_hc
/// [`BackgroundColor`]: bevy::prelude::BackgroundColor /// [`BackgroundColor`]: bevy::prelude::BackgroundColor
#[derive(bevy::prelude::Component, Debug, Clone, Copy)] #[derive(bevy::prelude::Component, Debug, Clone, Copy)]
pub struct HighContrastBackground { pub struct HighContrastBackground {
/// Background colour to use when high-contrast mode is *off* — /// Background colour to use when high-contrast mode is *off* —
/// the site's normal idle / active-state colour. /// the site's normal idle / active-state colour.
pub default_color: bevy::prelude::Color, pub default_color: bevy::prelude::Color,
/// Background colour to use when high-contrast mode is *on*.
/// Defaults to [`BORDER_SUBTLE_HC`] via [`with_default`].
///
/// [`with_default`]: HighContrastBackground::with_default
pub hc_color: bevy::prelude::Color,
} }
impl HighContrastBackground { impl HighContrastBackground {
/// Convenience constructor — /// Convenience constructor — HC colour defaults to
/// `HighContrastBackground::with_default(BORDER_SUBTLE)`. /// [`BORDER_SUBTLE_HC`].
pub const fn with_default(default_color: bevy::prelude::Color) -> Self { pub const fn with_default(default_color: bevy::prelude::Color) -> Self {
Self { default_color } Self { default_color, hc_color: BORDER_SUBTLE_HC }
}
/// Constructor for sites whose HC colour differs from the standard
/// [`BORDER_SUBTLE_HC`]. Currently used by the WIN MOVE scrub-bar
/// marker which bumps `STATE_SUCCESS` → `STATE_SUCCESS_HC` rather
/// than to a neutral gray.
pub const fn with_hc(
default_color: bevy::prelude::Color,
hc_color: bevy::prelude::Color,
) -> Self {
Self { default_color, hc_color }
} }
} }