66 * ① 字阶/字族的 `var(--ed-x, 兜底字面量)` 的兜底值必须等于真源 `SCALE_TOKENS`
77 * (生成器改档后不重跑本层,兜底值会静默退回旧档 —— 未定义变量不报错);
88 * ② `Text.tsx` 产出的每个类,`Text.css` 里必须有对应规则(类名拼错 = 静默无样式,编译期全绿);
9- * ③ `motion.css` 的 7 个动效变量与**全仓唯一一条** reduced-motion 块 —— 批 6 要靠它们删块接管。
9+ * ③ `motion.css` 的 10 个动效变量、**全仓唯一一条** reduced-motion 块,以及规格 §8.4 的
10+ * 「位移上限 8px」—— 批 6 要靠前两者删块接管,靠第三者不越界。
1011 *
11- * 副作用:只读磁盘(同目录三份文件 ),不修改任何文件。
12+ * 副作用:只读磁盘(同目录 + `primitives/` 下的 `.css` ),不修改任何文件。
1213 * 边界:口径与 `tokens.drift.test.ts` 一致 —— **必须归一 EOL**(本仓无 `.gitattributes` 且
1314 * `core.autocrlf=true`,逐字节断言会在别人机器上假阳性);判据前先**剥掉注释**(注释里提到
14- * `--ed-dur-x` / 颜色名不该让守卫误报 )。颜色只做「本层不得出现字面量」的反例守门,
15- * 不重述 token 真源(那是 `contrast.test.ts` / `tokens.drift.test.ts` 的职责)。
15+ * `--ed-dur-x` / 颜色名 / `translateY(12px)` 这类反例不该让守卫误报 )。颜色只做「本层不得出现
16+ * 字面量」的反例守门, 不重述 token 真源(那是 `contrast.test.ts` / `tokens.drift.test.ts` 的职责)。
1617 */
17- import { readFileSync } from "node:fs" ;
18+ import { readdirSync , readFileSync } from "node:fs" ;
1819import { dirname , join } from "node:path" ;
1920import { fileURLToPath } from "node:url" ;
2021import { describe , expect , it } from "vitest" ;
@@ -46,6 +47,27 @@ const MOTION_CLASSES: readonly string[] = [
4647
4748const COLOR_LITERAL = / # [ 0 - 9 a - f A - F ] { 3 , 8 } \b | \b (?: r g b | h s l ) a ? \( / ;
4849
50+ /** 规格 §8.4 末句:位移上限 8px(响应层/编排层/环境层一律适用;批 6 的 GSAP 也不得越过) */
51+ const SHIFT_MAX_PX = 8 ;
52+
53+ /**
54+ * 取一段 CSS 里所有 `translate*()` 的**数值实参**(px/rem)并换算成 px。
55+ * 百分比(`translate(-50%, -50%)` 居中技巧)、`calc()`、`var()` 一律跳过 —— 它们不是「位移量」,
56+ * 而本条判据要守的正是「位移量 ≤ 8px」这一个规范硬数字。
57+ */
58+ function shiftViolations ( css : string , file : string ) : string [ ] {
59+ const out : string [ ] = [ ] ;
60+ for ( const m of stripComments ( css ) . matchAll ( / t r a n s l a t e (?: 3 d | X | Y | Z ) ? \( ( [ ^ ) ] * ) \) / g) ) {
61+ for ( const arg of m [ 1 ] . split ( "," ) ) {
62+ const px = / ^ \s * ( - ? \d + (?: \. \d + ) ? ) ( p x | r e m ) \s * $ / . exec ( arg ) ;
63+ if ( ! px ) continue ;
64+ const value = px [ 2 ] === "rem" ? Math . abs ( Number ( px [ 1 ] ) ) * 16 : Math . abs ( Number ( px [ 1 ] ) ) ;
65+ if ( value > SHIFT_MAX_PX ) out . push ( `${ file } : ${ m [ 0 ] } = ${ value } px > ${ SHIFT_MAX_PX } px` ) ;
66+ }
67+ }
68+ return out ;
69+ }
70+
4971describe ( "Text 类名 ↔ CSS 规则一致(类名拼错 = 静默无样式)" , ( ) => {
5072 it ( "Text.tsx 产出的每个类在 Text.css 里都有规则" , ( ) => {
5173 for ( const cls of TEXT_CLASSES ) {
@@ -95,7 +117,7 @@ describe("token 兜底字面量 == 真源(生成器改档后不重跑本层会
95117} ) ;
96118
97119describe ( "motion.css 接缝契约(批 6 删块即接管,故名字与取值必须钉住)" , ( ) => {
98- it ( "规格 §8.4 的 7 个动效变量落值,一个不多一个不少" , ( ) => {
120+ it ( "规格 §8.4 的 10 个动效变量落值,一个不多一个不少" , ( ) => {
99121 const clean = stripComments ( MOTION_CSS ) ;
100122 const expected : ReadonlyArray < readonly [ string , string ] > = [
101123 [ "--ed-dur-micro" , "120ms" ] ,
@@ -104,12 +126,24 @@ describe("motion.css 接缝契约(批 6 删块即接管,故名字与取值
104126 [ "--ed-dur-toast-in" , "180ms" ] ,
105127 [ "--ed-dur-toast-out" , "140ms" ] ,
106128 [ "--ed-dur-skeleton" , "1200ms" ] ,
129+ // 控制方 2026-09-11 裁决补入:批 6 的编排层 / 页面切换不得重新硬编码这三个
130+ [ "--ed-dur-card" , "220ms" ] ,
131+ [ "--ed-dur-reveal" , "500ms" ] ,
132+ [ "--ed-dur-page" , "150ms" ] ,
107133 [ "--ed-ease" , "cubic-bezier(0.2, 0, 0, 1)" ] ,
108134 ] ;
109135 for ( const [ name , value ] of expected ) expect ( clean ) . toContain ( `${ name } : ${ value } ;` ) ;
110136 expect ( clean . match ( / - - e d - [ a - z 0 - 9 - ] + \s * : / g) ) . toHaveLength ( expected . length ) ;
111137 } ) ;
112138
139+ it ( "规格 §8.4:原语 CSS 的位移一律 ≤ 8px(8px 是注释不变量,本条是它的机器判据)" , ( ) => {
140+ const files = readdirSync ( HERE ) . filter ( ( f ) => f . endsWith ( ".css" ) ) ;
141+ // 防空目录把守卫静默关掉(同 zIndex.guard.test.ts 的「名单非空」思路)
142+ expect ( files . length , "primitives/ 下应有 Text.css 与 motion.css" ) . toBeGreaterThanOrEqual ( 2 ) ;
143+ const violations = files . flatMap ( ( f ) => shiftViolations ( read ( f ) , f ) ) ;
144+ expect ( violations , `位移超过规格 §8.4 的 8px 上限:\n${ violations . join ( "\n" ) } ` ) . toEqual ( [ ] ) ;
145+ } ) ;
146+
113147 it ( "全仓唯一一条 reduced-motion 块,覆盖 11 个 `.ed-*` 基类(含 transition 与 animation 两条)" , ( ) => {
114148 const clean = stripComments ( MOTION_CSS ) ;
115149 expect ( clean . match ( / @ m e d i a \( p r e f e r s - r e d u c e d - m o t i o n : r e d u c e \) / g) ) . toHaveLength ( 1 ) ;
0 commit comments