From 24aa2f516d25d786e855ec19b3acab91d463aa0e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:58:50 -0700 Subject: [PATCH] feat(ui): add animated paperclip page loaders (#10456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents for work > - Operators regularly pass through full-page loading states while authentication and company context resolve > - Those states currently render as small bare text, which is easy to miss and does not reinforce Paperclip's visual identity > - A shared loading component gives these transitions one accessible, consistent representation > - This pull request introduces an animated paperclip loader and uses it at the existing full-page loading boundaries > - The benefit is a clearer, calmer loading experience with reduced-motion and screen-reader support ## Linked Issues or Issue Description ### Subsystem affected `ui/` — React + Vite board UI ### Problem or motivation Full-page authentication, access-gate, and company-context waits use small bare `Loading…` text that is visually weak and inconsistent. ### Proposed solution Use one large centered paperclip loader at those boundaries, drawing the SVG with `currentColor` so it follows the active theme. ### Alternatives considered Keeping text-only states or adding a generic spinner would preserve less of Paperclip's product identity and would continue duplicating loading markup. ### Roadmap alignment This is tightly scoped UI polish and does not duplicate a planned roadmap capability. ### Additional context Internal coordination task PAP-15760 requested this focused change. ## What Changed - Added `AnimatedPaperclipIcon`, a theme-aware SVG whose stroke draws in a loop. - Added `PaperclipLoading`, a large full-viewport centered loader with `role="status"` and an `sr-only` `Loading…` label. - Added a static fully drawn fallback under `prefers-reduced-motion: reduce`. - Replaced bare loading text in `CloudAccessGate`, the Auth session check, and three company-context redirects. - Used token-safe Tailwind utilities throughout the component. - Added focused coverage for the status semantics and the Auth layout height override. ## Verification - `pnpm check:token-gates` - `pnpm -C ui exec tsc -b` - `pnpm -C ui exec vitest run src/components/AnimatedPaperclipIcon.test.tsx` - `pnpm -C ui exec vitest run src/App.test.tsx src/App.cases-routing.test.tsx` — 8/8 tests passed - Visually checked light and dark loading states; the screenshot below shows both themes. ![Large centered paperclip loading state in light and dark themes](https://pages.paperclip.ing/pap-15760-loading-state/pap15760-loading.png) ## Risks - Low risk: this changes presentation only at existing loading branches. - Motion-sensitive users receive a static, fully drawn paperclip through the reduced-motion media query. - Screen readers retain a concise loading announcement through the status role and visually hidden label. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 (exact service build ID and context-window size are not exposed in this environment), with reasoning, repository inspection, shell tool use, code execution, and image inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with all deployment details available to this environment - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and found none - [x] I have described the issue in-PR following the feature-request fields - [x] I included the task-mandated internal parent reference and no private instance URL - [x] I preserved the task-mandated existing branch name without renaming it - [x] I have run scoped tests locally and they pass - [x] I added focused component coverage for the new loading state - [x] No documentation update is required for this presentation-only change - [x] I have considered and documented the risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- ui/src/App.tsx | 7 +-- .../components/AnimatedPaperclipIcon.test.tsx | 23 ++++++++++ ui/src/components/AnimatedPaperclipIcon.tsx | 36 ++++++++++++++++ ui/src/components/CloudAccessGate.tsx | 5 ++- ui/src/index.css | 43 +++++++++++++++++++ ui/src/pages/Auth.tsx | 3 +- 6 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 ui/src/components/AnimatedPaperclipIcon.test.tsx create mode 100644 ui/src/components/AnimatedPaperclipIcon.tsx 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…

+
); }