diff --git a/apps/website/content/docs/render/api/provide-render.mdx b/apps/website/content/docs/render/api/provide-render.mdx index a01bbf766..5d52ad08e 100644 --- a/apps/website/content/docs/render/api/provide-render.mdx +++ b/apps/website/content/docs/render/api/provide-render.mdx @@ -1,220 +1,181 @@ +--- +description: Register the registry, store, computed functions, and handlers that every render surface in an application uses by default. +--- + # provideRender() -Registers global default configuration for `@threadplane/render` via Angular's dependency injection system. +`provideRender()` registers the defaults that every `` in an application falls back to: a component registry, a state store, the named functions a spec may call through `$computed`, and the handlers its actions dispatch to. It returns `EnvironmentProviders`, so it belongs in `ApplicationConfig.providers` or in a `bootstrapApplication()` call. The running example is the computed-functions demo, whose four functions are registered exactly this way, and this page walks the files that make it work. -## Import +## What the demo does -```typescript -import { provideRender, RENDER_CONFIG } from '@threadplane/render'; -``` +The Run tab shows a split view. On the left is a live render surface; on the right is the spec JSON that feeds it, arriving character by character. Nothing streams until you press play in the transport bar at the bottom, which also lets you scrub the timeline and change the speed. -## Signature +Press play and the first spec streams in: `hello world` comes out uppercased and `streaming` comes out reversed. Switch to Data Display and an ISO timestamp comes out as a local date while `7 x 6` comes out as `42`. None of those results are in the spec. The spec asks for a function by name, and the four functions registered in `provideRender()` produce the text. The Spec tabs in the header hold three specs -- Text Transforms, Data Display, and Mixed Functions -- and each tab restarts the stream with a different mix of the same four functions. -```typescript -function provideRender(config: RenderConfig): EnvironmentProviders; -``` +## How it is built -### Parameters +Four pieces carry the feature: an application config that registers the functions, a registry and store held by the demo component, the `` element that mounts the surface, and a view component that receives the finished value. Open the Code tab to read them in place. -| Parameter | Type | Description | -|-----------|------|-------------| -| `config` | `RenderConfig` | Configuration object with default registry, store, functions, and handlers | +### Registering the computed functions -### Returns +The whole application config is one call. `provideRender()` receives a `functions` map, and every key in it becomes a name a spec may call. -`EnvironmentProviders` -- suitable for use in `ApplicationConfig.providers` or `bootstrapApplication()`. + -## RenderConfig +Each function takes a single `args` object and returns a value, which is the `ComputedFunction` contract from `@json-render/core`. The argument names are yours: `formatDate` and `uppercase` and `reverse` read `args['value']`, and `multiply` reads `args['a']` and `args['b']`, so a spec that calls `multiply` has to supply those two keys. + + +Prop resolution runs whenever the spec or the bound state changes, which during a stream is many times a second. Keep these functions pure and cheap, and memoize anything expensive outside the function body. + + +A spec calls one of them with a `$computed` expression in place of a literal prop value. The demo's first spec asks for two of the four: ```typescript -interface RenderConfig { - registry?: AngularRegistry; - store?: StateStore; - functions?: Record; - handlers?: Record) => unknown | Promise>; -} +const upper = { + type: 'Value', + props: { + label: 'Uppercase', + value: { $computed: 'uppercase', args: { value: 'hello world' } }, + }, +}; + +const reversed = { + type: 'Value', + props: { + label: 'Reversed', + value: { $computed: 'reverse', args: { value: 'streaming' } }, + }, +}; ``` -| Property | Type | Description | -|----------|------|-------------| -| `registry` | `AngularRegistry` | Default component registry for all `` instances | -| `store` | `StateStore` | Default state store for all `` instances | -| `functions` | `Record` | Default computed functions for `$computed` prop expressions | -| `handlers` | `Record` | Default event handlers for action dispatch | +The `args` values are themselves prop expressions, so an argument may be a literal, as it is here, or a `$state` reference that reads from the store. -All properties are optional. Only provide the defaults you need. +### The registry and store the surface renders with -## RENDER_CONFIG Token +This example puts its functions in the global config and keeps the other two pieces local. The component builds a registry of three inline view components with `defineAngularRegistry()` and an empty store with `signalStateStore()`. -The configuration is stored in the `RENDER_CONFIG` injection token: + -```typescript -import { InjectionToken } from '@angular/core'; +Both are equally valid in `provideRender()`. Keeping them on the component is what you do when one surface needs its own component set, which is the case here because the three view components exist only for this demo. -const RENDER_CONFIG = new InjectionToken('RENDER_CONFIG'); -``` +### Mounting the surface -You can inject it directly if needed: +`` takes the spec and, because this example did not put them in the global config, the registry and store as inputs. The `loading` input tells the registered components that the spec is still arriving, which is how the skeleton rows appear ahead of the real values. -```typescript -import { inject } from '@angular/core'; -import { RENDER_CONFIG } from '@threadplane/render'; + -const config = inject(RENDER_CONFIG, { optional: true }); -// null if provideRender() was not called -``` +No `functions` input appears here, so the component falls back to the map registered in `provideRender()`. -## Usage +### What a view component receives -### Basic Setup +A view component never sees a `$computed` expression. It declares plain inputs and receives resolved values. -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideRender, defineAngularRegistry } from '@threadplane/render'; -import { TextComponent } from './components/text.component'; -import { CardComponent } from './components/card.component'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideRender({ - registry: defineAngularRegistry({ - Text: TextComponent, - Card: CardComponent, - }), - }), - ], -}; -``` + -### With Store and Handlers +`label` and `value` are the props the spec sets; `childKeys`, `spec`, `bindings`, `emit`, and `loading` are supplied by the render engine to every registered component. `value` is typed as `unknown` and coerced for display because mid-stream a `$computed` expression can still be half-parsed, and the demo prefers a skeleton row over rendering a partial object. -```typescript -import { - provideRender, - defineAngularRegistry, - signalStateStore, -} from '@threadplane/render'; - -const globalStore = signalStateStore({ theme: 'light' }); - -export const appConfig: ApplicationConfig = { - providers: [ - provideRender({ - registry: defineAngularRegistry({ - Text: TextComponent, - Card: CardComponent, - }), - store: globalStore, - functions: { - uppercase: (args: Record) => - String(args['text']).toUpperCase(), - }, - handlers: { - toggleTheme: () => { - const current = globalStore.get('/theme'); - globalStore.set('/theme', current === 'light' ? 'dark' : 'light'); - }, - }, - }), - ], -}; -``` +### The graph that ships with the example -### Registry Only +The example also carries a LangGraph graph. It plays no part in the render pipeline: nothing in the Angular application connects to it, and the spec that streams on the left comes from a local simulator, not from an agent. The graph is a single node that answers questions about computed functions, and it is included here because the Code tab shows it. -If you only need a global registry and want to provide stores per-instance: + + +## Import ```typescript -provideRender({ - registry: defineAngularRegistry({ - Text: TextComponent, - Card: CardComponent, - Button: ButtonComponent, - }), -}) +import { provideRender, RENDER_CONFIG } from '@threadplane/render'; ``` -## Resolution Priority - -`RenderSpecComponent` resolves each configuration value using this priority: - -| Priority | Source | Description | -|----------|--------|-------------| -| 1 (highest) | Component input | `[registry]`, `[store]`, `[functions]`, `[handlers]` on `` | -| 2 | `RENDER_CONFIG` | Global defaults from `provideRender()` | -| 3 (lowest) | Internal fallback | Empty registry, internal `signalStateStore()` from `spec.state` | - -This means inputs always win over global config: +## Signature ```typescript -// Global config -provideRender({ registry: registryA, store: storeA }); - -// In template -- registryB overrides registryA, but storeA is still used - +function provideRender(config: RenderConfig): EnvironmentProviders; ``` -## Global vs Component-Level Config +The returned providers carry the `RENDER_CONFIG` value and the internal lifecycle service that coordinates mount and unmount events across dynamically rendered components. Call `provideRender()` once per application. - - - -Use `provideRender()` when you want shared defaults across your entire application: +## RenderConfig ```typescript -// All instances use this registry by default -provideRender({ registry: myRegistry }) +interface RenderConfig { + telemetry?: boolean; + registry?: AngularRegistry; + store?: StateStore; + functions?: Record; + handlers?: Record) => unknown | Promise>; +} ``` -This is ideal when you have a single component library that all specs should use. +| Property | Type | Description | +|----------|------|-------------| +| `telemetry` | `boolean` | Set false to disable automatic development browser collection for this render tree | +| `registry` | `AngularRegistry` | Default component registry for all `` instances | +| `store` | `StateStore` | Default state store for all `` instances | +| `functions` | `Record` | Named functions a spec may call through `$computed` | +| `handlers` | `Record) => unknown \| Promise>` | Named action handlers invoked when interactive elements fire | + +Every property is optional. Provide only the defaults you need, as the example does with `functions` alone. - - +## The RENDER_CONFIG token -Pass inputs directly to `` when different parts of your app need different configurations: +The configuration object is stored under the `RENDER_CONFIG` injection token, which is exported alongside `provideRender()`. Inject it when you need to read the defaults yourself: ```typescript -// Dashboard uses one registry - +import { inject } from '@angular/core'; +import { RENDER_CONFIG } from '@threadplane/render'; -// Form builder uses a different registry - +const config = inject(RENDER_CONFIG, { optional: true }); +// null when provideRender() was not called ``` -This is useful when you have multiple rendering contexts with different component sets. +## Resolution priority + +`RenderSpecComponent` resolves each value independently, and an input always wins over the global default. - - +| Value | Priority | +|-------|----------| +| `registry` | `[registry]` input, then `RENDER_CONFIG`, then the `VIEW_REGISTRY` token from `provideViews()`, then an empty registry | +| `store` | `[store]` input, then `RENDER_CONFIG`, then an internal `signalStateStore()` seeded from `spec.state` | +| `functions` | `[functions]` input, then `RENDER_CONFIG` | +| `handlers` | `[handlers]` input, then `RENDER_CONFIG` | -Use both for a layered approach -- global defaults with per-instance overrides: +Because the fallbacks are per value, a surface may take one piece from the global config and override another. The example does exactly that in reverse: it registers `functions` globally and passes `registry` and `store` as inputs. ```typescript -// Global: shared registry and handlers +// Global: one registry and one handler map for the whole application provideRender({ registry: baseRegistry, - handlers: { log: (p) => console.log(p) }, + handlers: { log: (params: Record) => console.log(params) }, }); +``` -// Component: override store, keep global registry and handlers +```html + ``` - - +## Calling provideRender is optional -## Optional Provider - -`provideRender()` is not required. If you skip it, `RenderSpecComponent` requires you to pass `registry` and `store` as inputs (or relies on the internal store fallback). +`provideRender()` is not required. Skip it and `` still renders, as long as the registry reaches it another way -- an input, or the `VIEW_REGISTRY` token -- and the internal store fallback covers state. ```html - + ``` -## Related - -- [RenderSpecComponent API](/docs/render/api/render-spec-component) -- how the component uses `RENDER_CONFIG` -- [defineAngularRegistry()](/docs/render/api/define-angular-registry) -- creating registries to pass to config -- [signalStateStore()](/docs/render/api/signal-state-store) -- creating stores to pass to config -- [Installation](/docs/render/getting-started/installation) -- initial setup with `provideRender()` +## What's Next + + + + The inputs and outputs of the element that mounts a spec. + + + Build the registry you pass to the config or to an input. + + + Create the signal-backed store that `$bindState` paths read and write. + + + The spec shape that `$computed` expressions live in. + + diff --git a/apps/website/content/docs/render/api/render-spec-component.mdx b/apps/website/content/docs/render/api/render-spec-component.mdx index 4d32968bc..c49395b94 100644 --- a/apps/website/content/docs/render/api/render-spec-component.mdx +++ b/apps/website/content/docs/render/api/render-spec-component.mdx @@ -1,81 +1,116 @@ +--- +description: How the element rendering example drives render-spec with a streaming spec, a registry and a store, plus the component inputs, outputs and resolution order. +--- + # RenderSpecComponent -The top-level entry point for rendering a `@json-render/core` spec as an Angular component tree. +`RenderSpecComponent` is the entry point for rendering a `@json-render/core` spec as an Angular component tree. It takes the spec plus the pieces needed to interpret it — a registry of element types, a state store, computed functions and action handlers — and mounts the root element. The running example streams a spec in one character at a time and renders it live, so every input on this page has a visible effect you can watch. -## Import +## What the demo does -```typescript -import { RenderSpecComponent } from '@threadplane/render'; -``` +The Run tab is split in two. On the left is the live render output; on the right is the raw JSON as it arrives, plus a checkbox. Press play on the transport bar at the bottom and the spec streams in character by character, materializing into rendered elements as soon as each one is complete. -## Selector +Three specs sit behind the tabs at the top. "Parent + Children" is a heading with two text children. "Deep Nesting" is a card wrapping a card wrapping a text element. "Visibility" adds an element whose `visible` condition reads `/showDetail` from the store, which is what the checkbox toggles: uncheck it and that element disappears without the spec changing at all. -``` -render-spec -``` +## How it is built -## Usage +The agent is not in the render path. The spec arrives from a local streaming simulator rather than a model, which keeps the moving parts down to the spec, the registry and the store. There is a `graph.py` in the capability, but this Angular application never calls it — its `app.config.ts` registers nothing but the render feature. -```html - -``` +### The application configuration + +`provideRender()` registers the shared `RENDER_CONFIG` token and the internal lifecycle service. This example passes an empty options object, because it supplies the registry and store per instance instead. + + + +### Registering element types and state + +`defineAngularRegistry()` maps the `type` string of an element to the Angular component that renders it. The three types in this demo are `Text`, `Heading` and `Card`. Alongside it, `signalStateStore()` creates the store that visibility conditions read from, seeded with `showDetail: true`. + + + +The constructor subscribes to the store and mirrors `/showDetail` into an Angular signal, which is the value the checkbox binds against. + +### Mounting the spec + +`` takes the partially materialized spec, the registry, the store, and a `loading` flag wired to whether the simulator is still playing. While the spec is `null` the template shows a placeholder instead. + + + +`loading` is passed down to every rendered component as an input of the same name, which is how the view components below know to draw skeletons instead of empty text. + +### Rendering children recursively + +This is the part the API surface does not make obvious: `` renders exactly one element, the one named by `spec.root`. Children are rendered because a view component asks for them. Every mounted component receives a `childKeys` input holding `element.children`, and a component that wants to render them loops over the keys and mounts a `` for each. + + + +`DemoTextComponent` in the same file declares `childKeys` but never loops over it, which is why text elements are leaves in this demo even when a spec gives them children. + + +`RenderElementComponent` passes five framework inputs — `bindings`, `emit`, `loading`, `childKeys` and `spec` — alongside the element's resolved props, then drops any key the target component does not declare, so a simple view component is not warned about inputs it ignores. The view components in this example declare all five. + + +### Driving visibility from the store + +The checkbox writes to the store, not to the spec. `RenderElementComponent` derives each element's visibility from a `computed` that reads the store, so a write to `/showDetail` re-evaluates the `{ $state: '/showDetail' }` condition on the conditional element and mounts or unmounts it. + + + +## Import ```typescript -@Component({ - imports: [RenderSpecComponent], - template: ``, -}) -export class MyComponent { - spec: Spec = { /* ... */ }; - registry = defineAngularRegistry({ /* ... */ }); -} +import { RenderSpecComponent } from '@threadplane/render'; ``` +The selector is `render-spec`, and the component is standalone, so add it to a component's `imports` array. + ## Inputs | Input | Type | Default | Description | |-------|------|---------|-------------| -| `spec` | `Spec \| null` | `null` | The json-render spec to render. When `null`, nothing is rendered. | -| `registry` | `AngularRegistry \| undefined` | `undefined` | Component registry mapping element types to Angular components. | -| `store` | `StateStore \| undefined` | `undefined` | State store for reactive prop resolution. | -| `functions` | `Record \| undefined` | `undefined` | Computed functions for `$computed` prop expressions. | -| `handlers` | `Record) => unknown \| Promise> \| undefined` | `undefined` | Event handlers invoked when components call `emit()`. | -| `loading` | `boolean` | `false` | Whether the spec is currently streaming. Passed to all rendered components as the `loading` input. | - -## Resolution Chain - -For `registry`, `store`, `functions`, and `handlers`, the component resolves values using this priority: - - - -Values passed as component inputs. - - -Global defaults provided via `provideRender()`. - - -For `store`: an internal `signalStateStore()` is created from `spec.state` (or an empty object). For `registry`: an empty registry is used (no components resolve). - - - -This means you can set defaults globally and override them per-instance: +| `spec` | `Spec \| null` | `null` | The json-render spec to render. When `null`, or when it has no `root`, nothing is rendered. | +| `registry` | `AngularRegistry \| undefined` | `undefined` | Component registry mapping element `type` names to Angular components. | +| `store` | `StateStore \| undefined` | `undefined` | State store that prop expressions and visibility conditions read from. | +| `functions` | `Record \| undefined` | `undefined` | Computed functions available to `$computed` prop expressions. | +| `handlers` | `Record) => unknown \| Promise> \| undefined` | `undefined` | Action handlers invoked when a rendered component calls `emit()`. | +| `loading` | `boolean` | `false` | Whether the spec is still streaming. Passed to every rendered component as its `loading` input. | +| `telemetry` | `boolean \| undefined` | `undefined` | Set `false` to disable automatic development collection for this render tree. | + +## Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `events` | `RenderEvent` | Every render event from this tree: `lifecycle` events for the spec and for elements that opt in, `stateChange` on each store write, `handler` after an action handler runs, and `result` when a component reports a value. | + +Handlers are wrapped whether they arrive as an input or from `provideRender()`, so a `handler` event is emitted after each one runs, including after a returned promise settles. + +## Resolution order + +`registry`, `store`, `functions` and `handlers` are each resolved independently, in this order: + +1. The component input, when it is set. +2. `RENDER_CONFIG`, the configuration registered by `provideRender()`. +3. For `registry` only, the `VIEW_REGISTRY` token registered by `provideViews()`, converted with `toRenderRegistry()`. +4. A last-resort fallback. For `store`, an internal `signalStateStore()` built from `spec.state`. For `registry`, an empty registry, in which case no element type resolves and nothing renders. + +Because each is resolved on its own, a per-instance override of one leaves the rest on their global defaults: ```typescript -// Global config provideRender({ registry: defaultRegistry, store: globalStore, - handlers: { log: (p) => console.log(p) }, + handlers: { log: (params) => console.log(params) }, }); +``` -// Per-instance override -- only registry is overridden +```html + -// store, functions, and handlers fall back to global config ``` -## RENDER_CONTEXT +## The render context -`RenderSpecComponent` provides a `RENDER_CONTEXT` injection token to its children via `viewProviders`. This context is consumed by `RenderElementComponent` instances and contains: +`RenderSpecComponent` provides a `RENDER_CONTEXT` token to its children through `viewProviders`. Every `RenderElementComponent` under it injects that context to find the registry, the store, and the rest: ```typescript interface RenderContext { @@ -88,106 +123,64 @@ interface RenderContext { } ``` -The context is a `computed` signal that updates when any input or config changes. Child components can inject it directly: +The context is a `computed` signal, so it is rebuilt when any input changes. A component mounted inside the tree can inject it directly: ```typescript import { inject } from '@angular/core'; import { RENDER_CONTEXT } from '@threadplane/render'; -const ctx = inject(RENDER_CONTEXT); -ctx.store.get('/some/path'); +const context = inject(RENDER_CONTEXT); +context.store.get('/showDetail'); ``` -## Template Behavior +## Template behavior -The component renders a single `` for the root element key from `spec.root`: +The component template is a single `` for the key named by `spec.root`: ```html - @if (spec()?.root; as rootKey) { } ``` -When `spec` is `null` or has no `root`, nothing is rendered. +Everything below the root is mounted by view components that render their `childKeys`, as the example section above shows. -## Internal Store Behavior +## The internal store -When no store is provided (neither as input nor via `RENDER_CONFIG`), the component lazily creates an internal `signalStateStore()` from `spec.state`. This internal store is created once and reused across spec changes -- it is not recreated when the spec input updates. +When no store is supplied — neither as an input nor through `RENDER_CONFIG` — the component lazily creates one with `signalStateStore()` from `spec.state`. It is created once and reused across spec changes, so it is not rebuilt when a later spec arrives with different `state`: ```typescript -// Spec with embedded state -- no external store needed const spec: Spec = { root: 'root', elements: { - root: { type: 'Text', props: { label: { $state: '/message' } } }, + root: { type: 'Text', props: { content: { $state: '/message' } } }, }, state: { message: 'Hello' }, }; ``` ```html - + ``` -## Change Detection - -The component uses `ChangeDetectionStrategy.OnPush`. All reactive updates flow through Angular Signals, ensuring efficient change detection without zone-based triggers. - -## Complete Example - -```typescript -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; -import { - RenderSpecComponent, - defineAngularRegistry, - signalStateStore, -} from '@threadplane/render'; -import type { Spec } from '@json-render/core'; -import { TextComponent } from './text.component'; -import { ButtonComponent } from './button.component'; - -@Component({ - selector: 'app-root', - standalone: true, - imports: [RenderSpecComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - - `, -}) -export class AppComponent { - isLoading = signal(false); - - registry = defineAngularRegistry({ - Text: TextComponent, - Button: ButtonComponent, - }); - - store = signalStateStore({ count: 0 }); - - handlers = { - increment: () => { - const count = this.store.get('/count') as number; - this.store.set('/count', count + 1); - }, - }; - - spec: Spec = { - root: 'app', - elements: { - app: { - type: 'Text', - props: { label: { $state: '/count' } }, - }, - }, - }; -} -``` +## Change detection + +The component uses `ChangeDetectionStrategy.OnPush`, and so does `RenderElementComponent`. Every reactive path runs through Angular signals: prop expressions, visibility conditions and the resolved context are all `computed` values. + +## What's Next + + + + Map element type names to components, with fallbacks and schema gating. + + + Read and write the store that prop expressions and visibility conditions use. + + + The spec format: root, elements, props, children and visibility. + + + Register a registry, store, functions and handlers as application defaults. + + diff --git a/apps/website/content/docs/render/guides/registry.mdx b/apps/website/content/docs/render/guides/registry.mdx index e0fd68eaf..616eedd41 100644 --- a/apps/website/content/docs/render/guides/registry.mdx +++ b/apps/website/content/docs/render/guides/registry.mdx @@ -1,59 +1,114 @@ +--- +description: How the registry example maps four type names to Angular components, what a registered component receives as inputs, and how an element resolves at render time. +--- + # Component Registry -The component registry maps element type names from your spec to Angular component classes. It's the bridge between the declarative JSON spec and your Angular component tree. +A registry maps the `type` string on a spec element to an Angular component class. It is the bridge between a declarative JSON spec and the component tree your application owns. The running example registers four small components, streams three specs through them, and shows exactly what each component receives when it mounts. + +## What the demo does + +The Run tab shows a split surface: the rendered output on the left, the JSON that produced it on the right, and a transport along the bottom. Press play and the spec streams in character by character. The parser materializes it as it arrives, so headings, cards, text, and badges appear as their props complete, with skeleton placeholders standing in until they do. + +The tabs at the top switch between three specs — Basic Types, Card Layout, and Mixed Components — and picking one restarts the stream against that spec. All three draw on the same four registered names, so what changes between them is the shape of the element tree, not the components. The transport scrubs to any point in the stream and plays at 1x, 2x, or 4x. + +## How it is built + +The example is one Angular file and an application config. Open the Code tab to read them in place. + +### The application config registers nothing + +Most applications register the registry once, at the root. This one calls `provideRender()` with an empty config. + + + +The registry travels as an input on the render surface instead, which is the form that takes precedence anyway. + +### What a registered component receives + +Every registered component is a plain standalone Angular component. `content` is the element's own prop from the spec; the five framework inputs at the bottom of the class are what the renderer supplies for every element it mounts. -## Creating a Registry + + +`loading` is true while the spec is still streaming, which is what selects the skeleton branch in the template. + +### Rendering children + +An element's `children` arrive as `childKeys`, and the whole `spec` arrives with them. That pair is everything `` needs to mount a child by key, which is how a tree of any depth renders. + + + +A component renders children only if its template asks for them: the badge in the same file declares `childKeys` and never reads it. + +### The registry + +`defineAngularRegistry()` takes a plain object whose keys are type names and whose values are component classes. + + + +The four keys are exactly the `type` strings the three specs use, and `signalStateStore({})` is the state a spec would bind against with `$bindState`. + + +The keys are the identifiers a spec author, or a model producing specs, has to write. Keep them descriptive and stable — `stat-card` rather than `c2` — because renaming one silently stops resolving every element that still uses the old name. + -Let's create a registry with `defineAngularRegistry()` from a plain object that maps type names to component classes: +### Mounting the spec -```typescript +`` receives the materialized spec, the registry, the store, and a `loading` flag driven by the simulator. + + + +`loading` reaches every mounted component as its own `loading` input, which is how the skeletons know the stream is still running. + +### The agent is not in the render path + +The capability also ships a backend, and it is worth being clear about what it does not do. This single-node LangGraph agent answers questions about registries; it never produces the specs on screen, which the example streams locally. + + + +## What an entry holds + +This is an illustrative registry, not the one the demo builds; it exists to show the shape an entry can take. A registered value can be a bare component class or a full entry object. `defineAngularRegistry()` normalizes both into the same shape: a component, a guaranteed fallback, and the optional `schema` and `description` an entry may declare. + +```ts +// component imports omitted import { defineAngularRegistry } from '@threadplane/render'; -import { TextComponent } from './text.component'; -import { CardComponent } from './card.component'; -import { ButtonComponent } from './button.component'; -import { ContainerComponent } from './container.component'; -export const uiRegistry = defineAngularRegistry({ +const registry = defineAngularRegistry({ Text: TextComponent, - Card: CardComponent, - Button: ButtonComponent, - Container: ContainerComponent, + Card: { + component: CardComponent, + fallback: CardSkeletonComponent, + description: 'A titled container that renders its children.', + }, }); -``` - -The returned `AngularRegistry` object has two methods: -- `getEntry(name: string)` -- returns the fully-normalized entry (`{ component, fallback, schema?, description? }`) for a registered name, or `undefined` if not registered. The resolved `fallback` is the entry's own renderer, or the library's default when the entry omits one. -- `names()` -- returns an array of all registered type names - -```typescript -uiRegistry.getEntry('Text')?.component; // TextComponent -uiRegistry.getEntry('Unknown'); // undefined -uiRegistry.getEntry('Text')?.fallback; // fallback renderer (or default) -uiRegistry.names(); // ['Text', 'Card', 'Button', 'Container'] +registry.getEntry('Text')?.component; // TextComponent +registry.getEntry('Text')?.fallback; // DefaultFallbackComponent +registry.getEntry('Card')?.fallback; // CardSkeletonComponent +registry.getEntry('Unknown'); // undefined +registry.names(); // ['Text', 'Card'] ``` -### Fallback Rendering +`getEntry(name)` returns the normalized entry or `undefined`, and `names()` lists every registered name. -Fallbacks belong to registered entries. When an element's type is not registered, there is no entry to read and the element renders nothing. When the type is registered, the entry's configured fallback can fill a transient gap while state-bound props are still resolving. Once the real component mounts, it stays mounted -- later re-renders never revert to the fallback. +### Fallbacks and unregistered types -## The Component Input Contract +An element whose type is not registered has no entry at all, so nothing mounts and the element renders nothing. When the type is registered, the entry's fallback fills the gap while a prop is still resolving to `undefined`, or while a declared `schema` does not yet validate the resolved props. Once the real component mounts it stays mounted: the switch is one-way per element instance, so a prop that later becomes undefined never reverts the element to its fallback. -Every component rendered by `@threadplane/render` receives inputs conforming to the `AngularComponentInputs` interface. Your custom props from the spec are spread as additional inputs alongside the standard ones. +## The component input contract -### Standard Inputs +Alongside the element's own props, the renderer passes a fixed set of framework inputs. | Input | Type | Description | |-------|------|-------------| -| `emit` | `(event: string) => void` | Function to dispatch named events | -| `bindings` | `Record` | Two-way binding paths: prop name to absolute state path | +| `emit` | `(event: string) => void` | Fires the element's `on[event]` handler bindings | +| `bindings` | `Record` | Prop name to the absolute state path it is bound to | | `loading` | `boolean` | Whether the spec is currently streaming | | `childKeys` | `string[]` | Element keys for recursive child rendering | -| `spec` | `Spec` | The full spec object (for child resolution) | - -### Custom Props +| `spec` | `Spec` | The full spec, for resolving those child keys | -Any props defined in the element's `props` are resolved and passed as additional inputs. For example, given this element: +Custom props are resolved from the element and spread alongside them. Given this element: ```json { @@ -65,173 +120,68 @@ Any props defined in the element's `props` are resolved and passed as additional } ``` -Your component receives `label` and `size` as inputs alongside the standard inputs. - -## Writing a Renderable Component +the component receives `label` and `size` as inputs. -Here's a complete component designed to work with the rendering system: +Every input is then filtered down to the names the target component actually declares, so a component that only wants `label` may declare only `label` and ignore the rest. -```typescript -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; -import type { Spec } from '@json-render/core'; - -@Component({ - selector: 'app-card', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-

{{ title() }}

-

{{ description() }}

- @if (loading()) { -
Loading...
- } -
- `, -}) -export class CardComponent { - // Custom props from the spec - readonly title = input(''); - readonly description = input(''); - - // Standard inputs from AngularComponentInputs - readonly emit = input<(event: string) => void>(() => {}); - readonly bindings = input>({}); - readonly loading = input(false); - readonly childKeys = input([]); - readonly spec = input(null); -} -``` - - -Always provide default values for your inputs. The rendering system spreads resolved props onto the component, but not all standard inputs are guaranteed to have values in every context. + +Props arrive while the spec is still streaming, so a prop may be missing on the first mount and appear later. A default value keeps that first render valid. -## Two-Way Bindings +## Two-way bindings -When a prop uses `$bindState`, the `bindings` input receives a mapping from the prop name to the state path. This enables two-way binding patterns: +When a prop uses `$bindState`, the prop resolves to the current value at that path and the `bindings` input receives the mapping from prop name to path. Given this element: -```typescript -// In your spec +```json { - type: 'Input', - props: { - value: { $bindState: '/form/email' }, - label: 'Email', - }, -} -``` - -Your component receives: - -- `value` resolved to the current state value (e.g., `"test@example.com"`) -- `bindings` set to `{ value: '/form/email' }` - -You can use the bindings map to write back to the store: - -```typescript -import { Component, ChangeDetectionStrategy, input, inject } from '@angular/core'; -import { RENDER_CONTEXT } from '@threadplane/render'; - -@Component({ - selector: 'app-input', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - - - `, -}) -export class InputComponent { - readonly value = input(''); - readonly label = input(''); - readonly bindings = input>({}); - readonly childKeys = input([]); - readonly spec = input(null); - - private readonly ctx = inject(RENDER_CONTEXT); - - onInput(event: Event) { - const path = this.bindings()['value']; - if (path) { - const target = event.target as HTMLInputElement; - this.ctx.store.set(path, target.value); - } + "type": "Input", + "props": { + "value": { "$bindState": "/form/email" }, + "label": "Email" } } ``` -## RenderHost +the component receives `value` resolved to the stored value and `bindings` set to `{ value: '/form/email' }`. To write back, read the path out of `bindings` and set it on the store. + +## Talking back through the render host -Inside a mounted view component, `injectRenderHost()` exposes the element-scoped host: +`injectRenderHost()` gives a mounted component the element-scoped host, which is the supported way to write state, fire events, and announce a result. -```typescript -import { Component, inject } from '@angular/core'; +```ts +import { Component, input } from '@angular/core'; import { injectRenderHost } from '@threadplane/render'; @Component({ selector: 'app-approval', standalone: true, - template: ` - - `, + template: ``, }) export class ApprovalComponent { + readonly bindings = input>({}); + private readonly host = injectRenderHost(); approve() { - this.host.set('/approved', true); + const path = this.bindings()['approved']; + if (path) { + this.host.set(path, true); + } this.host.emit('approved'); this.host.result({ approved: true }); } } ``` -Use `set(path, value)` for state writes, `emit(event, payload?)` for spec `on` handlers, and `result(value)` when the component has produced a value that the host should observe as a `RenderResultEvent`. - -## Recursive Children - -Container components can render their children by using the `childKeys` and `spec` inputs with `RenderElementComponent`: - -```typescript -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; -import { RenderElementComponent } from '@threadplane/render'; -import type { Spec } from '@json-render/core'; - -@Component({ - selector: 'app-container', - standalone: true, - imports: [RenderElementComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- @for (key of childKeys(); track key) { - - } -
- `, -}) -export class ContainerComponent { - readonly childKeys = input([]); - readonly spec = input(null); - readonly loading = input(false); - readonly emit = input<(event: string) => void>(() => {}); - readonly bindings = input>({}); -} -``` - -This enables deeply nested component trees -- containers render their children, which can themselves be containers with more children. - -## Providing the Registry +`set(path, value)` writes to the render state store at a JSON Pointer path, `emit(event, payload?)` routes to the element's `on` handlers, and `result(value)` surfaces the value to the host as a render result event. -Let's provide the registry. You have two ways to do it: +## Providing the registry - - +There are two places a registry can come from. The application root is the usual one: -```typescript -// app.config.ts -import { provideRender, defineAngularRegistry } from '@threadplane/render'; +```ts +import { ApplicationConfig } from '@angular/core'; +import { defineAngularRegistry, provideRender } from '@threadplane/render'; export const appConfig: ApplicationConfig = { providers: [ @@ -245,33 +195,15 @@ export const appConfig: ApplicationConfig = { }; ``` -```html - - -``` - - - - -```typescript -// component.ts -registry = defineAngularRegistry({ - Text: TextComponent, - Card: CardComponent, -}); -``` +The other is the `registry` input on the surface itself, which is what the example uses so that one page can render with a registry of its own: ```html - - + ``` - - - -When both are provided, the input always takes precedence over the global config. +`` resolves its registry in a fixed order: the input first, then the config from `provideRender()`, then a registry supplied through `provideViews()`, and finally an empty registry that resolves nothing. -## Next Steps +## What's Next diff --git a/apps/website/content/docs/render/guides/repeat-loops.mdx b/apps/website/content/docs/render/guides/repeat-loops.mdx index d9adf2c3d..b39f4dd82 100644 --- a/apps/website/content/docs/render/guides/repeat-loops.mdx +++ b/apps/website/content/docs/render/guides/repeat-loops.mdx @@ -1,9 +1,164 @@ --- -description: Render one element per item of a bound collection with $item and $index in a json-render spec, backed by a signal state store. +description: How a spec element repeats itself over an array in the state store with repeat, $item and $index, and how the running example holds that array. --- # Repeat Loops -A repeat loop renders one element per item of a bound collection, with `$item` and `$index` available to child expressions, so a spec can describe a list without enumerating its rows. The example binds the loop to a signal-backed state store and updates the list live. +A repeat loop lets one spec element stand for a whole list. Instead of naming every row as a child key, the element declares `repeat` with a JSON Pointer into the state store, and the renderer mounts it once per item in the array at that path. The running example holds the array side of that arrangement: a signal-backed store with an `/items` array, and controls that add to it and remove from it while a spec streams in beside it. -This page is the live example for repeat loops. Use **Run** to try the example live (it has no agent), **Code** to read the Angular source, and **API** for the extracted reference. The spec format, including the repeat element, is documented in [Specs & Elements](/docs/render/guides/specs). +## What the demo does + +The Run tab shows a split surface. The rendered output is on the left, the JSON that produced it streams in on the right, and a transport along the bottom scrubs through the stream or replays it at 1x, 2x, or 4x. The tabs at the top switch between three list-shaped specs — Simple List, Task List, and Sections — and picking one restarts the stream against that spec. + +Below the JSON is a List Controls panel over the store. **Add Item** appends a new entry to the `/items` array and the small cross beside each row removes it, so the array a repeat would bind to changes while the surface is mounted. The three streamed specs name their rows as explicit children with literal text, which is the enumerated form a repeat replaces. + + +The example holds the store, the `/items` path, and the immutable writes that a repeat depends on. Its streamed specs enumerate their rows as literal children, which is exactly the shape a `repeat` collapses, and the hand-written specs further down this page show the repeat half. So adding an item changes the List Controls panel, not the rendered surface. The store holds plain strings rather than objects, so `$item: ""` — the whole item — is the form that would bind them. + + +## How it is built + +The example is one Angular file and an application config. Open the Code tab to read them in place. + +### The application config + +`provideRender()` is called with an empty configuration. The registry and the store travel as inputs on the render surface instead. + + + +### The registry and the array + +Three type names are registered, and the store is seeded with the array a repeat would iterate. `signalStateStore()` is backed by an Angular signal, which is what makes later writes reach the rendered surface. + + + +`/items` is a JSON Pointer, and it is the exact string a `repeat` element would carry as its `statePath`. + +### Reading and writing the array + +The store is path-addressable: `get()` takes a pointer and returns the value, `set()` takes a pointer and a value. Both mutations replace the whole array rather than editing it in place. + + + +Replacement is what the store expects: `set()` compares the current value by reference and ignores a write that hands back the same one. + + +`store.set('/items', items)` with the same array instance is a no-op, so an in-place `push()` or `splice()` followed by a `set()` of the same reference notifies nobody. Build a new array, as the example does with a spread and a `filter()`. + + +### The render surface + +`` receives the streamed spec, the registry, the store, and a `loading` flag driven by the simulator. The store passed here is the same one the controls write to, which is how a repeat element inside the spec would resolve its array. + + + +### The list controls + +The controls are ordinary Angular template code around the store, not part of the spec. + + + +### The agent is not in the render path + +The capability also ships a backend, and it is worth being clear about what it does not do. This single-node LangGraph agent answers questions about repeat rendering; it never produces the specs on screen, which the example streams locally. + + + +## Declaring a repeat + +`repeat` sits at the element level, beside `type`, `props` and `children` — never inside `props`. It takes a `statePath` pointing at an array in the state model: + +```json +{ + "root": "list", + "elements": { + "list": { + "type": "Card", + "props": { "title": "Tasks" }, + "children": ["row"] + }, + "row": { + "type": "Text", + "props": { "content": { "$item": "title" } }, + "repeat": { "statePath": "/tasks" } + } + }, + "state": { + "tasks": [{ "title": "Review pull request" }, { "title": "Deploy" }] + } +} +``` + +The element carrying `repeat` is the one that multiplies: `row` mounts once per entry in `/tasks`, and each mount resolves its own props. A value at the path that is not an array yields no items at all, so an absent or still-streaming path renders nothing rather than failing. + +## Item expressions + +Inside a repeat, prop expressions gain three forms that resolve against the current item rather than the global state model. + +| Expression | Resolves to | +|---|---| +| `{ "$item": "field" }` | A field on the current item; `""` returns the whole item | +| `{ "$index": true }` | The zero-based array index | +| `{ "$bindItem": "field" }` | Two-way binding to a field on the current item, resolved to an absolute state path | + +`$state` still works and still reads the global model, so an element can mix per-item values with application-wide ones. `$bindItem` behaves like `$bindState`: the prop receives the resolved value, and the write-back path arrives on the component's `bindings` input as `/tasks/1/field`. + +The same `$item` and `$index` forms are available in a `visible` condition on the **children** of a repeated element. Each mount provides its iteration context through a child injector, so a child inherits the repeat scope and its condition resolves against that mount's item: + +```json +{ + "row": { + "type": "Card", + "children": ["label"], + "repeat": { "statePath": "/tasks" } + }, + "label": { + "type": "Text", + "props": { "content": { "$item": "title" } }, + "visible": { "$item": "done", "eq": false } + } +} +``` + +A `visible` condition on the element that carries `repeat` is a different matter: the Angular renderer does not evaluate it. That element takes the repeat branch, which mounts every item in the array, so a row cannot hide itself that way. Put the condition on a child, as above, or filter the array in the state model before it reaches the repeat path. + +## Item scope in Angular + +Each repeated mount gets its own child injector carrying a `RepeatScope`, which a registered component can inject when it needs the raw iteration context rather than resolved props: + +```ts +import { inject } from '@angular/core'; +import { REPEAT_SCOPE } from '@threadplane/render'; + +const scope = inject(REPEAT_SCOPE, { optional: true }); +scope?.item; // the current item +scope?.index; // zero-based index +scope?.basePath; // absolute pointer, for example '/tasks/1' +``` + +The token is optional because the same component class also mounts outside a repeat, where nothing provides it. `basePath` is `statePath` joined with the index, and it is what `$bindItem` uses to turn a field name into an absolute path. + +## Reactivity and identity + +The renderer reads the array through the store inside a computed, so a `set()` on the repeat path re-resolves the items and the mounts follow. Items are tracked by array position, which means an insertion at the front shifts every later row onto the preceding item's mount rather than moving the mounts. + + +`repeat` accepts an optional `key` alongside `statePath` in the spec type. The Angular renderer does not read it: it iterates by index. Treat `key` as advisory metadata for spec producers and tools, and do not depend on it for reconciliation. + + +## What's Next + + + + The store behind a repeat path: reads, writes and subscriptions. + + + The spec format that carries repeat: root, elements, props and children. + + + Map element type names to the components a repeat mounts per item. + + + The surface that receives the spec, the registry and the store. + + diff --git a/apps/website/content/docs/render/guides/specs.mdx b/apps/website/content/docs/render/guides/specs.mdx index e381df492..5d6cc2477 100644 --- a/apps/website/content/docs/render/guides/specs.mdx +++ b/apps/website/content/docs/render/guides/specs.mdx @@ -1,322 +1,266 @@ -# Specs +--- +description: How the spec rendering example streams a JSON spec into a live component tree, plus the spec format: root, elements, props, children, visibility and repeat. +--- -A spec is the JSON object that describes your entire UI tree. It tells `@threadplane/render` what to render, how to wire state, and when to show or hide an element. +# Specs -## Spec Format +A spec is the JSON object that describes an entire UI tree. It names a root element, holds every element in one flat map, and says for each element which component renders it, what props it receives, which children it owns, and when it is visible. The running example streams three specs one character at a time and renders each one as it arrives, so the format on this page is the format you can watch being parsed. -Every spec has three top-level properties: +## What the demo does -```typescript -import type { Spec } from '@json-render/core'; +The Run tab is split in two. The left side is the live render output, the right side is the raw JSON as it streams, and the bar along the bottom is a transport: play and pause, a track you can scrub to any character, a character count, and 1x, 2x and 4x speeds. -const spec: Spec = { - root: 'page', // key of the root element - elements: { // flat map of element definitions - page: { /* ... */ }, - header: { /* ... */ }, - content: { /* ... */ }, - }, - state: { // (optional) initial state - title: 'My App', - }, -}; -``` +Three tabs across the top pick the spec. "Heading + Text" is a heading with one text child. "Card + Badge" is a card holding a badge and a paragraph. "Nested Layout" is a heading over two cards, each with its own text child. Picking a tab restarts the stream against that spec, and elements appear as soon as their props arrive. The Text, Heading and Card components draw a skeleton placeholder while they wait; the Badge component has none, so a badge simply appears once its label arrives. -| Property | Type | Description | -|----------|------|-------------| -| `root` | `string` | The key in `elements` that is the entry point for rendering | -| `elements` | `Record` | A flat map of all element definitions, keyed by unique identifiers | -| `state` | `object` | (Optional) Initial state used to create an internal state store when no external store is provided | +## How it is built - -Elements are stored in a flat map rather than a nested tree. Parent-child relationships are expressed through the `children` property, which contains keys pointing to other elements in the map. This keeps lookups fast and makes specs easy to generate from server-side tools. - +The Code tab holds the component and the application config. Open it to read them in place. -## UIElement Properties +### The spec the demo streams -Each element in the `elements` map is a `UIElement` object: +The three specs live in `specs.ts`, beside the component, as plain JSON strings. This is the first one, and it is the whole format in miniature: a `root` key, a flat `elements` map, and a `children` array that holds keys rather than nested objects. -```typescript +```json { - type: 'Card', // registry component name - props: { // inputs for the component - title: 'Static value', - count: { $state: '/counter' }, // reactive state expression - email: { $bindState: '/form/email' }, // two-way binding - }, - children: ['header', 'body'], // child element keys - visible: { $state: '/showCard' }, // conditional visibility - repeat: { statePath: '/items' }, // loop over state array - on: { // event handlers - submit: { action: 'handleSubmit', params: {} }, - }, + "root": "root", + "elements": { + "root": { + "type": "Heading", + "props": { "content": "Welcome to Spec Rendering" }, + "children": ["desc"] + }, + "desc": { + "type": "Text", + "props": { + "content": "This UI is rendered entirely from a JSON specification. Each element maps to a registered Angular component." + } + } + } } ``` -| Property | Type | Description | -|----------|------|-------------| -| `type` | `string` | The name used to look up the component in the registry | -| `props` | `Record` | Input values for the component. Can be static or dynamic expressions | -| `children` | `string[]` | Keys of child elements in the `elements` map | -| `visible` | `unknown` | Visibility condition -- evaluated by `@json-render/core` | -| `repeat` | `{ statePath: string }` | Repeat configuration for rendering lists | -| `on` | `Record` | Event handler bindings | +Nothing in the spec names an Angular class: `Heading` and `Text` are registry keys, resolved at render time. -## Prop Expressions +### The application configuration -Props can be static values or dynamic expressions resolved by `@json-render/core`: +`provideRender()` registers the render feature. This example passes an empty options object, because the registry and the store travel as inputs on the render surface instead. -### $state -- Read from State + -Reads a value from the state store at the given JSON Pointer path: +### The registry names the element types -```typescript -props: { - label: { $state: '/user/name' }, // resolves to store.get('/user/name') -} -``` +Every `type` string in a spec has to resolve to something. `defineAngularRegistry()` is where the four names the three specs use are bound to components, and `signalStateStore()` is the store that prop expressions and visibility conditions read from. -### $bindState -- Two-Way Binding + -Like `$state`, but also populates the `bindings` input so the component can write back to the store: +The store starts empty here because these specs use literal props rather than state expressions. -```typescript -props: { - value: { $bindState: '/form/email' }, -} -// Component receives: -// value = 'current email value' -// bindings = { value: '/form/email' } -``` +### Mounting the root element -### $item -- Repeat Item Value +`` takes the spec, the registry, the store and a `loading` flag. It renders exactly one element, the one named by `spec.root`. -Inside a `repeat` loop, `$item` resolves to the current array item. Pass an empty string to get the whole item, or a path to access a nested property: + -```typescript -props: { - label: { $item: '' }, // the full item - name: { $item: 'name' }, // item.name (for object items) -} -``` +While the simulator has produced no spec at all, the template shows a placeholder instead. -### $index -- Repeat Index +### Children render because a component renders them -Inside a `repeat` loop, `$index` resolves to the current zero-based iteration index: +An element's `children` array reaches the mounted component as a `childKeys` input, and the full `spec` arrives with it. A component that wants children loops over the keys and mounts a `` for each, which is how the flat map turns into a tree of any depth. -```typescript -props: { - position: { $index: true }, // 0, 1, 2, ... -} -``` - -### $computed -- Computed Function - -Calls a registered computed function with the given arguments: - -```typescript -props: { - label: { - $computed: 'uppercase', - args: { text: { $state: '/name' } } - }, -} -``` + -The `$computed` value names 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: +The text component in the same file declares `childKeys` and never loops over it, so a text element is a leaf even when a spec gives it children. -```typescript -import type { ComputedFunction } from '@json-render/core'; +### A partial spec still renders -const functions: Record = { - uppercase: (args) => String(args['text']).toUpperCase(), -}; -``` +The spec does not arrive whole. `StreamingSimulator`, a shared helper that sits beside the examples rather than in the block below, is the piece that feeds characters into an incremental JSON parser and materializes whatever is complete so far, so the value handed to `` grows one element and one prop at a time. -Wire the map through the `[functions]` input on ``: + -```html - -``` - -Or register it globally via `provideRender()` so every `` resolves it: + +A prop can be missing on the first mount and present a frame later. Give every input a default, and use the `loading` input to draw a placeholder rather than an empty element. + -```typescript -import { provideRender } from '@threadplane/render'; +### The agent is not in the render path -provideRender({ - registry: myRegistry, - functions: { - uppercase: (args) => String(args['text']).toUpperCase(), - }, -}); -``` +The capability also ships a backend, and it is worth being clear about what it does not do. This single-node LangGraph agent talks about render specs in prose; it never produces the specs on screen, which this example streams locally. -With either wiring, the `$computed` expression above resolves `label` to the uppercased value of `/name`. The input takes priority over the `provideRender()` config when both are present. + -## Children +## Spec format -The `children` property is an array of element keys that reference other entries in the `elements` map: +A spec has one required key for the entry point, one for the elements, and an optional seed for state. ```typescript +import type { Spec } from '@json-render/core'; + const spec: Spec = { root: 'page', elements: { - page: { - type: 'Container', - props: {}, - children: ['heading', 'body'], - }, - heading: { - type: 'Text', - props: { label: 'Welcome' }, - }, - body: { - type: 'Text', - props: { label: 'Page content here' }, - }, + page: { type: 'Container', props: {}, children: ['heading'] }, + heading: { type: 'Text', props: { content: 'My App' } }, + }, + state: { + title: 'My App', }, }; ``` -The rendered `ContainerComponent` receives `childKeys: ['heading', 'body']` and the full `spec`, enabling it to recursively render its children using `RenderElementComponent`. +| Property | Type | Description | +|----------|------|-------------| +| `root` | `string` | The key in `elements` that rendering starts from | +| `elements` | `Record` | A flat map of every element definition, keyed by a unique string | +| `state` | `Record` | Optional. Seeds the internal state store that `` creates when neither a `[store]` input nor a `provideRender({ store })` is supplied. The internal store is created once and is not re-seeded by a later spec | + + +Elements are stored in a flat map rather than a nested tree. Parent-child relationships are expressed through the `children` property, which holds keys pointing at other entries in the same map. That keeps lookups constant-time and makes a spec easy for a model to emit and to patch one element at a time. + -### Deeply Nested Trees +## Element properties -Because children reference keys in the same flat map, you can build arbitrarily deep trees: +Each entry in `elements` is a `UIElement`: ```typescript -elements: { - root: { type: 'Container', props: {}, children: ['level1'] }, - level1: { type: 'Container', props: {}, children: ['level2'] }, - level2: { type: 'Container', props: {}, children: ['leaf'] }, - leaf: { type: 'Text', props: { label: 'Deep content' } }, +interface UIElement { + type: string; + props: Record; + children?: string[]; + visible?: VisibilityCondition; + repeat?: { statePath: string; key?: string }; + on?: Record; + // Part of the spec schema; the Angular renderer does not act on it today + // (use `on` bindings or an effect over the store). + watch?: Record; } ``` -## Conditional Rendering +| Property | Type | Description | +|----------|------|-------------| +| `type` | `string` | The name looked up in the registry | +| `props` | `Record` | Inputs for the component. Static values or expressions | +| `children` | `string[]` | Keys of child elements in the same `elements` map | +| `visible` | `VisibilityCondition` | Visibility condition, evaluated against the store | +| `repeat` | `{ statePath: string; key?: string }` | Render this element once per item in a state array. `key` is schema-level and is not used by the Angular renderer | +| `on` | `Record` | Event name to the action or actions it fires | +| `watch` | `Record` | State path to the actions that fire when the value there changes. Part of the spec schema; the Angular renderer does not act on it today (use `on` bindings or an effect over the store) | -The `visible` property controls whether an element is rendered. When the condition evaluates to a falsy value, the element and all its children are excluded from the DOM. +## Prop expressions -### Static Visibility +A prop is a literal unless it is an object with one of these reserved keys. Expressions are resolved fresh whenever the store or the surrounding repeat scope changes. -```typescript -{ - type: 'Text', - props: { label: 'Always hidden' }, - visible: false, -} -``` +| Expression | Resolves to | +|------------|-------------| +| `{ $state: '/user/name' }` | The value at that JSON Pointer path in the store | +| `{ $bindState: '/form/email' }` | The value at that path, plus the path itself in `bindings` | +| `{ $item: 'name' }` | A field on the current repeat item. Use `''` for the whole item | +| `{ $bindItem: 'name' }` | The same field, plus its absolute path in `bindings` | +| `{ $index: true }` | The current zero-based repeat index | +| `{ $cond, $then, $else }` | `$then` when the condition holds, otherwise `$else` | +| `{ $computed: 'uppercase', args }` | The return value of a registered function, called with resolved `args` | +| `{ $template: 'Hi ${/user/name}' }` | The string with each `${/path}` replaced by the value at that path | + +Arrays and plain objects are walked, so an expression nested inside a prop object is resolved too. -### State-Driven Visibility +### Two-way binding + +`$bindState` resolves like `$state` and additionally populates the component's `bindings` input with the path, so the component can write back: ```typescript -{ - type: 'Text', - props: { label: 'Conditionally shown' }, - visible: { $state: '/showMessage' }, -} +const props = { + value: { $bindState: '/form/email' }, +}; +// The component receives: +// value = the current value at /form/email +// bindings = { value: '/form/email' } ``` -When `/showMessage` is `true` in the state store, the element renders. When it is `false`, it is removed. +`$bindItem` does the same inside a repeat loop, resolving the item-relative path against the item's base path. -### Default Visibility +### Computed values -When `visible` is omitted or `undefined`, the element is visible by default. +`$computed` names a function in the `functions` map. Each function is a `ComputedFunction` from `@json-render/core`, so it takes a record of already-resolved arguments and returns a value: -## Repeat Loops +```typescript +import type { ComputedFunction } from '@json-render/core'; -The `repeat` property renders an element once for each item in a state array: +const functions: Record = { + uppercase: (args) => String(args['text']).toUpperCase(), +}; +``` ```typescript -const spec: Spec = { - root: 'list', - elements: { - list: { - type: 'ListItem', - props: { - label: { $item: 'name' }, - index: { $index: true }, - }, - repeat: { statePath: '/todos' }, - }, - }, - state: { - todos: [ - { name: 'Buy groceries' }, - { name: 'Write docs' }, - { name: 'Ship feature' }, - ], - }, +const props = { + label: { $computed: 'uppercase', args: { text: { $state: '/name' } } }, }; ``` -For each item in the array at `/todos`, the library: +Pass the map through the `[functions]` input on ``, or register it once with [`provideRender()`](/docs/render/api/provide-render). An unregistered name resolves to `undefined` and logs a warning. -1. Creates a `RepeatScope` with the `item`, `index`, and `basePath` (e.g., `/todos/0`) -2. Provides the scope via a child `Injector` using the `REPEAT_SCOPE` token -3. Resolves props using the repeat scope context -- `$item` and `$index` expressions are evaluated per-iteration -4. Renders the component with the resolved inputs +## Conditional rendering - -When an element has `repeat`, visibility evaluation is handled differently -- all items are rendered. To conditionally render individual repeat items, use conditional logic within the rendered component itself. - +The `visible` property decides whether an element mounts. When it evaluates to false the element and everything under it stays out of the DOM. Omitting it means visible. + +```typescript +import type { UIElement } from '@json-render/core'; + +const never: UIElement = { + type: 'Text', + props: { content: 'Never shown' }, + visible: false, +}; + +const whenFlag: UIElement = { + type: 'Text', + props: { content: 'Shown when the flag is truthy' }, + visible: { $state: '/showMessage' }, +}; + +const whenOverFive: UIElement = { + type: 'Text', + props: { content: 'Shown when the count is over five' }, + visible: { $state: '/count', gt: 5 }, +}; +``` + +A single condition reads `$state`, `$item` or `$index` and applies at most one comparison operator: `eq`, `neq`, `gt`, `gte`, `lt` or `lte`. With no operator it checks truthiness, and `not: true` inverts the result. An array of conditions is an implicit AND; `{ $and: [...] }` and `{ $or: [...] }` are the explicit forms and may nest. -## Complete Example +## Repeat loops -Let's put it together. Here's a spec that combines children, state expressions, conditional rendering, and repeat loops: +`repeat` renders one copy of the element for each item in a state array: ```typescript -const spec: Spec = { - root: 'app', - elements: { - app: { - type: 'Container', - props: {}, - children: ['title', 'toggle', 'list'], - }, - title: { - type: 'Text', - props: { label: { $state: '/heading' } }, - }, - toggle: { - type: 'Button', - props: { label: 'Toggle List' }, - on: { click: { action: 'toggleList', params: {} } }, - }, - list: { - type: 'ListItem', - props: { - name: { $item: 'name' }, - position: { $index: true }, - }, - visible: { $state: '/showList' }, - repeat: { statePath: '/items' }, - }, - }, - state: { - heading: 'My Todo List', - showList: true, - items: [ - { name: 'First task' }, - { name: 'Second task' }, - ], +import type { UIElement } from '@json-render/core'; + +const item: UIElement = { + type: 'ListItem', + props: { + label: { $item: 'name' }, + position: { $index: true }, }, + repeat: { statePath: '/todos' }, }; ``` -## Next Steps +For each item the renderer builds a repeat scope holding the item, its index and its base path (`/todos/0`, `/todos/1`, and so on), provides that scope through a child injector, and resolves the element's props inside it, so `$item`, `$bindItem` and `$index` mean something different in every copy. + + +An element with `repeat` renders every item. To hide individual items, branch inside the component that renders them. + + +The [repeat loops guide](/docs/render/guides/repeat-loops) covers the scope and the write-back paths in full. + +## What's Next - - How components receive props and render children + + How type names resolve and what a registered component receives + + + Reading and writing the store that expressions resolve against Event handler bindings and action dispatch - - Managing reactive state with signalStateStore() - Full API reference for the entry-point component diff --git a/apps/website/content/docs/render/guides/state-store.mdx b/apps/website/content/docs/render/guides/state-store.mdx index b032c790a..8dff3b816 100644 --- a/apps/website/content/docs/render/guides/state-store.mdx +++ b/apps/website/content/docs/render/guides/state-store.mdx @@ -1,192 +1,213 @@ +--- +description: How the state management example seeds a signal-backed store, resolves $state props against JSON Pointer paths, and re-renders when the host writes. +--- + # State Store -The state store holds the reactive state that drives your rendered UI. `@threadplane/render` provides `signalStateStore()`, an Angular Signals-backed implementation of the `StateStore` interface from `@json-render/core`. +The state store holds the values a rendered spec reads from and writes back to. `@threadplane/render` ships `signalStateStore()`, an Angular signal-backed implementation of the `StateStore` interface from `@json-render/core`. The running example seeds one store with a user and a settings object, streams three specs whose props point at those values, and puts form controls next to the render output so you can watch a write land. -## Creating a State Store +## What the demo does -```typescript -import { signalStateStore } from '@threadplane/render'; +The Run tab is split in two. On the left is the live render output; on the right is the raw JSON as it streams in, with a State Controls panel underneath it and a transport along the bottom. Press play and the spec arrives character by character, materializing into rendered elements as each one completes. -const store = signalStateStore({ - user: { name: 'Alice', age: 30 }, - items: ['apple', 'banana', 'cherry'], - isVisible: true, -}); -``` +The tabs at the top switch between three specs. "User Profile" is a heading with two text children bound to `/user/name` and `/user/age`. "Nested Paths" renders the same two values plus `/settings/theme` as labelled rows. "Form Display" wraps two of the rows in a card. -The function accepts an optional initial state object (defaults to `{}`). It returns a `StateStore` that uses Angular Signals internally, so any state change automatically triggers Angular's change detection. +Type in the Name field and the rendered output changes as you type, because the field writes straight to the store and every element bound to `/user/name` resolves again. Switching the Theme select does the same for `/settings/theme`, which the second and third specs display. -## JSON Pointer Paths +## How it is built -All state access uses [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) paths. A JSON Pointer is a string that identifies a specific value within a JSON document. +The example is one Angular component file and an application config, with the three specs in a `specs.ts` beside them. The capability also ships a `graph.py`, a single-node LangGraph agent that answers questions about state management, but it plays no part in what the Run tab renders: the specs come from a local streaming simulator. -| Path | Resolves to | -|------|-------------| -| `/user/name` | `'Alice'` | -| `/user/age` | `30` | -| `/items/0` | `'apple'` | -| `/items/2` | `'cherry'` | -| `/isVisible` | `true` | +### The application configuration -Paths always start with `/`. Each segment separated by `/` traverses one level deeper into the object. Array elements are accessed by index. +`provideRender()` registers the render feature. This example passes an empty options object, because it supplies the registry and the store per instance instead. -### Escaping + -JSON Pointer defines two escape sequences for special characters in property names: +### The registry and the seeded store -- `~0` represents `~` -- `~1` represents `/` +`defineAngularRegistry()` maps the four `type` strings the specs use to the components that render them. `signalStateStore()` takes the initial state object, and that object is the whole state tree the three specs address. -For example, to access a property named `a/b`, the pointer would be `/a~1b`. + -## Reading State +Every pointer on this page, in the specs and in the controls, is a path into that seed. -Use `get()` to read a value at a path: +### The specs address the seed by pointer -```typescript -const store = signalStateStore({ user: { name: 'Alice' } }); +A spec prop is either a literal or an expression object. `{ "$state": "/user/name" }` is the expression that reads a pointer out of the store. This is the User Profile spec from `specs.ts`: -store.get('/user/name'); // 'Alice' -store.get('/user'); // { name: 'Alice' } -store.get('/missing'); // undefined +```json +{ + "root": "root", + "elements": { + "root": { + "type": "Heading", + "props": { "content": "User Profile" }, + "children": ["name", "age"] + }, + "name": { "type": "Text", "props": { "content": { "$state": "/user/name" } } }, + "age": { "type": "Text", "props": { "content": { "$state": "/user/age" } } } + } +} ``` -## Writing State +The other two specs use the same expressions against `Label` elements, which is why one store serves all three. -### Single Value +### Mounting the surface with an explicit store -Use `set()` to write a single value. The store performs an immutable update -- it clones the path to the target and sets the new value. If the new value is referentially equal to the current one, the update is skipped. +`` receives the materialized spec, the registry, the store, and a `loading` flag driven by the simulator. -```typescript -store.set('/user/name', 'Bob'); -store.get('/user/name'); // 'Bob' -``` + -### Batch Updates +Passing `[store]` is what makes the controls and the rendered elements talk to the same state. -Use `update()` to set multiple values in a single operation. This triggers only one notification to subscribers, regardless of how many values change. +### A resolved prop arrives as an ordinary input -```typescript -store.update({ - '/user/name': 'Charlie', - '/user/age': 25, - '/isVisible': false, -}); -``` +Nothing in a registered component knows about the store. The element's props arrive as inputs of the same name, already resolved, so `value` here holds whatever sits at the pointer the spec named. -If none of the values actually change (all are referentially equal), no notification is triggered. + -## Snapshots +`loading` is true while the simulator is playing, which is what selects the skeleton branch. -Use `getSnapshot()` to get the entire state object: +### Reading and writing from the host component -```typescript -const store = signalStateStore({ x: 1, y: 2 }); -store.getSnapshot(); // { x: 1, y: 2 } +The component that owns the store reads and writes it directly. `get()` takes a pointer and returns the current value; `set()` takes a pointer and the new value. -store.set('/x', 10); -store.getSnapshot(); // { x: 10, y: 2 } -``` + -## Subscribing to Changes +The State Controls panel is plain Angular markup outside the render tree, and it calls those two methods. -Use `subscribe()` to register a callback that is invoked whenever the state changes. The function returns an unsubscribe function. + -```typescript -const store = signalStateStore({ count: 0 }); +Because the store is backed by a signal, a write re-runs prop resolution for every mounted element that reads the written pointer. -const unsubscribe = store.subscribe(() => { - console.log('State changed:', store.getSnapshot()); -}); +## How a binding resolves -store.set('/count', 1); // logs: State changed: { count: 1 } -store.set('/count', 2); // logs: State changed: { count: 2 } +The pieces meet in a fixed order, and no view component subscribes to anything. -unsubscribe(); // stop listening -store.set('/count', 3); // no log -- unsubscribed -``` +`RenderElementComponent` builds a prop resolution context from `store.getSnapshot()`, which reads the underlying signal. `resolveElementProps()` from `@json-render/core` then walks the element's props and replaces each `{ $state: '/path' }` expression with the value at that pointer, leaving literals alone. The result becomes the mounted component's inputs, filtered down to the names that component declares. + +That whole chain lives inside Angular computed signals, so a `set()` on the store invalidates the snapshot, re-resolves the props of every element that reads it, and re-renders. An element's `visible` condition reads the store through the same context, and a `repeat` element's `statePath` reads the same store inside the same computed graph, so a single write can also show, hide, or re-count elements. + +## JSON Pointer paths + +Every read and write addresses state with a [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) path. Against the seed in this example: + +| Path | Resolves to | +|------|-------------| +| `/user/name` | `'Alice'` | +| `/user/age` | `30` | +| `/settings/theme` | `'dark'` | +| `/` | the whole state object | +| `/missing` | `undefined` | + +Paths start with `/`, and each segment traverses one level deeper. Array elements are addressed by index, so `/items/2` is the third element of `items`. + +### Escaping + +A property name that itself contains `/` or `~` is escaped in the pointer: + +- `~0` represents `~` +- `~1` represents `/` + +A property named `a/b` is addressed as `/a~1b`, and one named `c~d` as `/c~0d`. + +## What the store exposes + +`signalStateStore(initialState)` returns a `StateStore` with these five methods, and nothing else. + +| Method | Behavior | +|--------|----------| +| `get(path)` | The value at the pointer, or `undefined` if the path does not resolve | +| `set(path, value)` | Immutable write: clones the path to the target. Skipped when the new value is referentially equal to the current one | +| `update(updates)` | A record of pointer to value, applied together, notifying subscribers once. No notification if nothing changed | +| `getSnapshot()` | The whole state object | +| `subscribe(listener)` | Registers a change listener and returns the function that removes it | + +`RenderSpecComponent` is the one caller of `subscribe()` in the library; it uses it to emit `stateChange` events. -## Reactive Behavior with Angular Signals +Writing an array element by index preserves the array, so `set('/items/1', 'B')` on `['a', 'b', 'c']` leaves `['a', 'B', 'c']`. The [`signalStateStore()` reference](/docs/render/api/signal-state-store) has the signature of each method. -Under the hood, `signalStateStore()` wraps the state in an Angular `signal()`. This means: +## Two-way bindings -- Components using `OnPush` change detection automatically update when the state changes -- Props resolved via `$state` expressions in specs are re-evaluated when the underlying signal updates -- The store fits Angular's reactivity model -- no RxJS or manual subscription management needed +The controls in this example sit outside the render tree, so they call the store themselves. A component mounted *by* the renderer writes back a different way: the spec marks the prop with `$bindState` instead of `$state`. -```typescript -// In a spec, $state props are automatically reactive +```json { - type: 'Text', - props: { - label: { $state: '/user/name' }, // re-evaluated on state change - }, + "type": "Input", + "props": { + "value": { "$bindState": "/user/name" }, + "label": "Name" + } } ``` -## Working with Arrays +The prop still resolves to the current value at that pointer, and the pointer itself arrives in the `bindings` input keyed by prop name — here, `{ value: '/user/name' }`. To write, read the pointer out of `bindings` and set it through the element-scoped render host. -The store preserves array types when updating elements by index: - -```typescript -const store = signalStateStore({ items: ['a', 'b', 'c'] }); - -store.set('/items/1', 'B'); -store.get('/items/1'); // 'B' -store.get('/items'); // ['a', 'B', 'c'] +```ts +import { Component, input } from '@angular/core'; +import { injectRenderHost } from '@threadplane/render'; -// The items value is still an array -Array.isArray(store.get('/items')); // true +@Component({ + selector: 'app-name-field', + standalone: true, + template: ``, +}) +export class NameFieldComponent { + readonly value = input(''); + readonly bindings = input>({}); + + private readonly host = injectRenderHost(); + + write(next: string) { + const path = this.bindings()['value']; + if (path) { + this.host.set(path, next); + } + } +} ``` -## Providing the Store +`injectRenderHost()` returns the host for the element the component was mounted for. Its `set(path, value)` writes the render store, which puts the write back on the same resolution chain as any other. -Let's wire the store in. You have three ways to provide one, and `RenderSpecComponent` resolves it using this priority chain: +## Where the store comes from - - +`RenderSpecComponent` resolves the store in a fixed order: the `store` input first, then the store on the `provideRender()` configuration, then an internal one. -Pass a store directly to ``: +The input is what this example uses, and it wins over everything else: ```html - + ``` - - - -Set a store in `provideRender()`: +A store on the configuration serves every surface in the application that does not pass one: -```typescript +```ts provideRender({ registry: myRegistry, store: signalStateStore({ theme: 'dark' }), -}) +}); ``` - - - -If no external store is provided, `RenderSpecComponent` creates an internal `signalStateStore()` from `spec.state`: +With neither, the surface creates its own store from `spec.state`: -```typescript +```ts const spec: Spec = { root: 'root', elements: { /* ... */ }, - state: { message: 'Hello' }, // used to create an internal store + state: { message: 'Hello' }, }; ``` - - - -## Testing Rendered Output + +The internal store is built the first time it is needed and reused for the life of the surface, so a later spec carrying a different `state` object does not replace it. Pass a store you own whenever anything outside the render tree needs to read or write the same values. + -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. +## Testing a store-driven render -This spec renders a `Text` component bound to `/message`, then checks that writing the store re-renders it: +`signalStateStore()` is a plain factory and `RenderSpecComponent` is a standard standalone component, so the whole render path fits in `TestBed` with no server and no model. Mount the surface with a store you control, write to it, and assert the rendered output followed. -```typescript +```ts import { Component, input } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RenderSpecComponent, defineAngularRegistry, signalStateStore } from '@threadplane/render'; @@ -198,53 +219,46 @@ import type { Spec } from '@json-render/core'; template: `{{ label() }}`, }) class TextComponent { - readonly label = input(''); + 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'); - }); +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 fixture = TestBed.createComponent(RenderSpecComponent); + fixture.componentRef.setInput('spec', spec); + fixture.componentRef.setInput('registry', defineAngularRegistry({ Text: TextComponent })); + fixture.componentRef.setInput('store', store); + fixture.detectChanges(); + + const text = (): HTMLElement => fixture.nativeElement.querySelector('[data-testid="text"]'); + expect(text().textContent?.trim()).toBe('hello'); + + store.set('/message', 'updated'); + fixture.detectChanges(); + expect(text().textContent?.trim()).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). +The same shape covers visibility, by toggling a bound flag and asserting the element appeared or disappeared, and repeat loops, by setting an array and counting the rendered rows. -## Next Steps +## What's Next - Full API reference for the state store factory + The signature and behavior of each store method. - - How $state expressions connect props to the store + + The spec format that $state and $bindState expressions live in. - - Two-way bindings with $bindState + + What a registered component receives, including the bindings input. - - Updating state in response to events + + Updating state in response to events fired by rendered elements. diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index 0e57f93a0..4bceb8101 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -39,12 +39,6 @@ const PENDING_PAGES = new Set([ '/docs/deep-agents/capabilities/planning', '/docs/deep-agents/capabilities/skills', '/docs/deep-agents/capabilities/subagents', - '/docs/render/api/provide-render', - '/docs/render/api/render-spec-component', - '/docs/render/guides/registry', - '/docs/render/guides/repeat-loops', - '/docs/render/guides/specs', - '/docs/render/guides/state-store', '/docs/runtimes/aws-strands/overview', '/docs/runtimes/mastra/overview', '/docs/runtimes/microsoft-agent-framework/overview', diff --git a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts index e4b1cbb44..bf747799b 100644 --- a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts +++ b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts @@ -45,6 +45,7 @@ import { toDisplayText } from '../../../../shared/to-display-text'; } `, }) +// #region value-inputs class DemoValueComponent { readonly label = input(''); readonly value = input(''); @@ -57,6 +58,7 @@ class DemoValueComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion @Component({ selector: 'demo-heading', @@ -266,6 +268,7 @@ class DemoCardComponent { +
Live Render Output
@if (simulator.spec(); as renderedSpec) { @@ -274,6 +277,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -314,6 +318,7 @@ export class ComputedFunctionsComponent implements OnDestroy { }); } + // #region registry-and-store protected readonly registry = defineAngularRegistry({ Value: DemoValueComponent, Heading: DemoHeadingComponent, @@ -321,6 +326,7 @@ export class ComputedFunctionsComponent implements OnDestroy { }); protected readonly store = signalStateStore({}); + // #endregion protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/computed-functions/python/docs/guide.md b/cockpit/render/computed-functions/python/docs/guide.md deleted file mode 100644 index 4a5fc0a81..000000000 --- a/cockpit/render/computed-functions/python/docs/guide.md +++ /dev/null @@ -1,104 +0,0 @@ -# Computed Functions with @threadplane/render - - -Define custom functions for prop resolution and data transformation in -render specs. Register functions with provideRender() and reference them -in spec prop expressions for dynamic computed values. - - - -Add computed functions to this Angular application using `provideRender()` -from `@threadplane/render`. Define custom functions for data formatting, -register them in the render config, and use them in spec props. - - - - - -Create functions for data transformation: - -```typescript -const functions = { - formatDate: (value: string) => new Date(value).toLocaleDateString(), - uppercase: (value: string) => value.toUpperCase(), - multiply: (a: number, b: number) => a * b, -}; -``` - - - - -Pass functions to the provideRender configuration: - -```typescript -export const appConfig: ApplicationConfig = { - providers: [ - provideRender({ - functions, - }), - ], -}; -``` - - - - -Reference computed functions in render spec prop expressions: - -```typescript -const spec = { - type: 'text', - props: { - content: { compute: 'formatDate', args: ['2024-01-15'] }, - }, -}; -``` - - - - -Computed functions can read from the state store: - -```typescript -const spec = { - type: 'text', - props: { - content: { compute: 'uppercase', args: [{ bind: '/user/name' }] }, - }, -}; -``` - - - - -Configure `provideAgent()` in your app config, then call `injectAgent()` to -receive specs with computed props from the agent: - -```typescript -// app.config.ts -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - }), - ], -}; -``` - -```typescript -// app.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -protected readonly stream = injectAgent(); -``` - - - - - -Keep computed functions pure and side-effect-free. They run during every -change detection cycle, so expensive operations should be memoized. - diff --git a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts index eb9769c8c..c919760b6 100644 --- a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts +++ b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts @@ -38,6 +38,7 @@ class DemoTextComponent { readonly loading = input(false); } +// #region child-recursion @Component({ selector: 'demo-heading', standalone: true, @@ -63,6 +64,7 @@ class DemoHeadingComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion @Component({ selector: 'demo-card', @@ -325,6 +327,7 @@ class DemoCardComponent {
+
Live Render Output
@if (simulator.spec(); as renderedSpec) { @@ -333,6 +336,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -370,6 +374,7 @@ export class ElementRenderingComponent implements OnDestroy { private readonly jsonScroll = viewChild>('jsonScroll'); + // #region registry-and-store protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, Heading: DemoHeadingComponent, @@ -386,6 +391,7 @@ export class ElementRenderingComponent implements OnDestroy { this.store.subscribe(() => { this.showDetail.set(this.store.get('/showDetail') as boolean ?? true); }); + // #endregion // Auto-scroll JSON pane effect(() => { @@ -399,11 +405,13 @@ export class ElementRenderingComponent implements OnDestroy { }); } + // #region visibility-toggle protected onToggleDetail(_event: Event): void { const current = this.showDetail(); this.store.set('/showDetail', !current); this.showDetail.set(!current); } + // #endregion protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/element-rendering/python/docs/guide.md b/cockpit/render/element-rendering/python/docs/guide.md deleted file mode 100644 index 92e6598ce..000000000 --- a/cockpit/render/element-rendering/python/docs/guide.md +++ /dev/null @@ -1,101 +0,0 @@ -# Element Rendering with @threadplane/render - - -Recursively render nested element trees using RenderElementComponent. -Each element resolves its type from the registry and supports visibility -conditions bound to a reactive state store. - - - -Add recursive element rendering to this Angular component using -`RenderElementComponent` from `@threadplane/render`. Define a nested element -spec, create a state store for visibility toggling, and render the tree. - - - - - -Create a spec with nested children forming a recursive tree: - -```typescript -const spec = { - type: 'container', - props: { class: 'space-y-2' }, - children: [ - { type: 'heading', props: { text: 'Parent Element' } }, - { - type: 'container', - props: { class: 'pl-4' }, - children: [ - { type: 'text', props: { content: 'Child element' } }, - { type: 'text', props: { content: 'Another child', visible: { bind: '/showDetail' } } }, - ], - }, - ], -}; -``` - - - - -Use `signalStateStore()` to manage visibility flags: - -```typescript -import { signalStateStore } from '@threadplane/render'; - -const store = signalStateStore({ showDetail: true }); -``` - - - - -Pass the spec and store to the render component: - -```html - -``` - -RenderElementComponent handles the recursive rendering internally, -walking each level of the tree. - - - - -Each element in the tree is rendered by RenderElementComponent. Children -are resolved recursively, so deeply nested structures render correctly. -Visibility conditions at any level control the entire subtree below. - - - - -Configure `provideAgent()` in your app config, then call `injectAgent()` to -receive element specs from the agent: - -```typescript -// app.config.ts -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - }), - ], -}; -``` - -```typescript -// app.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -protected readonly stream = injectAgent(); -``` - - - - - -Use JSON Pointer paths like `/showDetail` to bind visibility conditions -to values in the state store. - diff --git a/cockpit/render/registry/angular/src/app/registry.component.ts b/cockpit/render/registry/angular/src/app/registry.component.ts index 452942dbb..8227364da 100644 --- a/cockpit/render/registry/angular/src/app/registry.component.ts +++ b/cockpit/render/registry/angular/src/app/registry.component.ts @@ -15,6 +15,7 @@ import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- +// #region text-view @Component({ selector: 'demo-text', standalone: true, @@ -37,7 +38,9 @@ class DemoTextComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion +// #region heading-view @Component({ selector: 'demo-heading', standalone: true, @@ -63,6 +66,7 @@ class DemoHeadingComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion @Component({ selector: 'demo-badge', @@ -273,6 +277,7 @@ class DemoCardComponent {
+
Live Render Output
@if (simulator.spec(); as renderedSpec) { @@ -281,6 +286,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -321,6 +327,7 @@ export class RegistryComponent implements OnDestroy { }); } +// #region registry protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, Heading: DemoHeadingComponent, @@ -329,6 +336,7 @@ export class RegistryComponent implements OnDestroy { }); protected readonly store = signalStateStore({}); +// #endregion protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/registry/python/docs/guide.md b/cockpit/render/registry/python/docs/guide.md deleted file mode 100644 index 37bea143c..000000000 --- a/cockpit/render/registry/python/docs/guide.md +++ /dev/null @@ -1,83 +0,0 @@ -# Registry with @threadplane/render - - -Map type strings to Angular component classes using defineAngularRegistry. -The registry resolves component types at render time, enabling JSON specs -to reference components by name. - - - -Add a component registry to this Angular application using -`defineAngularRegistry()` from `@threadplane/render`. Register component -types, look them up with `registry.get()`, and list all types with -`registry.names()`. - - - - - -Create simple Angular components that will be registered: - -```typescript -@Component({ selector: 'app-card', template: '
' }) -export class CardComponent {} - -@Component({ selector: 'app-badge', template: '{{ label }}' }) -export class BadgeComponent { @Input() label = ''; } -``` - -
- - -Map type strings to component classes: - -```typescript -import { defineAngularRegistry } from '@threadplane/render'; - -const registry = defineAngularRegistry({ - card: CardComponent, - badge: BadgeComponent, -}); -``` - - - - -Look up a component class by its type string: - -```typescript -const CardClass = registry.get('card'); // CardComponent -const BadgeClass = registry.get('badge'); // BadgeComponent -``` - - - - -Get all registered type strings: - -```typescript -const types = registry.names(); // ['card', 'badge'] -``` - - - - -Pass the registry to provideRender in your app config: - -```typescript -export const appConfig: ApplicationConfig = { - providers: [ - provideRender({ registry }), - ], -}; -``` - -RenderSpecComponent will use this registry to resolve types in JSON specs. - - -
- - -Keep registry entries focused — each type string should map to exactly one -component. Use descriptive names like 'data-table' or 'stat-card' for clarity. - diff --git a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts index 57b8baea1..5f1bd4831 100644 --- a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts +++ b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts @@ -325,6 +325,7 @@ class DemoCardComponent {
+
Live Render Output
@if (simulator.spec(); as renderedSpec) { @@ -333,6 +334,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -345,6 +347,7 @@ class DemoCardComponent {
+
List Controls
@@ -353,8 +356,9 @@ class DemoCardComponent {
{{ item }}
}
-

Mutates the /items array in the state store; the rendered list reconciles by key.

+

Mutates the /items array in the state store.

+ @@ -384,6 +388,7 @@ export class RepeatLoopsComponent implements OnDestroy { }); } + // #region registry-and-store protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, Heading: DemoHeadingComponent, @@ -391,7 +396,9 @@ export class RepeatLoopsComponent implements OnDestroy { }); protected readonly store = signalStateStore({ items: ['Alpha', 'Beta', 'Gamma'] }); + // #endregion + // #region list-state private counter = 0; protected getItems(): string[] { @@ -408,6 +415,7 @@ export class RepeatLoopsComponent implements OnDestroy { const items = this.getItems(); this.store.set('/items', items.filter((_: string, i: number) => i !== index)); } + // #endregion protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/repeat-loops/python/docs/guide.md b/cockpit/render/repeat-loops/python/docs/guide.md deleted file mode 100644 index 0784b6129..000000000 --- a/cockpit/render/repeat-loops/python/docs/guide.md +++ /dev/null @@ -1,110 +0,0 @@ -# Repeat Loops with @threadplane/render - - -Iterate over arrays in the state store using repeat specs. Each iteration -provides RepeatScope context with repeatItem, repeatIndex, and repeatBasePath -for per-item rendering. - - - -Add repeat rendering to this Angular component using repeat specs from -`@threadplane/render`. Define array state, create a repeat spec template, -access RepeatScope context, and add/remove items dynamically. - - - - - -Create a state store with an array to iterate over: - -```typescript -import { signalStateStore } from '@threadplane/render'; - -const store = signalStateStore({ - items: [ - { name: 'Item A', done: false }, - { name: 'Item B', done: true }, - { name: 'Item C', done: false }, - ], -}); -``` - - - - -Define a spec with `repeat` pointing to the array path: - -```typescript -const spec = { - type: 'list', - repeat: '/items', - children: [ - { type: 'text', props: { content: { bind: 'name' } } }, - { type: 'checkbox', props: { checked: { bind: 'done' } } }, - ], -}; -``` - - - - -Inside repeated components, inject RepeatScope for iteration context: - -```typescript -const scope = inject(RepeatScope); -const item = scope.repeatItem; // current item -const index = scope.repeatIndex; // zero-based index -const basePath = scope.repeatBasePath; // e.g. '/items/0' -``` - - - - -Modify the array in the store to add or remove items: - -```typescript -// Add an item -store.update((draft) => { - draft.items.push({ name: 'New Item', done: false }); -}); - -// Remove an item by index -store.update((draft) => { - draft.items.splice(index, 1); -}); -``` - - - - -Configure `provideAgent()` in your app config, then call `injectAgent()` to -receive repeat specs from the agent: - -```typescript -// app.config.ts -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - }), - ], -}; -``` - -```typescript -// app.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -protected readonly stream = injectAgent(); -``` - - - - - -repeatBasePath gives you the JSON Pointer for the current item (e.g. `/items/2`), -so child bindings can use relative paths within each iteration. - diff --git a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts index b354cc185..5ff8d1bab 100644 --- a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +++ b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts @@ -38,6 +38,7 @@ class DemoTextComponent { readonly loading = input(false); } +// #region child-rendering @Component({ selector: 'demo-heading', standalone: true, @@ -63,6 +64,7 @@ class DemoHeadingComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion @Component({ selector: 'demo-badge', @@ -272,6 +274,7 @@ class DemoCardComponent { +
Live Render Output
@@ -281,6 +284,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -300,12 +304,14 @@ class DemoCardComponent { `, }) export class SpecRenderingComponent implements OnDestroy { + // #region streaming-source protected readonly specs = SPEC_RENDERING_SPECS; protected activeIndex = 0; protected readonly simulator = new StreamingSimulator(this.specs[0].json); protected readonly jsonTokens = computed(() => highlightJson(this.simulator.rawJson())); + // #endregion private readonly jsonScroll = viewChild>('jsonScroll'); @@ -321,6 +327,7 @@ export class SpecRenderingComponent implements OnDestroy { }); } + // #region registry-and-store protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, Heading: DemoHeadingComponent, @@ -329,6 +336,7 @@ export class SpecRenderingComponent implements OnDestroy { }); protected readonly store = signalStateStore({}); + // #endregion protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/spec-rendering/python/docs/guide.md b/cockpit/render/spec-rendering/python/docs/guide.md deleted file mode 100644 index 93075fbfd..000000000 --- a/cockpit/render/spec-rendering/python/docs/guide.md +++ /dev/null @@ -1,102 +0,0 @@ -# Spec Rendering with @threadplane/render - - -Render Angular components from JSON specifications using RenderSpecComponent. -The component recursively resolves element types from a registry and renders -them with reactive prop bindings. - - - -Add JSON-driven UI rendering to this Angular component using `RenderSpecComponent` -from `@threadplane/render`. Define a registry with `defineAngularRegistry()`, create -a state store with `signalStateStore()`, and pass a JSON spec to the template. - - - - - -Set up `provideRender()` in your app config with a component registry: - -```typescript -// app.config.ts -import { ApplicationConfig } from '@angular/core'; -import { provideRender } from '@threadplane/render'; -import { defineAngularRegistry } from '@threadplane/render'; -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideRender({ - registry: defineAngularRegistry({}), - }), - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - }), - ], -}; -``` - - - - -Create a spec object describing your UI layout. Each element has a `type` that -maps to a registered Angular component: - -```typescript -const spec = { - type: 'container', - props: { class: 'p-4 space-y-2' }, - children: [ - { type: 'heading', props: { text: 'Hello from a JSON spec' } }, - { type: 'text', props: { content: 'Rendered by RenderSpecComponent' } }, - ], -}; -``` - - - - -Use `` in your template to render the JSON spec: - -```html - -``` - -RenderSpecComponent recursively walks the spec tree, resolves each type -from the registry, and creates Angular components with the specified props. - - - - -Use `signalStateStore()` to create a reactive state store that your spec -can bind to: - -```typescript -import { signalStateStore } from '@threadplane/render'; - -const store = signalStateStore({ count: 0, name: '' }); -store.set('/count', 1); -store.get('/name'); // Signal -``` - - - - -With `provideAgent()` configured above, call `injectAgent()` in your component -to connect to the agent and display render specs from the conversation: - -```typescript -// app.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -protected readonly stream = injectAgent(); -``` - - - - - -RenderSpecComponent is tree-shakeable — only registered component types are included -in your bundle. - diff --git a/cockpit/render/state-management/angular/src/app/state-management.component.ts b/cockpit/render/state-management/angular/src/app/state-management.component.ts index 851fdefc7..f6eea29f7 100644 --- a/cockpit/render/state-management/angular/src/app/state-management.component.ts +++ b/cockpit/render/state-management/angular/src/app/state-management.component.ts @@ -64,6 +64,7 @@ class DemoHeadingComponent { readonly loading = input(false); } +// #region label-view @Component({ selector: 'demo-label', standalone: true, @@ -103,6 +104,7 @@ class DemoLabelComponent { readonly emit = input<(event: string) => void>(() => {}); readonly loading = input(false); } +// #endregion label-view @Component({ selector: 'demo-card', @@ -341,6 +343,7 @@ class DemoCardComponent {
+
Live Render Output
@if (simulator.spec(); as renderedSpec) { @@ -349,6 +352,7 @@ class DemoCardComponent {
Press play to start streaming…
}
+
@@ -361,6 +365,7 @@ class DemoCardComponent {
+
State Controls
@@ -380,6 +385,7 @@ class DemoCardComponent {

Edits update the state store live. Elements bound via $state react instantly.

+ @@ -409,6 +415,7 @@ export class StateManagementComponent implements OnDestroy { }); } + // #region registry-and-store protected readonly registry = defineAngularRegistry({ Text: DemoTextComponent, Heading: DemoHeadingComponent, @@ -417,7 +424,9 @@ export class StateManagementComponent implements OnDestroy { }); protected readonly store = signalStateStore({ user: { name: 'Alice', age: 30 }, settings: { theme: 'dark' } }); + // #endregion registry-and-store + // #region state-accessors protected getState(path: string): unknown { return this.store.get(path); } @@ -425,6 +434,7 @@ export class StateManagementComponent implements OnDestroy { protected setState(path: string, value: unknown): void { this.store.set(path, value); } + // #endregion state-accessors protected percent(): number { return Math.round(this.simulator.progress() * 100); diff --git a/cockpit/render/state-management/python/docs/guide.md b/cockpit/render/state-management/python/docs/guide.md deleted file mode 100644 index 1896bad6b..000000000 --- a/cockpit/render/state-management/python/docs/guide.md +++ /dev/null @@ -1,90 +0,0 @@ -# State Management with @threadplane/render - - -Manage reactive UI state using signalStateStore with JSON Pointer paths. -The store provides get/set/update methods backed by Angular Signals for -automatic UI propagation. - - - -Add reactive state management to this Angular component using -`signalStateStore()` from `@threadplane/render`. Create a store with -nested state, read values with get(), write with set(), and batch -updates with update(). - - - - - -Initialize a `signalStateStore()` with your initial state shape: - -```typescript -import { signalStateStore } from '@threadplane/render'; - -const store = signalStateStore({ - user: { name: '', age: 0 }, - settings: { theme: 'dark' }, -}); -``` - - - - -Use JSON Pointer paths to read reactive Signal values: - -```typescript -const name = store.get('/user/name'); // Signal -const theme = store.get('/settings/theme'); // Signal - -// In template: {{ name() }} -``` - - - - -Set individual values at any path: - -```typescript -store.set('/user/name', 'Alice'); -store.set('/user/age', 30); -store.set('/settings/theme', 'light'); -``` - -All Signals referencing these paths update automatically. - - - - -Apply multiple changes atomically: - -```typescript -store.update((draft) => { - draft.user.name = 'Bob'; - draft.user.age = 25; - draft.settings.theme = 'dark'; -}); -``` - - - - -Render specs can bind props to store paths: - -```typescript -const spec = { - type: 'text', - props: { content: { bind: '/user/name' } }, -}; -``` - -```html - -``` - - - - - -JSON Pointer paths follow RFC 6901. Use `/` to separate segments: -`/user/name` points to `state.user.name`. -