@cardanvil/frame-kit
v0.6.0
Published
The contract Card Anvil frames are authored against: schemas, types, authoring helpers, and the distributable manifest format.
Readme
@cardanvil/frame-kit
The contract Card Anvil frames are authored against: the schemas that define what a frame is, the helpers you author one with, and the format frames are distributed in.
pnpm add -D @cardanvil/frame-kit zodzod is a peer dependency — the frame types are inferred from zod schemas, so you and this package
must share one copy.
Building a frame? Start from Card-Anvil/frame-template instead of wiring this up by hand. This package is the contract underneath it.
A frame is data
A frame is one plain object: box geometry, asset URLs, and a few render knobs. Nothing in it runs at
render time. That constraint is the whole reason frames are distributable — a frame can be serialized
to a frame.json plus a folder of images and loaded by an app that never compiled it, precisely
because there is no behaviour to carry across.
import { type Frame, withCollectorInfoDefaults } from "@cardanvil/frame-kit";
import preview from "./preview.jpg";
import { regularLayoutConfig } from "./regular/config";
export const myFrame = {
name: "Parchment",
description: "A hand-inked parchment frame.",
previewImage: preview,
tags: ["Custom"],
config: {
layouts: {
normal: withCollectorInfoDefaults(regularLayoutConfig, collectorBounds),
},
},
} as const satisfies Frame;as const satisfies Frame is the idiom: satisfies checks the object against the contract, as const
keeps the literal types.
Entry points
| Import | What it gives you |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| @cardanvil/frame-kit | The schemas (FrameSchema, LayoutConfigSchema, FrameAssetsSchema, …), the inferred types (Frame, LayoutConfig, CardBoxes, TextBox, Layout), and the authoring helpers |
| @cardanvil/frame-kit/manifest | The distributable format: frameToManifest, manifestToFrame, walkAssets, FrameManifestSchema, isContractCompatible, assertDeclarative |
| @cardanvil/frame-kit/packaging | The packaging tools the CLI is built on: validateFrames, buildFrames, FrameIndexSchema. Needs Node and Vite, so it is deliberately not on the main entry point |
The frame-kit command
frame-kit validate # check every frame, write nothing
frame-kit build --out dist # check, then pack each one
frame-kit build --out dist --watch # …and repack whenever a source changes
frame-kit schema meta # a format as JSON Schema
frame-kit --helpA frame is any directory holding a frame.meta.json, so this works both in a
workspace of packages and in a flat repository.
validate loads every frame through Vite, parses it against FrameSchema,
resolves every asset to a file that exists, and round-trips it through the
manifest in memory. Problems are collected rather than thrown, so one run
reports everything; under GitHub Actions they become inline annotations.
build adds hashing, zipping and the release index. Each frame becomes a
<slug>-<version>.cardframe, and frame-index.json describes the set.
Exit codes: 0 clean, 1 one or more frames failed, 2 used wrongly.
Frames you do not want to ship
A descriptor can opt its frame out of every build:
{
"id": "com.example.example",
"private": true,
"author": { "name": "Your Name" },
"license": "CC-BY-4.0"
}validate still loads and checks it; build reports it as skipped and packs
nothing, so it never reaches a bundle, the index, or a release. That is the
combination a worked example needs — a reference frame stays in the repository
and stays correct, without an id that is permanent identity ending up in
somebody's release by accident.
--watch
build --watch builds once, then rebuilds only the frames whose files changed,
until you stop it. It is meant to be pointed at a folder Card Anvil is linked to,
so saving a source file updates the preview without any manual step.
frame-kit build --out ~/CardAnvilFrames --watchA failed rebuild leaves the previous bundle in place and prints the problem — the
app keeps rendering the last good build rather than losing the frame mid-edit.
Bundles are written atomically, so a watcher on the other side never reads a
half-written file. --watch refuses to combine with --json or --summary-md,
which describe a single run that has finished.
Changes are debounced 200 ms, and a rebuild already in flight is allowed to
finish before the next one starts: two builds writing one bundle would race.
Output files, dotfiles, node_modules, dist and editor droppings are ignored,
so a rebuild cannot trigger itself.
Because bundles are byte-deterministic, saving a file that does not change the built output produces an identical bundle, and a consumer watching for content changes sees nothing happen — which is correct.
⚠ Frames are loaded through a long-lived Vite server, and its SSR module
cache is invalidated on each pass. Without that a rebuild would re-serve the
module Vite loaded first, so an edited layout number would rebuild
byte-identically and the watch would appear to do nothing. If you embed the
build API yourself, call invalidateFrameModules between passes on a server you
reuse.
Packaging needs Vite — it is what turns import w from "./w.png" into a file —
so it is an optional peer dependency, loaded only when packaging runs. The
schemas above work under plain Node and in a browser without it.
frame-kit studio
frame-kit studio [--root <dir>] [--port <n>] [--frame <slug>] [--no-write]A visual box editor, served from dist/studio and opened at
http://localhost:4620. It loads frames through the same Vite SSR loader
validate uses, so what it draws is the validated frame, and it reloads when a
source file changes.
The card name and type line are previewed in Beleren at the authored size and flagged when they overflow — one line each, with no wrapping, symbols or auto-shrink, so the app remains the authority on a full render.
Working on the studio's own UI: --dev serves it from source with hot
reloading, on the same port as the API, so one command covers both. It needs
the UI's source, which only exists inside the frames workspace.
Dragging a box rewrites the literal it came from, in place — nothing is
reprinted, so comments and formatting survive. Editing needs typescript
installed (an optional peer); without it the studio still runs read-only. Values
that are not written as literals are reported as such rather than edited.
@cardanvil/frame-kit/layout
How a frame's art is chosen and placed, as pure functions:
selectFrameLayers(details, options)— the base frame and section overlays a set ofFrameDetailsselects. Split out of Card Anvil'sgetFrameLayers, so the studio and the renderer cannot drift on two-colour splits, hybrid halves or the coloured-artifact fallback.frameDetailsFor(shape)— those details from a plain description of a card (colours plus artifact / vehicle / land / enchantment / hybrid / devoid), for callers with no Scryfall card to parse.placeAsset(path, config)— where a piece of art is drawn. Most frame art is full-sheet and centres, but crowns, nicknames and PT plates come from the layout's own render config; centring those puts a legendary crown in the middle of the card.resolveFrameAsset/resolveBaseFrame— exact per-family lookup, and the base-frame fallback chain.
Card Anvil still owns the other half — reading a Scryfall card into
FrameDetails — because that is where the Scryfall types live.
Type it, then validate it
Typing a config as Frame is weaker than the schema a loading app enforces. The clearest example:
passing a whole asset barrel to a typed field type-checks even when the barrel exports extra keys,
because excess property checks do not apply to namespace objects — and those extra keys then enter the
frame contract where nothing can resolve them.
import { FrameSchema } from "@cardanvil/frame-kit";
const result = FrameSchema.safeParse(myFrame);
expect(result.error?.issues ?? []).toEqual([]);The coordinate space
Boxes are absolute pixels on a 3264 × 4440 canvas.
⚠ A Magic card face is 63 × 88 mm — not 2.5 × 3.5 inches, which is the poker-card size and is wrong by 0.5 mm across and 0.9 mm tall, roughly 24 × 43 pixels at 1200 DPI. In inches the card is 2.48 × 3.46.
The canvas is not the card face: it is the full printed sheet including bleed — 2.72 × 3.7 in
(69.09 × 93.98 mm) at 1200 DPI, about 3 mm of bleed per edge. A box at y: 0 sits in the bleed, above
the top of the card. Override the canvas per frame with config.canvas.
fontSize is in points, not pixels; the renderer converts.
The distributable format
@cardanvil/frame-kit/manifest converts a Frame into something an app can install: a frame.json
with every asset URL rewritten to a package-relative path, plus the files themselves.
Asset positions are found by walking the schema, not from a hard-coded list of field paths, so a new asset field in the contract is picked up automatically.
import { frameToManifest } from "@cardanvil/frame-kit/manifest";
const manifest = frameToManifest(myFrame, {
id: "com.example.parchment",
version: "1.0.0",
resolveAsset: (url) => ({ path: `assets/${hashOf(url)}.png` }),
});manifestToFrame reverses it, turning package-relative paths back into URLs the renderer can load —
an object URL for a zip, an asset:// URL for a directory on disk, an absolute URL for a remote
package.
A manifest carries a contractVersion of the form MAJOR.MINOR, independent of this package's
version. A differing major is refused; a newer minor loads with a reduced-fidelity warning, because
added fields are always optional. A layout the loader does not recognise is dropped with a
degraded note rather than failing the frame, so one new layout cannot cost an author every other
one. Packaging preserves everything the contract defines and drops everything it does not — keys
outside the schema do not survive the round trip.
Licence
MIT. See LICENSE.
