Skip to content

Commit cc6907c

Browse files
antfubotantfu
andauthored
feat(hub-ui-onboard): tiny stand-in that installs the hub UI on demand (#436)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent f0cca99 commit cc6907c

46 files changed

Lines changed: 1682 additions & 12 deletions

Some content is hidden

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

‎.agents/03-stack-and-commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
4343

4444
## Generated artifacts under `src/`
4545

46-
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.
46+
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.
4747

4848
## `starter/`
4949

‎.agents/06-design-system.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ Each consumer's `uno.config.ts` composes the same stack: `presetAnthonyDesign({
1212

1313
## Wind4 by default, Wind3 for shadow roots
1414

15-
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.
15+
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.
1616

17-
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):
17+
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):
1818

1919
- **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.
2020
- **`--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.

‎.agents/08-diagnostics.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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`.
44

5-
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.
5+
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.
66

77
## Code ranges
88

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

2021
## Adding a new error
2122

‎alias.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const alias = {
6161
'@devframes/hub/types': r('hub/src/types/index.ts'),
6262
'@devframes/hub': r('hub/src/index.ts'),
6363
'@devframes/hub-ui': r('hub-ui/src/index.ts'),
64+
'@devframes/hub-ui-onboard': r('hub-ui-onboard/src/index.ts'),
6465
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
6566
'@devframes/nuxt/single': r('nuxt/src/single.ts'),
6667
'@devframes/nuxt/hub/client': r('nuxt/src/hub-client.ts'),

‎design/build-shadow-css.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface BuildShadowCssOptions {
4242
* shadow trees on the same host page never collide.
4343
*/
4444
varPrefix: string
45+
/**
46+
* Also scan `@antfu/design`'s Vue components so the classes they use ship
47+
* in the stylesheet. Default `true`; a surface that renders none of those
48+
* components turns it off to keep the stylesheet small.
49+
*/
50+
scanDesignComponents?: boolean
4551
}
4652

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

6975
const require = createRequire(import.meta.url)
@@ -81,11 +87,13 @@ export async function buildShadowCss(options: BuildShadowCssOptions): Promise<Bu
8187
// package's component sources too so those classes ship in the injected
8288
// CSS.
8389
const designComponentsDir = join(require.resolve('@antfu/design/package.json'), '..', 'components')
84-
const designFiles = await glob('**/*.vue', {
85-
cwd: designComponentsDir,
86-
absolute: true,
87-
ignore: IGNORE,
88-
})
90+
const designFiles = scanDesignComponents
91+
? await glob('**/*.vue', {
92+
cwd: designComponentsDir,
93+
absolute: true,
94+
ignore: IGNORE,
95+
})
96+
: []
8997

9098
const generator = await createGenerator(config)
9199

‎docs/content/1.guide/18.hub-initiate.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ interface DevframeHubUi {
6363

6464
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}`.
6565

66+
## Onboarding without the hub installed
67+
68+
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).
69+
6670
## Renderer modules
6771

6872
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:
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
---
2+
title: 'Opt-in DevTools with Onboarding'
3+
navigation:
4+
icon: i-lucide-download
5+
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.'
6+
---
7+
8+
`@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.
9+
10+
## Why
11+
12+
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.
13+
14+
## What the user sees
15+
16+
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:
17+
18+
- **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.
19+
- **Hide for now** removes the button for the current browser tab.
20+
- **Disable entirely** writes a state file so the host stops injecting the button on every later start.
21+
22+
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.
23+
24+
## Create the onboarding
25+
26+
```ts
27+
import { createOnboarding } from '@devframes/hub-ui-onboard'
28+
29+
const onboarding = createOnboarding({
30+
packages: ['@devframes/hub', '@devframes/hub-ui'],
31+
branding: { productName: 'My DevTools', logo: '/logo.svg', primaryColor: '#646cff' },
32+
})
33+
```
34+
35+
`createOnboarding()` returns four things:
36+
37+
- `handler(request)`: a web-standard `Request => Response` handler for every path under `base` (default `/__devframes/`).
38+
- `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`.
39+
- `scriptSrc`: `<base>embedded.js`, the URL to inject as `<script type="module">`.
40+
- `disabled` and `installed`: what the host needs to decide whether to inject the script at all (below).
41+
42+
`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.
43+
44+
## Hand the base to the hub
45+
46+
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.
47+
48+
```ts
49+
import { createRequire } from 'node:module'
50+
import { join } from 'node:path'
51+
import { pathToFileURL } from 'node:url'
52+
53+
const require = createRequire(join(cwd, 'package.json'))
54+
const load = <T>(id: string): Promise<T> => import(pathToFileURL(require.resolve(id)).href)
55+
56+
const onboarding = createOnboarding({
57+
cwd,
58+
packages: ['@devframes/hub', '@devframes/hub-ui'],
59+
async onInstalled() {
60+
const [{ initHub }, { createUi }] = await Promise.all([
61+
load<typeof import('@devframes/hub/initiate')>('@devframes/hub/initiate'),
62+
load<typeof import('@devframes/hub-ui')>('@devframes/hub-ui'),
63+
])
64+
const hub = initHub({ base: '/__devframes/', cwd, server: httpServer, ui: createUi(), devframes: [] })
65+
return hub.handler
66+
},
67+
})
68+
```
69+
70+
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.
71+
72+
`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.
73+
74+
Without `onInstalled`, or when it returns nothing, the panel reports the install and asks for a restart. The next start finds the packages installed.
75+
76+
## When to inject the button
77+
78+
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`.
79+
80+
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.
81+
82+
## Hosts
83+
84+
### Vite
85+
86+
A plugin mounts the middleware and injects the tag. `examples/hub-onboard-vite` is the complete version with the hub hand-off.
87+
88+
```ts [vite.config.ts]
89+
import type { Plugin } from 'vite'
90+
import { createOnboarding } from '@devframes/hub-ui-onboard'
91+
92+
function hubOnboarding(): Plugin {
93+
const onboarding = createOnboarding({ packages: ['@devframes/hub', '@devframes/hub-ui'] })
94+
return {
95+
name: 'hub-onboarding',
96+
apply: 'serve',
97+
configureServer(server) {
98+
server.middlewares.use(onboarding.nodeMiddleware)
99+
},
100+
transformIndexHtml() {
101+
return onboarding.disabled
102+
? []
103+
: [{ tag: 'script', attrs: { type: 'module', src: onboarding.scriptSrc }, injectTo: 'body' }]
104+
},
105+
}
106+
}
107+
```
108+
109+
### Nuxt
110+
111+
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.
112+
113+
```ts [modules/devtools-onboarding.ts]
114+
import { createOnboarding } from '@devframes/hub-ui-onboard'
115+
import { addVitePlugin, defineNuxtModule } from '@nuxt/kit'
116+
117+
export default defineNuxtModule({
118+
setup(_, nuxt) {
119+
if (!nuxt.options.dev)
120+
return
121+
const onboarding = createOnboarding({
122+
cwd: nuxt.options.rootDir,
123+
packages: ['@nuxt/devtools'],
124+
branding: { productName: 'Nuxt DevTools', primaryColor: '#00dc82' },
125+
})
126+
addVitePlugin({
127+
name: 'devtools-onboarding',
128+
configureServer: server => server.middlewares.use(onboarding.nodeMiddleware),
129+
})
130+
if (!onboarding.disabled)
131+
(nuxt.options.app.head.script ??= []).push({ type: 'module', src: onboarding.scriptSrc })
132+
},
133+
})
134+
```
135+
136+
### Next.js
137+
138+
A route handler forwards `Request` objects, and the root layout renders the tag.
139+
140+
```ts [app/__devframes/[[...path]]/route.ts]
141+
import { onboarding } from '../../../devtools-onboarding'
142+
143+
export const GET = (request: Request) => onboarding.handler(request)
144+
export const POST = (request: Request) => onboarding.handler(request)
145+
```
146+
147+
```tsx [app/layout.tsx]
148+
import { onboarding } from '../devtools-onboarding'
149+
150+
export default function RootLayout({ children }: { children: React.ReactNode }) {
151+
return (
152+
<html lang="en">
153+
<body>
154+
{children}
155+
{process.env.NODE_ENV === 'development' && !onboarding.disabled && (
156+
<script type="module" src={onboarding.scriptSrc} />
157+
)}
158+
</body>
159+
</html>
160+
)
161+
}
162+
```
163+
164+
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).
165+
166+
### Any Node server
167+
168+
```ts
169+
import { createServer } from 'node:http'
170+
171+
createServer((req, res) => {
172+
onboarding.nodeMiddleware(req, res, () => {
173+
res.statusCode = 404
174+
res.end()
175+
})
176+
}).listen(3000)
177+
```
178+
179+
Frameworks with a `Request => Response` surface (Hono, Nitro, Deno) mount `onboarding.handler` under `base` instead.
180+
181+
## Strings and branding
182+
183+
`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).
184+
185+
## Errors
186+
187+
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).

‎docs/content/1.guide/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,5 @@ The CLI adapter serves the SPA at `/`; embedded in a host framework (`vite`, `em
178178
- [The Standard Handler](/adapters/initiate): mount into any host framework
179179
- [Adapters](/adapters): convenience entry points
180180
- [Hub](/guide/hub): compose many devframes
181+
- [Opt-in DevTools with Onboarding](/guide/hub-ui-onboard): ship a 20 kB button and install the hub on demand
181182
- [Pluggable, Extensible, and Playful DevTools](/posts/pluggable-extensible-playful-devtools): the vision and story behind Devframe

0 commit comments

Comments
 (0)