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:
- 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 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): 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 — 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 totrue.cardType: 'widget'(default) → plainoffered ∈ manifest.sizesset 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.
.tckb wire format
Format 2 — six-entry envelope with format.json discriminator, unscoped widget.css adopted into a shadow root, and a separate widget.properties.css stream for @property registrations.
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.