افزودن سیستم 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
+1 -1
View File
@@ -341,7 +341,7 @@ export default function AdminErrorsPage() {
const tiles = summary?.open_by_severity ?? {};
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کنسول خطا</h1>
<p className="text-sm text-slate-400">
+1 -1
View File
@@ -41,7 +41,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<nav className="flex flex-wrap gap-1 border-b border-surface-border">
{SECTIONS.map((section) => {
const active =
+2 -2
View File
@@ -99,7 +99,7 @@ export default function AdminModelsPage() {
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مدل‌ها</h1>
<p className="text-sm text-slate-400">
@@ -214,7 +214,7 @@ export default function AdminModelsPage() {
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
</tr>
</thead>
<tbody>
<tbody className="anim-stagger">
{models.map((model) => (
<tr key={model.id} className="border-b border-surface-border/60">
<td className="px-2 py-2">
@@ -39,7 +39,7 @@ export default function AdminOrganizationsPage() {
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">سازمان‌ها</h1>
<p className="text-sm text-slate-400">
@@ -74,7 +74,7 @@ export default function AdminOrganizationsPage() {
<th className="px-2 py-2 text-right font-medium">مصرف</th>
</tr>
</thead>
<tbody>
<tbody className="anim-stagger">
{organizations.map((org) => {
const row = usageFor(org.id);
return (
+1 -1
View File
@@ -48,7 +48,7 @@ export default function AdminOverviewPage() {
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
<p className="text-sm text-slate-400">
@@ -72,7 +72,7 @@ export default function AdminProvidersPage() {
};
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">ارائه‌دهنده‌ها</h1>
<p className="text-sm text-slate-400">
@@ -104,7 +104,7 @@ export default function AdminRateCardsPage() {
};
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">تعرفه‌ها</h1>
<p className="text-sm text-slate-400">
@@ -191,7 +191,7 @@ export default function AdminRateCardsPage() {
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
</tr>
</thead>
<tbody>
<tbody className="anim-stagger">
{cards.map((card) => {
const margin = card.customer_price - card.internal_cost_estimate;
const marginPct =
@@ -100,7 +100,7 @@ export default function AdminRoutingPage() {
};
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">سیاست مسیریابی</h1>
<p className="text-sm text-slate-400">
@@ -195,7 +195,7 @@ export default function AdminRoutingPage() {
description="بدون سیاست پیش‌فرض، سامانه به حالت متعادل برمی‌گردد."
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
<div className="anim-stagger grid gap-3 md:grid-cols-2">
{policies.map((policy) => (
<Card key={policy.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
+1 -1
View File
@@ -82,7 +82,7 @@ export default function AdminUsagePage() {
];
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مصرف سراسری</h1>
<p className="text-sm text-slate-400">
+1 -1
View File
@@ -153,7 +153,7 @@ export default function BillingPage() {
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مالی و مصرف</h1>
<p className="text-sm text-slate-400">
+1 -1
View File
@@ -68,7 +68,7 @@ export default function DashboardPage() {
const databaseOk = readiness?.checks.database === "ok";
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">
{activeOrganization.name}
+2 -2
View File
@@ -169,7 +169,7 @@ export default function ManagerPage() {
if (error) return <ErrorState message={error} onRetry={load} />;
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<div className="anim-stagger flex h-[calc(100vh-8rem)] flex-col gap-4">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">گفتگو با مدیر</h1>
@@ -225,7 +225,7 @@ export default function ManagerPage() {
<div
key={message.id}
className={
"max-w-[85%] rounded-xl border px-3 py-2 text-sm " +
"anim-rise max-w-[85%] rounded-xl border px-3 py-2 text-sm " +
(message.role === "user"
? "self-end border-accent/40 bg-accent-soft text-slate-100"
: "self-start border-surface-border bg-surface-overlay text-slate-300")
@@ -84,7 +84,7 @@ export default function ProjectDetailPage({
}
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/projects" className="hover:text-slate-300">
پروژه‌ها
@@ -408,7 +408,7 @@ function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: st
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
<div className="anim-stagger grid gap-3 md:grid-cols-2">
{domains.map((domain) => (
<Card key={domain.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
+2 -2
View File
@@ -76,7 +76,7 @@ export default function ProjectsPage() {
}
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">پروژه‌ها</h1>
<p className="text-sm text-slate-400">
@@ -126,7 +126,7 @@ export default function ProjectsPage() {
description="اولین پروژه را با فرم بالا بسازید؛ سپس می‌توانید مخزن Git و دامنهٔ تست بسازید."
/>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Card key={project.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
+1 -1
View File
@@ -178,7 +178,7 @@ export default function SettingsPage() {
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">تنظیمات سازمان</h1>
<p className="text-sm text-slate-400">{activeOrganization.name}</p>
+1 -1
View File
@@ -181,7 +181,7 @@ export default function TaskDetailPage({
const runIsLive = selectedRun ? !TERMINAL.has(selectedRun.status) : false;
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/tasks" className="hover:text-slate-300">
کارها
+1 -1
View File
@@ -116,7 +116,7 @@ export default function TasksPage() {
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارها</h1>
+2 -2
View File
@@ -183,7 +183,7 @@ export default function TracesPage() {
const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length;
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">ردیابی و رویدادها</h1>
<p className="text-sm text-slate-400">
@@ -206,7 +206,7 @@ export default function TracesPage() {
{rows.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
) : (
<ul className="mt-3 flex flex-col gap-2">
<ul className="anim-stagger mt-3 flex flex-col gap-2">
{rows.map(({ run, taskId, taskTitle }) => (
<li key={run.id}>
<button
@@ -192,7 +192,7 @@ export default function EmployeeDetailPage({
const allowedTools = employee.tool_permissions.filter((p) => p.allowed);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500">
<Link href="/workforce" className="hover:text-slate-300">
کارمندهای هوش مصنوعی
+2 -2
View File
@@ -152,7 +152,7 @@ export default function WorkforcePage() {
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
return (
<div className="flex flex-col gap-5">
<div className="anim-stagger flex flex-col gap-5">
<header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
@@ -227,7 +227,7 @@ export default function WorkforcePage() {
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
/>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="anim-stagger grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{employees.map((employee) => {
const detail = details[employee.id];
return (
+167
View File
@@ -34,3 +34,170 @@ body {
::selection {
background-color: #2b3b63;
}
/* ---------------------------------------------------------------------------
* Motion vocabulary.
*
* One set of keyframes and one fixed set of utility classes, so every surface
* animates with the same duration, easing and character. The UI is dark and
* flat by rule, which means motion is the only depth cue it has — so it stays
* short and purposeful rather than decorative.
*
* Every duration sits behind a custom property so a single override (for
* `prefers-reduced-motion`, or a global speed pass) retunes the whole app.
* --------------------------------------------------------------------------- */
:root {
--anim-fast: 120ms;
--anim-base: 180ms;
--anim-slow: 260ms;
--anim-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--anim-stagger: 45ms;
}
@keyframes anim-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes anim-rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes anim-scale-in {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes anim-slide-up {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/*
* Drawers sit against an edge, so they enter from that edge. The direction is
* expressed with `[dir]` rather than `margin-inline` because transforms are
* the honest way to say "slide in from off-canvas".
*/
@keyframes anim-drawer-in-start {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes anim-drawer-in-start-rtl {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
/* --- primitives ---------------------------------------------------------- */
.anim-fade-in {
animation: anim-fade-in var(--anim-base) var(--anim-ease) both;
}
.anim-rise {
animation: anim-rise var(--anim-base) var(--anim-ease) both;
}
.anim-scale-in {
animation: anim-scale-in var(--anim-base) var(--anim-ease) both;
}
.anim-slide-up {
animation: anim-slide-up var(--anim-slow) var(--anim-ease) both;
}
.anim-drawer-start {
animation: anim-drawer-in-start var(--anim-slow) var(--anim-ease) both;
}
[dir="rtl"] .anim-drawer-start {
animation: anim-drawer-in-start-rtl var(--anim-slow) var(--anim-ease) both;
}
/*
* Direct children animate in sequence. Holding the `from` state with `both`
* keeps a child invisible until its delay elapses, otherwise the cascade
* collapses into every item appearing at once.
*/
.anim-stagger > * {
animation: anim-rise var(--anim-slow) var(--anim-ease) both;
}
.anim-stagger > *:nth-child(1) {
animation-delay: calc(var(--anim-stagger) * 0);
}
.anim-stagger > *:nth-child(2) {
animation-delay: calc(var(--anim-stagger) * 1);
}
.anim-stagger > *:nth-child(3) {
animation-delay: calc(var(--anim-stagger) * 2);
}
.anim-stagger > *:nth-child(4) {
animation-delay: calc(var(--anim-stagger) * 3);
}
.anim-stagger > *:nth-child(5) {
animation-delay: calc(var(--anim-stagger) * 4);
}
.anim-stagger > *:nth-child(6) {
animation-delay: calc(var(--anim-stagger) * 5);
}
.anim-stagger > *:nth-child(7) {
animation-delay: calc(var(--anim-stagger) * 6);
}
.anim-stagger > *:nth-child(8) {
animation-delay: calc(var(--anim-stagger) * 7);
}
.anim-stagger > *:nth-child(9) {
animation-delay: calc(var(--anim-stagger) * 8);
}
.anim-stagger > *:nth-child(n + 10) {
animation-delay: calc(var(--anim-stagger) * 9);
}
/*
* Motion is a affordance here, never the only signal — every animation states
* its end point too. Anyone who asks for less motion gets exactly that.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-sm">
<Card className="anim-rise w-full max-w-sm">
<CardTitle>ورود به Smart Organization</CardTitle>
<CardHint>با ایمیل و رمز عبور خود وارد شوید.</CardHint>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-md">
<Card className="anim-rise w-full max-w-md">
<CardTitle>ساخت اولین سازمان</CardTitle>
<CardHint>
سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف
+1 -1
View File
@@ -32,7 +32,7 @@ export default function SignupPage() {
return (
<div className="flex min-h-screen items-center justify-center p-5">
<Card className="w-full max-w-sm">
<Card className="anim-rise w-full max-w-sm">
<CardTitle>ساخت حساب</CardTitle>
<CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint>