diff --git a/web/src/components/CardView.tsx b/web/src/components/CardView.tsx index cff2c48..b1d9678 100644 --- a/web/src/components/CardView.tsx +++ b/web/src/components/CardView.tsx @@ -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 = { + 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 ( + + + + + + ) +} + +// The tier shown as a die face (1–6 pips), like the real cards' corner marker. +const DIE_PIPS: Record = { + 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 ( + + + {pips.map(([cx, cy], i) => ( + + ))} + + ) +} + // 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,33 +263,43 @@ export function CardView({ onMouseLeave={preview ? undefined : () => setHover(null)} > {previewEl} -
- {card.tier ? `T${card.tier}` : '·'} - {card.suit && } -
- {/* 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. */} - {card.kind === 'pet' && ( -
+ {/* 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. */} +
+ {card.kind === 'pet' && ( 0 ? 'is-buffed' : ''}`}>{power} - {damage > 0 && !dead && −{damage}} + )} + {damage > 0 && !dead && −{damage}} +
+ {artFor(card.name)}
- )} -
- {artFor(card.name)}
-
{card.name}
-
- {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' - : ''} + {/* Lower panel: the suit hat, name, and tier die on one row, the ability + beneath — a white card in a rounded green frame. */} +
+
+ {card.suit ? ( + + ) : ( + + )} + + {card.name} + + {card.tier ? : } +
+
+ {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' + : ''} +
{selected &&
}
diff --git a/web/src/styles.css b/web/src/styles.css index 5b9541d..bebd611 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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;