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:
- The typed
ContainerParamsobject the container offers. - The bilateral size contract — what
manifest.sizesdeclares vs what the container is allowed to offer. - The size-mismatch check at
host.mountWidget/host.restoreWidget/host.resizeWidget, and theWidgetSizeErrorit 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): ContainerParamsparseContainerParams 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:
| Param | Type | Notes |
|---|---|---|
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
| Role | Who | Mechanism |
|---|---|---|
| Declare capability | Widget author | manifest.sizes — the set of sizes this widget renders correctly at |
| Offer a value | Container (host) | ContainerParams.size — one size, chosen from what the widget declared |
| Consume the value | Widget code | ctx.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): booleanWidgetSizeError
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.warnonce per(manifestId, offeredSize)pair (Set-deduped), then mount / resize at the offered size anyway. Observe-only — never changes the outcome.'throw'— construct and throwWidgetSizeError. 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 fixed2x2cell (no fixed-height layout). - A fixed-grid widget genuinely cannot render in a
fill-autoslot (committed geometry thefill-autoslot 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.
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.
Visual container contract (chrome)
The host paints the frosted-translucent frame; the widget renders content-only. manifest.chrome opts a widget out for hello-tier self-chromed cases.