افزودن سیستم 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
+15 -1
View File
@@ -3,7 +3,7 @@
> **وضعیت (به‌روزرسانی نهایی):** پیاده‌سازی کامل شده است. > **وضعیت (به‌روزرسانی نهایی):** پیاده‌سازی کامل شده است.
> >
> - بک‌اند: **۲۱۶ تست سبز**، `ruff check` تمیز، `mypy` روی **۹۰ فایل** بدون خطا. > - بک‌اند: **۲۱۶ تست سبز**، `ruff check` تمیز، `mypy` روی **۹۰ فایل** بدون خطا.
> - فرانت‌اند: **۲۵ تست سبز**، `next lint` بدون هشدار، `tsc --noEmit` تمیز. > - فرانت‌اند: **۴۵ تست سبز**، `next lint` بدون هشدار، `tsc --noEmit` تمیز.
> - دو migration در `apps/api/alembic/versions/` ساخته و با `alembic check` > - دو migration در `apps/api/alembic/versions/` ساخته و با `alembic check`
> تأیید شد که هیچ drift ندارد. مسیر upgrade و downgrade هر دو تست شده‌اند. > تأیید شد که هیچ drift ندارد. مسیر upgrade و downgrade هر دو تست شده‌اند.
> - workerها (`billing-worker`, `ai-runtime`, `sandbox-gateway`) واقعی و تست‌شده‌اند. > - workerها (`billing-worker`, `ai-runtime`, `sandbox-gateway`) واقعی و تست‌شده‌اند.
@@ -15,6 +15,20 @@
> `error_events` ذخیره می‌شود و در `/admin/errors` قابل مشاهده و رسیدگی است. > `error_events` ذخیره می‌شود و در `/admin/errors` قابل مشاهده و رسیدگی است.
> هیچ خطایی بدون پاک‌سازی secret ذخیره نمی‌شود (`app/core/redact.py`). > هیچ خطایی بدون پاک‌سازی secret ذخیره نمی‌شود (`app/core/redact.py`).
> >
> **افزودهٔ بعد از بستن مراحل (درخواست کاربر):** پالت فرمان (`⌘K`) و صفحهٔ
> `/admin/usage`.
>
> **افزودهٔ بعد از بستن مراحل (درخواست کاربر):** Motion system. یک واژگان
> مشترک در `apps/web/src/app/globals.css` — شش `@keyframes` و شش کلاس
> `.anim-*` به‌همراه `.anim-stagger` برای ورود آبشاری — و همهٔ مدت‌زمان‌ها
> پشت custom property. در `packages/ui/src/index.tsx` روی Button، Card،
> Stat، Loading/Empty/ErrorState، UsageBar، Timeline، Modal، Drawer، Table،
> ToastStack و ArtifactViewer اعمال شده و ریشهٔ هر صفحهٔ `(app)` با
> `.anim-stagger` وارد می‌شود. `prefers-reduced-motion` مدت‌زمان‌ها را به
> ۰.۰۱ms می‌رساند تا وضعیت پایانی حفظ شود. قرارداد motion در
> `src/test/motion.test.tsx` Tests می‌شود تا بازنویسی بعدی آن را بی‌صدا
> حذف نکند.
>
> **چیزهایی که عمداً باز مانده‌اند:** در `docs/security-checklist.md` دو مورد > **چیزهایی که عمداً باز مانده‌اند:** در `docs/security-checklist.md` دو مورد
> «⚠️ بدون تست اختصاصی» علامت خورده‌اند (نبود secret در frontend، و مقدار پیش‌فرض > «⚠️ بدون تست اختصاصی» علامت خورده‌اند (نبود secret در frontend، و مقدار پیش‌فرض
> `NetworkPolicy.DENY_ALL`). هر دو کنترل در کد درست کار می‌کنند، فقط تست مستقل > `NetworkPolicy.DENY_ALL`). هر دو کنترل در کد درست کار می‌کنند، فقط تست مستقل
+1 -1
View File
@@ -341,7 +341,7 @@ export default function AdminErrorsPage() {
const tiles = summary?.open_by_severity ?? {}; const tiles = summary?.open_by_severity ?? {};
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کنسول خطا</h1> <h1 className="text-lg font-medium text-slate-100">کنسول خطا</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
+1 -1
View File
@@ -41,7 +41,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
} }
return ( 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"> <nav className="flex flex-wrap gap-1 border-b border-surface-border">
{SECTIONS.map((section) => { {SECTIONS.map((section) => {
const active = 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; const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مدل‌ها</h1> <h1 className="text-lg font-medium text-slate-100">مدل‌ها</h1>
<p className="text-sm text-slate-400"> <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> <th className="px-2 py-2 text-right font-medium">وضعیت</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="anim-stagger">
{models.map((model) => ( {models.map((model) => (
<tr key={model.id} className="border-b border-surface-border/60"> <tr key={model.id} className="border-b border-surface-border/60">
<td className="px-2 py-2"> <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); const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">سازمان‌ها</h1> <h1 className="text-lg font-medium text-slate-100">سازمان‌ها</h1>
<p className="text-sm text-slate-400"> <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> <th className="px-2 py-2 text-right font-medium">مصرف</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="anim-stagger">
{organizations.map((org) => { {organizations.map((org) => {
const row = usageFor(org.id); const row = usageFor(org.id);
return ( return (
+1 -1
View File
@@ -48,7 +48,7 @@ export default function AdminOverviewPage() {
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0); const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1> <h1 className="text-lg font-medium text-slate-100">کنسول مالک سامانه</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
@@ -72,7 +72,7 @@ export default function AdminProvidersPage() {
}; };
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">ارائه‌دهنده‌ها</h1> <h1 className="text-lg font-medium text-slate-100">ارائه‌دهنده‌ها</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
@@ -104,7 +104,7 @@ export default function AdminRateCardsPage() {
}; };
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">تعرفه‌ها</h1> <h1 className="text-lg font-medium text-slate-100">تعرفه‌ها</h1>
<p className="text-sm text-slate-400"> <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> <th className="px-2 py-2 text-right font-medium">وضعیت</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="anim-stagger">
{cards.map((card) => { {cards.map((card) => {
const margin = card.customer_price - card.internal_cost_estimate; const margin = card.customer_price - card.internal_cost_estimate;
const marginPct = const marginPct =
@@ -100,7 +100,7 @@ export default function AdminRoutingPage() {
}; };
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">سیاست مسیریابی</h1> <h1 className="text-lg font-medium text-slate-100">سیاست مسیریابی</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
@@ -195,7 +195,7 @@ export default function AdminRoutingPage() {
description="بدون سیاست پیش‌فرض، سامانه به حالت متعادل برمی‌گردد." description="بدون سیاست پیش‌فرض، سامانه به حالت متعادل برمی‌گردد."
/> />
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="anim-stagger grid gap-3 md:grid-cols-2">
{policies.map((policy) => ( {policies.map((policy) => (
<Card key={policy.id} className="flex flex-col gap-2"> <Card key={policy.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
+1 -1
View File
@@ -82,7 +82,7 @@ export default function AdminUsagePage() {
]; ];
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مصرف سراسری</h1> <h1 className="text-lg font-medium text-slate-100">مصرف سراسری</h1>
<p className="text-sm text-slate-400"> <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); const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مالی و مصرف</h1> <h1 className="text-lg font-medium text-slate-100">مالی و مصرف</h1>
<p className="text-sm text-slate-400"> <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"; const databaseOk = readiness?.checks.database === "ok";
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100"> <h1 className="text-lg font-medium text-slate-100">
{activeOrganization.name} {activeOrganization.name}
+2 -2
View File
@@ -169,7 +169,7 @@ export default function ManagerPage() {
if (error) return <ErrorState message={error} onRetry={load} />; if (error) return <ErrorState message={error} onRetry={load} />;
return ( 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"> <header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">گفتگو با مدیر</h1> <h1 className="text-lg font-medium text-slate-100">گفتگو با مدیر</h1>
@@ -225,7 +225,7 @@ export default function ManagerPage() {
<div <div
key={message.id} key={message.id}
className={ 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" (message.role === "user"
? "self-end border-accent/40 bg-accent-soft text-slate-100" ? "self-end border-accent/40 bg-accent-soft text-slate-100"
: "self-start border-surface-border bg-surface-overlay text-slate-300") : "self-start border-surface-border bg-surface-overlay text-slate-300")
@@ -84,7 +84,7 @@ export default function ProjectDetailPage({
} }
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500"> <nav className="text-xs text-slate-500">
<Link href="/projects" className="hover:text-slate-300"> <Link href="/projects" className="hover:text-slate-300">
پروژه‌ها پروژه‌ها
@@ -408,7 +408,7 @@ function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: st
description="برای انتشار موقت پروژه، یک زیردامنه بسازید." description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
/> />
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2"> <div className="anim-stagger grid gap-3 md:grid-cols-2">
{domains.map((domain) => ( {domains.map((domain) => (
<Card key={domain.id} className="flex flex-col gap-3"> <Card key={domain.id} className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
+2 -2
View File
@@ -76,7 +76,7 @@ export default function ProjectsPage() {
} }
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">پروژه‌ها</h1> <h1 className="text-lg font-medium text-slate-100">پروژه‌ها</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
@@ -126,7 +126,7 @@ export default function ProjectsPage() {
description="اولین پروژه را با فرم بالا بسازید؛ سپس می‌توانید مخزن Git و دامنهٔ تست بسازید." 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) => ( {projects.map((project) => (
<Card key={project.id} className="flex flex-col gap-3"> <Card key={project.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1"> <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); const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">تنظیمات سازمان</h1> <h1 className="text-lg font-medium text-slate-100">تنظیمات سازمان</h1>
<p className="text-sm text-slate-400">{activeOrganization.name}</p> <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; const runIsLive = selectedRun ? !TERMINAL.has(selectedRun.status) : false;
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500"> <nav className="text-xs text-slate-500">
<Link href="/tasks" className="hover:text-slate-300"> <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); const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
return ( 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"> <header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارها</h1> <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; const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length;
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">ردیابی و رویدادها</h1> <h1 className="text-lg font-medium text-slate-100">ردیابی و رویدادها</h1>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
@@ -206,7 +206,7 @@ export default function TracesPage() {
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p> <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 }) => ( {rows.map(({ run, taskId, taskTitle }) => (
<li key={run.id}> <li key={run.id}>
<button <button
@@ -192,7 +192,7 @@ export default function EmployeeDetailPage({
const allowedTools = employee.tool_permissions.filter((p) => p.allowed); const allowedTools = employee.tool_permissions.filter((p) => p.allowed);
return ( return (
<div className="flex flex-col gap-5"> <div className="anim-stagger flex flex-col gap-5">
<nav className="text-xs text-slate-500"> <nav className="text-xs text-slate-500">
<Link href="/workforce" className="hover:text-slate-300"> <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); const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
return ( 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"> <header className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1> <h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
@@ -227,7 +227,7 @@ export default function WorkforcePage() {
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود." 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) => { {employees.map((employee) => {
const detail = details[employee.id]; const detail = details[employee.id];
return ( return (
+167
View File
@@ -34,3 +34,170 @@ body {
::selection { ::selection {
background-color: #2b3b63; 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 ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <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> <CardTitle>ورود به Smart Organization</CardTitle>
<CardHint>با ایمیل و رمز عبور خود وارد شوید.</CardHint> <CardHint>با ایمیل و رمز عبور خود وارد شوید.</CardHint>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <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> <CardTitle>ساخت اولین سازمان</CardTitle>
<CardHint> <CardHint>
سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف سازمان ظرف اصلی محصول است: اعضا، پروژه‌ها، کارمندهای هوش مصنوعی و مصرف
+1 -1
View File
@@ -32,7 +32,7 @@ export default function SignupPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center p-5"> <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> <CardTitle>ساخت حساب</CardTitle>
<CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint> <CardHint>پس از ثبت‌نام، اولین سازمان خود را می‌سازید.</CardHint>
+4 -4
View File
@@ -66,7 +66,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<span className="text-sm font-medium text-slate-100">Smart Organization</span> <span className="text-sm font-medium text-slate-100">Smart Organization</span>
</div> </div>
<nav className="flex flex-1 flex-col gap-1 p-3"> <nav className="anim-stagger flex flex-1 flex-col gap-1 p-3">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return ( return (
@@ -126,7 +126,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{activeOrganization?.name ?? "بدون سازمان"} {activeOrganization?.name ?? "بدون سازمان"}
</Button> </Button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-10 z-40 w-64 rounded-lg border border-surface-border bg-surface-overlay p-1.5"> <div className="anim-slide-up absolute right-0 top-10 z-40 w-64 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
{organizations.length === 0 && ( {organizations.length === 0 && (
<p className="px-2 py-2 text-xs text-slate-400">هنوز سازمانی نساخته‌اید.</p> <p className="px-2 py-2 text-xs text-slate-400">هنوز سازمانی نساخته‌اید.</p>
)} )}
@@ -179,7 +179,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>} {user?.is_platform_owner && <Badge tone="info">مالک سامانه</Badge>}
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5"> <div className="anim-slide-up absolute end-0 top-11 z-40 w-56 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">
<p className="truncate text-xs text-slate-200"> <p className="truncate text-xs text-slate-200">
{user?.name || "کاربر"} {user?.name || "کاربر"}
@@ -215,7 +215,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<main className="min-w-0 flex-1 p-5">{children}</main> <main className="anim-fade-in min-w-0 flex-1 p-5">{children}</main>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -173,7 +173,7 @@ export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?:
<EmptyState title="نتیجه‌ای پیدا نشد" description="عبارت دیگری را امتحان کنید." /> <EmptyState title="نتیجه‌ای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
) : ( ) : (
filteredGroups.map((group) => ( filteredGroups.map((group) => (
<div key={group.title} className="flex flex-col gap-1"> <div key={group.title} className="anim-stagger flex flex-col gap-1">
<p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p> <p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p>
{group.items.map((item) => { {group.items.map((item) => {
const index = visible.indexOf(item); const index = visible.indexOf(item);
+140
View File
@@ -0,0 +1,140 @@
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();
});
});
+22 -19
View File
@@ -55,7 +55,9 @@ export function Button({
{...rest} {...rest}
disabled={disabled || loading} disabled={disabled || loading}
className={cx( 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", "focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
BUTTON_VARIANTS[variant], BUTTON_VARIANTS[variant],
@@ -88,6 +90,7 @@ export function Card({
{...rest} {...rest}
className={cx( className={cx(
"rounded-xl border border-surface-border bg-surface-raised p-5", "rounded-xl border border-surface-border bg-surface-raised p-5",
"transition-colors duration-200",
className, className,
)} )}
> >
@@ -148,7 +151,7 @@ export function Stat({
tone?: Tone; tone?: Tone;
}) { }) {
return ( 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="text-xs text-slate-400">{label}</p>
<p <p
className={cx( className={cx(
@@ -184,7 +187,7 @@ export function Field({ label, hint, error, className, id, ...rest }: FieldProps
id={inputId} id={inputId}
{...rest} {...rest}
className={cx( 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", "placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
error ? "border-danger" : "border-surface-border", error ? "border-danger" : "border-surface-border",
className, className,
@@ -219,7 +222,7 @@ export function Select({ label, hint, error, options, className, id, ...rest }:
id={selectId} id={selectId}
{...rest} {...rest}
className={cx( 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", "focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
error ? "border-danger" : "border-surface-border", error ? "border-danger" : "border-surface-border",
className, className,
@@ -266,7 +269,7 @@ export function Checkbox({
export function LoadingState({ label = "در حال بارگذاری…" }: { label?: string }) { export function LoadingState({ label = "در حال بارگذاری…" }: { label?: string }) {
return ( 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> <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-1/3 animate-pulse rounded bg-surface-overlay" />
<div className="h-4 w-2/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; action?: React.ReactNode;
}) { }) {
return ( 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> <p className="text-sm font-medium text-slate-200">{title}</p>
{description && <p className="max-w-md text-xs text-slate-400">{description}</p>} {description && <p className="max-w-md text-xs text-slate-400">{description}</p>}
{action} {action}
@@ -305,7 +308,7 @@ export function ErrorState({
return ( return (
<div <div
role="alert" 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> <p className="text-sm font-medium text-danger">{title}</p>
{message && <p className="text-xs text-slate-300">{message}</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="h-2 w-full overflow-hidden rounded-full bg-surface-overlay">
<div <div
className={cx( 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", pct > 90 ? "bg-danger" : pct > 70 ? "bg-warning" : "bg-accent",
)} )}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
@@ -365,7 +368,7 @@ export function Timeline({ items }: { items: TimelineItem[] }) {
return <p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>; return <p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>;
} }
return ( return (
<ol className="flex flex-col gap-3"> <ol className="anim-stagger flex flex-col gap-3">
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="flex gap-3"> <li key={item.id} className="flex gap-3">
<span <span
@@ -485,9 +488,9 @@ export function ConfirmDialog({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} 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> <p className="text-sm font-medium text-slate-100">{title}</p>
{description && <p className="mt-2 text-xs text-slate-400">{description}</p>} {description && <p className="mt-2 text-xs text-slate-400">{description}</p>}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
@@ -536,14 +539,14 @@ export function Modal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} 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} onClick={onClose}
> >
<div <div
// Clicks inside must not bubble up and close the dialog. // Clicks inside must not bubble up and close the dialog.
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
className={cx( 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], MODAL_SIZES[size],
)} )}
> >
@@ -583,13 +586,13 @@ export function Drawer({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} 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} onClick={onClose}
> >
<aside <aside
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
className={cx( 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", side === "start" ? "border-l" : "border-r",
)} )}
> >
@@ -658,7 +661,7 @@ export function Table<T>({
); );
} }
return ( 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"> <table className="w-full border-collapse text-sm">
{caption && <caption className="sr-only">{caption}</caption>} {caption && <caption className="sr-only">{caption}</caption>}
<thead> <thead>
@@ -677,7 +680,7 @@ export function Table<T>({
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="anim-stagger">
{rows.map((row) => ( {rows.map((row) => (
<tr key={rowKey(row)} className="border-t border-surface-border"> <tr key={rowKey(row)} className="border-t border-surface-border">
{columns.map((column) => ( {columns.map((column) => (
@@ -775,7 +778,7 @@ export function ToastStack({
key={toast.id} key={toast.id}
role="status" role="status"
className={cx( 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"], TONES[toast.tone ?? "neutral"],
)} )}
> >
@@ -817,7 +820,7 @@ export function ArtifactViewer({
createdAt?: string; createdAt?: string;
}) { }) {
return ( 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 justify-between gap-3 border-b border-surface-border px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-200">{name}</span> <span className="text-xs font-medium text-slate-200">{name}</span>