@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
renderWidgetAPI). - 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 chromiumCLI
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).
| Flag | Default | Description |
|---|---|---|
--out <path> | required | PNG output path (parent dirs created on demand). |
--size <s> | manifest.defaultSize | One of 1x1, 2x1, 1x2, 2x2, 4x2, 4x4, fill-auto. Snapshot fails fast if the requested value isn't in manifest.sizes. |
--preview-width <p> | medium | fill-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> | light | light or dark. Drives data-theme on the harness. |
--chrome <c> | none | none (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> | 2 | Device 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> | 10000 | Hard 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 pxmedium— 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 theskyaccent when the defaults are empty, and neutral secondary otherwise. An existinghost.ask-agentID is preserved without duplication.updatedAt: preserve a non-null Widget default; otherwise use the fixed timestamp2026-01-01T12:00:00.000Zand 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.
- Materialize.
.tckbis read straight from disk;.tsxis bundled first viatck-bundlerinto a throwaway.tckbundernode_modules/.cache/tck-snapshot-*/and then unpacked. - Preflight.
manifest.sizesis intersected with the requested size; mismatch throwsSnapshotErrorbefore chromium starts. The bundler's ownvalidateManifestalready ran during the pack, so the manifest is always shape-valid by this point. - Local server. A tiny
node:httplistener bound to127.0.0.1serves the bundle-agnosticharness.htmland__deps__/directory straight out oftck-host-native'sdist/, plus the unpackedwidget.mjs/widget.css/widget.properties.css/manifest.json. - Navigate. Playwright opens
http://127.0.0.1:<port>/harness.html?size=…&theme=…&chrome=…&background=…. The harness reads all four params from the URL, setsbody[data-chrome]andbody[data-background]data-attributes, and its own CSS paints the rounded card + the chosen ground. - Wait for ready. The harness flips
body[data-ready="1"]once<WidgetMount>paints; the snapshot then awaitsdocument.fonts.ready(capped at 2 s) and the optional--waitselector.fill-autowidgets get a bounding-box-stability poll (50 ms steady window) on#rootbefore the viewport is resized to the measured height. - 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:
| Param | Source | Values |
|---|---|---|
size | canonical container offer (parseContainerParams) | 1x1 / 2x1 / 1x2 / 2x2 / 4x2 / 4x4 / fill-auto |
theme | canonical container offer | light / dark |
chrome | snapshot tooling param | none (default) / tile |
background | snapshot tooling param | transparent (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
- Package:
packages/tck-snapshot - Underlying harness:
tck-host-native— the snapshot reuses itsharness.html+__deps__/verbatim.