Today Canvas Kit

Theme-injection contract

Theme is externally injected by the consumer. SDK code does not consult prefers-color-scheme. The one sanctioned read lives at mountHost's auto mode.

The SDK's theme is externally injected by the consumer. SDK code — host, frame, chrome, background, bundled widget.css — does not consult prefers-color-scheme. The OS preference is consulted at one point: the injection boundary the consumer opts into, and nowhere else.

Sibling pages: container contract (theme as a typed parameter on ContainerParams), render-mode contract (the per-mode theme propagation channel), Platform ABI § Tailwind / CSS scoping (the bundler's @custom-variant dark binding).

Four-point contract

  1. SDK code MUST NOT consult prefers-color-scheme — no @media (prefers-color-scheme: ...) CSS rules, no matchMedia('(prefers-color-scheme: ...)') JS reads — anywhere on the host / frame / chrome / background / theming path.
  2. The SDK's theme is externally injected by the consumer — the web app, the native shell, the embed page, the standalone surface, the dev workbench. Whoever embeds the SDK passes the theme in.
  3. The only sanctioned read is at the injection boundary. The public API exposes an 'auto' (a.k.a. 'system') mode that, when selected by the consumer, resolves to the OS preference at that single edge. No other SDK code path may consult prefers-color-scheme.
  4. Production callers MUST NOT select 'auto'. Production callers (the web app's useTheme hook, the native shell's bridge) always have a theme decision of their own and pass an explicit 'light' | 'dark'. 'auto' exists for standalone / dev callers that genuinely don't have one.

The injection boundary — mountHost

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

interface MountHostOptions {
  readonly theme: 'light' | 'dark' | 'system'
  readonly hostCssUrl?: string
}
  • 'light' / 'dark' — explicit; no prefers-color-scheme is consulted. mountHost writes data-theme="light" / "dark" on the theme root.
  • 'system' — the boundary's auto mode. mountHost resolves via window.matchMedia('(prefers-color-scheme: dark)') once at mount, then subscribes for live OS-level changes. This is the sole sanctioned prefers-color-scheme read in the entire SDK.
  • mountControl.setTheme(theme) accepts the same three values and re-resolves 'system' against the same matchMedia.

Why exactly one boundary. Two reads in different places drift — one updates, the other doesn't; one subscribes, the other one-shots. The OS-preference read has the same shape as any cross-process input: it crosses a trust / configuration boundary into the SDK exactly once, and inside the SDK the resolved value is just data.

Tailwind v4 dark variant

Tailwind v4's default dark variant compiles to @media (prefers-color-scheme: dark) unless overridden. The bundler overrides it. Emitted widget.css registers:

@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));

So dark:bg-zinc-900 in a widget compiles to a selector keyed off the [data-theme='dark'] attribute — exactly the attribute mountHost writes. A unit test asserts the emitted CSS contains zero prefers-color-scheme substrings; the bundler is the structural enforcer.

packages/tck-host/src/style.css follows the same convention: every dark-mode block is a [data-theme='dark'] selector, never a prefers-color-scheme media query.

Propagation into Shadow DOM

For inline-mode widgets the [data-theme] attribute on <html> does not reach inside the shadow boundary as a selector match. <WidgetMount> mirrors [data-theme] from the document root onto the shadow host element via a per-mount MutationObserver, so :host([data-theme='dark']) and descendant [data-theme='dark'] * selectors inside the shadow both match. This keeps dark: widget utilities working without a prefers-color-scheme fallback.

Consumer integration

Production wire-shape:

// In the consumer (today-platform-web, native bridge, embed page):
import { mountHost } from '@todayai-labs/tck-host/mount'

const theme = useTheme() // 'light' | 'dark' — consumer's own decision
const control = mountHost(document, { theme, hostCssUrl: '/__tck/v1/style.css' })

// Wire runtime updates through the same boundary:
useEffect(() => {
  control.setTheme(theme)
}, [theme])

// SSR equivalent — emit the head nodes server-side:
import { getHostHeadNodes } from '@todayai-labs/tck-host/server'
const headNodes = getHostHeadNodes({ theme, hostCssUrl: '/__tck/v1/style.css' })

Dev-shell / standalone callers — tck-preview, ad-hoc demos — can pass theme: 'system' to defer to the OS preference. Production callers should not. A consumer that has its own theme decision (a user preference store, a system-wide hook) defeats the purpose of the contract by handing the boundary back to prefers-color-scheme: the SDK now responds to the OS instead of to the consumer's product preference.

Per-mode theme propagation

Cross-reference the render-mode contract § Theme channel per mode for the per-transport channel:

  • Inline (Unbundled / Bundled / Embed)<html data-theme> mutated in place; <WidgetMount> mirrors to shadow hosts.
  • IframepostMessage from parent to inner document; inner mountControl.setTheme(...) updates inner <html data-theme>.

Either way the read inside the SDK is exclusively at mountHost's theme parameter, and prefers-color-scheme is consulted at most once per mountHost boot when theme: 'system' is explicitly passed.

On this page