Skip to content
Merged
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
22 changes: 22 additions & 0 deletions apps/website/content/docs/render/api/define-angular-registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,28 @@ export class MyComponent {
}
```

### Per-Component Fallbacks

Each entry can be a bare component class or a `{ component, fallback }` object. The `fallback` mounts while any state-bound prop on the element is still unresolved -- useful for streaming UI, where structure arrives before data:

```typescript
import { defineAngularRegistry } from '@threadplane/render';
import { TextComponent } from './text.component';
import { CardComponent } from './card.component';
import { CardSkeletonComponent } from './card-skeleton.component';

const registry = defineAngularRegistry({
Text: TextComponent,
Card: { component: CardComponent, fallback: CardSkeletonComponent },
});

registry.getFallback('Card'); // CardSkeletonComponent (the configured fallback)
registry.getFallback('Text'); // DefaultFallbackComponent (entry omits one)
registry.getFallback('Missing'); // undefined (not registered)
```

An entry that omits `fallback` -- including every bare-component entry like `Text` above -- falls back to the library's `DefaultFallbackComponent`. Once the real component mounts, the choice is monotonic for that element instance: a later prop resolving to `undefined` never reverts it to the fallback.

## Internal Behavior

The function converts the input object to an internal `Map<string, AngularComponentRenderer>` for O(1) lookups:
Expand Down
22 changes: 22 additions & 0 deletions apps/website/content/docs/render/api/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ const all = views({
});
```

## Per-View Fallbacks

Each entry can be a bare component or a `{ component, fallback }` object. The fallback mounts while a state-bound prop on the view is still unresolved -- the same shape `defineAngularRegistry()` accepts:

```typescript
const ui = views({
'plan-checklist': {
component: PlanChecklistComponent,
fallback: PlanSkeletonComponent,
},
'file-preview': FilePreviewComponent, // bare form uses the default fallback
});
```

`withViews()` and `overrideViews()` accept the same object form in their addition and override maps.

## API

### `views(map)`
Expand Down Expand Up @@ -149,3 +165,9 @@ The chat component checks each message for a `ui` field containing a valid spec:
```

The `type` in the spec is matched against the view registry to resolve the Angular component.

## Related

- [defineAngularRegistry()](/docs/render/api/define-angular-registry) -- the low-level `AngularRegistry` that `toRenderRegistry()` converts a `ViewRegistry` into
- [provideRender()](/docs/render/api/provide-render) -- global render config, including a registry
- [Chat generative-UI guide](/docs/chat/guides/generative-ui) -- wiring `views()` into chat so an agent can stream specs that render inline
12 changes: 10 additions & 2 deletions apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ import {
defineAngularRegistry,
signalStateStore,
} from '@threadplane/render';

const registry = defineAngularRegistry({
OrderSummary: OrderSummaryComponent,
});
```

When you render a spec directly through `<render-spec>`, build the registry with `defineAngularRegistry()`. The chat-facing `views()` helper shown in [Registries And Catalogs](#registries-and-catalogs) below produces a `ViewRegistry` that chat converts to this same `AngularRegistry` under the hood. See the [Registry guide](/docs/render/guides/registry) for the full registry contract.

Choose json-render when:

- The UI can be represented as one spec.
Expand Down Expand Up @@ -106,9 +112,9 @@ The practical cost is protocol discipline. The agent must emit valid envelopes i
| First non-whitespace character is `{` | json-render |
| Starts with `---a2ui_JSON---` | A2UI JSONL |

For json-render, chat parses the JSON into a spec and renders it with the `views` registry.
For json-render, chat parses the JSON into a spec and renders it with the `views` registry. The [chat generative-UI guide](/docs/chat/guides/generative-ui) covers how to wire the agent and catalog for this path end to end.

For A2UI, chat parses newline-delimited A2UI messages, applies them to an `A2uiSurfaceStore`, and renders each surface with `<a2ui-surface>`.
For A2UI, chat parses newline-delimited A2UI messages, applies them to an `A2uiSurfaceStore`, and renders each surface with `<a2ui-surface>`. The [chat A2UI overview](/docs/chat/a2ui/overview) covers the `---a2ui_JSON---` detection path and the surface-store wiring.

```html
<chat [agent]="agent" [views]="catalog" [handlers]="handlers" />
Expand All @@ -134,6 +140,8 @@ const registry = views({
});
```

`views()` produces a `ViewRegistry` -- the shape chat catalogs consume. `defineAngularRegistry()` produces the `AngularRegistry` that `<render-spec>` consumes directly. `toRenderRegistry()` converts the former into the latter, so a chat catalog and a direct render share one component map. Use `views()` when you're wiring components into chat; use `defineAngularRegistry()` when you're driving `<render-spec>` yourself. See the [Registry guide](/docs/render/guides/registry) for the low-level registry, and the [chat views guide](/docs/chat/guides/generative-ui) for the chat-facing path.

A2UI catalogs use the same underlying registry shape in chat. Component names such as `Text`, `Button`, `Card`, `Column`, `Row`, `TextField`, `CheckBox`, `MultipleChoice`, and `Slider` resolve to Angular components.

You can compose registries with `withViews()` and pass the result to chat.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ const registry = defineAngularRegistry({
});
```

`defineAngularRegistry()` is the canonical constructor for driving `<render-spec>` directly. When you wire components into `@threadplane/chat` instead, you'll see `views()` -- a chat-facing helper that produces a `ViewRegistry` chat converts to this same registry internally. Reach for `defineAngularRegistry()` here; reach for `views()` when feeding a chat catalog.

### State Store

The **state store** holds the reactive state that drives your UI. Values are accessed via JSON Pointer paths (like `/user/name`). The library provides `signalStateStore()`, which uses Angular Signals internally so that state changes trigger change detection automatically.
Expand Down
45 changes: 45 additions & 0 deletions apps/website/content/docs/render/guides/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,57 @@ const handlers = {
};
```

## Observing Render Events

Beyond dispatching handlers, `<render-spec>` emits a single stream of every notable thing that happens during rendering through its `events` output. Bind to it to observe handler dispatch, state changes, and mount/destroy lifecycle in one place:

```html
<render-spec
[spec]="spec"
[registry]="registry"
[store]="store"
[handlers]="handlers"
(events)="onEvent($event)"
/>
```

```typescript
import type { RenderEvent } from '@threadplane/render';

onEvent(event: RenderEvent) {
switch (event.type) {
case 'handler':
console.log('handler ran:', event.action, event.params, event.result);
break;
case 'stateChange':
console.log('state changed:', event.path, '=', event.value);
break;
case 'lifecycle':
console.log('lifecycle:', event.event, event.scope, event.elementType);
break;
}
}
```

`RenderEvent` is a discriminated union of three variants, keyed by `type`:

| `type` | Interface | Fires when | Notable fields |
|--------|-----------|------------|----------------|
| `'handler'` | `RenderHandlerEvent` | A handler finishes running | `action`, `params`, `result?` |
| `'stateChange'` | `RenderStateChangeEvent` | The store value changes | `path`, `value`, `snapshot` |
| `'lifecycle'` | `RenderLifecycleEvent` | A spec or element mounts/destroys | `event` (`'mounted'` \| `'destroyed'`), `scope` (`'spec'` \| `'element'`), `elementKey?`, `elementType?` |

All three interfaces are exported from `@threadplane/render`. This output is the single source the [Lifecycle guide](/docs/render/guides/lifecycle) builds its `RENDER_LIFECYCLE` signals on top of -- both observe the same stream, so there's no double-counting.

## Next Steps

<CardGroup cols={2}>
<Card title="Registry Guide" href="/docs/render/guides/registry">
The emit input and component contract
</Card>
<Card title="Lifecycle Guide" href="/docs/render/guides/lifecycle">
Per-context lifecycle signals derived from the events stream
</Card>
<Card title="State Store Guide" href="/docs/render/guides/state-store">
Updating state from handlers
</Card>
Expand Down
34 changes: 34 additions & 0 deletions apps/website/content/docs/render/guides/specs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,37 @@ props: {
}
```

The `name` references a function you register in a `functions` map. Each function is a `ComputedFunction` from `@json-render/core` -- `(args: Record<string, unknown>) => unknown`. The `args` object is resolved first (so `{ $state: '/name' }` becomes the current value at `/name`), then passed to your function:

```typescript
import type { ComputedFunction } from '@json-render/core';

const functions: Record<string, ComputedFunction> = {
uppercase: (args) => String(args['text']).toUpperCase(),
};
```

Wire the map through the `[functions]` input on `<render-spec>`:

```html
<render-spec [spec]="spec" [registry]="registry" [functions]="functions" />
```

Or register it globally via `provideRender()` so every `<render-spec>` resolves it:

```typescript
import { provideRender } from '@threadplane/render';

provideRender({
registry: myRegistry,
functions: {
uppercase: (args) => String(args['text']).toUpperCase(),
},
});
```

With either wiring, the `$fn` expression above resolves `label` to the uppercased value of `/name`. The input takes priority over the `provideRender()` config when both are present.

## Children

The `children` property is an array of element keys that reference other entries in the `elements` map:
Expand Down Expand Up @@ -291,4 +322,7 @@ const spec: Spec = {
<Card title="RenderSpecComponent API" href="/docs/render/api/render-spec-component">
Full API reference for the entry-point component
</Card>
<Card title="Using this in chat" href="/docs/chat/guides/generative-ui">
How an agent streams a spec that chat renders inline
</Card>
</CardGroup>
52 changes: 52 additions & 0 deletions apps/website/content/docs/render/guides/state-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,58 @@ const spec: Spec = {
</Step>
</Steps>

## Testing Rendered Output

Because `signalStateStore()` is a plain factory and `RenderSpecComponent` is a standard standalone component, you can test the full render path in `TestBed` with no server and no LLM. Build a spec, mount `<render-spec>` with a store you control, mutate the store, and assert the projected component updated.

This spec renders a `Text` component bound to `/message`, then checks that writing the store re-renders it:

```typescript
import { Component, input } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RenderSpecComponent, defineAngularRegistry, signalStateStore } from '@threadplane/render';
import type { Spec } from '@json-render/core';

@Component({
selector: 'app-text',
standalone: true,
template: `<span data-testid="text">{{ label() }}</span>`,
})
class TextComponent {
readonly label = input<string>('');
}

describe('render output', () => {
it('re-renders when the store changes', () => {
const spec: Spec = {
root: 'msg',
elements: {
msg: { type: 'Text', props: { label: { $state: '/message' } } },
},
};
const store = signalStateStore({ message: 'hello' });
const registry = defineAngularRegistry({ Text: TextComponent });

const fixture = TestBed.createComponent(RenderSpecComponent);
fixture.componentRef.setInput('spec', spec);
fixture.componentRef.setInput('registry', registry);
fixture.componentRef.setInput('store', store);
fixture.detectChanges();

const span = (): HTMLElement =>
fixture.nativeElement.querySelector('[data-testid="text"]');
expect(span().textContent?.trim()).toBe('hello');

store.set('/message', 'updated');
fixture.detectChanges();
expect(span().textContent?.trim()).toBe('updated');
expect(store.get('/message')).toBe('updated');
});
});
```

The same pattern covers handlers (assert the store after the rendered component calls `emit`), visibility (toggle a `$state` flag and assert the element appears or disappears), and repeat loops (set an array and count the rendered rows).

## Next Steps

<CardGroup cols={2}>
Expand Down
Loading