Skip to content

Commit 52477d5

Browse files
authored
chore: extract shared buildShadowCss pipeline into design/ (#232)
1 parent 648ec5c commit 52477d5

8 files changed

Lines changed: 193 additions & 219 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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.

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+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,20 @@
4545
"@types/node": "catalog:types",
4646
"@types/prompts": "catalog:types",
4747
"@types/ws": "catalog:types",
48+
"@unocss/reset": "catalog:frontend",
4849
"bumpp": "catalog:tooling",
4950
"crossws": "catalog:deps",
5051
"eslint": "catalog:tooling",
5152
"h3": "catalog:deps",
5253
"knip": "catalog:tooling",
5354
"lightningcss": "catalog:build",
55+
"magic-string": "catalog:build",
5456
"nano-staged": "catalog:tooling",
5557
"pathe": "catalog:deps",
5658
"prompts": "catalog:tooling",
5759
"simple-git-hooks": "catalog:tooling",
5860
"skills-npm": "catalog:tooling",
61+
"tinyglobby": "catalog:deps",
5962
"tsnapi": "catalog:testing",
6063
"tsx": "catalog:build",
6164
"turbo": "catalog:build",

packages/hub-ui/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,7 @@
6161
"dompurify": "catalog:frontend",
6262
"fuse.js": "catalog:frontend",
6363
"iframe-pane": "catalog:frontend",
64-
"magic-string": "catalog:build",
6564
"storybook": "catalog:storybook",
66-
"tinyglobby": "catalog:deps",
6765
"tsdown": "catalog:build",
6866
"tsx": "catalog:build",
6967
"unocss": "catalog:frontend",
Lines changed: 13 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,23 @@
1-
import { Buffer } from 'node:buffer'
2-
import fs from 'node:fs/promises'
3-
import { createRequire } from 'node:module'
41
import { join } from 'node:path'
52
import { fileURLToPath } from 'node:url'
63
import { colors as c } from 'devframe/utils/colors'
7-
import { transform } from 'lightningcss'
8-
import MagicString from 'magic-string'
9-
import { glob } from 'tinyglobby'
10-
import { createGenerator } from 'unocss'
11-
import { namespaceShadowCssVars, rewireBakedPrimaryColors, shadowSurfaceSafelist } from '../../../design/uno.config'
4+
import { buildShadowCss } from '../../../design/build-shadow-css'
125
import config from '../uno.config'
136

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

30-
export async function buildCSS(): Promise<void> {
31-
const require = createRequire(import.meta.url)
32-
const reset = await fs.readFile(require.resolve('@unocss/reset/tailwind.css'), 'utf-8')
33-
const files = await glob(GLOBS, {
34-
cwd: SRC_DIR,
35-
absolute: true,
36-
ignore: IGNORE,
37-
})
38-
39-
// The dock reuses `@antfu/design`'s Vue components (buttons, badges, …)
40-
// directly. UnoCSS ignores `node_modules` by default, so their semantic
41-
// shortcut classes (`btn-primary`, `btn-action`, `badge-*`, …) would be
42-
// absent from the shadow-root stylesheet — scan the design package's
43-
// component sources too so those classes ship in the injected CSS.
44-
const designComponentsDir = join(require.resolve('@antfu/design/package.json'), '..', 'components')
45-
const designFiles = await glob('**/*.vue', {
46-
cwd: designComponentsDir,
47-
absolute: true,
48-
ignore: IGNORE,
49-
})
50-
51-
const generator = await createGenerator(config)
52-
53-
const tokens = new Set<string>()
54-
for (const file of [...files, ...designFiles]) {
55-
const content = await fs.readFile(file, 'utf-8')
56-
await generator.applyExtractors(content, file, tokens)
57-
}
58-
59-
// The hand-written stylesheet may use `--at-apply` — run it through the
60-
// configured transformers (directives, variant groups) before merging.
61-
const userStyle = new MagicString(await fs.readFile(USER_STYLE, 'utf-8').catch(() => ''))
62-
for (const transformer of generator.config.transformers ?? []) {
63-
await transformer.transform(userStyle, USER_STYLE, { uno: generator } as any)
64-
}
65-
66-
const primaryRamp = await fs.readFile(PRIMARY_RAMP, 'utf-8')
67-
const unoResult = await generator.generate(tokens)
68-
// Wind3 drops a *plain* semantic shortcut (`.bg-base` / `.color-base`) from
69-
// the main pass when the same shortcut also appears variant-prefixed in the
70-
// sources (e.g. `@antfu/design`'s Tabs emits `data-[state=active]:bg-base`) —
71-
// a shortcut+variant interaction. Generate the shadow-surface tokens in a
72-
// dedicated pass so their plain (and `.dark`) rules are always present.
73-
const surfaces = await generator.generate(shadowSurfaceSafelist.join(' '))
74-
// Wind3 bakes the `primary` theme color to literal `rgb()` triplets at
75-
// generate-time — rewire them to read the live `--colors-primary-*`
76-
// variables `primary-ramp.css` derives from `--devframe-primary`, so a
77-
// rebrand actually retints `text-primary`/`bg-primary`/`btn-primary`/…
78-
// (see `rewireBakedPrimaryColors`'s own comment).
79-
const primaryTheme = (generator.config.theme as { colors?: Record<string, Record<string, string>> }).colors?.primary ?? {}
80-
const unoCss = rewireBakedPrimaryColors(unoResult.css, primaryTheme)
81-
const surfacesCss = rewireBakedPrimaryColors(surfaces.css, primaryTheme)
82-
// Namespace Wind's `--un-*` vars (→ `--un-hub-*`) so this shadow-root
83-
// stylesheet is immune to a host page's Wind4 `@property` registrations
84-
// (see `namespaceShadowCssVars`).
85-
let css = [
86-
reset,
87-
userStyle.toString(),
88-
unoCss,
89-
surfacesCss,
90-
primaryRamp,
91-
].join('\n')
92-
93-
css = namespaceShadowCssVars(css, '--un-hub-')
94-
css = transform({
95-
filename: 'hub-ui.css',
96-
code: Buffer.from(css),
97-
minify: true,
98-
}).code.toString()
99-
100-
await fs.mkdir(join(SRC_DIR, '.generated'), { recursive: true })
101-
await fs.writeFile(GENERATED_CSS, [
102-
`/* eslint-disable eslint-comments/no-unlimited-disable */`,
103-
`/* eslint-disable */`,
104-
`export default ${JSON.stringify(String(css))}`,
105-
'',
106-
].join('\n'))
107-
console.log(`${c.green('✓')} CSS built (${files.length} sources, ${(css.length / 1024).toFixed(1)} kB)`)
108-
}
109-
110-
await buildCSS()
15+
const { sourceCount, css } = await buildShadowCss({
16+
srcDir: SRC_DIR,
17+
globs: ['components/**/*.{ts,vue}', 'state/**/*.ts', 'embedded/**/*.ts', 'standalone/**/*.{ts,html}'],
18+
config,
19+
primaryRampPath: join(SRC_DIR, 'primary-ramp.css'),
20+
userStylePath: join(SRC_DIR, 'style.css'),
21+
varPrefix: '--un-hub-',
22+
})
23+
console.log(`${c.green('✓')} CSS built (${sourceCount} sources, ${(css.length / 1024).toFixed(1)} kB)`)

packages/json-render-ui/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,9 @@
6161
"@storybook/addon-docs": "catalog:storybook",
6262
"@storybook/vue3-vite": "catalog:storybook",
6363
"@unocss/preset-icons": "catalog:frontend",
64-
"@unocss/reset": "catalog:frontend",
6564
"@vitejs/plugin-vue": "catalog:build",
6665
"devframe": "workspace:*",
6766
"storybook": "catalog:storybook",
68-
"tinyglobby": "catalog:deps",
6967
"tsdown": "catalog:build",
7068
"tsx": "catalog:build",
7169
"unocss": "catalog:frontend",

0 commit comments

Comments
 (0)