A card template is a single JSON document describing a card's artwork: its size, its sides, the elements painted on each side, and the variables a person fills in. Davi renders it client-side, so the same file produces the same picture in a browser, on a server, and in a print pipeline.
You can drop any template into the TemplateKit Inspector to render it, walk its layer tree, and drive its variables live — the file is read in your browser and never uploaded.
Format version 1.x. Only major version
1is supported. The minor version is not read, so1.0and1.5are treated identically.
A minimal template
Everything required, and nothing else — a background and one line of text bound to a variable:
{
"format_version": "1.0",
"version": "1.0.0",
"id": "minimal-card",
"name": "Minimal",
"product": "card_cr80",
"width": 1012,
"height": 638,
"fields": {
"type": "object",
"properties": {
"displayName": {
"type": "string",
"title": "Display name",
"default": "You"
}
},
"required": ["displayName"]
},
"template_data": [
{
"name": "front",
"background": {
"id": "front_bg",
"type": "rect",
"properties": { "fill": "#fef3c7" }
},
"elements": [
{
"id": "displayName",
"type": "text",
"pos": { "x": 64, "y": 80 },
"size": { "width": 884, "height": 80 },
"properties": {
"value": "{{displayName}}",
"font": { "family": "Comfortaa", "size": 56, "weight": 700 },
"color": "#1a1a1a",
"align": "left"
}
}
]
}
]
}
Top-level fields
| Field | Required | Meaning |
|---|---|---|
| format_version | ✅ | The wire format. Major version must be 1. |
| version | ✅ | Your template's own version string. Non-empty; otherwise unconstrained and unread. |
| id | ✅ | Stable identifier. Non-empty. |
| name | ✅ | Human-readable name. Non-empty. |
| product | ✅ | The product the artwork targets, e.g. card_cr80. A free string — the renderer never reads it. |
| width, height | ✅ | Canvas size in pixels. Positive integers. |
| fields | ✅ | The variables this template accepts. May declare no properties. |
| template_data | ✅ | One entry per side. Must not be empty. |
| description, mood | — | Free-text metadata. |
| author | — | { name, url? }. |
| fonts | — | Font declarations. |
| variants | — | Named colourways. |
| assets | — | Images carried inside the file. |
| source | — | Provenance. Only kind is fixed; all other keys are preserved verbatim. |
| warnings | — | Advisory export notes. |
Coordinates are bare numbers meaning pixels — there is no unit field, and no
bleed or safe-area concept in this format. The origin is the top-left, y down,
and a nested element's pos is relative to its parent.
Unknown keys are stripped, not rejected. Validation discards keys it doesn't know, everywhere except
sourceand variant element overrides. Don't rely on a validated template being a faithful round-trip of your input.
Sides
template_data is an array of frames — one per side. Each frame needs a
unique name, a background, and its elements:
"template_data": [
{ "name": "front", "background": { }, "elements": [ ] },
{ "name": "back", "background": { }, "elements": [ ] }
]
front/back is a card convention, not a rule — the format only requires at
least one uniquely-named frame.
background is required, and must be a rect or an image (not any element
type). It must cover the whole frame: either omit pos/size, or set pos to
{x: 0, y: 0} and size to the template's width/height. Omitting them is the
simplest way to say "fills the frame".
Elements
Every element has an id and a type. Everything else is optional:
| Property | Meaning |
|---|---|
| pos, size | Position and size. Optional — both default to zero. |
| rotation, opacity, blendMode, blur, shadow | Visual modifiers. |
| layoutChild | How the element behaves inside an auto-layout frame. |
There is no visible or hidden property — hide a layer by setting
opacity: 0, which keeps it in the layout so siblings don't reflow.
Element types
| type | Paints | Required properties | Also supports |
|---|---|---|---|
| text | A run of text | font (with family, size) | value, spans, color, fill, align, verticalAlign, fit, case, leadingTrim, maxLines |
| image | A raster | src and fit | cornerRadius, mask, stroke |
| rect | A rectangle | — | fill, stroke, cornerRadius, cornerSmoothing |
| vector | An SVG path | d | fill, stroke |
| qr_code | A QR code | value | errorCorrection (L/M/Q/H), foreground, background, margin |
| frame | A container | children | fill, stroke, cornerRadius, clipsContent, layout |
image.fit is one of cover, contain, fill, tile. Font weight is one of
400, 500, 600, 700, 800. lineHeight is a number or "auto" (resolved
per family at render time; a consumer that ignores the marker renders at 1.2).
A text element needs neither value nor spans — with neither it paints an
empty string. When both are present, spans wins.
Fills accept a solid colour or a gradient — linear, radial, or angular, each
with a stops array of { offset, color } (at least two stops).
Nesting and auto-layout
Elements nest only through frame.properties.children. A frame can position
its children absolutely, or lay them out automatically via
frame.properties.layout:
{
"id": "row", "type": "frame",
"pos": { "x": 64, "y": 400 },
"properties": {
"layout": { "direction": "row", "gap": 16, "primaryAlign": "start", "crossAlign": "center" },
"children": [ ]
}
}
layout takes direction (row/column, required), gap, crossGap,
padding, primaryAlign, crossAlign and wrap. Each child can carry
layoutChild with width/height (fixed/hug/fill), grow, align,
absolute, and min/max sizes.
Scaling
Rendering scales the whole template by target width ÷ template width. The target
aspect ratio must match the template's within a small tolerance — otherwise
compiling throws rather than distorting the artwork.
Fields (variables)
fields is a JSON-Schema-shaped object — a fixed subset, not real JSON Schema.
Every field is a string; format is the only type-ish distinction:
"fields": {
"type": "object",
"properties": {
"displayName": { "type": "string", "title": "Display name", "default": "You" },
"accent": { "type": "string", "format": "color" },
"photo": { "type": "string", "format": "image", "x-image-aspect": [1, 1] },
"bio": { "type": "string", "format": "longText", "maxLength": 140 }
},
"required": ["displayName"]
}
| Key | Meaning |
|---|---|
| type | Always "string". |
| title, description | Labels for the input. |
| default | Applied before caller-supplied values. |
| format | One of color, url, image, longText. Absent means plain text. |
| minLength, maxLength, pattern | Validation hints, checked against supplied values. |
| readOnly | Not editable by hand. |
| x-source | Who supplies it: user, system, or order. |
| x-widget | Names a specific input control. |
| x-image-aspect | [w, h] aspect for an image field. |
Referencing a field
Elements interpolate fields with a mustache token whose id is
[a-zA-Z_][a-zA-Z0-9_]*:
"properties": { "value": "Hello, {{displayName}}" }
Substitution walks every string in every nested property, so a token works in
text.value, text.color, rect.fill, image.src, qr_code.value and so on. A
missing value renders as an empty string.
Every reference inside template_data must resolve to a declared field —
validation fails with unknown_field_reference otherwise. The inspector also
flags the reverse: a declared field no element references.
Gap worth knowing: reference checking covers
template_dataonly. An unknown token inside avariantsoverride currently passes validation.
System fields
Some fields are supplied by Davi rather than typed by hand. A consumer treats a
field as system-supplied when readOnly is true, x-source is "system", or
its id is card_url, identifier, or begins with $$:
card_url— the full per-card tap URL, injected per physical card.identifier— the bare card id. Deprecated in favour ofcard_url; older templates embedhttps://davi.social/c/{{identifier}}.$$davi$…— Davi logo configuration ($$davi$logo_enabled,$$davi$logo_placement,$$davi$logo_color). Note$is not valid in a mustache id, so these are never interpolated — they're out-of-band configuration carried infields.propertiesand read by the renderer.
The format itself treats all of these as ordinary string fields; only consumers give them meaning.
Variants
A variant is a named colourway that overrides properties without duplicating the template:
"variants": [
{
"id": "amber",
"label": "Amber",
"swatch": "#fef3c7",
"overrides": [
{
"name": "front",
"background": { "id": "front_bg", "type": "rect", "properties": { "fill": "#fde68a" } },
"elements": [ { "id": "displayName", "properties": { "color": "#78350f" } } ]
}
]
}
]
An override targets a frame by name. background is a whole replacement;
elements are {id, properties} shallow-merge deltas, matched by id and
resolved into nested frame children too. Variant ids and labels are required,
ids must be unique, and each override must name a frame that exists (once per
variant).
Inline assets
An image can reference a raster carried inside the template, addressed by content hash:
"assets": [
{ "sha256": "deadbeef…", "base64": "iVBORw0KGgo…", "contentType": "image/png" }
]
An element points at it with src: "asset:deadbeef…". Compiling rewrites that to
a data: URL. Assets are resolved in frame backgrounds, elements, nested frame
children, and variant overrides.
If a reference has no matching bytes the template still validates and still
renders — the layer paints a grey placeholder with a photo glyph and the
renderer reports an image_load_failed warning. (The sha256 is not verified to
be a hash.)
Fonts
Declare the families a template uses so a renderer can fetch them. A descriptor is one of three kinds:
"fonts": [
{ "kind": "google", "family": "Comfortaa", "url": "https://fonts.googleapis.com/css2?family=Comfortaa:wght@700&display=swap" },
{ "kind": "fontsource", "family": "Plus Jakarta Sans", "url": "https://…css" },
{ "kind": "local", "family": "Acme", "files": [ { "weight": 400, "src": "asset:…" } ] }
]
url is required for google and fontsource, and it must point at a CSS
stylesheet, not a font file. local requires files, each with a weight and
src. Families must be non-empty and not repeat.
The block is deliberately lax: it may be partial or absent, so a template can name a family it never declares. What happens then depends on the consumer:
@davi/template-kitalone supplies no bytes for an undeclared family — its text paints with whatever fallback the canvas has registered.- The Davi order pipeline uses fonts stored against the template record.
- The Inspector, which only ever sees your file, looks the family up on Google Fonts by name and reports it as guessed.
A family whose stylesheet fails to load is dropped on its own without taking the other families with it.
Warnings
warnings records what an export could not represent faithfully. They are
advisory — a template with error-severity warnings still validates and still
renders.
"warnings": [
{ "severity": "warn", "code": "raster_empty", "message": "…", "nodeId": "logo" }
]
severity is info, warn, or error. code is an open string — the format
defines no vocabulary. The codes the Figma exporter emits today are
missing_pick, variant_structure_mismatch, raster_empty,
raster_unavailable, and qr_layer_hidden; another producer may emit its own.
Validation
validate() returns either { ok: true, value } or { ok: false, errors },
where each error is { path, code, message } and path is a JSON-Pointer-ish
location like /template_data/0/elements/1/id.
Checks include:
format_versionmajor is1—missing_format_version,unsupported_format_versionid,name,version,productpresent and non-empty —missing_required_fieldwidth/heightare positive integers —invalid_dimensiontemplate_datanon-empty with unique frame names —empty_template_data,duplicate_frame_name- the background covers the frame —
background_must_fill_frame - element ids present and unique —
missing_element_id,duplicate_element_id - variants well-formed —
duplicate_variant_id,missing_variant_label,unknown_frame_name,duplicate_override_name - every
template_datatoken resolves —unknown_field_reference - gradients valid —
gradient_needs_two_stops,invalid_stop_offset,invalid_stop_color - the fonts block is well-formed —
font_family_required,duplicate_font_family,stylesheet_font_url_required,local_font_files_required
A structural problem that fails the base parse — a missing font url, say —
surfaces as the catch-all invalid_shape rather than its specific code, because
the shape check runs first.
Element-id uniqueness is per frame, and only over a frame's top-level
elements. The same id in two different frames passes, and so do two nested children of a frame sharing an id.
validateValues() separately checks supplied values against fields, reporting
missing_required_value, invalid_value_type, value_too_long,
value_too_short, and value_pattern_mismatch.
Reading a file in
Older files may use a wrapper — the template nested under template with
assets/source/warnings beside it, keyed by schemaVersion: 1 (a different
field from format_version). And because ids are slugged from layer names, a file
can carry duplicates. Both are handled before validating:
import { healElementIds, unwrapLegacyBundle, validate } from "@davi/template-kit";
const result = validate(healElementIds(unwrapLegacyBundle(JSON.parse(text))));
Both helpers are idempotent. healElementIds renames layers (Vector, Vector_2,
Vector_3), so a tool showing the file to a person should say what changed rather
than heal silently — the Inspector validates as written first and only heals on
failure, then reports what it renamed.
Rendering it yourself
render() compiles a template plus values and paints it with CanvasKit:
import { collectFontBytes } from "@davi/template-kit";
import { render } from "@davi/template-kit/render";
import { createHeadlessEnv } from "@davi/template-kit/headless";
const fonts = await collectFontBytes(template);
const [result] = await render(
template,
{ displayName: "Alex" },
{ width: 1012, height: 638, variantId: "amber", frameNames: ["front"] },
{ ck, env: createHeadlessEnv({ fonts }), fonts },
);
// result.png — encoded PNG bytes
Use compile() when you want the node tree first — to pre-load images, inspect
it, or paint it with your own renderer — and renderCompiled() to paint it.
compile() validates internally and throws on an invalid template, an aspect-ratio
mismatch, or an unknown variantId.
Two canonical fixtures ship with the package for snapshot testing:
@davi/template-kit/fixtures exports minimalCard and fullFeatureCard.
Not specified
Deliberately open, so don't build on it: the warnings.code vocabulary,
product's semantics (never read), source beyond its kind, whether sha256
really is a sha256, and what a minor format_version bump means.
Next
- TemplateKit Inspector — render and debug a template in your browser.
- Core concepts — how cards, profiles and wallets relate.