Appearance
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 is not a typed fork of an untyped example — it proves things no untyped component can:
- it declares an
interface(CardMeta) and atypealias (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
$computeddeclarations 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.
// 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).
// 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.
// 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 {
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]);
function optionCount(opts: Options | null): number {
return opts === null ? 0 : Object.keys(opts).length;
}
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 {
title?: string;
tone?: string;
children?: Snippet;
snippets?: Record<string, any>;
[key: string]: unknown;
}
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.
// 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).
// 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, effect, inject, input, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
// 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.
// 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 {}
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-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 ?? templates()?.['defaultSlot'])" />
</div>
</article>
`,
styles: [`
.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);
meta = computed(() => ({
label: this.title(),
emphasis: this.tone() as Tone === 'accent'
}));
badge = computed(() => this.meta().emphasis ? this.meta().label.toUpperCase() : this.meta().label);
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 = (() => {
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 = [];
});
}
});
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.
// 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 {
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, 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.
// 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`
.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>
`;
}
optionCount = (opts: Options | null): number => {
return opts === null ? 0 : Object.keys(opts).length;
};
get meta() { return {
label: this.title,
emphasis: this.tone as Tone === 'accent'
}; }
get badge() { return 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).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['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;
}
}