'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 = { metaballs: ( <> ), relay: ( <> ), corners: ( <> ), // burst (Thinking) — four dots fling out of a center cross and are swallowed // at the window edge, then fire again: the Core churning. burst: ( <> ), compass: ( <> ), 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. */} ), // 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: ( <> ), // 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: , } /** * 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 = { 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 * * ``` */ 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( 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 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(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(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 = ( {/* 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. */} {/* 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. */} {/* Glow color + per-theme opacity ride a CSS var (light 0.6 / dark 0.9) so one filter serves both themes. */} {/* 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. */} {/* Shapes clip BEFORE the goo filter, so anything exiting the frame melts into the edge instead of getting a hard post-filter cut. */} {/* The original burst loader hid its flying dots under a 10px border, swallowing them at the inner window — this clip reproduces it. */} {stages.map((v) => ( {VARIANT_SHAPES[v]} ))} ) if (!displayLabel) return loader return ( {loader} {exitingLabel ? ( {exitingLabel} ) : null} {shownLabel} ) }