diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1011501dfe..7d28bd2b1a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { Cases } from "./pages/Cases"; import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; +import { PaperclipLoading } from "./components/AnimatedPaperclipIcon"; import { Dashboard } from "./pages/Dashboard"; import { DashboardLive } from "./pages/DashboardLive"; import { Timeline } from "./pages/Timeline"; @@ -338,7 +339,7 @@ function LegacySettingsRedirect() { const { companyPrefix } = useParams<{ companyPrefix?: string }>(); if (loading) { - return
Loading...
; + return ; } const targetCompany = @@ -446,7 +447,7 @@ function CompanyRootRedirect() { const location = useLocation(); if (loading) { - return
Loading...
; + return ; } const targetCompany = selectedCompany ?? companies[0] ?? null; @@ -477,7 +478,7 @@ function UnprefixedBoardRedirect() { const { companies, selectedCompany, loading } = useCompany(); if (loading) { - return
Loading...
; + return ; } const targetCompany = selectedCompany ?? companies[0] ?? null; diff --git a/ui/src/components/AnimatedPaperclipIcon.test.tsx b/ui/src/components/AnimatedPaperclipIcon.test.tsx new file mode 100644 index 0000000000..d960eecf37 --- /dev/null +++ b/ui/src/components/AnimatedPaperclipIcon.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment node + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { PaperclipLoading } from "./AnimatedPaperclipIcon"; + +describe("PaperclipLoading", () => { + it("renders an accessible full-page loading state", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="status"'); + expect(html).toContain("min-h-dvh"); + expect(html).toContain('aria-hidden="true"'); + expect(html).toContain('Loading…'); + }); + + it("allows containing layouts to override the full-page height", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("min-h-0"); + expect(html).not.toContain("min-h-dvh"); + }); +}); diff --git a/ui/src/components/AnimatedPaperclipIcon.tsx b/ui/src/components/AnimatedPaperclipIcon.tsx new file mode 100644 index 0000000000..df297bc032 --- /dev/null +++ b/ui/src/components/AnimatedPaperclipIcon.tsx @@ -0,0 +1,36 @@ +import type { SVGProps } from "react"; +import { cn } from "../lib/utils"; + +export function AnimatedPaperclipIcon({ className, ...props }: SVGProps) { + return ( + + ); +} + +/** Full-page loading state: a large, centered, gray animated paperclip. */ +export function PaperclipLoading({ className }: { className?: string }) { + return ( +
+ + Loading… +
+ ); +} diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx index 762eba5c67..960533875a 100644 --- a/ui/src/components/CloudAccessGate.tsx +++ b/ui/src/components/CloudAccessGate.tsx @@ -6,6 +6,7 @@ import { authApi } from "@/api/auth"; import { healthApi } from "@/api/health"; import { queryKeys } from "@/lib/queryKeys"; import { BootstrapPendingPage } from "@/components/BootstrapPendingPage"; +import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; import { Card } from "@/components/ui/card"; function NoBoardAccessPage() { @@ -74,7 +75,7 @@ export function CloudAccessGate() { (isAuthenticatedMode && sessionQuery.isLoading) || (isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading) ) { - return
Loading...
; + return ; } if (healthQuery.error || boardAccessQuery.error) { @@ -92,7 +93,7 @@ export function CloudAccessGate() { if (isBootstrapPending) { const health = healthQuery.data; if (!health) { - return
Loading...
; + return ; } const claimError = claimMutation.error instanceof ApiError ? { status: claimMutation.error.status, message: claimMutation.error.message } diff --git a/ui/src/index.css b/ui/src/index.css index 5aaa9707c2..01f8f60600 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -550,12 +550,55 @@ animation: shimmer-text-slide 2.5s linear infinite; } +.paperclip-thinking-icon { + transform-origin: 50% 50%; +} + +@keyframes paperclip-thinking-draw { + 0% { + stroke-dasharray: 0 85.717; + stroke-dashoffset: -85.717; + opacity: 1; + animation-timing-function: cubic-bezier(0.455, 0.03, 0.515, 0.955); + } + 39.0625% { + stroke-dasharray: 85.717 85.717; + stroke-dashoffset: 0; + opacity: 1; + animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); + } + 78.125% { + stroke-dasharray: 0 85.717; + stroke-dashoffset: 0; + opacity: 1; + } + 78.225% { + opacity: 0; + } + 100% { + stroke-dasharray: 0 85.717; + stroke-dashoffset: 0; + opacity: 0; + } +} + +.paperclip-thinking-icon-path { + animation: paperclip-thinking-draw 1s linear infinite; +} + @media (prefers-reduced-motion: reduce) { .shimmer-text { animation: none; -webkit-text-fill-color: unset; background: none; } + + .paperclip-thinking-icon-path { + animation: none; + opacity: 1; + stroke-dasharray: 85.717 85.717; + stroke-dashoffset: 0; + } } /* Agent heartbeat capsule motion (PAP-75). diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index 6948254782..670677ea33 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -6,6 +6,7 @@ import { queryKeys } from "../lib/queryKeys"; import { getRememberedInvitePath } from "../lib/invite-memory"; import { Button } from "@/components/ui/button"; import { AsciiArtAnimation } from "@/components/AsciiArtAnimation"; +import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; import { ThemeToggle } from "@/components/ThemeToggle"; import { Sparkles } from "lucide-react"; @@ -70,7 +71,7 @@ export function AuthPage() { if (isSessionLoading) { return (
-

Loading…

+
); }