diff --git a/apps/website/content/docs/render/api/define-angular-registry.mdx b/apps/website/content/docs/render/api/define-angular-registry.mdx index 4b6c90576..da9ebb98d 100644 --- a/apps/website/content/docs/render/api/define-angular-registry.mdx +++ b/apps/website/content/docs/render/api/define-angular-registry.mdx @@ -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` for O(1) lookups: diff --git a/apps/website/content/docs/render/api/views.mdx b/apps/website/content/docs/render/api/views.mdx index 658c7fe0e..7415203fc 100644 --- a/apps/website/content/docs/render/api/views.mdx +++ b/apps/website/content/docs/render/api/views.mdx @@ -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)` @@ -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 diff --git a/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx b/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx index 2d533df7a..d2e1e366b 100644 --- a/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx +++ b/apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx @@ -41,8 +41,14 @@ import { defineAngularRegistry, signalStateStore, } from '@threadplane/render'; + +const registry = defineAngularRegistry({ + OrderSummary: OrderSummaryComponent, +}); ``` +When you render a spec directly through ``, 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. @@ -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 ``. +For A2UI, chat parses newline-delimited A2UI messages, applies them to an `A2uiSurfaceStore`, and renders each surface with ``. The [chat A2UI overview](/docs/chat/a2ui/overview) covers the `---a2ui_JSON---` detection path and the surface-store wiring. ```html @@ -134,6 +140,8 @@ const registry = views({ }); ``` +`views()` produces a `ViewRegistry` -- the shape chat catalogs consume. `defineAngularRegistry()` produces the `AngularRegistry` that `` 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 `` 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. diff --git a/apps/website/content/docs/render/getting-started/introduction.mdx b/apps/website/content/docs/render/getting-started/introduction.mdx index 8febb9f4c..8ea9c013b 100644 --- a/apps/website/content/docs/render/getting-started/introduction.mdx +++ b/apps/website/content/docs/render/getting-started/introduction.mdx @@ -60,6 +60,8 @@ const registry = defineAngularRegistry({ }); ``` +`defineAngularRegistry()` is the canonical constructor for driving `` 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. diff --git a/apps/website/content/docs/render/guides/events.mdx b/apps/website/content/docs/render/guides/events.mdx index 21aad87bc..f9f8f5f95 100644 --- a/apps/website/content/docs/render/guides/events.mdx +++ b/apps/website/content/docs/render/guides/events.mdx @@ -241,12 +241,57 @@ const handlers = { }; ``` +## Observing Render Events + +Beyond dispatching handlers, `` 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 + +``` + +```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 The emit input and component contract + + Per-context lifecycle signals derived from the events stream + Updating state from handlers diff --git a/apps/website/content/docs/render/guides/specs.mdx b/apps/website/content/docs/render/guides/specs.mdx index f0e38a072..8b692b206 100644 --- a/apps/website/content/docs/render/guides/specs.mdx +++ b/apps/website/content/docs/render/guides/specs.mdx @@ -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) => 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 = { + uppercase: (args) => String(args['text']).toUpperCase(), +}; +``` + +Wire the map through the `[functions]` input on ``: + +```html + +``` + +Or register it globally via `provideRender()` so every `` 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: @@ -291,4 +322,7 @@ const spec: Spec = { Full API reference for the entry-point component + + How an agent streams a spec that chat renders inline + diff --git a/apps/website/content/docs/render/guides/state-store.mdx b/apps/website/content/docs/render/guides/state-store.mdx index 5d1b37f29..b032c790a 100644 --- a/apps/website/content/docs/render/guides/state-store.mdx +++ b/apps/website/content/docs/render/guides/state-store.mdx @@ -180,6 +180,58 @@ const spec: Spec = { +## 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 `` 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: `{{ label() }}`, +}) +class TextComponent { + readonly label = input(''); +} + +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