@tini-work/json-render
v1.0.0
Published
Spec-driven UI renderer for React. Build interfaces from JSON; render with any component pack you wire in.
Downloads
242
Readme
@tini-work/json-render
Spec-driven UI renderer for React. Build interfaces from JSON; render with any component pack you wire in.
Wraps @json-render/core + @json-render/react and ships canonical helpers (defineCatalog, defineRegistry), a provider (TiniProvider), state hooks (useTiniStore, useTiniSetState), and an AI surface (useUIStream, streamUISpec, buildSystemPrompt, buildCatalogManifest, specJsonSchema). Ships no components — pair with @tini-work/json-render-shadcn or your own component pack.
Install
pnpm add @tini-work/json-render @tini-work/json-render-shadcn @tini-work/tokens react react-domPeer deps: react@^19, react-dom@^19, @tini-work/tokens@workspace:*. Optional peer: ai (Vercel AI SDK) for server-side streamUISpec.
Quick start
import { TiniRenderer, type Spec } from '@tini-work/json-render'
import { registry } from '@tini-work/json-render-shadcn/registry'
const spec: Spec = {
root: 'card',
state: { count: 0 },
elements: {
card: { type: 'Card', children: ['header', 'content'] },
header: { type: 'CardHeader', children: ['title'] },
title: { type: 'CardTitle', children: ['titleText'] },
titleText: { type: 'Text', props: { value: 'Counter' } },
content: { type: 'CardContent', children: ['btn'] },
btn: {
type: 'Button',
children: ['btnText'],
on: { press: { action: 'increment' } },
},
btnText: { type: 'Text', props: { value: 'Increment' } },
},
}
export default function App() {
return (
<TiniRenderer
spec={spec}
registry={registry}
handlers={{
increment: (_params, setState) =>
setState((prev) => ({
...prev,
count: (prev.count as number) + 1,
})),
}}
/>
)
}Provider composition
For multi-TiniRenderer trees that share state:
import { TiniProvider, useTiniStore, useTiniSetState } from '@tini-work/json-render'
import { registry } from '@tini-work/json-render-shadcn/registry'
<TiniProvider registry={registry} initialState={{ step: 1 }} handlers={{...}}>
<Wizard />
<DebugPanel />
</TiniProvider>Inside the subtree: const state = useTiniStore().getSnapshot(). Mutate: useTiniSetState()(prev => ({ ...prev, step: 2 })).
State + actions
- Spec carries seed state under
spec.state(RFC-6901 pointer-flat). - Read inside any prop:
"checked": { "$state": "/notifications" }. - Components emit events (declared in their
eventsarray). Wire to host handlers via"on": { "press": { "action": "increment" } }. - Handlers receive
(params, setState, state).setStateis React-style; the renderer translates updates back into the per-pointer store viabindSetState.
Known constraint: upstream @json-render/[email protected] exposes emit(event) with no payload. Form input values, sort directions, etc. cannot round-trip via $payload. Handlers must derive next state from current snapshot or read DOM directly.
AI / prompt-to-UI
Client-side hook + server helper (Vercel AI SDK):
// Client
import { useUIStream, TiniRenderer } from '@tini-work/json-render'
import { registry } from '@tini-work/json-render-shadcn/registry'
const { spec, isStreaming, send } = useUIStream({ api: '/api/ui' })
return (
<>
<button onClick={() => send('a settings panel')}>Generate</button>
{spec && <TiniRenderer spec={spec} registry={registry} />}
</>
)// Server (e.g. Next.js route handler)
import { streamUISpec } from '@tini-work/json-render'
import { catalog } from '@tini-work/json-render-shadcn/catalog'
import { anthropic } from '@ai-sdk/anthropic'
export async function POST(req: Request) {
const { prompt } = await req.json()
return streamUISpec({
model: anthropic('claude-haiku-4-5'),
catalog,
prompt,
})
}Custom catalogs
Build your own component pack via the public helpers:
import { defineCatalog, defineRegistry } from '@tini-work/json-render'
import { z } from 'zod'
export const catalog = defineCatalog(z.unknown(), {
components: {
MyButton: {
description: 'A custom button',
props: z.object({ label: z.string() }),
slots: ['default'],
events: ['press'],
},
},
})
export const registry = defineRegistry({
components: {
MyButton: ({ props, emit }) => (
<button onClick={() => emit('press')}>{String(props.label ?? '')}</button>
),
},
})Pass registry to TiniRenderer. Pass catalog to streamUISpec / buildSystemPrompt to ground the LLM.
Public exports
| Module | Exports |
|---|---|
| . (root) | TiniRenderer, TiniProvider, useTiniStore, useTiniSetState, useUIStream, streamUISpec, defineCatalog, defineRegistry, cn, bindSetState, flattenToPointers, types |
| ./ai | buildSystemPrompt, buildCatalogManifest, specJsonSchema, types |
Architecture
src/TiniRenderer.tsx— wrapsJSONUIProvider+ upstreamRenderer+bindSetStateshim.src/TiniProvider.tsx— explicit provider for multi-renderer composition.src/hooks.ts—useTiniStore,useTiniSetState.src/useUIStream.ts— client hook for prompt-driven streams.src/streamUISpec.ts— server helper forai-backed structured generation.src/ai.ts— catalog-agnosticbuildSystemPrompt,buildCatalogManifest,specJsonSchema.src/lib/define-catalog.ts,define-registry.ts— public builder helpers.src/lib/{cn,bindSetState,flattenToPointers,adapt-registry,catalog-entry}.ts— shared utilities.
