Skip to content

Commit eb55386

Browse files
committed
feat(motion): 落三档强度通道与源序守卫
1 parent ae868f7 commit eb55386

5 files changed

Lines changed: 441 additions & 0 deletions

File tree

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

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* @ai-context intensity.test.ts — 三档强度通道的**行为判据**(node 环境,不加 jsdom 指令注释)。
3+
*
4+
* Why 用 node 而不是 jsdom:本文件要判的两条事实**恰好与 jsdom 无关** —— ① 「真环境里没有
5+
* `window.matchMedia` 时不抛」(实测 jsdom 30.0.1 没有它,而 T10 会给 jsdom 补桩 ⇒ 用 jsdom 判
6+
* 就变成"判桩的默认值",桩一改用例就红);② 三档的值语义与持久化键是纯函数契约(照
7+
* `views/useViewMemory.ts` 的范式:注入 `Storage` ⇒ 内存桩即可)。故本文件用**假全局**
8+
* (`window` / `document` / `localStorage`)逐条注入、逐条 `finally` 还原,不依赖 jsdom 也不污染
9+
* 别的文件。
10+
* 口径:`useMotionIntensity` 用 `react-dom/server` 的 `renderToString` 真跑 hook(node 下唯一入口)。
11+
* ⚠️ 服务端**不执行** `useEffect` ⇒ 「挂载即把档位落到 `<html>`」这条由 T7 的 jsdom 组件判据承担
12+
* (T7 V2 断 `documentElement.dataset.motion`);本文件判的是**初值**与 `select` 通道。
13+
* 副作用:临时改写 `globalThis` 上的 `window` / `document` / `localStorage`(逐条还原)。
14+
* 边界:`el.dataset.motion` 用**假元素**判写入路径;真实 `<html>` 的同一断言见 T7。
15+
*/
16+
import { createElement } from "react";
17+
import { renderToString } from "react-dom/server";
18+
import { describe, expect, it } from "vitest";
19+
import {
20+
MOTION_INTENSITIES,
21+
MOTION_INTENSITY_KEY,
22+
applyIntensity,
23+
defaultIntensity,
24+
readIntensity,
25+
useMotionIntensity,
26+
writeIntensity,
27+
} from "./intensity";
28+
import type { MotionIntensity } from "./intensity";
29+
30+
/** 逐条注入假全局并还原(`Reflect.deleteProperty` 处理"本来没有"的情形)。 */
31+
function withGlobal<T>(key: "window" | "document" | "localStorage", value: unknown, run: () => T): T {
32+
const had = Object.getOwnPropertyDescriptor(globalThis, key);
33+
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
34+
try {
35+
return run();
36+
} finally {
37+
if (had) Object.defineProperty(globalThis, key, had);
38+
else Reflect.deleteProperty(globalThis, key);
39+
}
40+
}
41+
42+
/** 记录型 `Storage`;`"throw"` ⇒ 每次访问都抛(隐私模式 / 配额拒绝的真实形态)。 */
43+
function fakeStorage(kind: "memory" | "throw" = "memory", seed?: string): Storage {
44+
const map = new Map<string, string>();
45+
if (seed !== undefined) map.set(MOTION_INTENSITY_KEY, seed);
46+
const denied = (): never => {
47+
throw new Error("storage denied");
48+
};
49+
return {
50+
get length(): number {
51+
return map.size;
52+
},
53+
clear: () => map.clear(),
54+
getItem: (k: string) => (kind === "throw" ? denied() : (map.get(k) ?? null)),
55+
key: (i: number) => [...map.keys()][i] ?? null,
56+
removeItem: (k: string) => void map.delete(k),
57+
setItem: (k: string, v: string) => (kind === "throw" ? denied() : void map.set(k, v)),
58+
};
59+
}
60+
61+
/** 假元素 / 假 document:只带判据要用的那一层(`applyIntensity` 的唯一写入面 = `dataset.motion`)。 */
62+
const fakeEl = (): HTMLElement => ({ dataset: {} } as unknown as HTMLElement);
63+
const fakeDocument = (el: HTMLElement): Document => ({ documentElement: el } as unknown as Document);
64+
65+
/** 真跑一次 hook(`renderToString`),取回当前档与 `select`。 */
66+
function renderProbe(): { value: MotionIntensity; select: (v: MotionIntensity) => void } {
67+
let seen: MotionIntensity | null = null;
68+
let select: ((v: MotionIntensity) => void) | null = null;
69+
const Probe = (): string => {
70+
const [value, setValue] = useMotionIntensity();
71+
seen = value;
72+
select = setValue;
73+
return value;
74+
};
75+
const html = renderToString(createElement(Probe));
76+
if (seen === null || select === null) throw new Error(`hook 没跑起来(渲染出的 html="${html}")`);
77+
return { value: seen, select };
78+
}
79+
80+
describe("初值跟随系统(§8.5 逐字:reduce ⇒ eco,否则 standard)", () => {
81+
it("defaultIntensity:reduce ⇒ eco;非 reduce / 无 mql ⇒ standard", () => {
82+
expect(defaultIntensity({ matches: true })).toBe("eco");
83+
expect(defaultIntensity({ matches: false })).toBe("standard");
84+
expect(defaultIntensity(null)).toBe("standard");
85+
expect(defaultIntensity(undefined)).toBe("standard");
86+
});
87+
88+
it("hook 初值:真 node 环境没有 window(也没有 matchMedia)⇒ standard,且不抛", () => {
89+
expect(typeof window).toBe("undefined");
90+
expect(renderProbe().value).toBe("standard");
91+
});
92+
93+
it("hook 初值:系统 reduce ⇒ eco,且查询串逐字是 prefers-reduced-motion: reduce", () => {
94+
const asked: string[] = [];
95+
const stub = {
96+
matchMedia: (q: string) => {
97+
asked.push(q);
98+
return { matches: true };
99+
},
100+
};
101+
expect(withGlobal("window", stub, renderProbe).value).toBe("eco");
102+
expect(asked).toEqual(["(prefers-reduced-motion: reduce)"]);
103+
});
104+
105+
it("hook 初值:window 在但**没有** matchMedia(实测 jsdom 30.0.1 正是这种)⇒ standard,不抛", () => {
106+
expect(withGlobal("window", {}, renderProbe).value).toBe("standard");
107+
});
108+
});
109+
110+
describe("持久化:键逐字 motion:intensity(照 useViewMemory 范式:注入 Storage + 静默降级)", () => {
111+
it("三档名册与键的字面量冻结(CSS 侧的取值集合判据在 motion-coverage.test.ts)", () => {
112+
expect([...MOTION_INTENSITIES]).toEqual(["eco", "standard", "rich"]);
113+
expect(MOTION_INTENSITY_KEY).toBe("motion:intensity");
114+
});
115+
116+
it("写读往返:写进的就是 motion:intensity,读回的等于写入值", () => {
117+
const storage = fakeStorage();
118+
writeIntensity("rich", storage);
119+
expect(storage.getItem("motion:intensity")).toBe("rich");
120+
expect(readIntensity(storage)).toBe("rich");
121+
});
122+
123+
it("垃圾值(空串 / 大小写不符 / 多余空白 / 旧版遗留 / 不是档位名)一律当作没有记忆 ⇒ null", () => {
124+
for (const junk of ["", "loud", "RICH", "standard ", " eco", "eco;rich"]) {
125+
expect(readIntensity(fakeStorage("memory", junk)), `垃圾值 ${JSON.stringify(junk)}`).toBeNull();
126+
}
127+
});
128+
129+
it("Storage 抛(配额满 / 隐私模式)⇒ 读返回 null、写不抛(静默降级)", () => {
130+
const denied = fakeStorage("throw");
131+
expect(() => writeIntensity("eco", denied)).not.toThrow();
132+
expect(readIntensity(denied)).toBeNull();
133+
});
134+
135+
it("hook:已存值优先于系统初值;`select` 同时写状态与持久化", () => {
136+
const storage = fakeStorage("memory", "rich");
137+
const seen = withGlobal("localStorage", storage, () => {
138+
const { value, select } = renderProbe();
139+
select("eco");
140+
return value;
141+
});
142+
expect(seen, "已存的选择必须盖过系统初值(此处系统未 reduce ⇒ 初值本会是 standard)").toBe("rich");
143+
expect(storage.getItem(MOTION_INTENSITY_KEY)).toBe("eco");
144+
});
145+
});
146+
147+
describe("applyIntensity:写真属性 data-motion(CSS 侧 html[data-motion=…] 的唯一入口)", () => {
148+
it("显式传元素 ⇒ 写在它的 dataset.motion 上;再写一次覆盖旧档", () => {
149+
const el = fakeEl();
150+
applyIntensity("rich", el);
151+
expect(el.dataset.motion).toBe("rich");
152+
applyIntensity("eco", el);
153+
expect(el.dataset.motion).toBe("eco");
154+
});
155+
156+
it("不传元素 ⇒ 默认目标是 document.documentElement(`<html>`)", () => {
157+
const el = fakeEl();
158+
withGlobal("document", fakeDocument(el), () => applyIntensity("standard"));
159+
expect(el.dataset.motion).toBe("standard");
160+
});
161+
162+
it("无 document(node 真环境)且不传元素 / 传 null ⇒ 静默返回,不抛", () => {
163+
expect(typeof document).toBe("undefined");
164+
expect(() => applyIntensity("rich")).not.toThrow();
165+
expect(() => applyIntensity("rich", null)).not.toThrow();
166+
});
167+
});

‎app/src/motion/intensity.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* intensity.ts — 三档动效强度通道(规格 §8.5;R3.1 / R3.2)。
3+
* @ai-context Why:载体 = 写在 `<html>` 上的 `data-motion`(取值逐字 "eco" | "standard" | "rich"),
4+
* CSS 才能用一条选择器改幅度/频率;「系统 `prefers-reduced-motion` 优先于档位」由 `motion.css` 的
5+
* **源序**实现(档位块写在 reduced-motion 块之前)—— 本文件只负责「值 ↔ 属性 ↔ 持久化」,
6+
* 不碰任何时长数值(时长/缓动的唯一真源是 `scripts/gen-tokens.mjs`)。
7+
* @ai-context 初值**跟随系统**:`prefers-reduced-motion: reduce` ⇒ "eco",否则 "standard";已存过的
8+
* 用户选择优先于系统初值(记忆是显式选择,初值只是缺省)。
9+
* @ai-context 副作用:写 `<html data-motion>`(文档级;Tauri 多窗口变体各自独立)与 `motion:intensity`。
10+
* 三处降级**一律静默**:① 无 `document`(node 测试环境)⇒ 不写 DOM;② 无 `localStorage` / 配额
11+
* 拒绝 / 隐私模式 ⇒ 不记忆;③ 无 `window.matchMedia`(实测 jsdom 30.0.1 没有它)⇒ 按「未 reduce」
12+
* 取 "standard",**不抛**。
13+
* @ai-context 边界:只认三档字面量;垃圾值(空串 / 大小写不符 / 多余空白 / 旧版遗留)当作**没有记忆**,回退系统初值。
14+
*/
15+
import { useCallback, useEffect, useState } from "react";
16+
export const MOTION_INTENSITIES = ["eco", "standard", "rich"] as const;
17+
export type MotionIntensity = (typeof MOTION_INTENSITIES)[number];
18+
export const MOTION_INTENSITY_KEY = "motion:intensity";
19+
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; // matchMedia 的唯一入参(逐字)
20+
/** 初值:`matches === true` ⇒ "eco";`mql` 缺失(无 `matchMedia`)⇒ "standard"(§8.5 逐字)。 */
21+
export function defaultIntensity(mql?: Pick<MediaQueryList, "matches"> | null): MotionIntensity {
22+
return mql?.matches === true ? "eco" : "standard";
23+
}
24+
/** 读记忆:无值 / 值不在三档内 / `Storage` 抛 ⇒ `null`(调用方回退系统初值)。 */
25+
export function readIntensity(storage: Storage = globalThis.localStorage): MotionIntensity | null {
26+
try {
27+
const raw = storage.getItem(MOTION_INTENSITY_KEY);
28+
return raw !== null && (MOTION_INTENSITIES as readonly string[]).includes(raw) ? (raw as MotionIntensity) : null;
29+
} catch {
30+
return null;
31+
}
32+
}
33+
/** 写记忆:配额满 / 隐私模式 / node 无 localStorage 抛 ⇒ 静默(记忆失败不阻塞切换)。 */
34+
export function writeIntensity(value: MotionIntensity, storage: Storage = globalThis.localStorage): void {
35+
try {
36+
storage.setItem(MOTION_INTENSITY_KEY, value);
37+
} catch {
38+
/* 静默降级:无 localStorage / 配额满 / 隐私模式 */
39+
}
40+
}
41+
/** 写 `data-motion`(默认目标 = `<html>`);无 `document` 时静默返回。 */
42+
export function applyIntensity(value: MotionIntensity, el?: HTMLElement | null): void {
43+
const target = el ?? (typeof document === "undefined" ? null : document.documentElement);
44+
if (target !== null) target.dataset.motion = value;
45+
}
46+
/** 强度 hook:`[当前档, 选择档]`。初值**惰性读取**(已存值 → 系统初值),并在 `<html>` 上落地。 */
47+
export function useMotionIntensity(): readonly [MotionIntensity, (v: MotionIntensity) => void] {
48+
const [value, setValue] = useState<MotionIntensity>(() => {
49+
const stored = readIntensity();
50+
if (stored !== null) return stored;
51+
const noApi = typeof window === "undefined" || typeof window.matchMedia !== "function";
52+
return defaultIntensity(noApi ? null : window.matchMedia(REDUCED_QUERY));
53+
});
54+
useEffect(() => applyIntensity(value), [value]);
55+
const select = useCallback((next: MotionIntensity) => {
56+
setValue(next);
57+
writeIntensity(next);
58+
}, []);
59+
return [value, select];
60+
}

‎app/src/ui/primitives/motion-coverage.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { readdirSync, readFileSync } from "node:fs";
2424
import { dirname, join } from "node:path";
2525
import { fileURLToPath } from "node:url";
2626
import { describe, expect, it } from "vitest";
27+
import { MOTION_INTENSITIES } from "../../motion/intensity";
2728

2829
const HERE = dirname(fileURLToPath(import.meta.url));
2930
const normalizeEol = (s: string): string => s.replace(/\r\n/g, "\n");
@@ -145,3 +146,35 @@ describe("★ 每一处 `animation` 声明的选择器都在名单里(`animati
145146
).toEqual([]);
146147
});
147148
});
149+
150+
/* ── T6 追加段(R3.1):三档强度通道的**源序**与**值域闭合**。本段**只追加** —— 上面两条 describe
151+
* 与既有 import 一行未动;`:142` 的「每个 `animation` 选择器逐字进名单」是只读铁判据(G7)。
152+
* 为什么源序是判据而不是注释:§8.5 逐字要求「系统 `prefers-reduced-motion` 优先于档位」,而在同一张
153+
* 样式表里这**只能**靠源序实现(档位块先写、reduced-motion 块后写 ⇒ 后者的 `!important` 覆盖赢);
154+
* 把两块顺序对调,用户选的档位就会盖掉系统无障碍设置,且**没有任何其它判据会报错**。
155+
* 口径:注释先**就地掩码**(换行保留 ⇒ 报告里的行号与真实文件一致;`stripComments` 会连注释里的换行
156+
* 一起删掉、行号漂移);掩码后取「档位块首个选择器」与「reduced-motion 块起始」的**行号**比对。 */
157+
describe("★ 三档强度通道:源序(档位块在 reduced-motion 块之前)与值域闭合", () => {
158+
const masked = read("motion.css").replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
159+
const tierAt = masked.search(/\[data-motion="(?:eco|standard|rich)"\]/);
160+
const reducedAt = masked.indexOf("@media (prefers-reduced-motion");
161+
const lineAt = (index: number): number => masked.slice(0, index).split("\n").length;
162+
163+
it("两块都在(防选择器改名把守卫静默关掉)", () => {
164+
expect(tierAt, "motion.css 里找不到档位块(`html[data-motion=…]`)").toBeGreaterThanOrEqual(0);
165+
expect(reducedAt, "motion.css 里找不到 reduced-motion 块").toBeGreaterThanOrEqual(0);
166+
});
167+
168+
it("档位块行号 < reduced-motion 块行号(§8.5:系统设置永远赢过用户选的档)", () => {
169+
expect(tierAt >= 0 && reducedAt >= 0, "两块必须都在,否则本条是空真").toBe(true);
170+
expect(
171+
lineAt(tierAt),
172+
`档位块在第 ${lineAt(tierAt)} 行、reduced-motion 在第 ${lineAt(reducedAt)} 行 —— 顺序写反 = 用户选的档位盖掉系统无障碍设置`,
173+
).toBeLessThan(lineAt(reducedAt));
174+
});
175+
176+
it("值域闭合:CSS 里的档位取值集合 === MOTION_INTENSITIES(多一档 / 少一块都红)", () => {
177+
const inCss = [...new Set([...masked.matchAll(/\[data-motion="([a-z]+)"\]/g)].map((m) => m[1]))].sort();
178+
expect(inCss, "CSS 的档位取值与 motion/intensity.ts 的名册不一致").toEqual([...MOTION_INTENSITIES].sort());
179+
});
180+
});

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,38 @@
3535
*/
3636

3737

38+
/* ★ 强度三档(规格 §8.5 / R3.1)—— **本块必须写在下面 reduced-motion 块之前**:源序即优先级,
39+
后写的 reduced-motion 覆盖带 `!important` ⇒ 系统偏好永远赢过用户选的档(§8.5 逐字「系统
40+
`prefers-reduced-motion` 优先于档位」);顺序写反 = 用户选的档位盖掉系统无障碍设置。
41+
机器判据 = `motion-coverage.test.ts` 的「三档强度通道」describe(档位块行号 < reduced 块行号;
42+
CSS 里的档位取值集合 === `MOTION_INTENSITIES`)。
43+
⚠️ 本块**不得**定义任何 `--ed-*` 变量(真源只有 `scripts/gen-tokens.mjs`;`style-seams.test.ts`
44+
断言本文件的 `--ed-*` 定义恒 0)⇒ 档位差异一律**直接覆写属性**,不引私变量、不新造第二个真源。
45+
⚠️ 本块**不得**写 `animation:` / `animation-name:`(那会让这些选择器必须再进 reduced-motion 名单)
46+
—— 只覆写 `animation-duration` / `animation-iteration-count`。 */
47+
48+
/* ① 节能(eco):只留响应层。环境层的循环动效「一次即止」;一次性入场直接跳终态(`both` 保持终态)。
49+
编排层的「跳终态」由 JS 侧读档位决定 —— 本文件不得覆写时长真源。 */
50+
html[data-motion="eco"] .ed-skeleton::after,
51+
html[data-motion="eco"] .ed-probe,
52+
html[data-motion="eco"] .ed-empty-enter {
53+
animation-duration: 1ms;
54+
animation-iteration-count: 1;
55+
}
56+
57+
/* ② 标准(standard,默认档):四层全开 —— **故意留空**。缺省即标准,覆写任何属性都会把「默认档」
58+
变成要维护的第二真源;空块保留是为了让「三档取值都在 CSS 里有落点」(值域闭合判据要求 CSS 的
59+
取值集合与 `MOTION_INTENSITIES` 相等)。 */
60+
html[data-motion="standard"] {
61+
}
62+
63+
/* ③ 丰富(rich):环境层频率提高(幅度上限不动 —— §8.6.1 第 1 条「环境层永远不抢注意力」)。
64+
只动 token 支撑的那条循环:`.ed-probe` 的 `2.4s` 是 `Loading.css` 的既有字面量、尚未 token 化
65+
⇒ 本档不给它新造第二个字面量(登记:环境层落点的档位节奏随 T13 的环境层落点一并定)。 */
66+
html[data-motion="rich"] .ed-skeleton::after {
67+
animation-duration: calc(var(--ed-dur-skeleton, 1200ms) * 0.75);
68+
}
69+
3870
/* ★ 名单 = 12 个 `.ed-*` **基类** + **每一处 `animation` 声明的选择器原文**(含伪元素)。
3971
⚠️ 为什么伪元素必须逐字列出(T11 评审用 headless Chromium `--force-prefers-reduced-motion` 实测的 Critical):
4072
`animation-duration` / `animation-iteration-count` **不是可继承属性** —— 覆盖写在宿主元素上,

0 commit comments

Comments
 (0)