{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "easing-picker",
  "title": "Easing Picker",
  "description": "Ridiculously typed CSS easing-function picker with a bezier canvas, spring/bounce/wiggle physics baked to linear(), and a polynomial preset gallery. Ships a 6-property animation preview and 3-format output (CSS / Tailwind v3 / v4).",
  "dependencies": [],
  "registryDependencies": [
    "button",
    "popover"
  ],
  "files": [
    {
      "path": "src/components/ui/easing-picker/index.ts",
      "content": "export type {\n  BezierCanvasProps,\n  BounceControlsProps,\n  EasingPanelProps,\n  EasingPickerProps,\n  EasingPreviewProps,\n  PresetGalleryProps,\n  PreviewProperty,\n  Sample,\n  SpringControlsProps,\n  StepsControlsProps,\n  WiggleControlsProps,\n} from \"./easing-picker\"\nexport {\n  BezierCanvas,\n  BounceControls,\n  bakeLinear,\n  bezierFromPreset,\n  EasingPanel,\n  EasingPicker,\n  EasingPreview,\n  formatEasing,\n  matchPreset,\n  PRESETS,\n  PresetGallery,\n  parseEasing,\n  SpringControls,\n  StepsControls,\n  sampleBounce,\n  sampleSpring,\n  sampleWiggle,\n  WiggleControls,\n} from \"./easing-picker\"\nexport type {\n  BasisOfString,\n  CubicBezierString,\n  Direction,\n  EasingBasis,\n  EasingKeyword,\n  EasingLiteral,\n  EasingState,\n  EasingString,\n  EasingStringMap,\n  FunctionOf,\n  LinearString,\n  PolynomialFamily,\n  PresetName,\n  StepPosition,\n  StepsString,\n} from \"./easing-picker.types\"\nexport { easing } from \"./easing-picker.types\"\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/index.ts"
    },
    {
      "path": "src/components/ui/easing-picker/easing-picker.tsx",
      "content": "\"use client\"\n\nimport type React from \"react\"\nimport { useEffect, useRef, useState } from \"react\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  ALL_BASES,\n  DEFAULT_BEZIER_STATE,\n  DEFAULT_BY_BASIS,\n} from \"./easing-picker.constants\"\nimport { formatEasing, matchPreset, parseEasing } from \"./easing-picker.helpers\"\nimport type {\n  EasingBasis,\n  EasingState,\n  EasingString,\n  EasingStringMap,\n} from \"./easing-picker.types\"\nimport { BasisControls } from \"./panel/basis-controls\"\nimport { PreviewSection } from \"./panel/preview-section\"\nimport type { PreviewProperty } from \"./preview/easing-preview\"\nimport type { OutputFormat } from \"./preview/output-panel\"\n\n// ---------------------------------------------------------------------------\n// Public re-exports — keep the barrel surface stable for tests + examples.\n// ---------------------------------------------------------------------------\n\nexport type { BezierCanvasProps } from \"./controls/bezier-canvas\"\nexport { BezierCanvas } from \"./controls/bezier-canvas\"\nexport type {\n  BounceControlsProps,\n  SpringControlsProps,\n  WiggleControlsProps,\n} from \"./controls/physics-controls\"\nexport {\n  BounceControls,\n  SpringControls,\n  WiggleControls,\n} from \"./controls/physics-controls\"\nexport type { PresetGalleryProps } from \"./controls/preset-gallery\"\nexport { PresetGallery } from \"./controls/preset-gallery\"\nexport type { StepsControlsProps } from \"./controls/steps-controls\"\nexport { StepsControls } from \"./controls/steps-controls\"\nexport type { Sample } from \"./easing-picker.helpers\"\nexport {\n  bakeLinear,\n  bezierFromPreset,\n  formatEasing,\n  matchPreset,\n  PRESETS,\n  parseEasing,\n  sampleBounce,\n  sampleSpring,\n  sampleWiggle,\n} from \"./easing-picker.helpers\"\nexport type {\n  EasingPreviewProps,\n  PreviewProperty,\n} from \"./preview/easing-preview\"\nexport { EasingPreview } from \"./preview/easing-preview\"\n\n// ---------------------------------------------------------------------------\n// EasingPicker — popover trigger wrapping the panel\n// ---------------------------------------------------------------------------\n\nexport interface EasingPickerProps<\n  TBasis extends EasingBasis | undefined = undefined,\n> extends EasingPanelProps<TBasis> {}\n\nexport function EasingPicker<\n  TBasis extends EasingBasis | undefined = undefined,\n>({\n  value,\n  onChange,\n  basis,\n  output,\n  className,\n  \"aria-label\": ariaLabel = \"Pick an easing\",\n}: EasingPickerProps<TBasis>) {\n  const parsed = parseEasing(value)\n  const label = computeTriggerLabel(parsed)\n  const thumb = computeTriggerThumb(parsed)\n\n  return (\n    <Popover>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          className={cn(\"h-9 gap-2 px-3\", className)}\n          aria-label={ariaLabel}\n        >\n          <div className=\"size-5 text-foreground/70\">{thumb}</div>\n          <span className=\"font-mono text-xs\">{label}</span>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-0\" align=\"start\">\n        <EasingPanel\n          value={value}\n          onChange={onChange}\n          basis={basis}\n          output={output}\n        />\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nfunction computeTriggerLabel(state: EasingState | null): string {\n  if (!state) return \"(invalid)\"\n  switch (state.basis) {\n    case \"bezier\": {\n      const name = matchPreset(state.x1, state.y1, state.x2, state.y2)\n      return name ?? \"cubic-bezier\"\n    }\n    case \"spring\":\n      return \"spring\"\n    case \"bounce\":\n      return \"bounce\"\n    case \"wiggle\":\n      return \"wiggle\"\n    case \"steps\":\n      return `steps(${state.n})`\n  }\n}\n\nfunction computeTriggerThumb(state: EasingState | null): React.ReactNode {\n  if (!state || state.basis !== \"bezier\") {\n    return (\n      <svg viewBox=\"0 0 48 32\" aria-hidden=\"true\">\n        <title>Easing curve preview</title>\n        <line\n          x1=\"0\"\n          y1=\"16\"\n          x2=\"48\"\n          y2=\"16\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n        />\n      </svg>\n    )\n  }\n  const path = `M 0 32 C ${state.x1 * 48} ${(1 - state.y1) * 32}, ${state.x2 * 48} ${(1 - state.y2) * 32}, 48 0`\n  return (\n    <svg viewBox=\"0 0 48 32\" aria-hidden=\"true\">\n      <title>Easing curve preview</title>\n      <path d={path} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n    </svg>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// EasingPanel — the full editor (basis controls + preview + output)\n// ---------------------------------------------------------------------------\n\nexport interface EasingPanelProps<\n  TBasis extends EasingBasis | undefined = undefined,\n> {\n  value: EasingString | (string & {})\n  onChange: (\n    value: TBasis extends EasingBasis ? EasingStringMap[TBasis] : EasingString,\n  ) => void\n  basis?: TBasis\n  output?: OutputFormat\n  className?: string\n  \"aria-label\"?: string\n}\n\nexport function EasingPanel<\n  TBasis extends EasingBasis | undefined = undefined,\n>({\n  value,\n  onChange,\n  basis: basisProp,\n  output: outputProp = \"css\",\n  className,\n  \"aria-label\": ariaLabel = \"Pick an easing\",\n}: EasingPanelProps<TBasis>) {\n  const parsed = parseEasing(value) ?? DEFAULT_BEZIER_STATE\n  const [internal, setInternal] = useState<EasingState>(parsed)\n  const [outputFormat, setOutputFormat] = useState<OutputFormat>(outputProp)\n  const [previewProperty, setPreviewProperty] =\n    useState<PreviewProperty>(\"moveX\")\n  const lastEmittedRef = useRef<string | null>(null)\n\n  // Resync from external value (skip our own emits)\n  useEffect(() => {\n    if (value === lastEmittedRef.current) return\n    const next = parseEasing(value)\n    if (next) setInternal(next)\n  }, [value])\n\n  const setAndEmit = (updater: (prev: EasingState) => EasingState) => {\n    setInternal((prev) => {\n      const next = updater(prev)\n      const s = formatEasing(next)\n      lastEmittedRef.current = s\n      onChange(s as never)\n      return next\n    })\n  }\n\n  const switchBasis = (basis: EasingBasis) => {\n    setAndEmit(() => DEFAULT_BY_BASIS[basis])\n  }\n\n  const available: readonly EasingBasis[] = basisProp\n    ? ([basisProp] as const)\n    : ALL_BASES\n\n  const easing = formatEasing(internal)\n\n  return (\n    <fieldset\n      className={cn(\n        \"m-0 w-[480px] space-y-3 border-0 bg-background p-3\",\n        className,\n      )}\n      aria-label={ariaLabel}\n    >\n      <BasisControls\n        state={internal}\n        available={available}\n        onSwitchBasis={switchBasis}\n        onChangeState={setAndEmit}\n      />\n      <PreviewSection\n        easing={easing}\n        previewProperty={previewProperty}\n        onPreviewPropertyChange={setPreviewProperty}\n        outputFormat={outputFormat}\n        onOutputFormatChange={setOutputFormat}\n      />\n    </fieldset>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/easing-picker.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/easing-picker.types.ts",
      "content": "// =====================================================================\n// 1. PRIMITIVES — private helpers, not exported\n// =====================================================================\n\ntype Digit = \"0\" | \"1\" | \"2\" | \"3\" | \"4\" | \"5\" | \"6\" | \"7\" | \"8\" | \"9\"\n\ntype WS = \" \" | \"\\n\" | \"\\t\"\n\ntype TrimLeft<S extends string> = S extends `${WS}${infer R}` ? TrimLeft<R> : S\ntype TrimRight<S extends string> = S extends `${infer R}${WS}`\n  ? TrimRight<R>\n  : S\ntype Trim<S extends string> = TrimLeft<TrimRight<S>>\n\ntype AllChars<S extends string, Allowed extends string> = S extends \"\"\n  ? true\n  : S extends `${infer C}${infer R}`\n    ? C extends Allowed\n      ? AllChars<R, Allowed>\n      : false\n    : false\n\ntype NonEmptyAllChars<S extends string, Allowed extends string> = S extends \"\"\n  ? false\n  : AllChars<S, Allowed>\n\ntype StripLeadingZeros<S extends string> = S extends `0${infer R}`\n  ? R extends \"\"\n    ? \"0\"\n    : StripLeadingZeros<R>\n  : S\n\ntype NormalizeInt<S extends string> = S extends \"\" ? \"0\" : StripLeadingZeros<S>\n\ntype IsIntPart<S extends string> = S extends \"\"\n  ? true\n  : NonEmptyAllChars<S, Digit>\n\ntype And<A extends boolean, B extends boolean> = A extends true\n  ? B extends true\n    ? true\n    : false\n  : false\n\ntype KeepIf<B extends boolean, S extends string> = B extends true ? S : never\n\ntype IsNumber0To1<S extends string> = S extends `${infer I}.${infer F}`\n  ? And<IsIntPart<I>, NonEmptyAllChars<F, Digit>> extends true\n    ? NormalizeInt<I> extends \"0\"\n      ? true\n      : NormalizeInt<I> extends \"1\"\n        ? AllChars<F, \"0\">\n        : false\n    : false\n  : NonEmptyAllChars<S, Digit> extends true\n    ? NormalizeInt<S> extends \"0\" | \"1\"\n      ? true\n      : false\n    : false\n\ntype IsNonNegativeNumber<S extends string> = S extends `${infer I}.${infer F}`\n  ? And<IsIntPart<I>, NonEmptyAllChars<F, Digit>>\n  : NonEmptyAllChars<S, Digit>\n\ntype IsSignedDecimal<S extends string> = S extends `-${infer R}`\n  ? IsNonNegativeNumber<R>\n  : IsNonNegativeNumber<S>\n\ntype IsPositiveInt<S extends string> = S extends \"0\"\n  ? false\n  : S extends \"\" | \"-\"\n    ? false\n    : NonEmptyAllChars<S, Digit>\n\n// =====================================================================\n// 2. STRICT VALIDATORS — exported, generic. Used by easing() helper.\n// =====================================================================\n\n/** Named CSS Easing L1 keyword. */\nexport type EasingKeywordLiteral<S extends string> = S extends EasingKeyword\n  ? S\n  : never\n\n/** `cubic-bezier(x1, y1, x2, y2)` — x ∈ [0,1], y signed (overshoot OK). */\nexport type CubicBezierLiteral<S extends string> =\n  S extends `cubic-bezier(${infer X1}, ${infer Y1}, ${infer X2}, ${infer Y2})`\n    ? KeepIf<\n        And<\n          IsNumber0To1<Trim<X1>>,\n          And<\n            IsSignedDecimal<Trim<Y1>>,\n            And<IsNumber0To1<Trim<X2>>, IsSignedDecimal<Trim<Y2>>>\n          >\n        >,\n        S\n      >\n    : S extends `cubic-bezier(${infer X1} ${infer Y1} ${infer X2} ${infer Y2})`\n      ? KeepIf<\n          And<\n            IsNumber0To1<Trim<X1>>,\n            And<\n              IsSignedDecimal<Trim<Y1>>,\n              And<IsNumber0To1<Trim<X2>>, IsSignedDecimal<Trim<Y2>>>\n            >\n          >,\n          S\n        >\n      : never\n\n/** `steps(n)` or `steps(n, position)` — n positive integer. */\nexport type StepsLiteral<S extends string> =\n  S extends `steps(${infer N}, ${infer P})`\n    ? P extends StepPosition\n      ? KeepIf<IsPositiveInt<Trim<N>>, S>\n      : never\n    : S extends `steps(${infer N})`\n      ? KeepIf<IsPositiveInt<Trim<N>>, S>\n      : never\n\n/**\n * `linear()` — weak validation. Variadic stop range-checking at the type\n * level would blow up compile time. Runtime parser does real validation.\n */\nexport type LinearLiteral<S extends string> = S extends `linear(${infer Body})`\n  ? Trim<Body> extends \"\"\n    ? never\n    : S\n  : never\n\n/** Union — accepts any valid CSS easing function or keyword. */\nexport type EasingLiteral<S extends string> =\n  | EasingKeywordLiteral<S>\n  | CubicBezierLiteral<S>\n  | StepsLiteral<S>\n  | LinearLiteral<S>\n\n/** Call-site validator helper. Mirrors `color()` from color-picker. */\nexport const easing = <S extends string>(value: S & EasingLiteral<S>): S =>\n  value\n\n// =====================================================================\n// 3. SUGGESTION STRINGS — non-generic, for IntelliSense + onChange returns\n// =====================================================================\n\n/** CSS Easing L1 named keywords. */\nexport type EasingKeyword =\n  | \"linear\"\n  | \"ease\"\n  | \"ease-in\"\n  | \"ease-out\"\n  | \"ease-in-out\"\n  | \"step-start\"\n  | \"step-end\"\n\n/** `cubic-bezier(x1, y1, x2, y2)` — both comma and space forms. */\nexport type CubicBezierString =\n  | `cubic-bezier(${number}, ${number}, ${number}, ${number})`\n  | `cubic-bezier(${number} ${number} ${number} ${number})`\n\nexport type StepPosition =\n  | \"start\"\n  | \"end\"\n  | \"jump-start\"\n  | \"jump-end\"\n  | \"jump-both\"\n  | \"jump-none\"\n\nexport type StepsString =\n  | `steps(${number})`\n  | `steps(${number}, ${StepPosition})`\n\n/** `linear()` multi-stop — variadic, weakly suggested. */\nexport type LinearString = `linear(${string})`\n\n/** Union of every valid easing output. */\nexport type EasingString =\n  | EasingKeyword\n  | CubicBezierString\n  | StepsString\n  | LinearString\n\n/** Basis → output-string type map. Used by `basis?` prop to narrow onChange. */\nexport interface EasingStringMap {\n  bezier: CubicBezierString\n  spring: LinearString\n  bounce: LinearString\n  wiggle: LinearString\n  steps: StepsString\n}\n\nexport type EasingBasis = keyof EasingStringMap\n\nexport type PolynomialFamily =\n  | \"Sine\"\n  | \"Quad\"\n  | \"Cubic\"\n  | \"Quart\"\n  | \"Quint\"\n  | \"Expo\"\n  | \"Circ\"\n  | \"Back\"\n\nexport type Direction = \"In\" | \"Out\" | \"InOut\" | \"OutIn\"\n\nexport type PresetName =\n  | EasingKeyword\n  | `ease${Direction}${PolynomialFamily}`\n  | \"anticipate\"\n  | \"smoothStep\"\n\n// =====================================================================\n// 4. UTILITY TYPES — operate on easing literals at the type level.\n// =====================================================================\n\n/**\n * Extract CSS function type from a literal at the type level.\n *\n * @example\n * type T1 = FunctionOf<\"cubic-bezier(0,0,1,1)\">  // \"bezier\"\n * type T2 = FunctionOf<\"steps(3)\">                // \"steps\"\n * type T3 = FunctionOf<\"ease-in\">                 // \"bezier\"\n * type T4 = FunctionOf<\"step-start\">              // \"steps\"\n */\nexport type FunctionOf<S extends string> = S extends `cubic-bezier(${string}`\n  ? \"bezier\"\n  : S extends `steps(${string}`\n    ? \"steps\"\n    : S extends `linear(${string}`\n      ? \"linear\"\n      : S extends \"step-start\" | \"step-end\"\n        ? \"steps\"\n        : S extends EasingKeyword\n          ? \"bezier\"\n          : never\n\n/**\n * Extract basis from a literal. Note: `linear()` output is ambiguous —\n * baking erases the physics type, so spring/bounce/wiggle all collapse.\n */\nexport type BasisOfString<S extends string> = S extends LinearString\n  ? \"spring\" | \"bounce\" | \"wiggle\"\n  : S extends CubicBezierString | EasingKeyword\n    ? \"bezier\"\n    : S extends StepsString\n      ? \"steps\"\n      : never\n\n// =====================================================================\n// 5. INTERNAL STATE — discriminated union, source of truth in the editor.\n//    Exported for advanced use cases (custom serialization, dehydration).\n// =====================================================================\n\nexport type EasingState =\n  | {\n      basis: \"bezier\"\n      x1: number\n      y1: number\n      x2: number\n      y2: number\n      extraTop: number\n      extraBottom: number\n    }\n  | { basis: \"spring\"; stiffness: number; damping: number; mass: number }\n  | { basis: \"bounce\"; bounces: number; stiffness: number }\n  | { basis: \"wiggle\"; wiggles: number; damping: number }\n  | { basis: \"steps\"; n: number; position: StepPosition }\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/easing-picker.types.ts"
    },
    {
      "path": "src/components/ui/easing-picker/easing-picker.constants.ts",
      "content": "// =====================================================================\n// easing-picker.constants.ts\n//\n// UI-facing constants shared across the panel and its controls:\n// default state per basis, the ordered basis list for the tabs, and\n// the ordered step-position list for the <select>.\n// =====================================================================\n\nimport type {\n  EasingBasis,\n  EasingState,\n  StepPosition,\n} from \"./easing-picker.types\"\n\nexport const DEFAULT_SPRING_STATE: Extract<EasingState, { basis: \"spring\" }> = {\n  basis: \"spring\",\n  stiffness: 100,\n  damping: 10,\n  mass: 1,\n}\nexport const DEFAULT_BOUNCE_STATE: Extract<EasingState, { basis: \"bounce\" }> = {\n  basis: \"bounce\",\n  bounces: 3,\n  stiffness: 0.5,\n}\nexport const DEFAULT_WIGGLE_STATE: Extract<EasingState, { basis: \"wiggle\" }> = {\n  basis: \"wiggle\",\n  wiggles: 4,\n  damping: 5,\n}\nexport const DEFAULT_BEZIER_STATE: Extract<EasingState, { basis: \"bezier\" }> = {\n  basis: \"bezier\",\n  x1: 0.42,\n  y1: 0,\n  x2: 0.58,\n  y2: 1,\n  extraTop: 0.25,\n  extraBottom: 0.25,\n}\nexport const DEFAULT_STEPS_STATE: Extract<EasingState, { basis: \"steps\" }> = {\n  basis: \"steps\",\n  n: 4,\n  position: \"end\",\n}\n\nexport const DEFAULT_BY_BASIS: Record<EasingBasis, EasingState> = {\n  bezier: DEFAULT_BEZIER_STATE,\n  spring: DEFAULT_SPRING_STATE,\n  bounce: DEFAULT_BOUNCE_STATE,\n  wiggle: DEFAULT_WIGGLE_STATE,\n  steps: DEFAULT_STEPS_STATE,\n}\n\nexport const ALL_BASES: readonly EasingBasis[] = [\n  \"bezier\",\n  \"spring\",\n  \"bounce\",\n  \"wiggle\",\n  \"steps\",\n] as const\n\nexport const STEP_POSITIONS: StepPosition[] = [\n  \"start\",\n  \"end\",\n  \"jump-start\",\n  \"jump-end\",\n  \"jump-both\",\n  \"jump-none\",\n]\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/easing-picker.constants.ts"
    },
    {
      "path": "src/components/ui/easing-picker/easing-picker.helpers.ts",
      "content": "// =====================================================================\n// easing-picker.helpers.ts\n//\n// Pure runtime logic for the easing picker — no React, no DOM. Covers:\n//   - parse / format between an `EasingString` and the internal\n//     `EasingState` discriminated union,\n//   - the physics samplers (spring / bounce / wiggle) and the\n//     `linear()` baker that turns sample arrays into a CSS string,\n//   - the preset accessors `bezierFromPreset` / `matchPreset` (the\n//     PRESETS data table itself lives in easing-picker.presets.ts),\n//   - small numeric utils (fmtNum / parseNumber / clamp).\n//\n// Behaviour here is pinned by the easing-parse / easing-format /\n// easing-presets / easing-baking specs — keep it a pure move.\n// =====================================================================\n\nimport { PRESETS } from \"./easing-picker.presets\"\nimport type {\n  CubicBezierString,\n  EasingState,\n  EasingString,\n  PresetName,\n  StepPosition,\n} from \"./easing-picker.types\"\n\nexport type { PresetEntry } from \"./easing-picker.presets\"\n// Re-export the preset data so the public barrel surface is unchanged.\nexport { PRESETS } from \"./easing-picker.presets\"\n\n// ---------------------------------------------------------------------------\n// Numeric utils\n// ---------------------------------------------------------------------------\n\nexport function clamp(v: number, lo: number, hi: number): number {\n  return Math.max(lo, Math.min(hi, v))\n}\n\n/** Round to up to 4 decimal places, strip trailing zeros + bare decimal point. */\nexport function fmtNum(n: number): string {\n  const rounded = Math.round(n * 10000) / 10000\n  return rounded.toString()\n}\n\nfunction parseNumber(s: string): number | null {\n  const t = s.trim()\n  if (t === \"\" || t === \"-\") return null\n  const n = Number(t)\n  return Number.isFinite(n) ? n : null\n}\n\n// ---------------------------------------------------------------------------\n// Parsing / formatting\n// ---------------------------------------------------------------------------\n\nexport const KEYWORD_BEZIER: Record<string, [number, number, number, number]> =\n  {\n    linear: [0, 0, 1, 1],\n    ease: [0.25, 0.1, 0.25, 1],\n    \"ease-in\": [0.42, 0, 1, 1],\n    \"ease-out\": [0, 0, 0.58, 1],\n    \"ease-in-out\": [0.42, 0, 0.58, 1],\n  }\n\nconst STEP_POSITIONS: ReadonlySet<StepPosition> = new Set([\n  \"start\",\n  \"end\",\n  \"jump-start\",\n  \"jump-end\",\n  \"jump-both\",\n  \"jump-none\",\n])\n\nconst DEFAULT_EXTRA = 0.25\n\nconst DEFAULT_SPRING = { stiffness: 100, damping: 10, mass: 1 } as const\n\nexport function parseEasing(value: string): EasingState | null {\n  const v = value.trim()\n\n  // Keywords\n  if (v in KEYWORD_BEZIER) {\n    const [x1, y1, x2, y2] = KEYWORD_BEZIER[v]\n    return {\n      basis: \"bezier\",\n      x1,\n      y1,\n      x2,\n      y2,\n      extraTop: DEFAULT_EXTRA,\n      extraBottom: DEFAULT_EXTRA,\n    }\n  }\n  if (v === \"step-start\")\n    return { basis: \"steps\", n: 1, position: \"jump-start\" }\n  if (v === \"step-end\") return { basis: \"steps\", n: 1, position: \"jump-end\" }\n\n  // cubic-bezier(...)\n  const cb = v.match(/^cubic-bezier\\((.+)\\)$/)\n  if (cb) {\n    const body = cb[1]\n    const parts = body.includes(\",\")\n      ? body.split(\",\").map((p) => p.trim())\n      : body.split(/\\s+/).filter(Boolean)\n    if (parts.length !== 4) return null\n    const nums = parts.map(parseNumber)\n    if (nums.some((n) => n === null)) return null\n    const [x1, y1, x2, y2] = nums as [number, number, number, number]\n    if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) return null\n    return {\n      basis: \"bezier\",\n      x1,\n      y1,\n      x2,\n      y2,\n      extraTop: DEFAULT_EXTRA,\n      extraBottom: DEFAULT_EXTRA,\n    }\n  }\n\n  // steps(...)\n  const st = v.match(/^steps\\((.+)\\)$/)\n  if (st) {\n    const body = st[1]\n    const parts = body.split(\",\").map((p) => p.trim())\n    if (parts.length < 1 || parts.length > 2) return null\n    const n = parseNumber(parts[0])\n    if (n === null || !Number.isInteger(n) || n < 1) return null\n    if (parts.length === 1) return { basis: \"steps\", n, position: \"end\" }\n    const pos = parts[1] as StepPosition\n    if (!STEP_POSITIONS.has(pos)) return null\n    return { basis: \"steps\", n, position: pos }\n  }\n\n  // linear(...)\n  const ln = v.match(/^linear\\((.+)\\)$/)\n  if (ln) {\n    const body = ln[1].trim()\n    if (body === \"\") return null\n    return { basis: \"spring\", ...DEFAULT_SPRING }\n  }\n\n  return null\n}\n\nexport function formatEasing(state: EasingState): EasingString {\n  switch (state.basis) {\n    case \"bezier\": {\n      const { x1, y1, x2, y2 } = state\n      return `cubic-bezier(${fmtNum(x1)}, ${fmtNum(y1)}, ${fmtNum(x2)}, ${fmtNum(y2)})` as EasingString\n    }\n    case \"steps\": {\n      const { n, position } = state\n      return position === \"end\" ? `steps(${n})` : `steps(${n}, ${position})`\n    }\n    case \"spring\": {\n      const { stiffness, damping, mass } = state\n      return bakeLinear(\n        sampleSpring(stiffness, damping, mass, 60),\n      ) as EasingString\n    }\n    case \"bounce\": {\n      const { bounces, stiffness } = state\n      return bakeLinear(sampleBounce(bounces, stiffness)) as EasingString\n    }\n    case \"wiggle\": {\n      const { wiggles, damping } = state\n      return bakeLinear(sampleWiggle(wiggles, damping)) as EasingString\n    }\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Physics samplers + baking\n// ---------------------------------------------------------------------------\n\nexport interface Sample {\n  y: number\n  t: number\n}\n\nconst SETTLE_EPSILON = 0.001\n\nexport function sampleSpring(\n  stiffness: number,\n  damping: number,\n  mass: number,\n  samples: number,\n): Sample[] {\n  const k = stiffness\n  const c = damping\n  const m = mass\n  const w0 = Math.sqrt(k / m)\n  const zeta = c / (2 * Math.sqrt(k * m))\n\n  // Total simulation time scales inversely with natural frequency.\n  // Pick a window long enough for the curve to settle.\n  const tMax = Math.max(3 / (zeta * w0), 5) // seconds-equivalent; normalized below\n  const dt = tMax / samples\n\n  const out: Sample[] = []\n  for (let i = 0; i < samples; i++) {\n    const t = i * dt\n    let y: number\n    if (zeta < 1) {\n      // Underdamped\n      const wd = w0 * Math.sqrt(1 - zeta * zeta)\n      y =\n        1 -\n        Math.exp(-zeta * w0 * t) *\n          (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t))\n    } else if (zeta === 1) {\n      // Critically damped\n      y = 1 - Math.exp(-w0 * t) * (1 + w0 * t)\n    } else {\n      // Overdamped\n      const r1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1))\n      const r2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1))\n      y = 1 - (r2 * Math.exp(r1 * t) - r1 * Math.exp(r2 * t)) / (r2 - r1)\n    }\n    out.push({ y, t: i / (samples - 1) })\n    // Early-exit once settled within epsilon for several consecutive samples\n    if (\n      i > samples / 4 &&\n      Math.abs(y - 1) < SETTLE_EPSILON &&\n      out.slice(-3).every((s) => Math.abs(s.y - 1) < SETTLE_EPSILON)\n    ) {\n      // Force last sample to exactly 1, t=1\n      out[out.length - 1] = { y: 1, t: 1 }\n      break\n    }\n  }\n  // Always force endpoint t=1, y=1\n  if (out[out.length - 1].t < 1) out.push({ y: 1, t: 1 })\n  return out\n}\n\nconst PRUNE_TOLERANCE = 0.005\n\n/** Drop stops that fall on the line between their neighbors within tolerance. */\nfunction pruneCollinear(samples: Sample[]): Sample[] {\n  if (samples.length <= 3) return samples\n  const out: Sample[] = [samples[0]]\n  for (let i = 1; i < samples.length - 1; i++) {\n    const prev = out[out.length - 1]\n    const curr = samples[i]\n    const next = samples[i + 1]\n    const slope = (next.y - prev.y) / (next.t - prev.t)\n    const expectedY = prev.y + slope * (curr.t - prev.t)\n    if (Math.abs(curr.y - expectedY) >= PRUNE_TOLERANCE) {\n      out.push(curr)\n    }\n  }\n  out.push(samples[samples.length - 1])\n  return out\n}\n\nexport function bakeLinear(samples: Sample[]): string {\n  const pruned = pruneCollinear(samples)\n  const parts = pruned.map((s, i) => {\n    if (i === 0 || i === pruned.length - 1) return fmtNum(s.y)\n    return `${fmtNum(s.y)} ${fmtNum(s.t * 100)}%`\n  })\n  return `linear(${parts.join(\", \")})`\n}\n\nexport function sampleBounce(bounces: number, stiffness: number): Sample[] {\n  // Parabolic-bounce model. Restitution decreases per bounce; each bounce\n  // is half a parabola (descending → contact → ascending).\n  const restitution = 0.4 + 0.5 * stiffness // 0.4..0.9\n  const out: Sample[] = []\n\n  // Compute durations such that total = 1\n  const segDurations: number[] = []\n  let energy = 1\n  for (let i = 0; i <= bounces; i++) {\n    segDurations.push(Math.sqrt(energy))\n    energy *= restitution\n  }\n  const totalDur = segDurations.reduce((a, b) => a + b, 0)\n  for (let i = 0; i < segDurations.length; i++) segDurations[i] /= totalDur\n\n  let t = 0\n  // Initial drop: descend from y=0 (start) to y=1 (ground)\n  const samplesPerSeg = 12\n  for (let i = 0; i < samplesPerSeg; i++) {\n    const localT = i / samplesPerSeg\n    out.push({ y: localT * localT, t: t + localT * segDurations[0] })\n  }\n  t += segDurations[0]\n  out.push({ y: 1, t })\n\n  // Bounces: rise to peak, fall back to ground\n  let energyTracker = restitution\n  for (let b = 0; b < bounces; b++) {\n    const segDur = segDurations[b + 1]\n    for (let i = 1; i <= samplesPerSeg; i++) {\n      const localT = i / samplesPerSeg\n      // y = 1 - peak*(1 - (2*localT - 1)^2) — inverted parabola from 1 to 1-peak to 1\n      const u = 2 * localT - 1\n      const y = 1 - (1 - energyTracker) * (1 - u * u)\n      out.push({ y, t: t + localT * segDur })\n    }\n    t += segDur\n    energyTracker *= restitution\n  }\n  // Ensure exactly ends at y=1, t=1\n  out.push({ y: 1, t: 1 })\n  return out\n}\n\nexport function sampleWiggle(wiggles: number, damping: number): Sample[] {\n  // Decaying cosine wave around y=1. After settling, y=1.\n  const samples = 80\n  const out: Sample[] = []\n  for (let i = 0; i < samples; i++) {\n    const t = i / (samples - 1)\n    const decay = Math.exp(-damping * t)\n    const y = 1 - decay * Math.cos(wiggles * 2 * Math.PI * t)\n    out.push({ y, t })\n  }\n  // Force endpoint\n  out[out.length - 1] = { y: 1, t: 1 }\n  return out\n}\n\n// ---------------------------------------------------------------------------\n// Preset accessors (the PRESETS table lives in easing-picker.presets.ts)\n// ---------------------------------------------------------------------------\n\nexport function bezierFromPreset(name: PresetName): CubicBezierString {\n  const preset = PRESETS.find((p) => p.name === name)\n  if (!preset) throw new Error(`Unknown preset: ${name}`)\n  const [x1, y1, x2, y2] = preset.bezier\n  return `cubic-bezier(${fmtNum(x1)}, ${fmtNum(y1)}, ${fmtNum(x2)}, ${fmtNum(y2)})` as CubicBezierString\n}\n\nconst PRESET_MATCH_TOLERANCE = 0.005\n\nexport function matchPreset(\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n): PresetName | null {\n  for (const p of PRESETS) {\n    const [px1, py1, px2, py2] = p.bezier\n    if (\n      Math.abs(x1 - px1) < PRESET_MATCH_TOLERANCE &&\n      Math.abs(y1 - py1) < PRESET_MATCH_TOLERANCE &&\n      Math.abs(x2 - px2) < PRESET_MATCH_TOLERANCE &&\n      Math.abs(y2 - py2) < PRESET_MATCH_TOLERANCE\n    ) {\n      return p.name\n    }\n  }\n  return null\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/easing-picker.helpers.ts"
    },
    {
      "path": "src/components/ui/easing-picker/easing-picker.presets.ts",
      "content": "// =====================================================================\n// easing-picker.presets.ts\n//\n// The PRESETS data table — 5 CSS keywords + 8 polynomial families ×\n// 4 directions (32) + 2 specials. Pure data, no logic; the\n// `bezierFromPreset` / `matchPreset` accessors live in the helpers.\n// =====================================================================\n\nimport type {\n  Direction,\n  PolynomialFamily,\n  PresetName,\n} from \"./easing-picker.types\"\n\nexport interface PresetEntry {\n  readonly name: PresetName\n  readonly bezier: readonly [number, number, number, number]\n  readonly family?: PolynomialFamily\n  readonly direction?: Direction\n}\n\nexport const PRESETS: readonly PresetEntry[] = [\n  // CSS keywords (5)\n  { name: \"linear\", bezier: [0, 0, 1, 1] },\n  { name: \"ease\", bezier: [0.25, 0.1, 0.25, 1] },\n  { name: \"ease-in\", bezier: [0.42, 0, 1, 1] },\n  { name: \"ease-out\", bezier: [0, 0, 0.58, 1] },\n  { name: \"ease-in-out\", bezier: [0.42, 0, 0.58, 1] },\n\n  // Sine\n  {\n    name: \"easeInSine\",\n    bezier: [0.12, 0, 0.39, 0],\n    family: \"Sine\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutSine\",\n    bezier: [0.61, 1, 0.88, 1],\n    family: \"Sine\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutSine\",\n    bezier: [0.37, 0, 0.63, 1],\n    family: \"Sine\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInSine\",\n    bezier: [0.45, 1, 0.55, 0],\n    family: \"Sine\",\n    direction: \"OutIn\",\n  },\n\n  // Quad\n  {\n    name: \"easeInQuad\",\n    bezier: [0.11, 0, 0.5, 0],\n    family: \"Quad\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutQuad\",\n    bezier: [0.5, 1, 0.89, 1],\n    family: \"Quad\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutQuad\",\n    bezier: [0.45, 0, 0.55, 1],\n    family: \"Quad\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInQuad\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Quad\",\n    direction: \"OutIn\",\n  },\n\n  // Cubic\n  {\n    name: \"easeInCubic\",\n    bezier: [0.32, 0, 0.67, 0],\n    family: \"Cubic\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutCubic\",\n    bezier: [0.33, 1, 0.68, 1],\n    family: \"Cubic\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutCubic\",\n    bezier: [0.65, 0, 0.35, 1],\n    family: \"Cubic\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInCubic\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Cubic\",\n    direction: \"OutIn\",\n  },\n\n  // Quart\n  {\n    name: \"easeInQuart\",\n    bezier: [0.5, 0, 0.75, 0],\n    family: \"Quart\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutQuart\",\n    bezier: [0.25, 1, 0.5, 1],\n    family: \"Quart\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutQuart\",\n    bezier: [0.76, 0, 0.24, 1],\n    family: \"Quart\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInQuart\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Quart\",\n    direction: \"OutIn\",\n  },\n\n  // Quint\n  {\n    name: \"easeInQuint\",\n    bezier: [0.64, 0, 0.78, 0],\n    family: \"Quint\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutQuint\",\n    bezier: [0.22, 1, 0.36, 1],\n    family: \"Quint\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutQuint\",\n    bezier: [0.83, 0, 0.17, 1],\n    family: \"Quint\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInQuint\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Quint\",\n    direction: \"OutIn\",\n  },\n\n  // Expo\n  {\n    name: \"easeInExpo\",\n    bezier: [0.7, 0, 0.84, 0],\n    family: \"Expo\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutExpo\",\n    bezier: [0.16, 1, 0.3, 1],\n    family: \"Expo\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutExpo\",\n    bezier: [0.87, 0, 0.13, 1],\n    family: \"Expo\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInExpo\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Expo\",\n    direction: \"OutIn\",\n  },\n\n  // Circ\n  {\n    name: \"easeInCirc\",\n    bezier: [0.55, 0, 1, 0.45],\n    family: \"Circ\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutCirc\",\n    bezier: [0, 0.55, 0.45, 1],\n    family: \"Circ\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutCirc\",\n    bezier: [0.85, 0, 0.15, 1],\n    family: \"Circ\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInCirc\",\n    bezier: [0.5, 1, 0.5, 0],\n    family: \"Circ\",\n    direction: \"OutIn\",\n  },\n\n  // Back (overshoot)\n  {\n    name: \"easeInBack\",\n    bezier: [0.36, 0, 0.66, -0.56],\n    family: \"Back\",\n    direction: \"In\",\n  },\n  {\n    name: \"easeOutBack\",\n    bezier: [0.34, 1.56, 0.64, 1],\n    family: \"Back\",\n    direction: \"Out\",\n  },\n  {\n    name: \"easeInOutBack\",\n    bezier: [0.68, -0.6, 0.32, 1.6],\n    family: \"Back\",\n    direction: \"InOut\",\n  },\n  {\n    name: \"easeOutInBack\",\n    bezier: [0.5, 1.6, 0.5, -0.6],\n    family: \"Back\",\n    direction: \"OutIn\",\n  },\n\n  // Special\n  { name: \"anticipate\", bezier: [0.45, -0.5, 0.55, 1] },\n  { name: \"smoothStep\", bezier: [0.45, 0, 0.55, 1] },\n] as const\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/easing-picker.presets.ts"
    },
    {
      "path": "src/components/ui/easing-picker/controls/basis-tabs.tsx",
      "content": "import { cn } from \"@/lib/utils\"\nimport { ALL_BASES } from \"../easing-picker.constants\"\nimport type { EasingBasis } from \"../easing-picker.types\"\n\ninterface BasisTabsProps {\n  value: EasingBasis\n  onChange: (basis: EasingBasis) => void\n  available?: readonly EasingBasis[]\n}\n\nexport function BasisTabs({\n  value,\n  onChange,\n  available = ALL_BASES,\n}: BasisTabsProps) {\n  return (\n    <div className=\"flex gap-1 border-b text-xs\">\n      {available.map((basis) => (\n        <button\n          key={basis}\n          type=\"button\"\n          onClick={() => onChange(basis)}\n          className={cn(\n            \"px-3 py-1.5 capitalize transition-colors\",\n            value === basis\n              ? \"border-primary border-b-2 text-foreground\"\n              : \"text-muted-foreground hover:text-foreground\",\n          )}\n        >\n          {basis}\n        </button>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/basis-tabs.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/bezier-canvas.tsx",
      "content": "import type React from \"react\"\nimport { useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { clamp } from \"../easing-picker.helpers\"\n\nexport interface BezierCanvasProps {\n  value: { x1: number; y1: number; x2: number; y2: number }\n  onChange: (v: { x1: number; y1: number; x2: number; y2: number }) => void\n  extraTop?: number\n  extraBottom?: number\n  showLinearReference?: boolean\n  className?: string\n}\n\nconst CANVAS_SIZE = 240\nconst CANVAS_PAD = 8\n\nexport function BezierCanvas({\n  value,\n  onChange,\n  extraTop = 0.25,\n  extraBottom = 0.25,\n  showLinearReference = true,\n  className,\n}: BezierCanvasProps) {\n  const svgRef = useRef<SVGSVGElement>(null)\n  const draggingRef = useRef<\"p1\" | \"p2\" | null>(null)\n\n  const yMin = -extraBottom\n  const yMax = 1 + extraTop\n  const yRange = yMax - yMin\n  const innerSize = CANVAS_SIZE - 2 * CANVAS_PAD\n\n  const toScreen = (x: number, y: number) => ({\n    sx: CANVAS_PAD + x * innerSize,\n    sy: CANVAS_PAD + (yMax - y) * (innerSize / yRange),\n  })\n\n  const fromScreen = (sx: number, sy: number) => ({\n    x: clamp((sx - CANVAS_PAD) / innerSize, 0, 1),\n    y: yMax - ((sy - CANVAS_PAD) / innerSize) * yRange,\n  })\n\n  const p0 = toScreen(0, 0)\n  const p3 = toScreen(1, 1)\n  const p1 = toScreen(value.x1, value.y1)\n  const p2 = toScreen(value.x2, value.y2)\n\n  const pathD = `M ${p0.sx} ${p0.sy} C ${p1.sx} ${p1.sy}, ${p2.sx} ${p2.sy}, ${p3.sx} ${p3.sy}`\n\n  const handlePointerDown = (which: \"p1\" | \"p2\") => (e: React.PointerEvent) => {\n    e.currentTarget.setPointerCapture?.(e.pointerId)\n    draggingRef.current = which\n  }\n\n  const handlePointerMove = (e: React.PointerEvent) => {\n    const active = draggingRef.current\n    if (!active || !svgRef.current) return\n    const rect = svgRef.current.getBoundingClientRect()\n    const scale = rect.width > 0 ? CANVAS_SIZE / rect.width : 1\n    const sx = (e.clientX - rect.left) * scale\n    const sy = (e.clientY - rect.top) * scale\n    const { x, y } = fromScreen(sx, sy)\n    if (active === \"p1\") onChange({ ...value, x1: x, y1: y })\n    else onChange({ ...value, x2: x, y2: y })\n  }\n\n  const handlePointerUp = (e: React.PointerEvent) => {\n    e.currentTarget.releasePointerCapture?.(e.pointerId)\n    draggingRef.current = null\n  }\n\n  return (\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}`}\n      className={cn(\"size-full rounded bg-muted/30\", className)}\n      role=\"img\"\n      aria-label=\"Cubic bezier curve editor — drag the two handles to shape the curve\"\n    >\n      <title>Cubic bezier curve editor</title>\n      {showLinearReference && (\n        <line\n          x1={p0.sx}\n          y1={p0.sy}\n          x2={p3.sx}\n          y2={p3.sy}\n          stroke=\"currentColor\"\n          strokeOpacity={0.15}\n          strokeDasharray=\"2 2\"\n        />\n      )}\n      <line\n        x1={p0.sx}\n        y1={p0.sy}\n        x2={p1.sx}\n        y2={p1.sy}\n        stroke=\"currentColor\"\n        strokeOpacity={0.4}\n      />\n      <line\n        x1={p3.sx}\n        y1={p3.sy}\n        x2={p2.sx}\n        y2={p2.sy}\n        stroke=\"currentColor\"\n        strokeOpacity={0.4}\n      />\n      <path\n        data-curve\n        d={pathD}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n      />\n      <circle\n        data-handle=\"p1\"\n        cx={p1.sx}\n        cy={p1.sy}\n        r=\"6\"\n        fill=\"currentColor\"\n        className=\"cursor-grab\"\n        onPointerDown={handlePointerDown(\"p1\")}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n      />\n      <circle\n        data-handle=\"p2\"\n        cx={p2.sx}\n        cy={p2.sy}\n        r=\"6\"\n        fill=\"currentColor\"\n        className=\"cursor-grab\"\n        onPointerDown={handlePointerDown(\"p2\")}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n      />\n    </svg>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/bezier-canvas.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/bezier-inputs.tsx",
      "content": "import type React from \"react\"\n\ninterface BezierInputsValue {\n  x1: number\n  y1: number\n  x2: number\n  y2: number\n  extraTop: number\n  extraBottom: number\n}\n\ninterface BezierInputsProps {\n  value: BezierInputsValue\n  onChange: (v: BezierInputsValue) => void\n}\n\nexport function BezierInputs({ value, onChange }: BezierInputsProps) {\n  const set =\n    (k: keyof BezierInputsValue) =>\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const n = Number(e.target.value)\n      if (!Number.isFinite(n)) return\n      onChange({ ...value, [k]: n })\n    }\n  return (\n    <div className=\"grid grid-cols-2 gap-2 text-xs\">\n      <Field\n        label=\"X1\"\n        value={value.x1}\n        min={0}\n        max={1}\n        step={0.01}\n        onChange={set(\"x1\")}\n      />\n      <Field label=\"Y1\" value={value.y1} step={0.01} onChange={set(\"y1\")} />\n      <Field\n        label=\"X2\"\n        value={value.x2}\n        min={0}\n        max={1}\n        step={0.01}\n        onChange={set(\"x2\")}\n      />\n      <Field label=\"Y2\" value={value.y2} step={0.01} onChange={set(\"y2\")} />\n      <Field\n        label=\"Extra Top\"\n        value={value.extraTop}\n        min={0}\n        step={0.05}\n        onChange={set(\"extraTop\")}\n      />\n      <Field\n        label=\"Extra Bottom\"\n        value={value.extraBottom}\n        min={0}\n        step={0.05}\n        onChange={set(\"extraBottom\")}\n      />\n    </div>\n  )\n}\n\nfunction Field({\n  label,\n  value,\n  min,\n  max,\n  step,\n  onChange,\n}: {\n  label: string\n  value: number\n  min?: number\n  max?: number\n  step?: number\n  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void\n}) {\n  return (\n    <label className=\"flex flex-col gap-0.5\">\n      <span className=\"text-muted-foreground\">{label}</span>\n      <input\n        type=\"number\"\n        value={value}\n        min={min}\n        max={max}\n        step={step}\n        onChange={onChange}\n        className=\"rounded bg-muted px-2 py-1 text-foreground\"\n      />\n    </label>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/bezier-inputs.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/physics-controls.tsx",
      "content": "import { type SliderField, SliderGroup } from \"./slider-group\"\n\n// Public physics-control panels. Each is a thin wrapper over the\n// data-driven <SliderGroup>: they differ only in their field lists.\n\n// ---------------------------------------------------------------------------\n// SpringControls\n// ---------------------------------------------------------------------------\n\nexport interface SpringControlsProps {\n  value: { stiffness: number; damping: number; mass: number }\n  onChange: (v: SpringControlsProps[\"value\"]) => void\n  className?: string\n}\n\nconst SPRING_FIELDS: ReadonlyArray<\n  SliderField<keyof SpringControlsProps[\"value\"]>\n> = [\n  { key: \"stiffness\", label: \"Stiffness\", min: 1, max: 500, step: 1 },\n  { key: \"damping\", label: \"Damping\", min: 1, max: 100, step: 1 },\n  { key: \"mass\", label: \"Mass\", min: 0.5, max: 5, step: 0.1 },\n]\n\nexport function SpringControls({\n  value,\n  onChange,\n  className,\n}: SpringControlsProps) {\n  return (\n    <SliderGroup\n      value={value}\n      fields={SPRING_FIELDS}\n      onChange={onChange}\n      className={className}\n    />\n  )\n}\n\n// ---------------------------------------------------------------------------\n// BounceControls\n// ---------------------------------------------------------------------------\n\nexport interface BounceControlsProps {\n  value: { bounces: number; stiffness: number }\n  onChange: (v: BounceControlsProps[\"value\"]) => void\n  className?: string\n}\n\nconst BOUNCE_FIELDS: ReadonlyArray<\n  SliderField<keyof BounceControlsProps[\"value\"]>\n> = [\n  { key: \"bounces\", label: \"Bounces\", min: 1, max: 6, step: 1 },\n  { key: \"stiffness\", label: \"Stiffness\", min: 0, max: 1, step: 0.01 },\n]\n\nexport function BounceControls({\n  value,\n  onChange,\n  className,\n}: BounceControlsProps) {\n  return (\n    <SliderGroup\n      value={value}\n      fields={BOUNCE_FIELDS}\n      onChange={onChange}\n      className={className}\n    />\n  )\n}\n\n// ---------------------------------------------------------------------------\n// WiggleControls\n// ---------------------------------------------------------------------------\n\nexport interface WiggleControlsProps {\n  value: { wiggles: number; damping: number }\n  onChange: (v: WiggleControlsProps[\"value\"]) => void\n  className?: string\n}\n\nconst WIGGLE_FIELDS: ReadonlyArray<\n  SliderField<keyof WiggleControlsProps[\"value\"]>\n> = [\n  { key: \"wiggles\", label: \"Wiggles\", min: 1, max: 10, step: 1 },\n  { key: \"damping\", label: \"Damping\", min: 1, max: 30, step: 0.5 },\n]\n\nexport function WiggleControls({\n  value,\n  onChange,\n  className,\n}: WiggleControlsProps) {\n  return (\n    <SliderGroup\n      value={value}\n      fields={WIGGLE_FIELDS}\n      onChange={onChange}\n      className={className}\n    />\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/physics-controls.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/preset-gallery.tsx",
      "content": "import { cn } from \"@/lib/utils\"\nimport {\n  bezierFromPreset,\n  PRESETS,\n  type PresetEntry,\n} from \"../easing-picker.helpers\"\nimport type { CubicBezierString, PresetName } from \"../easing-picker.types\"\n\nexport interface PresetGalleryProps {\n  value?: PresetName\n  onChange: (preset: PresetName, bezier: CubicBezierString) => void\n  className?: string\n}\n\nexport function PresetGallery({\n  value,\n  onChange,\n  className,\n}: PresetGalleryProps) {\n  const keywords = PRESETS.filter(\n    (p) => !p.family && p.name !== \"anticipate\" && p.name !== \"smoothStep\",\n  )\n  const polynomials = PRESETS.filter((p) => p.family)\n  const specials = PRESETS.filter(\n    (p) => p.name === \"anticipate\" || p.name === \"smoothStep\",\n  )\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      <PresetRow\n        label=\"Keywords\"\n        presets={keywords}\n        value={value}\n        onChange={onChange}\n      />\n      <PresetGrid presets={polynomials} value={value} onChange={onChange} />\n      <PresetRow\n        label=\"Special\"\n        presets={specials}\n        value={value}\n        onChange={onChange}\n      />\n    </div>\n  )\n}\n\nfunction PresetThumb({\n  x1,\n  y1,\n  x2,\n  y2,\n}: {\n  x1: number\n  y1: number\n  x2: number\n  y2: number\n}) {\n  const path = `M 0 32 C ${x1 * 48} ${(1 - y1) * 32}, ${x2 * 48} ${(1 - y2) * 32}, 48 0`\n  return (\n    <svg viewBox=\"0 0 48 32\" className=\"size-full\" aria-hidden=\"true\">\n      <title>Preset curve thumbnail</title>\n      <path d={path} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n    </svg>\n  )\n}\n\nfunction PresetRow({\n  label,\n  presets,\n  value,\n  onChange,\n}: {\n  label: string\n  presets: readonly PresetEntry[]\n  value?: PresetName\n  onChange: (preset: PresetName, bezier: CubicBezierString) => void\n}) {\n  return (\n    <div>\n      <div className=\"mb-1 text-muted-foreground text-xs uppercase tracking-wide\">\n        {label}\n      </div>\n      <div className=\"flex flex-wrap gap-1\">\n        {presets.map((p) => (\n          <PresetCard\n            key={p.name}\n            preset={p}\n            active={value === p.name}\n            onClick={() => onChange(p.name, bezierFromPreset(p.name))}\n            iconOnly\n          />\n        ))}\n      </div>\n    </div>\n  )\n}\n\nfunction PresetGrid({\n  presets,\n  value,\n  onChange,\n}: {\n  presets: readonly PresetEntry[]\n  value?: PresetName\n  onChange: (preset: PresetName, bezier: CubicBezierString) => void\n}) {\n  return (\n    <div className=\"grid grid-cols-10 gap-1\">\n      {presets.map((p) => (\n        <PresetCard\n          key={p.name}\n          preset={p}\n          active={value === p.name}\n          onClick={() => onChange(p.name, bezierFromPreset(p.name))}\n          iconOnly\n        />\n      ))}\n    </div>\n  )\n}\n\nfunction PresetCard({\n  preset,\n  active,\n  onClick,\n  iconOnly = false,\n}: {\n  preset: PresetEntry\n  active: boolean\n  onClick: () => void\n  iconOnly?: boolean\n}) {\n  const [x1, y1, x2, y2] = preset.bezier\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"flex flex-col items-center gap-0.5 rounded border transition-colors\",\n        iconOnly ? \"p-0.5\" : \"p-1.5 text-xs\",\n        active\n          ? \"border-accent-foreground/20 bg-accent\"\n          : \"border-transparent bg-transparent hover:bg-accent/50\",\n      )}\n      title={preset.name}\n      aria-label={preset.name}\n    >\n      <div\n        className={cn(\"text-muted-foreground\", iconOnly ? \"size-6\" : \"size-10\")}\n      >\n        <PresetThumb x1={x1} y1={y1} x2={x2} y2={y2} />\n      </div>\n      {!iconOnly && (\n        <span className=\"w-full truncate text-center text-[10px]\">\n          {preset.name}\n        </span>\n      )}\n    </button>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/preset-gallery.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/slider-group.tsx",
      "content": "import { cn } from \"@/lib/utils\"\n\n// ---------------------------------------------------------------------------\n// Slider — single labelled range input\n// ---------------------------------------------------------------------------\n\nexport function Slider({\n  label,\n  value,\n  min,\n  max,\n  step,\n  onChange,\n}: {\n  label: string\n  value: number\n  min: number\n  max: number\n  step: number\n  onChange: (v: number) => void\n}) {\n  return (\n    <label className=\"flex flex-col gap-0.5 text-xs\">\n      <span className=\"flex justify-between text-muted-foreground\">\n        <span>{label}</span>\n        <span>{value.toFixed(2)}</span>\n      </span>\n      <input\n        type=\"range\"\n        aria-label={label}\n        min={min}\n        max={max}\n        step={step}\n        value={value}\n        onChange={(e) => onChange(Number(e.target.value))}\n      />\n    </label>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// SliderGroup — data-driven stack of sliders bound to a record value.\n//\n// Collapses the three near-identical physics control panels\n// (spring / bounce / wiggle): each differs only in its field list.\n// ---------------------------------------------------------------------------\n\nexport interface SliderField<TKey extends string> {\n  key: TKey\n  label: string\n  min: number\n  max: number\n  step: number\n}\n\nexport interface SliderGroupProps<TKey extends string> {\n  value: Record<TKey, number>\n  fields: ReadonlyArray<SliderField<TKey>>\n  onChange: (value: Record<TKey, number>) => void\n  className?: string\n}\n\nexport function SliderGroup<TKey extends string>({\n  value,\n  fields,\n  onChange,\n  className,\n}: SliderGroupProps<TKey>) {\n  return (\n    <div className={cn(\"space-y-2\", className)}>\n      {fields.map((f) => (\n        <Slider\n          key={f.key}\n          label={f.label}\n          value={value[f.key]}\n          min={f.min}\n          max={f.max}\n          step={f.step}\n          onChange={(v) => onChange({ ...value, [f.key]: v })}\n        />\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/slider-group.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/controls/steps-controls.tsx",
      "content": "import { cn } from \"@/lib/utils\"\nimport { STEP_POSITIONS } from \"../easing-picker.constants\"\nimport type { StepPosition } from \"../easing-picker.types\"\n\nexport interface StepsControlsProps {\n  value: { n: number; position: StepPosition }\n  onChange: (v: { n: number; position: StepPosition }) => void\n  minSteps?: number\n  maxSteps?: number\n  className?: string\n}\n\nexport function StepsControls({\n  value,\n  onChange,\n  minSteps = 1,\n  maxSteps = 100,\n  className,\n}: StepsControlsProps) {\n  return (\n    <div className={cn(\"grid grid-cols-2 gap-2 text-xs\", className)}>\n      <label className=\"flex flex-col gap-0.5\">\n        <span className=\"text-muted-foreground\">Steps</span>\n        <input\n          type=\"number\"\n          aria-label=\"Steps\"\n          min={minSteps}\n          max={maxSteps}\n          step={1}\n          value={value.n}\n          onChange={(e) => {\n            const n = Math.max(\n              minSteps,\n              Math.min(maxSteps, Math.floor(Number(e.target.value))),\n            )\n            if (Number.isFinite(n)) onChange({ ...value, n })\n          }}\n          className=\"rounded bg-muted px-2 py-1 text-foreground\"\n        />\n      </label>\n      <label className=\"flex flex-col gap-0.5\">\n        <span className=\"text-muted-foreground\">Position</span>\n        <select\n          aria-label=\"Position\"\n          value={value.position}\n          onChange={(e) =>\n            onChange({ ...value, position: e.target.value as StepPosition })\n          }\n          className=\"rounded bg-muted px-2 py-1 text-foreground\"\n        >\n          {STEP_POSITIONS.map((p) => (\n            <option key={p} value={p}>\n              {p}\n            </option>\n          ))}\n        </select>\n      </label>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/controls/steps-controls.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/panel/basis-controls.tsx",
      "content": "import { BasisTabs } from \"../controls/basis-tabs\"\nimport { BezierCanvas } from \"../controls/bezier-canvas\"\nimport { BezierInputs } from \"../controls/bezier-inputs\"\nimport {\n  BounceControls,\n  SpringControls,\n  WiggleControls,\n} from \"../controls/physics-controls\"\nimport { PresetGallery } from \"../controls/preset-gallery\"\nimport { StepsControls } from \"../controls/steps-controls\"\nimport { matchPreset, parseEasing } from \"../easing-picker.helpers\"\nimport type { EasingBasis, EasingState } from \"../easing-picker.types\"\n\ninterface BasisControlsProps {\n  state: EasingState\n  available: readonly EasingBasis[]\n  onSwitchBasis: (basis: EasingBasis) => void\n  onChangeState: (updater: (prev: EasingState) => EasingState) => void\n}\n\n/**\n * Basis selection + the per-basis control body. Owns the tabs and the\n * branch that renders the right editor (bezier canvas + presets, the\n * physics sliders, or the steps inputs) for the current basis.\n */\nexport function BasisControls({\n  state,\n  available,\n  onSwitchBasis,\n  onChangeState,\n}: BasisControlsProps) {\n  const currentName =\n    state.basis === \"bezier\"\n      ? matchPreset(state.x1, state.y1, state.x2, state.y2)\n      : null\n\n  return (\n    <>\n      <BasisTabs\n        value={state.basis}\n        onChange={onSwitchBasis}\n        available={available}\n      />\n      {state.basis === \"bezier\" && (\n        <>\n          <PresetGallery\n            value={currentName ?? undefined}\n            onChange={(_, bezier) => {\n              const next = parseEasing(bezier)\n              if (next) onChangeState(() => next)\n            }}\n          />\n          <div className=\"grid grid-cols-[1fr_180px] gap-3\">\n            <div className=\"size-44\">\n              <BezierCanvas\n                value={{\n                  x1: state.x1,\n                  y1: state.y1,\n                  x2: state.x2,\n                  y2: state.y2,\n                }}\n                extraTop={state.extraTop}\n                extraBottom={state.extraBottom}\n                onChange={(v) =>\n                  onChangeState((prev) =>\n                    prev.basis === \"bezier\" ? { ...prev, ...v } : prev,\n                  )\n                }\n              />\n            </div>\n            <BezierInputs\n              value={state}\n              onChange={(v) => onChangeState(() => ({ basis: \"bezier\", ...v }))}\n            />\n          </div>\n        </>\n      )}\n      {state.basis === \"spring\" && (\n        <SpringControls\n          value={state}\n          onChange={(v) => onChangeState(() => ({ basis: \"spring\", ...v }))}\n        />\n      )}\n      {state.basis === \"bounce\" && (\n        <BounceControls\n          value={state}\n          onChange={(v) => onChangeState(() => ({ basis: \"bounce\", ...v }))}\n        />\n      )}\n      {state.basis === \"wiggle\" && (\n        <WiggleControls\n          value={state}\n          onChange={(v) => onChangeState(() => ({ basis: \"wiggle\", ...v }))}\n        />\n      )}\n      {state.basis === \"steps\" && (\n        <StepsControls\n          value={state}\n          onChange={(v) => onChangeState(() => ({ basis: \"steps\", ...v }))}\n        />\n      )}\n    </>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/panel/basis-controls.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/panel/preview-section.tsx",
      "content": "import { EasingPreview, type PreviewProperty } from \"../preview/easing-preview\"\nimport { type OutputFormat, OutputPanel } from \"../preview/output-panel\"\n\ninterface PreviewSectionProps {\n  easing: string\n  previewProperty: PreviewProperty\n  onPreviewPropertyChange: (property: PreviewProperty) => void\n  outputFormat: OutputFormat\n  onOutputFormatChange: (format: OutputFormat) => void\n}\n\n/**\n * The bottom half of the panel: the preview-property picker, the live\n * animated curve preview, and the copyable output snippet.\n */\nexport function PreviewSection({\n  easing,\n  previewProperty,\n  onPreviewPropertyChange,\n  outputFormat,\n  onOutputFormatChange,\n}: PreviewSectionProps) {\n  return (\n    <>\n      <div className=\"flex items-center gap-2 text-xs\">\n        <label htmlFor=\"preview-property\" className=\"text-muted-foreground\">\n          Preview:\n        </label>\n        <select\n          id=\"preview-property\"\n          value={previewProperty}\n          onChange={(e) =>\n            onPreviewPropertyChange(e.target.value as PreviewProperty)\n          }\n          className=\"rounded bg-muted px-2 py-1 text-foreground\"\n        >\n          <option value=\"moveX\">Move X</option>\n          <option value=\"moveY\">Move Y</option>\n          <option value=\"scale\">Scale</option>\n          <option value=\"rotate\">Rotate</option>\n          <option value=\"opacity\">Opacity</option>\n          <option value=\"width\">Width</option>\n        </select>\n      </div>\n      <EasingPreview easing={easing} property={previewProperty} />\n      <OutputPanel\n        easing={easing}\n        format={outputFormat}\n        onFormatChange={onOutputFormatChange}\n      />\n    </>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/panel/preview-section.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/preview/easing-preview.tsx",
      "content": "import { useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport type { EasingString } from \"../easing-picker.types\"\n\nexport type PreviewProperty =\n  | \"moveX\"\n  | \"moveY\"\n  | \"scale\"\n  | \"scaleX\"\n  | \"scaleY\"\n  | \"rotate\"\n  | \"opacity\"\n  | \"width\"\n  | \"color\"\n  | \"blur\"\n\nexport interface EasingPreviewProps {\n  easing: EasingString | (string & {})\n  property?: PreviewProperty\n  duration?: number\n  loop?: boolean\n  showLinearComparison?: boolean\n  className?: string\n}\n\nconst PROP_KEYFRAMES: Record<PreviewProperty, { from: string; to: string }> = {\n  moveX: {\n    from: \"transform: translateX(0)\",\n    to: \"transform: translateX(400px)\",\n  },\n  moveY: {\n    from: \"transform: translateY(0)\",\n    to: \"transform: translateY(100px)\",\n  },\n  scale: { from: \"transform: scale(0.5)\", to: \"transform: scale(1.5)\" },\n  scaleX: { from: \"transform: scaleX(0.5)\", to: \"transform: scaleX(1.5)\" },\n  scaleY: { from: \"transform: scaleY(0.5)\", to: \"transform: scaleY(1.5)\" },\n  rotate: { from: \"transform: rotate(0)\", to: \"transform: rotate(360deg)\" },\n  opacity: { from: \"opacity: 0\", to: \"opacity: 1\" },\n  width: { from: \"width: 50px\", to: \"width: 200px\" },\n  color: {\n    from: \"background-color: oklch(0.55 0.2 300)\",\n    to: \"background-color: oklch(0.55 0.2 30)\",\n  },\n  blur: { from: \"filter: blur(0px)\", to: \"filter: blur(8px)\" },\n}\n\nexport function EasingPreview({\n  easing,\n  property = \"moveX\",\n  duration = 800,\n  loop = false,\n  showLinearComparison = false,\n  className,\n}: EasingPreviewProps) {\n  const [animKey, setAnimKey] = useState(0)\n  const [playing, setPlaying] = useState(true)\n  const animName = `easing-preview-${property}`\n\n  return (\n    <div\n      className={cn(\n        \"relative h-[120px] w-full overflow-hidden rounded bg-muted/30\",\n        className,\n      )}\n    >\n      <style>\n        {`@keyframes ${animName} {\n          from { ${PROP_KEYFRAMES[property].from}; }\n          to { ${PROP_KEYFRAMES[property].to}; }\n        }`}\n      </style>\n      {showLinearComparison && (\n        <div\n          key={`ghost-${animKey}`}\n          data-preview-ghost\n          className=\"absolute top-6 left-4 size-8 rounded bg-muted-foreground/35\"\n          style={{\n            animation: `${animName} ${duration}ms ${loop ? \"infinite\" : \"1\"} linear`,\n            animationPlayState: playing ? \"running\" : \"paused\",\n          }}\n        />\n      )}\n      <div\n        key={`target-${animKey}`}\n        data-preview-target\n        data-animation-key={animKey}\n        className=\"absolute top-6 left-4 size-8 rounded bg-primary\"\n        style={{\n          animation: `${animName} ${duration}ms ${loop ? \"infinite\" : \"1\"} ${easing}`,\n          animationPlayState: playing ? \"running\" : \"paused\",\n        }}\n      />\n      <div className=\"absolute right-2 bottom-2 flex gap-1\">\n        {loop && (\n          <button\n            type=\"button\"\n            onClick={() => setPlaying((p) => !p)}\n            className=\"rounded border bg-background px-2 py-1 text-xs\"\n          >\n            {playing ? \"Pause\" : \"Play\"}\n          </button>\n        )}\n        <button\n          type=\"button\"\n          onClick={() => setAnimKey((k) => k + 1)}\n          className=\"rounded border bg-background px-2 py-1 text-xs\"\n        >\n          Replay\n        </button>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/preview/easing-preview.tsx"
    },
    {
      "path": "src/components/ui/easing-picker/preview/output-panel.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type OutputFormat = \"css\" | \"tailwind-v3\" | \"tailwind-v4\"\n\ninterface OutputPanelProps {\n  easing: string\n  format: OutputFormat\n  onFormatChange: (format: OutputFormat) => void\n}\n\nexport function OutputPanel({\n  easing,\n  format,\n  onFormatChange,\n}: OutputPanelProps) {\n  const [varName, setVarName] = useState(\"ease-custom\")\n  const [copied, setCopied] = useState(false)\n  const [copyError, setCopyError] = useState(false)\n  // Hold the reset timer so we can cancel it on a new copy / unmount.\n  // Without this, the popover Portal can close before the timeout fires,\n  // triggering a setState-after-unmount.\n  const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  useEffect(() => {\n    return () => {\n      if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current)\n    }\n  }, [])\n\n  const snippet = formatSnippet(easing, format, varName)\n\n  const copy = async () => {\n    if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current)\n    try {\n      await navigator.clipboard.writeText(snippet)\n      setCopied(true)\n      resetTimerRef.current = setTimeout(() => setCopied(false), 1500)\n    } catch {\n      setCopyError(true)\n      resetTimerRef.current = setTimeout(() => setCopyError(false), 1500)\n    }\n  }\n\n  return (\n    <div className=\"space-y-2\">\n      <div className=\"flex gap-1 text-xs\">\n        {([\"css\", \"tailwind-v3\", \"tailwind-v4\"] as const).map((f) => (\n          <button\n            key={f}\n            type=\"button\"\n            onClick={() => onFormatChange(f)}\n            className={cn(\n              \"rounded border px-2 py-1\",\n              format === f\n                ? \"border-accent-foreground/20 bg-accent\"\n                : \"border-transparent hover:bg-accent/50\",\n            )}\n          >\n            {f}\n          </button>\n        ))}\n      </div>\n      {format === \"tailwind-v4\" && (\n        <label className=\"flex items-center gap-2 text-xs\">\n          <span className=\"text-muted-foreground\">--var name:</span>\n          <input\n            type=\"text\"\n            value={varName}\n            onChange={(e) =>\n              setVarName(\n                e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, \"\"),\n              )\n            }\n            className=\"flex-1 rounded bg-muted px-2 py-1 text-foreground\"\n          />\n        </label>\n      )}\n      <pre className=\"overflow-auto whitespace-pre-wrap rounded bg-muted p-2 font-mono text-xs\">\n        {snippet}\n      </pre>\n      <button\n        type=\"button\"\n        onClick={copy}\n        className=\"w-full rounded bg-primary px-2 py-1 text-primary-foreground text-xs\"\n      >\n        {copyError ? \"Failed\" : copied ? \"Copied\" : \"Copy\"}\n      </button>\n    </div>\n  )\n}\n\nfunction formatSnippet(\n  easing: string,\n  format: OutputFormat,\n  varName: string,\n): string {\n  switch (format) {\n    case \"css\":\n      return easing\n    case \"tailwind-v3\": {\n      // Tailwind v3 arbitrary values: strip spaces (cubic-bezier args stay valid).\n      const encoded = easing.replace(/\\s+/g, \"\")\n      return `class=\"ease-[${encoded}]\"`\n    }\n    case \"tailwind-v4\":\n      return `@theme {\\n  --${varName}: ${easing};\\n}\\n/* usage: class=\"${varName}\" */`\n  }\n}\n",
      "type": "registry:ui",
      "target": "components/ui/easing-picker/preview/output-panel.tsx"
    }
  ],
  "type": "registry:ui"
}