Appearance
Card (with CardHeader)
A wrapper-pair. Card.rozie declares CardHeader in its <components> block and renders <CardHeader title=... :on-close=... /> at the top of its template; the rest of the body comes from a default slot.
Live demo
The X button only renders because we passed an onClose handler — CardHeader does r-if="$props.onClose" on its close button.
Each target picks its idiomatic import + child-tag form for the cross-component reference:
- Vue / Svelte — import with the target extension (
./CardHeader.vue,./CardHeader.svelte). - React / Solid — bare-path import (
./CardHeader). - Angular — named import + class added to
@Component({ imports: [...] }), and the<CardHeader>tag rewritten to selector form<rozie-card-header>.
Note the auto kebab/camel conversion: the source writes :on-close="$props.onClose", and each emitter reconciles that against the onClose prop declaration in the appropriate per-target idiom.
Card — source
rozie
<!--
Card.rozie
Demonstrates Phase 06.2 wrapper composition (D-119):
- <components> block declaring CardHeader as a child component.
- Template embeds <CardHeader title="..." :on-close="..." /> at the top
of the body, with a default <slot /> in the body content area.
- tagKind: 'component' on the <CardHeader> tag (P1 lowering); P2 emit
synthesizes a top-of-file import per target — Vue/Svelte get the
target-extension form, React gets bare './CardHeader', Angular gets
named import + class added to @Component({ imports: [...] }) AND the
tag is rewritten to selector form `<rozie-card-header>`.
Wrapper-pair partner of CardHeader.rozie.
-->
<rozie name="Card">
<components>
{
CardHeader: './CardHeader.rozie',
}
</components>
<props>
{
title: { type: String, default: '' },
onClose: { type: Function, default: null },
}
</props>
<template>
<article class="card">
<CardHeader :title="$props.title" :on-close="$props.onClose" />
<div class="card__body">
<slot />
</div>
</article>
</template>
<style>
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
</style>
</rozie>Card — compiled output
vue
<template>
<article class="card" v-bind="$attrs">
<CardHeader :title="props.title" :on-close="props.onClose"></CardHeader>
<div class="card__body">
<slot></slot>
</div>
</article>
</template>
<script setup lang="ts">
import CardHeader from './CardHeader.vue';
const props = withDefaults(
defineProps<{ title?: string; onClose?: ((...args: any[]) => any) | null }>(),
{ title: '', onClose: null }
);
defineSlots<{
default(props: { }): any;
}>();
</script>
<style scoped>
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
</style>tsx
import type { ReactNode } from 'react';
import { clsx } from '@rozie/runtime-react';
import './Card.css';
import CardHeader from './CardHeader';
interface CardProps {
title?: string;
onClose?: ((...args: any[]) => any) | null;
children?: ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export default function Card(_props: CardProps): JSX.Element {
const props: Omit<CardProps, 'title' | 'onClose'> & { title: string; onClose: ((...args: any[]) => any) | null } = {
..._props,
title: _props.title ?? '',
onClose: _props.onClose ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { title, onClose, ...rest } = _props as CardProps & Record<string, unknown>;
void title; void onClose;
return rest;
})();
return (
<>
<article {...attrs} className={clsx("card", (attrs.className as string | undefined))} data-rozie-s-a88c221e="">
<CardHeader title={props.title} onClose={props.onClose} data-rozie-s-a88c221e="" />
<div className={"card__body"} data-rozie-s-a88c221e="">
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</div>
</article>
</>
);
}svelte
<script lang="ts">
import CardHeader from './CardHeader.svelte';
import { applyListeners } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
interface Props {
title?: string;
onClose?: ((...args: any[]) => any) | null;
children?: Snippet;
snippets?: Record<string, any>;
[key: string]: unknown;
}
let {
title = '',
onClose = null,
children: __childrenProp,
snippets,
...__rozieAttrs
}: Props = $props();
const children = $derived(__childrenProp ?? snippets?.children);
</script>
<article {...__rozieAttrs} class={["card", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-a88c221e><CardHeader title={title} onClose={onClose} data-rozie-s-a88c221e></CardHeader><div class="card__body" data-rozie-s-a88c221e>{@render children?.()}</div></article>
<style>
:global {
.card[data-rozie-s-a88c221e] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body[data-rozie-s-a88c221e] { padding: 1rem; }
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, inject, input, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { CardHeader } from './CardHeader';
interface DefaultCtx {}
@Component({
selector: 'rozie-card',
standalone: true,
imports: [NgTemplateOutlet, CardHeader],
template: `
<article class="card" #rozieSpread_0 #rozieListenersTarget_1>
<rozie-card-header [title]="title()" [onClose]="onClose()"></rozie-card-header>
<div class="card__body">
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
</div>
</article>
`,
styles: [`
.card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body { padding: 1rem; }
`],
})
export class Card {
title = input<string>('');
onClose = input<((...args: unknown[]) => unknown) | null>(null);
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
static ngTemplateContextGuard(
_dir: Card,
_ctx: unknown,
): _ctx is DefaultCtx {
return true;
}
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
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 Card;tsx
import type { JSX } from 'solid-js';
import { children, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle } from '@rozie/runtime-solid';
import CardHeader from './CardHeader';
__rozieInjectStyle('Card-a88c221e', `.card[data-rozie-s-a88c221e] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body[data-rozie-s-a88c221e] { padding: 1rem; }`);
interface CardProps {
title?: string;
onClose?: ((...args: unknown[]) => unknown) | null;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
}
export default function Card(_props: CardProps): JSX.Element {
const _merged = mergeProps({ title: '', onClose: null }, _props);
const [local, attrs] = splitProps(_merged, ['title', 'onClose', 'children']);
const resolved = children(() => local.children);
return (
<>
<article {...attrs} class={"card" + (((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-a88c221e="">
<CardHeader title={local.title} onClose={local.onClose} data-rozie-s-a88c221e="" />
<div class={"card__body"} data-rozie-s-a88c221e="">
{resolved()}
</div>
</article>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import './CardHeader.rozie';
@customElement('rozie-card')
export default class Card extends SignalWatcher(LitElement) {
static styles = css`
.card[data-rozie-s-a88c221e] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.card__body[data-rozie-s-a88c221e] { padding: 1rem; }
`;
@property({ type: String, reflect: true }) title: string = '';
@property({ type: Function }) onClose: ((...args: unknown[]) => unknown) | null = null;
@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();
}
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`
<article class="card" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-a88c221e>
<rozie-card-header .title=${this.title} .onClose=${this.onClose} data-rozie-s-a88c221e></rozie-card-header>
<div class="card__body" data-rozie-s-a88c221e>
<slot></slot>
</div>
</article>
`;
}
/**
* 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['title', 'on-close', 'onclose']);
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;
}
}CardHeader — source
A tiny leaf component (~30 lines) — no <components> block, no slots, no lifecycle. Stands alone and is consumed by Card. Worth seeing because the contrast with Card highlights the cost-of-features model: leaves are cheap, only components that actually need composition/listeners/refs pay for them.
rozie
<!--
CardHeader.rozie
Small leaf component (~30 LOC) — the wrapper-target of Card.rozie.
Demonstrates that components consumed via <components> blocks don't need
to declare any composition themselves; this file has no <components> block
and no recursion.
Phase 06.2 D-119 wrapper-pair partner of Card.rozie.
-->
<rozie name="CardHeader">
<props>
{
title: { type: String, default: '' },
onClose: { type: Function, default: null },
}
</props>
<template>
<header class="card-header">
<h3 class="card-header__title">{{ $props.title }}</h3>
<button r-if="$props.onClose" class="card-header__close" @click="$props.onClose">×</button>
</header>
</template>
<style>
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
</style>
</rozie>CardHeader — compiled output
vue
<template>
<header class="card-header" v-bind="$attrs">
<h3 class="card-header__title">{{ props.title }}</h3>
<button v-if="props.onClose" class="card-header__close" @click="props.onClose">×</button></header>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{ title?: string; onClose?: ((...args: any[]) => any) | null }>(),
{ title: '', onClose: null }
);
</script>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
</style>tsx
import { clsx } from '@rozie/runtime-react';
import './CardHeader.css';
interface CardHeaderProps {
title?: string;
onClose?: ((...args: any[]) => any) | null;
}
export default function CardHeader(_props: CardHeaderProps): JSX.Element {
const props: Omit<CardHeaderProps, 'title' | 'onClose'> & { title: string; onClose: ((...args: any[]) => any) | null } = {
..._props,
title: _props.title ?? '',
onClose: _props.onClose ?? null,
};
const attrs: Record<string, unknown> = (() => {
const { title, onClose, ...rest } = _props as CardHeaderProps & Record<string, unknown>;
void title; void onClose;
return rest;
})();
return (
<>
<header {...attrs} className={clsx("card-header", (attrs.className as string | undefined))} data-rozie-s-f3e60f5a="">
<h3 className={"card-header__title"} data-rozie-s-f3e60f5a="">{props.title}</h3>
{(props.onClose) && <button className={"card-header__close"} onClick={($event) => { ((props.onClose) as ((...args: any[]) => any) | undefined)?.($event); }} data-rozie-s-f3e60f5a="">×</button>}</header>
</>
);
}svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
interface Props {
title?: string;
onClose?: ((...args: any[]) => any) | null;
[key: string]: unknown;
}
let {
title = '',
onClose = null,
...__rozieAttrs
}: Props = $props();
</script>
<header {...__rozieAttrs} class={["card-header", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-f3e60f5a><h3 class="card-header__title" data-rozie-s-f3e60f5a>{title}</h3>{#if onClose}<button class="card-header__close" onclick={($event) => { (onClose)($event); }} data-rozie-s-f3e60f5a>×</button>{/if}</header>
<style>
:global {
.card-header[data-rozie-s-f3e60f5a] { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title[data-rozie-s-f3e60f5a] { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close[data-rozie-s-f3e60f5a] { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, inject, input, viewChild } from '@angular/core';
@Component({
selector: 'rozie-card-header',
standalone: true,
template: `
<header class="card-header" #rozieSpread_0 #rozieListenersTarget_1>
<h3 class="card-header__title">{{ title() }}</h3>
@if (onClose()) {
<button class="card-header__close" (click)="(onClose())($event)">×</button>
}</header>
`,
styles: [`
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
`],
})
export class CardHeader {
title = input<string>('');
onClose = input<((...args: unknown[]) => unknown) | null>(null);
private __rozieDestroyRef = inject(DestroyRef);
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
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 CardHeader;tsx
import type { JSX } from 'solid-js';
import { Show, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle } from '@rozie/runtime-solid';
__rozieInjectStyle('CardHeader-f3e60f5a', `.card-header[data-rozie-s-f3e60f5a] { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title[data-rozie-s-f3e60f5a] { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close[data-rozie-s-f3e60f5a] { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }`);
interface CardHeaderProps {
title?: string;
onClose?: ((...args: unknown[]) => unknown) | null;
}
export default function CardHeader(_props: CardHeaderProps): JSX.Element {
const _merged = mergeProps({ title: '', onClose: null }, _props);
const [local, attrs] = splitProps(_merged, ['title', 'onClose']);
return (
<>
<header {...attrs} class={"card-header" + (((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-f3e60f5a="">
<h3 class={"card-header__title"} data-rozie-s-f3e60f5a="">{local.title}</h3>
{<Show when={local.onClose}><button class={"card-header__close"} onClick={($event) => { (local.onClose)?.($event); }} data-rozie-s-f3e60f5a="">×</button></Show>}</header>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieListeners, rozieSpread } from '@rozie/runtime-lit';
@customElement('rozie-card-header')
export default class CardHeader extends SignalWatcher(LitElement) {
static styles = css`
.card-header[data-rozie-s-f3e60f5a] { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
.card-header__title[data-rozie-s-f3e60f5a] { margin: 0; font-size: 1rem; font-weight: 600; }
.card-header__close[data-rozie-s-f3e60f5a] { background: none; border: 0; cursor: pointer; font-size: 1.25rem; padding: 0; line-height: 1; }
`;
@property({ type: String, reflect: true }) title: string = '';
@property({ type: Function }) onClose: ((...args: unknown[]) => unknown) | null = null;
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`
<header class="card-header" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-f3e60f5a>
<h3 class="card-header__title" data-rozie-s-f3e60f5a>${this.title}</h3>
${this.onClose ? html`<button class="card-header__close" @click=${this.onClose} data-rozie-s-f3e60f5a>×</button>` : nothing}</header>
`;
}
/**
* 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['title', 'on-close', 'onclose']);
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;
}
}