Decks tab: a deck list page, with the builder on its own screen

The deck list is no longer a sidebar pinned beside the builder; on every
screen size the Decks tab opens to a deck list page (DeckList), and picking
a deck opens its builder full-width with an "All decks" back button. The
list shows each deck as an art tile (or a compact row, toggleable) with its
colors, size against its format, owned %, cost and legality, and can be
searched, filtered by format and sorted (recently opened, name, format,
size, cost, owned %). Rename/duplicate/export/delete live in each deck's
menu and beside the builder's title; New deck now asks for a format too,
defaulting to the last one used.

A deck's cover art is the commander, else its priciest nonland, unless a
card is chosen with "Use as cover" in the card preview. The cover is stored
in decks.cover_oracle_id (migration 0007) and synced as DeckDoc.cover; a
document carrying one is version 3, so the sync server must be updated
before clients publish covers. The list's stats come from one
all_deck_contents query, summarised with the builder's own analysis code,
now in deckAnalysis.ts.

Closes #9
Closes #11
Closes #12
This commit is contained in:
2026-10-25 15:04:41 -05:00
parent fae1aa2b05
commit d286d3420c
15 changed files with 2113 additions and 970 deletions

View File

@@ -95,6 +95,8 @@ export interface PreviewContext {
onChanged: () => void;
/** The deck the previewed card is a slot of. */
deckId?: number;
/** That deck's chosen cover card, so the preview can offer to change it. */
coverOracleId?: string | null;
}
export interface Deck {
@@ -103,6 +105,8 @@ export interface Deck {
format: string;
commander_oracle_id: string | null;
notes: string;
/** The card chosen to headline the deck in the deck list; null = automatic. */
cover_oracle_id: string | null;
}
export interface DeckImportResult {
@@ -493,6 +497,9 @@ export const api = {
invoke<Deck>("duplicate_deck", { deckId, newName }),
setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }),
/** Choose the card headlining the deck in the deck list; null = automatic. */
setDeckCover: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_cover", { deckId, oracleId }),
setDeckCommander: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_commander", { deckId, oracleId }),
/** One commander, or a partner pair; the first is the deck's primary. */
@@ -512,6 +519,8 @@ export const api = {
shareCardImage: (url: string, name: string) =>
invoke<"copied" | "shared">("share_card_image", { url, name }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
/** Every deck's contents by deck id; decks with no cards are absent. */
allDeckContents: () => invoke<Record<number, DeckEntry[]>>("all_deck_contents"),
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),

View File

@@ -107,6 +107,32 @@ const ICON_SHARE = icon(
</>,
);
const ICON_CHECK = icon(<polyline points="20 6 9 17 4 12" />);
const ICON_ARROW_LEFT = icon(
<>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</>,
);
const ICON_PLUS = icon(
<>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</>,
);
const ICON_MORE = icon(
<>
<circle cx="5" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</>,
);
const ICON_IMAGE = icon(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</>,
);
// A box (as on the "Not owned" filter) with a plus: add to the library.
const ICON_LIBRARY_ADD = icon(
<>
@@ -167,8 +193,19 @@ export const Icons = {
libraryAdd: ICON_LIBRARY_ADD,
share: ICON_SHARE,
check: ICON_CHECK,
arrowLeft: ICON_ARROW_LEFT,
plus: ICON_PLUS,
more: ICON_MORE,
image: ICON_IMAGE,
};
/** Scryfall serves every crop size at the same path under a different
* segment, so the stored "normal" card image gives us the art alone. */
export function artCrop(imageUri: string): string | null {
if (!imageUri) return null;
return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/");
}
// --- Dialogs ------------------------------------------------------------
/**
@@ -1042,6 +1079,8 @@ function PrintingsSection({
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
const [error, setError] = useState<string | null>(null);
// Tracked here: `ctx` is a snapshot from when the preview opened.
const [isCover, setIsCover] = useState(ctx.coverOracleId === card.oracle_id);
useEffect(() => {
let live = true;
@@ -1092,6 +1131,12 @@ function PrintingsSection({
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
});
const setCover = (cover: boolean) =>
run(async () => {
await api.setDeckCover(ctx.deckId!, cover ? card.oracle_id : null);
setIsCover(cover);
});
function onPick(p: PrintingRef, finish: string) {
const mode = picking;
setPicking(null);
@@ -1120,6 +1165,24 @@ function PrintingsSection({
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
</span>
</div>
<div className="printing-row">
<span className="printing-row-label">
{isCover ? "This card's art is the deck's cover" : "Deck cover"}
</span>
<span className="spacer" />
<span className="printing-actions">
{isCover ? (
<button onClick={() => setCover(false)} title="Let the deck list pick the cover again">
Automatic
</button>
) : (
<button onClick={() => setCover(true)} title="Headline the deck with this card's art in the deck list">
{Icons.image}
Use as cover
</button>
)}
</span>
</div>
</div>
)}
<div className="preview-info-section">
@@ -1821,11 +1884,13 @@ export type ViewMode = "list" | "tiles";
* multi-column table forces horizontal scrolling on a phone, so narrow
* screens start on Tiles (a reflowing grid, no horizontal scroll) instead.
* This only picks the *default* — once a mode is explicitly chosen it's
* remembered and used everywhere, same as before. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
* remembered and used everywhere, same as before. A view whose list mode
* reflows fine on a phone can pass its own `fallback` instead. */
export function useViewMode(key: string, fallback?: ViewMode): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(key) as ViewMode | null;
if (saved) return saved;
if (fallback) return fallback;
return typeof window !== "undefined" && window.innerWidth <= 720 ? "tiles" : "list";
});
const set = useCallback(

378
src/deckAnalysis.ts Normal file
View File

@@ -0,0 +1,378 @@
// Pure deck analysis shared by the deck builder and the deck list: composition
// stats, format legality, commander pairing, and the deck list's per-deck
// summary.
import { COLORS, Deck, DeckEntry, legalStatusFor, Partner } from "./api";
/** Copies of a deck card you'd need to acquire to cover the deck. */
export const shortBy = (e: DeckEntry) => Math.max(0, e.quantity - e.quantity_owned);
export interface DeckStats {
totalCards: number;
uniqueCards: number;
landCount: number;
avgManaValue: number;
/** USD for every copy in the deck, commander included; cards without a
* known price count as $0. */
totalCost: number;
curve: {
label: string;
count: number;
colorCounts: [number, number, number, number, number];
colorlessCount: number;
}[];
colorCounts: [number, number, number, number, number];
colorlessCount: number;
manaProduction: [number, number, number, number, number];
colorlessProduction: number;
manaConsumption: [number, number, number, number, number];
colorlessConsumption: number;
}
/**
* Deck composition stats, excluding the commander (shown separately in its
* own hero display) and the maybeboard (cards under consideration, not
* actually part of the deck) — matches the usual "99 + commander" framing
* even for non-Commander decks, where it's just "everything but those".
*/
export function computeDeckStats(contents: DeckEntry[]): DeckStats {
const cards = contents.filter((e) => !e.is_commander && !e.is_maybeboard);
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
const lands = cards.filter((e) => e.type_line.includes("Land"));
const nonlands = cards.filter((e) => !e.type_line.includes("Land"));
const landCount = lands.reduce((sum, e) => sum + e.quantity, 0);
const nonlandCount = nonlands.reduce((sum, e) => sum + e.quantity, 0);
const avgManaValue = nonlandCount
? nonlands.reduce((sum, e) => sum + e.mana_value * e.quantity, 0) / nonlandCount
: 0;
const bucketLabels = ["0", "1", "2", "3", "4", "5", "6+"];
const curveBuckets = new Map(
bucketLabels.map((l) => [
l,
{ count: 0, colorCounts: [0, 0, 0, 0, 0] as [number, number, number, number, number], colorlessCount: 0 },
]),
);
for (const e of nonlands) {
const label = e.mana_value >= 6 ? "6+" : String(Math.floor(e.mana_value));
const bucket = curveBuckets.get(label)!;
bucket.count += e.quantity;
if (e.color_identity === 0) {
bucket.colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) bucket.colorCounts[i] += e.quantity;
});
}
}
const curve = bucketLabels.map((label) => ({ label, ...curveBuckets.get(label)! }));
const colorCounts: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessCount = 0;
for (const e of cards) {
if (e.color_identity === 0) {
colorlessCount += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.color_identity & c.bit) colorCounts[i] += e.quantity;
});
}
}
const manaProduction: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessProduction = 0;
for (const e of cards) {
if (e.produces_colorless) colorlessProduction += e.quantity;
COLORS.forEach((c, i) => {
if (e.produced_mana & c.bit) manaProduction[i] += e.quantity;
});
}
// Consumption: colored cost requirements of the spells actually cast —
// lands are excluded since they have no colored casting cost.
const manaConsumption: [number, number, number, number, number] = [0, 0, 0, 0, 0];
let colorlessConsumption = 0;
for (const e of nonlands) {
if (e.colors === 0) {
colorlessConsumption += e.quantity;
} else {
COLORS.forEach((c, i) => {
if (e.colors & c.bit) manaConsumption[i] += e.quantity;
});
}
}
// Unlike the rest, cost counts the commander: it's a card you have to buy.
const totalCost = contents
.filter((e) => !e.is_maybeboard)
.reduce((sum, e) => sum + e.price_usd * e.quantity, 0);
return {
totalCards,
uniqueCards: cards.length,
landCount,
avgManaValue,
totalCost,
curve,
colorCounts,
colorlessCount,
manaProduction,
colorlessProduction,
manaConsumption,
colorlessConsumption,
};
}
/** "$84.50", or whole dollars once it's big enough that cents are noise. */
export function formatCost(usd: number): string {
return `$${usd.toLocaleString(undefined, {
minimumFractionDigits: usd >= 100 ? 0 : 2,
maximumFractionDigits: usd >= 100 ? 0 : 2,
})}`;
}
/** Whether two cards can be commanders together. Mirrors `pairs_with` in
* crates/mtgc-core/src/partner.rs — keep the two in step. */
export function pairsWith(a: DeckEntry, b: DeckEntry): boolean {
const p = a.partner;
const q = b.partner;
if (!p || !q) return false;
switch (p.kind) {
case "partner":
return q.kind === "partner" && a.oracle_id !== b.oracle_id;
case "variant":
return q.kind === "variant" && q.name === p.name && a.oracle_id !== b.oracle_id;
case "with":
return q.kind === "with" && p.name === b.name && q.name === a.name;
case "chooses_background":
return q.kind === "background";
case "background":
return q.kind === "chooses_background";
case "doctors_companion":
return q.kind === "doctor";
case "doctor":
return q.kind === "doctors_companion";
}
}
/** What the second-commander search box asks for, given the first's ability. */
export function partnerPlaceholder(partner: Partner): string {
switch (partner.kind) {
case "chooses_background":
return "Search to add a Background…";
case "background":
return "Search to add a commander that chooses a Background…";
case "doctors_companion":
return "Search to add a Doctor…";
case "doctor":
return "Search to add a Doctor's companion…";
case "with":
return `Search to add ${partner.name}…`;
case "variant":
return `Search to add a partner (${partner.name})…`;
case "partner":
return "Search to add a partner…";
}
}
export const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
export const COMMANDER_FORMATS = ["Commander", "Brawl"];
export interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
export const COMMANDER_DECK_SIZE = 100;
export const CONSTRUCTED_MIN_DECK_SIZE = 60;
export const CONSTRUCTED_MAX_COPIES = 4;
/**
* Format-legality check for the deck as a whole: structural deck-building
* rules (size, singleton/copy limits, commander color identity) plus
* Scryfall's per-format banned/restricted lists (via each card's
* `legalities` bitmask). Blank format and "Other" run no checks at all —
* "unchecked" is a neutral state, distinct from "illegal".
*/
export function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
if (!deck || deck.format === "" || deck.format === "Other") {
return { status: "unchecked", reasons: [] };
}
const reasons: string[] = [];
const cards = contents.filter((e) => !e.is_maybeboard);
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
// singleton/copy-limit rule via oracle text ("a deck can have any number
// of cards named..."). Detecting this needs oracle_text on CardRef, which
// isn't wired through yet — known limitation, not blocking this feature.
if (COMMANDER_FORMATS.includes(deck.format)) {
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
// structurally identical to Commander — 100 cards incl. commander,
// singleton, commander color-identity restriction — differing only in
// which legal/banned card pool applies. Both formats share this
// rule-set and differ only in which legalities bit (deck.format itself,
// "Commander" or "Brawl") is consulted for the banned/restricted check
// below.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards !== COMMANDER_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
);
}
// Two commanders are allowed when their partner abilities pair them up.
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 2) {
reasons.push(`More than two cards are marked as commanders.`);
} else if (commanders.length === 2 && !pairsWith(commanders[0], commanders[1])) {
reasons.push(`${commanders[0].name} and ${commanders[1].name} can't be commanders together.`);
}
for (const e of cards) {
if (e.is_commander) continue;
if (!isBasicLand(e) && e.quantity > 1) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
);
}
}
if (commanders.length > 0) {
const identity = commanders.reduce((mask, e) => mask | e.color_identity, 0);
const whose = commanders.length > 1 ? "commanders'" : "commander's";
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the ${whose} color identity.`);
}
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
} else {
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
// Vintage, Pauper. No commander concept — is_commander is ignored here.
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
reasons.push(
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
);
}
for (const e of cards) {
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
reasons.push(
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
);
}
}
for (const e of cards) {
const status = legalStatusFor(e.legalities, deck.format);
if (status === "banned") {
reasons.push(`${e.name} is banned in ${deck.format}.`);
} else if (status === "restricted" && e.quantity > 1) {
// In practice this only ever fires for Vintage among the 8 tracked
// formats, since Vintage is the only format with a restricted list —
// but the check is generic/future-proof rather than Vintage-special-cased.
reasons.push(
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
);
}
}
}
return { status: reasons.length ? "illegal" : "legal", reasons };
}
/** What the deck list shows for one deck. */
export interface DeckSummary {
/** The card whose art headlines the deck, or null for an empty deck. */
cover: DeckEntry | null;
/** The primary commander first, then its partner. */
commanders: DeckEntry[];
/** WUBRG mask: the commanders' identity, else the whole mainboard's. */
colors: number;
/** Mainboard copies, commander included. */
size: number;
/** The size the format calls for (a minimum for 60-card formats), or null
* when there's no format to measure against. */
target: number | null;
/** Fraction of the mainboard's copies you own, 0–1 (1 for an empty deck). */
owned: number;
cost: number;
legality: DeckLegality;
maybeboardCount: number;
}
/**
* The card whose art headlines a deck: the chosen cover while it's still on
* the mainboard, then the primary commander, then the priciest nonland (the
* card most likely to be the deck's centrepiece), then any card at all.
*/
export function pickCover(deck: Deck, contents: DeckEntry[]): DeckEntry | null {
const main = contents.filter((e) => !e.is_maybeboard);
const byId = (id: string | null) => (id ? main.find((e) => e.oracle_id === id) : undefined);
const priciest = (cards: DeckEntry[]) =>
cards.reduce<DeckEntry | null>((best, e) => (!best || e.price_usd > best.price_usd ? e : best), null);
return (
byId(deck.cover_oracle_id) ??
byId(deck.commander_oracle_id) ??
main.find((e) => e.is_commander) ??
priciest(main.filter((e) => !e.type_line.includes("Land"))) ??
priciest(main) ??
priciest(contents)
);
}
export function summarizeDeck(deck: Deck, contents: DeckEntry[]): DeckSummary {
const main = contents.filter((e) => !e.is_maybeboard);
const isPrimary = (e: DeckEntry) => Number(e.oracle_id === deck.commander_oracle_id);
const commanders = main.filter((e) => e.is_commander).sort((a, b) => isPrimary(b) - isPrimary(a));
const colorSource = commanders.length ? commanders : main;
const size = main.reduce((n, e) => n + e.quantity, 0);
const ownedCopies = main.reduce((n, e) => n + Math.min(e.quantity, e.quantity_owned), 0);
const target = COMMANDER_FORMATS.includes(deck.format)
? COMMANDER_DECK_SIZE
: deck.format && deck.format !== "Other"
? CONSTRUCTED_MIN_DECK_SIZE
: null;
return {
cover: pickCover(deck, contents),
commanders,
colors: colorSource.reduce((mask, e) => mask | e.color_identity, 0),
size,
target,
owned: size ? ownedCopies / size : 1,
cost: main.reduce((sum, e) => sum + e.price_usd * e.quantity, 0),
legality: computeDeckLegality(deck, contents),
maybeboardCount: contents.length - main.length,
};
}

View File

@@ -1224,20 +1224,6 @@ h4 {
color: #fff;
}
/* Row-level action icons (e.g. rename/duplicate/delete a deck): hidden until
* the row is hovered/focused, so the collapsed list stays uncluttered. */
.row-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
li:hover .row-actions,
li:focus-within .row-actions {
opacity: 1;
}
/* ============================================================
Dialogs: confirm (delete) / prompt (rename, duplicate-as)
============================================================ */
@@ -1312,7 +1298,8 @@ li:focus-within .row-actions {
gap: 6px;
margin-bottom: 14px;
}
.dialog-field input {
.dialog-field input,
.dialog-field select {
width: 100%;
}
.dialog-error {
@@ -1359,26 +1346,6 @@ li:focus-within .row-actions {
color: var(--text-strong);
}
/* Deck list: name+format on one row, actions on their own row below, so
* long names don't push the action icons off the edge of the sidebar. */
.result-list.deck-list li {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.deck-list-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.deck-list-row .card-name {
overflow-wrap: break-word;
}
.deck-list-actions-row {
justify-content: space-between;
}
/* Suggestions list: a thumbnail + a compact name/badges header + wrapped
* synergy reason chips per row, with stacked actions on the right. Denser
* than the default .result-list row (smaller padding/gaps, chips instead of
@@ -2613,25 +2580,412 @@ tbody tr:hover {
}
/* ============================================================
Deck metadata (format + commander)
Deck list (the Decks tab's front page)
============================================================ */
/* Two-pane deck screen: deck list + selected-deck detail. Stacks below the
tablet breakpoint (see the responsive section) so the list sits above the
detail on narrow/portrait viewports. */
.deck-layout {
.deck-gallery-toolbar {
display: flex;
gap: var(--space-5);
align-items: flex-start;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.deck-sidebar {
width: 240px;
.deck-gallery-toolbar h2 {
margin: 0;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.deck-gallery-search {
width: 260px;
}
.deck-gallery-controls,
.deck-gallery-create {
display: flex;
align-items: center;
gap: var(--space-2);
}
.deck-gallery-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
padding: var(--space-8) var(--space-5);
}
.deck-gallery-empty h3 {
margin: 0;
border: none;
padding: 0;
}
.deck-gallery-empty .hint {
margin: 0 0 var(--space-3);
}
.deck-gallery-empty-icon svg {
width: 36px;
height: 36px;
color: var(--accent-quiet);
}
/* Tiles: an art banner with the deck's name on it, stats below. */
.deck-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
}
.deck-tile {
position: relative;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
box-shadow: var(--panel-highlight);
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.deck-tile:hover {
transform: translateY(-2px);
border-color: var(--accent-glow);
box-shadow: var(--panel-highlight), var(--shadow-md);
}
.deck-tile:focus-visible,
.deck-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* The hover lift makes each tile its own stacking context, so the one whose
menu is open has to sit above its neighbours or they'd cover the menu. */
.deck-tile.menu-open,
.deck-row.menu-open {
z-index: 5;
}
.deck-tile-cover {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.deck-tile-art {
position: absolute;
inset: 0;
}
.deck-tile-art img,
.deck-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.deck-tile-art img {
transition: transform 0.4s ease;
}
.deck-tile:hover .deck-tile-art img {
transform: scale(1.04);
}
.deck-tile-art,
.deck-row-art {
display: flex;
align-items: center;
justify-content: center;
background: repeating-linear-gradient(-45deg, var(--bg-input) 0 10px, var(--bg-hover) 10px 20px);
}
.deck-art-empty {
font-size: 44px;
font-weight: 800;
color: var(--faint);
}
/* Darkens the top (for the badge + menu) and, more, the bottom (for the
name), so both stay legible over any art. */
.deck-tile-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(12, 10, 8, 0.45) 0%,
rgba(12, 10, 8, 0) 28%,
rgba(12, 10, 8, 0) 45%,
rgba(12, 10, 8, 0.92) 100%
);
}
.deck-tile-heading {
position: absolute;
left: var(--space-3);
right: var(--space-3);
bottom: var(--space-2);
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-2);
}
.deck-tile-name {
font-size: 17px;
font-weight: 700;
line-height: 1.25;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.deck-tile-heading .pips {
flex-shrink: 0;
margin-bottom: 2px;
}
.deck-tile-cover .deck-legality {
position: absolute;
top: var(--space-2);
left: var(--space-2);
}
.deck-tile-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-3) var(--space-3);
}
.deck-tile-sub {
font-size: var(--fs-caption);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-tile-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-caption);
min-height: 22px;
}
.deck-tile-meta .deck-chip,
.deck-row-stats .deck-chip {
margin-right: 0;
}
.deck-tile-foot {
display: flex;
justify-content: space-between;
font-size: var(--fs-caption);
color: var(--muted);
}
.deck-tile-foot span:last-child,
.deck-row-cost {
color: var(--text);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.deck-size {
font-size: var(--fs-caption);
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.deck-size-target {
color: var(--faint);
font-weight: 500;
}
.deck-size.done {
color: var(--success);
}
.deck-size.warn {
color: var(--warn);
}
.deck-owned-bar {
display: block;
height: 4px;
border-radius: var(--radius-pill);
background: var(--bg-input);
overflow: hidden;
}
.deck-owned-bar > span {
display: block;
height: 100%;
background: var(--success);
border-radius: inherit;
}
.deck-legality {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--fs-label);
font-weight: 700;
white-space: nowrap;
}
.deck-tile .deck-legality {
padding: 2px var(--space-2);
border-radius: var(--radius-pill);
background: rgba(12, 10, 8, 0.72);
backdrop-filter: blur(2px);
}
.deck-legality.legal {
color: var(--success);
}
.deck-legality.illegal {
color: var(--warn);
}
/* The ⋯ menu: over the tile's top-right corner, or at a row's end. */
.deck-menu {
position: relative;
flex-shrink: 0;
}
.deck-detail-pane {
.deck-tile > .deck-menu {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.deck-menu-list {
left: auto;
right: 0;
min-width: 170px;
}
.deck-menu-list > button.danger-text {
color: var(--danger);
}
/* Where there's a hover, the tile's menu waits for it; on touch it's always
showing, since there's nothing to reveal it. */
@media (hover: hover) {
.deck-tile > .deck-menu {
opacity: 0;
transition: opacity 0.12s;
}
.deck-tile:hover > .deck-menu,
.deck-tile:focus-within > .deck-menu,
.deck-tile.menu-open > .deck-menu {
opacity: 1;
}
}
/* Rows: the compact alternative, more decks per screen. */
.deck-gallery-rows {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.deck-row {
position: relative;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-2);
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--panel-highlight);
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.deck-row:hover {
border-color: var(--accent-glow);
background: var(--bg-hover);
}
.deck-row-art {
width: 72px;
height: 52px;
flex-shrink: 0;
overflow: hidden;
border-radius: var(--radius-sm);
}
.deck-row-art .deck-art-empty {
font-size: 22px;
}
.deck-row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.deck-row-name {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.deck-row-name .card-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-row-name .pips {
flex-shrink: 0;
}
.deck-row-sub {
font-size: var(--fs-caption);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-row-stats {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
font-size: var(--fs-caption);
}
.deck-row-owned {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.deck-row-owned .deck-owned-bar {
width: 56px;
}
.deck-row-owned > span:last-child {
text-align: right;
}
/* Fixed widths, so each stat lines up in a column down the list. */
.deck-row-stats .deck-size {
min-width: 4.5em;
text-align: right;
}
.deck-row-owned > span:last-child {
min-width: 4ch;
}
.deck-row-cost {
min-width: 4.5em;
text-align: right;
}
.deck-row .deck-legality {
min-width: 1em;
font-size: var(--fs-body);
}
/* The builder's way back to the deck list. */
.deck-builder-back {
background: none;
border: none;
padding: 0;
margin-bottom: var(--space-2);
color: var(--muted);
font-size: 13px;
}
.deck-builder-back:hover {
color: var(--text-strong);
background: none;
}
.deck-builder-title {
gap: var(--space-1);
min-width: 0;
}
.deck-builder-title h2 {
margin-right: var(--space-2);
overflow-wrap: anywhere;
}
/* ============================================================
Deck metadata (format + commander)
============================================================ */
/* Surface from `.panel`; layout only here. */
.deck-meta {
@@ -2809,27 +3163,9 @@ tbody tr:hover {
Responsive groundwork
Desktop (the primary target) is unchanged above; these breakpoints keep
the layout usable as the window narrows and lay the foundation for a
future mobile port. Breakpoints: 1000 (tablet) and 720 (compact).
future mobile port. Breakpoint: 720 (compact).
============================================================ */
/* Tablet: the deck two-pane stacks (list above detail). */
@media (max-width: 1000px) {
.deck-layout {
flex-direction: column;
}
.deck-sidebar {
width: 100%;
}
}
/* Back button into the deck list — only meaningful once the list and the
deck-building pane become separate screens below, so it's hidden outside
that breakpoint (and on the tablet stacked layout, where both panes are
still visible together). */
.deck-back-btn {
display: none;
}
/* Compact: tighten page gutters and collapse the nav rail to an icon strip
the left rail's job moves to .bottom-nav below instead, the customary
placement on Android). */
@@ -2871,6 +3207,41 @@ tbody tr:hover {
.deck-stats-curve {
min-width: 0;
}
/* Deck list toolbar: title and create buttons on top, then search, then
the filter/sort/view controls sharing a line. */
.deck-gallery-toolbar .spacer {
display: none;
}
.deck-gallery-toolbar h2 {
flex: 1;
}
.deck-gallery-create {
order: 1;
}
.deck-gallery-search {
order: 2;
width: 100%;
}
.deck-gallery-controls {
order: 3;
width: 100%;
}
.deck-gallery-controls select {
flex: 1;
min-width: 0;
}
/* Rows put their stats on a second line, under the name. */
.deck-row {
flex-wrap: wrap;
row-gap: var(--space-1);
}
.deck-row-stats {
order: 3;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--space-3);
padding-left: calc(72px + var(--space-3));
}
/* `.deck-meta` puts the Format selector and the commander search box side
by side as flex-wrap row items — with `.commander-box`'s min-width now
0, it no longer wraps onto its own line, it just gets squeezed into
@@ -2880,20 +3251,6 @@ tbody tr:hover {
flex-direction: column;
align-items: stretch;
}
/* Deck list and deck-building pane become separate screens (only one
visible at a time) instead of stacking above one another — with many
decks, scrolling past the whole list to reach the builder doesn't
scale. `.deck-layout`'s show-list/show-detail class is toggled by
DeckView in response to picking a deck / the back button. */
.deck-back-btn {
display: inline-flex;
}
.deck-layout.show-list .deck-detail-pane {
display: none;
}
.deck-layout.show-detail .deck-sidebar {
display: none;
}
/* The Suggested Cards dialog: `.suggestion-actions`' two nowrap buttons
are flex-shrink:0, so they claimed their full fixed width no matter how
little room was left, crushing `.suggestion-main` down to almost

426
src/views/DeckList.tsx Normal file
View File

@@ -0,0 +1,426 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { api, Deck, DeckEntry } from "../api";
import { artCrop, ColorPips, fold, IconButton, Icons, usePersistedState, useViewMode, ViewToggle } from "../components";
import { DeckSummary, formatCost, summarizeDeck } from "../deckAnalysis";
type SortMode = "recent" | "name" | "format" | "size" | "cost" | "owned";
const SORTS: { id: SortMode; label: string }[] = [
{ id: "recent", label: "Recently opened" },
{ id: "name", label: "Name" },
{ id: "format", label: "Format" },
{ id: "size", label: "Size" },
{ id: "cost", label: "Cost" },
{ id: "owned", label: "Owned %" },
];
interface DeckActions {
onOpen: (id: number) => void;
onRename: (deck: Deck) => void;
onDuplicate: (deck: Deck) => void;
onDelete: (deck: Deck) => void;
onExport: (deck: Deck) => void;
}
interface Row {
deck: Deck;
summary: DeckSummary;
}
/** "Muldrotha, the Gravetide" / "Tymna & Thrasios" / "Featuring Sol Ring" —
* or, for a deck named after its commander, the commander's type line rather
* than its name again. */
function subtitle({ deck, summary }: Row): string {
if (summary.commanders.length) {
const names = summary.commanders.map((c) => c.name).join(" & ");
if (fold(names) !== fold(deck.name.trim())) return names;
return summary.commanders[0].type_line;
}
if (summary.cover) return `Featuring ${summary.cover.name}`;
return "No cards yet";
}
/** Floored, so a deck one card short never reads as "100% owned". */
function ownedLabel(s: DeckSummary): string {
if (s.size === 0) return "—";
return s.owned === 1 ? "All owned" : `${Math.floor(s.owned * 100)}% owned`;
}
/** The deck's size against its format: "87/100" until it's there, then just
* the count — a 60-card minimum isn't a target to show as "62/60". */
function DeckSize({ summary: s }: { summary: DeckSummary }) {
const exact = s.target === 100;
const done = s.target !== null && (exact ? s.size === s.target : s.size >= s.target);
const over = exact && s.size > s.target!;
const cls = over ? "warn" : done ? "done" : "";
if (s.target === null || (done && !exact)) {
return (
<span className={`deck-size ${cls}`}>
{s.size} card{s.size === 1 ? "" : "s"}
</span>
);
}
return (
<span className={`deck-size ${cls}`} title={`${s.size} of ${s.target} cards`}>
{s.size}
<span className="deck-size-target">/{s.target}</span>
</span>
);
}
function LegalityBadge({ summary: s, compact }: { summary: DeckSummary; compact?: boolean }) {
// An empty deck is "illegal" only in the sense that it isn't built yet.
if (s.legality.status === "unchecked" || s.size === 0) return null;
const legal = s.legality.status === "legal";
const n = s.legality.reasons.length;
const title = legal ? "Meets the format's deck-building rules" : s.legality.reasons.join("\n");
return (
<span className={`deck-legality ${legal ? "legal" : "illegal"}`} title={title}>
{legal ? "✓" : "⚠"}
{!compact && <span>{legal ? "Legal" : `${n} issue${n === 1 ? "" : "s"}`}</span>}
</span>
);
}
function OwnedBar({ summary: s }: { summary: DeckSummary }) {
return (
<span className="deck-owned-bar" aria-hidden="true">
<span style={{ width: `${s.size ? s.owned * 100 : 0}%` }} />
</span>
);
}
/** The cover card's art, or the deck's initial when there's none to show. */
function DeckArt({ row, className }: { row: Row; className: string }) {
const src = row.summary.cover ? artCrop(row.summary.cover.image_uri) : null;
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div className={className}>
{src && !failed ? (
<img src={src} alt="" loading="lazy" draggable={false} onError={() => setFailed(true)} />
) : (
<span className="deck-art-empty">{row.deck.name.trim().charAt(0).toUpperCase() || "?"}</span>
)}
</div>
);
}
/** "⋯" menu of whole-deck actions. Its clicks never reach the tile/row
* underneath, which opens the deck. */
function DeckMenu({
deck,
overlay,
actions,
onOpenChange,
}: {
deck: Deck;
overlay?: boolean;
actions: DeckActions;
onOpenChange: (open: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const toggle = (next: boolean) => {
setOpen(next);
onOpenChange(next);
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) toggle(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && toggle(false);
document.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const item = (label: string, run: () => void, danger?: boolean) => (
<button
role="menuitem"
className={danger ? "danger-text" : undefined}
onClick={() => {
toggle(false);
run();
}}
>
{label}
</button>
);
return (
<div
className="deck-menu"
ref={ref}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<IconButton icon={Icons.more} label="Deck actions" overlay={overlay} onClick={() => toggle(!open)} />
{open && (
<div className="library-menu deck-menu-list" role="menu">
{item("Open", () => actions.onOpen(deck.id))}
{item("Rename…", () => actions.onRename(deck))}
{item("Duplicate…", () => actions.onDuplicate(deck))}
{item("Export…", () => actions.onExport(deck))}
<hr />
{item("Delete…", () => actions.onDelete(deck), true)}
</div>
)}
</div>
);
}
/** Click or Enter/Space opens the deck. */
function openProps(open: () => void) {
return {
role: "button",
tabIndex: 0,
onClick: open,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open();
}
},
};
}
function DeckTile({ row, actions }: { row: Row; actions: DeckActions }) {
const { deck, summary: s } = row;
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className={`deck-tile ${menuOpen ? "menu-open" : ""}`} {...openProps(() => actions.onOpen(deck.id))}>
<div className="deck-tile-cover">
<DeckArt row={row} className="deck-tile-art" />
<div className="deck-tile-scrim" />
<LegalityBadge summary={s} />
<div className="deck-tile-heading">
<span className="deck-tile-name">{deck.name}</span>
{s.size > 0 && <ColorPips mask={s.colors} />}
</div>
</div>
{/* Outside the cover, whose overflow clipping would cut the menu off. */}
<DeckMenu deck={deck} overlay actions={actions} onOpenChange={setMenuOpen} />
<div className="deck-tile-body">
<div className="deck-tile-sub">{subtitle(row)}</div>
<div className="deck-tile-meta">
{deck.format ? <span className="deck-chip">{deck.format}</span> : <span className="muted">No format</span>}
{s.maybeboardCount > 0 && <span className="muted">+{s.maybeboardCount} maybe</span>}
<span className="spacer" />
<DeckSize summary={s} />
</div>
<OwnedBar summary={s} />
<div className="deck-tile-foot">
<span>{ownedLabel(s)}</span>
<span>{formatCost(s.cost)}</span>
</div>
</div>
</div>
);
}
function DeckRow({ row, actions }: { row: Row; actions: DeckActions }) {
const { deck, summary: s } = row;
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className={`deck-row ${menuOpen ? "menu-open" : ""}`} {...openProps(() => actions.onOpen(deck.id))}>
<DeckArt row={row} className="deck-row-art" />
<div className="deck-row-main">
<div className="deck-row-name">
<span className="card-name">{deck.name}</span>
{s.size > 0 && <ColorPips mask={s.colors} />}
</div>
<div className="deck-row-sub">{subtitle(row)}</div>
</div>
<div className="deck-row-stats">
{deck.format && <span className="deck-chip">{deck.format}</span>}
<DeckSize summary={s} />
<span className="deck-row-owned" title={ownedLabel(s)}>
<OwnedBar summary={s} />
<span>{s.size ? `${Math.floor(s.owned * 100)}%` : "—"}</span>
</span>
<span className="deck-row-cost">{formatCost(s.cost)}</span>
<LegalityBadge summary={s} compact />
</div>
<DeckMenu deck={deck} actions={actions} onOpenChange={setMenuOpen} />
</div>
);
}
/**
* The Decks tab's front page: every deck as an art-forward tile (or a compact
* row), with its colors, size against its format, how much of it you own,
* what it costs, and whether it's legal. Picking one opens its builder.
*/
export function DeckList({
decks,
refreshSignal,
openedAt,
onNew,
onImport,
...actions
}: {
decks: Deck[];
refreshSignal?: number;
/** When each deck was last opened (ms), for the "Recently opened" sort. */
openedAt: Record<number, number>;
onNew: () => void;
onImport: () => void;
} & DeckActions) {
const [contents, setContents] = useState<Record<number, DeckEntry[]> | null>(null);
const [view, setView] = useViewMode("deck-list-view", "tiles");
const [sort, setSort] = usePersistedState<SortMode>("deck-list-sort", "recent");
const [query, setQuery] = useState("");
const [format, setFormat] = useState<string | null>(null);
// `decks` is refetched after anything that adds a deck, so it doubles as
// the signal that contents may have changed.
useEffect(() => {
let live = true;
api
.allDeckContents()
.then((c) => live && setContents(c))
.catch(() => live && setContents({}));
return () => {
live = false;
};
}, [decks, refreshSignal]);
const rows: Row[] = useMemo(
() => decks.map((deck) => ({ deck, summary: summarizeDeck(deck, contents?.[deck.id] ?? []) })),
[decks, contents],
);
const formats = useMemo(() => [...new Set(decks.map((d) => d.format))].sort(), [decks]);
const shown = useMemo(() => {
const q = fold(query.trim());
const matches = (r: Row) =>
!q ||
fold(r.deck.name).includes(q) ||
fold(r.deck.format).includes(q) ||
r.summary.commanders.some((c) => fold(c.name).includes(q));
const byName = (a: Row, b: Row) => a.deck.name.localeCompare(b.deck.name, undefined, { sensitivity: "base" });
const desc = (f: (r: Row) => number) => (a: Row, b: Row) => f(b) - f(a) || byName(a, b);
const compare: Record<SortMode, (a: Row, b: Row) => number> = {
recent: desc((r) => openedAt[r.deck.id] ?? 0),
name: byName,
// Decks with no format sink to the bottom rather than leading with "".
format: (a, b) =>
Number(!a.deck.format) - Number(!b.deck.format) ||
a.deck.format.localeCompare(b.deck.format) ||
byName(a, b),
size: desc((r) => r.summary.size),
cost: desc((r) => r.summary.cost),
owned: desc((r) => (r.summary.size ? r.summary.owned : -1)),
};
return rows
.filter((r) => (format === null || r.deck.format === format) && matches(r))
.sort(compare[sort] ?? byName);
}, [rows, query, format, sort, openedAt]);
const filtering = query.trim() !== "" || format !== null;
return (
<div className="deck-gallery">
<div className="deck-gallery-toolbar">
<h2>
Decks {decks.length > 0 && <span className="badge">{decks.length}</span>}
</h2>
<span className="spacer" />
{decks.length > 0 && (
<>
<input
className="deck-gallery-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search decks or commanders"
/>
<div className="deck-gallery-controls">
{formats.length > 1 && (
<select
value={format ?? "*"}
onChange={(e) => setFormat(e.target.value === "*" ? null : e.target.value)}
title="Filter by format"
>
<option value="*">All formats</option>
{formats.map((f) => (
<option key={f} value={f}>
{f || "No format"}
</option>
))}
</select>
)}
<select value={sort} onChange={(e) => setSort(e.target.value as SortMode)} title="Sort decks">
{SORTS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
<ViewToggle mode={view} onChange={setView} />
</div>
</>
)}
<div className="deck-gallery-create">
<IconButton icon={Icons.upload} label="Import deck…" onClick={onImport} />
<button className="primary" onClick={onNew}>
{Icons.plus}
New deck
</button>
</div>
</div>
{decks.length === 0 ? (
<div className="panel deck-gallery-empty">
<span className="deck-gallery-empty-icon">{Icons.cards}</span>
<h3>Build your first deck</h3>
<p className="hint">Start from scratch, or import a decklist exported from another app.</p>
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
<button className="primary" onClick={onNew}>
{Icons.plus}
New deck
</button>
<button onClick={onImport}>
{Icons.upload}
Import decklist
</button>
</div>
</div>
) : shown.length === 0 ? (
<div className="deck-gallery-empty">
<p className="hint">No decks match.</p>
{filtering && (
<button
onClick={() => {
setQuery("");
setFormat(null);
}}
>
Clear filters
</button>
)}
</div>
) : view === "tiles" ? (
<div className="deck-gallery-grid">
{shown.map((r) => (
<DeckTile key={r.deck.id} row={r} actions={actions} />
))}
</div>
) : (
<div className="deck-gallery-rows">
{shown.map((r) => (
<DeckRow key={r.deck.id} row={r} actions={actions} />
))}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { CSSProperties, useEffect, useReducer, useRef, useState } from "react";
import { api, Deck } from "../api";
import { ConfirmDialog, DialogFrame, usePersistedState } from "../components";
import { artCrop, ConfirmDialog, DialogFrame, usePersistedState } from "../components";
import { useBackButton } from "../backButton";
// --- Game state ------------------------------------------------------------
@@ -108,13 +108,6 @@ function randomInt(n: number): number {
return buf[0] % n;
}
/** Scryfall serves every crop size at the same path under a different
* segment, so the stored "normal" card image gives us the art alone. */
function artCrop(imageUri: string): string | null {
if (!imageUri) return null;
return imageUri.replace(/(cards\.scryfall\.io\/)(normal|large|small|png)\//, "$1art_crop/");
}
const PLAYER_COLORS = ["#c8553d", "#3a8fd0", "#479b63", "#8a63c9", "#e0a34a", "#3fa9a5"];
const LIFE_PRESETS = [