واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-* بههمراه .anim-stagger برای ورود آبشاری. همهٔ مدتزمانها پشت custom property هستند تا یک override کل اپ را تنظیم کند. - packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar، Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer - ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد میشود - AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up - prefers-reduced-motion مدتزمانها را به ۰.۰۱ms میرساند تا وضعیت پایانی حفظ شود - قرارداد motion در src/test/motion.test.tsx تست میشود تا بازنویسی بعدی آن را بیصدا حذف نکند - تستهای فرانتاند: ۳۵ ← ۴۵
141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { render } from "@testing-library/react";
|
|
import {
|
|
Drawer,
|
|
EmptyState,
|
|
ErrorState,
|
|
Modal,
|
|
Stat,
|
|
Table,
|
|
Timeline,
|
|
ToastStack,
|
|
UsageBar,
|
|
type TableColumn,
|
|
} from "@agy/ui";
|
|
|
|
/**
|
|
* The motion contract.
|
|
*
|
|
* These assert that animated surfaces actually carry an animation class, and
|
|
* that every class they carry has keyframes behind it. A `className` with no
|
|
* matching `@keyframes` is a silent no-op — nobody notices until the app feels
|
|
* dead — so the vocabulary and the surfaces are checked together.
|
|
*
|
|
* Timing and feel are deliberately not asserted here: they are a design
|
|
* decision, not behaviour, and a test that pins them down only fights the next
|
|
* tuning pass.
|
|
*/
|
|
|
|
interface Row {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
const columns: Array<TableColumn<Row>> = [
|
|
{ key: "name", header: "name", render: (row) => row.name },
|
|
];
|
|
|
|
const ROWS: Row[] = [
|
|
{ id: "1", name: "one" },
|
|
{ id: "2", name: "two" },
|
|
];
|
|
|
|
describe("motion vocabulary", () => {
|
|
const globals = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf-8");
|
|
|
|
it("declares every keyframe the utilities reference", () => {
|
|
for (const name of [
|
|
"anim-fade-in",
|
|
"anim-rise",
|
|
"anim-scale-in",
|
|
"anim-slide-up",
|
|
"anim-drawer-in-start",
|
|
"anim-drawer-in-start-rtl",
|
|
]) {
|
|
expect(globals).toContain(`@keyframes ${name}`);
|
|
}
|
|
});
|
|
|
|
it("keeps each duration behind a custom property so one override retunes the app", () => {
|
|
for (const token of ["--anim-fast", "--anim-base", "--anim-slow", "--anim-ease"]) {
|
|
expect(globals).toContain(token);
|
|
}
|
|
// Every utility must read those tokens, not a hard-coded value.
|
|
expect(globals).not.toMatch(/animation:[^;]*\b\d+ms\b/);
|
|
});
|
|
|
|
it("honours prefers-reduced-motion", () => {
|
|
expect(globals).toContain("@media (prefers-reduced-motion: reduce)");
|
|
// Durations collapse, but the end state must still be reachable — so the
|
|
// animation is kept at a near-zero duration rather than being removed.
|
|
expect(globals).toMatch(/animation-duration:\s*0\.01ms !important/);
|
|
expect(globals).toMatch(/transition-duration:\s*0\.01ms !important/);
|
|
});
|
|
|
|
it("never animates a property that is not also stated by the end state", () => {
|
|
// `both` fill mode is what makes an animation land on its final frame even
|
|
// when the element is removed mid-flight or never re-rendered.
|
|
for (const utility of ["anim-fade-in", "anim-rise", "anim-scale-in", "anim-slide-up"]) {
|
|
expect(globals).toMatch(
|
|
new RegExp(`\\.${utility}\\s*\\{[^}]*both\\s*;`),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("animated surfaces", () => {
|
|
it("scales the modal panel in and fades its backdrop", () => {
|
|
const { container } = render(<Modal open title="title" onClose={() => undefined} />);
|
|
const panel = container.querySelector(".anim-scale-in");
|
|
expect(panel).not.toBeNull();
|
|
// The backdrop is the panel's parent: it owns the dialog role and fades.
|
|
const backdrop = panel?.parentElement;
|
|
expect(backdrop).toHaveAttribute("role", "dialog");
|
|
expect(backdrop?.className).toContain("anim-fade-in");
|
|
});
|
|
|
|
it("slides the drawer in from its edge", () => {
|
|
const { container } = render(
|
|
<Drawer open title="details" onClose={() => undefined}>
|
|
<p>body</p>
|
|
</Drawer>,
|
|
);
|
|
expect(container.querySelector("aside.anim-drawer-start")).not.toBeNull();
|
|
});
|
|
|
|
it("slides each toast up", () => {
|
|
const { container } = render(
|
|
<ToastStack toasts={[{ id: "t1", title: "saved" }]} onDismiss={() => undefined} />,
|
|
);
|
|
expect(container.querySelector(".anim-slide-up")).not.toBeNull();
|
|
});
|
|
|
|
it("staggers table rows", () => {
|
|
const { container } = render(
|
|
<Table columns={columns} rows={ROWS} rowKey={(row) => row.id} />,
|
|
);
|
|
expect(container.querySelector("tbody.anim-stagger")).not.toBeNull();
|
|
});
|
|
|
|
it("rises the summary and async states", () => {
|
|
const states = [
|
|
render(<EmptyState title="nothing here" />),
|
|
render(<ErrorState title="failed" />),
|
|
render(<Stat label="credit" value="0" />),
|
|
];
|
|
for (const { container } of states) {
|
|
expect(container.querySelector(".anim-rise")).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
it("staggers the timeline and transitions the usage fill", () => {
|
|
const timeline = render(<Timeline items={[{ id: "a", title: "event" }]} />);
|
|
expect(timeline.container.querySelector("ol.anim-stagger")).not.toBeNull();
|
|
|
|
const bar = render(<UsageBar used={40} total={100} />);
|
|
expect(bar.container.querySelector(".transition-\\[width\\]")).not.toBeNull();
|
|
});
|
|
});
|