Improved card design.

This commit is contained in:
Greyson Parrelli
2026-07-25 14:08:59 -04:00
parent 3a7f92b3cb
commit 3598180ff1
2 changed files with 310 additions and 156 deletions
+91 -24
View File
@@ -3,6 +3,60 @@ import { createPortal } from 'react-dom'
import type { Card } from '../types' import type { Card } from '../types'
import { artFor } from '../petArt' import { artFor } from '../petArt'
// The suit's "hat" — the trade-in symbol printed on the real cards, tinted to
// the suit's colour. Drawn as a tiny sun-hat SVG so it scales crisply.
const SUIT_HAT: Record<string, { fill: string; band: string }> = {
red: { fill: '#e5563f', band: '#b62c1f' },
blue: { fill: '#4d8ce3', band: '#2b5fb0' },
yellow: { fill: '#f2c24d', band: '#d9992f' },
}
function SuitHat({ suit }: { suit: string }) {
const c = SUIT_HAT[suit] ?? SUIT_HAT.yellow
return (
<svg className="card-hat" viewBox="0 0 28 18" aria-hidden>
<path
d="M7 12.5 Q7 3.5 14 3.5 Q21 3.5 21 12.5 Z"
fill={c.fill}
stroke="rgba(50,25,8,.45)"
strokeWidth="1"
/>
<path d="M7.4 11 Q14 13 20.6 11 L20.6 12.6 Q14 14.6 7.4 12.6 Z" fill={c.band} />
<ellipse
cx="14"
cy="13.4"
rx="12.6"
ry="3.7"
fill={c.fill}
stroke="rgba(50,25,8,.45)"
strokeWidth="1"
/>
</svg>
)
}
// The tier shown as a die face (16 pips), like the real cards' corner marker.
const DIE_PIPS: Record<number, [number, number][]> = {
1: [[15, 15]],
2: [[9, 9], [21, 21]],
3: [[9, 9], [15, 15], [21, 21]],
4: [[9, 9], [21, 9], [9, 21], [21, 21]],
5: [[9, 9], [21, 9], [15, 15], [9, 21], [21, 21]],
6: [[9, 9], [21, 9], [9, 15], [21, 15], [9, 21], [21, 21]],
}
function TierDie({ tier }: { tier: number }) {
const pips = DIE_PIPS[tier] ?? DIE_PIPS[1]
return (
<svg className="card-die" viewBox="0 0 30 30" aria-hidden>
<rect x="1.5" y="1.5" width="27" height="27" rx="6.5" fill="#fff" stroke="#2c1c0e" strokeWidth="2" />
{pips.map(([cx, cy], i) => (
<circle key={i} cx={cx} cy={cy} r="2.9" fill="#2c1c0e" />
))}
</svg>
)
}
// useFitText shrinks the effect text until it fits its band, so long abilities // useFitText shrinks the effect text until it fits its band, so long abilities
// (and the smaller battle cards) render in full instead of being clipped by // (and the smaller battle cards) render in full instead of being clipped by
// the fixed card height. It never grows past the CSS size, only shrinks toward // the fixed card height. It never grows past the CSS size, only shrinks toward
@@ -177,6 +231,9 @@ export function CardView({
// Shrink long ability text to fit the (fixed-height) card, re-fitting when // Shrink long ability text to fit the (fixed-height) card, re-fitting when
// the text or card size changes. Only the branch that renders attaches it. // the text or card size changes. Only the branch that renders attaches it.
const effectRef = useFitText(`${size}|${card.effectText ?? ''}|${card.food ?? ''}`) const effectRef = useFitText(`${size}|${card.effectText ?? ''}|${card.food ?? ''}`)
// Long single-word names (e.g. Calygreyhound) shrink to fit the name row
// instead of wrapping into a ragged stack.
const nameRef = useFitText(`${size}|${card.name}`)
const classes = [ const classes = [
'card', 'card',
`card-${size}`, `card-${size}`,
@@ -206,33 +263,43 @@ export function CardView({
onMouseLeave={preview ? undefined : () => setHover(null)} onMouseLeave={preview ? undefined : () => setHover(null)}
> >
{previewEl} {previewEl}
<div className="card-top"> {/* Upper illustration: a stylised landscape with the pet as a sticker and
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span> the red starburst attack badge floating at the top, like the real
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />} cards. */}
</div> <div className="card-scene">
{/* Power sits in a spiky burst badge on its own centered row (pets only), {card.kind === 'pet' && (
with any battle damage marker beside it, so it never overlaps the art,
name, or ability text. */}
{card.kind === 'pet' && (
<div className="card-combat">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span> <span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>} )}
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div> </div>
)}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div> </div>
<div className="card-name">{card.name}</div> {/* Lower panel: the suit hat, name, and tier die on one row, the ability
<div className="card-effect" ref={effectRef}> beneath — a white card in a rounded green frame. */}
{card.effectText <div className="card-panel">
? renderEffect(card.effectText) <div className="card-panel-head">
: card.kind === 'food' && card.food === 'apple' {card.suit ? (
? '+1 power (this battle)' <SuitHat suit={card.suit} />
: card.ailment === 'spooked' ) : (
? 'Deals 1 less damage' <span className="card-panel-slot" aria-hidden />
: card.ailment === 'exposed' )}
? 'Takes 1 extra damage' <span className="card-name" ref={nameRef}>
: ''} {card.name}
</span>
{card.tier ? <TierDie tier={card.tier} /> : <span className="card-panel-slot" aria-hidden />}
</div>
<div className="card-effect" ref={effectRef}>
{card.effectText
? renderEffect(card.effectText)
: card.kind === 'food' && card.food === 'apple'
? '+1 power (this battle)'
: card.ailment === 'spooked'
? 'Deals 1 less damage'
: card.ailment === 'exposed'
? 'Takes 1 extra damage'
: ''}
</div>
</div> </div>
{selected && <div className="card-check"></div>} {selected && <div className="card-check"></div>}
</div> </div>
+219 -132
View File
@@ -980,46 +980,36 @@ h3 {
/* Thick printed stock: a warm parchment face, a soft ink border with an inner /* Thick printed stock: a warm parchment face, a soft ink border with an inner
keyline, a physical shadow, and a lit sheen along the top. */ keyline, a physical shadow, and a lit sheen along the top. */
/* Cards echo the printed board-game cards: a chunky golden frame around a
landscape illustration up top and a green-framed rule panel below. */
.card { .card {
position: relative; position: relative;
width: 108px; width: 114px;
height: 148px; height: 154px;
background: linear-gradient(168deg, #fdf6e3, var(--cream) 45%, var(--cream-dark)); background: linear-gradient(165deg, #f8c451, #f0a828 55%, #e2941c);
border: 2px solid var(--cocoa);
border-radius: var(--card-radius); border-radius: var(--card-radius);
color: var(--ink); color: var(--ink);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; padding: 5px;
padding: 7px 6px; gap: 4px;
box-shadow: box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.55), inset 0 0 0 1px rgba(255, 255, 255, 0.5),
inset 0 -8px 14px rgba(180, 140, 80, 0.22), inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
var(--shadow-card); var(--shadow-card);
flex-shrink: 0; flex-shrink: 0;
transition: transform 130ms cubic-bezier(0.34, 1.4, 0.5, 1), box-shadow 130ms ease; transition: transform 130ms cubic-bezier(0.34, 1.4, 0.5, 1), box-shadow 130ms ease;
user-select: none; user-select: none;
} }
/* A faint printed sheen sweeping across the top of every card. */
.card::before {
content: '';
position: absolute;
inset: 1.5px 1.5px auto 1.5px;
height: 42%;
border-radius: 11px 11px 40% 40%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), transparent);
pointer-events: none;
}
.card-lg { .card-lg {
width: 132px; width: 140px;
height: 180px; height: 190px;
} }
.card-sm { .card-sm {
width: 84px; width: 88px;
height: 116px; height: 120px;
} }
.card.is-clickable { .card.is-clickable {
@@ -1029,8 +1019,8 @@ h3 {
.card.is-clickable:hover { .card.is-clickable:hover {
transform: translateY(-8px) rotate(-1.2deg); transform: translateY(-8px) rotate(-1.2deg);
box-shadow: box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.5),
inset 0 -8px 14px rgba(180, 140, 80, 0.22), inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
0 18px 30px -8px rgba(20, 12, 4, 0.6), 0 18px 30px -8px rgba(20, 12, 4, 0.6),
0 4px 8px rgba(20, 12, 4, 0.4); 0 4px 8px rgba(20, 12, 4, 0.4);
} }
@@ -1040,8 +1030,8 @@ h3 {
outline-offset: 1px; outline-offset: 1px;
transform: translateY(-8px); transform: translateY(-8px);
box-shadow: box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.6), inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
0 0 20px rgba(246, 201, 78, 0.45), 0 0 20px rgba(246, 201, 78, 0.5),
var(--shadow-card); var(--shadow-card);
} }
@@ -1053,109 +1043,188 @@ h3 {
filter: grayscale(1); filter: grayscale(1);
} }
.card-top { /* --- Upper illustration: a stylised landscape --- */
width: 100%; .card-scene {
display: flex; position: relative;
/* Tier sits at the top-left, suit dot at the top-right. */ /* Fixed share of the card so the panel below (and the ability text inside it)
justify-content: space-between; stays height-bounded — that's what lets useFitText shrink long abilities. */
align-items: center; flex: 0 0 58%;
gap: 5px; min-height: 0;
font-size: 0.72rem; border-radius: 7px;
z-index: 1; overflow: hidden;
min-height: 14px; box-shadow: inset 0 0 0 1.5px rgba(90, 55, 15, 0.35);
}
/* Combat stats (power badge + battle damage marker) get their own centered
row just under the tier/suit line, so they never overlap the art or text. */
.card-combat {
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; background:
margin: 2px 0 1px; radial-gradient(120% 70% at 50% -8%, rgba(255, 255, 255, 0.4), transparent 55%),
linear-gradient(
180deg,
#c2e8f8 0%,
#a7dcf0 24%,
#74bf68 44%,
#57a44b 58%,
#e0a751 66%,
#d5942f 86%,
#57a44b 90%,
#4c9640 100%
);
} }
.card-tier { /* Distant mountain silhouette in the hills band. */
font-family: var(--font-display); .card-scene::before {
font-size: 0.68rem;
color: rgba(74, 44, 20, 0.5);
}
.card-suit {
font-size: 0.95rem;
}
/* The art sits in a soft printed halo so it reads as the card's hero. */
.card-art {
position: relative;
font-size: 2rem;
line-height: 1;
flex: 0 0 auto;
margin-top: 1px;
z-index: 1;
filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.28));
}
.card-art::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: -2%;
top: 52%; right: -2%;
width: 2.5em; top: 24%;
height: 2.5em; height: 22%;
transform: translate(-50%, -50%); background: linear-gradient(180deg, #6ea0c6, #5388b2);
border-radius: 50%; clip-path: polygon(
background: radial-gradient(circle, rgba(246, 201, 78, 0.32), transparent 68%); 0 100%, 10% 45%, 20% 78%, 34% 22%, 47% 66%, 60% 30%, 73% 72%, 85% 40%, 100% 82%, 100% 100%
z-index: -1; );
opacity: 0.9;
}
/* A row of rounded bush tops where the greenery begins. */
.card-scene::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: 40%;
height: 12%;
background: radial-gradient(circle at 50% 100%, #3f8f3a 0 45%, transparent 47%) 0 0 / 21% 100% repeat-x;
opacity: 0.92;
}
/* The pet reads as a die-cut sticker: a white outline hugging the art. */
.card-art {
position: relative;
z-index: 2;
font-size: 2.5rem;
line-height: 1;
transform: translateY(8%);
filter:
drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1.5px #fff)
drop-shadow(0 3px 3px rgba(0, 0, 0, 0.35));
} }
.card-lg .card-art { .card-lg .card-art {
font-size: 2.7rem; font-size: 3.1rem;
} }
.card-sm .card-art { .card-sm .card-art {
font-size: 1.9rem; font-size: 2rem;
}
/* --- Lower rule panel --- */
.card-panel {
flex: 1 1 auto;
min-height: 0;
background: #fbf7ee;
border: 2px solid #4e9a3f;
border-radius: 8px;
padding: 3px 4px 4px;
display: flex;
flex-direction: column;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
}
/* Small cards (no ability text) get a compact name plate and a taller scene. */
.card-sm .card-scene {
flex: 1 1 auto;
}
.card-sm .card-panel {
flex: 0 0 auto;
}
.card-panel-head {
display: grid;
/* minmax(0, 1fr) lets the name column shrink below its min-content so a long
name wraps instead of shoving the tier die out past the card edge. */
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 4px;
border-bottom: 1.5px solid rgba(78, 154, 63, 0.4);
padding-bottom: 2px;
}
.card-hat {
width: 20px;
height: 13px;
display: block;
}
.card-die {
width: 15px;
height: 15px;
display: block;
justify-self: end;
}
.card-panel-slot {
display: block;
width: 15px;
}
.card-lg .card-hat {
width: 23px;
height: 15px;
}
.card-lg .card-die,
.card-lg .card-panel-slot {
width: 17px;
height: 17px;
}
.card-sm .card-hat {
width: 16px;
height: 10px;
}
.card-sm .card-die,
.card-sm .card-panel-slot {
width: 12px;
height: 12px;
} }
.card-name { .card-name {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 0.84rem; font-size: 0.78rem;
line-height: 1.05; line-height: 1.05;
margin-top: 3px;
flex: 0 0 auto;
z-index: 1;
color: var(--cocoa); color: var(--cocoa);
/* Always center the name — including when a long name wraps to two lines, so
the second line stays centered instead of drifting off to one side. */
width: 100%;
text-align: center; text-align: center;
min-width: 0;
overflow-wrap: break-word; overflow-wrap: break-word;
/* Cap the name at ~two lines; useFitText shrinks longer names to fit. */
max-height: 2.1em;
overflow: hidden;
} }
.card-lg .card-name { .card-lg .card-name {
font-size: 0.94rem; font-size: 0.9rem;
}
.card-sm .card-name {
font-size: 0.66rem;
} }
.card-effect { .card-effect {
font-size: 0.55rem; font-size: 0.52rem;
line-height: 1.2; line-height: 1.18;
font-weight: 700; font-weight: 700;
color: var(--ink-soft); color: var(--ink-soft);
text-align: center; text-align: center;
padding: 2px 3px 0; padding: 2px 1px 0;
z-index: 1; /* Grow to fill the panel and center short abilities; long ones top-align so
/* Grow into the space between the name and the power badge and center the the leading words are never the clipped ones. */
text there, so long effects use the whole middle band instead of being
clipped just under the name. */
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
/* Stack multiple abilities (e.g. Squirrel's Buy and Sell) vertically rather
than side by side. */
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
/* Center short text in the band, but fall back to top-aligned when the text
is taller than the band, so the leading words are never the ones clipped. */
align-items: safe center; align-items: safe center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
@@ -1167,7 +1236,7 @@ h3 {
} }
.card-lg .card-effect { .card-lg .card-effect {
font-size: 0.63rem; font-size: 0.6rem;
} }
.card-sm .card-effect { .card-sm .card-effect {
@@ -1200,75 +1269,89 @@ h3 {
background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f); background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f);
} }
/* The power badge is a fiery starburst — like a little explosion — carrying the /* The power badge is a red starburst floating at the top of the illustration,
pet's attack. The spikes are cut with clip-path, so the drop shadow lives on like a little explosion carrying the pet's attack. The spikes are cut with
`filter` (a box-shadow would be clipped away with the corners). */ clip-path, so the drop shadow lives on `filter`. */
.card-power { .card-power {
position: absolute;
top: 4%;
left: 50%;
transform: translateX(-50%);
z-index: 3;
font-family: var(--font-display); font-family: var(--font-display);
background: radial-gradient(circle at 38% 30%, #ffcf6b 4%, var(--coral) 42%, var(--coral-dark)); background: radial-gradient(circle at 40% 30%, #ff8a6b 2%, #e23b28 44%, #bd2716);
color: #fff; color: #fff;
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.7); -webkit-text-stroke: 0.5px rgba(120, 20, 5, 0.85);
width: 32px; text-shadow: 0 1px 1px rgba(120, 20, 5, 0.8);
height: 32px; width: 30px;
height: 30px;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 0.9rem; font-size: 0.92rem;
clip-path: polygon( clip-path: polygon(
50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%, 50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%,
93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%, 93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%,
7% 79%, 17% 58%, 0% 43%, 22% 34%, 18% 12%, 39% 18% 7% 79%, 17% 58%, 0% 43%, 22% 34%, 18% 12%, 39% 18%
); );
filter: drop-shadow(0 2px 2px rgba(20, 12, 4, 0.5)); filter: drop-shadow(0 1.5px 2px rgba(20, 12, 4, 0.55));
} }
.card-power.is-buffed { .card-power.is-buffed {
background: radial-gradient(circle at 38% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55); background: radial-gradient(circle at 40% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55);
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.7); -webkit-text-stroke: 0.5px rgba(15, 60, 30, 0.85);
text-shadow: 0 1px 1px rgba(15, 60, 30, 0.7);
filter: drop-shadow(0 0 5px rgba(80, 220, 120, 0.75)); filter: drop-shadow(0 0 5px rgba(80, 220, 120, 0.75));
} }
.card-lg .card-power { .card-lg .card-power {
width: 37px; width: 35px;
height: 37px; height: 35px;
font-size: 1.02rem; font-size: 1.02rem;
} }
.card-sm .card-power { .card-sm .card-power {
width: 27px; width: 24px;
height: 27px; height: 24px;
font-size: 0.76rem; font-size: 0.74rem;
} }
.card-damage { .card-damage {
position: absolute;
top: 5%;
right: 5%;
z-index: 3;
font-family: var(--font-display); font-family: var(--font-display);
color: #fff; color: #fff;
background: linear-gradient(180deg, #e85c49, var(--red)); background: linear-gradient(180deg, #e85c49, var(--red));
border: 1.5px solid rgba(120, 20, 10, 0.4); border: 1.5px solid rgba(120, 20, 10, 0.4);
border-radius: 8px; border-radius: 8px;
padding: 2px 7px; padding: 1px 6px;
font-size: 0.8rem; font-size: 0.72rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
} }
/* Food cards get a rosy stock so they read apart from pets at a glance. */ /* Food cards swap the golden frame for a warm terracotta one, ailments a cold
violet one, so the three kinds read apart at a glance. */
.card-food { .card-food {
background: linear-gradient(168deg, #fff0ea, #ffe0d1 55%, #ffceba); background: linear-gradient(165deg, #f3a67a, #e07f4f 55%, #cf6a3a);
} }
.card-food::before { .card-food .card-panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent); border-color: #c96a3a;
}
.card-food .card-effect {
color: #8a3320;
} }
.card-food .card-effect,
.card-food .card-name { .card-food .card-name {
color: #8a3320; color: #8a3320;
} }
.card-food .card-art::before { .card-ailment .card-panel {
background: radial-gradient(circle, rgba(255, 140, 100, 0.28), transparent 68%); border-color: #6f57b0;
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
} }
.card-check { .card-check {
@@ -1402,13 +1485,10 @@ h3 {
width: fit-content; width: fit-content;
} }
/* Unicorn ailment cards: a cold, spectral palette distinct from foods/pets. */ /* Unicorn ailment cards: a cold violet frame (panel + text colours are set
with the other card-kind overrides above). */
.card-ailment { .card-ailment {
background: linear-gradient(168deg, #e9e6f7, #cfc7ec 55%, #b3a8dd); background: linear-gradient(165deg, #b9a6e6, #9a83d9 55%, #7d64c6);
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
} }
/* Ailment badges pinned to a battle unit. */ /* Ailment badges pinned to a battle unit. */
@@ -2161,7 +2241,7 @@ h3 {
shrinking the decorative art; useFitText then keeps the text at a larger shrinking the decorative art; useFitText then keeps the text at a larger
size before it has to shrink to fit. */ size before it has to shrink to fit. */
.battle-unit .card-art { .battle-unit .card-art {
font-size: 1.5rem; font-size: 1.95rem;
} }
/* A pet flips face-up off its deck: it starts over the stack (outer edge) still /* A pet flips face-up off its deck: it starts over the stack (outer edge) still
@@ -2666,6 +2746,13 @@ h3 {
padding-bottom: 10px; padding-bottom: 10px;
} }
/* Keep the illustration a healthy size while the panel grows to fit the full,
unclipped ability text. */
.card-magnify .card-scene {
flex: 0 0 auto;
height: 104px;
}
.card-magnify .card-effect { .card-magnify .card-effect {
flex: 0 0 auto; flex: 0 0 auto;
overflow: visible; overflow: visible;