Skip to content

Commit 1a1ea2a

Browse files
committed
feat(ui): L1 原语 StatusLine(三红归一)
1 parent fa64fb5 commit 1a1ea2a

6 files changed

Lines changed: 402 additions & 6 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* StatusLine.css —— 状态行的**语义用色唯一出口**(批 0-D Task 12;规格 §5.1「三红归一」 / §4.1)。
3+
*
4+
* Why(为什么存在):错误行现状 175 行 / 98 文件里三种红并存(recon §2.1 的色值分词:主红 138 次 /
5+
* 深红 37 次 / 亮红 2 次),另有 30 行 / 23 文件的错误块底色各自手写;错误行还常挂在列表最底部
6+
* (规格 §5.1 判为「视觉盲区」)。四档语义必须有**一处**出口,否则「哪一种红才算错」永远没有答案。
7+
*
8+
* 边界(四条都是硬约束):
9+
* ① 颜色一律 `var(--ed-*)`,**本文件零颜色字面量**(色值只许在 ui/tokens.css 与 token 生成器里)。
10+
* 这条也约束注释:本文件的注释里**不写任何色值**(写次数与 token 名即可)—— 守卫按文件文本判,
11+
* 注释里的色值会制造假红(先例:`Button.css` 连危险色 token 的字面名字都不写)。
12+
* ② **本文件不写任何底色声明**(连该属性名都不出现):危险色是全站唯一非中性色,规格 §4.1 原文
13+
* 写明「绝不用于按钮」⇒ 四档一律只写 `color:`。机器判据在本原语的测试里,与 Task 14 Step 1
14+
* 第 4 条**同向**(同一判据、同一方向,不另立标准)。
15+
* ③ 不写 `z-index`(层级走 `ui/zIndex.ts` 六档标尺);不写 `position`(定位是调用点的事)。
16+
* ④ **不写 `@keyframes`、不写循环动画**:环境层的「探针摆动」落在 `Loading` 的 `Probe`,而状态行
17+
* 是阅读面 —— 「不抢注意力」在这条原语上的正确形态就是**没有**持续动效(§8.6.1 第 1 条)。
18+
* reduced-motion 由 `motion.css` 的唯一媒体查询块统一覆盖(`.ed-status` 已在名单里),
19+
* 本文件不重复写媒体查询(重复会让「一处改对所有地方」失效)。
20+
*/
21+
22+
.ed-status {
23+
display: flex;
24+
flex-wrap: wrap;
25+
align-items: center;
26+
gap: var(--ed-space-8, 8px);
27+
/* 接缝(§8.6.1 第 2/3 条):`color` 与 `opacity` 是批 6「错误浮现 / 消退」的两个可动画属性。
28+
单属性、无时序、120ms(§8.2 判据:<200ms 的单属性反馈一律 CSS transition)⇒ 下一个输入直接
29+
接管当前过渡,不排队、可反向。刻意**不加** `translate` / `scale`:状态行是阅读面,位移会让它
30+
抢注意力;它的「即时回执」由 `role` 承载(见 StatusLine.tsx 的边界①),不由动效承载。 */
31+
transition: color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)),
32+
opacity var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1));
33+
}
34+
35+
/* 四档语义色 —— 一律 `color:`,全文件没有任何底色声明。
36+
四者对两档底 ≥4.5:1 由 `gen-tokens.test.mjs` / `contrast.test.ts` 断言(本文件不重述 token 真源);
37+
其中到期色亮档在剪报底上 4.5571:1 是余量最小的一条(第二次对比度修正后的值)。 */
38+
.ed-status--error { color: var(--ed-stamp); }
39+
.ed-status--warn { color: var(--ed-due); }
40+
.ed-status--info { color: var(--ed-ink-3); }
41+
.ed-status--ok { color: var(--ed-ok); }
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context StatusLine.test.tsx —— 状态行的**契约**测试(批 0-D Task 12;规格 §5.1 / §4.1 / §8.6.1)。
4+
*
5+
* Why jsdom:要断言真实 DOM 上的 `role` / 类名 / 两个槽的渲染与缺席。但 jsdom **不加载样式表**
6+
* (`vitest.config.ts` 的 `css` 为 false)⇒ 样式类契约与用色契约一律**读文本**(范式 C,同
7+
* `ConfirmDialog.test.tsx` / `Modal.test.tsx`)。
8+
*
9+
* 本文件钉住四件事:
10+
* ① 四档 kind 的类映射 + `role` **二值契约**(`error → alert` 立即播报,其余三档 → `status`);
11+
* ② `detail` / `action` 两个槽的渲染与**缺席**行为(缺席时不得凭空出现按钮或空节点);
12+
* ③ **危险语义用色**(控制方 2026-09-11 裁决③ · 规格 §4.1「绝不用于按钮」):危险色只作文字色
13+
* —— 本原语 CSS 里连底色声明都没有,且**结构上不可能渲染按钮**(不 import `Button`);
14+
* 与 Task 14 Step 1 第 4 条的批次守卫**同向**(同一判据、同一方向,不另立标准)。
15+
* ④ 动效接缝与 reduced-motion:浮现过渡的可动画属性齐备、**无循环动画**(环境层不得抢注意力)、
16+
* `.ed-status` 已进 `motion.css` 那条唯一的媒体查询名单。
17+
*
18+
* 副作用:无(只挂 React 树 + 读四个文本文件)。**不用假计时器**:本原语没有计时器、没有 presence
19+
* 状态机(浮现是纯 CSS transition 的接缝,驱动权在批 6)。
20+
* 边界:不用 jest-dom(本仓未装)—— 一律 `getAttribute` / `textContent` / `queryAllByRole`。
21+
*/
22+
import { cleanup, render, screen } from "@testing-library/react";
23+
import { readFileSync } from "node:fs";
24+
import { dirname, join } from "node:path";
25+
import { fileURLToPath } from "node:url";
26+
import { afterEach, describe, expect, it } from "vitest";
27+
import { StatusLine } from "./StatusLine";
28+
import type { StatusKind } from "./StatusLine";
29+
30+
const HERE = dirname(fileURLToPath(import.meta.url));
31+
/** 归一 EOL(本仓无 `.gitattributes` 且 `core.autocrlf=true`,逐字节断言会在别人机器上假阳性) */
32+
const readText = (n: string): string => readFileSync(join(HERE, n), "utf8").replace(/\r\n/g, "\n");
33+
const STATUS_TSX = readText("StatusLine.tsx");
34+
const STATUS_CSS = readText("StatusLine.css");
35+
const MOTION_CSS = readText("motion.css");
36+
const INDEX_TS = readText("index.ts");
37+
/** 判据前先剥注释(注释里提到反例不该让守卫误报 —— 同 `style-seams.test.ts` 的口径) */
38+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
39+
40+
const cls = (el: Element): string => el.getAttribute("class") ?? "";
41+
const line = (): HTMLElement => screen.getByTestId("st");
42+
/** 取某条规则的声明体(`选择器 {` 到该规则第一个 `}`);容忍对齐用的多余空白 */
43+
const rule = (css: string, selector: string): string => {
44+
const m = new RegExp(`${selector.replace(/\./g, "\\.")}\\s*\\{`).exec(css);
45+
expect(m, `CSS 缺少 ${selector} 的规则`).not.toBeNull();
46+
if (m === null) throw new Error(`CSS 缺少 ${selector} 的规则`);
47+
return css.slice(m.index, css.indexOf("}", m.index));
48+
};
49+
50+
/** 规则体里 `{` 之后、`}` 之前的声明原文(断言「这条规则只写了什么」时用它) */
51+
const decls = (css: string, selector: string): string => rule(css, selector).replace(/^[^{]*\{/, "").trim();
52+
53+
/** 契约里的 `role` 二值表(§5.2 的弹层语义之外,状态行自己的一条无障碍契约) */
54+
const ROLES: ReadonlyArray<readonly [StatusKind, "alert" | "status"]> = [
55+
["error", "alert"],
56+
["warn", "status"],
57+
["info", "status"],
58+
["ok", "status"],
59+
];
60+
61+
afterEach(cleanup);
62+
63+
describe("① 四档 kind 与 role 二值契约", () => {
64+
for (const [kind, role] of ROLES) {
65+
it(`${kind} → 类 ed-status--${kind} + role=${role}`, () => {
66+
render(
67+
<StatusLine kind={kind} testId="st">
68+
保存失败
69+
</StatusLine>,
70+
);
71+
expect(cls(line())).toContain(`ed-status--${kind}`);
72+
expect(line().getAttribute("role")).toBe(role);
73+
expect(line().getAttribute("data-kind")).toBe(kind);
74+
});
75+
}
76+
77+
it("默认 kind=info(信息档是中性默认),类名一个不多一个不少", () => {
78+
render(<StatusLine testId="st">已保存</StatusLine>);
79+
expect(cls(line())).toBe("ed-status ed-status--info");
80+
expect(line().getAttribute("role")).toBe("status");
81+
expect(screen.getByRole("status")).toBe(line());
82+
});
83+
84+
it("error 档进无障碍树的 alert(「收到了」不必等一次朗读结束)", () => {
85+
render(
86+
<StatusLine kind="error" testId="st">
87+
保存失败
88+
</StatusLine>,
89+
);
90+
expect(screen.getByRole("alert")).toBe(line());
91+
});
92+
93+
it("不重复声明 aria-live(两个 role 已隐含对应值,重复声明 = 第二个真源)", () => {
94+
for (const [kind] of ROLES) {
95+
render(
96+
<StatusLine kind={kind} testId="st">
97+
x
98+
</StatusLine>,
99+
);
100+
expect(line().getAttribute("aria-live")).toBeNull();
101+
cleanup();
102+
}
103+
});
104+
105+
it("语义色只走类、不出内联 style(否则批 6 只能逐处改)", () => {
106+
render(
107+
<StatusLine kind="error" testId="st" detail="检查网络后重试">
108+
保存失败
109+
</StatusLine>,
110+
);
111+
expect(line().getAttribute("style")).toBeNull();
112+
});
113+
});
114+
115+
describe("② detail / action 两个槽(缺席时不得凭空多出节点)", () => {
116+
it("主文案继承容器的语义色(`ed-text--inherit`),不是自己挑一档墨度", () => {
117+
render(
118+
<StatusLine kind="error" testId="st" detail="检查网络后重试">
119+
保存失败
120+
</StatusLine>,
121+
);
122+
const main = screen.getByText("保存失败");
123+
expect(cls(main)).toContain("ed-text--inherit");
124+
expect(cls(main)).toContain("ed-text--s5");
125+
});
126+
127+
it("detail 渲染为次级文本(`ink-3`):技术细节不该被语义色染红", () => {
128+
render(
129+
<StatusLine kind="error" testId="st" detail="检查网络后重试">
130+
保存失败
131+
</StatusLine>,
132+
);
133+
expect(cls(screen.getByText("检查网络后重试"))).toContain("ed-text--ink-3");
134+
expect(cls(screen.getByText("检查网络后重试"))).not.toContain("ed-text--inherit");
135+
});
136+
137+
it("不传 detail ⇒ 只有主文案;传了才多一个节点(含空串也算传了)", () => {
138+
const { rerender } = render(
139+
<StatusLine testId="st">已保存</StatusLine>,
140+
);
141+
expect(line().textContent).toBe("已保存");
142+
expect(line().children).toHaveLength(1);
143+
rerender(
144+
<StatusLine testId="st" detail="3 分钟前">
145+
已保存
146+
</StatusLine>,
147+
);
148+
expect(line().textContent).toBe("已保存3 分钟前");
149+
expect(line().children).toHaveLength(2);
150+
});
151+
152+
it("action 槽原样渲染(形态由调用方决定),且是容器最后一个子节点", () => {
153+
render(
154+
<StatusLine
155+
kind="error"
156+
testId="st"
157+
detail="检查网络"
158+
action={
159+
<button type="button" data-testid="retry">
160+
重试
161+
</button>
162+
}
163+
>
164+
保存失败
165+
</StatusLine>,
166+
);
167+
const retry = screen.getByTestId("retry");
168+
expect(retry.textContent).toBe("重试");
169+
expect(line().lastElementChild).toBe(retry);
170+
});
171+
172+
it("children 原样渲染:中文、嵌套元素、多子节点都不动", () => {
173+
render(
174+
<StatusLine testId="st">
175+
「高数」导入失败:<strong>第 3 段</strong>为空
176+
</StatusLine>,
177+
);
178+
expect(line().textContent).toBe("「高数」导入失败:第 3 段为空");
179+
expect(line().querySelector("strong")?.textContent).toBe("第 3 段");
180+
});
181+
});
182+
183+
describe("③ 危险语义用色(控制方裁决③ · 与 Task 14 第 4 条守卫同向)", () => {
184+
it("本原语**结构上不可能渲染按钮**:不 import Button、源码里没有 button 元素", () => {
185+
expect(STATUS_TSX, "状态行自己不产按钮 —— 按钮由 action 槽外部传入").not.toContain('from "./Button"');
186+
expect(STATUS_TSX).not.toMatch(/<button/);
187+
});
188+
189+
it("四档全是 `color:` 声明;CSS 里连底色声明都没有(该 token 绝不作任何元素的底色)", () => {
190+
const clean = stripComments(STATUS_CSS);
191+
for (const kind of ["error", "warn", "info", "ok"]) {
192+
const decl = rule(clean, `.ed-status--${kind}`);
193+
expect(decl, `${kind} 档必须只写文字色`).toMatch(/color:\s*var\(--ed-/);
194+
expect(decl).not.toContain("background");
195+
}
196+
expect(clean, "本文件不得出现任何底色声明").not.toMatch(/background/);
197+
});
198+
199+
it("危险色在本文件只出现一次,且那一次在 `color:` 值位(报告里的同向证据)", () => {
200+
const clean = stripComments(STATUS_CSS);
201+
const hits = clean.split("\n").filter((l) => l.includes("var(--ed-stamp)"));
202+
expect(hits).toHaveLength(1);
203+
expect(hits[0]).toContain("color: var(--ed-stamp)");
204+
expect(decls(clean, ".ed-status--error")).toBe("color: var(--ed-stamp);");
205+
});
206+
207+
it("本层零颜色字面量(色值只许在 ui/tokens.css 与 token 生成器里)", () => {
208+
const clean = stripComments(STATUS_CSS);
209+
expect(clean).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
210+
expect(clean).not.toMatch(/\b(?:rgb|hsl)a?\(/);
211+
expect(STATUS_TSX, "TSX 里也不得写色值(内联 style 会绕开类契约)").not.toMatch(/#[0-9a-fA-F]{6}\b/);
212+
});
213+
214+
it("error 档不渲染任何按钮(结构保证的行为面:无 action 就无按钮)", () => {
215+
render(
216+
<StatusLine kind="error" testId="st" detail="详情">
217+
保存失败
218+
</StatusLine>,
219+
);
220+
expect(screen.queryAllByRole("button")).toHaveLength(0);
221+
});
222+
});
223+
224+
describe("④ 动效接缝与 reduced-motion(§8.6.1 四条硬约束)", () => {
225+
it("浮现钩子:`.ed-status` 的 transition 同时含 `color` 与 `opacity`,单属性 120ms(可中断、不排队)", () => {
226+
const decl = rule(stripComments(STATUS_CSS), ".ed-status");
227+
expect(decl).toContain("transition:");
228+
expect(decl).toMatch(/transition:[^;]*color\s+var\(--ed-dur-micro, 120ms\)/);
229+
expect(decl).toMatch(/opacity\s+var\(--ed-dur-micro, 120ms\)/);
230+
expect(decl, "时长必须带同值兜底字面量(批 6 删变量块即生效)").toContain("var(--ed-ease, cubic-bezier(0.2, 0, 0, 1))");
231+
});
232+
233+
it("不抢注意力:无 `@keyframes`、无 `animation`、无 `infinite`(探针摆动是 Loading 的落点,不是状态行的)", () => {
234+
const clean = stripComments(STATUS_CSS);
235+
expect(clean).not.toMatch(/@keyframes/);
236+
expect(clean).not.toMatch(/\banimation/);
237+
expect(clean).not.toMatch(/infinite/);
238+
});
239+
240+
it("不越界:不写 z-index、不写 position(层级是 ui/zIndex.ts 标尺的职责,定位属调用点)", () => {
241+
const clean = stripComments(STATUS_CSS);
242+
expect(clean).not.toMatch(/z-index/);
243+
expect(clean).not.toMatch(/position\s*:/);
244+
});
245+
246+
it("reduced-motion 覆盖:`.ed-status` 已进 motion.css 那条唯一的媒体查询名单", () => {
247+
const clean = stripComments(MOTION_CSS);
248+
expect(clean.match(/@media \(prefers-reduced-motion: reduce\)/g)).toHaveLength(1);
249+
const block = clean.slice(clean.indexOf("@media (prefers-reduced-motion"));
250+
expect(block, "名单缺本原语的基类 ⇒ reduced-motion 用户仍会看到浮现过渡").toContain(".ed-status");
251+
expect(block).toContain("transition-duration: 1ms !important");
252+
});
253+
254+
it("批 4 的消费入口:barrel 导出了本原语与它的两个类型", () => {
255+
expect(INDEX_TS).toContain('export { StatusLine } from "./StatusLine";');
256+
expect(INDEX_TS).toContain('export type { StatusKind, StatusLineProps } from "./StatusLine";');
257+
});
258+
});

0 commit comments

Comments
 (0)