Add colorless filters, and make set listings filterable

Colorless

There was no way to ask for colorless cards — the colour toggles are a 5-bit
WUBRG mask where 0 already means "don't filter by colour", leaving nothing to
express "no colours at all". That's exactly what you need to find a colorless
commander's card pool.

Adds a "C" pip to both colour rows, matching how ColorPips already renders
colorless cards, backed by two explicit booleans rather than a sixth bit — a
filter mask and a card's real colour mask staying the same shape is worth
keeping. The two rows mean different things and both are useful: on the
commander row C filters colour *identity*, which is the Commander rule; on the
includes row it filters the card's own colours. A card printed colorless can
still carry a coloured identity through mana symbols in its text, and it's the
identity that bars it from a colorless commander's deck.

Set listings

News > Releases had its own card path and so couldn't be filtered. A new
set_code filter (matched against printings.set_code, already ingested) lets the
set browser run through search_reference instead, so every criterion works
there identically rather than being reimplemented over a live Scryfall
response.

That alone would have cost the ability to browse an upcoming set, since
search_reference only knows downloaded card data — and upcoming sets are half
the point of the tab. So the browser picks a source per set: downloaded data
where it exists, with the full filter panel; otherwise Scryfall directly, with
the panel hidden and a line explaining why. A panel that silently does nothing
would be worse than no panel.

The source is decided by an *unfiltered* probe. Keying off whether the current
criteria match anything would flip a properly indexed set to "live" the moment
a filter excluded everything, dropping the panel exactly when it was in use.
This commit is contained in:
2026-08-10 22:34:46 -05:00
parent dff52a9d83
commit dfba2403a8
7 changed files with 345 additions and 17 deletions

View File

@@ -110,6 +110,14 @@ export interface LibraryFilter {
legal_in?: string | null;
/** Only cards that can head a Commander deck. */
can_be_commander?: boolean;
/** Only cards printed in this set (matched against printings.set_code).
* Lets the release browser reuse the whole criteria pipeline. */
set_code?: string | null;
/** Only cards with an empty color identity — a colorless commander's pool.
* Separate from the color bitmasks, where 0 means "no filter". */
colorless_identity?: boolean;
/** Only cards that are themselves colorless. */
colorless_colors?: boolean;
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
* since those can't be compared numerically. */
power_min?: number | null;

View File

@@ -1376,9 +1376,20 @@ export function CardTile({
export function ColorToggles({
value,
onChange,
colorless,
onColorlessChange,
colorlessTitle,
}: {
value: number;
onChange: (mask: number) => void;
/** Renders a "C" pip alongside the colours when provided.
*
* Colourlessness can't live in the bitmask: 0 there already means "don't
* filter by colour", so there'd be no way to say "no colours at all" — which
* is exactly what you need to find a colourless commander's card pool. */
colorless?: boolean;
onColorlessChange?: (v: boolean) => void;
colorlessTitle?: string;
}) {
return (
<span className="color-toggles">
@@ -1392,6 +1403,15 @@ export function ColorToggles({
{c.code}
</span>
))}
{onColorlessChange && (
<span
title={colorlessTitle ?? "Colorless"}
className={`pip C ${colorless ? "on" : ""}`}
onClick={() => onColorlessChange(!colorless)}
>
C
</span>
)}
</span>
);
}

View File

@@ -48,6 +48,9 @@ export function CardSearch({
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
const [colorlessIdentity, setColorlessIdentity] = useState(false);
const [colorlessColors, setColorlessColors] = useState(false);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
@@ -89,6 +92,8 @@ export function CardSearch({
text.trim() !== "" ||
commander !== 0 ||
colorsInclude !== 0 ||
colorlessIdentity ||
colorlessColors ||
cmcMin !== "" ||
cmcMax !== "" ||
types.length > 0 ||
@@ -107,6 +112,8 @@ export function CardSearch({
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
colorless_identity: colorlessIdentity,
colorless_colors: colorlessColors,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
@@ -133,6 +140,8 @@ export function CardSearch({
text,
commander,
colorsInclude,
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
@@ -454,11 +463,23 @@ export function CardSearch({
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
<ColorToggles
value={commander}
onChange={setCommander}
colorless={colorlessIdentity}
onColorlessChange={setColorlessIdentity}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
<ColorToggles
value={colorsInclude}
onChange={setColorsInclude}
colorless={colorlessColors}
onColorlessChange={setColorlessColors}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>

View File

@@ -49,6 +49,9 @@ export function LibraryBrowser({
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
// Colourlessness can't ride in the bitmask — 0 there means "no filter".
const [colorlessIdentity, setColorlessIdentity] = useState(false);
const [colorlessColors, setColorlessColors] = useState(false);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [types, setTypes] = useState<string[]>([]);
@@ -119,6 +122,8 @@ export function LibraryBrowser({
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
colorless_identity: colorlessIdentity,
colorless_colors: colorlessColors,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types,
@@ -137,6 +142,8 @@ export function LibraryBrowser({
text,
commander,
colorsInclude,
colorlessIdentity,
colorlessColors,
cmcMin,
cmcMax,
types,
@@ -538,11 +545,23 @@ export function LibraryBrowser({
<div className={`filters-more ${filtersExpanded ? "expanded" : ""}`}>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
<ColorToggles
value={commander}
onChange={setCommander}
colorless={colorlessIdentity}
onColorlessChange={setColorlessIdentity}
colorlessTitle="Colorless commander — only cards with no color identity"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
<ColorToggles
value={colorsInclude}
onChange={setColorsInclude}
colorless={colorlessColors}
onColorlessChange={setColorlessColors}
colorlessTitle="Colorless cards only"
/>
</div>
<div className="field">
<label>Mana value</label>

View File

@@ -1,7 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { api, News, SetCard, SetInfo } from "../api";
import { CardTile, QtyStepper } from "../components";
import { api, LibraryCard, LibraryFilter, News, SetCard, SetInfo } from "../api";
import {
CardCriteriaFields,
CardTile,
ColorToggles,
QtyStepper,
applyCardCriteria,
useCardCriteria,
} from "../components";
/** Sets whose release date is still ahead of us, newest-announced first. */
function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } {
@@ -59,8 +66,21 @@ export function NewsView() {
const [showAll, setShowAll] = useState(false);
const [openSet, setOpenSet] = useState<SetInfo | null>(null);
const [cards, setCards] = useState<SetCard[] | null>(null);
const [cards, setCards] = useState<(LibraryCard | SetCard)[] | null>(null);
// Where a set's cards come from. "local" reads downloaded card data, which is
// what makes the criteria filters possible; "live" queries Scryfall directly
// for a set that isn't in reference.db yet — an upcoming or newly-released
// one — which can't be filtered, so the panel is hidden rather than shown
// doing nothing. Null while we work out which.
const [source, setSource] = useState<"local" | "live" | null>(null);
const [cardsError, setCardsError] = useState<string | null>(null);
// The same criteria the Library and Card Search panels use — the set browser
// runs through search_reference rather than its own path, so every filter
// works here identically instead of being reimplemented.
const [setText, setSetText] = useState("");
const [setColors, setSetColors] = useState(0);
const [setColorless, setSetColorless] = useState(false);
const [setCriteria, patchSetCriteria] = useCardCriteria("news-set-criteria");
const load = useCallback(async (refresh: boolean) => {
setBusy(true);
@@ -78,17 +98,83 @@ export function NewsView() {
load(false);
}, [load]);
async function chooseSet(set: SetInfo) {
function openSetBrowser(set: SetInfo) {
setOpenSet(set);
setCards(null);
setCardsError(null);
try {
setCards(await api.getSetCards(set.code));
} catch (err) {
setCardsError(String(err));
}
setSource(null);
}
// Decide the source once per set, with an *unfiltered* probe: asking whether
// the current criteria match anything would flip a perfectly indexed set to
// "live" the moment a filter excluded everything.
useEffect(() => {
if (!openSet) return;
let cancelled = false;
setSource(null);
api
.searchReference({ set_code: openSet.code }, 1)
.then((r) => !cancelled && setSource(r.length > 0 ? "local" : "live"))
.catch(() => !cancelled && setSource("live"));
return () => {
cancelled = true;
};
}, [openSet]);
// Not in the downloaded card data: fetch the set straight from Scryfall so an
// upcoming set is still browsable, just without filters.
useEffect(() => {
if (!openSet || source !== "live") return;
let cancelled = false;
api
.getSetCards(openSet.code)
.then((r) => {
if (cancelled) return;
setCards(r);
setCardsError(null);
})
.catch((e) => {
if (cancelled) return;
setCards([]);
setCardsError(String(e));
});
return () => {
cancelled = true;
};
}, [openSet, source]);
// Refetch whenever the set or any criterion changes, debounced like the other
// filter panels so typing doesn't fire a query per keystroke.
useEffect(() => {
if (!openSet || source !== "local") return;
const filter: LibraryFilter = {
set_code: openSet.code,
text: setText.trim() || null,
colors_include: setColors || null,
colorless_colors: setColorless,
...applyCardCriteria(setCriteria),
};
let cancelled = false;
const t = setTimeout(() => {
api
.searchReference(filter, 500)
.then((r) => {
if (cancelled) return;
setCards(r);
setCardsError(null);
})
.catch((e) => {
if (cancelled) return;
setCards([]);
setCardsError(String(e));
});
}, 120);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [openSet, source, setText, setColors, setColorless, setCriteria]);
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
setCards((cs) =>
@@ -113,17 +199,59 @@ export function NewsView() {
{formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "}
{openSet.code.toUpperCase()}
</div>
{/* Filters run against downloaded card data, so they only exist in
"local" mode. Showing a panel that silently does nothing would be
worse than not showing one. */}
{source === "local" && (
<div className="panel workspace">
<div className="filters">
<div className="field">
<label>Name contains</label>
<input
value={setText}
onChange={(e) => setSetText(e.target.value)}
placeholder="e.g. bolt"
/>
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles
value={setColors}
onChange={setSetColors}
colorless={setColorless}
onColorlessChange={setSetColorless}
colorlessTitle="Colorless cards only"
/>
</div>
<CardCriteriaFields value={setCriteria} onChange={patchSetCriteria} />
</div>
</div>
)}
{source === "live" && (
<p className="hint">
This set isn't in your downloaded card data yet, so it's being read
straight from Scryfall — filters aren't available here. Run{" "}
<strong>Update card data</strong> once it's out to filter it like the
rest.
</p>
)}
{cardsError ? (
<p className="hint">{cardsError}</p>
) : !cards ? (
) : !cards || source === null ? (
<p className="hint">Loading cards…</p>
) : cards.length === 0 ? (
<p className="hint">No cards listed for this set yet.</p>
<p className="hint">
{source === "local"
? "No cards match these filters."
: "No cards listed for this set yet."}
</p>
) : (
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
{cards.map((c) => (
<CardTile
key={c.oracle_id + c.collector_number}
key={c.oracle_id}
card={c}
corner={
<QtyStepper
@@ -151,7 +279,7 @@ export function NewsView() {
<li
key={s.code}
className={`set-row ${upcoming ? "upcoming" : ""}`}
onClick={() => chooseSet(s)}
onClick={() => openSetBrowser(s)}
>
{/* Fixed-width date gutter: every set name then starts at the same x,
which is what turns this from a list into a timeline. */}