Documentation

Card templates

The TemplateKit JSON format that describes a Davi card's artwork.

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 1 is supported. The minor version is not read, so 1.0 and 1.5 are 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 source and 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_data only. An unknown token inside a variants override 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 of card_url; older templates embed https://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 in fields.properties and 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-kit alone 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_version major is 1missing_format_version, unsupported_format_version
  • id, name, version, product present and non-empty — missing_required_field
  • width/height are positive integers — invalid_dimension
  • template_data non-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_data token 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