@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 (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 --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 iframe (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" — 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 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).
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.