{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "loading-01",
  "title": "Thinking ring",
  "description": "A thinking status whose eight dots push out from their ring in turn, so a bulge travels round it, beside a label a soft light passes over; when the work is done the ring draws itself closed round a check.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/mesh-tile.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/segments.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/loading/loading-01.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useRef } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { DoneLayer, useDrawnStroke } from './_parts/done-mark';\nimport type { Loop } from './_parts/loops';\nimport {\n  ThinkingDemo,\n  type IndicatorState,\n  type ThinkingDemoAgent,\n} from './_parts/thinking-demo';\nimport {\n  DEFAULT_LABEL,\n  MARK_BOX,\n  ThinkingStatus,\n  type ThinkingIndicatorProps,\n  type ThinkingSize,\n} from './_parts/thinking-status';\nimport { useLoopGate, useLoops } from './_parts/use-loops';\n\nconst DOT_COUNT = 8;\n\nconst DOTS = Array.from({ length: DOT_COUNT }, (_, index) => index);\n\nconst TURN_DEG = 360 / DOT_COUNT;\n\nconst WOBBLE_LAP_MS = 1200;\n\nconst DOT_STEP_MS = WOBBLE_LAP_MS / DOT_COUNT;\n\n/** Reaches are in dot sizes, so the same keyframes serve every size of the mark. */\nconst REST_REACH = -200;\n\nconst OUT_REACH = -258;\n\nconst IN_REACH = -186;\n\nconst REST_OPACITY = 0.35;\n\nconst RISE_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nconst SWAY_EASING = 'cubic-bezier(0.45, 0, 0.55, 1)';\n\ntype DotPose = {\n  reach: number;\n  scale: number;\n  opacity: number;\n};\n\nconst REST_POSE: DotPose = {\n  reach: REST_REACH,\n  scale: 1,\n  opacity: REST_OPACITY,\n};\n\n/** Under reduced motion the ring rests on one moment of its wobble, the bulge near the top right. */\nconst STILL_POSES: DotPose[] = [\n  { reach: -192, scale: 0.96, opacity: 0.6 },\n  { reach: -250, scale: 1.1, opacity: 1 },\n  { reach: -234, scale: 1.06, opacity: 0.8 },\n  { reach: -210, scale: 1.02, opacity: 0.5 },\n  REST_POSE,\n  REST_POSE,\n  REST_POSE,\n  { reach: -196, scale: 0.98, opacity: 0.45 },\n];\n\nfunction transformOf(index: number, reach: number, scale: number) {\n  const angle = index * TURN_DEG;\n  return `rotate(${angle}deg) translateY(${reach}%) scale(${scale})`;\n}\n\nfunction wobbleKeyframesOf(index: number): Keyframe[] {\n  const rest = transformOf(index, REST_REACH, 1);\n  return [\n    {\n      transform: rest,\n      opacity: REST_OPACITY,\n      offset: 0,\n      easing: RISE_EASING,\n    },\n    {\n      transform: transformOf(index, OUT_REACH, 1.12),\n      opacity: 1,\n      offset: 0.2,\n      easing: SWAY_EASING,\n    },\n    {\n      transform: transformOf(index, IN_REACH, 0.94),\n      opacity: 0.6,\n      offset: 0.42,\n      easing: SWAY_EASING,\n    },\n    { transform: rest, opacity: REST_OPACITY, offset: 0.62 },\n    { transform: rest, opacity: REST_OPACITY, offset: 1 },\n  ];\n}\n\n/** Each dot pushes out from the ring and sways back in turn, so a bulge travels round it. */\nfunction wobbleLoopsOf(root: HTMLElement): Loop[] {\n  const dots = Array.from(root.querySelectorAll('[data-dot]'));\n  return dots.map((element, index) => ({\n    element,\n    keyframes: wobbleKeyframesOf(index),\n    timing: {\n      duration: WOBBLE_LAP_MS,\n      delay: index * DOT_STEP_MS,\n      iterations: Infinity,\n    },\n  }));\n}\n\nfunction poseOf(index: number, showsStill: boolean) {\n  if (showsStill) {\n    return STILL_POSES[index] ?? REST_POSE;\n  }\n  return REST_POSE;\n}\n\nfunction dotStyleOf(index: number, showsStill: boolean) {\n  const pose = poseOf(index, showsStill);\n  const transform = transformOf(index, pose.reach, pose.scale);\n  return { transform, opacity: pose.opacity };\n}\n\nfunction dotsLayerOf(isDone: boolean) {\n  if (isDone) {\n    return 'opacity-0 duration-150';\n  }\n  return 'opacity-100 duration-200';\n}\n\nfunction ClosedRing() {\n  const ring = useDrawnStroke(0.42);\n  const check = useDrawnStroke(0.26, 0.24);\n  return (\n    <DoneLayer>\n      <motion.circle\n        cx={12}\n        cy={12}\n        r={7.7}\n        strokeWidth={1.75}\n        transform='rotate(-90 12 12)'\n        {...ring}\n      />\n      <motion.path\n        d='M8.9 12.3 11 14.4 15.1 10'\n        strokeWidth={1.75}\n        {...check}\n      />\n    </DoneLayer>\n  );\n}\n\ntype RingMarkProps = {\n  size: ThinkingSize;\n  isDone: boolean;\n  canLoop: boolean;\n  isPlaying: boolean;\n  isStill: boolean;\n};\n\nfunction RingMark({\n  size,\n  isDone,\n  canLoop,\n  isPlaying,\n  isStill,\n}: RingMarkProps) {\n  const dotsRef = useRef<HTMLSpanElement>(null);\n  const showsStill = isStill && !isDone;\n\n  useLoops(dotsRef, { canLoop, isPlaying, create: wobbleLoopsOf });\n\n  return (\n    <span\n      aria-hidden='true'\n      className={cn('relative shrink-0', MARK_BOX[size])}\n    >\n      <span\n        ref={dotsRef}\n        className={cn(\n          'absolute inset-0 transition-opacity ease-(--ease-out-strong)',\n          dotsLayerOf(isDone)\n        )}\n      >\n        {DOTS.map((index) => (\n          <span\n            key={index}\n            data-dot=''\n            style={dotStyleOf(index, showsStill)}\n            className='bg-primary absolute top-1/2 left-1/2 -mt-[8%] -ml-[8%] size-[16%] rounded-full'\n          />\n        ))}\n      </span>\n      <AnimatePresence initial={false}>\n        {isDone && <ClosedRing key='done' />}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/**\n * A thinking status: eight dots on a ring push out and sway back in turn, so a bulge travels round\n * it, beside a label a soft light passes over. When the work is done the dots fade as the ring\n * draws itself closed round a check.\n */\nexport function ThinkingRing({\n  label = DEFAULT_LABEL,\n  size = 'md',\n  isDone = false,\n  className,\n}: ThinkingIndicatorProps) {\n  const { ref, canLoop, isPlaying, isStill } = useLoopGate(!isDone);\n\n  return (\n    <ThinkingStatus\n      ref={ref}\n      label={label}\n      size={size}\n      isDone={isDone}\n      canLoop={canLoop}\n      isPlaying={isPlaying}\n      className={className}\n    >\n      <RingMark\n        size={size}\n        isDone={isDone}\n        canLoop={canLoop}\n        isPlaying={isPlaying}\n        isStill={isStill}\n      />\n    </ThinkingStatus>\n  );\n}\n\nconst AGENT: ThinkingDemoAgent = { name: 'Planner', series: 1 };\n\nconst STEPS = ['Thinking', 'Reading the brief', 'Weighing three options'];\n\nfunction renderRing({ label, isDone }: IndicatorState) {\n  return <ThinkingRing label={label} isDone={isDone} size='lg' />;\n}\n\nexport default function Loading01() {\n  return (\n    <ThinkingDemo\n      agent={AGENT}\n      steps={STEPS}\n      doneLabel='Plan ready'\n      renderIndicator={renderRing}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/loading/loading-01.tsx"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/done-mark.tsx",
      "content": "'use client';\n\nimport { motion, type Transition } from 'motion/react';\nimport type { ReactNode } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { EASE_OUT_STRONG } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nconst DRAWN = { pathLength: 1 };\n\nconst NOT_DRAWN = { pathLength: 0 };\n\nconst SHOWN = { opacity: 1 };\n\nconst HIDDEN = { opacity: 0 };\n\nconst LEAVING = { opacity: 0, transition: { duration: 0.1 } };\n\nconst FADE_IN: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nfunction layerStartOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return HIDDEN;\n  }\n  return SHOWN;\n}\n\ntype DoneLayerProps = {\n  /** Inside a 24 by 24 box, drawn in the text colour of success. */\n  children: ReactNode;\n  className?: string;\n};\n\n/**\n * The strokes a mark lands on when the work is done, over the whole mark. It leaves faster than it\n * came, and under reduced motion it fades in already drawn.\n */\nexport function DoneLayer({ children, className }: DoneLayerProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  return (\n    <motion.svg\n      viewBox='0 0 24 24'\n      fill='none'\n      stroke='currentColor'\n      strokeLinecap='round'\n      strokeLinejoin='round'\n      initial={layerStartOf(shouldReduceMotion)}\n      animate={SHOWN}\n      exit={LEAVING}\n      transition={FADE_IN}\n      className={cn(\n        'text-success-foreground absolute inset-0 size-full',\n        className\n      )}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\n/** A stroke that draws itself from its start, after `delay` seconds; already drawn under reduced motion. */\nexport function useDrawnStroke(durationS: number, delay = 0) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const transition: Transition = {\n    duration: durationS,\n    delay,\n    ease: EASE_OUT_STRONG,\n  };\n  if (shouldReduceMotion) {\n    return { initial: DRAWN, animate: DRAWN, transition };\n  }\n  return { initial: NOT_DRAWN, animate: DRAWN, transition };\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/done-mark.tsx"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/loops.ts",
      "content": "export type Loop = {\n  element: Element;\n  keyframes: Keyframe[];\n  timing: KeyframeAnimationOptions;\n};\n\nconst LAND_MS = 220;\n\nconst LAND_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\n/**\n * Each loop runs in the browser's own animation engine on transform and opacity only, so the\n * compositor plays it and the page does no work per frame.\n */\nexport function startLoops(loops: readonly Loop[]) {\n  return loops.map((loop) => loop.element.animate(loop.keyframes, loop.timing));\n}\n\n/**\n * A cancelled animation is left alone: playing or even pausing it would bring it back and hold its\n * element in a pose its mark has already left.\n */\nfunction isCancelled(animation: Animation) {\n  return animation.playState === 'idle';\n}\n\nexport function playAll(animations: readonly Animation[]) {\n  for (const animation of animations) {\n    if (!isCancelled(animation)) {\n      animation.play();\n    }\n  }\n}\n\nexport function pauseAll(animations: readonly Animation[]) {\n  for (const animation of animations) {\n    if (!isCancelled(animation)) {\n      animation.pause();\n    }\n  }\n}\n\nexport function cancelAll(animations: readonly Animation[]) {\n  for (const animation of animations) {\n    animation.cancel();\n  }\n}\n\nfunction targetOf(animation: Animation) {\n  const effect = animation.effect;\n  if (effect instanceof KeyframeEffect) {\n    return effect.target;\n  }\n  return null;\n}\n\nfunction poseOf(element: Element): Keyframe {\n  const styles = getComputedStyle(element);\n  return { transform: styles.transform, opacity: styles.opacity };\n}\n\n/** From the pose its loop left it in, an element glides back to its own resting style, then lets go. */\nfunction land(animation: Animation) {\n  const target = targetOf(animation);\n  if (!target?.isConnected) {\n    animation.cancel();\n    return;\n  }\n  const shown = poseOf(target);\n  animation.cancel();\n  const rest = poseOf(target);\n  target.animate([shown, rest], { duration: LAND_MS, easing: LAND_EASING });\n}\n\nexport function landAll(animations: readonly Animation[]) {\n  for (const animation of animations) {\n    land(animation);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/loops.ts"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/shimmer-label.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, type Transition } from 'motion/react';\nimport { Fragment, useRef } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { EASE_OUT_STRONG } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { leadingSpaceOf, wordsOf } from '@/registry/ndeko/lib/words';\n\nimport type { Loop } from './loops';\nimport { useLoops } from './use-loops';\n\nconst LETTER_STEP_MS = 70;\n\nconst GLINT_RISE_MS = 140;\n\nconst GLINT_FALL_MS = 260;\n\n/** How far the light lifts a letter towards the text colour: a soft shimmer, short of a flash. */\nconst GLINT_PEAK = 0.85;\n\nconst PASS_REST_MS = 900;\n\nconst SINE_EASING = 'cubic-bezier(0.37, 0, 0.63, 1)';\n\nconst ENTER: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nconst EXIT: Transition = { duration: 0.12, ease: EASE_OUT_STRONG };\n\nconst ARRIVING = { opacity: 0, y: 4, filter: 'blur(2px)' };\n\nconst SHOWN = { opacity: 1, y: 0, filter: 'blur(0px)', transition: ENTER };\n\nconst LEAVING = { opacity: 0, y: -4, filter: 'blur(2px)', transition: EXIT };\n\nconst FADING_IN = { opacity: 0 };\n\nconst FADED_IN = { opacity: 1, transition: ENTER };\n\nconst FADING_OUT = { opacity: 0, transition: EXIT };\n\nfunction presenceOf(shouldReduceMotion: boolean) {\n  if (shouldReduceMotion) {\n    return { initial: FADING_IN, animate: FADED_IN, exit: FADING_OUT };\n  }\n  return { initial: ARRIVING, animate: SHOWN, exit: LEAVING };\n}\n\nfunction periodOf(letterCount: number) {\n  const sweep = letterCount * LETTER_STEP_MS;\n  const glint = GLINT_RISE_MS + GLINT_FALL_MS;\n  return sweep + glint + PASS_REST_MS;\n}\n\nfunction glintKeyframesOf(periodMs: number): Keyframe[] {\n  const litAt = GLINT_RISE_MS / periodMs;\n  const goneAt = (GLINT_RISE_MS + GLINT_FALL_MS) / periodMs;\n  return [\n    { opacity: 0, offset: 0, easing: SINE_EASING },\n    { opacity: GLINT_PEAK, offset: litAt, easing: SINE_EASING },\n    { opacity: 0, offset: goneAt },\n    { opacity: 0, offset: 1 },\n  ];\n}\n\n/** A soft light passes over the letters one after the other: each one brightens towards the text colour and fades back. */\nfunction glintLoopsOf(root: HTMLElement): Loop[] {\n  const glints = Array.from(root.querySelectorAll('[data-glint]'));\n  const periodMs = periodOf(glints.length);\n  const keyframes = glintKeyframesOf(periodMs);\n  return glints.map((element, index) => ({\n    element,\n    keyframes,\n    timing: {\n      duration: periodMs,\n      delay: index * LETTER_STEP_MS,\n      iterations: Infinity,\n    },\n  }));\n}\n\ntype WordProps = {\n  word: string;\n};\n\n/** A word never breaks inside itself; each letter carries its own light over it. */\nfunction Word({ word }: WordProps) {\n  const letters = Array.from(word);\n  return (\n    <span className='whitespace-nowrap'>\n      {letters.map((letter, index) => (\n        <span key={index} className='relative inline-block'>\n          {letter}\n          <span\n            data-glint=''\n            className='text-foreground pointer-events-none absolute inset-0 opacity-0 select-none'\n          >\n            {letter}\n          </span>\n        </span>\n      ))}\n    </span>\n  );\n}\n\ntype ShimmerTextProps = {\n  text: string;\n  canLoop: boolean;\n  isPlaying: boolean;\n};\n\nfunction ShimmerText({ text, canLoop, isPlaying }: ShimmerTextProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const words = wordsOf(text);\n\n  useLoops(ref, { canLoop, isPlaying, create: glintLoopsOf });\n\n  return (\n    <span ref={ref} className='block'>\n      {words.map((word, index) => (\n        <Fragment key={index}>\n          {leadingSpaceOf(index)}\n          <Word word={word} />\n        </Fragment>\n      ))}\n    </span>\n  );\n}\n\nfunction toneOf(isDone: boolean) {\n  if (isDone) {\n    return 'text-foreground';\n  }\n  return 'text-muted-foreground';\n}\n\ntype ShimmerLabelProps = {\n  text: string;\n  isDone: boolean;\n  canLoop: boolean;\n  isPlaying: boolean;\n  className?: string;\n};\n\n/**\n * The words of a status, muted while the work runs, with a light that passes over them letter by\n * letter (opacity only), and in the text colour once it is done. A new label rises in as the old\n * one leaves. The words are drawn for the eye: the status that holds them says them once.\n */\nexport function ShimmerLabel({\n  text,\n  isDone,\n  canLoop,\n  isPlaying,\n  className,\n}: ShimmerLabelProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const presence = presenceOf(shouldReduceMotion);\n\n  return (\n    <span\n      aria-hidden='true'\n      className={cn(\n        'relative block min-w-0 transition-colors duration-200',\n        toneOf(isDone),\n        className\n      )}\n    >\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span key={text} className='block' {...presence}>\n          <ShimmerText text={text} canLoop={canLoop} isPlaying={isPlaying} />\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/shimmer-label.tsx"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/thinking-demo.tsx",
      "content": "'use client';\n\nimport { useState, type ReactNode } from 'react';\n\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { seriesColorOf, type SeriesSlot } from '@/registry/ndeko/lib/agent-run';\nimport { hiddenIf } from '@/registry/ndeko/lib/demonstration';\nimport { MeshTile } from '@/registry/ndeko/ui/mesh-tile';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\ntype Phase = 'thinking' | 'done';\n\nconst PHASES: Segment<Phase>[] = [\n  { value: 'thinking', label: 'Thinking' },\n  { value: 'done', label: 'Done' },\n];\n\nconst LABEL_HOLD_MS = 1800;\n\nconst DONE_HOLD_MS = 2400;\n\nexport type ThinkingDemoAgent = {\n  name: string;\n  series: SeriesSlot;\n};\n\nexport type IndicatorState = {\n  label: string;\n  isDone: boolean;\n};\n\ntype ThinkingDemoProps = {\n  agent: ThinkingDemoAgent;\n  /** The labels the work goes through; the first one shows when it starts. */\n  steps: readonly string[];\n  doneLabel: string;\n  renderIndicator: (state: IndicatorState) => ReactNode;\n  /** A second mark after the agent's name, for a variant shown in a header. */\n  renderBesideName?: (state: IndicatorState) => ReactNode;\n};\n\nfunction phaseOf(isDone: boolean): Phase {\n  if (isDone) {\n    return 'done';\n  }\n  return 'thinking';\n}\n\nfunction labelOf(\n  steps: readonly string[],\n  stepIndex: number,\n  isDone: boolean,\n  doneLabel: string\n) {\n  if (isDone) {\n    return doneLabel;\n  }\n  return steps[stepIndex] ?? '';\n}\n\n/**\n * An agent's turn in a conversation: its name, then its status. The demonstration walks the work\n * through its labels, lands it on done, holds, and starts again; the switch under the card lets a\n * person do the same.\n */\nexport function ThinkingDemo({\n  agent,\n  steps,\n  doneLabel,\n  renderIndicator,\n  renderBesideName,\n}: ThinkingDemoProps) {\n  const [stepIndex, setStepIndex] = useState(0);\n  const [isDone, setIsDone] = useState(false);\n  const label = labelOf(steps, stepIndex, isDone, doneLabel);\n  const state: IndicatorState = { label, isDone };\n\n  function startOver() {\n    setStepIndex(0);\n    setIsDone(false);\n  }\n\n  function choose(phase: Phase) {\n    if (phase === 'done') {\n      setIsDone(true);\n      return;\n    }\n    startOver();\n  }\n\n  const nextLabels: AutoplayStep[] = steps.slice(1).map((_, index) => ({\n    after: LABEL_HOLD_MS,\n    run: () => setStepIndex(index + 1),\n  }));\n  const autoplaySteps: AutoplayStep[] = [\n    ...nextLabels,\n    { after: LABEL_HOLD_MS, run: () => setIsDone(true) },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(autoplaySteps, {\n    loopAfter: DONE_HOLD_MS,\n    onReset: startOver,\n  });\n\n  useKeptHeight(ref);\n\n  return (\n    <div\n      ref={ref}\n      className='flex w-full max-w-[22rem] flex-col items-center gap-3'\n    >\n      <div className='bg-card hairline-ring ring-border flex w-full flex-col gap-2 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'>\n        <div className='flex min-w-0 items-center gap-2'>\n          <MeshTile seed={agent.name} color={seriesColorOf(agent.series)} />\n          <p className='min-w-0 truncate text-[13px] font-medium'>\n            {agent.name}\n          </p>\n          {renderBesideName?.(state)}\n        </div>\n        <div aria-hidden={hiddenIf(isAutoplaying)} className='min-w-0 ps-7'>\n          {renderIndicator(state)}\n        </div>\n      </div>\n      <Segments\n        value={phaseOf(isDone)}\n        segments={PHASES}\n        onValueChange={choose}\n        label='Indicator state'\n        className='w-full max-w-[13rem]'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/thinking-demo.tsx"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/thinking-status.tsx",
      "content": "'use client';\n\nimport type { ReactNode, Ref } from 'react';\n\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { ShimmerLabel } from './shimmer-label';\n\nexport type ThinkingSize = 'sm' | 'md' | 'lg';\n\nexport type ThinkingIndicatorProps = {\n  /** What the agent is doing, beside the mark and read by screen readers: Thinking, Reading the brief. */\n  label?: string;\n  /** `sm` sits in a line of small text, `md` in a message, `lg` alone. */\n  size?: ThinkingSize;\n  /** The work is over: the mark lands on its done state and the label stops shimmering. */\n  isDone?: boolean;\n  className?: string;\n};\n\nexport const DEFAULT_LABEL = 'Thinking';\n\n/** The mark is as tall as one line of its label, so a label that wraps keeps the mark on its first line. */\nexport const MARK_BOX: Record<ThinkingSize, string> = {\n  sm: 'size-4',\n  md: 'size-5',\n  lg: 'size-6',\n};\n\nconst LABEL_TYPE: Record<ThinkingSize, string> = {\n  sm: 'text-xs leading-4',\n  md: 'text-[13px] leading-5',\n  lg: 'text-sm leading-6',\n};\n\nconst ROW_GAP: Record<ThinkingSize, string> = {\n  sm: 'gap-1.5',\n  md: 'gap-2',\n  lg: 'gap-2.5',\n};\n\ntype ThinkingStatusProps = {\n  ref: Ref<HTMLSpanElement>;\n  label: string;\n  size: ThinkingSize;\n  isDone: boolean;\n  canLoop: boolean;\n  isPlaying: boolean;\n  /** False for a mark shown alone: the label is still read by screen readers. */\n  showsLabel?: boolean;\n  className?: string;\n  /** The mark, hidden from screen readers. */\n  children: ReactNode;\n};\n\n/** A mark and its label in one status, so a change of label or the end of the work is said once. */\nexport function ThinkingStatus({\n  ref,\n  label,\n  size,\n  isDone,\n  canLoop,\n  isPlaying,\n  showsLabel = true,\n  className,\n  children,\n}: ThinkingStatusProps) {\n  return (\n    <span\n      ref={ref}\n      role='status'\n      className={cn(\n        'inline-flex max-w-full min-w-0 items-start align-top',\n        ROW_GAP[size],\n        className\n      )}\n    >\n      {children}\n      <span className='sr-only'>{label}</span>\n      {showsLabel && (\n        <ShimmerLabel\n          text={label}\n          isDone={isDone}\n          canLoop={canLoop}\n          isPlaying={isPlaying}\n          className={LABEL_TYPE[size]}\n        />\n      )}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/thinking-status.tsx"
    },
    {
      "path": "registry/ndeko/components/loading/_parts/use-loops.ts",
      "content": "'use client';\n\nimport { useInView } from 'motion/react';\nimport { useEffect, useEffectEvent, useRef, type RefObject } from 'react';\n\nimport { useDocumentVisible } from '@/registry/ndeko/hooks/use-agent-run';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\n\nimport {\n  cancelAll,\n  landAll,\n  pauseAll,\n  playAll,\n  startLoops,\n  type Loop,\n} from './loops';\n\n/**\n * Whether an indicator may loop and whether it plays right now. It loops while it runs and the\n * reduced motion setting is off; it plays only while it is on screen in the tab that is shown.\n */\nexport function useLoopGate<Root extends HTMLElement = HTMLSpanElement>(\n  isRunning: boolean\n) {\n  const ref = useRef<Root>(null);\n  const isInView = useInView(ref);\n  const isTabVisible = useDocumentVisible();\n  const isStill = usePrefersReducedMotion();\n  const canLoop = isRunning && !isStill;\n  const isOnScreen = isInView && isTabVisible;\n  const isPlaying = canLoop && isOnScreen;\n\n  return { ref, canLoop, isPlaying, isStill };\n}\n\ntype LoopsOptions<Root> = {\n  canLoop: boolean;\n  isPlaying: boolean;\n  create: (root: Root) => Loop[];\n};\n\n/**\n * The loops of a mark, made when it starts running and paused whenever it is not seen, so they ask\n * for no frame off screen or in a hidden tab. When it stops running each element glides back to\n * its resting style and the loops are gone, so a finished mark costs nothing.\n */\nexport function useLoops<Root extends HTMLElement>(\n  rootRef: RefObject<Root | null>,\n  { canLoop, isPlaying, create }: LoopsOptions<Root>\n) {\n  const running = useRef<Animation[] | null>(null);\n  const isStill = usePrefersReducedMotion();\n\n  const loopsOf = useEffectEvent((root: Root) => {\n    const loops = create(root);\n    return startLoops(loops);\n  });\n\n  /** Stopped by the reduced motion setting, a mark goes straight to rest instead of gliding there. */\n  const finish = useEffectEvent((animations: Animation[], root: Root) => {\n    const canGlide = root.isConnected && !isStill;\n    if (canGlide) {\n      landAll(animations);\n      return;\n    }\n    cancelAll(animations);\n  });\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root || !canLoop) {\n      return;\n    }\n    const animations = loopsOf(root);\n    pauseAll(animations);\n    running.current = animations;\n    return () => {\n      running.current = null;\n      finish(animations, root);\n    };\n  }, [rootRef, canLoop]);\n\n  useEffect(() => {\n    const animations = running.current;\n    if (!animations || !isPlaying) {\n      return;\n    }\n    playAll(animations);\n    return () => pauseAll(animations);\n  }, [canLoop, isPlaying]);\n}\n",
      "type": "registry:component",
      "target": "components/loading/_parts/use-loops.ts"
    }
  ],
  "categories": [
    "components",
    "loading"
  ],
  "type": "registry:component"
}