{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-envelope-overlay",
  "type": "registry:ui",
  "title": "Envelope Overlay",
  "description": "An editable AHDSR amplitude envelope on the SampleView's seconds axis (or any positioned box). Attack, hold, decay-and-sustain and release nodes drag and step as sliders; the sustain plateau drags vertically. Every value goes through the stage descriptor's taper, range and step exactly like the knob path (knob-core curves, pinned equal to audio-contracts normalize/denormalize), and arrow keys are the knob's keys. Segments past the take end are drawn dimmed; note-off is drawn at a host gate. Pure geometry exported and tested.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-envelope-display.json"
  ],
  "files": [
    {
      "path": "components/ui/envelope-overlay.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/envelope-overlay\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 * EnvelopeOverlay — an editable AHDSR amplitude envelope drawn on the same\n * seconds axis as the take it shapes.\n *\n * Put it in `SampleView`'s `overlay` slot and it reads the view's axis from\n * context; outside a view, pass `axisSeconds`/`durationSeconds` and place it\n * in a positioned box. Attack, hold, decay (with sustain on its vertical\n * axis) and release are draggable nodes; the sustain plateau drags up and\n * down. Every node is a focusable `slider` with the knob's arrow-key steps,\n * and every value goes through the stage descriptor's taper, range and step\n * exactly as the knob path does (`envelope-overlay-geometry`), so the overlay\n * and a `FaceParamKnob` bound to the same parameter never disagree.\n *\n * The part of the envelope past the end of the take (a release longer than\n * the audio, say) is drawn dimmed and dashed, over the view's dimmed\n * extension area. Note-off is drawn at `gateSeconds`, or at\n * `defaultEnvelopeGate`. The overlay latches the view's axis for the length\n * of a drag, so the view rescales only after release.\n *\n * Controlled: `value` in, `onChange` per move, `onChangeEnd` once per gesture\n * (or keypress) — the automation edit bracket.\n */\n\nimport { useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from \"react\"\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 { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\nimport {\n  defaultEnvelopeGate,\n  envelopeKeyEdit,\n  envelopePointerEdit,\n  envelopePoints,\n  envelopeStagesPastTakeEnd,\n  envelopeTimeline,\n  formatEnvelopeSeconds,\n  resolveEnvelopeStages,\n  sanitizeAhdsr,\n  splitEnvelopeAtTakeEnd,\n  type AHDSR,\n  type EnvelopeNode,\n  type EnvelopePoint,\n  type EnvelopeStage,\n  type EnvelopeStageDescriptor,\n} from \"maruaudio/ui/shared/envelope-overlay-geometry\"\nimport { useSampleViewGeometry } from \"maruaudio/ui/shared/sample-view-context\"\n\nexport interface EnvelopeOverlayProps {\n  readonly value: AHDSR\n  readonly onChange?: (next: AHDSR) => void\n  /** Once per drag or keypress: the automation edit bracket. */\n  readonly onChangeEnd?: (next: AHDSR) => void\n  /** Per-stage range, step and taper; omitted stages use the display defaults. */\n  readonly stages?: Partial<Record<EnvelopeStage, EnvelopeStageDescriptor>>\n  /** Where note-off is drawn, in seconds. Defaults to `defaultEnvelopeGate`. */\n  readonly gateSeconds?: number\n  /** Show the hold node. Defaults to whether `value.hold` is defined. */\n  readonly showHold?: boolean\n  /** Geometry outside a SampleView. Inside one, the view's axis wins. */\n  readonly axisSeconds?: number\n  readonly durationSeconds?: number\n  readonly disabled?: boolean\n  /** Accessible name of the group. Defaults to \"Amplitude envelope\". */\n  readonly label?: string\n  /** Stroke colour. Defaults to the kit accent. */\n  readonly color?: string\n}\n\nconst VB_W = 1000\nconst VB_H = 100\nconst PAD_Y = 8\nconst INNER_H = VB_H - PAD_Y * 2\n\nconst xOf = (seconds: number, axis: number) =>\n  axis > 0 ? (seconds / axis) * VB_W : 0\nconst yOf = (level: number) => PAD_Y + (1 - level) * INNER_H\n\nfunction polyline(points: readonly EnvelopePoint[], axis: number): string {\n  return points\n    .map((point, index) => `${index === 0 ? \"M\" : \"L\"} ${xOf(point.t, axis)} ${yOf(point.level)}`)\n    .join(\" \")\n}\n\nfunction area(points: readonly EnvelopePoint[], axis: number): string {\n  if (points.length < 2) return \"\"\n  const first = points[0]!\n  const last = points[points.length - 1]!\n  return `${polyline(points, axis)} L ${xOf(last.t, axis)} ${yOf(0)} L ${xOf(first.t, axis)} ${yOf(0)} Z`\n}\n\nconst NODE_LABELS: Record<Exclude<EnvelopeNode, \"sustain\">, string> = {\n  attack: \"Attack\",\n  hold: \"Hold\",\n  decay: \"Decay and sustain\",\n  release: \"Release\",\n}\n\nexport function EnvelopeOverlay({\n  value,\n  onChange,\n  onChangeEnd,\n  stages: stageInput,\n  gateSeconds,\n  showHold,\n  axisSeconds: axisInput,\n  durationSeconds: durationInput,\n  disabled: disabledInput = false,\n  label = \"Amplitude envelope\",\n  color = token.accent,\n}: EnvelopeOverlayProps) {\n  const view = useSampleViewGeometry()\n  const gradientId = `uikit-env-overlay-${useId().replace(/[^a-zA-Z0-9]/g, \"\")}`\n  const stages = useMemo(() => resolveEnvelopeStages(stageInput), [stageInput])\n  const duration = view?.durationSeconds ?? Math.max(0, durationInput ?? 0)\n  const axis = view?.axisSeconds ?? (Math.max(duration, axisInput ?? duration) || 1)\n  const disabled = disabledInput || view?.disabled === true\n  const withHold = showHold ?? value.hold !== undefined\n\n  const envelope = sanitizeAhdsr(withHold ? { hold: 0, ...value } : value, stages)\n  const gate = gateSeconds ?? defaultEnvelopeGate(envelope, duration)\n  const timeline = envelopeTimeline(envelope, gate)\n  const points = envelopePoints(timeline)\n  const { inside, past } = splitEnvelopeAtTakeEnd(points, duration)\n  const pastStages = envelopeStagesPastTakeEnd(timeline, duration)\n\n  const latest = useRef(envelope)\n  latest.current = envelope\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const dragNode = useRef<EnvelopeNode | null>(null)\n  const origin = useRef<{ envelope: AHDSR; gate: number; axis: number } | null>(null)\n  const [activeNode, setActiveNode] = useState<EnvelopeNode | null>(null)\n  const [focusedNode, setFocusedNode] = useState<EnvelopeNode | null>(null)\n\n  const emit = (next: AHDSR) => {\n    const clean = sanitizeAhdsr(next, stages)\n    const previous = latest.current\n    if (\n      clean.attack === previous.attack &&\n      clean.decay === previous.decay &&\n      clean.sustain === previous.sustain &&\n      clean.release === previous.release &&\n      clean.hold === previous.hold\n    )\n      return\n    latest.current = clean\n    onChange?.(clean)\n  }\n\n  const drag = useSurfacePointer(\n    (state) => {\n      const node = dragNode.current\n      const start = origin.current\n      const rect = svgRef.current?.getBoundingClientRect()\n      // Pressing a node only grabs it; the value moves with the pointer.\n      if (!node || !start || !rect || rect.width <= 0 || state.first) return\n      const seconds = ((state.clientX - rect.left) / rect.width) * start.axis\n      const y = ((state.clientY - rect.top) / rect.height) * VB_H\n      const level = 1 - (y - PAD_Y) / INNER_H\n      emit(envelopePointerEdit(node, start, seconds, level, stages))\n    },\n    {\n      disabled,\n      onEnd: () => {\n        dragNode.current = null\n        origin.current = null\n        setActiveNode(null)\n        view?.endGesture()\n        onChangeEnd?.(latest.current)\n      },\n    }\n  )\n\n  const startDrag = (node: EnvelopeNode) => (event: PointerEvent<Element>) => {\n    if (disabled || event.button !== 0) return\n    event.stopPropagation()\n    dragNode.current = node\n    origin.current = { envelope: latest.current, gate, axis }\n    setActiveNode(node)\n    view?.beginGesture()\n    drag.onPointerDown(event)\n  }\n\n  const onKeyDown = (node: EnvelopeNode) => (event: KeyboardEvent<SVGGElement>) => {\n    if (disabled) return\n    const next = envelopeKeyEdit(node, latest.current, event.code || event.key, event.shiftKey, stages)\n    if (!next) return\n    event.preventDefault()\n    emit(next)\n    onChangeEnd?.(latest.current)\n  }\n\n  const nodes: ReadonlyArray<{\n    id: Exclude<EnvelopeNode, \"sustain\">\n    t: number\n    level: number\n    now: number\n    descriptor: EnvelopeStageDescriptor\n    valueText: string\n  }> = [\n    {\n      id: \"attack\",\n      t: timeline.attackEnd,\n      level: 1,\n      now: envelope.attack,\n      descriptor: stages.attack,\n      valueText: formatEnvelopeSeconds(envelope.attack),\n    },\n    ...(withHold\n      ? [\n          {\n            id: \"hold\" as const,\n            t: timeline.holdEnd,\n            level: 1,\n            now: envelope.hold ?? 0,\n            descriptor: stages.hold,\n            valueText: formatEnvelopeSeconds(envelope.hold ?? 0),\n          },\n        ]\n      : []),\n    {\n      id: \"decay\",\n      t: timeline.decayEnd,\n      level: timeline.sustain,\n      now: envelope.decay,\n      descriptor: stages.decay,\n      valueText: `${formatEnvelopeSeconds(envelope.decay)}, sustain ${Math.round(envelope.sustain * 100)}%`,\n    },\n    {\n      id: \"release\",\n      t: timeline.releaseEnd,\n      level: 0,\n      now: envelope.release,\n      descriptor: stages.release,\n      valueText: formatEnvelopeSeconds(envelope.release),\n    },\n  ]\n\n  const nodeX = (t: number) => Math.max(0, Math.min(VB_W, xOf(t, axis)))\n\n  return (\n    <svg\n      ref={svgRef}\n      role=\"group\"\n      aria-label={label}\n      data-envelope-overlay=\"\"\n      data-envelope-past-end={past.length > 0 || undefined}\n      viewBox={`0 0 ${VB_W} ${VB_H}`}\n      preserveAspectRatio=\"none\"\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        width: \"100%\",\n        height: \"100%\",\n        overflow: \"visible\",\n        pointerEvents: \"none\",\n        touchAction: \"none\",\n      }}\n    >\n      <defs>\n        <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor={color} stopOpacity=\"0.32\" />\n          <stop offset=\"100%\" stopColor={color} stopOpacity=\"0.02\" />\n        </linearGradient>\n      </defs>\n      <path d={area(inside, axis)} fill={`url(#${gradientId})`} />\n      <path\n        d={polyline(inside, axis)}\n        fill=\"none\"\n        stroke={color}\n        strokeWidth={1.6}\n        strokeLinejoin=\"round\"\n        vectorEffect=\"non-scaling-stroke\"\n        data-envelope-segment=\"inside\"\n      />\n      {past.length > 1 ? (\n        <g opacity={0.42} data-envelope-segment=\"past\" data-past-take-end=\"\">\n          <path d={area(past, axis)} fill={color} fillOpacity={0.06} />\n          <path\n            d={polyline(past, axis)}\n            fill=\"none\"\n            stroke={color}\n            strokeWidth={1.4}\n            strokeDasharray=\"4 3\"\n            strokeLinejoin=\"round\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        </g>\n      ) : null}\n\n      {/* Note-off: where release begins. */}\n      <line\n        x1={xOf(timeline.gate, axis)}\n        x2={xOf(timeline.gate, axis)}\n        y1={PAD_Y}\n        y2={yOf(0)}\n        stroke={color}\n        strokeWidth={1}\n        strokeDasharray=\"2 3\"\n        opacity={timeline.gate > duration ? 0.25 : 0.45}\n        vectorEffect=\"non-scaling-stroke\"\n        data-envelope-gate=\"\"\n      />\n\n      {/* The sustain plateau drags vertically; the decay knee steps it by key. */}\n      <line\n        x1={xOf(timeline.decayEnd, axis)}\n        x2={xOf(timeline.gate, axis)}\n        y1={yOf(timeline.sustain)}\n        y2={yOf(timeline.sustain)}\n        stroke=\"transparent\"\n        strokeWidth={14}\n        vectorEffect=\"non-scaling-stroke\"\n        pointerEvents={disabled ? \"none\" : \"stroke\"}\n        style={{ cursor: disabled ? \"default\" : \"ns-resize\" }}\n        onPointerDown={startDrag(\"sustain\")}\n        data-envelope-sustain=\"\"\n      />\n\n      {nodes.map((node) => {\n        const x = nodeX(node.t)\n        const y = yOf(node.level)\n        const lit = activeNode === node.id || focusedNode === node.id\n        const pastEnd = pastStages.has(node.id)\n        return (\n          <g\n            key={node.id}\n            role=\"slider\"\n            tabIndex={disabled ? -1 : 0}\n            aria-label={NODE_LABELS[node.id]}\n            aria-valuemin={node.descriptor.min}\n            aria-valuemax={node.descriptor.max}\n            aria-valuenow={node.now}\n            aria-valuetext={node.valueText}\n            aria-disabled={disabled || undefined}\n            data-envelope-node={node.id}\n            data-past-take-end={pastEnd || undefined}\n            onPointerDown={startDrag(node.id)}\n            onKeyDown={onKeyDown(node.id)}\n            onFocus={() => setFocusedNode(node.id)}\n            onBlur={() => setFocusedNode((current) => (current === node.id ? null : current))}\n            opacity={pastEnd ? 0.6 : 1}\n            style={{\n              cursor: disabled ? \"not-allowed\" : node.id === \"decay\" ? \"move\" : \"ew-resize\",\n              outline: \"none\",\n              pointerEvents: disabled ? \"none\" : \"auto\",\n            }}\n          >\n            <path\n              d={svgMarkerPath(x, 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(x, y)}\n              fill=\"none\"\n              stroke={color}\n              strokeWidth={SVG_MARKER_OUTLINE_DIAMETER_PX}\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n              pointerEvents=\"none\"\n            />\n            <path\n              d={svgMarkerPath(x, y)}\n              fill=\"none\"\n              stroke={lit ? color : token.cap}\n              strokeWidth={SVG_MARKER_DIAMETER_PX}\n              strokeLinecap=\"round\"\n              vectorEffect=\"non-scaling-stroke\"\n              pointerEvents=\"none\"\n              style={{ filter: lit ? `drop-shadow(0 0 3px ${color})` : undefined }}\n            />\n          </g>\n        )\n      })}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/envelope-overlay.tsx"
    }
  ]
}
