feat: add semantic motion system (#1320)

* feat: add semantic motion system

* feat: animate session workspace transitions

* feat: refine motion accessibility and transfer feedback
This commit is contained in:
ZacharyZcR
2026-08-24 19:36:10 +08:00
committed by GitHub
parent fafd428072
commit 69002e6416
15 changed files with 569 additions and 90 deletions
@@ -1,5 +1,7 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface DownloadProgressToastProps {
fileName: string;
@@ -22,26 +24,6 @@ function formatBytes(bytes: number): string {
return `${formattedSize} ${units[unitIndex]}`;
}
function IndeterminateProgressBar() {
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
</div>
);
}
function DeterminateProgressBar({ value }: { value: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export function DownloadProgressToast({
fileName,
loaded,
@@ -58,14 +40,16 @@ export function DownloadProgressToast({
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<p className="text-sm font-medium leading-tight truncate">
{t("fileManager.downloadingFile", { name: fileName })}
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">
{t("fileManager.downloadingFile", { name: fileName })}
</span>
</p>
{percent === undefined ? (
<IndeterminateProgressBar />
) : (
<DeterminateProgressBar value={percent} />
)}
<TransferProgressBar
value={percent}
label={t("fileManager.downloadingFile", { name: fileName })}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{total !== undefined
@@ -0,0 +1,42 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
export function TransferProgressBar({
value,
label,
stalled = false,
}: {
value?: number;
label: string;
stalled?: boolean;
}) {
const clamped = Math.min(100, Math.max(0, value ?? 0));
const indeterminate = value === undefined;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
aria-busy={indeterminate}
data-stalled={stalled || undefined}
className="transfer-progress-track"
>
<div
className={cn(
"transfer-progress-fill",
indeterminate && "transfer-progress-indeterminate",
stalled && "transfer-progress-stalled",
)}
style={
indeterminate
? undefined
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
}
/>
</div>
);
}
@@ -5,6 +5,15 @@ import {
type TransferProgressResponse,
} from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import {
Archive,
ArchiveRestore,
ArrowRight,
Gauge,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface TransferProgressToastProps {
status: TransferProgressResponse;
@@ -15,26 +24,6 @@ interface TransferProgressToastProps {
cancelling?: boolean;
}
function IndeterminateProgressBar() {
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
</div>
);
}
function DeterminateProgressBar({ value }: { value: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export function TransferProgressToast({
status,
liveMbPerSec,
@@ -103,11 +92,29 @@ export function TransferProgressToast({
const showIndeterminate =
status.phase === "reconnecting" || percent === undefined;
const PhaseIcon =
status.phase === "compressing"
? Archive
: status.phase === "benchmarking"
? Gauge
: status.phase === "verifying"
? ShieldCheck
: status.phase === "extracting"
? ArchiveRestore
: status.phase === "reconnecting"
? RefreshCw
: ArrowRight;
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-tight">{title}</p>
<p
key={status.phase}
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
>
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">{title}</span>
</p>
{onCancel && status.status === "running" && status.transferId && (
<Button
type="button"
@@ -123,11 +130,11 @@ export function TransferProgressToast({
</Button>
)}
</div>
{showIndeterminate ? (
<IndeterminateProgressBar />
) : (
<DeterminateProgressBar value={percent} />
)}
<TransferProgressBar
value={showIndeterminate ? undefined : percent}
label={title}
stalled={stalled}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
<span