Skip to content

TodoList

Demonstrates r-for with required :key, two-way bound items array via model: true, an r-model two-way input binding on the draft field, multiple $emit calls for add/toggle/remove, named slot with fallback content (#header falls back to a default heading), default slot with per-item scoped params (the marquee scoped-slot pattern — consumer can override the row renderer), and r-if / r-else for the empty-state branch.

This is the heaviest scoped-slots example. The React output shows the documented divergence: instead of children-as-JSX, React consumers see a render-prop callback (children?: (ctx) => ReactNode, renderHeader?: (ctx) => ReactNode). The other five targets keep an idiomatic markup form.

Live demo

Two-way bound to the page's items ref. Add, toggle, remove — every mutation flows back through v-model:items to the parent state. Delete every item and the empty-state slot's fallback kicks in.

Items on the page-level ref: 3 (2 remaining)

Source — TodoList.rozie

rozie
<!--
  TodoList.rozie

  Demonstrates:
    - r-for with required :key
    - $data array mutation (reactive; signal-backed)
    - Multiple $emit calls for different custom events
    - Named slot with fallback content (slot body renders if consumer doesn't override)
    - Default slot with per-item slot params (the marquee scoped-slot pattern —
      consumer can provide a custom row renderer)
    - r-if / r-else with empty-state branch
    - Nested component composition (Counter used inline)
-->

<rozie name="TodoList">

<props>
{
  items: { type: Array, default: () => [], model: true },
  title: { type: String, default: 'Todo' },
}
</props>

<data>
{
  draft: '',
}
</data>

<script>
const remaining = $computed(() => $props.items.filter(i => !i.done).length)

const add = () => {
  const text = $data.draft.trim()
  if (!text) return
  $model.items = [...$props.items, { id: crypto.randomUUID(), text, done: false }]
  $data.draft = ''
  $emit('add', text)
}

const toggle = (id) => {
  $model.items = $props.items.map(i =>
    i.id === id ? { ...i, done: !i.done } : i
  )
  $emit('toggle', id)
}

// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id) => {
  $model.items = $props.items.filter(i => i.id !== id)
  $emit('remove', id)
}
</script>

<template>
<div class="todo-list">
  <header>
    <slot name="header" :remaining="remaining" :total="$props.items.length">
      <!-- Fallback content if consumer doesn't provide #header -->
      <h3>{{ $props.title }} ({{ remaining }} remaining)</h3>
    </slot>
  </header>

  <form @submit.prevent="add">
    <input r-model="$data.draft" placeholder="What needs doing?" />
    <button type="submit" :disabled="!$data.draft.trim()">Add</button>
  </form>

  <ul r-if="$props.items.length > 0">
    <li r-for="item in $props.items" :key="item.id" :class="{ done: item.done }">
      <!--
        Default slot with per-item params. Consumer can provide a custom row
        renderer via `#default="{ item, toggle, remove }"`, or omit it entirely
        to get the fallback row below.
      -->
      <slot :item="item" :toggle="() => toggle(item.id)" :remove="() => removeItem(item.id)">
        <label><input type="checkbox" :checked="item.done" @change="toggle(item.id)" /><span>{{ item.text }}</span></label>
        <button @click="removeItem(item.id)" aria-label="Remove">×</button>
      </slot>
    </li>
  </ul>

  <p r-else class="empty">
    <slot name="empty">Nothing to do. ✨</slot>
  </p>
</div>
</template>

<style>
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
</style>

</rozie>

Compiled output

vue
<template>

<div class="todo-list" v-bind="$attrs">
  <header>
    <slot name="header" :remaining="remaining" :total="items.length">
      
      <h3>{{ props.title }} ({{ remaining }} remaining)</h3>
    </slot>
  </header>

  <form @submit.prevent="add">
    <input v-model="draft" placeholder="What needs doing?" />
    <button type="submit" :disabled="!draft.trim()">Add</button>
  </form>

  <ul v-if="items.length > 0">
    <li v-for="item in items" :key="item.id" :class="{ done: item.done }">
      
      <slot :item="item" :toggle="() => toggle(item.id)" :remove="() => removeItem(item.id)">
        <label><input type="checkbox" :checked="item.done" @change="toggle(item.id)" /><span>{{ item.text }}</span></label>
        <button aria-label="Remove" @click="removeItem(item.id)">×</button>
      </slot>
    </li>
  </ul><p v-else class="empty">
    <slot name="empty">Nothing to do. ✨</slot>
  </p></div>

</template>

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

const props = withDefaults(
  defineProps<{ title?: string }>(),
  { title: 'Todo' }
);

const items = defineModel<any[]>('items', { default: () => [] });

const emit = defineEmits<{
  add: [...args: any[]];
  toggle: [...args: any[]];
  remove: [...args: any[]];
}>();

defineSlots<{
  header(props: { remaining: any; total: any }): any;
  default(props: { item: any; toggle: any; remove: any }): any;
  empty(props: {  }): any;
}>();

const draft = ref('');

const remaining = computed(() => items.value.filter((i: any) => !i.done).length);

const add = () => {
  const text = draft.value.trim();
  if (!text) return;
  items.value = [...items.value, {
    id: crypto.randomUUID(),
    text,
    done: false
  }];
  draft.value = '';
  emit('add', text);
};
const toggle = (id: any) => {
  items.value = items.value.map((i: any) => i.id === id ? {
    ...i,
    done: !i.done
  } : i);
  emit('toggle', id);
};

// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id: any) => {
  items.value = items.value.filter((i: any) => i.id !== id);
  emit('remove', id);
};
</script>

<style scoped>
.todo-list { font-family: system-ui, sans-serif; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done span { text-decoration: line-through; opacity: 0.5; }
.empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
</style>
tsx
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './TodoList.css';

interface HeaderCtx { remaining: any; total: any; }

interface ChildrenCtx { item: any; toggle: any; remove: any; }

interface TodoListProps {
  items?: any[];
  defaultItems?: any[];
  onItemsChange?: (items: any[]) => void;
  title?: string;
  onAdd?: (...args: any[]) => void;
  onToggle?: (...args: any[]) => void;
  onRemove?: (...args: any[]) => void;
  renderHeader?: (ctx: HeaderCtx) => ReactNode;
  children?: ReactNode | ((ctx: ChildrenCtx) => ReactNode);
  renderEmpty?: () => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function TodoList(_props: TodoListProps): JSX.Element {
  const props: Omit<TodoListProps, 'title'> & { title: string } = {
    ..._props,
    title: _props.title ?? 'Todo',
  };
  const attrs: Record<string, unknown> = (() => {
    const { items, title, defaultValue, onItemsChange, defaultItems, ...rest } = _props as TodoListProps & Record<string, unknown>;
    void items; void title; void defaultValue; void onItemsChange; void defaultItems;
    return rest;
  })();
  const [items, setItems] = useControllableState({
    value: props.items,
    defaultValue: props.defaultItems ?? (() => [])(),
    onValueChange: props.onItemsChange,
  });
  const [draft, setDraft] = useState('');
  const remaining = useMemo(() => items.filter((i: any) => !i.done).length, [items]);

  const { onAdd: _rozieProp_onAdd } = props;
    const add = useCallback(() => {
    const text = draft.trim();
    if (!text) return;
    setItems(prev => [...prev, {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    setDraft('');
    _rozieProp_onAdd && _rozieProp_onAdd(text);
  }, [_rozieProp_onAdd, draft, setItems]);
  const { onToggle: _rozieProp_onToggle } = props;
    const toggle = useCallback((id: any) => {
    setItems(prev => prev.map((i: any) => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    _rozieProp_onToggle && _rozieProp_onToggle(id);
  }, [_rozieProp_onToggle, setItems]);
  const { onRemove: _rozieProp_onRemove } = props;
    const removeItem = useCallback((id: any) => {
    setItems(prev => prev.filter((i: any) => i.id !== id));
    _rozieProp_onRemove && _rozieProp_onRemove(id);
  }, [_rozieProp_onRemove, setItems]);

  return (
    <>
    <div {...attrs} className={clsx("todo-list", (attrs.className as string | undefined))} data-rozie-s-52bec3de="">
      <header data-rozie-s-52bec3de="">
        {(props.renderHeader ?? props.slots?.['header']) ? ((props.renderHeader ?? props.slots?.['header']) as Function)({ remaining, total: items.length }) : <h3 data-rozie-s-52bec3de="">{props.title} ({rozieDisplay(remaining)} remaining)</h3>}
      </header>

      <form onSubmit={($event) => { $event.preventDefault(); ((add) as ((...args: any[]) => any))($event); }} data-rozie-s-52bec3de="">
        <input placeholder="What needs doing?" value={draft} onChange={e => setDraft(e.target.value)} data-rozie-s-52bec3de="" />
        <button type="submit" disabled={!draft.trim()} data-rozie-s-52bec3de="">Add</button>
      </form>

      {(items.length > 0) ? <ul data-rozie-s-52bec3de="">
        {items.map((item) => <li key={item.id} className={clsx({ done: item.done })} data-rozie-s-52bec3de="">
          
          {typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) }) : ((props.children ?? props.slots?.['']) ?? <><label data-rozie-s-52bec3de=""><input type="checkbox" checked={item.done} onChange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de="" /><span data-rozie-s-52bec3de="">{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onClick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de="">×</button></>)}
        </li>)}
      </ul> : <p className={"empty"} data-rozie-s-52bec3de="">
        {(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)() : "Nothing to do. ✨"}
      </p>}</div>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';

interface Props {
  items?: any[];
  title?: string;
  header?: Snippet<[{ remaining: any; total: any }]>;
  children?: Snippet<[{ item: any; toggle: any; remove: any }]>;
  empty?: Snippet;
  snippets?: Record<string, any>;
  onadd?: (...args: unknown[]) => void;
  ontoggle?: (...args: unknown[]) => void;
  onremove?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let {
  items = $bindable((() => [])()),
  title = 'Todo',
  header: __headerProp,
  children: __childrenProp,
  empty: __emptyProp,
  snippets,
  onadd,
  ontoggle,
  onremove,
  ...__rozieAttrs
}: Props = $props();

const header = $derived(__headerProp ?? snippets?.header);
const children = $derived(__childrenProp ?? snippets?.children);
const empty = $derived(__emptyProp ?? snippets?.empty);

let draft = $state('');

const add = () => {
  const text = draft.trim();
  if (!text) return;
  items = [...items, {
    id: crypto.randomUUID(),
    text,
    done: false
  }];
  draft = '';
  onadd?.(text);
};
const toggle = (id: any) => {
  items = items.map((i: any) => i.id === id ? {
    ...i,
    done: !i.done
  } : i);
  ontoggle?.(id);
};

// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
// Internal method renamed from `remove` to `removeItem` to avoid colliding
// with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
// methods as class fields and the resulting `remove(id)` signature is
// incompatible with the inherited `remove(): void`. Public API is unchanged:
// the slot param is still `:remove`, the emitted event is still `'remove'`.
const removeItem = (id: any) => {
  items = items.filter((i: any) => i.id !== id);
  onremove?.(id);
};

const remaining = $derived(items.filter((i: any) => !i.done).length);
</script>

<div {...__rozieAttrs} class={["todo-list", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-52bec3de><header data-rozie-s-52bec3de>{#if header}{@render header({ remaining, total: items.length })}{:else}<h3 data-rozie-s-52bec3de>{title} ({rozieDisplay(remaining)} remaining)</h3>{/if}</header><form onsubmit={($event) => { $event.preventDefault(); (add as (...a: any[]) => any)($event); }} data-rozie-s-52bec3de><input bind:value={draft} placeholder="What needs doing?" data-rozie-s-52bec3de /><button type="submit" disabled={!draft.trim()} data-rozie-s-52bec3de>Add</button></form>{#if items.length > 0}<ul data-rozie-s-52bec3de>{#each items as item (item.id)}<li class={{ done: item.done }} data-rozie-s-52bec3de>{#if children}{@render children({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) })}{:else}<label data-rozie-s-52bec3de><input type="checkbox" checked={item.done} onchange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de /><span data-rozie-s-52bec3de>{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onclick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de>×</button>{/if}</li>{/each}</ul>{:else}<p class="empty" data-rozie-s-52bec3de>{#if empty}{@render empty()}{:else}Nothing to do. ✨{/if}</p>{/if}</div>

<style>
:global {
  .todo-list[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
  ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
  li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
  li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
  .empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
  form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';

interface HeaderCtx {
  $implicit: { remaining: any; total: any };
  remaining: any;
  total: any;
}

interface DefaultCtx {
  $implicit: { item: any; toggle: any; remove: any };
  item: any;
  toggle: any;
  remove: any;
}

interface EmptyCtx {}

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-todo-list',
  standalone: true,
  imports: [NgTemplateOutlet, FormsModule],
  template: `

    <div class="todo-list" #rozieSpread_0 #rozieListenersTarget_1>
      <header>
        @if ((headerTpl ?? templates()?.['header'])) {
    <ng-container *ngTemplateOutlet="(headerTpl ?? templates()?.['header']); context: { $implicit: { remaining: remaining(), total: items().length }, remaining: remaining(), total: items().length }" />
    } @else {

          
          <h3>{{ title() }} ({{ rozieDisplay(remaining()) }} remaining)</h3>
        
    }
      </header>

      <form (submit)="$event.preventDefault(); _add()">
        <input [ngModel]="draft()" (ngModelChange)="draft.set($event)" [ngModelOptions]="{standalone: true}" placeholder="What needs doing?" />
        <button type="submit" [disabled]="!draft().trim()">Add</button>
      </form>

      @if (items().length > 0) {
    <ul>
        @for (item of items(); track item.id) {
    <li [class]="{ done: item.done }">
          
          @if ((defaultTpl ?? templates()?.['defaultSlot'])) {
    <ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot']); context: _defaultSlot_ctx_2(item)" />
    } @else {

            <label><input type="checkbox" [checked]="item.done" (change)="_toggle(item.id)" /><span>{{ rozieDisplay(item.text) }}</span></label>
            <button aria-label="Remove" (click)="removeItem(item.id)">×</button>
          
    }
        </li>
    }
      </ul>
    } @else {
    <p class="empty">
        @if ((emptyTpl ?? templates()?.['empty'])) {
    <ng-container *ngTemplateOutlet="(emptyTpl ?? templates()?.['empty'])" />
    } @else {
    Nothing to do. ✨
    }
      </p>
    }</div>

  `,
  styles: [`
    .todo-list { font-family: system-ui, sans-serif; }
    ul { list-style: none; padding: 0; }
    li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
    li.done span { text-decoration: line-through; opacity: 0.5; }
    .empty { color: rgba(0, 0, 0, 0.4); font-style: italic; }
    form { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => TodoList),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class TodoList {
  items = model<any[]>((() => [])());
  title = input<string>('Todo');
  draft = signal('');
  add = output<unknown>();
  toggle = output<unknown>();
  remove = output<unknown>();
  @ContentChild('header', { read: TemplateRef }) headerTpl?: TemplateRef<HeaderCtx>;
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  @ContentChild('empty', { read: TemplateRef }) emptyTpl?: TemplateRef<EmptyCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);

  remaining = computed(() => this.items().filter((i: any) => !i.done).length);

  _add = () => {
    const text = this.draft().trim();
    if (!text) return;
    this.items.set([...this.items(), {
      id: crypto.randomUUID(),
      text,
      done: false
    }]), this.__rozieCvaOnChange([...this.items(), {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    this.draft.set('');
    this.add.emit(text);
  };
  _toggle = (id: any) => {
    this.items.set(this.items().map((i: any) => i.id === id ? {
      ...i,
      done: !i.done
    } : i)), this.__rozieCvaOnChange(this.items().map((i: any) => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    this.toggle.emit(id);
  };
  removeItem = (id: any) => {
    this.items.set(this.items().filter((i: any) => i.id !== id)), this.__rozieCvaOnChange(this.items().filter((i: any) => i.id !== id));
    this.remove.emit(id);
  };

  private __rozieCvaOnChange: (v: any[]) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: any[] | null): void {
    this.items.set(v ?? (() => [])());
  }
  registerOnChange(fn: (v: any[]) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  static ngTemplateContextGuard(
    _dir: TodoList,
    _ctx: unknown,
  ): _ctx is HeaderCtx | DefaultCtx | EmptyCtx {
    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 = [];
      });
    }
  });

  private _defaultSlot_ctx_2 = (item: any) => ({ $implicit: { item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) }, item: item, toggle: () => this._toggle(item.id), remove: () => this.removeItem(item.id) });

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

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

export default TodoList;
tsx
import type { JSX } from 'solid-js';
import { For, Show, children, createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieClass, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('TodoList-52bec3de', `.todo-list[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
.empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }`);

interface HeaderSlotCtx { remaining: any; total: any; }

interface TodoListProps {
  items?: any[];
  defaultItems?: any[];
  onItemsChange?: (items: any[]) => void;
  title?: string;
  onAdd?: (...args: unknown[]) => void;
  onToggle?: (...args: unknown[]) => void;
  onRemove?: (...args: unknown[]) => void;
  headerSlot?: (ctx: HeaderSlotCtx) => JSX.Element;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  emptySlot?: JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function TodoList(_props: TodoListProps): JSX.Element {
  const _merged = mergeProps({ title: 'Todo' }, _props);
  const [local, attrs] = splitProps(_merged, ['items', 'title', 'children']);
  const resolved = children(() => local.children);

  const [items, setItems] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'items', (() => [])());
  const [draft, setDraft] = createSignal('');
  const remaining = createMemo(() => items().filter((i: any) => !i.done).length);

  function add() {
    const text = draft().trim();
    if (!text) return;
    setItems([...items(), {
      id: crypto.randomUUID(),
      text,
      done: false
    }]);
    setDraft('');
    _props.onAdd?.(text);
  }
  function toggle(id: any) {
    setItems(items().map((i: any) => i.id === id ? {
      ...i,
      done: !i.done
    } : i));
    _props.onToggle?.(id);
  }

  // Internal method renamed from `remove` to `removeItem` to avoid colliding
  // with `HTMLElement.prototype.remove()` on the Lit target — Lit emits user
  // methods as class fields and the resulting `remove(id)` signature is
  // incompatible with the inherited `remove(): void`. Public API is unchanged:
  // the slot param is still `:remove`, the emitted event is still `'remove'`.
  function removeItem(id: any) {
    setItems(items().filter((i: any) => i.id !== id));
    _props.onRemove?.(id);
  }

  return (
    <>
    <div {...attrs} class={"todo-list" + (((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-52bec3de="">
      <header data-rozie-s-52bec3de="">
        {(_props.headerSlot ?? _props.slots?.['header'])?.({ remaining: remaining(), total: items().length }) ?? <h3 data-rozie-s-52bec3de="">{local.title} ({rozieDisplay(remaining())} remaining)</h3>}
      </header>

      <form onSubmit={($event) => { $event.preventDefault(); add(); }} data-rozie-s-52bec3de="">
        <input placeholder="What needs doing?" value={draft()} onInput={e => setDraft(e.currentTarget.value)} data-rozie-s-52bec3de="" />
        <button type="submit" disabled={!draft().trim()} data-rozie-s-52bec3de="">Add</button>
      </form>

      {<Show when={items().length > 0} fallback={<p class={"empty"} data-rozie-s-52bec3de="">
        {(_props.emptySlot ?? _props.slots?.['empty']?.({})) ?? "Nothing to do. ✨"}
      </p>}><ul data-rozie-s-52bec3de="">
        <For each={items()}>{(item) => <li class={rozieClass({ done: item.done })} data-rozie-s-52bec3de="">
          
          {typeof local.children === 'function' ? (local.children as (s: any) => any)({ item, toggle: () => toggle(item.id), remove: () => removeItem(item.id) }) : (resolved() ?? <><label data-rozie-s-52bec3de=""><input type="checkbox" checked={item.done} onChange={($event) => { toggle(item.id); }} data-rozie-s-52bec3de="" /><span data-rozie-s-52bec3de="">{rozieDisplay(item.text)}</span></label><button aria-label="Remove" onClick={($event) => { removeItem(item.id); }} data-rozie-s-52bec3de="">×</button></>)}
        </li>}</For>
      </ul></Show>}</div>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';

interface RozieHeaderSlotCtx {
  remaining: unknown;
  total: unknown;
}

interface RozieDefaultSlotCtx {
  item: unknown;
  toggle: unknown;
  remove: unknown;
}

@customElement('rozie-todo-list')
export default class TodoList extends SignalWatcher(LitElement) {
  static styles = css`
.todo-list[data-rozie-s-52bec3de] { font-family: system-ui, sans-serif; }
ul[data-rozie-s-52bec3de] { list-style: none; padding: 0; }
li[data-rozie-s-52bec3de] { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; }
li.done[data-rozie-s-52bec3de] span[data-rozie-s-52bec3de] { text-decoration: line-through; opacity: 0.5; }
.empty[data-rozie-s-52bec3de] { color: rgba(0, 0, 0, 0.4); font-style: italic; }
form[data-rozie-s-52bec3de] { display: flex; gap: 0.25rem; margin-block: 0.5rem; }
`;

  @property({ type: Array, attribute: 'items' }) _items_attr: any[] = [];
  private _itemsControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'items-change', defaultValue: [], initialControlledValue: undefined });
  @property({ type: String, reflect: true }) title: string = 'Todo';
  private _draft = signal('');

  @state() private _hasSlotHeader = false;
  @queryAssignedElements({ slot: 'header', flatten: true }) private _slotHeaderElements!: Element[];
  @property({ attribute: false }) header?: (scope: { remaining: unknown; total: unknown }) => unknown;
  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
  @property({ attribute: false }) __rozieDefaultSlot__?: (scope: { item: unknown; toggle: unknown; remove: unknown }) => unknown;
  @state() private _hasSlotEmpty = false;
  @queryAssignedElements({ slot: 'empty', flatten: true }) private _slotEmptyElements!: 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[name="header"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotHeader = this._slotHeaderElements.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();
      }
    }

    {
      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();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="empty"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotEmpty = this._slotEmptyElements.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._hasSlotHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'header');
    this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    this._hasSlotEmpty = Array.from(this.children).some((el) => el.getAttribute('slot') === 'empty');
    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 = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'items') this._itemsControllable.notifyAttributeChange(value as unknown as any[]);
  }

  render() {
    return html`
<div class="todo-list" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-52bec3de>
  <header data-rozie-s-52bec3de>
    ${this.header !== undefined ? this.header({remaining: this.remaining, total: this.items.length}) : html`<slot name="header" data-rozie-params=${(() => { try { return JSON.stringify({remaining: this.remaining, total: this.items.length}); } catch { return '{}'; } })()}>
      
      <h3 data-rozie-s-52bec3de>${this.title} (${rozieDisplay(this.remaining)} remaining)</h3>
    </slot>`}
  </header>

  <form @submit=${($event: SubmitEvent) => { $event.preventDefault(); ((this.add) as (...args: any[]) => any)($event); }} data-rozie-s-52bec3de>
    <input placeholder="What needs doing?" .value=${this._draft.value} @input=${($event) => this._draft.value = ($event.target as HTMLInputElement).value} data-rozie-s-52bec3de />
    <button type="submit" ?disabled=${!this._draft.value.trim()} data-rozie-s-52bec3de>Add</button>
  </form>

  ${this.items.length > 0 ? html`<ul data-rozie-s-52bec3de>
    ${repeat<any>(this.items, (item, _idx) => item.id, (item, _idx) => html`<li class="${Object.entries({ done: item.done }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(item.id)} data-rozie-s-52bec3de>
      
      ${this.__rozieDefaultSlot__ !== undefined ? this.__rozieDefaultSlot__({item: item, toggle: () => this.toggle(item.id), remove: () => this.removeItem(item.id)}) : html`<slot data-rozie-params=${(() => { try { return JSON.stringify({item: item}); } catch { return '{}'; } })()} @rozie-default-toggle=${($event: CustomEvent) => ((() => this.toggle(item.id)) as (...args: any[]) => any)($event.detail)} @rozie-default-remove=${($event: CustomEvent) => ((() => this.removeItem(item.id)) as (...args: any[]) => any)($event.detail)}>
        <label data-rozie-s-52bec3de><input type="checkbox" ?checked=${item.done} @change=${($event: Event) => { this.toggle(item.id); }} data-rozie-s-52bec3de /><span data-rozie-s-52bec3de>${rozieDisplay(item.text)}</span></label>
        <button aria-label="Remove" @click=${($event: Event) => { this.removeItem(item.id); }} data-rozie-s-52bec3de>×</button>
      </slot>`}
    </li>`)}
  </ul>` : html`<p class="empty" data-rozie-s-52bec3de>
    <slot name="empty">Nothing to do. ✨</slot>
  </p>`}</div>
`;
  }

  get remaining() { return this.items.filter((i: any) => !i.done).length; }

  add = () => {
  const text = this._draft.value.trim();
  if (!text) return;
  this._itemsControllable.write([...this.items, {
    id: crypto.randomUUID(),
    text,
    done: false
  }]);
  this._draft.value = '';
  this.dispatchEvent(new CustomEvent("add", {
    detail: text,
    bubbles: true,
    composed: true
  }));
};

  toggle = (id: any) => {
  this._itemsControllable.write(this.items.map((i: any) => i.id === id ? {
    ...i,
    done: !i.done
  } : i));
  this.dispatchEvent(new CustomEvent("toggle", {
    detail: id,
    bubbles: true,
    composed: true
  }));
};

  removeItem = (id: any) => {
  this._itemsControllable.write(this.items.filter((i: any) => i.id !== id));
  this.dispatchEvent(new CustomEvent("remove", {
    detail: id,
    bubbles: true,
    composed: true
  }));
};

  get items(): any[] { return this._itemsControllable.read(); }
  set items(v: any[]) { this._itemsControllable.notifyPropertyWrite(v); }

  /**
   * 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>(['items', 'title']);
    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.