= ({ props }) =>
+ h('div', { class: 'color-base' }, [renderNode(props.data, null, props.defaultExpanded !== false)])
diff --git a/packages/json-render-ui/src/components/_error.ts b/packages/json-render-ui/src/components/_error.ts
new file mode 100644
index 00000000..35b5147d
--- /dev/null
+++ b/packages/json-render-ui/src/components/_error.ts
@@ -0,0 +1,13 @@
+import type { JrComponent } from './_shared'
+import { h } from 'vue'
+
+/**
+ * Internal placeholder rendered in place of an element whose props failed
+ * render-time validation, so one bad element is isolated rather than breaking
+ * the whole view. Not part of the public catalog.
+ */
+export const JsonRenderError: JrComponent<{ message?: string }> = ({ props }) =>
+ h('div', {
+ class: 'rounded border border-red bg-red:10 color-red text-xs font-mono px2 py1',
+ role: 'alert',
+ }, props.message ?? 'Invalid element')
diff --git a/packages/json-render-ui/src/components/_shared.ts b/packages/json-render-ui/src/components/_shared.ts
new file mode 100644
index 00000000..3673540b
--- /dev/null
+++ b/packages/json-render-ui/src/components/_shared.ts
@@ -0,0 +1,27 @@
+import type { BaseComponentProps } from '@json-render/vue'
+import type { VNodeChild } from 'vue'
+
+/**
+ * A base-catalog component render function. Receives the upstream
+ * {@link BaseComponentProps} contract (`props`, `children`, `emit`, `on`,
+ * `bindings`, `loading`) and returns Vue VNodes. Ported components are plain
+ * functions so they need no SFC compiler and can be imported individually.
+ */
+export type JrComponent> = (
+ ctx: BaseComponentProps
,
+) => VNodeChild
+
+/** Resolve a numeric prop that may arrive as a number or numeric string. */
+export function toNumber(value: unknown, fallback: number): number {
+ const n = typeof value === 'string' ? Number(value) : value
+ return typeof n === 'number' && Number.isFinite(n) ? n : fallback
+}
+
+/** Render an arbitrary value as a display string for table cells. */
+export function formatValue(value: unknown): string {
+ if (value == null)
+ return ''
+ if (typeof value === 'object')
+ return JSON.stringify(value)
+ return String(value)
+}
diff --git a/packages/json-render-ui/src/components/index.ts b/packages/json-render-ui/src/components/index.ts
new file mode 100644
index 00000000..c9baa6bd
--- /dev/null
+++ b/packages/json-render-ui/src/components/index.ts
@@ -0,0 +1,16 @@
+export type { JrComponent } from './_shared'
+
+export { Badge } from './Badge'
+export { Button } from './Button'
+export { Card } from './Card'
+export { CodeBlock } from './CodeBlock'
+export { DataTable } from './DataTable'
+export { Divider } from './Divider'
+export { Icon } from './Icon'
+export { KeyValueTable } from './KeyValueTable'
+export { Progress } from './Progress'
+export { Stack } from './Stack'
+export { Switch } from './Switch'
+export { Text } from './Text'
+export { TextInput } from './TextInput'
+export { Tree } from './Tree'
diff --git a/packages/json-render-ui/src/dock-renderer.ts b/packages/json-render-ui/src/dock-renderer.ts
new file mode 100644
index 00000000..d2d83665
--- /dev/null
+++ b/packages/json-render-ui/src/dock-renderer.ts
@@ -0,0 +1,80 @@
+import type { JsonRenderViewRef, Spec } from '@devframes/json-render'
+import type { ComponentRegistry } from '@json-render/vue'
+import type { ActionBridgeRpc } from './action-bridge'
+import { createApp, h, shallowRef } from 'vue'
+import { baseRegistry } from './registry'
+import { JsonRenderView } from './renderer'
+
+/**
+ * The mount options the hub client host hands a renderer. Declared
+ * structurally here so `@devframes/json-render-ui` needs no dependency on
+ * `@devframes/hub` — the returned factory is still assignable to the hub's
+ * `DockRenderer` at the host's registration site.
+ */
+export interface JsonRenderDockMountOptions {
+ entry: unknown
+ container: HTMLElement
+
+ context: { rpc: any }
+}
+
+/** A hub-compatible dock renderer. */
+export type JsonRenderDockRenderer = (
+ options: JsonRenderDockMountOptions,
+) => Promise<{ dispose?: () => void }>
+
+export interface JsonRenderDockRendererOptions {
+ /** Registry to render with. Defaults to the base registry. */
+ registry?: ComponentRegistry
+}
+
+/**
+ * Build a hub dock renderer for `'json-render'` entries. Register it at
+ * `createDevframeClientHost` boot:
+ *
+ * ```ts
+ * createDevframeClientHost({
+ * renderers: { 'json-render': createJsonRenderDockRenderer() },
+ * })
+ * ```
+ *
+ * It subscribes to the view's shared state (`entry.view.stateKey`), mounts a
+ * Vue app rendering {@link JsonRenderView}, and disposes cleanly — unmounting
+ * the app and unsubscribing the shared-state listener — when the dock
+ * deactivates (the client host drives that).
+ */
+export function createJsonRenderDockRenderer(
+ options: JsonRenderDockRendererOptions = {},
+): JsonRenderDockRenderer {
+ const registry = options.registry ?? baseRegistry
+ return async ({ entry, container, context }) => {
+ const view = (entry as { view: JsonRenderViewRef }).view
+ const rpc = context.rpc
+ const interactive = rpc.connectionMeta?.backend !== 'static'
+ const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
+
+ const specRef = shallowRef(state.value() as Spec | null)
+ const off = state.on('updated', () => {
+ specRef.value = state.value() as Spec | null
+ })
+
+ const app = createApp({
+ render: () => h(JsonRenderView, {
+ spec: specRef.value,
+ rpc: rpc as ActionBridgeRpc,
+ registry,
+ viewId: view.stateKey,
+ upstreamVersion: view.upstreamVersion,
+ interactive,
+ }),
+ })
+ app.mount(container)
+
+ return {
+ dispose() {
+ off()
+ app.unmount()
+ },
+ }
+ }
+}
diff --git a/packages/json-render-ui/src/index.ts b/packages/json-render-ui/src/index.ts
new file mode 100644
index 00000000..a1d45336
--- /dev/null
+++ b/packages/json-render-ui/src/index.ts
@@ -0,0 +1,14 @@
+export { createActionBridge } from './action-bridge'
+export type { ActionBridgeRpc, JsonRenderActionBridge, JsonRenderActionError } from './action-bridge'
+
+export * from './components'
+export { createJsonRenderDockRenderer } from './dock-renderer'
+export type {
+ JsonRenderDockMountOptions,
+ JsonRenderDockRenderer,
+ JsonRenderDockRendererOptions,
+} from './dock-renderer'
+
+export { baseRegistry, ERROR_COMPONENT_TYPE } from './registry'
+export { createRenderer, JsonRenderView, sanitizeSpec } from './renderer'
+export type { CreateRendererOptions } from './renderer'
diff --git a/packages/json-render-ui/src/registry.ts b/packages/json-render-ui/src/registry.ts
new file mode 100644
index 00000000..3d05fec5
--- /dev/null
+++ b/packages/json-render-ui/src/registry.ts
@@ -0,0 +1,49 @@
+import type { ComponentRegistry } from '@json-render/vue'
+import { baseCatalog } from '@devframes/json-render'
+import { defineRegistry } from '@json-render/vue'
+import {
+ Badge,
+ Button,
+ Card,
+ CodeBlock,
+ DataTable,
+ Divider,
+ Icon,
+ KeyValueTable,
+ Progress,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+ Tree,
+} from './components'
+import { JsonRenderError } from './components/_error'
+
+/** Reserved component type used to isolate an element that fails validation. */
+export const ERROR_COMPONENT_TYPE = '__jsonRenderError'
+
+/**
+ * The base Vue registry: the fourteen catalog-v1 components ported onto
+ * `@antfu/design` semantic tokens, wrapped as Vue components via upstream
+ * `defineRegistry`. A third party replaces the whole registry (there is no
+ * incremental extension in v1).
+ */
+export const baseRegistry: ComponentRegistry = defineRegistry(baseCatalog as any, {
+ components: {
+ Stack,
+ Card,
+ Text,
+ Badge,
+ Button,
+ Icon,
+ Divider,
+ TextInput,
+ Switch,
+ KeyValueTable,
+ DataTable,
+ CodeBlock,
+ Progress,
+ Tree,
+ [ERROR_COMPONENT_TYPE]: JsonRenderError,
+ } as any,
+}).registry
diff --git a/packages/json-render-ui/src/renderer.ts b/packages/json-render-ui/src/renderer.ts
new file mode 100644
index 00000000..d0c31d80
--- /dev/null
+++ b/packages/json-render-ui/src/renderer.ts
@@ -0,0 +1,150 @@
+import type { Spec } from '@devframes/json-render'
+import type { ComponentRegistry } from '@json-render/vue'
+import type { Component, PropType } from 'vue'
+import type { ActionBridgeRpc } from './action-bridge'
+import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
+import { JSONUIProvider, Renderer } from '@json-render/vue'
+import { computed, defineComponent, h, watch } from 'vue'
+import { createActionBridge } from './action-bridge'
+import { baseRegistry, ERROR_COMPONENT_TYPE } from './registry'
+
+// Upstream ships these as heavily-typed `DefineComponent`s; render them through
+// a loose alias so `h()` doesn't demand their full public-instance surface.
+
+const ProviderC = JSONUIProvider as any
+
+const RendererC = Renderer as any
+
+/**
+ * Render-time prop validation: parse every element's props against the base
+ * catalog schema and swap any element that fails for the reserved error
+ * component, so one bad element is isolated instead of breaking the view.
+ * Returns the effective spec (unchanged when everything validates).
+ */
+export function sanitizeSpec(spec: Spec): Spec {
+ let changed = false
+ const elements: Spec['elements'] = {}
+ for (const [key, element] of Object.entries(spec.elements ?? {})) {
+ const schema = basePropSchemas[element.type as keyof typeof basePropSchemas]
+ if (schema) {
+ const result = schema.safeParse(element.props ?? {})
+ if (!result.success) {
+ changed = true
+ const issues = result.error.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
+ // Browser-only render failure — keep console.* per the plan.
+ console.warn(`[@devframes/json-render-ui] invalid props on element "${key}" (${element.type}): ${issues}`)
+ elements[key] = { ...element, type: ERROR_COMPONENT_TYPE, props: { message: `${element.type}: ${issues}` } }
+ continue
+ }
+ }
+ elements[key] = element
+ }
+ return changed ? { ...spec, elements } : spec
+}
+
+const surface = 'flex items-center justify-center p4 text-sm color-faint'
+
+/**
+ * The reference renderer shell. Wires upstream `JSONUIProvider` + `Renderer`
+ * with the unrestricted {@link createActionBridge action bridge}, seeds
+ * `spec.state`, isolates invalid elements, surfaces action errors, and owns
+ * reset semantics: the provider is remounted (state reseeded) when the view
+ * identity or upstream version changes, and preserved across ordinary
+ * spec/state updates.
+ */
+export const JsonRenderView = defineComponent({
+ name: 'JsonRenderView',
+ props: {
+ spec: { type: Object as PropType, default: null },
+ rpc: { type: Object as PropType, required: true },
+ registry: { type: Object as PropType, default: () => baseRegistry },
+ viewId: { type: String, default: 'default' },
+ upstreamVersion: { type: String, default: undefined },
+ interactive: { type: Boolean, default: true },
+ loading: { type: Boolean, default: false },
+ connectionError: { type: String as PropType, default: null },
+ },
+ setup(props) {
+ const bridge = createActionBridge(props.rpc, { interactive: props.interactive })
+
+ // A renderer/upstream-version mismatch warns rather than blocking.
+ watch(
+ () => props.upstreamVersion,
+ (version) => {
+ if (version && version !== JSON_RENDER_UPSTREAM_VERSION) {
+ console.warn(
+ `[@devframes/json-render-ui] view "${props.viewId}" was authored against @json-render ${version}, `
+ + `but this renderer bundles ${JSON_RENDER_UPSTREAM_VERSION}. Rendering anyway.`,
+ )
+ }
+ },
+ { immediate: true },
+ )
+
+ // Reset the provider (reseed state) only on identity / version change.
+ const resetKey = computed(() => `${props.viewId}::${props.upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`)
+ const effectiveSpec = computed(() => (props.spec ? sanitizeSpec(props.spec) : null))
+
+ return () => {
+ if (props.loading)
+ return h('div', { class: surface }, 'Loading…')
+ if (props.connectionError)
+ return h('div', { class: `${surface} color-red` }, props.connectionError)
+ if (!props.spec)
+ return h('div', { class: surface }, 'No view to render.')
+
+ const banner = bridge.error.value
+ ? h('div', {
+ class: 'rounded border border-red bg-red:10 color-red text-xs px2 py1 mb2',
+ role: 'alert',
+ }, `Action "${bridge.error.value.action}" failed: ${String((bridge.error.value.error as Error)?.message ?? bridge.error.value.error)}`)
+ : null
+
+ const staticNote = !props.interactive
+ ? h('div', {
+ class: 'rounded border border-base bg-secondary color-faint text-xs px2 py1 mb2',
+ }, 'Interactive actions are unavailable in static output.')
+ : null
+
+ return h('div', { class: 'color-base' }, [
+ staticNote,
+ banner,
+ h(
+ ProviderC,
+ {
+ key: resetKey.value,
+ registry: props.registry,
+ handlers: bridge.handlers,
+ initialState: props.spec.state ?? {},
+ },
+ {
+ default: () => h(RendererC, { spec: effectiveSpec.value, registry: props.registry }),
+ },
+ ),
+ ])
+ }
+ },
+})
+
+/** Options for {@link createRenderer}. */
+export interface CreateRendererOptions {
+ /** Component registry to render with. Defaults to the base registry. */
+ registry?: ComponentRegistry
+}
+
+/**
+ * Create a configured renderer component bound to a registry. The returned
+ * component is {@link JsonRenderView} with the registry defaulted, so a host
+ * can `createRenderer({ registry: myRegistry })` to swap the whole registry.
+ */
+export function createRenderer(options: CreateRendererOptions = {}) {
+ const registry = options.registry ?? baseRegistry
+ return defineComponent({
+ name: 'ConfiguredJsonRenderView',
+ inheritAttrs: false,
+ setup(_props, { attrs }) {
+ // Default the registry; every other prop flows through via attrs.
+ return () => h(JsonRenderView as unknown as Component, { registry, ...attrs })
+ },
+ })
+}
diff --git a/packages/json-render-ui/src/shims.d.ts b/packages/json-render-ui/src/shims.d.ts
new file mode 100644
index 00000000..dce12e40
--- /dev/null
+++ b/packages/json-render-ui/src/shims.d.ts
@@ -0,0 +1,9 @@
+// `@antfu/design` ships its components as raw `.vue` source (consumed and
+// compiled by the consumer's Vite / @vitejs/plugin-vue). This ambient shim
+// lets `tsc` / the dts build resolve those imports to a Vue component type.
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+
+ const component: DefineComponent
+ export default component
+}
diff --git a/packages/json-render-ui/test/action-bridge.test.ts b/packages/json-render-ui/test/action-bridge.test.ts
new file mode 100644
index 00000000..c63beab2
--- /dev/null
+++ b/packages/json-render-ui/test/action-bridge.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createActionBridge } from '../src/action-bridge'
+
+describe('action bridge', () => {
+ it('dispatches any action name as an RPC call of the same name', async () => {
+ const call = vi.fn(async () => 'ok')
+ const bridge = createActionBridge({ call })
+ const result = await bridge.handlers.refreshData({ id: 1 })
+ expect(call).toHaveBeenCalledWith('refreshData', { id: 1 })
+ expect(result).toBe('ok')
+ })
+
+ it('does not shadow upstream built-ins', () => {
+ const bridge = createActionBridge({ call: async () => undefined })
+ expect(bridge.handlers.setState).toBeUndefined()
+ expect(bridge.handlers.pushState).toBeUndefined()
+ expect(bridge.handlers.validateForm).toBeUndefined()
+ expect((bridge.handlers as any).then).toBeUndefined()
+ })
+
+ it('tracks per-action loading state', async () => {
+ let resolve!: () => void
+ const call = vi.fn(() => new Promise((r) => {
+ resolve = r
+ }))
+ const bridge = createActionBridge({ call })
+ const p = bridge.handlers.slow()
+ expect(bridge.loading.slow).toBe(true)
+ resolve()
+ await p
+ expect(bridge.loading.slow).toBe(false)
+ })
+
+ it('surfaces and rethrows RPC failures', async () => {
+ const err = new Error('boom')
+ const bridge = createActionBridge({
+ call: async () => {
+ throw err
+ },
+ })
+ await expect(bridge.handlers.explode()).rejects.toThrow('boom')
+ expect(bridge.error.value).toEqual({ action: 'explode', error: err })
+ })
+
+ it('rejects with an unavailable error in static (non-interactive) output', async () => {
+ const call = vi.fn()
+ const bridge = createActionBridge({ call }, { interactive: false })
+ await expect(bridge.handlers.doThing()).rejects.toThrow(/unavailable in static output/)
+ expect(call).not.toHaveBeenCalled()
+ expect(bridge.error.value?.action).toBe('doThing')
+ })
+})
diff --git a/packages/json-render-ui/test/renderer.test.ts b/packages/json-render-ui/test/renderer.test.ts
new file mode 100644
index 00000000..de1428e4
--- /dev/null
+++ b/packages/json-render-ui/test/renderer.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { ERROR_COMPONENT_TYPE } from '../src/registry'
+import { sanitizeSpec } from '../src/renderer'
+
+describe('sanitizeSpec (render-time validation)', () => {
+ it('leaves a valid spec unchanged', () => {
+ const spec = {
+ root: 'a',
+ elements: { a: { type: 'Button', props: { label: 'Go', variant: 'primary' }, children: [] } },
+ }
+ expect(sanitizeSpec(spec)).toBe(spec)
+ })
+
+ it('isolates an element with invalid props behind the error component', () => {
+ const spec = {
+ root: 'a',
+ elements: {
+ a: { type: 'Button', props: { variant: 'nope' }, children: [] },
+ b: { type: 'Text', props: { text: 'ok' }, children: [] },
+ },
+ }
+ const result = sanitizeSpec(spec)
+ expect(result).not.toBe(spec)
+ expect(result.elements.a.type).toBe(ERROR_COMPONENT_TYPE)
+ expect(result.elements.b.type).toBe('Text')
+ })
+})
diff --git a/packages/json-render-ui/tsconfig.json b/packages/json-render-ui/tsconfig.json
new file mode 100644
index 00000000..9284a685
--- /dev/null
+++ b/packages/json-render-ui/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["esnext", "dom"],
+ "types": ["node"]
+ },
+ "include": ["src", "test", "uno.config.ts", "tsdown.config.ts"],
+ "exclude": ["dist", "node_modules"]
+}
diff --git a/packages/json-render-ui/tsdown.config.ts b/packages/json-render-ui/tsdown.config.ts
new file mode 100644
index 00000000..0883b81e
--- /dev/null
+++ b/packages/json-render-ui/tsdown.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'tsdown'
+
+// Browser-only library. Vue and the protocol package are peers, so they stay
+// external (the consuming app / hub host provides them). Components are plain
+// `ComponentFn` render functions in `.ts`, so no SFC compiler is needed.
+export default defineConfig({
+ entry: {
+ 'index': 'src/index.ts',
+ 'components/index': 'src/components/index.ts',
+ },
+ outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
+ clean: true,
+ tsconfig: '../../tsconfig.base.json',
+ dts: true,
+ platform: 'browser',
+ deps: {
+ // Keep peers external; `@antfu/design` ships `.vue` source that the
+ // consumer's Vite (with @vitejs/plugin-vue) compiles, so it must not be
+ // bundled/parsed here.
+ neverBundle: ['vue', '@antfu/design', /^@antfu\/design\//, '@devframes/json-render', '@devframes/json-render/core'],
+ },
+})
diff --git a/packages/json-render-ui/uno.config.ts b/packages/json-render-ui/uno.config.ts
new file mode 100644
index 00000000..f66b064e
--- /dev/null
+++ b/packages/json-render-ui/uno.config.ts
@@ -0,0 +1,43 @@
+import { presetAnthonyDesign } from '@antfu/design/unocss'
+import {
+ defineConfig,
+ presetIcons,
+ presetWebFonts,
+ presetWind4,
+ transformerDirectives,
+ transformerVariantGroup,
+} from 'unocss'
+
+// The reference frontend uses `@antfu/design` directly: its preset (tuned to
+// devframe's sage green) over a Wind4 base, Phosphor icons, DM Sans/Mono and
+// the directive/variant-group transformers. Component class strings are
+// authored in `.ts` render functions, so `.ts` is opted into extraction. The
+// named `z-*` layers are the app's to own (the preset blocks plain `z-`).
+export default defineConfig({
+ presets: [
+ presetAnthonyDesign({ primary: '#3a6a45' }),
+ presetWind4(),
+ presetIcons({ scale: 1.1 }),
+ presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
+ ],
+ transformers: [transformerDirectives(), transformerVariantGroup()],
+ preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
+ // `Badge` picks a `badge-color-` at runtime from a fixed set, so those
+ // classes can't be found by static extraction — safelist them.
+ safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue'],
+ shortcuts: {
+ 'z-nav': 'z-[30]',
+ 'z-dropdown': 'z-[40]',
+ 'z-tooltip': 'z-[45]',
+ 'z-toast': 'z-[50]',
+ 'z-modal-backdrop': 'z-[60]',
+ 'z-modal-content': 'z-[70]',
+ 'z-drawer-backdrop': 'z-[80]',
+ 'z-drawer-content': 'z-[90]',
+ },
+ content: {
+ pipeline: {
+ include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/],
+ },
+ },
+})
diff --git a/packages/json-render-ui/vitest.config.ts b/packages/json-render-ui/vitest.config.ts
new file mode 100644
index 00000000..ec76195d
--- /dev/null
+++ b/packages/json-render-ui/vitest.config.ts
@@ -0,0 +1,13 @@
+import vue from '@vitejs/plugin-vue'
+import { defineConfig } from 'vitest/config'
+import { alias } from '../../alias'
+
+// The component graph imports `@antfu/design` `.vue` SFCs, so tests need the Vue
+// plugin to parse them.
+export default defineConfig({
+ plugins: [vue()],
+ resolve: { alias },
+ test: {
+ name: '@devframes/json-render-ui',
+ },
+})
diff --git a/packages/json-render/package.json b/packages/json-render/package.json
new file mode 100644
index 00000000..335e28d3
--- /dev/null
+++ b/packages/json-render/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@devframes/json-render",
+ "type": "module",
+ "version": "0.7.5",
+ "description": "Opt-in, framework-neutral JSON-render protocol layer for devframe — spec/catalog types, base catalog, and the node runtime factory.",
+ "author": "Anthony Fu ",
+ "license": "MIT",
+ "homepage": "https://github.com/devframes/devframe#readme",
+ "repository": {
+ "directory": "packages/json-render",
+ "type": "git",
+ "url": "git+https://github.com/devframes/devframe.git"
+ },
+ "bugs": "https://github.com/devframes/devframe/issues",
+ "keywords": [
+ "devtools",
+ "devframe",
+ "json-render"
+ ],
+ "sideEffects": false,
+ "exports": {
+ ".": "./dist/index.mjs",
+ "./core": "./dist/core.mjs",
+ "./hub": "./dist/hub.mjs",
+ "./node": "./dist/node/index.mjs",
+ "./package.json": "./package.json"
+ },
+ "types": "./dist/index.d.mts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsdown",
+ "watch": "tsdown --watch",
+ "typecheck": "tsc --noEmit",
+ "prepack": "pnpm run build"
+ },
+ "peerDependencies": {
+ "@devframes/hub": "workspace:*",
+ "devframe": "workspace:*"
+ },
+ "peerDependenciesMeta": {
+ "@devframes/hub": {
+ "optional": true
+ }
+ },
+ "dependencies": {
+ "@json-render/core": "catalog:deps",
+ "nostics": "catalog:deps",
+ "zod": "catalog:deps"
+ },
+ "devDependencies": {
+ "@devframes/hub": "workspace:*",
+ "@types/node": "catalog:types",
+ "devframe": "workspace:*",
+ "tsdown": "catalog:build"
+ }
+}
diff --git a/packages/json-render/src/catalog.ts b/packages/json-render/src/catalog.ts
new file mode 100644
index 00000000..55990ee1
--- /dev/null
+++ b/packages/json-render/src/catalog.ts
@@ -0,0 +1,64 @@
+import { defineCatalog, defineSchema } from '@json-render/core'
+import { basePropSchemas } from './prop-schemas'
+
+/**
+ * The Devframes base-catalog schema. A Devframes spec is an
+ * `@json-render/core` `Spec` (flat `root` + `elements` map); the structural
+ * fields upstream's Vue schema omits (`state`, `on`, `repeat`, `watch`) pass
+ * through unchecked. The one validation Devframes adds is per-component prop
+ * validation (see {@link basePropSchemas}).
+ */
+export const baseSchema = defineSchema(s => ({
+ spec: s.object({
+ root: s.string(),
+ elements: s.record(s.object({
+ type: s.ref('catalog.components'),
+ props: s.propsOf('catalog.components'),
+ children: s.array(s.string()),
+ })),
+ }),
+ catalog: s.object({
+ components: s.map({
+ props: s.zod(),
+ description: s.string(),
+ }),
+ actions: s.map({
+ description: s.string(),
+ }),
+ }),
+}))
+
+const componentDescriptions: Record = {
+ Stack: 'Flex row/column container with gap, padding, alignment and justification.',
+ Card: 'Bordered container with an optional title and collapsible body.',
+ Text: 'Typographic text — heading, subheading, body, caption or inline code.',
+ Badge: 'Small status pill with a semantic variant.',
+ Button: 'Clickable button with a variant, optional icon and loading state.',
+ Icon: 'Renders an icon resolved by name at runtime.',
+ Divider: 'Horizontal rule with an optional centered label.',
+ TextInput: 'Single-line text input with two-way state binding.',
+ Switch: 'Accessible on/off toggle bound to a boolean state value.',
+ KeyValueTable: 'Two-column table of key/value pairs.',
+ DataTable: 'Tabular data with columns, rows and optional scroll height.',
+ CodeBlock: 'Preformatted code block with a filename and language label.',
+ Progress: 'Determinate progress bar.',
+ Tree: 'Recursive object/array viewer with expandable nodes.',
+}
+
+/**
+ * The Devframes base catalog (catalog v1): the fourteen canonical components
+ * with their Devframes-authored Zod prop schemas and descriptions, and an
+ * empty action set (actions are dispatched dynamically via the bridge, they
+ * are not declared here). Reference frontend libraries implement this set.
+ */
+export const baseCatalog = defineCatalog(baseSchema, {
+ components: Object.fromEntries(
+ (Object.keys(basePropSchemas) as (keyof typeof basePropSchemas)[]).map(name => [
+ name,
+ { props: basePropSchemas[name], description: componentDescriptions[name] },
+ ]),
+ ) as {
+ [K in keyof typeof basePropSchemas]: { props: typeof basePropSchemas[K], description: string }
+ },
+ actions: {},
+})
diff --git a/packages/json-render/src/core.ts b/packages/json-render/src/core.ts
new file mode 100644
index 00000000..a399a583
--- /dev/null
+++ b/packages/json-render/src/core.ts
@@ -0,0 +1,41 @@
+/**
+ * Curated, named re-exports of `@json-render/core` — the upstream wire
+ * contract Devframes builds on. This is an **explicit** allowlist (never
+ * `export *`): every name here is a Devframes semver commitment, so the
+ * surface stays small and reviewed. Streaming, prompt/generation, and
+ * devtools hooks are deliberately excluded from the base contract (a
+ * streaming subpath may be added later, see the plan).
+ *
+ * @see https://www.npmjs.com/package/@json-render/core
+ */
+
+// ── Builders (values) ────────────────────────────────────────────────────
+export {
+ createStateStore,
+ defineCatalog,
+ defineSchema,
+} from '@json-render/core'
+
+// ── Spec / element / state types ─────────────────────────────────────────
+export type {
+ Spec,
+ StateModel,
+ StateStore,
+ UIElement,
+} from '@json-render/core'
+
+// ── Catalog & schema typing (inference helpers) ──────────────────────────
+export type {
+ Catalog,
+ InferActionParams,
+ InferCatalogActions,
+ InferCatalogComponents,
+ InferCatalogInput,
+ InferComponentProps,
+ InferSpec,
+ Schema,
+ SchemaBuilder,
+ SchemaDefinition,
+ SchemaOptions,
+ SchemaType,
+} from '@json-render/core'
diff --git a/packages/json-render/src/hub.ts b/packages/json-render/src/hub.ts
new file mode 100644
index 00000000..81f7a71f
--- /dev/null
+++ b/packages/json-render/src/hub.ts
@@ -0,0 +1,36 @@
+import type { DevframeDockEntryBase } from '@devframes/hub/types'
+import type { JsonRenderView } from './types'
+import type { JsonRenderViewRef } from './view-ref'
+
+/**
+ * A `json-render` dock entry. Contributed to the hub's **open** dock union
+ * (`DevframeDockEntryRegistry`) by this opt-in integration — the hub itself
+ * hard-codes no json-render variant. Carries only the serializable
+ * {@link JsonRenderViewRef}; no functions cross the wire.
+ */
+export interface DevframeJsonRenderDockEntry extends DevframeDockEntryBase {
+ type: 'json-render'
+ /** Serializable reference the client subscribes through. */
+ view: JsonRenderViewRef
+}
+
+// Contribute the `json-render` variant to the hub's open dock registry. This
+// augmentation only loads when a consumer imports `@devframes/json-render/hub`
+// (the hub-mounted path), so a standalone app never pulls a hub type.
+declare module '@devframes/hub/types' {
+ interface DevframeDockEntryRegistry {
+ 'json-render': DevframeJsonRenderDockEntry
+ }
+}
+
+/**
+ * Build a `json-render` dock entry from a {@link JsonRenderView} and the dock
+ * metadata (id, title, icon, …). Projects the view down to its serializable
+ * {@link JsonRenderViewRef} so it survives dock projection into shared state.
+ */
+export function toJsonRenderDockEntry(
+ view: JsonRenderView,
+ meta: Omit,
+): DevframeJsonRenderDockEntry {
+ return { ...meta, type: 'json-render', view: view.ref }
+}
diff --git a/packages/json-render/src/index.ts b/packages/json-render/src/index.ts
new file mode 100644
index 00000000..287c098e
--- /dev/null
+++ b/packages/json-render/src/index.ts
@@ -0,0 +1,39 @@
+// ── Base catalog + Devframes-authored prop schemas ───────────────────────
+export { baseCatalog, baseSchema } from './catalog'
+// ── Re-exported upstream protocol types (from the curated `./core` list) ──
+export type {
+ Catalog,
+ InferComponentProps,
+ Spec,
+ StateModel,
+ StateStore,
+ UIElement,
+} from './core'
+
+export type { BaseComponentName } from './prop-schemas'
+
+export {
+ BadgePropsSchema,
+ baseComponentNames,
+ basePropSchemas,
+ ButtonPropsSchema,
+ CardPropsSchema,
+ CodeBlockPropsSchema,
+ DataTablePropsSchema,
+ DividerPropsSchema,
+ IconPropsSchema,
+ KeyValueTablePropsSchema,
+ ProgressPropsSchema,
+ StackPropsSchema,
+ SwitchPropsSchema,
+ TextInputPropsSchema,
+ TextPropsSchema,
+ TreePropsSchema,
+} from './prop-schemas'
+
+// ── Devframes-facing type names ──────────────────────────────────────────
+export type { DevframeJsonRenderSpec, JsonRenderView } from './types'
+// ── Serializable view reference ──────────────────────────────────────────
+export { JSON_RENDER_UPSTREAM_VERSION } from './view-ref'
+
+export type { JsonRenderViewRef } from './view-ref'
diff --git a/packages/json-render/src/node/create-view.ts b/packages/json-render/src/node/create-view.ts
new file mode 100644
index 00000000..6d83315b
--- /dev/null
+++ b/packages/json-render/src/node/create-view.ts
@@ -0,0 +1,164 @@
+import type { DevframeNodeContext, DevframeScopedNodeContext } from 'devframe/types'
+import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
+import type { DevframeJsonRenderSpec, JsonRenderStatePatch, JsonRenderView } from '../types'
+import { createSharedState } from 'devframe/utils/shared-state'
+import { basePropSchemas } from '../prop-schemas'
+import { JSON_RENDER_UPSTREAM_VERSION } from '../view-ref'
+import { diagnostics } from './diagnostics'
+
+/** Options for {@link createJsonRenderView}. */
+export interface CreateJsonRenderViewOptions {
+ /**
+ * Stable, author-supplied id, unique within the view's scope. Forms the
+ * shared-state key `devframe:json-render::` and never changes
+ * across updates, so a client keeps its subscription across reconnects.
+ */
+ id: string
+ /** The initial spec. */
+ spec: DevframeJsonRenderSpec
+ /**
+ * Override the scope segment of the view's stable id. Defaults to the
+ * context's namespace when created from a scoped context, otherwise
+ * `'global'`.
+ */
+ scope?: string
+}
+
+type AnyContext = DevframeNodeContext | DevframeScopedNodeContext
+
+function isScoped(ctx: AnyContext): ctx is DevframeScopedNodeContext {
+ return 'base' in ctx && 'namespace' in ctx
+}
+
+// One registry of live view keys per base context, so a duplicate id within a
+// scope is caught deterministically (not left to shared-state get() returning
+// the pre-existing entry).
+const registries = new WeakMap