Skip to content

Commit 64822cd

Browse files
committed
feat(ui): L1 原语 Text 与动效接缝(motion.css)
1 parent 3ca01c8 commit 64822cd

6 files changed

Lines changed: 446 additions & 0 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Text.css —— 墨度 × 字阶的唯一出口(批 0-D Task 3)。
3+
*
4+
* Why(为什么存在):全站排版现状是逐处手写(`fontSize` 1274 行/143 文件,recon §2),
5+
* 规格 §4.2 的 6 档字阶与 §4.3 的四档墨度必须能被**一处**驱动。本文件是那一处的落点。
6+
*
7+
* 边界:① 颜色 / 字号 / 字重 / 行高一律 `var(--ed-*)`,**本文件零颜色字面量**(`var()` 的兜底
8+
* 值也不许是色值 —— 色值只在 ui/tokens.css 与生成器里);② 兜底字面量与真源
9+
* `SCALE_TOKENS.typeScaleVars` 的一致性由 `style-seams.test.ts` 机器复核(防生成器改档后漂移);
10+
* ③ 不得写 `z-index`(层级走 `ui/zIndex.ts` 标尺)。
11+
*/
12+
13+
.ed-text {
14+
font-family: var(--ed-font-ui, "Inter", "Segoe UI Variable", "Microsoft YaHei UI", system-ui, sans-serif);
15+
/* 接缝(规格 §8.6.1 第 3 条):墨度与字距是批 6「记忆浮现」的可动画属性,先纳入 transition。
16+
`letter-spacing` 的初值显式写 `0` 而非 `normal`:`normal` 与长度之间**不可插值**(离散跳变),
17+
留 `normal` 会让这条接缝是死的 —— 批 6 改字距时不会有过渡。 */
18+
letter-spacing: 0;
19+
transition: color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
20+
letter-spacing var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
21+
}
22+
23+
.ed-text--s1 { font-size: var(--ed-type-1-size, 25px); line-height: var(--ed-type-1-line, 34px); font-weight: var(--ed-type-1-weight, 600); }
24+
.ed-text--s2 { font-size: var(--ed-type-2-size, 17px); line-height: var(--ed-type-2-line, 24px); font-weight: var(--ed-type-2-weight, 600); }
25+
.ed-text--s3 { font-size: var(--ed-type-3-size, 15.5px); line-height: var(--ed-type-3-line, 29.5px); font-weight: var(--ed-type-3-weight, 400); }
26+
.ed-text--s4 { font-size: var(--ed-type-4-size, 13px); line-height: var(--ed-type-4-line, 20px); font-weight: var(--ed-type-4-weight, 400); }
27+
.ed-text--s5 { font-size: var(--ed-type-5-size, 12px); line-height: var(--ed-type-5-line, 18px); font-weight: var(--ed-type-5-weight, 500); }
28+
.ed-text--s6 { font-size: var(--ed-type-6-size, 11.5px); line-height: var(--ed-type-6-line, 16px); font-weight: var(--ed-type-6-weight, 500); }
29+
30+
/* 墨度边界(不可省略的契约):`ink-4` 是**过渡态**(面对 3.22:1,ADR-032 第 4 条)——
31+
不得承载唯一关键信息(可用性不能挂在它身上)、任何交互须立即升到 `ink-2`、
32+
**剪报底纹(`--ed-mark-clip`)上禁止使用**(亮档实测 2.8489:1,低于它自己的 3:1 例外;
33+
该规则已写死在 docs/product/ui-ux-system.md)。 */
34+
.ed-text--ink-1 { color: var(--ed-ink-1); } .ed-text--ink-2 { color: var(--ed-ink-2); }
35+
.ed-text--ink-3 { color: var(--ed-ink-3); } .ed-text--ink-4 { color: var(--ed-ink-4); }
36+
.ed-text--stamp { color: var(--ed-stamp); } .ed-text--ok { color: var(--ed-ok); }
37+
.ed-text--due { color: var(--ed-due); } .ed-text--link { color: var(--ed-link); }
38+
.ed-text--inherit { color: inherit; }
39+
40+
/* 字族:基类已是 ui(`.ed-text` 自足),此处的 `--font-ui` 规则让**类契约对称** ——
41+
`font` 三档每档都有自己的类,动态切换时不必知道「ui 恰好等于基类」。 */
42+
.ed-text--font-ui { font-family: var(--ed-font-ui, "Inter", "Segoe UI Variable", "Microsoft YaHei UI", system-ui, sans-serif); }
43+
.ed-text--font-body { font-family: var(--ed-font-body, "Source Han Serif SC", "Songti SC", SimSun, serif); }
44+
.ed-text--font-mono { font-family: var(--ed-font-mono, "JetBrains Mono", Consolas, ui-monospace, monospace); font-variant-numeric: tabular-nums; }
45+
46+
.ed-text--truncate { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Text.test.tsx — L1 原语 `Text` 的接口契约(批 0-D Task 3)。
4+
*
5+
* Why:`Text` 是「墨度 × 字阶」的**唯一出口**,它产出的类名(`.ed-text--s4` / `.ed-text--ink-2` …)
6+
* 是**跨批次的公共契约** —— 批 4 的迁移与批 6 的动效都按这些类名挂接。所以本文件钉的是
7+
* 「标签语义 + 类名集合」,而不是 HTML 快照(快照会把无意义的 DOM 细节也变成契约)。
8+
* 类名与 CSS 的一致性由同目录 `style-seams.test.ts` 从另一侧守住(契约名单 ⊆ Text.css)。
9+
*
10+
* 副作用:无(纯展示组件,不读 store、不发请求、不写磁盘)。
11+
* 边界:本仓**未装** `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒
12+
* 断言一律用原生 DOM API(`tagName` / `getAttribute` / `style`),交互用 `fireEvent`。
13+
* 另一处边界:本文件走 `./index` 导入面(同 `ui/icons/Icon.test.tsx` 先例),因此
14+
* `index.ts` 的 `import "./motion.css"` 也会被执行 —— 这也是「导出面可用」的证据。
15+
*/
16+
import { render, within } from "@testing-library/react";
17+
import { describe, expect, it } from "vitest";
18+
import { Text } from "./index";
19+
import type { TextFont, TextSize, TextTag, TextTone } from "./index";
20+
21+
/** 契约名单:与 `Text.css` 的规则一一对应(由 style-seams.test.ts 从 CSS 侧复核) */
22+
const SIZES: readonly TextSize[] = [1, 2, 3, 4, 5, 6];
23+
const TONES: readonly TextTone[] = [
24+
"ink-1", "ink-2", "ink-3", "ink-4", "stamp", "ok", "due", "link", "inherit",
25+
];
26+
const FONTS: readonly TextFont[] = ["ui", "body", "mono"];
27+
const TAGS: ReadonlyArray<readonly [TextTag, string]> = [
28+
["span", "SPAN"], ["p", "P"], ["div", "DIV"], ["label", "LABEL"], ["strong", "STRONG"],
29+
["em", "EM"], ["h1", "H1"], ["h2", "H2"], ["h3", "H3"],
30+
];
31+
32+
const root = (container: HTMLElement): HTMLElement => {
33+
const el = container.firstElementChild;
34+
if (!el) throw new Error("Text 没有渲染出任何元素");
35+
return el as HTMLElement;
36+
};
37+
const classesOf = (container: HTMLElement): string[] => root(container).className.split(/\s+/);
38+
39+
describe("Text 默认契约", () => {
40+
it("默认 = span + s4 + ink-2 + font-ui,类名与顺序逐字固定", () => {
41+
const { container } = render(<Text>正文</Text>);
42+
expect(root(container).tagName).toBe("SPAN");
43+
expect(root(container).className).toBe("ed-text ed-text--s4 ed-text--ink-2 ed-text--font-ui");
44+
expect(root(container).textContent).toBe("正文");
45+
});
46+
47+
it("默认不带 truncate(截断是逐处显式选择,不是默认排版)", () => {
48+
const { container } = render(<Text>正文</Text>);
49+
expect(classesOf(container)).not.toContain("ed-text--truncate");
50+
});
51+
});
52+
53+
describe("Text 字阶 / 墨度 / 字族三轴映射到类", () => {
54+
it("6 档字阶各有对应类", () => {
55+
for (const size of SIZES) {
56+
const { container } = render(<Text size={size}>x</Text>);
57+
expect(classesOf(container), `第 ${size} 档`).toContain(`ed-text--s${size}`);
58+
}
59+
});
60+
61+
it("9 档墨度各有对应类(含 inherit —— 供继承父级墨度的内联片段)", () => {
62+
for (const tone of TONES) {
63+
const { container } = render(<Text tone={tone}>x</Text>);
64+
expect(classesOf(container), `墨度 ${tone}`).toContain(`ed-text--${tone}`);
65+
}
66+
});
67+
68+
it("3 档字族各有对应类(`mono` = 数字对齐,非等宽装饰)", () => {
69+
for (const font of FONTS) {
70+
const { container } = render(<Text font={font}>x</Text>);
71+
expect(classesOf(container), `字族 ${font}`).toContain(`ed-text--font-${font}`);
72+
}
73+
});
74+
75+
it("三轴组合出的类名集合可枚举(顺序固定,便于 grep 与批次迁移)", () => {
76+
const { container } = render(
77+
<Text size={1} tone="stamp" font="mono" truncate className="slot">x</Text>,
78+
);
79+
expect(root(container).className).toBe(
80+
"ed-text ed-text--s1 ed-text--stamp ed-text--font-mono ed-text--truncate slot",
81+
);
82+
});
83+
});
84+
85+
describe("Text 语义与透传", () => {
86+
it("as 的 9 个取值各自渲染出对应标签,且携带同一组类", () => {
87+
for (const [tag, tagName] of TAGS) {
88+
const { container } = render(<Text as={tag}>x</Text>);
89+
expect(root(container).tagName, `as=${tag}`).toBe(tagName);
90+
expect(classesOf(container), `as=${tag} 的类`).toContain("ed-text");
91+
expect(classesOf(container), `as=${tag} 的字阶`).toContain("ed-text--s4");
92+
}
93+
});
94+
95+
it("children 原样渲染:中文与嵌套元素都不改写", () => {
96+
const { container } = render(
97+
<Text as="p" tone="ink-3">
98+
中文与 <strong>嵌套</strong> 元素
99+
</Text>,
100+
);
101+
expect(container.textContent).toBe("中文与 嵌套 元素");
102+
expect(within(container).getByText("嵌套").tagName).toBe("STRONG");
103+
});
104+
105+
it("className 追加在契约类之后(调用点只做定位,不参与排版权威)", () => {
106+
const { container } = render(<Text className="my-slot">x</Text>);
107+
expect(classesOf(container)).toContain("my-slot");
108+
expect(classesOf(container)).toContain("ed-text");
109+
});
110+
111+
it("style 原样透传(批 6 的「记忆浮现」按此注入 letterSpacing / color 而无需改原语)", () => {
112+
const { container } = render(<Text style={{ letterSpacing: "0.02em" }}>x</Text>);
113+
expect(root(container).style.letterSpacing).toBe("0.02em");
114+
});
115+
116+
it("排版与墨度**只走类**,绝不落内联 style(否则批 6 无法一处改对所有地方)", () => {
117+
const { container } = render(<Text size={1} tone="stamp">x</Text>);
118+
const el = root(container);
119+
expect(el.style.color).toBe("");
120+
expect(el.style.fontSize).toBe("");
121+
expect(el.style.fontWeight).toBe("");
122+
expect(el.style.lineHeight).toBe("");
123+
});
124+
125+
it("渲染结果不含任何颜色字面量(墨度全部来自 --ed-ink-* 变量)", () => {
126+
const { container } = render(<Text tone="ink-1">x</Text>);
127+
expect(container.innerHTML).not.toMatch(/#[0-9a-fA-F]{3,8}/);
128+
expect(container.innerHTML).not.toMatch(/\b(?:rgb|hsl)a?\(/);
129+
});
130+
});

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @ai-context L1 原语:文本。**墨度 × 字阶的唯一出口**(批 0-D Task 3)。
3+
*
4+
* Why:现状 `fontSize:` 1274 行/143 文件 · `fontWeight` 230/96 · 弱化灰 `#9ca3af` 256 行/100 文件
5+
* (recon §2),字阶与墨度逐处手写;规格 §4.2 的 6 档字阶与 §4.3 的四档墨度必须能被**一处**驱动。
6+
* 本组件的**唯一职责就是排版与墨度** —— 它不做语义色决策(`tone` 只是「取哪一档墨度的名字」,
7+
* 颜色值只在 `ui/tokens.css`),也不承载动作(点击回执属 `Button`/`Surface`)。
8+
*
9+
* 副作用:`import "./Text.css"` —— 首个引入本原语的模块会带上该样式表。类规则只作用于 `.ed-text`
10+
* 元素,现存组件没有该类 ⇒ 批 0-D 期间**界面零变化**(本批不迁移任何调用点)。组件本身不读 store、
11+
* 不发请求、不写磁盘。
12+
*
13+
* 边界:① 排版与墨度**只出类名、不出内联 style**(否则批 6 的动效只能逐处改);`style` 只是透传,
14+
* 供批 6 的接缝(`letterSpacing` / `color` 直接驱动)与调用点布局使用;② `truncate` 会把元素
15+
* 变为**块级**(`display: block`)—— 放在 `<p>` 等内联语境时由调用点负责;③ `as` 决定语义标签,
16+
* 本组件不额外包裹任何 DOM(不制造「多一层 div」的排版权威);④ `ink-4` 是过渡态,
17+
* 不得承载唯一关键信息,剪报底纹上禁止使用(见 `Text.css` 的墨度边界注释)。
18+
*/
19+
20+
import type { CSSProperties, ReactElement, ReactNode } from "react";
21+
import "./Text.css";
22+
23+
/** 字阶档位,与 `SCALE_TOKENS.typeScaleVars` 的下标同序(1 起) */
24+
export type TextSize = 1 | 2 | 3 | 4 | 5 | 6;
25+
26+
/**
27+
* 墨度档位 = `--ed-ink-*` 四档 + 语义色三档 + `inherit`。
28+
* `inherit` 不引任何 token:用于「跟随父级墨度」的内联片段(如 `<Text as="strong">` 里的强调)。
29+
*/
30+
export type TextTone = "ink-1" | "ink-2" | "ink-3" | "ink-4" | "stamp" | "ok" | "due" | "link" | "inherit";
31+
32+
/** 字族:界面黑体 / 正文衬线 / 等宽(`mono` 另开 `tabular-nums`,为数字对齐而非装饰) */
33+
export type TextFont = "ui" | "body" | "mono";
34+
35+
/** 可渲染的语义标签(不提供 `a` / `button` —— 可交互元素属 `Button` 与调用点) */
36+
export type TextTag = "span" | "p" | "div" | "label" | "strong" | "em" | "h1" | "h2" | "h3";
37+
38+
export interface TextProps {
39+
/** 字阶档位,默认 `4`(13/20·400 = 正文基准) */
40+
size?: TextSize;
41+
/** 墨度档位,默认 `ink-2`(已确认 · 正文基准),见 `TextTone` */
42+
tone?: TextTone;
43+
/** 字族,默认 `ui`(界面黑体) */
44+
font?: TextFont;
45+
/** 单行截断(`overflow: hidden` + 省略号);注意它会把元素变为块级 */
46+
truncate?: boolean;
47+
/** 语义标签,默认 `span` */
48+
as?: TextTag;
49+
/** 追加类名(调用点只做定位,不参与排版权威 —— 排版与墨度一律由本原语的类决定) */
50+
className?: string;
51+
/** 透传内联样式:批 6 的动效接缝(`letterSpacing` / `color`)与调用点布局用 */
52+
style?: CSSProperties;
53+
children: ReactNode;
54+
}
55+
56+
/**
57+
* 渲染一段文本。
58+
*
59+
* 返回 `ReactElement`(= 契约里的 `JSX.Element`):React 19 的类型把全局 `JSX` 命名空间收进
60+
* `React.JSX`,直接写 `JSX.Element` 在 `@types/react@19` 下取不到。
61+
*/
62+
export function Text({
63+
size = 4,
64+
tone = "ink-2",
65+
font = "ui",
66+
truncate = false,
67+
as = "span",
68+
className,
69+
style,
70+
children,
71+
}: TextProps): ReactElement {
72+
const cls = [
73+
"ed-text",
74+
`ed-text--s${size}`,
75+
`ed-text--${tone}`,
76+
`ed-text--font-${font}`,
77+
truncate ? "ed-text--truncate" : null,
78+
className,
79+
]
80+
.filter(Boolean)
81+
.join(" ");
82+
83+
const Tag = as;
84+
return (
85+
<Tag className={cls} style={style}>
86+
{children}
87+
</Tag>
88+
);
89+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @ai-context L1 原语层的**唯一公共导出面**(同 `ui/icons/index.ts` 范式)。
3+
*
4+
* Why:批 4 之后全站从这里 import;收敛导出面使「换实现」只改这一个文件。
5+
* 副作用:`import "./motion.css"` —— 导入本层即带上动效接缝变量与**全仓唯一的
6+
* `prefers-reduced-motion` 块**。
7+
* 边界:**深导入单个原语(如 `./Text`)时不会带上 reduced-motion 块**;组内互引用请走
8+
* 相对文件路径(`./Text`),不要 import 本文件(避免循环依赖)。
9+
*/
10+
11+
import "./motion.css";
12+
13+
export { Text } from "./Text";
14+
export type { TextFont, TextProps, TextSize, TextTag, TextTone } from "./Text";

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* motion.css —— 原语层的**动效接缝**(批 0-D Task 3 一次写全,后续任务不再改本文件)。
3+
*
4+
* Why(为什么存在):规格 §8.4 的时长/缓动在批 0-D 还没有真源(0-A 计划自审把「时长与缓动」
5+
* 推迟到批 6),但本批的每个原语都要在 transition 里引用它们(如 `.ed-text` 的墨度过渡)。
6+
* 先把变量按规格 §8.4 的名字与取值落在这里,批 6 的动效 token 真源完成后**整块删除** ——
7+
* 所有引用点写的是 `var(--ed-dur-x, <同值字面量>)`,删块即生效,无需改任何规则。
8+
*
9+
* ② 本文件是**全仓第一条 `prefers-reduced-motion` 块**(实测改造前 0 处,recon §8.1)。
10+
* 名单一次写全 11 个 `.ed-*` 基类(含本批后面才实现的选择器 —— 未实现的选择器无害),
11+
* 换来「后续任务永不改本文件」;Task 14 的样式守卫断言每个 `.ed-*` 类都在名单里。
12+
* 无此项时 reduced-motion 用户仍会看到全部动效(§8.6.1 第 4 条:无障碍优先于「活」)。
13+
*
14+
* 边界:本文件**不得出现任何颜色字面量**(颜色只在 ui/tokens.css 与生成器里);
15+
* 也不得写 `z-index`(层级是 TS 标尺 `ui/zIndex.ts` 的职责,规格 §4.2①)。
16+
*/
17+
18+
:root {
19+
--ed-dur-micro: 120ms;
20+
--ed-dur-overlay-in: 200ms;
21+
--ed-dur-overlay-out: 160ms;
22+
--ed-dur-toast-in: 180ms;
23+
--ed-dur-toast-out: 140ms;
24+
--ed-dur-skeleton: 1200ms;
25+
--ed-ease: cubic-bezier(0.2, 0, 0, 1);
26+
}
27+
28+
@media (prefers-reduced-motion: reduce) {
29+
.ed-btn, .ed-surface, .ed-text, .ed-modal-overlay, .ed-modal, .ed-confirm,
30+
.ed-toast, .ed-empty, .ed-loading, .ed-skeleton, .ed-probe {
31+
transition-duration: 1ms !important;
32+
animation-duration: 1ms !important;
33+
animation-iteration-count: 1 !important;
34+
}
35+
}

0 commit comments

Comments
 (0)