Render-mode contract
The four transports a TCK widget can reach a viewport through — Unbundled, Bundled, Embed, Iframe — and the theme propagation channel each uses.
A widget bundle can mount through one of four transports, each with a different trust boundary and theme-propagation channel. This page pins the taxonomy, the invariants each mode relies on, and where the frosted-frame chrome lives across them.
Sibling pages: container contract (the typed ContainerParams offer), visual container contract (the frosted-frame recipe), theme-injection contract (the single sanctioned read of prefers-color-scheme).
The four transports
| Mode | What loads | Trust boundary | Used by |
|---|---|---|---|
| Unbundled | Workspace source via Vite (import './widget.tck.tsx') | Same realm as host. Live HMR. Dev only. | apps/host-playground workspace tiles, tck-preview Unbundled |
| Bundled | Local .tckb artifact loaded via tck-bundle-server ingest | Same realm as host. Exercises the canonical bundler output. | tck-preview Bundled, apps/host-playground workspace + project sources |
| Embed | Remote widget.mjs loaded into the host's React tree | Same realm as host. Production path on web. | today-platform-web canvas-boot (/feed/*, /embed/today-page/v2) |
| Iframe | widget.mjs loaded into a separate document inside an iframe | Cross-realm isolation by document boundary. Available for third-party widgets. | macOS Bundle window (post-#46), tck-preview Iframe picker |
The first three modes share one JavaScript realm; isolation is the Shadow DOM boundary established by <WidgetMount mode='inline'>. The fourth moves isolation to a document boundary.
Theme channel per mode
| Mode | Initial theme | Runtime theme change |
|---|---|---|
| Unbundled | <html data-theme> | <html data-theme> mutated in place; <WidgetMount> mirrors to each shadow host. |
| Bundled | <html data-theme> | Same as Unbundled. |
| Embed | ?theme=light|dark on URL | postMessage to canvas-boot → mountControl.setTheme(...) rewrites <html data-theme>. |
| Iframe | ?theme=light|dark on URL | postMessage cross-document → inner mountControl.setTheme(...). |
Widgets MUST NOT read prefers-color-scheme directly. Theme is externally injected by the consumer — see theme-injection contract for the single sanctioned read inside the SDK.
Iframe transparency — the invariant
The iframe element is transparent by UA default. For an embedded document to remain transparent under all color-scheme combinations, the document MUST declare an explicit transparent background:
html,
body {
margin: 0;
padding: 0;
background: transparent;
}This is the structural rule that lets a dark-theme widget render correctly inside a light-scheme parent (or vice versa). The CSS Color Adjust spec § "Color Schemes and IFrames" mandates a UA-opaque canvas on color-scheme mismatch — except when the embedded document declares its own background. transparent is a valid declaration; it explicitly overrides the would-be UA opaque canvas.
The rule is emitted by getHostHeadNodes() on every embed-mode boot. If anyone removes that declaration, iframe-mode regresses immediately under mismatched-color-scheme conditions — no error, no warning, no test failure with today's coverage. The host-shell-emitted rule applies whether the consumer is the embed route, a feed page, or a third-party host.
Where the frosted frame lives
Per the visual container contract the host paints the frame and the widget renders content-only. Across modes:
- Unbundled / Bundled / Embed — same realm, Shadow DOM per widget. The host's per-card
<FeedCardSlot>(or<WidgetSlot>) wraps<WidgetMount mode='inline'>in a<FrostedFrame>. The shared reset sheet adopted into every shadow root defaults widget roots to transparent. - Iframe — the parent host renders the
<FrostedFrame>around the<iframe>element; the inner document renders the widget content. The frosted-glass shadow cannot bleed outside the iframe element's rectangle — a known visual-fidelity tradeoff that iframe mode pays for cross-realm isolation. Acceptable for third-party widgets; preferred mode for first-party is inline.
Picking a mode
| You are | Default mode | When to switch |
|---|---|---|
| Writing a widget — iterating with HMR | Unbundled (pnpm dev in tck-preview) | Switch to Bundled to verify the artifact matches; switch to Iframe to test isolation explicitly. |
| Embedding in a web product | Embed (production), Bundled (dev fixtures) | Switch to Iframe if loading third-party / untrusted widgets. |
| Building a native shell | Embed (single-widget WebView) or Bundled (offline .tckb) | Switch to Iframe if hosting third-party widgets in one process. |
| Building a canvas workbench (Tier 2) | Bundled for portable build verification | Switch to Unbundled for an interactive HMR loop. |
The mode is the consumer's call; the SDK contract is uniform across modes — same <WidgetMount> API, same WidgetCtx, same externals whitelist, same Shadow DOM (inline mode) or iframe document (iframe mode).
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.
Feed-batch contract
Per-batch grouping for cardType:'feed' surfaces. Five-on-the-wire / three-on-the-surface kind set, header derivation, DOM contract.