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.  ## 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:
parent
a93a74f91a
commit
24aa2f516d
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue