Today Canvas Kit

Widget CSS contract

Five rules the widget bundler enforces so widget bundles render the same in every host. Rewrites Nrem to a host-controlled token; errors on @media viewport queries, viewport units, and root-font-metric units. The shared shadow reset closes the stacking-context and font-size leaks.

A widget bundle is a self-contained visual unit. Its rendered scale, container queries, and stacking decisions must NOT depend on which host happens to mount it. Shadow DOM enforces stylesheet scoping, but several CSS features still resolve against light-DOM state outside the widget — rem reads <html>'s root font-size, @media reads the document viewport, viewport units do too, and a widget's z-index decisions can interleave with host chrome.

tck-bundler and tck-host jointly enforce a hardened authoring contract. The bundler rewrites semantic-preserving leaks silently and errors on author-intent leaks — every error message names the suggested replacement.

Sibling pages: Platform ABI § Tailwind / CSS scoping (the bundler's emission pipeline), theme-injection contract (the [data-theme] channel — orthogonal to this contract), .tckb wire format § widget.css (what the bundler emits).

The five rules at a glance

RuleDispositionWhat violates itSuggested replacement
1. No @media viewport queriesCompile error@media (min-width: 768px) / (max-height: ...) / (aspect-ratio: ...) and their min- / max- / device- variants. Tailwind v4's viewport variants sm: md: lg: xl: 2xl: emit these.@container (...) directly, or Tailwind v4's container variants @sm: @md: @lg: @xl: @2xl:.
2. No viewport unitsCompile errorvw vh vmin vmax vi vb and their s/l/d-prefixed variants (svh dvh lvh ...) anywhere in a declaration value, including inside calc(...).100% to fill the widget's slot, or container-query units cqi / cqw / cqh / cqb / cqmin / cqmax.
3. No root-font-metric units other than remCompile errorrlh rex rch ric rcap. Tailwind v4 doesn't emit these; only hand-written CSS does.An explicit length, or an em-based equivalent the widget controls (em is a local-cascade unit and the widget root pins font-size — see Rule 4).
4. Nrem is rewritten to a host-controlled tokenSilent rewriteEvery Nrem declaration value (Tailwind v4 emits these via @theme tokens like --spacing: 0.25rem, plus any author-written CSS using rem).Bundler emits calc(N * var(--tck-rem, 16px)). Host overrides --tck-rem on [data-tck-host] to scale all widgets uniformly; the , 16px fallback keeps old hosts rendering at the previous baseline.
5. The shadow reset closes stacking + font-size leaksHost-side reset (no per-bundle work)z-index decisions inside a widget interleaving with host chrome's stacking; em / % font-size inheriting from host's <html> and diverging from rem after Rule 4 rewrites it.Shared shadow reset declares :host { isolation: isolate; font-size: var(--tck-rem, 16px); }. Widget em / % font-size now agrees with rem's rewritten value, and z-index is scoped to the widget's own subtree.

The contract is enforced by packages/tck-bundler/src/css-contract.ts (rules 1–4) and packages/tck-host/src/bootstrap/shared-stylesheets.ts + packages/tck-host/src/bootstrap/widget-mount.tsx (rule 5).

Rule 1 — No @media viewport queries

The leak. @media resolves against the document viewport of whatever host mounts the widget — not against the widget's allotted slot. A widget rendered in a 320×320 stage on a 1920px monitor still reads min-width: 769px and uses the desktop layout. Tailwind v4's viewport responsive variants (sm: md: lg: xl: 2xl:) emit @media (min-width: ...) queries, so authors run into this without realizing it.

The replacement. Use @container queries against the widget's own root container. The host's <WidgetMount> declares container-type: inline-size on every widget's root container (sibling-of-portal so portal overlays still escape — see WidgetMount internals), so @container (min-width: ...) and the inline-axis container query units cqi / cqw work out of the box.

- <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
+ <div class="grid grid-cols-1 @sm:grid-cols-2 @md:grid-cols-3">

Tailwind v4 ships the @sm: @md: @lg: ... variants natively — they emit @container (min-width: ...). The @<size>: variants are generic-container-aware: any ancestor with container-type: inline-size drives them, so widget authors don't name their container.

What's still allowed. @media queries that read OS / device preferences are unaffected — they read state that's identical inside vs outside the widget:

  • @media (prefers-reduced-motion: reduce) — accessibility motion preference
  • @media (prefers-color-scheme: ...) — discouraged in favor of [data-theme] (see theme-injection contract) but not blocked
  • @media (prefers-contrast: ...), @media (forced-colors: active)
  • @media (hover: hover), @media (pointer: coarse) — input-device queries; prefer the host-controlled tck-coarse: variant when the host must simulate or override the pointer affordance
  • @media print, @media screen — media types

The bundler's regex flags only the viewport-feature names: width / height / aspect-ratio and their min- / max- / device- variants.

Host-state variants

Tailwind variants that represent host decisions compile to shadow-host attribute selectors, not direct media queries:

VariantSelectorHost source
dark::host([data-theme='dark'])mountHost({ theme }), mirrored by <WidgetMount>
tck-coarse::host([data-tck-pointer='coarse'])mountHost({ pointer }), mirrored by <WidgetMount>

Use tck-coarse: for touch-oriented tweaks such as slightly larger type or hit targets:

<button className='text-[12px] tck-coarse:text-[14px]'>Open</button>

The host may let pointer: 'system' resolve through matchMedia('(pointer: coarse)'), or pass explicit 'fine' | 'coarse' for a preview or product override. Widget code should not own that policy decision.

Rule 2 — No viewport units

The leak. vh / vw / svh / dvh / lvh / vmin / vmax / vi / vb and friends resolve against the document viewport. A widget that writes height: 100vh floods the entire host, regardless of the slot the host allocated. The same is true for the dynamic / small / large variants from CSS Values 4.

The replacement. Two paths:

Author intentReplacement
Fill the widget's allotted spacewidth: 100% / height: 100% / inset: 0
Size relative to the widget's container query100cqi (inline) / 100cqw (inline) / 100cqh (block, requires the container to declare container-type: size) / 100cqb (block, same caveat)

Why container query units work without ceremony. The host's <WidgetMount> declares container-type: inline-size on the widget's root container. So cqi / cqw (inline axis) resolve against the widget's allotted inline size out of the box. cqh / cqb (block axis) require an ancestor with container-type: sizeinline-size doesn't cover the block axis (because size containment would break auto-height widgets the host's ResizeObserver measures). Authors wanting block-axis container queries declare container-type: size on a deeper element they own.

Rule 3 — No root-font-metric units other than rem

The leak. rlh / rex / rch / ric / rcap are root-font-metric units — their values depend on the glyph metrics of the root element's font (line-height for rlh, x-height for rex, "0"-character width for rch, CJK water-radical width for ric, cap-height for rcap). Like rem, they cross the shadow boundary to read <html>'s font.

Why an error rather than a rewrite. Substituting these with a heuristic ratio (rcap ≈ 0.7em, rch ≈ 0.5em, rex ≈ 0.5em, ric ≈ 1em, rlh ≈ 1.5em) silently misrepresents the author's design intent — those ratios are font-specific, the real values shift with font-family. An author reaching for rcap wants accurate glyph metrics; faking the value is worse than refusing to compile.

The replacement. Use an explicit length, or an em-based equivalent measured against your design font. Rule 4 + Rule 5 ensure the widget's em cascade is host-independent: the shadow root's :host { font-size: var(--tck-rem, 16px) } makes em at the widget root agree with the value rem resolves to.

Rule 4 — Nrem is rewritten through --tck-rem

The leak. rem resolves against <html>'s root font-size by CSS Values 4 §6.1.1, regardless of shadow-DOM scoping. The widget bundle has no contract on <html> — browser default (16px), accessibility override (user-set), or a host that decided to set html { font-size: 14px } for layout reasons all change the widget's rendered scale.

The rewrite. tck-bundler rewrites every Nrem literal in the widget's compiled CSS to calc(N * var(--tck-rem, 16px)):

/* Author-side or Tailwind-emitted: */
.foo {
  padding: 1rem;
}
:root {
  --spacing: 0.25rem;
}

/* Bundler emits: */
.foo {
  padding: calc(1 * var(--tck-rem, 16px));
}
:host {
  --spacing: calc(0.25 * var(--tck-rem, 16px));
}

The widget's rem chain now reads the host-controlled --tck-rem token instead of <html>'s root font-size. The , 16px fallback keeps old hosts that don't declare the token rendering at the previous baseline.

Host scaling knob. A host that wants to render every widget at a different uniform scale — compact mode, accessibility-large mode, brand calibration — overrides this single property:

:where([data-tck-host]) {
  --tck-rem: 14px; /* compact density */
}

tck-host/style.css declares --tck-rem: 16px on [data-tck-host] by default. Custom properties cross the shadow boundary by inheritance, so the override reaches every widget mount. Widgets need no per-instance accommodation.

Where [data-tck-host] belongs. The sentinel marks the host surface whose reset and design tokens are allowed to cascade into widget shadow hosts. Use <html data-tck-host> only for a true full-document host: an iframe frame, native harness document, or standalone workbench where every visible element belongs to TCK. In an app page, preview shell, or mixed document, put data-tck-host on the smallest element that owns the widget tile or canvas. Do not put TCK tokens on :root just to reach widgets; the host element is the inheritance boundary, and --tck-rem should be overridden there.

Idempotency. The rewrite's regex anchors on a numeric prefix and excludes ident-internal substrings via lookbehind, so re-bundling already-rewritten CSS is a no-op. The --tck-rem ident in the rewrite's output is never re-matched (its rem is preceded by -, which the lookbehind rejects).

What is NOT rewritten. The four other length-unit families inside the widget — em / %-form font-size (Rule 5 makes them agree with rem), px / pt / cm (absolute units; safe), container-query units (already widget-scoped), and percentage forms of layout properties — pass through unchanged.

Rule 5 — Shadow reset closes stacking and font-size leaks

Two host-side declarations. The shared shadow reset that every widget mount adopts (shared-stylesheets.ts) declares:

:host {
  display: block;
  isolation: isolate;
  font-size: var(--tck-rem, 16px);
  font-family: var(--font-sans, ...);
}

isolation: isolate makes the shadow host element a stacking-context root. Without it, a widget's z-index: 9999 declaration could paint on top of host chrome's modals or toasts — display: block on the shadow host doesn't establish a stacking context, so widget elements share the host's stacking pool. With isolation: isolate, every widget is a self-contained stacking subtree.

font-size: var(--tck-rem, 16px) pins the widget's em / %-font-size cascade. Without it, those units inherit from <html>'s root font-size (the shadow host element inherits font-size like any light-DOM element), creating a within-widget inconsistency: rem (rewritten by Rule 4) reads --tck-rem, but em reads <html>. Pinning :host { font-size: var(--tck-rem, 16px) } makes every length unit inside the widget agree on the same value.

WidgetMount internals

<WidgetMount mode='inline'> builds the shadow tree as two siblings of the shadow root:

shadow root
├── <div data-tck-widget-root>   — display:block; width:100%; height:100%;
│                                  container-type:inline-size; contain:paint
│   └── (the React root container — the widget tree mounts here)
└── <div data-tck-widget-portal> — position:fixed; inset:0; pointer-events:none

Why siblings, not nested. container-type: inline-size implies contain: layout per CSS Containment §1, which establishes a containing block for position: fixed descendants. The explicit contain: paint declaration (layered on top) does the same. If the portal target sat inside the root container, the portal's position: fixed; inset: 0 would resolve against the contained box and overlay UI would shrink to the widget's own bounds. Sibling-of-root keeps the portal outside the containment scope, so fixed still floods the document viewport — the deliberate escape hatch for toasts, modals, and dropdowns.

Why display: block; width: 100%; height: 100% and not display: contents. Pre-contract the root container ran display: contents to sidestep size-collapse for size-full widgets. container-type is meaningless on a display: contents element (it has no layout box), so the contract switch makes the root container a real layout box. Same outcome — widgets that write size-full chain through cleanly — with the wrapper actually present in layout, which is what container-type requires.

Why contain: paint on top of container-type: inline-size. container-type: inline-size gives us layout, style, and inline-size containment for free, but not paint. The explicit contain: paint declaration adds paint isolation: the browser can scope paint invalidation per-widget instead of per-document, so an offscreen widget's own redraws don't dirty the rest of the document's damage rect. On a host that resizes (the dominant per-frame cost in feeds with many widgets), this lets the browser do less work in the PrePaint / Paint phases. Safe wrt the portal escape hatch for the same reason container-type is safe here — the portal target is a sibling, not a descendant.

Host chrome containment + portal escape hatch

A trace of a host with many mounted widgets will typically show layout cost dominated by host-chrome reflow (the <article> cards, group headers, grid wrapper) rather than widget-internal cost — widget internals are already contain: layout / contain: paint (above), but host chrome isn't. The obvious next step is to layer contain: layout / contain: paint / content-visibility: auto on the host's <article> wrapper or on the <WidgetMount> light-DOM <div>. Doing this without first restructuring the portal escape hatch will silently break overlays.

Why it breaks. The portal's position: fixed resolves to the nearest containing-block-establishing ancestor. The shadow-tree's sibling-of-root placement only escapes containment inside the shadow — any ancestor in the light DOM that establishes a containing block for fixed will trap the portal at that level:

<article>                       host chrome
  <div ref={WidgetMount}>       shadow host (light DOM)
    #shadow-root
    ├── rootContainer           contain: paint (intentional, see above)
    │   └── widget tree
    └── portalTarget            position: fixed; inset: 0
                                  resolves to nearest containing-block ancestor

If the consumer applies any of the following to <article> or the <div ref={WidgetMount}> shadow host, the portal's fixed positioning collapses to that element's box and toasts / modals / dropdowns shrink to the widget's bounds:

PropertyEstablishes containing block for position: fixed?
contain: layout / contain: paint / contain: strict / contain: contentyes
content-visibility: auto / content-visibility: hiddenyes (implies contain: layout style paint)
transform: <non-none> / perspective: <non-none> / filter: <non-none>yes
will-change: transform / will-change: filter / will-change: perspectiveyes
backdrop-filter: <non-none>no — safe
overflow: hidden / clip-path: <...>no — safe (clips paint but doesn't change containing block)
position: relative / position: absolute / position: stickyno — only affects positioning of absolute descendants
isolation: isolateno — only affects stacking context

The shape of a host-chrome optimization that's safe. If a consumer wants the perf benefits of containment on the article wrapper, the portal escape hatch must be restructured first. Two options, both contract changes:

  1. Document-level overlay layer. Host allocates a single <div> directly under <body> (outside any containment-applying ancestors) and passes it as the portal target into buildCtx. Every widget's portal renders into the shared overlay layer; per-widget portal containers go away. This is the cleanest fix, but requires careful z-index / stacking-context management on the overlay div so widget overlays don't fight host chrome modals.
  2. Per-widget portal hoisted out of the shadow. Host allocates the portal target as a sibling of <article> (or anywhere in light DOM outside the containment chain) and passes it in via buildCtx. Preserves the "one portal per widget" model at the cost of more DOM bookkeeping per mount.

Until one of those lands, the only ancestor that should add contain: layout / contain: paint / content-visibility between <html> and the WidgetMount shadow host is the WidgetMount itself, which is what this contract enforces. Reviewers should flag any PR that adds containment to host-chrome elements without addressing portal hoisting first.

position: fixed is the deliberate escape hatch

The contract does NOT block position: fixed inside widgets. The portal target is itself position: fixed; inset: 0 and is meant to flood the document viewport for overlays. A widget's tooltip, modal, or dropdown SHOULD route through WidgetCtx.portalTarget and use position: fixed to position against the viewport.

Don't do this. A widget element with position: fixed placed directly in the widget tree (not via the portal) escapes the widget's bounds with no shadow-tree containment — that's the same overlay you'd get via the portal, just rendered in a place where the widget author probably doesn't realize the consequence. The portal is the visible API for the same capability; use it.

The shadow boundary still scopes the CSS that styles the fixed element (the widget's widget.css does NOT bleed into adjacent host content), so the fixed element shows the widget's design tokens. But its position is host-document-relative, not widget-bounds-relative — that's the design intent.

Migration — pre-contract widget bundles

The contract change is a major for @todayai-labs/tck-bundler. Bundles compiled with v2 still mount and render correctly — the host-side additions are non-breaking — but bundles compiled with v3 reject the patterns Rules 1–3 forbid.

Existing widget source migration:

Pre-contractPost-contract
class="md:flex" (Tailwind viewport variant)class="@md:flex" (Tailwind container variant)
class="lg:grid-cols-3"class="@lg:grid-cols-3"
<style>{'@media (min-width: 768px) { ... }'}</style><style>{'@container (min-width: 768px) { ... }'}</style>
style={{ height: '100vh' }}style={{ height: '100%' }} (fill slot) or '100cqi' (container-relative)
Hand-written font-size: 1rcapExplicit length or em-based equivalent measured against your design font

Nrem literals in widget source need NO change — Rule 4's rewrite is silent and behavior-preserving.

Why error vs rewrite

The split between rewrite (silent, value-substitution) and error (compile-time, surfaces to author) follows one rule:

  • Rewrite when the violation is a unit-semantics gotcha the author did not author intentionally. Tailwind v4's --spacing: 0.25rem token emission is a typical case — the author wrote class="p-4", the bundler emitted the rem literal. Rewriting to calc(N * var(--tck-rem, 16px)) preserves the semantic of rem (host-relative scale) while changing its resolution source (--tck-rem token instead of <html> font-size). The author observes no behavior change in any current host (where --tck-rem is 16px); the indirection only matters when a host dials it.
  • Error when the author wrote the construct deliberately and rewriting would change observable behavior. @media (min-width: 768px) becoming @container (min-width: 768px) flips the query target from "device viewport" to "widget container". An author who wrote @media may have the wrong mental model (treating widgets as occupying the viewport). Forcing them to write @container makes the new semantic explicit, and produces a per-widget responsive layout that's actually correct.

Aggregated errors. The bundler walks the CSS once, collects every violation, and throws one WidgetCssContractError carrying the full set — authors see all problems per compile, not one at a time.

Source-attributed errors

The bundler extracts class candidates AST-aware (via oxc-parser walking the widget's TS/TSX source), so every emitted utility is traceable back to the JSX class write site that produced it. When a violation is found, the bundler decodes the offending rule's selector, looks the class up in the source-location index, and surfaces a per-violation block like:

Widget CSS contract violation in widgets/foo/src/widget.tck.tsx:
  - in widgets/foo/src/widget.tck.tsx:15:24, class `md:grid-cols-3`
    emits @media (width >= 48rem)
    @media (width >= 48rem) reads the document viewport, not the widget's container.
    Replace with @container (...) — or use Tailwind v4's container variants
    (@sm: / @md: / @lg: / ...) instead of the viewport variants
    (sm: / md: / lg: / ...).

See documents/WIDGET-CSS-CONTRACT.md for the full contract.

The two-layer extractor split is what keeps both ends of this honest. Emission uses @tailwindcss/oxide's Scanner — Tailwind's own byte-level scanner is intentionally permissive ("we'd rather emit dead bytes than miss a class") and is the right scope for "what classes should Tailwind compile": authoring patterns like const cls = 'flex p-4' followed by <div className={cls}> reach the compiler, the way authors expect. Lint / source-attribution uses an AST extractor that mirrors eslint-plugin-better-tailwindcss's selector model — it only picks up class strings from JSX class / className attributes and recognised class-merge helpers (cn, clsx, cva, classnames, tw, cx), so the contract pass knows exactly which classes the author meant as class data.

When the contract pass finds a violation in the compiled CSS, it consults the AST index: a class the author wrote (in JSX or a recognised callee) throws WidgetCssContractError with full source attribution; a class that only Oxide picked up (from a JSDoc word, a variable identifier, prose) gets the offending rule silently elided from the emit. The author gets a loud error for code they wrote and silence for over-collection they didn't.

On this page