Skip to content

Commit 174b189

Browse files
committed
feat(ui): L1 原语 Loading/Skeleton/Probe(含 keyframes)
1 parent 0e5e78a commit 174b189

4 files changed

Lines changed: 406 additions & 0 deletions

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Loading.css —— 加载三形态(`Loading` / `Skeleton` / `Probe`)的唯一样式落点(批 0-D Task 11)。
3+
*
4+
* Why(为什么存在):全站加载提示现状是 **83 行/31 文件的一行灰字**,且**全站 0 骨架屏**
5+
* (recon §2);规格 §5.1 把加载收敛为三形态(骨架 = 已知结构 / 探针 = 时长未知)。
6+
* 本文件同时是**全仓第一批 `@keyframes` 的产地**(实测改造前 0 个,recon §8.1):循环环境动效
7+
* 按规格 §8.2 判据走 CSS keyframes —— **不占 JS 主线程**,且 reduced-motion 一条媒体查询即静态。
8+
*
9+
* 边界:① 零颜色字面量(色值只在 ui/tokens.css 与生成器里);② 不写 `z-index`(层级是
10+
* `ui/zIndex.ts` 标尺的职责);③ 位移一律 ≤ 8px(规格 §8.4 末句,由 `style-seams.test.ts` 扫
11+
* `primitives/*.css` 的 `translate*()` 数值实参机器判定);④ 两条动画**必须 `infinite`** ——
12+
* 环境层「闲置时也要有生命感」(§8.6.1 第 1 条),一次性动画等于没有生命感;
13+
* ⑤ 静止态由 `motion.css` 的既有块承担(`animation-iteration-count: 1`),**本文件不得再写
14+
* 第二条媒体查询**(§8.2 的「一条媒体查询即静态」);⑥ 本文件只出三个**基类**
15+
* (`ed-loading` / `ed-skeleton` / `ed-probe`)+ 一个修饰类(`ed-loading--inline`):多行骨架的
16+
* 条距走相邻兄弟规则而**不新增容器类**,免得 T14 的「基类 ⊆ reduced-motion 名单」守卫为第三处开口子。
17+
*/
18+
19+
.ed-loading {
20+
display: inline-flex;
21+
align-items: center;
22+
gap: var(--ed-space-8, 8px);
23+
}
24+
25+
/* `inline`:嵌进按钮 / 行内文本的用法。与相邻文字同基线、间隙收窄,且**标签跟随宿主字阶** ——
26+
按钮里的「保存中…」若按原语自己的 13px 排版,同一行会出现两种字号(本原语不夺宿主的排版权)。 */
27+
.ed-loading--inline { vertical-align: middle; gap: var(--ed-space-4, 4px); }
28+
.ed-loading--inline .ed-text { font-size: inherit; line-height: inherit; }
29+
30+
.ed-skeleton {
31+
position: relative;
32+
overflow: hidden;
33+
background: var(--ed-bg-sunken);
34+
border-radius: var(--ed-radius-control, 5px);
35+
}
36+
37+
/* 多行:条距靠相邻兄弟规则(不新增容器类,见文件头边界⑥) */
38+
.ed-skeleton + .ed-skeleton { margin-top: var(--ed-space-8, 8px); }
39+
40+
/* 微光:`::after` 是一条比骨架底更亮的窄带,横向扫过。`--ed-bg-surface` 在亮/暗两档下都比
41+
`--ed-bg-sunken` 亮 ⇒ 两个主题都成立;取 60% 而非不透明 —— 环境层幅度上限不动(§8.6.1 第 1 条)。 */
42+
.ed-skeleton::after {
43+
content: "";
44+
position: absolute;
45+
inset: 0;
46+
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ed-bg-surface) 60%, transparent), transparent);
47+
animation: ed-skeleton-shimmer var(--ed-dur-skeleton, 1200ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)) infinite;
48+
}
49+
50+
/* 全仓第一个 @keyframes:只动 `transform`(合成层,不逐帧触发布局) */
51+
@keyframes ed-skeleton-shimmer {
52+
from { transform: translateX(-100%); }
53+
to { transform: translateX(100%); }
54+
}
55+
56+
.ed-probe {
57+
display: inline-block;
58+
flex: none; /* 作为 `.ed-loading` 的 flex 子项时,8px 圆点不被压成椭圆 */
59+
width: 8px;
60+
height: 8px;
61+
border-radius: 50%;
62+
background: var(--ed-ink-3);
63+
/* 周期 2.4s 落在环境层 2–6s 区间(规格 §8.1);`ease-in-out` 让两端有短暂停顿感 */
64+
animation: ed-probe-swing 2.4s ease-in-out infinite;
65+
}
66+
67+
/* 探针摆动:±2px(总幅度 4px,规格 §8.4 的 8px 上限内)· 墨度 0.45→1 —— 取「看得见但不抢注意力」
68+
的最小幅度;停住时回到基类(实心圆点),不是空白(reduced-motion 的静态态要求)。 */
69+
@keyframes ed-probe-swing {
70+
0%, 100% { transform: translateX(-2px); opacity: 0.45; }
71+
50% { transform: translateX(2px); opacity: 1; }
72+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context Loading.test.tsx — L1 原语 `Loading` / `Skeleton` / `Probe` 的接口契约(批 0-D Task 11)。
4+
*
5+
* Why:三形态的价值全在**可判定的语义边界**(形状已知 → `Skeleton` · 要文字 → `Loading` ·
6+
* 无文字的最小单点 → `Probe`)。批 4 要把 83 行/31 文件的「加载中…」按这张表迁移,边界一旦漂移,
7+
* 三个词就会退化成今天那种逐处手写的灰字。故本文件钉的是「谁渲染什么 + 类名 + 无障碍角色」。
8+
*
9+
* ② 后半段(`describe("Loading.css …")`)是 **CSS 文本契约**,不是样式复述:本任务是全仓第一批
10+
* `@keyframes` 的产地(recon §8.1 实测改造前 0 个),而**周期与 `infinite` 在 jsdom 里无从观测**
11+
* (jsdom 既不跑动画也不解析样式表)⇒ 只能直接读 `Loading.css` 文本断言(计划 Step 1 明写
12+
* 「node 与 jsdom 都能跑」)。该段只读盘、不依赖 DOM。
13+
*
14+
* 副作用:无(纯展示组件;不读 store、不发请求、不写磁盘)。
15+
* 边界:本仓未装 `@testing-library/jest-dom` / `user-event`(硬约束:不新增依赖)⇒ 断言一律用
16+
* 原生 DOM API(`getAttribute` / `style` / `querySelector`);`vitest.config.ts` 未开 `globals`
17+
* ⇒ RTL 的自动清理不生效,必须显式 `cleanup()`(先例 `ConfirmDialog.test.tsx:55`)。本文件从
18+
* `./index` 导入(同 `Text.test.tsx` 先例)⇒ 连 `index.ts` 的 `import "./motion.css"` 一并执行。
19+
*/
20+
import { readFileSync } from "node:fs";
21+
import { dirname, join } from "node:path";
22+
import { fileURLToPath } from "node:url";
23+
import { cleanup, render, screen } from "@testing-library/react";
24+
import { afterEach, describe, expect, it } from "vitest";
25+
import { Loading, Probe, Skeleton } from "./index";
26+
27+
afterEach(() => cleanup());
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
/** 归一 EOL 后读文本(本仓无 `.gitattributes` 且 `core.autocrlf=true` ⇒ 禁止逐字节换行断言) */
31+
const readText = (file: string): string => readFileSync(join(HERE, file), "utf8").replace(/\r\n/g, "\n");
32+
/** 判据前剥注释(注释里提到 `@media` / `translateX` 这类反例不该让守卫误报 —— 同 `style-seams.test.ts`) */
33+
const stripComments = (s: string): string => s.replace(/\/\*[\s\S]*?\*\//g, "");
34+
/** 取一条规则的规则体(不含选择器);规则不存在时返回 null(让断言以「缺规则」而不是空串失败) */
35+
const ruleBody = (css: string, selector: string): string | null => {
36+
const at = css.indexOf(`${selector} {`);
37+
return at < 0 ? null : css.slice(at, css.indexOf("}", at));
38+
};
39+
const classesOf = (el: Element | null): string[] =>
40+
(el?.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
41+
const textOf = (el: Element): string[] =>
42+
Array.from(el.querySelectorAll(".ed-text")).map((n) => (n.getAttribute("class") ?? ""));
43+
44+
const CSS = stripComments(readText("Loading.css"));
45+
const MOTION_CSS = stripComments(readText("motion.css"));
46+
47+
describe("Loading —— 不知道形状、但需要一句文字说明在等什么(判定顺序第 2 问)", () => {
48+
it("默认渲染可读文案「加载中…」并带 role=status(内容出现即被读到)", () => {
49+
const { container } = render(<Loading />);
50+
expect(container.textContent).toBe("加载中…");
51+
const root = screen.getByRole("status");
52+
expect(root.tagName).toBe("SPAN");
53+
expect(root.textContent).toBe("加载中…");
54+
});
55+
56+
it("默认不是 inline 用法(独立占位是默认档,嵌进按钮/行内才显式声明)", () => {
57+
render(<Loading />);
58+
expect(classesOf(screen.getByRole("status"))).toEqual(["ed-loading"]);
59+
});
60+
61+
it("inline 追加 ed-loading--inline(该档让标签跟随宿主字阶,见 CSS 规则)", () => {
62+
render(<Loading inline />);
63+
expect(classesOf(screen.getByRole("status"))).toEqual(["ed-loading", "ed-loading--inline"]);
64+
});
65+
66+
it("标签排版交给 `Text`(本原语不写字号/颜色):ink-3 + s4 档", () => {
67+
render(<Loading />);
68+
expect(textOf(screen.getByRole("status"))).toEqual(["ed-text ed-text--s4 ed-text--ink-3 ed-text--font-ui"]);
69+
});
70+
71+
it("label 是 ReactNode 槽 —— 多行说明(模型下载进度等)可直接传入而不必改原语", () => {
72+
render(
73+
<Loading
74+
label={
75+
<>
76+
<span>⏳ 正在下载模型(~650MB)…</span>
77+
<span>encoder.onnx:12MB / 650MB</span>
78+
</>
79+
}
80+
/>,
81+
);
82+
const root = screen.getByRole("status");
83+
expect(root.textContent).toContain("正在下载模型");
84+
expect(root.textContent).toContain("encoder.onnx:12MB / 650MB");
85+
expect(root.querySelectorAll("span")).toHaveLength(4); // 探针 + Text 外壳 + 两行说明
86+
});
87+
88+
it("testId 落到根元素(批 4 的迁移靶子原样保留 data-testid)", () => {
89+
render(<Loading testId="group-delete-loading" />);
90+
expect(screen.getByTestId("group-delete-loading").getAttribute("role")).toBe("status");
91+
});
92+
93+
it("内部复用 Probe,且圆点是装饰:可读语义由标签承载,不由圆点承载", () => {
94+
const { container } = render(<Loading />);
95+
const probe = container.querySelector(".ed-probe");
96+
expect(probe).not.toBeNull();
97+
expect(probe?.getAttribute("aria-hidden")).toBe("true");
98+
});
99+
});
100+
101+
describe("Skeleton —— 知道要出现什么形状(判定顺序第 1 问)", () => {
102+
it("默认 1 条 · 高 12px · 满宽(12px 与正文行高同量级 = 一行文字的形状)", () => {
103+
render(<Skeleton testId="sk" />);
104+
const bars = screen.getAllByTestId("sk-line");
105+
expect(bars).toHaveLength(1);
106+
expect(bars[0].getAttribute("class")).toBe("ed-skeleton");
107+
expect(bars[0].style.height).toBe("12px");
108+
expect(bars[0].style.width).toBe("100%");
109+
});
110+
111+
it("lines={3} 渲染 3 条骨架条(GoalDetail 的「加载中…」一行灰字 → 3 行骨架)", () => {
112+
render(<Skeleton lines={3} testId="sk" />);
113+
expect(screen.getAllByTestId("sk-line")).toHaveLength(3);
114+
});
115+
116+
it("height / width 落到行内 style(number → px;string 原样,供百分比与 calc 用)", () => {
117+
render(<Skeleton height={20} width="60%" testId="sk" />);
118+
const bar = screen.getByTestId("sk-line");
119+
expect(bar.style.height).toBe("20px");
120+
expect(bar.style.width).toBe("60%");
121+
});
122+
123+
it("边界:lines 为 0 / 负数 / 非数时夹到 1 条(夹不住的后果是**空白区** —— 看起来像加载完了)", () => {
124+
for (const bad of [0, -3, Number.NaN]) {
125+
const { unmount } = render(<Skeleton lines={bad} testId="sk" />);
126+
expect(screen.getAllByTestId("sk-line"), `lines=${String(bad)}`).toHaveLength(1);
127+
unmount();
128+
}
129+
});
130+
131+
it("边界:小数 lines 向下取整(2.9 → 2 条,不产生半条)", () => {
132+
render(<Skeleton lines={2.9} testId="sk" />);
133+
expect(screen.getAllByTestId("sk-line")).toHaveLength(2);
134+
});
135+
136+
it("装饰性:整组 aria-hidden(形状是纯视觉信息,可读语义由调用点的 role=status 提供)", () => {
137+
render(<Skeleton lines={2} testId="sk" />);
138+
expect(screen.getByTestId("sk").getAttribute("aria-hidden")).toBe("true");
139+
});
140+
});
141+
142+
describe("Probe —— 不需要文字的最小单元(判定顺序第 3 问)", () => {
143+
it("渲染单个 .ed-probe,且是装饰(aria-hidden)", () => {
144+
const { container } = render(<Probe />);
145+
const probe = container.firstElementChild;
146+
expect(classesOf(probe)).toEqual(["ed-probe"]);
147+
expect(probe?.getAttribute("aria-hidden")).toBe("true");
148+
});
149+
150+
it("label 只作鼠标悬停的原生 title(`aria-hidden` 元素上的可访问名是死属性 ⇒ 不写 aria-label)", () => {
151+
const { container } = render(<Probe label="加载中" />);
152+
expect(container.firstElementChild?.getAttribute("title")).toBe("加载中");
153+
expect(container.firstElementChild?.getAttribute("aria-label")).toBeNull();
154+
});
155+
156+
it("无 label 时连 title 属性都不渲染(不产生空 tooltip)", () => {
157+
const { container } = render(<Probe />);
158+
expect(container.firstElementChild?.hasAttribute("title")).toBe(false);
159+
});
160+
161+
it("没有包装层:testId 落在单点本身(嵌进按钮/行内时不多一层 DOM)", () => {
162+
const { container } = render(<Probe testId="p" />);
163+
expect(screen.getByTestId("p").getAttribute("class")).toBe("ed-probe");
164+
expect(container.childElementCount).toBe(1);
165+
});
166+
});
167+
168+
describe("Loading.css —— 全仓第一批 @keyframes(周期与 infinite 只能从 CSS 文本钉)", () => {
169+
it("恰好两个 @keyframes 各定义一次(骨架微光 + 探针摆动;本文件就是全仓产地)", () => {
170+
expect(CSS.match(/@keyframes ed-skeleton-shimmer/g)).toHaveLength(1);
171+
expect(CSS.match(/@keyframes ed-probe-swing/g)).toHaveLength(1);
172+
expect(CSS.match(/@keyframes/g)).toHaveLength(2);
173+
});
174+
175+
it("周期钉住:骨架走 --ed-dur-skeleton(兜底 1200ms)· 探针 2.4s(环境层 2–6s,§8.1)", () => {
176+
expect(CSS).toContain("ed-skeleton-shimmer var(--ed-dur-skeleton, 1200ms)");
177+
expect(CSS).toContain("ed-probe-swing 2.4s");
178+
});
179+
180+
it("两条都是 infinite(§8.6.1 第 1 条:环境层不得退为纯背景 —— 一次性动画等于没有生命感)", () => {
181+
expect(CSS.match(/infinite/g)).toHaveLength(2);
182+
expect(CSS).toContain("var(--ed-ease, cubic-bezier(0.2, 0, 0, 1)) infinite");
183+
});
184+
185+
it("探针幅度 ±2px(总 4px,不抢注意力;≤8px 上限由 style-seams.test.ts 扫本文件机器判定)", () => {
186+
expect(CSS.match(/translateX\(-2px\)/g)).toHaveLength(1);
187+
expect(CSS.match(/translateX\(2px\)/g)).toHaveLength(1);
188+
});
189+
190+
it("微光只动 transform(合成层)· 亮带取 60% · 骨架不靠透明度闪烁(「不抢注意力」)", () => {
191+
expect(CSS).toContain("color-mix(in srgb, var(--ed-bg-surface) 60%, transparent)");
192+
const shimmerToSwing = CSS.slice(CSS.indexOf("@keyframes ed-skeleton-shimmer"), CSS.indexOf("@keyframes ed-probe-swing"));
193+
expect(shimmerToSwing).not.toContain("opacity");
194+
expect(CSS.match(/opacity/g)).toHaveLength(2); // 两处都在探针摆动里
195+
});
196+
});
197+
198+
describe("reduced-motion:静态由 motion.css 的既有块承担(§8.2「一条媒体查询即静态」)", () => {
199+
it("本文件不得自带第二条媒体查询(全仓唯一一条 reduced-motion 块在 motion.css)", () => {
200+
expect(CSS).not.toContain("@media");
201+
});
202+
203+
it("motion.css 的块覆盖本文件三个基类,且 iteration-count: 1 让循环动画停住", () => {
204+
const at = MOTION_CSS.indexOf("@media (prefers-reduced-motion");
205+
expect(at, "motion.css 缺少 reduced-motion 块").toBeGreaterThanOrEqual(0);
206+
const block = MOTION_CSS.slice(at);
207+
for (const cls of ["ed-loading", "ed-skeleton", "ed-probe"]) {
208+
expect(block, `reduced-motion 名单缺 .${cls}`).toContain(`.${cls}`);
209+
}
210+
expect(block).toContain("animation-iteration-count: 1 !important");
211+
});
212+
213+
it("静止态不是空白:两者的可见性写在不依赖动画的基类规则里(停住后仍是灰条与实心圆点)", () => {
214+
expect(ruleBody(CSS, ".ed-skeleton")).toContain("background: var(--ed-bg-sunken)");
215+
expect(ruleBody(CSS, ".ed-probe")).toContain("background: var(--ed-ink-3)");
216+
expect(ruleBody(CSS, ".ed-probe")).not.toContain("opacity: 0");
217+
});
218+
});
219+
220+
describe("消费纪律:颜色只经 var(--ed-*) · 层级不归本层", () => {
221+
it("零颜色字面量(色值只在 ui/tokens.css 与生成器里)", () => {
222+
expect(CSS).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
223+
expect(CSS).not.toMatch(/\b(?:rgba?|hsla?)\(/);
224+
});
225+
226+
it("不写 z-index(层级是 ui/zIndex.ts 标尺的职责,规格 §4.2①)", () => {
227+
expect(CSS).not.toMatch(/z-index/);
228+
});
229+
});

0 commit comments

Comments
 (0)