Files
masi a07522a189 MAGIK — Commander deck builder
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>
2026-10-01 21:12:19 -03:00

9.5 KiB
Raw Permalink Blame History

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-design skill 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 under templates/.
  • Dockerfile + deploy/k8s/magic.yaml + scripts/deploy.sh.
  • Published at https://magic.marcospaulo.dev.br through 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_partner is 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_COMMANDERS in scripts/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.
  • validateDeck had no rule for an unresolved card — every check skipped them, so a deck of unknown cards validated as clean. Added as missing.
  • 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_size on every build, and a mismatch is refused.
  • Search ranked only by prefix and substring, so q=Sol Ring could 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 cancelled flag abandoned the run in flight. replaceDeck is 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 to node --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 a role="button" container (CardTile, deck rows). Invalid ARIA, and the inner control was unreachable by keyboard because it carried tabIndex={-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 one Modal component.
  • 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-toggle was 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.commander is 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.