Skip to content

Commit 3820c29

Browse files
committed
feat(design): enforce strict component styles across plugins and examples
Tighten the shared `@internal/design` vocabulary so every surface reads as one product: - One nav: a single fixed-height `df-nav` led by `navBrand()` (a primary-tinted Phosphor icon + the product name), replacing each surface's bespoke header. - Exactly three button forms: a text button (`button()`), an icon button (`iconButton()`, bordered) and a borderless icon button (`iconButton({ variant: 'ghost' })`). Icon sizes are removed from the text-button recipe so the forms can't blur together. - One segmented view switcher (`tabsList()` + `tab()`) with a leading icon per tab. Inspect's tabs gain icons; git drops its sidebar switcher for the same top-nav selector. Bring every plugin (inspect, git, a11y, terminals, code-server) and all five examples onto the system, adding the UnoCSS toolchain to the examples. Document the rules in AGENTS.md, including the antfu / antfu-design skills and the upstream inspector UIs the surfaces descend from.
1 parent 9783716 commit 3820c29

43 files changed

Lines changed: 683 additions & 910 deletions

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: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,17 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
4040

4141
### Design system
4242

43-
All five built-in plugins share one design system, `@internal/design`, so they look and feel like one product across frameworks (Git is React/Next, terminals is Svelte, code-server is vanilla DOM, inspect is Vue, a11y is Solid). It's a private, source-only package — never built or published; consumers import its TypeScript/CSS directly (resolved through `alias.ts` for bundlers and the package `exports` for config loaders), so editing it needs no rebuild.
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` (Phosphor) + the directive/variant-group transformers, the semantic token theme, and the shared `df-*` component shortcuts (which it safelists). 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. The brand primary is devframe's sage green; flip the OS preference onto `<html class="dark">` from the SPA entry.
47-
- **Shared component vocabulary.** Build UI from the `df-*` classes (`df-btn`, `df-badge`, `df-tab`, `df-navtab`, `df-nav`, `df-toolbar`, `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.
48-
- **Component builders.** Prefer the framework-neutral recipes from `@internal/design/components` (`button`, `badge`, `tab`, `navTab`, `nav`, `toolbar`, `card`, `panel`, `input`, `link`, `dot`, `spinner`, `tag`) over hand-written class strings — they return the canonical `df-*` classes and read the same in React (`className=`), Svelte (`class=`), vanilla DOM and Solid. Because these classes are assembled at runtime, the preset safelists the `df-*` vocabulary so UnoCSS always emits it; add new component classes to `DF_SAFELIST` when you extend the set.
49-
- **Icons** come from the shared Phosphor set (`i-ph-*`, duotone preferred) via `presetIcons` — use them across every plugin instead of per-plugin icon libraries or bespoke SVG.
50-
- **A plugin keeping its own component CSS** (inspect, a11y) re-bases its color/radius tokens onto the `--df-*` variables rather than hardcoding a palette, so it tracks the shared theme.
51-
- **Plain `.ts`/vanilla views** must opt `.ts` into UnoCSS extraction (`content.pipeline.include`), since UnoCSS only scans framework files by default.
43+
All five built-in plugins — and every example under `examples/` — share one design system, `@internal/design`, so they look and feel like one product across frameworks (Git is React/Next, terminals is Svelte, code-server is vanilla DOM, inspect is Vue, a11y is Solid, the examples are Preact/Next/vanilla). It's a private, source-only package — never built or published; consumers import its TypeScript/CSS directly (resolved through `alias.ts` for bundlers and the package `exports` for config loaders), so editing it needs no rebuild.
44+
45+
- **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).
46+
- **One preset to extend.** Each consumer's `uno.config.ts` is just `presets: [presetDevframe()]` (imported from `@internal/design/preset`). The preset bundles `presetWind4` + `presetIcons` (Phosphor) + the directive/variant-group transformers, the semantic token theme, and the shared `df-*` component shortcuts (which it safelists). Don't re-declare presets, palettes, or shortcuts per consumer.
47+
- **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. The brand primary is devframe's sage green; flip the OS preference onto `<html class="dark">` from the SPA entry.
48+
- **Shared component vocabulary.** Build UI from the `df-*` classes (`df-btn`, `df-badge`, `df-tab`, `df-navtab`, `df-nav`, `df-nav-brand`, `df-toolbar`, `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.
49+
- **Component builders.** Prefer the framework-neutral recipes from `@internal/design/components` (`button`, `iconButton`, `badge`, `tab`, `tabsList`, `navTab`, `nav`, `navBrand`, `toolbar`, `card`, `panel`, `input`, `link`, `dot`, `spinner`, `tag`) over hand-written class strings — they return the canonical `df-*` classes and read the same in React (`className=`), Svelte/Vue (`class=`), vanilla DOM and Solid. Because these classes are assembled at runtime, the preset safelists the `df-*` vocabulary so UnoCSS always emits it; add new component classes to `DF_SAFELIST` when you extend the set.
50+
- **One nav, three buttons, one tab selector — strictly.** Every surface opens with the same top bar: `nav()` (a single row, one fixed height) led by `navBrand()` (a primary-tinted `i-ph-*` icon + the product name). Buttons come in exactly three forms and nothing else: a **text button** (`button({ variant, size })`), an **icon button** (`iconButton()` — bordered), and a **borderless icon button** (`iconButton({ variant: 'ghost' })`). Multi-view tools (inspect, git) switch views with the one shared segmented selector — `tabsList()` wrapping `tab()` buttons that carry `data-state="active"` and a leading `i-ph-*` icon per tab. Don't invent bespoke nav bars, button shapes, or tab styles.
51+
- **Icons** come from the shared Phosphor set (`i-ph-*`, duotone preferred) via `presetIcons` — use them everywhere instead of per-consumer icon libraries or bespoke SVG.
52+
- **A surface keeping its own component CSS** (inspect, a11y) re-bases its color/radius tokens onto the `--df-*` variables rather than hardcoding a palette, so it tracks the shared theme.
53+
- **Plain `.ts`/vanilla views** must opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite, or `content.filesystem` globs for the `@unocss/postcss` setup Next uses), since UnoCSS only scans framework files by default.
5254

5355
### Devframe design principles
5456

examples/files-inspector/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"test": "vitest run"
1717
},
1818
"dependencies": {
19+
"@internal/design": "workspace:*",
1920
"devframe": "workspace:*",
2021
"preact": "catalog:frontend",
2122
"tinyglobby": "catalog:deps"
@@ -24,6 +25,7 @@
2425
"@preact/preset-vite": "catalog:build",
2526
"get-port-please": "catalog:deps",
2627
"h3": "catalog:deps",
28+
"unocss": "catalog:frontend",
2729
"vite": "catalog:build",
2830
"vitest": "catalog:testing",
2931
"ws": "catalog:deps"
Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DevframeScopedClientContext } from 'devframe/client'
2+
import { nav, navBrand, tab as tabClass, tabsList } from '@internal/design/components'
23
import { connectDevframe } from 'devframe/client'
34
import { useEffect, useState } from 'preact/hooks'
45
import { About } from './routes/about'
@@ -7,6 +8,11 @@ import { Home } from './routes/home'
78
const NAMESPACE = 'devframe-files-inspector'
89
export type InspectorCtx = DevframeScopedClientContext<typeof NAMESPACE>
910

11+
const NAV_ITEMS = [
12+
{ route: '/', label: 'Home', icon: 'i-ph-house-duotone' },
13+
{ route: '/about', label: 'About', icon: 'i-ph-info-duotone' },
14+
] as const
15+
1016
function getBasePath(): string {
1117
return new URL(document.baseURI).pathname
1218
}
@@ -44,48 +50,58 @@ export function App() {
4450
setRoute(to)
4551
}
4652

47-
if (!ctx)
48-
return <p>Connecting to devframe…</p>
53+
if (!ctx) {
54+
return (
55+
<div class="grid min-h-screen place-items-center bg-background text-muted-foreground font-sans text-sm">
56+
Connecting to devframe…
57+
</div>
58+
)
59+
}
60+
61+
// Any non-/about route resolves to Home, mirroring the route switch below.
62+
const active = route === '/about' ? '/about' : '/'
4963

5064
return (
51-
<main>
52-
<header>
53-
<h1>Files Inspector</h1>
54-
<nav>
55-
<a
56-
href={basePath}
57-
onClick={(e) => {
58-
e.preventDefault()
59-
navigate('/')
60-
}}
61-
>
62-
Home
63-
</a>
64-
{' · '}
65-
<a
66-
href={`${basePath}about`}
67-
onClick={(e) => {
68-
e.preventDefault()
69-
navigate('/about')
70-
}}
71-
>
72-
About
73-
</a>
65+
<div class="flex flex-col min-h-screen bg-background text-foreground font-sans">
66+
<header class={nav()}>
67+
<span class={navBrand()}>
68+
<span class="i-ph-folder-duotone text-base color-active" />
69+
<span>Files Inspector</span>
70+
</span>
71+
72+
<nav class={tabsList()} role="tablist" aria-label="Views">
73+
{NAV_ITEMS.map(({ route: r, label, icon }) => (
74+
<button
75+
key={r}
76+
type="button"
77+
role="tab"
78+
aria-selected={active === r}
79+
data-state={active === r ? 'active' : 'inactive'}
80+
class={tabClass()}
81+
onClick={() => navigate(r)}
82+
>
83+
<span class={icon} />
84+
{label}
85+
</button>
86+
))}
7487
</nav>
75-
<small>
76-
base:
77-
{' '}
78-
<code>{basePath}</code>
79-
{' | '}
80-
backend:
81-
{' '}
82-
<code>{ctx.base.connectionMeta.backend}</code>
88+
89+
<span class="flex-1" />
90+
91+
<small class="flex items-center gap-1.5 text-muted-foreground text-xs font-mono">
92+
<span>base</span>
93+
<code class="color-base">{basePath}</code>
94+
<span class="op-mute">·</span>
95+
<span>backend</span>
96+
<code class="color-base">{ctx.base.connectionMeta.backend}</code>
8397
</small>
8498
</header>
85-
<hr />
86-
{route === '/about'
87-
? <About ctx={ctx} basePath={basePath} />
88-
: <Home ctx={ctx} />}
89-
</main>
99+
100+
<main class="scrollbar-slim min-h-0 flex-1 overflow-auto">
101+
{active === '/about'
102+
? <About ctx={ctx} basePath={basePath} />
103+
: <Home ctx={ctx} />}
104+
</main>
105+
</div>
90106
)
91107
}

examples/files-inspector/src/client/main.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import { render } from 'preact'
22
import { App } from './app'
3+
import 'virtual:uno.css'
4+
import '@internal/design/theme.css'
5+
6+
// Shared design tokens flip on the `.dark` class; mirror the OS preference onto
7+
// <html> (the built-in devframe plugins follow the same approach).
8+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
9+
function applyScheme(d: boolean) {
10+
document.documentElement.classList.toggle('dark', d)
11+
document.documentElement.classList.toggle('light', !d)
12+
}
13+
applyScheme(mq.matches)
14+
mq.addEventListener('change', e => applyScheme(e.matches))
315

416
const root = document.getElementById('app')
517
if (!root)

examples/files-inspector/src/client/routes/about.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,35 @@ export function About({ ctx, basePath }: { ctx: InspectorCtx, basePath: string }
1010
})
1111
}, [ctx])
1212

13+
const rows = [
14+
{ label: 'Resolved base path', value: basePath, icon: 'i-ph-path-duotone' },
15+
{ label: 'Server cwd', value: cwd || '…', icon: 'i-ph-folder-duotone' },
16+
{ label: 'RPC backend', value: ctx.base.connectionMeta.backend, icon: 'i-ph-plugs-connected-duotone' },
17+
]
18+
1319
return (
14-
<section>
15-
<h2>About</h2>
16-
<p>
17-
This page demonstrates that the SPA discovers its mount path at
18-
runtime — the same bundle works under any base path.
20+
<section class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
21+
<div class="flex items-center gap-2">
22+
<span class="i-ph-info-duotone text-lg color-active" />
23+
<h2 class="text-base font-semibold">About</h2>
24+
</div>
25+
26+
<p class="text-sm text-muted-foreground">
27+
This page demonstrates that the SPA discovers its mount path at runtime —
28+
the same bundle works under any base path.
1929
</p>
20-
<dl>
21-
<dt>Resolved base path</dt>
22-
<dd><code>{basePath}</code></dd>
23-
<dt>Server cwd</dt>
24-
<dd><code>{cwd || '…'}</code></dd>
25-
<dt>RPC backend</dt>
26-
<dd><code>{ctx.base.connectionMeta.backend}</code></dd>
30+
31+
<dl class="overflow-hidden rounded-md border border-border bg-card text-card-foreground">
32+
{rows.map(({ label, value, icon }) => (
33+
<div
34+
key={label}
35+
class="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
36+
>
37+
<span class={`${icon} shrink-0 text-muted-foreground`} />
38+
<dt class="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
39+
<dd class="m-0 min-w-0 flex-1 truncate font-mono text-sm">{value}</dd>
40+
</div>
41+
))}
2742
</dl>
2843
</section>
2944
)

examples/files-inspector/src/client/routes/home.tsx

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { InspectorCtx } from '../app'
2+
import { badge, button } from '@internal/design/components'
23
import { useEffect, useState } from 'preact/hooks'
34

45
export function Home({ ctx }: { ctx: InspectorCtx }) {
@@ -22,22 +23,46 @@ export function Home({ ctx }: { ctx: InspectorCtx }) {
2223
}, [])
2324

2425
return (
25-
<section>
26-
<h2>
27-
Files
28-
{' '}
29-
<small>
30-
(
26+
<section class="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4">
27+
<div class="flex items-center gap-2">
28+
<span class="i-ph-files-duotone text-lg color-active" />
29+
<h2 class="text-base font-semibold">Files</h2>
30+
<span class={badge({ variant: 'secondary', class: 'font-mono tabular-nums' })}>
3131
{files.length}
32-
)
33-
</small>
34-
</h2>
35-
<button onClick={refresh} disabled={loading}>
36-
{loading ? 'Loading…' : 'Refresh'}
37-
</button>
38-
<ul>
39-
{files.map(f => <li key={f}>{f}</li>)}
40-
</ul>
32+
</span>
33+
<span class="flex-1" />
34+
<button
35+
type="button"
36+
class={button({ variant: 'outline', size: 'sm' })}
37+
onClick={refresh}
38+
disabled={loading}
39+
>
40+
<span class={loading ? 'i-ph-arrows-clockwise animate-spin' : 'i-ph-arrows-clockwise'} />
41+
{loading ? 'Loading…' : 'Refresh'}
42+
</button>
43+
</div>
44+
45+
<div class="overflow-hidden rounded-md border border-border bg-card text-card-foreground">
46+
{files.length === 0
47+
? (
48+
<p class="px-3 py-10 text-center text-sm text-muted-foreground">
49+
{loading ? 'Loading files…' : 'No files in the working directory.'}
50+
</p>
51+
)
52+
: (
53+
<ul>
54+
{files.map(f => (
55+
<li
56+
key={f}
57+
class="flex items-center gap-2 border-b border-border px-3 py-1.5 text-sm transition-colors last:border-b-0 hover:bg-accent"
58+
>
59+
<span class="i-ph-file-duotone shrink-0 text-muted-foreground" />
60+
<span class="truncate font-mono">{f}</span>
61+
</li>
62+
))}
63+
</ul>
64+
)}
65+
</div>
4166
</section>
4267
)
4368
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Side-effect style imports used by the Preact SPA entry.
2+
declare module '*.css' {}
3+
declare module 'virtual:uno.css' {}

examples/files-inspector/src/client/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { fileURLToPath } from 'node:url'
22
import preact from '@preact/preset-vite'
3+
import UnoCSS from 'unocss/vite'
34
import { defineConfig } from 'vite'
45
import { alias } from '../../../../alias'
56

67
export default defineConfig({
78
base: './',
89
root: fileURLToPath(new URL('.', import.meta.url)),
910
resolve: { alias },
10-
plugins: [preact()],
11+
plugins: [UnoCSS(), preact()],
1112
build: {
1213
outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)),
1314
emptyOutDir: true,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { presetDevframe } from '@internal/design/preset'
2+
import { defineConfig } from 'unocss'
3+
4+
// This example's Preact SPA extends the shared devframe design system for its
5+
// tokens, `df-*` vocabulary and Phosphor icons — matching the built-in plugins.
6+
export default defineConfig({
7+
presets: [presetDevframe()],
8+
content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } },
9+
})

examples/next-runtime-snapshot/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"test": "vitest run"
1818
},
1919
"dependencies": {
20+
"@internal/design": "workspace:*",
2021
"devframe": "workspace:*",
2122
"next": "catalog:frontend",
2223
"react": "catalog:frontend",
@@ -25,8 +26,10 @@
2526
"devDependencies": {
2627
"@types/react": "catalog:types",
2728
"@types/react-dom": "catalog:types",
29+
"@unocss/postcss": "catalog:frontend",
2830
"get-port-please": "catalog:deps",
2931
"h3": "catalog:deps",
32+
"unocss": "catalog:frontend",
3033
"vitest": "catalog:testing",
3134
"ws": "catalog:deps"
3235
}

0 commit comments

Comments
 (0)