Skip to content

Commit 2240cae

Browse files
committed
Merge branch 'feature/header-system' into develop
2 parents 33de556 + 4a638de commit 2240cae

23 files changed

Lines changed: 833 additions & 275 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Header 设计规范
2+
3+
> **版本:** 1.0
4+
> **日期:** 2026-05-18
5+
> **状态:** Draft
6+
> **作者:** Codex
7+
8+
## 0. 目标
9+
10+
统一当前项目的页面 header、列表/面板 header、弹框 header,收敛到固定共享 contract,禁止页面、功能模块、弹层各自再写一套标题区结构和字号规则。
11+
12+
本规范的核心目标只有两个:
13+
14+
- header 结构统一
15+
- header 排版 token 固定,禁止自定义字号
16+
17+
## 1. 适用范围
18+
19+
本规范适用于普通业务 UI 中承担“标题区 / 顶部信息区”职责的 header:
20+
21+
- 一级页面 header
22+
- 二级页面 header
23+
- panel / 列表 / 侧栏 / 卡片上沿 header
24+
- modal / dialog / sheet 标题区
25+
26+
不纳入本规范的豁免面:
27+
28+
- terminal / code editor / diff / review 等技术面板 header
29+
- 纯品牌展示型 hero
30+
- 不承担页面或容器标题职责的普通工具栏
31+
32+
说明:
33+
34+
- 名字里带 `header` 不等于必须纳入本规范。
35+
- 是否纳入,取决于它是不是“页面或容器的标准标题区”。
36+
37+
## 2. 唯一允许的 Header Contract
38+
39+
业务 UI 只允许使用以下共享 header 入口:
40+
41+
### 2.1 `PageHeader`
42+
43+
用途:
44+
45+
- 一级页面
46+
- 二级页面
47+
- 设置页、内容页、详情页的顶部标题区
48+
49+
当前 contract:
50+
51+
- `level="primary"`:页面级主标题
52+
- `level="secondary"`:二级页 / 常规页标题
53+
- `onBack` / `backLabel`
54+
- `kicker`
55+
- `rightSlot`
56+
57+
排版约束:
58+
59+
- `PageHeader.primary` title 固定使用 `--type-section-title-*`
60+
- `PageHeader.secondary` title 固定使用 `--type-app-title-*`
61+
- `PageHeader` kicker 固定使用 `--type-kicker-*`
62+
- `PageHeader` back 文案固定使用 `--type-label-*`
63+
64+
### 2.2 `MobilePageHeader`
65+
66+
用途:
67+
68+
- 移动端页面 header
69+
- 全屏 mobile sheet 的标准 header
70+
71+
说明:
72+
73+
- `MobilePageHeader``PageHeader` 的移动端包装,不是另一套独立排版系统。
74+
- 它只能调整移动端布局、间距和返回区交互密度,不能自定义 title 字号。
75+
76+
排版约束:
77+
78+
- title 固定使用 `--type-app-title-*`
79+
- back 文案固定使用 `--type-code-inline-*`
80+
81+
### 2.3 `PanelHeader`
82+
83+
用途:
84+
85+
- 列表区块
86+
- workspace panel
87+
- 侧栏 / 卡片 / 面板上沿标题区
88+
89+
当前 contract:
90+
91+
- `title`
92+
- `status`
93+
- `meta`
94+
- `actions`
95+
96+
排版约束:
97+
98+
- title 固定使用 `--type-app-title-*`
99+
- `status` / `meta` 只能使用共享辅助文案语义,不允许局部重写 panel title 字号
100+
101+
### 2.4 `DialogHeader`
102+
103+
用途:
104+
105+
- modal
106+
- dialog
107+
- 标准弹层标题区
108+
109+
说明:
110+
111+
- `DialogHeader` 是弹层 header 的 canonical 入口。
112+
- `ModalHeader` 仅作为兼容命名,不应继续扩展成新规范分支。
113+
114+
推荐结构:
115+
116+
- `dialog-header__leading`
117+
- `dialog-header__icon`
118+
- `dialog-header__copy`
119+
- `dialog-header__description`
120+
- `ModalTitle`
121+
122+
排版约束:
123+
124+
- `ModalTitle` / dialog title 固定使用 `--type-section-title-*`
125+
- `dialog-header__description` 固定使用 `--type-meta-*`
126+
127+
## 3. 允许的差异
128+
129+
允许存在的差异只有以下几类:
130+
131+
- 高度
132+
- padding / gap / 对齐方式
133+
- 是否带返回
134+
- 是否带 kicker
135+
- 是否带 actions / meta / status
136+
- icon 样式和状态色
137+
138+
这些差异必须建立在共享 contract 之上实现,不能通过重新发明一套 header DOM 来实现。
139+
140+
## 4. 明确禁止项
141+
142+
以下做法一律不允许:
143+
144+
- 页面自己新写 `xxx-header` DOM 结构来替代 `PageHeader`
145+
- modal / dialog 自己拼一套标题区而不走 `DialogHeader`
146+
- panel 自己写标题区而不走 `PanelHeader`
147+
- 在页面包装层覆盖 `.page-header__title` / `.panel-header__title` / `.modal-title` 字号
148+
- 在场景样式里新增 header 专属 `font-size` / `line-height` / `font-weight` override
149+
- header 继续直接使用裸写 `px`
150+
- header 继续直接使用旧 `--text-*` 尺度 token
151+
152+
一句话规则:
153+
154+
- 页面可以改 header 的布局,不可以改 header 的排版语义。
155+
156+
## 5. Token 使用规则
157+
158+
header 只允许消费 `--type-*` 语义排版 token,不允许直接消费基础字号 token。
159+
160+
当前批准映射如下:
161+
162+
- `PageHeader.primary` -> `--type-section-title-*`
163+
- `PageHeader.secondary` -> `--type-app-title-*`
164+
- `MobilePageHeader.title` -> `--type-app-title-*`
165+
- `MobilePageHeader.back` -> `--type-code-inline-*`
166+
- `PanelHeader.title` -> `--type-app-title-*`
167+
- `DialogHeader.title` -> `--type-section-title-*`
168+
- `DialogHeader.description` -> `--type-meta-*`
169+
170+
如果未来要调整 header 的视觉层级,只能修改这份映射和共享实现,不能在业务页面就地逃逸。
171+
172+
## 6. 落地原则
173+
174+
后续新增页面或重构旧页面时,按下面规则执行:
175+
176+
1. 先判断这是页面 header、panel header 还是 dialog header。
177+
2. 直接复用共享组件。
178+
3. 如果现有 props 不够,优先扩展共享组件 contract。
179+
4. 只有在确认不属于标准标题区时,才允许不用这套 header contract。
180+
181+
评审口径:
182+
183+
- 看到自定义 header DOM,默认视为问题
184+
- 看到 header 自定义字号,默认视为问题
185+
- 看到 header 使用 `--text-*` 或裸 `px`,默认视为问题
186+
187+
## 7. 当前仓库结论
188+
189+
截至 2026-05-18,这套规范在当前代码中的主路径已经收敛为:
190+
191+
- `PageHeader`
192+
- `MobilePageHeader`
193+
- `PanelHeader`
194+
- `DialogHeader`
195+
196+
同时已确认:
197+
198+
- settings 桌面 header 已回收为共享 `PageHeader`
199+
- dialog description 已回收为 `dialog-header__description`
200+
- header 主路径已与 `develop` 上新的 semantic typography token 对齐
201+
202+
后续如果再出现 header 自定义字号,应视为违反规范而不是可接受的局部例外。

packages/web/src/components/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export { Input } from "./input";
2323
export type { KbdProps } from "./kbd";
2424
export { Kbd } from "./kbd";
2525
export type { ModalProps, ModalSize } from "./modal";
26-
export { Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from "./modal";
26+
export { DialogHeader, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from "./modal";
2727
export type { NoticeProps, NoticeTone } from "./notice";
2828
export { Notice } from "./notice";
2929
export type { PillProps } from "./pill";

packages/web/src/components/ui/modal/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
```tsx
77
<Modal open onOpenChange={setOpen}>
8-
<ModalHeader>
8+
<DialogHeader>
99
<ModalTitle>Workspace details</ModalTitle>
10-
</ModalHeader>
10+
</DialogHeader>
1111
<ModalBody>Body</ModalBody>
1212
<ModalFooter>Footer</ModalFooter>
1313
</Modal>
@@ -26,4 +26,6 @@
2626
## 注意
2727
- 组件通过 portal 渲染到 `document.body`
2828
- 打开后会把焦点移入弹窗,关闭后恢复到先前焦点。
29+
- `DialogHeader` 是弹层 header 的 canonical 入口;`ModalHeader` 保留为兼容命名。
30+
- 如需标准化 icon / copy / description 结构,使用 `dialog-header__leading``dialog-header__icon``dialog-header__copy``dialog-header__description`
2931
- 迁移期仍保留 `modal-overlay``modal-card``modal-header``modal-title``modal-body``modal-footer` 等兼容类名。

packages/web/src/components/ui/modal/index.module.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,47 @@
5353
border-bottom: 1px solid var(--border);
5454
}
5555

56+
.dialogHeader,
57+
:global(.dialog-header) {
58+
align-items: flex-start;
59+
}
60+
61+
:global(.dialog-header__leading) {
62+
display: flex;
63+
align-items: flex-start;
64+
gap: var(--sp-3);
65+
min-width: 0;
66+
flex: 1;
67+
}
68+
69+
:global(.dialog-header__copy) {
70+
display: flex;
71+
min-width: 0;
72+
flex: 1;
73+
flex-direction: column;
74+
gap: 2px;
75+
}
76+
77+
:global(.dialog-header__icon) {
78+
display: inline-flex;
79+
align-items: center;
80+
justify-content: center;
81+
width: 28px;
82+
height: 28px;
83+
border-radius: var(--radius-md);
84+
flex-shrink: 0;
85+
background: var(--bg-hover);
86+
color: var(--text-secondary);
87+
}
88+
89+
:global(.dialog-header__description) {
90+
margin: 0;
91+
font-size: var(--type-meta-size);
92+
line-height: var(--type-meta-line-height);
93+
font-weight: var(--type-meta-weight);
94+
color: var(--text-tertiary);
95+
}
96+
5697
.title,
5798
:global(.modal-title) {
5899
display: flex;
@@ -111,6 +152,8 @@
111152

112153
.header,
113154
:global(.modal-header),
155+
.dialogHeader,
156+
:global(.dialog-header),
114157
.body,
115158
:global(.modal-body),
116159
.footer,

packages/web/src/components/ui/modal/index.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { createRef } from "react";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5-
import { Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from ".";
5+
import { DialogHeader, Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from ".";
66

77
const activeElementState = {
88
current: null as HTMLElement | null,
@@ -78,6 +78,20 @@ describe("Modal", () => {
7878
expect(document.body).toContainElement(dialog);
7979
});
8080

81+
it("exports DialogHeader as the canonical modal header alias", () => {
82+
render(
83+
<DialogHeader>
84+
<ModalTitle>Workspace details</ModalTitle>
85+
</DialogHeader>
86+
);
87+
88+
const header = document.querySelector(".dialog-header");
89+
90+
expect(header).not.toBeNull();
91+
expect(header).toHaveClass("modal-header");
92+
expect(screen.getByText("Workspace details")).toBeInTheDocument();
93+
});
94+
8195
it("applies the large card variant for size='lg'", () => {
8296
render(<ModalFixture size="lg" />);
8397

packages/web/src/components/ui/modal/index.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,21 @@ export function ModalHeader({ className, ...props }: HTMLAttributes<HTMLDivEleme
134134
return <div {...props} className={clsx(styles.header, "modal-header", className)} />;
135135
}
136136

137+
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
138+
return (
139+
<div
140+
{...props}
141+
className={clsx(
142+
styles.header,
143+
styles.dialogHeader,
144+
"modal-header",
145+
"dialog-header",
146+
className
147+
)}
148+
/>
149+
);
150+
}
151+
137152
export function ModalTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
138153
const context = useContext(ModalContext);
139154
const fallbackId = useId();

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,20 @@ describe("SessionCard", () => {
279279
</Provider>
280280
);
281281

282-
const header = screen.getByText("SESSION-56").closest(".session-header");
282+
const bespokeHeader = screen.getByText("SESSION-56").closest(".session-header");
283+
const header = screen.getByText("SESSION-56").closest(".panel-header");
284+
const actions = header?.querySelector(".panel-header__actions");
283285
const accessory = screen.getByRole("button", { name: "Supervisor entry" });
284286
const right = header?.querySelector(".session-header-right");
285287

288+
expect(bespokeHeader).toBeNull();
286289
expect(header).not.toBeNull();
290+
expect(actions).not.toBeNull();
287291
expect(accessory.parentElement).toHaveClass("session-header-accessory");
288292
expect(right).not.toBeNull();
289293
expect(right).toContainElement(accessory);
290-
expect(header?.lastElementChild).toBe(right);
294+
expect(actions).toContainElement(right as HTMLElement);
295+
expect(header?.lastElementChild).toBe(actions);
291296
});
292297

293298
it("forces the terminal read-only when terminalReadOnlyOverride is true", () => {

0 commit comments

Comments
 (0)