Skip to content

TypeScript authoring ​

A <script> block can opt into TypeScript with a lang="ts" attribute. The compiler keeps every author-written annotation intact and routes it to the right place in each target: a typed $computed becomes a typed computed() / useMemo / $derived / signal, a typed prop flows into defineProps<T>() (Vue), a React interface, Svelte's $props<T>(), Angular's @Input() types. Rozie does not transpile the TypeScript itself — each target's own toolchain (vue-tsc, svelte-check, tsc) does, exactly as it would for a hand-written component.

TypedCard.rozie is the type-preservation fixture. It proves things no untyped component can:

  • it declares an interface (CardMeta) and a type alias (Tone) inside the <script lang="ts"> block
  • it uses a type-only import — import type { Options } from 'sortablejs'
  • those author types are consumed by typed $computed declarations and a typed function parameter

Statement-position type declarations are hoisted

A bare interface or type written at statement position has to reach module scope on the class-based targets — Angular and Lit wrap the component body in a class, and a type declaration cannot live inside a class body. The compiler hoists interface CardMeta and type Tone out to module scope for those two targets; on the function-bodied targets (React / Vue / Solid) they stay where they were authored. Compare the Angular and React outputs below to see the split.

Type-only imports survive — and stay erasable

import type { Options } from 'sortablejs' reaches every target's module scope as an import type, never a value import. It is referenced by the optionCount parameter annotation, so the type checker genuinely uses it — but because it is import type, it is fully erased from the runtime bundle. The live demo below needs no sortablejs dependency at all.

Live demo ​

The card below is the actual examples/typed/TypedCard.rozie file, compiled by @rozie/unplugin/vite into a Vue SFC. The heading text comes from the typed badge computed (meta.emphasis ? label.toUpperCase() : label), the meta line from optionLabel, and the accent border from the tone prop.

Source — TypedCard.rozie ​

rozie
<!--
  typed/TypedCard.rozie

  Genuinely-new typed example for Phase 9 (`<script lang="ts">`) — NOT a fork.

  Its job is to prove author-defined-type preservation, which no untyped
  example can be forked into:
    - declares an `interface` and a `type` alias inside <script lang="ts">
    - uses a type-only import (`import type { … } from '…'`)
    - the author types are consumed by typed `$computed` declarations and a
      typed function parameter

  Statement-position `interface` / `type` declarations must be hoisted to
  module scope for the class-based targets (Angular / Lit) — this fixture
  exercises that hoist path (Plan 09-04 OQ-3). Prop reads are routed through
  `$computed` (the idiomatic Rozie pattern) so the emitted output is
  reactivity-clean on every target, including Solid. It is small on purpose
  and compiles cleanly to all six targets.
-->

<rozie name="TypedCard">

<props>
{
  title: { type: String, default: '' },
  tone:  { type: String, default: 'neutral' },
}
</props>

<script lang="ts">
// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs'

// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent'

// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string
  emphasis: boolean
}

// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).
const optionCount = (opts: Options | null): number => {
  return opts === null ? 0 : Object.keys(opts).length
}

// Prop reads happen inside $computed (a tracked scope on every target) — the
// idiomatic Rozie pattern. The author annotations on these computed `const`s
// are exactly the Phase 9 surface.
const meta: CardMeta = $computed(() => ({
  label: $props.title,
  emphasis: ($props.tone as Tone) === 'accent',
}))

const badge: string = $computed(() => meta.emphasis ? meta.label.toUpperCase() : meta.label)
const optionLabel: string = `${optionCount(null)} options`
</script>

<template>
<article class="typed-card" :class="{ 'typed-card--accent': $props.tone === 'accent' }">
  <h3 class="typed-card__title">{{ badge }}</h3>
  <div class="typed-card__body">
    <span class="typed-card__meta">{{ optionLabel }}</span>
    <slot />
  </div>
</article>
</template>

<style>
.typed-card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.typed-card--accent { border-color: #4f46e5; }
.typed-card__title { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
.typed-card__body { padding: 1rem; }
.typed-card__meta { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }
</style>

</rozie>

Compiled output ​

vue
<template>

<article :class="['typed-card', { 'typed-card--accent': props.tone === 'accent' }]" v-bind="$attrs">
  <h3 class="typed-card__title">{{ badge }}</h3>
  <div class="typed-card__body">
    <span class="typed-card__meta">{{ optionLabel }}</span>
    <slot></slot>
  </div>
</article>

</template>

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

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

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

const meta = computed(() => ({
  label: props.title,
  emphasis: props.tone as Tone === 'accent'
}));
const badge = computed(() => meta.value.emphasis ? meta.value.label.toUpperCase() : meta.value.label);

// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';
// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';
// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}
// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).
const optionCount = (opts: Options | null): number => {
  return opts === null ? 0 : Object.keys(opts).length;
};

// Prop reads happen inside $computed (a tracked scope on every target) — the
// idiomatic Rozie pattern. The author annotations on these computed `const`s
// are exactly the Phase 9 surface.
const optionLabel: string = `${optionCount(null)} options`;
</script>

<style scoped>
.typed-card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.typed-card--accent { border-color: #4f46e5; }
.typed-card__title { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
.typed-card__body { padding: 1rem; }
.typed-card__meta { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }
</style>
tsx
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieDisplay } from '@rozie/runtime-react';
import './TypedCard.css';
// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';

// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';

// Author-declared interface — same hoisting requirement.

// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}

// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).

interface TypedCardProps extends Omit<import('react').ComponentPropsWithoutRef<'article'>, 'title' | 'tone' | 'children' | 'slots' | 'dangerouslySetInnerHTML'> {
  title?: string;
  tone?: string;
  children?: ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export default function TypedCard(_props: TypedCardProps): JSX.Element {
  const props: Omit<TypedCardProps, 'title' | 'tone'> & { title: string; tone: string } = {
    ..._props,
    title: _props.title ?? '',
    tone: _props.tone ?? 'neutral',
  };
  const attrs: Record<string, unknown> = (() => {
    const { title, tone, ...rest } = _props as TypedCardProps & Record<string, unknown>;
    void title; void tone;
    return rest;
  })();
  const meta = useMemo(() => ({
    label: props.title,
    emphasis: props.tone as Tone === 'accent'
  }), [props.title, props.tone]);
  const badge = useMemo(() => meta.emphasis ? meta.label.toUpperCase() : meta.label, [meta]);

  // A typed function parameter (`opts: Options | null`) consumes the type-only
  // import, so the `import type` is genuinely referenced (not elided as unused).
  function optionCount(opts: Options | null): number {
    return opts === null ? 0 : Object.keys(opts).length;
  }

  // Prop reads happen inside $computed (a tracked scope on every target) — the
  // idiomatic Rozie pattern. The author annotations on these computed `const`s
  // are exactly the Phase 9 surface.
  const optionLabel: string = `${optionCount(null)} options`;

  return (
    <>
    <article {...attrs} className={clsx(clsx("typed-card", { "typed-card--accent": props.tone === 'accent' }), (attrs.className as string | undefined))} data-rozie-s-fb7c7f4c="">
      <h3 className={"typed-card__title"} data-rozie-s-fb7c7f4c="">{rozieDisplay(badge)}</h3>
      <div className={"typed-card__body"} data-rozie-s-fb7c7f4c="">
        <span className={"typed-card__meta"} data-rozie-s-fb7c7f4c="">{rozieDisplay(optionLabel)}</span>
        {(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
      </div>
    </article>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';

interface Props extends Omit<import('svelte/elements').SvelteHTMLElements['article'], 'title' | 'tone' | 'children' | 'snippets'> {
  title?: string;
  tone?: string;
  children?: Snippet;
  snippets?: Record<string, any>;
}

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

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

// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';
// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';
// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}
// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).
const optionCount = (opts: Options | null): number => {
  return opts === null ? 0 : Object.keys(opts).length;
};

// Prop reads happen inside $computed (a tracked scope on every target) — the
// idiomatic Rozie pattern. The author annotations on these computed `const`s
// are exactly the Phase 9 surface.
const optionLabel: string = `${optionCount(null)} options`;

const meta = $derived({
  label: title,
  emphasis: tone as Tone === 'accent'
});
const badge = $derived(meta.emphasis ? meta.label.toUpperCase() : meta.label);
</script>

<article {...__rozieAttrs} class={["typed-card", { 'typed-card--accent': tone === 'accent' }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-fb7c7f4c><h3 class="typed-card__title" data-rozie-s-fb7c7f4c>{rozieDisplay(badge)}</h3><div class="typed-card__body" data-rozie-s-fb7c7f4c><span class="typed-card__meta" data-rozie-s-fb7c7f4c>{rozieDisplay(optionLabel)}</span>{@render children?.()}</div></article>

<style>
:global {
  .typed-card[data-rozie-s-fb7c7f4c] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
  .typed-card--accent[data-rozie-s-fb7c7f4c] { border-color: #4f46e5; }
  .typed-card__title[data-rozie-s-fb7c7f4c] { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
  .typed-card__body[data-rozie-s-fb7c7f4c] { padding: 1rem; }
  .typed-card__meta[data-rozie-s-fb7c7f4c] { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, inject, input, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';

// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';

// Author-declared interface — same hoisting requirement.

// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}

// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).

interface DefaultCtx {}

@Component({
  selector: 'rozie-typed-card',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass],
  template: `

    <article class="typed-card" [ngClass]="{ 'typed-card--accent': tone() === 'accent' }" #rozieSpread_0 #rozieListenersTarget_1>
      <h3 class="typed-card__title">{{ rozieDisplay(badge()) }}</h3>
      <div class="typed-card__body">
        <span class="typed-card__meta">{{ rozieDisplay(optionLabel) }}</span>
        <ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" />
      </div>
    </article>

  `,
  styles: [`
    :host(rozie-typed-card) { display: contents; }
    .typed-card { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
    .typed-card--accent { border-color: #4f46e5; }
    .typed-card__title { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
    .typed-card__body { padding: 1rem; }
    .typed-card__meta { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }
  `],
})
export class TypedCard {
  title = input<string>('');
  tone = input<string>('neutral');
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });

  meta = computed(() => ({
    label: this.title(),
    emphasis: this.tone() as Tone === 'accent'
  }));
  badge = computed(() => this.meta().emphasis ? this.meta().label.toUpperCase() : this.meta().label);

  // A typed function parameter (`opts: Options | null`) consumes the type-only
  // import, so the `import type` is genuinely referenced (not elided as unused).
  optionCount = (opts: Options | null): number => {
    return opts === null ? 0 : Object.keys(opts).length;
  };
  optionLabel: string = `${this.optionCount(null)} options`;

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

  private __rozieDestroyRef = inject(DestroyRef);

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

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

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

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

  private __rozieListenersRenderer = inject(Renderer2);

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

  private __rozieListenersDestroyRegistered_1 = false;

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

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

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

export default TypedCard;
tsx
import type { JSX } from 'solid-js';
import { children, createMemo, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';

// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';

// Author-declared interface — same hoisting requirement.

// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}

// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).

__rozieInjectStyle('TypedCard-fb7c7f4c', `.typed-card[data-rozie-s-fb7c7f4c] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.typed-card--accent[data-rozie-s-fb7c7f4c] { border-color: #4f46e5; }
.typed-card__title[data-rozie-s-fb7c7f4c] { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
.typed-card__body[data-rozie-s-fb7c7f4c] { padding: 1rem; }
.typed-card__meta[data-rozie-s-fb7c7f4c] { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }`);

interface TypedCardProps extends Omit<import('solid-js').ComponentProps<'article'>, 'title' | 'tone' | 'children' | 'slots' | 'innerHTML' | 'innerText' | 'textContent' | 'ref'> {
  title?: string;
  tone?: string;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
}

export default function TypedCard(_props: TypedCardProps): JSX.Element {
  const _merged = mergeProps({ title: '', tone: 'neutral' }, _props);
  const [local, attrs] = splitProps(_merged, ['title', 'tone', 'children']);
  const resolved = children(() => local.children);

  const meta = createMemo(() => ({
    label: local.title,
    emphasis: local.tone as Tone === 'accent'
  }));
  const badge = createMemo(() => meta().emphasis ? meta().label.toUpperCase() : meta().label);

  // A typed function parameter (`opts: Options | null`) consumes the type-only
  // import, so the `import type` is genuinely referenced (not elided as unused).
  function optionCount(opts: Options | null): number {
    return opts === null ? 0 : Object.keys(opts).length;
  }

  // Prop reads happen inside $computed (a tracked scope on every target) — the
  // idiomatic Rozie pattern. The author annotations on these computed `const`s
  // are exactly the Phase 9 surface.

  const optionLabel: string = `${optionCount(null)} options`;

  return (
    <>
    <article {...attrs} class={"typed-card" + " " + rozieClass({ 'typed-card--accent': local.tone === 'accent' }) + (((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-fb7c7f4c="">
      <h3 class={"typed-card__title"} data-rozie-s-fb7c7f4c="">{rozieDisplay(badge())}</h3>
      <div class={"typed-card__body"} data-rozie-s-fb7c7f4c="">
        <span class={"typed-card__meta"} data-rozie-s-fb7c7f4c="">{rozieDisplay(optionLabel)}</span>
        {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 { rozieDisplay, rozieListeners, rozieMemo, rozieSpread } from '@rozie/runtime-lit';
// Type-only import — resolves against the ambient engine-module stub in the
// per-target typecheck gates (sortablejs → minimal real types). Demonstrates
// that an `import type { … }` survives to every target's module scope.
import type { Options } from 'sortablejs';

// Author-declared type alias — must be preserved verbatim and hoisted to
// module scope on the class-based targets.
type Tone = 'neutral' | 'accent';

// Author-declared interface — same hoisting requirement.

// Author-declared interface — same hoisting requirement.
interface CardMeta {
  label: string;
  emphasis: boolean;
}

// A typed function parameter (`opts: Options | null`) consumes the type-only
// import, so the `import type` is genuinely referenced (not elided as unused).

@customElement('rozie-typed-card')
export default class TypedCard extends SignalWatcher(LitElement) {
  static styles = css`
:host{display:contents}
.typed-card[data-rozie-s-fb7c7f4c] { border: 1px solid #ddd; border-radius: 6px; overflow: hidden; background: #fff; }
.typed-card--accent[data-rozie-s-fb7c7f4c] { border-color: #4f46e5; }
.typed-card__title[data-rozie-s-fb7c7f4c] { margin: 0; padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid #eee; }
.typed-card__body[data-rozie-s-fb7c7f4c] { padding: 1rem; }
.typed-card__meta[data-rozie-s-fb7c7f4c] { display: block; font-size: 0.75rem; color: #666; margin-bottom: 0.5rem; }
`;

  @property({ type: String, reflect: true }) title: string = '';
  @property({ type: String, reflect: true }) tone: string = 'neutral';

  @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="${Object.entries({ "typed-card": true, 'typed-card--accent': this.tone === 'accent' }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-fb7c7f4c>
  <h3 class="typed-card__title" data-rozie-s-fb7c7f4c>${rozieDisplay(this.badge)}</h3>
  <div class="typed-card__body" data-rozie-s-fb7c7f4c>
    <span class="typed-card__meta" data-rozie-s-fb7c7f4c>${rozieDisplay(this.optionLabel)}</span>
    <slot></slot>
  </div>
</article>
`;
  }

  // A typed function parameter (`opts: Options | null`) consumes the type-only
  // import, so the `import type` is genuinely referenced (not elided as unused).
  optionCount = (opts: Options | null): number => {
  return opts === null ? 0 : Object.keys(opts).length;
};

  // Prop reads happen inside $computed (a tracked scope on every target) — the
  // idiomatic Rozie pattern. The author annotations on these computed `const`s
  // are exactly the Phase 9 surface.
  get meta() { return rozieMemo(this, 'meta', [this.title, this.tone], () => ({
  label: this.title,
  emphasis: this.tone as Tone === 'accent'
})); }

  get badge() { return rozieMemo(this, 'badge', [this.meta], () => (this.meta.emphasis ? this.meta.label.toUpperCase() : this.meta.label)); }

  optionLabel: string = `${this.optionCount(null)} options`;

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

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

Pre-1.0 — APIs may change between minor versions.