Skip to content

V2 Architecture and Refactor - #6

Draft
simonkarman wants to merge 34 commits into
mainfrom
v2
Draft

V2 Architecture and Refactor#6
simonkarman wants to merge 34 commits into
mainfrom
v2

Conversation

@simonkarman

Copy link
Copy Markdown
Owner

Kindred Paths has grown well over ~2 years. The domain core is strong; but there is pain in the presentation and rendering layers, plus features that are no longer used. This is a long running PR to resolve these issues.

@simonkarman
simonkarman marked this pull request as draft July 27, 2026 21:20
- \u00a74: rewrite CardConjurer update workflow around executable
  generate-goldens + git diff + PR review flow
- \u00a74: document Renderer interface + registry pattern; goldens per
  registered renderer under collection/goldens/<name>/
- \u00a710: lock v2 workspace layout (5 packages + 1 app, pnpm workspaces,
  @kindred-paths/* scope, apps/web owns the whole backend, SDK-only
  HTTP boundary for CLI + MCP)
- \u00a711 Phase 0.9: initial golden capture from v1 into
  collection/goldens/cardconjurer/
- \u00a711 Phase 1a: registry-driven generate-goldens + test:golden with
  --renderer and --card scope flags; both always force-rerender
  (bypass render cache) since otherwise a code change would silently
  return the cached PNG; document the tag:golden URL bookmark that
  the v2 overview must keep supporting
The renderable's hasRules flag was using cardFace.rules.length, which
counts all rules including planeswalker loyalty abilities (+X:, -Y:,
etc.). compute-planeswalker-data.ts uses this flag to decide whether to
prepend the passive rules text as a leading costless ability slot, so a
planeswalker with only loyalty abilities (and no true passive) would
render one empty extra ability slot.

Fix: check cardFace.renderRules().length instead. renderRules() already
breaks at the first loyalty ability (card-face.ts:420-423), so its
output length is exactly the non-loyalty rules length.
One-shot capture of golden PNGs from v1 into
collection/goldens/renderers/cardconjurer/<cid>-f<N>.png.

Reads tag:golden cards from collection/cards/, hits v1's
GET /render/:cid/:faceIndex?force=true to bypass the render cache, and
writes PNGs to disk. Renderable faces per layout:

  normal    -> face 0 only
  adventure -> face 0 only (v1 errors on face 1)
  modal     -> face 0 and face 1
  transform -> face 0 and face 1

Full run wipes the target dir first for clean capture; --card <cid>
mode is surgical. Verifies v1 reachable before starting.

Throwaway: once pnpm generate-goldens exists in the v2 workspace
(Phase 1a), this script is retired.
Ship the v2 pnpm workspace and the golden diff harness that replaces
the throwaway spike/goldens/initial-capture.mjs. See docs/v2-architecture.md
§11 Phase 1a.

Workspace scaffold
- pnpm-workspace.yaml — packages/* + apps/* (v1 client/server/mcp/shared
  stay outside, still npm-managed per the strangler plan §10)
- packages/shared/ — stub only; the real port from v1 shared/ lands with
  Phase 1b when the renderer needs Card → Renderable
- packages/renderer/ — the load-bearing package
  - src/interface.js — Renderer contract (name + render(renderable, opts))
    with skipCache option (required by the harness — otherwise a code
    change would silently return the cached PNG and pass every diff)
  - src/index.js — central registry; adding a future renderer is one line
  - src/cardconjurer/node.js — full CC-in-Node bridge, ported from the
    proven spike (@napi-rs/canvas + vm sandbox + generic DOM shim) with
    the suppress-drawFrames + await-real-decodes + single-composite fix
    from Phase 0.8. Spike-quality driver: default autoFrame path only;
    faithful port of transform/adventure/planeswalker/etc lands in Phase 1b
  - src/cardconjurer/pin.js — pin placeholder (activated in Phase 1b when
    the CC clone moves under packages/renderer/external/cardconjurer/)
- apps/web/ — empty skeleton (populated in Phase 1b/1c)

Harness commands
- scripts/generate-goldens.mjs — discovers renderers from the registry
  and iterates. Full scope wipes each affected renderer dir and rewrites
  every tag:golden card. --renderer <name> scopes to one renderer.
  --card <cid>[,<cid>...] is surgical (no wipe, overwrites specified
  files only). Always renders fresh (skipCache: true).
- scripts/test-goldens.mjs — membership check (missing/orphan PNGs) then
  pixelmatch diff (threshold 0.1, max 0.5% pixels differ). --card
  skips membership and diffs only specified cards; fails with an
  actionable message on missing goldens. Emits an HTML report at
  collection/goldens/report.html grouped by renderer with side-by-side
  golden/actual/diff thumbnails.

Both commands wired to root package.json:
  pnpm generate-goldens [--renderer <name>] [--card <cid>[,<cid>...]]
  pnpm test:golden      [--renderer <name>] [--card <cid>[,<cid>...]]

Verified end-to-end
- pnpm generate-goldens --card <cid> — renders in ~1.3s (build 71ms /
  composite 284ms / encode 584ms), writes to
  collection/goldens/renderers/cardconjurer/<cid>-f<N>.png
- pnpm test:golden --card <cid> — correctly diffs against the v1-captured
  golden and reports the delta (spike-quality driver → large diffs
  today; closing that gap is Phase 1b's job)

Removed
- spike/goldens/initial-capture.mjs — its role is fully replaced by
  pnpm generate-goldens (registry-driven, zero v1 dependency at runtime)
Foundation for Phase 1b (renderer to parity). Ports the minimum set of v1
shared/ modules needed by the renderer's Card → Renderable mapping into
packages/shared/, and adds the CardConjurer-specific helpers under
packages/renderer/src/cardconjurer/. Semantics-preserving: goldens diff at
exactly the same delta as before this commit (verified: card 96rs6la6 →
34.809% pre and post).

@kindred-paths/shared (packages/shared/)
- Port verbatim from v1 shared/src/: card.ts, card-face.ts, card-id.ts,
  colors.ts, hash.ts, layout.ts, mechanics.ts, serialized-card{,-face}.ts,
  token-extracter.ts, typography.ts
- index.ts re-exports the ported subset (deliberately narrow; strategy,
  bucket, blueprint criteria, set matrix, filter DSL, statistics stay
  unported until their consumers need them — several are Phase 5/6 removals
  anyway per §3 Keep/Replace/Trim)
- tsconfig: target/lib es2023 (mechanics.ts uses Array.prototype.toSorted)
- Depends on zod ^3.25.76 (matches v1 shared/package.json)
- Builds cleanly (npx tsc); CommonJS output for max interop; @napi ESM host
  can import named exports thanks to Node's ESM↔CJS interop

packages/renderer/src/cardconjurer/
- renderable.js — Card → Renderable mapping. Ported verbatim from v1
  server/src/services/render-service.ts getRender(), minus set metadata
  (Wave 6). CC-specific, not shared: a future non-CC renderer defines
  its own equivalent
- helpers.js — getFrameColors / getPowerToughnessColor /
  getModalFrameColors / getModalPowerToughnessColor /
  getModalLegendaryCrownColor. Ported verbatim from v1 card-conjurer.ts:16-76
- planeswalker-data.js — computePlaneswalkerData. Ported verbatim from
  v1 server/src/utils/compute-planeswalker-data.ts (originally imported by
  card-conjurer.ts, so genuinely renderer-specific)

packages/renderer/src/cardconjurer/node.js
- Now depends on @kindred-paths/shared (workspace:*) and imports
  cardToRenderable
- Removed the ad-hoc cardToSpec + manaCostToString/typeLine/rulesText/
  ptString stubs. Driver now reads Renderable fields directly:
  renderable.manaCost, renderable.name, renderable.typeLine,
  renderable.rules, renderable.pt (as {power, toughness}),
  renderable.tags.borderless
- Wave 1 driver is unchanged in shape — still only the default autoFrame
  path. The faithful port (all frame branches, Edit Bounds, planeswalker
  geometry, set symbol, collector info, art loading) lands in Wave 2+ via
  the driver.js abstraction

Diff behavior after Wave 1 (spot checks vs v1-captured goldens):
- 96rs6la6 (vanilla mono-W creature)    34.809% differ (unchanged from pre-Wave 1)
- z0o6n4cx (mono-B dense rules)          4.174% differ (Renderable's rules format is v1-accurate)
- 8a8vucpt (3-color creature WUB)        1.380% differ (M15RegularNew handles multi)
- ekjxhpno f0 (transform front)          3.015% differ (rendered as default-frame; Wave 5 fixes)
- ekjxhpno f1 (transform back)          28.746% differ (as above)
- k9cncsdj (planeswalker)               66.540% differ (Wave 4 fixes)

Housekeeping
- packages/*/dist/ added to .gitignore (build artifacts)
The renderer is now split into three layers with clear seams:

  packages/renderer/src/cardconjurer/
    driver.js            <- NEW: host-agnostic build sequence
    hosts/node-handle.js <- NEW: Node adapter (extracted from node.js)
    node.js              <- shrunk to a thin factory (wires host + driver)
    renderable.js         (Wave 1)
    helpers.js            (Wave 1)
    planeswalker-data.js  (Wave 1)
    set-metadata.js       <- NEW: v1 symbol-service port

The CCHandle contract (see hosts/node-handle.js docstring) is what driver.js
talks to; the browser accelerator in Phase 1b-int will provide the same
shape backed by a real iframe/window. One driver, two hosts, same pixels.

Wave 2 renderer port (default autoFrame path only — v1 card-conjurer.ts:447-449
fallthrough branch):

  - mana / title / type / rules / PT text fields, written directly to
    card.text.*.text (no CC debounce; no Playwright keystroke simulation)
  - Edit Bounds equivalents: type.width = 1550/2010; rules.y = 1782/2814,
    rules.height = 798/2814
  - Artifact-vehicle PT prefix ({fontcolor#fff})
  - fs/rules font-size override on the rules textbox
  - Collector info block: fills #info-* stubs, enables
    #enableCollectorInfo + #enableNewCollectorStyle, awaits setBottomInfoStyle()
  - Set-symbol request via fetchSetSymbol() (SVG decode still fails in the
    Node host — Wave 6 adds rasterization — but the collector-info shortName
    text now matches v1)
  - Art loading via CC's own uploadArt(url, 'autoFit'). Non-http paths get
    the /local_art/ prefix (matches v1 imageURL); node-handle.js's resolveSrc
    routes that to collection/art/<path>, preserving sub-paths like
    'suggestions/foo.png'. Between cards we uploadArt('/img/blank.png') to
    keep card N from inheriting card N-1's warm-sandbox art

Renderable now populates renderable.set via the ported symbol-service
(set-metadata.js). Author, shortName, and collectorNumberOffset flow
through to the collector info block.

KP_RENDER_DATE stabilization: v1's collector info block calls new Date()
every render, so goldens naturally go stale as days pass. driver.js reads
KP_RENDER_DATE (YYYY-MM-DD) to freeze the date/year stamp; both harness
scripts default it to 2026-07-31 (the golden capture date). Overridable
if the goldens are ever recaptured on a new date.

Diff outcomes vs v1-captured goldens (~30 cards under 2%, was mostly
34%+ before Wave 2):

  cards 1-13, 15-21, 24-25       0.5-2%    (default frame branch)
  card 22 (basic Plains)         5.7%      (needs Wave 3 basic land icon)
  card 23 (borderless basic)     42.8%     (Wave 3 textless frame)
  cards 26-29 (adventure)        17-20%    (Wave 5)
  cards 30-36 f0 (transform/    3-8%      (Wave 5 back-face frames close the rest)
    modal front)
  cards 30-36 f1 (transform/    18-37%    (Wave 5)
    modal back)
  cards 37-43 (tokens)           20-67%    (Wave 4)
  cards 44-47 (planeswalkers)    32-62%    (Wave 4)
  card 48 (borderless non-basic) 2.7%
  cards 49-50                    0.9-1.2%

The ~1-2% residual on Wave 2's close cards is dominated by two things:
  - missing set symbol (SVG rasterization — Wave 6)
  - font antialiasing drift between Skia (@napi-rs/canvas) and Chromium

The AA drift is a fidelity ceiling of the CC-in-Node host — same driver
running in a warm headless browser (Phase 1b-int) would eliminate it.
Every glyph shows a thin colored outline in the diff PNG.
Every render now boots a fresh CC sandbox — mirrors v1's per-page
Playwright model. Fixes two silent cross-card state-leak bugs and makes
the golden diffs order-independent.

Bugs fixed (both surfaced in Wave 2):
  - Golden Four (mono-R instant) inherited a PT box from the preceding
    creature. CC's drawText iterates every card.text.* entry
    unconditionally, and card.text.pt.text = '1/1' left over from
    render N-1 was drawn onto render N. Also stacked a PT frame image
    via autoFrame (autoFrame.js:1418 triggers on non-empty pt.text).
    → 1.242% → 0.565% (no PT box).
  - Golden Plains (basic land, no rules) inherited rules text from the
    preceding card with rules. Same mechanism on card.text.rules.text.
    → 5.749% → 3.109% (no stale rules; residual is Wave 3's basic land
    icon).

Design change: hosts/node-handle.js splits into:
  - registerFonts()       — process-global one-shot (GlobalFonts is a
                            @napi-rs/canvas singleton anyway)
  - bootFreshSandbox()    — everything sandbox-scoped (DOM shim, DomImage,
                            vm context, CC scripts, M15 bootstrap)
  - createNodeHandle()    — lightweight factory returning { buildAndComposite }
  - buildAndComposite(cb) — boots a fresh sandbox on every call, runs
                            the callback, awaits image decodes, does the
                            single guaranteed-complete composite, returns
                            the PNG buffer, drops the sandbox reference

The driver signature changes from driveRender(renderable, h) to
driveRender(renderable, ctx) where ctx = { sandbox, card, document,
loadFrameScript } — passed into the build callback by buildAndComposite.
The sandbox lifecycle is now completely private to the host.

Downstream simplifications in driver.js:
  - card.frames = [] reset removed (fresh sandbox has [] at boot)
  - the else branch that called sandbox.uploadArt('/img/blank.png') for
    no-art cards removed (fresh sandbox has blank art at boot)

Cost analysis (measured on node v22, macOS ARM, warm caches):
  - Fresh sandbox boot: ~330ms median (5 runs, min 319ms, max 344ms)
  - Full 57-golden sweep: ~124s (was ~57s warm) → +67s for correctness
  - Surgical --card runs: single boot regardless (unchanged)

Rationale: warm was tried in Wave 2 and produced two silent leaks that
were only visible via pixel diff (not exceptions). Fixing via snapshot/
restore was possible but fragile — a new mutable CC global (or one we
forgot to snapshot) would silently leak again. Fresh sandbox trades
~330ms for guaranteed correctness, order-independence, and CC-update
robustness (no snapshot list to keep in sync with CC internals). The
warm path still exists for the interactive editor (Phase 1b-int, browser
host) where it's safe: text-only edits overwrite the same-named field
that's already there, no cross-card-shape leaks possible.

Full 57-golden sweep vs Wave 2 baseline: 27 cards improved, 21 stayed
the same (small numeric differences within Skia AA noise), 2 slightly
worse (both by <0.9%, within noise). No regressions of substance. Three
cards now cross the 0.5% threshold to green: Golden Six, Golden Sanctuary,
Golden Coast.
Cards 22 (Golden Plains) and 23 (Golden Island) — both v1's specialised
branches — now pass under the 0.5% golden threshold. No regressions on
any other card (byte-for-byte identical to Wave 2.1 output for the other
55 renderable faces).

  card 22 (basic Plains):        3.109% → 0.151%  ✓ PASS
  card 23 (borderless Island):  43.643% → 0.151%  ✓ PASS

New file: packages/renderer/src/cardconjurer/frame.js
  Host-agnostic addFrameImage(ctx, image, {framePack, mask, placement})
  and loadFramePack(ctx, packName, {fireLoadFrameVersion}). Direct-drive
  equivalent of v1's Playwright helper (server/src/card-conjurer.ts:177-198)
  and its selectOption/#loadFrameVersion sequence. Depends on CC's
  addFrame() + selectedFrameIndex/selectedMaskIndex globals — CC's stable
  contract used by its own JSON save/load path (creator-23.js:4569),
  survives CC updates that add new frames or packs.

Driver changes (packages/renderer/src/cardconjurer/driver.js):
  - Matches v1's discipline: #autoFrame='false' at top of render, then
    branches on card shape and only re-enables autoFrame in the default
    fallthrough. Specialised branches set useAutoFrame=false and stack
    frames manually.
  - New branch for isBorderlessBasic (v1:438-445): loads TextlessBasics2022
    pack (fires #loadFrameVersion to install the stripped {mana,title,type}
    text template — v1's browser does this automatically via
    #autoLoadFrameVersion=checked default; our stub inputs need explicit
    invocation), then addFrameImage of textless/2022/<c> + textless/2022/s<c>.
  - New basic-land-icon overlay (v1:614-633): condition
    supertype=basic + !borderless + land + no rules. Runs autoFrame FIRST
    (default land frame goes on card.frames), then loads M15Lands pack
    (additive, no #loadFrameVersion), then addFrameImage of m15/basics/<c>
    on top. useAutoFrame=false to avoid double-running.

Node-host DOM shim (hosts/node-handle.js):
  - Add firstChild/lastChild as fresh-stub getters (was undefined). CC
    calls .firstChild.click() in loadTextOptions (creator-23.js:1204)
    when a frame pack's #loadFrameVersion.onclick installs a text
    template — this was throwing before.
  - Add prepend/append as no-ops. CC's addFrame does
    #frame-list.prepend(el) at creator-23.js:975.

Wave 3 is exclusively additive. No functional changes to the default
autoFrame path, no changes to any of the other TODO branches. 5 of 57
goldens now pass (was 3).
The set symbol was completely missing from every render. Root causes,
all in packages/renderer/src/cardconjurer/hosts/node-handle.js:

1. resolveSrc() never mapped '/img/setSymbols/official/custom/' (what
   CC's fetchSetSymbol() requests for any custom symbol) to
   collection/symbols/ — v1 did this via a Docker volume mount
   (server/card-conjurer.sh). Requests silently 404'd, so
   card.setSymbolSource stayed at blank.png forever.

2. Even after fixing the mount, @napi-rs/canvas's bundled resvg SVG
   decoder renders some fills as fully transparent: paths that combine
   a fill AND a stroke with certain self-intersecting arc-flag
   combinations (confirmed via raw pixel sampling of
   collection/symbols/gld-c.svg — 0 white pixels, only black-stroke
   and transparent) lose their fill entirely. Cross-checked against a
   real Playwright/Chromium render of the identical SVG (the same
   engine v1 used to capture goldens) — it renders correctly, fill
   included. This is a genuine resvg limitation, not something we can
   route around by changing our SVG markup (future custom set symbols
   for other sets could hit the same shape class). Fix: SVG bytes are
   now rasterized via  (bundles librsvg, a much more
   spec-compliant renderer) before being handed to @napi-rs/canvas as
   plain PNG bytes. Verified pixel-for-pixel visual match against the
   Playwright reference render.

3. Found and fixed along the way (unrelated but real): @napi-rs/canvas's
   native Image binding fires .onload/.onerror automatically once
   decode completes — our DomImage.set src was ALSO manually invoking
   onload from decode().then(), double-firing it. This was harmless on
   its own (both firings observed identical state), but once this
   method needed to do async work before super.src = ... (the SVG
   rasterization path), the two firings could observe DIFFERENT state
   (e.g. a later .src reassignment on the same reused Image instance
   racing with an earlier decode's stale onload firing) — this
   corrupted art zoom/position (art.artZoom computed from a
   still-blank 1x1 placeholder instead of the real decoded image).
   Fixed by letting native auto-invoke be the only onload/onerror
   trigger; our own decode() call is now purely for
   pendingDecodes bookkeeping.

Added sharp as a new dependency of @kindred-paths/renderer.

Impact: full 57-golden sweep — 19 cards improved, 3 slightly worse
(<0.07% each, all still far from any pass/fail boundary), 35 unchanged
byte-for-byte. No card crossed the 0.5% pass/fail threshold in either
direction; the same 5 cards pass before and after, each improving
slightly. The set symbol occupies a small, fixed region of the card,
so the improvement per-card is modest, but it removes a systematic
correctness bug that would otherwise have affected every future wave.
- Token frame branch: color-count x dominant-type frame selection, forced-black
  title on white frames, PT overlay (v1 card-conjurer.ts:376-410).
- Planeswalker frame branch: frame pack by size, ability text/geometry via
  computePlaneswalkerData + #planeswalker-height/cost/shift-N inputs +
  planeswalkerEdited() (v1 :411-437, :519-567).
- Shared isFullArt art-focus-preset override for both branches (v1 :643-664).
- Ported CC's curlyQuotes() transform (creator-23.js:1227) into helpers.js and
  apply it in driver.js's setText() for every field - our direct card.text
  writes were bypassing it, rendering straight quotes with the wrong glyph.
- Fixed two node-handle.js DomImage bugs, both only surfaced by planeswalkers'
  async mask handling:
  1. .decode() could resolve before the async SVG-rasterize-then-assign
     pipeline actually ran, reporting .complete=true with 0x0 dimensions and
     silently no-oping the ability-box mask's destination-in clip (bled the
     highlight band's intentional overdraw margin into the art).
  2. Reassigning .src a second time on the same Image instance (regular svg
     mask -> tall png mask) left drawImage() painting stale pixel data from
     the first assignment, even though width/height matched by coincidence.
     Fixed by swapping in a fresh Image() instance instead.
- test-goldens.mjs: report.html now shows golden/actual/diff thumbnails for
  passing cards too (collapsed by default), not just failures.

9/11 tokens+planeswalkers pass; the remaining 2 are accepted AA-ceiling
near-misses (0.971%, 0.577%), same class as the already-accepted cards 49/50.
Ports the three remaining specialised frame branches from v1's card-conjurer.ts:
- Adventure (:208-251, :480-499): mono/multi-color base frame, book panel
  (Rules Left/Right + Multicolor masks), PT frame, legendary crown.
- Transform front/back (:252-326, :607-611): color-count x isLand x isVehicle x
  isArtifact x legendary frame stacking, front's "Reverse PT" (reminder) text,
  back's color-identity pips + type-line shift.
- MDFC/modal (:328-374, :470-478): front/back frame stacking sharing one pack,
  multicolor/vehicle overlays, legendary crown, flipside-type/text fields.

Text field key names (mana2/title2/type2/rules2, flipsideType/flipSideReminder,
reminder) were verified against the actual CC pack scripts' loadTextOptions(),
not guessed from v1's UI display labels.

Also fixes two bugs in shared infra, surfaced by this wave:
- frame.js's addFrameImage assumed every frame image is a .png; the
  color-identity pip frames are .svg-sourced. Now checks both extensions.
- addFrameImage has no "default pack" fallback (unlike v1's closure), so any
  call omitting framePack after a preceding call switched pack explicitly
  needs to say so itself. Bit MDFC's PT/flipside calls following the
  legendary-crown call (ModalLegendCrowns -> needs ModalRegular again).

And a host bug that crashed the test runner (not any single render): CC's
notify(msg, seconds) schedules a real setTimeout that can fire mid-render for
a LATER card; our stub's click() called onclick() with no event, crashing
closeNotification's event.target.closest(...). Now passes a synthetic
{ target: this } event and closest() returns a safe no-op stub.

16/18 Wave 5 golden renders pass; the other 2 (cards 35/36 front faces) are
accepted AA-ceiling near-misses (0.503%/0.840%), visually confirmed as pure
text/crown-edge AA noise, same class as cards 37/47/49/50.
The default branch (M15RegularNew / Borderless fallthrough) set #autoFrame's
value but never fired the target pack's #loadFrameVersion handler, unlike
every other specialised branch. v1's real browser does this implicitly via
autoLoadFrameVersion (localStorage default 'true' at CC boot); our sandbox
boots on M15Regular-1 and never explicitly swaps to M15RegularNew/Borderless
text templates without this call.

Effects fixed:
- M15RegularNew cards silently used M15Regular-1's slightly-off text
  coordinates (few-px offsets in mana/title/type).
- Borderless cards silently used M15Regular-1's BLACK text instead of
  Borderless's required WHITE text (illegible against dark art).

Frame IMAGES were always correct (sandbox.autoFrame() loads those
unconditionally) - only text/bounds templates were affected.

Result: golden suite 30/57 -> 49/57 passing cards.
@napi-rs/canvas's destination-out compositing silently no-ops once the canvas
context has a non-identity transform (rotate) applied, verified in isolation
with a bare fillRect. CardConjurer's corner-cutout code reuses one mask image
via 3 ctx.rotate(90deg) calls between draws, so only the first (unrotated)
corner actually gets cut -- the other 3 render fully opaque/black instead of
transparent, a regression from v1 (real Chromium via Playwright).

Rather than patch the vendored CC fork or reimplement the rotated cut
ourselves, disable it entirely so every render gets consistent square corners.
Rounded corners, when needed, become a presentation-layer concern applied
later on top of the finished square PNG.
Adds `pnpm --filter web export:static -- [--query <search-DSL>] [--base-path /<subpath>]`
that produces a fully static, GitHub-Pages-deployable snapshot of the read-only v2
app at apps/web/generated/site/, and relocates the pinned CardConjurer clone from
v1's server/.cardconjurer/ to packages/renderer/external/cardconjurer/ (Docker-free,
shallow blobless partial clone via `pnpm setup:cardconjurer`).

Establishes the three-rule design contract every subsequent phase must respect
(see docs/v2-architecture.md §13 and docs/v2-phase1d-static-export.md):

1. NEXT_PUBLIC_KP_STATIC — single build-time flag
2. <DynamicOnly> — server-component gate around interactive UI (editor/AI/etc.)
3. Data via server components (baked into HTML), images via assetPath() —
   no client fetches to /api/* in static mode

CardConjurer:
- packages/renderer/src/cardconjurer/pin.js: pinned to 25800ee3
- packages/renderer/scripts/setup.mjs replaces setup.sh placeholder
- node-handle.js default path now packages/renderer/external/cardconjurer
- Validated: 57/57 goldens pass byte-identically from the new clone.

Static plumbing (apps/web):
- next.config.ts layered overlays gated on NEXT_PUBLIC_KP_STATIC
  (output:'export', trailingSlash, basePath/assetPrefix, unoptimized images)
- src/components/dynamic-only.tsx, src/lib/asset-path.ts
- search/page.tsx now a server component that embeds initialCards prop
- card-grid.tsx drops mount-time fetch('/api/cards')
- card/[cid]/page.tsx adds generateStaticParams()
- searchParams reads guarded under static mode; ?q= and ?face= picked up
  client-side in static builds

Export script (apps/web/scripts/export-static.mjs):
- Wipes apps/web/generated/, filters cards, renders + copies PNGs/thumbs,
  stages into public/renders/, moves src/app/api/ aside during next build
  (routes rely on request state), runs next build, moves out/ -> generated/site/

Docs:
- New docs/v2-phase1d-static-export.md — full plan/design
- docs/v2-architecture.md: new §13, updated §10 Hosting row,
  Phase 1d slot in §11 Roadmap, §4 clarification
The renderer was ported from spike/renderer/'s .mjs files during Phase 1b and kept
JSDoc types as advisory annotations. Bring it in line with packages/shared: strict TS,
build to dist/, published exports point at compiled output.

- All 13 src files converted (.js → .ts): interface, cache, index, cardconjurer/*
  (pin, version, helpers, planeswalker-data, set-metadata, renderable, frame, driver,
  node, hosts/node-handle). Unit test also converted (.mjs → .ts, moved to
  src/__tests__ so tsc picks it up alongside src/).
- Real TS types replace JSDoc @typedef blocks: Renderer/RenderResult/RenderInput/
  RenderOptions/RenderTimings interfaces, CardconjurerPin, CCContext, PlaneswalkerData,
  SetMetadata, Renderable (and its sub-shapes: RenderableMdfc/Adventure/Transform),
  WithCacheOptions, ThumbnailConfig, RenderCachePaths, NodeCCHandle.
- tsconfig.json mirrors packages/shared with NodeNext module/moduleResolution (ESM
  preserved) and strict: true. Emits declaration + source maps.
- package.json exports rewritten to point at ./dist/*, with types conditional export
  so TS consumers get .d.ts alongside .js. Adds typescript + @types/node devDeps.
  New scripts: build (tsc), clean, test (builds then runs node --test against dist).
- CardConjurer node host: extending @napi-rs/canvas's NapiImage doesn't quite fit our
  DomImage override shape (onload/onerror widened to accept null, decode() delegating
  to a stored pending promise, src reassignment via native prototype descriptor).
  Cast base to any so TS doesn't validate against the strict native shapes — JS's late
  binding handles it correctly at runtime.
- Golden harness scripts (generate-goldens.mjs, test-goldens.mjs) now import from
  ../packages/renderer/dist/index.js instead of src/. Root package.json's
  generate-goldens/test:golden gain a build:packages prerequisite so a fresh checkout
  works out of the box.
- Validated end-to-end: 57/57 goldens still pass byte-identically; cache unit tests
  all pass; apps/web still builds cleanly.
Ports v1's ESLint rule set (from server/eslint.config.mjs — the modernized flat-config
variant that already governs the shared/server/mcp packages) to the v2 workspace, with
two v2-specific deltas:

  - no-process-env: 'off'   v2 uses process.env.KP_* pervasively (see next.config.ts,
                            packages/renderer/src/cardconjurer/hosts/node-handle.ts,
                            apps/web/scripts/export-static.mjs).
  - globalIgnores widened   v2's generated/build/output dirs — .next, generated, out,
                            packages/*/dist, packages/renderer/external, .cache — plus
                            the v1 client/server/shared/mcp dirs (they still have their
                            own root-level configs; they'll be removed in Phase 5), and
                            apps/web (has its own config, run separately).

Layout:
  - eslint.config.mjs (root)             — the shared base
  - packages/{renderer,shared}/eslint.config.mjs
                                          — re-export the base unchanged
  - apps/web/eslint.config.mjs           — base + @next/eslint-plugin-next's flat
                                            'core-web-vitals' config (which extends
                                            'recommended'). Wired via the plugin's own
                                            flat-config export rather than
                                            @eslint/eslintrc's FlatCompat (Next 16
                                            trips a circular-ref bug in compat's
                                            normalize path). max-len bumped to 200 for
                                            .tsx/.jsx to accommodate Tailwind classes.

Scripts:
  - pnpm lint         root eslint run (ignores apps/web + v1 dirs)
  - pnpm lint:all     runs each workspace's own lint (renderer, shared, web) in serial
  - Per-package: lint = eslint .   (apps/web's old 'next lint' replaced — it prompted
                                     an interactive config setup and was unusable in CI)

Deps added at workspace root: eslint@^9, @eslint/js, @eslint/eslintrc,
typescript-eslint@^8, globals. apps/web additionally gets @next/eslint-plugin-next
and eslint-config-next.

Fix pass — all v2 code passes lint (was 116 problems on first run, 82 auto-fixed by
eslint --fix, remaining 33 fixed manually):
  - Formatting (indent/semi/quotes/comma-dangle/spacing) auto-fixed across shared,
    renderer, scripts.
  - no-plusplus: replaced x++ with x += 1 in export-static.mjs, semaphore.ts,
    cache.test.ts, generate-goldens.mjs, test-goldens.mjs (idiomatic patterns that
    were already permitted for for-loop afterthoughts stay; only standalone x++
    statements changed).
  - no-useless-assignment: dropped explicit '= undefined' initializations and
    unreachable initial-value branches in card-filterer.ts and export-static.mjs.
  - no-use-before-define: reordered card-filterer.ts exports so
    filterCardsBasedOnSearchWithFaces appears before its arrow-function-const consumer.
  - no-explicit-any: filter-query-handler.ts token type widened from any to unknown.
  - no-this-alias: added an inline disable + rationale in node-handle.ts's DomImage
    (the async IIFE plus .catch handler both need a stable  reference).
  - no-unused-vars: removed unused hybridManaColors import + existsSync import.

Validation: pnpm lint (root), pnpm lint:all (all packages), pnpm build:packages,
pnpm --filter web build, pnpm test:golden all pass. 57/57 goldens still
byte-identical; 12/12 renderer unit tests pass.
The husky v4 setup crashed on every commit under modern pnpm: v4's generated
.git/hooks/husky.sh hardcodes 'pnpm dlx --no-install husky-run', but pnpm 9 interprets
--no-install as a package name to install-and-execute, producing
ERR_PNPM_FETCH_404: --no-install is not in the npm registry. Even when the hook
survived, its chain (npm run precommit → npm --prefix shared|server|client|mcp
precommit) only ran v1 packages and depended on those v1 node_modules being installed —
also destined to break entirely when v1 is deleted in Phase 5.

This commit:
- Removes husky v4 (dependency + package.json 'husky.hooks' block) and its generated
  .git/hooks/{pre-commit,husky.sh,husky.local.sh,...} v4 stubs.
- Deletes the v1 postinstall+precommit chains from root package.json entirely.
- Installs husky v9 + lint-staged, runs 'husky init'.
- Adds a pre-commit hook (.husky/pre-commit) that runs lint-staged.
- Configures lint-staged in root package.json:
    - apps/web/**/*.{ts,tsx,js,jsx,mjs}          → eslint --max-warnings=0
    - apps/web/**/*.{ts,tsx}                     → typecheck:web
    - packages/renderer/**/*.{ts,mts,js,mjs}     → eslint --max-warnings=0
    - packages/renderer/**/*.ts                  → typecheck:renderer
    - packages/shared/**/*.ts                    → eslint + typecheck:shared
    - scripts/**/*.mjs, eslint.config.mjs        → eslint --max-warnings=0
- Adds scripts/lint-staged-typecheck.mjs — a tiny wrapper that ignores lint-staged's
  per-file args and runs the whole-package tsc --noEmit (project-wide typecheck can't
  meaningfully target individual files).
- Adds typecheck scripts to root package.json: typecheck, typecheck:{web,renderer,
  shared}. Each is 'pnpm --filter <pkg> exec tsc --noEmit'.

Not included:
- pnpm test:golden — 30s+ per run, gated to CI (commit 4) rather than pre-commit.
- Full v1 script cleanup — 'start'/'dev' chains still reference v1 packages, kept
  intact until Phase 5 deletes v1.

Validated: 'pnpm typecheck' passes across all 3 v2 packages. A dummy commit exercising
the hook ran lint-staged, invoked eslint on the staged .mjs, and completed cleanly.
Three jobs, all wired through the same v2 scripts we use locally:

- quick: pnpm install → build:packages → lint → lint:all → typecheck → build web.
  Runs on every push (v2 branch), PR, and workflow_dispatch. ~2-3 min.

- renderer-tests: the 12 packages/renderer unit tests (cache.test.ts). No
  CardConjurer, no renders. ~1 min.

- goldens: full golden-image regression. Checks out simonkarman/kindred-paths-
  collection as a sibling dir, runs pnpm setup:cardconjurer + pnpm test:golden.
  Uses two GitHub Actions caches:
    - CardConjurer partial clone at packages/renderer/external/cardconjurer,
      keyed on the pinned SHA (grep'd from pin.ts). First run per pin: ~4 min;
      cached runs skip the network entirely.
    - .cache/renders (content-hash keyed inside), keyed on card JSON + art hash
      + renderer source hash, with restore-keys for partial hits. First run per
      collection: ~25-30 min; steady state: 1-5 min per push.
  On failure, uploads collection/goldens/{report.html,.report/*} as an artifact
  so pixel diffs can be inspected locally. Same cache pattern the Phase 1d
  publish workflow uses in the collection repo (docs/v2-phase1d-static-export.md).

Also fixes a small gap: scripts/{generate,test}-goldens.mjs hardcoded
join(REPO, 'collection/…'), which only works when the collection lives at
<repo>/collection. Now respects KP_COLLECTION_PATH like everything else in the
codebase — CI checks the collection out to a sibling path.

Note: if the collection repo is private or has a different name, adjust the
'Checkout collection' step in .github/workflows/ci.yml (single line change,
commented inline).
CardConjurer's checked-out working tree is ~2 GB of frame/symbol PNGs. The
publish workflow in the collection repo hit 'No space left on device' at 68%
through checkout — the default ubuntu-latest runner only has ~14 GB free at
job start, most of the 75 GB disk being pre-installed toolchains (Android SDK,
.NET, Haskell, Docker images, etc.) this job never uses.

Add jlumbroso/free-disk-space as the first step of the goldens job, which
strips those toolchains and frees ~30-40 GB before we clone anything.
packages/renderer/scripts/setup.mjs still imported '../src/cardconjurer/pin.js',
which stopped existing once Commit 1 converted packages/renderer/src to
TypeScript (pin.ts, compiled to dist/cardconjurer/pin.js). This script is a
standalone .mjs, not part of the tsc build, so nothing caught the stale import
until CI actually ran setup:cardconjurer and hit ERR_MODULE_NOT_FOUND.

Now imports from ../dist/cardconjurer/pin.js, matching the pattern already
used by generate-goldens.mjs/test-goldens.mjs (which import dist/index.js).

Also add a 'Build workspace packages' step to the goldens job in ci.yml before
Setup CardConjurer / Run goldens — neither dist/cardconjurer/pin.js nor
dist/index.js (imported by test:golden) existed there previously, since that
job never ran build:packages.

Verified locally end-to-end: wiped packages/renderer/dist and
packages/renderer/external/cardconjurer, ran build:packages then
setup:cardconjurer (fresh ~5 GB partial clone) then test:golden — 57/57
goldens pass.
…; fix lint-staged running wrong eslint config for apps/web

Three related bugs found while trying to commit the previously-untracked
apps/web/src/core/collection/cards.ts:

1. .gitignore's first rule was bare 'collection' (no leading slash), intended
   to ignore only the top-level collection/ (the nested collection-data repo,
   correctly excluded by design). Gitignore patterns with no non-trailing
   slash match any file/directory with that name at any depth, so this also
   matched apps/web/src/core/collection/ — silently excluding cards.ts from
   git entirely since the directory was introduced (785bfb6). The file existed
   on disk locally so every local build/dev run worked fine, masking the bug.
   It only surfaced on a fresh clone (CI's static export build): 'Module not
   found: Can't resolve @/core/collection/cards' in both /card/[cid] and
   /search routes. Fixed by anchoring to the repo root (/collection).

2. eslint.config.mjs had the identical bug in its own ignore list
   ('collection/', unanchored) — also anchored to the repo root
   ('/collection/').

3. Separately, package.json's lint-staged config ran plain
   'eslint --max-warnings=0' for the apps/web/** glob from the repo root
   cwd. ESLint's flat config always resolves to the nearest config from cwd,
   not per-file — so this was silently picking up the ROOT eslint.config.mjs
   (which deliberately ignores all of apps/web/, since apps/web has its own
   config with the Next.js plugin) instead of apps/web/eslint.config.mjs. Every
   apps/web file has therefore always been reported as 'ignored' by this
   lint-staged rule — which is why this went unnoticed until now (the pre-
   commit hook was previously only smoke-tested against scripts/**/*.mjs).
   Fixed by running eslint through 'pnpm --filter @kindred-paths/web exec',
   so cwd is apps/web/ and its own flat config (with the Next plugin) is used.

Verified locally: pnpm --filter @kindred-paths/web build succeeds, pnpm lint +
pnpm lint:all both clean, git check-ignore confirms collection/ is still
ignored and cards.ts is not, and the corrected lint-staged command passes on
cards.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant