React 19 SPA + node:http/node:sqlite API in one container. Card data is baked into the image from a Scryfall bulk build, so the runtime never calls Scryfall. No server-side user state: decks live in the browser and travel in the share URL, which is why there is no PVC and replicas: 1 is correct. Adds a README covering the architecture and the reasoning behind it, and publishes the project skills under .claude/skills/. Co-Authored-By: Claude Code <noreply@anthropic.com>
9.5 KiB
MAGIK — Commander deck builder · progress log
Target: a Magic: The Gathering Commander (EDH, 100) deck builder served publicly at
https://magic.marcospaulo.dev.br.
Status: public and live. Verified through Oracle VPS Caddy over Tailscale on
2026-10-01 — /api/health returns {"ok":true,"cards":32199}, /browse
returns the SPA shell with valid public TLS.
Stack decision
| Layer | Choice | Why |
|---|---|---|
| UI | React 19 + TypeScript + Vite | 12ui emits HTML/CSS/React; Vite gives one static bundle the ingress can serve with no SSR runtime. |
| Search API | node:http + node:sqlite (Node 26 built-in) |
One container, one process, no ORM, no native module to compile. SQLite ships with Node, so the image needs no build toolchain for the API. |
| Card data | Scryfall bulk default_cards → SQLite, built before image build |
Commander filtering is a one-off; doing it ahead of time removes all rate-limit and CORS concerns at runtime. The image bakes the file in, so the cluster never talks to Scryfall. |
| Deploy | K3s Deployment + ClusterIP + Traefik Ingress, with public traffic through Oracle VPS Caddy over Tailscale | Reuses the existing public edge without publishing the homelab node directly. |
No runtime dependency on the Scryfall API. Card art is hot-linked from
cards.scryfall.io; metadata is served from the local SQLite index.
Design
Direction approved from the 12ui draft run crt-b32f0ec9d4c827f11995b6fea27906ac78572be8
(design/draft/A.png–D.png). Candidate D was chosen: dense three-column desktop
layout, real card art, mana-curve + colour-identity summary strip, type-grouped deck
list, card detail modal, and a live Commander-rules validator. Candidates A–D are kept
in design/draft/ as the design record. The 12ui HTML conversion of the approved
screens never succeeded; the PNGs remain the visual authority and the React routes
were built to them.
Task log
- Install
12ui-designskill for claude-code (~/.claude/skills/12ui-design). - 12ui
draft— 4 landscape candidates from a Commander-deck-builder brief. - Pick candidate D.
- Research: Commander rules + Scryfall data shape.
- Research: homelab K3s deploy pattern.
scripts/build-cards.mjs— Scryfall bulk → SQLite index (32,199 cards, 3,797 Commander-eligible).- Implement deck-legality engine (100 cards, singleton, colour identity, ban list, partner commanders, basic-land exemption).
- Implement API (
/api/health,/api/search,/api/cards,/api/cards/:id,/api/commanders,/api/lookup). - Implement UI routes (deck builder, decks gallery, browse, analyze, settings).
- Tests — 33 passing across the rules engine and decklist/share handling.
- Project skills under
.claude/skills/, templates undertemplates/. - Dockerfile +
deploy/k8s/magic.yaml+scripts/deploy.sh. - Published at
https://magic.marcospaulo.dev.brthrough Caddy on the Oracle VPS. - RAG decision.
- Improvement pass — accessibility, modals, share-link size, then redeploy.
The data problem, and what it cost
Rule 903.3a — "can be your commander" — exists in no accessible dataset.
Scryfall and MTGJSON both strip the sentence from published card text; it is a
designation on the physical card. Measured against the full 668 MB MTGJSON
stream: zero occurrences. Scryfall's card endpoint reports keywords: [] for
Kiora; MTGJSON reports is_partner: 0.
Consequences before the fix: only 35 of 311 legendary planeswalkers were eligible, Kiora could not be a commander at all, a Background was falsely eligible, and 0 of 48 legendary Vehicles were.
Sources tried and rejected: Scryfall card JSON and SSR page, MTGJSON
AllPrintings.json (668 MB, no isCommander key), json.edhrec.com (403),
Xmage (domain parked), Gatherer's may be your commander and partner rules
filters (34 and 125 names, and they added zero cards once the type-line
rules were in place).
The fix is rule-based rather than data-derived — legendary creature, Vehicle,
Planeswalker, P/T-bearing Spacecraft by type line, plus an 11-card curated
DESIGNATED_COMMANDERS list for the artifact/enchantment/land/sorcery
commanders — and a Background is explicitly excluded. Eligible went
3,478 → 3,797.
Known limits
- Partner planeswalkers cannot be validated.
is_partneris read from oracle text, which Scryfall strips. Kiora + Akkas will not validate as a partner pair. Partner creature pairs work. - The Scryfall feed is missing real cards. Measured, not assumed: a 100-card Atraxa list resolved 73 names and reported 27 as absent from the index — Orim the Seraphic, Disaster, Search the Citadel, Emeria, Shakedown Sky, Tami, Collector of Wonders and others. The bulk file itself carries 36,832 distinct names, so the gap is upstream, not in the pipeline. Such cards are kept in an imported deck with an empty id rather than dropped, and the rules panel lists them by name under Missing cards.
- 486 indexed cards have no artwork, transform cards especially — the art lives on the other face and is not in the bulk feed. They render a name plate instead.
- A newly printed non-creature Commander needs a manual edit to
DESIGNATED_COMMANDERSinscripts/build-cards.mjs. - Card art is hot-linked from Scryfall, so images need the browser to reach
cards.scryfall.io; the app's own UI does not. - The 12ui HTML conversion of the approved screens never succeeded.
Defects found and fixed by deploying it
- The share-link route and the decklist importer silently deleted every card the index could not resolve, turning a shared 100-card deck into a 73-card one that still looked finished. They now keep the entry and the rules panel reports it.
validateDeckhad no rule for an unresolved card — every check skipped them, so a deck of unknown cards validated as clean. Added asmissing.- A transform planeswalker's type line names both faces ("Legendary
Creature — Human Wizard // Legendary Planeswalker — Jace"). The column
counts used first-match while the deck view used a substring test, so those
cards were listed twice and the counts disagreed with the rows shown. Both
now use
primaryType. - The Scryfall download had no integrity check, and a partial download
un-gzips into valid lines and produces a plausible, quietly incomplete
index. The cache is now size-checked against the catalogue's
compressed_sizeon every build, and a mismatch is refused. - Search ranked only by prefix and substring, so
q=Sol Ringcould not put Sol Ring first. Exact-name match now outranks prefix, substring and rules-text hits.
Defects found and fixed in the improvement pass
- A share link stopped opening anything. The route guarded its work with
two mechanisms that fought each other: a ref latched the slug as "already
done", and a
cancelledflag abandoned the run in flight.replaceDeckis rebuilt every time the deck store recomputes, so the effect re-ran almost immediately after mount — the re-run's cleanup cancelled the in-flight decode and the guard stopped it starting again. The page sat on "Opening shared deck…" forever with no error. The work is keyed by the URL, so the URL is now the only dependency and the callbacks are read through a ref. This was invisible tonode --test(nothing exercised the browser) and to both slugs, which is why it was found by driving a real browser. - A share link was 3,519 characters for a full 100-card deck — unusable,
because Discord rejects a message over 2,000. The payload is now deflated
(
CompressionStream('deflate-raw'), a Node and browser built-in, so no dependency) before base64. The same deck is 1,236 characters. Unprefixed slugs still decode, because links are already in the wild. - Two places nested a real
<button>inside arole="button"container (CardTile, deck rows). Invalid ARIA, and the inner control was unreachable by keyboard because it carriedtabIndex={-1}inside something already focusable. Both are now containers of sibling buttons. - The three modals each reimplemented focus trapping, Escape, and focus
return by hand. They now use native
<dialog>+showModal(), which gets all of it from the platform, behind oneModalcomponent. - Adding a card changed the validator verdict off to the side with no focus
move and no announcement. Both the verdict and the import result are now
role="status"live regions (WCAG 4.1.3), mounted empty and debounced so a burst of Adds is not a queue of announcements. .pip-togglewas 22px, under the WCAG 2.2 AA target size of 24px (2.5.8).- Settings credited the ban list to "Commander's Decree". There is no
document by that name; the ban list is Wizards' Banned & Restricted
List.
legalities.commanderis the field that tracks it.
Operating
scripts/deploy.sh # test, typecheck, build, image, push, roll
scripts/deploy.sh --rebuild-cards # force a fresh Scryfall download first
Public routing is configured on the Oracle VPS: DNS-only A record to
129.148.56.8, then Caddy proxies magic.marcospaulo.dev.br to the homelab
Traefik ingress at 100.117.115.61:80 over Tailscale. The deploy script does
not call accessctl expose, which would replace the public DNS target with the
Tailnet address.
Skills: .claude/skills/commander-card-index, .claude/skills/commander-deck-rules,
.claude/skills/homelab-web-deploy. Templates: templates/service-runbook.md,
templates/card-index-refresh.md.