Lays out the lockstep migration from legacy white-card PNGs + constants to the Terminal aesthetic. Steps 4 + 5 (artwork + constant + test updates) must land in one commit so the PNG path and the constant-fallback path don't visually diverge. Tracks Option D from the SESSION_HANDOFF Resume prompt. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 KiB
Card-face artwork migration plan
Status: planning artifact (no code changed by this document).
Tracks: the "Card-face / suit / card-back artwork regeneration"
item in SESSION_HANDOFF.md → "Visual-identity follow-ups"
(SESSION_HANDOFF Resume prompt option D).
Companion to: docs/ui-mockups/design-system.md (Game Cards
spec, lines 214–233) and docs/ui-mockups/desktop-adaptation.md
(rules-based companion to the mockups).
Why this is a multi-session arc
Every post-v0.20.0 visual-identity port to date (modal scaffold, toasts, table chrome, splash boot screen, replay overlay) was a single rendering path — change tokens, change comments, ship. Cards have two rendering paths that are visually identical today and would visually disagree the moment one moves:
- PNG path (production).
assets/cards/faces/<rank><suit>.pngloaded intoCardImageSet.faces[suit][rank]at startup; card sprites blit the texture. 52 face PNGs + 5 back PNGs already inassets/, all the legacy white-card aesthetic from the pre-Terminal design system. - Constant fallback (tests + asset-missing edge). When
CardImageSetisn't a registered resource (the case underMinimalPluginstest fixtures, and the bare-bones path the first-frame of production hits before assets resolve), the renderer falls back to solid-colour sprites driven by thecard_pluginconstants:CARD_FACE_COLOUR—(0.98, 0.98, 0.95)cream-ish white.RED_SUIT_COLOUR—(0.78, 0.12, 0.15)warm red.BLACK_SUIT_COLOUR—(0.08, 0.08, 0.08)near-black.CARD_FACE_COLOUR_RED_CBM—(0.85, 0.92, 1.0, 1.0)light blue (the legacy color-blind tint).card_back_colour(idx)— five legacy back themes.
A single-path migration leaves a known-broken state where tests pass against Terminal constants while a human sees legacy artwork on screen — the exact bisection-hostile drift the handoff's "in lockstep" warning preempts.
Target state — Terminal aesthetic
Per design-system.md § Game Cards (lines 214–233):
Card face
| Element | Spec |
|---|---|
| Background | #1a1a1a |
| Border | 1 px solid in suit colour (pink for ♥/♦, foreground gray for ♠/♣) |
| Corner radius | 8 px |
| Top-left | rank in JetBrains Mono Bold 18 px + small suit glyph (10 px) |
| Bottom-right | large suit glyph (32 px), rotated 180° |
| Glyph fill rule | ♥ ♠ filled; ♦ ♣ outlined (1.5 px stroke). Always on, not a toggle. |
Suit colours (always-on glyph differentiation is the primary
distinguishing mechanism; colour is supplementary):
| Suit | Default | Color-blind mode |
|---|---|---|
| Hearts | #fb9fb1 (pink) |
#6fc2ef (cyan) |
| Diamonds | #fb9fb1 (pink) |
#6fc2ef (cyan) |
| Spades | #d0d0d0 (gray) |
#d0d0d0 (unchanged) |
| Clubs | #d0d0d0 (gray) |
#d0d0d0 (unchanged) |
Card back ("Terminal" theme)
| Element | Spec |
|---|---|
| Background | #151515 |
| Pattern | horizontal scanlines at 2 px pitch in #1a1a1a (1 px line, 1 px gap), full bleed |
| Border | 1 px solid #353535 |
| Top-left badge | 12×16 px solid #6fc2ef block, 6 px from corner |
| Bottom-right monogram | ▌RS in JetBrains Mono 12 px #505050, 6 px from corner |
| Corner radius | 8 px |
| Theme name / author | "Terminal" / "Rusty Solitaire" |
Generation pipeline — programmatic SVG via the existing
resvg stack
Why this path (vs. external tooling or direct tiny_skia)
The codebase already ships an SVG-to-PNG rasteriser at
solitaire_engine/src/assets/svg_loader.rs:
- Public
rasterize_svg(svg_bytes: &[u8], target: UVec2) -> Result<Image, _> - Backed by
usvg(parser) +resvg(renderer) +tiny_skia(CPU pixmap) - Bundled font db includes JetBrains-style mono (FiraMono — same face the splash uses; close enough to JetBrains Mono for rasterisation purposes, and identical to what the Bevy UI consumes in the rest of the app)
RenderAssetUsages::default()is the call-site convention here
This means: generating new card PNGs is one new file
(solitaire_engine/examples/card_face_generator.rs) calling an
existing public function. No new dependencies, no asset-pipeline
changes, no build-script machinery. Anyone who runs the example
gets bit-identical artwork.
The two alternatives are weaker:
- External tool (Inkscape / Figma / hand-design) — produces one-off PNGs that can't be re-generated reproducibly without re-opening the source files in a specific tool. Iteration cost is high; design tweaks (e.g. "make the suit glyph 2 px larger") require a designer-in-the-loop.
- Direct
tiny_skiapainting calls — bypasses SVG entirely, but loses the readability of "open the SVG to see exactly what the card looks like." Also reinvents primitives (rounded rectangles, text layout) thatusvgalready handles.
Output format
PNG, RGBA8 sRGB, dimensions 256 × 384 (2:3 aspect, half the
default SvgLoaderSettings of 512 × 768).
Rationale: cards never exceed ~250 px wide on desktop windows today, and 256 × 384 PNGs are ~6 KB each at this content density (13.4 KB total for a full deck of 52 + 5 backs). The default 512 × 768 is 2× what's needed and quadruples the on-disk asset weight. The existing legacy PNGs are 512 × 768 — reducing the new ones halves the runtime asset size.
Lockstep migration — recommended order
Each step is a separate commit; the constraint is that steps 4 and 5 must land in the same commit (or at most adjacent commits on the same branch) so the rendered output never diverges between the two paths.
- (Done — this commit) Land the migration plan doc.
- Land the SVG generator example. New
solitaire_engine/examples/card_face_generator.rs. Output goes toassets/cards/faces/andassets/cards/backs/. Run once locally to seed the new artwork. The example file stays in-tree as a regenerator for future tweaks. - (Optional — can land separately) Add a one-shot regression
test that re-runs the generator into a
tempdirand compares the resulting bytes against the on-disk artwork; pinning the generator output prevents silent drift ifusvg/resvgever tweak rendering. Skip if the test runtime cost is unacceptable. - Land the new artwork (PNG bytes from step 2 committed to
assets/cards/) and the constant migration in the same commit:CARD_FACE_COLOUR→Color::srgb(0.102, 0.102, 0.102)(#1a1a1a)RED_SUIT_COLOUR→Color::srgb(0.984, 0.624, 0.694)(#fb9fb1)BLACK_SUIT_COLOUR→Color::srgb(0.816, 0.816, 0.816)(#d0d0d0)CARD_FACE_COLOUR_RED_CBM→Color::srgb(0.435, 0.761, 0.937)(#6fc2ef) — note this is now the colour-blind suit colour, not a face tint; semantics shift slightly.card_back_colour(idx)— re-author for the Terminal palette; index 0 stays the canonical "Terminal" back fromdesign-system.md.
- Test updates land in step 4's commit. The pinning tests at
card_plugin.rslines 1749, 1750, 1767, 1768, 2057, 2063, 2071, 2081 all assert against the old constants. New assertions update in lockstep with the constant changes.
CBM (color-blind mode) semantics shift — flag
The legacy CARD_FACE_COLOUR_RED_CBM was a face tint — red
suits got a light-blue background wash. The Terminal spec
moves CBM into the suit colour itself (red glyphs swap to cyan).
Step 4 will rename / repurpose this constant; it's not a 1:1
replacement.
Two options:
- Rename + repurpose:
CARD_FACE_COLOUR_RED_CBM→RED_SUIT_COLOUR_CBM. Communicates the semantic shift in the symbol name. Requires touching every callsite. - Keep the name, change the meaning: less code churn but worse for greppability — a future reader hitting the legacy name will assume face-tint behaviour.
Recommendation: rename. The CBM swap is a one-frame operation
even if it touches every existing callsite (currently lines 642,
2071, 2081 per grep -n CARD_FACE_COLOUR_RED_CBM).
Theme system — out of scope here
The card-theme system (docs/CARD_PLAN.md, theme/plugin.rs)
already supports user-supplied themes via assets/themes/<theme>/
SVG files rasterised by svg_loader.rs. The new Terminal artwork
is the default theme, not a new entry in the theme picker —
the theme system continues to overlay user themes on top of the
default at runtime.
If the next session wants to also ship Terminal as a named theme
slot (so a user can switch back to the legacy artwork via the
theme picker), that's an additive change after step 4 and lives
in theme::plugin::apply_theme_to_card_image_set.
Test impact summary
grep -n CARD_FACE_COLOUR\\b\|RED_SUIT_COLOUR\\b\|BLACK_SUIT_COLOUR\\b in
card_plugin.rs:
- Line 1749–1750: red-suit text colour assertions (♥ + ♦).
- Line 1767–1768: black-suit text colour assertions (♠ + ♣).
- Line 2057, 2063: face-colour assertion in default mode.
- Line 2071, 2081: face-colour assertion in CBM.
The four suit-colour and two face-colour tests are invariant guards — they exist precisely so a constant tweak surfaces here rather than in a visual review. Step 4 updates each in lockstep with the constant value change. No new test infrastructure needed.
Open questions to resolve before step 4
- Border colour conflict. The spec (line 218) says "Border: 1 px solid in suit colour." The fallback path doesn't draw a border today — it draws solid-colour sprites. Step 4 either: (a) leaves the fallback as solid-colour squares (the test environment doesn't visually validate borders anyway), or (b) extends the fallback renderer to paint a 1 px outline. Recommend (a) — fallback fidelity isn't load-bearing.
- Glyph rendering in the constant fallback. The fallback today doesn't render suit glyphs at all — it's a coloured square. The spec's filled-vs-outlined glyph differentiation only matters in the PNG path. No change to the constant fallback for glyphs.
- High-contrast mode.
design-system.mdline 274 mentions a high-contrast accessibility mode (boosts foreground from#d0d0d0to#f5f5f5, suit-red from#fb9fb1to#ff8aa0). Not currently implemented anywhere; out of scope for this migration but worth flagging for a future accessibility pass.
Post-migration — what's still open
- High-contrast mode (above).
- Reduced-motion mode for card lift / drop transitions
(also a
design-system.mdaccessibility item, separate from artwork). - The 9 missing-plugin screens (splash, challenge,
time-attack, weekly-goals, leaderboard, sync, level-up,
replay, radial-menu) per
project_ui_overhaulmemory still need their plugin ports — separate from the cards arc.
Sign-off criteria for "D closed"
D from the SESSION_HANDOFF Resume prompt is closed when all of the following hold simultaneously:
- The 52 face PNGs + 5 back PNGs in
assets/cards/are the Terminal-aesthetic artwork (regeneratable via the example). - The five
card_pluginconstants reflect the Terminal palette. - All pinning tests pass against the new values.
- A human boots the game and sees Terminal cards (not white
cards). This sign-off needs a real
cargo run, not justcargo test.