Skip to content

Commit 2fe5e18

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/remote-client-assets
# Conflicts: # docs/errors/DF0058.md # packages/devframe/src/node/diagnostics.ts # packages/devframe/src/node/host-h3.ts # tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts # tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts # tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts # tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts
2 parents e0b6273 + 3afb315 commit 2fe5e18

98 files changed

Lines changed: 834 additions & 719 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 & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Positioning
44

5-
**`devframe`** is the framework-neutral container for one devtool integration, portable across viewers. Build a single tool (its RPC, its SPA, its diagnostics, its CLI/build/spa/embedded outputs) without caring how it'll be displayed. A devframe app runs standalone (CLI, static deploy, embedded SPA) just as well as it mounts inside a hub.
5+
**`devframe`** is the framework-neutral container for one devtool integration, portable across viewers. Build a single tool (its RPC, its SPA, its diagnostics, its CLI/build/embedded outputs) without caring how it'll be displayed. A devframe app runs standalone (CLI, static deploy, embedded SPA) just as well as it mounts inside a hub.
66

77
**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI - implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. It does ship a **headless client runtime** (`createDevframeClientHost()` from `@devframes/hub/client`): booted in the host page, it assembles the shared `DevframeClientContext` (panel, docks, commands, when) and imports each dock entry's client script (`action` / `custom-render` / iframe `clientScript`) into that page - how a plugin like the a11y inspector runs code inside the page being inspected. See `examples/hub-vite/` for a working ~120-line Vite host demonstrating the protocol end to end.
88

@@ -62,7 +62,7 @@ All five built-in plugins - and every example under `examples/` - share one desi
6262

6363
- **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).
6464
- **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.
65-
- **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):
65+
- **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 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):
6666
- **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.
6767
- **`--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.
6868
- **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.
@@ -79,8 +79,8 @@ These reinforce devframe's positioning as "the container for one devtool integra
7979

8080
- **Single-integration scope.** Devframe describes one tool. If a feature only makes sense when multiple tools share a UI - docking, a unified command palette, cross-tool toasts, terminal aggregation - it belongs in a hub package, not here.
8181
- **Headless by default.** No default startup banners, no opinionated logging to stdout, no default styling. Provide hooks (`onReady`, `cli.configure`, etc.); let the application print its own branding. Structured diagnostics via `nostics` are fine - ad-hoc `console.log`s baked into adapters are not.
82-
- **Mount path depends on adapter context.** Given `id: 'foo'`, the default mount path is `/__foo/` for *hosted* adapters (`vite`, `embedded`) and `/` for *standalone* adapters (`cli`, `spa`, `build`). Authors override via `DevframeDefinition.basePath`. Don't hardcode mount paths in adapter code paths that may run standalone.
83-
- **SPAs own their basePath at runtime.** Build SPAs with relative asset paths (`vite.base: './'`); discover the effective base in the browser from the executing script's location / `document.baseURI`. `createBuild` / `createSpa` copy SPA output verbatim - no HTML rewriting, no build-time `--base` injection. The client (`connectDevframe`) resolves `.connection.json` relative to the runtime base automatically.
82+
- **Mount path depends on adapter context.** Given `id: 'foo'`, the default mount path is `/__foo/` for *hosted* adapters (`vite`, `embedded`) and `/` for *standalone* adapters (`cli`, `build`). Authors override via `DevframeDefinition.basePath`. Don't hardcode mount paths in adapter code paths that may run standalone.
83+
- **SPAs own their basePath at runtime.** Build SPAs with relative asset paths (`vite.base: './'`); discover the effective base in the browser from the executing script's location / `document.baseURI`. `createBuild` copies SPA output verbatim - no HTML rewriting, no build-time `--base` injection. The client (`connectDevframe`) resolves `.connection.json` relative to the runtime base automatically.
8484
- **CLI flags compose from both sides.** The `cac` instance backing `createCac` is exposed both to the `DevframeDefinition` (`cli.configure(cli)`) - for capabilities contributed by the tool itself - and to the `createCac` caller - for flags added at the final assembly stage. Parsed flag values are forwarded to `setup(ctx, { flags })`. Never hardcode domain-specific flags into `createCac`.
8585

8686
### Hub example parity

‎design/build-shadow-css.ts‎

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { UserConfig } from 'unocss'
2+
import { Buffer } from 'node:buffer'
3+
import fs from 'node:fs/promises'
4+
import { createRequire } from 'node:module'
5+
import { join } from 'node:path'
6+
import { transform } from 'lightningcss'
7+
import MagicString from 'magic-string'
8+
import { glob } from 'tinyglobby'
9+
import { createGenerator } from 'unocss'
10+
import { namespaceShadowCssVars, rewireBakedPrimaryColors, shadowSurfaceSafelist } from './uno.config'
11+
12+
// Story-only utility classes must not leak into a shipped shadow-root
13+
// stylesheet.
14+
const IGNORE = ['**/*.stories.*', '**/__tests__/**']
15+
16+
export interface BuildShadowCssOptions {
17+
/**
18+
* Absolute path of the package's UnoCSS-scanned source directory. The
19+
* compiled stylesheet is written to `<srcDir>/.generated/css.ts`.
20+
*/
21+
srcDir: string
22+
/** Glob patterns (relative to `srcDir`) UnoCSS extracts classes from. */
23+
globs: string[]
24+
/** The package's own `uno.config` default export. */
25+
config: UserConfig<any>
26+
/**
27+
* Absolute path to the primary-ramp override stylesheet, appended AFTER
28+
* the UnoCSS output so its `:host`/`:root, :host` block wins over Wind's
29+
* own primary declarations (see each package's `primary-ramp.css`).
30+
*/
31+
primaryRampPath: string
32+
/**
33+
* Absolute path to a hand-authored stylesheet run through the generator's
34+
* configured transformers (directives, variant groups) and merged in
35+
* right after the CSS reset. Omit for a package with no hand-written
36+
* styles.
37+
*/
38+
userStylePath?: string
39+
/**
40+
* Prefix Wind's `--un-*` custom properties are renamed to (see
41+
* `namespaceShadowCssVars`) — unique per shadow-root surface so two
42+
* shadow trees on the same host page never collide.
43+
*/
44+
varPrefix: string
45+
}
46+
47+
export interface BuildShadowCssResult {
48+
/** Number of source files scanned for class extraction. */
49+
sourceCount: number
50+
/** The compiled, minified shadow-root stylesheet. */
51+
css: string
52+
}
53+
54+
// Compile a shadow-root surface's UnoCSS output ahead of time into a plain
55+
// string module (`<srcDir>/.generated/css.ts`) that the surface adopts into
56+
// its shadow root — fully styled inside any host page without a global
57+
// stylesheet, and immune to the host page's own styles leaking in. Shared by
58+
// `@devframes/hub-ui`'s dock and `@devframes/json-render-ui`'s renderer
59+
// module: same pipeline, same two shadow-root gotchas (see the root
60+
// AGENTS.md "Design system" section), different source globs. Writes the
61+
// generated file itself; returns stats so each caller (a `scripts/` entry,
62+
// exempt from the `no-console` lint rule) prints its own summary line.
63+
export async function buildShadowCss(options: BuildShadowCssOptions): Promise<BuildShadowCssResult> {
64+
const { srcDir, globs, config, primaryRampPath, userStylePath, varPrefix } = options
65+
const generatedCss = join(srcDir, '.generated/css.ts')
66+
67+
const require = createRequire(import.meta.url)
68+
const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8')
69+
const files = await glob(globs, {
70+
cwd: srcDir,
71+
absolute: true,
72+
ignore: IGNORE,
73+
})
74+
75+
// Shadow-root surfaces reuse `@antfu/design`'s Vue components (buttons,
76+
// badges, …) directly. UnoCSS ignores `node_modules` by default, so their
77+
// semantic shortcut classes (`btn-primary`, `btn-action`, `badge-*`, …)
78+
// would be absent from the shadow-root stylesheet — scan the design
79+
// package's component sources too so those classes ship in the injected
80+
// CSS.
81+
const designComponentsDir = join(require.resolve('@antfu/design/package.json'), '..', 'components')
82+
const designFiles = await glob('**/*.vue', {
83+
cwd: designComponentsDir,
84+
absolute: true,
85+
ignore: IGNORE,
86+
})
87+
88+
const generator = await createGenerator(config)
89+
90+
const tokens = new Set<string>()
91+
for (const file of [...files, ...designFiles]) {
92+
const content = await fs.readFile(file, 'utf-8')
93+
await generator.applyExtractors(content, file, tokens)
94+
}
95+
96+
// The hand-written stylesheet (if any) may use `--at-apply` — run it
97+
// through the configured transformers (directives, variant groups) before
98+
// merging.
99+
const userStyle = userStylePath
100+
? new MagicString(await fs.readFile(userStylePath, 'utf-8').catch(() => ''))
101+
: undefined
102+
if (userStyle) {
103+
for (const transformer of generator.config.transformers ?? []) {
104+
await transformer.transform(userStyle, userStylePath!, { uno: generator } as any)
105+
}
106+
}
107+
108+
const primaryRamp = await fs.readFile(primaryRampPath, 'utf-8')
109+
const unoResult = await generator.generate(tokens)
110+
// Wind3 drops a *plain* semantic shortcut (`.bg-base` / `.color-base`) from
111+
// the main pass when the same shortcut also appears variant-prefixed in the
112+
// sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`) —
113+
// a shortcut+variant interaction. Generate the shadow-surface tokens in a
114+
// dedicated pass so their plain (and `.dark`) rules are always present.
115+
const surfaces = await generator.generate(shadowSurfaceSafelist.join(' '))
116+
// Wind3 bakes the `primary` theme color to literal `rgb()` triplets at
117+
// generate-time — rewire them to read the live `--colors-primary-*`
118+
// variables `primary-ramp.css` derives from `--devframe-primary`, so a
119+
// rebrand actually retints `text-primary`/`bg-primary`/`btn-primary`/…
120+
// (see `rewireBakedPrimaryColors`'s own comment).
121+
const primaryTheme = (generator.config.theme as { colors?: Record<string, Record<string, string>> }).colors?.primary ?? {}
122+
const unoCss = rewireBakedPrimaryColors(unoResult.css, primaryTheme)
123+
const surfacesCss = rewireBakedPrimaryColors(surfaces.css, primaryTheme)
124+
// Namespace Wind's `--un-*` vars so this shadow-root stylesheet is immune
125+
// to a host page's Wind4 `@property` registrations (see
126+
// `namespaceShadowCssVars`).
127+
let css = [
128+
reset,
129+
userStyle?.toString(),
130+
unoCss,
131+
surfacesCss,
132+
primaryRamp,
133+
].filter((part): part is string => part !== undefined).join('\n')
134+
135+
css = namespaceShadowCssVars(css, varPrefix)
136+
css = transform({
137+
filename: 'hub-ui.css',
138+
code: Buffer.from(css),
139+
minify: true,
140+
}).code.toString()
141+
142+
await fs.mkdir(join(srcDir, '.generated'), { recursive: true })
143+
await fs.writeFile(generatedCss, [
144+
`/* eslint-disable eslint-comments/no-unlimited-disable */`,
145+
`/* eslint-disable */`,
146+
`export default ${JSON.stringify(String(css))}`,
147+
'',
148+
].join('\n'))
149+
150+
return { sourceCount: files.length, css }
151+
}

‎docs/adapters/build.md‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,22 @@ Produces a self-contained static deploy of a devframe:
1010
2. Runs `setup(ctx)` with `mode: 'build'`.
1111
3. Collects RPC dumps for every `'static'` function and any `'query'` function with `dump.inputs` / `snapshot: true`.
1212
4. Writes `<outDir>/__connection.json` (`{ backend: 'static' }`) and sharded dump files under `<outDir>/__rpc-dump/` — both at the SPA root so the deployed client discovers them via relative paths from `document.baseURI`.
13-
5. When `def.spa` is set, also writes `<outDir>/spa-loader.json` describing how the SPA hydrates its data.
1413

1514
```ts
1615
import { createBuild } from 'devframe/adapters/build'
1716
import devframe from './devframe'
1817

1918
await createBuild(devframe, {
2019
outDir: 'dist-static',
21-
base: '/',
2220
})
2321
```
2422

2523
| Option | Default | Description |
2624
|--------|---------|-------------|
2725
| `outDir` | `dist-static` | Output directory. Cleared on each build. |
28-
| `base` | `/` | Absolute URL base the output is served from. |
2926
| `distDir` | `def.cli?.distDir` | Override the SPA dist directory. |
27+
| `pretty` | `false` | Pretty-print dump JSON (larger on disk). |
3028

3129
The resulting directory hosts on any static web server (`serve`, nginx, GitHub Pages, …). The client auto-detects `static` mode by resolving `./__connection.json` against `document.baseURI` and runs in read-only form.
3230

3331
`createBuild` copies the SPA verbatim, so deploying under a custom URL base just means building the SPA with relative asset paths (`vite.base: './'`) — the client discovers the effective base at runtime.
34-
35-
When `def.spa` is set on the definition, `createBuild` also writes `spa-loader.json` next to `index.html` describing how the deployed SPA sources its data:
36-
37-
- `'none'` — use the baked RPC dump only (read-only static view).
38-
- `'query'` — hydrate from URL search params.
39-
- `'upload'` — accept a drag-and-drop file.
40-
41-
Deployed SPAs that use `setupBrowser` ship their own client entry that registers the handlers.

‎docs/adapters/cac.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ outline: deep
44

55
# CLI (cac)
66

7-
The cac adapter wraps a `DevframeDefinition` in a [`cac`](https://github.com/cacjs/cac)-powered command-line interface. From one entry it spins up an `h3` dev server with WebSocket RPC, builds static snapshots, builds SPA bundles, or starts an MCP server.
7+
The cac adapter wraps a `DevframeDefinition` in a [`cac`](https://github.com/cacjs/cac)-powered command-line interface. From one entry it spins up an `h3` dev server with WebSocket RPC, builds static snapshots, or starts an MCP server.
88

99
`cac` is an optional peer dependency, pulled in only through this adapter — install it alongside `devframe` to opt into `createCac`:
1010

@@ -68,7 +68,7 @@ defineDevframe({
6868
id: 'my-devframe',
6969
cli: {
7070
command: 'my-devframe', // binary name; default: the id
71-
distDir: './client/dist', // required for dev/build/spa
71+
distDir: './client/dist', // required for dev/build
7272
port: 7777, // preferred port
7373
portRange: [7777, 9000], // passed through to get-port-please
7474
random: false, // passed through to get-port-please

‎docs/adapters/index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ A devframe's SPA basePath depends on which adapter is running it:
2525

2626
| Adapter kind | Default basePath | Reason |
2727
|--------------|------------------|--------|
28-
| `cli`, `spa`, `build` (standalone) | `/` | The devframe owns the origin. |
28+
| `cli`, `build` (standalone) | `/` | The devframe owns the origin. |
2929
| `vite`, `embedded` (hosted) | `/__<id>/` | The devframe shares the origin with a host app and namespaces itself. |
3030

3131
Override either side explicitly with `DevframeDefinition.basePath`:

0 commit comments

Comments
 (0)