|
| 1 | +# Plan: json-render as an iframe view provider — one renderer for every UI |
| 2 | + |
| 3 | +> Plan of record settled in a design interview on 2026-08-10. Capture only — |
| 4 | +> **not yet implemented**. Implementation lands as a staged GitHub stack |
| 5 | +> (bottom → top), each PR passing the full gauntlet |
| 6 | +> (`pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build`). |
| 7 | +> |
| 8 | +> Supersedes the first draft of this file (which had hub-ui reuse |
| 9 | +> `@devframes/json-render-ui` as an **in-process Vue `DockRenderer`**). The |
| 10 | +> interview pivoted to an **iframe-hosted view-provider** model, which decouples |
| 11 | +> json-render from the hub UI's framework entirely. |
| 12 | +
|
| 13 | +## Goal |
| 14 | + |
| 15 | +Make "choose or build your own UI" coherent and make json-render + the hub UI |
| 16 | +**compose** instead of fuse. Deliver json-render as a **swappable, framework- |
| 17 | +agnostic view provider rendered in an iframe**: a hub maps a dock *view type* |
| 18 | +(e.g. `json-render`) to a provider SPA; the hub UI renders that type as an |
| 19 | +iframe pointing at the provider; when no provider is registered for a type, the |
| 20 | +UI shows an explicit empty state. The official provider is |
| 21 | +`@devframes/json-render-ui`; community implementations swap it. |
| 22 | + |
| 23 | +**The crux win:** json-render is implemented **once** (json-render-ui's SPA) and |
| 24 | +reused by *every* hub UI — Vue, React, vanilla — via an iframe. The current |
| 25 | +triplication (json-render-ui Vue + hub-ui's inline Vue copy + the Next example's |
| 26 | +React port) collapses to a single implementation. |
| 27 | + |
| 28 | +## Current reality (why this matters) |
| 29 | + |
| 30 | +- `@devframes/json-render` — framework-neutral **core**: catalog/schema (the 14 |
| 31 | + catalog-v1 components, `packages/json-render/src/prop-schemas.ts:131`), the |
| 32 | + node factory `createJsonRenderView` (publishes a spec to a shared-state |
| 33 | + `stateKey` + a `JSON_RENDER_INDEX_KEY` entry, |
| 34 | + `packages/json-render/src/node/create-view.ts:131`), and the `/hub` projection |
| 35 | + `DevframeJsonRenderDockEntry` carrying only a **serializable** |
| 36 | + `JsonRenderViewRef = { stateKey } | { spec }` (`packages/json-render/src/hub.ts:11`, |
| 37 | + `view-ref.ts:9`). |
| 38 | +- `@devframes/json-render-ui` — the official **Vue** renderer. Already ships a |
| 39 | + standalone **SPA** (`jsonRenderSpaDir` + `createJsonRenderDevframe`, |
| 40 | + `packages/json-render-ui/src/spa.ts:17`) that `connectDevframe()`s and reads |
| 41 | + specs from shared state — but as a **multi-view dashboard** driven by |
| 42 | + `JSON_RENDER_INDEX_KEY` (`packages/json-render-ui/src/spa/main.ts:31`), not a |
| 43 | + single-view renderer pointed at one dock. Also ships an in-process |
| 44 | + `createJsonRenderDockRenderer` (`dock-renderer.ts:14`). |
| 45 | +- `@devframes/hub-ui` — the reference viewer. Carries its **own inline Vue |
| 46 | + json-render** copy and renders json-render via a hardcoded `ViewEntry.vue` |
| 47 | + switch → `ViewJsonRender.vue`, on the **deprecated** `entry.ui._stateKey`. |
| 48 | +- `examples/next-devframe-hub` — a **third** (React) port of the catalog. |
| 49 | + |
| 50 | +**Machinery we reuse (already exists):** |
| 51 | + |
| 52 | +- A **mounted devframe is already an iframe dock**: `mountDevframe` serves an SPA |
| 53 | + at `<base><id>/` + the shared connection meta, then auto-registers |
| 54 | + `{ type:'iframe', url: base }` (`packages/hub/src/node/mount-devframe.ts:91,103`). |
| 55 | + A "provider SPA" is just a mounted devframe. |
| 56 | +- **iframe-pane pooling** keys live iframes by `frameId` and preserves their |
| 57 | + state across tab switches (`packages/hub-ui/src/client/components/views/ViewIframe.vue:74,204`). |
| 58 | +- An **iframe SPA under the hub base reads the same shared state / RPC** (one |
| 59 | + socket, relative `./__connection.json`), so a `stateKey` reaches it |
| 60 | + (`packages/json-render-ui/src/spa/main.ts:25,68`). |
| 61 | +- **Cross-frame dark/light** already propagates via the `devframes-color-scheme` |
| 62 | + localStorage key (`packages/hub-ui/src/client/state/color-mode.ts:11`), and the |
| 63 | + brand primary is published as `branding.json` (the branding feature, PR #177). |
| 64 | + |
| 65 | +**Gaps this plan fills:** there is no view-type → provider-URL registry, no |
| 66 | +single-view mode on the provider SPA, no inline-spec→iframe path, and no |
| 67 | +"no provider" placeholder — the only current detection is a `console.warn` in |
| 68 | +the in-process renderer seam (`packages/hub/src/client/host.ts:453`). |
| 69 | + |
| 70 | +## Decisions (design interview, 2026-08-10) |
| 71 | + |
| 72 | +1. **Scope**: agree architecture + staged plan; capture as this doc; implement |
| 73 | + later. |
| 74 | +2. **General mechanism**: a **view-type → iframe-provider** mapping (any dock view |
| 75 | + type can resolve to a swappable provider SPA), with `json-render` as the |
| 76 | + first/official one. |
| 77 | +3. **Iframe is the model**: json-render renders as an iframe provider in hub-ui |
| 78 | + and the examples; the in-process `createJsonRenderDockRenderer` is |
| 79 | + **deprecated**. The hub's in-process `renderers` seam **stays** for niche/ |
| 80 | + custom in-process types and `custom-render` — just not for json-render. |
| 81 | +4. **Registration**: node-side `initHub({ viewProviders })` mounts each provider |
| 82 | + SPA (via the existing `mountDevframe` path) and maps view-type → its base URL. |
| 83 | + Headless core: `@devframes/hub` takes **no** json-render-ui dependency; the |
| 84 | + app/reference setup wires the official provider. Swap by passing a different |
| 85 | + provider; omit it → placeholder. |
| 86 | +5. **Provider descriptor**: a **mountable devframe SPA** (default = json-render-ui's |
| 87 | + SPA). Off-origin/hosted providers work through the existing iframe `remote` |
| 88 | + option as a documented extension, not the v1 focus. |
| 89 | +6. **Discovery**: the hub publishes the view-type → base-URL map as a **read-only |
| 90 | + shared-state key** (`devframe:view-providers`), seeded node-side, read by the |
| 91 | + client pre-mount (like `devframe:docks`). Works on both `initHub` and the |
| 92 | + manual `createHubContext` path; needs no `__index.json` fetch. Absent entry = |
| 93 | + placeholder. |
| 94 | +7. **View ref → iframe**: the provider SPA gains a **single-view mode** |
| 95 | + (`?view=<stateKey>`) rendering exactly that view from shared state. Entries |
| 96 | + with an inline `spec` are **materialized by the client resolver into an |
| 97 | + ephemeral shared-state key** (disposed on unmount), so the iframe always |
| 98 | + receives a uniform `stateKey`. |
| 99 | +8. **Frame model**: **one pooled iframe per view**, `frameId = provider + stateKey`, |
| 100 | + `src = <providerBase>?view=<stateKey>` — reuses iframe-pane pooling (state |
| 101 | + preserved, reused on reselect). No host↔iframe nav protocol. |
| 102 | +9. **Placeholder**: hub-ui renders a **built-in empty state** naming the missing |
| 103 | + type ("This host has no `json-render` view provider registered", with a hint to |
| 104 | + register `@devframes/json-render-ui`), detected from the empty |
| 105 | + `devframe:view-providers` entry. No iframe mounted. |
| 106 | +10. **Theming the provider iframe**: the provider SPA follows the host via the |
| 107 | + shared same-origin channels — dark/light from `devframes-color-scheme` |
| 108 | + storage, brand primary by fetching the hub's `branding.json` and applying |
| 109 | + `--devframe-primary`. No new protocol. |
| 110 | +11. **Catalog growth**: promote hub-ui's extra `Tabs`/`Link`/`Select` into the |
| 111 | + base catalog (additive; port hub-ui's prop shapes) so the one provider renders |
| 112 | + all 17. (Carried over from the prior interview.) |
| 113 | +12. **UI selection**: `initHub({ ui })` remains the swap point for the hub UI |
| 114 | + itself; `viewProviders` is the swap point for renderers. Renderer/provider |
| 115 | + packages stay hub-agnostic; document the contracts. No runtime multi-UI |
| 116 | + registry. |
| 117 | + |
| 118 | +## Target architecture |
| 119 | + |
| 120 | +### Package topology |
| 121 | + |
| 122 | +| Package | Role | |
| 123 | +|---|---| |
| 124 | +| `@devframes/json-render` | Framework-neutral **core** — catalog/schema (**17**), node `createJsonRenderView` (publishes `stateKey` + index), `/hub` projection (`DevframeJsonRenderDockEntry` with `view: JsonRenderViewRef`). | |
| 125 | +| `@devframes/json-render-ui` | The **one** renderer, shipped primarily as a **provider SPA** (iframe): `jsonRenderSpaDir` + a `jsonRenderProvider()` factory for `viewProviders`, a **single-view mode** (`?view=<stateKey>`), and `branding.json` theming. The Vue registry/components power the SPA. In-process `createJsonRenderDockRenderer` is **deprecated**. | |
| 126 | +| `@devframes/hub` | Headless runtime; gains the **general view-provider mechanism**: `initHub({ viewProviders })` mounts provider SPAs and publishes the read-only `devframe:view-providers` shared-state map. Keeps the in-process `renderers` seam for custom in-process types. No json-render-ui dependency. | |
| 127 | +| `@devframes/hub-ui` | Reference viewer. Resolves a provider-backed dock's type → base URL, renders it as a **pooled iframe** (`frameId = provider+stateKey`, `?view=<stateKey>`), materializes inline specs to an ephemeral key, and shows the **no-provider empty state**. No inline json-render, no json-render-ui dependency, no `--jr-primary` bridge. | |
| 128 | +| examples (Vite/Vue, Next/React) | BYO references — wire `initHub({ viewProviders: { 'json-render': jsonRenderProvider() } })`; the **React example drops its own json-render port** and uses the shared iframe provider. | |
| 129 | + |
| 130 | +### Composition model — "choose or build your own UI" |
| 131 | + |
| 132 | +- A **hub UI** = a node `DevframeHubUi` provider (`viewer`/`embedded`/`assets`, |
| 133 | + swapped via `initHub({ ui })`) + a client shell. |
| 134 | +- **Renderers are iframe view providers**, registered node-side per view type |
| 135 | + (`initHub({ viewProviders })`), swappable, and **framework-agnostic**: the same |
| 136 | + Vue json-render-ui SPA renders inside a Vue, React, or vanilla host because it's |
| 137 | + an iframe. The in-process `DockRenderer` seam remains for niche same-process |
| 138 | + needs but is no longer json-render's path. |
| 139 | +- **Data path**: plugin → `createJsonRenderView` (publishes spec to a `stateKey`) |
| 140 | + → dock entry `view: { stateKey }` → hub UI resolves `json-render` → provider |
| 141 | + base, mounts `<base>?view=<stateKey>` → provider SPA reads that `stateKey` off |
| 142 | + the shared socket and renders. Inline `spec` → client writes an ephemeral key |
| 143 | + first. |
| 144 | +- **No provider** for a type → hub UI empty state. |
| 145 | + |
| 146 | +## Staged PR stack |
| 147 | + |
| 148 | +Each PR independently shippable, gated by the full gauntlet; land bottom → top. |
| 149 | + |
| 150 | +### PR A — Catalog growth (core) · depends on: — |
| 151 | +Add `Tabs`/`Link`/`Select` to `@devframes/json-render` `basePropSchemas` (zod) + |
| 152 | +`componentDescriptions`, porting hub-ui's current prop shapes; they flow into |
| 153 | +`baseCatalog` automatically. Additive (old specs stay valid). Update `tsnapi` |
| 154 | +snapshots. |
| 155 | + |
| 156 | +### PR B — json-render-ui: 17 components + provider SPA · depends on: A |
| 157 | +- Implement `Tabs`/`Link`/`Select` in the SPA's registry (→ 17). |
| 158 | +- Add **single-view mode** to the SPA: `?view=<stateKey>` renders only that view |
| 159 | + from shared state (keep the index-driven dashboard for standalone use). |
| 160 | +- Add **theming**: fetch `branding.json`, apply `--devframe-primary`; dark/light |
| 161 | + via the shared `devframes-color-scheme` key. |
| 162 | +- Export **`jsonRenderProvider()`** (a `DevframeDefinition` over `jsonRenderSpaDir`, |
| 163 | + building on `createJsonRenderDevframe`) for `viewProviders`. |
| 164 | +- **Deprecate** `createJsonRenderDockRenderer`. |
| 165 | + |
| 166 | +### PR C — hub: general view-provider mechanism · depends on: — |
| 167 | +- `InitHubOptions.viewProviders?: Record<string, ViewProviderDef>` — mount each |
| 168 | + provider via the existing `mountDevframe` path and record type → base URL. |
| 169 | +- Publish a **read-only `devframe:view-providers`** shared-state map |
| 170 | + (type → `{ base }`), seeded node-side; same on the manual `createHubContext` |
| 171 | + path. Keep the in-process `renderers` seam untouched. |
| 172 | +- Document the provider contract (mountable devframe; `remote` URL extension) and |
| 173 | + the (unchanged, structurally-decoupled) `DockRenderer` contract. |
| 174 | + |
| 175 | +### PR D — hub-ui: render providers as iframes · depends on: B, C |
| 176 | +- Read `devframe:view-providers` at context init; for a provider-backed dock type |
| 177 | + (json-render + any future type), render a **pooled iframe** |
| 178 | + (`frameId = provider+stateKey`, `src = <base>?view=<stateKey>`) — via |
| 179 | + `ViewIframe` or a thin `ViewProvider.vue`. |
| 180 | +- **Inline resolver**: `view: { spec }` → write to an ephemeral shared-state key, |
| 181 | + pass that key, dispose on unmount. |
| 182 | +- **No-provider empty state** component, driven by the absent map entry. |
| 183 | +- Migrate `ViewEntry.vue` json-render branch to the provider iframe; migrate off |
| 184 | + `entry.ui._stateKey` to `entry.view`. **Delete** `src/client/json-render/`, |
| 185 | + `ViewJsonRender.vue`, the local `DevframeViewJsonRender` type, and the |
| 186 | + `--jr-primary` bridge (json-render now themes itself in its own SPA). |
| 187 | + |
| 188 | +### PR E — examples · depends on: B, C |
| 189 | +- Wire `initHub({ viewProviders: { 'json-render': jsonRenderProvider() } })` in the |
| 190 | + Vite and Next examples. |
| 191 | +- **Delete the Next example's React json-render registry + dock-renderer** — the |
| 192 | + shared iframe provider renders it. (Supersedes the prior "React example parity" |
| 193 | + decision: no React json-render implementation is needed.) |
| 194 | + |
| 195 | +### PR F — later cycle (out of scope now) · depends on: D, E |
| 196 | +Remove the deprecated hub json-render API (`DevframeViewJsonRender`, |
| 197 | +`createJsonRenderer`, `_stateKey`) and json-render-ui's deprecated |
| 198 | +`createJsonRenderDockRenderer`. Its own breaking-change cycle. |
| 199 | + |
| 200 | +## Interactions & sequencing notes |
| 201 | + |
| 202 | +- **Branding stack (#176, #177)**: PR D deletes the inline json-render and the |
| 203 | + `--jr-primary` bridge #177 added; json-render now themes via `branding.json` |
| 204 | + inside its own SPA (decision 10). Land D after #176/#177 merge (or rebase). |
| 205 | +- **Additive catalog**: PR A cannot break existing specs. |
| 206 | +- **One implementation everywhere**: after D+E, hub-ui and both examples render |
| 207 | + json-render through the same iframe provider SPA — the triplication is gone and |
| 208 | + the React port is deleted, not duplicated. |
| 209 | +- **`__index.json` avoided**: discovery rides `devframe:view-providers` shared |
| 210 | + state (decision 6), sidestepping the manual-path/`__index.json` fetch gap found |
| 211 | + during the branding work. |
| 212 | + |
| 213 | +## Non-goals |
| 214 | + |
| 215 | +- A runtime multi-UI registry or config-file UI selection (decision 12). |
| 216 | +- First-party non-Vue renderer packages — unnecessary now that any host reuses the |
| 217 | + Vue provider via iframe (decision 2/3). |
| 218 | +- Removing the in-process `renderers` seam (kept for custom in-process types, |
| 219 | + decision 3). |
| 220 | +- First-class remote/off-origin providers in v1 (documented extension only, |
| 221 | + decision 5). |
| 222 | +- Removing deprecated APIs now (PR F, later cycle). |
0 commit comments