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 (transparent card with hairline border + soft shadow + 18 px radius + 32 px outer padding band — 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 --chrometile 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 iframe (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" — transparent card with hairline border + soft
# shadow on the sky gradient. The "glass" quality is just the
# unfilled card letting the gradient show through, the same way
# preview-playground's stage chrome does.
tck-snapshot widget.tckb --out widget-tile.png --chrome tile --theme light

--chrome tile is intentionally non-tunable. Pixel-aligned with preview-playground's stage chrome (packages/tck-preview/preview/stage.tsx): outer padding 32 px, border radius 18 px, 1 px hairline border in the host's --tck-border tone (drawn via box-shadow: inset 0 0 0 1px ... so the wrapper box stays at exactly innerWidth × innerHeight), soft drop shadow (0 8px 24px rgba(20,20,30,0.06) light / 0 12px 30px rgba(0,0,0,0.55) dark), no translucent fill, no backdrop-filter, inner padding 0. Marketing screenshots are only useful when uniform — adding knobs invites per-author drift across a docs site.

The "glass quality" is the gradient showing through, not a CSS effect. The card has no fill of its own — what you read as "glass" is simply the chosen --background (sky by default for tile) showing through the unfilled card the same way it shows through preview-playground's stage iframe. A widget that respects the canvas-kit contract (transparent root background — see create-widget's template README) lets the gradient transition through its card body. A widget that explicitly opts out (e.g. bg-white on the root) covers the gradient from inside the iframe, leaving only the chrome shape (rounded corners, drop shadow, outer padding band) visible — same as how the real host treats opaque widgets. If you want the gradient uniformly across a screenshot set, drop the opaque fills from the offending widgets.

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).

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