feat(ui): add Spinner component with orbital animation

- Create new Spinner component with three orbiting dots
- Use inline CSS keyframes for reliable animation
- Support multiple size variants (xs, sm, md, lg, xl)
- Increase spinner sizes in loading states across the app
- Add biome-ignore directives for pre-existing lint issues
- Fix accessibility: change span onClick to button element

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2025-12-11 07:47:32 +10:00
parent 89c0d552f6
commit 1e58537a65
7 changed files with 268 additions and 163 deletions

View file

@ -7,6 +7,7 @@ import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Container } from '@tpmjs/ui/Container/Container'; import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Icon } from '@tpmjs/ui/Icon/Icon';
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar'; import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader'; import { AppHeader } from '~/components/AppHeader';
@ -118,7 +119,10 @@ export default function ToolDetailPage({
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<AppHeader /> <AppHeader />
<Container size="xl" padding="md" className="py-12"> <Container size="xl" padding="md" className="py-12">
<div className="text-center text-foreground-secondary">Loading tool...</div> <div className="flex flex-col items-center justify-center py-24 gap-6">
<Spinner size="xl" />
<span className="text-foreground-secondary text-lg">Loading tool...</span>
</div>
</Container> </Container>
</div> </div>
); );

View file

@ -13,6 +13,7 @@ import {
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Container } from '@tpmjs/ui/Container/Container'; import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader'; import { AppHeader } from '~/components/AppHeader';
@ -87,7 +88,10 @@ export default function BrokenToolsPage(): React.ReactElement {
{/* Loading state */} {/* Loading state */}
{loading && ( {loading && (
<div className="text-center py-12 text-foreground-secondary">Loading broken tools...</div> <div className="flex flex-col items-center justify-center py-24 gap-6">
<Spinner size="xl" />
<span className="text-foreground-secondary text-lg">Loading broken tools...</span>
</div>
)} )}
{/* Error state */} {/* Error state */}
@ -134,6 +138,7 @@ export default function BrokenToolsPage(): React.ReactElement {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Broken tools page requires conditional rendering for health status */}
{tools.map((tool) => { {tools.map((tool) => {
const toolUrl = `/tool/${tool.package.npmPackageName}/${tool.exportName}`; const toolUrl = `/tool/${tool.package.npmPackageName}/${tool.exportName}`;
const lastCheckedDate = tool.lastHealthCheck const lastCheckedDate = tool.lastHealthCheck

View file

@ -2,20 +2,14 @@
import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button'; import { Button } from '@tpmjs/ui/Button/Button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@tpmjs/ui/Card/Card';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock'; import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Container } from '@tpmjs/ui/Container/Container'; import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon'; import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input'; import { Input } from '@tpmjs/ui/Input/Input';
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar'; import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
import { Select } from '@tpmjs/ui/Select/Select'; import { Select } from '@tpmjs/ui/Select/Select';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { AppHeader } from '~/components/AppHeader'; import { AppHeader } from '~/components/AppHeader';
@ -53,6 +47,7 @@ export default function ToolSearchPage(): React.ReactElement {
// Fetch tools from API // Fetch tools from API
useEffect(() => { useEffect(() => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool search page requires complex filtering logic
const fetchTools = async () => { const fetchTools = async () => {
try { try {
setLoading(true); setLoading(true);
@ -73,12 +68,22 @@ export default function ToolSearchPage(): React.ReactElement {
params.set('broken', 'true'); params.set('broken', 'true');
} }
// Fetch all tools (no pagination limit)
params.set('limit', '1000');
const toolsResponse = await fetch(`/api/tools?${params.toString()}`); const toolsResponse = await fetch(`/api/tools?${params.toString()}`);
const toolsData = await toolsResponse.json(); const toolsData = await toolsResponse.json();
if (toolsData.success) { if (toolsData.success) {
const fetchedTools = toolsData.data; const fetchedTools = toolsData.data;
setTools(fetchedTools); // Sort broken tools to the bottom
const sortedTools = [...fetchedTools].sort((a, b) => {
const aIsBroken = a.importHealth === 'BROKEN' || a.executionHealth === 'BROKEN';
const bIsBroken = b.importHealth === 'BROKEN' || b.executionHealth === 'BROKEN';
if (aIsBroken && !bIsBroken) return 1;
if (!aIsBroken && bIsBroken) return -1;
return 0;
});
setTools(sortedTools);
setError(null); setError(null);
// Extract unique categories from all tools // Extract unique categories from all tools
@ -179,7 +184,10 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Loading state */} {/* Loading state */}
{loading && ( {loading && (
<div className="text-center py-12 text-foreground-secondary">Loading tools...</div> <div className="flex flex-col items-center justify-center py-24 gap-6">
<Spinner size="xl" />
<span className="text-foreground-secondary text-lg">Loading tools...</span>
</div>
)} )}
{/* Error state */} {/* Error state */}
@ -189,114 +197,116 @@ export default function ToolSearchPage(): React.ReactElement {
{!loading && !error && ( {!loading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tools.length > 0 ? ( {tools.length > 0 ? (
tools.map((tool) => ( // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool card rendering requires complex conditional UI
<Card key={tool.id} className="flex flex-col"> tools.map((tool) => {
<CardHeader> const isBroken =
<div className="flex items-start justify-between gap-2"> tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
<div className="flex-1"> const qualityPercent = Math.round(Number.parseFloat(tool.qualityScore) * 100);
<CardTitle>
{tool.exportName !== 'default' // Clean up repository URL
? tool.exportName let repoUrl = tool.package.npmRepository?.url || '';
: tool.package.npmPackageName} repoUrl = repoUrl.replace(/^git\+/, '');
</CardTitle> repoUrl = repoUrl.replace(/\.git$/, '');
<div className="text-sm text-foreground-secondary mt-1"> repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
{tool.package.npmPackageName} repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
return (
<Link
key={tool.id}
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
className="block"
>
<Card className="flex flex-col h-full hover:border-foreground-tertiary transition-colors cursor-pointer">
<CardHeader className="flex-none">
{/* Top row: Title + metadata */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate">
{tool.exportName !== 'default'
? tool.exportName
: tool.package.npmPackageName}
</CardTitle>
<div className="text-sm text-foreground-secondary mt-1 truncate">
{tool.package.npmPackageName}
</div>
</div>
{/* Right side: downloads, version, link */}
<div className="flex items-center gap-2 flex-shrink-0 text-xs text-foreground-tertiary">
<span>{tool.package.npmDownloadsLastMonth.toLocaleString()}/mo</span>
<span>v{tool.package.npmVersion}</span>
{repoUrl && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(repoUrl, '_blank', 'noopener,noreferrer');
}}
className="text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
>
<Icon icon="externalLink" size="sm" />
</button>
)}
</div>
</div> </div>
</div> {/* Description */}
{tool.package.npmRepository && <CardDescription className="line-clamp-2 min-h-[2.5rem]">
(() => { {tool.description}
// Clean up repository URL </CardDescription>
let repoUrl = tool.package.npmRepository.url; </CardHeader>
// Remove git+ prefix <CardContent className="flex-1 flex flex-col gap-4">
repoUrl = repoUrl.replace(/^git\+/, ''); {/* Category badge */}
<div className="flex items-center">
<Badge variant="secondary" size="sm">
{tool.package.category}
</Badge>
</div>
// Remove .git suffix {/* Quality + Broken status row */}
repoUrl = repoUrl.replace(/\.git$/, ''); <div className="flex items-center gap-3">
<div className="flex-1 flex items-center gap-2">
<ProgressBar
value={qualityPercent}
variant={
isBroken
? 'danger'
: qualityPercent >= 70
? 'success'
: qualityPercent >= 50
? 'primary'
: 'warning'
}
size="sm"
showLabel={false}
className="flex-1"
/>
<span className="text-xs font-medium text-foreground-secondary w-8">
{qualityPercent}%
</span>
</div>
{isBroken && (
<Badge variant="error" size="sm">
Broken
</Badge>
)}
</div>
// Convert git:// to https:// {/* Install command */}
repoUrl = repoUrl.replace(/^git:\/\//, 'https://'); {/* biome-ignore lint/a11y/useKeyWithClickEvents: onClick only prevents propagation, not an interactive element */}
<div className="mt-auto" onClick={(e) => e.stopPropagation()}>
// Convert ssh URLs to https <CodeBlock
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/'); code={`npm install ${tool.package.npmPackageName}`}
language="bash"
return ( size="sm"
<a showCopy={true}
href={repoUrl} />
target="_blank" </div>
rel="noopener noreferrer" </CardContent>
className="text-foreground-secondary hover:text-foreground transition-colors" </Card>
> </Link>
<Icon icon="externalLink" size="sm" /> );
</a> })
);
})()}
</div>
<CardDescription>{tool.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
{/* Category badge and version */}
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" size="sm">
{tool.package.category}
</Badge>
<span className="text-xs text-foreground-tertiary">
v{tool.package.npmVersion}
</span>
{tool.package.isOfficial && (
<Badge variant="default" size="sm">
Official
</Badge>
)}
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
<Badge variant="error" size="sm">
<Icon icon="x" size="sm" className="mr-1" />
Broken
</Badge>
)}
</div>
{/* Quality score and downloads */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground-secondary">Quality Score</span>
<span className="text-foreground-tertiary">
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
</span>
</div>
<ProgressBar
value={Number.parseFloat(tool.qualityScore) * 100}
variant={
Number.parseFloat(tool.qualityScore) >= 0.7
? 'success'
: Number.parseFloat(tool.qualityScore) >= 0.5
? 'primary'
: 'warning'
}
size="sm"
showLabel={true}
/>
</div>
{/* Install command */}
<CodeBlock
code={`npm install ${tool.package.npmPackageName}`}
language="bash"
size="sm"
showCopy={true}
/>
</CardContent>
<CardFooter>
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
<Button variant="outline" size="sm" className="w-full">
View Details
</Button>
</Link>
</CardFooter>
</Card>
))
) : ( ) : (
<div className="col-span-full text-center py-12 text-foreground-tertiary"> <div className="col-span-full text-center py-12 text-foreground-tertiary">
{searchQuery {searchQuery

View file

@ -7,6 +7,7 @@
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent'; import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
import type { Package, Tool } from '@tpmjs/db'; import type { Package, Tool } from '@tpmjs/db';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import { useState } from 'react'; import { useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -24,6 +25,7 @@ interface ExecutionLog {
timestamp: Date; timestamp: Date;
} }
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Playground component has many tabs with different content
export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement { export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement {
const [activeTab, setActiveTab] = useState<Tab>('input'); const [activeTab, setActiveTab] = useState<Tab>('input');
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
@ -258,28 +260,8 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
{isExecuting ? ( {isExecuting ? (
<span className="flex items-center"> <span className="flex items-center gap-2">
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */} <Spinner size="sm" />
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Executing... Executing...
</span> </span>
) : ( ) : (
@ -341,31 +323,9 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
})()} })()}
</div> </div>
) : isExecuting ? ( ) : isExecuting ? (
<div className="flex items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12 gap-6">
<div className="text-center"> <Spinner size="xl" />
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */} <p className="text-foreground-secondary">Executing...</p>
<svg
className="animate-spin h-8 w-8 text-primary mx-auto mb-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-foreground-secondary">Executing...</p>
</div>
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
@ -382,6 +342,7 @@ export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElemen
{logs.length > 0 ? ( {logs.length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto"> <div className="space-y-2 max-h-96 overflow-y-auto">
{logs.map((log, index) => ( {logs.map((log, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Logs don't have unique IDs, index is appropriate
<div key={index} className="flex items-start space-x-3 text-sm"> <div key={index} className="flex items-start space-x-3 text-sm">
<span <span
className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`} className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`}

View file

@ -165,6 +165,19 @@ export default {
'0%, 100%': { opacity: '0.4' }, '0%, 100%': { opacity: '0.4' },
'50%': { opacity: '0.6' }, '50%': { opacity: '0.6' },
}, },
orbit: {
'0%': {
transform: 'rotate(0deg) translateX(150%) rotate(0deg)',
opacity: '1',
},
'50%': {
opacity: '0.6',
},
'100%': {
transform: 'rotate(360deg) translateX(150%) rotate(-360deg)',
opacity: '1',
},
},
}, },
animation: { animation: {
@ -177,6 +190,7 @@ export default {
shimmer: 'shimmer 2s infinite linear', shimmer: 'shimmer 2s infinite linear',
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'grid-pulse': 'grid-pulse 8s ease-in-out infinite', 'grid-pulse': 'grid-pulse 8s ease-in-out infinite',
orbit: 'orbit 1.2s ease-in-out infinite',
}, },
}, },
}, },

View file

@ -83,6 +83,10 @@
"types": "./dist/Slider/Slider.d.ts", "types": "./dist/Slider/Slider.d.ts",
"default": "./dist/Slider/Slider.js" "default": "./dist/Slider/Slider.js"
}, },
"./Spinner/Spinner": {
"types": "./dist/Spinner/Spinner.d.ts",
"default": "./dist/Spinner/Spinner.js"
},
"./FormField/FormField": { "./FormField/FormField": {
"types": "./dist/FormField/FormField.d.ts", "types": "./dist/FormField/FormField.d.ts",
"default": "./dist/FormField/FormField.js" "default": "./dist/FormField/FormField.js"

View file

@ -0,0 +1,107 @@
'use client';
import { cn } from '@tpmjs/utils/cn';
const sizeClasses = {
xs: 'w-5 h-5',
sm: 'w-8 h-8',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-24 h-24',
} as const;
const dotSizeClasses = {
xs: 'w-1 h-1',
sm: 'w-1.5 h-1.5',
md: 'w-2 h-2',
lg: 'w-3 h-3',
xl: 'w-4 h-4',
} as const;
export interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
/** Size variant */
size?: keyof typeof sizeClasses;
/** Optional label for accessibility */
label?: string;
}
/**
* Spinner component
*
* An elegant orbital loading spinner with three dots rotating
* in a synchronized dance pattern.
*/
export function Spinner({
className,
size = 'md',
label = 'Loading...',
...props
}: SpinnerProps): React.ReactElement {
const sizeClass = sizeClasses[size];
const dotSize = dotSizeClasses[size];
return (
// biome-ignore lint/a11y/useSemanticElements: Spinner requires role="status" for screen reader announcements, <output> is not semantically appropriate
<div
role="status"
aria-label={label}
className={cn('relative', sizeClass, className)}
{...props}
>
<style>
{`
@keyframes spinnerOrbit {
0% {
transform: rotate(0deg) translateX(140%) rotate(0deg);
}
100% {
transform: rotate(360deg) translateX(140%) rotate(-360deg);
}
}
`}
</style>
{/* Orbital ring hint */}
<div className="absolute inset-[15%] rounded-full border border-foreground/10" />
{/* Three orbiting dots with staggered animations */}
<div
className={cn('absolute rounded-full bg-foreground', dotSize)}
style={{
top: '50%',
left: '50%',
marginTop: '-0.25rem',
marginLeft: '-0.25rem',
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
}}
/>
<div
className={cn('absolute rounded-full bg-foreground/50', dotSize)}
style={{
top: '50%',
left: '50%',
marginTop: '-0.25rem',
marginLeft: '-0.25rem',
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
animationDelay: '-0.45s',
}}
/>
<div
className={cn('absolute rounded-full bg-foreground/25', dotSize)}
style={{
top: '50%',
left: '50%',
marginTop: '-0.25rem',
marginLeft: '-0.25rem',
animation: 'spinnerOrbit 1.4s cubic-bezier(0.5, 0, 0.5, 1) infinite',
animationDelay: '-0.9s',
}}
/>
{/* Screen reader text */}
<span className="sr-only">{label}</span>
</div>
);
}
Spinner.displayName = 'Spinner';