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
+78 -11
View File
@@ -3,6 +3,60 @@ import { createPortal } from 'react-dom'
import type { Card } from '../types'
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
// (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
@@ -177,6 +231,9 @@ export function CardView({
// 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.
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 = [
'card',
`card-${size}`,
@@ -206,23 +263,32 @@ export function CardView({
onMouseLeave={preview ? undefined : () => setHover(null)}
>
{previewEl}
<div className="card-top">
<span className="card-tier">{card.tier ? `T${card.tier}` : '·'}</span>
{card.suit && <span className={`suit-dot suit-${card.suit}`} title={`${card.suit} suit`} />}
</div>
{/* Power sits in a spiky burst badge on its own centered row (pets only),
with any battle damage marker beside it, so it never overlaps the art,
name, or ability text. */}
{/* Upper illustration: a stylised landscape with the pet as a sticker and
the red starburst attack badge floating at the top, like the real
cards. */}
<div className="card-scene">
{card.kind === 'pet' && (
<div className="card-combat">
<span className={`card-power ${bonus > 0 ? 'is-buffed' : ''}`}>{power}</span>
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
</div>
)}
{damage > 0 && !dead && <span className="card-damage">{damage}</span>}
<div className="card-art" aria-hidden>
{artFor(card.name)}
</div>
<div className="card-name">{card.name}</div>
</div>
{/* Lower panel: the suit hat, name, and tier die on one row, the ability
beneath — a white card in a rounded green frame. */}
<div className="card-panel">
<div className="card-panel-head">
{card.suit ? (
<SuitHat suit={card.suit} />
) : (
<span className="card-panel-slot" aria-hidden />
)}
<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)
@@ -234,6 +300,7 @@ export function CardView({
? 'Takes 1 extra damage'
: ''}
</div>
</div>
{selected && <div className="card-check"></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
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 {
position: relative;
width: 108px;
height: 148px;
background: linear-gradient(168deg, #fdf6e3, var(--cream) 45%, var(--cream-dark));
border: 2px solid var(--cocoa);
width: 114px;
height: 154px;
background: linear-gradient(165deg, #f8c451, #f0a828 55%, #e2941c);
border-radius: var(--card-radius);
color: var(--ink);
display: flex;
flex-direction: column;
align-items: center;
padding: 7px 6px;
padding: 5px;
gap: 4px;
box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.55),
inset 0 -8px 14px rgba(180, 140, 80, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
var(--shadow-card);
flex-shrink: 0;
transition: transform 130ms cubic-bezier(0.34, 1.4, 0.5, 1), box-shadow 130ms ease;
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 {
width: 132px;
height: 180px;
width: 140px;
height: 190px;
}
.card-sm {
width: 84px;
height: 116px;
width: 88px;
height: 120px;
}
.card.is-clickable {
@@ -1029,8 +1019,8 @@ h3 {
.card.is-clickable:hover {
transform: translateY(-8px) rotate(-1.2deg);
box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.6),
inset 0 -8px 14px rgba(180, 140, 80, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
0 18px 30px -8px rgba(20, 12, 4, 0.6),
0 4px 8px rgba(20, 12, 4, 0.4);
}
@@ -1040,8 +1030,8 @@ h3 {
outline-offset: 1px;
transform: translateY(-8px);
box-shadow:
inset 0 0 0 1.5px rgba(255, 255, 255, 0.6),
0 0 20px rgba(246, 201, 78, 0.45),
inset 0 0 0 2.5px rgba(150, 92, 18, 0.55),
0 0 20px rgba(246, 201, 78, 0.5),
var(--shadow-card);
}
@@ -1053,109 +1043,188 @@ h3 {
filter: grayscale(1);
}
.card-top {
width: 100%;
display: flex;
/* Tier sits at the top-left, suit dot at the top-right. */
justify-content: space-between;
align-items: center;
gap: 5px;
font-size: 0.72rem;
z-index: 1;
min-height: 14px;
}
/* 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;
/* --- Upper illustration: a stylised landscape --- */
.card-scene {
position: relative;
/* Fixed share of the card so the panel below (and the ability text inside it)
stays height-bounded — that's what lets useFitText shrink long abilities. */
flex: 0 0 58%;
min-height: 0;
border-radius: 7px;
overflow: hidden;
box-shadow: inset 0 0 0 1.5px rgba(90, 55, 15, 0.35);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 2px 0 1px;
background:
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 {
font-family: var(--font-display);
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 {
/* Distant mountain silhouette in the hills band. */
.card-scene::before {
content: '';
position: absolute;
left: 50%;
top: 52%;
width: 2.5em;
height: 2.5em;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgba(246, 201, 78, 0.32), transparent 68%);
z-index: -1;
left: -2%;
right: -2%;
top: 24%;
height: 22%;
background: linear-gradient(180deg, #6ea0c6, #5388b2);
clip-path: polygon(
0 100%, 10% 45%, 20% 78%, 34% 22%, 47% 66%, 60% 30%, 73% 72%, 85% 40%, 100% 82%, 100% 100%
);
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 {
font-size: 2.7rem;
font-size: 3.1rem;
}
.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 {
font-family: var(--font-display);
font-size: 0.84rem;
font-size: 0.78rem;
line-height: 1.05;
margin-top: 3px;
flex: 0 0 auto;
z-index: 1;
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;
min-width: 0;
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 {
font-size: 0.94rem;
font-size: 0.9rem;
}
.card-sm .card-name {
font-size: 0.66rem;
}
.card-effect {
font-size: 0.55rem;
line-height: 1.2;
font-size: 0.52rem;
line-height: 1.18;
font-weight: 700;
color: var(--ink-soft);
text-align: center;
padding: 2px 3px 0;
z-index: 1;
/* Grow into the space between the name and the power badge and center the
text there, so long effects use the whole middle band instead of being
clipped just under the name. */
padding: 2px 1px 0;
/* Grow to fill the panel and center short abilities; long ones top-align so
the leading words are never the clipped ones. */
flex: 1 1 auto;
display: flex;
/* Stack multiple abilities (e.g. Squirrel's Buy and Sell) vertically rather
than side by side. */
flex-direction: column;
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;
justify-content: center;
overflow: hidden;
@@ -1167,7 +1236,7 @@ h3 {
}
.card-lg .card-effect {
font-size: 0.63rem;
font-size: 0.6rem;
}
.card-sm .card-effect {
@@ -1200,75 +1269,89 @@ h3 {
background: radial-gradient(circle at 35% 28%, #ffdd6b, #e6ac1f);
}
/* The power badge is a fiery starburst — like a little explosion — carrying the
pet's attack. The spikes are cut with clip-path, so the drop shadow lives on
`filter` (a box-shadow would be clipped away with the corners). */
/* The power badge is a red starburst floating at the top of the illustration,
like a little explosion carrying the pet's attack. The spikes are cut with
clip-path, so the drop shadow lives on `filter`. */
.card-power {
position: absolute;
top: 4%;
left: 50%;
transform: translateX(-50%);
z-index: 3;
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;
text-shadow: 0 1px 2px rgba(120, 45, 5, 0.7);
width: 32px;
height: 32px;
-webkit-text-stroke: 0.5px rgba(120, 20, 5, 0.85);
text-shadow: 0 1px 1px rgba(120, 20, 5, 0.8);
width: 30px;
height: 30px;
display: grid;
place-items: center;
font-size: 0.9rem;
font-size: 0.92rem;
clip-path: polygon(
50% 0%, 61% 18%, 82% 12%, 78% 34%, 100% 43%, 83% 58%,
93% 79%, 70% 76%, 62% 98%, 50% 82%, 38% 98%, 30% 76%,
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 {
background: radial-gradient(circle at 38% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55);
text-shadow: 0 1px 2px rgba(15, 60, 30, 0.7);
background: radial-gradient(circle at 40% 30%, #c9ffd4 4%, #4fd07f 42%, #1f9e55);
-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));
}
.card-lg .card-power {
width: 37px;
height: 37px;
width: 35px;
height: 35px;
font-size: 1.02rem;
}
.card-sm .card-power {
width: 27px;
height: 27px;
font-size: 0.76rem;
width: 24px;
height: 24px;
font-size: 0.74rem;
}
.card-damage {
position: absolute;
top: 5%;
right: 5%;
z-index: 3;
font-family: var(--font-display);
color: #fff;
background: linear-gradient(180deg, #e85c49, var(--red));
border: 1.5px solid rgba(120, 20, 10, 0.4);
border-radius: 8px;
padding: 2px 7px;
font-size: 0.8rem;
padding: 1px 6px;
font-size: 0.72rem;
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 {
background: linear-gradient(168deg, #fff0ea, #ffe0d1 55%, #ffceba);
background: linear-gradient(165deg, #f3a67a, #e07f4f 55%, #cf6a3a);
}
.card-food::before {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent);
}
.card-food .card-effect {
color: #8a3320;
.card-food .card-panel {
border-color: #c96a3a;
}
.card-food .card-effect,
.card-food .card-name {
color: #8a3320;
}
.card-food .card-art::before {
background: radial-gradient(circle, rgba(255, 140, 100, 0.28), transparent 68%);
.card-ailment .card-panel {
border-color: #6f57b0;
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
}
.card-check {
@@ -1402,13 +1485,10 @@ h3 {
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 {
background: linear-gradient(168deg, #e9e6f7, #cfc7ec 55%, #b3a8dd);
}
.card-ailment .card-effect,
.card-ailment .card-name {
color: #45308a;
background: linear-gradient(165deg, #b9a6e6, #9a83d9 55%, #7d64c6);
}
/* Ailment badges pinned to a battle unit. */
@@ -2161,7 +2241,7 @@ h3 {
shrinking the decorative art; useFitText then keeps the text at a larger
size before it has to shrink to fit. */
.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
@@ -2666,6 +2746,13 @@ h3 {
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 {
flex: 0 0 auto;
overflow: visible;