Skip to content

Commit 4d60663

Browse files
committed
feat(motion): 加位移上限唯一出口与扩域守卫
1 parent 488cb53 commit 4d60663

5 files changed

Lines changed: 273 additions & 1 deletion

File tree

‎app/src/motion/shift.guard.test.ts‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* @ai-context shift.guard.test.ts — 位移上限 8px 的**扩域守卫**(批 6 T9 · R11.4 · 计划 V1/V4b)。
3+
*
4+
* Why 单独成件:计划 Step 2 要「把 `style-seams.test.ts` 的位移判据域从 `primitives/*.css` 扩到全仓」,
5+
* 而该文件在 T5 之后已 **297 行**(余 3)⇒ 扩域要的遍历器与名单跟着挤不进 300 行。计划 Files 表因此
6+
* 预置了本文件(`app/src/motion/shift.guard.test.ts`)作为**拆出去的落点**;`style-seams.test.ts` 只留
7+
* 一条 2 行的常量对拍(防「两边各写一个 8」)。两处判据的**正则与换算逻辑逐字同源**(原样抄自
8+
* `style-seams.test.ts` 的 `shiftViolations`),本件的域**严格包含**那一处的域。
9+
*
10+
* 判据域(R11.4「裁决域 = 只判 `translate*()` + `motion/` 层的位移常量」):
11+
* ① `app/src/**` 的**生产** `.css` / `.ts` / `.tsx`(剥注释后)的 `translate*()` 数值实参 ≤ 8px;
12+
* ② `app/src/motion/**` 的**生产**模块里 `*_PX` 具名位移常量 ≤ 8px。
13+
* 🔴 **测试文件(`*.test.ts` / `*.test.tsx`)不在域内** —— 实测依据:R8.1 的「唯一正解」harness 用
14+
* **大位移样本**做测量(`motion.md` 的逐字例子是 `x: 0 → 100` ⇒ `translate3d(87.5px,0px,0px)`),
15+
* T3 的 `engine.test.ts` 就断言 `translate3d(50px, 0px, 0px)` ⇒ 把测试文件拉进域会让本判据**当场红在
16+
* 别人的已提交文件上**,而那是**测量样本**不是交付的动效(§8.4 管的是后者)。⇒ 逐条登记在报告里。
17+
* ③ `margin` / `left` / `top` 的 >8px **不判**(R11.4:实测全是布局量,判它们会造假阳性);
18+
* `%` / `calc()` / `var()` 形态**有意跳过**(居中与扫光,不是位移量 —— 既有口径逐字保留)。
19+
*
20+
* 副作用:只读磁盘(整棵 `app/src`);不写任何文件。
21+
* 边界:① 剥注释复用共享仪器 `ui/primitives/sliceScan.ts` 的状态机(R8.7 的正解先例;同批 T4 的
22+
* `engine.guard.test.ts` 同款用法);② **文本级扫描,不做 AST** —— 它只能证「字面量在不在」,
23+
* 不证实现是否真的遵守(评审 I-4 的那条限度);③ 未按 `*_PX` 命名的位移常量(如 `CARD_LIFT = 12`)
24+
* 本判据抓不到,只由评审兜 —— **诚实登记,不声称封死**。
25+
*/
26+
import { readdirSync, readFileSync, statSync } from "node:fs";
27+
import { dirname, join } from "node:path";
28+
import { fileURLToPath } from "node:url";
29+
import { describe, expect, it } from "vitest";
30+
import { relOf, stripComments } from "../ui/primitives/sliceScan";
31+
import { SHIFT_MAX_PX } from "./shift";
32+
33+
const SRC_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); // app/src
34+
const MOTION_ROOT = join(SRC_ROOT, "motion");
35+
const DOMAIN_EXT = /\.(?:css|ts|tsx)$/;
36+
const TEST_FILE = /\.test\.tsx?$/;
37+
const SOURCE_FILE = /\.tsx?$/;
38+
39+
/** 递归收集域内文件(生产面;`.d.ts` 之类也照扫 —— 它们不产位移,扫了无害) */
40+
function walk(dir: string, out: string[] = []): string[] {
41+
for (const name of readdirSync(dir)) {
42+
const path = join(dir, name);
43+
if (statSync(path).isDirectory()) walk(path, out);
44+
else if (DOMAIN_EXT.test(name) && !TEST_FILE.test(name)) out.push(path);
45+
}
46+
return out;
47+
}
48+
49+
/** 既有口径(`style-seams.test.ts` 原样):`translate*()` 的全部调用;百分比 / `calc()` / `var()` 由数值判定跳过 */
50+
const TRANSLATE_CALL = /translate(?:3d|X|Y|Z)?\(([^)]*)\)/g;
51+
const NUMERIC_ARG = /^\s*(-?\d+(?:\.\d+)?)(px|rem)\s*$/;
52+
53+
/** 一条 `translate*()` 调用里的**数值实参**(px / rem;rem 按 16 换算,与既有口径一致) */
54+
interface ShiftSite {
55+
readonly call: string;
56+
readonly value: number;
57+
}
58+
59+
function shiftSites(text: string): ShiftSite[] {
60+
const out: ShiftSite[] = [];
61+
for (const m of stripComments(text).matchAll(TRANSLATE_CALL)) {
62+
for (const arg of m[1].split(",")) {
63+
const px = NUMERIC_ARG.exec(arg);
64+
if (!px) continue;
65+
out.push({ call: m[0], value: px[2] === "rem" ? Math.abs(Number(px[1])) * 16 : Math.abs(Number(px[1])) });
66+
}
67+
}
68+
return out;
69+
}
70+
71+
/** 计划 V1 的判据体:域内逐文件找 > `SHIFT_MAX_PX` 的位移量,返回可读的犯规清单 */
72+
function shiftViolations(text: string, file: string): string[] {
73+
return shiftSites(text)
74+
.filter((s) => s.value > SHIFT_MAX_PX)
75+
.map((s) => `${file}: ${s.call} = ${s.value}px > ${SHIFT_MAX_PX}px`);
76+
}
77+
78+
/** `export const <name> = <number>` 形态的数值常量(R11.4 的「位移常量」面) */
79+
const NUMERIC_CONST = /export const ([A-Za-z_$][\w$]*)\s*=\s*(-?\d+(?:\.\d+)?)\s*;/g;
80+
const PX_NAME = /_PX$/;
81+
82+
interface NumericConst {
83+
readonly name: string;
84+
readonly value: number;
85+
}
86+
87+
function numericConstants(text: string, onlyPxNames: boolean): NumericConst[] {
88+
const out: NumericConst[] = [];
89+
for (const m of stripComments(text).matchAll(NUMERIC_CONST)) {
90+
if (onlyPxNames && !PX_NAME.test(m[1])) continue;
91+
out.push({ name: m[1], value: Number(m[2]) });
92+
}
93+
return out;
94+
}
95+
96+
const readText = (file: string): string => readFileSync(file, "utf8");
97+
98+
describe("V1 全仓生产文件的 `translate*()` 数值实参 ≤ 8px(R11.4 的扩域半)", () => {
99+
it("域内生产文件逐条扫(实测 330 件),>8px 的位移量为 0(剥注释后)", () => {
100+
const files = walk(SRC_ROOT);
101+
const rels = files.map((f) => relOf(SRC_ROOT, f));
102+
// 防空扫:遍历器必须看得见这几件(含动效宿主样式表与本模块自身),否则下面的 `[]` 一文不值
103+
expect(files.length, "扫描域不该缩到空(T9 实测生产面 330 件)").toBeGreaterThanOrEqual(300);
104+
for (const known of ["main.tsx", "ui/primitives/motion.css", "motion/shift.ts"]) {
105+
expect(rels, `域内必须含 ${known}(遍历器静默失效时本判据会变恒真)`).toContain(known);
106+
}
107+
108+
const violations = files.flatMap((f) => shiftViolations(readText(f), relOf(SRC_ROOT, f)));
109+
expect(violations, `全仓位移超过规格 §8.4 的 ${SHIFT_MAX_PX}px 上限:\n${violations.join("\n")}`).toEqual([]);
110+
});
111+
112+
it("域收窄的守门:测试文件一个都不许进域(否则 R8.1 的测量样本会当场假红)", () => {
113+
const leaked = walk(SRC_ROOT)
114+
.map((f) => relOf(SRC_ROOT, f))
115+
.filter((f) => TEST_FILE.test(f));
116+
expect(leaked, "测试文件被拉进域 ⇒ `engine.test.ts` 实测的 50px 测量样本会假红(域收窄理由见文件头)").toEqual([]);
117+
});
118+
119+
it("口径锚:>8px 命中 / =8px 不命中 / `%`·`var()` 跳过 / 注释里的字面量不算命中 / rem 按 16 换算", () => {
120+
// 犯规样本用拼接写(本文件文本里不出现完整犯规字面量 —— 防「全文件文本扫描」型守卫的误伤)
121+
const bad = "transform: rotate(0deg) translateY(" + "12px)";
122+
expect(shiftViolations(bad, "synth.css")).toEqual(["synth.css: translateY(12px) = 12px > 8px"]);
123+
124+
expect(shiftViolations("transform: translateY(" + "8px)", "synth.css"), "8px 恰在上限上(`>` 不是 `>=`)").toEqual([]);
125+
expect(shiftViolations("transform: translate(-50%, -50%)", "synth.css"), "居中技巧不是位移量").toEqual([]);
126+
expect(shiftViolations("transform: translateX(var(--ed-x, 4px))", "synth.css"), "var() 不是字面量").toEqual([]);
127+
128+
const hidden = "/* translateY(" + "12px) */\n.x { transform: translateY(" + "1px); }";
129+
expect(hidden, "锚自身必须含犯规样本,否则它在测空气").toContain("translateY(");
130+
expect(shiftViolations(hidden, "synth.css"), "剥注释失效 ⇒ 本守卫会在注释上假红").toEqual([]);
131+
132+
expect(shiftViolations("transform: translateY(0.6rem)", "synth.css"), "0.6rem = 9.6px 应被判红").toHaveLength(1);
133+
expect(shiftViolations("transform: translateY(0.5rem)", "synth.css"), "0.5rem = 8px 不判").toEqual([]);
134+
});
135+
});
136+
137+
describe("V4b `motion/` 层的位移常量 ≤ 8px(R11.4 的第二半)", () => {
138+
it("`motion/**` 生产模块的 `*_PX` 具名常量逐条 ≤ SHIFT_MAX_PX", () => {
139+
const files = walk(MOTION_ROOT).filter((f) => SOURCE_FILE.test(f));
140+
const consts = files.flatMap((f) =>
141+
numericConstants(readText(f), true).map((c) => ({ file: relOf(SRC_ROOT, f), ...c })),
142+
);
143+
// 防空扫:本层的位移出口常量必须在这份名单里(否则是遍历器/正则失效)
144+
expect(consts.map((c) => c.name), "`motion/` 层的 `*_PX` 名单里必须有唯一出口常量").toContain("SHIFT_MAX_PX");
145+
for (const c of consts) {
146+
expect(c.value, `${c.file}: ${c.name} = ${c.value} 越过 §8.4 的 ${SHIFT_MAX_PX}px`).toBeLessThanOrEqual(SHIFT_MAX_PX);
147+
}
148+
});
149+
150+
it("`shift.ts` 自身的**全部**导出数值常量 ≤ 8px(不只 `_PX` 命名面 —— 它是位移模块,时长不该住这)", () => {
151+
const consts = numericConstants(readText(join(MOTION_ROOT, "shift.ts")), false);
152+
expect(consts.map((c) => c.name)).toContain("SHIFT_MAX_PX");
153+
for (const c of consts) {
154+
expect(c.value, `motion/shift.ts: ${c.name} = ${c.value} 越过 §8.4 的 ${SHIFT_MAX_PX}px`).toBeLessThanOrEqual(SHIFT_MAX_PX);
155+
}
156+
});
157+
158+
it("口径锚:合成的 `_PX` 常量 —— >8px 必须被抓到、=8px 不抓、非 `_PX` 名在 `_PX` 口径下不抓", () => {
159+
const bad = "export const CARD_LIFT_PX = " + "12;";
160+
const ok = "export const SHIFT_MAX_PX = " + "8;";
161+
const other = "export const STAGGER_MS = " + "60;";
162+
expect(numericConstants(bad, true).map((c) => c.value)).toEqual([12]);
163+
expect(numericConstants(ok, true).map((c) => c.value)).toEqual([8]);
164+
expect(numericConstants(other, true), "`_PX` 口径只收位移常量(毫秒常量不在此列)").toEqual([]);
165+
expect(numericConstants(other, false).map((c) => c.value), "`shift.ts` 全量口径收得下它").toEqual([60]);
166+
});
167+
});

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* @ai-context shift.test.ts — 位移唯一出口 `shift.ts` 的**行为级判据**(批 6 T9 · 计划 V2/V3/V4)。
3+
*
4+
* Why:`SHIFT_MAX_PX` / `clampShift` / `ANIMATABLE_PROPERTIES` 是波 C(T12/T27/T31)所有位移 tween 的公共
5+
* 入口 —— 入口的**行为**(夹取边界、NaN 口径、白名单内容)必须逐条可红,否则「8px 上限」只是一句注释。
6+
* 每条判据的专属变异体见 `task-9-report.md`(M2 上界 / M2b NaN / M2c 下界 / M3 常量 / M4 白名单)。
7+
* 副作用:无(纯函数;**node 环境**,本文件不加 jsdom 头)。
8+
* 边界:`clampShift` **不能**挡住绕过本模块的裸 `gsap.to(el, { y: 40 })`(`shift.ts` 边界②);
9+
* 本文件判的是入口自身的行为,**不判**调用方是否真的走了入口(那是 `shift.guard.test.ts` 与评审的事)。
10+
*/
11+
import { describe, expect, it } from "vitest";
12+
import { ANIMATABLE_PROPERTIES, SHIFT_BANNED_PROPERTIES, SHIFT_MAX_PX, clampShift } from "./shift";
13+
14+
describe("clampShift:§8.4 的 8px 夹取(上下界与 NaN 各有专属判据)", () => {
15+
it("上界:0 / 7.5 / 8 原样;9 与 ±Infinity 夹到 ±8(断言写裸 8 ⇒ 常量被改也红)", () => {
16+
expect(clampShift(0)).toBe(0);
17+
expect(clampShift(7.5)).toBe(7.5);
18+
expect(clampShift(8)).toBe(8);
19+
expect(clampShift(9)).toBe(8);
20+
expect(clampShift(Number.POSITIVE_INFINITY)).toBe(8);
21+
expect(SHIFT_MAX_PX).toBe(8);
22+
});
23+
24+
it("下界:-8 原样、-9 / -Infinity 夹到 -8(只守上界会在反向动效上漏)", () => {
25+
expect(clampShift(-8)).toBe(-8);
26+
expect(clampShift(-9)).toBe(-8);
27+
expect(clampShift(Number.NEGATIVE_INFINITY)).toBe(-8);
28+
});
29+
30+
it("NaN ⇒ 抛(不静默归零:NaN 意味着上游解析失败,归零会把它伪装成「合法的不动」)", () => {
31+
expect(() => clampShift(Number.NaN)).toThrow(/NaN/);
32+
});
33+
});
34+
35+
describe("ANIMATABLE_PROPERTIES:R8.4 的属性白名单(T11 的属性集合审计消费它)", () => {
36+
it("逐序等于 R8.4 的逐字集合(**逐序数组相等** —— 换序同样红,集合相等测不出换序)", () => {
37+
expect(ANIMATABLE_PROPERTIES).toEqual(["transform", "translate", "rotate", "scale", "opacity", "filter"]);
38+
});
39+
40+
it("不含任何 layout 属性(`width` / `height` / `margin` / `left` / `top` 逐个反例)", () => {
41+
for (const banned of ["width", "height", "margin", "left", "top"]) {
42+
expect(ANIMATABLE_PROPERTIES, `${banned} 是 layout 属性,动它会触发排版`).not.toContain(banned);
43+
}
44+
});
45+
46+
it("两张表不相交,且 SHIFT_BANNED_PROPERTIES 覆盖 R11.4 点名的三个布局量", () => {
47+
expect(ANIMATABLE_PROPERTIES.filter((p) => SHIFT_BANNED_PROPERTIES.includes(p))).toEqual([]);
48+
for (const prop of ["margin", "left", "top"]) expect(SHIFT_BANNED_PROPERTIES).toContain(prop);
49+
});
50+
});

‎app/src/motion/shift.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @ai-context shift.ts — **位移的唯一出口**(批 6 T9;规格 §8.4(:518)末句「位移上限 8px」)。
3+
*
4+
* Why 单独成件:§8.4 的 8px 是**硬上限**,而批 6 的编排层(对齐 / 显影 / 相变 / 回跳 / 浮现…)逐个都要写位移
5+
* 参数 —— 各写各的必然在 6 个签名动效之间逐处漂移。`ui/primitives/motion.css` 亦逐字要求「批 6 的 GSAP
6+
* 时间线同样不得越过该上限」。⇒ 位移量**只**从本模块取,由 `app/src/motion/shift.guard.test.ts` 的机器判据
7+
* 守两半:① 全仓 `app/src/**` **生产文件**的 `translate*()` 数值实参 ≤ 8px;② `motion/**` 的位移常量 ≤ 8px。
8+
*
9+
* 副作用:无(纯常量 + 纯函数,不碰 DOM / 存储 / IPC)。
10+
* 边界:
11+
* ① 它是**上界**不是目标值 —— 编排层的「远」靠**时长与错开**表达,不靠位移;
12+
* ② `clampShift` 是**运行时安全网**,**挡不住**绕过本模块直接 `gsap.to(el, { y: 40 })` 的写法 ——
13+
* 那条路径只由属性集合审计(`ANIMATABLE_PROPERTIES`,R8.4)与评审间接约束,**本模块不声称封死**;
14+
* ③ **布局量不在判据域**(R11.4):`margin` / `left` / `top` 这类 >8px 实测一大片(T9 探针:5 处 px
15+
* 字面量 + 大量行内 `marginTop: 60` 风格值),**全是布局量** ⇒ 判它们会造假阳性;
16+
* 「动画不得动布局属性」由 `ANIMATABLE_PROPERTIES` 单独覆盖;
17+
* ④ `%` 形态(居中 `translate(-50%, -50%)`、扫光 `translateX(±100%)`)**有意不判** —— 它们是居中与扫光,
18+
* 不是位移量(既有口径逐字保留)。
19+
*/
20+
export const SHIFT_MAX_PX = 8;
21+
22+
/**
23+
* 把任意位移量夹进 §8.4 的 ±8px(**运行时**安全网,不是许可证)。
24+
* `NaN` ⇒ **抛**:`NaN` 不是一个位移量,静默归零会把「上游解析失败」伪装成「合法的不动」
25+
* (AGENTS.md §4 明禁忽略错误)。`±Infinity` 是合法的「很大」,照常夹到 ±8。
26+
*/
27+
export function clampShift(px: number): number {
28+
if (Number.isNaN(px)) throw new Error("clampShift: 位移量是 NaN(上游解析失败,不许静默归零)");
29+
return Math.max(-SHIFT_MAX_PX, Math.min(px, SHIFT_MAX_PX));
30+
}
31+
32+
/** 唯一允许被动画化的属性白名单(R8.4 的代理判据常量;**逐序**冻结为 R8.4 的逐字序,layout 属性一律不在内)。 */
33+
export const ANIMATABLE_PROPERTIES: readonly string[] = [
34+
"transform",
35+
"translate",
36+
"rotate",
37+
"scale",
38+
"opacity",
39+
"filter",
40+
];
41+
42+
/** 位移**绝不许**动的布局属性(R11.4:它们不在 8px 判据域内,而是被属性集合审计**整体**排除)。 */
43+
export const SHIFT_BANNED_PROPERTIES: readonly string[] = [
44+
"margin",
45+
"left",
46+
"top",
47+
"right",
48+
"bottom",
49+
"width",
50+
"height",
51+
"padding",
52+
];

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { dirname, join } from "node:path";
2424
import { fileURLToPath } from "node:url";
2525
import { describe, expect, it } from "vitest";
2626
import { SCALE_TOKENS } from "../tokens";
27+
import { SHIFT_MAX_PX as SHIFT_MAX_PX_SOURCE } from "../../motion/shift";
2728
import { DURATION_TOKENS, EASING_TOKENS, renderAll } from "../../../scripts/gen-tokens.mjs";
2829

2930
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -189,6 +190,7 @@ describe("动效 token 真源契约(批 6 起临时接缝已迁走 ⇒ 钉唯
189190
});
190191

191192
it("规格 §8.4:原语 CSS 的位移一律 ≤ 8px(8px 是注释不变量,本条是它的机器判据)", () => {
193+
expect(SHIFT_MAX_PX, "本文件的常量与唯一出口 motion/shift.ts 已分叉(两边各写一个 8)").toBe(SHIFT_MAX_PX_SOURCE);
192194
const files = readdirSync(HERE).filter((f) => f.endsWith(".css"));
193195
// 防空目录把守卫静默关掉(同 zIndex.guard.test.ts 的「名单非空」思路)
194196
expect(files.length, "primitives/ 下应有 Text.css 与 motion.css").toBeGreaterThanOrEqual(2);

‎docs/standards/motion.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
- 规格 §8.4(:518)的时长 / 缓动**只有一个真源** = `app/scripts/gen-tokens.mjs` ⇒ 产物 `app/src/ui/tokens.css` + `app/src/ui/tokens.gen.ts`;`motion.css` **不得**再出现 `:root{}` 的 `--ed-dur-*` / `--ed-ease` 定义(单一真源、绝不双写)。生成物**永不手改**:改 `gen-tokens.mjs` 后**必须**重生成,并**同提交**带上两个产物。
6666
- 既有 10 个变量:`--ed-dur-micro` 120ms · `--ed-dur-overlay-in` / `-out` 200 / 160ms · `--ed-dur-toast-in` / `-out` 180 / 140ms · `--ed-dur-skeleton` 1200ms · `--ed-dur-card` 220ms · `--ed-dur-reveal` 500ms · `--ed-dur-page` 150ms · `--ed-ease` `cubic-bezier(0.2, 0, 0, 1)`(**出场比进场快**)。
6767
- **位移上限 8px**(规格 §8.4(:518)末句):任何动效位移(CSS `translate*()` 实参、GSAP tween 的位移参数)**≤ 8px**;GSAP 侧的位移参数经 `app/src/motion/` 层的**唯一出口**集中定义 —— 编排层的「远」靠**时长与错开**表达,不靠位移。
68-
- 位移**不做成 CSS 变量**:没有消费者 = 死变量(还会给人「已被强制」的错觉)。强制手段是机器判据 —— 扫 `primitives/*.css` 的 `translate*()` 数值实参与 `motion/` 层的位移常量,> 8px 即红。
68+
- 位移**不做成 CSS 变量**:没有消费者 = 死变量(还会给人「已被强制」的错觉)。强制手段是机器判据 —— 扫**全仓** `app/src/**` 的**生产文件**(`.css` / `.ts` / `.tsx`;**测试文件不在域内** —— 那里的 >8px 位移是 R8.1 的**测量样本**,实测 `motion/engine.test.ts` 的 `translate3d(50px, 0px, 0px)`)的 `translate*()` 数值实参,以及 `motion/` 层的位移常量,> 8px 即红(**判据域以裁决 R11.4 为准**:`margin` / `left` / `top` 这类**布局量不判** —— 实测那一类确有 >8px,但**全是布局量**,判它们会造假阳性;「动画不得动布局属性」由 `## 判据纪律` 的**属性集合审计**单独覆盖)。
6969
- **相变 chrome 用绝对定位交叉淡入,不 animate height**;**列折叠内容先淡出 120ms 后宽度瞬跳**(宽度本身不做 transition,除非走 Flip)。
7070

7171
**`@keyframes` 桶边界(硬)**:循环环境动效**只留三处** —— `Loading` / `Skeleton` / `Probe`。`StatusLine` 归 **transition(无循环动画)**:状态行是**信息**不是「呼吸物」,循环动效会让它在长列表里变成噪音(「闲置时也有生命感」由 `Probe` 承担)。新循环动效**只能写进 `motion.css`** —— `Loading.css` / `Button.css` / `StatusLine.css` 三处均被既有守卫禁止新增 keyframes。
@@ -109,6 +109,7 @@
109109
## 判据纪律(可测与不可测)
110110

111111
> 本节是**机械输入**:下列字符串被测试底座与守卫**逐字复用**,改写即判据失配。
112+
> 🔴 **限度**:本规范的**纪律串判据是机械的**:只证「字符串在不在」,**不证**实现是否真的遵守。
112113
> 依据:规格 §11-10(:766)「6 个签名动效各自**可中断、可反向**,reduced-motion 下正确降级」。
113114
114115
### 可用判据(四类,优先级即此序)

0 commit comments

Comments
 (0)