Skip to content

Commit ecffe42

Browse files
committed
feat(decks): show Commander Spellbook combos
1 parent d79ac53 commit ecffe42

22 files changed

Lines changed: 793 additions & 2 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
id: TASK-56
3+
title: Show Commander Spellbook combos for decks
4+
status: Done
5+
assignee:
6+
- '@cfbender'
7+
created_date: '2026-08-22 16:18'
8+
updated_date: '2026-08-22 16:44'
9+
labels: []
10+
dependencies: []
11+
references:
12+
- 'https://backend.commanderspellbook.com/'
13+
type: feature
14+
ordinal: 69000
15+
---
16+
17+
## Description
18+
19+
<!-- SECTION:DESCRIPTION:BEGIN -->
20+
Let users inspect infinite combos detected by Commander Spellbook directly from a deck action menu. Results load only when requested and are not persisted.
21+
<!-- SECTION:DESCRIPTION:END -->
22+
23+
## Acceptance Criteria
24+
<!-- AC:BEGIN -->
25+
- [x] #1 Each deck three-dot menu exposes an action that opens its Commander Spellbook combo results.
26+
- [x] #2 Opening the action loads the current commander and mainboard card list ad hoc and displays fully included combos with cards, outcomes, instructions, prerequisites, and source links.
27+
- [x] #3 Loading, empty, and external-service error states are clear and retryable without changing deck data.
28+
- [x] #4 The combo dialog works on deck gallery and deck detail layouts at desktop and mobile widths.
29+
- [x] #5 Backend normalization and frontend interaction tests cover successful, empty, and failed lookups.
30+
<!-- AC:END -->
31+
32+
## Implementation Plan
33+
34+
<!-- SECTION:PLAN:BEGIN -->
35+
1. Add a read-only Catalog integration and GraphQL query that submits commander and mainboard cards to Commander Spellbook and normalizes fully included variants.
36+
2. Add a reusable lazy combo dialog and wire it to gallery and detail three-dot menus.
37+
3. Cover payload/normalization and menu/dialog states with focused tests, then run typecheck, unit tests, detector, and desktop/mobile UI verification.
38+
<!-- SECTION:PLAN:END -->
39+
40+
## Implementation Notes
41+
42+
<!-- SECTION:NOTES:BEGIN -->
43+
Implemented a read-only Commander Spellbook proxy for commander and mainboard cards, a private GraphQL query, and one lazy non-persisting combo dialog shared by deck gallery and detail menus. The mobile confirmation pass removed a local max-height override so the dialog preserves the shared full-height mobile contract.
44+
45+
Validation: 6 focused backend tests, 8 focused frontend tests, all 632 ExUnit tests, 196 model tests, and 86 React tests passed. mix compile --warnings-as-errors, frontend lint, typecheck, production build, targeted format checks, and the Impeccable detector passed. Live browser verification exercised a real Sanguine Bond + Exquisite Blood result at 1440x960 and 390x844 with no browser errors or overflow; finish review disposition: ship. Repository-wide Credo and format checks still report unrelated pre-existing findings in lib/manavault/ai*.ex, assets/react/test/deck-bracket.test.tsx, and aube-lock.yaml.
46+
47+
Post-review polish: aligned instruction markers to the text baseline and right-aligned the number column. The focused 4-test dialog suite and a live 390x844 browser inspection passed.
48+
<!-- SECTION:NOTES:END -->
49+
50+
## Final Summary
51+
52+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
53+
Added ad-hoc Commander Spellbook infinite-combo results to both deck action menus through a normalized Phoenix/GraphQL integration and a responsive lazy dialog with loading, empty, retry, and result states. Verified with full backend/frontend suites, production build, detector, and real desktop/mobile browser flows.
54+
<!-- SECTION:FINAL_SUMMARY:END -->

assets/react/src/gql/gql.ts

Lines changed: 6 additions & 0 deletions
Large diffs are not rendered by default.

assets/react/src/gql/graphql.ts

Lines changed: 8 additions & 0 deletions
Large diffs are not rendered by default.

assets/react/src/pages/decks/deck-actions.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ArrowLeftRight,
33
Download,
44
Edit3,
5+
Infinity as InfinityIcon,
56
MoreVertical,
67
Share2,
78
Scissors,
@@ -36,6 +37,7 @@ export function SummaryActionMenu({
3637
analyzePending = false,
3738
label,
3839
onAnalyze,
40+
onCombos,
3941
onCompare,
4042
onDelete,
4143
onDisassemble,
@@ -51,6 +53,7 @@ export function SummaryActionMenu({
5153
analyzePending?: boolean
5254
label: string
5355
onAnalyze?: () => void
56+
onCombos?: () => void
5457
onCompare?: () => void
5558
onDelete?: () => void
5659
onDisassemble?: () => void
@@ -81,6 +84,12 @@ export function SummaryActionMenu({
8184
{analyzeLabel}
8285
</DropdownMenuItem>
8386
) : null}
87+
{onCombos ? (
88+
<DropdownMenuItem onSelect={onCombos}>
89+
<InfinityIcon className="h-4 w-4" />
90+
Infinite combos
91+
</DropdownMenuItem>
92+
) : null}
8493
<DropdownMenuItem onSelect={onEdit}>
8594
<Edit3 className="h-4 w-4" />
8695
Edit
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { useQuery } from "@apollo/client/react"
2+
import { ExternalLink, Infinity as InfinityIcon, RotateCw } from "lucide-react"
3+
4+
import { CardImage, EmptyState } from "../../components/card-image"
5+
import { Badge } from "../../components/ui/badge"
6+
import { Button } from "../../components/ui/button"
7+
import {
8+
Dialog,
9+
DialogClose,
10+
DialogContent,
11+
DialogHeader,
12+
DialogTitle,
13+
} from "../../components/ui/dialog"
14+
import type { DeckCombosQuery } from "../../gql/graphql"
15+
import { ManaText } from "../cards/card-text"
16+
import { DeckCombosDocument } from "./queries"
17+
18+
type Combo = DeckCombosQuery["deckCombos"][number]
19+
type ComboDeck = { id: string; name: string }
20+
21+
export function DeckCombosDialog({
22+
deck,
23+
onOpenChange,
24+
open,
25+
}: {
26+
deck: ComboDeck | null
27+
onOpenChange: (open: boolean) => void
28+
open: boolean
29+
}) {
30+
const comboQuery = useQuery(DeckCombosDocument, {
31+
variables: { id: deck?.id || "" },
32+
skip: !open || !deck?.id,
33+
fetchPolicy: "network-only",
34+
})
35+
const combos = comboQuery.data?.deckCombos || []
36+
const isLoading = comboQuery.loading && !comboQuery.data
37+
38+
return (
39+
<Dialog open={open} onOpenChange={onOpenChange}>
40+
<DialogContent className="max-w-6xl" labelledBy="deck-combos-title">
41+
<DialogHeader>
42+
<div className="min-w-0">
43+
<DialogTitle id="deck-combos-title" className="flex items-center gap-2">
44+
<InfinityIcon aria-hidden="true" className="h-5 w-5 text-primary" />
45+
Infinite combos
46+
</DialogTitle>
47+
<p className="mt-1 truncate text-sm text-base-content/60">{deck?.name}</p>
48+
</div>
49+
<DialogClose onClose={() => onOpenChange(false)} />
50+
</DialogHeader>
51+
52+
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
53+
{isLoading ? <ComboLoadingState /> : null}
54+
55+
{comboQuery.error ? (
56+
<div
57+
className="rounded-box border border-error/30 bg-error/10 p-4 text-sm text-error"
58+
role="alert"
59+
>
60+
<p className="font-bold">Commander Spellbook could not check this deck.</p>
61+
<p className="mt-1 text-error/85">Try the request again in a moment.</p>
62+
<Button
63+
type="button"
64+
variant="outline"
65+
size="sm"
66+
className="mt-3"
67+
onClick={() => void comboQuery.refetch()}
68+
>
69+
<RotateCw className="h-4 w-4" />
70+
Retry
71+
</Button>
72+
</div>
73+
) : null}
74+
75+
{!isLoading && !comboQuery.error && combos.length === 0 ? (
76+
<EmptyState
77+
title="No infinite combos found"
78+
description="Commander Spellbook did not find a complete combo among this deck's commander and mainboard cards."
79+
action={<CommanderSpellbookLink />}
80+
/>
81+
) : null}
82+
83+
{combos.length > 0 ? (
84+
<div>
85+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 pb-4">
86+
<p className="text-sm text-base-content/70">
87+
<span className="font-mono font-black tabular-nums text-base-content">
88+
{combos.length}
89+
</span>{" "}
90+
{combos.length === 1 ? "combo" : "combos"} found in the current decklist
91+
</p>
92+
<CommanderSpellbookLink />
93+
</div>
94+
<div className="divide-y divide-base-300">
95+
{combos.map((combo) => (
96+
<ComboResult key={combo.id} combo={combo} />
97+
))}
98+
</div>
99+
</div>
100+
) : null}
101+
</div>
102+
</DialogContent>
103+
</Dialog>
104+
)
105+
}
106+
107+
function ComboLoadingState() {
108+
return (
109+
<div
110+
className="space-y-5"
111+
aria-busy="true"
112+
aria-label="Checking Commander Spellbook"
113+
role="status"
114+
>
115+
<p className="text-sm font-bold text-base-content/70">Checking the current decklist…</p>
116+
{[0, 1].map((index) => (
117+
<div key={index} className="space-y-3 border-t border-base-300 pt-5 first:border-t-0">
118+
<div className="flex gap-3">
119+
<div className="h-20 w-14 animate-pulse rounded-box bg-base-200" />
120+
<div className="h-20 w-14 animate-pulse rounded-box bg-base-200" />
121+
</div>
122+
<div className="h-5 w-64 max-w-full animate-pulse rounded bg-base-200" />
123+
<div className="h-4 w-full animate-pulse rounded bg-base-200" />
124+
</div>
125+
))}
126+
</div>
127+
)
128+
}
129+
130+
function ComboResult({ combo }: { combo: Combo }) {
131+
const steps = combo.description
132+
.split(/\r?\n/u)
133+
.map((step) => step.trim())
134+
.filter(Boolean)
135+
136+
return (
137+
<article className="py-5 first:pt-4 last:pb-0">
138+
<div className="flex flex-col gap-5 lg:grid lg:grid-cols-[minmax(15rem,0.8fr)_minmax(0,1.2fr)]">
139+
<div className="space-y-4">
140+
<ul className="flex flex-wrap gap-3" aria-label="Combo cards">
141+
{combo.cards.map((card, index) => (
142+
<li key={`${combo.id}-${card.name}-${index}`} className="flex items-center gap-3">
143+
{index > 0 ? (
144+
<span aria-hidden="true" className="text-lg font-black text-base-content/35">
145+
+
146+
</span>
147+
) : null}
148+
<div className="flex items-center gap-2">
149+
<CardImage
150+
printing={{ imageUrl: card.imageUrl, card: { name: card.name } }}
151+
className="h-20 w-14 shrink-0 rounded-box"
152+
/>
153+
<span className="max-w-36 text-sm font-bold leading-snug">
154+
{card.quantity > 1 ? `${card.quantity}× ` : ""}
155+
{card.name}
156+
</span>
157+
</div>
158+
</li>
159+
))}
160+
</ul>
161+
162+
<div>
163+
<h3 className="text-xs font-bold uppercase text-base-content/55">Produces</h3>
164+
<div className="mt-2 flex flex-wrap gap-2">
165+
{combo.produces.map((result) => (
166+
<Badge key={result} tone="success">
167+
{result}
168+
</Badge>
169+
))}
170+
</div>
171+
</div>
172+
</div>
173+
174+
<div className="space-y-4">
175+
<div className="flex flex-wrap items-center justify-between gap-3">
176+
<h3 className="font-black">How it works</h3>
177+
<Button asChild variant="outline" size="sm">
178+
<a href={combo.url} target="_blank" rel="noreferrer">
179+
Open combo
180+
<ExternalLink className="h-3.5 w-3.5" />
181+
</a>
182+
</Button>
183+
</div>
184+
185+
{steps.length ? (
186+
<ol className="space-y-2 text-sm leading-6 text-base-content/80">
187+
{steps.map((step, index) => (
188+
<li key={`${combo.id}-step-${index}`} className="flex items-baseline gap-3">
189+
<span className="w-5 shrink-0 text-right font-mono text-xs font-black leading-6 tabular-nums text-primary">
190+
{index + 1}.
191+
</span>
192+
<span>{step}</span>
193+
</li>
194+
))}
195+
</ol>
196+
) : null}
197+
198+
{combo.manaNeeded || combo.prerequisites.length > 0 || combo.notes ? (
199+
<dl className="grid gap-3 border-t border-base-300 pt-4 text-sm sm:grid-cols-2">
200+
{combo.manaNeeded ? (
201+
<div>
202+
<dt className="font-bold text-base-content/60">Mana needed</dt>
203+
<dd className="mt-1 font-medium">
204+
<ManaText text={combo.manaNeeded} />
205+
</dd>
206+
</div>
207+
) : null}
208+
{combo.prerequisites.length > 0 ? (
209+
<div className={combo.manaNeeded ? undefined : "sm:col-span-2"}>
210+
<dt className="font-bold text-base-content/60">Prerequisites</dt>
211+
<dd className="mt-1">
212+
<ul className="space-y-1">
213+
{combo.prerequisites.map((prerequisite) => (
214+
<li key={prerequisite}>{prerequisite}</li>
215+
))}
216+
</ul>
217+
</dd>
218+
</div>
219+
) : null}
220+
{combo.notes ? (
221+
<div className="sm:col-span-2">
222+
<dt className="font-bold text-base-content/60">Notes</dt>
223+
<dd className="mt-1 text-base-content/75">{combo.notes}</dd>
224+
</div>
225+
) : null}
226+
</dl>
227+
) : null}
228+
</div>
229+
</div>
230+
</article>
231+
)
232+
}
233+
234+
function CommanderSpellbookLink() {
235+
return (
236+
<a
237+
href="https://commanderspellbook.com/"
238+
target="_blank"
239+
rel="noreferrer"
240+
className="inline-flex items-center gap-1.5 text-sm font-bold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
241+
>
242+
Commander Spellbook
243+
<ExternalLink className="h-3.5 w-3.5" />
244+
</a>
245+
)
246+
}

assets/react/src/pages/decks/deck-detail-header.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type DeckDetailHeaderProps = {
5858
legalityIssues: DeckLegalityIssue[]
5959
saltSum: number | null
6060
onAddCard: () => void
61+
onCombos: () => void
6162
onCompareDeck: () => void
6263
onCopySharedDecklist: () => void
6364
onDisassemble: () => void
@@ -197,6 +198,7 @@ export function DeckDetailHeader({
197198
legalityIssues,
198199
saltSum,
199200
onAddCard,
201+
onCombos,
200202
onCompareDeck,
201203
onCopySharedDecklist,
202204
onDisassemble,
@@ -295,6 +297,7 @@ export function DeckDetailHeader({
295297
analyzePending={analysisMutation.loading}
296298
label={`${deck.name} actions`}
297299
onAnalyze={analyze}
300+
onCombos={onCombos}
298301
onCompare={onCompareDeck}
299302
onDisassemble={canEdit ? onDisassemble : undefined}
300303
onEdhrec={canEdit && deck.format === "commander" ? onOpenEdhrec : undefined}

assets/react/src/pages/decks/deck-detail-overlay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type DeckDetailOverlay =
1111
mode: DeckPullListMode
1212
selectedItemIds: Record<string, string | null>
1313
}
14+
| { kind: "combos" }
1415
| { kind: "compare-deck" }
1516
| { kind: "delete-card"; deckCard: DeckCardEntry }
1617
| { kind: "delete-selected" }

assets/react/src/pages/decks/deck-detail-utility-overlays.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ConfirmDialog } from "../../components/ui/confirm-dialog"
2+
import { DeckCombosDialog } from "./deck-combos-dialog"
23
import { DeckCompareDialog } from "./deck-compare-dialog"
34
import { EditDeckDialog } from "./deck-editor-dialogs"
45
import type { DeckDetailOverlay } from "./deck-detail-overlay"
@@ -70,6 +71,9 @@ export function DeckDetailUtilityOverlays({
7071

7172
return (
7273
<>
74+
{overlay.kind === "combos" ? (
75+
<DeckCombosDialog deck={deck} open onOpenChange={(open) => !open && onClose()} />
76+
) : null}
7377
{overlay.kind === "edit-deck" ? (
7478
<EditDeckDialog deck={deck} open onOpenChange={(open) => !open && onClose()} />
7579
) : null}

assets/react/src/pages/decks/detail-page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ export function DeckDetailPage({
399399
legalityIssues={legalityIssues}
400400
saltSum={deferredDeckAnalysis?.stats.saltSum ?? null}
401401
onAddCard={() => setOverlay({ kind: "add-card" })}
402+
onCombos={() => setOverlay({ kind: "combos" })}
402403
onCompareDeck={() => setOverlay({ kind: "compare-deck" })}
403404
onCopySharedDecklist={copySharedDecklist}
404405
onDisassemble={() => disassemblyActions.preview(deck.id)}

0 commit comments

Comments
 (0)