Today Canvas Kit

Container contract (size / theme)

Typed ContainerParams the host offers a widget, the size-mismatch check at the mount boundary, and how fill-auto interacts with cardType.

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 one the manifest's widget renders correctly at.
 * For `cardType: 'feed'` widgets the size contract does not apply
 * (see fill-auto below) and this returns true unconditionally.
 */
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 — not a wildcard

fill-auto is the canonical size for cardType: 'feed' widgets — 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.

The size predicate is cardType-gated:

  • cardType: 'feed' → the size contract does not apply (host measures rendered content); the predicate short-circuits to true.
  • cardType: 'widget' (default) → plain offered ∈ manifest.sizes set membership, no special case.

The correlation is exact in the existing widget set: every widget that declares fill-auto also declares cardType: 'feed' (and sizes: ['fill-auto'], defaultSize: 'fill-auto'). No canvas-grid widget uses fill-auto; canvas widgets use only 1x1, 2x1, 1x2, 2x2, 4x2, 4x4.

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.

Persistence note

validateManifest requires sizes non-empty and defaultSize ∈ sizes for all widgets, including feed cards — so every feed card carries dead sizes: ['fill-auto'] it never consults. A future schema tightening may make sizes/defaultSize forbidden-or-optional when cardType === 'feed'; this would be additive at the same ABI label, since older consumers continue to read the dead fields without harm.

On this page