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.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ All five built-in plugins - and every example under `examples/` - share one desi

- **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) + 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):
- **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):
- **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.
Expand Down
151 changes: 151 additions & 0 deletions design/build-shadow-css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { UserConfig } from 'unocss'
import { Buffer } from 'node:buffer'
import fs from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { transform } from 'lightningcss'
import MagicString from 'magic-string'
import { glob } from 'tinyglobby'
import { createGenerator } from 'unocss'
import { namespaceShadowCssVars, rewireBakedPrimaryColors, shadowSurfaceSafelist } from './uno.config'

// Story-only utility classes must not leak into a shipped shadow-root
// stylesheet.
const IGNORE = ['**/*.stories.*', '**/__tests__/**']

export interface BuildShadowCssOptions {
/**
* Absolute path of the package's UnoCSS-scanned source directory. The
* compiled stylesheet is written to `<srcDir>/.generated/css.ts`.
*/
srcDir: string
/** Glob patterns (relative to `srcDir`) UnoCSS extracts classes from. */
globs: string[]
/** The package's own `uno.config` default export. */
config: UserConfig<any>
/**
* Absolute path to the primary-ramp override stylesheet, appended AFTER
* the UnoCSS output so its `:host`/`:root, :host` block wins over Wind's
* own primary declarations (see each package's `primary-ramp.css`).
*/
primaryRampPath: string
/**
* Absolute path to a hand-authored stylesheet run through the generator's
* configured transformers (directives, variant groups) and merged in
* right after the CSS reset. Omit for a package with no hand-written
* styles.
*/
userStylePath?: string
/**
* Prefix Wind's `--un-*` custom properties are renamed to (see
* `namespaceShadowCssVars`) — unique per shadow-root surface so two
* shadow trees on the same host page never collide.
*/
varPrefix: string
}

export interface BuildShadowCssResult {
/** Number of source files scanned for class extraction. */
sourceCount: number
/** The compiled, minified shadow-root stylesheet. */
css: string
}

// Compile a shadow-root surface's UnoCSS output ahead of time into a plain
// string module (`<srcDir>/.generated/css.ts`) that the surface adopts into
// its shadow root — fully styled inside any host page without a global
// stylesheet, and immune to the host page's own styles leaking in. Shared by
// `@devframes/hub-ui`'s dock and `@devframes/json-render-ui`'s renderer
// module: same pipeline, same two shadow-root gotchas (see the root
// AGENTS.md "Design system" section), different source globs. Writes the
// generated file itself; returns stats so each caller (a `scripts/` entry,
// 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 generatedCss = join(srcDir, '.generated/css.ts')

const require = createRequire(import.meta.url)
const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8')
const files = await glob(globs, {
cwd: srcDir,
absolute: true,
ignore: IGNORE,
})

// Shadow-root surfaces reuse `@antfu/design`'s Vue components (buttons,
// badges, …) directly. UnoCSS ignores `node_modules` by default, so their
// semantic shortcut classes (`btn-primary`, `btn-action`, `badge-*`, …)
// would be absent from the shadow-root stylesheet — scan the design
// 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 generator = await createGenerator(config)

const tokens = new Set<string>()
for (const file of [...files, ...designFiles]) {
const content = await fs.readFile(file, 'utf-8')
await generator.applyExtractors(content, file, tokens)
}

// The hand-written stylesheet (if any) may use `--at-apply` — run it
// through the configured transformers (directives, variant groups) before
// merging.
const userStyle = userStylePath
? new MagicString(await fs.readFile(userStylePath, 'utf-8').catch(() => ''))
: undefined
if (userStyle) {
for (const transformer of generator.config.transformers ?? []) {
await transformer.transform(userStyle, userStylePath!, { uno: generator } as any)
}
}

const primaryRamp = await fs.readFile(primaryRampPath, 'utf-8')
const unoResult = await generator.generate(tokens)
// Wind3 drops a *plain* semantic shortcut (`.bg-base` / `.color-base`) from
// the main pass when the same shortcut also appears variant-prefixed in the
// sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`) —
// a shortcut+variant interaction. Generate the shadow-surface tokens in a
// dedicated pass so their plain (and `.dark`) rules are always present.
const surfaces = await generator.generate(shadowSurfaceSafelist.join(' '))
// Wind3 bakes the `primary` theme color to literal `rgb()` triplets at
// generate-time — rewire them to read the live `--colors-primary-*`
// variables `primary-ramp.css` derives from `--devframe-primary`, so a
// rebrand actually retints `text-primary`/`bg-primary`/`btn-primary`/…
// (see `rewireBakedPrimaryColors`'s own comment).
const primaryTheme = (generator.config.theme as { colors?: Record<string, Record<string, string>> }).colors?.primary ?? {}
const unoCss = rewireBakedPrimaryColors(unoResult.css, primaryTheme)
const surfacesCss = rewireBakedPrimaryColors(surfaces.css, primaryTheme)
// Namespace Wind's `--un-*` vars so this shadow-root stylesheet is immune
// to a host page's Wind4 `@property` registrations (see
// `namespaceShadowCssVars`).
let css = [
reset,
userStyle?.toString(),
unoCss,
surfacesCss,
primaryRamp,
].filter((part): part is string => part !== undefined).join('\n')

css = namespaceShadowCssVars(css, varPrefix)
css = transform({
filename: 'hub-ui.css',
code: Buffer.from(css),
minify: true,
}).code.toString()

await fs.mkdir(join(srcDir, '.generated'), { recursive: true })
await fs.writeFile(generatedCss, [
`/* eslint-disable eslint-comments/no-unlimited-disable */`,
`/* eslint-disable */`,
`export default ${JSON.stringify(String(css))}`,
'',
].join('\n'))

return { sourceCount: files.length, css }
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,20 @@
"@types/node": "catalog:types",
"@types/prompts": "catalog:types",
"@types/ws": "catalog:types",
"@unocss/reset": "catalog:frontend",
"bumpp": "catalog:tooling",
"crossws": "catalog:deps",
"eslint": "catalog:tooling",
"h3": "catalog:deps",
"knip": "catalog:tooling",
"lightningcss": "catalog:build",
"magic-string": "catalog:build",
"nano-staged": "catalog:tooling",
"pathe": "catalog:deps",
"prompts": "catalog:tooling",
"simple-git-hooks": "catalog:tooling",
"skills-npm": "catalog:tooling",
"tinyglobby": "catalog:deps",
"tsnapi": "catalog:testing",
"tsx": "catalog:build",
"turbo": "catalog:build",
Expand Down
2 changes: 0 additions & 2 deletions packages/hub-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@
"dompurify": "catalog:frontend",
"fuse.js": "catalog:frontend",
"iframe-pane": "catalog:frontend",
"magic-string": "catalog:build",
"storybook": "catalog:storybook",
"tinyglobby": "catalog:deps",
"tsdown": "catalog:build",
"tsx": "catalog:build",
"unocss": "catalog:frontend",
Expand Down
113 changes: 13 additions & 100 deletions packages/hub-ui/scripts/build-css.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,23 @@
import { Buffer } from 'node:buffer'
import fs from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { colors as c } from 'devframe/utils/colors'
import { transform } from 'lightningcss'
import MagicString from 'magic-string'
import { glob } from 'tinyglobby'
import { createGenerator } from 'unocss'
import { namespaceShadowCssVars, rewireBakedPrimaryColors, shadowSurfaceSafelist } from '../../../design/uno.config'
import { buildShadowCss } from '../../../design/build-shadow-css'
import config from '../uno.config'

// Compile the components' UnoCSS output ahead of time into a plain string
// Compiles the components' UnoCSS output ahead of time into a plain string
// module (`src/client/.generated/css.ts`) that `defineCustomElement` adopts
// into each shadow root — the dock stays fully styled inside any host page
// without a global stylesheet, and the host page's own styles can't leak in.
// See `design/build-shadow-css.ts` for the shared pipeline (mirrored by
// `@devframes/json-render-ui`'s `scripts/build-css.ts`).
const SRC_DIR = fileURLToPath(new URL('../src/client', import.meta.url))
const GLOBS = ['components/**/*.{ts,vue}', 'state/**/*.ts', 'embedded/**/*.ts', 'standalone/**/*.{ts,html}']
// Story-only utility classes must not leak into the shipped stylesheet.
const IGNORE = ['**/*.stories.*', '**/__tests__/**']
const USER_STYLE = join(SRC_DIR, 'style.css')
// The single-overridable-variable primary ramp. Appended AFTER the UnoCSS
// output so its `:host` block wins over Wind4's own `:root, :host` primary
// declarations (kept in its own file so the Storybook preview can import the
// exact same override after `virtual:uno.css`). See the file's own comment.
const PRIMARY_RAMP = join(SRC_DIR, 'primary-ramp.css')
const GENERATED_CSS = join(SRC_DIR, '.generated/css.ts')

export async function buildCSS(): Promise<void> {
const require = createRequire(import.meta.url)
const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8')
const files = await glob(GLOBS, {
cwd: SRC_DIR,
absolute: true,
ignore: IGNORE,
})

// The dock reuses `@antfu/design`'s Vue components (buttons, badges, …)
// directly. UnoCSS ignores `node_modules` by default, so their semantic
// shortcut classes (`btn-primary`, `btn-action`, `badge-*`, …) would be
// absent from the shadow-root stylesheet — scan the design 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 generator = await createGenerator(config)

const tokens = new Set<string>()
for (const file of [...files, ...designFiles]) {
const content = await fs.readFile(file, 'utf-8')
await generator.applyExtractors(content, file, tokens)
}

// The hand-written stylesheet may use `--at-apply` — run it through the
// configured transformers (directives, variant groups) before merging.
const userStyle = new MagicString(await fs.readFile(USER_STYLE, 'utf-8').catch(() => ''))
for (const transformer of generator.config.transformers ?? []) {
await transformer.transform(userStyle, USER_STYLE, { uno: generator } as any)
}

const primaryRamp = await fs.readFile(PRIMARY_RAMP, 'utf-8')
const unoResult = await generator.generate(tokens)
// Wind3 drops a *plain* semantic shortcut (`.bg-base` / `.color-base`) from
// the main pass when the same shortcut also appears variant-prefixed in the
// sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`) —
// a shortcut+variant interaction. Generate the shadow-surface tokens in a
// dedicated pass so their plain (and `.dark`) rules are always present.
const surfaces = await generator.generate(shadowSurfaceSafelist.join(' '))
// Wind3 bakes the `primary` theme color to literal `rgb()` triplets at
// generate-time — rewire them to read the live `--colors-primary-*`
// variables `primary-ramp.css` derives from `--devframe-primary`, so a
// rebrand actually retints `text-primary`/`bg-primary`/`btn-primary`/…
// (see `rewireBakedPrimaryColors`'s own comment).
const primaryTheme = (generator.config.theme as { colors?: Record<string, Record<string, string>> }).colors?.primary ?? {}
const unoCss = rewireBakedPrimaryColors(unoResult.css, primaryTheme)
const surfacesCss = rewireBakedPrimaryColors(surfaces.css, primaryTheme)
// Namespace Wind's `--un-*` vars (→ `--un-hub-*`) so this shadow-root
// stylesheet is immune to a host page's Wind4 `@property` registrations
// (see `namespaceShadowCssVars`).
let css = [
reset,
userStyle.toString(),
unoCss,
surfacesCss,
primaryRamp,
].join('\n')

css = namespaceShadowCssVars(css, '--un-hub-')
css = transform({
filename: 'hub-ui.css',
code: Buffer.from(css),
minify: true,
}).code.toString()

await fs.mkdir(join(SRC_DIR, '.generated'), { recursive: true })
await fs.writeFile(GENERATED_CSS, [
`/* eslint-disable eslint-comments/no-unlimited-disable */`,
`/* eslint-disable */`,
`export default ${JSON.stringify(String(css))}`,
'',
].join('\n'))
console.log(`${c.green('✓')} CSS built (${files.length} sources, ${(css.length / 1024).toFixed(1)} kB)`)
}

await buildCSS()
const { sourceCount, css } = await buildShadowCss({
srcDir: SRC_DIR,
globs: ['components/**/*.{ts,vue}', 'state/**/*.ts', 'embedded/**/*.ts', 'standalone/**/*.{ts,html}'],
config,
primaryRampPath: join(SRC_DIR, 'primary-ramp.css'),
userStylePath: join(SRC_DIR, 'style.css'),
varPrefix: '--un-hub-',
})
console.log(`${c.green('✓')} CSS built (${sourceCount} sources, ${(css.length / 1024).toFixed(1)} kB)`)
2 changes: 0 additions & 2 deletions packages/json-render-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,9 @@
"@storybook/addon-docs": "catalog:storybook",
"@storybook/vue3-vite": "catalog:storybook",
"@unocss/preset-icons": "catalog:frontend",
"@unocss/reset": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:build",
"devframe": "workspace:*",
"storybook": "catalog:storybook",
"tinyglobby": "catalog:deps",
"tsdown": "catalog:build",
"tsx": "catalog:build",
"unocss": "catalog:frontend",
Expand Down
Loading
Loading