Skip to content

ThemeContext ($provide / $inject)

The cross-component context primitive. ThemeProvider.rozie publishes a live theme object with $provide('theme', …); ThemeButton.rozie reads it with $inject('theme'); ThemePassthrough.rozie sits between them and knows nothing about the theme — no theme prop, no forwarding. Three separately-compiled .rozie modules, nested by ThemeContextDemo.rozie.

$provide / $inject solve the prop-drilling problem compound components hit — Tabs/Tab, Select/Option, Form/Field: a parent has to hand a value to a deep descendant through middle components that know nothing about it. Every framework already has a context mechanism (Vue provide/inject, Svelte setContext/getContext, React/Solid Context.Provider + useContext, Angular DI, Lit @lit/context), but each spells it differently; Rozie gives you one pair of sigils that lowers to each. The ancestor provides once; a descendant at any depth injects; the components in between carry no contract.

Two scope rules to keep straight:

  • Resolution is per component tree, not global. $inject returns the nearest provided value above it in the component tree — context is not a global store, and two sibling provider subtrees don't leak into each other. Only the token identity is shared process-wide, which is what lets separately-compiled modules find each other (see below).
  • Context does not cross a portal boundary. A subtree relocated with r-portal (or mounted through a portal slot) cannot be assumed to still reach a provider above its original position. Keep $inject consumers in normal child position and portal only presentation subtrees.

Live demo

Click the button: its label cycles red → green → blue. The click calls the provider's cycle() through the injected handle, the provider mutates its reactive $data.color, and the new value arrives back at depth through the live getter — a full reactive round-trip with no prop passed at any level. Inspect the DOM: the dashed box is ThemePassthrough, a dumb <div> + <slot /> that never sees a theme prop.

Walkthrough

The provider — $provide with a live getter

rozie
$provide('theme', {
  get color() {
    return $data.color
  },
  cycle,
})

$provide(key, value) is a top-level <script> statement whose key must be a string literal (a runtime-computed key is compile error ROZ129). The getter is the load-bearing line: it is what makes the context reactive. Provide a value that carries live references — a getter, a $computed accessor, or a signal — never a snapshotted primitive. $provide('theme', $data.color) would compile, but the descendant would see the color frozen at provide time; reading through get color() rides the reactive $data.color at the moment the consumer renders. See the live-reference rule in the guide.

The unaware middle

ThemePassthrough renders <slot /> and nothing else — no $inject, no theme prop. It exists to prove the point: the injected value reaches the button through it without it participating. Components with no $provide/$inject emit byte-for-byte unchanged — the context machinery costs nothing where it isn't used.

The consumer — $inject bound to a const

rozie
<script>
const theme = $inject('theme')
</script>

<template>
<button @click="theme && theme.cycle()">
  {{ theme && theme.color }}
</button>
</template>

$inject(key, fallback?) is an expression that must bind a local const (ROZ132) with a string-literal key (ROZ130). It returns the nearest provided value and is usable in setup, template, and reactive contexts.

The guarded reads (theme && theme.color) are deliberate: they cover the Lit async edge. @lit/context's consumer resolves via a context-request event round-trip, so on the first paint the injected value can be undefined even when a provider exists higher up; the other five targets resolve context synchronously during setup, where the guard is a harmless no-op.

How separately-compiled modules meet

The three files here are compiled independently — no shared import ties the provider to the consumer. The string key is the rendezvous: Vue and Svelte use the literal key directly; Lit uses a process-global Symbol.for('rozie:theme'); React, Solid, and Angular back their token in a globalThis registry keyed by your string, so two independently-built modules resolve the same Context object / InjectionToken. Each target lowers the pair to its native context idiom:

Target$provide('theme', v)$inject('theme')
Vueprovide('theme', v)inject('theme')
Svelte 5setContext('theme', v) at initgetContext('theme')
Reactreturned JSX wrapped in <C.Provider value={v}>, C = rozieContext('theme')useContext(rozieContext('theme'))
Solidreturned JSX wrapped in <C.Provider value={v}>, C = rozieContext('theme')useContext(rozieContext('theme'))
Angularproviders: [{ provide: rozieToken('theme'), useFactory: () => v }]inject(rozieToken('theme'))
Litnew ContextProvider(this, { context: C, initialValue: v }) + setValue on changenew ContextConsumer(this, { context: C, subscribe: true })

Four compile-time diagnostics (ROZ129ROZ132) catch malformed forms — see the Diagnostics notes in the guide.

The pattern in production

The Lexical family is built on exactly this seam: <LexicalEditor> $provides the live editor under 'rozie-lexical-editor', and every plugin, the toolbar, and any custom child you author $injects it — the compositional plugin model on all six targets.

Source — ThemeContextDemo.rozie

The composer. Three separately-compiled modules nested three deep:

rozie
<!--
  ThemeContextDemo.rozie — the composer that proves the no-prop-drill claim
  (Phase 36, $provide / $inject), productized from Spike 010's mounted
  `<ThemeProvider><Panel><ThemedButton/></Panel></ThemeProvider>` fixture.

  Three SEPARATELY-COMPILED modules are nested here:

    ThemeProvider   — $provide('theme', { get color, cycle })
      ThemePassthrough  — renders <slot/>, KNOWS NOTHING about theme
        ThemeButton   — $inject('theme'), shows color, click → cycle()

  The button shows the injected color even though `ThemePassthrough` never
  forwards it (inject reached depth through the unaware middle — R11, the
  cross-file token-identity proof). Clicking cycles the color red→green→blue
  at depth (reactive round-trip — R13). The `context-behavior.spec.ts`
  Playwright cell drives exactly this surface across all 6 targets (Angular in
  a real analogjs build, REQ-31).
-->
<rozie name="ThemeContextDemo">

<components>
{
  ThemeProvider: '../ThemeProvider.rozie',
  ThemePassthrough: '../ThemePassthrough.rozie',
  ThemeButton: '../ThemeButton.rozie',
}
</components>

<template>
<div class="theme-context-demo" data-theme-context-demo>
  <ThemeProvider>
    <ThemePassthrough>
      <ThemeButton />
    </ThemePassthrough>
  </ThemeProvider>
</div>
</template>

<style>
.theme-context-demo {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 1rem;
  max-width: 320px;
}
</style>

</rozie>

Source — ThemeProvider.rozie

rozie
<!--
  ThemeProvider.rozie — the PROVIDER half of the cross-component context
  primitive (Phase 36, $provide / $inject), productized 1:1 from Spike 010's
  validated `ThemeProvider` fixture.

  It holds reactive `color` state, exposes a `cycle()` that advances it
  red -> green -> blue, and publishes BOTH to descendants via
  `$provide('theme', …)`. Crucially the provided value carries a *getter*
  (`get color()`), NOT a snapshotted primitive — so any depth-N consumer that
  reads `theme.color` rides the live reference and re-renders when `cycle()`
  mutates it (D-3 / REQ-29). Snapshotting `color` here (`{ color }`) would
  freeze the value at provide-time and the reactive round-trip would be dead.

  ThemeProvider renders only `<slot/>` — it knows nothing about WHO consumes
  the theme or how deep they sit. That decoupling is the whole point.
-->
<rozie name="ThemeProvider">

<data>
{
  // Reactive state — the `$data` sigil makes this signal-backed on every
  // target (React state, Vue ref, Svelte $state, Angular signal, Solid signal,
  // Lit `this._color.value` preact-signal). A bare top-level `let` would NOT
  // re-render the React/Solid/Lit provider when mutated only from a handler;
  // `$data` is the reactive contract this feature rides on. (Confirmed against
  // Spike 010's validated refs, which used useState/createSignal.)
  color: 'red'
}
</data>

<script>
// The cycle order. A plain module constant — never reassigned.
const NEXT = { red: 'green', green: 'blue', blue: 'red' }

const cycle = () => {
  $data.color = NEXT[$data.color]
}

// Publish the live theme. The GETTER is load-bearing (D-3 / REQ-29): reading
// `theme.color` at depth always reflects the current reactive `$data.color`,
// so clicking through `cycle()` cycles the displayed color at depth (the
// reactive round-trip). Snapshotting the primitive here (`{ color: $data.color }`)
// would freeze it at provide-time and kill the round-trip.
$provide('theme', {
  get color() {
    return $data.color
  },
  cycle,
})
</script>

<template>
<div class="theme-provider" data-theme-provider>
  <slot />
</div>
</template>

<style>
.theme-provider {
  display: block;
}
</style>

</rozie>

ThemeProvider — compiled output

vue
<template>

<div class="theme-provider" data-theme-provider="" v-bind="$attrs">
  <slot></slot>
</div>

</template>

<script setup lang="ts">
import { provide, ref } from 'vue';

defineSlots<{
  default(props: {  }): any;
}>();

const color = ref('red');

// The cycle order. A plain module constant — never reassigned.
const NEXT = {
  red: 'green',
  green: 'blue',
  blue: 'red'
};
const cycle = () => {
  color.value = NEXT[color.value];
};

// Publish the live theme. The GETTER is load-bearing (D-3 / REQ-29): reading
// `theme.color` at depth always reflects the current reactive `$data.color`,
// so clicking through `cycle()` cycles the displayed color at depth (the
// reactive round-trip). Snapshotting the primitive here (`{ color: $data.color }`)
// would freeze it at provide-time and kill the round-trip.

provide('theme', {
  get color() {
    return color.value;
  },
  cycle
});
</script>

<style scoped>
.theme-provider {
  display: block;
}
</style>
tsx
import { useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieContext } from '@rozie/runtime-react';
import './ThemeProvider.css';

interface ThemeProviderProps {
  children?: ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function ThemeProvider(props: ThemeProviderProps): JSX.Element {
  const __ctx_theme = rozieContext("theme");
  const attrs = props as Record<string, unknown>;
  const [color, setColor] = useState('red');

  // The cycle order. A plain module constant — never reassigned.
  const NEXT = {
    red: 'green',
    green: 'blue',
    blue: 'red'
  };
  function cycle() {
    setColor(prev => NEXT[prev]);
  }

  return (
    <__ctx_theme.Provider value={{
  get color() {
    return color;
  },
  cycle
}}>
    <>
    <div data-theme-provider="" {...attrs} className={clsx("theme-provider", (attrs.className as string | undefined))} data-rozie-s-00821bac="">
      {(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
    </div>
    </>
    </__ctx_theme.Provider>
  );
}
svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { setContext } from 'svelte';

interface Props {
  children?: Snippet;
  snippets?: Record<string, any>;
  [key: string]: unknown;
}

let {
  children: __childrenProp,
  snippets,
  ...__rozieAttrs
}: Props = $props();

const children = $derived(__childrenProp ?? snippets?.children);

let color = $state('red');

// The cycle order. A plain module constant — never reassigned.
const NEXT = {
  red: 'green',
  green: 'blue',
  blue: 'red'
};
const cycle = () => {
  color = NEXT[color];
};

// Publish the live theme. The GETTER is load-bearing (D-3 / REQ-29): reading
// `theme.color` at depth always reflects the current reactive `$data.color`,
// so clicking through `cycle()` cycles the displayed color at depth (the
// reactive round-trip). Snapshotting the primitive here (`{ color: $data.color }`)
// would freeze it at provide-time and kill the round-trip.

setContext('theme', {
  get color() {
    return color;
  },
  cycle
});
</script>

<div data-theme-provider="" {...__rozieAttrs} class={["theme-provider", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-00821bac>{@render children?.()}</div>

<style>
:global {
  .theme-provider[data-rozie-s-00821bac] {
    display: block;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, forwardRef, inject, input, signal, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieToken } from '@rozie/runtime-angular';

interface DefaultCtx {}

@Component({
  selector: 'rozie-theme-provider',
  standalone: true,
  imports: [NgTemplateOutlet],
  template: `

    <div class="theme-provider" data-theme-provider="" #rozieSpread_0 #rozieListenersTarget_1>
      <ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" />
    </div>

  `,
  styles: [`
    :host(rozie-theme-provider) { display: contents; }
    .theme-provider {
      display: block;
    }
  `],
  providers: [
    {
      provide: rozieToken('theme'),
      useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => ThemeProvider)); return ({
  get color() {
    return __rozieCtxHost.color();
  },
  cycle: __rozieCtxHost.cycle
}); },
    },
  ],
})
export class ThemeProvider {
  color = signal('red');
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });

  NEXT = {
    red: 'green',
    green: 'blue',
    blue: 'red'
  };
  cycle = () => {
    this.color.set(this.NEXT[this.color()]);
  };

  static ngTemplateContextGuard(
    _dir: ThemeProvider,
    _ctx: unknown,
  ): _ctx is DefaultCtx {
    return true;
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });
}

export default ThemeProvider;
tsx
import type { JSX } from 'solid-js';
import { createSignal, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieContext } from '@rozie/runtime-solid';

__rozieInjectStyle('ThemeProvider-00821bac', `.theme-provider[data-rozie-s-00821bac] {
  display: block;
}`);

interface ThemeProviderProps {
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function ThemeProvider(_props: ThemeProviderProps): JSX.Element {
  const [local, attrs] = splitProps(_props, ['children']);
  const resolved = () => local.children;

  const __ctx_theme = rozieContext("theme");
  const [color, setColor] = createSignal('red');

  // The cycle order. A plain module constant — never reassigned.
  const NEXT = {
    red: 'green',
    green: 'blue',
    blue: 'red'
  };
  function cycle() {
    setColor(NEXT[color()]);
  }

  // Publish the live theme. The GETTER is load-bearing (D-3 / REQ-29): reading
  // `theme.color` at depth always reflects the current reactive `$data.color`,
  // so clicking through `cycle()` cycles the displayed color at depth (the
  // reactive round-trip). Snapshotting the primitive here (`{ color: $data.color }`)
  // would freeze it at provide-time and kill the round-trip.

  return (
    <__ctx_theme.Provider value={{
  get color() {
    return color();
  },
  cycle
}}>
    <>
    <div data-theme-provider="" {...attrs} class={"theme-provider" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-00821bac="">
      {resolved()}
    </div>
    </>
    </__ctx_theme.Provider>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal } from '@lit-labs/preact-signals';
import { rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';

const __rozieCtx_theme = createContext(Symbol.for("rozie:theme"));

@customElement('rozie-theme-provider')
export default class ThemeProvider extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.theme-provider[data-rozie-s-00821bac] {
  display: block;
}
`;

  private _color = signal('red');
private __rozieCtxProvider_theme = new ContextProvider(this, { context: __rozieCtx_theme, initialValue: ((__rozieCtxHost) => ({
  get color() {
    return __rozieCtxHost._color.value;
  },
  cycle: __rozieCtxHost.cycle
}))(this) });

  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();

    this._disconnectCleanups.push(effect(() => { void this._color.value; this.__rozieCtxProvider_theme.setValue(((__rozieCtxHost) => ({
      get color() {
        return __rozieCtxHost._color.value;
      },
      cycle: __rozieCtxHost.cycle
    }))(this)); }));
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<div class="theme-provider" data-theme-provider="" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-00821bac>
  <slot></slot>
</div>
`;
  }

  NEXT = {
  red: 'green',
  green: 'blue',
  blue: 'red'
};

  cycle = () => {
  this._color.value = this.NEXT[this._color.value];
};

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Source — ThemePassthrough.rozie

The unaware middle layer. Worth compiling in your head: it uses no context, so its emitted output contains none of the context machinery above.

rozie
<!--
  ThemePassthrough.rozie — the UNAWARE middle layer (Phase 36, $provide /
  $inject), productized from Spike 010's `Panel` fixture.

  This component renders its children and KNOWS NOTHING ABOUT THEME — no
  `$inject`, no `theme` prop, no forwarding. It exists purely to sit between
  the provider and the deep consumer so the VR cell proves inject reaches the
  consumer WITHOUT prop-drilling through here (R11 — cross-file token
  identity through an unaware module). If context required prop-drilling, this
  component would have to declare + thread a `theme` prop; it deliberately
  does not.
-->
<rozie name="ThemePassthrough">

<template>
<div class="theme-passthrough" data-theme-passthrough>
  <slot />
</div>
</template>

<style>
.theme-passthrough {
  display: block;
  padding: 0.5rem;
  border: 1px dashed rgba(0, 0, 0, 0.2);
  border-radius: 6px;
}
</style>

</rozie>

Source — ThemeButton.rozie

rozie
<!--
  ThemeButton.rozie — the deep CONSUMER half of the cross-component context
  primitive (Phase 36, $provide / $inject), productized from Spike 010's
  `ThemedButton` fixture (renamed — `examples/ThemedButton.rozie` is the
  Phase 14/15 attribute-fallthrough fixture and is NOT this).

  It injects the nearest provided `'theme'` and renders a button whose label
  is the live `theme.color`; clicking calls `theme.cycle()`, which mutates
  the provider's reactive `color` and round-trips back here (the displayed
  color advances red -> green -> blue). It declares NO theme prop — the value
  arrives through context, having crossed the unaware `ThemePassthrough`.

  LIT ASYNC EDGE (REQ-30): on Lit, `@lit/context`'s ContextConsumer resolves
  via an async `context-request` round-trip, so the injected value may be
  `undefined` on the very first paint until the provider responds. The
  template guards the read (`theme && theme.color`) so the first paint is a
  clean empty button rather than a crash; the VR spec asserts EVENTUAL fill
  (`toBeVisible({ timeout })`) for Lit rather than synchronous presence.
-->
<rozie name="ThemeButton">

<script>
// The nearest provided 'theme'. Reactive at depth: reading `theme.color`
// rides the provider's live getter, so a provider-side `cycle()` updates this
// button's label without any prop being passed down.
const theme = $inject('theme')
</script>

<template>
<button class="theme-button" data-theme-button type="button" @click="theme && theme.cycle()">
  {{ theme && theme.color }}
</button>
</template>

<style>
.theme-button {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 0.375rem 0.75rem;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.3);
  cursor: pointer;
}
</style>

</rozie>

ThemeButton — compiled output

vue
<template>

<button class="theme-button" data-theme-button="" type="button" v-bind="$attrs" @click="theme && theme.cycle()">
  {{ theme && theme.color }}
</button>

</template>

<script setup lang="ts">
import { inject } from 'vue';

const theme = inject('theme');
</script>

<style scoped>
.theme-button {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 0.375rem 0.75rem;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.3);
  cursor: pointer;
}
</style>
tsx
import { useContext } from 'react';
import { clsx, rozieContext, rozieDisplay } from '@rozie/runtime-react';
import './ThemeButton.css';

interface ThemeButtonProps {}

export default function ThemeButton(props: ThemeButtonProps): JSX.Element {
  const theme = useContext(rozieContext("theme"));
  const attrs = props as Record<string, unknown>;

  return (
    <>
    <button data-theme-button="" type="button" {...attrs} className={clsx("theme-button", (attrs.className as string | undefined))} onClick={($event) => { theme && theme.cycle(); }} data-rozie-s-9f40a7ea="">
      {rozieDisplay(theme && theme.color)}
    </button>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';

import { getContext } from 'svelte';

interface Props {
  [key: string]: unknown;
}

let { ...__rozieAttrs }: Props = $props();

const theme = getContext('theme');
</script>

<button data-theme-button="" type="button" {...__rozieAttrs} class={["theme-button", (__rozieAttrs)?.class]} onclick={($event) => { theme && theme.cycle(); }} use:applyListeners={__rozieAttrs} data-rozie-s-9f40a7ea>{rozieDisplay(theme && theme.color)}</button>

<style>
:global {
  .theme-button[data-rozie-s-9f40a7ea] {
    font-family: system-ui, -apple-system, sans-serif;
    padding: 0.375rem 0.75rem;
    border-radius: 6px;
    border: 1px solid rgba(0, 0, 0, 0.3);
    cursor: pointer;
  }
}
</style>
ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, inject, viewChild } from '@angular/core';
import { createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay, rozieToken } from '@rozie/runtime-angular';

@Component({
  selector: 'rozie-theme-button',
  standalone: true,
  template: `

    <button class="theme-button" data-theme-button="" type="button" #rozieSpread_0 (click)="theme && theme.cycle()" #rozieListenersTarget_1>
      {{ rozieDisplay(theme && theme.color) }}
    </button>

  `,
  styles: [`
    :host(rozie-theme-button) { display: contents; }
    .theme-button {
      font-family: system-ui, -apple-system, sans-serif;
      padding: 0.375rem 0.75rem;
      border-radius: 6px;
      border: 1px solid rgba(0, 0, 0, 0.3);
      cursor: pointer;
    }
  `],
})
export class ThemeButton {
  theme = inject(rozieToken('theme'));

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default ThemeButton;
tsx
import type { JSX } from 'solid-js';
import { splitProps, useContext } from 'solid-js';
import { __rozieInjectStyle, mergeListeners, rozieContext, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('ThemeButton-9f40a7ea', `.theme-button[data-rozie-s-9f40a7ea] {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 0.375rem 0.75rem;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.3);
  cursor: pointer;
}`);

interface ThemeButtonProps {}

export default function ThemeButton(_props: ThemeButtonProps): JSX.Element {
  const [local, attrs] = splitProps(_props, []);

  const theme = useContext(rozieContext("theme"));

  return (
    <>
    <button data-theme-button="" type="button" {...attrs} class={"theme-button" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onClick: ($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { theme && theme.cycle(); } }, attrs)} data-rozie-s-9f40a7ea="">
      {rozieDisplay(theme && theme.color)}
    </button>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { ContextConsumer, createContext } from '@lit/context';

const __rozieCtx_theme = createContext(Symbol.for("rozie:theme"));

@customElement('rozie-theme-button')
export default class ThemeButton extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.theme-button[data-rozie-s-9f40a7ea] {
  font-family: system-ui, -apple-system, sans-serif;
  padding: 0.375rem 0.75rem;
  border-radius: 6px;
  border: 1px solid rgba(0, 0, 0, 0.3);
  cursor: pointer;
}
`;

private __rozieCtxConsumer_theme = new ContextConsumer(this, { context: __rozieCtx_theme, subscribe: true });
private get theme() { return this.__rozieCtxConsumer_theme.value; }

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<button class="theme-button" data-theme-button="" type="button" ${rozieSpread(this.$attrs)} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.theme && this.theme.cycle(); }} ${rozieListeners(this.$listeners)} data-rozie-s-9f40a7ea>
  ${rozieDisplay(this.theme && this.theme.color)}
</button>
`;
  }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Pre-1.0 — APIs may change between minor versions.