diff --git a/.claude/SEARCH.md b/.claude/SEARCH.md
index 4a1ced0..f0eea09 100644
--- a/.claude/SEARCH.md
+++ b/.claude/SEARCH.md
@@ -153,14 +153,26 @@ Colors can be specified as:
### Color Operators
-Color comparisons use set theory:
-- `:` or `>=` - Card has at least these colors (superset)
+Color comparisons use set theory. **Important:** The default `:` operator behaves differently for color vs identity:
+
+**For `c:` (color):**
+- `:` or `>=` - Card has at least these colors (superset) - `c:rg` matches Gruul and 3+ color cards
- `=` - Card has exactly these colors
-- `<=` - Card has at most these colors (subset) - useful for commander
+- `<=` - Card has at most these colors (subset)
- `<` - Strict subset
- `>` - Strict superset
- `!=` - Not exactly these colors
+**For `id:`/`ci:` (color identity):**
+- `:` or `<=` - Card fits in a deck with this identity (subset) - `id:rg` matches mono-R, mono-G, and Gruul
+- `>=` - Card has at least this identity (superset) - `id>=rg` matches Gruul and 3+ color cards
+- `=` - Card has exactly this identity
+- `<` - Strict subset
+- `>` - Strict superset
+- `!=` - Not exactly this identity
+
+This matches Scryfall's behavior: `id:rg` finds cards playable in a Gruul commander deck.
+
### Identity Count Queries
The `identity` field also supports numeric comparisons on the *number* of colors:
diff --git a/src/components/SearchPrimer.tsx b/src/components/SearchPrimer.tsx
index d306f12..2526979 100644
--- a/src/components/SearchPrimer.tsx
+++ b/src/components/SearchPrimer.tsx
@@ -180,18 +180,18 @@ export function SearchPrimer() {
{/* Color identity for commander */}
- For commander decks
+ Color identity (for commander)
- goes in
+ fits in
decks
- 2+ color commanders
+ has at least
+
- PEDH in
-
+ 2+ color commanders
diff --git a/src/lib/search/__tests__/describe.test.ts b/src/lib/search/__tests__/describe.test.ts
index 775a316..d7b1056 100644
--- a/src/lib/search/__tests__/describe.test.ts
+++ b/src/lib/search/__tests__/describe.test.ts
@@ -88,7 +88,7 @@ describe("describeQuery", () => {
it.each([
["c:r", "color includes {R}"],
- ["id:bg", "color identity includes {B}{G}"],
+ ["id:bg", "color identity is within {B}{G}"],
["id<=bg", "color identity is within {B}{G}"],
["id>=bg", "color identity includes at least {B}{G}"],
["id=wubrg", "color identity is exactly {W}{U}{B}{R}{G}"],
diff --git a/src/lib/search/__tests__/integration.test.ts b/src/lib/search/__tests__/integration.test.ts
index c600e2b..3089609 100644
--- a/src/lib/search/__tests__/integration.test.ts
+++ b/src/lib/search/__tests__/integration.test.ts
@@ -147,6 +147,27 @@ describe("Scryfall search integration", () => {
}
});
+ it("c: uses superset semantics (at least these colors)", async () => {
+ const bolt = await cards.get("Lightning Bolt"); // R
+ const bte = await cards.get("Burning-Tree Emissary"); // RG
+
+ // c:r means "at least red" - matches mono-R and multicolor with R
+ const atLeastRed = search("c:r");
+ expect(atLeastRed.ok).toBe(true);
+ if (atLeastRed.ok) {
+ expect(atLeastRed.value.match(bolt)).toBe(true); // R contains R
+ expect(atLeastRed.value.match(bte)).toBe(true); // RG contains R
+ }
+
+ // c:rg means "at least RG" - only matches cards with both
+ const atLeastGruul = search("c:rg");
+ expect(atLeastGruul.ok).toBe(true);
+ if (atLeastGruul.ok) {
+ expect(atLeastGruul.value.match(bolt)).toBe(false); // R doesn't contain G
+ expect(atLeastGruul.value.match(bte)).toBe(true); // RG contains RG
+ }
+ });
+
it("c: differs from id: (color vs color identity)", async () => {
const forest = await cards.get("Forest");
@@ -174,6 +195,38 @@ describe("Scryfall search integration", () => {
});
describe("color identity matching", () => {
+ it("id: uses subset semantics (commander deckbuilding)", async () => {
+ const bolt = await cards.get("Lightning Bolt"); // R
+ const elves = await cards.get("Llanowar Elves"); // G
+ const bte = await cards.get("Burning-Tree Emissary"); // RG
+
+ // id:rg means "identity fits in Gruul" (subset)
+ const gruul = search("id:rg");
+ expect(gruul.ok).toBe(true);
+ if (gruul.ok) {
+ expect(gruul.value.match(bolt)).toBe(true); // R fits in RG
+ expect(gruul.value.match(elves)).toBe(true); // G fits in RG
+ expect(gruul.value.match(bte)).toBe(true); // RG fits in RG
+ }
+
+ // id:r should NOT match BTE (RG doesn't fit in mono-R)
+ const monoRed = search("id:r");
+ expect(monoRed.ok).toBe(true);
+ if (monoRed.ok) {
+ expect(monoRed.value.match(bolt)).toBe(true); // R fits in R
+ expect(monoRed.value.match(bte)).toBe(false); // RG doesn't fit in R
+ }
+
+ // id>=rg means "identity contains at least RG" (superset)
+ const atLeastGruul = search("id>=rg");
+ expect(atLeastGruul.ok).toBe(true);
+ if (atLeastGruul.ok) {
+ expect(atLeastGruul.value.match(bolt)).toBe(false); // R doesn't contain G
+ expect(atLeastGruul.value.match(elves)).toBe(false); // G doesn't contain R
+ expect(atLeastGruul.value.match(bte)).toBe(true); // RG contains RG
+ }
+ });
+
it("id<= matches subset (commander deckbuilding)", async () => {
const bolt = await cards.get("Lightning Bolt");
const elves = await cards.get("Llanowar Elves");
diff --git a/src/lib/search/__tests__/parser.test.ts b/src/lib/search/__tests__/parser.test.ts
index 524ff57..bf9b063 100644
--- a/src/lib/search/__tests__/parser.test.ts
+++ b/src/lib/search/__tests__/parser.test.ts
@@ -97,6 +97,14 @@ describe("parse", () => {
}
});
+ it("parses ci: as identity alias", () => {
+ const node = expectParse("ci:wubrg");
+ expect(node.type).toBe("FIELD");
+ if (node.type === "FIELD") {
+ expect(node.field).toBe("identity");
+ }
+ });
+
it("parses numeric fields", () => {
const node = expectParse("cmc>=3");
expect(node.type).toBe("FIELD");
diff --git a/src/lib/search/colors.ts b/src/lib/search/colors.ts
index 25e36d9..43cd0f0 100644
--- a/src/lib/search/colors.ts
+++ b/src/lib/search/colors.ts
@@ -2,9 +2,11 @@
* Color set comparison utilities for Scryfall search
*
* Scryfall uses set theory for color comparisons:
- * - : or >= means "superset of" (card has at least these colors)
+ * - For color (c:), : means "superset of" (card has at least these colors)
+ * - For identity (id:/ci:), : means "subset of" (card fits in this commander's deck)
+ * - >= means "superset of" (card has at least these colors)
* - = means "exactly these colors"
- * - <= means "subset of" (card has at most these colors) - key for commander deckbuilding
+ * - <= means "subset of" (card has at most these colors)
* - < means "strict subset"
* - > means "strict superset"
*/
diff --git a/src/lib/search/describe.ts b/src/lib/search/describe.ts
index a236299..5a64a26 100644
--- a/src/lib/search/describe.ts
+++ b/src/lib/search/describe.ts
@@ -66,6 +66,12 @@ const COLOR_OPERATOR_LABELS: Record = {
">=": "includes at least",
};
+// For identity, : means "is within" (subset) for commander deckbuilding
+const IDENTITY_OPERATOR_LABELS: Record = {
+ ...COLOR_OPERATOR_LABELS,
+ ":": "is within",
+};
+
const WUBRG_ORDER = ["W", "U", "B", "R", "G"];
/**
@@ -201,7 +207,6 @@ function describeValue(value: FieldValue, quoted = true): string {
function describeField(node: FieldNode): string {
const fieldLabel = FIELD_LABELS[node.field];
- const isColorField = node.field === "color" || node.field === "identity";
// Special handling for identity count queries (id>1, id=2, etc.)
if (node.field === "identity" && node.value.kind === "number") {
@@ -243,7 +248,9 @@ function describeField(node: FieldNode): string {
// But regex always uses "includes" since it's a pattern match, not exact
// "in" field is special - the label already implies the relationship
let opLabel: string;
- if (isColorField) {
+ if (node.field === "identity") {
+ opLabel = IDENTITY_OPERATOR_LABELS[node.operator];
+ } else if (node.field === "color") {
opLabel = COLOR_OPERATOR_LABELS[node.operator];
} else if (node.field === "in" && node.operator === ":") {
opLabel = "";
diff --git a/src/lib/search/fields.ts b/src/lib/search/fields.ts
index 59fa7d2..cf51abe 100644
--- a/src/lib/search/fields.ts
+++ b/src/lib/search/fields.ts
@@ -90,7 +90,7 @@ export function compileField(
case "color":
return ok(compileColorField((c) => c.colors, operator, value));
- case "identity":
+ case "identity": {
// Numeric comparison: id>1 means "more than 1 color in identity"
if (value.kind === "number") {
return ok(
@@ -101,7 +101,12 @@ export function compileField(
),
);
}
- return ok(compileColorField((c) => c.color_identity, operator, value));
+ // For identity, default : means "subset" (<=) not "superset" (>=)
+ // This matches Scryfall's commander deckbuilding semantics:
+ // id:rg finds cards playable in a Gruul deck (identity within RG)
+ const identityOp = operator === ":" ? "<=" : operator;
+ return ok(compileColorField((c) => c.color_identity, identityOp, value));
+ }
// Mana fields
case "mana":
diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts
index b429f0f..2f826e6 100644
--- a/src/lib/search/types.ts
+++ b/src/lib/search/types.ts
@@ -136,6 +136,7 @@ export const FIELD_ALIASES: Record = {
// Colors
c: "color",
color: "color",
+ ci: "identity",
id: "identity",
identity: "identity",
// Mana