From 0d23d178bcec8ed581e6faa7020a629e56112d0f Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 13 Jul 2026 14:33:51 -0400 Subject: [PATCH 1/3] fix(deck): distinguish ghost card rows for a11y, scope their Trash to the membership Ghost rows (secondary-category fan-out copies of a multi-category DeckCard) were indistinguishable to assistive tech and their Trash deleted the card from the whole deck. - Record the section category on each fan-out copy (groupByCategory) so a ghost knows which membership it represents. - Add an "(also in {primary category})" qualifier to ghost rows/tiles across the sortable and read-only variants. - Ghost-row Trash now strips only that secondary membership via setCardCategories; the primary row keeps full-delete semantics. Refs #89 (M10 from #30 multi-category cards review). --- .../builder/card-row-sortable.test.tsx | 139 ++++++++++++++++++ app/_components/builder/card-row-sortable.tsx | 31 +++- app/_components/builder/card-row.test.tsx | 42 ++++++ app/_components/builder/card-row.tsx | 5 + .../builder/card-stack-sortable.tsx | 30 +++- app/_components/builder/card-stack.tsx | 6 +- lib/deck/__tests__/group-sort.test.ts | 19 +++ lib/deck/group-sort.ts | 12 +- lib/deck/zone-view.ts | 5 +- 9 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 app/_components/builder/card-row-sortable.test.tsx diff --git a/app/_components/builder/card-row-sortable.test.tsx b/app/_components/builder/card-row-sortable.test.tsx new file mode 100644 index 0000000..f30de22 --- /dev/null +++ b/app/_components/builder/card-row-sortable.test.tsx @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DndContext } from "@dnd-kit/core"; +import { SortableContext } from "@dnd-kit/sortable"; +import type { DeckCard } from "@/lib/deck/zone-view"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("@/lib/deck/editor-actions", () => ({ + updateCardQuantity: vi.fn().mockResolvedValue(undefined), + removeCardFromDeck: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/app/_actions/deck/categories", () => ({ + moveCardTo: vi.fn().mockResolvedValue(undefined), + setCardCategories: vi.fn().mockResolvedValue(undefined), +})); + +import { removeCardFromDeck } from "@/lib/deck/editor-actions"; +import { setCardCategories } from "@/app/_actions/deck/categories"; +import { CardRowSortable } from "./card-row-sortable"; + +const mockRemoveCard = vi.mocked(removeCardFromDeck); +const mockSetCategories = vi.mocked(setCardCategories); + +const DECK_ID = "deck-1"; +const PRINTING_ID = 42; + +function makeDc(overrides: Partial = {}): DeckCard { + return { + id: "dc-1", + deckId: DECK_ID, + cardId: 1, + quantity: 1, + zone: "MAINBOARD", + categories: [], + printingId: PRINTING_ID, + isFoil: false, + createdAt: new Date(), + updatedAt: new Date(), + card: { + id: 1, + name: "Sol Ring", + mainType: "Artifact", + typeLine: "Artifact", + manaCost: null, + oracleText: null, + colorIdentity: [], + gameChanger: false, + legalities: null, + printings: [{ id: PRINTING_ID, imageUri: null, backImageUri: null }], + }, + printing: { + id: PRINTING_ID, + setCode: "lea", + collectorNumber: "270", + imageUri: null, + backImageUri: null, + }, + ...overrides, + } as unknown as DeckCard; +} + +function renderRow(dc: DeckCard) { + return render( + + +
    + +
+
+
, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CardRowSortable ghost rows", () => { + it("labels a ghost row with its primary category so it is distinguishable", () => { + const dc = makeDc({ + isSecondary: true, + categories: ["Ramp", "Removal"], + sectionCategory: "Removal", + }); + renderRow(dc); + + expect(screen.getByText(/\(also in Ramp\)/)).toBeInTheDocument(); + }); + + it("primary row carries no ghost qualifier", () => { + renderRow(makeDc({ categories: ["Ramp"] })); + + expect(screen.queryByText(/also in/i)).toBeNull(); + }); + + it("ghost Trash strips only the section membership, keeping the card in the deck", async () => { + const user = userEvent.setup(); + const dc = makeDc({ + isSecondary: true, + categories: ["Ramp", "Removal"], + sectionCategory: "Removal", + }); + renderRow(dc); + + await user.click( + screen.getByRole("button", { name: /remove sol ring from removal/i }), + ); + + expect(mockSetCategories).toHaveBeenCalledWith(DECK_ID, "dc-1", ["Ramp"]); + expect(mockRemoveCard).not.toHaveBeenCalled(); + }); + + it("primary row Trash deletes the whole card", async () => { + const user = userEvent.setup(); + renderRow(makeDc({ categories: ["Ramp"] })); + + await user.click( + screen.getByRole("button", { name: /remove sol ring from deck/i }), + ); + + expect(mockRemoveCard).toHaveBeenCalledWith(DECK_ID, "dc-1"); + expect(mockSetCategories).not.toHaveBeenCalled(); + }); +}); diff --git a/app/_components/builder/card-row-sortable.tsx b/app/_components/builder/card-row-sortable.tsx index 39e2a3b..a64a708 100644 --- a/app/_components/builder/card-row-sortable.tsx +++ b/app/_components/builder/card-row-sortable.tsx @@ -28,7 +28,7 @@ import { updateCardQuantity, removeCardFromDeck, } from "@/lib/deck/editor-actions"; -import { moveCardTo } from "@/app/_actions/deck/categories"; +import { moveCardTo, setCardCategories } from "@/app/_actions/deck/categories"; import type { Zone } from "@/lib/generated/prisma/enums"; const ROW_ZONE_BY_KEY: Record = { @@ -180,6 +180,22 @@ export function CardRowSortable({ }); } + // Ghost rows list the card under a secondary category. Their Trash strips + // only that membership; the card stays in the deck under its other + // categories. Full deletion remains the primary row's job. + function removeMembership() { + const next = dc.categories.filter((c) => c !== dc.sectionCategory); + startTransition(async () => { + dispatch({ + type: "move", + deckCardId: dc.id, + zone: "MAINBOARD", + categories: next, + }); + await setCardCategories(deckId, dc.id, next); + }); + } + function moveToZone(nextZone: Zone) { if (nextZone === dc.zone) return; startTransition(async () => { @@ -257,6 +273,11 @@ export function CardRowSortable({ > {dc.card.name} + {dc.isSecondary && ( + + (also in {dc.categories[0]}) + + )} {illegalBadge} @@ -320,9 +341,13 @@ export function CardRowSortable({ + {dc.isSecondary && ( + + (also in {dc.categories[0]}) + + )} {illegalBadge} diff --git a/app/_components/builder/card-stack-sortable.tsx b/app/_components/builder/card-stack-sortable.tsx index 81c81e8..e9ed8c0 100644 --- a/app/_components/builder/card-stack-sortable.tsx +++ b/app/_components/builder/card-stack-sortable.tsx @@ -19,6 +19,7 @@ import { updateCardQuantity, removeCardFromDeck, } from "@/lib/deck/editor-actions"; +import { setCardCategories } from "@/app/_actions/deck/categories"; import { cn } from "@/lib/utils"; import type { DeckCard, ZoneAction } from "@/lib/deck/zone-view"; import type { Format } from "@/lib/generated/prisma/enums"; @@ -119,6 +120,21 @@ function CardStackItemSortable({ }); } + // Ghost tiles show the card under a secondary category; their Trash strips + // only that membership rather than deleting the card from the deck. + function removeMembership() { + const next = dc.categories.filter((c) => c !== dc.sectionCategory); + startTransition(async () => { + dispatch({ + type: "move", + deckCardId: dc.id, + zone: "MAINBOARD", + categories: next, + }); + await setCardCategories(deckId, dc.id, next); + }); + } + function onTileClick(e: React.MouseEvent) { if (isInteractiveTargetStack(e.target)) return; preview?.openDetail(previewPayload, tileRef.current); @@ -165,7 +181,11 @@ function CardStackItemSortable({ onFocus={() => preview?.preview(previewPayload)} onClick={onTileClick} onKeyDown={onTileKeyDown} - aria-label={`${dc.card.name} ×${dc.quantity}`} + aria-label={ + dc.isSecondary + ? `${dc.card.name} ×${dc.quantity} (also in ${dc.categories[0]})` + : `${dc.card.name} ×${dc.quantity}` + } > {imageUri ? ( diff --git a/app/_components/builder/card-stack.tsx b/app/_components/builder/card-stack.tsx index 5a5e5a9..b17a831 100644 --- a/app/_components/builder/card-stack.tsx +++ b/app/_components/builder/card-stack.tsx @@ -212,7 +212,11 @@ function CardStackItem({ onFocus={() => preview?.preview(previewPayload)} onClick={onTileClick} onKeyDown={onTileKeyDown} - aria-label={`${dc.card.name} ×${dc.quantity}`} + aria-label={ + dc.isSecondary + ? `${dc.card.name} ×${dc.quantity} (also in ${dc.categories[0]})` + : `${dc.card.name} ×${dc.quantity}` + } > {imageUri ? ( { expect(removalEntry.isSecondary).toBe(true); }); + it("records the section category on each secondary fan-out entry", () => { + const cards = [ + makeCard({ id: "a", categories: ["ramp", "removal", "draw"] }), + ]; + const sections = groupCards(cards, "category", [ + "ramp", + "removal", + "draw", + ]); + + const rampEntry = sections.find((s) => s.key === "ramp")!.cards[0]!; + const removalEntry = sections.find((s) => s.key === "removal")!.cards[0]!; + const drawEntry = sections.find((s) => s.key === "draw")!.cards[0]!; + // Primary entry carries no sectionCategory; each ghost names its own. + expect(rampEntry.sectionCategory).toBeUndefined(); + expect(removalEntry.sectionCategory).toBe("removal"); + expect(drawEntry.sectionCategory).toBe("draw"); + }); + it("puts a zero-membership card only in Uncategorized, never as secondary", () => { const cards = [ makeCard({ id: "a", categories: [] }), diff --git a/lib/deck/group-sort.ts b/lib/deck/group-sort.ts index 83af2cc..6ce1a0e 100644 --- a/lib/deck/group-sort.ts +++ b/lib/deck/group-sort.ts @@ -48,6 +48,12 @@ export type GroupSortCard = { * ghosted, are excluded from section counts, and are not draggable. */ isSecondary?: boolean; + /** + * The category (a non-primary membership) whose section this fan-out copy + * was emitted under. Set alongside `isSecondary`; lets a ghost row strip + * just this membership instead of deleting the card from the deck. + */ + sectionCategory?: string; }; interface GroupedSection { @@ -250,7 +256,11 @@ function groupByCategory( for (const key of memberships) { if (!map.has(key)) map.set(key, []); const isSecondary = key !== memberships[0]; - map.get(key)!.push(isSecondary ? { ...dc, isSecondary: true } : dc); + map + .get(key)! + .push( + isSecondary ? { ...dc, isSecondary: true, sectionCategory: key } : dc, + ); } } const sections: GroupedSection[] = []; diff --git a/lib/deck/zone-view.ts b/lib/deck/zone-view.ts index b5adf26..21f8ef4 100644 --- a/lib/deck/zone-view.ts +++ b/lib/deck/zone-view.ts @@ -12,7 +12,10 @@ export type Deck = NonNullable>>; * card's non-primary memberships: they render ghosted, don't count toward * section totals, and are not draggable. */ -export type DeckCard = Deck["cards"][number] & { isSecondary?: boolean }; +export type DeckCard = Deck["cards"][number] & { + isSecondary?: boolean; + sectionCategory?: string; +}; export function resolveCardImage(dc: DeckCard): string | null { return resolveCardImageRule({ printing: dc.printing, card: dc.card }); From cb521dfb294550c012a5d5bcba37e06bdaecf559 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Mon, 13 Jul 2026 14:59:57 -0400 Subject: [PATCH 2/3] no-mistakes(document): document ghost-row Trash strips only secondary membership --- CONTEXT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTEXT.md b/CONTEXT.md index a46ca3a..c82fff7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,7 @@ A **Card** placed in the `COMPANION` **Zone** whose deckbuilding restriction the A user-defined free-text grouping within the `MAINBOARD` **Zone** (e.g. "Ramp", "Removal"). A **DeckCard** may belong to several **Categories** at once: memberships are ordered, the first is the **primary**, and the rest are secondary. The card renders in full under its primary and ghosted under each secondary; section header counts and commander-template targets count every membership (ghosts included), while the stats distribution bar tallies primaries only so it sums to deck size. +Removing a ghost (secondary) row strips only that one membership — the **DeckCard** stays in the deck under its remaining **Categories** — whereas removing the primary row deletes the **DeckCard** from the deck entirely. Zero memberships means uncategorized; leaving `MAINBOARD` clears all memberships. Distinct from **CardType** (Creature/Instant/...) and from **Format**. From c5adecda26ffaa0c69498e0239cf18185e0318a6 Mon Sep 17 00:00:00 2001 From: Jarrod Servilla Date: Wed, 15 Jul 2026 05:59:44 -0700 Subject: [PATCH 3/3] fix(deck): scope keyboard delete on ghost rows to membership Keyboard Delete/Backspace on a ghost (secondary) row passed only `remove` into handleRowKeyDown, fully deleting the DeckCard instead of stripping its secondary-category membership. Wire the keyboard path to route `dc.isSecondary ? removeMembership : remove`, mirroring the Trash button. Add regression tests covering Delete/Backspace on both ghost and primary rows. --- .../builder/card-row-sortable.test.tsx | 33 ++++++++++++++++++- app/_components/builder/card-row-sortable.tsx | 13 +++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app/_components/builder/card-row-sortable.test.tsx b/app/_components/builder/card-row-sortable.test.tsx index f30de22..2d68fd0 100644 --- a/app/_components/builder/card-row-sortable.test.tsx +++ b/app/_components/builder/card-row-sortable.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DndContext } from "@dnd-kit/core"; import { SortableContext } from "@dnd-kit/sortable"; @@ -136,4 +136,35 @@ describe("CardRowSortable ghost rows", () => { expect(mockRemoveCard).toHaveBeenCalledWith(DECK_ID, "dc-1"); expect(mockSetCategories).not.toHaveBeenCalled(); }); + + it.each(["Backspace", "Delete"])( + "keyboard %s on a ghost row strips only the section membership", + (key) => { + const dc = makeDc({ + isSecondary: true, + categories: ["Ramp", "Removal"], + sectionCategory: "Removal", + }); + const { container } = renderRow(dc); + + const row = container.querySelector("[data-deck-row]") as HTMLElement; + fireEvent.keyDown(row, { key }); + + expect(mockSetCategories).toHaveBeenCalledWith(DECK_ID, "dc-1", ["Ramp"]); + expect(mockRemoveCard).not.toHaveBeenCalled(); + }, + ); + + it.each(["Backspace", "Delete"])( + "keyboard %s on a primary row deletes the whole card", + (key) => { + const { container } = renderRow(makeDc({ categories: ["Ramp"] })); + + const row = container.querySelector("[data-deck-row]") as HTMLElement; + fireEvent.keyDown(row, { key }); + + expect(mockRemoveCard).toHaveBeenCalledWith(DECK_ID, "dc-1"); + expect(mockSetCategories).not.toHaveBeenCalled(); + }, + ); }); diff --git a/app/_components/builder/card-row-sortable.tsx b/app/_components/builder/card-row-sortable.tsx index a64a708..c3f55b9 100644 --- a/app/_components/builder/card-row-sortable.tsx +++ b/app/_components/builder/card-row-sortable.tsx @@ -215,7 +215,18 @@ export function CardRowSortable({ } function onRowKeyDown(e: React.KeyboardEvent) { - handleRowKeyDown(e, { dc, preview, previewPayload, rowRef, changeQty, moveToZone, remove, setPrintingPickerOpen }); + // Keyboard delete mirrors the Trash button: a ghost (secondary) row strips + // only its membership, a primary row deletes the whole DeckCard. + handleRowKeyDown(e, { + dc, + preview, + previewPayload, + rowRef, + changeQty, + moveToZone, + remove: dc.isSecondary ? removeMembership : remove, + setPrintingPickerOpen, + }); } const li = (