{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sula-segmented",
  "type": "registry:ui",
  "dependencies": [
    "clsx",
    "motion",
    "ogl",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "https://usva.build/r/sula-motion.json",
    "https://usva.build/r/sula-core.json"
  ],
  "files": [
    {
      "path": "segmented-geometry.ts",
      "target": "components/ui/segmented-geometry.ts",
      "type": "registry:ui",
      "content": "import type { Blob, Neck } from \"./geometry\";\nimport {\n  c1Settle,\n  clamp01,\n  mix,\n  smoother,\n  smoothstep,\n} from \"./curves\";\n\n/** The tether back to the source starts melting once the pill has clearly left. */\nconst NECK_MELT_START = 0.08;\n/** ...and is fully gone by here, well before landing, via strength rather than\n * by thinning: a thinning neck ends as a hard line that snaps out. */\nconst NECK_MELT_END = 0.55;\n/** Mid-flight the pill squashes a little, like liquid stretched by its travel. */\nconst FLIGHT_SQUASH = 0.16;\nconst FLIGHT_STRETCH = 0.09;\n\n/** A measured segment rect, relative to the stage box, as a fully rounded pill. */\nexport function pillFromRect(rect: {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}): Blob {\n  const hw = rect.width / 2;\n  const hh = rect.height / 2;\n  return {\n    cx: rect.left + hw,\n    cy: rect.top + hh,\n    hw,\n    hh,\n    r: Math.min(hw, hh),\n  };\n}\n\n/**\n * The indicator mid-transition: one pill that leaves the source, travels in a\n * straight line to the target and merges into it, its size eased continuously\n * from source to target the whole way. A brief fat neck ties it back to the\n * source and melts via strength while the pill is still near, so nothing ever\n * thins into a thread or snaps. `t` runs 0 (source) to 1 (target) and may pass 1\n * for the settle overshoot; overshoot moves position, never size. At rest a\n * caller draws just `[targetPill]`; this covers only the live transition.\n */\nexport function indicatorPhase(\n  source: Blob,\n  target: Blob,\n  t: number,\n): { blobs: Blob[]; neck: Neck | null } {\n  const p = clamp01(t);\n  const travel = c1Settle(t, 0);\n  const sizeT = smoother(p);\n  const flight = Math.sin(Math.PI * p);\n\n  const cx = mix(source.cx, target.cx, travel);\n  const cy = mix(source.cy, target.cy, travel);\n  const hw = mix(source.hw, target.hw, sizeT) * (1 + FLIGHT_STRETCH * flight);\n  const hh = mix(source.hh, target.hh, sizeT) * (1 - FLIGHT_SQUASH * flight);\n  const pill: Blob = { cx, cy, hw, hh, r: Math.min(hw, hh) };\n\n  const strength = 1 - smoothstep(NECK_MELT_START, NECK_MELT_END, p);\n  if (strength <= 0.001) return { blobs: [pill], neck: null };\n\n  const dir = Math.sign(target.cx - source.cx) || 1;\n  const neck: Neck = {\n    ax: source.cx,\n    ay: source.cy,\n    bx: cx - dir * hw,\n    by: cy,\n    r: Math.min(source.hh, hh) * 0.55,\n    strength,\n  };\n  return { blobs: [pill], neck };\n}\n"
    },
    {
      "path": "sula-segmented.tsx",
      "target": "components/ui/sula-segmented.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\nimport { animate, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  createField,\n  type FieldColors,\n  resolveColor,\n  shineForBackdrop,\n} from \"./field\";\nimport { type Blob, type Neck, packUniforms } from \"./geometry\";\nimport { createPauseGate } from \"./pause\";\nimport { useContextRecovery } from \"./recovery\";\nimport { useFieldRetune } from \"./retune\";\nimport { createEnergyTracker } from \"./energy\";\nimport { switchSpring } from \"./springs\";\nimport { indicatorPhase, pillFromRect } from \"./segmented-geometry\";\n\nexport interface SulaSegmentedItem {\n  value: string;\n  label: React.ReactNode;\n  icon?: React.ReactNode;\n}\n\nexport interface SulaSegmentedProps\n  extends Omit<\n    React.HTMLAttributes<HTMLDivElement>,\n    \"onChange\" | \"defaultValue\"\n  > {\n  items: SulaSegmentedItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  size?: \"sm\" | \"md\";\n  /** false or reduced-motion renders the plain sliding pill and mounts no canvas. */\n  fluid?: boolean;\n  /** Drops the track fill and border, leaving the indicator on a bare surface. */\n  bare?: boolean;\n  accentColor?: string;\n  backdrop?: string;\n  tint?: string;\n  shine?: number;\n}\n\nconst sizeClasses: Record<NonNullable<SulaSegmentedProps[\"size\"]>, string> = {\n  sm: \"h-8 px-3\",\n  md: \"h-9 px-4\",\n};\n\n/** Room past the track so the pill's rounded caps are not clipped by the stage. */\nconst SLACK_X = 20;\nconst SLACK_Y = 10;\nconst MAX_DPR = 2;\n/** Merge radius at rest: firm glass. */\nconst REST_K = 14;\n/** Merge radius while a switch is live: gooey. */\nconst K_ACTIVE = 24;\n/** Peak surface undulation in px, alive only while the droplet moves. */\nconst WOBBLE = 0.6;\n\nfunction readColors(\n  node: HTMLElement,\n  overrides: {\n    backdrop?: string;\n    tint?: string;\n    accent?: string;\n    shine?: number;\n  },\n): FieldColors {\n  const styles = getComputedStyle(node);\n  const token = (name: string) => styles.getPropertyValue(name).trim();\n  const backdrop = resolveColor(overrides.backdrop ?? token(\"--usva-bg\"));\n  const tintToken =\n    overrides.tint ?? token(\"--usva-surface-2\") ?? token(\"--usva-surface\");\n  return {\n    backdrop,\n    tint: resolveColor(tintToken || token(\"--usva-surface\")),\n    accent: resolveColor(overrides.accent ?? token(\"--usva-accent\")),\n    shine: overrides.shine ?? shineForBackdrop(backdrop),\n  };\n}\n\nexport const SulaSegmented = React.forwardRef<\n  HTMLDivElement,\n  SulaSegmentedProps\n>(\n  (\n    {\n      items,\n      value,\n      defaultValue,\n      onValueChange,\n      size = \"md\",\n      fluid = true,\n      bare = false,\n      accentColor,\n      backdrop,\n      tint,\n      shine,\n      className,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const reduced = useReducedMotion();\n    const rootRef = React.useRef<HTMLDivElement | null>(null);\n    const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n    const stageRef = React.useRef<HTMLDivElement | null>(null);\n    const segmentRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n\n    const { failed, generation, onContextLost, onContextReady } =\n      useContextRecovery(canvasRef);\n    const overrides = React.useMemo(\n      () => ({ backdrop, tint, accent: accentColor, shine }),\n      [backdrop, tint, accentColor, shine],\n    );\n    const overridesRef = React.useRef(overrides);\n    overridesRef.current = overrides;\n    const fieldRef = React.useRef<ReturnType<typeof createField>>(null);\n    const wakeRef = React.useRef<(() => void) | null>(null);\n    const [mounted, setMounted] = React.useState(false);\n    React.useEffect(() => setMounted(true), []);\n\n    const isFluid = fluid && !reduced && !failed && mounted;\n\n    const isControlled = value !== undefined;\n    const [uncontrolled, setUncontrolled] = React.useState(\n      () => defaultValue ?? items[0]?.value,\n    );\n    const current = isControlled ? value : uncontrolled;\n\n    const activeIndex = Math.max(\n      0,\n      items.findIndex((item) => item.value === current),\n    );\n    const activeIndexRef = React.useRef(activeIndex);\n    activeIndexRef.current = activeIndex;\n\n    const switchRef = React.useRef<(previous: number) => void>(() => {});\n\n    // The plain sliding pill, used whenever the field is not running.\n    const [indicator, setIndicator] = React.useState({\n      left: 0,\n      width: 0,\n      ready: false,\n    });\n    const measurePlain = React.useCallback(() => {\n      const el = segmentRefs.current[activeIndex];\n      if (!el) return;\n      setIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });\n    }, [activeIndex]);\n\n    React.useLayoutEffect(() => {\n      measurePlain();\n    }, [measurePlain]);\n\n    React.useEffect(() => {\n      if (typeof ResizeObserver === \"undefined\") return;\n      const observer = new ResizeObserver(() => measurePlain());\n      for (const el of segmentRefs.current) {\n        if (el) observer.observe(el);\n      }\n      return () => observer.disconnect();\n    }, [measurePlain]);\n\n    // biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context\n    React.useEffect(() => {\n      if (!isFluid) return;\n      const canvas = canvasRef.current;\n      const stage = stageRef.current;\n      const root = rootRef.current;\n      if (!canvas || !stage || !root) return;\n\n      const field = createField({\n        canvas,\n        colors: readColors(root, overridesRef.current),\n        onContextLost,\n      });\n      if (!field) {\n        onContextLost();\n        return;\n      }\n      fieldRef.current = field;\n      onContextReady();\n\n      let pills: Blob[] = [];\n      let canvasH = 0;\n      let dpr = 1;\n      let raf = 0;\n      let running = 0;\n      const energy = createEnergyTracker();\n      const start = performance.now();\n\n      const swT = { value: 1 };\n      let pSwitch = 1;\n      let transitioning = false;\n      let source: Blob | null = null;\n      let target: Blob | null = null;\n      let lastIndicator: Blob | null = null;\n\n      let lastW = 0;\n      let lastH = 0;\n      const measure = () => {\n        const box = stage.getBoundingClientRect();\n        const width = Math.ceil(box.width);\n        const height = Math.ceil(box.height);\n        const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);\n        if (width !== lastW || height !== lastH || nextDpr !== dpr) {\n          canvas.style.width = `${width}px`;\n          canvas.style.height = `${height}px`;\n          field.resize(width, height, nextDpr);\n          lastW = width;\n          lastH = height;\n        }\n        canvasH = height;\n        dpr = nextDpr;\n        pills = segmentRefs.current.map((el) => {\n          const rect = el?.getBoundingClientRect();\n          if (!rect)\n            return pillFromRect({ left: 0, top: 0, width: 0, height: 0 });\n          return pillFromRect({\n            left: rect.left - box.left,\n            top: rect.top - box.top,\n            width: rect.width,\n            height: rect.height,\n          });\n        });\n      };\n\n      const paint = (blobs: Blob[], necks: Neck[], liveK: number) => {\n        field.draw({\n          packed: packUniforms({ blobs, necks, k: liveK }, dpr, canvasH),\n          k: liveK * dpr,\n          time: (performance.now() - start) / 1000,\n          wobble: WOBBLE * energy.value,\n          alpha: 1,\n        });\n      };\n\n      const liveK = () => REST_K + (K_ACTIVE - REST_K) * energy.value;\n\n      const tick = () => {\n        if (transitioning && source && target) {\n          const v = swT.value - pSwitch;\n          pSwitch = swT.value;\n          energy.bump(Math.abs(v));\n          const k = liveK();\n          const { blobs, neck } = indicatorPhase(source, target, swT.value);\n          lastIndicator = blobs[0] ?? null;\n          const necks: Neck[] = neck ? [neck] : [];\n          paint(blobs, necks, k);\n        } else {\n          energy.bump(0);\n          const active = pills[activeIndexRef.current];\n          if (active) paint([active], [], liveK());\n        }\n\n        if (running === 0 && energy.parked()) {\n          transitioning = false;\n          return;\n        }\n        raf = requestAnimationFrame(tick);\n      };\n      const wake = () => {\n        if (!gate.awake()) return;\n        cancelAnimationFrame(raf);\n        raf = requestAnimationFrame(tick);\n      };\n      wakeRef.current = wake;\n      const gate = createPauseGate({\n        target: root,\n        onPause: () => cancelAnimationFrame(raf),\n        onResume: () => wake(),\n      });\n\n      const controls = new Set<ReturnType<typeof animate>>();\n      const run = (\n        subject: { value: number },\n        to: [number, number],\n        opts: typeof switchSpring,\n      ) => {\n        running += 1;\n        const control = animate(subject, { value: to }, opts);\n        controls.add(control);\n        const finish = () => {\n          running = Math.max(0, running - 1);\n          controls.delete(control);\n        };\n        void control.finished.then(finish, finish);\n        wake();\n        return control;\n      };\n\n      let disposed = false;\n      let swControl: ReturnType<typeof animate> | null = null;\n\n      const doSwitch = (previous: number) => {\n        /* Restarting an in-flight switch from a segment pill would yank the\n         * indicator back and read as a stall; continue from where it is. */\n        const inFlight = transitioning ? lastIndicator : null;\n        measure();\n        const src = inFlight ?? pills[previous];\n        const tgt = pills[activeIndexRef.current];\n        if (!src || !tgt) return;\n        source = src;\n        target = tgt;\n        transitioning = true;\n        swT.value = 0;\n        pSwitch = 0;\n        swControl?.stop();\n        /* Motion caches one visual element per animated subject and reads the next\n         * \"from\" out of it, so a bare swT.value reset is invisible to it. The\n         * explicit [0, 1] keyframes are what make the second and every later switch\n         * animate instead of snapping. */\n        swControl = run(swT, [0, 1], switchSpring);\n        void swControl.finished.then(\n          () => {\n            if (disposed) return;\n            transitioning = false;\n            wake();\n          },\n          () => undefined,\n        );\n      };\n      switchRef.current = doSwitch;\n\n      measure();\n      wake();\n\n      const remeasure = () => {\n        measure();\n        wake();\n      };\n      const observer =\n        typeof ResizeObserver === \"undefined\"\n          ? null\n          : new ResizeObserver(remeasure);\n      observer?.observe(root);\n      window.addEventListener(\"resize\", remeasure);\n      void document.fonts?.ready.then(remeasure).catch(() => undefined);\n\n      const themeObserver =\n        typeof MutationObserver === \"undefined\"\n          ? null\n          : new MutationObserver(() => {\n              field.setColors(readColors(root, overridesRef.current));\n              wake();\n            });\n      themeObserver?.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"data-theme\", \"class\"],\n      });\n\n      return () => {\n        disposed = true;\n        for (const control of controls) control.stop();\n        switchRef.current = () => {};\n        window.removeEventListener(\"resize\", remeasure);\n        observer?.disconnect();\n        themeObserver?.disconnect();\n        gate.dispose();\n        cancelAnimationFrame(raf);\n        field.dispose();\n        fieldRef.current = null;\n        wakeRef.current = null;\n        canvas.style.width = \"\";\n        canvas.style.height = \"\";\n      };\n    }, [isFluid, generation]);\n\n    useFieldRetune(\n      fieldRef,\n      () => (rootRef.current ? readColors(rootRef.current, overrides) : null),\n      overrides,\n      wakeRef,\n    );\n\n    const previousIndex = React.useRef(activeIndex);\n    React.useLayoutEffect(() => {\n      const previous = previousIndex.current;\n      previousIndex.current = activeIndex;\n      if (isFluid && previous !== activeIndex) {\n        switchRef.current(previous);\n      }\n    }, [activeIndex, isFluid]);\n\n    const select = React.useCallback(\n      (next: string) => {\n        if (!isControlled) setUncontrolled(next);\n        onValueChange?.(next);\n      },\n      [isControlled, onValueChange],\n    );\n\n    const focusIndex = (index: number) => {\n      const clamped = (index + items.length) % items.length;\n      const targetItem = items[clamped];\n      if (!targetItem) return;\n      segmentRefs.current[clamped]?.focus();\n      select(targetItem.value);\n    };\n\n    const onKeyDown = (event: React.KeyboardEvent, index: number) => {\n      switch (event.key) {\n        case \"ArrowRight\":\n        case \"ArrowDown\":\n          event.preventDefault();\n          focusIndex(index + 1);\n          break;\n        case \"ArrowLeft\":\n        case \"ArrowUp\":\n          event.preventDefault();\n          focusIndex(index - 1);\n          break;\n        case \"Home\":\n          event.preventDefault();\n          focusIndex(0);\n          break;\n        case \"End\":\n          event.preventDefault();\n          focusIndex(items.length - 1);\n          break;\n        default:\n          break;\n      }\n    };\n\n    return (\n      <div\n        ref={(node) => {\n          rootRef.current = node;\n          if (typeof forwardedRef === \"function\") forwardedRef(node);\n          else if (forwardedRef) forwardedRef.current = node;\n        }}\n        role=\"radiogroup\"\n        data-fluid={isFluid ? \"on\" : \"off\"}\n        className={cn(\n          \"relative inline-flex items-center rounded-full p-1\",\n          !bare && \"border border-border bg-surface\",\n          className,\n        )}\n        {...props}\n      >\n        {isFluid ? (\n          <div\n            ref={stageRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute overflow-hidden\"\n            style={{\n              top: -SLACK_Y,\n              bottom: -SLACK_Y,\n              left: -SLACK_X,\n              right: -SLACK_X,\n            }}\n          >\n            <canvas ref={canvasRef} />\n          </div>\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute top-1 bottom-1 left-0 rounded-full bg-surface-2 shadow-raised\",\n              \"[filter:drop-shadow(var(--usva-glow-accent))]\",\n              \"transition-layout duration-slow ease-spring motion-reduce:transition-none\",\n              !indicator.ready && \"opacity-0\",\n            )}\n            style={{\n              width: indicator.width,\n              transform: `translateX(${indicator.left}px)`,\n            }}\n          />\n        )}\n        {items.map((item, index) => {\n          const checked = item.value === current;\n          return (\n            // biome-ignore lint/a11y/useSemanticElements: segmented control needs a button with a roving tabindex and a custom indicator; a native radio input can't render this pattern\n            <button\n              key={item.value}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={checked}\n              tabIndex={checked ? 0 : -1}\n              ref={(node) => {\n                segmentRefs.current[index] = node;\n              }}\n              onClick={() => select(item.value)}\n              onKeyDown={(event) => onKeyDown(event, index)}\n              className={cn(\n                \"relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full text-sm whitespace-nowrap outline-none\",\n                sizeClasses[size],\n                \"text-muted transition-tint duration-fast ease-soft\",\n                \"hover:text-ink aria-checked:text-ink\",\n                \"focus-visible:ring-focus\",\n              )}\n            >\n              {item.icon ? (\n                <span className=\"inline-flex shrink-0\" aria-hidden=\"true\">\n                  {item.icon}\n                </span>\n              ) : null}\n              {item.label}\n            </button>\n          );\n        })}\n      </div>\n    );\n  },\n);\nSulaSegmented.displayName = \"SulaSegmented\";\n"
    }
  ]
}