Today Canvas Kit

Consumer render pipeline

The consumer-side path from an already-discoverable .tckb bundle to a mounted widget: unpack, import-map resolution, manifest validation, size choice, host bootstrap, CSS adoption, and render.

This page describes the render pipeline from the consumer's point of view: a host already has, or can derive, the bundle's module URL, CSS URLs, and manifest. It does not prescribe how the import map was produced, how a bundle reached storage, or how a batch was selected. Those are covered by Platform ABI, .tckb wire format, and Cross-repo lifecycle.

The important mental model: unpacking, page bootstrap, module loading, instance mounting, and DOM rendering are separate boundaries. The same widget may cross them in different places depending on the consumer. A production BFF may unpack .tckb server-side and serve widget.mjs; a dev Vite host may ingest .tckb into tck-bundle-server; a preview iframe may load a workspace module directly. Once the host has a WidgetModule and CSS URLs, the mount path converges on TodayHost + <WidgetMount>.

End-to-end shape

consumer has .tckb bytes or bundle URLs
  ├─ unpack .tckb, if needed
  │    format gate → required entries → raw manifest parse → integrity/hash check

  ├─ make browser-loadable URLs
  │    widget.mjs URL, optional widget.css URL, optional widget.properties.css URL

  ├─ document bootstrap
  │    importmap already in <head> → host CSS loaded → mountHost verifies both
  │    mountHost writes <html data-theme="...">

  ├─ host setup
  │    new TodayHost(...) → optional setAmbientWidgetCss(...) → registerWidget(...)

  ├─ code + CSS resolution
  │    WidgetLoader.load(widget.mjs)
  │    validate named manifest export + ABI
  │    fetch/parse widget.css
  │    fetch/register widget.properties.css

  ├─ instance mount
  │    choose offered size → size contract check → seed or restore doc state
  │    WidgetInstance carries module + widgetSheet

  └─ render
       host chrome chooses geometry
       WidgetMount attaches shadow root
       shared reset + widgetSheet adopted
       WidgetCtx provided
       React renders module.default

The exact order matters in three places:

  • The import map must exist in <head> before any module script imports widget.mjs or host ESM islands that contain bare specifiers.
  • TodayHost.mountWidget resolves the module and widget CSS before it returns the WidgetInstance, so <WidgetMount> can adopt CSS synchronously on first commit.
  • <WidgetMount> creates the shadow root before rendering the widget body, so WidgetCtx.portalTarget, the shared reset, the widget stylesheet, and the theme mirror exist before widget UI runs.

1. Unpack the bundle, if the consumer still has .tckb bytes

The common unpacker is unpackTckb(bytes, options?) from @todayai-labs/tck-bundle-format. It runs in Node and in the browser.

It performs, in order:

  1. Unzip the archive.
  2. Gate format.json first. Current readers require tckbFormat: 2; a missing format.json is treated as a format-1 bundle and rejected.
  3. Require widget.mjs and manifest.json.
  4. Parse manifest.json as raw JSON. This is not full WidgetManifest validation yet.
  5. Parse and verify integrity.json when present.
  6. Compute bundleHash = sha384(widget.mjs) and, if verifyHash was passed, compare it to the caller's expected hash.

The unpack result contains:

interface UnpackedBundle {
  readonly bundleHash: string
  readonly mjs: Uint8Array
  readonly mjsByteLength: number
  readonly css: Uint8Array | null
  readonly propertiesCss: Uint8Array | null
  readonly manifest: unknown
  readonly integrity: IntegrityFile | null
}

mjsByteLength is the unpacked widget.mjs byte length. It is not the .tckb zip size and not the transfer size.

Where unpacking happens

There is no single mandated unpack location.

Consumer shapeWhat it usually does
Production web BFFFetches .tckb, calls unpackTckb(bytes, { verifyHash }), then serves /api/widgets/v1/<hash>/widget.mjs, /widget.css, and /widget.properties.css as plain responses.
Dev Vite host with @todayai-labs/tck-bundle-serverIngests upload/fetch/watch .tckb bytes into BundleStore, stores decoded widget.mjs/CSS text by sha384(widget.mjs), and serves /__tck/bundle/<hash>/widget.mjs through Vite's module pipeline.
Browser-only ad-hoc hostMay call unpackTckb in the browser, create a Blob URL for widget.mjs, and create equivalent CSS URLs or CSS text plumbing.
Workspace dev widgetSkips .tckb unpacking entirely and loads the source module through Vite.

The output needed by the rest of the pipeline is the same: a module URL for widget.mjs, optionally a widget.css URL, optionally a widget.properties.css URL, and a manifest value suitable for registration.

2. Let the browser resolve widget imports through the import map

widget.mjs is an ESM module with externalized bare specifiers such as react, react-dom/client, and @todayai-labs/tck. The browser can evaluate it only if a native import map maps those specifiers to host-served ESM URLs.

mountHost does not inject the import map. It only verifies one is already present in document.head:

import { mountHost } from '@todayai-labs/tck-host/mount'

mountHost(document, { theme: 'dark', hostCssUrl: '/__tck/v1/style.css' })

If no <script type="importmap"> is found in <head>, mountHost throws HostBootstrapError. A body-placed import map is not treated as valid because it cannot affect earlier module resolution.

The import map must preserve singleton identity:

  • All React specifiers resolve to the same physical React runtime bundle.
  • All @todayai-labs/tck subpaths resolve to the same physical TCK bundle, so the widget's useWidgetCtx() reads the same WidgetCtxContext instance the host provides.
  • @todayai-labs/tck-host is host-only. Widgets do not import it, but host ESM islands may resolve it through the same map.

Vite dev consumers usually use hostImportMapPlugin() to inject the map at HTML transform time. SSR consumers usually emit it through getHostHeadNodes({ hostCssUrl, importMap }). Production hosts may have their own generator, but the runtime precondition is the same: the native import map is present before any module script that needs it runs.

3. Bootstrap the document-level host contract

Before mounting widgets, the document needs host-level CSS and theme state.

There are two CSS wiring tiers:

  • Import CSS in an app entry that the bundler turns into a <link> or dev <style>:
import '@todayai-labs/tck-host/style.css'
  • Or pass an explicit URL to mountHost:
mountHost(document, {
  theme: 'light',
  hostCssUrl: '/__tck/v1/style.css',
})

Do not use both for the same document. If hostCssUrl is omitted, mountHost looks for evidence that the CSS import path already ran: link[data-tck-host-style], a tck-host stylesheet URL, or a Vite dev style tag. If it cannot verify CSS, it throws HostBootstrapError instead of letting widgets render with missing palette and host chrome styles.

@todayai-labs/tck-host/style.css is scoped by the [data-tck-host] sentinel. Place that attribute on the smallest element that is actually the TCK host surface: the widget tile article, canvas root, or feed root. Use <html data-tck-host> only when the whole document is a TCK host, such as a standalone iframe frame, native harness, or dedicated workbench. The stylesheet should not force tokens onto :root in mixed documents; variables such as --tck-rem inherit from the marked host element into each widget's shadow host.

mountHost also owns document-level host-state attributes:

  • It writes data-theme="light" or data-theme="dark" to options.themeRoot, defaulting to <html>.
  • If passed theme: 'system', it resolves prefers-color-scheme at this one boundary and updates on OS theme changes.
  • It writes data-tck-pointer="fine" or data-tck-pointer="coarse" to options.pointerRoot, defaulting to the theme root.
  • If pointer is omitted or passed as 'system', it resolves matchMedia('(pointer: coarse)') at this boundary and updates when it changes.
  • It returns a control with setTheme(theme), setPointer(pointer), and dispose().

The document-level stylesheet is not the widget's full styling. It provides host chrome, the palette variables such as --color-*, and document-level rules. Shadow-root widgets still need the shared reset and their own widget.css adopted inside the shadow tree later.

4. Create the host runtime

The host runtime is TodayHost.

import { TodayHost, ephemeralStorage } from '@todayai-labs/tck-host'

const host = new TodayHost({
  storage: ephemeralStorage,
  loaderOptions: {
    loadModule: (url) => import(/* @vite-ignore */ url),
  },
  onSizeMismatch: 'warn',
})

TodayHost owns:

  • registry: manifestId -> { manifest, bundleUrl, cssUrl?, propertiesCssUrl? }.
  • loader: dynamic import and WidgetModule validation.
  • docStore: per-instance JSON state snapshots.
  • dispatcher: patch sequencing and stale-patch rejection.
  • agent: host-side agent bus.
  • A host-session-owned widget CSS cache.
  • The mounted WidgetInstance map and layout subscribers.

For unbundled workspace widgets, the host must also set one ambient widget stylesheet. If the ambient build emits an @property stream, pass it as the optional second argument:

host.setAmbientWidgetCss(compiledWidgetTailwindCss, compiledWidgetPropertiesCss)

Unbundled widgets do not have a widget.css URL. Their shadow roots adopt this ambient sheet instead. The optional properties stream is registered in the same document-level sink as bundled widget.properties.css. Bundled widgets ignore the ambient sheet because they carry their own widget.css and widget.properties.css.

5. Register the widget

Registration validates the manifest value and records the URLs the host will use later.

host.registerWidget({
  manifest,
  bundleUrl: `/api/widgets/v1/${hash}/widget.mjs`,
  cssUrl: `/api/widgets/v1/${hash}/widget.css`,
  propertiesCssUrl: `/api/widgets/v1/${hash}/widget.properties.css`,
})

ManifestRegistry.register runs validateManifest(manifest). This applies manifest defaults and enforces semantic invariants, including:

  • id is a reverse-DNS-style stable id.
  • version is semver-shaped.
  • schemaVersion is an integer.
  • sizes is non-empty.
  • defaultSize is a member of sizes.
  • defaultState defaults to {}.
  • sizes/defaultSize default to ['fill-auto']/'fill-auto' for feed-card-shaped widgets.

This is the first full manifest validation boundary for consumers that read manifest.json without importing code. The loader validates again after importing widget.mjs, because the module's named manifest export is the canonical value the running widget code shipped with.

6. Choose the size and host geometry

Size has two related but distinct jobs:

  • The host surface needs geometry: grid span, feed width, iframe dimensions, or preview frame dimensions.
  • The widget needs the offered value through ctx.getSize() / useCurrentWidgetSize().

The manifest declares supported sizes:

type WidgetSize = '1x1' | '2x1' | '1x2' | '2x2' | '4x2' | '4x4' | 'fill-auto'

Fixed CxR sizes reserve grid cells. fill-auto fills the available width and measures height from rendered content.

For Tier-2 hosts that call host.mountWidget directly, the size is usually chosen before mount:

const instance = await host.mountWidget({
  manifestId: manifest.id,
  size: savedSize ?? manifest.defaultSize,
  position,
})

If size is explicit, TodayHost checks isSizeSupported(module.manifest, size) after the module loads. On mismatch:

  • onSizeMismatch: 'warn' logs once per (manifestId, offeredSize) and proceeds.
  • onSizeMismatch: 'throw' throws WidgetSizeError.

If no explicit size is passed, mountWidget uses module.manifest.defaultSize, which is already known to be a member of sizes.

Tier-1 surfaces can use <TodayWidget size={...}>. The component passes the initial size into host.mountWidget({ size }), so the host-owned WidgetInstance.size and the widget's first WidgetCtx.getSize() read agree before the first paint. Later prop changes call host.resizeWidget(instanceId, nextSize) and notify onSizeChange listeners before paint, so the host instance, surrounding geometry, and widget hook state stay aligned without remounting the widget.

7. Load code and resolve widget CSS

TodayHost.mountWidget does the load-time work:

const [loaded, sheet] = await Promise.all([
  host.loader.load(reg.bundleUrl),
  reg.cssUrl !== undefined ? widgetCss.acquireSheet(reg.cssUrl) : Promise.resolve(null),
  reg.propertiesCssUrl !== undefined
    ? widgetCss.acquireProperties(reg.propertiesCssUrl)
    : undefined,
])

Code load

WidgetLoader.load(bundleUrl) dynamically imports the module URL. Then it validates the module shape:

  • The imported value must be a module object.
  • default must be a function, used as the React component root.
  • A named manifest export must exist.
  • validateManifest(module.manifest) must pass.
  • If migrations exists, it must be an array of { to: number, run: function }.
  • manifest.engines.abi, when present, must equal the host ABI label. Missing ABI is treated as implicit v1 for back-compat and warns once per bundle URL.

The loader caches by bundleUrl, so N instances of one widget import and validate once.

widget.css

For a bundled widget, WidgetCssCache.acquireSheet(cssUrl) fetches widget.css, parses it into a constructed CSSStyleSheet, and caches it by URL with a refcount. Concurrent mounts of the same URL share one fetch and one parsed sheet.

If the CSS fetch fails, returns 404, or runs in a non-DOM context, the method resolves to null and warns. Missing CSS never blocks mount. The widget will render with only the shared reset sheet.

For an unbundled widget, cssUrl is absent. mountWidget uses the host's ambient sheet from setAmbientWidgetCss(css, propertiesCss?) instead.

Consumers should pass cssUrl and propertiesCssUrl only when the bundle registry or bundle server says those files exist. Do not infer sibling CSS URLs for every bundle: a bundle without widget.css is valid, and guessing a missing URL turns an intentional absence into an avoidable fetch failure.

widget.properties.css

widget.properties.css contains @property registrations. These are document-scoped by the CSS spec, so they are not adopted into the widget shadow root. WidgetCssCache.acquireProperties(propertiesCssUrl) fetches the CSS text and calls adoptWidgetProperties, which appends deduped @property --name { ... } rules to one document-level CSSStyleSheet in document.adoptedStyleSheets.

This fetch is also non-fatal. A missing properties stream may degrade typed-property utilities, but it must not blank the widget.

8. Mount or restore the instance

Once module and CSS resolution settle, TodayHost creates a WidgetInstance:

interface WidgetInstance {
  readonly instanceId: WidgetInstanceId
  readonly manifestId: string
  readonly version: string
  readonly schemaVersion: number
  readonly size: WidgetSize
  readonly position: GridPosition
  readonly transient: boolean
  readonly module: WidgetModule
  readonly cssUrl?: string
  readonly widgetSheet?: CSSStyleSheet
}

Fresh mountWidget:

  • Generates or accepts a caller-provided instanceId.
  • Seeds docStore with seedState ?? module.manifest.defaultState.
  • Stores the instance in the host's live map.
  • Emits a layout update.

Cold restoreWidget:

  • Loads the same module and CSS.
  • Initializes the doc store from storage or defaultState.
  • If the stored schema version differs, runs migrateDoc(module, currentDoc, storedSchemaVersion).
  • On migration failure, warns and resets that instance's doc to defaultState.

Removing an instance clears its doc snapshot, patch dispatcher state, and one hold on its cached widget.css sheet. The parsed sheet is evicted when the last instance using that CSS URL unmounts.

9. Render host chrome and choose a mount transport

The host surface owns the outer visual container and geometry. For a grid tile, that usually means:

  • Compute cell span from parseSize(instance.size).
  • Place an <article>/frame in the host grid.
  • Render host-owned frame chrome, border, shadow, toolbar, drag affordances, and actions.
  • Mount the widget body inside the frame.

For inline rendering, the body is:

<WidgetMount
  instance={instance}
  module={instance.module}
  mode='inline'
  className='size-full'
  buildCtx={buildCtx}
  onReset={() => host.resetWidget(instance.instanceId)}
/>

For iframe rendering, the parent surface renders frame chrome around an <iframe>. The iframe document then runs its own mountHost, constructs its own TodayHost, and usually mounts a closed <TodayWidget> inside <TodayHostBoundary>. Parent-to-child postMessage keeps theme, size, and state in sync.

The iframe path is a transport boundary. It still converges inside the child document on the same host primitives: mountHostTodayHostBoundary<TodayWidget><WidgetMount mode="inline">.

The preview shell has one extra timing constraint: it must size the outer stage before the iframe or inline mount has finished posting its runtime manifest. For bundle entries, the bundle list already carries the resolved manifest, so preview derives defaultSize from that entry instead of briefly rendering as 1x1. For workspace entries whose manifest is not known until module load, preview renders a host-level text-only Loading widget placeholder while a hidden 1 px mount resolves the manifest. That placeholder intentionally has no widget frame, rounded card, or feed-width geometry because no widget size exists yet.

For fill-auto, preview does not show a fake fixed-height widget. Once the size is known, it uses the latest positive ResizeObserver measurement for the selected widget/size/preview-width tuple when available; otherwise it keeps the real mount hidden and absolutely positioned for measurement and shows a second, content-level Loading widget placeholder inside the widget frame until the first measured height arrives. The hidden mount must not participate in the placeholder's normal layout, or the loading text will be visually off-center. Iframe measurements are tagged with the size and preview-width tuple that produced them, and inline measurements are tagged with the selected widget id as well. The shell keeps the current tuple in a render-synchronous ref, not a parent effect, because an inline child's initial ResizeObserver callback can arrive before parent effects run; rejecting that first stable height would leave the stage stuck in measuring. Manifest-ready callbacks must not clear the current measurement; selection changes own that reset. Production feed hosts should follow the same principle: reserve or cache host-owned geometry, but do not paint content at an arbitrary fallback height and then resize it a frame later.

10. Build the widget context

<WidgetMount> does not know the host surface's state model. The caller supplies buildCtx(portalTarget).

A typical WidgetCtx exposes:

  • instanceId, manifestId, and permissions.
  • getSize() / onSizeChange(fn).
  • getVisible() / onVisibilityChange(fn).
  • getTheme() / onThemeChange(fn).
  • getDoc() and subscribe(fn) backed by host.docStore.
  • applyPatch(patch) backed by host.submitPatch.
  • reset() backed by host.resetWidget.
  • requestFocus() and setChromeTitle(title) when the surface has chrome to update.
  • agent, filtered by manifest permissions.
  • portalTarget, the shadow-local portal container.

The context must read live size/theme/visibility through refs or stable stores. A move or resize should not force a widget remount just to update useCurrentWidgetSize().

11. What <WidgetMount> actually does

<WidgetMount mode="inline"> is the low-level render primitive. On mount it:

  1. Reuses or attaches an open shadow root on its host <div>.
  2. Clears previous shadow children.
  3. Adopts the shared reset sheet, then the instance's widgetSheet when present.
  4. Mirrors document.documentElement[data-theme] onto the shadow host as data-theme.
  5. Mirrors data-tck-pointer from the nearest host-state ancestor (or <html>) onto the shadow host.
  6. Observes host-state attribute changes and keeps the shadow host in sync.
  7. Creates two shadow children:
    • rootContainer, styled display: contents, as the React root container.
    • portalTarget, fixed and click-through by default, for shadow-local portals.
  8. Calls buildCtx(portalTarget).
  9. Renders:
<StrictMode>
  <WidgetErrorBoundary fallback={...} onReset={...}>
    <WidgetCtxContext.Provider value={ctx}>
      {createElement(module.default)}
    </WidgetCtxContext.Provider>
  </WidgetErrorBoundary>
</StrictMode>

The shared reset sheet includes:

  • The cascade layer order declaration.
  • :host { display: block; font-family: var(--font-sans, ...) }.
  • Tailwind v4 preflight inside @layer base.

The widget sheet is either:

  • The bundled widget.css parsed by TodayHost, or
  • The ambient workspace stylesheet set by setAmbientWidgetCss, or
  • Absent, in which case only the shared reset is adopted.

There is a separate sheet re-adoption effect keyed on instance.widgetSheet identity. A host that swaps CSS without remounting must pass a new WidgetInstance object carrying the new sheet; mutating instance.widgetSheet in place will not trigger React's dependency comparison.

Widget runtime crashes are caught by WidgetErrorBoundary, not by the module-load boundary. Module import / manifest / ABI failures are thrown before the widget body mounts and should be caught by the surface's outer error boundary.

12. Patches and state after first paint

Widget hooks read state from WidgetCtx, not from browser storage.

When a widget applies a patch:

  1. ctx.applyPatch(patch) reads the current docStore version.
  2. host.submitPatch wraps it in a PatchEnvelope with a new patchId.
  3. PatchDispatcher applies the patch or rejects stale baseVersion.
  4. On stale rejection, TodayHost replaces the doc-store snapshot with the current authoritative snapshot so subscribers re-render against the latest version.
  5. Applied patches are published as agent state.change messages.

This is why a host must build WidgetCtx around the same TodayHost instance that mounted the widget. Splitting loader, doc store, and dispatcher across different host objects gives the widget a provider that cannot see its own state.

The two common render paths

Tier-2 canvas / feed host

Use this when the surface owns many instances, layout, drag/drop, persistence, or custom chrome.

host.registerWidget({ manifest, bundleUrl, cssUrl, propertiesCssUrl })

const instance = await host.mountWidget({
  manifestId: manifest.id,
  size,
  position,
  seedState,
})

return (
  <FrostedFrame style={gridPlacementFrom(instance.size, instance.position)}>
    <WidgetMount
      instance={instance}
      module={instance.module}
      mode='inline'
      className='size-full'
      buildCtx={(portalTarget) => buildSurfaceCtx(host, instance, portalTarget)}
    />
  </FrostedFrame>
)

This is the path used by canvas/feed style hosts that want direct control over WidgetCtx.

Tier-1 single-widget embed

Use this when the surface wants the closed canonical load-register-mount-ctx-render pipeline.

const host = new TodayHost({ storage: ephemeralStorage })

return (
  <TodayHostBoundary host={host}>
    <TodayWidget
      src={`/api/widgets/v1/${hash}/widget.mjs`}
      cssUrl={`/api/widgets/v1/${hash}/widget.css`}
      propertiesCssUrl={`/api/widgets/v1/${hash}/widget.properties.css`}
      instanceId={instanceId}
      size={size}
    />
  </TodayHostBoundary>
)

<TodayWidget>:

  1. Loads by src, or parses a devModule value.
  2. Registers the loaded manifest and URLs on the host.
  3. Calls host.mountWidget.
  4. Builds the canonical minimum WidgetCtx.
  5. Delegates the actual render to <WidgetMount mode="inline">.

The closed contract intentionally has no loadModule override; every module must cross WidgetLoader validation.

Failure boundaries

BoundaryFailure type / symptomWho should handle it
.tckb unpackTckbUnpackError for bad format, missing entries, bad JSON, integrity/hash mismatchBFF, bundle ingest server, or browser unpack caller
Document bootstrapHostBootstrapError for missing import map or unverifiable host CSSPage/frame bootstrap; render a wiring error, not a blank widget
Dynamic importWidgetLoadError wrapping failed import()Host surface outer error boundary
Module shape / manifestWidgetLoadError or InvalidManifestError for missing exports, invalid manifest, invalid migrationsHost surface outer error boundary
ABI mismatchWidgetAbiErrorHost surface; message should tell the operator to rebuild the widget or upgrade the served ABI
Size mismatchWarns by default, or throws WidgetSizeError when onSizeMismatch: 'throw'Host surface; remediation is to change the offered size, not rebuild the bundle
Widget CSS fetchConsole warning; widgetSheet is absentNon-fatal; widget renders with only the shared reset
@property CSS fetchConsole warning; typed-property utilities may degradeNon-fatal
Widget render crashCaught by <WidgetErrorBoundary> inside <WidgetMount>Widget fallback UI; offer reload/reset when appropriate
Patch stalePatch ack { ok: false, reason: 'stale' }; host refreshes doc snapshotUsually automatic; widget subscribers re-render against current state

Consumer checklist

  • Emit or inject a native import map before any module script imports widget or host ESM that contains bare specifiers.
  • Load @todayai-labs/tck-host/style.css exactly once per document, either by bundler CSS import or by hostCssUrl.
  • Mark the smallest widget host surface with data-tck-host; reserve <html data-tck-host> for full-document hosts.
  • Call mountHost(document, { theme, ... }) before mounting widgets, and keep theme / pointer changes flowing through its control.
  • Register each widget with the validated manifest plus its widget.mjs, widget.css, and widget.properties.css URLs when available.
  • Pick a size from manifest.sizes; use manifest.defaultSize when there is no saved preference.
  • Let TodayHost.mountWidget / restoreWidget resolve module and CSS before rendering <WidgetMount>.
  • Render host-owned chrome outside the shadow root; render widget content through <WidgetMount>.
  • Route portals to ctx.portalTarget, never document.body.
  • Keep widget state in host.docStore through WidgetCtx; do not let widgets use browser storage.
  • Treat widget.css / widget.properties.css absence as degraded rendering, not a load failure.

See also

On this page