{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waveform",
  "title": "Waveform",
  "description": "A static waveform of a clip with a smooth playhead, seeking by pointer and keyboard, hover time, draggable regions and markers.",
  "registryDependencies": [
    "@audiocn/core",
    "@audiocn/use-frame-source",
    "@audiocn/use-visibility"
  ],
  "files": [
    {
      "path": "components/ui/waveform.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  ComponentProps,\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent,\n} from \"react\";\n\nimport { useFrameSource } from \"@/hooks/use-frame-source\";\nimport { useVisibility } from \"@/hooks/use-visibility\";\nimport { resampleLevels } from \"@/lib/audio/bands\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { subscribeFrame } from \"@/lib/audio/frame-loop\";\nimport { formatTime } from \"@/lib/audio/time\";\nimport type { FrameSource } from \"@/lib/audio/types\";\nimport { cn } from \"@/lib/utils\";\n\nconst COLOR_REFRESH_FRAMES = 30;\n\ninterface WaveformContextValue {\n  peaks: ArrayLike<number> | null;\n  duration: number;\n  variant: \"bars\" | \"line\" | \"mirror\";\n  barWidth: number;\n  barGap: number;\n  barRadius: number;\n  loading: boolean;\n  /** Current progress, 0..1, read by the canvas and cursor on every frame. */\n  progressRef: { current: number };\n  /** The hovered time, or null. Read it with useSyncExternalStore. */\n  getHover: () => number | null;\n  subscribeHover: (listener: () => void) => () => void;\n  /** Whether pointer seeking (and so the hover line) is on. */\n  interactive: boolean;\n  timeToPosition: (time: number) => number;\n}\n\nconst WaveformContext = createContext<WaveformContextValue | null>(null);\n\nconst getNoHover = () => null;\n\nconst useWaveform = (part: string) => {\n  const context = useContext(WaveformContext);\n  if (!context) {\n    throw new Error(`${part} must be used inside Waveform.`);\n  }\n  return context;\n};\n\nconst seekTarget = (\n  key: string,\n  current: number,\n  amount: number,\n  duration: number\n): number | null => {\n  const targets: Record<string, number> = {\n    ArrowDown: current - amount,\n    ArrowLeft: current - amount,\n    ArrowRight: current + amount,\n    ArrowUp: current + amount,\n    End: duration,\n    Home: 0,\n  };\n  return targets[key] ?? null;\n};\n\nconst defaultHoverFormat = (value: number) => formatTime(value);\n\nconst drawRoundedBar = (\n  context: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  width: number,\n  height: number,\n  radius: number\n) => {\n  if (typeof context.roundRect === \"function\") {\n    context.beginPath();\n    context.roundRect(\n      x,\n      y,\n      width,\n      height,\n      Math.min(radius, width / 2, height / 2)\n    );\n    context.fill();\n  } else {\n    context.fillRect(x, y, width, height);\n  }\n};\n\nexport const WaveformCanvas = ({\n  className,\n  ...props\n}: ComponentProps<\"canvas\">) => {\n  const { barGap, barRadius, barWidth, loading, peaks, progressRef, variant } =\n    useWaveform(\"WaveformCanvas\");\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const visibleRef = useVisibility(canvasRef);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const context = canvas?.getContext(\"2d\");\n    if (!(canvas && context)) {\n      return;\n    }\n    const size = { height: 0, width: 0 };\n    let ratio = 1;\n    let colors = { played: \"\", unplayed: \"\" };\n    let framesSinceColor = COLOR_REFRESH_FRAMES;\n    let lastProgress = -1;\n    let levels = new Float32Array(0);\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      ratio = window.devicePixelRatio || 1;\n      size.width = rect.width;\n      size.height = rect.height;\n      canvas.width = Math.max(1, Math.round(size.width * ratio));\n      canvas.height = Math.max(1, Math.round(size.height * ratio));\n      const count = Math.max(\n        1,\n        Math.floor((size.width + barGap) / (barWidth + barGap))\n      );\n      levels = new Float32Array(count);\n      if (peaks && peaks.length > 0) {\n        resampleLevels(peaks, 0, peaks.length, levels);\n      }\n      lastProgress = -1;\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(canvas);\n\n    const drawPass = (color: string, clipWidth: number) => {\n      context.save();\n      context.beginPath();\n      context.rect(0, 0, clipWidth, size.height);\n      context.clip();\n      context.fillStyle = color;\n      context.strokeStyle = color;\n      const pitch = barWidth + barGap;\n      const middle = size.height / 2;\n      if (variant === \"line\") {\n        context.lineWidth = 1.5;\n        context.beginPath();\n        for (let index = 0; index < levels.length; index += 1) {\n          const x = index * pitch + barWidth / 2;\n          const y = middle - (levels[index] ?? 0) * (middle - 1);\n          if (index === 0) {\n            context.moveTo(x, y);\n          } else {\n            context.lineTo(x, y);\n          }\n        }\n        for (let index = levels.length - 1; index >= 0; index -= 1) {\n          const x = index * pitch + barWidth / 2;\n          context.lineTo(x, middle + (levels[index] ?? 0) * (middle - 1));\n        }\n        context.closePath();\n        context.fill();\n      } else {\n        for (let index = 0; index < levels.length; index += 1) {\n          const level = levels[index] ?? 0;\n          const barHeight = Math.max(2, level * size.height);\n          const y =\n            variant === \"mirror\"\n              ? middle - barHeight / 2\n              : size.height - barHeight;\n          drawRoundedBar(\n            context,\n            index * pitch,\n            y,\n            barWidth,\n            barHeight,\n            barRadius\n          );\n        }\n      }\n      context.restore();\n    };\n\n    const draw = () => {\n      framesSinceColor += 1;\n      if (framesSinceColor >= COLOR_REFRESH_FRAMES) {\n        framesSinceColor = 0;\n        const style = getComputedStyle(canvas);\n        const next = {\n          played:\n            style.getPropertyValue(\"--waveform-progress\").trim() || style.color,\n          unplayed: style.getPropertyValue(\"--waveform\").trim() || style.color,\n        };\n        if (\n          next.played !== colors.played ||\n          next.unplayed !== colors.unplayed\n        ) {\n          colors = next;\n          lastProgress = -1;\n        }\n      }\n      const progress = progressRef.current;\n      // Off screen, leave lastProgress alone so it repaints when it returns.\n      if (\n        progress === lastProgress ||\n        size.width === 0 ||\n        !visibleRef.current\n      ) {\n        return;\n      }\n      lastProgress = progress;\n      context.setTransform(ratio, 0, 0, ratio, 0, 0);\n      context.clearRect(0, 0, size.width, size.height);\n      if (loading || !peaks) {\n        return;\n      }\n      drawPass(colors.unplayed, size.width);\n      drawPass(colors.played, progress * size.width);\n    };\n\n    const unsubscribe = subscribeFrame(draw);\n    return () => {\n      unsubscribe();\n      observer.disconnect();\n    };\n  }, [\n    barGap,\n    barRadius,\n    barWidth,\n    loading,\n    peaks,\n    progressRef,\n    variant,\n    visibleRef,\n  ]);\n\n  return (\n    <>\n      {loading ? (\n        <div\n          aria-hidden\n          className=\"bg-muted absolute inset-0 animate-pulse rounded-lg\"\n          data-slot=\"waveform-skeleton\"\n        />\n      ) : null}\n      <canvas\n        aria-hidden\n        className={cn(\"absolute inset-0 size-full\", className)}\n        data-slot=\"waveform-canvas\"\n        ref={canvasRef}\n        {...props}\n      />\n    </>\n  );\n};\n\nexport const WaveformCursor = ({\n  className,\n  ...props\n}: ComponentProps<\"div\">) => (\n  <div\n    aria-hidden\n    className={cn(\n      \"pointer-events-none absolute inset-y-0 left-[calc(var(--waveform-position)*100%)] w-0.5 -translate-x-1/2 rounded-full bg-(--waveform-cursor) group-data-loading/waveform:hidden\",\n      className\n    )}\n    data-slot=\"waveform-cursor\"\n    {...props}\n  />\n);\n\nexport interface WaveformHoverProps extends ComponentProps<\"div\"> {\n  format?: (time: number) => string;\n}\n\nexport const WaveformHover = ({\n  format = defaultHoverFormat,\n  className,\n  style,\n  ...props\n}: WaveformHoverProps) => {\n  const { getHover, interactive, subscribeHover, timeToPosition } =\n    useWaveform(\"WaveformHover\");\n  const hover = useSyncExternalStore(subscribeHover, getHover, getNoHover);\n\n  if (!interactive || hover === null) {\n    return null;\n  }\n\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        \"bg-foreground/40 pointer-events-none absolute inset-y-0 left-(--waveform-hover) w-px\",\n        className\n      )}\n      data-slot=\"waveform-hover\"\n      style={\n        {\n          \"--waveform-hover\": `${timeToPosition(hover) * 100}%`,\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      <span className=\"bg-foreground text-background absolute -top-6 left-1/2 -translate-x-1/2 rounded-md px-1.5 py-0.5 font-mono text-[0.625rem] whitespace-nowrap tabular-nums\">\n        {format(hover)}\n      </span>\n    </div>\n  );\n};\n\nexport interface WaveformRegionValue {\n  start: number;\n  end: number;\n}\n\nexport interface WaveformRegionProps extends Omit<\n  ComponentProps<\"div\">,\n  \"onChange\" | \"defaultValue\"\n> {\n  start: number;\n  end: number;\n  onValueChange?: (value: WaveformRegionValue) => void;\n  /** Drag the edges. Default true. */\n  resizable?: boolean;\n  /** Drag the whole region. Default true. */\n  draggable?: boolean;\n  /** Shortest region in seconds. Default 0.1. */\n  minLength?: number;\n}\n\ninterface RegionDrag {\n  kind: \"start\" | \"end\" | \"move\";\n  x: number;\n  start: number;\n  end: number;\n}\n\nexport const WaveformRegion = ({\n  start,\n  end,\n  onValueChange,\n  resizable = true,\n  draggable = true,\n  minLength = 0.1,\n  className,\n  children,\n  style,\n  ...props\n}: WaveformRegionProps) => {\n  const { duration, timeToPosition } = useWaveform(\"WaveformRegion\");\n  const dragRef = useRef<RegionDrag | null>(null);\n  const regionRef = useRef<HTMLDivElement>(null);\n\n  const secondsPerPixel = () => {\n    const width =\n      regionRef.current?.parentElement?.getBoundingClientRect().width ?? 1;\n    return duration / width;\n  };\n\n  const begin =\n    (kind: RegionDrag[\"kind\"]) => (event: PointerEvent<HTMLElement>) => {\n      event.stopPropagation();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      dragRef.current = { end, kind, start, x: event.clientX };\n    };\n\n  const move = (event: PointerEvent<HTMLElement>) => {\n    const drag = dragRef.current;\n    if (!drag) {\n      return;\n    }\n    event.stopPropagation();\n    const delta = (event.clientX - drag.x) * secondsPerPixel();\n    if (drag.kind === \"move\") {\n      const length = drag.end - drag.start;\n      const nextStart = clamp(drag.start + delta, 0, duration - length);\n      onValueChange?.({ end: nextStart + length, start: nextStart });\n    } else if (drag.kind === \"start\") {\n      onValueChange?.({\n        end: drag.end,\n        start: clamp(drag.start + delta, 0, drag.end - minLength),\n      });\n    } else {\n      onValueChange?.({\n        end: clamp(drag.end + delta, drag.start + minLength, duration),\n        start: drag.start,\n      });\n    }\n  };\n\n  const finish = (event: PointerEvent<HTMLElement>) => {\n    event.stopPropagation();\n    dragRef.current = null;\n  };\n\n  const nudge =\n    (edge: \"start\" | \"end\") => (event: KeyboardEvent<HTMLSpanElement>) => {\n      const amount = event.shiftKey ? 1 : 0.1;\n      let delta = 0;\n      if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n        delta = amount;\n      } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n        delta = -amount;\n      }\n      if (delta === 0) {\n        return;\n      }\n      event.preventDefault();\n      event.stopPropagation();\n      if (edge === \"start\") {\n        onValueChange?.({\n          end,\n          start: clamp(start + delta, 0, end - minLength),\n        });\n      } else {\n        onValueChange?.({\n          end: clamp(end + delta, start + minLength, duration),\n          start,\n        });\n      }\n    };\n\n  const left = timeToPosition(start) * 100;\n  const width = (timeToPosition(end) - timeToPosition(start)) * 100;\n  const handleClass =\n    \"absolute inset-y-0 w-2 cursor-ew-resize rounded-sm bg-primary/60 after:absolute after:inset-y-0 after:-inset-x-1.5 pointer-coarse:after:-inset-x-3 outline-none focus-visible:bg-primary focus-visible:ring-3 focus-visible:ring-ring/30\";\n\n  return (\n    <div\n      className={cn(\n        \"bg-primary/15 ring-primary/40 absolute inset-y-0 left-(--region-start) w-(--region-size) rounded-sm ring-1\",\n        draggable && \"cursor-grab active:cursor-grabbing\",\n        className\n      )}\n      data-slot=\"waveform-region\"\n      onLostPointerCapture={finish}\n      onPointerDown={\n        draggable ? begin(\"move\") : (event) => event.stopPropagation()\n      }\n      onPointerMove={move}\n      onPointerUp={finish}\n      ref={regionRef}\n      style={\n        {\n          \"--region-size\": `${width}%`,\n          \"--region-start\": `${left}%`,\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      {children}\n      {resizable ? (\n        <>\n          <span\n            aria-label=\"Region start\"\n            aria-valuemax={Math.round(end)}\n            aria-valuemin={0}\n            aria-valuenow={Math.round(start)}\n            aria-valuetext={formatTime(start)}\n            className={cn(handleClass, \"-left-1\")}\n            data-slot=\"waveform-region-start\"\n            onKeyDown={nudge(\"start\")}\n            onPointerDown={begin(\"start\")}\n            role=\"slider\"\n            tabIndex={0}\n          />\n          <span\n            aria-label=\"Region end\"\n            aria-valuemax={Math.round(duration)}\n            aria-valuemin={Math.round(start)}\n            aria-valuenow={Math.round(end)}\n            aria-valuetext={formatTime(end)}\n            className={cn(handleClass, \"-right-1\")}\n            data-slot=\"waveform-region-end\"\n            onKeyDown={nudge(\"end\")}\n            onPointerDown={begin(\"end\")}\n            role=\"slider\"\n            tabIndex={0}\n          />\n        </>\n      ) : null}\n    </div>\n  );\n};\n\nexport interface WaveformMarkerProps extends ComponentProps<\"div\"> {\n  time: number;\n}\n\nexport const WaveformMarker = ({\n  time,\n  className,\n  children,\n  style,\n  ...props\n}: WaveformMarkerProps) => {\n  const { timeToPosition } = useWaveform(\"WaveformMarker\");\n  return (\n    <div\n      className={cn(\n        \"bg-meter-warn pointer-events-none absolute inset-y-0 left-(--marker-position) w-px\",\n        className\n      )}\n      data-slot=\"waveform-marker\"\n      style={\n        {\n          \"--marker-position\": `${timeToPosition(time) * 100}%`,\n          ...style,\n        } as CSSProperties\n      }\n      {...props}\n    >\n      {children ? (\n        <span className=\"bg-meter-warn/20 text-foreground absolute top-0 left-1 rounded-sm px-1 text-[0.625rem] font-medium whitespace-nowrap\">\n          {children}\n        </span>\n      ) : null}\n    </div>\n  );\n};\n\nexport interface WaveformProps extends Omit<\n  ComponentProps<\"div\">,\n  \"onSeeked\" | \"defaultValue\"\n> {\n  /** Peaks, 0..1, from `useWaveformData`. */\n  peaks: ArrayLike<number> | null;\n  /** Length in seconds. */\n  duration: number;\n  /** Playhead position in seconds. */\n  currentTime?: number;\n  defaultCurrentTime?: number;\n  /** A smooth playhead with no React renders. */\n  time?: FrameSource<number> | null;\n  /** Fires while seeking. */\n  onSeek?: (time: number) => void;\n  /** Fires when the pointer is released or after keyboard seeking. */\n  onSeekCommitted?: (time: number) => void;\n  /** Seconds per arrow key. Default 5. */\n  step?: number;\n  /** Seconds per Shift+arrow. Default 15. */\n  largeStep?: number;\n  /** Default `bars`. */\n  variant?: \"bars\" | \"line\" | \"mirror\";\n  /** Bar width in pixels. Default 2. */\n  barWidth?: number;\n  /** Gap in pixels. Default 1. */\n  barGap?: number;\n  /** Corner radius in pixels. Default 1. */\n  barRadius?: number;\n  /** When false, the waveform is display only. Default true. */\n  interactive?: boolean;\n  loading?: boolean;\n  disabled?: boolean;\n}\n\nconst isInteractive = (\n  interactive: boolean,\n  disabled: boolean,\n  loading: boolean,\n  duration: number\n) => interactive && !disabled && !loading && duration > 0;\n\n/** The hovered time, as a tiny external store. */\nconst createHoverChannel = () => {\n  const listeners = new Set<() => void>();\n  let hovered: number | null = null;\n  return {\n    get: () => hovered,\n    set: (next: number | null) => {\n      if (next === hovered) {\n        return;\n      }\n      hovered = next;\n      for (const listener of listeners) {\n        listener();\n      }\n    },\n    subscribe: (listener: () => void) => {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n  };\n};\n\ninterface PointerOptions {\n  setHover: (time: number | null) => void;\n  seekTo: (time: number, commit: boolean) => void;\n  timeAtPointer: (event: PointerEvent<HTMLDivElement>) => number;\n}\n\n/** Pointer seeking and hover tracking for the waveform root. */\nconst useWaveformPointer = ({\n  setHover,\n  seekTo,\n  timeAtPointer,\n}: PointerOptions) => {\n  const [dragging, setDragging] = useState(false);\n  return {\n    dragging,\n    handlers: {\n      onPointerDown: (event: PointerEvent<HTMLDivElement>) => {\n        if (event.button !== 0) {\n          return;\n        }\n        event.currentTarget.setPointerCapture(event.pointerId);\n        setDragging(true);\n        seekTo(timeAtPointer(event), false);\n      },\n      onPointerLeave: () => {\n        setHover(null);\n      },\n      onPointerMove: (event: PointerEvent<HTMLDivElement>) => {\n        const at = timeAtPointer(event);\n        setHover(at);\n        if (dragging) {\n          seekTo(at, false);\n        }\n      },\n      onPointerUp: (event: PointerEvent<HTMLDivElement>) => {\n        if (!dragging) {\n          return;\n        }\n        setDragging(false);\n        seekTo(timeAtPointer(event), true);\n      },\n    },\n  };\n};\n\nexport const Waveform = ({\n  peaks,\n  duration,\n  currentTime,\n  defaultCurrentTime = 0,\n  time,\n  onSeek,\n  onSeekCommitted,\n  step = 5,\n  largeStep = 15,\n  variant = \"bars\",\n  barWidth = 2,\n  barGap = 1,\n  barRadius = 1,\n  interactive = true,\n  loading = false,\n  disabled = false,\n  className,\n  children,\n  ...props\n}: WaveformProps) => {\n  const [internalTime, setInternalTime] = useState(defaultCurrentTime);\n  const shownTime = currentTime ?? internalTime;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const progressRef = useRef(0);\n  const hover = useMemo(() => createHoverChannel(), []);\n  const latestTimeRef = useRef(shownTime);\n\n  const timeToPosition = useCallback(\n    (value: number) => (duration > 0 ? clamp(value / duration, 0, 1) : 0),\n    [duration]\n  );\n\n  const writeProgress = useCallback(\n    (value: number) => {\n      latestTimeRef.current = value;\n      progressRef.current = timeToPosition(value);\n      rootRef.current?.style.setProperty(\n        \"--waveform-position\",\n        progressRef.current.toFixed(5)\n      );\n    },\n    [timeToPosition]\n  );\n\n  useEffect(() => {\n    writeProgress(shownTime);\n  }, [shownTime, writeProgress]);\n\n  useFrameSource(time, writeProgress);\n\n  const seekTo = (value: number, commit: boolean) => {\n    const next = clamp(value, 0, duration);\n    writeProgress(next);\n    if (currentTime === undefined) {\n      setInternalTime(next);\n    }\n    onSeek?.(next);\n    if (commit) {\n      onSeekCommitted?.(next);\n    }\n  };\n\n  const timeAtPointer = (event: PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const position = clamp((event.clientX - rect.left) / rect.width, 0, 1);\n    return position * duration;\n  };\n\n  const active = isInteractive(interactive, disabled, loading, duration);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!active) {\n      return;\n    }\n    const next = seekTarget(\n      event.key,\n      latestTimeRef.current,\n      event.shiftKey ? largeStep : step,\n      duration\n    );\n    if (next !== null) {\n      event.preventDefault();\n      seekTo(next, true);\n    }\n  };\n\n  const contextValue = useMemo<WaveformContextValue>(\n    () => ({\n      barGap,\n      barRadius,\n      barWidth,\n      duration,\n      getHover: hover.get,\n      interactive: active,\n      loading,\n      peaks,\n      progressRef,\n      subscribeHover: hover.subscribe,\n      timeToPosition,\n      variant,\n    }),\n    [\n      active,\n      barGap,\n      barRadius,\n      barWidth,\n      duration,\n      hover,\n      loading,\n      peaks,\n      timeToPosition,\n      variant,\n    ]\n  );\n\n  const pointer = useWaveformPointer({\n    seekTo,\n    setHover: hover.set,\n    timeAtPointer,\n  });\n\n  // Leaving still clears the hover, so turning interactivity off mid-hover\n  // (while a track loads) doesn't leave a stale line.\n  const inactiveProps = { onPointerLeave: pointer.handlers.onPointerLeave };\n  const interactiveProps = active\n    ? {\n        \"aria-valuemax\": Math.round(duration),\n        \"aria-valuemin\": 0,\n        \"aria-valuenow\": Math.round(shownTime),\n        \"aria-valuetext\": `${formatTime(shownTime)} of ${formatTime(duration)}`,\n        onKeyDown: handleKeyDown,\n        ...pointer.handlers,\n        role: \"slider\",\n        tabIndex: 0,\n      }\n    : inactiveProps;\n\n  return (\n    <WaveformContext.Provider value={contextValue}>\n      <div\n        className={cn(\n          \"group/waveform focus-visible:ring-ring/30 relative h-20 w-full touch-none rounded-lg outline-none select-none [--waveform-cursor:var(--foreground)] [--waveform-position:0] [--waveform-progress:var(--primary)] [--waveform:var(--muted-foreground)] focus-visible:ring-3 data-disabled:opacity-50\",\n          className\n        )}\n        data-disabled={disabled ? \"\" : undefined}\n        data-dragging={pointer.dragging ? \"\" : undefined}\n        data-loading={loading ? \"\" : undefined}\n        data-slot=\"waveform\"\n        data-variant={variant}\n        ref={rootRef}\n        {...interactiveProps}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <WaveformCanvas />\n            <WaveformCursor />\n          </>\n        )}\n      </div>\n    </WaveformContext.Provider>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}