{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-piano-roll",
  "type": "registry:ui",
  "title": "Piano Roll",
  "description": "The pitch × beat note grid a melodic module needs: time left to right in beats, pitch bottom to top like a keyboard, every note a block drawn at its own beat and pitch, and the whole loop rendered at once — no virtualization, the deliberate choice for a short loop shown whole. A two-tone grid keeps bars, beats and sixteenths countable while it plays; row stripes read the octaves; the keyboard gutter names the naturals. The playhead is a prop the host drives from its own clock, and when it is null the cursor and its bar.beat.sixteenth readout are simply not drawn — a stopped transport shows no line pinned at zero. Notes are selectable and lit with the accent; an experimental edit mode lets a selected note move or stretch with optional snapping, behind a flag until a sequencer consumes it. Every colour is a --uikit-* token, so an embedded roll follows a host that repaints per mode with no prop passing.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/piano-roll.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/piano-roll\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 * PianoRoll — the pitch × beat note grid every melodic module ends up needing.\n *\n * Time runs left→right in beats, pitch runs bottom→top like a keyboard, and\n * every note is drawn as a block at its own beat and pitch. The whole loop is\n * rendered at once — no virtualization, no scroll model. That is the deliberate\n * choice for the case this component is for: a short loop (a handful of bars,\n * dozens of notes) shown whole, the way the loop lab wants it. A DAW-scale roll\n * with thousands of notes across minutes of timeline is a different component\n * with a different budget; see the header note below on where the line is.\n *\n * ## Who owns what\n *\n * The HOST owns the clock. `playhead` is a prop — an absolute beat position, or\n * null — exactly as `step-grid` takes its step and `lfo-visualizer` takes\n * phase: nothing in here schedules, times or animates a transport, so the\n * cursor is wherever the audio actually is, not wherever a display timer\n * drifted to. When it is null the playhead and its bar.beat.sixteenth readout\n * are simply not drawn — a stopped transport shows no cursor pinned at zero and\n * no \"1.1.1\", which is the point: a parked playhead reads as a bug.\n *\n * The host owns the notes too. Selection is reported through `onSelectionChange`\n * and (experimentally) edits through `onNoteChange`; the component keeps no copy\n * of the note list. The only state it holds is view state — which notes are\n * selected, which drag is in flight — which belongs to the widget the way a\n * scroll position does.\n *\n * ## Grid, and why two tones\n *\n * Bar lines are drawn heavier than beat lines, and beat lines heavier than\n * sixteenth lines, so the measures stay countable at a glance while a loop\n * plays — the same two-tone snap/sub-snap convention a DAW's time grid uses.\n * `subdivisions` chooses how fine the faint grid goes (1 = beats only, 4 =\n * sixteenths). Row striping tints the black-key rows so the octaves read.\n *\n * ## Reading the notes\n *\n * A note's velocity is its opacity, so a soft note reads as soft. Accidentals —\n * notes on black keys — are drawn a touch slimmer and quieter than naturals, so\n * a run of sharps and flats recedes and the white-key notes carry the melody\n * rather than every note fighting for the same weight. And with\n * `highlightActiveNotes` (on by default), the note the playhead is currently\n * inside lights to full opacity with an accent halo — live feedback on what is\n * sounding. That override is what keeps an accidental legible at the one moment\n * it matters: while it plays it is the brightest block on screen, quiet only\n * before and after. It lights nothing while the playhead is null, so it needs no\n * separate \"only while playing\" switch.\n *\n * The same flag also lights the LEFT GUTTER: the key (or label) of every pitch\n * sounding right now takes the accent, so the keyboard animates the melody the\n * way a DAW's does. It is a well-worn convention precisely because it is\n * glanceable — the note on the roll answers \"when\", the lit key answers \"which\"\n * without reading a label — and it is cheap here: the sounding-pitch set is\n * computed once per frame and both the notes and the gutter read it, so the two\n * cues can never disagree.\n *\n * ## Selection and (experimental) editing\n *\n * With `onSelectionChange`, notes become selectable: click one to select it,\n * Shift-click to extend, click the background to clear. Selected notes are lit\n * with the accent so you can pick a phrase out of the roll.\n *\n * With `experimentalEdit` AND `onNoteChange`, a selected note can be dragged to\n * move it (pitch + time) or stretched from its right edge to change its length,\n * optionally snapped to `snapDivision`. This is behind a flag on purpose: there\n * is no sequencer module consuming edits yet, so it ships as a preview rather\n * than a promise. Read-only — no `onSelectionChange`, no `onNoteChange` — the\n * roll is a pure display, which is how a generated-loop view uses it.\n *\n * ## Theming\n *\n * Every colour comes from the kit's `--uikit-*` custom properties (see\n * `lib/control-theme`), with a `color` prop to override the accent for one\n * instance. That is what lets an embedded roll follow a host that repaints per\n * \"team\"/mode: point `--uikit-accent` at the mode's colour on any ancestor and\n * the roll — playhead, selection, note fill — recolours with no prop passing\n * and no re-render. SSR-safe: geometry is pure maths (see\n * `lib/piano-roll-geometry`) and all DOM access lives in handlers and effects.\n */\n\nimport {\n  memo,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport type { CSSProperties, PointerEvent as ReactPointerEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  createPianoRollLayout,\n  defaultNoteName,\n  formatBarsBeatsFromBeats,\n  isNaturalPitch,\n  movePianoRollNote,\n  resizePianoRollNote,\n  resolvePitchWindow,\n} from \"maruaudio/ui/shared/piano-roll-geometry\"\n\n/** How the left gutter labels each pitch row. See `PianoRollProps.gutterStyle`. */\nexport type PianoRollGutterStyle = \"names\" | \"numbers\" | \"keys\"\n\n/** The roll's colour scheme. See `PianoRollProps.scheme`. */\nexport type PianoRollScheme = \"dark\" | \"light\" | \"inherit\"\n\n/**\n * A light skin for the roll, applied by scoping the kit's `--uikit-*` variables\n * to the roll's own wrapper — so a light roll can sit inside a dark module\n * without repainting anything around it. Paper-white surface, ink grid and text,\n * and a keyboard that reads the way sheet music does: white naturals, ink\n * accidentals. The accent is left to inherit so the host's team colour still\n * lights the notes and playhead.\n */\nconst LIGHT_SCHEME_VARS: CSSProperties = {\n  \"--uikit-surface\": \"#f7f7f4\",\n  \"--uikit-text\": \"#1b1e27\",\n  \"--uikit-text-muted\": \"rgba(27, 30, 39, 0.55)\",\n  \"--uikit-border\": \"rgba(27, 30, 39, 0.28)\",\n} as CSSProperties\n\n/** Dark is the roll's built-in look, so forcing it just clears any inherited\n * light vars back to the kit defaults. */\nconst DARK_SCHEME_VARS: CSSProperties = {\n  \"--uikit-surface\": \"#14161d\",\n  \"--uikit-text\": \"#e8eaf0\",\n  \"--uikit-text-muted\": \"rgba(232, 234, 240, 0.55)\",\n  \"--uikit-border\": \"rgba(148, 163, 184, 0.35)\",\n} as CSSProperties\n\n/** A note to draw. The host's own note shape maps onto this. */\nexport interface PianoRollNote {\n  /** Stable identity across renders — selection and edits key on it. */\n  readonly id: string\n  /** MIDI note number, 0..127. */\n  readonly midi: number\n  /** Onset in beats from the roll's start. */\n  readonly startBeats: number\n  /** Length in beats. */\n  readonly durationBeats: number\n  /** 0..127. Drawn as the block's opacity, so soft notes read as soft. */\n  readonly velocity?: number\n  /**\n   * A semantic bucket the host classifies — chord tone, scale tone, outside.\n   * Only \"muted\" and \"accent\" are styled distinctly; everything else takes the\n   * default note colour. Kept small on purpose: a roll that paints five\n   * categories at once stops reading as a melody and starts reading as a legend.\n   */\n  readonly tone?: \"default\" | \"accent\" | \"muted\"\n  /** An override accent for this one note. */\n  readonly color?: string\n  /** Text drawn inside the block when it is wide enough. Defaults to the note\n   * name. Pass \"\" to suppress it. */\n  readonly label?: string\n}\n\nexport interface PianoRollPreviewProps {\n  notes: readonly Pick<PianoRollNote, \"midi\" | \"startBeats\" | \"durationBeats\">[]\n  /** Total time shown, in the same units as the notes. No tempo is inferred. */\n  durationBeats?: number\n  className?: string\n  style?: CSSProperties\n}\n\n/** Static thumbnail of the same pitch/time plane as PianoRoll. The enclosing\n * control owns its accessible description and action; no grid, clock or handlers\n * are mounted here. Seconds may be passed as beats when only ratios matter. */\nexport const PianoRollPreview = memo(function PianoRollPreview({\n  notes,\n  durationBeats = 0,\n  className,\n  style,\n}: PianoRollPreviewProps) {\n  const rectangles = useMemo(() => {\n    const window = resolvePitchWindow(\n      notes.map((note) => note.midi),\n      {\n        padding: 0,\n        minSpan: 8,\n      }\n    )\n    const duration = Math.max(\n      0.05,\n      durationBeats,\n      ...notes.map((note) => note.startBeats + note.durationBeats)\n    )\n    const layout = createPianoRollLayout({\n      ...window,\n      bars: 1,\n      beatsPerBar: duration,\n      width: 100,\n      rowHeight: 32 / (window.high - window.low + 1),\n      gutter: 0,\n    })\n    return notes.map((note) => {\n      const rect = layout.noteRect(\n        note.startBeats,\n        note.durationBeats,\n        note.midi,\n        0.8\n      )\n      const height = Math.max(1.2, Math.min(rect.height * 0.8, 4))\n      return { ...rect, y: rect.y + (rect.height - height) / 2, height }\n    })\n  }, [notes, durationBeats])\n\n  return (\n    <svg\n      viewBox=\"0 0 100 32\"\n      preserveAspectRatio=\"none\"\n      aria-hidden=\"true\"\n      className={className}\n      style={{ display: \"block\", width: \"100%\", height: \"100%\", ...style }}\n    >\n      {rectangles.map((rect, index) => (\n        <rect key={index} {...rect} fill=\"currentColor\" fillOpacity={0.55} />\n      ))}\n    </svg>\n  )\n})\n\n/**\n * A full-height column band behind the notes: a scale's in/out columns, a\n * selected region, a loop range. `strength` (0..1, default 1) scales its tint.\n */\nexport interface PianoRollBand {\n  readonly startBeats: number\n  readonly durationBeats: number\n  readonly strength?: number\n  /** Band colour. Defaults to the roll's accent. */\n  readonly color?: string\n}\n\nexport interface PianoRollProps {\n  /** The notes to draw. Everything absent is empty space. */\n  notes: readonly PianoRollNote[]\n  /** Column bands drawn under the grid and notes. */\n  bands?: readonly PianoRollBand[]\n  /**\n   * Notes drawn as dashed outlines behind `notes`: a \"before\" to compare the\n   * notes against (the phrase before a transpose, the note a swap replaced,\n   * the straight timing under a swing). Display only: never selectable, never\n   * lit by the playhead. The auto-cropped pitch window includes them. Omit for\n   * none, which draws exactly what the roll drew before.\n   */\n  ghostNotes?: readonly Pick<\n    PianoRollNote,\n    \"id\" | \"midi\" | \"startBeats\" | \"durationBeats\"\n  >[]\n  /** Bars in the roll. */\n  bars: number\n  /** Quarter-note beats per bar. Defaults to 4. */\n  beatsPerBar?: number\n\n  /**\n   * Pin the lowest MIDI note drawn. Omit to let the roll crop to the notes: the\n   * window is derived from the sounding pitches (see `pitchPadding`,\n   * `minPitchSpan`, `centerPitch`), not from any external register — so it\n   * follows a transpose or octave shift instead of framing empty staff. Pass a\n   * value only when you want a fixed lower edge regardless of the music.\n   */\n  low?: number\n  /** Pin the highest MIDI note drawn. See `low`. */\n  high?: number\n  /**\n   * Semitone margin kept above the top note and below the bottom note when the\n   * roll crops to the notes, so the outermost notes aren't flush against the\n   * frame. Defaults to 2. Ignored for an edge that `low`/`high` pins.\n   */\n  pitchPadding?: number\n  /**\n   * Fewest pitch rows the auto-cropped window may span. A short passage — even\n   * one repeated note — is widened up to this many semitones so the roll never\n   * collapses to a sliver; the extra rows sit around the phrase when\n   * `centerPitch` is on. Defaults to 1 (crop as tight as the padding allows).\n   * A generated-loop view typically passes ~20 so a two-note phrase still reads\n   * as music, not a dot. Clamped to the 128-row MIDI range.\n   */\n  minPitchSpan?: number\n  /**\n   * Center the phrase in the window when `minPitchSpan` (or a single pinned\n   * edge) leaves extra room, rather than pinning it to the bottom. Defaults to\n   * true. No effect when both `low` and `high` are pinned.\n   */\n  centerPitch?: boolean\n\n  /** Absolute beat the host's clock is on, or null for no cursor. A null\n   * playhead draws nothing — no line, no readout. */\n  playhead?: number | null\n\n  /**\n   * Light up the note the playhead is currently inside, so the roll gives live\n   * feedback on which note is sounding. A note is active while the playhead beat\n   * falls in `[startBeats, startBeats + durationBeats)`; an active note is drawn\n   * at full opacity with a lit accent halo, whatever its tone or velocity would\n   * otherwise dim it to, AND the left gutter lights the key/label of every\n   * sounding pitch, so the keyboard animates the melody. Defaults to true. Has\n   * no effect when `playhead` is null (a stopped transport lights nothing) — so\n   * it needs no separate \"only while playing\" flag.\n   */\n  highlightActiveNotes?: boolean\n\n  /**\n   * Faint grid lines per beat: 1 draws beat lines only, 4 draws sixteenths.\n   * Bar lines are always drawn and always heaviest. Defaults to 4.\n   */\n  subdivisions?: number\n  /** Draw the keyboard gutter down the left edge. Defaults to true. */\n  showGutter?: boolean\n  /**\n   * What the left gutter shows for each row:\n   *   - `\"names\"`  — the note name on every natural row (C4, D4, …). Default.\n   *   - `\"numbers\"`— the raw MIDI number on every natural row (60, 62, …).\n   *   - `\"keys\"`   — a real piano keyboard: a white/black key per row, with the\n   *                  octave C labelled. Best when there is vertical room — a few\n   *                  octaves with tall rows — so the keys read as keys.\n   * `keys` widens the gutter automatically to fit the keyboard unless an\n   * explicit `gutterWidth` is given.\n   */\n  gutterStyle?: PianoRollGutterStyle\n  /** Force the gutter width in layout px. Omit to let the style pick a sensible\n   * width (wider for `keys`). Ignored when `showGutter` is false. */\n  gutterWidth?: number\n\n  /**\n   * Colour scheme:\n   *   - `\"inherit\"` — take the ambient `--uikit-*` variables (dark by default).\n   *     The default, so nothing changes for existing callers.\n   *   - `\"light\"`   — a paper-white roll: light surface, ink grid/text, a\n   *     white-naturals / ink-accidentals keyboard. Scoped to the roll, so it can\n   *     sit inside a dark module untouched.\n   *   - `\"dark\"`    — force the built-in dark look regardless of ambient vars.\n   * The note/playhead accent is left to inherit in every scheme, so the host's\n   * team colour still lights the notes.\n   */\n  scheme?: PianoRollScheme\n  /** Tint the black-key rows so octaves read. Defaults to true. */\n  showRowStripes?: boolean\n  /** Show the bar.beat.sixteenth transport readout while playing. Defaults to\n   * true. Never shown when the playhead is null. */\n  showPosition?: boolean\n\n  /** Map a MIDI note to a display name. Defaults to C4=60 naming. */\n  noteName?: (midi: number) => string\n\n  /**\n   * Selection reporting. Present = notes are selectable (click to select,\n   * Shift-click to extend, background to clear). Absent = read-only, no\n   * selection affordance at all.\n   */\n  onSelectionChange?: (selectedIds: string[]) => void\n  /** Controlled selection. Omit to let the roll keep its own. */\n  selectedIds?: readonly string[]\n  /** Fired when a note is activated (click without a modifier, Enter/Space on\n   * a focused note) — the hook to audition it. */\n  onNoteActivate?: (note: PianoRollNote) => void\n\n  /**\n   * EXPERIMENTAL. With `onNoteChange` also given, a selected note can be\n   * dragged to move (pitch + time) or stretched from its right edge. Off by\n   * default: no sequencer module consumes edits yet, so it ships as a preview.\n   */\n  experimentalEdit?: boolean\n  /** Fired on an experimental edit with the note's next geometry. */\n  onNoteChange?: (\n    id: string,\n    next: { midi: number; startBeats: number; durationBeats: number }\n  ) => void\n  /** Snap grid for edits, in beats — 0.25 is a sixteenth, 0 is free. Default\n   * 0.25. */\n  snapDivision?: number\n\n  /** Overall roll height in pixels. Rows share it evenly. Defaults to 200. */\n  height?: number\n  width?: number | string\n  /** Override the accent for this instance. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Faintest a note block is drawn, however low its velocity — a mark must stay\n * a mark, the same floor `step-grid` and `delay-tap-display` keep. */\nconst MIN_NOTE_OPACITY = 0.4\n\n/**\n * Accidentals (black-key notes) are drawn quieter than naturals: a shade closer\n * to the grid and a slightly slimmer block, so a run of accidentals recedes and\n * the white-key notes carry the melody. The block is inset by this fraction of\n * the row height, top and bottom, and its opacity is scaled by\n * `ACCIDENTAL_OPACITY` — the same \"less contrasty, slightly smaller\" treatment a\n * printed roll gives its sharps and flats.\n */\nconst ACCIDENTAL_INSET = 0.16\nconst ACCIDENTAL_OPACITY = 0.66\n\n/** Layout units the right-edge stretch handle spans, in beats-space px. */\nconst RESIZE_HANDLE_PX = 6\n\ninterface DragState {\n  id: string\n  mode: \"move\" | \"resize\"\n  startClientX: number\n  startClientY: number\n  origin: { midi: number; startBeats: number; durationBeats: number }\n}\n\nexport function PianoRoll({\n  notes,\n  bands,\n  ghostNotes,\n  bars,\n  beatsPerBar = 4,\n  low,\n  high,\n  pitchPadding = 2,\n  minPitchSpan = 1,\n  centerPitch = true,\n  playhead = null,\n  highlightActiveNotes = true,\n  subdivisions = 4,\n  showGutter = true,\n  gutterStyle = \"names\",\n  gutterWidth,\n  scheme = \"inherit\",\n  showRowStripes = true,\n  showPosition = true,\n  noteName = defaultNoteName,\n  onSelectionChange,\n  selectedIds,\n  onNoteActivate,\n  experimentalEdit = false,\n  onNoteChange,\n  snapDivision = 0.25,\n  height = 200,\n  width = \"100%\",\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: PianoRollProps) {\n  const accent = color ?? token.accent\n  const selectable = typeof onSelectionChange === \"function\"\n  const editable =\n    experimentalEdit && typeof onNoteChange === \"function\" && selectable\n\n  // Pitch window: crop to the sounding notes (padded, floored to a minimum\n  // span, phrase centred) so the roll follows the music and a transpose moves\n  // the frame with it. An explicit low/high pins that edge to a fixed frame.\n  // The policy lives in the geometry lib so every roll inherits it identically.\n  const pitches = useMemo(\n    () => [...notes, ...(ghostNotes ?? [])].map((note) => note.midi),\n    [notes, ghostNotes]\n  )\n  const { low: resolvedLow, high: resolvedHigh } = useMemo(\n    () =>\n      resolvePitchWindow(pitches, {\n        padding: pitchPadding,\n        minSpan: minPitchSpan,\n        center: centerPitch,\n        low,\n        high,\n      }),\n    [pitches, pitchPadding, minPitchSpan, centerPitch, low, high]\n  )\n\n  // Gutter width: an explicit prop wins; otherwise the keyboard needs more room\n  // than a text label, so `keys` gets a wider default. Zero when hidden.\n  const gutter = !showGutter\n    ? 0\n    : gutterWidth !== undefined\n      ? Math.max(0, gutterWidth)\n      : gutterStyle === \"keys\"\n        ? 40\n        : undefined\n  const rowHeight = useMemo(() => {\n    const span = Math.max(1, resolvedHigh - resolvedLow + 1)\n    // Fit the requested pixel height, but keep rows readable within bounds.\n    return Math.max(6, Math.min(16, (height - 22) / span))\n  }, [resolvedHigh, resolvedLow, height])\n\n  // Measured render width, so the layout's coordinate box equals the pixels the\n  // SVG actually occupies. The SVG scales its viewBox to fill the container with\n  // `preserveAspectRatio=\"none\"` — which stretches the *horizontal* axis when the\n  // box width and the pixel width disagree, squishing note labels and pitch names\n  // along with the grid. Feeding the true width back in makes that horizontal\n  // scale exactly 1×, so text keeps its natural glyph geometry while the grid and\n  // notes still fill the width (they are simply recomputed at the real width).\n  // Falls back to the default box before the first measurement / during SSR.\n  // (The ResizeObserver that keeps this current lives just below, on svgRef.)\n  const [measuredWidth, setMeasuredWidth] = useState<number | null>(null)\n\n  const layout = useMemo(\n    () =>\n      createPianoRollLayout({\n        low: resolvedLow,\n        high: resolvedHigh,\n        bars,\n        beatsPerBar,\n        rowHeight,\n        // Map the coordinate box onto the real pixel width so text is never\n        // horizontally stretched (see the measuredWidth note above).\n        ...(measuredWidth !== null ? { width: measuredWidth } : {}),\n        ...(gutter !== undefined ? { gutter } : {}),\n      }),\n    [\n      resolvedLow,\n      resolvedHigh,\n      bars,\n      beatsPerBar,\n      rowHeight,\n      gutter,\n      measuredWidth,\n    ]\n  )\n\n  const [internalSelection, setInternalSelection] = useState<readonly string[]>(\n    []\n  )\n  const selection = selectedIds ?? internalSelection\n  const selectionSet = useMemo(() => new Set(selection), [selection])\n  const setSelection = (next: string[]) => {\n    setInternalSelection(next)\n    onSelectionChange?.(next)\n  }\n\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const dragRef = useRef<DragState | null>(null)\n\n  const useIsomorphicLayoutEffect =\n    typeof window === \"undefined\" ? useEffect : useLayoutEffect\n  useIsomorphicLayoutEffect(() => {\n    const el = svgRef.current\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const measure = () => {\n      const next = Math.round(el.getBoundingClientRect().width)\n      if (next > 0) setMeasuredWidth((prev) => (prev === next ? prev : next))\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  /* ------------------------------------------------------------- geometry */\n\n  const beatLines = useMemo(() => {\n    const perBeatDivisions = Math.max(1, Math.round(subdivisions))\n    const lines: { beat: number; kind: \"bar\" | \"beat\" | \"sub\" }[] = []\n    const total = layout.totalBeats * perBeatDivisions\n    for (let i = 0; i <= total; i += 1) {\n      const beat = i / perBeatDivisions\n      const kind =\n        beat % layout.beatsPerBar === 0\n          ? \"bar\"\n          : Number.isInteger(beat)\n            ? \"beat\"\n            : \"sub\"\n      lines.push({ beat, kind })\n    }\n    return lines\n  }, [subdivisions, layout])\n\n  const bars0 = Array.from({ length: layout.bars }, (_, i) => i)\n\n  /* ------------------------------------------------------------- gestures */\n\n  const clientToLayout = (clientX: number, clientY: number) => {\n    const rect = svgRef.current?.getBoundingClientRect()\n    if (!rect || rect.width === 0) return null\n    return {\n      x: ((clientX - rect.left) / rect.width) * layout.width,\n      y: ((clientY - rect.top) / rect.height) * layout.height,\n    }\n  }\n\n  const selectNote = (note: PianoRollNote, additive: boolean) => {\n    if (!selectable) return\n    if (additive) {\n      const next = selectionSet.has(note.id)\n        ? selection.filter((id) => id !== note.id)\n        : [...selection, note.id]\n      setSelection(next)\n    } else {\n      setSelection([note.id])\n    }\n  }\n\n  const onNotePointerDown =\n    (note: PianoRollNote, atRightEdge: boolean) =>\n    (event: ReactPointerEvent<SVGGElement>) => {\n      if (!selectable) return\n      const additive = event.shiftKey || event.metaKey\n      if (!selectionSet.has(note.id)) selectNote(note, additive)\n\n      if (editable) {\n        dragRef.current = {\n          id: note.id,\n          mode: atRightEdge ? \"resize\" : \"move\",\n          startClientX: event.clientX,\n          startClientY: event.clientY,\n          origin: {\n            midi: note.midi,\n            startBeats: note.startBeats,\n            durationBeats: note.durationBeats,\n          },\n        }\n        ;(event.target as Element).setPointerCapture?.(event.pointerId)\n        event.preventDefault()\n      }\n    }\n\n  const onSurfacePointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {\n    const drag = dragRef.current\n    if (!drag || !editable) return\n    const from = clientToLayout(drag.startClientX, drag.startClientY)\n    const to = clientToLayout(event.clientX, event.clientY)\n    if (!from || !to) return\n\n    const deltaBeats = layout.beatForX(to.x) - layout.beatForX(from.x)\n    const deltaRows = Math.round((to.y - from.y) / layout.rowHeight)\n\n    if (drag.mode === \"resize\") {\n      onNoteChange?.(\n        drag.id,\n        resizePianoRollNote(drag.origin, deltaBeats, snapDivision)\n      )\n    } else {\n      onNoteChange?.(\n        drag.id,\n        movePianoRollNote(\n          drag.origin,\n          deltaBeats,\n          deltaRows,\n          layout,\n          snapDivision\n        )\n      )\n    }\n    event.preventDefault()\n  }\n\n  const endDrag = () => {\n    dragRef.current = null\n  }\n\n  const onBackgroundPointerDown = (\n    event: ReactPointerEvent<SVGRectElement>\n  ) => {\n    if (!selectable) return\n    // A bare click on empty grid clears the selection; a modifier keeps it,\n    // so a Shift-marquee gesture (future) has room to grow.\n    if (!event.shiftKey && !event.metaKey) setSelection([])\n  }\n\n  /* -------------------------------------------------------------- drawing */\n\n  const svgHeight = layout.height\n  const positionText =\n    playhead !== null && Number.isFinite(playhead)\n      ? formatBarsBeatsFromBeats(playhead, layout.beatsPerBar)\n      : null\n\n  const playheadX =\n    playhead !== null &&\n    Number.isFinite(playhead) &&\n    playhead >= 0 &&\n    playhead <= layout.totalBeats\n      ? layout.xForBeat(playhead)\n      : null\n\n  // The MIDI pitches sounding right now — a note is sounding while the playhead\n  // is in its span. The gutter uses this to light the key/label of each pitch\n  // currently playing, the same \"which note is this\" cue a DAW keyboard gives.\n  // Empty whenever the highlight is off or the transport is stopped, so a\n  // parked roll lights no keys.\n  const activePitches = useMemo(() => {\n    const set = new Set<number>()\n    if (\n      !highlightActiveNotes ||\n      playhead === null ||\n      !Number.isFinite(playhead)\n    )\n      return set\n    for (const note of notes) {\n      if (\n        (playhead as number) >= note.startBeats &&\n        (playhead as number) < note.startBeats + note.durationBeats\n      )\n        set.add(Math.round(note.midi))\n    }\n    return set\n  }, [notes, playhead, highlightActiveNotes])\n\n  const label =\n    ariaLabel ??\n    `Piano roll: ${notes.length} note${notes.length === 1 ? \"\" : \"s\"} across ${layout.bars} bar${\n      layout.bars === 1 ? \"\" : \"s\"\n    }${selectable ? \"\" : \" (read-only)\"}`\n\n  // Scheme is applied by scoping the kit variables to this roll's wrapper, so a\n  // light roll re-skins only itself — no repaint, no leak to the host module.\n  const schemeVars =\n    scheme === \"light\"\n      ? LIGHT_SCHEME_VARS\n      : scheme === \"dark\"\n        ? DARK_SCHEME_VARS\n        : undefined\n\n  const lineColor = (kind: \"bar\" | \"beat\" | \"sub\") =>\n    kind === \"bar\" ? token.border : token.border\n  const lineOpacity = (kind: \"bar\" | \"beat\" | \"sub\") =>\n    kind === \"bar\" ? 0.9 : kind === \"beat\" ? 0.5 : 0.22\n  const lineWidth = (kind: \"bar\" | \"beat\" | \"sub\") =>\n    kind === \"bar\" ? 1.4 : kind === \"beat\" ? 0.8 : 0.5\n\n  return (\n    <div\n      className={className}\n      style={{\n        ...schemeVars,\n        width,\n        fontFamily: token.font,\n        position: \"relative\",\n        ...style,\n      }}\n      role={selectable ? \"group\" : \"img\"}\n      aria-label={label}\n    >\n      {/* The transport readout: only while a playhead exists (i.e. playing),\n          never a stopped \"1.1.1\" pinned at zero. */}\n      {showPosition && positionText ? (\n        <div\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            top: 4,\n            right: 6,\n            zIndex: 2,\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 6,\n            pointerEvents: \"none\",\n          }}\n        >\n          <span\n            style={{\n              fontFamily: token.fontMono,\n              fontSize: 11,\n              fontVariantNumeric: \"tabular-nums\",\n              color: token.text,\n              background: token.surface,\n              border: `1px solid ${token.border}`,\n              borderRadius: 4,\n              padding: \"1px 5px\",\n            }}\n          >\n            {positionText}\n          </span>\n        </div>\n      ) : null}\n\n      <svg\n        ref={svgRef}\n        role=\"img\"\n        aria-label={label}\n        viewBox={`0 0 ${layout.width} ${svgHeight}`}\n        preserveAspectRatio=\"none\"\n        onPointerMove={editable ? onSurfacePointerMove : undefined}\n        onPointerUp={editable ? endDrag : undefined}\n        onPointerCancel={editable ? endDrag : undefined}\n        style={{\n          display: \"block\",\n          width: \"100%\",\n          height: svgHeight,\n          background: token.surface,\n          border: `1px solid ${token.border}`,\n          borderRadius: token.radius,\n          touchAction: editable ? \"none\" : undefined,\n        }}\n      >\n        {/* Background click target — clears selection. Behind everything. */}\n        <rect\n          x={0}\n          y={0}\n          width={layout.width}\n          height={svgHeight}\n          fill=\"transparent\"\n          onPointerDown={onBackgroundPointerDown}\n        />\n\n        {/* Row stripes: tint the black-key rows so octaves read. */}\n        {showRowStripes\n          ? Array.from({ length: layout.pitchCount }, (_, row) => {\n              const midi = layout.high - row\n              if (isNaturalPitch(midi)) return null\n              const rect = layout.rowRect(midi)\n              return (\n                <rect\n                  key={`stripe-${midi}`}\n                  x={rect.x}\n                  y={rect.y}\n                  width={rect.width}\n                  height={rect.height}\n                  fill={token.text}\n                  opacity={0.05}\n                />\n              )\n            })\n          : null}\n\n        {/* Bar shading — every other bar a hair darker, for countability. */}\n        {bars0.map((bar) =>\n          bar % 2 === 1 ? (\n            <rect\n              key={`bar-shade-${bar}`}\n              x={layout.xForBeat(bar * layout.beatsPerBar)}\n              y={0}\n              width={\n                layout.xForBeat((bar + 1) * layout.beatsPerBar) -\n                layout.xForBeat(bar * layout.beatsPerBar)\n              }\n              height={svgHeight}\n              fill={token.text}\n              opacity={0.03}\n            />\n          ) : null\n        )}\n\n        {/* Column bands: host-defined regions, under the grid and notes. */}\n        {(bands ?? []).map((band, i) => {\n          const x = layout.xForBeat(band.startBeats)\n          const width =\n            layout.xForBeat(band.startBeats + band.durationBeats) - x\n          return width > 0 ? (\n            <rect\n              key={`band-${i}`}\n              data-piano-roll-band={i}\n              x={x}\n              y={0}\n              width={width}\n              height={svgHeight}\n              fill={band.color ?? accent}\n              opacity={0.16 * Math.max(0, Math.min(1, band.strength ?? 1))}\n              pointerEvents=\"none\"\n            />\n          ) : null\n        })}\n\n        {/* Vertical grid: bars heavy, beats medium, sixteenths faint. */}\n        {beatLines.map((line, i) => (\n          <line\n            key={`grid-${i}`}\n            x1={layout.xForBeat(line.beat)}\n            x2={layout.xForBeat(line.beat)}\n            y1={0}\n            y2={svgHeight}\n            stroke={lineColor(line.kind)}\n            strokeWidth={lineWidth(line.kind)}\n            opacity={lineOpacity(line.kind)}\n          />\n        ))}\n\n        {/* Horizontal row separators — faint, so the pitch grid is legible\n            without competing with the beat grid. */}\n        {Array.from({ length: layout.pitchCount + 1 }, (_, row) => (\n          <line\n            key={`row-${row}`}\n            x1={layout.gutter}\n            x2={layout.width}\n            y1={row * layout.rowHeight}\n            y2={row * layout.rowHeight}\n            stroke={token.border}\n            strokeWidth={0.5}\n            opacity={0.18}\n          />\n        ))}\n\n        {/* Keyboard gutter. Three styles, chosen by `gutterStyle`:\n            - keys    : a real piano keyboard, one key per row.\n            - names   : the note name on every natural row (C4, D4, …).\n            - numbers : the raw MIDI number on every natural row. */}\n        {showGutter && gutterStyle === \"keys\"\n          ? // A key per row. Whites fill the gutter; blacks are a shorter, darker\n            // key inset from the right so they read as sitting over the whites,\n            // the way a keyboard looks side-on. Only the C of each octave is\n            // labelled, so the keyboard stays legible without crowding.\n            Array.from({ length: layout.pitchCount }, (_, row) => {\n              const midi = layout.high - row\n              const rect = layout.rowRect(midi)\n              const natural = isNaturalPitch(midi)\n              const isC = ((midi % 12) + 12) % 12 === 0\n              const keyWidth = natural ? layout.gutter : layout.gutter * 0.62\n              // A sounding pitch lights its key: the accent fills it and the\n              // whole row goes opaque, so the keyboard animates the melody the\n              // same way a DAW's does — a glance at the gutter says which note.\n              const lit = activePitches.has(midi)\n              return (\n                <g key={`key-${midi}`}>\n                  <rect\n                    x={0}\n                    y={rect.y + 0.5}\n                    width={keyWidth}\n                    height={Math.max(1, layout.rowHeight - 1)}\n                    rx={1}\n                    fill={lit ? accent : natural ? token.surface : token.text}\n                    stroke={lit ? accent : token.border}\n                    strokeWidth={0.5}\n                    opacity={lit ? 1 : natural ? 0.9 : 0.82}\n                  />\n                  {isC && layout.rowHeight >= 8 ? (\n                    <text\n                      x={layout.gutter - 4}\n                      y={rect.y + rect.height - 2}\n                      textAnchor=\"end\"\n                      fontFamily={token.fontMono}\n                      fontSize={Math.min(9, layout.rowHeight - 2)}\n                      fill={token.textMuted}\n                      style={{ pointerEvents: \"none\" }}\n                    >\n                      {noteName(midi)}\n                    </text>\n                  ) : null}\n                </g>\n              )\n            })\n          : null}\n\n        {/* In name/number mode, an accent tick in the gutter lights every\n            sounding row — including accidentals, which carry no printed label —\n            so the left edge still animates the melody without a full keyboard. */}\n        {showGutter && gutterStyle !== \"keys\" && activePitches.size > 0\n          ? Array.from(activePitches)\n              .filter((midi) => midi >= layout.low && midi <= layout.high)\n              .map((midi) => {\n                const rect = layout.rowRect(midi)\n                return (\n                  <rect\n                    key={`gutter-lit-${midi}`}\n                    x={0}\n                    y={rect.y + 0.5}\n                    width={layout.gutter - 1}\n                    height={Math.max(1, layout.rowHeight - 1)}\n                    rx={1}\n                    fill={accent}\n                    opacity={0.28}\n                  />\n                )\n              })\n          : null}\n\n        {showGutter && gutterStyle !== \"keys\"\n          ? Array.from({ length: layout.pitchCount }, (_, row) => {\n              const midi = layout.high - row\n              if (!isNaturalPitch(midi)) return null\n              const rect = layout.rowRect(midi)\n              const lit = activePitches.has(midi)\n              return (\n                <text\n                  key={`label-${midi}`}\n                  x={layout.gutter - 4}\n                  y={rect.y + rect.height - 2}\n                  textAnchor=\"end\"\n                  fontFamily={token.fontMono}\n                  fontSize={Math.min(9, layout.rowHeight - 1)}\n                  fill={lit ? token.text : token.textMuted}\n                  fontWeight={lit ? 700 : undefined}\n                >\n                  {gutterStyle === \"numbers\" ? midi : noteName(midi)}\n                </text>\n              )\n            })\n          : null}\n\n        {/* Gutter divider. */}\n        {showGutter ? (\n          <line\n            x1={layout.gutter}\n            x2={layout.gutter}\n            y1={0}\n            y2={svgHeight}\n            stroke={token.border}\n            strokeWidth={1}\n            opacity={0.6}\n          />\n        ) : null}\n\n        {/* Ghosts: the \"before\", dashed and quiet, under the notes. */}\n        {ghostNotes?.map((note) => {\n          const rect = layout.noteRect(\n            note.startBeats,\n            note.durationBeats,\n            note.midi\n          )\n          return (\n            <rect\n              key={`ghost-${note.id}`}\n              aria-hidden\n              x={rect.x + 0.75}\n              y={rect.y + 0.75}\n              width={Math.max(2, rect.width - 1.5)}\n              height={Math.max(3, rect.height - 1.5)}\n              rx={2}\n              fill=\"none\"\n              stroke={token.textMuted}\n              strokeWidth={1.25}\n              strokeDasharray=\"3 2.5\"\n              pointerEvents=\"none\"\n            />\n          )\n        })}\n\n        {/* The notes. */}\n        {notes.map((note) => {\n          const rect = layout.noteRect(\n            note.startBeats,\n            note.durationBeats,\n            note.midi\n          )\n          const selected = selectionSet.has(note.id)\n          const velocity = Math.min(\n            1,\n            Math.max(0, (note.velocity ?? 100) / 127)\n          )\n          const noteAccent = note.color ?? accent\n\n          // Active = the playhead is currently inside this note's span. The\n          // upper edge is exclusive so a note that just ended does not stay lit\n          // into the next one's onset.\n          const active =\n            highlightActiveNotes &&\n            playhead !== null &&\n            Number.isFinite(playhead) &&\n            (playhead as number) >= note.startBeats &&\n            (playhead as number) < note.startBeats + note.durationBeats\n\n          // Accidentals (black keys) read quieter than naturals: a slimmer\n          // block and a lower opacity, so the sharps and flats recede and the\n          // white-key notes carry the line. An active note overrides this — the\n          // thing sounding now is never the quiet one.\n          const accidental = !isNaturalPitch(note.midi)\n\n          const fill =\n            note.tone === \"muted\"\n              ? token.textMuted\n              : note.tone === \"accent\"\n                ? noteAccent\n                : selected\n                  ? noteAccent\n                  : token.accent2\n\n          const velocityOpacity =\n            MIN_NOTE_OPACITY + (1 - MIN_NOTE_OPACITY) * velocity\n          const opacity = active\n            ? 1\n            : accidental\n              ? velocityOpacity * ACCIDENTAL_OPACITY\n              : velocityOpacity\n\n          // Slim an accidental in from the row band, top and bottom; a natural\n          // fills its row (less the usual hairline). An active note is never\n          // slimmed — it should read as the fullest block on screen.\n          const inset =\n            accidental && !active ? rect.height * ACCIDENTAL_INSET : 0.5\n          const blockY = rect.y + inset\n          const blockHeight = Math.max(3, rect.height - inset * 2)\n\n          const text = note.label ?? noteName(note.midi)\n          const showText = rect.width > 26 && layout.rowHeight >= 9\n\n          const activate = () => onNoteActivate?.(note)\n\n          return (\n            <g\n              key={note.id}\n              onPointerDown={onNotePointerDown(note, false)}\n              onClick={(event) => {\n                if (!(event.shiftKey || event.metaKey)) activate()\n              }}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\" || event.key === \" \") {\n                  event.preventDefault()\n                  activate()\n                }\n              }}\n              tabIndex={selectable ? 0 : -1}\n              role={selectable ? \"button\" : undefined}\n              aria-label={`${noteName(note.midi)} at beat ${note.startBeats.toFixed(2)}${active ? \", sounding\" : \"\"}${selected ? \", selected\" : \"\"}`}\n              aria-pressed={selectable ? selected : undefined}\n              style={{\n                cursor: selectable ? \"pointer\" : \"default\",\n                outline: \"none\",\n              }}\n            >\n              {/* Active halo — a soft accent ring behind the block, so the\n                  sounding note reads even when its fill is already the accent.\n                  Drawn first, so the block sits on top of it. */}\n              {active ? (\n                <rect\n                  x={rect.x - 1}\n                  y={blockY - 1}\n                  width={rect.width + 2}\n                  height={blockHeight + 2}\n                  rx={3}\n                  fill=\"none\"\n                  stroke={accent}\n                  strokeWidth={2}\n                  opacity={0.9}\n                />\n              ) : null}\n              <rect\n                x={rect.x}\n                y={blockY}\n                width={rect.width}\n                height={blockHeight}\n                rx={2}\n                fill={fill}\n                opacity={opacity}\n                stroke={selected ? noteAccent : \"transparent\"}\n                strokeWidth={selected ? 1.5 : 0}\n              />\n              {/* Right-edge stretch handle — only when editing, only on a\n                  selected note, so the affordance appears where it works. */}\n              {editable && selected ? (\n                <rect\n                  x={rect.x + rect.width - RESIZE_HANDLE_PX}\n                  y={blockY}\n                  width={RESIZE_HANDLE_PX}\n                  height={blockHeight}\n                  fill={noteAccent}\n                  opacity={0.9}\n                  onPointerDown={onNotePointerDown(note, true)}\n                  style={{ cursor: \"ew-resize\" }}\n                />\n              ) : null}\n              {showText ? (\n                <text\n                  x={rect.x + 4}\n                  y={rect.y + rect.height - 3}\n                  fontFamily={token.fontMono}\n                  fontSize={Math.min(8, layout.rowHeight - 2)}\n                  fill={token.surface}\n                  opacity={0.85}\n                  style={{ pointerEvents: \"none\" }}\n                >\n                  {text}\n                </text>\n              ) : null}\n            </g>\n          )\n        })}\n\n        {/* The playhead — a lit head where the host's clock is, and nothing\n            when the host is stopped (playhead null → no line at all). */}\n        {playheadX !== null ? (\n          <g aria-hidden style={{ pointerEvents: \"none\" }}>\n            <line\n              x1={playheadX}\n              x2={playheadX}\n              y1={0}\n              y2={svgHeight}\n              stroke={accent}\n              strokeWidth={1.75}\n            />\n            {/* A small cap at the top so the head reads as a head, not just a\n                gridline that happens to be brighter. */}\n            <path\n              d={`M ${playheadX - 4} 0 L ${playheadX + 4} 0 L ${playheadX} 5 Z`}\n              fill={accent}\n            />\n          </g>\n        ) : null}\n      </svg>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/piano-roll.tsx"
    }
  ]
}
