feat(ui): add animated paperclip page loaders (#10456)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-29 17:58:50 -07:00 committed by GitHub
parent a93a74f91a
commit 24aa2f516d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 111 additions and 6 deletions

View File

@ -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 <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
return <PaperclipLoading />;
}
const targetCompany =
@ -446,7 +447,7 @@ function CompanyRootRedirect() {
const location = useLocation();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
return <PaperclipLoading />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;
@ -477,7 +478,7 @@ function UnprefixedBoardRedirect() {
const { companies, selectedCompany, loading } = useCompany();
if (loading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
return <PaperclipLoading />;
}
const targetCompany = selectedCompany ?? companies[0] ?? null;

View File

@ -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(<PaperclipLoading />);
expect(html).toContain('role="status"');
expect(html).toContain("min-h-dvh");
expect(html).toContain('aria-hidden="true"');
expect(html).toContain('<span class="sr-only">Loading…</span>');
});
it("allows containing layouts to override the full-page height", () => {
const html = renderToStaticMarkup(<PaperclipLoading className="min-h-0" />);
expect(html).toContain("min-h-0");
expect(html).not.toContain("min-h-dvh");
});
});

View File

@ -0,0 +1,36 @@
import type { SVGProps } from "react";
import { cn } from "../lib/utils";
export function AnimatedPaperclipIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="-1 -1 26 26"
className={cn("paperclip-thinking-icon", className)}
aria-hidden="true"
{...props}
>
<path
className="paperclip-thinking-icon-path"
d="M16 6 l-8.414 8.586 a2.000 2.000 0 0 0 2.828 2.828 l8.414 -8.586 a4.000 4.000 0 1 0 -5.657 -5.657 l-8.379 8.551 a6.000 6.000 0 1 0 8.485 8.485 l8.379 -8.551"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** Full-page loading state: a large, centered, gray animated paperclip. */
export function PaperclipLoading({ className }: { className?: string }) {
return (
<div
role="status"
className={cn("flex min-h-dvh w-full items-center justify-center", className)}
>
<AnimatedPaperclipIcon className="h-24 w-24 text-muted-foreground" />
<span className="sr-only">Loading</span>
</div>
);
}

View File

@ -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 <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
return <PaperclipLoading />;
}
if (healthQuery.error || boardAccessQuery.error) {
@ -92,7 +93,7 @@ export function CloudAccessGate() {
if (isBootstrapPending) {
const health = healthQuery.data;
if (!health) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
return <PaperclipLoading />;
}
const claimError = claimMutation.error instanceof ApiError
? { status: claimMutation.error.status, message: claimMutation.error.message }

View File

@ -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).

View File

@ -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 (
<div className="fixed inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading</p>
<PaperclipLoading className="min-h-0" />
</div>
);
}