Appearance
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.
$injectreturns 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$injectconsumers 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') |
|---|---|---|
| Vue | provide('theme', v) | inject('theme') |
| Svelte 5 | setContext('theme', v) at init | getContext('theme') |
| React | returned JSX wrapped in <C.Provider value={v}>, C = rozieContext('theme') | useContext(rozieContext('theme')) |
| Solid | returned JSX wrapped in <C.Provider value={v}>, C = rozieContext('theme') | useContext(rozieContext('theme')) |
| Angular | providers: [{ provide: rozieToken('theme'), useFactory: () => v }] | inject(rozieToken('theme')) |
| Lit | new ContextProvider(this, { context: C, initialValue: v }) + setValue on change | new ContextConsumer(this, { context: C, subscribe: true }) |
Four compile-time diagnostics (ROZ129–ROZ132) 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;
}
}