diff --git a/AGENTS.md b/AGENTS.md index b0e1bf27..fd9547c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,6 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co ## Conventions -- **Be very strict about the public API surface.** Every exported symbol on a published subpath is a contract users can depend on - additions and changes must be deliberate, not a side effect of where code happens to live. Before exporting anything new, ask whether it needs to be public at all: helpers shared between first-party packages and transports belong on **`devframe/internal`** (explicitly unstable, can change in any minor release), and module-local code should simply not be exported. Barrel files that `export *` make accidental exposure easy - when adding to a star-exported module, check what rides along. The `tsnapi` snapshots under `tests/__snapshots__/tsnapi/` guard the entire surface: review every snapshot diff as an API-design decision, never regenerate it as a chore, and treat a `TSNAPI_ALLOW_BREAKING` update as something that needs the same scrutiny as the breaking change itself. - RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin::` (matching the plugin's `@devframes/plugin-` package name). - **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency - no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal - not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise - no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations - recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations). - Shared state via `devframe/utils/shared-state`; keep values serializable. @@ -50,7 +49,10 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co All five built-in plugins - and every example under `examples/` - share one design system, [`@antfu/design`](https://github.com/antfu/design), so they look and feel like one product across frameworks (Git is React/Next, terminals is Svelte, code-server is Vue, inspect is Vue, a11y is Solid, the examples are Preact/Next/vanilla). It's a dev dependency consumed at build time: its UnoCSS preset and shipped styles drive every surface, and its Vue components are the canonical reference every framework matches. There is no shared internal design package - each app wires the preset itself and owns its own component ports. - **Respect the skills.** This design system is built to the `antfu` and `antfu-design` skills (UnoCSS-first, class-based semantic tokens, dual light/dark, anti-slop) - load and follow them when building or changing any UI here. The surfaces deliberately echo the upstream devtools they descend from; reference their UI/UX when in doubt: [`antfu/node-modules-inspector`](https://github.com/antfu/node-modules-inspector), [`antfu/vite-plugin-inspect`](https://github.com/antfu/vite-plugin-inspect), [`eslint/config-inspector`](https://github.com/eslint/config-inspector), and [`vitejs/devtools` → `packages/rolldown`](https://github.com/vitejs/devtools/tree/main/packages/rolldown). -- **One preset, wired per app.** Each consumer's `uno.config.ts` composes the same stack: `presetAnthonyDesign({ primary })` (from `@antfu/design/unocss`, tuned to devframe's sage green) + `presetWind4()` + `presetIcons()` (Phosphor) + `transformerDirectives()` + `transformerVariantGroup()`, plus the named `z-*` layers the nav/overlay surfaces reference (`z-nav`, `z-dropdown`, `z-tooltip`, `z-toast`, `z-modal-*`, `z-drawer-*`) - `presetAnthonyDesign` blocks plain `z-` so every layer is named. Keep the block identical across apps so the surfaces stay consistent. +- **One preset, wired per app.** Each consumer's `uno.config.ts` composes the same stack: `presetAnthonyDesign({ primary })` (from `@antfu/design/unocss`, tuned to devframe's sage green) + a Wind base + `presetIcons()` (Phosphor) + `transformerDirectives()` + `transformerVariantGroup()`, plus the named `z-*` layers the nav/overlay surfaces reference (`z-nav`, `z-dropdown`, `z-tooltip`, `z-toast`, `z-modal-*`, `z-drawer-*`) - `presetAnthonyDesign` blocks plain `z-` so every layer is named. The shared `design/uno.config.ts` exposes this as `designConfig` (the default, on `presetWind4()`) and a `createDesignConfig({ base })` factory; keep the block identical across apps so the surfaces stay consistent. +- **Wind4 by default, Wind3 for web components.** Ordinary surfaces (plugins served in iframes, examples in the page) use `presetWind4()`. A surface whose stylesheet is injected into a **shadow root** (`@devframes/hub-ui`'s dock custom element, `@devframes/json-render-ui`'s renderer module) must build on **`presetWind3()`** instead - pass it via `createDesignConfig({ base: presetWind3() })`, or `presetWind3()` directly. Wind4 keeps `@antfu/design`'s theme in a document `:root {}` block and registers its `--un-*` custom properties with `@property { inherits: false }`, neither of which reaches a shadow tree - so its `color-mix(var(--colors-*))` semantic utilities (`bg-base`, `color-base`, …) resolve to nothing inside a shadow root. Wind3 bakes the same shortcuts to concrete `rgb()` + `.dark` variants, self-contained in the shadow tree. Two shadow-root gotchas the ahead-of-time CSS builder must compensate for (both handled in `packages/{hub-ui,json-render-ui}/scripts/build-css.ts`; the Vite `unocss/vite` path for standalone SPAs and Storybook is not affected): + - **Plain-vs-variant shortcut drop.** When a semantic shortcut also appears **variant-prefixed** in the scanned sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`), a single-pass `generate(tokens)` drops the *plain* `.bg-base` / `.color-base` rule - so emit the surface tokens (`design/uno.config.ts`'s exported `shadowSurfaceSafelist`) in a **dedicated `generate()` pass** and append them. + - **`--un-*` collision with a Wind4 host.** `@property` registrations are document-global, so a host page built on Wind4 registers `--un-bg-opacity` / `--un-border-opacity` / `--un-text-opacity` as `@property { syntax: '' }` for the whole document, including our shadow tree - which invalidates the *unitless* values Wind3 writes (`--un-border-opacity: 0.13`) and collapses the dependent `rgb(… / var(--un-*))` color (a visibly wrong border/background). Rename every `--un-` in the shadow stylesheet to a private prefix with `design/uno.config.ts`'s exported `namespaceShadowCssVars()` so it's immune to whatever the host registered. - **Tokens are semantic shortcuts.** Build UI from `@antfu/design`'s class vocabulary - surfaces `bg-base` / `bg-secondary` / `bg-active`, text `color-base` / `color-muted` / `color-faint` / `color-active`, `border-base`, `op-fade` / `op-mute` - never a hardcoded palette. Import `@antfu/design/styles.css` (or cherry-pick `@antfu/design/styles/base.css` + `scrollbar.css`) once per page; dark mode is the `.dark` class on ``, flipped from the OS preference in the SPA entry. - **Vue uses the components directly; other frameworks port them.** The Vue surface (inspect) imports components straight from `@antfu/design/components/*` (`ActionButton`, `ActionIconButton`, `DisplayBadge`, `LayoutTabs`, `LayoutToolbar`, `LayoutCard`, …). Every non-Vue surface ports the components it needs into its own framework - React in git and the Next examples, Svelte in terminals, Solid in a11y, Preact in the Preact examples, vanilla DOM helpers in the Vite hub - mirroring the upstream component's markup, classes and behavior so it renders identically. Port on demand: recreate only what a surface uses, and keep each port faithful to its `@antfu/design` source. - **One nav, three buttons, one tab selector - strictly.** Every surface opens with the same top bar - a `LayoutToolbar`-style row led by a brand block (a primary-tinted `i-ph:*` icon + the product name). Buttons come in exactly three forms: a **text button** (`ActionButton` → `btn-action` / `btn-primary`), a **bordered icon button** (`ActionIconButton` → `btn-icon-square`), and a **borderless icon button** (round `btn-icon`). Multi-view tools (inspect, git) switch views with the one shared segmented selector (`LayoutTabs` `variant="segment"`: a `bg-secondary` track with `data-[state=active]:bg-base` triggers). Don't invent bespoke nav bars, button shapes, or tab styles. diff --git a/alias.ts b/alias.ts index cd546bd1..4b33ee2f 100644 --- a/alias.ts +++ b/alias.ts @@ -59,6 +59,7 @@ export const alias = { '@devframes/json-render/node': r('json-render/src/node/index.ts'), '@devframes/json-render': r('json-render/src/index.ts'), '@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'), + '@devframes/json-render-ui/hub': r('json-render-ui/src/hub.ts'), '@devframes/json-render-ui/spa': r('json-render-ui/src/spa.ts'), '@devframes/json-render-ui': r('json-render-ui/src/index.ts'), 'json-render/dashboard': fileURLToPath(new URL('./examples/json-render/src/dashboard.ts', import.meta.url)), diff --git a/design/uno.config.ts b/design/uno.config.ts index f94e7972..c85684aa 100644 --- a/design/uno.config.ts +++ b/design/uno.config.ts @@ -1,3 +1,4 @@ +import type { Preset } from 'unocss' import { fileURLToPath } from 'node:url' import { presetAnthonyDesign } from '@antfu/design/unocss' import { @@ -8,8 +9,24 @@ import { transformerVariantGroup, } from 'unocss' +export interface CreateDesignConfigOptions { + /** + * The base utility preset `@antfu/design` layers on top of. Defaults to + * {@link presetWind4} (what every plugin and example uses). Surfaces that + * render inside a **web-component shadow root** (the hub-ui dock, the + * json-render renderer module) pass `presetWind3()` instead: Wind4 registers + * its theme + `--un-*` custom properties via `@property { inherits: false }` + * and keeps them in a document `:root {}` block, neither of which reaches a + * shadow tree — so its `color-mix(var(--colors-*))` utilities resolve to + * nothing there. Wind3 bakes the same `@antfu/design` semantic utilities to + * concrete `rgb()` + `.dark` variants, which are self-contained inside a + * shadow root. + */ + base?: Preset | Preset[] +} + // Shared devframe UnoCSS base. Every plugin and example composes `@antfu/design` -// the same way — its preset (tuned to devframe's sage green) over a Wind4 base, +// the same way — its preset (tuned to devframe's sage green) over a Wind base, // Phosphor icons, DM Sans/Mono web fonts, and the directive/variant-group // transformers — so the surfaces look and feel like one product across // frameworks. Each app extends this via `mergeConfigs([designConfig, { … }])` @@ -19,33 +36,90 @@ import { // navbar height live here so every surface shares one font stack, one z-index // scale and one fixed navbar height. The `@antfu/design` preset blocks plain // `z-`, so the layers are named on purpose. -export const designConfig = defineConfig({ - presets: [ - presetAnthonyDesign({ primary: '#3a6a45' }), - presetWind4(), - presetIcons({ scale: 1.1 }), - ], - transformers: [transformerDirectives(), transformerVariantGroup()], - // The shared class-helper builders (`design/design.ts`) assemble their class - // chains at runtime, so every app scans that one file (it carries - // `@unocss-include`) for extraction regardless of its own framework globs. - content: { - filesystem: [fileURLToPath(new URL('./design.ts', import.meta.url))], - }, - // Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle - // shared border color (matching `border-base`) for unqualified borders. - preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], - shortcuts: { - // Fixed navbar height, shared by every surface's top nav. - 'h-nav': 'h-10', - // Named z-index layers, shared across every surface. - '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]', - }, -}) +export function createDesignConfig(options: CreateDesignConfigOptions = {}) { + const base = options.base ?? presetWind4() + return defineConfig({ + presets: [ + presetAnthonyDesign({ primary: '#3a6a45' }), + ...(Array.isArray(base) ? base : [base]), + presetIcons({ scale: 1.1 }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + // The shared class-helper builders (`design/design.ts`) assemble their class + // chains at runtime, so every app scans that one file (it carries + // `@unocss-include`) for extraction regardless of its own framework globs. + content: { + filesystem: [fileURLToPath(new URL('./design.ts', import.meta.url))], + }, + // Wind leaves bare `border`/`border-b` at currentColor; restore the subtle + // shared border color (matching `border-base`) for unqualified borders. + preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + shortcuts: { + // Fixed navbar height, shared by every surface's top nav. + 'h-nav': 'h-10', + // Named z-index layers, shared across every surface. + '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]', + }, + }) +} + +// The default shared base (Wind4), consumed by every plugin and example. +export const designConfig = createDesignConfig() + +/** + * The `@antfu/design` semantic surface/text tokens a shadow-root surface + * (hub-ui dock, json-render renderer module) needs guaranteed in its compiled + * stylesheet. Each is a shortcut that expands to a base utility plus a + * `.dark:` variant; safelisting the shortcut name makes the generator emit + * both variants even when the class only reaches the extractor through a + * `.dark`-prefixed or dynamically-assembled string it can't see. Wind3 bakes + * these to concrete `rgb()`, so they stay self-contained inside the shadow + * tree. + */ +/** + * Rename Wind's internal `--un-*` custom properties to a private prefix in a + * stylesheet destined for a **shadow root**. + * + * `@property` registrations are document-global regardless of where they're + * declared, so a host page built with Wind4 registers `--un-bg-opacity` / + * `--un-border-opacity` / `--un-text-opacity` (et al.) as + * `@property { syntax: ''; inherits: false }` for the whole + * document — including inside our shadow tree. Our shadow CSS is Wind3, which + * sets those same vars **unitless** (`--un-border-opacity: 0.13`), so the + * global `` registration makes every such declaration invalid and + * the dependent `color-mix()` / `rgb(… / var(--un-*))` value collapses (a + * visibly wrong border/background/text color). + * + * The shadow stylesheet sets and reads these vars entirely within itself, so + * renaming every `--un-` to a per-surface prefix (`--un-jr-`, `--un-hub-`) + * keeps it self-consistent while making it immune to whatever the host page + * registered — the renamed names are distinct properties the host's + * `@property --un-*` rules never match. Apply only to shadow-injected CSS + * (`hub-ui` dock, `json-render-ui` renderer module) — the Vite-served SPAs own + * their whole document and need no rename. + * + * @param css - The compiled shadow-root stylesheet. + * @param prefix - The replacement for `--un-` (e.g. `--un-jr-`, `--un-hub-`). + */ +export function namespaceShadowCssVars(css: string, prefix: string): string { + return css.replaceAll('--un-', prefix) +} + +export const shadowSurfaceSafelist: string[] = [ + 'bg-base', + 'bg-secondary', + 'bg-active', + 'bg-hover', + 'color-base', + 'color-muted', + 'color-faint', + 'color-active', + 'border-base', +] diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1b1d2e9c..e82430c7 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -36,6 +36,8 @@ function guideItems(prefix: string) { { text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` }, { text: 'Deep Linking', link: `${prefix}/guide/deep-linking` }, { text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` }, + { text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` }, + { text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` }, { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, ] satisfies DefaultTheme.NavItemWithLink[] } diff --git a/docs/errors/DF8108.md b/docs/errors/DF8108.md new file mode 100644 index 00000000..12429f32 --- /dev/null +++ b/docs/errors/DF8108.md @@ -0,0 +1,33 @@ +--- +outline: deep +--- + +# DF8108: Duplicate Renderer Module Type + +## Message + +> A renderer module is already registered for dock type "`{type}`" + +## Cause + +`initHub({ renderers })` received two registrations carrying the same `type`. Each dock type resolves to exactly one renderer module in the hub's renderer manifest — the module served at `__renderers/.mjs` — so a second registration for the same type would be unreachable. + +## Example + +```ts +initHub({ + renderers: [ + jsonRenderUiRenderer(), + { type: 'json-render', file: myOtherRenderer }, // ✗ duplicate type + ], +}) +``` + +## Fix + +- Keep one registration per dock type — pick the implementation you want the manifest to serve. +- To override a manifest module for one specific client, register a renderer locally instead (`createDevframeClientHost({ renderers })`); local registrations take precedence. + +## Source + +- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `resolveRendererRegistrations()` throws when a `type` repeats. diff --git a/docs/errors/DF8109.md b/docs/errors/DF8109.md new file mode 100644 index 00000000..4369edd2 --- /dev/null +++ b/docs/errors/DF8109.md @@ -0,0 +1,32 @@ +--- +outline: deep +--- + +# DF8109: Renderer Module File Missing + +## Message + +> The renderer module registered for dock type "`{type}`" does not exist at "`{file}`" + +## Cause + +An `initHub({ renderers })` registration points at a file that isn't on disk. Renderer modules are prebuilt, self-contained browser ES modules the hub serves verbatim at `__renderers/.mjs` — a missing bundle would make every client's lazy import 404 at mount time, so the hub fails fast at startup instead. + +## Example + +```ts +initHub({ + renderers: [ + { type: 'json-render', file: '/path/that/was/never/built.mjs' }, // ✗ + ], +}) +``` + +## Fix + +- Build the renderer package first — the bundle is a build artifact (e.g. `@devframes/json-render-ui`'s `dist/renderer/json-render.mjs`). +- Prefer the package's registration helper over a hand-written path — `jsonRenderUiRenderer()` from `@devframes/json-render-ui/hub` resolves the shipped bundle for you. + +## Source + +- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `resolveRendererRegistrations()` throws when the resolved `file` fails the existence probe. diff --git a/docs/errors/DF8110.md b/docs/errors/DF8110.md new file mode 100644 index 00000000..a448bca7 --- /dev/null +++ b/docs/errors/DF8110.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# DF8110: Renderer Type Is Not URL-Safe + +## Message + +> Dock type "`{type}`" is not a servable renderer-module name — the hub serves each module at `__renderers/.mjs` + +## Cause + +An `initHub({ renderers })` registration carries a `type` that can't become a URL segment. The hub derives each module's serving path — and the manifest's `importFrom` — from the type, so `:` and `*` (route-pattern markers to the underlying router) or separators like `/` would break the route. + +## Example + +```ts +initHub({ + renderers: [ + { type: 'my:renderer', file: bundle }, // ✗ `:` is a route-param marker + ], +}) +``` + +## Fix + +Use a route-safe dock type: letters, digits, `_`, `-`, and `.` only (e.g. `json-render`, `my-renderer`). The dock entries' `type` discriminator must match, so pick the safe name once, in the integration that declares the type. + +## Source + +- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `resolveRendererRegistrations()` rejects a `type` failing the `[\w.-]+` segment check. diff --git a/docs/guide/build-your-own-hub-ui.md b/docs/guide/build-your-own-hub-ui.md new file mode 100644 index 00000000..83258683 --- /dev/null +++ b/docs/guide/build-your-own-hub-ui.md @@ -0,0 +1,100 @@ +# Build Your Own Hub UI + +A hub viewer is a replaceable implementation of two contracts — the node-side +`ui` slot and the client-side context — so you can ship a completely custom +devtools surface (your framework, your design system) on top of the hub's +infrastructure. `@devframes/hub-ui` is the reference implementation of both; +this page is the map for writing another. + +## The node seam: `DevframeHubUi` + +`initHub({ ui })` takes pure data (see [the `ui` +slot](./hub-initiate#the-ui-slot)): + +```ts +interface DevframeHubUi { + viewer?: { distDir: string } // a standalone SPA served at the hub base + embedded?: { entry: string } // a self-contained bootstrap at embedded.js + assets?: Record string | Uint8Array> // extra UI-owned files +} +``` + +Ship a function returning this object (the reference is `createUi()`), with +prebuilt assets: the viewer SPA is built with relative asset paths, and the +embedded entry is one self-contained ES module that mounts your dock into any +host page. + +## The client contracts + +A viewer renders from the hub's shared state and drives it through +`@devframes/hub/client`. The simplest boot is +[`createDevframeClientHost()`](./client-context) — it assembles the whole +`DevframeClientContext` (docks, commands, renderers, when-clauses, connection) +and loads dock client scripts for you; the reference UI assembles the same +context shape with its own reactive machinery instead. Either way, honor these +contracts: + +### Dock entry types + +Render the built-in variants of the open dock union +(`DevframeDockEntryRegistry` from `@devframes/hub/types`): + +| Type | The viewer renders | +|---|---| +| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` for shared frames); honor `subTabs` soft navigation | +| `action` | a bar button only — activating it runs the entry's client script | +| `custom-render` | a container the entry's client script mounts into | +| `launcher` | a launch call-to-action reflecting `launcher.status` | +| `group` | one bar button collapsing its member entries | +| `~builtin` | your own native views (settings, feeds) for reserved ids | + +Honor `when` / `visibility` clauses, `category` grouping (order from +`DEFAULT_CATEGORIES_ORDER` in `@devframes/hub/constants`), and the +`hub:docks:activate` broadcast. + +### The renderer registry and its fallback + +**Every other dock type routes through the dock-renderer registry** — build it +with `createDockRenderersContext()` from `@devframes/hub/client` so local +registrations, the hub's [renderer +manifest](./hub-initiate#renderer-modules), and the typed mount result behave +like every other viewer: + +```ts +import { createDockRenderersContext } from '@devframes/hub/client' + +const renderers = createDockRenderersContext({ + context: () => context, + manifest: () => manifestState.value(), // the devframe:dock-renderers slot +}) + +const result = await renderers.mount(entry, container) +``` + +The mount result is the fallback contract. A viewer shows a visible state for +each variant instead of a dead panel: + +- `{ status: 'mounted', dispose }` — the renderer owns the container; call + `dispose` when the view unmounts. +- `{ status: 'missing-renderer' }` — render a fallback view: *No renderer for + "``" in the current environment*. `renderers.has(type)` answers up + front, so you can render this declaratively without a mount attempt. +- `{ status: 'load-error', error }` — the module failed to import or the + renderer threw; render the error with a retry affordance (a failed import is + not cached, so retrying re-imports). + +### The theme contract for renderers + +Renderer modules style themselves (they may attach a shadow root inside your +container). Your part: keep a live `dark` class on the mount container +reflecting your color mode, and let CSS custom properties inherit — a +`--devframe-primary` set on an ancestor rebrands rendered content too. + +## Reference points + +- `packages/hub-ui` — the full reference viewer (Vue, `@antfu/design`). +- [`examples/hub-vite`](/examples/hub-vite) and + [`examples/hub-next`](/examples/hub-next) — protocol witnesses: complete + hand-rolled viewers in ~500 lines of vanilla DOM and React respectively, + covering docks, the drawer subsystems, the renderer registry, and the + missing-renderer fallback. diff --git a/docs/guide/build-your-own-json-render-frontend.md b/docs/guide/build-your-own-json-render-frontend.md new file mode 100644 index 00000000..aa56e7b0 --- /dev/null +++ b/docs/guide/build-your-own-json-render-frontend.md @@ -0,0 +1,79 @@ +# Build Your Own JSON-Render Frontend + +`@devframes/json-render-ui` is the reference frontend, not the protocol — any +implementation of the renderer contract replaces it, in any framework. The +[Next hub witness](/examples/hub-next) ships a complete React one in two files +(`src/client/json-render/`); this page is the contract it implements. + +## The contract + +`@devframes/json-render/hub` owns the types: + +```ts +import type { JsonRenderDockRenderer } from '@devframes/json-render/hub' + +// a hub DockRenderer narrowed to the json-render dock entry +const renderer: JsonRenderDockRenderer = async ({ entry, container, context }) => { + // mount your framework's root into `container`, render `entry.view` + return { dispose() { /* unmount, unsubscribe */ } } +} +``` + +Resolve the entry's serializable `view` reference: + +- `{ stateKey }` — subscribe to that shared state via + `context.rpc.sharedState.get(stateKey)`, render its value as the live spec, + and re-render on `'updated'`. **Unsubscribe in `dispose`.** +- `{ spec }` — render the embedded spec directly; no shared state involved. + +Detect static output via `context.rpc.connectionMeta.backend === 'static'` and +disable action dispatch there. + +## Behavior expectations + +Match the reference frontend's semantics so specs behave identically across +frontends: + +- **Actions** — a spec action name dispatches an RPC call of the same name. + Never bridge the reserved built-ins (`setState`, `pushState`, `removeState`, + `validateForm` — handled by the upstream renderer) or promise probes + (`then`/`catch`/`finally`). Surface failures to the view rather than + swallowing them. +- **Validation** — validate element props against `basePropSchemas` from + `@devframes/json-render`; swap an invalid element for an error placeholder so + one bad element doesn't break the view. +- **Unknown components** — a component your registry lacks renders as a + placeholder (type + prop-key gist) with a `console.warn`; the rest of the + view renders. +- **State reset** — reseed spec state only when the view identity changes, not + on every spec update. + +## Plugging it in + +Two seams, one contract: + +- **Local registration** — a host page that bundles its own client passes + `createDevframeClientHost({ renderers: { 'json-render': myRenderer } })`. + Local registrations win over the manifest. +- **A prebuilt renderer module** — bundle your renderer as one self-contained + browser ES module (framework and styles included) whose default export is the + renderer, and ship a node helper returning the hub registration: + + ```ts + import type { DockRendererRegistration } from '@devframes/hub/initiate' + + export function myRenderer(): DockRendererRegistration { + return { type: 'json-render', file: myPrebuiltModulePath } + } + ``` + + Hosts compose it with `initHub({ renderers: [myRenderer()] })` — the hub + serves the module and every viewer imports it lazily (see [renderer + modules](./hub-initiate#renderer-modules)). + +A prebuilt module must be **self-styling and shadow-root-safe**: the viewer's +container may live inside a shadow root, so deliver your stylesheet into the +mount subtree (the reference module attaches its own shadow root inside the +container and injects its compiled CSS there). Read the theme from the live +`dark` class the viewer keeps on the container, and derive brand color from the +inherited `--devframe-primary` custom property when present. diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 9a9f8c93..902acece 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -38,7 +38,7 @@ Viewers with an HTML pipeline layer injection on top: `@vitejs/devtools` wraps t | `connect` | Options forwarded to `connectDevframe` when `rpc` is not supplied — pass `baseURL` to point at the hub's connection-meta mount (e.g. `/__hub/`). | | `clientType` | `'standalone'` (default) — the runtime owns the whole page (a hub UI). `'embedded'` — the runtime lives inside a user app alongside a panel. | | `loadClientScripts` | Import and run dock entries' client scripts. Default `true`. | -| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': createJsonRenderDockRenderer() }` from `@devframes/json-render-ui`). The hub ships none. | +| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': createJsonRenderDockRenderer() }` from `@devframes/json-render-ui`). Local registrations take precedence over the hub's [renderer manifest](./hub-initiate#renderer-modules). | Boot the host once per page: a second boot replaces the published context and logs a warning. `dispose()` tears down its listeners and unpublishes the context it owns. @@ -53,7 +53,7 @@ Boot the host once per page: a second boot replaces the published context and lo | `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, plus `register()` / `update()` for [client-only docks](#client-only-docks). | | `panel` | Dock panel state: position, size, drag/resize flags. | | `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. | -| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a host-registered renderer (e.g. [JSON-Render](./json-render)); the hub ships none. | +| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer: one registered locally at boot, or a prebuilt module lazy-imported from the hub's [renderer manifest](./hub-initiate#renderer-modules) (local wins). `mount()` resolves a typed result — `{ status: 'mounted', dispose }`, `{ status: 'missing-renderer' }`, or `{ status: 'load-error', error }` — so a viewer renders a visible fallback for a type nothing covers instead of a dead panel; `has()` answers for both sources so the fallback can render without a mount attempt. | | `when` | The [when-clause](./when-clauses) evaluation context. | | `connection` | The client's live [connection status](./client#handling-connection-and-auth-errors) — `status`, `error`, and `events` — so a viewer can render one central connection indicator for every docked plugin. | diff --git a/docs/guide/hub-initiate.md b/docs/guide/hub-initiate.md index 3c4e1275..2ae4ab67 100644 --- a/docs/guide/hub-initiate.md +++ b/docs/guide/hub-initiate.md @@ -61,6 +61,26 @@ interface DevframeHubUi { `@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one ` - - diff --git a/examples/hub-next/src/client/app/page.tsx b/examples/hub-next/src/client/app/page.tsx index 3f220d79..d20aa2de 100644 --- a/examples/hub-next/src/client/app/page.tsx +++ b/examples/hub-next/src/client/app/page.tsx @@ -12,11 +12,35 @@ import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client' import { useEffect, useMemo, useRef, useState } from 'react' -import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' +import { createReactJsonRenderDockRenderer } from '../json-render/react-renderer' import { dockIconSvg } from './icons' const HUB_BASE = '/__devframes/' +// ── transport preference (`?transport=` param) ────────────────────────────── +// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the +// client's `transport` option picks one, `auto` trusting the server's +// advertisement. A connected client has no live switch, so the toggle writes +// the `?transport=` param and reloads to reconnect on the pinned transport. +const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const +type TransportPref = (typeof TRANSPORT_PREFS)[number] + +function readTransportPref(): TransportPref { + const raw = new URLSearchParams(window.location.search).get('transport') + return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto' +} +function applyTransportPref(pref: TransportPref): void { + const url = new URL(window.location.href) + if (pref === 'auto') + url.searchParams.delete('transport') + else + url.searchParams.set('transport', pref) + window.location.href = url.href +} +function transportLabel(pref: TransportPref): string { + return pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto' +} + interface Status { text: string kind?: 'ready' | 'error' @@ -30,11 +54,14 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock { return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' } -// A dock this shell can display: an iframe, or one with a registered renderer -// (the json-render dock, rendered by the mini React registry). -const RENDERER_TYPES = new Set(['json-render']) +// Dock types this shell renders natively (or that carry no panel view of +// their own). Everything else routes through the hub's dock-renderer +// registry - the local React renderer registered at boot, or a prebuilt +// module from the hub's renderer manifest - and a type nothing covers shows +// the missing-renderer fallback. +const NATIVE_TYPES = new Set(['action', 'launcher', 'group', '~builtin']) function isRenderableDock(d: DevframeDockEntry): boolean { - return isIframeDock(d) || RENDERER_TYPES.has(d.type) + return isIframeDock(d) || !NATIVE_TYPES.has(d.type) } // One iframe is kept alive per `frameId` (shared-frame docks) or per dock id @@ -138,6 +165,64 @@ function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec } } +type ClientContext = ClientHost['context'] + +// Register the two *client-only* docks (an iframe from a Blob URL + an inline +// interactive json-render view) on the client host context, so they stay local +// to this page and never enter `devframe:docks` shared state. `force` lets +// React StrictMode re-run the boot effect without tripping the duplicate-id +// guard. Returns a disposer that removes them again. +function registerClientDocks(ctx: ClientContext): () => void { + const notes = ctx.docks.register({ + id: 'client-notes', + title: 'Client Notes', + icon: 'ph:note-pencil-duotone', + type: 'iframe', + url: createClientNotesUrl(), + category: 'app', + }, true) + notes.update({ badge: ctx.clientType }) // patch in place via the handle + const playground = ctx.docks.register({ + id: 'client-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', + type: 'json-render', + view: { spec: createClientPlaygroundSpec(ctx.clientType) }, + category: 'app', + }, true) + return () => { + notes.dispose() + playground.dispose() + } +} + +// Poll the two kit-local RPCs that expose the hub's message + terminal +// subsystems (a fuller kit would push over the hub's `*:updated` broadcasts). +// Returns a stop function that ends the polling. +function pollDrawer( + rpc: DevframeRpcClient, + onMessages: (m: DevframeMessageEntry[]) => void, + onTerminals: (t: TerminalSummary[]) => void, +): () => void { + let alive = true + const refresh = async (): Promise => { + const [messages, terminals] = await Promise.all([ + rpc.call('example:next-devframe-hub:messages:list' as any) as Promise, + rpc.call('example:next-devframe-hub:terminals:list' as any) as Promise, + ]) + if (alive) { + onMessages(messages) + onTerminals(terminals) + } + } + void refresh() + const interval = window.setInterval(() => void refresh(), 2000) + return () => { + alive = false + window.clearInterval(interval) + } +} + /** Fetches (and caches, for the component's lifetime) a dock icon's sanitized SVG. */ function useDockIconSvg(icon: DevframeDockEntry['icon']): string | undefined { const [svg, setSvg] = useState(undefined) @@ -168,34 +253,6 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) { return {initial} } -// ── transport preference (`?transport=` param) ────────────────────────────── -// The hub serves both live transports (WS at `__ws`, SSE at `__sse`); the -// client's `transport` option picks one, `auto` trusting the server's -// advertisement. A closed client has no reconnect, so the toggle writes the -// preference into the URL and reloads - the whole host boots on the chosen -// transport. - -const TRANSPORT_PREFS = ['auto', 'websocket', 'sse'] as const -type TransportPref = (typeof TRANSPORT_PREFS)[number] - -function readTransportPref(): TransportPref { - const raw = new URLSearchParams(window.location.search).get('transport') - return (TRANSPORT_PREFS as readonly string[]).includes(raw ?? '') ? raw as TransportPref : 'auto' -} - -function applyTransportPref(pref: TransportPref) { - const url = new URL(window.location.href) - if (pref === 'auto') - url.searchParams.delete('transport') - else - url.searchParams.set('transport', pref) - window.location.href = url.href -} - -function transportLabel(pref: TransportPref): string { - return pref === 'websocket' ? 'WS' : pref === 'sse' ? 'SSE' : 'Auto' -} - export default function Page() { const [status, setStatus] = useState({ text: 'Connecting...' }) const [transport, setTransport] = useState(null) @@ -206,6 +263,9 @@ export default function Page() { const [terminals, setTerminals] = useState([]) const [pingResult, setPingResult] = useState('Run ping') const [selectedDockId, setSelectedDockId] = useState(null) + // Fallback shown when the selected renderer dock's type has no renderer + // (missing-renderer) or its manifest module failed to import (load-error). + const [panelFallback, setPanelFallback] = useState<{ message: string, hint: string } | null>(null) const rpcRef = useRef(null) const hostRef = useRef(null) // The stage holds the kept-alive iframe pool; the panel hosts renderer docks. @@ -235,8 +295,13 @@ export default function Page() { // context and imports each dock's client script into this page - e.g. // the a11y inspector's in-page agent, which then scans this hub live. // - // Register a mini React json-render renderer so the hub can display the - // `json-render` dock authored server-side via @devframes/json-render. + // Register a mini React json-render renderer. The hub also publishes + // the reference Vue frontend through its renderer manifest + // (`initHub({ renderers: [jsonRenderUiRenderer()] })`), but a locally + // registered renderer takes precedence - witnessing that any frontend + // implementing the `JsonRenderDockRenderer` contract can replace the + // reference one. Delete this `renderers` option and the same dock + // renders through the manifest-served Vue module instead. const clientHost = await createDevframeClientHost({ rpc, renderers: { 'json-render': createReactJsonRenderDockRenderer() }, @@ -244,40 +309,10 @@ export default function Page() { hostRef.current = clientHost const ctx = clientHost.context - // Register a *client-only* dock - one this page synthesizes itself. - // Unlike the server-authored docks, it's registered on the client host - // context, so it never enters the `devframe:docks` shared state: it - // stays local to this page and is not synced to the hub server or other - // viewers. It merges into `ctx.docks.entries` alongside the server - // docks. `force` lets React StrictMode re-run this effect without - // tripping the duplicate-id guard. - const clientDock = ctx.docks.register({ - id: 'client-notes', - title: 'Client Notes', - icon: 'ph:note-pencil-duotone', - type: 'iframe', - url: createClientNotesUrl(), - category: 'app', - }, true) - // Patch it in place with the returned handle (the id is immutable). - clientDock.update({ badge: ctx.clientType }) - - // Register a second client-only dock - this one a *json-render* view the - // page authors itself, the richer sibling of the iframe dock above. Its - // spec is carried **inline** in the dock entry (`view.spec`), so it needs - // no shared state at all: it lives only in this page yet renders - and - // stays fully interactive (inputs, toggles, and buttons that mutate its - // state) - through the same `json-render` dock renderer (the mini React - // registry) as a server-authored view. `force` lets React StrictMode - // re-run this effect safely. - const clientJsonRenderDock = clientHost.context.docks.register({ - id: 'client-playground', - title: 'Client Playground', - icon: 'ph:sliders-horizontal-duotone', - type: 'json-render', - view: { spec: createClientPlaygroundSpec(clientHost.context.clientType) }, - category: 'app', - }, true) + // Two *client-only* docks (an iframe + an interactive inline + // json-render view), local to this page - never entering + // `devframe:docks` shared state, merged into `ctx.docks.entries`. + const disposeClientDocks = registerClientDocks(ctx) const docksState = await rpc.sharedState.get( 'devframe:docks', @@ -315,36 +350,13 @@ export default function Page() { } window.addEventListener('message', onMessage) - const refreshMessages = async () => { - const entries = await rpc.call( - 'example:next-devframe-hub:messages:list' as any, - ) as DevframeMessageEntry[] - if (!cancelled) - setMessages(entries) - } - - const refreshTerminals = async () => { - const sessions = await rpc.call( - 'example:next-devframe-hub:terminals:list' as any, - ) as TerminalSummary[] - if (!cancelled) - setTerminals(sessions) - } - - await refreshMessages() - await refreshTerminals() - - const interval = window.setInterval(() => { - void refreshMessages() - void refreshTerminals() - }, 2000) + const stopPolling = pollDrawer(rpc, setMessages, setTerminals) cleanup = () => { - window.clearInterval(interval) + stopPolling() window.removeEventListener('message', onMessage) // Remove the client-only docks, then tear down the host + local DOM. - clientDock.dispose() - clientJsonRenderDock.dispose() + disposeClientDocks() clientHost.dispose() wiredRef.current.clear() for (const el of iframePoolRef.current.values()) el.remove() @@ -433,24 +445,49 @@ export default function Page() { } }, [selectedDockId, docks, selectedDock]) - // Mount a renderer dock (json-render) into the panel via the client host's - // renderer registry, disposing when the selection changes. + // Mount a renderer dock (e.g. json-render) into the panel via the client + // host's renderer registry - the local React renderer, or a prebuilt module + // lazy-imported from the hub's renderer manifest - disposing when the + // selection changes. Each mount gets a fresh container element (a + // self-styling renderer may attach a shadow root to it); the typed mount + // result drives the missing-renderer / load-error fallback below. useEffect(() => { const host = hostRef.current const dock = selectedDock - const container = panelRef.current - if (!host || !dock || isIframeDock(dock) || !container) + const stage = panelRef.current + if (!host || !dock || isIframeDock(dock) || !stage) return let alive = true let dispose: (() => void) | undefined - void host.context.renderers.mount(dock, container).then((d) => { - if (alive) - dispose = d - else d() + setPanelFallback(null) + const container = document.createElement('div') + container.className = 'h-full w-full' + stage.append(container) + void host.context.renderers.mount(dock, container).then((result) => { + if (!alive) { + if (result.status === 'mounted') + result.dispose() + return + } + if (result.status === 'mounted') { + dispose = result.dispose + return + } + setPanelFallback(result.status === 'missing-renderer' + ? { + message: `No renderer for “${dock.type}” in the current environment`, + hint: 'The host has not registered a renderer for this dock type.', + } + : { + message: `The renderer for “${dock.type}” failed to load`, + hint: 'Check the console, then re-select the dock to retry.', + }) }) return () => { alive = false dispose?.() + container.remove() + setPanelFallback(null) } }, [selectedDockId, selectedIsIframe]) @@ -513,8 +550,14 @@ export default function Page() { {/* Iframe docks are pooled here (one kept-alive iframe per frameId), shown/hidden on switch so shared-frame tabs soft-navigate. */}