{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-weight-bars",
  "type": "registry:ui",
  "title": "Weight Bars",
  "description": "An editable bar chart, such as a pitch-class histogram: twelve bars labelled C to B over a row coloured like piano keys, with the scale's notes highlighted and the tonic accented. Each bar is a vertical Base UI slider, so it is a native range input named by its label with arrows, Page Up/Down, Home and End. The pointer paints across bars: press to set the bar under it, drag sideways to set every bar crossed along a straight line, so a fast stroke leaves no gaps. Values draw clamped to the bar but an edit only rewrites the bars it touched, and a stroke commits once for one undo step. The pointer reading, the stroke and the colouring sit in a pure core beside it.",
  "dependencies": [
    "@base-ui/react",
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/weight-bars.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/weight-bars\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 * WeightBars — an editable bar chart: one weight per bar, raised or lowered\n * by hand. Its first use is a pitch-class histogram (twelve bars, C to B, over\n * a row coloured like piano keys), but nothing in it is specific to pitch.\n *\n * ## Each bar is a Base UI slider\n *\n * A vertical Base UI Slider per bar owns the keyboard and the ARIA contract:\n * a native `<input type=\"range\">` named by the bar's label, arrows by `step`,\n * Shift+arrow or Page Up/Down by `largeStep`, Home to 0 and End to `max`. So\n * each bar is one tab stop that a screen reader announces as \"E, 9\" and can\n * adjust, and it behaves like every other slider in the kit.\n *\n * ## The pointer paints\n *\n * The pointer is not Base UI's, because a gesture here spans bars. Press a\n * bar and it jumps to the height under the pointer; drag sideways and every\n * bar crossed takes the height under the pointer, filled along a straight\n * line so a fast stroke leaves no gaps (`paintWeightStroke`). This is the\n * gesture `step-lane` uses for its steps, with a height instead of a state.\n * The press also focuses the bar, so the arrows carry on from there.\n * `onValuesCommitted` fires once when the stroke ends: the place for one undo\n * step per drag.\n *\n * ## Clamped on screen, untouched in the data\n *\n * Bars draw their value clamped to `[0, max]`, but an edit only rewrites the\n * bars it touched, so an out-of-range weight elsewhere in the row survives.\n * Pick `max` to fit the data; a multiple of `step` keeps the top reachable.\n *\n * ## Colour carries meaning, the label carries it too\n *\n * `highlighted` draws a set of bars in full and dims the rest (the scale's\n * notes against the others), and `accent` marks one bar (the tonic) in the\n * second accent colour with an underlined label. With `keyboard`, the label\n * row is coloured as piano keys for pitch classes, index 0 being C. The\n * weights also print above their bars, so nothing depends on colour alone.\n *\n * Bars fill with the value colour (`--uikit-value`), not the accent, and every\n * label and printed weight stands at least 4.5:1 against what it sits on:\n * a dimmed bar is told apart by its fill and its label's weight, never by\n * fading its text.\n *\n * Styling comes from the kit's CSS custom properties (`lib/control-theme`),\n * with `color` to override the fill for one instance. SSR-safe: layout is\n * CSS grid and the pointer is measured only inside its handlers.\n */\n\nimport { useCallback, useRef, useState } from \"react\"\nimport type { CSSProperties, FocusEvent, PointerEvent } from \"react\"\nimport { Slider } from \"@base-ui/react/slider\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { CONSOLE_VALUE } from \"maruaudio/ui/shared/console-styles\"\nimport {\n  clampWeight,\n  defaultWeightLargeStep,\n  formatWeight,\n  paintWeightStroke,\n  setWeight,\n  weightAtHeight,\n  weightBarIndexAt,\n  weightBarLabel,\n  weightBarTone,\n  type WeightPoint,\n} from \"maruaudio/ui/shared/weight-bars-math\"\n\nexport interface WeightBarsProps {\n  /** One weight per bar. The number of bars is its length. */\n  readonly values: readonly number[]\n  /** Every edit, as the whole row, with the index of the bar under the pointer or key. */\n  readonly onValuesChange?: (values: number[], index: number) => void\n  /** Once per gesture: when a paint stroke ends, or per key press. */\n  readonly onValuesCommitted?: (values: number[]) => void\n  /** The top of every bar. Defaults to 10. */\n  readonly max?: number\n  /** The keyboard step and the grid a paint stroke snaps to. Defaults to 1. */\n  readonly step?: number\n  /** The Shift+arrow and Page Up/Down step. Defaults to a fifth of `max`, in whole steps. */\n  readonly largeStep?: number\n  /**\n   * A label per bar, drawn under it and used as its accessible name. Defaults\n   * to pitch-class names with `keyboard`, and to 1, 2, 3… otherwise.\n   */\n  readonly labels?: readonly string[]\n  /** Colour the label row as piano keys for pitch classes, index 0 being C. */\n  readonly keyboard?: boolean\n  /** Bars drawn in full, for example the scale's notes; the others are dimmed. */\n  readonly highlighted?: ReadonlySet<number>\n  /** One bar to mark, for example the tonic. */\n  readonly accent?: number\n  /** Bars grow upward. The only orientation for now, and the default. */\n  readonly orientation?: \"vertical\"\n  /** Spells a weight above its bar and for `aria-valuetext`. Defaults to `formatWeight`. */\n  readonly formatValue?: (value: number, index: number) => string\n  /** Print each weight above its bar. Defaults to true. */\n  readonly showValues?: boolean\n  /** Hide printed labels while retaining each slider's accessible name. */\n  readonly showLabels?: boolean\n  /** Line uses the same painting and keyboard controls for editable curves. */\n  readonly variant?: \"bars\" | \"line\"\n  /** Height of the bars in pixels, excluding the value and label rows. Defaults to 96. */\n  readonly height?: number\n  /** Gutter between bars in pixels. Defaults to 3. */\n  readonly gap?: number\n  readonly disabled?: boolean\n  /** Override the bar colour for this instance. Defaults to the value fill (`--uikit-value`). */\n  readonly color?: string\n  /** Defaults to filling the container. */\n  readonly width?: number | string\n  readonly className?: string\n  readonly style?: CSSProperties\n  /** The group's accessible name. Defaults to \"Weights\". */\n  readonly \"aria-label\"?: string\n}\n\n/** Height of the bar cap, the visible handle at each bar's top, in pixels. */\nconst CAP = 4\n\n/** The black keys' face, which stays black in every skin (see `piano-keyboard`). */\nconst BLACK_KEY_FACE = \"rgba(8, 10, 14, 0.92)\"\nconst BLACK_KEY_TEXT = \"rgba(255, 255, 255, 0.72)\"\n\n/** Where a keyboard focus ring should show: after keys, not after a press. */\nfunction matchesFocusVisible(element: Element): boolean {\n  try {\n    return element.matches(\":focus-visible\")\n  } catch {\n    return true\n  }\n}\n\ninterface BarFocus {\n  index: number\n  visible: boolean\n}\n\n/** A paint stroke in progress: the row as painted so far and the last point. */\ninterface Stroke {\n  values: readonly number[]\n  last: WeightPoint\n  changed: boolean\n}\n\nexport function WeightBars({\n  values,\n  onValuesChange,\n  onValuesCommitted,\n  max = 10,\n  step = 1,\n  largeStep,\n  labels,\n  keyboard = false,\n  highlighted,\n  accent,\n  formatValue,\n  showValues = true,\n  showLabels = true,\n  variant = \"bars\",\n  height = 96,\n  gap = 3,\n  disabled = false,\n  color,\n  width = \"100%\",\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: WeightBarsProps) {\n  const count = values.length\n  const fill = color ?? CONSOLE_VALUE\n  const top = Number.isFinite(max) && max > 0 ? max : 1\n  const format = formatValue ?? ((value: number) => formatWeight(value, step))\n  const bigStep = largeStep ?? defaultWeightLargeStep(top, step)\n  const columns = `repeat(${Math.max(1, count)}, minmax(0, 1fr))`\n\n  const plotRef = useRef<HTMLDivElement | null>(null)\n  const inputs = useRef<(HTMLInputElement | null)[]>([])\n  const stroke = useRef<Stroke | null>(null)\n  const [focus, setFocus] = useState<BarFocus | null>(null)\n\n  /** The bar and weight under a pointer, from the plot's own box. */\n  const pointAt = useCallback(\n    (clientX: number, clientY: number): WeightPoint | null => {\n      const rect = plotRef.current?.getBoundingClientRect()\n      if (!rect || rect.width <= 0 || rect.height <= 0) return null\n      const index = weightBarIndexAt(\n        clientX - rect.left,\n        rect.width,\n        count,\n        gap\n      )\n      if (index < 0) return null\n      return {\n        index,\n        value: weightAtHeight(clientY - rect.top, rect.height, top, step),\n      }\n    },\n    [count, gap, step, top]\n  )\n\n  /** Emit the stroke's next row, if it moved anything. */\n  const apply = useCallback(\n    (next: readonly number[], index: number) => {\n      const current = stroke.current\n      if (!current || next === current.values) return\n      current.values = next\n      current.changed = true\n      onValuesChange?.(next.slice(), index)\n    },\n    [onValuesChange]\n  )\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled || event.button !== 0) return\n      const point = pointAt(event.clientX, event.clientY)\n      if (!point) return\n      event.preventDefault()\n      event.currentTarget.setPointerCapture?.(event.pointerId)\n      stroke.current = { values, last: point, changed: false }\n      apply(paintWeightStroke(values, null, point, step, top), point.index)\n      inputs.current[point.index]?.focus({ preventScroll: true })\n    },\n    [apply, disabled, pointAt, step, top, values]\n  )\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      const current = stroke.current\n      if (!current) return\n      const point = pointAt(event.clientX, event.clientY)\n      if (!point) return\n      apply(\n        paintWeightStroke(current.values, current.last, point, step, top),\n        point.index\n      )\n      current.last = point\n    },\n    [apply, pointAt, step, top]\n  )\n\n  const endStroke = useCallback(() => {\n    const current = stroke.current\n    stroke.current = null\n    if (current?.changed) onValuesCommitted?.(current.values.slice())\n  }, [onValuesCommitted])\n\n  const tones = values.map((_, index) =>\n    weightBarTone(index, { keyboard, highlighted, accent })\n  )\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Weights\"}\n      aria-disabled={disabled || undefined}\n      data-weight-bars=\"\"\n      data-weight-variant={variant}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: 4,\n        width,\n        boxSizing: \"border-box\",\n        fontFamily: token.font,\n        opacity: disabled ? 0.5 : 1,\n        ...style,\n      }}\n    >\n      {showValues ? (\n        <div\n          aria-hidden\n          style={{\n            display: \"grid\",\n            gridTemplateColumns: columns,\n            columnGap: gap,\n          }}\n        >\n          {values.map((value, index) => (\n            <span\n              key={index}\n              data-weight-value={index}\n              style={{\n                textAlign: \"center\",\n                fontFamily: token.fontMono,\n                fontSize: 11,\n                lineHeight: \"13px\",\n                overflow: \"hidden\",\n                whiteSpace: \"nowrap\",\n                color:\n                  Number.isFinite(value) && value > 0 && !tones[index].dimmed\n                    ? token.text\n                    : token.textMuted,\n              }}\n            >\n              {format(Number.isFinite(value) ? value : 0, index)}\n            </span>\n          ))}\n        </div>\n      ) : null}\n\n      {/* The plot: the pointer surface is exactly the bars' box, so a\n          pointer's y reads straight as a height on every bar. */}\n      <div\n        ref={plotRef}\n        data-weight-plot=\"\"\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endStroke}\n        onPointerCancel={endStroke}\n        onLostPointerCapture={endStroke}\n        style={{\n          position: \"relative\",\n          display: \"grid\",\n          gridTemplateColumns: columns,\n          columnGap: gap,\n          width: \"100%\",\n          height,\n          touchAction: \"none\",\n          userSelect: \"none\",\n          cursor: disabled ? \"not-allowed\" : \"ns-resize\",\n        }}\n      >\n        {variant === \"line\" && (\n          <svg\n            aria-hidden\n            viewBox=\"0 0 1000 1000\"\n            preserveAspectRatio=\"none\"\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              width: \"100%\",\n              height: \"100%\",\n              pointerEvents: \"none\",\n              overflow: \"visible\",\n            }}\n          >\n            <path\n              d=\"M0 500H1000\"\n              stroke={token.border}\n              vectorEffect=\"non-scaling-stroke\"\n            />\n            <polyline\n              data-weight-line=\"\"\n              points={values\n                .map(\n                  (value, index) =>\n                    `${((index + 0.5) / Math.max(1, count)) * 1000},${(1 - clampWeight(value, top) / top) * 1000}`\n                )\n                .join(\" \")}\n              fill=\"none\"\n              stroke={fill}\n              strokeWidth=\"2\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </svg>\n        )}\n        {values.map((value, index) => {\n          const tone = tones[index]\n          const label = weightBarLabel(index, labels, keyboard)\n          const barFill = tone.accent ? token.accent2 : fill\n          const focused = focus?.index === index\n          return (\n            <Slider.Root\n              key={index}\n              value={clampWeight(value, top)}\n              onValueChange={(next) =>\n                onValuesChange?.(setWeight(values, index, next).slice(), index)\n              }\n              onValueCommitted={(next) =>\n                onValuesCommitted?.(setWeight(values, index, next).slice())\n              }\n              min={0}\n              max={top}\n              step={step}\n              largeStep={bigStep}\n              orientation=\"vertical\"\n              disabled={disabled}\n              // The outer group names the chart; a nameless group per bar\n              // would only add noise to the accessibility tree.\n              role=\"presentation\"\n              data-weight-bar={index}\n              data-highlighted={tone.highlighted ? \"\" : undefined}\n              data-accent={tone.accent ? \"\" : undefined}\n              style={{ minWidth: 0, height: \"100%\" }}\n            >\n              <Slider.Track\n                style={{\n                  position: \"relative\",\n                  width: \"100%\",\n                  height: \"100%\",\n                  borderRadius: 3,\n                  background: variant === \"line\" ? \"transparent\" : token.track,\n                  opacity: tone.dimmed ? 0.7 : 1,\n                }}\n              >\n                <Slider.Indicator\n                  style={{\n                    position: \"absolute\",\n                    bottom: 0,\n                    left: 0,\n                    width: \"100%\",\n                    height: `${(clampWeight(value, top) / top) * 100}%`,\n                    background: variant === \"line\" ? \"transparent\" : barFill,\n                    opacity: tone.dimmed ? 0.35 : 0.85,\n                    borderRadius: \"3px 3px 1px 1px\",\n                  }}\n                />\n                <Slider.Thumb\n                  aria-label={label}\n                  getAriaValueText={(_formatted, thumbValue) =>\n                    format(thumbValue, index)\n                  }\n                  inputRef={(input: HTMLInputElement | null) => {\n                    inputs.current[index] = input\n                  }}\n                  onFocus={(event: FocusEvent<HTMLInputElement>) =>\n                    setFocus({\n                      index,\n                      visible: matchesFocusVisible(event.currentTarget),\n                    })\n                  }\n                  onBlur={() =>\n                    setFocus((current) =>\n                      current?.index === index ? null : current\n                    )\n                  }\n                  onKeyDown={() =>\n                    setFocus((current) =>\n                      current?.index === index && !current.visible\n                        ? { index, visible: true }\n                        : current\n                    )\n                  }\n                  style={{\n                    boxSizing: \"border-box\",\n                    width: \"100%\",\n                    height: CAP,\n                    borderRadius: 2,\n                    background:\n                      variant === \"line\" && !focused\n                        ? \"transparent\"\n                        : tone.dimmed\n                          ? token.textMuted\n                          : token.text,\n                    boxShadow:\n                      focused && focus?.visible\n                        ? `0 0 0 2px ${token.focus}`\n                        : undefined,\n                    outline: \"none\",\n                  }}\n                />\n              </Slider.Track>\n            </Slider.Root>\n          )\n        })}\n      </div>\n\n      {showLabels && (\n        <div\n          aria-hidden\n          style={{\n            display: \"grid\",\n            gridTemplateColumns: columns,\n            columnGap: gap,\n          }}\n        >\n          {values.map((_, index) => {\n            const tone = tones[index]\n            const strong =\n              tone.highlighted || tone.accent || focus?.index === index\n            return (\n              <span\n                key={index}\n                data-weight-label={index}\n                data-black={tone.black ? \"\" : undefined}\n                style={{\n                  display: \"block\",\n                  boxSizing: \"border-box\",\n                  minWidth: 0,\n                  padding: \"2px 0\",\n                  textAlign: \"center\",\n                  fontSize: 11,\n                  lineHeight: \"13px\",\n                  overflow: \"hidden\",\n                  whiteSpace: \"nowrap\",\n                  borderRadius: 2,\n                  background: keyboard\n                    ? tone.black\n                      ? BLACK_KEY_FACE\n                      : `color-mix(in srgb, ${token.text} 22%, transparent)`\n                    : undefined,\n                  // On a white key's wash the muted colour falls under 4.5:1,\n                  // so every white key's label is full text; weight says which\n                  // are strong.\n                  color: tone.black\n                    ? BLACK_KEY_TEXT\n                    : strong || keyboard\n                      ? token.text\n                      : token.textMuted,\n                  fontWeight: strong ? 600 : 400,\n                  // The accent's underline and the highlight's are the same\n                  // mark in two colours, so the tonic reads as a scale note too.\n                  boxShadow: tone.accent\n                    ? `inset 0 -2px 0 ${token.accent2}`\n                    : tone.highlighted\n                      ? `inset 0 -2px 0 ${fill}`\n                      : undefined,\n                }}\n              >\n                {weightBarLabel(index, labels, keyboard)}\n              </span>\n            )\n          })}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/weight-bars.tsx"
    }
  ]
}
