{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "components-knob",
  "type": "registry:component",
  "title": "Knob",
  "description": "A rotary control for one value in a range",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2",
    "react@^19.2.4"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-tokens.json"
  ],
  "files": [
    {
      "path": "packages/components/src/internal/theme.ts",
      "target": "@ui/gpuixui/components/internal/theme.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport * as Schema from \"effect/Schema\";\nimport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  brandTextOn,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  pointerWash,\n  selectionOn,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n  type BoxShadow,\n  type ElevationLevel,\n  type FindHighlightSource,\n  type NativeTextFill,\n  type PlatformName,\n  type PointerWashSurface,\n  type StatusTone,\n  type SyntaxColors,\n  type SyntaxRole,\n  type TextRole,\n  type Theme,\n  type ThemeColors,\n} from \"../../tokens/index.js\";\n\nexport {\n  alpha,\n  avatarPixels,\n  avatarSize,\n  controlFocusRing,\n  controlHeight,\n  controlPaddingX,\n  currentPlatform,\n  darkTheme,\n  disabledAction,\n  disabledOpacity,\n  elevationShadow,\n  filledHover,\n  filledPressed,\n  findHighlight,\n  focusRing,\n  keyboardFocusRing,\n  lightTheme,\n  nativeTextTheme,\n  statusTone,\n  syntaxColors,\n  targetSize,\n  textStyle,\n};\nexport type {\n  BoxShadow,\n  ElevationLevel,\n  FindHighlightSource,\n  NativeTextFill,\n  PlatformName,\n  StatusTone,\n  SyntaxColors,\n  SyntaxRole,\n  TextRole,\n  Theme,\n};\nexport { ThemeProvider, useTheme } from \"../../tokens/react.js\";\n\nexport const keyboardRing = (theme: Theme, invalid = false): StyleDesc =>\n  controlFocusRing(theme, { invalid }) ?? {};\n\nexport interface ControlRingState {\n  readonly disabled?: boolean;\n\n  readonly invalid?: boolean;\n}\n\nexport const controlRing = (\n  theme: Theme,\n  state: ControlRingState = {},\n): StyleDesc => controlFocusRing(theme, state) ?? {};\n\nexport const pressWash = (theme: Theme, replaced: PointerWashSurface): string =>\n  pointerWash(theme, \"active\", replaced) ?? theme.colors.active;\n\nexport const selectionWash = (theme: Theme): StyleDesc => ({\n  selectionColor: theme.colors.selection,\n});\n\nexport const keepContent: StyleDesc = { keyboardFocusDim: false };\n\nexport const disabledFade = (\n  disabled: boolean,\n): { readonly opacity: number } | undefined =>\n  disabled ? { opacity: disabledOpacity } : undefined;\n\nconst Tones = Schema.Literals([\"info\", \"success\", \"warning\", \"danger\"]);\n\nexport type Tone = typeof Tones.Type;\n\nexport const toneOf = (value: unknown): Tone =>\n  Schema.is(Tones)(value) ? value : \"info\";\n\nexport interface ToneStyle {\n  readonly fg: string;\n  readonly bg: string;\n  readonly border: string;\n}\n\nconst TOKEN_TONE: Readonly<Record<Tone, StatusTone>> = {\n  info: \"info\",\n  success: \"success\",\n  warning: \"warning\",\n  danger: \"destructive\",\n};\n\nexport const tone = (theme: Theme, value: Tone): ToneStyle => {\n  const { background, border, foreground } = statusTone(\n    theme,\n    TOKEN_TONE[value],\n  );\n  return { bg: background, border, fg: foreground };\n};\n\nconst softRoles: Readonly<Record<Tone, keyof ThemeColors>> = {\n  info: \"infoSoft\",\n  success: \"successSoft\",\n  warning: \"warningSoft\",\n  danger: \"destructiveSoft\",\n};\n\nexport const messageLink = (theme: Theme, value: Tone): string =>\n  brandTextOn(theme, softRoles[value]);\n\nexport const messageSelection = (theme: Theme, value: Tone): string =>\n  selectionOn(theme, softRoles[value]);\n\nexport const toneSurface = (theme: Theme, value: Tone): StyleDesc => {\n  const { bg, border, fg } = tone(theme, value);\n  return {\n    backgroundColor: bg,\n    color: fg,\n    borderWidth: theme.borderWidth.thin,\n    borderColor: border,\n  };\n};\n"
    },
    {
      "path": "packages/components/src/knob.tsx",
      "target": "@ui/gpuixui/components/knob.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { EventPayload, KeyEvent, Props } from \"@gpuix/react\";\nimport { useRef, useState } from \"react\";\nimport { disabledFade, useTheme } from \"./internal/theme.js\";\nimport { knobDot, knobDrag, knobKey } from \"./knob/logic.js\";\n\nexport interface KnobProps extends Omit<Props, \"children\" | \"role\"> {\n  readonly label: string;\n  readonly value?: number;\n  readonly defaultValue?: number;\n  readonly min?: number;\n  readonly max?: number;\n  readonly step?: number;\n  readonly size?: number;\n  readonly disabled?: boolean;\n  readonly indicatorColor?: string;\n  readonly onValueChange?: (value: number) => void;\n}\n\ninterface Grip {\n  readonly y: number;\n  readonly value: number;\n}\n\nexport const Knob = ({\n  label,\n  value,\n  defaultValue,\n  min = 0,\n  max = 100,\n  step = 1,\n  size = 48,\n  disabled = false,\n  indicatorColor,\n  onValueChange,\n  style,\n  ...rest\n}: KnobProps) => {\n  const theme = useTheme();\n  const [stored, setStored] = useState(defaultValue ?? min);\n  const grip = useRef<Grip | null>(null);\n  const range = { min, max, step };\n  const shown = value ?? stored;\n  const dot = Math.max(4, Math.round(size / 8));\n  const [dx, dy] = knobDot(shown, range, size / 2 - dot);\n\n  const change = (next: number): void => {\n    if (next === shown) return;\n    setStored(next);\n    onValueChange?.(next);\n  };\n  const onKeyDown = (event: KeyEvent): void => {\n    if (disabled) return;\n    const next = knobKey(event.key ?? \"\", shown, range);\n    if (next !== null) change(next);\n  };\n  const onMouseDown = (event: EventPayload): void => {\n    if (disabled) return;\n    grip.current = { y: event.y ?? 0, value: shown };\n  };\n  const onMouseMove = (event: EventPayload): void => {\n    const held = grip.current;\n    if (held === null) return;\n    if (event.pressedButton !== 0) {\n      grip.current = null;\n      return;\n    }\n    change(knobDrag(held.value, held.y - (event.y ?? held.y), range));\n  };\n\n  return (\n    <div\n      {...rest}\n      role=\"slider\"\n      aria-label={label}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuenow={shown}\n      aria-disabled={disabled}\n      tabIndex={disabled ? -1 : 0}\n      onKeyDown={onKeyDown}\n      onMouseDown={onMouseDown}\n      onMouseMove={onMouseMove}\n      onMouseUp={() => {\n        grip.current = null;\n      }}\n      style={{\n        position: \"relative\",\n        width: size,\n        height: size,\n        flexShrink: 0,\n        borderRadius: size / 2,\n        backgroundColor: theme.colors.muted,\n        ...disabledFade(disabled),\n        ...style,\n      }}\n    >\n      <div\n        testId=\"knob-indicator\"\n        style={{\n          position: \"absolute\",\n          left: size / 2 + dx - dot / 2,\n          top: size / 2 + dy - dot / 2,\n          width: dot,\n          height: dot,\n          borderRadius: dot / 2,\n          pointerEvents: \"none\",\n          backgroundColor: indicatorColor ?? theme.colors.primary,\n        }}\n      />\n    </div>\n  );\n};\n\nexport const knobMeta = {\n  name: \"Knob\",\n  tier: 2,\n  status: \"done\",\n  gpuixSupport: \"yes\",\n  limitations: [],\n  docs: \"docs/knob.mdx\",\n  example: \"examples/knob.example.tsx\",\n  testFile: \"src/behaviour/knob.test.tsx\",\n} as const;\n"
    },
    {
      "path": "packages/components/src/knob/logic.ts",
      "target": "@ui/gpuixui/components/knob/logic.ts",
      "type": "registry:lib",
      "content": "export interface KnobRange {\n  readonly min: number;\n  readonly max: number;\n  readonly step: number;\n}\n\nexport const KNOB_SWEEP = (Math.PI * 3) / 2;\nconst DRAG_TRAVEL = 160;\n\nconst clamp = (value: number, { min, max }: KnobRange): number =>\n  Math.min(max, Math.max(min, value));\n\nexport const knobAngle = (value: number, range: KnobRange): number => {\n  const span = range.max - range.min;\n  const share = span <= 0 ? 0 : (clamp(value, range) - range.min) / span;\n  return Math.PI * 0.75 + share * KNOB_SWEEP;\n};\n\nexport const knobDot = (\n  value: number,\n  range: KnobRange,\n  reach: number,\n): readonly [number, number] => {\n  const angle = knobAngle(value, range);\n  return [Math.cos(angle) * reach, Math.sin(angle) * reach];\n};\n\nexport const knobKey = (\n  key: string,\n  value: number,\n  range: KnobRange,\n): number | null => {\n  switch (key) {\n    case \"up\":\n    case \"right\":\n      return clamp(value + range.step, range);\n    case \"down\":\n    case \"left\":\n      return clamp(value - range.step, range);\n    case \"home\":\n      return range.min;\n    case \"end\":\n      return range.max;\n    default:\n      return null;\n  }\n};\n\nexport const knobDrag = (\n  start: number,\n  rise: number,\n  range: KnobRange,\n): number => {\n  const raw = start + (rise / DRAG_TRAVEL) * (range.max - range.min);\n  const stepped =\n    range.min + Math.round((raw - range.min) / range.step) * range.step;\n  return clamp(stepped, range);\n};\n"
    }
  ],
  "docs": "[Knob](https://gpuixui.sudokw.com/components/knob) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/components/knob",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}