Skip to content

Commit 4eb5c46

Browse files
committed
feat(ui): L1 原语 Surface(面的四档收敛)
1 parent d3357bd commit 4eb5c46

4 files changed

Lines changed: 331 additions & 0 deletions

File tree

‎app/src/ui/primitives/Surface.css‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Surface.css —— 面(底 / 边框 / 圆角 / 阴影)的唯一出口(批 0-D Task 4)。
3+
*
4+
* Why(为什么存在):现状 `borderRadius` 584 行/134 文件(6/8/10/12 四值混用)· `1px solid`
5+
* 412 行/121 文件 · `boxShadow:` 38 行/38 文件且是 **18 个互不相同的字面值**(recon §2 / §4①)
6+
* —— 面的四件事逐处手写,改一次「卡片长什么样」要动上百个文件。本文件是那一处的落点。
7+
*
8+
* 边界:① 颜色 / 圆角 / 阴影 / 间距一律 `var(--ed-*)`,**本文件零颜色字面量**(`var()` 的兜底
9+
* 值也不许是色值 —— 色值只在 ui/tokens.css 与生成器里);② 不得写 `z-index`(层级走
10+
* `ui/zIndex.ts` 六档标尺,规格 §4.2①);③ **不写 `box-sizing`**:尺寸口径属调用点排版,
11+
* 本原语只负责「底 / 边框 / 圆角 / 阴影」四件事(批 4 逐处迁移时按各处实际需要决定,
12+
* 避免原语替调用点改掉盒模型而让迁移期的观感对不上)。
13+
*
14+
* 暗档策略(继承 token 层,本文件不写第二条规则):`--ed-shadow-1` 在 `[data-theme="dark"]` 里
15+
* 是 `0 0 0 1px rgba(255,255,255,.06)` 的**反相白描边**(暗底上黑色投影不可见)⇒ hover 的
16+
* 「升起」在暗档自动退化为**边框墨度**的推进(`--ed-border` → `--ed-border-strong`)。
17+
*/
18+
19+
.ed-surface {
20+
background: var(--ed-bg-surface);
21+
border-radius: var(--ed-radius-panel, 8px);
22+
}
23+
24+
/* 默认 bordered=true(卡片 / 列的常态);`bordered={false}` 用于「只有底色、不要分隔线」的阅读面 */
25+
.ed-surface--bordered { border: 1px solid var(--ed-border); }
26+
27+
/* 四档底:与调色板 token 一一对应,原语不判断语义(该用哪档由消费方决定) */
28+
.ed-surface--sunken { background: var(--ed-bg-sunken); } /* 输入槽 / 骨架 / 内嵌 */
29+
.ed-surface--canvas { background: var(--ed-bg-canvas); } /* 窗口底(纸) */
30+
.ed-surface--surface { background: var(--ed-bg-surface); } /* 卡片 / 列 / 阅读面(= 基类,显式写出让四档对称) */
31+
.ed-surface--raised { background: var(--ed-bg-raised); box-shadow: var(--ed-shadow-1); } /* 弹层 / 菜单 / 浮窗 */
32+
33+
/* 四档圆角:token 真源是 `SCALE_TOKENS.radiusScale`(3/5/8/10),类名带 `r-` 前缀以免与
34+
同名的 level 档(如 `--surface`)撞在一起 */
35+
.ed-surface--r-stamp { border-radius: var(--ed-radius-stamp, 3px); }
36+
.ed-surface--r-control { border-radius: var(--ed-radius-control, 5px); }
37+
.ed-surface--r-panel { border-radius: var(--ed-radius-panel, 8px); }
38+
.ed-surface--r-overlay { border-radius: var(--ed-radius-overlay, 10px); }
39+
40+
.ed-surface--padded { padding: var(--ed-space-12, 12px); }
41+
42+
/* 接缝(规格 §8.6.1 接缝表第 2 行:hover 升起 / 边框墨度)——
43+
① 只用**单属性、无时序、120ms** 的属性 transition(规格 §8.2 判据):交互反馈不排队,快速
44+
划过时下一次 hover 直接接管当前动效(§8.6.1 第 3 条「可中断、可反向」);若改用 keyframes
45+
或长时序,连点会排队播完,用户会看到「迟到的回执」;
46+
② 位移 **1px** ≪ §8.4 的 8px 上限:升起感主要由阴影与边框墨度承载,位移只是「纸被指尖抬起」
47+
的暗示,越大越像抖动;
48+
③ `interactive` **只加视觉、不加 `tabIndex`/`role`** —— 键盘可达性必须由消费方用真实
49+
`<button>`/`<a>` 承载(现状全仓 `tabIndex` 仅 1 处,「用 div 假装可点」正是本批要消灭的形态,
50+
§8.6.1 第 4 条:键盘路径与「活」冲突时无障碍优先);
51+
④ reduced-motion 由 motion.css 的**唯一**媒体查询块统一覆盖,本文件不重复写媒体查询
52+
(重复会让「一处改对所有地方」失效)。 */
53+
.ed-surface--interactive {
54+
cursor: pointer;
55+
transition: box-shadow var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
56+
border-color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
57+
transform var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
58+
}
59+
.ed-surface--interactive:hover { box-shadow: var(--ed-shadow-1); border-color: var(--ed-border-strong); transform: translateY(-1px); }
60+
/* 按下回执:位移归零(「收到了」= hover 的升起,「做完了」= 点击后的落位);阴影与边框保持
61+
hover 值不回落,否则按下瞬间整块面会「瘪一下」,回执反而变成噪音 */
62+
.ed-surface--interactive:active { transform: translateY(0); }
63+
/* 焦点可见:`--ed-ink-1` 亮档是墨黑、暗档是纸白 ⇒ 焦点环自动反相,无需第二条规则。
64+
用 `:focus-visible` 而非 `:focus`:鼠标点击不该留焦点环,但键盘 Tab 必须看得见 */
65+
.ed-surface--interactive:focus-visible { outline: 2px solid var(--ed-ink-1); outline-offset: 2px; }
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Surface.test.tsx — L1 原语 `Surface` 的接口契约(批 0-D Task 4)。
4+
*
5+
* Why:`Surface` 是「面(底 / 边框 / 圆角 / 阴影)」的**唯一出口**,它产出的类名
6+
* (`.ed-surface--raised` / `.ed-surface--r-panel` / `.ed-surface--interactive` …)是**跨批次
7+
* 公共契约** —— 批 4 的迁移与批 6 的动效都按这些类名挂接(hover 升起 / 边框墨度落在
8+
* `Surface.css` 的 `.ed-surface--interactive` 一族规则上)。所以本文件钉的是「标签语义 +
9+
* 类名集合 + 参数不外溢成内联样式」,而不是 HTML 快照(快照会把无意义的 DOM 细节变成契约)。
10+
*
11+
* 副作用:无(纯展示组件,不读 store、不发请求、不写磁盘)。
12+
* 边界:① 本仓**未装** `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒
13+
* 断言一律用原生 DOM API(`tagName` / `getAttribute` / `className` / `style`);
14+
* ② `:hover` / `:active` / `:focus-visible` 是 CSS 伪类,jsdom **不做样式级联** ⇒ 三条
15+
* 交互态只能由 `Surface.css` 的文本守卫(Task 14 的 `style-contract.test.ts`)与人工
16+
* 核对承接,本文件守的是「类名契约」这一侧,**不假装验证了视觉**;
17+
* ③ 本文件走 `./index` 导入面(同 `Text.test.tsx` 先例),因此 `index.ts` 的
18+
* `import "./motion.css"` 也会被执行 —— 这也是「导出面可用」的证据。
19+
*/
20+
import { render, within } from "@testing-library/react";
21+
import { describe, expect, it } from "vitest";
22+
import { Surface } from "./index";
23+
import type { SurfaceLevel, SurfaceRadius, SurfaceTag } from "./index";
24+
25+
/** 契约名单:与 `Surface.css` 的规则一一对应(改名单必须同时改 CSS 与测试) */
26+
const LEVELS: readonly SurfaceLevel[] = ["sunken", "canvas", "surface", "raised"];
27+
const RADIUS: readonly SurfaceRadius[] = ["stamp", "control", "panel", "overlay"];
28+
const TAGS: ReadonlyArray<readonly [SurfaceTag, string]> = [
29+
["div", "DIV"], ["section", "SECTION"], ["article", "ARTICLE"], ["aside", "ASIDE"], ["li", "LI"],
30+
];
31+
32+
const root = (container: HTMLElement): HTMLElement => {
33+
const el = container.firstElementChild;
34+
if (!el) throw new Error("Surface 没有渲染出任何元素");
35+
return el as HTMLElement;
36+
};
37+
const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/);
38+
39+
describe("Surface 默认契约", () => {
40+
it("默认 = div + surface + r-panel + bordered,类名与顺序逐字固定", () => {
41+
const { container } = render(<Surface>内容</Surface>);
42+
expect(root(container).tagName).toBe("DIV");
43+
expect(root(container).className).toBe("ed-surface ed-surface--surface ed-surface--r-panel ed-surface--bordered");
44+
expect(root(container).textContent).toBe("内容");
45+
});
46+
47+
it("默认不带 interactive / padded(可点与内边距都是逐处显式选择,不是面的默认形态)", () => {
48+
const cls = classesOf(render(<Surface>x</Surface>).container);
49+
expect(cls).not.toContain("ed-surface--interactive");
50+
expect(cls).not.toContain("ed-surface--padded");
51+
});
52+
53+
it("六轴全开时的类名集合可枚举(顺序固定,便于 grep 与批次迁移)", () => {
54+
const { container } = render(
55+
<Surface level="raised" radius="overlay" interactive padded className="slot">x</Surface>,
56+
);
57+
expect(root(container).className).toBe(
58+
"ed-surface ed-surface--raised ed-surface--r-overlay ed-surface--bordered ed-surface--interactive ed-surface--padded slot",
59+
);
60+
});
61+
});
62+
63+
describe("Surface 四档底 / 四档圆角映射到类", () => {
64+
it("4 档 level 各有对应类(sunken 输入槽 · canvas 纸 · surface 卡面 · raised 弹层)", () => {
65+
for (const level of LEVELS) {
66+
const cls = classesOf(render(<Surface level={level}>x</Surface>).container);
67+
expect(cls, `档位 ${level}`).toContain(`ed-surface--${level}`);
68+
}
69+
});
70+
71+
it("4 档 radius 各有对应类(3/5/8/10px 由 token 决定,原语只出档名)", () => {
72+
for (const radius of RADIUS) {
73+
const cls = classesOf(render(<Surface radius={radius}>x</Surface>).container);
74+
expect(cls, `圆角 ${radius}`).toContain(`ed-surface--r-${radius}`);
75+
}
76+
});
77+
78+
it("结构不变量:level 类与 radius 类各至多一个(不产生两档叠加态)", () => {
79+
const cls = classesOf(render(<Surface level="raised" radius="overlay">x</Surface>).container);
80+
expect(cls.filter((c) => LEVELS.some((l) => c === `ed-surface--${l}`))).toHaveLength(1);
81+
expect(cls.filter((c) => c.startsWith("ed-surface--r-"))).toHaveLength(1);
82+
});
83+
84+
it("反例守门:radius 类必须带 `r-` 前缀(`ed-surface--panel` 是最易漏写的形态,CSS 里没有该规则)", () => {
85+
const cls = classesOf(render(<Surface radius="panel">x</Surface>).container);
86+
expect(cls).toContain("ed-surface--r-panel");
87+
expect(cls).not.toContain("ed-surface--panel");
88+
});
89+
});
90+
91+
describe("Surface 三个布尔开关", () => {
92+
it("bordered 默认 true;bordered={false} 后类名集合精确收缩(不留空档或多余类)", () => {
93+
expect(classesOf(render(<Surface>x</Surface>).container)).toContain("ed-surface--bordered");
94+
const { container } = render(<Surface bordered={false}>x</Surface>);
95+
expect(root(container).className).toBe("ed-surface ed-surface--surface ed-surface--r-panel");
96+
expect(classesOf(container)).not.toContain("ed-surface--bordered");
97+
});
98+
99+
it("interactive 默认 false;开启后加 `--interactive`(hover 升起 / 边框墨度 / 焦点环的挂点)", () => {
100+
expect(classesOf(render(<Surface>x</Surface>).container)).not.toContain("ed-surface--interactive");
101+
expect(classesOf(render(<Surface interactive>x</Surface>).container)).toContain("ed-surface--interactive");
102+
});
103+
104+
it("padded 默认 false;开启后加 `--padded`(内边距走 `--ed-space-12`)", () => {
105+
expect(classesOf(render(<Surface padded>x</Surface>).container)).toContain("ed-surface--padded");
106+
});
107+
108+
it("interactive 只加视觉:不设 tabIndex / role(键盘可达性由消费方用真实 button/a 承载,§8.6.1 第 4 条)", () => {
109+
const el = root(render(<Surface interactive>x</Surface>).container);
110+
expect(el.hasAttribute("tabindex")).toBe(false);
111+
expect(el.getAttribute("role")).toBeNull();
112+
});
113+
});
114+
115+
describe("Surface 语义与透传", () => {
116+
it("as 的 5 个取值各自渲染出对应标签,且携带同一组类", () => {
117+
for (const [tag, tagName] of TAGS) {
118+
const { container } = render(<Surface as={tag}>x</Surface>);
119+
expect(root(container).tagName, `as=${tag}`).toBe(tagName);
120+
expect(classesOf(container), `as=${tag} 的基类`).toContain("ed-surface");
121+
expect(classesOf(container), `as=${tag} 的底`).toContain("ed-surface--surface");
122+
}
123+
});
124+
125+
it("testId 落到 data-testid;不传时不产生该属性(不留 `data-testid=\"undefined\"`)", () => {
126+
expect(root(render(<Surface testId="card">x</Surface>).container).getAttribute("data-testid")).toBe("card");
127+
expect(root(render(<Surface>x</Surface>).container).hasAttribute("data-testid")).toBe(false);
128+
});
129+
130+
it("children 原样渲染:中文与嵌套元素都不改写", () => {
131+
const { container } = render(
132+
<Surface as="section" level="canvas">
133+
阅读面 <strong>嵌套</strong> 元素
134+
</Surface>,
135+
);
136+
expect(container.textContent).toBe("阅读面 嵌套 元素");
137+
expect(within(container).getByText("嵌套").tagName).toBe("STRONG");
138+
});
139+
140+
it("className 追加在契约类之后;style 原样透传(批 6 的动效按此注入 transform/boxShadow 而无需改原语)", () => {
141+
const { container } = render(
142+
<Surface className="my-slot" style={{ boxShadow: "var(--ed-shadow-2)" }}>x</Surface>,
143+
);
144+
expect(classesOf(container)).toContain("my-slot");
145+
expect(classesOf(container)).toContain("ed-surface");
146+
expect(root(container).style.boxShadow).toBe("var(--ed-shadow-2)");
147+
});
148+
});
149+
150+
describe("Surface 反例守门(批 6 要一处改对所有地方)", () => {
151+
it("底 / 边框 / 圆角 / 阴影 / 内边距 / 位移**只走类**,绝不落内联 style", () => {
152+
const el = root(
153+
render(<Surface level="raised" radius="overlay" interactive padded>x</Surface>).container,
154+
);
155+
expect(el.style.background).toBe("");
156+
expect(el.style.borderRadius).toBe("");
157+
expect(el.style.boxShadow).toBe("");
158+
expect(el.style.padding).toBe("");
159+
expect(el.style.transform).toBe("");
160+
expect(el.style.cursor).toBe("");
161+
});
162+
163+
it("渲染结果不含任何颜色字面量(底与边框全部来自 --ed-* 变量)", () => {
164+
const { container } = render(<Surface level="raised" bordered>x</Surface>);
165+
expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/);
166+
expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/);
167+
});
168+
});

‎app/src/ui/primitives/Surface.tsx‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @ai-context L1 原语:面。**底 / 边框 / 圆角 / 阴影的唯一出口**(批 0-D Task 4)。
3+
*
4+
* Why:现状 `borderRadius` 584 行/134 文件(6/8/10/12 四值混用)· `border 1px solid` 412 行/
5+
* 121 文件 · `boxShadow:` 38 行/38 文件且是 18 个互不相同的字面值(recon §2 / §4①)——
6+
* 「面长什么样」逐处手写,改一次要动上百个文件。规格 §5.1 要求它收敛成 `Surface` 一个出口。
7+
*
8+
* 本组件的**唯一职责是面本身**(底 / 边框 / 圆角 / 阴影 + 可选内边距):它不做语义决策
9+
* (`level` 只是「取哪一档底色」的名字,颜色值只在 `ui/tokens.css`),也不承载内容排版
10+
* (墨度与字阶属 `Text`,动作回执属 `Button`)。卡片 / 列 / 阅读面 / 弹层底都是它。
11+
*
12+
* 副作用:`import "./Surface.css"` —— 首个引入本原语的模块会带上该样式表;`interactive` 会
13+
* 引入 `:hover` / `:active` / `:focus-visible` 三条伪类规则(**全仓活代码里此前 0 处**,
14+
* recon §8.2)。类规则只作用于 `.ed-surface` 元素,现存组件没有该类 ⇒ 批 0-D 期间**界面零变化**
15+
* (本批不迁移任何调用点)。组件本身不读 store、不发请求、不写磁盘。
16+
*
17+
* 边界:① 底 / 边框 / 圆角 / 阴影 / 内边距**只出类名、不出内联 style**(否则批 6 的动效只能
18+
* 逐处改);`style` 只是透传,供批 6 的接缝(直接驱动 `transform` / `boxShadow`)与调用点
19+
* 布局使用;② `interactive` 只给视觉,**不加 `tabIndex` / `role`** —— 键盘可达性由消费方
20+
* 用真实 `<button>` / `<a>` 承载(§8.6.1 第 4 条:键盘路径优先于「活」);③ `bordered`
21+
* 默认 `true`,`bordered={false}` 用于「只有底色、不要分隔线」的阅读面;④ 不写 `box-sizing`
22+
* (尺寸口径属调用点排版,见 `Surface.css` 的边界注释)。
23+
*/
24+
25+
import type { CSSProperties, ReactElement, ReactNode } from "react";
26+
import "./Surface.css";
27+
28+
/** 四档底,与 `COLOR_TOKENS` 的 `bg-*` 四个颜色 token 一一对应(值只在 `ui/tokens.css`) */
29+
export type SurfaceLevel = "sunken" | "canvas" | "surface" | "raised";
30+
31+
/** 四档圆角,与 `SCALE_TOKENS.radiusScale` 同序(3 / 5 / 8 / 10px) */
32+
export type SurfaceRadius = "stamp" | "control" | "panel" | "overlay";
33+
34+
/** 可渲染的语义标签(不含 `button` / `a` —— 可交互元素属 `Button` 与调用点) */
35+
export type SurfaceTag = "div" | "section" | "article" | "aside" | "li";
36+
37+
export interface SurfaceProps {
38+
/** 底色档位,默认 `surface`(卡片 / 列 / 阅读面) */
39+
level?: SurfaceLevel;
40+
/** 圆角档位,默认 `panel`(8px) */
41+
radius?: SurfaceRadius;
42+
/** 是否带 1px 边框,默认 `true`(`false` = 只有底色,用于阅读面) */
43+
bordered?: boolean;
44+
/** 是否可点(hover 升起 / 边框墨度 / 焦点环 / `cursor: pointer`),默认 `false` */
45+
interactive?: boolean;
46+
/** 是否加 12px 内边距,默认 `false`(内边距多数由调用点决定,故不默认开) */
47+
padded?: boolean;
48+
/** 语义标签,默认 `div` */
49+
as?: SurfaceTag;
50+
/** 追加类名(调用点只做定位与排布,不参与「面」的视觉权威) */
51+
className?: string;
52+
/** 透传内联样式:批 6 的动效接缝(`transform` / `boxShadow`)与调用点布局用 */
53+
style?: CSSProperties;
54+
/** 落到 `data-testid`;不传时不产生该属性 */
55+
testId?: string;
56+
children: ReactNode;
57+
}
58+
59+
/**
60+
* 渲染一个「面」。
61+
*
62+
* 返回 `ReactElement`(= 契约里的 `JSX.Element`):React 19 的类型把全局 `JSX` 命名空间收进
63+
* `React.JSX`,直接写 `JSX.Element` 在 `@types/react@19` 下取不到(同 `Text.tsx` 的先例)。
64+
*/
65+
export function Surface({
66+
level = "surface",
67+
radius = "panel",
68+
bordered = true,
69+
interactive = false,
70+
padded = false,
71+
as = "div",
72+
className,
73+
style,
74+
testId,
75+
children,
76+
}: SurfaceProps): ReactElement {
77+
const cls = [
78+
"ed-surface",
79+
`ed-surface--${level}`,
80+
`ed-surface--r-${radius}`,
81+
bordered ? "ed-surface--bordered" : null,
82+
interactive ? "ed-surface--interactive" : null,
83+
padded ? "ed-surface--padded" : null,
84+
className,
85+
]
86+
.filter(Boolean)
87+
.join(" ");
88+
89+
const Tag = as;
90+
return (
91+
<Tag className={cls} style={style} data-testid={testId}>
92+
{children}
93+
</Tag>
94+
);
95+
}

‎app/src/ui/primitives/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ import "./motion.css";
1212

1313
export { Text } from "./Text";
1414
export type { TextFont, TextProps, TextSize, TextTag, TextTone } from "./Text";
15+
16+
export { Surface } from "./Surface";
17+
export type { SurfaceLevel, SurfaceProps, SurfaceRadius, SurfaceTag } from "./Surface";

0 commit comments

Comments
 (0)