-
+
diff --git a/app/_components/header-search/header-search-bar.tsx b/app/_components/header-search/header-search-bar.tsx
index 422659e..c1785c8 100644
--- a/app/_components/header-search/header-search-bar.tsx
+++ b/app/_components/header-search/header-search-bar.tsx
@@ -1,20 +1,17 @@
"use client";
-import { useSyncExternalStore } from "react";
import { useHeaderSearch } from "@/app/_components/header-search/header-search-context";
import { SimpleBar } from "./simple-bar";
import { DeckModeBar } from "./deck-mode-bar";
-const noopSubscribe = () => () => {};
-
export function HeaderSearchBar() {
const { deckRoute } = useHeaderSearch();
- const hydrated = useSyncExternalStore(
- noopSubscribe,
- () => true,
- () => false,
- );
- if (hydrated && deckRoute) {
+ // deckRoute is null on the server and on the first client render (it is
+ // seeded by DeckRouteBridge's layout effect), so SSR and hydration both
+ // render SimpleBar — no mismatch. The layout effect then sets deckRoute
+ // before paint, so DeckModeBar (and its owner controls) appears on the
+ // first visible frame rather than a frame later.
+ if (deckRoute) {
return
;
}
return
;
diff --git a/app/_components/header-search/header-search-context.tsx b/app/_components/header-search/header-search-context.tsx
index 0e6a288..159bf41 100644
--- a/app/_components/header-search/header-search-context.tsx
+++ b/app/_components/header-search/header-search-context.tsx
@@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -12,6 +13,11 @@ import {
} from "react";
import { Zone } from "@/lib/generated/prisma/enums";
+// useLayoutEffect on the server warns; fall back to useEffect there. The
+// component using it (DeckRouteBridge) renders null on the server anyway.
+const useIsomorphicLayoutEffect =
+ typeof window !== "undefined" ? useLayoutEffect : useEffect;
+
export interface DeckRouteSignal {
deckId: string;
isOwner: boolean;
@@ -165,7 +171,11 @@ export function HeaderSearchProvider({ children }: { children: ReactNode }) {
*/
export function DeckRouteBridge({ deckId, isOwner }: DeckRouteSignal) {
const { registerDeckRoute } = useHeaderSearch();
- useEffect(() => {
+ // Layout effect (not useEffect) so the route registers before the browser
+ // paints — otherwise the header paints SimpleBar first, then swaps in
+ // DeckModeBar a frame later, making the owner controls (e.g. the Browse
+ // cards icon) appear to load in slowly.
+ useIsomorphicLayoutEffect(() => {
registerDeckRoute({ deckId, isOwner });
return () => registerDeckRoute(null);
}, [registerDeckRoute, deckId, isOwner]);
diff --git a/app/api/cards/browse/route.test.ts b/app/api/cards/browse/route.test.ts
new file mode 100644
index 0000000..3211add
--- /dev/null
+++ b/app/api/cards/browse/route.test.ts
@@ -0,0 +1,141 @@
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/rate-limit/redis", () => ({ rateLimit: vi.fn() }));
+vi.mock("@/lib/rate-limit/request", () => ({ getClientIp: vi.fn(() => "1.2.3.4") }));
+vi.mock("@/lib/search/card-search", () => ({ searchCardsBySyntax: vi.fn() }));
+
+import { GET } from "./route";
+import { rateLimit } from "@/lib/rate-limit/redis";
+import { searchCardsBySyntax } from "@/lib/search/card-search";
+
+const rateLimitMock = vi.mocked(rateLimit);
+const searchMock = vi.mocked(searchCardsBySyntax);
+
+function req(path: string) {
+ return new NextRequest(`http://localhost${path}`);
+}
+
+function allow() {
+ rateLimitMock.mockResolvedValue({
+ success: true,
+ limit: 90,
+ remaining: 89,
+ resetSeconds: 60,
+ });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("GET /api/cards/browse", () => {
+ it("returns 429 with rate-limit headers when denied", async () => {
+ rateLimitMock.mockResolvedValue({
+ success: false,
+ limit: 90,
+ remaining: 0,
+ resetSeconds: 7,
+ });
+
+ const res = await GET(req("/api/cards/browse?q=c:U"));
+
+ expect(res.status).toBe(429);
+ expect(res.headers.get("Retry-After")).toBe("7");
+ expect(searchMock).not.toHaveBeenCalled();
+ });
+
+ it("returns an empty array for a missing query without hitting the table", async () => {
+ allow();
+
+ const res = await GET(req("/api/cards/browse"));
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual([]);
+ expect(searchMock).not.toHaveBeenCalled();
+ expect(res.headers.get("X-RateLimit-Remaining")).toBe("89");
+ });
+
+ it("returns an empty array for a whitespace query", async () => {
+ allow();
+
+ const res = await GET(req("/api/cards/browse?q=%20%20"));
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual([]);
+ expect(searchMock).not.toHaveBeenCalled();
+ });
+
+ it("parses the syntax and returns results with pagination", async () => {
+ allow();
+ const results = [{ id: 1, name: "Counterspell" }];
+ searchMock.mockResolvedValue(
+ results as unknown as Awaited
>,
+ );
+
+ const res = await GET(req("/api/cards/browse?q=c%3AU+t%3Ainstant&limit=20&offset=40"));
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual(results);
+ const [parsed, colors, types, limit, offset] = searchMock.mock.calls[0]!;
+ expect(parsed.colors).toEqual(["U"]);
+ expect(parsed.typeFragments).toEqual(["instant"]);
+ expect(colors).toEqual([]);
+ expect(types).toEqual([]);
+ expect(limit).toBe(20);
+ expect(offset).toBe(40);
+ });
+
+ it("normalizes a non-numeric limit to the default", async () => {
+ allow();
+ searchMock.mockResolvedValue(
+ [] as unknown as Awaited>,
+ );
+
+ const res = await GET(req("/api/cards/browse?q=c%3AU&limit=abc&offset=0"));
+
+ expect(res.status).toBe(200);
+ const [, , , limit, offset] = searchMock.mock.calls[0]!;
+ expect(limit).toBe(60);
+ expect(offset).toBe(0);
+ });
+
+ it("clamps a negative offset to zero", async () => {
+ allow();
+ searchMock.mockResolvedValue(
+ [] as unknown as Awaited>,
+ );
+
+ const res = await GET(req("/api/cards/browse?q=c%3AU&limit=20&offset=-1"));
+
+ expect(res.status).toBe(200);
+ const [, , , limit, offset] = searchMock.mock.calls[0]!;
+ expect(limit).toBe(20);
+ expect(offset).toBe(0);
+ });
+
+ it("caps a very large offset to bound deep scans", async () => {
+ allow();
+ searchMock.mockResolvedValue(
+ [] as unknown as Awaited>,
+ );
+
+ const res = await GET(
+ req("/api/cards/browse?q=c%3AU&limit=200&offset=999999999"),
+ );
+
+ expect(res.status).toBe(200);
+ const [, , , limit, offset] = searchMock.mock.calls[0]!;
+ expect(limit).toBe(120); // clamped to MAX_LIMIT
+ expect(offset).toBe(10_000); // clamped to MAX_OFFSET
+ });
+
+ it("returns 400 when the query exceeds the max length", async () => {
+ allow();
+
+ const res = await GET(req(`/api/cards/browse?q=${"a".repeat(65)}`));
+
+ expect(res.status).toBe(400);
+ expect(searchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/api/cards/browse/route.ts b/app/api/cards/browse/route.ts
new file mode 100644
index 0000000..c658d7f
--- /dev/null
+++ b/app/api/cards/browse/route.ts
@@ -0,0 +1,75 @@
+import { type NextRequest } from "next/server";
+import { searchCardsBySyntax } from "@/lib/search/card-search";
+import { parseSyntax } from "@/lib/search/syntax-parser";
+import { rateLimit } from "@/lib/rate-limit/redis";
+import { getClientIp } from "@/lib/rate-limit/request";
+
+const MAX_Q_LENGTH = 64;
+const RATE_LIMIT = 90;
+const RATE_WINDOW_SECONDS = 60;
+const DEFAULT_LIMIT = 60;
+const MAX_LIMIT = 120;
+// Cap offset to bound how deep a paginated scan can go — large offsets force
+// the DB to skip a huge prefix, which is expensive and never reached in the UI.
+const MAX_OFFSET = 10_000;
+
+export async function GET(request: NextRequest) {
+ const ip = getClientIp(request);
+ const limit = await rateLimit(`cards-browse:${ip}`, RATE_LIMIT, RATE_WINDOW_SECONDS);
+
+ if (!limit.success) {
+ return Response.json(
+ { error: "Too many requests" },
+ {
+ status: 429,
+ headers: {
+ "Retry-After": String(limit.resetSeconds),
+ "X-RateLimit-Limit": String(limit.limit),
+ "X-RateLimit-Remaining": "0",
+ "X-RateLimit-Reset": String(limit.resetSeconds),
+ },
+ },
+ );
+ }
+
+ const rateHeaders = {
+ "X-RateLimit-Limit": String(limit.limit),
+ "X-RateLimit-Remaining": String(limit.remaining),
+ "X-RateLimit-Reset": String(limit.resetSeconds),
+ };
+
+ const q = request.nextUrl.searchParams.get("q");
+
+ // Empty/whitespace query returns nothing — the browse grid must never stream
+ // the entire card table when no filter is active.
+ if (!q || !q.trim()) {
+ return Response.json([], { headers: rateHeaders });
+ }
+
+ if (q.trim().length > MAX_Q_LENGTH) {
+ return Response.json(
+ { error: `Query parameter q must be ${MAX_Q_LENGTH} characters or fewer` },
+ { status: 400, headers: rateHeaders },
+ );
+ }
+
+ const parsedLimit = parseInt(
+ request.nextUrl.searchParams.get("limit") ?? String(DEFAULT_LIMIT),
+ 10,
+ );
+ const pageLimit = Number.isFinite(parsedLimit)
+ ? Math.max(1, Math.min(MAX_LIMIT, Math.trunc(parsedLimit)))
+ : DEFAULT_LIMIT;
+
+ const parsedOffset = parseInt(
+ request.nextUrl.searchParams.get("offset") ?? "0",
+ 10,
+ );
+ const offset = Number.isFinite(parsedOffset)
+ ? Math.min(MAX_OFFSET, Math.max(0, Math.trunc(parsedOffset)))
+ : 0;
+
+ const parsed = parseSyntax(q.trim());
+ const results = await searchCardsBySyntax(parsed, [], [], pageLimit, offset);
+ return Response.json(results, { headers: rateHeaders });
+}
diff --git a/app/globals.css b/app/globals.css
index f78bcc2..99166bc 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -263,4 +263,108 @@
font-family: "Arial", sans-serif;
text-align: center;
z-index: 1;
-}
\ No newline at end of file
+}
+/* ── Card browser (issue #22) ─────────────────────────────────────────────
+ Range slider, scrollbars, and paint-safe entrance motion ported from the
+ design handoff. Every entrance keyframe rests at opacity:1 with only a small
+ transform offset, so a frozen timeline (reduced motion, throttled tab) still
+ paints the element fully visible at its resting position. */
+
+/* Thin, on-brand scrollbars (panel body) */
+.scroll-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+}
+.scroll-thin::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+.scroll-thin::-webkit-scrollbar-thumb {
+ background: color-mix(in oklab, var(--foreground) 18%, transparent);
+ border-radius: 99px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+.scroll-thin::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklab, var(--foreground) 30%, transparent);
+}
+
+/* Hide scrollbar but keep scrolling (tray filmstrip) */
+.scroll-none {
+ scrollbar-width: none;
+}
+.scroll-none::-webkit-scrollbar {
+ display: none;
+}
+
+/* Range slider (mana value) — minimal, monochrome */
+input[type="range"].md-range {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 3px;
+ border-radius: 99px;
+ background: var(--border);
+ outline: none;
+}
+input[type="range"].md-range::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--foreground);
+ cursor: pointer;
+ border: 2px solid var(--background);
+}
+input[type="range"].md-range::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--foreground);
+ cursor: pointer;
+ border: 2px solid var(--background);
+}
+
+/* Entrance motion — paint-safe (see note above) */
+.anim-fade,
+.anim-slide-right,
+.anim-slide-up {
+ opacity: 1;
+}
+
+@keyframes md-slide-right {
+ from {
+ transform: translateX(16px);
+ }
+ to {
+ transform: none;
+ }
+}
+@keyframes md-slide-up {
+ from {
+ transform: translateY(16px);
+ }
+ to {
+ transform: none;
+ }
+}
+@keyframes md-slide-subtle {
+ from {
+ transform: translateY(4px);
+ }
+ to {
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .anim-fade {
+ animation: md-slide-subtle 0.18s ease both;
+ }
+ .anim-slide-right {
+ animation: md-slide-right 0.26s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+ }
+ .anim-slide-up {
+ animation: md-slide-up 0.28s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+ }
+}
diff --git a/lib/deck/__tests__/editor-actions.test.ts b/lib/deck/__tests__/editor-actions.test.ts
index 5f9e23b..38dd536 100644
--- a/lib/deck/__tests__/editor-actions.test.ts
+++ b/lib/deck/__tests__/editor-actions.test.ts
@@ -36,6 +36,7 @@ import {
type PlannedChange,
} from "@/lib/deck/mutation";
import {
+ addCardsToDeck,
addCardToDeck,
bulkUpdateDeck,
removeCardFromDeck,
@@ -142,6 +143,95 @@ describe("addCardToDeck", () => {
});
});
+// ---------------------------------------------------------------------------
+// addCardsToDeck
+// ---------------------------------------------------------------------------
+
+describe("addCardsToDeck", () => {
+ it("maps each card to an add op in one applyChanges call", async () => {
+ asOwner();
+
+ await addCardsToDeck(DECK_ID, [
+ { cardId: 1, quantity: 2 },
+ { cardId: 2 },
+ ]);
+
+ expect(changesPassedToApply()).toEqual([
+ { op: "add", cardId: 1, quantity: 2, zone: Zone.MAINBOARD, category: null },
+ { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, category: null },
+ ]);
+ });
+
+ it("per-card zone/category win over the shared opts fallback", async () => {
+ asOwner();
+
+ await addCardsToDeck(
+ DECK_ID,
+ [
+ { cardId: 1, zone: Zone.SIDEBOARD },
+ { cardId: 2, category: "Ramp" },
+ ],
+ { zone: Zone.MAINBOARD },
+ );
+
+ expect(changesPassedToApply()).toEqual([
+ { op: "add", cardId: 1, quantity: 1, zone: Zone.SIDEBOARD, category: null },
+ { op: "add", cardId: 2, quantity: 1, zone: Zone.MAINBOARD, category: "Ramp" },
+ ]);
+ });
+
+ it("falls back to opts zone/category when a card omits them", async () => {
+ asOwner();
+
+ await addCardsToDeck(DECK_ID, [{ cardId: 7 }], {
+ zone: Zone.MAINBOARD,
+ category: "Lands",
+ });
+
+ expect(changesPassedToApply()).toEqual([
+ { op: "add", cardId: 7, quantity: 1, zone: Zone.MAINBOARD, category: "Lands" },
+ ]);
+ });
+
+ it("rejects a category on a non-MAINBOARD zone before invoking applyChanges", async () => {
+ asOwner();
+
+ await expect(
+ addCardsToDeck(DECK_ID, [{ cardId: 1, zone: Zone.SIDEBOARD, category: "Ramp" }]),
+ ).rejects.toThrow("Subcategories only apply to MAINBOARD cards");
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+
+ it("swallows InvariantViolation so the action is a silent no-op", async () => {
+ asOwner();
+ mockApply.mockRejectedValueOnce(
+ new InvariantViolation([
+ { kind: "singleton_violation", cardName: "Sol Ring", quantity: 2 },
+ ]),
+ );
+
+ await expect(
+ addCardsToDeck(DECK_ID, [{ cardId: 1 }]),
+ ).resolves.toBeUndefined();
+ });
+
+ it("propagates non-InvariantViolation errors", async () => {
+ asOwner();
+ mockApply.mockRejectedValueOnce(new Error("boom"));
+
+ await expect(addCardsToDeck(DECK_ID, [{ cardId: 1 }])).rejects.toThrow("boom");
+ });
+
+ it("404s for non-owners", async () => {
+ asOutsider();
+
+ await expect(addCardsToDeck(DECK_ID, [{ cardId: 1 }])).rejects.toThrow(
+ "NEXT_NOT_FOUND",
+ );
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+});
+
// ---------------------------------------------------------------------------
// removeCardFromDeck
// ---------------------------------------------------------------------------
diff --git a/lib/deck/editor-actions.ts b/lib/deck/editor-actions.ts
index 0958a72..e6be8d4 100644
--- a/lib/deck/editor-actions.ts
+++ b/lib/deck/editor-actions.ts
@@ -37,6 +37,32 @@ export const addCardToDeck = runOwnerDeckMutation(
},
);
+export const addCardsToDeck = runOwnerDeckMutation(
+ "deck.addCards",
+ "none",
+ async (
+ { deckId, userId },
+ cards: { cardId: number; quantity?: number; zone?: Zone; category?: string | null }[],
+ opts?: { zone?: Zone; category?: string | null },
+ ): Promise => {
+ const changes = cards.map((c) => {
+ const zone = c.zone ?? opts?.zone ?? Zone.MAINBOARD;
+ const category = c.category ?? opts?.category ?? null;
+ if (category !== null && zone !== Zone.MAINBOARD) {
+ throw new Error("Subcategories only apply to MAINBOARD cards");
+ }
+ return { op: "add" as const, cardId: c.cardId, quantity: c.quantity ?? 1, zone, category };
+ });
+
+ try {
+ await applyChanges(deckId, userId, changes); // one tx + one revalidation
+ } catch (err) {
+ if (err instanceof InvariantViolation) return;
+ throw err;
+ }
+ },
+);
+
export const removeCardFromDeck = runOwnerDeckMutation(
"deck.removeCard",
"none",
diff --git a/lib/search/__tests__/card-search.test.ts b/lib/search/__tests__/card-search.test.ts
index c8353cd..1687b2f 100644
--- a/lib/search/__tests__/card-search.test.ts
+++ b/lib/search/__tests__/card-search.test.ts
@@ -45,6 +45,15 @@ function emptyParsed(overrides: Partial = {}): ParsedWhere {
};
}
+type SqlCall = { sql?: string; strings?: string[]; values: unknown[] };
+
+/** Flatten the composed Prisma.Sql of a $queryRaw call into a searchable haystack + values. */
+function inspect(): { text: string; values: unknown[] } {
+ const call = mockQueryRaw.mock.calls[0]![0] as SqlCall;
+ const text = call.sql ?? (call.strings ?? []).join("?");
+ return { text, values: call.values };
+}
+
describe("searchCards", () => {
it("returns [] for whitespace-only query without hitting the database", async () => {
const result = await searchCards(" ");
@@ -115,15 +124,45 @@ describe("searchCards", () => {
});
describe("searchCardsBySyntax", () => {
- it("returns mapped rows when no conditions are present (Prisma.empty branch)", async () => {
- mockQueryRaw.mockResolvedValue([RAW_ROW] as never);
-
+ it("short-circuits to [] when no conditions are present, never hitting the database", async () => {
const result = await searchCardsBySyntax(emptyParsed());
- expect(mockQueryRaw).toHaveBeenCalledTimes(1);
- expect(mockCacheTag).toHaveBeenCalledWith("card-search");
- expect(result).toHaveLength(1);
- expect(result[0]?.name).toBe("Lightning Bolt");
+ expect(result).toEqual([]);
+ expect(mockQueryRaw).not.toHaveBeenCalled();
+ });
+
+ it("filters colors on color_identity (not the printed colors column)", async () => {
+ mockQueryRaw.mockResolvedValue([] as never);
+
+ await searchCardsBySyntax(emptyParsed({ colors: ["U"] }));
+
+ const { text, values } = inspect();
+ expect(text).toContain("color_identity @>");
+ expect(text).not.toContain("c.colors @>");
+ expect(values).toContain("U");
+ });
+
+ it("builds a cmc comparison condition with the operator and value", async () => {
+ mockQueryRaw.mockResolvedValue([] as never);
+
+ await searchCardsBySyntax(emptyParsed({ cmcFilters: [{ op: ">=", value: 3 }] }));
+
+ const { text, values } = inspect();
+ expect(text).toContain("c.cmc >=");
+ expect(values).toContain(3);
+ });
+
+ it("routes type and oracle fragments through websearch_to_tsquery", async () => {
+ mockQueryRaw.mockResolvedValue([] as never);
+
+ await searchCardsBySyntax(
+ emptyParsed({ typeFragments: ["creature"], oracleFragments: ["draw"] }),
+ );
+
+ const { text, values } = inspect();
+ expect(text).toContain("websearch_to_tsquery");
+ expect(values).toContain("creature");
+ expect(values).toContain("draw");
});
it("merges parsed colors/types with chip-level colors/types and dedupes", async () => {
@@ -208,7 +247,7 @@ describe("searchCardsBySyntax", () => {
{ ...RAW_ROW, legalities: null, game_changer: null, color_identity: null },
] as never);
- const [row] = await searchCardsBySyntax(emptyParsed());
+ const [row] = await searchCardsBySyntax(emptyParsed({ nameFragments: ["bolt"] }));
expect(row?.legalities).toEqual({});
expect(row?.gameChanger).toBe(false);
diff --git a/lib/search/__tests__/serialize-where.test.ts b/lib/search/__tests__/serialize-where.test.ts
new file mode 100644
index 0000000..f99076c
--- /dev/null
+++ b/lib/search/__tests__/serialize-where.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import {
+ parseSyntax,
+ serializeWhere,
+ type ParsedWhere,
+} from "../syntax-parser";
+
+const empty: ParsedWhere = {
+ nameFragments: [],
+ colors: [],
+ typeFragments: [],
+ cmcFilters: [],
+ oracleFragments: [],
+};
+
+describe("serializeWhere — filter → syntax mapping", () => {
+ it("serializes nothing for an empty filter", () => {
+ expect(serializeWhere(empty)).toBe("");
+ });
+
+ it("maps colors to a single c: token in WUBRG order", () => {
+ expect(serializeWhere({ ...empty, colors: ["G", "W", "U"] })).toBe("c:WUG");
+ });
+
+ it("emits no c: token when colors hold no WUBRG letters", () => {
+ expect(serializeWhere({ ...empty, colors: ["C", "X"] })).toBe("");
+ });
+
+ it("emits one t: token per type fragment", () => {
+ expect(
+ serializeWhere({ ...empty, typeFragments: ["creature", "artifact"] }),
+ ).toBe("t:creature t:artifact");
+ });
+
+ it("maps cmc bounds to cmc>= / cmc<=", () => {
+ expect(
+ serializeWhere({
+ ...empty,
+ cmcFilters: [
+ { op: ">=", value: 2 },
+ { op: "<=", value: 5 },
+ ],
+ }),
+ ).toBe("cmc>=2 cmc<=5");
+ });
+
+ it("maps oracle words to o: and quotes phrases", () => {
+ expect(
+ serializeWhere({ ...empty, oracleFragments: ["flying", "draw a card"] }),
+ ).toBe('o:flying o:"draw a card"');
+ });
+
+ it("quotes multi-word name fragments", () => {
+ expect(serializeWhere({ ...empty, nameFragments: ["sol", "ring of"] })).toBe(
+ 'sol "ring of"',
+ );
+ });
+});
+
+describe("serializeWhere ↔ parseSyntax round-trip", () => {
+ const cases: ParsedWhere[] = [
+ { ...empty, colors: ["W", "U", "B", "R", "G"] },
+ { ...empty, typeFragments: ["creature"] },
+ {
+ ...empty,
+ colors: ["U"],
+ typeFragments: ["instant"],
+ cmcFilters: [{ op: "<=", value: 3 }],
+ oracleFragments: ["flying"],
+ },
+ {
+ ...empty,
+ nameFragments: ["bolt"],
+ cmcFilters: [
+ { op: ">=", value: 1 },
+ { op: "<=", value: 4 },
+ ],
+ },
+ { ...empty, oracleFragments: ["draw a card"] },
+ ];
+
+ it.each(cases)("parseSyntax(serializeWhere(p)) === p for %o", (p) => {
+ expect(parseSyntax(serializeWhere(p))).toEqual(p);
+ });
+
+ it("is stable under a second round-trip", () => {
+ for (const p of cases) {
+ const once = serializeWhere(p);
+ expect(serializeWhere(parseSyntax(once))).toBe(once);
+ }
+ });
+});
diff --git a/lib/search/card-search.ts b/lib/search/card-search.ts
index 2563554..65eaaaa 100644
--- a/lib/search/card-search.ts
+++ b/lib/search/card-search.ts
@@ -114,6 +114,7 @@ export async function searchCardsBySyntax(
colors: string[] = [],
chipTypes: string[] = [],
limit = 60,
+ offset = 0,
): Promise {
"use cache";
cacheLife("minutes");
@@ -137,7 +138,7 @@ export async function searchCardsBySyntax(
}
for (const color of allColors) {
- conditions.push(Prisma.sql`c.colors @> ARRAY[${color}]::text[]`);
+ conditions.push(Prisma.sql`c.color_identity @> ARRAY[${color}]::text[]`);
}
// Type fragments: use the card_search_tsv GIN index (tsvector over name +
@@ -165,10 +166,9 @@ export async function searchCardsBySyntax(
);
}
- const whereClause =
- conditions.length > 0
- ? Prisma.sql`WHERE ${Prisma.join(conditions, " AND ")}`
- : Prisma.empty;
+ if (conditions.length === 0) return [];
+
+ const whereClause = Prisma.sql`WHERE ${Prisma.join(conditions, " AND ")}`;
const rows = await prisma.$queryRaw(Prisma.sql`
SELECT
@@ -192,6 +192,7 @@ export async function searchCardsBySyntax(
${whereClause}
ORDER BY c.name
LIMIT ${limit}
+ OFFSET ${offset}
`);
return rows.map((row) => ({
diff --git a/lib/search/syntax-parser.ts b/lib/search/syntax-parser.ts
index 4ddd234..a231aa2 100644
--- a/lib/search/syntax-parser.ts
+++ b/lib/search/syntax-parser.ts
@@ -21,7 +21,7 @@
export type ParsedWhere = {
/** ILIKE name fragments — ANDed together */
nameFragments: string[];
- /** Colors that must ALL appear in the card's colors array */
+ /** Colors that must ALL appear in the card's color identity */
colors: string[];
/** Type line must contain all of these strings */
typeFragments: string[];
@@ -106,3 +106,41 @@ export function parseSyntax(input: string): ParsedWhere {
return result;
}
+const WUBRG_ORDER = ["W", "U", "B", "R", "G"] as const;
+
+/**
+ * Serialize a {@link ParsedWhere} back into the Scryfall-syntax dialect this
+ * module parses — the inverse of {@link parseSyntax}. Emits, in order: name
+ * fragments, `c:WUBRG`, `t:` per type, `cmcN`, then `o:` oracle
+ * fragments. Fragments containing whitespace are quoted. The output is stable
+ * under `parseSyntax(serializeWhere(p))`, so the Filters tab and the syntax box
+ * round-trip through a single source-of-truth query string.
+ */
+export function serializeWhere(p: ParsedWhere): string {
+ const parts: string[] = [];
+
+ for (const frag of p.nameFragments) {
+ parts.push(/\s/.test(frag) ? `"${frag}"` : frag);
+ }
+
+ if (p.colors.length > 0) {
+ const present = new Set(p.colors.map((c) => c.toUpperCase()));
+ const ordered = WUBRG_ORDER.filter((c) => present.has(c));
+ if (ordered.length > 0) parts.push(`c:${ordered.join("")}`);
+ }
+
+ for (const type of p.typeFragments) {
+ parts.push(`t:${type}`);
+ }
+
+ for (const { op, value } of p.cmcFilters) {
+ parts.push(`cmc${op}${value}`);
+ }
+
+ for (const frag of p.oracleFragments) {
+ parts.push(/\s/.test(frag) ? `o:"${frag}"` : `o:${frag}`);
+ }
+
+ return parts.join(" ");
+}
+
diff --git a/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql b/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql
new file mode 100644
index 0000000..0a1490e
--- /dev/null
+++ b/prisma/migrations/20260608000000_card_color_identity_gin/migration.sql
@@ -0,0 +1,18 @@
+-- Add a GIN index on card.color_identity (text[]) to back the `@>` array
+-- containment used by the `c:` color filter in card search (searchCardsBySyntax).
+-- Without it, `color_identity @> ARRAY[...]::text[]` falls back to a seq scan.
+-- The default GIN array_ops operator class supports @>, <@, &&, and =.
+
+-- NOTE ON PRODUCTION ROLLOUT:
+-- A plain CREATE INDEX takes an ACCESS EXCLUSIVE lock on "card" for the build.
+-- For zero-downtime prod apply, run the CONCURRENTLY form manually BEFORE
+-- `prisma migrate deploy`, then mark this migration already applied:
+--
+-- psql "$DATABASE_URL" <<'SQL'
+-- CREATE INDEX CONCURRENTLY IF NOT EXISTS "card_color_identity_idx"
+-- ON "card" USING GIN (color_identity);
+-- SQL
+-- pnpm prisma migrate resolve --applied 20260608000000_card_color_identity_gin
+
+CREATE INDEX IF NOT EXISTS "card_color_identity_idx"
+ ON "card" USING GIN (color_identity);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 976b889..4790c64 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -112,6 +112,7 @@ model Card {
@@index([name(ops: raw("gin_trgm_ops"))], type: Gin, map: "card_name_trgm_idx")
@@index([searchTsv], type: Gin, map: "card_search_tsv_idx")
+ @@index([colorIdentity], type: Gin, map: "card_color_identity_idx")
@@map("card")
}
diff --git a/vitest.config.ts b/vitest.config.ts
index 134f26f..a0c97fa 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -79,6 +79,7 @@ export default defineConfig({
"lib/card/types-meta.ts",
"lib/deck/io/adapters/types.ts",
"lib/deck/mutation/types.ts",
+ "app/_components/builder/card-browser/browser-state.ts",
// Next.js route shells — pages/layouts/error boundaries are exercised
// via integration/E2E, not unit coverage. The og-image route is a thin