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 (
+
+ );
+}
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 (
);
}