@todayai-labs/tck-preview
react-email-style dev shell for widgets. Boots Vite, auto-discovers widgets, renders them with theme/size/state controls.
A dev-only preview shell, modelled on react-email. Auto-discovers widgets in widgets/*/widget.tck.tsx, mounts each in a real TodayHost, and gives you theme + size + state controls without writing any host wiring yourself.
tck-preview is what pnpm dev invokes inside a scaffold produced by create-widget. The preview-side iframe runs each widget against the same WidgetLoader + import-map machinery production hosts use — tck-preview is not a sandbox toy, it's the production contract running locally.
CLI
# Boot the preview shell on the current directory
pnpm exec tck-preview
# Open an existing batch directly in Canvas Feed
pnpm dlx --package @todayai-labs/tck-preview tck-preview --feed ./dist --open
pnpm dlx --package @todayai-labs/tck-preview tck-preview --feed './eval/widget-batch/dist/*.tckb' --feed-label 'Eval run'
pnpm dlx --package @todayai-labs/tck-preview tck-preview \
--feed './one/dist/*.tckb' --feed-label 'One' \
--feed './one/dist/*.tckb,./two/dist/*.tckb' --feed-label 'One + Two'
pnpm dlx --package @todayai-labs/tck-preview tck-preview \
--feed './eval/widget-batch/dist/*.tckb' \
--feed-label 'Eval run' \
--feed-notes $'ran_at=2026-07-09T14:00Z\nmodel=gpt-5\nrounds=18'
npx @todayai-labs/tck-preview --feed ./dist --open
npx @todayai-labs/tck-preview --feed './eval/widget-batch/dist/*.tckb' --feed-label 'Eval run'
# Or programmatically
import { startPreview } from '@todayai-labs/tck-preview'
const handle = await startPreview({
cwd: process.cwd(),
feedSources: [
{
glob: '/abs/batch-one/dist/*.tckb',
group: 'single',
id: 'batch-one',
notes: 'ran_at=2026-07-09T14:00Z\nmodel=gpt-5\nrounds=18',
},
{
globs: ['/abs/batch-one/dist/*.tckb', '/abs/batch-two/dist/*.tckb'],
group: 'single',
id: 'combined',
notes: 'Combined comparison batch',
},
],
})For batch-only previews, --feed is enough. Use --cwd only when the preview
also needs a specific widget project root for interactive widgets or config discovery.
Repeat --feed to preview multiple batches in one server and switch between
them in the Canvas Feed sidebar. Use a comma-separated --feed value to merge
multiple directories/globs into one batch view. Use --feed-notes to attach
developer notes to each batch, such as run time, environment, user, parameters,
model, or iteration count. Notes render only on the Canvas surface, above the
Actions panel in the right rail.
CLI flags:
| Flag | Default | Description |
|---|---|---|
--port | 5173 | Preferred dev-server port; Vite uses the next free port if occupied. |
--host | localhost | Bind address. Pass 0.0.0.0 to expose on LAN. |
--open | false | Open the default browser on start. With --feed, opens Canvas Feed. |
--cwd | cwd | Project directory used for config discovery and workspace widgets. |
--config | auto | Explicit tck.preview.config.{ts,mts,js,mjs} path. |
--feed | — | One-off Canvas Feed source from a directory, .tckb glob, or comma-separated set of directories/globs. Repeat for multiple Sidebar entries. |
--feed-label | basename | Label for a --feed Canvas entry. Repeat in the same order as --feed. |
--feed-notes | — | Developer notes for a --feed Canvas entry. Repeat in the same order as --feed. |
Config model
tck.preview.config.ts has two orthogonal sections — they map 1:1 to the shell's two views. Everything is optional; zero-config Just Works on the implicit defaults.
import { defineConfig } from '@todayai-labs/tck-preview'
export default defineConfig({
scan: {
bundled: [
'../../widgets/*/dist/*.tckb',
{
glob: '../../widgets/interactive-widgets-template-v1/dist/*.tckb',
label: 'Interactive Widgets Template V1 (Latest)',
},
{
glob: '../../widgets/daily-brief-template-v7/dist/*.tckb',
label: 'Daily Brief Template V7 (Latest)',
},
{
glob: '../../widgets/on-demand-feeds-template-v1/dist/*.tckb',
label: 'On-demand Feeds Template V1',
},
{
glob: '../../widgets/pinned-feeds-template-v1/dist/*.tckb',
label: 'Pinned Feeds Template V1',
},
],
},
canvas: {
feeds: [
{ glob: '/abs/eval/*/widget-batch/dist/*.tckb', group: 'dir' },
{
globs: ['/abs/one/dist/*.tckb', '/abs/two/dist/*.tckb'],
group: 'single',
id: 'one-two',
label: 'One + Two',
notes: 'env=staging\nmodel=gpt-5\nrounds=18',
context: (bundle) => ({
slotValues: {
updatedAt: { timestamp: new Date().toISOString() },
},
}),
},
],
grids: [
{
glob: './home/dist/*.tckb',
group: 'single',
id: 'home',
notes: (key) => `live_widget_batch=${key}`,
},
],
},
})scan — single-widget inspection (Stage view)
Feeds the Stage picker. Two artifact kinds, each with an implicit default you never restate:
| Kind | Implicit default | Extend with |
|---|---|---|
| live source | <cwd>/widgets/*/widget.tck.tsx (HMR) | (not extendable — see below) |
| bundles | <cwd>/widgets/*/dist/*.tckb + <cwd>/dist/*.tckb | scan.bundled |
There is intentionally no scan.unbundled: custom out-of-tree live-source scanning is unsupported. Rendering a .tck.tsx from outside the project can't guarantee a single React / @todayai-labs/tck instance (no resolve.dedupe anchor), and the ambient Tailwind content scan only covers <cwd>/widgets/** — so such a widget would mount with a duplicate React or no utility CSS. The supported path for "preview source from elsewhere" is to build it and let scan.bundled ingest the self-contained .tckb (it ships its own CSS and externalizes React).
scan.bundled accepts raw glob strings or named entries:
type ScanBundledEntry = string | { glob: string; label?: string }A raw string renders in the Stage picker's Bundled tab under Custom location. A named entry uses label as the Bundled-tab section title, so several external widget projects can stay visually separated while sharing the same picker UI.
canvas — composition (Canvas view)
Feeds the Canvas view's Grid and Feed tabs. Each entry is a CompositionSource: its glob or globs are scanned + watched, and its bundles also surface in the Stage picker (under a canvas:* group) so you can always drill into one card. With no canvas.feeds / canvas.grids, the shell creates default Feed (all bundled) and Grid (all bundled) views that automatically list every ingested .tckb in the matching manifest.cardType family.
interface CompositionSource {
glob?: string // .tckb glob (resolved vs config dir)
globs?: string[] // several globs merged into this source; mutually exclusive with glob
group?:
| 'dir'
| 'single' // 'dir' (default) | 'single'
| ((b: FeedBundleInfo) => string | null) // …or a custom key fn
id?: string // feed/grid id (used by 'single')
label?: string | ((key: string) => string) // tab-list label; default = key
order?: number | ((key: string) => number) // tab-list order; default = numeric-aware on key
notes?: string | ((key: string) => string | undefined) // developer notes for the right rail
context?: PreviewHostContext | ((bundle: FeedBundleInfo) => PreviewHostContext | undefined)
}
interface FeedBundleInfo {
path?: string // absolute on-disk path
relPath?: string // path under the glob's static root, e.g. run-3/dist/001_x.tckb
label: string // .tckb basename (carries the NNN_ prefix)
manifest: WidgetManifest
}
interface PreviewHostContext {
slotValues?: {
actions?: WidgetSlotValues['actions']
updatedAt?: { timestamp: string; label?: string } | null
}
locale?: string | readonly string[]
timeZone?: string
}group: 'dir'(default) — one feed/grid per matched directory (therelPathsegment before the first/). A flat glob with no directory wildcard collapses to a single feed.group: 'single'— the whole glob, or the wholeglobsset, is one feed/grid.group: (bundle) => key— custom; bundles sharing a returned key stack together.nulldrops the bundle into the catch-allLoosefeed (nothing is silently lost).
The same bundle may appear in multiple configured views, so you can keep
individual feeds (One, Two) alongside combined feeds (One + Two, All)
without copying .tckb files.
notes attaches developer-only context to a resolved Canvas batch. Use it for
run timestamps, environment, user, input parameters, model, iteration count, or
any other free-form details that help interpret the batch. String notes apply to
every view produced by the source; a function can return per-key notes for
group: 'dir' or custom grouping. Notes are rendered only on the Canvas surface,
above the Actions panel in the right rail. The host settings panel remains fixed;
the Notes header can be clicked to collapse its body. The Actions panel is
collapsible in both Stage and Canvas.
context injects Host-owned values into every matched Widget. A static object applies to the
whole source; a function runs on the Preview server for each FeedBundleInfo, so it can check
the manifest's declared slots before returning values. The resolved JSON is included in the
feed index and reaches both inline and iframe mounts. For an updatedAt slot, { timestamp }
asks Preview to format and refresh the label using one shared clock; { timestamp, label }
passes exact Host copy through. Preview defaults omitted locale / timeZone to the browser,
while production Hosts should source them from product-owned user context. Invalid timestamps
are not injected.
Canvas Feed's Preview slot defaults are low-priority, session-only Host values. A default is
injected only when preview config does not set that key. CTA fallback is appended after the
Widget-authored action list and is secondary by default. Preview can promote it to primary; when it
does, any existing primary is demoted so the final list still has at most one primary. Color only
applies to a primary CTA. Update time fills the declared single-value slot. The right rail reports
how many cards are eligible and how many CTA/time values are actually injected. In Stage, the
Widget inspector's Resolved slots section starts from the selected Widget's manifest
declarations. Edits, action reordering, additions, removals, and reset operate as preview-only
per-card Host overrides; they do not mutate the bundle or tck.preview.config.ts.
Grid vs Feed is decided by manifest.cardType, not by config — mirroring how the production host composes a page (<TodayFeedStack> takes cardType: 'feed', <TodayWidgetCanvas> takes the rest). The feeds / grids arrays only declare which composition family + grouping a source belongs to; a bundle whose cardType doesn't match its declared family is skipped (a fill-auto feed card can't be laid out in the fixed-footprint grid).
The Grid tab is a four-column fixed-footprint canvas. A two-column card is placed only on a half-grid boundary: columns 0-2 or 2-4, never columns 1-3.
The group / label / order functions are Node closures, so they run server-side; the browser only receives the resolved feed index.
The Canvas Feed sidebar also accepts a dropped set of .tckb files. The shell uploads each bundle, then registers those bundle ids as a temporary in-memory feed batch. The batch exists only for the current dev-server process; it does not mutate tck.preview.config.ts, the scanned watch groups, or the source files.
Canvas navigation is URL-addressable: view=feed opens the Canvas surface, canvasTab=feed|grid chooses the sidebar tab, and canvasView=<id> selects the feed/grid view. The shell keeps those parameters in sync as you click, so copying the current URL reopens the same batch.
Host-global controls can also ride the URL:
| Param | Values | Scope |
|---|---|---|
theme | light, dark | Shell + widget theme |
background | grid, sky | Stage / Canvas background family |
feedWidth | narrow, medium, wide | fill-auto feed-card column width |
mount | inline, iframe | Stage widget mount strategy |
If a global parameter is absent, the shell uses its normal default or stored preference. If a parameter is present, it wins on load; once you change that control in the UI, the URL is updated with the selected value.
Escape hatch — viteFinal
Storybook-style last-chance hook. Receives the fully-built InlineConfig (plugins wired, port resolved, server.fs.allow set) and returns the config tck-preview actually starts the server with. Use it for one-line tweaks the SDK doesn't expose directly:
import { defineConfig } from '@todayai-labs/tck-preview'
export default defineConfig({
// Allow a Cloudflare/ngrok tunnel to reach the dev server.
viteFinal: (config) => ({
...config,
server: { ...config.server, allowedHosts: ['.trycloudflare.com'] },
}),
})Mutate-and-return is also allowed. Async hooks are fine. Programmatic startPreview({ viteFinal }) wins over the config-file value when both are present, matching the precedence of every other field.
Deprecated config fields
watchGlobs and bundledLocations are folded into scan.bundled (with a one-time warning). unbundledLocations is removed (out-of-tree live source — see above); it warns and is ignored. All three are slated for removal in the next major.
Data model & dev protocol
Like CDP, the preview server exposes a small, stable data model over a set of /__tck/* endpoints. The shell is a pure client of it; anything that speaks HTTP can drive the same surface.
Entities
| Entity | Identity | Notes |
|---|---|---|
| Bundle | id = server id | Pathless uploads use bundleHash as id; disk-watched bundles may add a path scope so identical bytes from different files can coexist. Carries bundleHash, manifest, source, label, path?, relPath?. |
| Feed / Grid | id (feed/grid key) | A composed view over bundles: an ordered list of bundle ids plus optional developer notes and per-bundle Host contexts. Derived from canvas.* + the bundle set; in-memory local feed batches are stored only for the current dev-server process. |
| WatchGroup | source label | A { source, glob } the server scans + fs-watches; the bundle's source is the group it came from. |
A Bundle is the unit of content; a Feed/Grid is a composition over bundles; cardType (on the manifest) decides which composition family a bundle is eligible for.
interface PublicBundle {
id: string
bundleHash: string // sha384(widget.mjs), base64url (64 chars)
manifest: WidgetManifest
source: string // watch-group label, or 'local' / 'remote'
label: string // .tckb basename
path?: string // absolute on-disk path (disk-watched only)
relPath?: string // path under the watch glob's static root
bundleUrl: string // /__tck/bundle/<id>/widget.mjs
cssUrl?: string
propertiesCssUrl?: string
loadedAt: number
}
interface FeedView {
id: string
label: string
notes?: string
bundleIds: string[]
contexts?: Record<string, PreviewHostContext> // keyed by server bundle id
}
interface FeedIndex {
feeds: FeedView[]
grids: FeedView[]
}HTTP / dev protocol
| Method + path | Body / params | Returns |
|---|---|---|
GET /__tck/bundles | — | { bundles: PublicBundle[] } |
GET /__tck/feeds | — | FeedIndex (resolved per the canvas.* grouping, recomputed each request) |
POST /__tck/bundle/upload | raw .tckb bytes; x-tck-label header | PublicBundle (source: 'local') |
POST /__tck/feed-batches | { id, label, bundles: { id, label }[] } | A temporary Feed view registered from already-uploaded local bundle ids; filters to cardType: "feed" and orders cards by the supplied .tckb labels. |
POST /__tck/bundle/fetch | { url } | PublicBundle (source: 'remote'; server-side fetch avoids CORS) |
POST /__tck/rescan | — | { ok, before, after, removed } — force a fresh disk scan of every watchGlobs group; reconciles both adds and deletions (see Live updates). |
DELETE /__tck/bundle/<id>.tckb | — | { deleted: boolean } |
GET /__tck/bundle/<id>/widget.mjs | — | the unpacked ESM, routed through Vite's transform pipeline (bare specifiers resolved like workspace source) |
GET /__tck/bundle/<id>/widget.css | — | text/css (adopted into the widget's shadow root) |
GET /__tck/bundle/<id>/widget.properties.css | — | text/css (@property registrations, injected into the document head) |
Live updates (HMR)
Whenever the in-memory store mutates — an HTTP upload/fetch/delete, or a disk-watcher add/change/unlink — the server pushes a Vite custom WS event:
event: tck:bundles-changed (empty payload)The shell re-fetches GET /__tck/bundles and GET /__tck/feeds on receipt, so the picker, the Feed/Grid tabs, and the open feed all re-partition live as batches land on disk. A bundle whose .tckb is rebuilt gets a new content hash → a new id → a fresh mount.
When running from this monorepo, preview servers resolve workspace TCK packages directly through Vite's /@fs source path. Only third-party dependencies are pre-bundled. This keeps manifest schemas, hooks, and action contracts aligned with the current checkout instead of retaining an older copy in a preview project's node_modules/.vite cache.
macOS deep-nested caveat. Vite's chokidar uses fsevents on macOS, which is known to drop add events for rapid mkdir+write sequences against a freshly-created nested directory tree (e.g. an eval driver that writes to mkdtemp/<id>/<date>/<kind>/widget-batch/dist/*.tckb — every intermediate dir is brand new). Files present at server boot are caught by the initial tinyglobby scan; files written into newly-minted nested dirs afterward may not be. The Canvas Feed/Grid tabs' refresh button hits POST /__tck/rescan (force a fresh tinyglobby walk), and the server broadcasts tck:bundles-changed after the rescan, so the UI re-hydrates. store.ingest is idempotent on store id, so a re-add is a cheap no-op when nothing changed; the rescan also reconciles deletions, pruning any disk-watched entry whose .tckb has since been removed (the same fsevents miss can drop an unlink), so a stale bundle can't linger in the list.
Programmatic surface
| Export | What it does |
|---|---|
startPreview(options): Promise<PreviewHandle> | Boot the Vite server + preview shell. Returns a handle with close() and the resolved URL. |
defineConfig(config) | Type-safe config helper for tck.preview.config.ts. |
PreviewHandle, PreviewOptions, PreviewConfig | Types for the boot API. |
ScanBundledEntry, ScanBundledSource | Types for scan.bundled raw glob and named glob entries. |
CompositionSource, FeedBundleInfo | Types for canvas.feeds / canvas.grids entries + server-side resolver functions. |
PreviewHostContext, PreviewSlotValues | Per-Widget Host context and slot inputs accepted by composition sources. |
Source
- Package:
packages/tck-preview - Preview UI:
packages/tck-preview/preview - Reference consumer:
apps/preview-playground— in-tree dev bench mirroring thecreate-widgetproject shape.
Preview language controls
The sidebar's Language section accepts a comma-separated list of preferred language tags, in priority order. Choose a suggested tag or enter a standard full or abbreviated tag, then select Apply. An empty list tests the card-default path. Invalid input shows an error and preserves the last applied preferences. The override is shared across Stage and Canvas for the current session; it does not edit the manifest or persist across reloads. Use configured / browser languages clears the override and restores per-card preview configuration, then browser preferences.
The Widget tab displays the selected card's supported languages, card default, and resolved
language. Canvas Host settings show the same information for each loaded card. Undeclared cards
are explicitly labeled and resolve to the Preview Host fallback, en, without claiming that their
content is English. Selection uses resolveCardLocale from @todayai-labs/tck-host-utils.
Applying preferences also overrides locale for timestamp-derived Host slot date/time formatting,
in both inline and iframe Canvas mounts. An empty override uses the Preview Host fallback for
that formatting. Fixed slot labels and authored card text remain unchanged; the control does not
automatically translate copy or add a runtime useLocale hook. The language of Host-owned labels
remains a separate policy from the negotiated card-content language.
@todayai-labs/tck-bundle-server
Vite plugin + in-memory store for ingesting .tckb bundles during development. Powers tck-preview's drop-to-load shell.
@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.