);
}
// How long a card decremented to 0 copies lingers (dimmed, still adjustable)
// before actually dropping out of the deck — same grace-period pattern as
// the Library tab, so a mistaken removal can be undone with +.
const REMOVAL_GRACE_MS = 5000;
const STARTING_HAND_SIZE = 7;
const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
interface DeckLegality {
status: "legal" | "illegal" | "unchecked";
reasons: string[];
}
const COMMANDER_DECK_SIZE = 100;
const CONSTRUCTED_MIN_DECK_SIZE = 60;
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".
*/
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).`,
);
}
const commanders = cards.filter((e) => e.is_commander);
if (commanders.length === 0) {
reasons.push(`No commander is set.`);
} else if (commanders.length > 1) {
reasons.push(`More than one card is marked as commander.`);
}
const commander = commanders[0];
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 (commander) {
for (const e of cards) {
if (e.is_commander) continue;
const outsideIdentity = e.color_identity & ~commander.color_identity;
if (outsideIdentity !== 0) {
reasons.push(`${e.name}'s color identity is outside the commander's 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 };
}
/**
* Deck editor. A deck's mainboard cards are split into two groups — "In your
* library" (owned) and "Not owned" — each with its own search box and each
* groupable by card attribute; "Combine sections" merges these two into one
* list. The maybeboard is always shown as its own separate section. Deck-level
* format + commander metadata sit at the top.
*
* `refreshSignal` is bumped by `App` when something outside this view's own
* control changes deck contents — e.g. undoing a Library bulk add-to-deck
* from the command palette while this view is already mounted — so the
* card list, mana curve, and legality tile recompute immediately instead of
* only on the next mount.
*/
export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const [decks, setDecks] = useState([]);
const [selected, setSelected] = usePersistedState(libraryKey("deck-selected"), null);
const [newName, setNewName] = useState("");
const [contents, setContents] = useState([]);
const [view, setView] = useViewMode("deck-view");
const tiles = useTileColumns();
const [groupBy, setGroupBy] = usePersistedState("deck-group", "none");
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
const [filterQuery, setFilterQuery] = useState("");
const [dialog, setDialog] = useState(null);
const [dialogError, setDialogError] = useState(null);
// On narrow screens the deck list and the deck-building interface are
// separate "screens" (only one visible at a time) rather than stacked —
// this only matters below the .deck-layout stacking breakpoint; CSS keeps
// both panes visible side by side above it regardless of this flag.
const [mobileShowList, setMobileShowList] = useState(true);
// Android back from the deck-building screen returns to the deck list, like
// its ← button. Only while they're separate screens (the ≤720px
// `.deck-layout` breakpoint): side by side, flipping the flag changes
// nothing visible, so back would seem to do nothing.
const listIsSeparateScreen = useMediaQuery("(max-width: 720px)");
useBackButton(() => setMobileShowList(true), listIsSeparateScreen && !mobileShowList);
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState([]);
const [anyQuery, setAnyQuery] = useState("");
const [anyResults, setAnyResults] = useState([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState([]);
const [maybeQuery, setMaybeQuery] = useState("");
const [maybeResults, setMaybeResults] = useState([]);
const [pendingRemoval, setPendingRemoval] = useState>(new Set());
const removalTimers = useRef