Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions apps/web/app/(main)/docs/catalog/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,42 @@ Each component in the catalog has:
```typescript
{
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
slots?: string[], // Named slots for children (e.g., ["default"])
slots?: string[], // Named slots for children (e.g., ["default", "header", "footer"])
description?: string, // Help AI understand when to use it
}
```

Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered.
### Named Slots

Components can define multiple named slots:

```typescript
const catalog = defineCatalog(schema, {
components: {
// Simple component with just default slot
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
description: "Container card",
},

// Component with multiple named slots
Layout: {
props: z.object({}),
slots: ["default", "header", "footer"],
description: "Full-page layout with header, main content, and footer",
},
},
});
```

The `slots` array lists all available slots:
- `"default"` - The main content slot, mapped from the `children` field in specs
- Other names (e.g., `"header"`, `"footer"`) - Named slots, mapped from the `slots` field in specs

When implementing the component in your registry, you'll receive:
- `children` prop - Content for the default slot
- `slots` prop - Object with content for named slots (e.g., `{ header: ReactNode, footer: ReactNode }`)

## Generating AI Prompts

Expand Down
22 changes: 21 additions & 1 deletion apps/web/app/(main)/docs/registry/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Each component receives a `ComponentContext` object:
interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (always defined)
slots?: Record<string, React.ReactNode>; // Named slots (header, footer, etc.)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the renderer is in a loading state
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
Expand Down Expand Up @@ -125,6 +125,26 @@ TextInput: ({ props, bindings }) => {

`useBoundProp` returns `[resolvedValue, setter]`. The setter writes to the bound state path. If no binding exists (the prop is a literal), the setter is a no-op.

### Named Slots

Components can define multiple named slots:

```tsx
export const { registry } = defineRegistry(myCatalog, {
components: {
Layout: ({ children, slots }) => (
<div className="layout">
<header>{slots?.header}</header>
<main>{children}</main>
<footer>{slots?.footer}</footer>
</div>
),
},
});
```

The `children` prop receives content from the `children` field in the spec, `slots` receives named slots content. See the [catalog documentation](/docs/catalog#named-slots) for more details.

### Action Handlers

Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
Expand Down
9 changes: 7 additions & 2 deletions apps/web/app/(main)/docs/specs/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,18 @@ Each element in the map has a consistent shape:
{
"type": "ComponentName",
"props": { "label": "Hello" },
"children": ["child-1", "child-2"]
"children": ["child-1", "child-2"],
"slots": {
"slot-1": ["child-3"],
"slot-2": ["child-4", "child-5"]
}
}
```

- `type` — Component type from your catalog
- `props` — Component properties
- `children` — Array of child element keys
- `children` — Array of child element keys (maps to default slot)
- `slots` — Object mapping slot names to arrays of child element keys

### Dynamic Data

Expand Down
58 changes: 50 additions & 8 deletions apps/web/components/playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,22 +283,64 @@ export function Playground() {

const propsStr = serializeProps(propsObj);
const hasChildren = element.children && element.children.length > 0;
const hasSlots = element.slots && Object.keys(element.slots).length > 0;

if (!hasChildren) {
if (!hasChildren && !hasSlots) {
return propsStr
? `${spaces}<${componentName} ${propsStr} />`
: `${spaces}<${componentName} />`;
}

const lines: string[] = [];
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);

for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
// If we have slots, we need to format them as props on the opening tag
if (hasSlots) {
lines.push(`${spaces}<${componentName}`);

// Add regular props if any
if (propsStr) {
lines.push(`${spaces} ${propsStr}`);
}

// Add slot props
for (const [slotName, slotKeys] of Object.entries(element.slots!)) {
if (slotKeys.length === 0) continue;

const slotChildren: string[] = [];
for (const childKey of slotKeys) {
slotChildren.push(generateJSX(childKey, 0).trim());
}

if (slotChildren.length === 1) {
// Single child - inline it
lines.push(`${spaces} ${slotName}={${slotChildren[0]}}`);
} else {
// Multiple children - use fragment
lines.push(`${spaces} ${slotName}={`);
lines.push(`${spaces} <>`);
for (const child of slotChildren) {
lines.push(`${spaces} ${child}`);
}
lines.push(`${spaces} </>`);
lines.push(`${spaces} }`);
}
}

lines.push(`${spaces}>`);
} else {
// No slots - regular opening tag
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
}

// Render default children
if (hasChildren) {
for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
}
}

lines.push(`${spaces}</${componentName}>`);
Expand Down
8 changes: 8 additions & 0 deletions packages/codegen/src/traverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export function traverseSpec(
visit(childKey, depth + 1, element);
}
}

if (element.slots) {
for (const slotChildren of Object.values(element.slots)) {
for (const childKey of slotChildren) {
visit(childKey, depth + 1, element);
}
}
}
}

visit(rootKey, 0, null);
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,14 +754,14 @@ Note: state patches appear right after the elements that use them, so the UI fil

for (const [name, def] of Object.entries(components)) {
const propsStr = def.props ? formatZodType(def.props) : "{}";
const hasChildren = def.slots && def.slots.length > 0;
const childrenStr = hasChildren ? " [accepts children]" : "";
const hasSlots = def.slots && def.slots.length > 0;
const slotsStr = hasSlots ? ` [slots: ${def.slots!.join(", ")}]` : "";
const eventsStr =
def.events && def.events.length > 0
? ` [events: ${def.events.join(", ")}]`
: "";
const descStr = def.description ? ` - ${def.description}` : "";
lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
lines.push(`- ${name}: ${propsStr}${descStr}${slotsStr}${eventsStr}`);
}
lines.push("");
}
Expand Down Expand Up @@ -959,6 +959,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.",
"ONLY use components listed above",
"Each element value needs: type, props, children (array of child keys)",
"Use children for the default slot. Use slots object for named slots (e.g., slots: { header: [...], footer: [...] }). Never use slots.default - that's what children is for",
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
]
: [
Expand All @@ -968,6 +969,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $state, $bindState, $bindItem, $item, $index, or repeat.",
"ONLY use components listed above",
"Each element value needs: type, props, children (array of child keys)",
"Use children for the default slot. Use slots object for named slots (e.g., slots: { header: [...], footer: [...] }). Never use slots.default - that's what children is for",
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
];
const schemaRules = catalog.schema.defaultRules ?? [];
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ export interface UIElement<
type: T;
/** Component props */
props: P;
/** Child element keys (flat structure) */
/** Child element keys (flat structure) - maps to the 'default' slot */
children?: string[];
/** Named slots - maps slot names to arrays of child element keys */
slots?: Record<string, string[]>;
/** Visibility condition */
visible?: VisibilityCondition;
/** Event bindings — maps event names to action bindings */
Expand Down
3 changes: 2 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"zod": "^4.0.0"
},
"peerDependencies": {
"react": "^19.2.3"
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/catalog-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface EventHandle {
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: ReactNode;
slots?: Record<string, ReactNode>;
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
Expand Down
Loading