{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-step-grid",
  "type": "registry:ui",
  "title": "Step Grid",
  "description": "The sequencer grid: steps across, lanes down, a page at a time. Tap a cell to toggle it, drag across a row to paint a whole stroke on or off, drag up or down on a lit cell to ride its velocity — with velocity drawn as fill height and brightness both, gate as the fill's width, ghost cells for computed previews, beat-group tinting, page tabs past 16 steps, and a playhead column driven by the host's clock, never by a display timer. Read-only it becomes a mini piano-roll, which is how the arpeggiator draws its computed line through the same cells a sequencer edits.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/step-grid.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/step-grid\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 * StepGrid — the sequencer grid: steps across, lanes down, a page at a time.\n *\n * The one surface every step-driven module ends up needing. Editable, it is\n * the classic hardware grid: tap a cell to toggle it, drag ACROSS a row to\n * paint cells on or off in one stroke, drag UP or DOWN on a lit cell to ride\n * its velocity, with a playhead column the host animates. Read-only, it is a\n * mini piano-roll: the arpeggiator renders its computed line through the same\n * cells the sequencer edits, which is the whole reason the pattern maths and\n * this grid share a lib (`lib/step-pattern` — see\n * todo/ui-kit/surface-primitives.md item 5).\n *\n * ## Who owns what\n *\n * The HOST owns the clock. The playhead is a prop — an absolute step index —\n * exactly as `lfo-visualizer` takes phase: nothing in here schedules, times or\n * animates a transport, so the cursor is wherever the audio actually is, not\n * wherever a display timer drifted to. The host owns the cells too: every\n * edit reports out through `onToggle` / `onVelocity` and the grid holds no\n * copy of the pattern. The only state kept here is view state — which page is\n * showing, which cell has keyboard focus — which belongs to the widget the\n * way a scroll position does.\n *\n * ## Geometry\n *\n * All placement comes from `createStepGridLayout`, which lays the grid out in\n * a fixed coordinate space and converts back with `hitTest`. The DOM maps that\n * space onto its responsive width with percentages, and pointer coordinates\n * are mapped into it before hit-testing — so cell placement and cell picking\n * are the same arithmetic, tested in Node, and a paint-drag cannot disagree\n * with the rectangles it is painting.\n *\n * ## Encodings, and why two at once\n *\n * A cell's VELOCITY is its fill height and its brightness both — by the time\n * a fill is six pixels tall, one cue is not enough to read it by, the same\n * call `delay-tap-display` makes about its pips. A cell's GATE is the fill's\n * width: the note's length inside its own step, drawn as exactly that.\n * `ghost` cells render muted and hollow — a computed preview (an arpeggiator\n * line for an example chord, a pattern about to be pasted) as distinct from\n * pattern truth.\n *\n * Pages follow the hardware convention: 16 steps at a time, tabs when the\n * pattern is longer, and (opt-in) the page follows the playhead so a running\n * sequence stays on screen. Column groups of 4 carry an alternating tint so\n * beats stay countable at a glance.\n *\n * Styling comes from CSS custom properties — see `lib/control-theme` — with a\n * `color` prop to override the accent for one instance. SSR-safe: geometry is\n * pure maths and all DOM access lives in handlers and effects.\n */\n\nimport { useEffect, useMemo, useRef, useState } from \"react\"\nimport type {\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n} from \"react\"\nimport {\n  isAccidentalPitchClass,\n  normalizePitchClass,\n} from \"maruaudio/ui/shared/music-theory-pitch-class\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  createStepGridLayout,\n  dragVelocity,\n  stepColumnOnPage,\n  stepDragMode,\n  stepPageBounds,\n  stepPageCount,\n  stepPageOf,\n  type StepDragMode,\n} from \"maruaudio/ui/shared/step-pattern\"\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/** One lit cell, addressed by absolute step and lane. */\nexport interface StepGridCellState {\n  /** Absolute step index, 0-based across the whole pattern (not the page). */\n  step: number\n  /** Row, 0-based from the top. */\n  lane: number\n  /** 0..1. Fill height and brightness. Defaults to 1. */\n  velocity?: number\n  /** 0..1. Fill width — the note's length inside its step. Defaults to 1. */\n  gate?: number\n  /** Muted, hollow rendering for computed/preview cells. */\n  ghost?: boolean\n  /** Override the accent for this one cell. */\n  color?: string\n}\n\nexport interface StepGridProps {\n  /** Total steps in the pattern, across all pages. */\n  steps: number\n  /** Rows. */\n  lanes: number\n  /** The lit cells. Everything absent is an empty cell. */\n  cells: readonly StepGridCellState[]\n  /** Row captions, top to bottom — note names, drum names. */\n  laneLabels?: readonly string[]\n  /** Optional MIDI pitches for melodic lanes, top to bottom. Supplying these\n   * enables the reusable piano-key or raw-number gutter treatments. */\n  laneMidiNotes?: readonly number[]\n  /** How melodic lane captions are drawn. Defaults to plain text labels. */\n  laneLabelStyle?: \"labels\" | \"numbers\" | \"piano\"\n  /** Lanes currently held by live MIDI input. The gutter and row flash without\n   * changing pattern truth. */\n  activeLanes?: readonly number[]\n\n  /** Absolute step index the host's clock is on, or null for no cursor. */\n  playhead?: number | null\n\n  /**\n   * Toggle a cell. Present = the grid is editable; absent = read-only (no\n   * buttons, no gestures, a summary label instead).\n   */\n  onToggle?: (step: number, lane: number, active: boolean) => void\n  /** Ride a lit cell's velocity (vertical drag / Shift+arrows). Optional even\n   * when editable — a gate-only grid simply omits it. */\n  onVelocity?: (step: number, lane: number, velocity: number) => void\n\n  /** Steps shown at a time. Defaults to 16. */\n  pageSize?: number\n  /** Controlled page. Omit to let the grid keep its own. */\n  page?: number\n  onPageChange?: (page: number) => void\n  /** Flip pages to keep the playhead on screen. Off by default so paging\n   * around a running sequence doesn't fight the cursor. */\n  followPlayhead?: boolean\n\n  /** Columns per beat-group tint. 0 disables. Defaults to 4. */\n  groupSize?: number\n\n  width?: number | string\n  /** Grid surface 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/** Faintest a lit cell's fill is drawn, however low its velocity — the same\n * reasoning as the tap display's pip floor: a mark must stay a mark. */\nconst MIN_CELL_OPACITY = 0.3\n\n/** In-flight gesture bookkeeping. Lives in a ref: pointer moves must not\n * re-render, and the gesture must read its own latest state, not a stale\n * closure's. */\ninterface PressState {\n  step: number\n  lane: number\n  active: boolean\n  startVelocity: number\n  clientX: number\n  clientY: number\n  mode: StepDragMode\n  /** Cells already painted this stroke, so a wiggle doesn't re-toggle them. */\n  painted: Set<string>\n}\n\nexport function StepGrid({\n  steps,\n  lanes,\n  cells,\n  laneLabels,\n  laneMidiNotes,\n  laneLabelStyle = \"labels\",\n  activeLanes = [],\n  playhead = null,\n  onToggle,\n  onVelocity,\n  pageSize = 16,\n  page,\n  onPageChange,\n  followPlayhead = false,\n  groupSize = 4,\n  width = \"100%\",\n  height = 160,\n  color,\n  onInteraction,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: StepGridProps) {\n  const accent = color ?? token.accent\n  const editable = typeof onToggle === \"function\"\n  const activeLaneSet = useMemo(() => new Set(activeLanes), [activeLanes])\n\n  const pages = stepPageCount(steps, pageSize)\n  const [internalPage, setInternalPage] = useState(0)\n  const currentPage =\n    pages === 0 ? 0 : Math.min(pages - 1, Math.max(0, page ?? internalPage))\n  const setPage = (next: number) => {\n    const clamped = pages === 0 ? 0 : Math.min(pages - 1, Math.max(0, next))\n    setInternalPage(clamped)\n    onPageChange?.(clamped)\n  }\n\n  const { start, end } = stepPageBounds(currentPage, steps, pageSize)\n  const pageSteps = end - start\n\n  // Follow the playhead onto its page — an effect rather than render math so\n  // a user's own page flip wins until the cursor actually moves again.\n  const playheadPage =\n    playhead !== null && playhead >= 0 && playhead < steps\n      ? stepPageOf(playhead, pageSize)\n      : null\n  useEffect(() => {\n    if (!followPlayhead || playheadPage === null) return\n    setInternalPage(playheadPage)\n  }, [followPlayhead, playheadPage])\n\n  const layout = useMemo(\n    () => createStepGridLayout({ steps: pageSteps, lanes }),\n    [pageSteps, lanes]\n  )\n\n  // Cells indexed for O(1) gesture and render lookups.\n  const cellMap = useMemo(() => {\n    const map = new Map<string, StepGridCellState>()\n    for (const cell of cells) map.set(`${cell.step}:${cell.lane}`, cell)\n    return map\n  }, [cells])\n  const cellAt = (step: number, lane: number) => cellMap.get(`${step}:${lane}`)\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  /* ------------------------------------------------------------- gestures */\n\n  const surfaceRef = useRef<HTMLDivElement | null>(null)\n  const pressRef = useRef<PressState | null>(null)\n\n  /** Client coordinates -> layout space, via the surface's live rect. */\n  const toLayout = (\n    clientX: number,\n    clientY: number\n  ): { 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) * layout.width,\n      y: ((clientY - rect.top) / rect.height) * layout.height,\n    }\n  }\n\n  const toggleCell = (step: number, lane: number, active: boolean) => {\n    onToggle?.(step, lane, active)\n  }\n\n  const drag = useSurfacePointer(\n    (state) => {\n      const press = pressRef.current\n      if (!press) return\n      if (state.first) {\n        notify(state.event)\n        press.clientX = state.clientX\n        press.clientY = state.clientY\n        return\n      }\n\n      const dx = state.clientX - press.clientX\n      const dy = state.clientY - press.clientY\n\n      if (press.mode === \"tap\") {\n        const decided = stepDragMode(dx, dy)\n        if (decided === \"tap\") return\n        // Velocity only rides a cell that is lit and has somewhere to report;\n        // otherwise any committed drag is a paint stroke.\n        press.mode =\n          decided === \"velocity\" && press.active && onVelocity\n            ? \"velocity\"\n            : \"paint\"\n        if (press.mode === \"paint\") {\n          // The pressed cell is the stroke's first entry, set to the target\n          // state the whole stroke will paint.\n          press.painted.add(`${press.step}:${press.lane}`)\n          toggleCell(press.step, press.lane, !press.active)\n        }\n      }\n\n      if (press.mode === \"velocity\") {\n        onVelocity?.(\n          press.step,\n          press.lane,\n          dragVelocity(press.startVelocity, dy, { fine: state.fine })\n        )\n        return\n      }\n\n      // Paint: whatever cell the pointer is over joins the stroke, once.\n      const point = toLayout(state.clientX, state.clientY)\n      if (!point) return\n      const hit = layout.hitTest(point.x, point.y)\n      if (!hit) return\n      const step = start + hit.column\n      const key = `${step}:${hit.lane}`\n      if (press.painted.has(key)) return\n      press.painted.add(key)\n      const target = !press.active\n      const already = cellAt(step, hit.lane) !== undefined\n      if (already !== target) toggleCell(step, hit.lane, target)\n    },\n    {\n      disabled: !editable,\n      onEnd: () => {\n        const press = pressRef.current\n        pressRef.current = null\n        if (!press) return\n        // A press that never committed to an axis is a tap: toggle on release.\n        if (press.mode === \"tap\")\n          toggleCell(press.step, press.lane, !press.active)\n      },\n    }\n  )\n\n  const cellPointerDown =\n    (step: number, lane: number) => (event: ReactPointerEvent<Element>) => {\n      if (!editable) return\n      const existing = cellAt(step, lane)\n      pressRef.current = {\n        step,\n        lane,\n        active: existing !== undefined && !existing.ghost,\n        startVelocity: existing?.velocity ?? 1,\n        clientX: event.clientX,\n        clientY: event.clientY,\n        mode: \"tap\",\n        painted: new Set(),\n      }\n      setFocus({ step, lane })\n      drag.onPointerDown(event)\n    }\n\n  /* ------------------------------------------------------------- keyboard */\n\n  const [focus, setFocus] = useState({ step: 0, lane: 0 })\n  const focusColumn = stepColumnOnPage(focus.step, currentPage, steps, pageSize)\n\n  const handleKeyDown = (event: KeyboardEvent) => {\n    if (!editable || steps === 0 || lanes === 0) return\n    notify(event)\n    const { step, lane } = focus\n    const existing = cellAt(step, lane)\n    let handled = true\n\n    if (event.key === \"Enter\" || event.key === \" \") {\n      toggleCell(step, lane, !(existing !== undefined && !existing.ghost))\n    } else if (\n      event.shiftKey &&\n      event.key === \"ArrowUp\" &&\n      onVelocity &&\n      existing\n    ) {\n      onVelocity(step, lane, Math.min(1, (existing.velocity ?? 1) + 0.05))\n    } else if (\n      event.shiftKey &&\n      event.key === \"ArrowDown\" &&\n      onVelocity &&\n      existing\n    ) {\n      onVelocity(step, lane, Math.max(0, (existing.velocity ?? 1) - 0.05))\n    } else if (event.key === \"ArrowRight\") {\n      const next = Math.min(steps - 1, step + 1)\n      setFocus({ step: next, lane })\n      if (stepPageOf(next, pageSize) !== currentPage)\n        setPage(stepPageOf(next, pageSize))\n    } else if (event.key === \"ArrowLeft\") {\n      const next = Math.max(0, step - 1)\n      setFocus({ step: next, lane })\n      if (stepPageOf(next, pageSize) !== currentPage)\n        setPage(stepPageOf(next, pageSize))\n    } else if (event.key === \"ArrowDown\") {\n      setFocus({ step, lane: Math.min(lanes - 1, lane + 1) })\n    } else if (event.key === \"ArrowUp\") {\n      setFocus({ step, lane: Math.max(0, lane - 1) })\n    } else if (event.key === \"PageDown\") {\n      setPage(currentPage + 1)\n      setFocus({ step: Math.min(steps - 1, step + (pageSize || 16)), lane })\n    } else if (event.key === \"PageUp\") {\n      setPage(currentPage - 1)\n      setFocus({ step: Math.max(0, step - (pageSize || 16)), lane })\n    } else {\n      handled = false\n    }\n    if (handled) event.preventDefault()\n  }\n\n  // Keep keyboard focus on the DOM cell it points at across page flips.\n  useEffect(() => {\n    if (!editable) return\n    if (focusColumn === null) return\n    const surface = surfaceRef.current\n    const target = surface?.querySelector<HTMLElement>(\n      `[data-cell=\"${focus.step}:${focus.lane}\"]`\n    )\n    if (target && surface?.contains(document.activeElement)) target.focus()\n  }, [editable, focus, focusColumn, currentPage])\n\n  /* -------------------------------------------------------------- drawing */\n\n  const playheadColumn =\n    playhead !== null\n      ? stepColumnOnPage(playhead, currentPage, steps, pageSize)\n      : null\n\n  const pct = (value: number, total: number) => `${(value / total) * 100}%`\n\n  const label =\n    ariaLabel ??\n    `Step grid: ${steps} step${steps === 1 ? \"\" : \"s\"} × ${lanes} lane${\n      lanes === 1 ? \"\" : \"s\"\n    }, ${cells.length} active${editable ? \"\" : \" (read-only)\"}${\n      pages > 1 ? `, page ${currentPage + 1} of ${pages}` : \"\"\n    }`\n\n  const groupTint = (column: number): boolean =>\n    groupSize > 0 && Math.floor((start + column) / groupSize) % 2 === 1\n\n  const hasLaneGutter =\n    (laneLabels && laneLabels.length > 0) ||\n    (laneMidiNotes && laneMidiNotes.length > 0)\n  const laneLabelWidth = hasLaneGutter\n    ? laneLabelStyle === \"piano\"\n      ? 48\n      : 34\n    : 0\n\n  return (\n    <div\n      className={className}\n      style={{ width, fontFamily: token.font, ...style }}\n      role={editable ? \"group\" : \"img\"}\n      aria-label={label}\n    >\n      <div style={{ display: \"flex\", gap: 4 }}>\n        {/* lane captions, on the same vertical arithmetic as the rows */}\n        {laneLabelWidth > 0 && (\n          <div\n            aria-hidden\n            style={{ position: \"relative\", width: laneLabelWidth, height }}\n          >\n            {Array.from({ length: lanes }, (_, lane) => {\n              const rect = layout.cellRect(0, lane)\n              const midi = laneMidiNotes?.[lane]\n              const pitchClass =\n                midi === undefined || !Number.isInteger(midi)\n                  ? -1\n                  : normalizePitchClass(midi)\n              const accidental =\n                pitchClass >= 0 && isAccidentalPitchClass(pitchClass)\n              const active = activeLaneSet.has(lane)\n              const caption =\n                laneLabelStyle === \"numbers\" && midi !== undefined\n                  ? String(midi)\n                  : (laneLabels?.[lane] ?? \"\")\n              return (\n                <span\n                  key={lane}\n                  style={{\n                    position: \"absolute\",\n                    right: laneLabelStyle === \"piano\" ? 0 : 4,\n                    top: pct(rect.y, layout.height),\n                    height: pct(rect.height, layout.height),\n                    display: \"flex\",\n                    alignItems: \"center\",\n                    justifyContent:\n                      laneLabelStyle === \"piano\" ? \"flex-end\" : undefined,\n                    width: laneLabelStyle === \"piano\" ? 46 : undefined,\n                    boxSizing: \"border-box\",\n                    paddingRight: laneLabelStyle === \"piano\" ? 4 : undefined,\n                    fontSize: 9,\n                    fontFamily: token.fontMono,\n                    color:\n                      laneLabelStyle === \"piano\"\n                        ? active\n                          ? token.surface\n                          : accidental\n                            ? token.text\n                            : token.surface\n                        : active\n                          ? accent\n                          : token.textMuted,\n                    background:\n                      laneLabelStyle === \"piano\"\n                        ? active\n                          ? accent\n                          : accidental\n                            ? token.track\n                            : token.text\n                        : undefined,\n                    border:\n                      laneLabelStyle === \"piano\"\n                        ? `1px solid ${token.border}`\n                        : undefined,\n                    borderRadius: laneLabelStyle === \"piano\" ? 2 : undefined,\n                    transform:\n                      laneLabelStyle === \"piano\" && accidental\n                        ? \"scaleX(0.66)\"\n                        : undefined,\n                    transformOrigin: \"right center\",\n                    whiteSpace: \"nowrap\",\n                    transition: \"background 80ms linear, color 80ms linear\",\n                  }}\n                >\n                  {laneLabelStyle === \"piano\" && pitchClass !== 0\n                    ? \"\"\n                    : caption}\n                </span>\n              )\n            })}\n          </div>\n        )}\n\n        {/* the grid surface */}\n        <div\n          ref={surfaceRef}\n          className=\"uikit-control\"\n          onKeyDown={editable ? handleKeyDown : undefined}\n          style={{\n            position: \"relative\",\n            flex: 1,\n            height,\n            background: token.surface,\n            border: `1px solid ${token.border}`,\n            borderRadius: token.radius,\n            touchAction: \"none\",\n            overflow: \"hidden\",\n          }}\n        >\n          {(steps === 0 || lanes === 0) && (\n            <span\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                fontSize: 10,\n                color: token.textMuted,\n              }}\n            >\n              no steps\n            </span>\n          )}\n\n          {/* Live MIDI feedback belongs behind cells, not in pattern state. */}\n          {Array.from(activeLaneSet).map((lane) => {\n            if (lane < 0 || lane >= lanes) return null\n            const rect = layout.cellRect(0, lane)\n            return (\n              <div\n                key={`active-lane-${lane}`}\n                aria-hidden\n                style={{\n                  position: \"absolute\",\n                  left: 0,\n                  right: 0,\n                  top: pct(rect.y, layout.height),\n                  height: pct(rect.height, layout.height),\n                  background: accent,\n                  opacity: 0.09,\n                }}\n              />\n            )\n          })}\n\n          {/* beat-group tint, behind everything */}\n          {groupSize > 0 &&\n            Array.from({ length: pageSteps }, (_, column) =>\n              groupTint(column) ? (\n                <div\n                  key={`group-${column}`}\n                  aria-hidden\n                  style={{\n                    position: \"absolute\",\n                    left: pct(layout.cellRect(column, 0).x, layout.width),\n                    width: pct(layout.cellWidth, layout.width),\n                    top: 0,\n                    bottom: 0,\n                    background: token.text,\n                    opacity: 0.045,\n                  }}\n                />\n              ) : null\n            )}\n\n          {/* the playhead column — where the host's clock is, and nothing else */}\n          {playheadColumn !== null && (\n            <div\n              aria-hidden\n              style={{\n                position: \"absolute\",\n                left: pct(\n                  layout.cellRect(playheadColumn, 0).x - layout.gap / 2,\n                  layout.width\n                ),\n                width: pct(layout.cellWidth + layout.gap, layout.width),\n                top: 0,\n                bottom: 0,\n                background: accent,\n                opacity: 0.14,\n                borderLeft: `1px solid ${accent}`,\n                borderRight: `1px solid ${accent}`,\n              }}\n            />\n          )}\n\n          {/* the cells */}\n          {Array.from({ length: pageSteps }, (_, column) =>\n            Array.from({ length: lanes }, (_, lane) => {\n              const step = start + column\n              const cell = cellAt(step, lane)\n              const rect = layout.cellRect(column, lane)\n              const velocity = Math.min(1, Math.max(0, cell?.velocity ?? 1))\n              const gate = Math.min(1, Math.max(0, cell?.gate ?? 1))\n              const cellAccent = cell?.color ?? accent\n              const onPlayhead = playheadColumn === column\n              const active = cell !== undefined\n\n              const fill = active ? (\n                <span\n                  aria-hidden\n                  style={{\n                    position: \"absolute\",\n                    left: 0,\n                    bottom: 0,\n                    width: `${gate * 100}%`,\n                    height: `${Math.max(0.08, velocity) * 100}%`,\n                    borderRadius: 2,\n                    background: cell?.ghost\n                      ? \"transparent\"\n                      : `linear-gradient(to top, ${cellAccent}, ${token.accent2})`,\n                    border: cell?.ghost ? `1px dashed ${cellAccent}` : \"none\",\n                    opacity: cell?.ghost\n                      ? 0.55\n                      : MIN_CELL_OPACITY +\n                        (1 - MIN_CELL_OPACITY) *\n                          velocity *\n                          (onPlayhead ? 1.15 : 1),\n                  }}\n                />\n              ) : null\n\n              const base: CSSProperties = {\n                position: \"absolute\",\n                left: pct(rect.x, layout.width),\n                top: pct(rect.y, layout.height),\n                width: pct(rect.width, layout.width),\n                height: pct(rect.height, layout.height),\n                borderRadius: 2,\n                border: `1px solid ${token.border}`,\n                background: token.track,\n                boxSizing: \"border-box\",\n                padding: 0,\n              }\n\n              if (!editable) {\n                return (\n                  <div\n                    key={`${step}:${lane}`}\n                    aria-hidden\n                    style={{ ...base, opacity: active ? 1 : 0.45 }}\n                  >\n                    {fill}\n                  </div>\n                )\n              }\n\n              const caption = laneLabels?.[lane] ?? `lane ${lane + 1}`\n              return (\n                <button\n                  key={`${step}:${lane}`}\n                  type=\"button\"\n                  data-cell={`${step}:${lane}`}\n                  tabIndex={focus.step === step && focus.lane === lane ? 0 : -1}\n                  aria-pressed={active && !cell?.ghost}\n                  aria-label={`Step ${step + 1}, ${caption}${\n                    active\n                      ? `, on, velocity ${Math.round(velocity * 100)}%`\n                      : \", off\"\n                  }`}\n                  onPointerDown={cellPointerDown(step, lane)}\n                  onFocus={() => setFocus({ step, lane })}\n                  style={{\n                    ...base,\n                    opacity: active ? 1 : 0.45,\n                    cursor: drag.dragging ? \"grabbing\" : \"pointer\",\n                    appearance: \"none\",\n                    font: \"inherit\",\n                  }}\n                >\n                  {fill}\n                </button>\n              )\n            })\n          )}\n        </div>\n      </div>\n\n      {/* page tabs — only when the pattern outruns one page */}\n      {pages > 1 && (\n        <div\n          style={{\n            display: \"flex\",\n            gap: 3,\n            justifyContent: \"flex-end\",\n            marginTop: 4,\n            paddingLeft: laneLabelWidth,\n          }}\n          role={editable ? \"tablist\" : undefined}\n          aria-label={editable ? \"Pattern pages\" : undefined}\n        >\n          {Array.from({ length: pages }, (_, index) => {\n            const active = index === currentPage\n            const holdsPlayhead = playheadPage === index\n            const tabStyle: CSSProperties = {\n              minWidth: 22,\n              padding: \"1px 4px\",\n              fontSize: 9,\n              fontFamily: token.fontMono,\n              borderRadius: 3,\n              border: `1px solid ${active ? accent : token.border}`,\n              background: active ? accent : \"transparent\",\n              color: active\n                ? token.surface\n                : holdsPlayhead\n                  ? accent\n                  : token.textMuted,\n              textAlign: \"center\" as const,\n            }\n            return editable ? (\n              <button\n                key={index}\n                type=\"button\"\n                role=\"tab\"\n                aria-selected={active}\n                aria-label={`Page ${index + 1}`}\n                onClick={() => setPage(index)}\n                style={{ ...tabStyle, cursor: \"pointer\", appearance: \"none\" }}\n              >\n                {index + 1}\n              </button>\n            ) : (\n              <span key={index} aria-hidden style={tabStyle}>\n                {index + 1}\n              </span>\n            )\n          })}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/step-grid.tsx"
    }
  ]
}
