{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-step-lane",
  "type": "registry:ui",
  "title": "Step Lane",
  "description": "One row of a step sequencer, with three states rather than two: off, on and accent, because accent is a real louder hit and a two-state lane forces it into a second row the player has to keep in sync by hand. Press to cycle, or press and drag to paint the value the first step took across the row. The playhead takes a fractional position, so a face animating from the audio clock draws a line that travels instead of a light that arrives after the step has already sounded. Layout, the press/drag reading, the paint rule and the `.`/`x`/`X` pattern notation sit in a pure core beside it.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/step-lane.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/step-lane\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 * StepLane — one row of a step sequencer: on / off / accent, with a playhead.\n *\n * Presentational and controlled, like every other surface in the kit. `steps`\n * is the row, the consumer owns it, and edits go out through `onStepsChange`\n * (the whole row) and `onStepChange` (the one that moved). Nothing about the\n * pattern lives here.\n *\n * THREE STATES, because accent is a real hit and not a decoration: a lane that\n * can only say on/off pushes the accent pattern into a second lane the player\n * has to keep in sync by hand. Pressing a step walks off → on → accent → off,\n * which is what the hardware's own button does.\n *\n * DRAG PAINTING is the gesture that makes a lane fast: pressing and dragging\n * across the row sets every step it passes to the value the FIRST step took,\n * rather than cycling each one under the finger. Cycling per step makes the\n * result depend on how many pointermove events the browser delivered, which is\n * not something a player can aim. The rule lives in `step-lane-math`'s\n * `beginPaint` / `paintStep`, along with the deliberate difference between a\n * press (a gutter is a miss — you aimed at something) and a drag (a gutter\n * keeps painting — you were travelling over it).\n *\n * THE PLAYHEAD takes a FRACTIONAL position, and that is the point. A playhead\n * that jumps step to step arrives after the step has already sounded at any\n * tempo where a step is longer than a frame. Given 4.5 it draws half way\n * between step 4 and step 5, so a face animating from the audio clock draws a\n * line that travels. Pass null when nothing is running.\n *\n * Keyboard: the lane is a `role=\"group\"` of `role=\"checkbox\"` steps in the tab\n * order; Enter or Space cycles the focused step. A three-state control is not a\n * checkbox in the strict sense, so `aria-checked=\"mixed\"` carries the accent\n * state — the one ARIA value that means \"on, and more than plainly on\".\n */\n\nimport { useCallback, useMemo, useRef, useState } from \"react\"\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\"\nimport {\n  STEP_ACCENT,\n  STEP_ON,\n  beginPaint,\n  hitTestStepLane,\n  paintStep,\n  playheadX,\n  stepIndexNear,\n  stepLaneLayout,\n  toStepState,\n  type StepPaint,\n  type StepState,\n} from \"maruaudio/ui/shared/step-lane-math\"\nimport { token } from \"maruaudio/ui/shared/theme\"\n\nexport interface StepLaneProps {\n  /** The row. Values coerce through `toStepState`, so a plain number[] works. */\n  steps: readonly (StepState | number)[]\n  /**\n   * Playhead position in steps, fractional for a moving line. Null (the\n   * default) draws none — which is what a stopped sequencer looks like.\n   */\n  playhead?: number | null\n  /** The whole row after an edit. */\n  onStepsChange?: (steps: StepState[]) => void\n  /** Just the step that moved, for a consumer that stores per-step. */\n  onStepChange?: (index: number, state: StepState) => void\n  /** false collapses the rotation to off/on. Defaults to true. */\n  accent?: boolean\n  /**\n   * Display only: no pointer editing, no tab stops, no cycling.\n   *\n   * Not the same as `disabled`. A read-only lane is a picture of a pattern that\n   * something else owns — a factory bank, another player's part — and it still\n   * lights and still runs its playhead. A disabled one is an editable lane that\n   * is temporarily unavailable.\n   */\n  readOnly?: boolean\n  disabled?: boolean\n  /** viewBox width. Defaults to 320. */\n  width?: number\n  /** viewBox height. Defaults to 24. */\n  height?: number\n  /** Gutter between steps. */\n  gap?: number\n  /** Steps per visual group. 0 disables grouping. Defaults to 4. */\n  groupSize?: number\n  /** Extra gutter at a group boundary. */\n  groupGap?: number\n  /** Fill for a normal on-step. Defaults to the theme accent. */\n  stepColor?: string\n  /** Fill for an accented step. Defaults to the theme accent. */\n  accentColor?: string\n  /** Moving playhead line and current-cell outline. Defaults to accent 2. */\n  playheadColor?: string\n  /** Optional fill for an inactive cell under the playhead. Active steps keep their note fill. */\n  playheadFillColor?: string\n  /** Whether the current cell gets the playhead outline. Defaults to true. */\n  playheadOutline?: boolean\n  /** Whether to draw the fractional moving playhead line. Defaults to true. */\n  playheadLine?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function StepLane({\n  steps,\n  playhead = null,\n  onStepsChange,\n  onStepChange,\n  accent = true,\n  readOnly = false,\n  disabled = false,\n  width = 320,\n  height = 24,\n  gap = 3,\n  groupSize = 4,\n  groupGap = 5,\n  stepColor = token.accent,\n  accentColor = token.accent,\n  playheadColor = token.accent2,\n  playheadFillColor,\n  playheadOutline = true,\n  playheadLine = true,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: StepLaneProps) {\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const [paint, setPaint] = useState<StepPaint | null>(null)\n\n  const values = useMemo(() => steps.map(toStepState), [steps])\n  const layout = useMemo(\n    () => stepLaneLayout({ count: values.length, width, height, gap, groupSize, groupGap }),\n    [values.length, width, height, gap, groupSize, groupGap],\n  )\n\n  const editable = !readOnly && !disabled\n\n  const commit = useCallback(\n    (next: StepState[], index: number) => {\n      // Identity-stable: `paintStep` returns the same array when nothing moved,\n      // so a pointermove inside one step costs no render and emits no event.\n      if (next === (values as StepState[])) return\n      onStepsChange?.(next)\n      onStepChange?.(index, next[index])\n    },\n    [onStepChange, onStepsChange, values],\n  )\n\n  /** viewBox x for a client x, undoing the uniform scale an SVG applies. */\n  const localX = useCallback(\n    (clientX: number): number | null => {\n      const rect = svgRef.current?.getBoundingClientRect()\n      if (!rect || rect.width <= 0) return null\n      const scale = Math.min(rect.width / layout.width, rect.height / layout.height)\n      const offset = (rect.width - layout.width * scale) / 2\n      return (clientX - rect.left - offset) / scale\n    },\n    [layout],\n  )\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<SVGSVGElement>) => {\n      if (!editable) return\n      const x = localX(event.clientX)\n      if (x === null) return\n      // A PRESS uses the strict hit test: a click in a gutter is a miss.\n      const cell = hitTestStepLane(layout, x)\n      if (!cell) return\n      event.preventDefault()\n      event.currentTarget.setPointerCapture?.(event.pointerId)\n      const next = beginPaint(values, cell.index, accent)\n      setPaint(next)\n      commit(paintStep(values, cell.index, next), cell.index)\n    },\n    [accent, commit, editable, layout, localX, values],\n  )\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<SVGSVGElement>) => {\n      if (!editable || !paint) return\n      const x = localX(event.clientX)\n      if (x === null) return\n      // A DRAG uses the nearest reading: passing over a gutter keeps painting,\n      // or the row ends up striped with the gaps the finger crossed.\n      const index = stepIndexNear(layout, x)\n      if (index < 0) return\n      commit(paintStep(values, index, paint), index)\n    },\n    [commit, editable, layout, localX, paint, values],\n  )\n\n  const endPaint = useCallback(() => setPaint(null), [])\n\n  const head = playhead === null || !Number.isFinite(playhead) ? null : playhead\n  const headStep = head === null ? -1 : Math.floor(head) % Math.max(1, layout.count)\n\n  return (\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${layout.width} ${layout.height}`}\n      className={className}\n      style={{\n        display: \"block\",\n        width: \"100%\",\n        touchAction: \"none\",\n        opacity: disabled ? 0.5 : 1,\n        ...style,\n      }}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Step lane\"}\n      aria-disabled={disabled || undefined}\n      aria-readonly={readOnly || undefined}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endPaint}\n      onPointerCancel={endPaint}\n      onLostPointerCapture={endPaint}\n    >\n      {layout.steps.map((cell) => {\n        const state = values[cell.index] ?? 0\n        const lit = state !== 0\n        const under = cell.index === headStep && head !== null\n        return (\n          <g key={cell.index}>\n            <rect\n              x={cell.x}\n              y={0}\n              width={cell.width}\n              height={layout.height}\n              rx={2}\n              ry={2}\n              role={editable ? \"checkbox\" : undefined}\n              aria-checked={\n                editable ? (state === STEP_ACCENT ? \"mixed\" : state === STEP_ON) : undefined\n              }\n              aria-label={editable ? `Step ${cell.index + 1}` : undefined}\n              tabIndex={editable ? 0 : undefined}\n              data-step={cell.index}\n              data-state={state}\n              fill={\n                state === STEP_ACCENT\n                  ? accentColor\n                  : lit\n                    ? stepColor\n                    : under && playheadFillColor\n                      ? playheadFillColor\n                      : token.track\n              }\n              fillOpacity={\n                state === STEP_ACCENT\n                  ? 1\n                  : lit\n                    ? 0.72\n                    : under && playheadFillColor\n                      ? 1\n                      : 0.4\n              }\n              stroke={under && playheadOutline ? playheadColor : token.border}\n              strokeWidth={under && playheadOutline ? 1.4 : 0.75}\n              style={{ cursor: editable ? \"pointer\" : \"default\", outline: \"none\" }}\n              onKeyDown={\n                editable\n                  ? (event: KeyboardEvent<SVGRectElement>) => {\n                      if (event.key !== \"Enter\" && event.key !== \" \") return\n                      if (event.repeat) return\n                      event.preventDefault()\n                      const next = beginPaint(values, cell.index, accent)\n                      commit(paintStep(values, cell.index, next), cell.index)\n                    }\n                  : undefined\n              }\n            />\n            {/* The accent mark: a brighter cap on the step, so accent reads as\n                \"louder\" at a glance rather than as a second colour to learn. */}\n            {state === STEP_ACCENT ? (\n              <rect\n                x={cell.x + 1}\n                y={1}\n                width={Math.max(0, cell.width - 2)}\n                height={Math.max(1, layout.height * 0.22)}\n                rx={1}\n                fill={token.accent2}\n                style={{ pointerEvents: \"none\" }}\n              />\n            ) : null}\n          </g>\n        )\n      })}\n\n      {head === null || !playheadLine ? null : (\n        <line\n          x1={playheadX(layout, head % layout.count)}\n          y1={0}\n          x2={playheadX(layout, head % layout.count)}\n          y2={layout.height}\n          stroke={playheadColor}\n          strokeWidth={1.5}\n          style={{ pointerEvents: \"none\" }}\n        />\n      )}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/step-lane.tsx"
    }
  ]
}
