From 059470753ae8340ba709e32debc250fecc1cc4f2 Mon Sep 17 00:00:00 2001 From: smakosh Date: Sun, 26 Jul 2026 15:42:42 +0200 Subject: [PATCH] feat: motion polish across ui, playground, code Implements the 10 gated opportunities from the animation audit: press feedback on all shared buttons, popover transform-origin fix, symmetric toast exits, DevPass activation + Reset Pass purchase visa stamps, Lounge points odometer roll, copy-button crossfades, animated Chats collapse, CREDITS ADDED stamp-in, and census page stagger. All movement carries prefers-reduced-motion fallbacks. Claude-Session: https://claude.ai/code/session_01DobvVEwfVg89UUMzUBsxRF --- .../code/src/app/dashboard/DashboardShell.tsx | 55 ++++++-- .../app/dashboard/components/QuickStart.tsx | 21 ++- .../dashboard/components/ResetPassCard.tsx | 62 ++++++--- apps/code/src/app/data/[year]/page.tsx | 24 +++- .../src/components/CodingModelsShowcase.tsx | 23 +++- apps/code/src/components/ui/button.tsx | 2 +- .../src/components/ai-elements/code-block.tsx | 33 ++++- .../credits/top-up-credits-dialog.tsx | 21 ++- .../src/components/lounge/sidebar-points.tsx | 49 +++++-- .../components/playground/chat-sidebar.tsx | 128 +++++++++++------- apps/playground/src/components/ui/button.tsx | 2 +- apps/playground/src/components/ui/popover.tsx | 2 +- apps/ui/src/lib/components/button.tsx | 2 +- apps/ui/src/lib/components/toast.tsx | 2 +- apps/ui/src/lib/utils/markdown-code-block.tsx | 23 +++- packages/shared/src/components/ui/button.tsx | 2 +- 16 files changed, 329 insertions(+), 122 deletions(-) diff --git a/apps/code/src/app/dashboard/DashboardShell.tsx b/apps/code/src/app/dashboard/DashboardShell.tsx index 77bfe3b347..64fab55b11 100644 --- a/apps/code/src/app/dashboard/DashboardShell.tsx +++ b/apps/code/src/app/dashboard/DashboardShell.tsx @@ -12,6 +12,7 @@ import { Stamp, UserRound, } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -102,8 +103,8 @@ const setupActivationCopy: Record< "DevPass will activate as soon as Stripe confirms the payment.", }, success: { - title: "DevPass activated", - description: "Refreshing your dashboard.", + title: "Welcome aboard", + description: "Loading your dashboard.", }, error: { title: "Activation failed", @@ -217,6 +218,7 @@ export default function DashboardShell({ ); const [setupActivationStatus, setSetupActivationStatus] = useState(null); + const reduceMotion = useReducedMotion(); const activeSetupSession = useRef(null); const finalizeDevPlanRef = useRef(finalizeMutation.mutateAsync); const purchaseTrackedSession = useRef(null); @@ -308,13 +310,12 @@ export default function DashboardShell({ }; finalizeDevPlan() - .then((result) => { + .then(async (result) => { if (signal.aborted) { return; } if (result?.status === "ok" || result?.status === "already_processed") { setSetupActivationStatus("success"); - toast.success("DevPass activated"); if (purchaseTrackedSession.current !== sessionId) { purchaseTrackedSession.current = sessionId; const tier = devPlanStatusRef.current?.devPlan; @@ -336,6 +337,9 @@ export default function DashboardShell({ return Array.isArray(key) && key[1] === "/dev-plans/status"; }, }); + // Hold the success screen long enough for the stamp to land and + // be read before the setup param is cleared and the card unmounts. + await wait(1600, signal); } else if (result?.status === "payment_pending") { shouldClearSetupParam = false; setSetupActivationStatus("processing"); @@ -512,14 +516,41 @@ export default function DashboardShell({ {activeSetupActivationCopy ? (
-
- -
+ {activeSetupActivationStatus === "success" ? ( + +
+
+ + DevPass activated +
+
+ Visa granted · welcome aboard +
+
+
+ ) : ( +
+ +
+ )}

{activeSetupActivationCopy.title}

diff --git a/apps/code/src/app/dashboard/components/QuickStart.tsx b/apps/code/src/app/dashboard/components/QuickStart.tsx index 7611b6b024..69144c74bd 100644 --- a/apps/code/src/app/dashboard/components/QuickStart.tsx +++ b/apps/code/src/app/dashboard/components/QuickStart.tsx @@ -144,12 +144,21 @@ export default function QuickStart({ apiKey }: { apiKey: string }) { onClick={handleCopy} className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" > - {copied ? ( - - ) : ( - - )} - {copied ? "Copied" : "Copy"} + + + + + + {copied ? "Copied" : "Copy"} +
diff --git a/apps/code/src/app/dashboard/components/ResetPassCard.tsx b/apps/code/src/app/dashboard/components/ResetPassCard.tsx index 204b8a6d35..2282fb7794 100644 --- a/apps/code/src/app/dashboard/components/ResetPassCard.tsx +++ b/apps/code/src/app/dashboard/components/ResetPassCard.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { ArrowUpRight, Loader2, Plane, Stamp } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import Link from "next/link"; import { usePostHog } from "posthog-js/react"; import { useState } from "react"; @@ -114,7 +114,10 @@ export default function ResetPassCard({ const queryClient = useQueryClient(); const { uiUrl, posthogKey } = useAppConfig(); const posthog = usePostHog(); - const [justStamped, setJustStamped] = useState(false); + const [stampOverlay, setStampOverlay] = useState< + { kind: "redeemed" } | { kind: "purchased"; amount: number } | null + >(null); + const reduceMotion = useReducedMotion(); const available = includedRemaining + purchased; const nothingToReset = premiumCreditsUsed <= 0; @@ -158,8 +161,8 @@ export default function ResetPassCard({ "/dev-plans/reset-pass/redeem", { onSuccess: async () => { - setJustStamped(true); - setTimeout(() => setJustStamped(false), 2200); + setStampOverlay({ kind: "redeemed" }); + setTimeout(() => setStampOverlay(null), 2200); await invalidateStatus(); }, onError: () => { @@ -173,9 +176,8 @@ export default function ResetPassCard({ "/dev-plans/reset-pass/purchase", { onSuccess: async (data) => { - toast.success("Reset Pass stamped into your passport", { - description: `$${data.amount} was charged to your saved payment method.`, - }); + setStampOverlay({ kind: "purchased", amount: data.amount }); + setTimeout(() => setStampOverlay(null), 2200); await invalidateStatus(); }, onError: (error) => { @@ -203,24 +205,50 @@ export default function ResetPassCard({ return (
- {/* Full-card stamp slammed on a successful redeem */} + {/* Full-card stamp slammed on a successful redeem or purchase */} - {justStamped && ( + {stampOverlay && ( -
-
- Allowance restored + {stampOverlay.kind === "redeemed" ? ( +
+
+ Allowance restored +
+
+ Fresh week · full premium limit +
-
- Fresh week · full premium limit + ) : ( +
+
+ Pass acquired +
+
+ ${stampOverlay.amount} charged · redeem anytime +
-
+ )} )} diff --git a/apps/code/src/app/data/[year]/page.tsx b/apps/code/src/app/data/[year]/page.tsx index ddb342fe63..ab15ec77c7 100644 --- a/apps/code/src/app/data/[year]/page.tsx +++ b/apps/code/src/app/data/[year]/page.tsx @@ -88,8 +88,20 @@ function ScoreMeter({ label, value }: { label: string; value: number }) { function ModelRow({ model, rank }: { model: ModelSurveyModel; rank: number }) { const topUseCase = model.useCases[0]; + // Only the rows near the fold stagger in; deep rows render instantly so a + // long registry never feels like it's loading. + const staggered = rank <= 10; + const staggerStep = (rank - 1) * 70; + const delayMs = 140 + staggerStep; return ( -
+
-
+
{isCurrentYear @@ -232,8 +244,12 @@ export default async function CensusPage({ label: "Models on the registry", value: results.totalModelsRated, }, - ].map((stat) => ( -
+ ].map((stat, index) => ( +
{stat.value.toLocaleString()}
diff --git a/apps/code/src/components/CodingModelsShowcase.tsx b/apps/code/src/components/CodingModelsShowcase.tsx index 8623f8ac32..321bc326c1 100644 --- a/apps/code/src/components/CodingModelsShowcase.tsx +++ b/apps/code/src/components/CodingModelsShowcase.tsx @@ -282,14 +282,25 @@ export function CodingModelsShowcase({
diff --git a/apps/code/src/components/ui/button.tsx b/apps/code/src/components/ui/button.tsx index 132f441df5..d55e9bab27 100644 --- a/apps/code/src/components/ui/button.tsx +++ b/apps/code/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", { variants: { variant: { diff --git a/apps/playground/src/components/ai-elements/code-block.tsx b/apps/playground/src/components/ai-elements/code-block.tsx index ea83de1c31..09d066f8b8 100644 --- a/apps/playground/src/components/ai-elements/code-block.tsx +++ b/apps/playground/src/components/ai-elements/code-block.tsx @@ -1,6 +1,7 @@ "use client"; import { CheckIcon, CopyIcon } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createContext, memo, @@ -461,6 +462,7 @@ export const CodeBlockCopyButton = ({ const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(0); const { code } = use(CodeBlockContext); + const reduceMotion = useReducedMotion(); const copyToClipboard = useCallback(async () => { if (typeof window === "undefined" || !navigator?.clipboard?.writeText) { @@ -490,8 +492,6 @@ export const CodeBlockCopyButton = ({ [], ); - const Icon = isCopied ? CheckIcon : CopyIcon; - return ( ); }; diff --git a/apps/playground/src/components/credits/top-up-credits-dialog.tsx b/apps/playground/src/components/credits/top-up-credits-dialog.tsx index 40cb4a627a..be14b34689 100644 --- a/apps/playground/src/components/credits/top-up-credits-dialog.tsx +++ b/apps/playground/src/components/credits/top-up-credits-dialog.tsx @@ -6,6 +6,7 @@ import { } from "@stripe/react-stripe-js"; import { useQueryClient } from "@tanstack/react-query"; import { CreditCard, ExternalLink, Plus } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -403,6 +404,7 @@ function AmountStep({ } function SuccessStep({ onClose }: { onClose: () => void }) { + const reduceMotion = useReducedMotion(); return ( <> @@ -414,12 +416,25 @@ function SuccessStep({ onClose }: { onClose: () => void }) {
- Credits added - +

Thank you for your purchase. Your credits are now available for use.

diff --git a/apps/playground/src/components/lounge/sidebar-points.tsx b/apps/playground/src/components/lounge/sidebar-points.tsx index 7c5f4e5b24..d3cf6f62d3 100644 --- a/apps/playground/src/components/lounge/sidebar-points.tsx +++ b/apps/playground/src/components/lounge/sidebar-points.tsx @@ -1,6 +1,7 @@ "use client"; import { Flame, Trophy } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import Link from "next/link"; import { useLoungePoints } from "@/hooks/useLoungePoints"; @@ -10,6 +11,7 @@ import { useUser } from "@/hooks/useUser"; export function SidebarLoungePoints() { const { user } = useUser(); const { data } = useLoungePoints(); + const reduceMotion = useReducedMotion(); if (!user || !data) { return null; @@ -26,16 +28,47 @@ export function SidebarLoungePoints() { className="mx-2 mb-1 flex items-center gap-2 rounded-md bg-sidebar-accent/60 px-3 py-2 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent group-data-[collapsible=icon]:hidden" > - - {stats.totalPoints.toLocaleString()} + {/* Points earned after a message roll in like an odometer so the + reward is visible without pulsing or floating badges. */} + + + + {stats.totalPoints.toLocaleString()} + + points · Lv {stats.level} - {stats.currentStreak > 1 ? ( - - - {stats.currentStreak} - - ) : null} + + {stats.currentStreak > 1 ? ( + + + {stats.currentStreak} + + ) : null} + ); } diff --git a/apps/playground/src/components/playground/chat-sidebar.tsx b/apps/playground/src/components/playground/chat-sidebar.tsx index ea116ae7c0..735afef240 100644 --- a/apps/playground/src/components/playground/chat-sidebar.tsx +++ b/apps/playground/src/components/playground/chat-sidebar.tsx @@ -530,6 +530,12 @@ export const ChatSidebar = function ChatSidebar({ .split("; ") .includes(`${HISTORY_COLLAPSED_STORAGE_KEY}=1`), ); + // Transitions stay off until after hydration so a cookie-collapsed list + // doesn't animate shut on every page load. + const [historyTransitionReady, setHistoryTransitionReady] = useState(false); + useEffect(() => { + setHistoryTransitionReady(true); + }, []); const toggleHistoryCollapsed = useCallback(() => { setIsHistoryCollapsed((prev) => { @@ -865,59 +871,79 @@ export const ChatSidebar = function ChatSidebar({ }`} /> - {isHistoryCollapsed ? null : chats.length === 0 ? ( -
- -

- No chat history -

-

- Start a new conversation to see it here -

-
- ) : ( - <> - {pinnedChats.length > 0 && ( -
-
- Pinned -
- {pinnedChats.map((chat) => ( -
- -
- ))} + {/* Collapse animates height so the rotating chevron's promise is + kept; grid-rows keeps the transition interruptible. */} +
+
+ {chats.length === 0 ? ( +
+ +

+ No chat history +

+

+ Start a new conversation to see it here +

+ ) : ( + <> + {pinnedChats.length > 0 && ( +
+
+ Pinned +
+ {pinnedChats.map((chat) => ( +
+ +
+ ))} +
+ )} + {historyRows.length > 0 && ( + + )} + )} - {historyRows.length > 0 && ( - - )} - - )} +
+
diff --git a/apps/playground/src/components/ui/button.tsx b/apps/playground/src/components/ui/button.tsx index 132f441df5..d55e9bab27 100644 --- a/apps/playground/src/components/ui/button.tsx +++ b/apps/playground/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", { variants: { variant: { diff --git a/apps/playground/src/components/ui/popover.tsx b/apps/playground/src/components/ui/popover.tsx index f1f4850a77..a087068943 100644 --- a/apps/playground/src/components/ui/popover.tsx +++ b/apps/playground/src/components/ui/popover.tsx @@ -26,7 +26,7 @@ const PopoverContent = ({ align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-none", + "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-none", "data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", diff --git a/apps/ui/src/lib/components/button.tsx b/apps/ui/src/lib/components/button.tsx index 92e18c3a80..07ff95e234 100644 --- a/apps/ui/src/lib/components/button.tsx +++ b/apps/ui/src/lib/components/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer active:scale-[0.98]", { variants: { variant: { diff --git a/apps/ui/src/lib/components/toast.tsx b/apps/ui/src/lib/components/toast.tsx index b69da41973..1403bc8002 100644 --- a/apps/ui/src/lib/components/toast.tsx +++ b/apps/ui/src/lib/components/toast.tsx @@ -28,7 +28,7 @@ const ToastViewport = ({ ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:not-data-[swipe=end]:slide-out-to-top-full data-[state=closed]:not-data-[swipe=end]:sm:slide-out-to-bottom-full data-[swipe=end]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", { variants: { variant: { diff --git a/apps/ui/src/lib/utils/markdown-code-block.tsx b/apps/ui/src/lib/utils/markdown-code-block.tsx index 369b8278b6..6c3cf8f60d 100644 --- a/apps/ui/src/lib/utils/markdown-code-block.tsx +++ b/apps/ui/src/lib/utils/markdown-code-block.tsx @@ -188,14 +188,25 @@ function CopyButton({ code }: { code: string }) { ); } diff --git a/packages/shared/src/components/ui/button.tsx b/packages/shared/src/components/ui/button.tsx index 132f441df5..d55e9bab27 100644 --- a/packages/shared/src/components/ui/button.tsx +++ b/packages/shared/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]", { variants: { variant: {