Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` 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.
Expand All @@ -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-<number>` 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-<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.
- **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: '<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.
- **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.
- **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.
Expand Down
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
136 changes: 105 additions & 31 deletions design/uno.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Preset } from 'unocss'
import { fileURLToPath } from 'node:url'
import { presetAnthonyDesign } from '@antfu/design/unocss'
import {
Expand All @@ -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<any> | Preset<any>[]
}

// 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, { … }])`
Expand All @@ -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-<number>`, 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: '<percentage>'; 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 `<percentage>` 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',
]
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down
33 changes: 33 additions & 0 deletions docs/errors/DF8108.md
Original file line number Diff line number Diff line change
@@ -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 `<base>__renderers/<type>.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.
32 changes: 32 additions & 0 deletions docs/errors/DF8109.md
Original file line number Diff line number Diff line change
@@ -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 `<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.

## 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.
Loading
Loading