Skip to content

Commit b089840

Browse files
committed
feat(json-render-ui): provider SPA single-view mode, theming, provider factory
Enables json-render-ui's SPA as the hub's iframe view provider: - Single-view mode: ?view=<stateKey> renders exactly one view (no nav) — the shape the hub mounts as one pooled iframe per view. The index-driven dashboard remains for standalone use. - Theming: follow the host color scheme via the shared devframes-color-scheme storage key, and the brand primary via the hub's branding.json (with a primary-ramp override so --devframe-primary retints the SPA). Best-effort: standalone use keeps the OS scheme + default palette. - jsonRenderProvider(): a turnkey DevframeDefinition serving this SPA, for initHub({ viewProviders: { 'json-render': jsonRenderProvider() } }). - Deprecate createJsonRenderDockRenderer in favor of the iframe provider.
1 parent f75d5dc commit b089840

9 files changed

Lines changed: 218 additions & 48 deletions

File tree

‎packages/json-render-ui/src/dock-renderer.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface JsonRenderDockRendererOptions {
3838
* })
3939
* ```
4040
*
41+
* @deprecated Prefer the iframe **view-provider** model: register
42+
* {@link jsonRenderProvider} with the hub (`initHub({ viewProviders: { 'json-render':
43+
* jsonRenderProvider() } })`), which renders json-render in a swappable,
44+
* framework-agnostic iframe. This in-process renderer couples the renderer to a
45+
* Vue host and is kept only for existing same-framework hosts.
46+
*
4147
* For a shared-state view (`entry.view.stateKey`) it subscribes to the live
4248
* spec and re-renders on every update; for an inline view (`entry.view.spec`)
4349
* it renders the embedded spec directly, with no shared-state round-trip — the

‎packages/json-render-ui/src/spa.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DevframeDefinition } from 'devframe/types'
2+
import { createRequire } from 'node:module'
23
import { fileURLToPath } from 'node:url'
34

45
/**
@@ -37,3 +38,35 @@ export function createJsonRenderDevframe(definition: DevframeDefinition): Devfra
3738
cli: { ...definition.cli, distDir: definition.cli?.distDir ?? jsonRenderSpaDir },
3839
}
3940
}
41+
42+
/**
43+
* A turnkey json-render **view provider** — a {@link DevframeDefinition} serving
44+
* this package's prebuilt SPA, ready to register with a hub's view-provider
45+
* slot so `json-render` docks render in an iframe:
46+
*
47+
* ```ts
48+
* import { jsonRenderProvider } from '@devframes/json-render-ui/spa'
49+
* initHub({ viewProviders: { 'json-render': jsonRenderProvider() } })
50+
* ```
51+
*
52+
* The provider frame only serves the SPA; view specs are published by other
53+
* devframes' `setup()` (via `createJsonRenderView`) into shared state the SPA
54+
* reads by `stateKey`, so its own `setup` is a no-op. Any field can be
55+
* overridden (id, name, port, a custom `distDir` for a community renderer, …).
56+
*/
57+
export function jsonRenderProvider(overrides: Partial<DevframeDefinition> = {}): DevframeDefinition {
58+
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
59+
return {
60+
id: 'json-render',
61+
name: 'JSON Render',
62+
version,
63+
packageName: '@devframes/json-render-ui',
64+
homepage: 'https://github.com/devframes/devframe#readme',
65+
description: 'Reference json-render view provider.',
66+
icon: 'ph:layout-duotone',
67+
setup: () => {},
68+
...overrides,
69+
spa: { loader: 'none', ...overrides.spa },
70+
cli: { ...overrides.cli, distDir: overrides.cli?.distDir ?? jsonRenderSpaDir },
71+
}
72+
}

‎packages/json-render-ui/src/spa/main.ts‎

Lines changed: 87 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,72 @@
11
import type { DevframeJsonRenderSpec, JsonRenderIndex, JsonRenderIndexEntry } from '@devframes/json-render'
2+
import type { DevframeRpcClient } from 'devframe/client'
3+
import type { Ref } from 'vue'
24
import type { ActionBridgeRpc } from '../action-bridge'
35
import { JSON_RENDER_INDEX_KEY } from '@devframes/json-render'
46
import { connectDevframe } from 'devframe/client'
57
import { computed, createApp, defineComponent, h, ref, shallowReactive, shallowRef, watch } from 'vue'
68
import { JsonRenderView } from '../renderer'
9+
import { applyBranding, initColorScheme } from './theme'
710
import 'virtual:uno.css'
811
import '@antfu/design/styles.css'
12+
// After uno so the brand-primary override wins (see primary-ramp.css).
13+
import './primary-ramp.css'
914

10-
// Shared design tokens flip on the `.dark` class; mirror the OS preference.
11-
const mq = window.matchMedia('(prefers-color-scheme: dark)')
12-
function applyScheme(dark: boolean): void {
13-
document.documentElement.classList.toggle('dark', dark)
14-
}
15-
applyScheme(mq.matches)
16-
mq.addEventListener('change', e => applyScheme(e.matches))
15+
// Follow the host's color scheme + brand primary (both best-effort standalone).
16+
initColorScheme()
17+
void applyBranding()
1718

1819
const surface = 'flex min-h-screen items-center justify-center color-faint text-sm'
1920

20-
async function main(): Promise<void> {
21-
const root = document.getElementById('app')
22-
if (!root)
23-
throw new Error('#app mount node missing')
24-
25-
const rpc = await connectDevframe()
26-
const interactive = rpc.connectionMeta.backend !== 'static'
27-
28-
// Discover every live view from the single view-index shared state, then
29-
// subscribe to each view's own state. The author never wires a view id into
30-
// the client — publishing a view is enough for it to appear here.
31-
const indexState = await rpc.sharedState.get<JsonRenderIndex>(JSON_RENDER_INDEX_KEY, { initialValue: {} })
32-
// Seed the current server value before mounting so an empty tree isn't
33-
// mistaken for "no views" during the first round-trip.
34-
if (interactive) {
35-
try {
36-
const value = await rpc.call('devframe:rpc:server-state:get', JSON_RENDER_INDEX_KEY)
37-
if (value && typeof value === 'object')
38-
indexState.mutate(() => value as JsonRenderIndex)
39-
}
40-
catch {
41-
// Non-fatal: fall back to live 'updated' events below.
42-
}
43-
}
21+
/** Subscribe to a view's live spec, keyed by `stateKey`. */
22+
function subscribeSpec(
23+
rpc: DevframeRpcClient,
24+
stateKey: string,
25+
into: (spec: DevframeJsonRenderSpec | null) => void,
26+
): void {
27+
void rpc.sharedState
28+
.get<DevframeJsonRenderSpec>(stateKey, { initialValue: null as unknown as DevframeJsonRenderSpec })
29+
.then((state) => {
30+
into(state.value() as DevframeJsonRenderSpec | null)
31+
state.on('updated', () => into(state.value() as DevframeJsonRenderSpec | null))
32+
})
33+
}
4434

45-
const indexRef = shallowRef<JsonRenderIndex>(indexState.value() as JsonRenderIndex)
46-
indexState.on('updated', () => {
47-
indexRef.value = indexState.value() as JsonRenderIndex
35+
/**
36+
* Single-view mode (`?view=<stateKey>`): render exactly one view, no nav — the
37+
* shape the hub's iframe view-provider mounts (one pooled iframe per view).
38+
*/
39+
function mountSingleView(root: HTMLElement, rpc: DevframeRpcClient, stateKey: string, interactive: boolean): void {
40+
const spec = shallowRef<DevframeJsonRenderSpec | null | undefined>(undefined)
41+
subscribeSpec(rpc, stateKey, s => (spec.value = s))
42+
const App = defineComponent({
43+
name: 'JsonRenderSingleView',
44+
setup() {
45+
return () => h('div', { class: 'min-h-screen bg-base color-base font-sans p6' }, [
46+
h(JsonRenderView, {
47+
spec: spec.value ?? null,
48+
rpc: rpc as unknown as ActionBridgeRpc,
49+
viewId: stateKey,
50+
interactive,
51+
loading: spec.value === undefined,
52+
}),
53+
])
54+
},
4855
})
56+
createApp(App).mount(root)
57+
}
4958

59+
/**
60+
* Dashboard mode: discover every live view from the view index and render a
61+
* segmented switcher. The standalone experience when no single view is pinned.
62+
*/
63+
function mountDashboard(root: HTMLElement, rpc: DevframeRpcClient, indexRef: Ref<JsonRenderIndex>, interactive: boolean): void {
5064
const App = defineComponent({
5165
name: 'JsonRenderSpa',
5266
setup() {
5367
const entries = computed<JsonRenderIndexEntry[]>(() =>
5468
Object.values(indexRef.value).sort((a, b) => a.title.localeCompare(b.title)))
5569

56-
// Per-view specs, keyed by stateKey. `undefined` = subscribing (loading),
57-
// `null` = subscribed with no spec yet.
5870
const specs = shallowReactive<Record<string, DevframeJsonRenderSpec | null | undefined>>({})
5971
const subscribed = new Set<string>()
6072
const active = ref<string | null>(null)
@@ -65,14 +77,7 @@ async function main(): Promise<void> {
6577
continue
6678
subscribed.add(entry.stateKey)
6779
specs[entry.stateKey] = undefined
68-
void rpc.sharedState
69-
.get<DevframeJsonRenderSpec>(entry.stateKey, { initialValue: null as unknown as DevframeJsonRenderSpec })
70-
.then((state) => {
71-
specs[entry.stateKey] = state.value() as DevframeJsonRenderSpec | null
72-
state.on('updated', () => {
73-
specs[entry.stateKey] = state.value() as DevframeJsonRenderSpec | null
74-
})
75-
})
80+
subscribeSpec(rpc, entry.stateKey, s => (specs[entry.stateKey] = s))
7681
}
7782
if (!active.value || !list.some(e => e.stateKey === active.value))
7883
active.value = list[0]?.stateKey ?? null
@@ -101,9 +106,6 @@ async function main(): Promise<void> {
101106

102107
const activeEntry = list.find(e => e.stateKey === active.value) ?? list[0]
103108
const spec = specs[activeEntry.stateKey]
104-
105-
// A single view renders on its own — no nav. The top bar (brand +
106-
// segmented view switcher) only appears once there's more than one.
107109
const multiple = list.length > 1
108110

109111
return h('div', { class: 'min-h-screen bg-base color-base font-sans' }, [
@@ -131,10 +133,47 @@ async function main(): Promise<void> {
131133
}
132134
},
133135
})
134-
135136
createApp(App).mount(root)
136137
}
137138

139+
async function main(): Promise<void> {
140+
const root = document.getElementById('app')
141+
if (!root)
142+
throw new Error('#app mount node missing')
143+
144+
const rpc = await connectDevframe()
145+
const interactive = rpc.connectionMeta.backend !== 'static'
146+
147+
// The hub's iframe view-provider pins one view via `?view=<stateKey>`.
148+
const pinned = new URLSearchParams(location.search).get('view')
149+
if (pinned) {
150+
mountSingleView(root, rpc, pinned, interactive)
151+
return
152+
}
153+
154+
// Dashboard: discover every live view from the single view-index shared
155+
// state. The author never wires a view id into the client — publishing a
156+
// view is enough for it to appear here.
157+
const indexState = await rpc.sharedState.get<JsonRenderIndex>(JSON_RENDER_INDEX_KEY, { initialValue: {} })
158+
if (interactive) {
159+
try {
160+
const value = await rpc.call('devframe:rpc:server-state:get', JSON_RENDER_INDEX_KEY)
161+
if (value && typeof value === 'object')
162+
indexState.mutate(() => value as JsonRenderIndex)
163+
}
164+
catch {
165+
// Non-fatal: fall back to live 'updated' events below.
166+
}
167+
}
168+
169+
const indexRef = shallowRef<JsonRenderIndex>(indexState.value() as JsonRenderIndex)
170+
indexState.on('updated', () => {
171+
indexRef.value = indexState.value() as JsonRenderIndex
172+
})
173+
174+
mountDashboard(root, rpc, indexRef, interactive)
175+
}
176+
138177
main().catch((error) => {
139178
console.error(error)
140179
document.body.textContent = `Failed to start: ${(error as Error).message}`
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Primary color as a single overridable variable, `--devframe-primary`, so the
3+
* host's brand (applied in theme.ts from branding.json) retints this provider
4+
* SPA. Imported after `virtual:uno.css` so it wins over Wind4's own `:root`
5+
* primary declarations. Each stop derives from `--devframe-primary` via
6+
* `color-mix`; unset, the intermediate vars are guaranteed-invalid so each
7+
* `--colors-primary-*` falls back to the exact devframe default hex.
8+
*
9+
* Mirrors the same block in `@devframes/hub-ui` (kept in sync deliberately;
10+
* the two packages don't share a stylesheet).
11+
*/
12+
:root {
13+
--devframe-primary-DEFAULT: var(--devframe-primary);
14+
--devframe-primary-600: color-mix(in oklab, var(--devframe-primary), white 9%);
15+
--devframe-primary-500: color-mix(in oklab, var(--devframe-primary), white 18%);
16+
--devframe-primary-400: color-mix(in oklab, var(--devframe-primary), white 34%);
17+
--devframe-primary-300: color-mix(in oklab, var(--devframe-primary), white 54%);
18+
19+
--colors-primary-DEFAULT: var(--devframe-primary-DEFAULT, #3a6a45);
20+
--colors-primary-600: var(--devframe-primary-600, #40704b);
21+
--colors-primary-500: var(--devframe-primary-500, #578861);
22+
--colors-primary-400: var(--devframe-primary-400, #75a67e);
23+
--colors-primary-300: var(--devframe-primary-300, #93c69d);
24+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Color-scheme + brand theming for the provider SPA, shared same-origin with
2+
// the host. Dark/light follows the `devframes-color-scheme` localStorage key
3+
// (the hub writes it; falls back to the OS preference), and the brand primary
4+
// comes from the hub's `branding.json`. Both are best-effort: run standalone
5+
// (no hub, no key, no branding.json) and it simply uses the OS scheme and the
6+
// default devframe palette.
7+
8+
const COLOR_SCHEME_KEY = 'devframes-color-scheme'
9+
type ColorSchemePreference = 'auto' | 'light' | 'dark'
10+
11+
function readPreference(): ColorSchemePreference {
12+
try {
13+
const value = localStorage.getItem(COLOR_SCHEME_KEY)
14+
if (value === 'light' || value === 'dark' || value === 'auto')
15+
return value
16+
}
17+
catch {
18+
// localStorage can throw in sandboxed frames — fall back to auto.
19+
}
20+
return 'auto'
21+
}
22+
23+
/**
24+
* Mirror the host's color scheme onto `.dark`, following the shared
25+
* `devframes-color-scheme` key (cross-frame via `storage` events) and the OS
26+
* preference when unset.
27+
*/
28+
export function initColorScheme(): void {
29+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
30+
const apply = (): void => {
31+
const preference = readPreference()
32+
const dark = preference === 'auto' ? mq.matches : preference === 'dark'
33+
document.documentElement.classList.toggle('dark', dark)
34+
}
35+
apply()
36+
mq.addEventListener('change', apply)
37+
window.addEventListener('storage', (e) => {
38+
if (e.key === COLOR_SCHEME_KEY || e.key === null)
39+
apply()
40+
})
41+
}
42+
43+
/**
44+
* Apply the host's brand primary by fetching `branding.json` (published by the
45+
* hub's `createUi({ branding })` at the hub base). The provider SPA is mounted
46+
* one segment under that base (`<base><id>/`), so try one level up first, then a
47+
* sibling; silently skip when unbranded.
48+
*/
49+
export async function applyBranding(): Promise<void> {
50+
for (const rel of ['../branding.json', './branding.json']) {
51+
try {
52+
const res = await fetch(new URL(rel, document.baseURI))
53+
if (!res.ok)
54+
continue
55+
const branding = await res.json() as { primaryColor?: string }
56+
if (branding && typeof branding.primaryColor === 'string')
57+
document.documentElement.style.setProperty('--devframe-primary', branding.primaryColor)
58+
return
59+
}
60+
catch {
61+
// Try the next candidate; unbranded/standalone use falls through.
62+
}
63+
}
64+
}

‎tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type JsonRenderDockRenderer = (_: JsonRenderDockMountOptions) => Promise<
4242
export declare function createActionBridge(_: ActionBridgeRpc, _?: {
4343
interactive?: boolean;
4444
}): JsonRenderActionBridge;
45+
/** @deprecated */
4546
export declare function createJsonRenderDockRenderer(_?: JsonRenderDockRendererOptions): JsonRenderDockRenderer;
4647
export declare function createRenderer(_?: CreateRendererOptions): import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
4748
[key: string]: any;

‎tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Functions
55
export function createActionBridge(_, _) {}
6+
/** @deprecated */
67
export function createJsonRenderDockRenderer(_) {}
78
export function createRenderer(_) {}
89
export function sanitizeSpec(_, _) {}

‎tests/__snapshots__/tsnapi/@devframes/json-render-ui/spa.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Functions
55
export declare function createJsonRenderDevframe(_: DevframeDefinition): DevframeDefinition;
6+
export declare function jsonRenderProvider(_?: Partial<DevframeDefinition>): DevframeDefinition;
67
// #endregion
78

89
// #region Variables

‎tests/__snapshots__/tsnapi/@devframes/json-render-ui/spa.snapshot.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Functions
55
export function createJsonRenderDevframe(_) {}
6+
export function jsonRenderProvider(_) {}
67
// #endregion
78

89
// #region Variables

0 commit comments

Comments
 (0)