Skip to content

Commit 97b65e9

Browse files
antfubotantfu
andauthored
feat(design): shared uno.config base with h-nav and unified nav header (#120)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent a478c37 commit 97b65e9

35 files changed

Lines changed: 684 additions & 1509 deletions

File tree

design/design.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// @unocss-include
2+
// Shared devframe -> @antfu/design class helpers: framework-neutral builders
3+
// returning @antfu/design's semantic shortcut classes, so every surface (Solid,
4+
// Svelte, React, Preact, vanilla) looks identical to the antfu Vue components.
5+
// The `@unocss-include` marker makes UnoCSS emit the runtime-assembled class
6+
// chains below; `design/uno.config.ts` adds this file to every app's content so
7+
// the chains are extracted regardless of framework.
8+
// Tag palette kept literal for extraction: badge-color-blue badge-color-amber
9+
// badge-color-green badge-color-red badge-color-sky badge-color-violet
10+
// badge-color-rose badge-color-teal badge-color-orange badge-color-emerald
11+
12+
export function cx(...parts: Array<string | false | null | undefined>): string {
13+
return parts.filter(Boolean).join(' ')
14+
}
15+
16+
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'
17+
export type ButtonSize = 'md' | 'sm' | 'lg'
18+
export interface ButtonProps { variant?: ButtonVariant, size?: ButtonSize, class?: string }
19+
20+
export function button({ variant = 'primary', size = 'md', class: extra }: ButtonProps = {}): string {
21+
const variantClass: Record<ButtonVariant, string> = {
22+
primary: 'btn-primary',
23+
secondary: 'btn-action',
24+
outline: 'btn-action',
25+
ghost: 'inline-flex items-center justify-center gap-1.5 rounded px2 py1 op75 hover:op100 hover:bg-active transition disabled:pointer-events-none disabled:op30!',
26+
destructive: 'btn-action text-error border-error/30!',
27+
link: 'inline-flex items-center gap-1.5 color-active hover:underline underline-offset-2',
28+
}
29+
const sizeClass = size === 'sm'
30+
? (variant === 'primary' ? 'text-sm px-2.5! py-1!' : 'text-sm')
31+
: size === 'lg' ? 'text-base px-4! py-2!' : ''
32+
return cx(variantClass[variant], sizeClass, extra)
33+
}
34+
35+
export type IconButtonVariant = 'outline' | 'ghost'
36+
export type IconButtonSize = 'md' | 'sm'
37+
export interface IconButtonProps { variant?: IconButtonVariant, size?: IconButtonSize, class?: string }
38+
39+
export function iconButton({ variant = 'outline', size = 'md', class: extra }: IconButtonProps = {}): string {
40+
const base = variant === 'ghost' ? 'btn-icon' : 'btn-icon-square'
41+
const sizeClass = size === 'sm' ? 'w-7! h-7! text-sm' : ''
42+
return cx(base, sizeClass, extra)
43+
}
44+
45+
export type BadgeVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
46+
export interface BadgeProps { variant?: BadgeVariant, class?: string }
47+
48+
export function badge({ variant = 'secondary', class: extra }: BadgeProps = {}): string {
49+
const variantClass: Record<BadgeVariant, string> = {
50+
primary: 'badge-active',
51+
secondary: 'badge-muted',
52+
success: 'badge badge-color-green',
53+
warning: 'badge badge-color-amber',
54+
destructive: 'badge badge-color-red',
55+
outline: 'badge border border-base',
56+
}
57+
return cx(variantClass[variant], extra)
58+
}
59+
60+
export function tag(color: string, extra?: string): string {
61+
return cx('badge', `badge-color-${color}`, extra)
62+
}
63+
64+
export function tabsList(extra?: string): string {
65+
return cx('inline-flex items-center gap-1 p-1 rounded-lg bg-secondary w-max', extra)
66+
}
67+
68+
export function tab(extra?: string): string {
69+
return cx(
70+
'px-3 py-1 rounded-md text-sm color-muted inline-flex gap-1.5 items-center whitespace-nowrap select-none cursor-pointer transition outline-none hover:color-base focus-visible:ring-2 focus-visible:ring-primary-500/40 data-[state=active]:bg-base data-[state=active]:color-base data-[state=active]:shadow-sm',
71+
extra,
72+
)
73+
}
74+
75+
export interface NavTabProps { active?: boolean, class?: string }
76+
export function navTab({ active = false, class: extra }: NavTabProps = {}): string {
77+
return cx(
78+
'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 hover:op100 hover:bg-active',
79+
active && 'op100! bg-active border-base! color-base',
80+
extra,
81+
)
82+
}
83+
84+
export function nav(extra?: string): string {
85+
return cx('flex items-center gap-2 shrink-0 h-nav px-3 border-b border-base bg-base z-nav', extra)
86+
}
87+
88+
export function navBrand(extra?: string): string {
89+
return cx('flex items-center gap-1.5 shrink-0 font-semibold text-sm select-none', extra)
90+
}
91+
92+
// Mirrors devframe's `DevframeConnectionStatus` (kept local so this class-helper
93+
// module stays free of package imports); the two share the same string members.
94+
export type ConnectionStatus = 'connecting' | 'connected' | 'unauthorized' | 'disconnected' | 'error'
95+
96+
export interface ConnectionIndicator {
97+
/** Short status label, e.g. `disconnected`. */
98+
label: string
99+
/** Class chain for the status dot. */
100+
dot: string
101+
/** Class chain for the pill wrapper. */
102+
class: string
103+
}
104+
105+
const CONNECTION_TONE: Record<Exclude<ConnectionStatus, 'connected'>, { label: string, dot: string }> = {
106+
connecting: { label: 'connecting…', dot: 'bg-neutral-400 animate-pulse' },
107+
disconnected: { label: 'disconnected', dot: 'bg-error' },
108+
unauthorized: { label: 'unauthorized', dot: 'bg-warning' },
109+
error: { label: 'error', dot: 'bg-error' },
110+
}
111+
112+
// The shared top-nav connection indicator: a small status dot + label. Returns
113+
// `null` when the client is `connected`, so every surface renders the indicator
114+
// only while the connection is not live.
115+
export function connectionIndicator(status: ConnectionStatus, extra?: string): ConnectionIndicator | null {
116+
if (status === 'connected')
117+
return null
118+
const tone = CONNECTION_TONE[status]
119+
return {
120+
label: tone.label,
121+
dot: cx('inline-block size-1.5 rounded-full shrink-0', tone.dot),
122+
class: cx('flex items-center gap-1.5 shrink-0 text-xs color-muted select-none', extra),
123+
}
124+
}
125+
126+
export interface ConnectionStateCopy {
127+
/** Phosphor icon for the state glyph. */
128+
icon: string
129+
/** Short heading, e.g. `Disconnected`. */
130+
title: string
131+
/** One-line explanation of the state and how to recover. */
132+
body: string
133+
/** Whether to offer the reload recovery button (every state but `connecting`). */
134+
reloadable: boolean
135+
/** Whether the glyph should animate while the handshake is in flight. */
136+
spin: boolean
137+
}
138+
139+
const CONNECTION_STATE: Record<Exclude<ConnectionStatus, 'connected'>, ConnectionStateCopy> = {
140+
connecting: {
141+
icon: 'i-ph-plugs-connected-duotone',
142+
title: 'Connecting…',
143+
body: 'Establishing a connection to the devframe server.',
144+
reloadable: false,
145+
spin: true,
146+
},
147+
disconnected: {
148+
icon: 'i-ph-plugs-duotone',
149+
title: 'Disconnected',
150+
body: 'Lost the connection to the devframe server. Reload once it is back up.',
151+
reloadable: true,
152+
spin: false,
153+
},
154+
unauthorized: {
155+
icon: 'i-ph-lock-key-duotone',
156+
title: 'Not authorized',
157+
body: 'This client isn’t authorized. Reopen the link printed by your dev server, then reload.',
158+
reloadable: true,
159+
spin: false,
160+
},
161+
error: {
162+
icon: 'i-ph-warning-octagon-duotone',
163+
title: 'Connection failed',
164+
body: 'Could not reach the devframe server.',
165+
reloadable: true,
166+
spin: false,
167+
},
168+
}
169+
170+
// The shared full-panel connection state copy: shown whenever the client isn't
171+
// `connected`, so a surface never sits on an infinite spinner without saying
172+
// why. Returns `null` when connected. Pair with the `connection*` class builders
173+
// below so every surface renders the identical centered glyph + title + body.
174+
export function connectionState(status: ConnectionStatus): ConnectionStateCopy | null {
175+
if (status === 'connected')
176+
return null
177+
return CONNECTION_STATE[status]
178+
}
179+
180+
// Centered fill for the full-panel state; each surface adds its own fill
181+
// strategy (`h-full`, `h-svh w-full`, `absolute inset-0`, …) via `extra`.
182+
export function connectionPanel(extra?: string): string {
183+
return cx('flex flex-col items-center justify-center gap-4 bg-base p-8 text-center', extra)
184+
}
185+
186+
export function connectionGlyph(spin = false, extra?: string): string {
187+
return cx('text-4xl color-active', spin && 'animate-pulse', extra)
188+
}
189+
190+
export function connectionTitle(extra?: string): string {
191+
return cx('text-lg font-medium color-base', extra)
192+
}
193+
194+
export function connectionBody(extra?: string): string {
195+
return cx('max-w-sm text-sm color-muted', extra)
196+
}
197+
198+
export function connectionDetail(extra?: string): string {
199+
return cx('mt-1 max-w-sm break-words font-mono text-xs color-faint', extra)
200+
}
201+
202+
export function toolbar(extra?: string): string {
203+
return cx('flex items-center gap-2 shrink-0 h-8 px-2.5 border-b border-base bg-secondary text-sm', extra)
204+
}
205+
206+
export function card(extra?: string): string {
207+
return cx('flex flex-col rounded-xl border border-base bg-base shadow-sm', extra)
208+
}
209+
210+
export function panel(extra?: string): string {
211+
return cx('rounded-lg border border-base bg-base', extra)
212+
}
213+
214+
export function input(extra?: string): string {
215+
return cx('w-full min-w-0 rounded border border-base bg-base px-2.5 py-1 text-sm outline-none transition placeholder:color-faint focus-visible:border-active focus-visible:ring-2 focus-visible:ring-primary-500/40', extra)
216+
}
217+
218+
export function link(extra?: string): string {
219+
return cx('color-active hover:underline underline-offset-2', extra)
220+
}
221+
222+
export type DotState = 'running' | 'idle' | 'error'
223+
export function dot(state: DotState, extra?: string): string {
224+
const stateClass: Record<DotState, string> = {
225+
running: 'bg-success',
226+
idle: 'bg-neutral-400',
227+
error: 'bg-error',
228+
}
229+
return cx('inline-block size-1.5 rounded-full shrink-0', stateClass[state], extra)
230+
}
231+
232+
export function spinner(extra?: string): string {
233+
return cx('inline-block size-4 rounded-full border-2 border-current border-t-transparent animate-spin', extra)
234+
}

design/uno.config.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { fileURLToPath } from 'node:url'
2+
import { presetAnthonyDesign } from '@antfu/design/unocss'
3+
import {
4+
defineConfig,
5+
presetIcons,
6+
presetWebFonts,
7+
presetWind4,
8+
transformerDirectives,
9+
transformerVariantGroup,
10+
} from 'unocss'
11+
12+
// Shared devframe UnoCSS base. Every plugin and example composes `@antfu/design`
13+
// the same way — its preset (tuned to devframe's sage green) over a Wind4 base,
14+
// Phosphor icons, DM Sans/Mono web fonts, and the directive/variant-group
15+
// transformers — so the surfaces look and feel like one product across
16+
// frameworks. Each app extends this via `mergeConfigs([designConfig, { … }])`
17+
// and contributes only its own extraction globs (and any safelist).
18+
//
19+
// The shared web fonts (`sans`/`mono`), the named `z-*` layers and the `h-nav`
20+
// navbar height live here so every surface shares one font stack, one z-index
21+
// scale and one fixed navbar height. The `@antfu/design` preset blocks plain
22+
// `z-<number>`, so the layers are named on purpose.
23+
export const designConfig = defineConfig({
24+
presets: [
25+
presetAnthonyDesign({ primary: '#3a6a45' }),
26+
presetWind4(),
27+
presetIcons({ scale: 1.1 }),
28+
presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
29+
],
30+
transformers: [transformerDirectives(), transformerVariantGroup()],
31+
// The shared class-helper builders (`design/design.ts`) assemble their class
32+
// chains at runtime, so every app scans that one file (it carries
33+
// `@unocss-include`) for extraction regardless of its own framework globs.
34+
content: {
35+
filesystem: [fileURLToPath(new URL('./design.ts', import.meta.url))],
36+
},
37+
// Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle
38+
// shared border color (matching `border-base`) for unqualified borders.
39+
preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }],
40+
shortcuts: {
41+
// Fixed navbar height, shared by every surface's top nav.
42+
'h-nav': 'h-10',
43+
// Named z-index layers, shared across every surface.
44+
'z-nav': 'z-[30]',
45+
'z-dropdown': 'z-[40]',
46+
'z-tooltip': 'z-[45]',
47+
'z-toast': 'z-[50]',
48+
'z-modal-backdrop': 'z-[60]',
49+
'z-modal-content': 'z-[70]',
50+
'z-drawer-backdrop': 'z-[80]',
51+
'z-drawer-content': 'z-[90]',
52+
},
53+
})
54+
55+
export default designConfig

0 commit comments

Comments
 (0)