{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-envelope-display",
  "type": "registry:ui",
  "title": "Envelope Display",
  "description": "An ADSR envelope you can draw on: filled shape, draggable handles at the peak, knee and tail, and a square-root time axis that keeps millisecond attacks legible next to multi-second releases.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/envelope-display.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/envelope-display\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * EnvelopeDisplay — an ADSR envelope you can draw on.\n *\n * The filled shape is the envelope; the three handles are the envelope. Drag\n * the peak to set attack, the knee to set decay and sustain together, the tail\n * to set release. Every handle is focusable and steps with the arrow keys, so\n * the whole control works without a pointer.\n *\n * Time axes use a square-root scale. Envelope times are perceptually\n * logarithmic — the difference between 5 ms and 50 ms matters far more than\n * between 3 s and 3.5 s — and a linear axis crushes the entire short end into\n * the first few pixels. Sqrt keeps the fast times legible while still showing\n * long ones, without log's awkwardness around zero.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`. A host that\n * frames the display into a panel (see `EnvelopeEditor`'s `frame` prop) can set\n * `inset` so the plot reads as a recessed window rather than a floating card.\n */\n\nimport { useCallback, useId, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties, KeyboardEvent } from \"react\"\nimport {\n  clamp,\n  sanitize,\n} from \"maruaudio/ui/shared/math\"\nimport {\n  normalizeValue01,\n  positionToValue01,\n} from \"maruaudio/ui/shared/knob-core\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  SVG_MARKER_DIAMETER_PX,\n  SVG_MARKER_HIT_DIAMETER_PX,\n  SVG_MARKER_OUTLINE_DIAMETER_PX,\n  svgMarkerPath,\n} from \"maruaudio/ui/shared/svg-markers\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\nimport { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\n\nexport interface ADSR {\n  /** Attack time in seconds. */\n  attack: number\n  /** Decay time in seconds. */\n  decay: number\n  /** Sustain level, 0..1. */\n  sustain: number\n  /** Release time in seconds. */\n  release: number\n}\n\nexport interface EnvelopeRange {\n  min: number\n  max: number\n}\n\nexport interface EnvelopeRanges {\n  attack?: EnvelopeRange\n  decay?: EnvelopeRange\n  sustain?: EnvelopeRange\n  release?: EnvelopeRange\n}\n\n/** Which handle a gesture is addressing. */\nexport type EnvelopeHandle = \"attack\" | \"decay\" | \"release\"\n\nexport interface EnvelopeDisplayProps {\n  /** AR fixes the held level at unity and exposes only attack and release. */\n  mode?: \"adsr\" | \"ar\"\n  /** Controlled envelope. Omit for uncontrolled operation. */\n  value?: ADSR\n  /** Starting envelope when uncontrolled, and the double-click reset target. */\n  defaultValue?: ADSR\n  onChange?: (value: ADSR) => void\n  /** Fires once when a drag gesture ends — the hook for undo coalescing. */\n  onChangeEnd?: (value: ADSR) => void\n  /** Fires once on the first gesture — for resuming an AudioContext. */\n  onInteraction?: (event: InteractionEvent) => void\n\n  /** Per-segment bounds. Anything omitted keeps its default. */\n  ranges?: EnvelopeRanges\n\n  /** Render the attack and decay segments as curves rather than ramps. */\n  curve?: \"linear\" | \"exponential\"\n  /**\n   * How a time (in seconds) is read out — in the segment labels and each\n   * handle's `aria-valuetext`. Defaults to milliseconds below one second and\n   * seconds above. A host whose other controls print times their own way\n   * passes its formatter so the graph and they agree.\n   */\n  formatTime?: (seconds: number) => string\n\n  width?: number | string\n  height?: number\n  /** Draw the horizontal level gridlines. */\n  showGrid?: boolean\n  /** Draw the A / D / S / R segment labels along the bottom. */\n  showSegmentLabels?: boolean\n  /**\n   * Recess the plot into its host panel: a soft inner shadow under the top\n   * edge, built from the border token so it holds up in light themes too.\n   * Meant for framed hosts (`EnvelopeEditor frame`); off by default.\n   */\n  inset?: boolean\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport const DEFAULT_ENVELOPE_RANGES: Required<EnvelopeRanges> = {\n  attack: { min: 0.001, max: 4 },\n  decay: { min: 0.001, max: 4 },\n  sustain: { min: 0, max: 1 },\n  release: { min: 0.001, max: 8 },\n}\n\nconst DEFAULT_ENVELOPE: ADSR = {\n  attack: 0.01,\n  decay: 0.2,\n  sustain: 0.6,\n  release: 0.4,\n}\n\n/** How far a press on the shared peak/knee point travels before it chooses\n *  the handle it drags. */\nconst PENDING_PRESS_SLOP_PX = 3\n\n/** Extra screen-space stroke used to make the hovered segment easy to trace. */\nconst HOVER_SEGMENT_WIDTH_PX = 5\n/** Invisible but forgiving stroke for hovering a thin envelope segment. */\nconst SEGMENT_HOVER_TARGET_WIDTH_PX = 16\n\n/** Sqrt time scaling — see the file header for why. */\nconst timeTo01 = (seconds: number, range: EnvelopeRange): number => {\n  const t = (seconds - range.min) / (range.max - range.min)\n  return Math.sqrt(clamp(t, 0, 1))\n}\n\nconst timeFrom01 = (unit: number, range: EnvelopeRange): number => {\n  const t = clamp(unit, 0, 1) ** 2\n  return range.min + t * (range.max - range.min)\n}\n\n/** Seconds, at the precision the number actually warrants. */\nfunction formatSeconds(seconds: number): string {\n  if (seconds < 1) return `${Math.round(seconds * 1000)} ms`\n  return `${seconds.toFixed(2)} s`\n}\n\nexport function EnvelopeDisplay({\n  mode = \"adsr\",\n  value,\n  defaultValue = DEFAULT_ENVELOPE,\n  onChange,\n  onChangeEnd,\n  onInteraction,\n  ranges,\n  curve = \"linear\",\n  formatTime = formatSeconds,\n  width = \"100%\",\n  height = 160,\n  showGrid = true,\n  showSegmentLabels = true,\n  inset = false,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: EnvelopeDisplayProps) {\n  const labelId = useId()\n  const gradientId = `uikit-env-${labelId.replace(/[^a-zA-Z0-9]/g, \"\")}`\n\n  const bounds = useMemo<Required<EnvelopeRanges>>(\n    () => ({\n      attack: ranges?.attack ?? DEFAULT_ENVELOPE_RANGES.attack,\n      decay: ranges?.decay ?? DEFAULT_ENVELOPE_RANGES.decay,\n      sustain: ranges?.sustain ?? DEFAULT_ENVELOPE_RANGES.sustain,\n      release: ranges?.release ?? DEFAULT_ENVELOPE_RANGES.release,\n    }),\n    [ranges]\n  )\n\n  const clean = useCallback(\n    (next: ADSR): ADSR => ({\n      attack: clamp(next.attack, bounds.attack.min, bounds.attack.max),\n      decay: clamp(next.decay, bounds.decay.min, bounds.decay.max),\n      sustain: clamp(next.sustain, bounds.sustain.min, bounds.sustain.max),\n      release: clamp(next.release, bounds.release.min, bounds.release.max),\n    }),\n    [bounds]\n  )\n\n  const isControlled = value !== undefined\n  const [internal, setInternal] = useState(() => clean(defaultValue))\n  const current = isControlled ? clean(value) : internal\n\n  const latest = useRef(current)\n  latest.current = current\n\n  const surfaceRef = useRef<SVGSVGElement | null>(null)\n  const [activeHandle, setActiveHandle] = useState<EnvelopeHandle | null>(null)\n  const [hoveredHandle, setHoveredHandle] = useState<EnvelopeHandle | null>(\n    null\n  )\n  const [focusedHandle, setFocusedHandle] = useState<EnvelopeHandle | null>(\n    null\n  )\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  const commit = useCallback(\n    (next: ADSR) => {\n      const value_ = clean(next)\n      if (!isControlled) setInternal(value_)\n      const previous = latest.current\n      if (\n        value_.attack !== previous.attack ||\n        value_.decay !== previous.decay ||\n        value_.sustain !== previous.sustain ||\n        value_.release !== previous.release\n      ) {\n        latest.current = value_\n        onChange?.(value_)\n      }\n    },\n    [clean, isControlled, onChange]\n  )\n\n  /* ------------------------------------------------------------ geometry --- */\n\n  // A viewBox keeps the maths resolution-independent: the SVG scales to\n  // whatever width the container gives it, and handle hit-testing converts\n  // client pixels back through the live bounding rect.\n  const VB_W = 300\n  const VB_H = 100\n  const pad = 10\n\n  const innerW = VB_W - pad * 2\n  const innerH = VB_H - pad * 2\n\n  // Each time segment gets a share of the width proportional to its scaled\n  // duration, with the sustain hold taking a fixed slice so the shape stays\n  // readable whatever the times are.\n  const HOLD_FRACTION = 0.22\n  const timeWidth = innerW * (1 - HOLD_FRACTION)\n  const aUnit = timeTo01(current.attack, bounds.attack)\n  const dUnit = mode === \"ar\" ? 0 : timeTo01(current.decay, bounds.decay)\n  const rUnit = timeTo01(current.release, bounds.release)\n  // Divide the time budget among the active stages so a maxed-out envelope\n  // exactly fills the axis instead of overflowing it.\n  const timeSegments = mode === \"ar\" ? 2 : 3\n  const aW = (aUnit / timeSegments) * timeWidth\n  const dW = (dUnit / timeSegments) * timeWidth\n  const rW = (rUnit / timeSegments) * timeWidth\n  const holdW = innerW * HOLD_FRACTION\n\n  const sustain01 =\n    mode === \"ar\"\n      ? 1\n      : normalizeValue01(\n          current.sustain,\n          bounds.sustain.min,\n          bounds.sustain.max\n        )\n\n  const yBase = pad + innerH\n  const yPeak = pad\n  const ySustain = pad + (1 - sustain01) * innerH\n\n  const xStart = pad\n  const xPeak = xStart + aW\n  const xKnee = xPeak + dW\n  const xHoldEnd = xKnee + holdW\n  const xEnd = xHoldEnd + rW\n\n  /**\n   * Exponential segments are drawn as quadratic curves whose control point is\n   * pulled toward the start, which is the familiar capacitor-charge shape.\n   */\n  const segment = (x1: number, x2: number, y2: number): string => {\n    if (curve === \"linear\") return `L ${x2} ${y2}`\n    const cx = x1 + (x2 - x1) * 0.25\n    return `Q ${cx} ${y2} ${x2} ${y2}`\n  }\n\n  const outline =\n    `M ${xStart} ${yBase}` +\n    ` ${segment(xStart, xPeak, yPeak)}` +\n    ` ${segment(xPeak, xKnee, ySustain)}` +\n    ` L ${xHoldEnd} ${ySustain}` +\n    ` ${segment(xHoldEnd, xEnd, yBase)}`\n\n  const fillPath = `${outline} L ${xStart} ${yBase} Z`\n\n  // Each node controls the segment that leads to it. Decay also controls the\n  // sustain level, so its hover includes the held section after the knee.\n  const segmentPaths: Record<EnvelopeHandle, string> = {\n    attack: `M ${xStart} ${yBase} ${segment(xStart, xPeak, yPeak)}`,\n    decay:\n      `M ${xPeak} ${yPeak}` +\n      ` ${segment(xPeak, xKnee, ySustain)}` +\n      ` L ${xHoldEnd} ${ySustain}`,\n    release: `M ${xHoldEnd} ${ySustain} ${segment(xHoldEnd, xEnd, yBase)}`,\n  }\n  const highlightedSegment = activeHandle ?? hoveredHandle ?? focusedHandle\n\n  /* --------------------------------------------------------- interaction --- */\n\n  /** Map a client point onto the viewBox coordinate system. */\n  const toViewBox = useCallback((clientX: number, clientY: number) => {\n    const rect = surfaceRef.current?.getBoundingClientRect()\n    if (!rect || rect.width === 0 || rect.height === 0) return null\n    return {\n      x: positionToValue01(clientX, rect.left, rect.width) * VB_W,\n      y: positionToValue01(clientY, rect.top, rect.height) * VB_H,\n    }\n  }, [])\n\n  const dragHandle = useRef<EnvelopeHandle | null>(null)\n  /**\n   * With no decay and full sustain the knee sits exactly on the peak, and the\n   * knee's marker (drawn last) would take every press there, leaving attack\n   * unreachable by pointer. A press on that shared point waits for the first\n   * real movement to choose: pulling DOWN takes the knee (sustain), anything\n   * else takes the peak (attack). This is where the pending press starts.\n   */\n  const pendingPress = useRef<{ x: number; y: number } | null>(null)\n\n  const applyPointer = useCallback(\n    (clientX: number, clientY: number, handle: EnvelopeHandle) => {\n      const point = toViewBox(clientX, clientY)\n      if (!point) return\n      const env = latest.current\n\n      if (handle === \"attack\") {\n        // Horizontal only: the peak is always full level.\n        const unit = clamp(\n          (point.x - xStart) / (timeWidth / timeSegments),\n          0,\n          1\n        )\n        commit({ ...env, attack: timeFrom01(unit, bounds.attack) })\n        return\n      }\n\n      if (handle === \"decay\") {\n        // The knee carries two parameters at once: x is decay, y is sustain.\n        const unit = clamp((point.x - xPeak) / (timeWidth / timeSegments), 0, 1)\n        const level = clamp(1 - (point.y - pad) / innerH, 0, 1)\n        commit({\n          ...env,\n          decay: timeFrom01(unit, bounds.decay),\n          sustain:\n            bounds.sustain.min +\n            level * (bounds.sustain.max - bounds.sustain.min),\n        })\n        return\n      }\n\n      // Release: horizontal distance from the end of the hold segment.\n      const unit = clamp(\n        (point.x - xHoldEnd) / (timeWidth / timeSegments),\n        0,\n        1\n      )\n      commit({ ...env, release: timeFrom01(unit, bounds.release) })\n    },\n    [\n      toViewBox,\n      commit,\n      bounds,\n      xStart,\n      xPeak,\n      xHoldEnd,\n      timeWidth,\n      timeSegments,\n      innerH,\n      pad,\n    ]\n  )\n\n  const drag = useSurfacePointer(\n    (state) => {\n      if (state.first) notify(state.event)\n      const press = pendingPress.current\n      if (press !== null) {\n        const dx = state.clientX - press.x\n        const dy = state.clientY - press.y\n        if (state.first || Math.hypot(dx, dy) < PENDING_PRESS_SLOP_PX) return\n        pendingPress.current = null\n        const chosen: EnvelopeHandle = dy > Math.abs(dx) ? \"decay\" : \"attack\"\n        dragHandle.current = chosen\n        setActiveHandle(chosen)\n      }\n      const handle = dragHandle.current\n      if (!handle) return\n      applyPointer(state.clientX, state.clientY, handle)\n    },\n    {\n      disabled,\n      onEnd: () => {\n        dragHandle.current = null\n        pendingPress.current = null\n        setActiveHandle(null)\n        onChangeEnd?.(latest.current)\n      },\n    }\n  )\n\n  // The knee on the peak: no decay travel and the sustain at its top.\n  const kneeOnPeak =\n    mode !== \"ar\" && Math.abs(xKnee - xPeak) < 0.5 && Math.abs(ySustain - yPeak) < 0.5\n\n  const startDrag =\n    (handle: EnvelopeHandle) => (event: React.PointerEvent<Element>) => {\n      if (disabled) return\n      if (kneeOnPeak && (handle === \"decay\" || handle === \"attack\")) {\n        pendingPress.current = { x: event.clientX, y: event.clientY }\n        dragHandle.current = null\n      } else {\n        pendingPress.current = null\n        dragHandle.current = handle\n        setActiveHandle(handle)\n      }\n      drag.onPointerDown(event)\n    }\n\n  /** Arrow keys step the handle's own parameters; Shift takes a finer pass. */\n  const handleKeyDown =\n    (handle: EnvelopeHandle) => (event: KeyboardEvent<SVGGElement>) => {\n      if (disabled) return\n      notify(event)\n      const env = latest.current\n      const fine = event.shiftKey\n\n      const stepTime = (\n        key: \"attack\" | \"decay\" | \"release\",\n        direction: number\n      ) => {\n        const range = bounds[key]\n        const unit = timeTo01(env[key], range)\n        const delta = (fine ? 0.005 : 0.02) * direction\n        return timeFrom01(unit + delta, range)\n      }\n\n      let next: ADSR | null = null\n      switch (event.key) {\n        case \"ArrowRight\":\n          next = { ...env, [handle]: stepTime(handle, 1) }\n          break\n        case \"ArrowLeft\":\n          next = { ...env, [handle]: stepTime(handle, -1) }\n          break\n        case \"ArrowUp\":\n        case \"ArrowDown\": {\n          // Only the knee has a vertical dimension.\n          if (handle !== \"decay\") return\n          const span = bounds.sustain.max - bounds.sustain.min\n          const delta =\n            (fine ? 0.01 : 0.05) * span * (event.key === \"ArrowUp\" ? 1 : -1)\n          next = { ...env, sustain: env.sustain + delta }\n          break\n        }\n        case \"Home\":\n          next = { ...env, [handle]: bounds[handle].min }\n          break\n        case \"End\":\n          next = { ...env, [handle]: bounds[handle].max }\n          break\n        default:\n          return\n      }\n\n      event.preventDefault()\n      commit(next)\n      onChangeEnd?.(latest.current)\n    }\n\n  /** Double-click resets just the handle's own segment. */\n  const handleDoubleClick = (handle: EnvelopeHandle) => () => {\n    if (disabled) return\n    const reset = clean(defaultValue)\n    const next =\n      handle === \"decay\"\n        ? { ...latest.current, decay: reset.decay, sustain: reset.sustain }\n        : { ...latest.current, [handle]: reset[handle] }\n    commit(next)\n    onChangeEnd?.(next)\n    onChangeEnd?.(latest.current)\n  }\n\n  const handles: ReadonlyArray<{\n    id: EnvelopeHandle\n    x: number\n    y: number\n    label: string\n    valueText: string\n    now: number\n    min: number\n    max: number\n  }> = [\n    {\n      id: \"attack\",\n      x: xPeak,\n      y: yPeak,\n      label: \"Attack\",\n      valueText: formatTime(current.attack),\n      now: current.attack,\n      min: bounds.attack.min,\n      max: bounds.attack.max,\n    },\n    {\n      id: \"decay\",\n      x: xKnee,\n      y: ySustain,\n      label: \"Decay and sustain\",\n      valueText: `${formatTime(current.decay)}, sustain ${Math.round(sustain01 * 100)}%`,\n      now: current.decay,\n      min: bounds.decay.min,\n      max: bounds.decay.max,\n    },\n    {\n      id: \"release\",\n      x: xEnd,\n      y: yBase,\n      label: \"Release\",\n      valueText: formatTime(current.release),\n      now: current.release,\n      min: bounds.release.min,\n      max: bounds.release.max,\n    },\n  ]\n\n  return (\n    <div\n      className={className}\n      style={{ width, fontFamily: token.font, ...style }}\n      aria-label={\n        ariaLabel ??\n        (mode === \"ar\" ? \"Attack/release envelope\" : \"ADSR envelope\")\n      }\n      role=\"group\"\n    >\n      <svg\n        ref={surfaceRef}\n        viewBox={`0 0 ${VB_W} ${VB_H}`}\n        width=\"100%\"\n        height={height}\n        preserveAspectRatio=\"none\"\n        className=\"uikit-control\"\n        style={{\n          display: \"block\",\n          background: token.surface,\n          borderRadius: token.radius,\n          border: `1px solid ${token.border}`,\n          // The recessed-window look: a soft shadow tucked under the top edge.\n          // token.border is translucent, so this stays a hint, not a smudge.\n          boxShadow: inset ? `inset 0 1px 4px ${token.border}` : undefined,\n          touchAction: \"none\",\n          cursor: disabled ? \"not-allowed\" : \"default\",\n          opacity: disabled ? 0.5 : 1,\n        }}\n      >\n        <defs>\n          <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0%\" stopColor={token.accent} stopOpacity=\"0.42\" />\n            <stop offset=\"100%\" stopColor={token.accent} stopOpacity=\"0.04\" />\n          </linearGradient>\n        </defs>\n\n        {/* Level gridlines. preserveAspectRatio=\"none\" stretches the viewBox,\n            so strokes are set in viewBox units and read thin at any size. */}\n        {showGrid &&\n          [0.25, 0.5, 0.75].map((fraction) => (\n            <line\n              key={fraction}\n              x1={pad}\n              y1={pad + fraction * innerH}\n              x2={pad + innerW}\n              y2={pad + fraction * innerH}\n              stroke={token.border}\n              strokeWidth={0.4}\n              strokeDasharray=\"2 3\"\n              opacity={0.5}\n            />\n          ))}\n\n        {/* Baseline */}\n        <line\n          x1={pad}\n          y1={yBase}\n          x2={pad + innerW}\n          y2={yBase}\n          stroke={token.border}\n          strokeWidth={0.6}\n        />\n\n        <path d={fillPath} fill={`url(#${gradientId})`} />\n        <path\n          d={outline}\n          fill=\"none\"\n          stroke={token.accent}\n          strokeWidth={1.6}\n          strokeLinejoin=\"round\"\n          strokeLinecap=\"round\"\n          vectorEffect=\"non-scaling-stroke\"\n        />\n\n        {/* Hover anywhere along a segment to preview the node that controls it.\n            The wide transparent stroke makes the thin curve easy to target. */}\n        {(Object.keys(segmentPaths) as EnvelopeHandle[])\n          .filter((handle) => mode !== \"ar\" || handle !== \"decay\")\n          .map((handle) => (\n            <path\n              key={`segment-target-${handle}`}\n              d={segmentPaths[handle]}\n              fill=\"none\"\n              stroke=\"transparent\"\n              strokeWidth={SEGMENT_HOVER_TARGET_WIDTH_PX}\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n              pointerEvents={disabled ? \"none\" : \"stroke\"}\n              onPointerEnter={() => setHoveredHandle(handle)}\n              onPointerLeave={() =>\n                setHoveredHandle((current) =>\n                  current === handle ? null : current\n                )\n              }\n            />\n          ))}\n\n        {highlightedSegment ? (\n          <path\n            d={segmentPaths[highlightedSegment]}\n            fill=\"none\"\n            stroke={token.accent}\n            strokeWidth={HOVER_SEGMENT_WIDTH_PX}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            opacity={0.36}\n            vectorEffect=\"non-scaling-stroke\"\n            pointerEvents=\"none\"\n          />\n        ) : null}\n\n        {/* Sustain guide, so the held level is readable against the grid. */}\n        <line\n          x1={xKnee}\n          y1={ySustain}\n          x2={xHoldEnd}\n          y2={ySustain}\n          stroke={token.accent}\n          strokeWidth={0.8}\n          strokeDasharray=\"3 2\"\n          opacity={0.7}\n          pointerEvents=\"none\"\n        />\n\n        {handles\n          .filter((handle) => mode !== \"ar\" || handle.id !== \"decay\")\n          .map((handle) => (\n            <g\n              key={handle.id}\n              role=\"slider\"\n              tabIndex={disabled ? -1 : 0}\n              aria-label={handle.label}\n              aria-valuemin={handle.min}\n              aria-valuemax={handle.max}\n              aria-valuenow={handle.now}\n              aria-valuetext={handle.valueText}\n              aria-disabled={disabled || undefined}\n              onPointerDown={startDrag(handle.id)}\n              onPointerEnter={() => {\n                if (!disabled) setHoveredHandle(handle.id)\n              }}\n              onPointerLeave={() =>\n                setHoveredHandle((current) =>\n                  current === handle.id ? null : current\n                )\n              }\n              onKeyDown={handleKeyDown(handle.id)}\n              onDoubleClick={handleDoubleClick(handle.id)}\n              onFocus={() => setFocusedHandle(handle.id)}\n              onBlur={() =>\n                setFocusedHandle((current) =>\n                  current === handle.id ? null : current\n                )\n              }\n              style={{\n                cursor: disabled ? \"not-allowed\" : \"grab\",\n                outline: \"none\",\n              }}\n            >\n              {/* Round linecaps keep both the visible marker and its hit target\n                circular in screen pixels, even though the plot stretches. */}\n              <path\n                d={svgMarkerPath(handle.x, handle.y)}\n                fill=\"none\"\n                stroke=\"transparent\"\n                strokeWidth={SVG_MARKER_HIT_DIAMETER_PX}\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n                pointerEvents=\"stroke\"\n              />\n              <path\n                d={svgMarkerPath(handle.x, handle.y)}\n                fill=\"none\"\n                stroke={token.accent}\n                strokeWidth={SVG_MARKER_OUTLINE_DIAMETER_PX}\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n                pointerEvents=\"none\"\n              />\n              <path\n                d={svgMarkerPath(handle.x, handle.y)}\n                fill=\"none\"\n                stroke={\n                  activeHandle === handle.id ||\n                  hoveredHandle === handle.id ||\n                  focusedHandle === handle.id\n                    ? token.accent\n                    : token.cap\n                }\n                strokeWidth={SVG_MARKER_DIAMETER_PX}\n                strokeLinecap=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n                pointerEvents=\"none\"\n                style={{\n                  filter:\n                    activeHandle === handle.id ||\n                    hoveredHandle === handle.id ||\n                    focusedHandle === handle.id\n                      ? `drop-shadow(0 0 3px ${token.accent})`\n                      : undefined,\n                }}\n              />\n            </g>\n          ))}\n      </svg>\n\n      {showSegmentLabels && (\n        <div\n          aria-hidden\n          style={{\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            marginTop: 6,\n            fontFamily: token.fontMono,\n            fontSize: 10,\n            fontVariantNumeric: \"tabular-nums\",\n            color: token.textMuted,\n          }}\n        >\n          <span>A {formatTime(current.attack)}</span>\n          {mode === \"ar\" ? (\n            <span>Held</span>\n          ) : (\n            <>\n              <span>D {formatTime(current.decay)}</span>\n              <span>S {Math.round(sustain01 * 100)}%</span>\n            </>\n          )}\n          <span>R {formatTime(current.release)}</span>\n        </div>\n      )}\n    </div>\n  )\n}\n\n/** Re-exported so composing components can share the clamping contract. */\nexport function sanitizeEnvelope(\n  envelope: ADSR,\n  ranges: Required<EnvelopeRanges> = DEFAULT_ENVELOPE_RANGES\n): ADSR {\n  return {\n    attack: sanitize(envelope.attack, ranges.attack.min, ranges.attack.max, 0),\n    decay: sanitize(envelope.decay, ranges.decay.min, ranges.decay.max, 0),\n    sustain: sanitize(\n      envelope.sustain,\n      ranges.sustain.min,\n      ranges.sustain.max,\n      0\n    ),\n    release: sanitize(\n      envelope.release,\n      ranges.release.min,\n      ranges.release.max,\n      0\n    ),\n  }\n}\n",
      "type": "registry:component",
      "target": "@ui/envelope-display.tsx"
    }
  ]
}
