Skip to content

Commit f1da12e

Browse files
committed
fix(ui): reduced-motion 名单补伪元素并加覆盖守卫
T11 评审用 headless Chromium 实测:animation-duration 不可继承,.ed-skeleton::after 拿不到宿主元素的覆盖(1.2s/infinite 仍在跑)。motion.css 名单补 .ed-skeleton::after 与 .ed-empty-enter(动画落点逐字列出),并新建 motion-coverage.test.ts:基类覆盖 + 动画落点逐字覆盖 + 名单无死条目;style-contract.test.ts 只留 CSS 接线/联合契约锚/退场时长三方对拍。 同批:StatusLine 归 transition(keyframes 桶只留 Loading/Skeleton/Probe)· 规格 §8.6.1 与 §11 判据改写 · ADR-033 记分桶依据与伪元素规则 · keyframes 先后口径订正(本批首个是 EmptyState 的 ed-empty-in)。
1 parent 44de78d commit f1da12e

7 files changed

Lines changed: 198 additions & 101 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @ai-context motion-coverage.test.ts — **`motion.css` 的 reduced-motion 覆盖守卫**(批 0-D Task 14;node 环境,无 DOM)。
3+
*
4+
* Why(两条判据,第二条堵的是 T11 评审用**真浏览器**实测出来的 Critical):
5+
* ① **基类覆盖率**(规格 §11 验收 5):每一类原语的**根类**必须在 `motion.css` 的媒体查询名单里,
6+
* 且名单**双向一致** —— 漏一个 = 那类原语在 reduced-motion 下照旧动;多一个 = 死名字。
7+
* 判据 = **基类名单,不是「全类集合 ⊇」**:修饰类(`--`)与子元素/钩子类
8+
* (`.ed-modal-head/body/foot` · `.ed-confirm-seal/-impacts/-keep` · `.ed-empty__title`)与基类同在一个
9+
* 元素上,已被同一条规则覆盖,逐字枚举只会假红(控制方 2026-09-11 拍定,`progress.md` §十五-3)。
10+
* ② **`animation` 声明的选择器原文必须逐字进名单**:`animation-duration` / `animation-iteration-count`
11+
* **不是可继承属性** ⇒ 覆盖写在宿主元素上,**伪元素拿不到**。T11 评审用 headless Chromium
12+
* (`--force-prefers-reduced-motion`)实测坐实:`.ed-skeleton` 元素是 `0.001s/1` ✅,
13+
* 而 **`.ed-skeleton::after` 仍是 `1.2s / infinite / ed-skeleton-shimmer`** ❌ ——
14+
* 「让两者都静止」的计划验收当时未达成,而文本包含式断言抓不到。⇒ 判据从「基类在名单里」
15+
* 升级为「**每一处动画的落点**(元素或伪元素)都在名单里」。
16+
*
17+
* 副作用:只读磁盘(同目录),不修改任何文件。
18+
* 边界:① 判据只覆盖 `app/src/ui/primitives/` 一层;② 判据前先归一 EOL、先剥注释(本仓无 `.gitattributes`
19+
* 且 `core.autocrlf=true`);③ 抽取按**规则块**做(`选择器 { 规则体 }`),不是通用 CSS parser —— 本层 CSS
20+
* 全部扁平、无嵌套规则、无 `@supports` 内层规则(真 parser 需要新依赖,违反「零新增依赖」);
21+
* ④ 这里不重述「零颜色字面量 / 接线 / 联合契约 / 时长三方对拍」(`style-seams.test.ts` 与 `style-contract.test.ts`)。
22+
*/
23+
import { readdirSync, readFileSync } from "node:fs";
24+
import { dirname, join } from "node:path";
25+
import { fileURLToPath } from "node:url";
26+
import { describe, expect, it } from "vitest";
27+
28+
const HERE = dirname(fileURLToPath(import.meta.url));
29+
const normalizeEol = (s: string): string => s.replace(/\r\n/g, "\n");
30+
const read = (file: string): string => normalizeEol(readFileSync(join(HERE, file), "utf8"));
31+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
32+
33+
const CSS_FILES: readonly string[] = readdirSync(HERE).filter((f) => f.endsWith(".css"));
34+
35+
/** 媒体查询里列出的条目(**含伪元素原文**,如 `ed-skeleton::after`) */
36+
const MOTION_ENTRIES: readonly string[] = (() => {
37+
const clean = stripComments(read("motion.css"));
38+
const block = clean.slice(clean.indexOf("@media (prefers-reduced-motion"));
39+
return [...block.matchAll(/\.([A-Za-z0-9_-]+(?:::?[A-Za-z-]+)?)/g)].map((m) => m[1]);
40+
})();
41+
42+
const BASE_CLASSES: ReadonlyArray<readonly [file: string, base: string]> = [
43+
["Text.css", "ed-text"],
44+
["Surface.css", "ed-surface"],
45+
["Button.css", "ed-btn"],
46+
["Modal.css", "ed-modal"],
47+
["Modal.css", "ed-modal-overlay"],
48+
["ConfirmDialog.css", "ed-confirm"],
49+
["Toast.css", "ed-toast"],
50+
["EmptyState.css", "ed-empty"],
51+
["Loading.css", "ed-loading"],
52+
["Loading.css", "ed-skeleton"],
53+
["Loading.css", "ed-probe"],
54+
["StatusLine.css", "ed-status"],
55+
];
56+
57+
/**
58+
* 5 个**现存非原语**的 `.ed-*` 名(T3 评审 M-4 实测)。它们不是 CSS 类 —— 是既有标识符里的子串:
59+
* `ed-desc`←`"updated-desc"` · `ed-label`←`note-link-linked-label` · `ed-note`←`…saved-note` ·
60+
* `ed-milestone-note`←`data-testid="degraded-milestone-note"` · `ed-low-confidence`←`structuredBlocks.ts:58`
61+
* 产出的类名串(其 `App.css` 规则已随 Task 13 删除)。任何「全树裸扫 `.ed-*`」的写法都会在这 5 个上假红。
62+
*/
63+
const NON_PRIMITIVE_ED_NAMES: readonly string[] = [
64+
"ed-desc",
65+
"ed-label",
66+
"ed-note",
67+
"ed-milestone-note",
68+
"ed-low-confidence",
69+
];
70+
71+
/** 全部 `primitives/*.css` 的选择器里出现过的 `.ed-*` 类名(剥注释后;含跨文件引用的基类) */
72+
const SELECTOR_NAMES: readonly string[] = [
73+
...new Set(CSS_FILES.flatMap((f) => [...stripComments(read(f)).matchAll(/\.(ed-[A-Za-z0-9_-]+)/g)].map((m) => m[1]))),
74+
];
75+
76+
/**
77+
* 抽取「声明了 `animation` 或 `animation-name` 的选择器**原文**」(含 `::after` 这类伪元素)。
78+
* 只看 `animation:` / `animation-name:` —— `animation-duration` / `animation-iteration-count` 是覆盖块
79+
* 自己写的属性,不是"动画落点"。
80+
*/
81+
function animationSelectors(): string[] {
82+
const out: string[] = [];
83+
for (const file of CSS_FILES) {
84+
for (const m of stripComments(read(file)).matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
85+
if (!/(?:^|[;\s])animation(?:-name)?\s*:/.test(m[2])) continue;
86+
for (const raw of m[1].split(",")) {
87+
const sel = raw.trim().replace(/\s+/g, " ");
88+
if (sel !== "" && !sel.startsWith("@")) out.push(sel);
89+
}
90+
}
91+
}
92+
return [...new Set(out)];
93+
}
94+
95+
describe("reduced-motion 基类覆盖率 100%(规格 §11 验收 5:判据 = 基类名单,不是「全类集合 ⊇」)", () => {
96+
it("每个基类都真有规则,且都在 `motion.css` 的名单里(漏一个 = 那类原语照旧动)", () => {
97+
expect(BASE_CLASSES, "基类名单不该缩水").toHaveLength(12);
98+
for (const [file, base] of BASE_CLASSES) {
99+
expect(stripComments(read(file)), `${file} 缺少基类 .${base} 的规则`).toContain(`.${base} {`);
100+
expect(MOTION_ENTRIES, `motion.css 的 reduced-motion 名单缺基类 .${base}`).toContain(base);
101+
}
102+
});
103+
104+
it("名单里没有死条目(每个条目要么是登记基类,要么是某处 `animation` 声明的选择器)", () => {
105+
const bases = new Set(BASE_CLASSES.map(([, base]) => base));
106+
const animated = new Set(animationSelectors());
107+
const dead = MOTION_ENTRIES.filter((entry) => !bases.has(entry) && !animated.has(`.${entry}`));
108+
expect(dead, `这些条目既不是基类、也不对应任何动画声明(删掉或改对名字):\n${dead.join("\n")}`).toEqual([]);
109+
});
110+
111+
it("选择器域里没有未登记的基类(修饰类 `--` / BEM 子元素 `__` / `<基类>-…` 之外一律要进名单)", () => {
112+
const bases = new Set(BASE_CLASSES.map(([, base]) => base));
113+
const unregistered = SELECTOR_NAMES.filter(
114+
(name) =>
115+
!NON_PRIMITIVE_ED_NAMES.includes(name) &&
116+
!name.includes("--") &&
117+
!name.includes("__") &&
118+
![...bases].some((base) => name.startsWith(`${base}-`)) &&
119+
!bases.has(name),
120+
);
121+
expect(unregistered, `新增基类必须同时加进 motion.css 与上面的 BASE_CLASSES:\n${unregistered.join("\n")}`).toEqual([]);
122+
});
123+
124+
it("口径锚:5 个非原语名不在选择器域里(它们在 TS 标识符里;真成了 CSS 类就该从排除名单摘掉)", () => {
125+
expect(NON_PRIMITIVE_ED_NAMES).toHaveLength(5);
126+
for (const name of NON_PRIMITIVE_ED_NAMES) expect(SELECTOR_NAMES).not.toContain(name);
127+
});
128+
});
129+
130+
describe("★ 每一处 `animation` 声明的选择器都在名单里(`animation-*` 不可继承 ⇒ 伪元素必须逐字列)", () => {
131+
it("抽取本身非空(防选择器解析失效把守卫静默关掉)", () => {
132+
const animated = animationSelectors();
133+
expect(animated.length, `primitives/*.css 里应能抽到动画落点`).toBeGreaterThanOrEqual(3);
134+
expect(animated).toContain(".ed-skeleton::after");
135+
});
136+
137+
it("逐条比对:缺一条即红(`.ed-skeleton::after` 被真浏览器实测打不到覆盖 —— T11 评审 Critical)", () => {
138+
const animated = animationSelectors();
139+
const uncovered = animated.filter((sel) => !MOTION_ENTRIES.includes(sel.slice(1)));
140+
expect(
141+
uncovered,
142+
`这些选择器声明了 animation 却不在 motion.css 的 reduced-motion 名单里(伪元素拿不到宿主元素的覆盖):\n${uncovered.join("\n")}`,
143+
).toEqual([]);
144+
});
145+
});

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,18 @@
5555
--ed-ease: cubic-bezier(0.2, 0, 0, 1);
5656
}
5757

58+
/* ★ 名单 = 12 个 `.ed-*` **基类** + **每一处 `animation` 声明的选择器原文**(含伪元素)。
59+
⚠️ 为什么伪元素必须逐字列出(T11 评审用 headless Chromium `--force-prefers-reduced-motion` 实测的 Critical):
60+
`animation-duration` / `animation-iteration-count` **不是可继承属性** —— 覆盖写在宿主元素上,
61+
伪元素(`.ed-skeleton::after`)**拿不到**,实测它仍是 `1.2s / infinite / ed-skeleton-shimmer`。
62+
⇒ 这条规则**不许**只写基类:漏掉伪元素是系统性洞(未来任何原语在伪元素上做动画都会再踩)。
63+
机器判据:`motion-coverage.test.ts` 从 `primitives/*.css` 抽取每个 `animation`/`animation-name`
64+
声明的选择器原文,逐个要求出现在下面这份名单里。 */
5865
@media (prefers-reduced-motion: reduce) {
5966
.ed-btn, .ed-surface, .ed-text, .ed-modal-overlay, .ed-modal, .ed-confirm,
6067
.ed-toast, .ed-empty, .ed-loading, .ed-skeleton, .ed-probe,
61-
.ed-status {
68+
.ed-status,
69+
.ed-skeleton::after, .ed-empty-enter {
6270
transition-duration: 1ms !important;
6371
animation-duration: 1ms !important;
6472
animation-iteration-count: 1 !important;

‎app/src/ui/primitives/style-contract.test.ts‎

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -182,83 +182,6 @@ describe("契约完整性锚:类型联合 ↔ CSS 类规则(给联合加一
182182
});
183183
});
184184

185-
/**
186-
* 「基类名单」= 每一类原语的**根类**(动效挂在它身上、必须被 `motion.css` 的媒体查询压住的选择器)。
187-
* ⚠️ 判据只到基类,**不含**修饰类与子元素类:`.ed-modal-head/body/foot`(T7 子元素)·
188-
* `.ed-confirm-seal/-impacts/-keep`(T8 子元素)· `.ed-empty__title`(BEM 子元素)·
189-
* `.ed-empty-enter` / `.ed-toast-action`(钩子与子元素)都不是独立元素 —— 它们与基类同在一个元素上,
190-
* 已被同一条规则覆盖。控制方 2026-09-11 拍定(`progress.md` §十五-3):照「全类集合 ⊇」判会在这
191-
* 些**非 `--` 名**上假红。
192-
*/
193-
const BASE_CLASSES: ReadonlyArray<readonly [file: string, base: string]> = [
194-
["Text.css", "ed-text"],
195-
["Surface.css", "ed-surface"],
196-
["Button.css", "ed-btn"],
197-
["Modal.css", "ed-modal"],
198-
["Modal.css", "ed-modal-overlay"],
199-
["ConfirmDialog.css", "ed-confirm"],
200-
["Toast.css", "ed-toast"],
201-
["EmptyState.css", "ed-empty"],
202-
["Loading.css", "ed-loading"],
203-
["Loading.css", "ed-skeleton"],
204-
["Loading.css", "ed-probe"],
205-
["StatusLine.css", "ed-status"],
206-
];
207-
208-
/**
209-
* 5 个**现存非原语**的 `.ed-*` 名(T3 评审 M-4 实测)。它们不是 CSS 类 —— 是既有标识符里的子串:
210-
* `ed-desc`←`"updated-desc"` · `ed-label`←`note-link-linked-label` · `ed-note`←`…saved-note` ·
211-
* `ed-milestone-note`←`data-testid="degraded-milestone-note"` · `ed-low-confidence`←`structuredBlocks.ts:58`
212-
* 产出的类名串(其 `App.css` 规则已随 Task 13 删除)。任何「全树裸扫 `.ed-*`」的写法都会在这 5 个上
213-
* 假红 ⇒ 它们进**判据链的第一道过滤**,把「判据域 = 样式选择器」这一口径写成可执行的形式。
214-
*/
215-
const NON_PRIMITIVE_ED_NAMES: readonly string[] = [
216-
"ed-desc",
217-
"ed-label",
218-
"ed-note",
219-
"ed-milestone-note",
220-
"ed-low-confidence",
221-
];
222-
223-
describe("reduced-motion 覆盖率 100%:判据 = 基类名单,不是「全类集合 ⊇」(规格 §11 验收 5)", () => {
224-
const motionClean = stripComments(read("motion.css"));
225-
const motionBlock = motionClean.slice(motionClean.indexOf("@media (prefers-reduced-motion"));
226-
const motionNames = [...motionBlock.matchAll(/\.(ed-[a-z0-9-]+)/g)].map((m) => m[1]);
227-
/** 全部 `primitives/*.css` 里出现过的 `.ed-*` 选择器名(剥注释后;含跨文件引用的基类) */
228-
const selectorNames = [
229-
...new Set(CSS_FILES.flatMap((f) => [...stripComments(read(f)).matchAll(/\.(ed-[A-Za-z0-9_-]+)/g)].map((m) => m[1]))),
230-
];
231-
232-
it("名单与 `motion.css` 的媒体查询**双向**一致(漏一个 = 那类原语在 reduced-motion 下照旧动;多一个 = 死名字)", () => {
233-
expect(BASE_CLASSES, "基类名单不该缩水").toHaveLength(12);
234-
expect([...motionNames].sort()).toEqual(BASE_CLASSES.map(([, base]) => base).sort());
235-
});
236-
237-
it("每个基类都真有规则,且宿主文件与名单一致(改名漏改名单 = 静默失去 reduced-motion 覆盖)", () => {
238-
for (const [file, base] of BASE_CLASSES) {
239-
expect(stripComments(read(file)), `${file} 缺少基类 .${base} 的规则`).toContain(`.${base} {`);
240-
}
241-
});
242-
243-
it("选择器域里没有未登记的基类(修饰类 `--` / BEM 子元素 `__` / `<基类>-…` 之外一律要进名单)", () => {
244-
const bases = new Set(BASE_CLASSES.map(([, base]) => base));
245-
const unregistered = selectorNames.filter(
246-
(name) =>
247-
!NON_PRIMITIVE_ED_NAMES.includes(name) &&
248-
!name.includes("--") &&
249-
!name.includes("__") &&
250-
![...bases].some((base) => name.startsWith(`${base}-`)) &&
251-
!bases.has(name),
252-
);
253-
expect(unregistered, `新增基类必须同时加进 motion.css 的名单与上面的 BASE_CLASSES:\n${unregistered.join("\n")}`).toEqual([]);
254-
});
255-
256-
it("口径锚:5 个非原语名不在选择器域里(它们在 TS 标识符里;真成了 CSS 类就该从排除名单摘掉)", () => {
257-
expect(NON_PRIMITIVE_ED_NAMES).toHaveLength(5);
258-
for (const name of NON_PRIMITIVE_ED_NAMES) expect(selectorNames).not.toContain(name);
259-
});
260-
});
261-
262185
/**
263186
* 退场时长**三方对拍**(T9 评审 I-3 的通用化 —— 原来只有 Toast 一条无判据):
264187
* JS 侧兜底窗口(`usePresence` 的 `exitMs`)与 CSS 侧过渡时长**必须是同一个数**,否则不会有任何报错:

‎docs/adr/ADR-033-l1-primitives-and-view-layer-contract.md‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ ADR-032 交付了 token 层(色阶 / 字阶 / z-index 标尺 / 图标),但
3838
| 8 | `Loading` / `Skeleton` / `Probe` | `Loading.tsx` 102 · `Loading.css` 72 · `Loading.test.tsx` 229 | 85 处/30 文件的手写灰字,全站 0 骨架屏 |
3939
| 9 | `StatusLine` | `StatusLine.tsx` 92 · `StatusLine.css` 41 · `StatusLine.test.tsx` 258 | 196 处/76 文件,三种红并存,错误常在列表最底部 |
4040
| — | 共享内核 | `usePresence.ts` 200 · `usePresence.test.tsx` 282 · `usePresence.node.test.ts` 71 · `useFocusTrap.ts` 122 · `useFocusTrap.test.tsx` 236 · `ime.ts` 18 · `ime.test.ts` 32 | 卸载时机 / 焦点陷阱 / IME 组合态 |
41-
| — | 接缝与守卫 | `motion.css` 66 · `index.ts` 45 · `style-seams.test.ts` 271 · `style-contract.test.ts` 267 | 动效变量与 reduced-motion 块 · 导出面 · 四条机器判据 |
41+
| — | 接缝与守卫 | `motion.css` 74 · `index.ts` 45 · `style-seams.test.ts` 271 · `style-contract.test.ts` 214 · `motion-coverage.test.ts` 145 | 动效变量与 reduced-motion 块 · 导出面 · 五条机器判据 |
4242

4343
**导出面**:`primitives/index.ts` 是唯一公共入口(批 4 之后全站从这里 import);**组内互引用走相对文件路径**
4444
(`./Text`),**不 import barrel** —— 避免循环依赖。`index.ts` 另承担 `import "./motion.css";`:导入本层即带上
4545
动效变量与 reduced-motion 块;**深导入单个原语不会带走 reduced-motion 块**(该边界写在 `index.ts` 的 `@ai-context` 里)。
4646

47-
**本批全部 41 个文件逐个 ≤300 行(最大 297)**,无需行数豁免登记。
47+
**本批全部 42 个文件逐个 ≤300 行(最大 297)**,无需行数豁免登记。
4848

4949
### 2. 依赖方向:领域 → 视图 → 容器 → 原语,**禁止反向**
5050

@@ -104,9 +104,22 @@ ADR-032 交付了 token 层(色阶 / 字阶 / z-index 标尺 / 图标),但
104104
`const EXIT_MS`)与 CSS 侧的**过渡时长**必须是同一个数,且与 `motion.css` 的 token 定值相等 —— 漂移时
105105
**不会有任何报错**:JS 早了 = 过渡被截断(看不见出场),JS 晚了 = 节点多残留一截(那段时间还能被点到)。
106106
机器判据:`style-contract.test.ts` 的「退场时长三方对拍」(`Modal.tsx`+`Modal.css` / `Toast.tsx`+`Toast.css` 各一行)。
107-
- **reduced-motion 判据 = 基类名单**,不是"全类集合 ⊇":`.ed-modal-head/body/foot`、`.ed-confirm-seal/-impacts/-keep`、
108-
`.ed-empty__title`、`.ed-empty-enter`、`.ed-toast-action` 这类**子元素/钩子类**与基类同在一个元素上,
109-
已被同一条规则覆盖(逐字枚举只会假红)。
107+
- **reduced-motion 判据 = 基类名单 + 动画落点名单**,不是"全类集合 ⊇":`.ed-modal-head/body/foot`、`.ed-confirm-seal/-impacts/-keep`、
108+
`.ed-empty__title`、`.ed-toast-action` 这类**子元素类**与基类同在一个元素上,已被同一条规则覆盖(逐字枚举只会假红);
109+
但 **`animation` 声明的选择器原文必须逐字进名单(含伪元素)** —— `animation-duration` / `animation-iteration-count`
110+
**不是可继承属性**,T11 评审用 headless Chromium(`--force-prefers-reduced-motion`)实测坐实:`.ed-skeleton` 元素是
111+
`0.001s / 1`,而 **`.ed-skeleton::after` 仍是 `1.2s / infinite / ed-skeleton-shimmer`** ⇒ 计划验收「让两者都静止」
112+
当时未达成。**裁决:名单里补 `.ed-skeleton::after`(伪元素)与 `.ed-empty-enter`(钩子类)**,理由是这一块是
113+
"让一切都静止"的**唯一收口处**,漏掉伪元素是**系统性洞**(未来任何原语在伪元素上做动画都会再踩);把微光挪到元素自身
114+
只能修这一个实例。判据落在 `motion-coverage.test.ts`(抽取 `primitives/*.css` 里每个 `animation` / `animation-name`
115+
声明的选择器原文,逐个要求出现在名单里;反向再断言名单无死条目)。
116+
- **分桶裁定(控制方 2026-09-11,回应 T12 评审 I-2)**:`StatusLine` 的接缝是**一次性浮现 `transition`(无循环动画)**,
117+
**keyframes 桶只留 `Loading` / `Skeleton` / `Probe`**(骨架微光 / 探针)—— 依据 §8.6.1 第 1 条「环境层永远不抢注意力」:
118+
状态行是**信息**不是「呼吸物」,循环动效会让它在长列表里变成噪音;"闲置时也有生命感"由 `Probe` 承担。
119+
规格 §8.6.1 第 3 条已按此改写。
120+
- **本批 `@keyframes` 的先后(口径订正)**:本批**第一个** `@keyframes` 是 `EmptyState.css` 的 **`ed-empty-in`**
121+
(提交 `0e5e78ab`,00:06:10),`Loading.css` 的 `ed-skeleton-shimmer` / `ed-probe-swing` 在其后(`174b1893`,00:06:41)。
122+
`Loading.css:50` 与其报告写的「全仓第一个 `@keyframes`」**与事实相反**,此处以正确口径记录(该文件的措辞由 T11 自行处置)。
110123

111124
### 6. 退场期指针事件门控(`[data-phase="exit"]` ⇒ `pointer-events: none`)
112125

@@ -221,7 +234,7 @@ z-index 的迁移纪律(0-A 交接第 3 条):**必须按叠放段整段推
221234
|---|---|
222235
| 原语 CSS 零颜色字面量(**剥注释**后判,注释里的旧色值不算犯规) | `style-seams.test.ts`「守卫①」 |
223236
| `--ed-stamp` 不作底色 + `Button.css` 内计数为 0 | `style-seams.test.ts`「守卫②」(含口径锚:印章仍以 `color`/`border` 消费它) |
224-
| reduced-motion 覆盖率按**基类名单**双向一致 + 5 个非原语 `.ed-*` 名排除 | `style-contract.test.ts` |
237+
| reduced-motion 覆盖率:**基类**名单逐一覆盖 + **动画落点**(含伪元素)逐字覆盖 + 名单无死条目 + 5 个非原语 `.ed-*` 名排除 | `motion-coverage.test.ts` |
225238
| 每个 `primitives/*.css` 被其宿主模块 import(删掉那行 import 时其余用例仍全绿 —— T3 评审实测的洞) | `style-contract.test.ts` |
226239
| 11 个取值联合 ↔ CSS 类规则的全枚举锚(`Record<Union, …>` 编译期双向 + 档数运行期冗余) | `style-contract.test.ts` |
227240
| 退场时长三方对拍:`EXIT_MS` == 组件 CSS 的 `var(--ed-dur-*, <n>ms)` 兜底 == `motion.css` 定值 | `style-contract.test.ts` |

0 commit comments

Comments
 (0)