Today Canvas Kit
SDK packages

@todayai-labs/tck-snapshot

Render a widget source or .tckb to a PNG via headless chromium. Reuses the tck-host-native harness; no parallel bootstrap implementation.

Render a Today Canvas Kit widget to a PNG via headless Chromium (driven by Playwright). Takes either a built .tckb envelope or a .tck.tsx source (bundled on the fly through tck-bundler); emits a PNG sized to the widget's declared geometry, optionally wrapped in a marketing-style tile chrome.

The package is the canonical answer to "I just want one PNG of this widget — for a registry thumbnail, a docs site, a Linear issue, a screenshot in a Slack message." It reuses the same harness.html the native macOS / iPadOS / visionOS hosts use, so what you see in a snapshot matches what those hosts render at runtime.

When to use

  • Generating widget thumbnails for a registry or marketplace.
  • Producing screenshots for documentation, blog posts, or design reviews.
  • Visual-regression suites in vitest (the package exports a programmatic renderWidget API).
  • Quick "does this widget actually paint?" smoke check during widget authoring.

Install + one-time browser download

pnpm add -D @todayai-labs/tck-snapshot
# Playwright ships chromium separately — must run once per machine.
pnpm exec playwright install chromium

CLI

tck-snapshot <entry-or-bundle> --out <png-path> [opts]

<entry-or-bundle> is either a .tckb (treated as already built) or a .tck.tsx source (bundled in-place into a temp .tckb first).

FlagDefaultDescription
--out <path>requiredPNG output path (parent dirs created on demand).
--size <s>manifest.defaultSizeOne of 1x1, 2x1, 1x2, 2x2, 4x2, 4x4, fill-auto. Snapshot fails fast if the requested value isn't in manifest.sizes.
--preview-width <p>mediumfill-auto column-width preset: narrow / medium / wide. Pinned against @todayai-labs/tck-preview/widths so snapshot's column matches the preview shell's.
--width <px>—fill-auto column width as raw CSS px. Mutually exclusive with --preview-width.
--theme <t>lightlight or dark. Drives data-theme on the harness.
--chrome <c>nonenone (image edge meets widget edge) or tile (frosted card with translucent fill, 4 px backdrop blur, hairline border, soft shadow, 19.5 px radius, and 36 px outer padding — pixel-aligned with preview-playground's stage chrome).
--background <b>solid (none) / sky (tile)transparent (alpha PNG), solid (pure white / black), grid (dot pattern), sky (gradient). Default tracks --chrome — tile defaults to sky because the unfilled card needs a gradient ground for the "glass" reading.
--scale <n>2Device pixel ratio for the rasterizer.
--wait <selector>—Extra selector to wait for inside the document (in addition to body[data-ready="1"] and document.fonts.ready).
--timeout <ms>10000Hard upper bound for the whole render.

Set TCK_SNAPSHOT_DEBUG=1 to stream chromium console output, page errors, and 4xx/5xx responses to stderr while debugging.

Two presets the design system asks for

# "Naked" — image dims == widget dims, pure black/white background.
tck-snapshot widget.tckb --out widget.png --chrome none --theme light

# "Marketing tile" — frosted card on the sky gradient,
# using the same shell as Preview Playground.
tck-snapshot widget.tckb --out widget-tile.png --chrome tile --theme light

--chrome tile uses the shared tck-host feed-card shell: 36 px outer padding, 19.5 px border radius, translucent fill, 4 px backdrop blur, a 1 px inset hairline, and a soft shadow. Inner padding is zero; the Widget owns its content spacing. These values match Preview Playground and the native harness.

A Widget with a transparent root lets the sky gradient and frosted shell show through. An opaque Widget root covers that background inside its shadow root; the surrounding card shape remains visible.

Font parity with preview

snapshot's harness loads the canonical tck-host-style.css only — the same bytes that ship to macOS / iPadOS / visionOS native hosts via tck-host-native. The host's --font-sans is 'Inter', 'SF Pro Text', system-ui, -apple-system, …, and snapshot screenshots resolve to that stack.

tck-preview (and any consumer like preview-playground) additionally imports Tailwind for its shell UI, but the shell document is not the widget host surface. Each preview tile marks its <article> with data-tck-host, so the host font token is defined on the closest light-DOM ancestor of the widget shadow host and wins over shell-level Tailwind theme tokens by inheritance distance. Snapshot, native harnesses, preview iframe mode, and preview inline tiles therefore resolve the same canonical host font stack.

Width presets — shared with the preview shell

fill-auto widgets render against a host-supplied column width. Snapshot offers three named presets sourced from @todayai-labs/tck-preview/widths, so the preview shell and the snapshot's screenshot:all loops pin against the same numbers:

  • narrow — 2 cells + 1 gutter = 336 px
  • medium — 3 cells + 2 gutters = 512 px (default)
  • wide — 4 cells + 3 gutters = 688 px

For off-preset widths, --width <px> accepts a raw CSS-pixel column width (mutually exclusive with --preview-width).

Host slot placeholders

Snapshots populate only standard slots declared in manifest.slots:

  • actions: preserve Widget-authored actions and append an Ask example (chat.composer.fill). Ask is primary with the sky accent when the defaults are empty, and neutral secondary otherwise. An existing host.ask-agent ID is preserved without duplication.
  • updatedAt: preserve a non-null Widget default; otherwise use the fixed timestamp 2026-01-01T12:00:00.000Z and label Updated just now.

These are deterministic display fixtures, not live data or a connected chat backend. Widgets without a corresponding declaration receive no injected value. The Widget controls how its resolved slots render; legacy inline CTAs render as authored.

Use --no-slot-placeholders on the CLI or { slotPlaceholders: false } in RenderConfig to capture only Widget-authored defaults. By default, slotPlaceholders is true. Snapshot sends the values through the native harness's setSlotValues bridge and waits for the Widget to repaint before measuring its height and capturing the PNG.

Programmatic API

import { renderWidget } from '@todayai-labs/tck-snapshot'
import { writeFile } from 'node:fs/promises'

const result = await renderWidget(
  { bundle: 'dist/widget.tckb' },
  { size: '2x2', theme: 'dark', chrome: 'tile', background: 'solid' },
)

await writeFile('widget.png', result.png)
console.log(`emitted ${result.widthPx}x${result.heightPx} px`)

RenderInput accepts either bundle (a .tckb path) or entry (a .tck.tsx path); the two are mutually exclusive. RenderResult.png is a Node Buffer of PNG bytes; widthPx / heightPx are the device-pixel image dimensions (CSS px × scale).

How it works

Single-document architecture (v0.2) — snapshot navigates Playwright directly at the canonical native harness with all decoration knobs encoded as URL params. No iframe layer, no snapshot-side outer document. The same harness.html the macOS / iPadOS / visionOS native shells run is the only document in the rendered tree.

  1. Materialize. .tckb is read straight from disk; .tsx is bundled first via tck-bundler into a throwaway .tckb under node_modules/.cache/tck-snapshot-*/ and then unpacked.
  2. Preflight. manifest.sizes is intersected with the requested size; mismatch throws SnapshotError before chromium starts. The bundler's own validateManifest already ran during the pack, so the manifest is always shape-valid by this point.
  3. Local server. A tiny node:http listener bound to 127.0.0.1 serves the bundle-agnostic harness.html and __deps__/ directory straight out of tck-host-native's dist/, plus the unpacked widget.mjs / widget.css / widget.properties.css / manifest.json.
  4. Navigate. Playwright opens http://127.0.0.1:<port>/harness.html?size=…&theme=…&chrome=…&background=…. The harness reads all four params from the URL, sets body[data-chrome] and body[data-background] data-attributes, and its own CSS paints the rounded card + the chosen ground.
  5. Wait for ready. The harness flips body[data-ready="1"] once <WidgetMount> paints; the snapshot then awaits document.fonts.ready (capped at 2 s) and the optional --wait selector. fill-auto widgets get a bounding-box-stability poll (50 ms steady window) on #root before the viewport is resized to the measured height.
  6. Screenshot. Chromium's viewport is the image canvas. For fixed-grid sizes it was set at launch to image dims; for fill-auto it's resized after measure. page.screenshot() writes the PNG — the chrome's drop shadow lives inside the outer-padding band, so nothing gets clipped.

URL contract

Snapshot's pipeline composes one URL per render and navigates to it directly:

ParamSourceValues
sizecanonical container offer (parseContainerParams)1x1 / 2x1 / 1x2 / 2x2 / 4x2 / 4x4 / fill-auto
themecanonical container offerlight / dark
chromesnapshot tooling paramnone (default) / tile
backgroundsnapshot tooling paramtransparent (default) / solid / grid / sky

size and theme are part of the canonical container contract. chrome and background are snapshot-side decoration knobs the harness reads with allow-list validation; an unknown value falls back to the default and the rendering proceeds. macOS / iPadOS / visionOS native shells leave both unset and get the historic edge-to-edge transparent layout exactly. The same harness URL works in every consumer — preview-playground could adopt the same params in a follow-up to retire its React-side stage chrome painting.

Snapshot exports buildHarnessUrl so consumers writing their own pipelines can compose the URL by hand if needed.

Versioning

Not in the fixed SDK group. Walks its own version line (currently 0.x) — see VERSIONING.md. The package is an optional dev tool, not part of the runtime SDK; consumers add it only when they need image output.

Source

On this page