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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+49
View File
@@ -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>
}
/>
)
}
+18
View File
@@ -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'
+30
View File
@@ -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>
)
}
+158
View File
@@ -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;
}
}
+17
View File
@@ -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;
}
}
+448
View File
@@ -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.12.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>
)
}