Skip to content

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;
  }
}

Pre-v1.0 — internal monorepo.