Skip to content

Commit 24e9f75

Browse files
committed
feat(design): unify built-in plugins on a shared @internal/design system
The git (React/Next), terminals (Svelte) and code-server (vanilla DOM) plugins each shipped their own styling engine, palette and components, so they read as three different products. Introduce a single, framework-neutral design system the built-in plugins extend, so they look and feel like one product everywhere. - Add @internal/design (private): one `presetDevframe()` UnoCSS preset (presetWind4 + presetIcons + directive/variant-group transformers + the semantic token theme + the shared `df-*` component vocabulary), the `--df-*` token values in theme.css (light/dark), and a prebuilt stylesheet for non-UnoCSS consumers. - Migrate terminals and code-server onto the shared preset + `df-*` classes. - Migrate git from Tailwind v4 + shadcn to UnoCSS driven by the same tokens. - Wire the package into alias/tsconfig/turbo and document the convention.
1 parent 3828b2d commit 24e9f75

36 files changed

Lines changed: 680 additions & 726 deletions

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3838
- Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`.
3939
- Dependencies go through the pnpm catalogs in `pnpm-workspace.yaml` (`cli`, `inlined`, `testing`, `types`) — add to a catalog and reference as `catalog:<name>`, don't pin versions in `package.json`.
4040

41+
### Design system
42+
43+
The built-in plugins share one design system, `@internal/design`, so they look and feel like one product across frameworks (the Git dashboard is React/Next, terminals is Svelte, code-server is vanilla DOM). It's an optional package plugins opt into — devframe core stays headless and ships no styling.
44+
45+
- **One preset to extend.** Each plugin's `uno.config.ts` is just `presets: [presetDevframe()]` (imported from `@internal/design/preset`). The preset bundles `presetWind4` + `presetIcons` + the directive/variant-group transformers, the semantic token theme, and the shared `df-*` component shortcuts. Don't re-declare presets, palettes, or shortcuts per plugin.
46+
- **One token source.** Import `@internal/design/theme.css` once on the page (after the generated UnoCSS stylesheet so its base layer wins). Token *values* (the `--df-*` custom properties, light + dark via the `.dark` class) live only there — never hardcode a palette in a plugin.
47+
- **Shared component vocabulary.** Build UI from the `df-*` classes (`df-btn`, `df-badge`, `df-tab`, `df-navtab`, `df-card`, `df-input`, `df-dot`, `df-tag-*`, …) and the semantic token utilities (`bg-primary`, `text-muted-foreground`, `bg-card`, `border-border`, …). Markup differs per framework; the classes resolve identically, which is what keeps the surfaces consistent. Non-UnoCSS consumers can link the prebuilt `@internal/design/style.css`.
48+
- **Plain `.ts`/vanilla views** must opt `.ts` into UnoCSS extraction (`content.pipeline.include`), since UnoCSS only scans framework files by default.
49+
4150
### Devframe design principles
4251

4352
These reinforce devframe's positioning as "the container for one devtool integration, portable to multiple viewers". When in doubt, err on the side of "devframe provides primitives, the hub provides UX".

alias.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export const alias = {
4545
'@devframes/hub': r('hub/src/index.ts'),
4646
'@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'),
4747
'@devframes/nuxt': r('nuxt/src/index.ts'),
48+
'@internal/design/preset': r('design/src/preset.ts'),
49+
'@internal/design/tokens': r('design/src/tokens.ts'),
50+
'@internal/design/theme.css': r('design/src/theme.css'),
51+
'@internal/design': r('design/src/index.ts'),
4852
'@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'),
4953
'@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'),
5054
'@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'),
@@ -65,11 +69,14 @@ export const alias = {
6569
'devframe': r('devframe/src'),
6670
}
6771

68-
// update tsconfig.base.json
72+
// update tsconfig.base.json — CSS aliases exist for Vite resolution only;
73+
// TypeScript resolves `*.css` side-effect imports through ambient shims.
6974
const raw = fs.readFileSync(join(root, 'tsconfig.base.json'), 'utf-8').trim()
7075
const tsconfig = JSON.parse(raw)
7176
tsconfig.compilerOptions.paths = Object.fromEntries(
72-
Object.entries(alias).map(([key, value]) => [key, [`./${relative(root, value)}`]]),
77+
Object.entries(alias)
78+
.filter(([key]) => !key.endsWith('.css'))
79+
.map(([key, value]) => [key, [`./${relative(root, value)}`]]),
7380
)
7481
const newRaw = JSON.stringify(tsconfig, null, 2)
7582
if (newRaw !== raw)

packages/design/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@internal/design",
3+
"type": "module",
4+
"version": "0.5.4",
5+
"private": true,
6+
"description": "Internal, unpublished design system for devframe's built-in plugins — one UnoCSS preset, one token set, one shared component vocabulary, portable across frameworks.",
7+
"author": "Anthony Fu <anthonyfu117@hotmail.com>",
8+
"license": "MIT",
9+
"sideEffects": [
10+
"**/*.css"
11+
],
12+
"exports": {
13+
".": "./dist/index.mjs",
14+
"./preset": "./dist/preset.mjs",
15+
"./tokens": "./dist/tokens.mjs",
16+
"./theme.css": "./dist/theme.css",
17+
"./style.css": "./dist/style.css",
18+
"./package.json": "./package.json"
19+
},
20+
"types": "./dist/index.d.mts",
21+
"files": [
22+
"dist"
23+
],
24+
"scripts": {
25+
"build": "tsdown && tsx scripts/build-css.ts",
26+
"watch": "tsdown --watch",
27+
"typecheck": "tsc --noEmit",
28+
"prepack": "pnpm run build"
29+
},
30+
"peerDependencies": {
31+
"unocss": "^66.0.0"
32+
},
33+
"dependencies": {
34+
"@iconify-json/ph": "catalog:frontend"
35+
},
36+
"devDependencies": {
37+
"@types/node": "catalog:types",
38+
"tsdown": "catalog:build",
39+
"tsx": "catalog:build",
40+
"unocss": "catalog:frontend"
41+
}
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
2+
import { resolve } from 'node:path'
3+
import process from 'node:process'
4+
import { fileURLToPath } from 'node:url'
5+
import { createGenerator } from 'unocss'
6+
import { presetDevframe, shortcuts } from '../src/preset'
7+
8+
/**
9+
* Emit the design package's shipped CSS:
10+
*
11+
* - `dist/theme.css` — the `--df-*` token values + base layer, verbatim.
12+
* - `dist/style.css` — a standalone stylesheet (UnoCSS preflights + every
13+
* `df-*` component class + the tokens) for consumers that don't run UnoCSS
14+
* themselves but still want the shared component vocabulary.
15+
*/
16+
17+
const root = fileURLToPath(new URL('..', import.meta.url))
18+
const distDir = resolve(root, 'dist')
19+
20+
async function main(): Promise<void> {
21+
await mkdir(distDir, { recursive: true })
22+
23+
const theme = await readFile(resolve(root, 'src/theme.css'), 'utf8')
24+
await writeFile(resolve(distDir, 'theme.css'), theme)
25+
26+
// Materialize every named `df-*` shortcut (and a few palette tags) so the
27+
// standalone stylesheet carries the full shared component vocabulary.
28+
const names = new Set<string>()
29+
for (const entry of shortcuts) {
30+
if (Array.isArray(entry))
31+
continue
32+
for (const key of Object.keys(entry))
33+
names.add(key)
34+
}
35+
for (const color of ['blue', 'amber', 'green', 'red', 'sky', 'violet', 'rose'])
36+
names.add(`df-tag-${color}`)
37+
38+
const generator = await createGenerator({ presets: [presetDevframe()] })
39+
const { css } = await generator.generate(names, { preflights: true })
40+
41+
const banner = '/* @internal/design — prebuilt stylesheet. Generated by scripts/build-css.ts; do not edit. */\n'
42+
await writeFile(resolve(distDir, 'style.css'), `${banner}${css}\n${theme}`)
43+
}
44+
45+
main().catch((error) => {
46+
console.error(error)
47+
process.exit(1)
48+
})

packages/design/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export { presetDevframe, shortcuts } from './preset'
2+
export type { PresetDevframeOptions } from './preset'
3+
export { presetDevframe as default } from './preset'
4+
export {
5+
cssVar,
6+
PAIRED_TOKENS,
7+
radius,
8+
SOLO_TOKENS,
9+
TOKEN_PREFIX,
10+
tokenColors,
11+
} from './tokens'
12+
13+
export type { DesignToken, PairedToken, SoloToken } from './tokens'

packages/design/src/preset.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { Preset } from 'unocss'
2+
import {
3+
definePreset,
4+
presetIcons,
5+
presetWind4,
6+
transformerDirectives,
7+
transformerVariantGroup,
8+
} from 'unocss'
9+
import { radius, tokenColors } from './tokens'
10+
11+
export interface PresetDevframeOptions {
12+
/**
13+
* Options forwarded to `presetIcons`. The default icon scale matches the
14+
* inline-with-text sizing used across the built-in plugins.
15+
*/
16+
icons?: Parameters<typeof presetIcons>[0]
17+
}
18+
19+
/**
20+
* The shared `df-*` component vocabulary. These shortcuts are the cross-frame
21+
* "components": markup differs per framework (React, Svelte, vanilla DOM) but a
22+
* `df-btn df-btn-primary` button — or a `df-badge`, `df-tab`, `df-card`, … —
23+
* resolves to the same CSS everywhere, so they look and feel identical. The
24+
* definitions mirror the flagship shadcn/ui primitives so a hand-written
25+
* `df-btn` and a generated `<Button>` are visually interchangeable.
26+
*/
27+
export const shortcuts = [
28+
{
29+
// Foundations — friendly aliases onto the semantic token palette.
30+
'bg-base': 'bg-background',
31+
'color-base': 'text-foreground',
32+
'border-base': 'border-border',
33+
'bg-active': 'bg-accent',
34+
'color-active': 'text-primary',
35+
'border-active': 'border-primary/40',
36+
'op-fade': 'op65 dark:op75',
37+
'op-mute': 'op40 dark:op45',
38+
39+
// Named depth layers so chrome stacks predictably.
40+
'z-toolbar': 'z-20',
41+
'z-nav': 'z-30',
42+
43+
// Buttons.
44+
'df-btn': 'inline-flex items-center justify-center gap-2 shrink-0 whitespace-nowrap select-none cursor-pointer rounded-md text-sm font-medium h-9 px-4 py-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:(pointer-events-none op50) focus-visible:(ring-[3px] ring-ring/50)',
45+
'df-btn-primary': 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
46+
'df-btn-secondary': 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
47+
'df-btn-outline': 'border border-border bg-background shadow-xs hover:(bg-accent text-accent-foreground)',
48+
'df-btn-ghost': 'hover:(bg-accent text-accent-foreground)',
49+
'df-btn-destructive': 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
50+
'df-btn-link': 'text-primary underline-offset-4 hover:underline',
51+
// Size modifiers override the base size with `!` so they win regardless of
52+
// stylesheet order — no `tailwind-merge` needed in Svelte/vanilla markup.
53+
'df-btn-sm': 'h-8! gap-1.5! px-3!',
54+
'df-btn-lg': 'h-10! px-6!',
55+
'df-btn-icon': 'w-9! px-0!',
56+
'df-btn-icon-sm': 'h-7! w-7! px-0!',
57+
58+
// Badges (solid / semantic) and soft tags share a silhouette.
59+
'df-badge': 'inline-flex items-center justify-center gap-1 w-fit shrink-0 whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium',
60+
'df-badge-primary': 'df-badge border-transparent bg-primary text-primary-foreground',
61+
'df-badge-secondary': 'df-badge border-transparent bg-secondary text-secondary-foreground',
62+
'df-badge-success': 'df-badge border-success/20 bg-success/15 text-success',
63+
'df-badge-warning': 'df-badge border-warning/20 bg-warning/15 text-warning',
64+
'df-badge-destructive': 'df-badge border-transparent bg-destructive text-white',
65+
'df-badge-outline': 'df-badge text-foreground',
66+
67+
// Segmented tabs (mirror the shadcn segmented control).
68+
'df-tabs-list': 'inline-flex items-center justify-center w-fit h-9 p-[3px] rounded-lg bg-muted text-muted-foreground',
69+
'df-tab': 'inline-flex flex-1 items-center justify-center gap-1.5 h-[calc(100%-1px)] px-2 py-1 rounded-md border border-transparent text-sm font-medium whitespace-nowrap select-none cursor-pointer outline-none transition-colors disabled:(pointer-events-none op50) focus-visible:(ring-[3px] ring-ring/50) data-[state=active]:(bg-background text-foreground shadow-sm)',
70+
71+
// Closeable navigation tabs (e.g. terminal sessions, open documents).
72+
'df-navtab': 'relative inline-flex items-center gap-1.5 max-w-52 px-2 py-1 rounded-md border border-transparent text-sm op-fade select-none cursor-pointer transition-colors hover:(op100 bg-accent)',
73+
'df-navtab-active': 'op100! bg-accent border-base! color-base',
74+
75+
// Surfaces.
76+
'df-card': 'flex flex-col rounded-xl border border-border bg-card text-card-foreground shadow-sm',
77+
'df-panel': 'rounded-lg border border-border bg-card text-card-foreground',
78+
79+
// Form controls.
80+
'df-input': 'flex w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-colors placeholder:text-muted-foreground disabled:(cursor-not-allowed op50) focus-visible:(border-ring ring-[3px] ring-ring/50)',
81+
82+
// Links.
83+
'df-link': 'text-primary underline-offset-4 hover:underline',
84+
85+
// Status dots — real lifecycle states only.
86+
'df-dot': 'inline-block h-1.5 w-1.5 rounded-full shrink-0',
87+
'df-dot-running': 'bg-success',
88+
'df-dot-idle': 'bg-muted-foreground',
89+
'df-dot-error': 'bg-destructive',
90+
91+
// Indeterminate spinner.
92+
'df-spinner': 'inline-block size-4 rounded-full border-2 border-current border-t-transparent animate-spin',
93+
},
94+
95+
// Soft, palette-driven tags: `df-tag-blue`, `df-tag-amber`, … for ad-hoc
96+
// categorical labels that fall outside the semantic palette.
97+
[
98+
/^df-tag-(\w+)$/,
99+
([, color]: string[]) =>
100+
`inline-flex items-center gap-1 w-fit whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium border-${color}-500/20 bg-${color}-400/15 text-${color}-700 dark:text-${color}-300`,
101+
] as [RegExp, (match: string[]) => string],
102+
]
103+
104+
/**
105+
* The single UnoCSS preset every devframe plugin extends. It bundles
106+
* `presetWind4` (Tailwind-compatible utilities + class-based `dark:`),
107+
* `presetIcons` (Phosphor via `@iconify-json/ph`), the directive + variant-group
108+
* transformers, the semantic token theme, and the shared `df-*` shortcuts.
109+
*
110+
* A plugin's entire `uno.config.ts` becomes:
111+
*
112+
* ```ts
113+
* import { presetDevframe } from '@internal/design/preset'
114+
* import { defineConfig } from 'unocss'
115+
*
116+
* export default defineConfig({ presets: [presetDevframe()] })
117+
* ```
118+
*
119+
* Pair it with `import '@internal/design/theme.css'` once on the page to define
120+
* the `--df-*` token values and base element styling.
121+
*/
122+
export function presetDevframe(options: PresetDevframeOptions = {}): Preset {
123+
return definePreset({
124+
name: '@internal/design/preset',
125+
theme: {
126+
colors: tokenColors,
127+
radius,
128+
},
129+
shortcuts,
130+
presets: [
131+
presetWind4(),
132+
presetIcons({ scale: 1.1, ...options.icons }),
133+
],
134+
transformers: [
135+
transformerDirectives(),
136+
transformerVariantGroup(),
137+
],
138+
})
139+
}
140+
141+
export default presetDevframe

0 commit comments

Comments
 (0)