Skip to content

Commit fef4a8d

Browse files
committed
feat(motion): 落双基调缓动 token 与具名 ease
1 parent e531a0c commit fef4a8d

8 files changed

Lines changed: 250 additions & 9 deletions

File tree

‎app/scripts/gen-tokens.d.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export interface EasingToken {
7777
/** 批 6 T5 起:动效时长真源(9 条)—— 迁移自 primitives/motion.css 的临时接缝 */
7878
export declare const DURATION_TOKENS: readonly DurationToken[];
7979

80-
/** 批 6 T5 起:缓动真源(今日 1 条;T8 追加双基调两条) */
80+
/** 批 6 T5 起:缓动真源(今日 3 条 = 全站唯一曲线 + T8 的双基调两条) */
8181
export declare const EASING_TOKENS: readonly EasingToken[];
8282

8383
/* 注:合并名册 `MOTION_TOKENS` 只存在于**产物** `src/ui/tokens.gen.ts`(由 `renderTs()` 生成),

‎app/scripts/gen-tokens.mjs‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,21 @@ export const DURATION_TOKENS = [
101101
/**
102102
* 缓动 token(规格 §8.4「`--ease cubic-bezier(0.2,0,0,1)`」)—— 与时长**分开列**:
103103
* 变量名是 `--ed-ease`(没有 `dur-` 段),而时长恒为 `--ed-dur-<name>`,两者的拼法不同名。
104-
* 批 6 的 T8 会在此追加双基调的两条缓动(`data-tone` 的仪器 / 纸),**只许追加、不许改本条**。
104+
* 批 6 的 T8 已在此追加双基调的两条(`data-tone` 的仪器 / 纸,裁决 R3.4)—— **只许追加、第 1 条不许改**。
105+
*
106+
* 双基调的两条(规格 §8.3 的两张面;两侧的同源机理见 `src/motion/tone.ts` 的头注):
107+
* · `ease-instrument`(精密仪器 · 有「读数」的界面:采集 / 复习 / 时间轴 / 到期刻度):GSAP 侧是
108+
* `power3.inOut`,而 CSS 没有 ease 族 ⇒ 这里**只能是 bezier 近似**(微小曲线差本批接受、不引入
109+
* 第二套 ease 定义);值取计划 T8 Step 2 的逐字值 `cubic-bezier(0.4, 0, 0.2, 1)`。
110+
* · `ease-paper`(活的纸 · 有「文字」的界面:笔记 / 会话 / 体系):`power2.out` 的 bezier 近似,
111+
* 与 `engine.ts` 里 `CustomEase.create("ed-paper-bleed", …)` 用**同一组控制点** ⇒ 两侧是同一条曲线。
112+
* 🔴 两条都**不是回弹**(规格 §8.7 明确不做回弹/弹性):单调不减由 `motion/tone.test.ts` 的 101 点判据
113+
* 钉住 —— 改这里的 `value` 时那条判据会红(y 控制点必须满足 `0 ≤ y1 ≤ y2 ≤ 1`)。
105114
*/
106115
export const EASING_TOKENS = [
107116
{ name: "ease", value: "cubic-bezier(0.2, 0, 0, 1)", usage: "全站唯一曲线(规格 §8.4)" },
117+
{ name: "ease-instrument", value: "cubic-bezier(0.4, 0, 0.2, 1)", usage: "双基调·精密仪器 —— power3.inOut 的 bezier 近似" },
118+
{ name: "ease-paper", value: "cubic-bezier(0.215, 0.61, 0.355, 1)", usage: "双基调·活的纸 —— power2.out 的 bezier 近似,与 engine 的具名 ease ed-paper-bleed 同曲线" },
108119
];
109120

110121
/** 非颜色 token:两档共用(规范 §4.2) */
@@ -199,7 +210,7 @@ ${SHADOW_TOKENS.map((t) => ` --ed-${t.name}: ${t.light}; /* ${t.usage} */`).joi
199210
/* 壳层纵向基准(规格 §1 决策 16):顶栏高度 —— 页面用 calc(100vh - var(--ed-nav-h)) 消费 */
200211
--ed-nav-h: ${SCALE_SOURCE.navHeight}px;
201212
202-
/* 动效(规格 §8.4):9 个时长 + 1 个缓动 —— 批 6 从 primitives/motion.css 的临时接缝迁入,值逐字不变 */
213+
/* 动效:9 个时长(规格 §8.4,逐字迁自 primitives/motion.css 的临时接缝)+ 3 个缓动(全站唯一曲线 + 双基调两条) */
203214
${DURATION_TOKENS.map((t) => ` --ed-dur-${t.name}: ${t.ms}ms;`).join("\n")}
204215
${EASING_TOKENS.map((t) => ` --ed-${t.name}: ${t.value};`).join("\n")}
205216
@@ -279,7 +290,7 @@ export interface DurationToken {
279290
}
280291
281292
export interface EasingToken {
282-
/** 不含 \`--ed-\` 前缀(今日只有全站唯一曲线 \`ease\`) */
293+
/** 不含 \`--ed-\` 前缀(第 1 条是全站唯一曲线 \`ease\`;后两条是 T8 追加的双基调落点) */
283294
readonly name: string;
284295
readonly value: string;
285296
readonly usage: string;
@@ -293,7 +304,7 @@ ${durationRows}
293304
/** 时长短名的字面量联合:门面收窄入参用(拼错必须编译期报错,不是运行期静默取不到值) */
294305
export type DurationTokenName = (typeof DURATION_TOKENS)[number]["name"];
295306
296-
/** 缓动曲线(规格 §8.4)—— 批 6 的 T8 会追加双基调的两条,只许追加 */
307+
/** 缓动曲线(规格 §8.4 的全站唯一曲线 + §8.3 双基调的两条)—— 第 1 条恒不可改,后两条由 T8 追加 */
297308
export const EASING_TOKENS = [
298309
${easingRows}
299310
] as const satisfies readonly EasingToken[];

‎app/src/motion/engine.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,14 @@ import { CustomEase } from "gsap/CustomEase";
3939

4040
gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin, CustomEase);
4141

42+
/**
43+
* 双基调的**具名 ease**(裁决 R3.4 · 规格 §8.3 的「活的纸」/「洇开」曲线,计划 T8 Step 3)。
44+
* 控制点与 CSS 侧 `--ed-ease-paper` **同值**(`0.215,0.61,0.355,1` = `power2.out` 的 bezier 近似)
45+
* ⇒ 两侧不只是共享「不是回弹」这一性质,而是**同一条曲线**(回弹/过冲会被 `tone.test.ts` 的
46+
* 101 点单调性判据拦下)。名字与 `motion/tone.ts` 的 `TONE_EASE_NAME.paper` 逐字对应 ——
47+
* 两边分叉时那条判据当场红(`parseEase` 对未注册名**静默**返回 `undefined`)。
48+
* ⚠️ 必须在 `registerPlugin(CustomEase)` **之后**:未注册时 `CustomEase.create` 进不了 GSAP 的 ease 表。
49+
*/
50+
CustomEase.create("ed-paper-bleed", "M0,0 C0.215,0.61 0.355,1 1,1");
51+
4252
export { gsap, useGSAP };

‎app/src/motion/tone.test.ts‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context tone.test.ts — 双基调(`data-tone`)与两条缓动落点的**行为级契约**
4+
* (规格 §8.3 · ADR-035 · 裁决 R3.4 · 计划 T8 的 V1–V4 + 派发书追加的两条)。
5+
*
6+
* Why 每条判据都在这里(编号 ↔ 计划 V 号;每条各带专属变异体,读数见 task-8-report.md):
7+
* V1 **token 名 + 生成物定值**:`toneEaseVar` 的逐字变量名与生成物 `tokens.css` 里的定值必须**同时**成立
8+
* ——只改一侧时落点会静默取不到值(未定义的 CSS 变量不报错,属性被丢弃)。
9+
* V2 **具名 ease 真注册**:未 `registerPlugin(CustomEase)` / 未 `CustomEase.create` 时
10+
* `gsap.parseEase("ed-paper-bleed")` **静默返回 `undefined`**(只有一条 stderr 警告、动画照跑默认曲线)。
11+
* V3 「**不是回弹**」= 单调不减(本任务最有牙的一条,§8.7 明确不做回弹):两侧都取样 ——
12+
* GSAP 侧 101 点逐点单调 + 端点 `f(0)=0 / f(1)=1`;CSS 侧 bezier 的 y 控制点落在 `[0,1]` 且 `y1 ≤ y2`
13+
* (这正是 cubic-bezier 单调不减的充分条件)⇒ 把「带惯性沉降、不是回弹」从形容词变成可测性质。
14+
* V4 **取值域闭合**:`MOTION_TONES` 与两张表的键集**逐序数组相等**(R14.7 I-2:集合/数量相等测不出纯换序)。
15+
* V5 **每个基调都能被落点声明**:DOM 侧 `data-tone` 往返 + 两条落点(CSS var / GSAP ease)都可解析。
16+
* V6 **reduced-motion 下不产生运动**:import 期 tween 数 0、声明基调不写 inline style、查曲线不改读数
17+
* (§8.6.1 第 4 条;真正的「跳过」由落点在波 B/C 兑现,本层只承诺**自己不产生运动**)。
18+
*
19+
* 判据纪律(R8.1/R8.3):断言全同步;**不**用 `gsap.updateRoot` / `ticker.tick` / `ticker.sleep` / `await sleep`;
20+
* **不**把 jsdom 的 `performance` 挂到 `globalThis`;GSAP 家族的**静态** import 只许在 `motion/engine.ts`
21+
* ⇒ 本文件经 `await import("./engine")` 取引擎(同 `engine.test.ts` 的口径)。
22+
* 副作用:只读磁盘(生成物)+ 给 `window.matchMedia` 打桩 + 建**游离**元素(不挂进 document,不产生动画)。
23+
* 边界:① 本文件**不**判 `data-tone` 的落点接线(那是波 B/C)⇒ 今日 `app/src` 生产代码里 `data-tone` 仍 0 命中;
24+
* ② `CustomEase` 的曲线控制点是**手感参数**,本文件只保证「单调不减 + 端点正确」,**不保证视觉正确**。
25+
*/
26+
import { describe, expect, it } from "vitest";
27+
import { EASING_TOKENS, renderAll } from "../../scripts/gen-tokens.mjs";
28+
import { MOTION_TONES, TONE_EASE_NAME, TONE_EASE_TOKEN, toneEase, toneEaseVar } from "./tone";
29+
30+
const CSS = renderAll().css;
31+
/** 101 个采样点(t = 0, 0.01, …, 1)—— 计划 V3 的逐字口径 */
32+
const SAMPLES = 101;
33+
34+
/** 从生成物里抓某条缓动 token 的 bezier 四元组;抓不到 = 仪器失效,**抛**而不是静默通过。 */
35+
function bezierOf(token: string): number[] {
36+
const m = new RegExp(`--ed-${token}:\\s*cubic-bezier\\(([^)]+)\\);`).exec(CSS);
37+
if (m === null || m[1] === undefined) throw new Error(`生成物里没有 --ed-${token} 的 cubic-bezier 定值`);
38+
return m[1].split(",").map((s) => Number(s.trim()));
39+
}
40+
41+
/** `matchMedia` 桩:R3.2 要求实现 legacy 的 `addListener`/`removeListener`(GSAP 走 legacy 分支)。 */
42+
function stubMatchMedia(matches: boolean): void {
43+
Object.defineProperty(window, "matchMedia", {
44+
configurable: true,
45+
writable: true,
46+
value: (query: string) => ({
47+
matches,
48+
media: query,
49+
onchange: null,
50+
addListener: () => {},
51+
removeListener: () => {},
52+
addEventListener: () => {},
53+
removeEventListener: () => {},
54+
dispatchEvent: () => false,
55+
}),
56+
});
57+
}
58+
59+
/** 变量名归一:由 `toneEaseVar` 的产物剥掉 CSS 包装(不另写一份真源) */
60+
const varName = (tone: (typeof MOTION_TONES)[number]): string => toneEaseVar(tone).replace(/^var\(|\)$/g, "");
61+
62+
describe("双基调:基调登记表与两条缓动落点", () => {
63+
it("V6 reduced-motion 下不产生运动(import 期 0 tween · 零 inline style · 查曲线不改读数)", async () => {
64+
// 桩必须在**首次** import engine 之前生效 ⇒ 本文件把它排成第一例(模块缓存后重 import 是空操作)
65+
stubMatchMedia(true);
66+
const { gsap } = await import("./engine");
67+
68+
expect(gsap.globalTimeline.getChildren(), "本层在 import 期就建了 tween ⇒ 违反「reduce 下整体静态」").toHaveLength(0);
69+
const els = MOTION_TONES.map((tone) => {
70+
const el = document.createElement("div");
71+
el.setAttribute("data-tone", tone);
72+
return el;
73+
});
74+
expect(els.map((el) => el.getAttribute("style")), "声明基调这个动作不得写任何 inline style").toEqual(
75+
MOTION_TONES.map(() => null),
76+
);
77+
78+
let calls = 0;
79+
for (let i = 0; i < SAMPLES; i += 1) {
80+
for (const tone of MOTION_TONES) {
81+
toneEase(tone);
82+
calls += 1;
83+
}
84+
}
85+
expect(calls, "采样循环没跑满 ⇒ 上面那条是空真").toBe(SAMPLES * MOTION_TONES.length);
86+
expect(gsap.globalTimeline.getChildren(), "查询曲线这个只读动作竟然建了 tween").toHaveLength(0);
87+
});
88+
89+
it("V1 两条缓动 token 的变量名逐字正确,且都在生成物里有非空定值", () => {
90+
// 期望值写成**拼接**形态:`motionTokens.consumption.test.ts` 会全树扫 CSS 变量消费点并要求同值兜底,
91+
// 这里是断言里的期望值(不是消费点),拼接可让那台扫描器不误判本行。
92+
expect(toneEaseVar("instrument")).toBe("var" + "(--ed-ease-instrument)");
93+
expect(toneEaseVar("paper")).toBe("var" + "(--ed-ease-paper)");
94+
95+
const declared = EASING_TOKENS.map((t) => `--ed-${t.name}`);
96+
for (const tone of MOTION_TONES) {
97+
const name = varName(tone);
98+
expect(declared, `${name} 不在生成器真源 EASING_TOKENS 里(加了落点却没进真源)`).toContain(name);
99+
const m = new RegExp(`${name}:\\s*([^;]+);`).exec(CSS);
100+
expect(m?.[1], `${name} 在生成物 tokens.css 里没有定值`).toBeTruthy();
101+
expect(m?.[1] ?? "", `${name} 的定值里出现 undefined 串 ⇒ 真源那条漏了 value`).not.toContain("undefined");
102+
}
103+
});
104+
105+
it("V2 两个基调的具名 ease 都已在 engine 注册(paper = CustomEase 的具名 ease)", async () => {
106+
const { gsap } = await import("./engine");
107+
expect(TONE_EASE_NAME.instrument, "仪器的 GSAP 侧是内置曲线").toBe("power3.inOut");
108+
expect(TONE_EASE_NAME.paper, "纸的 GSAP 侧是 engine 里 CustomEase.create 的具名 ease").toBe("ed-paper-bleed");
109+
for (const tone of MOTION_TONES) {
110+
const name = TONE_EASE_NAME[tone];
111+
expect(typeof gsap.parseEase(name), `"${name}" 没进 GSAP 的 ease 表(CustomEase 未注册 ⇒ 静默退化)`).toBe(
112+
"function",
113+
);
114+
}
115+
});
116+
117+
it("V3 「不是回弹」= 单调不减:GSAP 侧 101 点单调 + 端点;CSS 侧 bezier 的 y 落在 [0,1] 且 y1 ≤ y2", async () => {
118+
const { gsap } = await import("./engine");
119+
for (const tone of MOTION_TONES) {
120+
const name = TONE_EASE_NAME[tone];
121+
const ease = gsap.parseEase(name) as ((t: number) => number) | undefined;
122+
expect(typeof ease, `${name} 没注册 ⇒ 下面三条会变成空真`).toBe("function");
123+
const ys = Array.from({ length: SAMPLES }, (_, i) => (ease as (t: number) => number)(i / (SAMPLES - 1)));
124+
const drops = ys.flatMap((y, i) =>
125+
i > 0 && y < (ys[i - 1] ?? 0) - 1e-9 ? [`t=${(i / (SAMPLES - 1)).toFixed(2)}: ${ys[i - 1]} → ${y}`] : [],
126+
);
127+
expect(drops, `${name} 的采样序列出现下降 ⇒ 是回弹/过冲(§8.7 明确不做)`).toEqual([]);
128+
expect(ys[0], `${name} 的 f(0)`).toBeCloseTo(0, 6);
129+
expect(ys[SAMPLES - 1], `${name} 的 f(1)`).toBeCloseTo(1, 6);
130+
}
131+
132+
for (const tone of MOTION_TONES) {
133+
const [x1, y1, x2, y2] = bezierOf(TONE_EASE_TOKEN[tone]);
134+
const at = `--ed-${TONE_EASE_TOKEN[tone]}`;
135+
expect(x1 >= 0 && x1 <= 1, `${at} 的 x1=${x1} 越界(CSS 要求 x ∈ [0,1])`).toBe(true);
136+
expect(x2 >= 0 && x2 <= 1, `${at} 的 x2=${x2} 越界(CSS 要求 x ∈ [0,1])`).toBe(true);
137+
expect(
138+
(y1 ?? 0) >= 0 && (y1 ?? 0) <= (y2 ?? 0) && (y2 ?? 0) <= 1,
139+
`${at} 的 y 控制点 ${y1}/${y2} 不满足 0 ≤ y1 ≤ y2 ≤ 1 ⇒ 曲线可能过冲(回弹形状)`,
140+
).toBe(true);
141+
}
142+
});
143+
144+
it("V4 基调取值域闭合:两张表的键集与 MOTION_TONES 逐序数组相等(纯换序也必须红)", () => {
145+
expect([...MOTION_TONES]).toEqual(["instrument", "paper"]);
146+
expect(Object.keys(TONE_EASE_TOKEN), "TONE_EASE_TOKEN 的键集/顺序与基调集合不一致").toEqual([...MOTION_TONES]);
147+
expect(Object.keys(TONE_EASE_NAME), "TONE_EASE_NAME 的键集/顺序与基调集合不一致").toEqual([...MOTION_TONES]);
148+
});
149+
150+
it("V5 每个基调都能被落点声明:data-tone 往返 + 两条落点都可解析", async () => {
151+
const { gsap } = await import("./engine");
152+
for (const tone of MOTION_TONES) {
153+
const el = document.createElement("div");
154+
el.setAttribute("data-tone", tone);
155+
expect(el.getAttribute("data-tone"), "落点声明的载体逐字是 data-tone").toBe(tone);
156+
expect(el.dataset.tone, "dataset 往返:落点写得出、读得回").toBe(tone);
157+
expect(el.getAttribute("style"), "声明基调本身不得写 inline style").toBeNull();
158+
159+
expect(
160+
new RegExp(`${varName(tone)}:\\s*cubic-bezier\\(`).test(CSS),
161+
`${tone} 的 CSS 侧落点 ${varName(tone)} 在生成物里没有 cubic-bezier 定值`,
162+
).toBe(true);
163+
expect(typeof gsap.parseEase(toneEase(tone)), `${tone} 的 GSAP 侧落点未注册`).toBe("function");
164+
}
165+
});
166+
});

‎app/src/motion/tone.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* tone.ts — 双基调(`data-tone`)的**唯一登记表**与两条缓动落点(规格 §8.3 · ADR-035 · 裁决 R3.4)。
3+
*
4+
* @ai-context 业务背景:本仓的动效分两张面 —— ①**精密仪器**(有「读数」的界面:采集 / 复习 / 时间轴 /
5+
* 到期刻度)用 `power3.inOut` 族:匀速段更长、沿轴线、带刻度感;②**活的纸**(有「文字」的界面:
6+
* 笔记 / 会话 / 体系)用 `power2.out` / 自定义「洇开」曲线:带**惯性沉降**(减速到停),**不是回弹**。
7+
* 载体逐字 = `data-tone` 属性(取值 `"instrument" | "paper"`,**每个动效落点显式声明**);
8+
* 🔴 **禁止**用类名区分基调 —— 那会与既有 `ed-*` 类名空间冲突。本层只**定义**基调与缓动,
9+
* 落点接线(把 `data-tone` 写进 DOM)在波 B/C。
10+
* @ai-context CSS 侧与 GSAP 侧的**同源机理**:CSS 只有 `cubic-bezier(...)`(真源 = 生成物 `tokens.css` 的
11+
* `--ed-ease-instrument` / `--ed-ease-paper` 两条,由 `scripts/gen-tokens.mjs` 的 `EASING_TOKENS` 渲染),
12+
* GSAP 侧是 `CustomEase.create("ed-paper-bleed", …)`(在 `engine.ts` 注册)——「活的纸」两侧是**同一条**
13+
* bezier(`0.215,0.61,0.355,1`,即 `power2.out` 的 bezier 近似);「精密仪器」两侧只是**近似**
14+
* (CSS 没有 GSAP 的 ease 族,`power3.inOut` 用 `cubic-bezier(0.4,0,0.2,1)` 近似 —— 微小曲线差本批接受,
15+
* 且**不引入第二套 ease 定义**)。两侧共享的**硬性质** = 「不是回弹」(单调不减、无过冲、无二次反方向),
16+
* 由 `tone.test.ts` 的 101 点单调性判据钉住 —— 那是本任务最有牙的一条。
17+
*
18+
* 副作用:无 —— 纯数据 + 纯函数,**不 import GSAP**(故本模块可被首屏静态引用,不进 `vendor-gsap` 懒 chunk)。
19+
* 边界:① `toneEase()` 返回的是**实参**(内置名或已注册的具名 ease),它**不注册**任何东西 —— 注册在
20+
* `engine.ts`(`CustomEase` 在那里 `registerPlugin` + `create`)⇒ 取用前必须先 import engine,否则
21+
* `gsap.parseEase("ed-paper-bleed")` **静默返回 `undefined`**(只有一条 stderr 警告、动画照跑默认 ease)。
22+
* ② 本层**不产生运动**:没有 tween / timeline 工厂;`prefers-reduced-motion: reduce` 时是否跳过由**落点**
23+
* 决定(规格 §8.6.1 第 4 条「『活』不得以无障碍为代价」),本层只提供基调名与曲线。
24+
*/
25+
export const MOTION_TONES = ["instrument", "paper"] as const;
26+
export type MotionTone = (typeof MOTION_TONES)[number];
27+
28+
/** token 名(不含 `--ed-` 前缀)—— `tokens.css` 里由生成器的 `EASING_TOKENS` 定义 */
29+
export const TONE_EASE_TOKEN: Readonly<Record<MotionTone, string>> = {
30+
instrument: "ease-instrument",
31+
paper: "ease-paper",
32+
};
33+
34+
/** GSAP 侧具名 ease:`power3.inOut` 是内置;`ed-paper-bleed` 由 `engine.ts` 的 `CustomEase.create` 注册 */
35+
export const TONE_EASE_NAME: Readonly<Record<MotionTone, string>> = {
36+
instrument: "power3.inOut",
37+
paper: "ed-paper-bleed",
38+
};
39+
40+
/** CSS 侧落点:`transition-timing-function: toneEaseVar(tone)` ⇒ 例 `var(` + `--ed-ease-instrument)` */
41+
export function toneEaseVar(tone: MotionTone): string {
42+
return `var(--ed-${TONE_EASE_TOKEN[tone]})`;
43+
}
44+
45+
/** GSAP 侧落点:`gsap.to(el, { ease: toneEase(tone) })` */
46+
export function toneEase(tone: MotionTone): string {
47+
return TONE_EASE_NAME[tone];
48+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ describe("动效 token 真源契约(批 6 起临时接缝已迁走 ⇒ 钉唯
185185
for (const [name, value] of expected) expect(source).toContain(`${name}: ${value};`);
186186
// 真源侧计数:把「一个不多一个不少」钉在生成器的名单上(不再钉临时接缝)
187187
expect(DURATION_TOKENS).toHaveLength(9);
188-
expect(EASING_TOKENS).toHaveLength(1);
188+
expect(EASING_TOKENS.map((t) => t.name), "缓动真源追加须同步本条").toEqual(["ease", "ease-instrument", "ease-paper"]); // G18(T8):1 → 3 条,逐序数组相等(R14.7 I-2;净增 0 行)
189189
});
190190

191191
it("规格 §8.4:原语 CSS 的位移一律 ≤ 8px(8px 是注释不变量,本条是它的机器判据)", () => {

0 commit comments

Comments
 (0)