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
2 changes: 1 addition & 1 deletion .agents/03-stack-and-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co

## Generated artifacts under `src/`

Ahead-of-time build artifacts that live under `src/` - the shadow-root stylesheets in `packages/hub-ui/src/client/.generated/` and `packages/json-render-ui/src/.generated/` - are **generated, not committed** (`.generated` is gitignored). Each owning package builds its own with `pnpm run build:css`; three things guarantee the file is on disk before anything imports it: the root `postinstall` runs `turbo run build:css`, the Turbo `typecheck` task depends on both `build:css` tasks, and each package's `build` script chains `build:css` first. A new generated-under-`src` artifact MUST follow the same shape - its own build script, declared `outputs` in `turbo.json`, and a `typecheck` dependency - and MUST NOT be checked in: a minified single-line blob conflicts on every concurrent edit.
Ahead-of-time build artifacts that live under `src/` - the shadow-root stylesheets in `packages/hub-ui/src/client/.generated/`, `packages/hub-ui-onboard/src/client/.generated/` and `packages/json-render-ui/src/.generated/` - are **generated, not committed** (`.generated` is gitignored). Each owning package builds its own with `pnpm run build:css`; three things guarantee the file is on disk before anything imports it: the root `postinstall` runs `turbo run build:css`, the Turbo `typecheck` task depends on every `build:css` task, and each package's `build` script chains `build:css` first. A new generated-under-`src` artifact MUST follow the same shape - its own build script, declared `outputs` in `turbo.json`, and a `typecheck` dependency - and MUST NOT be checked in: a minified single-line blob conflicts on every concurrent edit.

## `starter/`

Expand Down
4 changes: 2 additions & 2 deletions .agents/06-design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ Each consumer's `uno.config.ts` composes the same stack: `presetAnthonyDesign({

## Wind4 by default, Wind3 for shadow roots

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 - 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 - 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.
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, `@devframes/hub-ui-onboard`'s floating button) MUST build on **`presetWind3()`** instead - 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 - 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 the shared `design/build-shadow-css.ts` pipeline, consumed by `packages/{hub-ui,json-render-ui}/scripts/build-css.ts`; the Vite `unocss/vite` path for standalone SPAs and Storybook is not affected):
Two shadow-root gotchas the ahead-of-time CSS builder MUST compensate for (both handled in the shared `design/build-shadow-css.ts` pipeline, consumed by `packages/{hub-ui,hub-ui-onboard,json-render-ui}/scripts/build-css.ts`; a surface that renders none of `@antfu/design`'s Vue components passes `scanDesignComponents: false` to keep its stylesheet small; 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.
Expand Down
3 changes: 2 additions & 1 deletion .agents/08-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

All node-side warnings and errors use structured diagnostics via [`nostics`](https://www.npmjs.com/package/nostics). Node-side code MUST NOT use raw `console.warn`, `console.error`, or `throw new Error` with ad-hoc messages - always define a coded diagnostic. Browser-only code is out of scope and keeps using `console.*` / `throw`.

Import `defineDiagnostics` (and `Diagnostic` for `instanceof` checks) from `devframe/utils/nostics`, never from `nostics` directly - it pre-wires devframe's ANSI console reporter, so a plugin's `diagnostics.ts` never builds its own reporter (`colors`, `ansiFormatter`) or depends on `nostics` itself.
Import `defineDiagnostics` (and `Diagnostic` for `instanceof` checks) from `devframe/utils/nostics`, never from `nostics` directly - it pre-wires devframe's ANSI console reporter, so a plugin's `diagnostics.ts` never builds its own reporter (`colors`, `ansiFormatter`) or depends on `nostics` itself. One exception: `@devframes/hub-ui-onboard` MUST stay free of `devframe` (a host ships it while devframe is not installed), so it imports `defineDiagnostics` and `createConsoleReporter` from `nostics` directly.

## Code ranges

Expand All @@ -16,6 +16,7 @@ Prefix: **`DF`**. Codes are sequential 4-digit numbers (e.g. `DF0033`) - check t
- `DF83xx` - messages
- `DF84xx` - commands
- `DF85xx` - built-in RPC commands
- `DF90xx` - `@devframes/hub-ui-onboard` (install, state file, hand-off)

## Adding a new error

Expand Down
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const alias = {
'@devframes/hub/types': r('hub/src/types/index.ts'),
'@devframes/hub': r('hub/src/index.ts'),
'@devframes/hub-ui': r('hub-ui/src/index.ts'),
'@devframes/hub-ui-onboard': r('hub-ui-onboard/src/index.ts'),
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
'@devframes/nuxt/single': r('nuxt/src/single.ts'),
'@devframes/nuxt/hub/client': r('nuxt/src/hub-client.ts'),
Expand Down
20 changes: 14 additions & 6 deletions design/build-shadow-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface BuildShadowCssOptions {
* shadow trees on the same host page never collide.
*/
varPrefix: string
/**
* Also scan `@antfu/design`'s Vue components so the classes they use ship
* in the stylesheet. Default `true`; a surface that renders none of those
* components turns it off to keep the stylesheet small.
*/
scanDesignComponents?: boolean
}

export interface BuildShadowCssResult {
Expand All @@ -63,7 +69,7 @@ export interface BuildShadowCssResult {
* exempt from the `no-console` lint rule) prints its own summary line.
*/
export async function buildShadowCss(options: BuildShadowCssOptions): Promise<BuildShadowCssResult> {
const { srcDir, globs, config, primaryRampPath, userStylePath, varPrefix } = options
const { srcDir, globs, config, primaryRampPath, userStylePath, varPrefix, scanDesignComponents = true } = options
const generatedCss = join(srcDir, '.generated/css.ts')

const require = createRequire(import.meta.url)
Expand All @@ -81,11 +87,13 @@ export async function buildShadowCss(options: BuildShadowCssOptions): Promise<Bu
// package's component sources too so those classes ship in the injected
// CSS.
const designComponentsDir = join(require.resolve('@antfu/design/package.json'), '..', 'components')
const designFiles = await glob('**/*.vue', {
cwd: designComponentsDir,
absolute: true,
ignore: IGNORE,
})
const designFiles = scanDesignComponents
? await glob('**/*.vue', {
cwd: designComponentsDir,
absolute: true,
ignore: IGNORE,
})
: []

const generator = await createGenerator(config)

Expand Down
4 changes: 4 additions & 0 deletions docs/content/1.guide/18.hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ interface DevframeHubUi {

To add a language, add its tag and native name to `packages/hub-ui/src/locales.ts`, translate a copy of `packages/hub-ui/src/client/i18n/locales/en.json` under that tag, and register the file in the `messages` map in `packages/hub-ui/src/client/i18n/index.ts`. The i18n test fails on a missing key or a changed `{slot}`.

## Onboarding without the hub installed

A host that wants DevTools as an opt-in install ships `@devframes/hub-ui-onboard` instead of the hub: a 20 kB floating button at the same `<base>embedded.js` URL, an Install action that runs the project's package manager, and an `onInstalled` hook that hands `base` to the real hub in the same process. See [Opt-in DevTools with Onboarding](/guide/hub-ui-onboard).

## Renderer modules

A dock type's renderer (e.g. [JSON-Render](/guide/json-render)) composes via `initHub({ renderers })`. Each registration `{ type, file, importName? }` (`file` = a prebuilt ES module exporting a `DockRenderer`) is served at `<base>__renderers/<type>.mjs` and published into the `devframe:dock-renderers` manifest; client runtimes import it lazily on first mount:
Expand Down
187 changes: 187 additions & 0 deletions docs/content/1.guide/23.hub-ui-onboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
---
title: 'Opt-in DevTools with Onboarding'
navigation:
icon: i-lucide-download
description: '@devframes/hub-ui-onboard lets a host ship a 20 kB floating button instead of the hub, install the hub on demand, and hand the hub base to it without a restart.'
---

`@devframes/hub-ui-onboard` lets a host ship a 20 kB floating button instead of the hub, install the hub on demand, and hand the hub base to it in the same process.

## Why

A hub UI provider, its Vue runtime, and the devframes it mounts add tens of megabytes to a framework's install size. A host that wants DevTools as an opt-in can move those packages to optional peers and ship only this package: one browser file, a handful of routes, and three small runtime dependencies (a package-manager detector, a process runner, the diagnostics library). The user still discovers DevTools through the usual floating button; the first click installs them.

## What the user sees

The button sits at the bottom left, dimmed until hovered. It opens a panel with the product name and logo, one sentence, the exact command the install will run (for example `pnpm add -D @nuxt/devtools`), and three actions:

- **Install** runs the command in the project. The panel shows progress, then either the real dock replaces the button in place, or the panel asks for a restart.
- **Hide for now** removes the button for the current browser tab.
- **Disable entirely** writes a state file so the host stops injecting the button on every later start.

The panel follows the shared design tokens, the host's `primaryColor`, and the user's hub color scheme, so the swap to the real dock looks like one product.

## Create the onboarding

```ts
import { createOnboarding } from '@devframes/hub-ui-onboard'

const onboarding = createOnboarding({
packages: ['@devframes/hub', '@devframes/hub-ui'],
branding: { productName: 'My DevTools', logo: '/logo.svg', primaryColor: '#646cff' },
})
```

`createOnboarding()` returns four things:

- `handler(request)`: a web-standard `Request => Response` handler for every path under `base` (default `/__devframes/`).
- `nodeMiddleware(req, res, next)`: the same handler as Connect middleware for Vite, Express, Fastify with `@fastify/middie`, or a plain `node:http` server. It calls `next()` for paths outside `base`.
- `scriptSrc`: `<base>embedded.js`, the URL to inject as `<script type="module">`.
- `disabled` and `installed`: what the host needs to decide whether to inject the script at all (below).

`packages` are package specs as the package manager accepts them. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn`, `bun`, `deno`), `dev: true` adds `-D`, and `cwd` (default `process.cwd()`) is the project that receives the dependency. In a workspace, point `cwd` at the package that runs the dev server. The packages are fixed at creation, and a `POST` from another origin is refused.

## Hand the base to the hub

Return a handler from `onInstalled` and the hub takes over `base` in the same process. The button then loads the real `embedded.js` from that handler and removes itself.

```ts
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'

const require = createRequire(join(cwd, 'package.json'))
const load = <T>(id: string): Promise<T> => import(pathToFileURL(require.resolve(id)).href)

const onboarding = createOnboarding({
cwd,
packages: ['@devframes/hub', '@devframes/hub-ui'],
async onInstalled() {
const [{ initHub }, { createUi }] = await Promise.all([
load<typeof import('@devframes/hub/initiate')>('@devframes/hub/initiate'),
load<typeof import('@devframes/hub-ui')>('@devframes/hub-ui'),
])
const hub = initHub({ base: '/__devframes/', cwd, server: httpServer, ui: createUi(), devframes: [] })
return hub.handler
},
})
```

Resolve the new packages from the project's `package.json`, as above. Under pnpm they are dependencies of the project, so a bare `import('@devframes/hub')` from the host's own file fails. Pass the live `node:http` server so the hub attaches its WebSocket to it; `initHub` accepts a server after it started listening.

`onInstalled` is also how the onboarding short-circuits. When every named package is already in `node_modules` at creation, `onboarding.installed` is `true`, `onInstalled` runs on the first request, and the user never sees the button. A host can therefore mount the onboarding unconditionally during development: it serves the hub when the packages exist and the button when they are missing.

Without `onInstalled`, or when it returns nothing, the panel reports the install and asks for a restart. The next start finds the packages installed.

## When to inject the button

Inject `scriptSrc` only when `onboarding.disabled` is `false`. The user set that flag with "Disable entirely"; it lives in `<stateDir>/hub-ui-onboard.json`, default `<cwd>/node_modules/.devframe`.

Mount the onboarding only when the user did not set the host's own devtools option. An explicit `devtools: true` means the host installs or requires DevTools itself; an explicit `devtools: false` means no button. The onboarding covers the unset case, and a user who disabled it from the panel turns it back on by setting the option.

## Hosts

### Vite

A plugin mounts the middleware and injects the tag. `examples/hub-onboard-vite` is the complete version with the hub hand-off.

```ts [vite.config.ts]
import type { Plugin } from 'vite'
import { createOnboarding } from '@devframes/hub-ui-onboard'

function hubOnboarding(): Plugin {
const onboarding = createOnboarding({ packages: ['@devframes/hub', '@devframes/hub-ui'] })
return {
name: 'hub-onboarding',
apply: 'serve',
configureServer(server) {
server.middlewares.use(onboarding.nodeMiddleware)
},
transformIndexHtml() {
return onboarding.disabled
? []
: [{ tag: 'script', attrs: { type: 'module', src: onboarding.scriptSrc }, injectTo: 'body' }]
},
}
}
```

### Nuxt

Nuxt runs Vite, so a module reuses the plugin above and adds the tag to `app.head`. Gate it on the dev server, and skip it when the user set your own devtools option.

```ts [modules/devtools-onboarding.ts]
import { createOnboarding } from '@devframes/hub-ui-onboard'
import { addVitePlugin, defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
setup(_, nuxt) {
if (!nuxt.options.dev)
return
const onboarding = createOnboarding({
cwd: nuxt.options.rootDir,
packages: ['@nuxt/devtools'],
branding: { productName: 'Nuxt DevTools', primaryColor: '#00dc82' },
})
addVitePlugin({
name: 'devtools-onboarding',
configureServer: server => server.middlewares.use(onboarding.nodeMiddleware),
})
if (!onboarding.disabled)
(nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc })
},
})
```

### Next.js

A route handler forwards `Request` objects, and the root layout renders the tag.

```ts [app/__devframes/[[...path]]/route.ts]
import { onboarding } from '../../../devtools-onboarding'

export const GET = (request: Request) => onboarding.handler(request)
export const POST = (request: Request) => onboarding.handler(request)
```

```tsx [app/layout.tsx]
import { onboarding } from '../devtools-onboarding'

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{process.env.NODE_ENV === 'development' && !onboarding.disabled && (
<script type="module" src={onboarding.scriptSrc} />
)}
</body>
</html>
)
}
```

A Next route handler cannot accept a WebSocket upgrade, so a hub started from `onInstalled` uses a side-car socket (`ws: { sidecar: true }`); see [Next](/frameworks/next#mounting-a-hub).

### Any Node server

```ts
import { createServer } from 'node:http'

createServer((req, res) => {
onboarding.nodeMiddleware(req, res, () => {
res.statusCode = 404
res.end()
})
}).listen(3000)
```

Frameworks with a `Request => Response` surface (Hono, Nitro, Deno) mount `onboarding.handler` under `base` instead.

## Strings and branding

`branding` takes `productName`, `logo` (one URL or `{ light, dark }`) and `primaryColor`, the same three fields hub-ui's `DevframeBranding` starts with. Every string in the panel derives from `productName` and is overridable through `messages`; the keys are listed in the [Hub API reference](/references/hub-api#onboarding-options-and-routes).

## Errors

The Node side reports through `DF9000` to `DF9004`. An install failure or a throwing `onInstalled` also reaches the panel as `{ state: 'error', error: { code, message } }`, with a Retry button. See the [error reference](/errors).
1 change: 1 addition & 0 deletions docs/content/1.guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,5 @@ The CLI adapter serves the SPA at `/`; embedded in a host framework (`vite`, `em
- [The Standard Handler](/adapters/initiate): mount into any host framework
- [Adapters](/adapters): convenience entry points
- [Hub](/guide/hub): compose many devframes
- [Opt-in DevTools with Onboarding](/guide/hub-ui-onboard): ship a 20 kB button and install the hub on demand
- [Pluggable, Extensible, and Playful DevTools](/posts/pluggable-extensible-playful-devtools): the vision and story behind Devframe
Loading
Loading