From 6013aa3f176a908a60693d364be7d893655aa947 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 6 Oct 2026 02:16:41 +0000 Subject: [PATCH 1/5] feat(hub-ui-onboard): add a tiny stand-in that installs the hub UI on demand A host can now make DevTools an opt-in install without losing discovery. `@devframes/hub-ui-onboard` ships one ~20 kB browser file served at the hub base, offers Install / Disable, runs the project's package manager with the configured packages, and hands the base to the real hub in the same process when the host returns a handler from `onInstalled`. It depends on neither `devframe` nor `@devframes/hub`; node-side errors use the new `DF90xx` range through `nostics` directly. --- .agents/03-stack-and-commands.md | 2 +- .agents/06-design-system.md | 4 +- .agents/08-diagnostics.md | 3 +- alias.ts | 1 + design/build-shadow-css.ts | 20 +- docs/content/1.guide/18.hub-initiate.md | 24 ++ docs/content/6.errors/DF9000.md | 28 +++ docs/content/6.errors/DF9001.md | 20 ++ docs/content/6.errors/DF9002.md | 20 ++ docs/content/6.errors/DF9003.md | 20 ++ docs/content/6.errors/DF9004.md | 20 ++ docs/content/8.references/1.terms.md | 1 + examples/hub-onboard-vite/README.md | 23 ++ examples/hub-onboard-vite/index.html | 18 ++ examples/hub-onboard-vite/package.json | 17 ++ examples/hub-onboard-vite/tsconfig.json | 9 + examples/hub-onboard-vite/vite.config.ts | 69 ++++++ packages/hub-ui-onboard/package.json | 50 ++++ packages/hub-ui-onboard/scripts/build-css.ts | 18 ++ packages/hub-ui-onboard/src/client/index.ts | 201 ++++++++++++++++ packages/hub-ui-onboard/src/client/logo.ts | 2 + packages/hub-ui-onboard/src/client/style.css | 57 +++++ packages/hub-ui-onboard/src/diagnostics.ts | 33 +++ packages/hub-ui-onboard/src/index.ts | 217 ++++++++++++++++++ packages/hub-ui-onboard/src/install.ts | 61 +++++ packages/hub-ui-onboard/src/types.ts | 73 ++++++ .../test/fixtures/pkg/package.json | 5 + .../hub-ui-onboard/test/onboarding.test.ts | 154 +++++++++++++ packages/hub-ui-onboard/test/size.test.ts | 25 ++ packages/hub-ui-onboard/tsconfig.json | 9 + packages/hub-ui-onboard/tsdown.config.ts | 16 ++ packages/hub-ui-onboard/uno.config.ts | 23 ++ packages/hub-ui-onboard/vite.client.config.ts | 20 ++ packages/hub-ui-onboard/vitest.config.ts | 9 + pnpm-lock.yaml | 47 ++++ pnpm-workspace.yaml | 2 + .../hub-ui-onboard/index.snapshot.d.ts | 58 +++++ .../hub-ui-onboard/index.snapshot.js | 6 + tsconfig.base.json | 3 + turbo.json | 11 +- vitest.config.ts | 1 + 41 files changed, 1389 insertions(+), 11 deletions(-) create mode 100644 docs/content/6.errors/DF9000.md create mode 100644 docs/content/6.errors/DF9001.md create mode 100644 docs/content/6.errors/DF9002.md create mode 100644 docs/content/6.errors/DF9003.md create mode 100644 docs/content/6.errors/DF9004.md create mode 100644 examples/hub-onboard-vite/README.md create mode 100644 examples/hub-onboard-vite/index.html create mode 100644 examples/hub-onboard-vite/package.json create mode 100644 examples/hub-onboard-vite/tsconfig.json create mode 100644 examples/hub-onboard-vite/vite.config.ts create mode 100644 packages/hub-ui-onboard/package.json create mode 100644 packages/hub-ui-onboard/scripts/build-css.ts create mode 100644 packages/hub-ui-onboard/src/client/index.ts create mode 100644 packages/hub-ui-onboard/src/client/logo.ts create mode 100644 packages/hub-ui-onboard/src/client/style.css create mode 100644 packages/hub-ui-onboard/src/diagnostics.ts create mode 100644 packages/hub-ui-onboard/src/index.ts create mode 100644 packages/hub-ui-onboard/src/install.ts create mode 100644 packages/hub-ui-onboard/src/types.ts create mode 100644 packages/hub-ui-onboard/test/fixtures/pkg/package.json create mode 100644 packages/hub-ui-onboard/test/onboarding.test.ts create mode 100644 packages/hub-ui-onboard/test/size.test.ts create mode 100644 packages/hub-ui-onboard/tsconfig.json create mode 100644 packages/hub-ui-onboard/tsdown.config.ts create mode 100644 packages/hub-ui-onboard/uno.config.ts create mode 100644 packages/hub-ui-onboard/vite.client.config.ts create mode 100644 packages/hub-ui-onboard/vitest.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub-ui-onboard/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/hub-ui-onboard/index.snapshot.js diff --git a/.agents/03-stack-and-commands.md b/.agents/03-stack-and-commands.md index ecec5ba00..6b48e0f88 100644 --- a/.agents/03-stack-and-commands.md +++ b/.agents/03-stack-and-commands.md @@ -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/` diff --git a/.agents/06-design-system.md b/.agents/06-design-system.md index 2870a002a..2ddc30ab9 100644 --- a/.agents/06-design-system.md +++ b/.agents/06-design-system.md @@ -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: '' }` 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. diff --git a/.agents/08-diagnostics.md b/.agents/08-diagnostics.md index 8dcbfb1db..5986e2057 100644 --- a/.agents/08-diagnostics.md +++ b/.agents/08-diagnostics.md @@ -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 @@ -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 diff --git a/alias.ts b/alias.ts index 6940f8e2f..b128d0ad5 100644 --- a/alias.ts +++ b/alias.ts @@ -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'), diff --git a/design/build-shadow-css.ts b/design/build-shadow-css.ts index c68f86082..686056ef7 100644 --- a/design/build-shadow-css.ts +++ b/design/build-shadow-css.ts @@ -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 { @@ -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 { - 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) @@ -81,11 +87,13 @@ export async function buildShadowCss(options: BuildShadowCssOptions): Promiseembedded.js`, the same URL the hub uses, plus three routes under `__onboard/`: `GET status`, `POST install` and `POST disable`. + +```ts +import { createOnboarding } from '@devframes/hub-ui-onboard' + +const onboarding = createOnboarding({ + packages: ['@devframes/hub', '@devframes/hub-ui'], + branding: { productName: 'My DevTools', primaryColor: '#646cff' }, + async onInstalled() { + // Resolve from the project root; under pnpm the new packages are only reachable from there. + const hub = await startHub() + return hub.handler // every later request at goes here, no restart + }, +}) + +server.middlewares.use(onboarding.nodeMiddleware) +if (!onboarding.disabled) + injectScript(onboarding.scriptSrc) +``` + +Install runs the project's package manager (detected from the lockfile) with the configured `packages` only; the request body controls nothing, and a cross-origin `POST` is refused. The button polls `status` once a second and, when `onInstalled` returned a handler, loads the real `embedded.js` and removes itself. Without a handler it asks the user to restart. Disable writes `/hub-ui-onboard.json` (default `node_modules/.devframe`); the host reads `onboarding.disabled` on the next start and injects nothing. Mount the onboarding only when the user did not set the host's own devtools option, so an explicit `devtools: true` or `false` in the host configuration always wins. [`examples/hub-onboard-vite`](https://github.com/devframes/devframe/tree/main/examples/hub-onboard-vite) shows the whole flow on Vite. + ## 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 `__renderers/.mjs` and published into the `devframe:dock-renderers` manifest; client runtimes import it lazily on first mount: diff --git a/docs/content/6.errors/DF9000.md b/docs/content/6.errors/DF9000.md new file mode 100644 index 000000000..409c1a392 --- /dev/null +++ b/docs/content/6.errors/DF9000.md @@ -0,0 +1,28 @@ +--- +title: 'DF9000: No Packages to Install' +description: '`createOnboarding()` received no packages to install.' +--- + +## Message + +> `createOnboarding()` received no packages to install. + +## Cause + +The Install button runs one package-manager command built from `packages`. With an empty list there is nothing for it to do. + +## Example + +```ts +import { createOnboarding } from '@devframes/hub-ui-onboard' + +createOnboarding({ packages: [] }) // ✗ throws DF9000 +``` + +## Fix + +Pass at least one package spec, for example `packages: ['@nuxt/devtools']`. + +## Source + +- [`packages/hub-ui-onboard/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/index.ts): `createOnboarding` throws this before it reads any other option. diff --git a/docs/content/6.errors/DF9001.md b/docs/content/6.errors/DF9001.md new file mode 100644 index 000000000..f9f827a99 --- /dev/null +++ b/docs/content/6.errors/DF9001.md @@ -0,0 +1,20 @@ +--- +title: 'DF9001: Install Command Failed' +description: 'The package manager exited with a non-zero code.' +--- + +## Message + +> `{command}` exited with code `{exitCode}`. + +## Cause + +The Install button ran the project's package manager (`npm`, `pnpm`, `yarn`, `bun` or `deno`, detected from the lockfile) and it failed. The last lines of its error output follow the message. Common reasons: no network, a package name that does not exist, or a package manager that is not on `PATH`. + +## Fix + +Run the command shown in the panel in a terminal to see the full output. Fix the cause, then click Retry. The panel keeps the error until the next attempt. + +## Source + +- [`packages/hub-ui-onboard/src/install.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/install.ts): `runInstall` throws this when the process exits with a non-zero code. The status route reports it as `{ state: 'error', error: { code, message } }`. diff --git a/docs/content/6.errors/DF9002.md b/docs/content/6.errors/DF9002.md new file mode 100644 index 000000000..c459fe671 --- /dev/null +++ b/docs/content/6.errors/DF9002.md @@ -0,0 +1,20 @@ +--- +title: 'DF9002: Installed Package Not Found' +description: 'The install finished, but a package is not in the project node_modules.' +--- + +## Message + +> The install finished, but "`{name}`" is not in `{cwd}`/node_modules. + +## Cause + +The package manager exited with code 0, but `/node_modules/` does not exist. This happens when `cwd` points at a directory other than the one the package manager installed into, for example the workspace root instead of the package that runs the dev server. + +## Fix + +Set `cwd` to the project that receives the dependency. In a workspace, that is the package whose dev server mounts the onboarding handler. + +## Source + +- [`packages/hub-ui-onboard/src/install.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/install.ts): `runInstall` checks each named package after the command succeeds. Path, URL and alias specs are not checked, because their installed name is not in the spec. diff --git a/docs/content/6.errors/DF9003.md b/docs/content/6.errors/DF9003.md new file mode 100644 index 000000000..a5cf346a3 --- /dev/null +++ b/docs/content/6.errors/DF9003.md @@ -0,0 +1,20 @@ +--- +title: 'DF9003: onInstalled Threw' +description: '`onInstalled` threw after the packages were installed.' +--- + +## Message + +> `onInstalled` threw after the packages were installed. + +## Cause + +The packages are installed. The host's `onInstalled` callback, which usually imports the new packages and starts the hub, threw. The original error is attached as `cause`. + +## Fix + +Read the `cause`. A frequent one is a module that cannot be resolved: resolve the new packages from the project root (`createRequire(join(cwd, 'package.json'))`), not from the config file, because under pnpm they are only reachable from there. A restart of the dev server also loads the installed packages. + +## Source + +- [`packages/hub-ui-onboard/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/index.ts): `install()` reports this and sets `state: 'error'` when `onInstalled` rejects. diff --git a/docs/content/6.errors/DF9004.md b/docs/content/6.errors/DF9004.md new file mode 100644 index 000000000..47f6bdb12 --- /dev/null +++ b/docs/content/6.errors/DF9004.md @@ -0,0 +1,20 @@ +--- +title: 'DF9004: Onboarding State File Unreadable' +description: 'The onboarding state file could not be read or written.' +--- + +## Message + +> The onboarding state file `{file}` could not be read or written. + +## Cause + +`createOnboarding()` reads `/hub-ui-onboard.json` at start to learn whether the user disabled DevTools, and the Disable button writes it. The file exists but is not valid JSON, or the directory is not writable. + +## Fix + +Delete the file if it is corrupt. If the directory is read-only, pass another `stateDir`. The default is `/node_modules/.devframe`. + +## Source + +- [`packages/hub-ui-onboard/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/index.ts): `readDisabled` warns and treats the state as not disabled; `disable()` answers the request with status 500 and this code. diff --git a/docs/content/8.references/1.terms.md b/docs/content/8.references/1.terms.md index d53d5703f..dfbc704d7 100644 --- a/docs/content/8.references/1.terms.md +++ b/docs/content/8.references/1.terms.md @@ -20,6 +20,7 @@ Every concept in these docs has exactly one name. This page fixes that vocabular | **opt-in package** | A capability shipped as its own package and added when needed. | `@devframes/json-render` | | **hub** | The composition layer that puts many devframes behind one handler; *a hub* is one `initHub()` instance. | `@devframes/hub`, `initHub()` | | **hub UI provider** | A hub UI implementation: the node-side `ui` slot plus the browser-side context contract. `@devframes/hub-ui` is the reference hub UI provider. | `initHub({ ui })` | +| **onboarding** | The stand-in a host ships when no hub UI provider is installed: a floating button that installs the configured packages and hands the hub base to the real hub. `@devframes/hub-ui-onboard`. Not a dock entry; the `launcher` dock entry type is unrelated. | `createOnboarding()` | ## Node side diff --git a/examples/hub-onboard-vite/README.md b/examples/hub-onboard-vite/README.md new file mode 100644 index 000000000..4cdc2d9c8 --- /dev/null +++ b/examples/hub-onboard-vite/README.md @@ -0,0 +1,23 @@ +# hub-onboard-vite + +A Vite host that ships only `@devframes/hub-ui-onboard` (about 20 kB in the browser) and installs the hub when the user asks for it. + +```sh +pnpm --filter hub-onboard-vite dev +``` + +Open the printed URL. The button at the bottom left opens a panel with two actions: + +- Install runs `pnpm add -D @devframes/hub @devframes/hub-ui @devframes/plugin-git` in this directory. When it finishes, the host starts the real hub on `/__devframes/` in the same process and the button swaps itself for the floating dock. +- Disable writes `node_modules/.devframe/hub-ui-onboard.json`. The host reads `onboarding.disabled` on the next start and injects no script. + +Install changes this example's `package.json` and the lockfile. Run `git checkout -- examples/hub-onboard-vite pnpm-lock.yaml` and delete the state file to reset the demo. + +## How it works + +[`vite.config.ts`](./vite.config.ts) holds the whole integration: + +- `createOnboarding({ packages, branding, onInstalled })` returns `nodeMiddleware`, `scriptSrc` and `disabled`. +- `server.middlewares.use(onboarding.nodeMiddleware)` serves `/__devframes/embedded.js` and the `/__devframes/__onboard/*` routes. +- `transformIndexHtml` injects `