افزودن سیستم 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:
@@ -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">
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
کارها
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
کارمندهای هوش مصنوعی
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
سازمان ظرف اصلی محصول است: اعضا، پروژهها، کارمندهای هوش مصنوعی و مصرف
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="text-sm font-medium text-slate-100">Smart Organization</span>
|
||||
</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) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
@@ -126,7 +126,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{activeOrganization?.name ?? "بدون سازمان"}
|
||||
</Button>
|
||||
{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 && (
|
||||
<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>}
|
||||
</button>
|
||||
{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">
|
||||
<p className="truncate text-xs text-slate-200">
|
||||
{user?.name || "کاربر"}
|
||||
@@ -215,7 +215,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -173,7 +173,7 @@ export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?:
|
||||
<EmptyState title="نتیجهای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
|
||||
) : (
|
||||
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>
|
||||
{group.items.map((item) => {
|
||||
const index = visible.indexOf(item);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user