Files
Ferrous-Solitaire/docs/ui-mockups/card-face-migration.md
T
funman300 56233687b0 docs(ui): add card-face artwork migration plan
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>
2026-05-08 09:08:04 -07:00

11 KiB
Raw Permalink Blame History

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 214233) 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:

  1. PNG path (production). assets/cards/faces/<rank><suit>.png loaded into CardImageSet.faces[suit][rank] at startup; card sprites blit the texture. 52 face PNGs + 5 back PNGs already in assets/, all the legacy white-card aesthetic from the pre-Terminal design system.
  2. Constant fallback (tests + asset-missing edge). When CardImageSet isn't a registered resource (the case under MinimalPlugins test 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 the card_plugin constants:
    • 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 214233):

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_skia painting 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) that usvg already 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.

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.

  1. (Done — this commit) Land the migration plan doc.
  2. Land the SVG generator example. New solitaire_engine/examples/card_face_generator.rs. Output goes to assets/cards/faces/ and assets/cards/backs/. Run once locally to seed the new artwork. The example file stays in-tree as a regenerator for future tweaks.
  3. (Optional — can land separately) Add a one-shot regression test that re-runs the generator into a tempdir and compares the resulting bytes against the on-disk artwork; pinning the generator output prevents silent drift if usvg/resvg ever tweak rendering. Skip if the test runtime cost is unacceptable.
  4. Land the new artwork (PNG bytes from step 2 committed to assets/cards/) and the constant migration in the same commit:
    • CARD_FACE_COLOURColor::srgb(0.102, 0.102, 0.102) (#1a1a1a)
    • RED_SUIT_COLOURColor::srgb(0.984, 0.624, 0.694) (#fb9fb1)
    • BLACK_SUIT_COLOURColor::srgb(0.816, 0.816, 0.816) (#d0d0d0)
    • CARD_FACE_COLOUR_RED_CBMColor::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 from design-system.md.
  5. Test updates land in step 4's commit. The pinning tests at card_plugin.rs lines 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_CBMRED_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 17491750: red-suit text colour assertions (♥ + ♦).
  • Line 17671768: 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

  1. 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.
  2. 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.
  3. High-contrast mode. design-system.md line 274 mentions a high-contrast accessibility mode (boosts foreground from #d0d0d0 to #f5f5f5, suit-red from #fb9fb1 to #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.md accessibility 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_overhaul memory 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_plugin constants 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 just cargo test.