V2 Architecture and Refactor - #6
Draft
simonkarman wants to merge 34 commits into
Draft
Conversation
…ing warm inbrowser-instance renders
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.
…ther and added a logo
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.