Today Canvas Kit

Container contract (size / theme)

Typed ContainerParams the host offers a widget, the size-mismatch check at the mount boundary, and how the size variant (not cardType) routes layout.

A widget's container — a grid slot, an embed surface, a WKWebView, a Tier-1 <TodayWidget> mount — is the authoritative source of two values the widget cannot decide for itself: how much space it has (size) and which theme is active (theme). This page pins:

  1. The typed ContainerParams object the container offers.
  2. The bilateral size contract — what manifest.sizes declares vs what the container is allowed to offer.
  3. The size-mismatch check at host.mountWidget / host.restoreWidget / host.resizeWidget, and the WidgetSizeError it throws.

Sibling pages: visual container contract (the frosted-frame chrome), render-mode contract (where the container lives — Unbundled / Bundled / Embed / Iframe), feed-batch contract (how the canvas groups many cards).

ContainerParams — the typed offer

import type { ContainerParams, Theme, WidgetSize } from '@todayai-labs/tck'

interface ContainerParams {
  /** The size the container offers this widget. */
  readonly size: WidgetSize
  /** Resolved 'light' | 'dark'. `'auto'` resolves at the consumer boundary, never here. */
  readonly theme: Theme
}

size and theme are already first-class on WidgetCtx via the getSize()/onSizeChange() and getTheme()/onThemeChange() pairs. ContainerParams is not a new widget-facing API — widgets keep consuming via the ctx getters. It is the typed shape of the host-side offer that feeds those getters: the thing a container constructs and a debug surface's pickers mutate.

ContainerParams is per-container, not per-host. A single TodayHost orchestrates many grid slots, each its own container with its own offered size. theme is usually document-wide (one <html data-theme>), but across separate documents — two macOS windows, each its own WKWebView — each container carries its own theme.

URL serialisation — typed round-trip

Some boundaries need to serialise ContainerParams — the macOS native side constructs the WKWebView URL that carries the initial offer into the JS realm. Use:

import { containerParamsToQuery, parseContainerParams } from '@todayai-labs/tck'

function containerParamsToQuery(p: ContainerParams): URLSearchParams
function parseContainerParams(q: URLSearchParams): ContainerParams

parseContainerParams validates each field (isWidgetSize for size, the 'light' | 'dark' set for theme) and is the one place a malformed query string is caught — within JS. Encoders in other languages (the macOS Bundle window's URL constructor is Swift) must mirror the param names and value set documented here:

ParamTypeNotes
size'1x1' | '2x1' | '1x2' | '2x2' | '4x2' | '4x4' | 'fill-auto'Must be a member of the target widget's manifest.sizes.
theme'light' | 'dark'No 'auto' on the wire — resolve upstream.

Runtime changes to the offer (the user flips a picker after mount) do NOT go through the URL — they flow over the bridge (bridge.onSize / bridge.onTheme).

Size contract — bilateral

manifest.sizes is a non-empty array of WidgetSize values and defaultSize is a member of it — both enforced by validateManifest. The widget MUST render correctly at every size it declares. The container's offered size MUST be a member of the widget's manifest.sizes. Both halves are part of the contract.

Roles

RoleWhoMechanism
Declare capabilityWidget authormanifest.sizes — the set of sizes this widget renders correctly at
Offer a valueContainer (host)ContainerParams.size — one size, chosen from what the widget declared
Consume the valueWidget codectx.getSize() / useCurrentWidgetSize()

Theme has the same declare / offer / consume shape minus the declaration: every widget supports every theme by construction — the bundler emits the dark: variant CSS for all widgets, so there is no "theme a widget doesn't support." Theme is therefore a parameter, not a contract — it flows container → widget and cannot mismatch.

Size-mismatch detection — at the mount boundary

The check lives in TodayHost, at the three methods that resolve and write WidgetInstance.size: mountWidget, restoreWidget, resizeWidget. Not at WidgetLoader.load() — the load boundary checks the bundle; the mount boundary checks the mount. (The load/mount distinction is invisible on a single-widget host but bites on a multi-instance one: one cached WidgetModule mounted as N tiles at N sizes — load runs once, the size check runs N times.)

isSizeSupported — the predicate

A pure predicate in @todayai-labs/tck, alongside the other size helpers:

import { isSizeSupported } from '@todayai-labs/tck'

/**
 * True when `size` is a member of `manifest.sizes`. Plain set
 * membership — no `cardType` special-casing. `fill-auto` is just one
 * of the size values, supported iff the manifest declares it.
 */
function isSizeSupported(manifest: WidgetManifest, size: WidgetSize): boolean

WidgetSizeError

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

class WidgetSizeError extends Error {
  override readonly name: 'WidgetSizeError'
  readonly manifestId: string
  readonly offeredSize: WidgetSize
  readonly declaredSizes: readonly WidgetSize[]
}

Extends Error, NOT WidgetLoadError. Different boundary, different remediation: an ABI failure happens during loader.load and means "the bundle is broken — rebuild it"; a size mismatch happens at mount time and means "fix the host's offered size." Nesting WidgetSizeError under WidgetLoadError would let a catch (WidgetLoadError) handler swallow a host-config bug with the wrong remediation message.

onSizeMismatch host option

interface TodayHostOptions {
  /** What to do when a mount offers a size the widget didn't declare.
   *  Defaults to `'warn'`. */
  readonly onSizeMismatch?: 'warn' | 'throw'
}
  • 'warn' (default)console.warn once per (manifestId, offeredSize) pair (Set-deduped), then mount / resize at the offered size anyway. Observe-only — never changes the outcome.
  • 'throw' — construct and throw WidgetSizeError. Enforcing. Debug and developer surfaces pass this explicitly.

Default is 'warn', not 'throw' — a deliberate divergence from the ABI check. An ABI mismatch cannot run (the bundle crashes at first hook call); a size mismatch runs fine, it just renders at an undesigned size. Defaulting to 'throw' would convert every latent cosmetic size bug across every existing host into a hard blank-slot failure on upgrade.

fill-auto — a size value that routes layout

fill-auto is the auto-height size: width fills whatever the host gives the widget; height is measured from the rendered DOM via ResizeObserver. It is a size value, not a wildcard that matches anywhere.

Measurement has one guardrail: border-only host chrome is not content height. Consumers should use isUsableAutoHeight(height) from @todayai-labs/tck and keep the card in its loading/off-flow measurement state until the measured block size is greater than MIN_USABLE_AUTO_HEIGHT_PX. This prevents a freshly mounted fill-auto card from reporting a 1-2px shell border as a real height before the widget body has painted.

The size variant decides layout, not cardType. The host partitions instances by isAutoHeightSize(size): a fill-auto instance renders in the auto-height feed stack (<TodayFeedStack>), a fixed CxR instance renders on the grid (<TodayWidgetCanvas>).

isSizeSupported is therefore plain set membership for all widgets — offered ∈ manifest.sizes, no cardType special case:

  • A fill-auto-only widget genuinely cannot render in a fixed 2x2 cell (no fixed-height layout).
  • A fixed-grid widget genuinely cannot render in a fill-auto slot (committed geometry the fill-auto slot does not honour).

Both are real mismatches and plain set membership reports both correctly. cardType: 'feed' conventionally pairs with fill-auto (which is what lands the card in the feed stack), but the field is a presentation hint — it never gates the size contract.

Authoring vs. resolved sizes

sizes and defaultSize are optional at authoring time. New source should use defineManifest(...): cardType: 'feed' selects the small Feed profile and omits both fields, while cardType: 'widget' exposes the full Interactive Widget profile. Historical explicit manifests using WidgetManifestInput remain valid.

The bundler resolves the defaults and bakes the populated values into the .tckb, so the resolved WidgetManifest the host validates always carries a non-empty sizes with defaultSize ∈ sizes. validateManifest enforces that on the resolved shape, and the published JSON Schema's required set — which describes the resolved wire form — is unchanged. The optionality is an authoring / TypeScript-input concern, surfaced in the JSON Schema through default keywords; downstream consumers reading the wire form see no change.

On this page