{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-timeline-roll",
  "type": "registry:ui",
  "title": "Timeline Roll",
  "description": "A read-only multitrack piano roll of a prepared timeline: ruler with cycle numbers, a section strip, pitched rows and a compact drum strip with General MIDI names. Sized by CSS (SVG paths stretched with preserveAspectRatio none, HTML labels in percent, no height prop); fit view samples to a note cap, detail view scrolls at 72px per cycle, culls to the viewport and follows the playhead unless the reader just scrolled or prefers reduced motion. The playhead is a leaf on a PlaybackClock moved by transform, so playback never re-renders the notes. Stale state dims it and says 'previous'. TimelineRollLegend adds swatches, mute/solo toggles (aria-pressed, 24px, Alt-click solo), channel numbers below 480px and an actions slot. Track colours are --uikit-track-1..8, starting at blue. Pure core beside it.",
  "dependencies": [
    "@webgpu/types",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-spectrogram.json"
  ],
  "files": [
    {
      "path": "components/ui/timeline-roll.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/timeline-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 * TimelineRoll — a read-only multitrack piano roll of a prepared timeline,\n * with a playhead that moves without re-rendering the notes.\n *\n *   <TimelineRoll notes={notes} tracks={tracks} durationSeconds={16} cycles={8}\n *     sections={sectionBands(events)} view=\"fit\" clock={clock} />\n *\n * ## What it draws\n *\n * From the top: a ruler with cycle numbers, a strip of named sections (one row\n * per arrangement playing at once), the pitched rows, and under them a compact\n * drum strip with one named row per drum note, so a kick never sits on top of\n * the bass line. Track colours come from `--uikit-track-1..8`, whose fallbacks\n * start at blue so that no track is drawn in the accent green.\n *\n * `view=\"fit\"` shows the whole timeline in the width it has, sampling evenly\n * down to a note cap. `view=\"detail\"` gives each cycle a fixed width, scrolls,\n * draws only the notes near the viewport and follows the playhead, unless the\n * reader has just scrolled or asked for reduced motion.\n *\n * ## Sized by CSS\n *\n * The roll fills its container's height (give the container one). Notes and\n * grid are SVG paths in seconds by rows, stretched with\n * `preserveAspectRatio=\"none\"`; text (pitch labels, cycle numbers, section\n * names, drum names) is HTML positioned in percent. A resize, including a\n * splitter drag, re-lays out nothing; only crossing a label-density step\n * re-renders.\n *\n * ## The clock\n *\n * The playhead is a leaf that reads `usePlaybackPosition(clock)` and moves one\n * line by `transform`. The layers under it are memoized, and nothing marks\n * sounding notes, so a frame of playback renders one small component.\n *\n * `stale` dims the roll and says \"previous\" in its corner, for a timeline that\n * no longer matches the code. SSR-safe: DOM access is in effects and handlers.\n */\n\nimport {\n  createElement,\n  memo,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type JSX,\n  type ReactElement,\n  type ReactNode,\n  type RefObject,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  usePlaybackPosition,\n  type PlaybackClock,\n} from \"maruaudio/ui/shared/playback-clock\"\nimport { midiToHz, midiToNoteName } from \"maruaudio/ui/shared/music-theory-pitch-class\"\nimport { Spectrogram } from \"@/components/ui/spectrogram\"\nimport {\n  type PreparedSpectrogramData,\n  type SpectrogramFrequencyScale,\n} from \"maruaudio/ui/shared/prepared-spectrogram\"\nimport {\n  accidentalRowsPath,\n  cullNotes,\n  describeTimeline,\n  DETAIL_CYCLE_PX,\n  DETAIL_NOTE_LIMIT,\n  detailWindow,\n  drumRows,\n  FIT_NOTE_LIMIT,\n  followScroll,\n  gmDrumName,\n  gridPaths,\n  gridSteps,\n  isTrackAudible,\n  notePath,\n  timelinePitchGeometry,\n  sampleNotes,\n  scrollChunk,\n  sectionRowCount,\n  trackColor,\n  velocityStep,\n  type SectionBand,\n  type TimelineRollNote,\n  type TimelineRollTrack,\n} from \"maruaudio/ui/shared/timeline-roll-math\"\n\nexport type TimelineRollView = \"fit\" | \"detail\"\n\nexport interface TimelineRollLane {\n  /** Fixed height in pixels, within the total roll height. */\n  readonly height: number\n  readonly label?: string\n  /** Fill the full width: x=0 is time zero; x=100% is durationSeconds. */\n  readonly content: ReactNode\n}\n\nexport interface TimelineRollProps {\n  readonly leadingLane?: TimelineRollLane\n  /** Host-prepared spectrum. Switches pitch geometry to a note-independent\n   * 20 Hz–20 kHz log axis (capped at Nyquist) and maps notes into that axis. */\n  readonly spectrogram?: PreparedSpectrogramData\n  /** Visual audition emphasis only; multiplies note velocity/mute opacity. Default 1. */\n  readonly noteOpacity?: number\n  /** Cursor position when the host clock is stopped (for scrubbing). */\n  readonly restingPositionSeconds?: number\n  /** Host-owned seek in seconds; enables pointer/keyboard scrubbing of the roll. */\n  readonly onSeek?: (seconds: number) => void\n  /** Optional plot click/Enter/Space action. Drags still seek; ruler clicks seek.\n   * The label describes the action for keyboard users and the pointer hint. */\n  readonly plotActivation?: { readonly label: string; readonly onActivate: () => void }\n\n  readonly notes: readonly TimelineRollNote[]\n  readonly tracks: readonly TimelineRollTrack[]\n  readonly durationSeconds: number\n  readonly cycles: number\n  /** Cycle numbers by default; seconds for clips without a detected musical tempo. */\n  readonly rulerUnit?: \"cycles\" | \"seconds\"\n  /** Bands for the section strip, as `sectionBands` makes them. */\n  readonly sections?: readonly SectionBand[]\n  readonly view: TimelineRollView\n  /** Where playback is; a stopped clock draws no playhead. */\n  readonly clock: PlaybackClock\n  /** Keep the playhead in sight in detail view. Defaults to on, off under reduced motion. */\n  readonly follow?: boolean\n  /** Track ids soloed; the others draw dimmed, as muted tracks do. */\n  readonly soloed?: readonly string[]\n  /** The timeline no longer matches its source: dimmed, with \"previous\" in the corner. */\n  readonly stale?: boolean\n  /** Drum strip label of a drum note. Defaults to its General MIDI name. */\n  readonly drumLabel?: (midi: number) => string\n  /** Controlled visual link to a matching drum pad, identified by MIDI note. */\n  readonly highlightedDrumMidi?: number | null\n  /** Hover/focus of a drum row label, cleared with null on leave/blur. */\n  readonly onDrumHoverChange?: (midi: number | null) => void\n  /** Overrides the generated description (notes, tracks, cycles, length, sections). */\n  readonly \"aria-label\"?: string\n  readonly className?: string\n}\n\n/* ---------------------------------------------------------------- metrics */\n\nconst RULER_PX = 16\nconst SECTION_ROW_PX = 14\nconst MIN_PITCHED_PX = 32\nconst DRUM_ROW_PX = 12\nconst MIN_DRUM_ROW_PX = 10\nconst GUTTER_PX = 52\n/** Width assumed before the first measure (and on the server). */\nconst DEFAULT_VIEWPORT_PX = 760\n/** A manual scroll suspends following for this long. */\nconst FOLLOW_PAUSE_MS = 2_500\n/** Measured widths are rounded to this, so a resize re-renders only per step. */\nconst WIDTH_STEP_PX = 32\n/** Muted and unsoloed tracks draw at this opacity. */\nconst DIMMED_OPACITY = 0.28\nconst LABEL_FONT_PX = 10\nconst MONO = token.fontMono\nconst PLAYHEAD = \"var(--uikit-playhead, #ff826e)\"\nconst GRID = \"var(--uikit-grid, rgba(148, 163, 184, 0.5))\"\nconst RAISED = \"color-mix(in srgb, var(--uikit-text, #e8eaf0) 7%, var(--uikit-surface, #14161d))\"\nconst KEY_SHADE = \"color-mix(in srgb, #000 22%, transparent)\"\n\n/* ---------------------------------------------------------------- styles */\n\n/** The `href` React dedupes the hoisted stylesheet by. */\nexport const TIMELINE_ROLL_STYLES_HREF = \"uikit-timeline-roll\"\n\nconst HOVER = \"color-mix(in srgb, var(--uikit-text, #e8eaf0) 9%, transparent)\"\nconst FOCUS = \"var(--uikit-focus, var(--uikit-accent, #6366f1))\"\nconst SR_ONLY = \"position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0\"\n\n/**\n * What inline styles cannot say: focus and hover, container queries (pitch\n * labels thin out on a short roll; legend names give way to channel numbers\n * below 480px), and transitions or brief note flashes, only for readers who\n * did not ask for less motion. Exported for hosts that inject styles themselves.\n */\nexport const TIMELINE_ROLL_STYLES = String.raw`\n[data-tl-scroller]{scrollbar-width:thin;scrollbar-color:${token.border} transparent}\n[data-tl-scroller]:focus-visible{outline:2px solid ${FOCUS};outline-offset:-2px}\n[data-tl-pitch-labels]{container:uikit-tl-pitch/size}\n[data-tl-drum-label][data-highlighted]{background:color-mix(in srgb, ${token.accent} 16%, transparent);color:${token.accent}}\n[data-tl-drum-label]:focus-visible{outline:1px solid ${FOCUS};outline-offset:-1px}\n@container uikit-tl-pitch (max-height: 80px){[data-tl-pitch-label][data-alt]{display:none}}\n[data-uikit-timeline-legend]{container:uikit-timeline-legend/inline-size}\n[data-tl-legend-channel]{display:none}\n@container uikit-timeline-legend (max-width: 479.98px){[data-uikit-timeline-legend]:not([data-show-counts]) [data-tl-legend-name]{${SR_ONLY}}[data-uikit-timeline-legend]:not([data-show-counts]) [data-tl-legend-channel]{display:inline}}\n[data-tl-toggle]{background:transparent;color:${token.textMuted};border:1px solid transparent}\n[data-tl-toggle]:hover{background:${HOVER};color:${token.text}}\n[data-tl-toggle]:focus-visible{outline:2px solid ${FOCUS};outline-offset:1px}\n[data-tl-toggle=\"mute\"][aria-pressed=\"true\"]{background:var(--uikit-value, #cfd6df);border-color:var(--uikit-value, #cfd6df);color:${token.surface}}\n[data-tl-toggle=\"solo\"][aria-pressed=\"true\"]{background:${token.accent};border-color:${token.accent};color:${token.surface}}\n[data-tl-legend-item][data-silent] [data-tl-legend-swatch]{opacity:.35}\n[data-tl-legend-item][data-silent] [data-tl-legend-label]{color:${token.textMuted}}\n@media (prefers-reduced-motion: no-preference){@keyframes uikit-tl-note-flash{0%{fill:#fff;fill-opacity:1}100%{fill:var(--tl-note-color);fill-opacity:var(--tl-note-opacity)}}[data-tl-note-flash]{animation:uikit-tl-note-flash 1s ease-out both}[data-tl-toggle]{transition:background-color 120ms ease-out,color 120ms ease-out,border-color 120ms ease-out}[data-tl-stale-layer]{transition:opacity 160ms ease-out}}\n`.replace(/\\n/g, \"\")\n\n/** The stylesheet as a hoisted `<style>`: one copy per document, on the server and in the browser. */\nexport function TimelineRollStyles(): ReactElement {\n  return createElement(\"style\", { href: TIMELINE_ROLL_STYLES_HREF, precedence: \"default\" }, TIMELINE_ROLL_STYLES)\n}\n\n/* ------------------------------------------------------------ test probe */\n\nlet staticRenderProbe: (() => void) | null = null\n\n/**\n * Test hook: `probe` is called each time a static layer (gutter or plot)\n * renders, so a test can show that a clock tick renders neither. Pass null to\n * remove it.\n */\nexport function setTimelineRollRenderProbe(probe: (() => void) | null): void {\n  staticRenderProbe = probe\n}\n\n/* ---------------------------------------------------------- reduced motion */\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReducedMotion(listener: () => void): () => void {\n  if (typeof globalThis.matchMedia !== \"function\") return () => undefined\n  const query = globalThis.matchMedia(REDUCED_MOTION)\n  query.addEventListener?.(\"change\", listener)\n  return () => query.removeEventListener?.(\"change\", listener)\n}\n\nfunction prefersReducedMotion(): boolean {\n  return typeof globalThis.matchMedia === \"function\" && globalThis.matchMedia(REDUCED_MOTION).matches\n}\n\n/** Whether the reader asked for reduced motion; false on the server. */\nexport function usePrefersReducedMotion(): boolean {\n  return useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, () => false)\n}\n\nconst useIsomorphicLayoutEffect = typeof document === \"undefined\" ? useEffect : useLayoutEffect\n\n/* ------------------------------------------------------------------ model */\n\ninterface PathGroup {\n  readonly key: string\n  readonly color: string\n  readonly opacity: number\n  readonly d: string\n  readonly flashId?: string | number\n}\n\ninterface RollModel {\n  readonly low: number\n  readonly high: number\n  readonly span: number\n  readonly drums: readonly number[]\n  readonly pitchedCount: number\n  readonly rows: string\n  readonly sectionRows: number\n  readonly frequencyScale: SpectrogramFrequencyScale\n  readonly pitchCenters: readonly { midi: number; y: number }[]\n}\n\n/** Notes split into pitched and drum, with each note's track (a missing track draws in slot 0, pitched). */\nfunction splitNotes(notes: readonly TimelineRollNote[], tracks: readonly TimelineRollTrack[]) {\n  const byId = new Map(tracks.map((track) => [track.id, track]))\n  const pitched: TimelineRollNote[] = []\n  const drums: TimelineRollNote[] = []\n  for (const note of notes) (byId.get(note.track)?.drum ? drums : pitched).push(note)\n  return { byId, pitched, drums }\n}\n\n/** One path per (colour, velocity step, dimmed, flash) group, in colour-slot order. */\nfunction groupPaths(\n  notes: readonly TimelineRollNote[],\n  byId: ReadonlyMap<string, TimelineRollTrack>,\n  soloed: readonly string[],\n  rowOf: (note: TimelineRollNote) => number | null,\n  size: { readonly minWidth: number; readonly gap: number; readonly inset?: number }\n): PathGroup[] {\n  const groups = new Map<\n    string,\n    { slot: number; color: string; opacity: number; flashId?: string | number; notes: TimelineRollNote[] }\n  >()\n  for (const note of notes) {\n    const track = byId.get(note.track)\n    const slot = track?.colorIndex ?? 0\n    const dimmed = track ? !isTrackAudible(track, soloed) : false\n    const opacity = velocityStep(note.velocity) * (dimmed ? DIMMED_OPACITY : 1)\n    const flashId = note.flash ? note.flashId ?? \"\" : undefined\n    const key = `${slot}|${opacity}${note.flash ? `|flash:${String(flashId)}` : \"\"}`\n    const group = groups.get(key)\n    if (group) group.notes.push(note)\n    else groups.set(key, {\n      slot,\n      color: trackColor(slot),\n      opacity,\n      ...(note.flash ? { flashId: note.flashId ?? \"\" } : {}),\n      notes: [note],\n    })\n  }\n  return [...groups]\n    .sort(([, a], [, b]) => a.slot - b.slot || a.opacity - b.opacity)\n    .map(([key, { color, opacity, flashId, notes: members }]) => ({\n      key,\n      color,\n      opacity,\n      ...(flashId === undefined ? {} : { flashId }),\n      d: notePath(members, rowOf, size),\n    }))\n}\n\n/* ----------------------------------------------------------------- gutter */\n\ninterface GutterProps {\n  readonly leadingLane?: TimelineRollLane\n  readonly model: RollModel\n  readonly drumLabel: (midi: number) => string\n  readonly highlightedDrumMidi?: number | null\n  readonly onDrumHoverChange?: (midi: number | null) => void\n}\n\nconst Gutter = memo(function Gutter({ model, drumLabel, leadingLane, highlightedDrumMidi, onDrumHoverChange }: GutterProps): JSX.Element {\n  staticRenderProbe?.()\n  const { low, high, span, drums, rows } = model\n  const octaves: number[] = []\n  for (let midi = high; midi >= low; midi -= 1) if (((midi % 12) + 12) % 12 === 0) octaves.push(midi)\n  return (\n    <div\n      data-tl-gutter=\"\"\n      aria-hidden={onDrumHoverChange ? undefined : true}\n      style={{\n        position: \"sticky\",\n        left: 0,\n        zIndex: 1,\n        flex: `0 0 ${GUTTER_PX}px`,\n        boxSizing: \"border-box\",\n        display: \"grid\",\n        gridTemplateRows: rows,\n        minHeight: 0,\n        borderRight: `1px solid ${token.border}`,\n        background: token.surface,\n        fontFamily: MONO,\n        fontSize: LABEL_FONT_PX,\n        lineHeight: 1,\n        color: token.textMuted,\n      }}\n    >\n      {leadingLane ? <div style={{ display: \"grid\", placeItems: \"center\" }}>{leadingLane.label}</div> : null}\n      <div style={{ borderBottom: `1px solid ${token.border}` }} />\n      <div />\n      <div data-tl-pitch-labels=\"\" style={{ position: \"relative\", minHeight: 0, overflow: \"hidden\" }}>\n        <svg\n          viewBox={`0 0 1 ${span}`}\n          preserveAspectRatio=\"none\"\n          width=\"100%\"\n          height=\"100%\"\n          style={{ position: \"absolute\", inset: 0, display: \"block\" }}\n        >\n          <path d={accidentalRowsPath(low, high, 1, model.frequencyScale)} fill={KEY_SHADE} />\n          {model.pitchCenters.map(({ midi, y }) => (\n            <g key={midi} data-tl-pitch-key={midi}>\n              <desc>{`${midiToNoteName(midi)} · MIDI ${midi} · ${Number(midiToHz(midi).toFixed(2))} Hz`}</desc>\n              <rect x={0} y={y - 0.5} width={1} height={1} fill=\"transparent\" />\n              <path d={`M0.9 ${y}H1`} stroke={token.textMuted} strokeOpacity={0.5} strokeWidth={0.5} vectorEffect=\"non-scaling-stroke\" />\n            </g>\n          ))}\n        </svg>\n        {model.pitchedCount > 0 || drums.length === 0\n          ? octaves.map((midi, index) => (\n              <span\n                key={midi}\n                data-tl-pitch-label=\"\"\n                data-alt={index % 2 === 1 ? \"\" : undefined}\n                style={{\n                  position: \"absolute\",\n                  right: 5,\n                  // Centred on its row, kept inside the box at the edges.\n                  top: `clamp(0px, calc(${model.frequencyScale.midiToY(midi) / span * 100}% - ${LABEL_FONT_PX / 2}px), calc(100% - ${LABEL_FONT_PX}px))`,\n                  pointerEvents: \"none\",\n                }}\n              >\n                {`C${Math.floor(midi / 12) - 1}`}\n              </span>\n            ))\n          : null}\n      </div>\n      {drums.length > 0 ? (\n        <div\n          style={{\n            display: \"grid\",\n            gridTemplateRows: `repeat(${drums.length}, 1fr)`,\n            minHeight: 0,\n            borderTop: `1px solid ${token.border}`,\n            fontFamily: token.font,\n          }}\n        >\n          {drums.map((midi) => (\n            <span\n              key={midi}\n              data-tl-drum-label={midi}\n              data-highlighted={highlightedDrumMidi === midi ? \"\" : undefined}\n              tabIndex={onDrumHoverChange ? 0 : undefined}\n              aria-label={onDrumHoverChange ? `${drumLabel(midi)}, MIDI ${midi}` : undefined}\n              onPointerEnter={onDrumHoverChange ? () => onDrumHoverChange(midi) : undefined}\n              onPointerLeave={onDrumHoverChange ? () => onDrumHoverChange(null) : undefined}\n              onFocus={onDrumHoverChange ? () => onDrumHoverChange(midi) : undefined}\n              onBlur={onDrumHoverChange ? () => onDrumHoverChange(null) : undefined}\n              style={{\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"flex-end\",\n                minHeight: 0,\n                padding: \"0 4px 0 5px\",\n                overflow: \"hidden\",\n                textOverflow: \"ellipsis\",\n                whiteSpace: \"nowrap\",\n                textAlign: \"right\",\n              }}\n            >\n              {drumLabel(midi)}\n            </span>\n          ))}\n        </div>\n      ) : (\n        <div />\n      )}\n    </div>\n  )\n})\n\n/* ------------------------------------------------------------------- plot */\n\ninterface PlotProps {\n  readonly leadingLane?: TimelineRollLane\n  readonly spectrogram?: PreparedSpectrogramData\n  readonly noteOpacity: number\n  readonly highlightedDrumMidi?: number | null\n  readonly model: RollModel\n  readonly notes: readonly TimelineRollNote[]\n  readonly tracks: readonly TimelineRollTrack[]\n  readonly soloed: readonly string[]\n  readonly sections: readonly SectionBand[]\n  readonly durationSeconds: number\n  readonly cycles: number\n  readonly rulerUnit?: \"cycles\" | \"seconds\"\n  readonly view: TimelineRollView\n  readonly viewportPx: number\n  readonly chunk: number\n  readonly label: string\n}\n\nconst Plot = memo(function Plot(props: PlotProps): JSX.Element {\n  staticRenderProbe?.()\n  const { model, durationSeconds, cycles, view, viewportPx, sections } = props\n  const duration = durationSeconds > 0 ? durationSeconds : 1\n  const plotPx = view === \"detail\" ? Math.max(viewportPx, cycles * DETAIL_CYCLE_PX) : viewportPx\n  const pxPerSecond = plotPx / duration\n  const steps = useMemo(() => gridSteps(plotPx / cycles), [plotPx, cycles])\n\n  const { pitchedGroups, drumGroups } = useMemo(() => {\n    const { byId, pitched, drums } = splitNotes(props.notes, props.tracks)\n    const pick = (list: readonly TimelineRollNote[], share: number) => {\n      if (view === \"fit\") return sampleNotes(list, Math.round(FIT_NOTE_LIMIT * share))\n      const { from, to } = detailWindow(props.chunk, viewportPx, plotPx, duration)\n      return cullNotes(list, from, to, Math.round(DETAIL_NOTE_LIMIT * share))\n    }\n    const total = Math.max(1, pitched.length + drums.length)\n    const size = { minWidth: 1.5 / pxPerSecond, gap: 1 / pxPerSecond }\n    const drumIndex = new Map(model.drums.map((midi, index) => [midi, index]))\n    return {\n      pitchedGroups: groupPaths(pick(pitched, pitched.length / total), byId, props.soloed,\n        (note) => (note.midi >= model.low && note.midi <= model.high ? model.frequencyScale.midiToY(note.midi) - 0.5 : null), size),\n      drumGroups: groupPaths(pick(drums, drums.length / total), byId, props.soloed,\n        (note) => drumIndex.get(Math.round(note.midi)) ?? null, { ...size, inset: 0.18 }),\n    }\n  }, [props.notes, props.tracks, props.soloed, props.chunk, view, viewportPx, plotPx, pxPerSecond, duration, model])\n\n  const pitchedGrid = useMemo(() => gridPaths(cycles, duration, model.span, steps), [cycles, duration, model.span, steps])\n  const drumGrid = useMemo(() => gridPaths(cycles, duration, model.drums.length, steps), [cycles, duration, model.drums.length, steps])\n  const highlightedDrumRow = props.highlightedDrumMidi == null ? -1 : model.drums.indexOf(props.highlightedDrumMidi)\n\n  const cycleLabels: number[] = []\n  for (let cycle = 0; cycle < cycles; cycle += steps.labelStep) cycleLabels.push(cycle)\n  const percent = (seconds: number) => `${(Math.max(0, Math.min(duration, seconds)) / duration) * 100}%`\n\n  const grid = (paths: ReturnType<typeof gridPaths>) => (\n    <>\n      {paths.beats ? <path d={paths.beats} stroke={GRID} strokeOpacity={0.35} strokeWidth={0.5} vectorEffect=\"non-scaling-stroke\" /> : null}\n      {paths.cycles ? <path d={paths.cycles} stroke={GRID} strokeOpacity={0.7} strokeWidth={1} vectorEffect=\"non-scaling-stroke\" /> : null}\n      {paths.phrases ? <path d={paths.phrases} stroke={token.textMuted} strokeOpacity={0.55} strokeWidth={1.2} vectorEffect=\"non-scaling-stroke\" /> : null}\n    </>\n  )\n  const layer: CSSProperties = { position: \"absolute\", inset: 0, display: \"block\", width: \"100%\", height: \"100%\", overflow: \"hidden\" }\n\n  return (\n    <div\n      data-tl-plot=\"\"\n      role={props.leadingLane || props.spectrogram ? \"group\" : \"img\"}\n      aria-label={props.label}\n      style={{ display: \"grid\", gridTemplateRows: model.rows, height: \"100%\", minHeight: 0 }}\n    >\n      {props.leadingLane ? <div data-tl-leading-lane=\"\" style={{ minWidth: 0, overflow: \"hidden\" }}>{props.leadingLane.content}</div> : null}\n      <div\n        data-tl-ruler=\"\"\n        style={{ position: \"relative\", borderBottom: `1px solid ${token.border}`, overflow: \"hidden\" }}\n      >\n        {cycleLabels.map((cycle) => (\n          <span\n            key={cycle}\n            style={{\n              position: \"absolute\",\n              left: `${(cycle / cycles) * 100}%`,\n              top: 0,\n              paddingLeft: 3,\n              lineHeight: `${RULER_PX - 1}px`,\n              fontFamily: MONO,\n              fontSize: LABEL_FONT_PX,\n              color: token.textMuted,\n              borderLeft: cycle % 4 === 0 ? `1px solid ${GRID}` : undefined,\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {props.rulerUnit === \"seconds\" ? `${Number((cycle / cycles * duration).toFixed(2))}s` : cycle + 1}\n          </span>\n        ))}\n      </div>\n      <div data-tl-sections=\"\" style={{ position: \"relative\", overflow: \"hidden\" }}>\n        {sections.map((band, index) => (\n          <span\n            key={`${band.row}-${band.startSeconds}-${index}`}\n            data-tl-section=\"\"\n            style={{\n              position: \"absolute\",\n              left: percent(band.startSeconds),\n              width: `calc(${percent(band.endSeconds - band.startSeconds)} - 1px)`,\n              top: band.row * SECTION_ROW_PX + 1,\n              height: SECTION_ROW_PX - 2,\n              boxSizing: \"border-box\",\n              padding: \"0 3px\",\n              borderRadius: 2,\n              background: index % 2 === 1 ? token.cap : RAISED,\n              boxShadow: `inset 0 0 0 1px ${token.border}`,\n              color: token.text,\n              fontFamily: token.font,\n              fontSize: LABEL_FONT_PX,\n              lineHeight: `${SECTION_ROW_PX - 2}px`,\n              overflow: \"hidden\",\n              textOverflow: \"ellipsis\",\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {band.name}\n          </span>\n        ))}\n      </div>\n      <div style={{ position: \"relative\", minHeight: 0 }}>\n        {props.spectrogram ? <Spectrogram\n          mode=\"prepared\"\n          prepared={props.spectrogram}\n          durationSeconds={duration}\n          minFrequency={model.frequencyScale.minFrequency}\n          maxFrequency={model.frequencyScale.maxFrequency}\n          scale=\"log\"\n          colorScale=\"magma\"\n          showAxis={false}\n          width=\"100%\"\n          height=\"100%\"\n          aria-label=\"Source audio energy aligned with detected notes\"\n          style={{ position: \"absolute\", inset: 0, border: 0, borderRadius: 0, opacity: 0.5, pointerEvents: \"none\" }}\n        /> : null}\n        <svg data-tl-notes=\"pitched\" viewBox={`0 0 ${duration} ${model.span}`} preserveAspectRatio=\"none\"\n          data-frequency-min={model.frequencyScale.minFrequency}\n          data-frequency-max={model.frequencyScale.maxFrequency}\n          style={layer} aria-hidden=\"true\">\n          <path d={accidentalRowsPath(model.low, model.high, duration, model.frequencyScale)} fill={KEY_SHADE} fillOpacity={0.5} />\n          {grid(pitchedGrid)}\n          {props.spectrogram ? <path\n            data-tl-frequency-guides=\"\"\n            d={model.pitchCenters.map(({ y }) => `M0 ${y}H${duration}`).join(\"\")}\n            stroke={token.textMuted}\n            strokeOpacity={0.2}\n            strokeWidth={0.5}\n            vectorEffect=\"non-scaling-stroke\"\n          /> : null}\n          <g data-tl-note-emphasis=\"\" opacity={props.noteOpacity}>{pitchedGroups.map((group) => (\n            <path\n              key={group.key}\n              d={group.d}\n              fill={group.color}\n              fillOpacity={group.opacity}\n              style={group.flashId === undefined ? undefined : {\n                \"--tl-note-color\": group.color,\n                \"--tl-note-opacity\": String(group.opacity),\n              } as CSSProperties}\n              {...(group.flashId === undefined\n                ? {}\n                : { \"data-tl-note-flash\": \"\", \"data-tl-flash-id\": group.flashId })}\n            />\n          ))}</g>\n        </svg>\n      </div>\n      {model.drums.length > 0 ? (\n        <div style={{ position: \"relative\", minHeight: 0, borderTop: `1px solid ${token.border}` }}>\n          <svg data-tl-notes=\"drums\" viewBox={`0 0 ${duration} ${model.drums.length}`} preserveAspectRatio=\"none\" style={layer} aria-hidden=\"true\">\n            {highlightedDrumRow >= 0 ? (\n              <rect\n                data-tl-drum-highlight={props.highlightedDrumMidi}\n                x={0}\n                y={highlightedDrumRow}\n                width={duration}\n                height={1}\n                fill={token.accent}\n                fillOpacity={0.16}\n              />\n            ) : null}\n            {grid(drumGrid)}\n            <g data-tl-note-emphasis=\"\" opacity={props.noteOpacity}>{drumGroups.map((group) => (\n              <path\n                key={group.key}\n                d={group.d}\n                fill={group.color}\n                fillOpacity={group.opacity}\n                style={group.flashId === undefined ? undefined : {\n                  \"--tl-note-color\": group.color,\n                  \"--tl-note-opacity\": String(group.opacity),\n                } as CSSProperties}\n                {...(group.flashId === undefined\n                  ? {}\n                  : { \"data-tl-note-flash\": \"\", \"data-tl-flash-id\": group.flashId })}\n              />\n            ))}</g>\n          </svg>\n        </div>\n      ) : (\n        <div />\n      )}\n    </div>\n  )\n})\n\n/* --------------------------------------------------------------- playhead */\n\ninterface PlayheadProps {\n  readonly restingPositionSeconds?: number\n  readonly clock: PlaybackClock\n  readonly durationSeconds: number\n  readonly follow: boolean\n  readonly scrollerRef: RefObject<HTMLDivElement | null>\n  readonly pausedUntil: RefObject<number>\n}\n\n/** The only part that renders per frame: one line, moved by transform. */\nfunction Playhead({ clock, durationSeconds, follow, scrollerRef, pausedUntil, restingPositionSeconds }: PlayheadProps): JSX.Element | null {\n  const position = usePlaybackPosition(clock) ?? restingPositionSeconds ?? null\n  const fraction = position === null || !(durationSeconds > 0) ? null : Math.max(0, Math.min(1, position / durationSeconds))\n\n  useEffect(() => {\n    const scroller = scrollerRef.current\n    if (!follow || fraction === null || !scroller) return\n    if ((globalThis.performance?.now() ?? Date.now()) < pausedUntil.current) return\n    // The sticky gutter covers the viewport's left edge: follow in plot coordinates.\n    const plotPx = scroller.scrollWidth - GUTTER_PX\n    const next = followScroll(fraction * plotPx, scroller.scrollLeft, scroller.clientWidth - GUTTER_PX)\n    if (next !== null) scroller.scrollLeft = next\n  }, [fraction, follow, scrollerRef, pausedUntil])\n\n  if (fraction === null) return null\n  return (\n    <div aria-hidden=\"true\" style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", pointerEvents: \"none\" }}>\n      <div data-tl-playhead=\"\" style={{ position: \"absolute\", inset: 0, transform: `translateX(${fraction * 100}%)` }}>\n        <div style={{ position: \"absolute\", top: 0, bottom: 0, left: -0.75, width: 1.5, background: PLAYHEAD }} />\n      </div>\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------- roll */\n\nfunction TimelineRollImpl(props: TimelineRollProps): JSX.Element {\n  const { notes, tracks, durationSeconds, view, clock, stale = false, drumLabel = gmDrumName } = props\n  const cycles = Math.max(1, Math.round(props.cycles) || 1)\n  const sections = props.sections ?? EMPTY_SECTIONS\n  const soloed = props.soloed ?? EMPTY_IDS\n  const reducedMotion = usePrefersReducedMotion()\n  const follow = view === \"detail\" && (props.follow ?? !reducedMotion)\n\n  const scrollerRef = useRef<HTMLDivElement | null>(null)\n  const pausedUntil = useRef(0)\n  const gesture = useRef<{ pointerId: number; x: number; y: number; activate: boolean; dragged: boolean } | null>(null)\n  const [viewportPx, setViewportPx] = useState(DEFAULT_VIEWPORT_PX)\n  const [chunk, setChunk] = useState(0)\n\n  const laneHeight = props.leadingLane?.height\n  const model = useMemo<RollModel>(() => {\n    const { pitched, drums } = splitNotes(notes, tracks)\n    const { low, high, span, frequencyScale } = timelinePitchGeometry(pitched, props.spectrogram)\n    const drumList = drumRows(drums)\n    const sectionRows = sectionRowCount(sections)\n    // Drum rows are 12px, down to 10px when the roll is short; pitched rows take the rest.\n    const pitchedRow = pitched.length === 0 && drumList.length > 0 ? \"0px\" : `minmax(${MIN_PITCHED_PX}px, 1fr)`\n    const drumRow = drumList.length === 0 ? \"0px\"\n      : pitched.length === 0 ? `minmax(${drumList.length * DRUM_ROW_PX}px, 1fr)`\n      : `minmax(${drumList.length * MIN_DRUM_ROW_PX}px, ${drumList.length * DRUM_ROW_PX}px)`\n    return {\n      low,\n      high,\n      span,\n      frequencyScale,\n      pitchCenters: Array.from({ length: Math.max(0, high - low + 1) }, (_, row) => {\n        const midi = high - row\n        return { midi, y: frequencyScale.midiToY(midi) }\n      }),\n      drums: drumList,\n      pitchedCount: pitched.length,\n      sectionRows,\n      rows: `${laneHeight === undefined ? \"\" : `${laneHeight}px `}${RULER_PX}px ${sectionRows * SECTION_ROW_PX}px ${pitchedRow} ${drumRow}`,\n    }\n  }, [notes, tracks, sections, laneHeight, props.spectrogram])\n\n  const label = props[\"aria-label\"] ?? describeTimeline({\n    noteCount: notes.length,\n    trackCount: tracks.length,\n    cycles,\n    durationSeconds,\n    sections,\n    stale,\n    rulerUnit: props.rulerUnit,\n  })\n\n  // Measure the viewport in steps, so a resize re-renders only per step.\n  useIsomorphicLayoutEffect(() => {\n    const scroller = scrollerRef.current\n    if (!scroller) return\n    const measure = () => {\n      const width = scroller.clientWidth - GUTTER_PX\n      if (width > 0) setViewportPx(Math.max(WIDTH_STEP_PX, Math.round(width / WIDTH_STEP_PX) * WIDTH_STEP_PX))\n    }\n    measure()\n    if (typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(measure)\n    observer.observe(scroller)\n    return () => observer.disconnect()\n  }, [])\n\n  // A new view or timeline starts at the beginning.\n  useEffect(() => {\n    const scroller = scrollerRef.current\n    if (scroller) scroller.scrollLeft = 0\n    setChunk(0)\n  }, [view, durationSeconds, cycles])\n\n  const pauseFollow = () => {\n    pausedUntil.current = (globalThis.performance?.now() ?? Date.now()) + FOLLOW_PAUSE_MS\n  }\n  const detail = view === \"detail\"\n\n  return (\n    <div\n      data-uikit-timeline-roll=\"\"\n      data-view={view}\n      data-stale={stale ? \"\" : undefined}\n      className={props.className}\n      style={{\n        position: \"relative\",\n        height: \"100%\",\n        minHeight: (laneHeight ?? 0) + RULER_PX + MIN_PITCHED_PX + 2,\n        minWidth: 0,\n        boxSizing: \"border-box\",\n        overflow: \"hidden\",\n        border: `1px solid ${token.border}`,\n        borderRadius: 6,\n        background: token.surface,\n        color: token.text,\n      }}\n    >\n      <TimelineRollStyles />\n      <div data-tl-stale-layer=\"\" style={{ height: \"100%\", opacity: stale ? 0.5 : 1 }}>\n        <div\n          ref={scrollerRef}\n          data-tl-scroller=\"\"\n          tabIndex={detail ? 0 : undefined}\n          role={detail ? \"group\" : undefined}\n          aria-label={detail ? \"Timeline, scrolls sideways\" : undefined}\n          onScroll={detail ? (event) => setChunk(scrollChunk(event.currentTarget.scrollLeft, viewportPx)) : undefined}\n          onWheel={detail ? pauseFollow : undefined}\n          onPointerDown={detail ? pauseFollow : undefined}\n          onTouchStart={detail ? pauseFollow : undefined}\n          onKeyDown={detail ? pauseFollow : undefined}\n          style={{ height: \"100%\", overflowX: detail ? \"auto\" : \"hidden\", overflowY: \"hidden\" }}\n        >\n          {/* The gutter scrolls with the plot but sticks to the left, so both share one height, scrollbar or not. */}\n          <div\n            style={{\n              display: \"flex\",\n              height: \"100%\",\n              width: detail ? `max(100%, ${GUTTER_PX + cycles * DETAIL_CYCLE_PX}px)` : \"100%\",\n            }}\n          >\n            <Gutter model={model} drumLabel={drumLabel} leadingLane={props.leadingLane} highlightedDrumMidi={props.highlightedDrumMidi} onDrumHoverChange={props.onDrumHoverChange} />\n            <div style={{ position: \"relative\", flex: \"1 1 auto\", minWidth: 0 }}>\n              <Plot\n                highlightedDrumMidi={props.highlightedDrumMidi}\n                leadingLane={props.leadingLane}\n                spectrogram={props.spectrogram}\n                noteOpacity={Number.isFinite(props.noteOpacity) ? Math.max(0, Math.min(1, props.noteOpacity!)) : 1}\n                model={model}\n                notes={notes}\n                tracks={tracks}\n                soloed={soloed}\n                sections={sections}\n                durationSeconds={durationSeconds}\n                cycles={cycles}\n                rulerUnit={props.rulerUnit}\n                view={view}\n                viewportPx={viewportPx}\n                chunk={detail ? chunk : 0}\n                label={label}\n              />\n              {props.onSeek || props.plotActivation ? (\n                <div\n                  data-tl-seek=\"\"\n                  role={props.onSeek ? \"slider\" : \"button\"}\n                  aria-label={props.onSeek ? \"Seek MIDI timeline\" : props.plotActivation?.label}\n                  aria-description={props.onSeek ? props.plotActivation?.label : undefined}\n                  aria-valuemin={props.onSeek ? 0 : undefined}\n                  aria-valuemax={props.onSeek ? durationSeconds : undefined}\n                  aria-valuenow={props.onSeek ? props.restingPositionSeconds ?? 0 : undefined}\n                  tabIndex={0}\n                  style={{ position: \"absolute\", inset: 0, top: laneHeight ?? 0, cursor: props.plotActivation ? \"pointer\" : \"crosshair\", touchAction: \"none\" }}\n                  onPointerDown={(event) => {\n                    if (event.button !== 0 || gesture.current) return\n                    event.preventDefault()\n                    event.currentTarget.focus()\n                    event.currentTarget.setPointerCapture(event.pointerId)\n                    pauseFollow()\n                    const rect = event.currentTarget.getBoundingClientRect()\n                    const activate = Boolean(props.plotActivation) && event.clientY - rect.top >= RULER_PX + model.sectionRows * SECTION_ROW_PX\n                    gesture.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, activate, dragged: false }\n                    if (!activate) props.onSeek?.(Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * durationSeconds)\n                  }}\n                  onPointerMove={(event) => {\n                    const active = gesture.current\n                    if (!active || active.pointerId !== event.pointerId) return\n                    active.dragged ||= Math.hypot(event.clientX - active.x, event.clientY - active.y) > 4\n                    if (active.activate && !active.dragged) return\n                    const rect = event.currentTarget.getBoundingClientRect()\n                    props.onSeek?.(Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)) * durationSeconds)\n                  }}\n                  onPointerUp={(event) => {\n                    const active = gesture.current\n                    if (!active || active.pointerId !== event.pointerId) return\n                    gesture.current = null\n                    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)\n                    const rect = event.currentTarget.getBoundingClientRect()\n                    const inside = event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom\n                    if (active.activate && !active.dragged && inside && Math.hypot(event.clientX - active.x, event.clientY - active.y) <= 4) props.plotActivation?.onActivate()\n                  }}\n                  onPointerCancel={(event) => {\n                    if (gesture.current?.pointerId === event.pointerId) gesture.current = null\n                  }}\n                  onLostPointerCapture={(event) => {\n                    if (gesture.current?.pointerId === event.pointerId) gesture.current = null\n                  }}\n                  onClick={(event) => {\n                    // Assistive technology may activate without a pointer gesture.\n                    if (event.detail === 0) props.plotActivation?.onActivate()\n                  }}\n                  onKeyDown={(event) => {\n                    if (props.plotActivation && (event.key === \"Enter\" || event.key === \" \")) {\n                      event.preventDefault()\n                      if (!event.repeat) props.plotActivation.onActivate()\n                      return\n                    }\n                    if (!props.onSeek) return\n                    const current = props.restingPositionSeconds ?? 0\n                    const step = event.shiftKey ? 1 : 0.1\n                    const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? durationSeconds\n                      : event.key === \"ArrowRight\" ? current + step : event.key === \"ArrowLeft\" ? current - step : undefined\n                    if (next === undefined) return\n                    event.preventDefault()\n                    pauseFollow()\n                    props.onSeek?.(Math.max(0, Math.min(durationSeconds, next)))\n                  }}\n                />\n              ) : null}\n              <Playhead\n                clock={clock}\n                durationSeconds={durationSeconds}\n                follow={follow}\n                scrollerRef={scrollerRef}\n                pausedUntil={pausedUntil}\n                restingPositionSeconds={props.restingPositionSeconds}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n      {stale ? (\n        <span\n          data-tl-stale=\"\"\n          style={{\n            position: \"absolute\",\n            top: 1,\n            right: 4,\n            padding: \"0 4px\",\n            borderRadius: 3,\n            background: token.surface,\n            color: token.text,\n            fontFamily: token.font,\n            fontSize: LABEL_FONT_PX,\n            lineHeight: `${RULER_PX - 3}px`,\n            pointerEvents: \"none\",\n          }}\n        >\n          previous\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\nconst EMPTY_SECTIONS: readonly SectionBand[] = []\nconst EMPTY_IDS: readonly string[] = []\n\n/** Read-only multitrack piano roll; see the file header. */\nexport const TimelineRoll = memo(TimelineRollImpl)\n",
      "type": "registry:component",
      "target": "@ui/timeline-roll.tsx"
    },
    {
      "path": "components/ui/timeline-roll-legend.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/timeline-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 * TimelineRollLegend — the tracks of a TimelineRoll in one compact row: a\n * swatch in the track's colour and its name, and, when the host passes the\n * handlers, a mute and a solo toggle per track.\n *\n *   <TimelineRollLegend tracks={tracks} soloed={soloed}\n *     onToggleMute={toggleMute} onToggleSolo={toggleSolo}>\n *     <ViewToggle /> <ExportButton />\n *   </TimelineRollLegend>\n *\n * - The toggles are buttons with `aria-pressed`, at least 24px square (the\n *   `--uikit-control-h-sm` height), named \"Mute keys\" and \"Solo keys\".\n * - Alt-click (Option-click) on a name solos that track, the DAW shortcut; the\n *   solo button is the way to do it from the keyboard.\n * - Below 480px of legend width the names give way to channel numbers (the\n *   swatch already tells tracks apart); the name stays for screen readers.\n * - `children` go at the end of the same row, for the host's view toggle and\n *   export, so the roll needs one bar, not two.\n *\n * A track that does not sound (muted, or not soloed while others are) draws\n * its swatch faded and its name muted. Mute and solo change what the host\n * plays; the legend only reports and asks.\n */\n\nimport type { JSX, MouseEvent, ReactNode } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { ConsoleIcon } from \"maruaudio/ui/shared/console-icons\"\nimport { TimelineRollStyles } from \"@/components/ui/timeline-roll\"\nimport {\n  isTrackAudible,\n  trackColor,\n  type TimelineRollTrack,\n} from \"maruaudio/ui/shared/timeline-roll-math\"\n\nexport interface TimelineRollLegendProps {\n  /** Display each track’s note count next to its name. Keeps names visible in narrow legends. */\n  readonly showNoteCounts?: boolean\n  readonly tracks: readonly TimelineRollTrack[]\n  /** Shows a mute toggle per track. */\n  readonly onToggleMute?: (trackId: string) => void\n  /** Shows a solo toggle per track, and makes Alt-click on a name solo it. */\n  readonly onToggleSolo?: (trackId: string) => void\n  /** Track ids soloed. */\n  readonly soloed?: readonly string[]\n  /** Name of the track list. Defaults to \"Tracks\". */\n  readonly \"aria-label\"?: string\n  /** Host controls at the end of the row (view toggle, export). */\n  readonly children?: ReactNode\n  readonly className?: string\n}\n\nconst TOGGLE = \"var(--uikit-control-h-sm, 24px)\"\n\nexport function TimelineRollLegend({\n  tracks,\n  showNoteCounts = false,\n  onToggleMute,\n  onToggleSolo,\n  soloed = [],\n  \"aria-label\": ariaLabel = \"Tracks\",\n  children,\n  className,\n}: TimelineRollLegendProps): JSX.Element {\n  const soloName = (id: string) => (event: MouseEvent) => {\n    if (!event.altKey || !onToggleSolo) return\n    event.preventDefault()\n    onToggleSolo(id)\n  }\n  return (\n    <div data-uikit-timeline-legend=\"\" data-show-counts={showNoteCounts || undefined} className={className} style={{ minWidth: 0 }}>\n      <TimelineRollStyles />\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 8, minWidth: 0 }}>\n        <ul\n          aria-label={ariaLabel}\n          style={{\n            display: \"flex\",\n            flex: \"1 1 auto\",\n            flexWrap: \"wrap\",\n            alignItems: \"center\",\n            gap: \"2px 12px\",\n            minWidth: 0,\n            margin: 0,\n            padding: 0,\n            listStyle: \"none\",\n            fontFamily: token.font,\n            fontSize: 11,\n            color: token.text,\n          }}\n        >\n          {tracks.map((track) => {\n            const isSoloed = soloed.includes(track.id)\n            const silent = !isTrackAudible(track, soloed)\n            return (\n              <li\n                key={track.id}\n                data-tl-legend-item=\"\"\n                data-silent={silent ? \"\" : undefined}\n                style={{ position: \"relative\", display: \"inline-flex\", alignItems: \"center\", gap: 4, minWidth: 0, minHeight: TOGGLE }}\n              >\n                <span\n                  data-tl-legend-swatch=\"\"\n                  aria-hidden=\"true\"\n                  style={{ flex: \"none\", width: 10, height: 10, borderRadius: 2, background: trackColor(track.colorIndex) }}\n                />\n                <span data-tl-legend-label=\"\" onClick={soloName(track.id)} style={{ minWidth: 0 }}>\n                  <span\n                    data-tl-legend-name=\"\"\n                    style={{\n                      display: \"inline-block\",\n                      maxWidth: \"10rem\",\n                      overflow: \"hidden\",\n                      textOverflow: \"ellipsis\",\n                      whiteSpace: \"nowrap\",\n                      verticalAlign: \"middle\",\n                      fontWeight: 600,\n                    }}\n                  >\n                    {track.name}\n                  </span>\n                  <span data-tl-legend-channel=\"\" aria-hidden=\"true\" style={{ fontFamily: token.fontMono, fontWeight: 600 }}>\n                    {track.channel}\n                  </span>\n                </span>\n                {showNoteCounts && track.noteCount !== undefined ? (\n                  <span data-tl-note-count=\"\" aria-label={`${track.noteCount} notes detected`} aria-live=\"polite\"\n                    style={{ padding: \"1px 6px\", borderRadius: 999, background: token.border, color: token.textMuted, fontVariantNumeric: \"tabular-nums\" }}>\n                    {track.noteCount}\n                  </span>\n                ) : null}\n                {onToggleMute ? (\n                  <LegendToggle\n                    kind=\"mute\"\n                    label={`Mute ${track.name}`}\n                    pressed={Boolean(track.muted)}\n                    onClick={() => onToggleMute(track.id)}\n                  />\n                ) : null}\n                {onToggleSolo ? (\n                  <LegendToggle\n                    kind=\"solo\"\n                    label={`Solo ${track.name}`}\n                    pressed={isSoloed}\n                    onClick={() => onToggleSolo(track.id)}\n                  />\n                ) : null}\n              </li>\n            )\n          })}\n        </ul>\n        {children !== undefined && children !== null ? (\n          <div data-tl-legend-actions=\"\" style={{ display: \"flex\", flex: \"none\", alignItems: \"center\", gap: 4, marginLeft: \"auto\" }}>\n            {children}\n          </div>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n\nfunction LegendToggle({\n  kind,\n  label,\n  pressed,\n  onClick,\n}: {\n  readonly kind: \"mute\" | \"solo\"\n  readonly label: string\n  readonly pressed: boolean\n  readonly onClick: () => void\n}): JSX.Element {\n  return (\n    <button\n      type=\"button\"\n      data-tl-toggle={kind}\n      aria-label={label}\n      aria-pressed={pressed}\n      onClick={onClick}\n      style={{\n        display: \"inline-grid\",\n        placeItems: \"center\",\n        flex: \"none\",\n        width: TOGGLE,\n        height: TOGGLE,\n        padding: 0,\n        borderRadius: 4,\n        cursor: \"pointer\",\n        font: \"inherit\",\n      }}\n    >\n      <ConsoleIcon name={kind} size={12} />\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/timeline-roll-legend.tsx"
    }
  ]
}
