AI 电子伴侣
创建时间: 2026-04-28最后更新: 2026-04-28

1. 概述

设计统一的界面规则,是构建可维护、可扩展的 UI 体系的关键。

同一个按钮在不同页面里出现不同圆角、不同描边、不同悬停色,是常见问题。问题的根源通常不在组件数量,而在样式决策散落在页面里:颜色靠临场判断,间距靠肉眼调整,状态样式靠复制粘贴。

这一篇文章要为大家分享的是,把高频视觉决策整理成可执行的设计规范,并写进 packages/ui。后面新增页面或组件时,优先复用这套规则,不再在业务代码里临时定样式

设计规范在工程里主要承担四件事:

  • 定义界面的层级关系
  • 统一交互状态的表达方式
  • 约束组件的尺寸与密度
  • 把样式决策下沉到 token 和共享组件

2. 设计规范的边界

我们只处理产品界面里复用频率最高、最容易失控的部分,不处理品牌延展、插画风格、营销页排版这些更偏视觉设计的话题。

当前范围限定在五类规则:

  • 色彩
  • 字体
  • 间距
  • 圆角
  • 阴影与状态反馈

落到代码里,这五类规则会分成三层:

  • 基础令牌:色阶、字号、间距、圆角、阴影这些原始值
  • 语义令牌surfacecontentborderbrandstate 这些可直接消费的语义值
  • 组件约束:Button、Card、Input、Badge 具体该怎么使用这些令牌

这三层职责清楚,后面改动时也不会互相牵连。调品牌色,主要影响 brand;调输入框聚焦样式,主要影响组件层;调页面层级,主要影响 surfaceborder

3. 基础令牌建立界面规则

3.1 色彩体系

深色产品界面最容易出的问题有两个:层级不清,状态色滥用

所以色彩体系先按职责分组:

  • Brand:关键操作、选中态、聚焦态
  • Surface:页面、卡片、浮层这些承载面
  • Content:标题、正文、辅助信息、禁用信息
  • Border:分隔、描边、聚焦描边、异常描边
  • State:成功、警告、错误、信息反馈

这里有三条约束先定下来:

  • 品牌色只服务于关键动作和关键状态,不拿来铺大面积背景
  • 状态色只表达反馈和风险,不承担普通强调职责
  • 深色层级主要靠亮度差和描边,少依赖高透明度白色叠加

3.2 字体体系

字体规范处理两个问题:阅读节奏和信息主次

常用界面里不需要很复杂的字号系统,保证标题、正文、辅助文字、标签文字四层关系清楚就够了。真正需要定死的是字号、字重、行高的组合关系,避免不同页面各写各的。

这一类后台或工具型界面,建议遵守下面的规则:

  • 页面标题和区块标题分层明确
  • 正文行高稳定,保证长文案可读性
  • 标签、占位符、辅助说明的灰度和字号低于正文
  • 数据密集区域优先控制一致性,少做局部放大

3.3 间距体系

间距的作用是建立节奏,不是把元素硬隔开。

建议统一使用 4px 基线,然后把常用步进固定下来。组件内部通常围绕 8、12、16、20、24 这些值展开,页面区块之间再使用更大的步进。这样做之后,按钮、输入框、卡片这些组件在不同页面里会自然保持同一密度。

3.4 圆角体系

圆角决定的是界面的亲和度和组件识别感,不是越大越高级。

这类产品界面更适合控制在三档:

  • 小圆角:输入框、标签、小尺寸按钮
  • 中圆角:默认按钮、卡片、下拉面板
  • 大圆角:弹层、重点内容容器

一定要注意,圆角层级过多,会让组件之间失去协调感

3.5 阴影与边界

深色界面里,阴影承担的是层级提示,边框承担的是轮廓识别。

卡片和浮层如果只有阴影,没有描边,在低亮度背景下很容易糊成一片。所以更稳妥的做法是:

  • 常规容器用弱描边 + 轻阴影
  • 浮层用更强的描边和更大的阴影
  • 关键聚焦元素单独给品牌色光晕

3.6 交互状态

状态规范至少要覆盖 defaulthoveractivefocus-visibledisabled

其中最容易被忽略的是 focus-visible。键盘导航时,聚焦环必须清楚可见,颜色和粗细也要统一。这个状态不该由页面临时写,应该收进基础组件。

4. 把规则写进 theme.css

前面定的是规则,接下来要把规则变成代码里的可复用令牌。

这里继续使用 Tailwind v4 + @theme,把设计令牌集中写进 packages/ui/src/theme.css

packages/ui/src/theme.css
01
@source "./**/*.{ts,tsx}";
02
03
@theme {
04
/* Brand */
05
--color-brand-50: #f3f7ff;
06
--color-brand-100: #dce8ff;
07
--color-brand-200: #b8d0ff;
08
--color-brand-300: #8eb8ff;
09
--color-brand-400: #6d9fff;
10
--color-brand-500: #4f7cff;
11
--color-brand-600: #315ee8;
12
--color-brand-700: #2749bb;
13
--color-brand-800: #1a317a;
14
--color-brand-900: #111f4a;
15
16
/* Surface */
17
--color-surface-canvas: #0a0f1e;
18
--color-surface-panel: #111827;
19
--color-surface-elevated: #172033;
20
--color-surface-overlay: rgba(10, 15, 30, 0.88);
21
22
/* Content */
23
--color-content-primary: #f8fafc;
24
--color-content-secondary: #cbd5e1;
25
--color-content-tertiary: #94a3b8;
26
--color-content-disabled: #64748b;
27
--color-content-inverse: #0f172a;
28
29
/* Border */
30
--color-border-default: rgba(255, 255, 255, 0.08);
31
--color-border-strong: rgba(255, 255, 255, 0.14);
32
--color-border-focus: rgba(79, 124, 255, 0.48);
33
--color-border-error: rgba(239, 68, 68, 0.45);
34
35
/* Feedback */
36
--color-state-success: #22c55e;
37
--color-state-success-subtle: rgba(34, 197, 94, 0.14);
38
--color-state-warning: #f59e0b;
39
--color-state-warning-subtle: rgba(245, 158, 11, 0.14);
40
--color-state-error: #ef4444;
41
--color-state-error-subtle: rgba(239, 68, 68, 0.14);
42
--color-state-info: #38bdf8;
43
--color-state-info-subtle: rgba(56, 189, 248, 0.14);
44
45
/* Radius */
46
--radius-sm: 0.5rem;
47
--radius-md: 0.75rem;
48
--radius-lg: 1rem;
49
--radius-xl: 1.5rem;
50
51
/* Shadow */
52
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.24);
53
--shadow-sm: 0 6px 16px rgba(0, 0, 0, 0.24);
54
--shadow-md: 0 12px 32px rgba(0, 0, 0, 0.32);
55
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.42);
56
--shadow-glow: 0 0 24px rgba(79, 124, 255, 0.18);
57
58
/* Typography */
59
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
60
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
61
62
--text-xs: 0.75rem;
63
--text-sm: 0.875rem;
64
--text-base: 1rem;
65
--text-lg: 1.125rem;
66
--text-xl: 1.25rem;
67
--text-2xl: 1.5rem;
68
--text-3xl: 1.875rem;
69
}

这份令牌有几个关键点:

  • surface 单独分了 canvaspanelelevatedoverlay 四层,页面层级会更稳定
  • content 单独区分 primarysecondarytertiary,文字颜色不会到处手写灰度
  • borderstate 分开,普通描边和异常反馈各走各的通道
  • 阴影没有做彩色扩散,品牌光感单独放在 shadow-glow

到这里,设计规范才真正进入工程体系。后面组件里写的类名,不再是临时选颜色,而是在消费明确的语义令牌。

5. 让基础组件消费这些令牌

设计规范只有写进组件,约束力才够强。页面代码直接写样式的空间越小,规范越不容易失守。

5.1 Button

按钮最容易暴露规范是否统一,因为它同时涉及颜色、圆角、尺寸、交互状态和风险语义。

packages/ui/src/button.tsx
01
import * as React from 'react'
02
import { Slot } from '@radix-ui/react-slot'
03
import { cva, type VariantProps } from 'class-variance-authority'
04
import { cn } from './lib/utils'
05
06
const buttonVariants = cva(
07
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-brand-500/45 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-canvas',
08
{
09
variants: {
10
variant: {
11
default: 'bg-brand-500 text-content-primary shadow-xs hover:bg-brand-600',
12
secondary:
13
'border border-border-default bg-surface-panel text-content-primary hover:border-border-strong hover:bg-surface-elevated',
14
outline:
15
'border border-border-default bg-transparent text-content-secondary hover:bg-surface-panel hover:text-content-primary',
16
ghost: 'text-content-secondary hover:bg-surface-panel hover:text-content-primary',
17
danger: 'bg-state-error text-content-primary shadow-xs hover:bg-red-600',
18
},
19
size: {
20
default: 'h-11 px-5',
21
sm: 'h-9 rounded-lg px-4 text-xs',
22
lg: 'h-12 px-6 text-base',
23
icon: 'size-10 px-0',
24
},
25
},
26
defaultVariants: {
27
variant: 'default',
28
size: 'default',
29
},
30
}
31
)
32
33
type ButtonProps = React.ComponentProps<'button'> &
34
VariantProps<typeof buttonVariants> & {
35
asChild?: boolean
36
}
37
38
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
39
const Comp = asChild ? Slot : 'button'
40
41
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />
42
}
43
44
export { Button, buttonVariants }

这版按钮主要落实了四个约束:

  • 品牌色只用于默认主操作
  • danger 单独占一个语义通道,不和品牌色混用
  • 所有尺寸共享同一套状态规则
  • 键盘聚焦样式集中在基础组件,不交给页面零散处理

5.2 Card

卡片组件更适合用来验证层级和边界规则。

packages/ui/src/card.tsx
01
import * as React from 'react'
02
import { cn } from './lib/utils'
03
04
function Card({ className, ...props }: React.ComponentProps<'div'>) {
05
return (
06
<div
07
className={cn(
08
'rounded-2xl border border-border-default bg-surface-panel text-content-primary shadow-sm backdrop-blur-xs',
09
className
10
)}
11
{...props}
12
/>
13
)
14
}
15
16
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
17
return <div className={cn('grid gap-1.5 p-6', className)} {...props} />
18
}
19
20
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
21
return <div className={cn('text-lg font-semibold tracking-tight', className)} {...props} />
22
}
23
24
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
25
return <div className={cn('text-sm leading-6 text-content-secondary', className)} {...props} />
26
}
27
28
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
29
return <div className={cn('px-6 pb-6', className)} {...props} />
30
}
31
32
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
33
return <div className={cn('flex items-center gap-3 px-6 pb-6', className)} {...props} />
34
}
35
36
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

这里的重点是把层级理清楚:背景来自 surface,轮廓来自 border,浮起感来自弱阴影,内容对比来自 content

5.3 Input

输入框要解决的是可读性、聚焦感和异常反馈。

packages/ui/src/input.tsx
01
import * as React from 'react'
02
import { cn } from './lib/utils'
03
04
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
05
return (
06
<input
07
type={type}
08
data-slot="input"
09
className={cn(
10
'flex h-11 w-full rounded-xl border border-border-default bg-surface-canvas px-4 text-sm text-content-primary outline-hidden transition-colors placeholder:text-content-tertiary disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-border-focus focus-visible:ring-2 focus-visible:ring-brand-500/25 aria-invalid:border-border-error aria-invalid:ring-2 aria-invalid:ring-state-error/20',
11
className
12
)}
13
{...props}
14
/>
15
)
16
}
17
18
export { Input }

这里有两个约束值得固定下来:

  • 默认背景使用 surface-canvas,让输入区和卡片区天然拉开层级
  • 无效态使用 aria-invalid,表单语义和视觉反馈保持同一路径

5.4 Label

Label 很简单,但它决定了表单信息的主次关系。

packages/ui/src/label.tsx
01
import * as React from 'react'
02
import * as LabelPrimitive from '@radix-ui/react-label'
03
import { cn } from './lib/utils'
04
05
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
06
return (
07
<LabelPrimitive.Root
08
className={cn(
09
'text-sm font-medium leading-none text-content-secondary peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
10
className
11
)}
12
{...props}
13
/>
14
)
15
}
16
17
export { Label }

Label 的颜色低于正文,高于占位符,这样字段名不会和用户输入抢层级。

5.5 Badge

标签组件最适合承载轻量状态和分类信息。

packages/ui/src/badge.tsx
01
import * as React from 'react'
02
import { cva, type VariantProps } from 'class-variance-authority'
03
import { cn } from './lib/utils'
04
05
const badgeVariants = cva(
06
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
07
{
08
variants: {
09
variant: {
10
default: 'border-transparent bg-brand-500/14 text-brand-300',
11
secondary: 'border-transparent bg-surface-elevated text-content-secondary',
12
outline: 'border-border-default text-content-secondary',
13
success: 'border-transparent bg-state-success-subtle text-state-success',
14
warning: 'border-transparent bg-state-warning-subtle text-state-warning',
15
error: 'border-transparent bg-state-error-subtle text-state-error',
16
},
17
},
18
defaultVariants: {
19
variant: 'default',
20
},
21
}
22
)
23
24
type BadgeProps = React.ComponentProps<'div'> & VariantProps<typeof badgeVariants>
25
26
function Badge({ className, variant, ...props }: BadgeProps) {
27
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
28
}
29
30
export { Badge, badgeVariants }

Badge 的规则很直接:轻量背景、清楚前景、尺寸紧凑。状态标签要能被快速扫到,但不能抢走主操作按钮的注意力。

6. 用页面验收这套规范

设计规范定完之后,必须放进真实页面看层级和密度。只在组件单测页里看,很难发现组合后的问题。

6.1 建一个设计系统演示页

演示页的目的不是炫组件,而是集中检查基础令牌和组件约束有没有冲突。

apps/web/app/design-system/page.tsx
01
import { Badge } from '@repo/ui/badge'
02
import { Button } from '@repo/ui/button'
03
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui/card'
04
import { Input } from '@repo/ui/input'
05
import { Label } from '@repo/ui/label'
06
import { Separator } from '@repo/ui/separator'
07
08
export default function DesignSystemPage() {
09
return (
10
<main className="mx-auto flex max-w-250 flex-col gap-8 p-8">
11
<header className="grid gap-2">
12
<h1 className="text-3xl font-semibold text-content-primary">Design System</h1>
13
<p className="text-content-secondary">
14
用于校验颜色、层级、表单状态和按钮变体的一组基础页面。
15
</p>
16
</header>
17
18
<Card>
19
<CardHeader>
20
<CardTitle>色彩与状态</CardTitle>
21
<CardDescription>检查品牌色、状态色和文字层级是否清楚。</CardDescription>
22
</CardHeader>
23
<CardContent className="grid gap-6">
24
<div className="grid gap-3">
25
<Label>Brand</Label>
26
<div className="flex gap-3">
27
<div className="size-12 rounded-xl bg-brand-400" />
28
<div className="size-12 rounded-xl bg-brand-500" />
29
<div className="size-12 rounded-xl bg-brand-600" />
30
</div>
31
</div>
32
33
<Separator />
34
35
<div className="grid gap-3">
36
<Label>State</Label>
37
<div className="flex flex-wrap gap-3">
38
<Badge variant="success">Success</Badge>
39
<Badge variant="warning">Warning</Badge>
40
<Badge variant="error">Error</Badge>
41
<Badge variant="default">Info</Badge>
42
</div>
43
</div>
44
</CardContent>
45
</Card>
46
47
<Card>
48
<CardHeader>
49
<CardTitle>按钮与操作层级</CardTitle>
50
<CardDescription>检查主操作、次操作和风险操作的区分是否稳定。</CardDescription>
51
</CardHeader>
52
<CardContent className="grid gap-4">
53
<div className="flex flex-wrap gap-3">
54
<Button>Primary</Button>
55
<Button variant="secondary">Secondary</Button>
56
<Button variant="outline">Outline</Button>
57
<Button variant="ghost">Ghost</Button>
58
<Button variant="danger">Danger</Button>
59
</div>
60
61
<Separator />
62
63
<div className="flex flex-wrap gap-3">
64
<Button size="sm">Small</Button>
65
<Button size="default">Default</Button>
66
<Button size="lg">Large</Button>
67
</div>
68
</CardContent>
69
</Card>
70
71
<Card>
72
<CardHeader>
73
<CardTitle>表单状态</CardTitle>
74
<CardDescription>检查输入区层级、聚焦环和异常态。</CardDescription>
75
</CardHeader>
76
<CardContent className="grid gap-4">
77
<div className="grid gap-2">
78
<Label htmlFor="project-name">项目名称</Label>
79
<Input id="project-name" placeholder="输入项目名称" />
80
</div>
81
82
<div className="grid gap-2">
83
<Label htmlFor="project-slug">项目标识</Label>
84
<Input id="project-slug" aria-invalid defaultValue="ai companion" />
85
</div>
86
</CardContent>
87
</Card>
88
</main>
89
)
90
}

这个页面重点看三件事:

  • 组件单独展示时是否协调
  • 组件组合后层级是否仍然清楚
  • 风险态、聚焦态、禁用态是否和普通态明显区分

6.2 更新共享演示组件

tailwind-demo.tsx 可以继续保留,但它的职责要更清楚:用来验证共享包组件在不同应用里都能正确消费,不承担完整规范说明。

packages/ui/src/tailwind-demo.tsx
01
import { Badge } from './badge'
02
import { Button } from './button'
03
import {
04
Card,
05
CardContent,
06
CardDescription,
07
CardFooter,
08
CardHeader,
09
CardTitle,
10
} from './card'
11
import { Input } from './input'
12
import { Label } from './label'
13
import { Separator } from './separator'
14
15
type TailwindDemoProps = {
16
appName: string
17
}
18
19
export function TailwindDemo({ appName }: TailwindDemoProps) {
20
return (
21
<Card className="w-full max-w-250 overflow-hidden">
22
<CardHeader className="grid gap-4 md:grid-cols-[1fr_auto] md:items-start">
23
<div className="grid gap-3">
24
<Badge>shared ui package</Badge>
25
<div className="grid gap-2">
26
<CardTitle className="text-2xl md:text-3xl">
27
Design system is active in {appName}
28
</CardTitle>
29
<CardDescription className="max-w-160">
30
基础组件已经统一消费设计令牌,颜色、层级、圆角和状态反馈保持一致。
31
</CardDescription>
32
</div>
33
</div>
34
35
<div className="grid gap-3 sm:grid-cols-3">
36
<div className="rounded-xl border border-border-default bg-surface-canvas px-4 py-3 text-sm text-content-secondary">
37
Shared tokens
38
</div>
39
<div className="rounded-xl border border-border-default bg-surface-canvas px-4 py-3 text-sm text-content-secondary">
40
Shared primitives
41
</div>
42
<div className="rounded-xl border border-border-default bg-surface-canvas px-4 py-3 text-sm text-content-secondary">
43
Shared states
44
</div>
45
</div>
46
</CardHeader>
47
48
<CardContent className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
49
<div className="grid gap-4 rounded-xl border border-border-default bg-surface-canvas p-5">
50
<div className="grid gap-2">
51
<Label htmlFor={`${appName}-workspace`}>Workspace</Label>
52
<Input id={`${appName}-workspace`} defaultValue={`${appName}.workspace.local`} />
53
</div>
54
<Separator />
55
<div className="flex flex-wrap gap-3">
56
<Button>Primary action</Button>
57
<Button variant="secondary">Secondary action</Button>
58
<Button variant="outline">Outline action</Button>
59
</div>
60
</div>
61
62
<div className="rounded-xl border border-border-default bg-surface-canvas p-5 text-sm leading-6 text-content-secondary">
63
所有应用都通过同一套共享组件输出界面,后续调整主题或组件状态时,只需要维护一处。
64
</div>
65
</CardContent>
66
67
<CardFooter className="flex-wrap gap-3 border-t border-border-default pt-6">
68
<Button asChild variant="ghost">
69
<a href="/design-system" target="_blank" rel="noopener noreferrer">
70
View design system
71
</a>
72
</Button>
73
</CardFooter>
74
</Card>
75
)
76
}

7. 组件使用约束

设计规范落地之后,团队还需要几条明确约束,避免页面代码重新把体系写散。

颜色约束

  • 页面代码不直接写 #xxxxxxrgba(...),先补 token
  • 文本颜色优先使用 content-*
  • 背景颜色优先使用 surface-*
  • 风险、成功、警告等反馈统一使用 state-*

组件约束

  • 主按钮、次按钮、风险按钮都从 Button 变体中选
  • 表单容器优先用 Card 组合,不重新手写面板结构
  • 标签和状态优先用 Badge,不临时拼一段颜色 class
  • 表单标签和输入框配对使用 Label + Input

扩展约束

  • 新增颜色、圆角、阴影,先更新 theme.css
  • 新增组件变体,优先扩展共享组件
  • 页面局部特殊样式要有明确原因,避免把例外写成常态