Skip to content

Commit 369cb55

Browse files
antfubotantfu
andauthored
feat!: swappable UI packages — renderer-module manifest + missing-renderer fallback (#194)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent e19d38a commit 369cb55

109 files changed

Lines changed: 3069 additions & 3104 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3838

3939
## Conventions
4040

41-
- **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.
4241
- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
4342
- **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).
4443
- 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
5049
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.
5150

5251
- **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).
53-
- **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-<number>` so every layer is named. Keep the block identical across apps so the surfaces stay consistent.
52+
- **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-<number>` 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.
53+
- **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):
54+
- **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.
55+
- **`--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: '<percentage>' }` 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.
5456
- **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 `<html>`, flipped from the OS preference in the SPA entry.
5557
- **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.
5658
- **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.

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const alias = {
5959
'@devframes/json-render/node': r('json-render/src/node/index.ts'),
6060
'@devframes/json-render': r('json-render/src/index.ts'),
6161
'@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'),
62+
'@devframes/json-render-ui/hub': r('json-render-ui/src/hub.ts'),
6263
'@devframes/json-render-ui/spa': r('json-render-ui/src/spa.ts'),
6364
'@devframes/json-render-ui': r('json-render-ui/src/index.ts'),
6465
'json-render/dashboard': fileURLToPath(new URL('./examples/json-render/src/dashboard.ts', import.meta.url)),

design/uno.config.ts

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Preset } from 'unocss'
12
import { fileURLToPath } from 'node:url'
23
import { presetAnthonyDesign } from '@antfu/design/unocss'
34
import {
@@ -8,8 +9,24 @@ import {
89
transformerVariantGroup,
910
} from 'unocss'
1011

12+
export interface CreateDesignConfigOptions {
13+
/**
14+
* The base utility preset `@antfu/design` layers on top of. Defaults to
15+
* {@link presetWind4} (what every plugin and example uses). Surfaces that
16+
* render inside a **web-component shadow root** (the hub-ui dock, the
17+
* json-render renderer module) pass `presetWind3()` instead: Wind4 registers
18+
* its theme + `--un-*` custom properties via `@property { inherits: false }`
19+
* and keeps them in a document `:root {}` block, neither of which reaches a
20+
* shadow tree — so its `color-mix(var(--colors-*))` utilities resolve to
21+
* nothing there. Wind3 bakes the same `@antfu/design` semantic utilities to
22+
* concrete `rgb()` + `.dark` variants, which are self-contained inside a
23+
* shadow root.
24+
*/
25+
base?: Preset<any> | Preset<any>[]
26+
}
27+
1128
// Shared devframe UnoCSS base. Every plugin and example composes `@antfu/design`
12-
// the same way — its preset (tuned to devframe's sage green) over a Wind4 base,
29+
// the same way — its preset (tuned to devframe's sage green) over a Wind base,
1330
// Phosphor icons, DM Sans/Mono web fonts, and the directive/variant-group
1431
// transformers — so the surfaces look and feel like one product across
1532
// frameworks. Each app extends this via `mergeConfigs([designConfig, { … }])`
@@ -19,33 +36,90 @@ import {
1936
// navbar height live here so every surface shares one font stack, one z-index
2037
// scale and one fixed navbar height. The `@antfu/design` preset blocks plain
2138
// `z-<number>`, so the layers are named on purpose.
22-
export const designConfig = defineConfig({
23-
presets: [
24-
presetAnthonyDesign({ primary: '#3a6a45' }),
25-
presetWind4(),
26-
presetIcons({ scale: 1.1 }),
27-
],
28-
transformers: [transformerDirectives(), transformerVariantGroup()],
29-
// The shared class-helper builders (`design/design.ts`) assemble their class
30-
// chains at runtime, so every app scans that one file (it carries
31-
// `@unocss-include`) for extraction regardless of its own framework globs.
32-
content: {
33-
filesystem: [fileURLToPath(new URL('./design.ts', import.meta.url))],
34-
},
35-
// Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle
36-
// shared border color (matching `border-base`) for unqualified borders.
37-
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
38-
shortcuts: {
39-
// Fixed navbar height, shared by every surface's top nav.
40-
'h-nav': 'h-10',
41-
// Named z-index layers, shared across every surface.
42-
'z-nav': 'z-[30]',
43-
'z-dropdown': 'z-[40]',
44-
'z-tooltip': 'z-[45]',
45-
'z-toast': 'z-[50]',
46-
'z-modal-backdrop': 'z-[60]',
47-
'z-modal-content': 'z-[70]',
48-
'z-drawer-backdrop': 'z-[80]',
49-
'z-drawer-content': 'z-[90]',
50-
},
51-
})
39+
export function createDesignConfig(options: CreateDesignConfigOptions = {}) {
40+
const base = options.base ?? presetWind4()
41+
return defineConfig({
42+
presets: [
43+
presetAnthonyDesign({ primary: '#3a6a45' }),
44+
...(Array.isArray(base) ? base : [base]),
45+
presetIcons({ scale: 1.1 }),
46+
],
47+
transformers: [transformerDirectives(), transformerVariantGroup()],
48+
// The shared class-helper builders (`design/design.ts`) assemble their class
49+
// chains at runtime, so every app scans that one file (it carries
50+
// `@unocss-include`) for extraction regardless of its own framework globs.
51+
content: {
52+
filesystem: [fileURLToPath(new URL('./design.ts', import.meta.url))],
53+
},
54+
// Wind leaves bare `border`/`border-b` at currentColor; restore the subtle
55+
// shared border color (matching `border-base`) for unqualified borders.
56+
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
57+
shortcuts: {
58+
// Fixed navbar height, shared by every surface's top nav.
59+
'h-nav': 'h-10',
60+
// Named z-index layers, shared across every surface.
61+
'z-nav': 'z-[30]',
62+
'z-dropdown': 'z-[40]',
63+
'z-tooltip': 'z-[45]',
64+
'z-toast': 'z-[50]',
65+
'z-modal-backdrop': 'z-[60]',
66+
'z-modal-content': 'z-[70]',
67+
'z-drawer-backdrop': 'z-[80]',
68+
'z-drawer-content': 'z-[90]',
69+
},
70+
})
71+
}
72+
73+
// The default shared base (Wind4), consumed by every plugin and example.
74+
export const designConfig = createDesignConfig()
75+
76+
/**
77+
* The `@antfu/design` semantic surface/text tokens a shadow-root surface
78+
* (hub-ui dock, json-render renderer module) needs guaranteed in its compiled
79+
* stylesheet. Each is a shortcut that expands to a base utility plus a
80+
* `.dark:` variant; safelisting the shortcut name makes the generator emit
81+
* both variants even when the class only reaches the extractor through a
82+
* `.dark`-prefixed or dynamically-assembled string it can't see. Wind3 bakes
83+
* these to concrete `rgb()`, so they stay self-contained inside the shadow
84+
* tree.
85+
*/
86+
/**
87+
* Rename Wind's internal `--un-*` custom properties to a private prefix in a
88+
* stylesheet destined for a **shadow root**.
89+
*
90+
* `@property` registrations are document-global regardless of where they're
91+
* declared, so a host page built with Wind4 registers `--un-bg-opacity` /
92+
* `--un-border-opacity` / `--un-text-opacity` (et al.) as
93+
* `@property { syntax: '<percentage>'; inherits: false }` for the whole
94+
* document — including inside our shadow tree. Our shadow CSS is Wind3, which
95+
* sets those same vars **unitless** (`--un-border-opacity: 0.13`), so the
96+
* global `<percentage>` registration makes every such declaration invalid and
97+
* the dependent `color-mix()` / `rgb(… / var(--un-*))` value collapses (a
98+
* visibly wrong border/background/text color).
99+
*
100+
* The shadow stylesheet sets and reads these vars entirely within itself, so
101+
* renaming every `--un-` to a per-surface prefix (`--un-jr-`, `--un-hub-`)
102+
* keeps it self-consistent while making it immune to whatever the host page
103+
* registered — the renamed names are distinct properties the host's
104+
* `@property --un-*` rules never match. Apply only to shadow-injected CSS
105+
* (`hub-ui` dock, `json-render-ui` renderer module) — the Vite-served SPAs own
106+
* their whole document and need no rename.
107+
*
108+
* @param css - The compiled shadow-root stylesheet.
109+
* @param prefix - The replacement for `--un-` (e.g. `--un-jr-`, `--un-hub-`).
110+
*/
111+
export function namespaceShadowCssVars(css: string, prefix: string): string {
112+
return css.replaceAll('--un-', prefix)
113+
}
114+
115+
export const shadowSurfaceSafelist: string[] = [
116+
'bg-base',
117+
'bg-secondary',
118+
'bg-active',
119+
'bg-hover',
120+
'color-base',
121+
'color-muted',
122+
'color-faint',
123+
'color-active',
124+
'border-base',
125+
]

docs/.vitepress/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ function guideItems(prefix: string) {
3636
{ text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` },
3737
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
3838
{ text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` },
39+
{ text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` },
40+
{ text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` },
3941
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
4042
] satisfies DefaultTheme.NavItemWithLink[]
4143
}

docs/errors/DF8108.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8108: Duplicate Renderer Module Type
6+
7+
## Message
8+
9+
> A renderer module is already registered for dock type "`{type}`"
10+
11+
## Cause
12+
13+
`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 `<base>__renderers/<type>.mjs` — so a second registration for the same type would be unreachable.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
renderers: [
20+
jsonRenderUiRenderer(),
21+
{ type: 'json-render', file: myOtherRenderer }, // ✗ duplicate type
22+
],
23+
})
24+
```
25+
26+
## Fix
27+
28+
- Keep one registration per dock type — pick the implementation you want the manifest to serve.
29+
- To override a manifest module for one specific client, register a renderer locally instead (`createDevframeClientHost({ renderers })`); local registrations take precedence.
30+
31+
## Source
32+
33+
- [`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.

docs/errors/DF8109.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8109: Renderer Module File Missing
6+
7+
## Message
8+
9+
> The renderer module registered for dock type "`{type}`" does not exist at "`{file}`"
10+
11+
## Cause
12+
13+
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 `<base>__renderers/<type>.mjs` — a missing bundle would make every client's lazy import 404 at mount time, so the hub fails fast at startup instead.
14+
15+
## Example
16+
17+
```ts
18+
initHub({
19+
renderers: [
20+
{ type: 'json-render', file: '/path/that/was/never/built.mjs' }, //
21+
],
22+
})
23+
```
24+
25+
## Fix
26+
27+
- Build the renderer package first — the bundle is a build artifact (e.g. `@devframes/json-render-ui`'s `dist/renderer/json-render.mjs`).
28+
- Prefer the package's registration helper over a hand-written path — `jsonRenderUiRenderer()` from `@devframes/json-render-ui/hub` resolves the shipped bundle for you.
29+
30+
## Source
31+
32+
- [`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.

0 commit comments

Comments
 (0)