chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover-hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover-hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover-hover:bg-accent hover-hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover-hover:bg-secondary/80',
|
||||
ghost: 'hover-hover:bg-accent hover-hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover-hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, ChipInput, Tooltip } from '@sim/emcn'
|
||||
import { Check, Clipboard, Eye, EyeOff, RefreshCw } from 'lucide-react'
|
||||
import { generatePassword } from '@/lib/core/security/encryption'
|
||||
|
||||
interface GeneratedPasswordInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
/** Show the Generate (random password) action. Off for consumer-facing entry forms. */
|
||||
showGenerate?: boolean
|
||||
required?: boolean
|
||||
autoComplete?: string
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Password field with reveal / copy / (optional) generate adornments, used by the
|
||||
* deploy-as-chat access controls and the file-share modal. Owns its show/copy UI
|
||||
* state; the caller owns the value.
|
||||
*/
|
||||
export function GeneratedPasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
showGenerate = true,
|
||||
required = false,
|
||||
autoComplete = 'new-password',
|
||||
error = false,
|
||||
}: GeneratedPasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!copySuccess) return
|
||||
const timer = setTimeout(() => setCopySuccess(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [copySuccess])
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopySuccess(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipInput
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
error={error}
|
||||
endAdornment={
|
||||
<div className='flex items-center'>
|
||||
{showGenerate ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => onChange(generatePassword(24))}
|
||||
disabled={disabled}
|
||||
aria-label='Generate password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
<RefreshCw className='size-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Generate</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : null}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={copyToClipboard}
|
||||
disabled={!value || disabled}
|
||||
aria-label='Copy password'
|
||||
className='!p-1.5'
|
||||
>
|
||||
{copySuccess ? <Check className='size-3' /> : <Clipboard className='size-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{copySuccess ? 'Copied' : 'Copy'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className='!p-1.5'
|
||||
>
|
||||
{showPassword ? <EyeOff className='size-3' /> : <Eye className='size-3' />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{showPassword ? 'Hide' : 'Show'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { Button, buttonVariants } from './button'
|
||||
export { GeneratedPasswordInput } from './generated-password-input'
|
||||
export { Progress } from './progress'
|
||||
export { SearchHighlight } from './search-highlight'
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './select'
|
||||
export { ShimmerText } from './shimmer-text'
|
||||
export { ThinkingLoader, type ThinkingLoaderVariant } from './thinking-loader'
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
|
||||
({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 bg-primary transition-all dark:bg-white',
|
||||
indicatorClassName
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
)
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,45 @@
|
||||
interface SearchHighlightProps {
|
||||
text: string
|
||||
searchQuery: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SearchHighlight({ text, searchQuery, className = '' }: SearchHighlightProps) {
|
||||
if (!searchQuery.trim()) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
const searchTerms = searchQuery
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((term) => term.length > 0)
|
||||
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
const regex = new RegExp(`(${searchTerms.join('|')})`, 'gi')
|
||||
const parts = text.split(regex)
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{parts.map((part, index) => {
|
||||
if (!part) return null
|
||||
|
||||
const isMatch = searchTerms.some((term) => new RegExp(term, 'gi').test(part))
|
||||
|
||||
return isMatch ? (
|
||||
<span
|
||||
key={index}
|
||||
className='bg-[var(--highlight-match-bg)] text-[var(--highlight-match-text)]'
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg border border-input bg-input-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className='size-4 opacity-50 transition-transform duration-200 ease-in-out' />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'sticky top-0 z-10 flex cursor-default items-center justify-center bg-popover py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className='size-4 opacity-70 transition-opacity hover-hover:opacity-100' />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'sticky bottom-0 z-10 flex cursor-default items-center justify-center bg-popover py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className='size-4 opacity-70 transition-opacity hover-hover:opacity-100' />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
position === 'popper' &&
|
||||
'data-[side=left]:-translate-x-1 data-[side=top]:-translate-y-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pr-2 pl-8 font-semibold text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className='size-4' />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Claude-style text shimmer: the text paints from a gradient with a light band
|
||||
* that sweeps across the glyphs via background-clip. This is the single source
|
||||
* of truth for the treatment — the ThinkingLoader label composes it. Under
|
||||
* reduced motion the sweep is replaced by a gentle opacity pulse in solid ink:
|
||||
* the shimmer conveys essential in-progress state, so it needs a vestibular-safe
|
||||
* fallback rather than none. Consumers whose resting text is not body ink set
|
||||
* `--shimmer-rest` to their resting color.
|
||||
*/
|
||||
.shimmer {
|
||||
background-image: linear-gradient(90deg, #4a4a4a 40%, #b0b0b0 50%, #4a4a4a 60%);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer-sweep 2.2s linear infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .shimmer {
|
||||
background-image: linear-gradient(90deg, #b9b9b9 40%, #f8f8f8 50%, #b9b9b9 60%);
|
||||
}
|
||||
|
||||
@keyframes shimmer-sweep {
|
||||
to {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer,
|
||||
:global(.dark) .shimmer {
|
||||
background-image: none;
|
||||
-webkit-background-clip: initial;
|
||||
background-clip: initial;
|
||||
color: var(--shimmer-rest, var(--text-body));
|
||||
animation: shimmer-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import styles from '@/components/ui/shimmer-text.module.css'
|
||||
|
||||
interface ShimmerTextProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweeping-highlight shimmer over a text phrase — the same treatment as the
|
||||
* ThinkingLoader's "Thinking…" label, reusable on any active/streaming row.
|
||||
* Size and weight come from the consumer's className; the gradient replaces
|
||||
* the text color, so color classes are ignored while shimmering.
|
||||
*/
|
||||
export function ShimmerText({ children, className }: ShimmerTextProps) {
|
||||
return <span className={cn(styles.shimmer, className)}>{children}</span>
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* ThinkingLoader gooey animation
|
||||
*
|
||||
* Metaball technique on the SVG alpha channel: ink shapes are blurred with
|
||||
* feGaussianBlur, then feColorMatrix crushes the soft alpha back to hard
|
||||
* edges. Unlike the CSS blur+contrast trick this needs no solid backdrop and
|
||||
* no blend mode, so the loader composites cleanly on any background.
|
||||
*
|
||||
* Every variant is authored in the shared 100x100 viewBox. All stages stack
|
||||
* inside one filtered group; because the alpha crush happens after the
|
||||
* stages composite, crossfading two stages reads as one pattern melting
|
||||
* into the next, which is what drives the cycling morph.
|
||||
*
|
||||
* Ink color rides on currentColor: near-black on light surfaces, off-white
|
||||
* in dark mode.
|
||||
*/
|
||||
|
||||
.frame {
|
||||
/* currentColor paints the squeeze ring's stroked arcs (the only non-filled
|
||||
geometry). It tracks the loader polarity — dark on light, light on dark —
|
||||
matching the gradient stops below. */
|
||||
color: #2c2c2c;
|
||||
flex: none;
|
||||
/* Loader fill is a radial gradient (center → edge) plus a soft white inner
|
||||
glow, per the Figma loader spec. Stops and glow opacity are theme vars,
|
||||
inherited by the gradient stops and feFlood inside the SVG, so one set of
|
||||
defs serves both themes. Light mode = the DARK-grey loader (reads on white):
|
||||
#2C2C2C → #5F5F5F, glow white 0.6. */
|
||||
--tl-grad-inner: #2c2c2c;
|
||||
--tl-grad-outer: #5f5f5f;
|
||||
--tl-glow: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Gradient stops + glow flood read the theme vars via CSS (a raw `var()` in an
|
||||
SVG presentation attribute would not resolve — it must come through CSS).
|
||||
`stop-color`/`flood-color` are transitionable even when fed by a var, so a
|
||||
caller that re-points the ink vars (e.g. settling the loader's material into
|
||||
a surface it hands off to) gets a smooth color tween, not a hard flip. */
|
||||
.gradInner {
|
||||
stop-color: var(--tl-grad-inner);
|
||||
transition: stop-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.gradOuter {
|
||||
stop-color: var(--tl-grad-outer);
|
||||
transition: stop-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.glow {
|
||||
flood-color: var(--tl-glow);
|
||||
transition: flood-color 450ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Wall-clock phase lock: the component sets --tl-sync to a shared negative
|
||||
delay (now mod the common animation period), so every instance's keyframes
|
||||
line up no matter when it mounted. The doubled class out-specifies the
|
||||
shape classes' animation shorthand (implicit 0s delay) regardless of
|
||||
source order. */
|
||||
.frame.frame * {
|
||||
animation-delay: var(--tl-sync, 0s);
|
||||
}
|
||||
|
||||
:global(.dark) .frame {
|
||||
color: #d6d6d6;
|
||||
/* Dark mode = the LIGHT-grey loader (reads on dark): #A7A7A7 → #D6D6D6,
|
||||
glow white 0.9. */
|
||||
--tl-grad-inner: #a7a7a7;
|
||||
--tl-grad-outer: #d6d6d6;
|
||||
--tl-glow: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* An explicit `.light` wrapper (e.g. the always-light landing) re-asserts the
|
||||
dark-grey loader even when an outer `.dark` theme is on the document — the
|
||||
nearer theme wins. Ordered after the `.dark` rule so on equal specificity
|
||||
(both ancestors present) this takes precedence. */
|
||||
:global(.light) .frame {
|
||||
color: #2c2c2c;
|
||||
--tl-grad-inner: #2c2c2c;
|
||||
--tl-grad-outer: #5f5f5f;
|
||||
--tl-glow: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* gap + label size scale with the loader (vars set per-instance); defaults
|
||||
match the chat's size-20 loader (8px gap, 14px text). */
|
||||
gap: var(--tl-label-gap, 8px);
|
||||
/* The status phrase must never break onto a second line — the glyph + phrase
|
||||
stay on one row regardless of phrase length or how narrow the available
|
||||
width is (e.g. the hero loader docked near the card's right edge).
|
||||
white-space inherits, so this one declaration reaches both the shimmer
|
||||
(.label) and static (.labelStatic) text spans. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The sweeping-band treatment itself (gradient, timing, dark mode, reduced
|
||||
motion) is owned by the shared shimmer-text module; this class only adds the
|
||||
loader-scaled font sizing. Canonical normal weight per emcn rules: body text
|
||||
is 400, never medium. */
|
||||
.label {
|
||||
composes: shimmer from "./shimmer-text.module.css";
|
||||
font-size: var(--tl-label-size, 14px);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Static label (shimmer off): the phrase in solid body ink, no gradient sweep. */
|
||||
.labelStatic {
|
||||
font-size: var(--tl-label-size, 14px);
|
||||
font-weight: 400;
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
/* Phrase crossfade — the incoming phrase rises and fades in while the outgoing
|
||||
one rises and fades out (stacked over it), so phrases rotate smoothly. */
|
||||
.labelStack {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.labelLayer {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* Incoming phrase wipes in left → right through an animated gradient mask
|
||||
(soft trailing edge), so it's revealed horizontally rather than fading up. */
|
||||
.labelIn {
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 70%, transparent 100%);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 70%, transparent 100%);
|
||||
-webkit-mask-size: 300% 100%;
|
||||
mask-size: 300% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
animation: label-reveal 460ms ease both;
|
||||
}
|
||||
.labelOut {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
animation: label-out 300ms ease both;
|
||||
}
|
||||
@keyframes label-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
-webkit-mask-position: 100% 0;
|
||||
mask-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
-webkit-mask-position: 0% 0;
|
||||
mask-position: 0% 0;
|
||||
}
|
||||
}
|
||||
@keyframes label-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.stageActive {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* metaballs — two circles drift apart and melt back together */
|
||||
.metaballsA {
|
||||
animation: metaballs-a 1s infinite alternate;
|
||||
}
|
||||
.metaballsB {
|
||||
animation: metaballs-b 1s infinite alternate;
|
||||
}
|
||||
@keyframes metaballs-a {
|
||||
90%,
|
||||
100% {
|
||||
transform: translateX(28px);
|
||||
}
|
||||
}
|
||||
@keyframes metaballs-b {
|
||||
90%,
|
||||
100% {
|
||||
transform: translateX(-28px);
|
||||
}
|
||||
}
|
||||
|
||||
/* relay (Return) — always left → right: the ball emerges from the left post,
|
||||
travels across, and dissolves into the right post (the goo morphs it into
|
||||
that shape). It fades out at the right and in at the left, so the reset back
|
||||
to the start happens while invisible — no bounce, no hard snap. */
|
||||
.relayBall {
|
||||
animation: relay 1.3s linear infinite;
|
||||
}
|
||||
@keyframes relay {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
78% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(58px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* corners — four dots rotate around a center square */
|
||||
.cornersA {
|
||||
animation: corners-a 0.8s infinite;
|
||||
}
|
||||
.cornersB {
|
||||
animation: corners-b 0.8s infinite;
|
||||
}
|
||||
.cornersC {
|
||||
animation: corners-c 0.8s infinite;
|
||||
}
|
||||
.cornersD {
|
||||
animation: corners-d 0.8s infinite;
|
||||
}
|
||||
@keyframes corners-a {
|
||||
100% {
|
||||
transform: translate(46px, 0);
|
||||
}
|
||||
}
|
||||
@keyframes corners-b {
|
||||
100% {
|
||||
transform: translate(0, 46px);
|
||||
}
|
||||
}
|
||||
@keyframes corners-c {
|
||||
100% {
|
||||
transform: translate(-46px, 0);
|
||||
}
|
||||
}
|
||||
@keyframes corners-d {
|
||||
100% {
|
||||
transform: translate(0, -46px);
|
||||
}
|
||||
}
|
||||
|
||||
/* burst — four dots repeatedly fling out of a center cross */
|
||||
.burstUp {
|
||||
animation: burst-up 0.8s infinite;
|
||||
}
|
||||
.burstDown {
|
||||
animation: burst-down 0.8s infinite;
|
||||
}
|
||||
.burstLeft {
|
||||
animation: burst-left 0.8s infinite;
|
||||
}
|
||||
.burstRight {
|
||||
animation: burst-right 0.8s infinite;
|
||||
}
|
||||
@keyframes burst-up {
|
||||
100% {
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
}
|
||||
@keyframes burst-down {
|
||||
100% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
}
|
||||
@keyframes burst-left {
|
||||
100% {
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
}
|
||||
@keyframes burst-right {
|
||||
100% {
|
||||
transform: translateX(50px);
|
||||
}
|
||||
}
|
||||
|
||||
/* compass — one dot tours the four cardinal points */
|
||||
.compassMover {
|
||||
animation: compass 2s infinite;
|
||||
}
|
||||
@keyframes compass {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: translate(27px, 27px);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0, 54px);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-27px, 27px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* squeeze — two bars pinch together inside the ring arcs */
|
||||
.squeezeBarL {
|
||||
animation: squeeze-l 0.6s infinite alternate;
|
||||
}
|
||||
.squeezeBarR {
|
||||
animation: squeeze-r 0.6s infinite alternate;
|
||||
}
|
||||
@keyframes squeeze-l {
|
||||
0%,
|
||||
30% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
@keyframes squeeze-r {
|
||||
0%,
|
||||
30% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* thinking — three small blobs drift out from the core and back on offset
|
||||
periods (1.3/1.6/1.9s), so the merged cloud churns and never repeats:
|
||||
the Core weighing options. */
|
||||
.thinkA {
|
||||
animation: think-a 1.6s infinite alternate;
|
||||
}
|
||||
.thinkB {
|
||||
animation: think-b 1.9s infinite alternate;
|
||||
}
|
||||
.thinkC {
|
||||
animation: think-c 1.3s infinite alternate;
|
||||
}
|
||||
@keyframes think-a {
|
||||
100% {
|
||||
transform: translate(-20px, -14px);
|
||||
}
|
||||
}
|
||||
@keyframes think-b {
|
||||
100% {
|
||||
transform: translate(22px, -10px);
|
||||
}
|
||||
}
|
||||
@keyframes think-c {
|
||||
100% {
|
||||
transform: translate(2px, 22px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frame *,
|
||||
.labelIn {
|
||||
animation: none;
|
||||
}
|
||||
.gradInner,
|
||||
.gradOuter,
|
||||
.glow {
|
||||
transition: none;
|
||||
}
|
||||
/* No crossfade — the incoming phrase just replaces the outgoing one. */
|
||||
.labelOut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import styles from '@/components/ui/thinking-loader.module.css'
|
||||
|
||||
const VARIANTS = [
|
||||
'metaballs',
|
||||
'relay',
|
||||
'corners',
|
||||
'burst',
|
||||
'compass',
|
||||
'squeeze',
|
||||
'thinking',
|
||||
'orb',
|
||||
] as const
|
||||
|
||||
export type ThinkingLoaderVariant = (typeof VARIANTS)[number]
|
||||
|
||||
/**
|
||||
* Shapes used in the random morph cycle. `orb` (the solid terminal circle) is
|
||||
* excluded — it is only reachable by pinning `variant='orb'` or via `settle`,
|
||||
* so the cycle never lands on it mid-stream.
|
||||
*/
|
||||
const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'orb')
|
||||
|
||||
/**
|
||||
* Deterministic integer hash — turns a step index into a spread-out pseudo-
|
||||
* random number. Used to pick both the shape and its hold time per step, so the
|
||||
* order and pacing look unpredictable (any shape can follow any other, some
|
||||
* linger, some pass quickly) while staying a pure function of the clock — every
|
||||
* loader instance lands on the same shape at the same time.
|
||||
*/
|
||||
function hashStep(n: number): number {
|
||||
let x = n | 0
|
||||
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
|
||||
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
|
||||
return ((x >>> 16) ^ x) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Common multiple of every shape animation period (800/1000/1200/2000ms,
|
||||
* alternates doubled) — the wall-clock modulus for the shared negative
|
||||
* animation-delay that phase-locks instances mounted at different times.
|
||||
*/
|
||||
const SYNC_PERIOD_MS = 12_000
|
||||
|
||||
/**
|
||||
* A super-cycle of steps, each holding a pseudo-random shape for a pseudo-random
|
||||
* duration (≈1.1–2.6s), so the morph pacing feels natural — some shapes linger,
|
||||
* some pass quickly — instead of a metronome. Deterministic, so instances stay
|
||||
* in lockstep; long enough not to read as a loop.
|
||||
*/
|
||||
const CYCLE_STEPS = 16
|
||||
const STEP_MIN_MS = 1100
|
||||
const STEP_RANGE_MS = 1500
|
||||
const STEP_DURATIONS = Array.from(
|
||||
{ length: CYCLE_STEPS },
|
||||
(_, i) => STEP_MIN_MS + (hashStep(i + 1000) % STEP_RANGE_MS)
|
||||
)
|
||||
const SUPER_PERIOD_MS = STEP_DURATIONS.reduce((sum, d) => sum + d, 0)
|
||||
|
||||
/** The shape for a given step — pseudo-random, never an immediate repeat. */
|
||||
function variantForStep(i: number): ThinkingLoaderVariant {
|
||||
const idx = hashStep(i) % CYCLE_VARIANTS.length
|
||||
const prevIdx = hashStep((i - 1 + CYCLE_STEPS) % CYCLE_STEPS) % CYCLE_VARIANTS.length
|
||||
return CYCLE_VARIANTS[idx === prevIdx ? (idx + 1) % CYCLE_VARIANTS.length : idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the shared timeline is on right now, and how long until the next.
|
||||
* Walks the super-cycle's varied step durations — a pure function of the wall
|
||||
* clock, so instances stay in lockstep.
|
||||
*/
|
||||
function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } {
|
||||
let t = Date.now() % SUPER_PERIOD_MS
|
||||
for (let i = 0; i < CYCLE_STEPS; i++) {
|
||||
if (t < STEP_DURATIONS[i]) {
|
||||
return { variant: variantForStep(i), msUntilNext: STEP_DURATIONS[i] - t }
|
||||
}
|
||||
t -= STEP_DURATIONS[i]
|
||||
}
|
||||
return { variant: variantForStep(0), msUntilNext: STEP_DURATIONS[0] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink shapes per variant, authored in the shared 100x100 viewBox.
|
||||
* Geometry mirrors the intrinsic CSS loaders these were adapted from,
|
||||
* contain-fit to the canvas. Animations live in the CSS module.
|
||||
*/
|
||||
const VARIANT_SHAPES: Record<ThinkingLoaderVariant, ReactNode> = {
|
||||
metaballs: (
|
||||
<>
|
||||
<circle className={styles.metaballsA} cx='22' cy='50' r='16' />
|
||||
<circle className={styles.metaballsB} cx='78' cy='50' r='16' />
|
||||
</>
|
||||
),
|
||||
relay: (
|
||||
<>
|
||||
<rect x='13' y='28' width='16' height='44' />
|
||||
<rect x='71' y='28' width='16' height='44' />
|
||||
<circle className={styles.relayBall} cx='21' cy='50' r='14' />
|
||||
</>
|
||||
),
|
||||
corners: (
|
||||
<>
|
||||
<rect x='27' y='27' width='46' height='46' />
|
||||
<circle className={styles.cornersA} cx='27' cy='27' r='14' />
|
||||
<circle className={styles.cornersB} cx='73' cy='27' r='14' />
|
||||
<circle className={styles.cornersC} cx='73' cy='73' r='14' />
|
||||
<circle className={styles.cornersD} cx='27' cy='73' r='14' />
|
||||
</>
|
||||
),
|
||||
// burst (Thinking) — four dots fling out of a center cross and are swallowed
|
||||
// at the window edge, then fire again: the Core churning.
|
||||
burst: (
|
||||
<>
|
||||
<rect x='12.5' y='43.75' width='75' height='12.5' />
|
||||
<rect x='43.75' y='12.5' width='12.5' height='75' />
|
||||
<circle cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstUp} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstDown} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstLeft} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstRight} cx='50' cy='50' r='12.5' />
|
||||
</>
|
||||
),
|
||||
compass: (
|
||||
<>
|
||||
<circle cx='50' cy='23' r='14' />
|
||||
<circle cx='23' cy='50' r='14' />
|
||||
<circle cx='77' cy='50' r='14' />
|
||||
<circle cx='50' cy='77' r='14' />
|
||||
<circle className={styles.compassMover} cx='50' cy='23' r='14' />
|
||||
</>
|
||||
),
|
||||
squeeze: (
|
||||
<>
|
||||
{/* Arcs are stroked, not filled — they inherit the group's gradient stroke
|
||||
(so the O is shaded identically to every other shape); the group pins
|
||||
stroke-width to 0, so each arc re-asserts its own 12.5. */}
|
||||
<path d='M 21.36 37.5 A 31.25 31.25 0 0 1 78.64 37.5' fill='none' strokeWidth='12.5' />
|
||||
<path d='M 21.36 62.5 A 31.25 31.25 0 0 0 78.64 62.5' fill='none' strokeWidth='12.5' />
|
||||
<rect className={styles.squeezeBarL} x='15' y='37.5' width='12.5' height='25' />
|
||||
<rect className={styles.squeezeBarR} x='72.5' y='37.5' width='12.5' height='25' />
|
||||
</>
|
||||
),
|
||||
// thinking — a core with small blobs drifting out and back on offset timings:
|
||||
// a restless thought-cloud that never quite resolves. The Core deliberating.
|
||||
thinking: (
|
||||
<>
|
||||
<circle cx='50' cy='50' r='15' />
|
||||
<circle className={styles.thinkA} cx='50' cy='50' r='12' />
|
||||
<circle className={styles.thinkB} cx='50' cy='50' r='12' />
|
||||
<circle className={styles.thinkC} cx='50' cy='50' r='11' />
|
||||
</>
|
||||
),
|
||||
// orb — a single solid disc that nearly fills the frame. Not part of the
|
||||
// random cycle; the loader settles here so it can hand off to a real circular
|
||||
// button (e.g. a send button) without a visible shape pop.
|
||||
orb: <circle cx='50' cy='50' r='42' />,
|
||||
}
|
||||
|
||||
/**
|
||||
* World-aligned status phrase per shape (used when `phase` is set). Each phrase
|
||||
* names what the shape represents in the Sim world, so the words on screen always
|
||||
* match the loader. Keys are the shape names; the comment is the world concept.
|
||||
*/
|
||||
const VARIANT_PHRASE: Record<ThinkingLoaderVariant, string> = {
|
||||
corners: 'Orchestrating…', // Mothership — the Core directing the work
|
||||
squeeze: 'Working…', // Pod — one agent on a task
|
||||
compass: 'In formation…', // Formation — many Pods in parallel
|
||||
metaballs: 'Dispatching…', // Dispatch — sending work out
|
||||
relay: 'Returning…', // Return — work coming back, consolidated
|
||||
burst: 'Thinking…', // Thinking — the Core deliberating
|
||||
thinking: 'Standing by…', // Waiting · Sentinel — holding for a condition
|
||||
orb: 'Ready', // Orb — settled, ready to act
|
||||
}
|
||||
|
||||
export interface ThinkingLoaderProps {
|
||||
/** Pin one pattern. When omitted, the loader morphs between all patterns at random. */
|
||||
variant?: ThinkingLoaderVariant
|
||||
/**
|
||||
* When cycling, open on this pattern (held one beat) before joining the
|
||||
* shared morph timeline. Use `'corners'` (the Mothership shape) to always
|
||||
* begin on the Core. Ignored when `variant` pins a single pattern.
|
||||
*/
|
||||
startVariant?: ThinkingLoaderVariant
|
||||
/**
|
||||
* Stop cycling and morph to the solid `orb` disc — the loader's terminal
|
||||
* resting shape. The goo filter melts the current shape into the orb (no hard
|
||||
* swap), so it can hand off to a real circular element underneath it. Ignored
|
||||
* when `variant` pins a single pattern.
|
||||
*/
|
||||
settle?: boolean
|
||||
/** Rendered square size in px. Defaults to 20. */
|
||||
size?: number
|
||||
/** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */
|
||||
label?: string
|
||||
/**
|
||||
* Show a world-aligned status phrase that matches the current shape — e.g.
|
||||
* "Dispatching…" under the Dispatch shape — updating as the loader morphs.
|
||||
* Overrides `label`.
|
||||
*/
|
||||
phase?: boolean
|
||||
/**
|
||||
* Phrase/label font size as a fraction of `size`. Defaults to `0.7`. Lower it
|
||||
* when the loader is shown scaled up (e.g. a zoomed hero shot) so the phrase
|
||||
* doesn't read oversized next to the glyph.
|
||||
*/
|
||||
labelRatio?: number
|
||||
/**
|
||||
* Paint the label with the sweeping Claude-style shimmer (default). Set
|
||||
* `false` for a static label in solid `--text-body` ink — no gradient, no
|
||||
* sweep — while the phrase crossfade still applies.
|
||||
*/
|
||||
shimmer?: boolean
|
||||
/** Layout-only classes (margins, alignment). The loader owns its chrome. */
|
||||
className?: string
|
||||
/**
|
||||
* Escape hatch for the ink material: merged onto the SVG, so a caller can
|
||||
* override the gradient/glow CSS vars (`--tl-grad-inner`, `--tl-grad-outer`,
|
||||
* `--tl-glow`) to match a surface it hands off to. Inline, so it beats the
|
||||
* module defaults. Use sparingly — the loader owns its look by default.
|
||||
*/
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Gooey mono thinking indicator shown wherever chat is working.
|
||||
* Ink is near-black on light surfaces and off-white on dark surfaces.
|
||||
*
|
||||
* The goo filter operates on the alpha channel of transparent SVG shapes,
|
||||
* so the loader needs no backdrop and composites on any background. All
|
||||
* patterns share one filtered group, so the default cycling mode melts one
|
||||
* pattern into the next instead of hard-swapping.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThinkingLoader label='Thinking…' />
|
||||
* ```
|
||||
*/
|
||||
export function ThinkingLoader({
|
||||
variant,
|
||||
startVariant,
|
||||
settle = false,
|
||||
size = 20,
|
||||
label,
|
||||
phase,
|
||||
labelRatio = 0.7,
|
||||
shimmer = true,
|
||||
className,
|
||||
style,
|
||||
}: ThinkingLoaderProps) {
|
||||
// useId emits colons, which break url(#...) filter references — strip them.
|
||||
const id = useId().replace(/[^a-zA-Z0-9-]/g, '')
|
||||
const filterId = `tl-goo-${id}`
|
||||
const clipId = `tl-clip-${id}`
|
||||
const windowClipId = `tl-window-${id}`
|
||||
const gradientId = `tl-grad-${id}`
|
||||
const [cycleVariant, setCycleVariant] = useState<ThinkingLoaderVariant>(
|
||||
startVariant ?? 'metaballs'
|
||||
)
|
||||
const cycling = variant === undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!cycling) return
|
||||
// Settle: stop the cycle and melt to the terminal orb (goo handles the morph).
|
||||
if (settle) {
|
||||
setCycleVariant('orb')
|
||||
return
|
||||
}
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
// When a startVariant is given, hold it for one min-step so the cycle always
|
||||
// opens on that shape, then join the shared wall-clock morph timeline.
|
||||
let opened = startVariant === undefined
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const tick = () => {
|
||||
if (!opened) {
|
||||
opened = true
|
||||
setCycleVariant(startVariant as ThinkingLoaderVariant)
|
||||
timeout = setTimeout(tick, STEP_MIN_MS)
|
||||
return
|
||||
}
|
||||
const { variant: next, msUntilNext } = variantAtNow()
|
||||
setCycleVariant(next)
|
||||
timeout = setTimeout(tick, msUntilNext)
|
||||
}
|
||||
tick()
|
||||
return () => clearTimeout(timeout)
|
||||
}, [cycling, startVariant, settle])
|
||||
|
||||
// Phase-lock the CSS animations to the wall clock (set after mount so
|
||||
// server and client markup agree). All instances share the same negative
|
||||
// delay modulus, so their keyframes line up regardless of mount time.
|
||||
const [syncDelay, setSyncDelay] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`)
|
||||
}, [])
|
||||
|
||||
const shown = variant ?? cycleVariant
|
||||
// `phase` shows the world phrase for the shape on screen; otherwise the
|
||||
// caller's static label (if any). Cycling, it updates as the shape morphs.
|
||||
const displayLabel = phase ? VARIANT_PHRASE[shown] : label
|
||||
|
||||
// Crossfade the phrase when it changes: the outgoing phrase rises and fades
|
||||
// out while the incoming one rises and fades in, so they rotate smoothly
|
||||
// instead of snapping. The shimmer keeps running on the text underneath.
|
||||
const [shownLabel, setShownLabel] = useState(displayLabel)
|
||||
const [exitingLabel, setExitingLabel] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (displayLabel === shownLabel) return
|
||||
setExitingLabel(shownLabel)
|
||||
setShownLabel(displayLabel)
|
||||
}, [displayLabel, shownLabel])
|
||||
useEffect(() => {
|
||||
if (!exitingLabel) return
|
||||
const timeout = setTimeout(() => setExitingLabel(undefined), 420)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [exitingLabel])
|
||||
|
||||
const stages = cycling ? VARIANTS : [shown]
|
||||
|
||||
const loader = (
|
||||
<svg
|
||||
role={displayLabel ? undefined : 'status'}
|
||||
aria-label={displayLabel ? undefined : 'Thinking'}
|
||||
aria-hidden={displayLabel ? true : undefined}
|
||||
viewBox='0 0 100 100'
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(styles.frame, !displayLabel && className)}
|
||||
style={{
|
||||
...(syncDelay ? ({ '--tl-sync': syncDelay } as CSSProperties) : {}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{/* sRGB so the radial gradient fill keeps its authored midtones
|
||||
through the blur (linearRGB would wash the gradient out). The goo
|
||||
crush runs first; the inner glow then rides the merged silhouette's
|
||||
edge — a soft white inset, per the Figma loader spec. */}
|
||||
<filter
|
||||
id={filterId}
|
||||
x='-30%'
|
||||
y='-30%'
|
||||
width='160%'
|
||||
height='160%'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feGaussianBlur in='SourceGraphic' stdDeviation='5' result='blur' />
|
||||
<feColorMatrix
|
||||
in='blur'
|
||||
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9'
|
||||
result='goo'
|
||||
/>
|
||||
{/* Inner shadow from the goo silhouette's alpha (Figma technique:
|
||||
blur the alpha, subtract it from itself to leave an inner ring,
|
||||
tint white). stdDeviation 4.86 = the spec's 3.5 scaled 72→100. */}
|
||||
<feColorMatrix
|
||||
in='goo'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='gooAlpha'
|
||||
/>
|
||||
<feGaussianBlur in='gooAlpha' stdDeviation='4.86' result='innerBlur' />
|
||||
<feComposite
|
||||
in='innerBlur'
|
||||
in2='gooAlpha'
|
||||
operator='arithmetic'
|
||||
k2='-1'
|
||||
k3='1'
|
||||
result='innerMask'
|
||||
/>
|
||||
{/* Glow color + per-theme opacity ride a CSS var (light 0.6 / dark
|
||||
0.9) so one filter serves both themes. */}
|
||||
<feFlood className={styles.glow} result='glowColor' />
|
||||
<feComposite in='glowColor' in2='innerMask' operator='in' result='glow' />
|
||||
<feMerge>
|
||||
<feMergeNode in='goo' />
|
||||
<feMergeNode in='glow' />
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Radial gradient (center → edge), theme stops via CSS vars. Matches
|
||||
the Figma loader: light #4F4F4F→#6F6F6F, dark #A7A7A7→#D6D6D6.
|
||||
objectBoundingBox (default units) so EACH blob is shaded on its own
|
||||
box — Figma fits the gradient per shape, so small/offset dots read
|
||||
glossy (center dark, edge light) instead of flat. */}
|
||||
<radialGradient id={gradientId} cx='0.5' cy='0.5' r='0.5'>
|
||||
<stop className={styles.gradInner} />
|
||||
<stop offset='1' className={styles.gradOuter} />
|
||||
</radialGradient>
|
||||
{/* Shapes clip BEFORE the goo filter, so anything exiting the frame
|
||||
melts into the edge instead of getting a hard post-filter cut. */}
|
||||
<clipPath id={clipId}>
|
||||
<rect width='100' height='100' />
|
||||
</clipPath>
|
||||
{/* The original burst loader hid its flying dots under a 10px border,
|
||||
swallowing them at the inner window — this clip reproduces it. */}
|
||||
<clipPath id={windowClipId}>
|
||||
<rect x='12.5' y='12.5' width='75' height='75' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
filter={`url(#${filterId})`}
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{stages.map((v) => (
|
||||
<g
|
||||
key={v}
|
||||
clipPath={`url(#${v === 'burst' ? windowClipId : clipId})`}
|
||||
className={cn(styles.stage, v === shown && styles.stageActive)}
|
||||
>
|
||||
{VARIANT_SHAPES[v]}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
if (!displayLabel) return loader
|
||||
|
||||
return (
|
||||
<output
|
||||
className={cn(styles.labelRow, className)}
|
||||
style={
|
||||
{
|
||||
'--tl-label-size': `${size * labelRatio}px`,
|
||||
'--tl-label-gap': `${size * 0.4}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{loader}
|
||||
<span className={styles.labelStack}>
|
||||
{exitingLabel ? (
|
||||
<span key={exitingLabel} className={cn(styles.labelLayer, styles.labelOut)}>
|
||||
<span className={shimmer ? styles.label : styles.labelStatic}>{exitingLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span key={shownLabel} className={cn(styles.labelLayer, styles.labelIn)}>
|
||||
<span className={shimmer ? styles.label : styles.labelStatic}>{shownLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</output>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user