افزودن سیستم Motion به تمام بخش‌های رابط کاربری

واژگان مشترک حرکت در 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 تست می‌شود تا
  بازنویسی بعدی آن را بی‌صدا حذف نکند
- تست‌های فرانت‌اند: ۳۵ ← ۴۵
This commit is contained in:
mjalmoudy94
2026-10-13 02:48:59 +03:30
parent 55b9055b50
commit df2c8546db
29 changed files with 381 additions and 57 deletions
+22 -19
View File
@@ -55,7 +55,9 @@ export function Button({
{...rest}
disabled={disabled || loading}
className={cx(
"inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition-colors",
"inline-flex items-center justify-center gap-2 rounded-lg border font-medium",
"transition-[color,background-color,border-color,transform] duration-150",
"active:scale-[0.97]",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
"disabled:cursor-not-allowed disabled:opacity-50",
BUTTON_VARIANTS[variant],
@@ -88,6 +90,7 @@ export function Card({
{...rest}
className={cx(
"rounded-xl border border-surface-border bg-surface-raised p-5",
"transition-colors duration-200",
className,
)}
>
@@ -148,7 +151,7 @@ export function Stat({
tone?: Tone;
}) {
return (
<div className="rounded-xl border border-surface-border bg-surface-raised p-4">
<div className="anim-rise rounded-xl border border-surface-border bg-surface-raised p-4">
<p className="text-xs text-slate-400">{label}</p>
<p
className={cx(
@@ -184,7 +187,7 @@ export function Field({ label, hint, error, className, id, ...rest }: FieldProps
id={inputId}
{...rest}
className={cx(
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100",
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100 transition-colors duration-150",
"placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
error ? "border-danger" : "border-surface-border",
className,
@@ -219,7 +222,7 @@ export function Select({ label, hint, error, options, className, id, ...rest }:
id={selectId}
{...rest}
className={cx(
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100",
"h-10 rounded-lg border bg-surface-overlay px-3 text-sm text-slate-100 transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
error ? "border-danger" : "border-surface-border",
className,
@@ -266,7 +269,7 @@ export function Checkbox({
export function LoadingState({ label = "در حال بارگذاری…" }: { label?: string }) {
return (
<div className="flex flex-col gap-3" role="status" aria-live="polite">
<div className="anim-fade-in flex flex-col gap-3" role="status" aria-live="polite">
<p className="text-xs text-slate-400">{label}</p>
<div className="h-4 w-1/3 animate-pulse rounded bg-surface-overlay" />
<div className="h-4 w-2/3 animate-pulse rounded bg-surface-overlay" />
@@ -285,7 +288,7 @@ export function EmptyState({
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-surface-border bg-surface-raised/50 px-6 py-12 text-center">
<div className="anim-rise flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-surface-border bg-surface-raised/50 px-6 py-12 text-center">
<p className="text-sm font-medium text-slate-200">{title}</p>
{description && <p className="max-w-md text-xs text-slate-400">{description}</p>}
{action}
@@ -305,7 +308,7 @@ export function ErrorState({
return (
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-xl border border-danger/40 bg-danger/5 px-5 py-4"
className="anim-rise flex flex-col items-start gap-3 rounded-xl border border-danger/40 bg-danger/5 px-5 py-4"
>
<p className="text-sm font-medium text-danger">{title}</p>
{message && <p className="text-xs text-slate-300">{message}</p>}
@@ -343,7 +346,7 @@ export function UsageBar({
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-overlay">
<div
className={cx(
"h-full rounded-full",
"h-full rounded-full transition-[width] duration-500 ease-out",
pct > 90 ? "bg-danger" : pct > 70 ? "bg-warning" : "bg-accent",
)}
style={{ width: `${pct}%` }}
@@ -365,7 +368,7 @@ export function Timeline({ items }: { items: TimelineItem[] }) {
return <p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>;
}
return (
<ol className="flex flex-col gap-3">
<ol className="anim-stagger flex flex-col gap-3">
{items.map((item) => (
<li key={item.id} className="flex gap-3">
<span
@@ -485,9 +488,9 @@ export function ConfirmDialog({
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
className="anim-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-raised p-5">
<div className="anim-scale-in w-full max-w-md rounded-xl border border-surface-border bg-surface-raised p-5">
<p className="text-sm font-medium text-slate-100">{title}</p>
{description && <p className="mt-2 text-xs text-slate-400">{description}</p>}
<div className="mt-4 flex justify-end gap-2">
@@ -536,14 +539,14 @@ export function Modal({
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
className="anim-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
// Clicks inside must not bubble up and close the dialog.
onClick={(event) => event.stopPropagation()}
className={cx(
"w-full rounded-xl border border-surface-border bg-surface-raised p-5",
"anim-scale-in w-full rounded-xl border border-surface-border bg-surface-raised p-5",
MODAL_SIZES[size],
)}
>
@@ -583,13 +586,13 @@ export function Drawer({
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-50 flex bg-black/60"
className="anim-fade-in fixed inset-0 z-50 flex bg-black/60"
onClick={onClose}
>
<aside
onClick={(event) => event.stopPropagation()}
className={cx(
"flex h-full w-full max-w-md flex-col border-surface-border bg-surface-raised",
"anim-drawer-start flex h-full w-full max-w-md flex-col border-surface-border bg-surface-raised",
side === "start" ? "border-l" : "border-r",
)}
>
@@ -658,7 +661,7 @@ export function Table<T>({
);
}
return (
<div className="overflow-x-auto rounded-xl border border-surface-border">
<div className="anim-fade-in overflow-x-auto rounded-xl border border-surface-border">
<table className="w-full border-collapse text-sm">
{caption && <caption className="sr-only">{caption}</caption>}
<thead>
@@ -677,7 +680,7 @@ export function Table<T>({
))}
</tr>
</thead>
<tbody>
<tbody className="anim-stagger">
{rows.map((row) => (
<tr key={rowKey(row)} className="border-t border-surface-border">
{columns.map((column) => (
@@ -775,7 +778,7 @@ export function ToastStack({
key={toast.id}
role="status"
className={cx(
"pointer-events-auto flex items-start justify-between gap-3 rounded-lg border px-4 py-3",
"anim-slide-up pointer-events-auto flex items-start justify-between gap-3 rounded-lg border px-4 py-3",
TONES[toast.tone ?? "neutral"],
)}
>
@@ -817,7 +820,7 @@ export function ArtifactViewer({
createdAt?: string;
}) {
return (
<div className="flex flex-col gap-2 rounded-xl border border-surface-border bg-surface-raised">
<div className="anim-fade-in flex flex-col gap-2 rounded-xl border border-surface-border bg-surface-raised">
<div className="flex items-center justify-between gap-3 border-b border-surface-border px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-200">{name}</span>