Skip to content

Commit 186e661

Browse files
committed
docs(adr): ADR-033 原语层契约 + 批 0-D 收尾守卫
ADR-033:原语层职责边界与依赖方向 · 9 类原语落点与文件清单 · --ed-shadow-1/2 定值与暗档反相描边 · --due 第二次对比度修正(#A05F10 -> #9F5E10,三底实测)· ink-4 禁止剪报底纹 · ui/tokens.css 入口接线 · 退场期指针事件门控 · 弹层唯一实现 · 动效接缝与批 6 接管机制。 守卫:style-seams 追加零颜色字面量与 --ed-stamp 不作底色;新建 style-contract(reduced-motion 基类名单 · CSS 接线 · 联合契约锚 · 退场时长三方对拍)。style-seams 曾涨到 340 行,按语义拆而非 --write 登记豁免。
1 parent 6cb4442 commit 186e661

8 files changed

Lines changed: 737 additions & 5 deletions

File tree

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
* 先把变量按规格 §8.4 的名字与取值落在这里,批 6 的动效 token 真源完成后**整块删除** ——
88
* 所有引用点写的是 `var(--ed-dur-x, <同值字面量>)`,删块即生效,无需改任何规则。
99
*
10-
* ② 本文件是**全仓第一条 `prefers-reduced-motion` 块**(实测改造前 0 处,recon §8.1)。
10+
* ② 本文件是 **`app/src` 内第一条 `prefers-reduced-motion` 块**(实测改造前 `app/src` 里 0 处,recon §8.1;
11+
* ⚠️ 口径只到 `app/src`:仓库根的 `website/components/dive/ChronosDemo.tsx:59` 早已有 JS 侧
12+
* `matchMedia("(prefers-reduced-motion: reduce)")`,故「全仓第一条 reduced-motion」的说法不成立)。
1113
* 名单一次写全 12 个 `.ed-*` 基类(含本批后面才实现的选择器 —— 未实现的选择器无害;
1214
* Task 12 追加 `.ed-status`),
1315
* 换来「后续任务永不改本文件」;Task 14 的样式守卫断言每个 `.ed-*` **基类**都在名单里
@@ -23,6 +25,13 @@
2325
* 这条**故意不做成 CSS 变量**:位移的落点是 CSS `translate*()` 的字面量与批 6 的 JS 数值,
2426
* 没有消费者读它,做成变量就是个死变量(还会给人「已被强制」的错觉)。强制手段是
2527
* `style-seams.test.ts` 的机器判据:扫 `primitives/*.css` 的 `translate*()` 数值实参,>8px 即红。
28+
*
29+
* ⑤ **行内 `style` 覆盖 CSS 类**(批 6 的接法澄清,T3 评审 I-4 实测订正):原语的排版/透明/位移一律
30+
* 只出类名,`style` 是**纯透传**(`Text` / `Surface` / `Button` 三个原语都已实测)。批 6 若用
31+
* `style` 直驱属性值(如 `style={{ color }}` 或 GSAP 写的内联属性),**类上的 transition 仍然在**
32+
* (过渡由类规则声明,不因值来自何处而消失),但**终值以 `style` 为准**(行内优先于类选择器)——
33+
* 即:接管是可行的,只是"目标值"要从类挪到 `style`。反过来,若批 6 想让类规则重新当家,
34+
* 必须先把 `style` 摘掉,否则改类不会有观感变化。
2635
*/
2736

2837
:root {
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/**
2+
* @ai-context style-contract.test.ts — 原语层的**跨文件契约守卫**(批 0-D Task 14;node 环境,无 DOM)。
3+
*
4+
* Why(每条堵一个**实测出来**的洞;本文件与 `style-seams.test.ts` 的分工见彼处文件头):
5+
* ① **CSS 接线**:删掉 `Text.tsx` 顶部的 `import "./Text.css"` 后,其余用例**仍然全绿**(类名照旧
6+
* 产出、只是样式静默失效)—— 批 4 迁移时的表现是「原语上线了但界面什么都没变」(T3 评审 I-2)。
7+
* ② **契约完整性锚**:既有两份测试的名单都是**手抄的**(`readonly TextTone[]` 只约束「成员属于
8+
* 联合」、不约束「联合成员都在」)⇒ 给联合加一档而忘改 CSS 或测试时全仓全绿(T3 评审 I-3)。
9+
* 本文件用 `Record<Union, …>` 全枚举(**编译期双向**)+ 档数冗余(**运行期**)钉住它。
10+
* ③ **退场时长三方对拍**:JS 兜底窗口与 CSS 过渡时长漂移时不会有任何报错(T9 评审 I-3)。
11+
*
12+
* 副作用:只读磁盘(同目录),不修改任何文件。**只 import 类型**(`import type`)—— 运行时不加载任何
13+
* React 组件 / `.css`,故本文件留在 vitest 的默认 node 环境(无 DOM 也能跑)。
14+
*
15+
* 边界:① 判据只覆盖 `app/src/ui/primitives/` 一层(其余 `.ed-*` 名属既有代码,见 `style-seams.test.ts`
16+
* 的 `NON_PRIMITIVE_ED_NAMES`);② 判据前**先剥注释**、先归一 EOL(本仓无 `.gitattributes` 且
17+
* `core.autocrlf=true`,逐字节断言会在别人机器上假阳性);③ 这里**不重述** token 真源
18+
* (`tokens.drift.test.ts` / `contrast.test.ts`)与「零颜色字面量 / reduced-motion 覆盖」的职责。
19+
*/
20+
import { readdirSync, readFileSync } from "node:fs";
21+
import { dirname, join } from "node:path";
22+
import { fileURLToPath } from "node:url";
23+
import { describe, expect, it } from "vitest";
24+
import type { ButtonSize, ButtonVariant } from "./Button";
25+
import type { ModalSize } from "./Modal";
26+
import type { StatusKind } from "./StatusLine";
27+
import type { SurfaceLevel, SurfaceRadius } from "./Surface";
28+
import type { TextFont, TextSize, TextTone } from "./Text";
29+
import type { ToastKind } from "./Toast";
30+
import type { PresencePhase } from "./usePresence";
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
const normalizeEol = (s: string): string => s.replace(/\r\n/g, "\n");
34+
const read = (file: string): string => normalizeEol(readFileSync(join(HERE, file), "utf8"));
35+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
36+
37+
const ENTRIES: readonly string[] = readdirSync(HERE);
38+
const CSS_FILES: readonly string[] = ENTRIES.filter((f) => f.endsWith(".css"));
39+
const MODULES: readonly string[] = ENTRIES.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
40+
41+
/** 样式表 → 其**宿主模块**:`motion.css` 没有同名组件,宿主是唯一导出面 barrel `index.ts` */
42+
function ownerOf(css: string): string {
43+
const base = css.replace(/\.css$/, "");
44+
return base === "motion" ? "index.ts" : `${base}.tsx`;
45+
}
46+
47+
describe("CSS 接线守卫:每个 primitives/*.css 必须被其同名模块 import(删掉那行 = 类名对、样式没了)", () => {
48+
it("逐个样式表被宿主模块 import(`index.ts` 是 `motion.css` 的宿主)", () => {
49+
expect(CSS_FILES.length, "primitives/ 下的样式表数量不应减少").toBeGreaterThanOrEqual(10);
50+
for (const css of CSS_FILES) {
51+
const owner = ownerOf(css);
52+
expect(MODULES, `${css} 没有同名宿主模块 ${owner}`).toContain(owner);
53+
expect(
54+
read(owner),
55+
`${owner} 缺 \`import "./${css}";\` —— 类名仍会产出,但样式静默失效(其余用例全绿,最难归因)`,
56+
).toContain(`import "./${css}";`);
57+
}
58+
});
59+
60+
it("反向:模块 import 的每个 `./x.css` 都必须真实存在(悬空 import 会被打包器静默放过)", () => {
61+
const dangling: string[] = [];
62+
for (const mod of MODULES) {
63+
for (const m of stripComments(read(mod)).matchAll(/import\s+"\.\/([A-Za-z0-9._-]+\.css)"/g)) {
64+
if (!CSS_FILES.includes(m[1])) dangling.push(`${mod} → ${m[1]}`);
65+
}
66+
}
67+
expect(dangling, `import 了不存在的样式表:\n${dangling.join("\n")}`).toEqual([]);
68+
});
69+
});
70+
71+
/*
72+
* 联合 → (样式宿主, 成员 → 类名) 的全枚举表。**两层机制,缺一不成**:
73+
* ① 编译期:`Record<TextTone, string>` **双向** —— 联合加档而不加键 = TS2739;加了联合里没有的键 = TS2353。
74+
* ② 运行期:`vitest` 用 esbuild 剥类型、**不做类型检查** ⇒ 只靠 ① 时「加档 + 补键与 CSS」会全绿;
75+
* 表里的 `expected` 档数就是这个**故意的冗余**(它逼你回来确认一次契约:三处都改了才算完成)。
76+
*/
77+
const TONE_CLASS: Record<TextTone, string> = {
78+
"ink-1": "ed-text--ink-1",
79+
"ink-2": "ed-text--ink-2",
80+
"ink-3": "ed-text--ink-3",
81+
"ink-4": "ed-text--ink-4",
82+
stamp: "ed-text--stamp",
83+
ok: "ed-text--ok",
84+
due: "ed-text--due",
85+
link: "ed-text--link",
86+
inherit: "ed-text--inherit",
87+
};
88+
const SIZE_CLASS: Record<TextSize, string> = {
89+
1: "ed-text--s1",
90+
2: "ed-text--s2",
91+
3: "ed-text--s3",
92+
4: "ed-text--s4",
93+
5: "ed-text--s5",
94+
6: "ed-text--s6",
95+
};
96+
const FONT_CLASS: Record<TextFont, string> = {
97+
ui: "ed-text--font-ui",
98+
body: "ed-text--font-body",
99+
mono: "ed-text--font-mono",
100+
};
101+
const LEVEL_CLASS: Record<SurfaceLevel, string> = {
102+
sunken: "ed-surface--sunken",
103+
canvas: "ed-surface--canvas",
104+
surface: "ed-surface--surface",
105+
raised: "ed-surface--raised",
106+
};
107+
const RADIUS_CLASS: Record<SurfaceRadius, string> = {
108+
stamp: "ed-surface--r-stamp",
109+
control: "ed-surface--r-control",
110+
panel: "ed-surface--r-panel",
111+
overlay: "ed-surface--r-overlay",
112+
};
113+
const VARIANT_CLASS: Record<ButtonVariant, string> = {
114+
primary: "ed-btn--primary",
115+
secondary: "ed-btn--secondary",
116+
ghost: "ed-btn--ghost",
117+
};
118+
const BTN_SIZE_CLASS: Record<ButtonSize, string> = { sm: "ed-btn--sm", md: "ed-btn--md", lg: "ed-btn--lg" };
119+
const STATUS_CLASS: Record<StatusKind, string> = {
120+
error: "ed-status--error",
121+
warn: "ed-status--warn",
122+
info: "ed-status--info",
123+
ok: "ed-status--ok",
124+
};
125+
const TOAST_CLASS: Record<ToastKind, string> = { info: "ed-toast--info", ok: "ed-toast--ok", err: "ed-toast--err" };
126+
const MODAL_SIZE_CLASS: Record<ModalSize, string> = { s: "ed-modal--s", m: "ed-modal--m", l: "ed-modal--l" };
127+
/** 相位不是类名而是 `[data-phase]` 属性选择器(`usePresence` 的三态协议,Modal / Toast 共用) */
128+
const PHASE_SELECTOR: Record<PresencePhase, string> = {
129+
enter: '[data-phase="enter"]',
130+
entered: '[data-phase="entered"]',
131+
exit: '[data-phase="exit"]',
132+
};
133+
134+
interface UnionContract {
135+
/** 被锚定的取值联合(名字只用于报错信息 —— 类型层由 `Record<U, …>` 的声明处强制) */
136+
readonly union: string;
137+
/** 消费该联合的样式宿主 */
138+
readonly css: string;
139+
readonly members: Readonly<Record<string, string>>;
140+
/** 档数(故意的运行期冗余,见上) */
141+
readonly expected: number;
142+
}
143+
144+
const CONTRACTS: readonly UnionContract[] = [
145+
{ union: "TextTone", css: "Text.css", members: TONE_CLASS, expected: 9 },
146+
{ union: "TextSize", css: "Text.css", members: SIZE_CLASS, expected: 6 },
147+
{ union: "TextFont", css: "Text.css", members: FONT_CLASS, expected: 3 },
148+
{ union: "SurfaceLevel", css: "Surface.css", members: LEVEL_CLASS, expected: 4 },
149+
{ union: "SurfaceRadius", css: "Surface.css", members: RADIUS_CLASS, expected: 4 },
150+
{ union: "ButtonVariant", css: "Button.css", members: VARIANT_CLASS, expected: 3 },
151+
{ union: "ButtonSize", css: "Button.css", members: BTN_SIZE_CLASS, expected: 3 },
152+
{ union: "StatusKind", css: "StatusLine.css", members: STATUS_CLASS, expected: 4 },
153+
{ union: "ToastKind", css: "Toast.css", members: TOAST_CLASS, expected: 3 },
154+
{ union: "ModalSize", css: "Modal.css", members: MODAL_SIZE_CLASS, expected: 3 },
155+
];
156+
157+
describe("契约完整性锚:类型联合 ↔ CSS 类规则(给联合加一档而忘改任一侧,必须变红)", () => {
158+
it("10 个联合的档数与契约表逐条一致", () => {
159+
expect(CONTRACTS).toHaveLength(10);
160+
for (const c of CONTRACTS) {
161+
expect(Object.keys(c.members), `${c.union} 档数变了:加档必须同时改这里与 ${c.css}`).toHaveLength(c.expected);
162+
}
163+
});
164+
165+
it("每个成员都有对应规则(正则含 `\\s*\\{` —— `--primary {` 的留白写法曾被坑过,T5 评审 M-1)", () => {
166+
for (const c of CONTRACTS) {
167+
const clean = stripComments(read(c.css));
168+
for (const [member, cls] of Object.entries(c.members)) {
169+
expect(clean, `${c.union} 的 ${member} 在 ${c.css} 里缺少 .${cls} 规则`).toMatch(new RegExp(`\\.${cls}\\s*\\{`));
170+
}
171+
}
172+
});
173+
174+
it("PresencePhase 三态在 Modal 与 Toast 上各自齐全(少一个相位 = 该段动效没有终值)", () => {
175+
expect(Object.keys(PHASE_SELECTOR)).toHaveLength(3);
176+
for (const css of ["Modal.css", "Toast.css"]) {
177+
const clean = stripComments(read(css));
178+
for (const [phase, selector] of Object.entries(PHASE_SELECTOR)) {
179+
expect(clean, `${css} 缺少相位 ${phase} 的规则`).toContain(selector);
180+
}
181+
}
182+
});
183+
});
184+
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+
262+
/**
263+
* 退场时长**三方对拍**(T9 评审 I-3 的通用化 —— 原来只有 Toast 一条无判据):
264+
* JS 侧兜底窗口(`usePresence` 的 `exitMs`)与 CSS 侧过渡时长**必须是同一个数**,否则不会有任何报错:
265+
* JS 早了 = 过渡被截断(看不见出场);JS 晚了 = 节点多残留一截(那段时间还能被点到,正是退场误触面)。
266+
* 三方 = 组件常量 `EXIT_MS` / 组件 CSS 的 `var(--ed-…, <n>ms)` 兜底字面量 / `motion.css` 的定值。
267+
* 新增原语时在表里加一行即可(表非空断言防"表被清空 = 守卫静默关掉")。
268+
*/
269+
const DURATION_PAIRS: ReadonlyArray<readonly [module: string, css: string]> = [
270+
["Modal.tsx", "Modal.css"],
271+
["Toast.tsx", "Toast.css"],
272+
];
273+
274+
describe("退场时长三方对拍:组件常量 == 组件 CSS 兜底字面量 == motion.css 定值", () => {
275+
const motion = stripComments(read("motion.css"));
276+
it("`const EXIT_MS` 与 `[data-phase=\"exit\"]` 的 `transition-duration` token 逐条相等", () => {
277+
expect(DURATION_PAIRS.length).toBeGreaterThanOrEqual(2);
278+
for (const [mod, css] of DURATION_PAIRS) {
279+
const constant = /const EXIT_MS = (\d+);/.exec(read(mod));
280+
expect(constant, `${mod} 里找不到 \`const EXIT_MS = <n>;\`(改名请同步本表)`).not.toBeNull();
281+
const clean = stripComments(read(css));
282+
const at = clean.indexOf('[data-phase="exit"]');
283+
expect(at, `${css} 缺少退场相位规则`).toBeGreaterThanOrEqual(0);
284+
const decl = /transition-duration:\s*var\(--(ed-dur-[a-z-]+),\s*(\d+)ms\)/.exec(clean.slice(at, clean.indexOf("}", at)));
285+
expect(decl, `${css} 的退场规则必须写 \`transition-duration: var(--ed-dur-…, <n>ms)\``).not.toBeNull();
286+
const jsMs = Number(constant?.[1]);
287+
expect(jsMs, `${mod} 的 EXIT_MS 与 ${css} 的兜底字面量不一致(漂移不报错,只表现为截断或残留)`).toBe(Number(decl?.[2]));
288+
expect(motion, `motion.css 的 --${decl?.[1]} 定值必须等于 ${jsMs}ms`).toContain(`--${decl?.[1]}: ${jsMs}ms;`);
289+
}
290+
});
291+
});

0 commit comments

Comments
 (0)