refactor(ui): self-contain Loading/Spinner styles and clarify usage semantics

Move all CSS out of globals.css into inline styles within each component.
Loading (Apple-style radiating lines) for passive waiting states;
Spinner (3x3 grid pulse) for active processing/execution states.
Switch device-pairing ConnectionStatus from Loading to Spinner.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 15:38:39 +08:00
parent 53c350ea33
commit b896ac402a
4 changed files with 77 additions and 65 deletions

View file

@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Loading } from "@multica/ui/components/ui/loading";
import { Spinner } from "@multica/ui/components/spinner";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@ -50,7 +50,7 @@ function ConnectionStatus({
return (
<div className={wrapper}>
<Loading className="text-muted-foreground text-sm" />
<Spinner className="text-muted-foreground text-sm" />
<div className="text-center space-y-1.5">
<p className="text-base font-medium">
{isVerifying ? "Waiting for approval" : "Connecting..."}

View file

@ -1,16 +1,12 @@
/**
* Spinner - 3x3 grid spinner based on SpinKit Grid
* Spinner 3x3 grid pulse for **active processing / execution** states.
*
* Features:
* - Uses currentColor (inherits text color from parent, typically theme primary)
* - Uses em sizing (scales with font-size)
* - 3x3 grid of cubes with staggered scale animation
* - Pure CSS animation (no JS state)
* Use when the system is actively doing work or waiting for human action
* (streaming content, generating responses, awaiting approval).
* For passive content-loading states, use `<Loading />` instead.
*
* Usage:
* <Spinner className="text-primary" /> // Uses primary theme color
* <Spinner className="text-muted-foreground" /> // Uses muted color
* <Spinner className="text-xs" /> // Controls size via Tailwind font-size
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
import { cn } from "@multica/ui/lib/utils"
@ -19,18 +15,33 @@ export interface SpinnerProps {
className?: string
}
const DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]
const cubeStyle: React.CSSProperties = {
backgroundColor: "currentColor",
animation: "spinner-grid 1.3s infinite ease-in-out",
transform: "scale3d(0.5, 0.5, 1)",
}
export function Spinner({ className }: SpinnerProps) {
return (
<span className={cn("spinner", className)} role="status" aria-label="Loading">
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span className="spinner-cube" />
<span
className={cn(className)}
role="status"
aria-label="Loading"
style={{
display: "inline-grid",
gridTemplateColumns: "repeat(3, 1fr)",
width: "1em",
height: "1em",
gap: "0.08em",
}}
>
{DELAYS.map((delay, i) => (
<span key={i} style={{ ...cubeStyle, animationDelay: `${delay}s` }} />
))}
<style>{`@keyframes spinner-grid{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}`}</style>
</span>
)
}

View file

@ -1,11 +1,52 @@
import { cn } from "@multica/ui/lib/utils"
const BAR_COUNT = 8
const DURATION = 1.2
const bars = Array.from({ length: BAR_COUNT }, (_, i) => ({
rotate: `${i * 45}deg`,
delay: `${-DURATION + (i * DURATION) / BAR_COUNT}s`,
}))
/**
* Loading Apple-style radiating-line spinner for **passive waiting** states.
*
* Use when the user is waiting for content to arrive (page init, data fetching).
* For active processing / execution states, use `<Spinner />` instead.
*
* Inherits color from `currentColor` (use Tailwind `text-*`).
* Scales with font-size (use Tailwind `text-*` for size).
*/
function Loading({ className, ...props }: React.ComponentProps<"span">) {
return (
<span className={cn("spinner text-muted-foreground", className)} {...props}>
{Array.from({ length: 9 }, (_, i) => (
<span key={i} className="spinner-cube" />
<span
className={cn("text-muted-foreground", className)}
role="status"
aria-label="Loading"
style={{ display: "inline-block", position: "relative", width: "1em", height: "1em" }}
{...props}
>
{bars.map((bar, i) => (
<span
key={i}
style={{
position: "absolute",
left: "calc(50% - 0.04em)",
top: "0.1em",
width: "0.08em",
height: "0.24em",
borderRadius: "1em",
backgroundColor: "currentColor",
transformOrigin: "50% 0.4em",
transform: `rotate(${bar.rotate})`,
animation: `loading-fade ${DURATION}s linear infinite`,
animationDelay: bar.delay,
}}
/>
))}
{/* keyframes injected once via <style> — React deduplicates identical tags */}
<style>{`@keyframes loading-fade{0%{opacity:1}100%{opacity:.15}}`}</style>
</span>
)
}

View file

@ -183,46 +183,6 @@
}
}
/* SPINNER - SpinKit Grid (3x3) */
.spinner {
display: inline-grid;
grid-template-columns: repeat(3, 1fr);
width: 1em;
height: 1em;
gap: 0.08em;
}
.spinner-cube {
background-color: currentColor;
animation: spinner-grid 1.3s infinite ease-in-out;
transform: scale3d(0.5, 0.5, 1);
}
.spinner-cube:nth-child(1) { animation-delay: 0.2s; }
.spinner-cube:nth-child(2) { animation-delay: 0.3s; }
.spinner-cube:nth-child(3) { animation-delay: 0.4s; }
.spinner-cube:nth-child(4) { animation-delay: 0.1s; }
.spinner-cube:nth-child(5) { animation-delay: 0.2s; }
.spinner-cube:nth-child(6) { animation-delay: 0.3s; }
.spinner-cube:nth-child(7) { animation-delay: 0s; }
.spinner-cube:nth-child(8) { animation-delay: 0.1s; }
.spinner-cube:nth-child(9) { animation-delay: 0.2s; }
@keyframes spinner-grid {
0%, 70%, 100% {
transform: scale3d(0.5, 0.5, 1);
}
35% {
transform: scale3d(0, 0, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner-cube {
animation: none;
opacity: 0.7;
}
}
/* Tool status: running glow pulse */
@keyframes glow-pulse {