{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-piano-keyboard",
  "type": "registry:ui",
  "title": "Piano Keyboard",
  "description": "A playable keyboard, one to ten octaves wide: press a key and it strikes with a velocity taken from how far down the key you landed, drag across it and it glissandos — previous note off, entered note on. Notes go out through onNoteOn/onNoteOff and never through a param channel, and what is lit comes back in as a held map shaped like the module contract's activeNotes, so a key that was struck but declined draws as struck-and-silent rather than as nothing at all. Scale membership and a root tint as controlled props, opt-in QWERTY input through lib/computer-keyboard, keys that are real buttons with note-name labels, and a read-only mode for drawing a voicing.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/piano-keyboard.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/piano-keyboard\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 * PianoKeyboard — the kit's first playable surface.\n *\n * Everything else in this registry edits a number. This one plays notes: press\n * a key and it sounds, drag along the keys and it glissandos, type on the\n * QWERTY row and it plays from there. Which makes it the first component that\n * has to answer the question the website's module contract already answered —\n * where does a performance gesture go? — and it answers it the same way.\n *\n * ## Notes are not params\n *\n * `apps/website/src/components/modules/custom/contract.ts`, rule 3b: \"A\n * playable surface calls `onTriggerNote` / `onReleaseNote`, never\n * `onParamChange` — performance gestures are transient and must not land in a\n * module's saved param state.\" So this component has TWO channels and no\n * others:\n *\n *   - **out**: `onNoteOn(note, velocity)` / `onNoteOff(note)` — transient,\n *     fire-and-forget, never state;\n *   - **in**: `held`, a `ReadonlyMap<number, boolean>` shaped exactly like the\n *     contract's `activeNotes` — the key of a MIDI note, mapped to whether the\n *     strike made a sound.\n *\n * It keeps NO copy of what is sounding. A key lights because the host says it\n * is lit, which means the light is telling the truth about the audio rather\n * than about the pointer. The demo on the website holds that state itself and\n * proves the loop closes.\n *\n * The `false` case of `held` is the one worth having, and the reasoning is\n * lifted straight from the contract: an instrument may legitimately decline a\n * note — the gamelan's slendro and pelog tunings leave most pitch classes with\n * no bar behind them — and a key that produces nothing is indistinguishable\n * from a broken instrument unless the panel says otherwise. A declined key is\n * therefore drawn distinctly (dashed, struck through, unlit) rather than left\n * dark, and carries `data-note-state=\"silent\"` so it is assertable from the\n * DOM. Unlike a one-octave tile strip this surface draws real MIDI notes, so\n * it reads `held` by note and never folds by pitch class; a held note outside\n * the drawn range simply is not drawn.\n *\n * ## Geometry, and why placement and picking are one arithmetic\n *\n * All of it comes from `lib/keyboard-geometry`: key rectangles in a unit box,\n * `hitTest` back from a point with the black keys winning where they overlap,\n * and the press-height velocity. The DOM maps that box onto its responsive\n * width and height with percentages, and pointer coordinates are mapped back\n * into it before hit-testing — so a glissando cannot disagree with the keys it\n * is sliding over. Same discipline as `step-grid` and its cells.\n *\n * ## Playing\n *\n * Pointer down strikes the key under the finger; the velocity comes from HOW\n * FAR DOWN the key the press landed (the front of the key is the loud end,\n * matching a real key's leverage and every phone DAW's convention) with a\n * floor, so an edge tap is quiet, never silent. Dragging is a glissando: the\n * previous note is released and the entered one struck, one voice at a time,\n * because `use-surface-pointer` is single-pointer by design. Leaving the\n * surface releases; coming back strikes again.\n *\n * Multi-touch chords are deliberately out of scope: they need per-pointer\n * voice tracking and a policy for what a second finger does to a glissando\n * already in flight, which is a bigger contract than this component wants.\n *\n * Computer-keyboard input is opt-in and wires `lib/computer-keyboard` rather\n * than reinventing it — physical-key mapping (so AZERTY and Dvorak survive),\n * text-field guard, held-key sustain, spacebar panic, all of it. Chords are\n * switched off there: the number row's chord degrees need a chord bank to\n * resolve against and a keyboard has none. That lib binds no octave-shift key\n * (its number row went to chord triggers), so the `-` and `=` keys are bound\n * here instead — unmapped in both of its layouts, so nothing is stolen.\n *\n * Read-only is the no-callback case: pass neither `onNoteOn` nor `onNoteOff`\n * and the keys become plain drawings that still light from `held` — a voicing\n * or chord display rather than an instrument.\n *\n * ## Key spans\n *\n * `keySpans` tints or mutes contiguous ranges of *keys* (not pitch classes —\n * that is `scalePcs`' job): a sampler's mapped zones, a keyswitch block, the\n * span an articulation owns. A tinted key wears the span's colour on its\n * face at the colour's own alpha — pass an opaque colour for the solid\n * Kontakt-style zone paint, a translucent one for a wash — with black keys\n * darkening it to keep their identity; a muted key is dimmed at rest, the resting\n * cousin of the struck-but-silent state, and carries `data-key-muted` so it\n * is assertable from the DOM. `sampler-keyboard` builds on exactly this to\n * eat instrument-format key colourings directly.\n *\n * `keySpanStyle` picks how a coloured span marks its keys. `\"outline\"` (the\n * default) is the editing presentation above: a 2 px border in the span's\n * colour plus a wash, so every mapped key is unmistakable. `\"rail\"` is the\n * quiet one for a slim keybed strip: the keys stay plain and each span draws\n * one 2 px rail along the top edge of the keys it covers, muting unchanged.\n *\n * ## Rendering cost\n *\n * Each key is its own memoised component fed primitives and stable handlers,\n * so a `held` change re-renders the keys whose state changed and nothing else\n * — a host that reports notes at performance rate does not repaint a 97-key\n * keybed per note.\n *\n * Styling comes from CSS custom properties — see `lib/control-theme` — with a\n * `color` prop to override the accent for one instance. White keys take the\n * raised-cap token and black keys the recessed surface token, which keeps them\n * the right way round in every shipped skin. SSR-safe: the geometry is pure\n * maths and all DOM and window access lives in handlers and effects.\n */\n\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport type {\n  CSSProperties,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  DEFAULT_KEY_VELOCITY,\n  DEFAULT_OCTAVES,\n  DEFAULT_START_NOTE,\n  isScaleNote,\n  keyPressVelocity,\n  keyboardLayout,\n  type KeyGeometry,\n} from \"maruaudio/ui/shared/keyboard-geometry\"\nimport {\n  createComputerKeyboard,\n  isComputerKeyboardRelayed,\n  isTextTarget,\n  type ComputerKeyboardController,\n} from \"maruaudio/ui/shared/computer-keyboard\"\nimport type { KeyboardLayoutId } from \"maruaudio/ui/shared/keyboard-layouts\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\nimport { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\n\n/** How a key labels itself. `\"c\"` labels only the Cs, which is what a player\n * navigates by — every other label is noise once you know where C is. */\nexport type PianoKeyLabels = \"none\" | \"c\" | \"all\"\n\n/** What a key is doing, mirrored onto `data-note-state` so it is assertable\n * from the DOM without reading colours. */\nexport type PianoKeyState = \"idle\" | \"sounding\" | \"silent\"\n\n/**\n * A contiguous, inclusive span of MIDI keys drawn as one zone — a sampler's\n * mapped range, a keyswitch block. Tinting and muting are independent: a key\n * inside a `color` span wears that wash, a key inside a `muted` span is\n * dimmed at rest (an instrument that cannot sound there), and overlapping\n * spans compose — the first matching `color` wins.\n */\nexport interface PianoKeySpan {\n  low: number\n  high: number\n  /** CSS colour painted on the span's keys, at the colour's own alpha.\n   * Absent means the span only mutes. */\n  color?: string\n  /** Draw the span's keys as unplayable at rest. */\n  muted?: boolean\n}\n\n/**\n * How a coloured {@link PianoKeySpan} marks its keys. `\"outline\"` borders and\n * washes every key in the span — the editing presentation. `\"rail\"` leaves\n * the keys plain and draws one thin rail above the span — a keybed strip.\n */\nexport type PianoKeySpanStyle = \"outline\" | \"rail\"\n\nexport interface PianoKeyboardProps {\n  /** Lowest MIDI note. Snapped down to a white key. Defaults to C3. */\n  startNote?: number\n  /** Octaves drawn, 1..10. The top C is included, so 2 octaves is 25 keys. */\n  octaves?: number\n\n  /**\n   * Strike. Present (with or without {@link onNoteOff}) makes the keyboard\n   * playable; absent makes it a read-only display that still lights from\n   * {@link held}. Velocity is 0..1 — the engine-facing scale this repo's note\n   * APIs use, not raw MIDI 0..127.\n   */\n  onNoteOn?: (note: number, velocity: number) => void\n  /** Release. A host that only wants one-shots can omit it and schedule its\n   * own release, exactly as the module contract's `duration` option does. */\n  onNoteOff?: (note: number) => void\n\n  /**\n   * MIDI notes currently held, mapped to whether that strike made a sound —\n   * the module contract's `activeNotes`, same shape and same meaning. `true`\n   * lights the key; `false` draws it as struck but silent. Absent means \"no\n   * information\", not \"nothing held\".\n   */\n  held?: ReadonlyMap<number, boolean>\n  /** Pitch classes in the current scale, tinted as members. Every octave of a\n   * member is tinted — that is what a scale is. */\n  scalePcs?: readonly number[]\n  /** The scale's root, marked more strongly than the other members. */\n  rootPc?: number\n  /** Key ranges tinted or muted as zones. See {@link PianoKeySpan}. */\n  keySpans?: readonly PianoKeySpan[]\n  /** How coloured spans are drawn. Defaults to `\"outline\"`. */\n  keySpanStyle?: PianoKeySpanStyle\n\n  /** Bind the QWERTY keyboard through `lib/computer-keyboard`. Ignored when\n   * the keyboard is read-only — there would be nowhere to send the notes —\n   * and when the document is a hosted face whose keys are relayed to the\n   * host's keyboard owner (`isComputerKeyboardRelayed`): the host already\n   * plays those keys, so a face-local binding would sound every note twice,\n   * with its own octave. Only a standalone page owns the keys this way. */\n  computerKeyboard?: boolean\n  /** Which of that lib's layouts to use. Defaults to its own default (the\n   * DAW piano mapping). */\n  computerKeyboardLayout?: KeyboardLayoutId\n\n  /** Which keys carry a name. Defaults to `\"c\"`. */\n  labels?: PianoKeyLabels\n  /** Velocity for strikes with no press position: Enter/Space and the\n   * computer keyboard. Defaults to 0.8, the same as that lib's own. */\n  velocity?: number\n\n  width?: number | string\n  /** Keyboard height in pixels. */\n  height?: number\n  /** Override the accent colour for this instance. */\n  color?: string\n  /** Fired once, on the first gesture of any kind — the place to resume a\n   * suspended AudioContext. */\n  onInteraction?: (event: InteractionEvent) => void\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Octave-shift bounds for the computer keyboard, in octaves. Four either way\n * covers the whole MIDI range from any of the lib's layouts. */\nconst MAX_OCTAVE_SHIFT = 4\n\n/**\n * The black keys' face, and the one colour in this component that is not a\n * token.\n *\n * A dark wash layered OVER the cap token rather than a flat colour, so the\n * skin still tints it — but it is a wash, and it wins. Black keys are black on\n * every piano ever built, in every room, and a skin that turned them pale\n * would not be a re-skinned piano, it would be a broken one. The bed, the\n * white keys, the lit state, the scale strip and the labels all come from\n * tokens as usual.\n */\nconst BLACK_KEY_FACE = `linear-gradient(to bottom, rgba(8, 10, 14, 0.94), rgba(8, 10, 14, 0.78)), ${token.cap}`\n\n/** Label colour on a black key, for the same reason: the face is dark in every\n * skin, so `textMuted` — which follows the skin — would vanish on a light one. */\nconst BLACK_KEY_LABEL = \"rgba(255, 255, 255, 0.62)\"\n\nconst pct = (value: number) => `${value * 100}%`\n\ninterface PianoKeyViewProps {\n  geometry: KeyGeometry\n  state: PianoKeyState\n  inScale: boolean\n  isRoot: boolean\n  focused: boolean\n  /** The span colour this key itself wears — only set by the \"outline\" span\n   * style; a rail is drawn once per span by the keyboard, not per key. */\n  tint: string | undefined\n  muted: boolean\n  playable: boolean\n  labelled: boolean\n  accent: string\n  onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void\n  onKeyUp: (event: ReactKeyboardEvent<HTMLElement>) => void\n  onBlur: () => void\n  onFocus: (event: ReactFocusEvent<HTMLElement>) => void\n}\n\n/**\n * One key, white or black. Both are the same rectangle maths — only the\n * colours, the depth and the stacking order differ.\n *\n * Memoised on primitives and stable handlers: `geometry` is the layout's own\n * object, the handlers read the note back from `data-note`, and everything\n * else is a boolean or a string. A note-state change therefore re-renders the\n * keys it touches, not the keybed.\n */\nconst PianoKeyView = memo(function PianoKeyView({\n  geometry: key,\n  state,\n  inScale,\n  isRoot,\n  focused,\n  tint,\n  muted,\n  playable,\n  labelled,\n  accent,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  onFocus,\n}: PianoKeyViewProps) {\n  const base: CSSProperties = {\n    position: \"absolute\",\n    left: pct(key.x),\n    width: pct(key.width),\n    top: 0,\n    height: pct(key.height),\n    boxSizing: \"border-box\",\n    borderRadius: `0 0 ${key.black ? 3 : 4}px ${key.black ? 3 : 4}px`,\n    // Keep the border longhands independent: sounding and declined keys\n    // update color/style during rerenders, and mixing those writes with the\n    // shorthand makes React remove one while applying the other.\n    borderWidth: 1,\n    borderStyle: \"solid\",\n    borderColor: token.border,\n    background: key.black\n      ? BLACK_KEY_FACE\n      : `linear-gradient(to bottom, ${token.cap}, ${token.surface})`,\n    // Black keys sit on top of the whites they overlap, which is also the\n    // hit-test's priority rule — one order, drawn and picked.\n    zIndex: key.black ? 2 : 1,\n    padding: 0,\n    appearance: \"none\",\n    font: \"inherit\",\n    display: \"flex\",\n    alignItems: \"flex-end\",\n    justifyContent: \"center\",\n    overflow: \"hidden\",\n    // The surface owns the playing cursor (pointer, or grabbing mid-drag), so\n    // a drag starting does not have to re-render every key to change it.\n    cursor: playable ? \"inherit\" : \"default\",\n  }\n\n  // Zone border: when a key belongs to a coloured zone and is not currently\n  // sounding, paint a clearly visible 2 px border in the zone's colour. This\n  // is the PRIMARY visual signal; the tint overlay is secondary.\n  if (tint !== undefined && state !== \"sounding\") {\n    base.borderColor = tint\n    base.borderWidth = 2\n  }\n\n  if (state === \"sounding\") {\n    base.background = `linear-gradient(to bottom, ${token.accent2}, ${accent})`\n    base.borderColor = accent\n    base.boxShadow = `0 0 10px -2px ${accent}`\n  } else if (state === \"silent\") {\n    // Struck and declined: dashed, dimmed, and struck through. A dead key\n    // has to look deliberate, not broken — see the header.\n    base.borderStyle = \"dashed\"\n    base.borderColor = token.textMuted\n    base.opacity = 0.55\n  } else if (muted) {\n    // The resting cousin of \"silent\": nothing here can sound, and the key\n    // says so before it is struck.\n    base.opacity = 0.45\n  }\n\n  const content = (\n    <>\n      {/* The zone wash: a subtle tint behind the key face, secondary to the\n          coloured border which is the primary zone indicator. White keys get\n          the zone colour at low opacity so the border pops; black keys get a\n          lightly tinted dark wash so they keep their identity. */}\n      {tint !== undefined && state !== \"sounding\" && (\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            background: key.black\n              ? `linear-gradient(rgba(10, 13, 18, 0.65), rgba(10, 13, 18, 0.65)), ${tint}`\n              : tint,\n            opacity: key.black ? 0.55 : 0.18,\n            pointerEvents: \"none\",\n          }}\n        />\n      )}\n      {/* The scale strip: a felt at the front of the key, so it survives the\n          key lighting up rather than being overwritten by it. */}\n      {(inScale || isRoot) && (\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            left: \"8%\",\n            right: \"8%\",\n            bottom: 3,\n            height: isRoot ? 5 : 3,\n            borderRadius: 2,\n            background: isRoot ? accent : token.accent2,\n            opacity: state === \"sounding\" ? 0.9 : isRoot ? 0.95 : 0.55,\n          }}\n        />\n      )}\n      {state === \"silent\" && (\n        <span\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            left: \"12%\",\n            right: \"12%\",\n            top: \"50%\",\n            height: 1,\n            background: token.textMuted,\n          }}\n        />\n      )}\n      {labelled && (\n        <span\n          aria-hidden\n          style={{\n            position: \"relative\",\n            marginBottom: inScale || isRoot ? 10 : 4,\n            fontSize: 8,\n            fontFamily: token.fontMono,\n            letterSpacing: \"-0.02em\",\n            color:\n              state === \"sounding\"\n                ? token.surface\n                : key.black\n                  ? BLACK_KEY_LABEL\n                  // The tint overlay is now very subtle (low opacity), so\n                  // white key labels stay on the pale key face — the\n                  // default muted colour remains legible.\n                  : token.textMuted,\n            pointerEvents: \"none\",\n          }}\n        >\n          {key.name}\n        </span>\n      )}\n    </>\n  )\n\n  if (!playable) {\n    return (\n      <div\n        aria-hidden\n        data-note={key.note}\n        data-note-state={state}\n        data-key-muted={muted || undefined}\n        style={base}\n      >\n        {content}\n      </div>\n    )\n  }\n\n  return (\n    <button\n      type=\"button\"\n      data-note={key.note}\n      data-note-state={state}\n      data-key-muted={muted || undefined}\n      tabIndex={focused ? 0 : -1}\n      aria-pressed={state === \"sounding\"}\n      // The label is the note name, and only says more when there is more to\n      // say: a key that produced no sound announces that it did not.\n      aria-label={state === \"silent\" ? `${key.name}, silent` : key.name}\n      onKeyDown={onKeyDown}\n      onKeyUp={onKeyUp}\n      onBlur={onBlur}\n      onFocus={onFocus}\n      style={{ ...base, outlineColor: token.focus }}\n    >\n      {content}\n    </button>\n  )\n})\n\n/** The note a key element stands for, read back from its `data-note`. */\nconst noteOfKeyElement = (element: HTMLElement): number =>\n  Number(element.dataset.note)\n\nexport function PianoKeyboard({\n  startNote = DEFAULT_START_NOTE,\n  octaves = DEFAULT_OCTAVES,\n  onNoteOn,\n  onNoteOff,\n  held,\n  scalePcs,\n  rootPc,\n  keySpans,\n  keySpanStyle = \"outline\",\n  computerKeyboard = false,\n  computerKeyboardLayout,\n  labels = \"c\",\n  velocity = DEFAULT_KEY_VELOCITY,\n  width = \"100%\",\n  height = 120,\n  color,\n  onInteraction,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: PianoKeyboardProps) {\n  const accent = color ?? token.accent\n  const playable = typeof onNoteOn === \"function\" || typeof onNoteOff === \"function\"\n\n  const layout = useMemo(\n    () => keyboardLayout({ startNote, octaves }),\n    [startNote, octaves],\n  )\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  // The callbacks are read through a ref inside long-lived listeners (pointer\n  // capture, window key handlers) so a re-render never leaves a stale one\n  // holding a note that can no longer be released.\n  const callbacks = useRef({ onNoteOn, onNoteOff, velocity })\n  useEffect(() => {\n    callbacks.current = { onNoteOn, onNoteOff, velocity }\n  })\n\n  const strike = (note: number, atVelocity: number) => {\n    callbacks.current.onNoteOn?.(note, Math.min(1, Math.max(0, atVelocity)))\n  }\n  const release = (note: number) => {\n    callbacks.current.onNoteOff?.(note)\n  }\n\n  /* -------------------------------------------------------------- pointer */\n\n  const surfaceRef = useRef<HTMLDivElement | null>(null)\n  /** The one note the pointer is currently sounding. Single-pointer by design\n   * — see the header on multi-touch. */\n  const pointerVoice = useRef<number | null>(null)\n\n  /** Client coordinates → the layout's unit box, via the surface's live rect. */\n  const toLayout = (clientX: number, clientY: number): { x: number; y: number } | null => {\n    const rect = surfaceRef.current?.getBoundingClientRect()\n    if (!rect || rect.width === 0 || rect.height === 0) return null\n    return {\n      x: (clientX - rect.left) / rect.width,\n      y: (clientY - rect.top) / rect.height,\n    }\n  }\n\n  const [focusNote, setFocusNote] = useState<number>(layout.startNote)\n\n  const pointer = useSurfacePointer(\n    (state) => {\n      if (state.first) notify(state.event)\n      const point = toLayout(state.clientX, state.clientY)\n      const key = point ? layout.hitTest(point.x, point.y) : null\n      const sounding = pointerVoice.current\n\n      // Off the keyboard: release and wait. Coming back strikes again, which\n      // is what sliding off the bottom edge and back on should feel like.\n      if (!key || !point) {\n        if (sounding !== null) release(sounding)\n        pointerVoice.current = null\n        return\n      }\n      if (sounding === key.note) return\n\n      // The glissando: previous note off, entered note on, in that order, so a\n      // monophonic host never hears two overlapping.\n      if (sounding !== null) release(sounding)\n      pointerVoice.current = key.note\n      setFocusNote(key.note)\n      strike(key.note, keyPressVelocity(key, point.y))\n    },\n    {\n      disabled: !playable,\n      onEnd: () => {\n        if (pointerVoice.current !== null) release(pointerVoice.current)\n        pointerVoice.current = null\n      },\n    },\n  )\n\n  /* ------------------------------------------------------ computer keyboard */\n\n  const [octaveShift, setOctaveShift] = useState(0)\n  const controllerRef = useRef<ComputerKeyboardController | null>(null)\n\n  /* A hosted face never owns the keyboard: its document relays keys to the\n   * host's one owner. Checked at render and again per key below, because a\n   * same-origin host may attach its forwarder after this face mounted. */\n  const ownsComputerKeyboard =\n    computerKeyboard && playable && !isComputerKeyboardRelayed()\n\n  useEffect(() => {\n    if (!ownsComputerKeyboard) return\n    if (typeof window === \"undefined\") return\n\n    const controller = createComputerKeyboard({\n      layoutId: computerKeyboardLayout,\n      velocity: callbacks.current.velocity,\n      // A keyboard has no chord bank to resolve a degree against, so the\n      // number row stays silent rather than emitting events nobody handles.\n      chordsEnabled: false,\n    })\n    controllerRef.current = controller\n\n    controller.on((event) => {\n      if (event.type === \"noteon\") strike(event.note, event.velocity)\n      else if (event.type === \"noteoff\") release(event.note)\n      else if (event.type === \"panic\") for (const note of event.notes) release(note)\n    })\n\n    /** Octave shift. The lib binds no key for this — its number row became\n     * chord triggers — and `-` / `=` are unmapped in both of its layouts, so\n     * binding them here steals nothing. Held notes are released first: the\n     * controller would release them correctly at their original pitch, but a\n     * player who shifts mid-hold means \"move\", not \"sustain the old octave\". */\n    const handleOctaveKey = (event: KeyboardEvent) => {\n      if (isComputerKeyboardRelayed()) return\n      if (event.defaultPrevented || event.isComposing) return\n      if (isTextTarget(event.target)) return\n      if (event.metaKey || event.ctrlKey || event.altKey) return\n      const delta = event.code === \"Minus\" ? -1 : event.code === \"Equal\" ? 1 : 0\n      if (delta === 0) return\n      event.preventDefault()\n      const next = Math.min(MAX_OCTAVE_SHIFT, Math.max(-MAX_OCTAVE_SHIFT, controller.octaveShift + delta))\n      if (next === controller.octaveShift) return\n      controller.stopAllNotes()\n      controller.octaveShift = next\n      setOctaveShift(next)\n    }\n\n    const notifyFirst = (event: KeyboardEvent) => {\n      if (isComputerKeyboardRelayed()) return\n      if (!isTextTarget(event.target)) notify(event)\n    }\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (isComputerKeyboardRelayed()) return\n      controller.handleKeyDown(event)\n    }\n\n    document.addEventListener(\"keydown\", notifyFirst)\n    document.addEventListener(\"keydown\", handleOctaveKey)\n    document.addEventListener(\"keydown\", handleKeyDown)\n    document.addEventListener(\"keyup\", controller.handleKeyUp)\n    // Not optional: a key held while the tab loses focus never gets its keyup\n    // and the note sounds forever.\n    window.addEventListener(\"blur\", controller.stopAllNotes)\n\n    return () => {\n      controller.stopAllNotes()\n      document.removeEventListener(\"keydown\", notifyFirst)\n      document.removeEventListener(\"keydown\", handleOctaveKey)\n      document.removeEventListener(\"keydown\", handleKeyDown)\n      document.removeEventListener(\"keyup\", controller.handleKeyUp)\n      window.removeEventListener(\"blur\", controller.stopAllNotes)\n      controllerRef.current = null\n      setOctaveShift(0)\n    }\n    // `velocity` is read through the ref rather than listed here: a host\n    // sliding a velocity control must not tear the controller down mid-note.\n  }, [ownsComputerKeyboard, computerKeyboardLayout])\n\n  // …it is pushed onto the live controller instead, so the change lands on the\n  // next keypress without disturbing the notes already down.\n  useEffect(() => {\n    if (controllerRef.current) controllerRef.current.velocity = velocity\n  }, [velocity])\n\n  /* ------------------------------------------------------------- keyboard */\n\n  /** The one note Enter/Space is holding, so blur and keyup release exactly\n   * what they started. */\n  const keyVoice = useRef<number | null>(null)\n\n  // A host can replace a face without stopping its engine. Surface-pointer\n  // teardown removes listeners but does not synthesize pointerup, and removing\n  // a focused key does not guarantee blur/keyup. Balance only gestures started\n  // here; `held` also contains hardware/sequencer notes that this view does not\n  // own. A visible-range change ends old gestures at their original pitch.\n  useEffect(() => () => {\n    const pointerNote = pointerVoice.current\n    const keyNote = keyVoice.current\n    pointerVoice.current = null\n    keyVoice.current = null\n    if (pointerNote !== null) callbacks.current.onNoteOff?.(pointerNote)\n    if (keyNote !== null) callbacks.current.onNoteOff?.(keyNote)\n  }, [layout.startNote, layout.endNote, playable])\n\n  const moveFocus = (note: number) => {\n    const clamped = Math.min(layout.endNote, Math.max(layout.startNote, note))\n    setFocusNote(clamped)\n    const target = surfaceRef.current?.querySelector<HTMLElement>(`[data-note=\"${clamped}\"]`)\n    target?.focus()\n  }\n\n  const handleKeyDown = (note: number) => (event: ReactKeyboardEvent<Element>) => {\n    if (!playable) return\n    let handled = true\n    if (event.key === \"Enter\" || event.key === \" \") {\n      if (!event.repeat && keyVoice.current === null) {\n        notify(event)\n        keyVoice.current = note\n        strike(note, callbacks.current.velocity)\n      }\n    } else if (event.key === \"ArrowRight\") moveFocus(note + 1)\n    else if (event.key === \"ArrowLeft\") moveFocus(note - 1)\n    else if (event.key === \"ArrowUp\") moveFocus(note + 12)\n    else if (event.key === \"ArrowDown\") moveFocus(note - 12)\n    else if (event.key === \"Home\") moveFocus(layout.startNote)\n    else if (event.key === \"End\") moveFocus(layout.endNote)\n    else handled = false\n\n    if (handled) {\n      event.preventDefault()\n      // These keys belong to the focused widget, not to the global QWERTY\n      // instrument — without this, Space would play the focused key AND fire\n      // the computer keyboard's panic.\n      event.stopPropagation()\n    }\n  }\n\n  const releaseKeyVoice = () => {\n    if (keyVoice.current === null) return\n    release(keyVoice.current)\n    keyVoice.current = null\n  }\n\n  const handleKeyUp = (event: ReactKeyboardEvent<Element>) => {\n    if (event.key !== \"Enter\" && event.key !== \" \") return\n    event.stopPropagation()\n    releaseKeyVoice()\n  }\n\n  /* -------------------------------------------------------------- drawing */\n\n  const soundingCount = useMemo(() => {\n    if (!held) return 0\n    let count = 0\n    for (const [note, sounded] of held) {\n      if (sounded && note >= layout.startNote && note <= layout.endNote) count += 1\n    }\n    return count\n  }, [held, layout])\n\n  const stateOf = (note: number): PianoKeyState => {\n    const sounded = held?.get(note)\n    if (sounded === undefined) return \"idle\"\n    return sounded ? \"sounding\" : \"silent\"\n  }\n\n  const label =\n    ariaLabel ??\n    `Piano keyboard, ${layout.keys[0].name} to ${layout.keys[layout.keys.length - 1].name}` +\n      `, ${soundingCount} sounding${playable ? \"\" : \" (read-only)\"}`\n\n  // A range change can strand the roving tabindex on a note that is no longer\n  // drawn, which would leave the whole widget untabbable. Fall back to the\n  // lowest key rather than tracking it in an effect.\n  const rovingNote = layout.keyAt(focusNote) ? focusNote : layout.startNote\n\n  const showLabel = (key: KeyGeometry): boolean => {\n    if (labels === \"none\") return false\n    if (labels === \"all\") return true\n    return key.pc === 0\n  }\n\n  /* The stable faces of the handlers above. A memoised key must not see a new\n   * function per render, so each of these reads the live closure through a ref\n   * and the note back from the element. */\n  const keyHandlers = useRef({ handleKeyDown, handleKeyUp, releaseKeyVoice })\n  useEffect(() => {\n    keyHandlers.current = { handleKeyDown, handleKeyUp, releaseKeyVoice }\n  })\n  const onKeyKeyDown = useCallback(\n    (event: ReactKeyboardEvent<HTMLElement>) =>\n      keyHandlers.current.handleKeyDown(noteOfKeyElement(event.currentTarget))(event),\n    [],\n  )\n  const onKeyKeyUp = useCallback(\n    (event: ReactKeyboardEvent<HTMLElement>) => keyHandlers.current.handleKeyUp(event),\n    [],\n  )\n  const onKeyBlur = useCallback(() => keyHandlers.current.releaseKeyVoice(), [])\n  const onKeyFocus = useCallback(\n    (event: ReactFocusEvent<HTMLElement>) =>\n      setFocusNote(noteOfKeyElement(event.currentTarget)),\n    [],\n  )\n\n  /* What the spans say about each drawn key, resolved once per span change\n   * rather than per key per render. First matching colour wins; muting\n   * composes independently — see `PianoKeySpan`. */\n  const spanPaint = useMemo(() => {\n    const paint = new Map<number, { tint: string | undefined; muted: boolean }>()\n    if (!keySpans || keySpans.length === 0) return paint\n    for (const key of layout.keys) {\n      const tint = keySpans.find(\n        (s) => s.color !== undefined && key.note >= s.low && key.note <= s.high,\n      )?.color\n      const muted = keySpans.some(\n        (s) => s.muted === true && key.note >= s.low && key.note <= s.high,\n      )\n      if (tint !== undefined || muted) paint.set(key.note, { tint, muted })\n    }\n    return paint\n  }, [keySpans, layout])\n\n  /* The \"rail\" span style: one thin bar per coloured span along the top edge\n   * of the keys it covers, clipped to the drawn range. The top edge is where\n   * black and white keys tile without overlap, so a rail starts and stops on\n   * the same boundary the eye reads as \"this key\". Identical spans (velocity\n   * layers on the same keys) draw once, and the list is reversed so the first\n   * matching colour ends on top — the same priority the outline style uses. */\n  const rails = useMemo(() => {\n    if (keySpanStyle !== \"rail\" || !keySpans) return []\n    const seen = new Set<string>()\n    const drawn: { id: string; low: number; high: number; color: string; left: number; width: number }[] = []\n    for (const span of keySpans) {\n      if (span.color === undefined) continue\n      const low = Math.max(Math.ceil(span.low), layout.startNote)\n      const high = Math.min(Math.floor(span.high), layout.endNote)\n      const first = layout.keyAt(low)\n      const last = layout.keyAt(high)\n      if (low > high || !first || !last) continue\n      const id = `${low}:${high}:${span.color}`\n      if (seen.has(id)) continue\n      seen.add(id)\n      const before = layout.keyAt(low - 1)\n      const after = layout.keyAt(high + 1)\n      const left = !first.black && before?.black ? before.x + before.width : first.x\n      const right = !last.black && after?.black ? after.x : last.x + last.width\n      drawn.push({ id, low, high, color: span.color, left, width: right - left })\n    }\n    return drawn.reverse()\n  }, [keySpanStyle, keySpans, layout])\n\n  const rootClass =\n    rootPc === undefined ? null : ((Math.round(rootPc) % 12) + 12) % 12\n  const keysWearSpanColour = keySpanStyle === \"outline\"\n\n  const renderKey = (key: KeyGeometry) => {\n    const paint = spanPaint.get(key.note)\n    return (\n      <PianoKeyView\n        key={key.note}\n        geometry={key}\n        state={stateOf(key.note)}\n        inScale={isScaleNote(key.note, scalePcs)}\n        isRoot={rootClass !== null && key.pc === rootClass}\n        focused={key.note === rovingNote}\n        tint={keysWearSpanColour ? paint?.tint : undefined}\n        muted={paint?.muted ?? false}\n        playable={playable}\n        labelled={showLabel(key)}\n        accent={accent}\n        onKeyDown={onKeyKeyDown}\n        onKeyUp={onKeyKeyUp}\n        onBlur={onKeyBlur}\n        onFocus={onKeyFocus}\n      />\n    )\n  }\n\n  return (\n    <div className={className} style={{ width, fontFamily: token.font, ...style }}>\n      <div\n        ref={surfaceRef}\n        className=\"uikit-control\"\n        role={playable ? \"group\" : \"img\"}\n        aria-label={label}\n        onPointerDown={playable ? pointer.onPointerDown : undefined}\n        style={{\n          position: \"relative\",\n          height,\n          background: token.track,\n          border: `1px solid ${token.border}`,\n          borderRadius: token.radius,\n          padding: 0,\n          touchAction: \"none\",\n          overflow: \"hidden\",\n          cursor: playable ? (pointer.dragging ? \"grabbing\" : \"pointer\") : undefined,\n        }}\n      >\n        {/* Whites first, blacks over them — the DOM order IS the stacking, and\n            it matches the hit-test's black-over-white priority. */}\n        {layout.whiteKeys.map(renderKey)}\n        {layout.blackKeys.map(renderKey)}\n        {/* Span rails sit over the keys' top edge and take no pointer, so the\n            key under one is still the key that is struck. The 1 px taken off\n            each rail's width is the gap that lets adjacent spans read as\n            separate segments. */}\n        {rails.map((rail) => (\n          <span\n            key={rail.id}\n            aria-hidden\n            data-key-span-rail={`${rail.low}-${rail.high}`}\n            style={{\n              position: \"absolute\",\n              top: 0,\n              left: pct(rail.left),\n              width: `calc(${pct(rail.width)} - 1px)`,\n              height: 2,\n              background: rail.color,\n              zIndex: 3,\n              pointerEvents: \"none\",\n            }}\n          />\n        ))}\n      </div>\n\n      {ownsComputerKeyboard && (\n        <div\n          style={{\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            gap: 8,\n            marginTop: 4,\n            fontSize: 9,\n            fontFamily: token.fontMono,\n            color: token.textMuted,\n          }}\n        >\n          <span>QWERTY keys play · space panics</span>\n          <span>\n            − / = octave {octaveShift > 0 ? `+${octaveShift}` : octaveShift}\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/piano-keyboard.tsx"
    }
  ]
}
