Files
wizwar6e/packages/web/src/Card.svelte
T
Eric WagonerandClaude Fable 5 3308850bb6 Credibility pass: sweep the workshop floor
Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.

Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.

Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:20:37 -04:00

244 lines
6.7 KiB
Svelte

<script lang="ts">
import { cardDef } from "@wizwar/engine";
import type { CardInstance } from "@wizwar/engine";
let {
card,
selected = false,
attached = false,
marked = false,
displayed = false,
glowing = false,
charges = null,
onclick,
onfaq,
}: {
card: CardInstance;
selected?: boolean;
attached?: boolean;
marked?: boolean;
displayed?: boolean;
/** Playable RIGHT NOW out of turn — a golden pulse invites the tap. */
glowing?: boolean;
charges?: number | null;
/** When set, cards with official FAQ rulings wear a tappable FAQ seal. */
onfaq?: (cardId: string) => void;
onclick?: () => void;
} = $props();
const def = $derived(cardDef(card.cardId));
const isNumber = $derived(def.cardType === "number");
const leftRail = $derived.by(() => {
switch (def.cardType) {
case "attack": return "ATTACK";
case "neutral": return "NEUTRAL";
case "counteraction": return "COUNTERACTION";
case "neutral/counteraction": return "NEUTRAL/COUNTERACTION";
case "trap": return "TRAP";
default: return "";
}
});
const rightRail = $derived(def.los ? "L.O.S." : def.adjacent ? "ADJACENT" : "");
// The text box is fixed; the type shrinks until every word fits. Longest
// card in the set is ~550 chars (Firefly Stick).
const textSize = $derived.by(() => {
const n = def.text?.length ?? 0;
if (n <= 130) return "0.6rem";
if (n <= 200) return "0.56rem";
if (n <= 280) return "0.5rem";
if (n <= 370) return "0.45rem";
if (n <= 470) return "0.41rem";
return "0.375rem";
});
const textLeading = $derived((def.text?.length ?? 0) > 280 ? "1.18" : "1.28");
</script>
<button
class="card"
class:selected
class:attached
class:glowing
class:marked
class:number={isNumber}
{onclick}
title={def.text ?? def.name}
>
{#if isNumber}
<span class="corner tl">{def.value}</span>
<span class="numeral" data-value={def.value}>{def.value}</span>
<span class="brand">Wiz-War</span>
<span class="corner br">{def.value}</span>
{:else}
{#if leftRail}<span class="rail left">{leftRail}</span>{/if}
{#if rightRail}<span class="rail right">{rightRail}</span>{/if}
<span class="name">{def.name}</span>
{#if def.text}<span class="text" style:font-size={textSize} style:line-height={textLeading}>{def.text}</span>{/if}
{/if}
{#if displayed}
<span class="badge">DISPLAYED{charges != null ? ` · ${charges}` : ""}</span>
{/if}
{#if onfaq && def.faqRulings.length > 0}
<span
class="faq-seal" role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onfaq?.(card.cardId); }}
onkeydown={() => {}}
>FAQ</span>
{/if}
</button>
<style>
.card {
position: relative;
width: 8.2rem;
height: 11.4rem;
flex: 0 0 auto;
background:
radial-gradient(ellipse at 30% 20%, rgba(255, 252, 240, 0.5), transparent 60%),
#e9e1cb;
border: 1px solid #b3a687;
border-radius: 7px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.5) inset,
0 3px 8px rgba(10, 8, 4, 0.45);
color: #5f4a33;
padding: 0.55rem 1.05rem 0.5rem;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
overflow: hidden;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.card:hover {
transform: translateY(-0.55rem);
box-shadow: 0 8px 18px rgba(10, 8, 4, 0.55);
z-index: 3;
}
.card:focus-visible {
outline: 2px solid #d8b23a;
outline-offset: 2px;
}
.card.selected {
transform: translateY(-0.8rem);
box-shadow:
0 0 0 2.5px #d8b23a,
0 10px 20px rgba(10, 8, 4, 0.6);
}
.card.attached {
transform: translateY(-0.5rem);
box-shadow: 0 0 0 2.5px #2e7d32, 0 8px 16px rgba(10, 8, 4, 0.55);
}
.card.marked {
box-shadow: 0 0 0 2.5px #b3372b, 0 4px 10px rgba(10, 8, 4, 0.5);
opacity: 0.85;
}
.rail {
position: absolute;
top: 0.55rem;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
font-weight: 500;
letter-spacing: 0.14em;
color: #4a3a26;
writing-mode: vertical-lr;
text-orientation: upright;
line-height: 1;
max-height: 9.5rem;
overflow: hidden;
}
.rail.left { left: 0.28rem; }
.rail.right { right: 0.28rem; }
.name {
font-family: "Oswald", sans-serif;
font-weight: 600;
font-size: 0.78rem;
line-height: 1.05;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #43331f;
margin: 0.35rem 0 0.3rem;
}
.text {
font-family: "Archivo Narrow", sans-serif;
overflow: hidden;
}
/* Number cards: a big numeral in a bordered plate, like the originals. */
.numeral {
font-family: "Oswald", sans-serif;
font-weight: 700;
font-size: 3.4rem;
line-height: 1;
color: #43331f;
margin-top: 1.6rem;
padding: 0.4rem 1rem 0.5rem;
border: 2px solid #5f4a33;
}
.numeral[data-value="3"] { border-style: dashed; }
.numeral[data-value="4"] { border-radius: 12px; }
.numeral[data-value="5"] { border-style: double; border-width: 4px; }
.numeral[data-value="6"] { border-style: none; outline: 2px dotted #5f4a33; outline-offset: 4px; }
.brand {
font-family: "Oswald", sans-serif;
font-size: 0.6rem;
letter-spacing: 0.06em;
margin-top: 0.45rem;
}
.corner {
position: absolute;
font-family: "Oswald", sans-serif;
font-weight: 600;
font-size: 0.75rem;
}
.corner.tl { top: 0.3rem; left: 0.45rem; }
.corner.br { bottom: 0.3rem; right: 0.45rem; transform: rotate(180deg); }
.badge {
position: absolute;
bottom: 0.3rem;
left: 50%;
transform: translateX(-50%);
background: #43331f;
color: #e9e1cb;
font-family: "Oswald", sans-serif;
font-size: 0.45rem;
letter-spacing: 0.12em;
padding: 0.12rem 0.4rem;
border-radius: 2px;
white-space: nowrap;
}
.faq-seal {
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing {
box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55);
animation: card-glow 1.4s ease-in-out infinite;
}
@keyframes card-glow {
0%, 100% { box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55); }
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
}
@media (prefers-reduced-motion: reduce) {
.card.glowing { animation: none; }
.card { transition: none; }
.card:hover, .card.selected, .card.attached { transform: none; }
}
</style>