{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "value-slider",
  "title": "数值滑块",
  "description": "带标签与读数的数值滑块，支持区间、缓冲进度、刻度磁吸、数值格式化与拖出两端的弹性过冲。",
  "dependencies": [
    "@base-ui/react",
    "class-variance-authority",
    "motion"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/value-slider/value-slider.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useDirection } from \"@base-ui/react/direction-provider\"\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\"\nimport { cva } from \"class-variance-authority\"\nimport { motion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { flowToneStyle } from \"@/lib/flow-tone\"\n\nimport { playEdge, playTick, useValueSliderMuted } from \"./sound\"\nimport type {\n  ValueSliderProps,\n  ValueSliderTuning,\n  ValueSliderValue,\n} from \"./types\"\nimport { useElasticPull } from \"./use-elastic-pull\"\nimport { useTouchIntent } from \"./use-touch-intent\"\nimport { useHandleDodge } from \"./use-handle-dodge\"\nimport {\n  ValueSliderReadout,\n  ValueSliderThumb,\n  ValueSliderTrack,\n  type ValueSliderEditor,\n} from \"./value-slider-parts\"\nimport {\n  clamp01,\n  crossedTick,\n  resolveTicks,\n  sameValue,\n  snapValuesToTicks,\n  stepDecimals,\n  toNumberList,\n  valueToPercent,\n} from \"./utils\"\n\n/** 刻度音效的最短间隔（毫秒），拖动时值变化很快，太密会糊成噪声。 */\nconst TICK_INTERVAL = 35\n/** 碰边音效的最短间隔（毫秒），磁吸与过冲可能同时报同一次碰边。 */\nconst EDGE_INTERVAL = 250\n/** 磁吸判定：与最近刻度的距离不超过整个范围的这个比例时吸过去。 */\nconst SNAP_RATIO = 0.03\n\nconst valueSliderVariants = cva(\"flex w-full flex-col select-none\", {\n  variants: {\n    size: {\n      sm: \"gap-1 text-xs [--value-slider-height:1.75rem] pointer-coarse:[--value-slider-height:2.25rem]\",\n      default:\n        \"gap-1 text-sm [--value-slider-height:2rem] pointer-coarse:[--value-slider-height:2.5rem]\",\n      lg: \"gap-1 text-sm [--value-slider-height:2.25rem] pointer-coarse:[--value-slider-height:2.75rem]\",\n    },\n    variant: {\n      // 三种形态都是方条，只是高度不同：bar 沿用尺寸档的高度，compact / slim 在 compoundVariants 里按尺寸细分\n      bar: \"[--value-slider-track-height:var(--value-slider-height)]\",\n      // 中等高度：轨道里仍放标签与读数，字号统一收到 xs\n      compact: \"text-xs\",\n      // 矮轨：文字移到上方，手柄比轨道高出一截\n      slim: \"gap-1.5\",\n    },\n  },\n  compoundVariants: [\n    {\n      variant: \"compact\",\n      size: \"sm\",\n      class: \"[--value-slider-track-height:1.125rem]\",\n    },\n    {\n      variant: \"compact\",\n      size: \"default\",\n      class: \"[--value-slider-track-height:1.375rem]\",\n    },\n    {\n      variant: \"compact\",\n      size: \"lg\",\n      class: \"[--value-slider-track-height:1.5rem]\",\n    },\n    {\n      variant: \"slim\",\n      size: \"sm\",\n      class:\n        \"[--value-slider-track-height:0.375rem] [--value-slider-thumb-height:0.875rem]\",\n    },\n    {\n      variant: \"slim\",\n      size: \"default\",\n      class:\n        \"[--value-slider-track-height:0.5rem] [--value-slider-thumb-height:1rem]\",\n    },\n    {\n      variant: \"slim\",\n      size: \"lg\",\n      class:\n        \"[--value-slider-track-height:0.625rem] [--value-slider-thumb-height:1.125rem]\",\n    },\n  ],\n  defaultVariants: {\n    size: \"default\",\n    variant: \"bar\",\n  },\n})\n\n/**\n * 可选的调整上下文：子树里所有 ValueSlider 的对应属性被这里的值覆盖。\n * 用于调试面板一类的场景；不包这一层时组件行为不变。\n */\nconst ValueSliderTuningContext = React.createContext<ValueSliderTuning>({})\n\n/** 带标签与读数的数值滑块，拖出两端时有橡皮筋过冲与弹簧回弹。 */\nfunction ValueSlider<Value extends ValueSliderValue = number>(\n  allProps: ValueSliderProps<Value>,\n) {\n  const { onReport, ...overrides } = React.useContext(ValueSliderTuningContext)\n  const {\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    onValueCommitted,\n    min = 0,\n    max = 100,\n    step = 1,\n    largeStep = 10,\n    minStepsBetweenValues = 0,\n    disabled = false,\n    name,\n    format,\n    locale,\n    label,\n    \"aria-label\": ariaLabel,\n    labelPlacement = \"inside\",\n    buffer,\n    formatValue,\n    showValue: showValueProp = true,\n    startIcon,\n    endIcon,\n    ticks: ticksProp,\n    snapToTicks: snapToTicksProp = false,\n    elasticity: elasticityProp = 12,\n    size: sizeProp = \"default\",\n    variant: variantProp = \"bar\",\n    tone: toneProp,\n    sound: soundProp = false,\n    editable = false,\n    modified = false,\n    className,\n    style,\n    ...props\n  } = allProps\n\n  // 回报示例自己的取值（覆盖前），面板打开时显示的就是示例现在的样子\n  React.useEffect(() => {\n    onReport?.({\n      elasticity: elasticityProp,\n      size: sizeProp,\n      variant: variantProp,\n      tone: toneProp,\n      // 显式刻度数组没有「段数」可言，报 undefined 让面板显示默认值\n      ticks: typeof ticksProp === \"number\" ? ticksProp : undefined,\n      snapToTicks: snapToTicksProp,\n      showValue: showValueProp,\n      sound: soundProp,\n    })\n  }, [\n    onReport,\n    elasticityProp,\n    sizeProp,\n    variantProp,\n    toneProp,\n    ticksProp,\n    snapToTicksProp,\n    showValueProp,\n    soundProp,\n  ])\n\n  const elasticity = Math.max(0, overrides.elasticity ?? elasticityProp)\n  const size = overrides.size ?? sizeProp\n  const variant = overrides.variant ?? variantProp\n  const ticks = overrides.ticks ?? ticksProp\n  const snapToTicks = overrides.snapToTicks ?? snapToTicksProp\n  const showValue = overrides.showValue ?? showValueProp\n  const sound = overrides.sound ?? soundProp\n  const tone =\n    overrides.tone === false ? undefined : (overrides.tone ?? toneProp)\n  const hasTone = tone !== undefined\n  // slim 的轨道只有几像素高，文字放不进去，一律摆到上方，等同 labelPlacement=\"outside\"\n  const slim = variant === \"slim\"\n  const outside = slim || labelPlacement === \"outside\"\n\n  // 读数、碰边判断都基于当前值，所以值统一收在这里，再以受控方式交给 Base UI\n  const [value, setValue] = useControllableState<Value>({\n    prop: valueProp,\n    defaultProp: (defaultValue ?? min) as Value,\n    onChange: onValueChange,\n  })\n  const values = React.useMemo(() => toNumberList(value), [value])\n  const isRange = values.length > 1\n\n  const direction = useDirection()\n  const [muted] = useValueSliderMuted(sound)\n  const soundOn = sound && !muted\n  const soundOnRef = React.useRef(soundOn)\n  React.useEffect(() => {\n    soundOnRef.current = soundOn\n  })\n\n  const decimals = stepDecimals(step)\n  const formatNumber = React.useMemo(() => {\n    if (formatValue) return formatValue\n    const formatter = new Intl.NumberFormat(locale ?? \"zh-CN\", {\n      minimumFractionDigits: 0,\n      maximumFractionDigits: decimals,\n      ...format,\n    })\n    return (item: number) => formatter.format(item)\n  }, [formatValue, locale, format, decimals])\n\n  const tickList = React.useMemo(\n    () => resolveTicks(ticks, min, max),\n    [ticks, min, max],\n  )\n  const innerTicks = React.useMemo(\n    () => tickList.filter((tick) => tick > min && tick < max),\n    [tickList, min, max],\n  )\n\n  /* ---------- 音效 ---------- */\n\n  const lastTickRef = React.useRef(0)\n  const lastEdgeRef = React.useRef(0)\n  const previousValuesRef = React.useRef<number[] | null>(null)\n\n  const playEdgeOnce = React.useCallback(() => {\n    const now = performance.now()\n    if (now - lastEdgeRef.current < EDGE_INTERVAL) return\n    lastEdgeRef.current = now\n    if (soundOnRef.current) playEdge()\n  }, [])\n\n  const handleValueChange = (next: Value) => {\n    const list = toNumberList(next)\n    const previous = previousValuesRef.current\n    previousValuesRef.current = list\n    setValue(next)\n\n    // 音效跟着变化最大的那个手柄走\n    let index = 0\n    let from = previous?.[0]\n    if (previous?.length === list.length) {\n      let largest = -1\n      list.forEach((item, itemIndex) => {\n        const delta = Math.abs(item - (previous[itemIndex] ?? item))\n        if (delta > largest) {\n          largest = delta\n          index = itemIndex\n          from = previous[itemIndex]\n        }\n      })\n    }\n    const changed = list[index] ?? min\n    if (changed <= min || changed >= max) playEdgeOnce()\n    if (!soundOn) return\n    const crossed = from !== undefined && crossedTick(from, changed, tickList)\n    const stepped =\n      from === undefined || Math.abs(changed - from) >= step - 1e-9\n    if (tickList.length > 0 ? crossed : stepped) {\n      const now = performance.now()\n      if (now - lastTickRef.current >= TICK_INTERVAL) {\n        lastTickRef.current = now\n        playTick(clamp01((changed - min) / Math.max(1e-6, max - min)))\n      }\n    }\n  }\n\n  /**\n   * 只有指针交互松开时才磁吸。\n   * Base UI 对每一次键盘变更也会调 `onValueCommitted`（reason 为 `\"keyboard\"`），\n   * 在那里吸附会让值被反复拉回最近的刻度，方向键就只能在刻度上原地挣扎。\n   */\n  const handleValueCommitted = (\n    next: Value,\n    details: SliderPrimitive.Root.CommitEventDetails,\n  ) => {\n    let committed = next\n    const fromPointer =\n      details.reason === \"drag\" || details.reason === \"track-press\"\n    if (snapToTicks && fromPointer && tickList.length > 0) {\n      const list = toNumberList(next)\n      const snapped = snapValuesToTicks(list, {\n        ticks: tickList,\n        min,\n        max,\n        step,\n        minGapSteps: minStepsBetweenValues,\n        tolerance: (max - min) * SNAP_RATIO,\n      })\n      if (!sameValue(snapped, list)) {\n        const result = (snapped.length === 1 ? snapped[0] : snapped) as Value\n        setValue(result)\n        committed = result\n      }\n    }\n    onValueCommitted?.(committed)\n  }\n\n  /* ---------- 过冲 ---------- */\n\n  const elastic = useElasticPull({ elasticity, disabled, onEdge: playEdgeOnce })\n\n  /* ---------- 触摸：上下滑滚动页面，左右拖才改值 ---------- */\n\n  const controlRef = React.useRef<HTMLDivElement>(null)\n  useTouchIntent(controlRef, disabled)\n\n  const handleThumbKeyDown = (\n    event: React.KeyboardEvent<HTMLInputElement>,\n    index: number,\n  ) => {\n    if (event.key === \"Enter\" && editor) {\n      event.preventDefault()\n      editor.start()\n      return\n    }\n    const current = values[index] ?? min\n    // RTL 时左右方向键的增减是反的；方向取自 Base UI 的 DirectionProvider，与它自己的按键映射一致\n    const rtl = direction === \"rtl\"\n    // 端点脉冲是物理方向的：RTL 时 min 在右边，所以两侧要互换\n    const minSide = rtl ? \"end\" : \"start\"\n    const maxSide = rtl ? \"start\" : \"end\"\n    const towardStart =\n      event.key === (rtl ? \"ArrowRight\" : \"ArrowLeft\") ||\n      event.key === \"ArrowDown\" ||\n      event.key === \"PageDown\" ||\n      event.key === \"Home\"\n    const towardEnd =\n      event.key === (rtl ? \"ArrowLeft\" : \"ArrowRight\") ||\n      event.key === \"ArrowUp\" ||\n      event.key === \"PageUp\" ||\n      event.key === \"End\"\n    if (towardStart && current <= min) elastic.pulse(minSide)\n    else if (towardEnd && current >= max) elastic.pulse(maxSide)\n  }\n\n  /* ---------- 手柄让开轨道内的文字 ---------- */\n\n  const insideText = !outside && (label !== undefined || showValue)\n  const { trackRef, labelRef, valueRef, isNear } = useHandleDodge(insideText)\n\n  const thumbName = ariaLabel ?? (typeof label === \"string\" ? label : \"数值\")\n  const readout = isRange\n    ? `${formatNumber(values[0] ?? min)} – ${formatNumber(values[values.length - 1] ?? max)}`\n    : formatNumber(values[0] ?? min)\n  /* ---------- 读数手动输入 ---------- */\n\n  const [editing, setEditing] = React.useState(false)\n  const [draft, setDraft] = React.useState(\"\")\n  // 回车提交后输入框卸载，可能紧跟着一次失焦提交；用 ref 保证只提交一次\n  const editingRef = React.useRef(false)\n  const canEdit = editable && !isRange && !disabled\n\n  const focusThumb = (from: HTMLElement) => {\n    const root = from.closest<HTMLElement>('[data-slot=\"value-slider\"]')\n    // 等输入框卸载后再聚焦，否则焦点会落回正在消失的元素\n    requestAnimationFrame(() =>\n      root\n        ?.querySelector<HTMLInputElement>(\n          '[data-slot=\"value-slider-thumb\"] input',\n        )\n        ?.focus(),\n    )\n  }\n\n  const editor: ValueSliderEditor | undefined = canEdit\n    ? {\n        editing,\n        draft,\n        label: `输入${thumbName}`,\n        start: () => {\n          editingRef.current = true\n          setDraft((values[0] ?? min).toFixed(decimals))\n          setEditing(true)\n        },\n        change: setDraft,\n        commit: (from, refocus) => {\n          if (!editingRef.current) return\n          editingRef.current = false\n          setEditing(false)\n          if (refocus) focusThumb(from)\n          // 容忍千分位与单位：只取数字部分；解析不出数字时当作放弃\n          const parsed = Number.parseFloat(draft.replace(/[^\\d.+-]/g, \"\"))\n          if (!Number.isFinite(parsed)) return\n          const stepped = min + Math.round((parsed - min) / step) * step\n          const next = Number(\n            Math.min(max, Math.max(min, stepped)).toFixed(decimals),\n          )\n          if (next === values[0]) return\n          // 手动输入是一次性跳到目标值，不放刻度音效，直接写值并当作一次提交\n          previousValuesRef.current = [next]\n          setValue(next as unknown as Value)\n          onValueCommitted?.(next as unknown as Value)\n        },\n        cancel: (from) => {\n          editingRef.current = false\n          setEditing(false)\n          focusThumb(from)\n        },\n      }\n    : undefined\n\n  const bufferPercent =\n    buffer === undefined || isRange\n      ? undefined\n      : valueToPercent(buffer, min, max)\n\n  return (\n    <SliderPrimitive.Root\n      {...props}\n      value={value}\n      onValueChange={(next) => handleValueChange(next as unknown as Value)}\n      onValueCommitted={(next, details) =>\n        handleValueCommitted(next as unknown as Value, details)\n      }\n      min={min}\n      max={max}\n      step={step}\n      largeStep={largeStep}\n      minStepsBetweenValues={minStepsBetweenValues}\n      // 手柄在两端时贴着轨道内侧，不伸出边框（默认的 center 会探出半个手柄）\n      thumbAlignment=\"edge\"\n      disabled={disabled}\n      name={name}\n      format={format}\n      locale={locale}\n      data-slot=\"value-slider\"\n      data-variant={variant}\n      data-modified={modified ? \"\" : undefined}\n      data-overshoot={elastic.side ?? undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        valueSliderVariants({ size, variant }),\n        \"group/value-slider data-[disabled]:opacity-50\",\n        className,\n      )}\n      style={{ ...flowToneStyle(tone), ...style }}\n    >\n      {outside ? (\n        <div\n          data-slot=\"value-slider-labels\"\n          className=\"flex items-baseline justify-between gap-2 px-0.5\"\n        >\n          <span\n            data-slot=\"value-slider-label\"\n            className=\"truncate font-medium text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground\"\n          >\n            {label}\n          </span>\n          {showValue ? (\n            <ValueSliderReadout\n              readout={readout}\n              editor={editor}\n              inside={false}\n            />\n          ) : null}\n        </div>\n      ) : null}\n      <div className=\"flex w-full min-w-0 items-center gap-2\">\n        {startIcon ? (\n          <motion.span\n            data-slot=\"value-slider-start-icon\"\n            aria-hidden=\"true\"\n            className=\"flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4\"\n            style={{ x: elastic.startIconX, scale: elastic.startIconScale }}\n          >\n            {startIcon}\n          </motion.span>\n        ) : null}\n        {/* Control 只负责把指针换算成数值，自己不缩放；缩放在它内部的视觉层上。\n            slim 的轨道只有几像素高，给 Control 一个 20px 的命中区，视觉层在其中垂直居中 */}\n        <SliderPrimitive.Control\n          ref={controlRef}\n          data-slot=\"value-slider-control\"\n          className={cn(\n            \"relative w-full min-w-0 cursor-pointer touch-pan-y select-none in-data-[disabled]:cursor-not-allowed has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-3 has-[[data-slot=value-slider-thumb]_input:focus-visible]:ring-ring/50\",\n            slim && \"flex min-h-5 items-center pointer-coarse:min-h-11\",\n          )}\n          onPointerDown={elastic.onPointerDown}\n        >\n          <motion.div\n            data-slot=\"value-slider-rail\"\n            className=\"relative w-full origin-center\"\n            style={{\n              x: elastic.x,\n              scaleX: elastic.scaleX,\n              scaleY: elastic.scaleY,\n            }}\n          >\n            <ValueSliderTrack\n              trackRef={trackRef}\n              labelRef={labelRef}\n              valueRef={valueRef}\n              variant={variant}\n              outside={outside}\n              label={label}\n              readout={readout}\n              showValue={showValue}\n              editor={editor}\n              bufferPercent={bufferPercent}\n              hasTone={hasTone}\n              ticks={innerTicks}\n              min={min}\n              max={max}\n            />\n            {values.map((item, index) => (\n              <ValueSliderThumb\n                key={index}\n                index={index}\n                variant={variant}\n                near={isNear(valueToPercent(item, min, max))}\n                hasTone={hasTone}\n                outside={outside}\n                label={\n                  isRange\n                    ? `${thumbName}（${index === 0 ? \"起点\" : \"终点\"}）`\n                    : thumbName\n                }\n                valueText={formatNumber(item)}\n                onKeyDown={(event) => handleThumbKeyDown(event, index)}\n              />\n            ))}\n          </motion.div>\n        </SliderPrimitive.Control>\n        {endIcon ? (\n          <motion.span\n            data-slot=\"value-slider-end-icon\"\n            aria-hidden=\"true\"\n            className=\"flex shrink-0 items-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4\"\n            style={{ x: elastic.endIconX, scale: elastic.endIconScale }}\n          >\n            {endIcon}\n          </motion.span>\n        ) : null}\n      </div>\n    </SliderPrimitive.Root>\n  )\n}\n\nexport {\n  ValueSlider,\n  ValueSliderTuningContext,\n  useValueSliderMuted,\n  valueSliderVariants,\n}\nexport type {\n  ValueSliderLabelPlacement,\n  ValueSliderProps,\n  ValueSliderReport,\n  ValueSliderSize,\n  ValueSliderTuning,\n  ValueSliderValue,\n  ValueSliderVariant,\n} from \"./types\"\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/value-slider.tsx"
    },
    {
      "path": "src/registry/ui/value-slider/value-slider-parts.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Slider as SliderPrimitive } from \"@base-ui/react/slider\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport type { ValueSliderVariant } from \"./types\"\nimport { valueToPercent } from \"./utils\"\n\ntype ValueSliderTrackProps = {\n  /** 观测轨道宽度，用于手柄让位 */\n  trackRef: React.RefObject<HTMLDivElement | null>\n  labelRef: React.RefObject<HTMLSpanElement | null>\n  valueRef: React.RefObject<HTMLSpanElement | null>\n  variant: ValueSliderVariant\n  /** 文字放在轨道上方时（`labelPlacement=\"outside\"`，以及 `slim`）轨道里不再渲染它们 */\n  outside: boolean\n  label: React.ReactNode\n  readout: string\n  showValue: boolean\n  /** 读数的手动输入；`undefined` 表示不可输入 */\n  editor: ValueSliderEditor | undefined\n  /** 缓冲填充的百分比；`undefined` 表示不画 */\n  bufferPercent: number | undefined\n  hasTone: boolean\n  ticks: readonly number[]\n  min: number\n  max: number\n}\n\n/**\n * 轨道与它内部的视觉层：缓冲填充、已选填充、刻度线、轨道内的标签与读数。\n * 只负责画，交互全部交给上层的 `Slider.Control` 与手柄。\n * 三种形态共用同一套方条语言（细边框、小圆角、淡色分层填充），只是高度不同；\n * `slim` 只有几像素高，填充加深一档，否则淡色叠在淡色上看不出进度。\n */\nfunction ValueSliderTrack({\n  trackRef,\n  labelRef,\n  valueRef,\n  variant,\n  outside,\n  label,\n  readout,\n  showValue,\n  editor,\n  bufferPercent,\n  hasTone,\n  ticks,\n  min,\n  max,\n}: ValueSliderTrackProps) {\n  const slim = variant === \"slim\"\n\n  return (\n    <SliderPrimitive.Track\n      ref={trackRef}\n      data-slot=\"value-slider-track\"\n      className={cn(\n        \"group/track relative h-(--value-slider-track-height) w-full overflow-hidden border border-edge bg-muted/60 in-aria-invalid:border-destructive in-aria-invalid:ring-3 in-aria-invalid:ring-destructive/20 dark:in-aria-invalid:border-destructive/50 dark:in-aria-invalid:ring-destructive/40\",\n        slim ? \"rounded-xs\" : \"rounded-md\",\n      )}\n    >\n      {/* 缓冲段画在已选填充下面：先出现的是「已加载」，盖上去的是「已播放」 */}\n      {bufferPercent === undefined ? null : (\n        <span\n          data-slot=\"value-slider-buffer\"\n          aria-hidden=\"true\"\n          className={cn(\n            \"absolute inset-y-0 start-0\",\n            hasTone\n              ? slim\n                ? \"bg-(--flow-tone)/20\"\n                : \"bg-(--flow-tone)/10\"\n              : \"bg-foreground/10 dark:bg-foreground/15\",\n          )}\n          style={{ width: `${bufferPercent}%` }}\n        />\n      )}\n      <SliderPrimitive.Indicator\n        data-slot=\"value-slider-indicator\"\n        className={cn(\n          \"h-full\",\n          hasTone\n            ? slim\n              ? \"bg-(--flow-tone)/45 dark:bg-(--flow-tone)/60\"\n              : \"bg-(--flow-tone)/20 dark:bg-(--flow-tone)/35\"\n            : slim\n              ? \"bg-foreground/25 dark:bg-foreground/30\"\n              : \"bg-foreground/10 dark:bg-foreground/15\",\n        )}\n      />\n      {ticks.map((tick) => (\n        <span\n          key={tick}\n          data-slot=\"value-slider-tick\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-1/2 h-2 w-px -translate-y-1/2 bg-foreground/15 transition-colors group-hover/track:bg-foreground/30 in-data-[dragging]:bg-foreground/30\"\n          // 逻辑属性定位：LTR 从左边、RTL 从右边量；自己减掉半个线宽，两个方向都是居中的\n          style={{\n            insetInlineStart: `calc(${valueToPercent(tick, min, max)}% - 0.5px)`,\n          }}\n        />\n      ))}\n      {label !== undefined && !outside ? (\n        <span\n          ref={labelRef}\n          data-slot=\"value-slider-label\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 font-medium whitespace-nowrap text-muted-foreground transition-colors group-data-[modified]/value-slider:text-foreground\"\n        >\n          {label}\n        </span>\n      ) : null}\n      {showValue && !outside ? (\n        <ValueSliderReadout\n          valueRef={valueRef}\n          readout={readout}\n          editor={editor}\n          inside\n        />\n      ) : null}\n    </SliderPrimitive.Track>\n  )\n}\n\n/** 读数手动输入的状态与动作，由根组件持有，读数在轨道里或轨道上方都用同一份。 */\ntype ValueSliderEditor = {\n  editing: boolean\n  draft: string\n  /** 输入框的可访问名称 */\n  label: string\n  start: () => void\n  change: (draft: string) => void\n  /** 提交草稿；`refocus` 为真时把焦点还给手柄（回车提交），失焦提交时不抢焦点 */\n  commit: (from: HTMLElement, refocus: boolean) => void\n  cancel: (from: HTMLElement) => void\n}\n\ntype ValueSliderReadoutProps = {\n  valueRef?: React.RefObject<HTMLSpanElement | null>\n  readout: string\n  editor: ValueSliderEditor | undefined\n  /** 画在轨道里（绝对定位在右端）还是轨道上方的标签行里 */\n  inside: boolean\n}\n\n/**\n * 读数。可输入时点一下（或在手柄上按回车）换成输入框：回车或失焦提交，Esc 放弃。\n * 读数和输入框都拦下 `pointerdown`，否则点读数会被 `Slider.Control` 当成点轨道、把值跳过去。\n * 读数本身对读屏隐藏（数值由手柄的 `aria-valuetext` 朗读），键盘用户从手柄按回车进入输入。\n */\nfunction ValueSliderReadout({\n  valueRef,\n  readout,\n  editor,\n  inside,\n}: ValueSliderReadoutProps) {\n  const focusInput = React.useCallback((node: HTMLInputElement | null) => {\n    node?.focus()\n  }, [])\n  const stop = (event: React.PointerEvent) => event.stopPropagation()\n\n  if (editor?.editing) {\n    return (\n      <input\n        data-slot=\"value-slider-input\"\n        aria-label={editor.label}\n        ref={focusInput}\n        inputMode=\"decimal\"\n        value={editor.draft}\n        onChange={(event) => editor.change(event.target.value)}\n        onPointerDown={stop}\n        // 进入编辑时整段选中，直接敲数字就是替换\n        onFocus={(event) => event.currentTarget.select()}\n        onBlur={(event) => editor.commit(event.currentTarget, false)}\n        onKeyDown={(event) => {\n          // 别让方向键、回车冒泡到 Slider 上去改值\n          event.stopPropagation()\n          if (event.key === \"Enter\") {\n            event.preventDefault()\n            editor.commit(event.currentTarget, true)\n          } else if (event.key === \"Escape\") {\n            event.preventDefault()\n            editor.cancel(event.currentTarget)\n          }\n        }}\n        className={cn(\n          // 嵌在读数原位的一小块：背景色底 + 色调细描边表示「正在编辑」，不再叠加粗焦点环\n          \"w-[8ch] rounded-sm bg-background px-1.5 text-end font-mono text-foreground tabular-nums shadow-xs ring-1 ring-[color-mix(in_oklab,var(--flow-tone,var(--foreground))_55%,transparent)] outline-none\",\n          inside\n            ? \"absolute top-1/2 right-1 h-[max(1rem,calc(100%-0.375rem))] -translate-y-1/2\"\n            : \"h-5\",\n        )}\n      />\n    )\n  }\n\n  return (\n    <span\n      ref={valueRef}\n      data-slot=\"value-slider-value\"\n      aria-hidden=\"true\"\n      onPointerDown={editor ? stop : undefined}\n      onClick={editor?.start}\n      className={cn(\n        \"font-mono whitespace-nowrap text-muted-foreground tabular-nums transition-colors group-hover/value-slider:text-foreground group-data-[modified]/value-slider:text-foreground in-data-[dragging]:text-foreground\",\n        inside && \"absolute top-1/2 right-3 -translate-y-1/2\",\n        editor\n          ? \"pointer-events-auto cursor-text decoration-dotted underline-offset-2 hover:underline\"\n          : inside && \"pointer-events-none\",\n      )}\n    >\n      {readout}\n    </span>\n  )\n}\n\ntype ValueSliderThumbProps = {\n  index: number\n  variant: ValueSliderVariant\n  /** 手柄压在标签或读数上：淡下去让文字可读 */\n  near: boolean\n  hasTone: boolean\n  /** 文字在轨道外时手柄常驻实色，不需要让位 */\n  outside: boolean\n  label: string\n  valueText: string\n  onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void\n}\n\n/**\n * 手柄：一根 4px 的竖线，放在轨道外面一层，端点处不会被轨道的 overflow 裁掉。\n * `bar` / `compact` 里竖线是轨道高度的一半；`slim` 的轨道太矮，竖线反过来比轨道高出一截\n * （`--value-slider-thumb-height`），像一根插在轨道上的标尺指针。\n * 内部是一个视觉隐藏的 `input`，键盘聚焦时由它来给手柄本身加出环（区间模式下 Control 上\n * 的整体焦点环指不出是哪个手柄）。焦点把手柄从 4px 加粗到 6px，并撑满不透明度。\n */\nfunction ValueSliderThumb({\n  index,\n  variant,\n  near,\n  hasTone,\n  outside,\n  label,\n  valueText,\n  onKeyDown,\n}: ValueSliderThumbProps) {\n  return (\n    <SliderPrimitive.Thumb\n      index={index}\n      data-slot=\"value-slider-thumb\"\n      data-near={near ? \"true\" : undefined}\n      aria-label={label}\n      aria-valuetext={valueText}\n      className={cn(\n        \"w-1 rounded-full transition-[width,opacity]\",\n        variant === \"slim\" ? \"h-(--value-slider-thumb-height)\" : \"h-1/2\",\n        \"has-[:focus-visible]:w-1.5 has-[:focus-visible]:opacity-100 has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50\",\n        hasTone ? \"bg-(--flow-tone)\" : \"bg-foreground\",\n        outside\n          ? \"opacity-100\"\n          : \"opacity-40 group-hover/value-slider:opacity-100 in-data-[dragging]:opacity-100 data-[near=true]:not-has-[:focus-visible]:opacity-20!\",\n      )}\n      onKeyDown={onKeyDown}\n    />\n  )\n}\n\nexport { ValueSliderReadout, ValueSliderThumb, ValueSliderTrack }\nexport type {\n  ValueSliderEditor,\n  ValueSliderReadoutProps,\n  ValueSliderThumbProps,\n  ValueSliderTrackProps,\n}\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/value-slider-parts.tsx"
    },
    {
      "path": "src/registry/ui/value-slider/types.ts",
      "content": "import type { Slider as SliderPrimitive } from \"@base-ui/react/slider\"\nimport type * as React from \"react\"\n\nimport type { FlowTone } from \"@/lib/flow-tone\"\n\ntype ValueSliderValue = number | readonly number[]\ntype ValueSliderSize = \"sm\" | \"default\" | \"lg\"\ntype ValueSliderLabelPlacement = \"inside\" | \"outside\"\ntype ValueSliderVariant = \"bar\" | \"compact\" | \"slim\"\n\ntype ValueSliderProps<Value extends ValueSliderValue = number> = Omit<\n  SliderPrimitive.Root.Props<Value>,\n  | \"aria-label\"\n  | \"children\"\n  | \"className\"\n  | \"defaultValue\"\n  | \"disabled\"\n  | \"format\"\n  | \"largeStep\"\n  | \"locale\"\n  | \"max\"\n  | \"min\"\n  | \"minStepsBetweenValues\"\n  | \"name\"\n  | \"onValueChange\"\n  | \"onValueCommitted\"\n  | \"orientation\"\n  | \"render\"\n  | \"step\"\n  | \"style\"\n  | \"thumbAlignment\"\n  | \"value\"\n> & {\n  /** 受控值。传数组即为区间，数组长度决定手柄数量 */\n  value?: Value\n  /** 非受控时的初始值；不传时取 `min` */\n  defaultValue?: Value\n  /**\n   * 最小值\n   * @default 0\n   */\n  min?: number\n  /**\n   * 最大值\n   * @default 100\n   */\n  max?: number\n  /**\n   * 步长，读数的默认小数位也随之变化\n   * @default 1\n   */\n  step?: number\n  /**\n   * PageUp / PageDown 与 Shift + 方向键的步长\n   * @default 10\n   */\n  largeStep?: number\n  /**\n   * 区间模式下两个手柄之间至少相隔几个 `step`，也参与磁吸后的合法性判断\n   * @default 0\n   */\n  minStepsBetweenValues?: number\n  /**\n   * 是否禁用\n   * @default false\n   */\n  disabled?: boolean\n  /** 提交到表单时使用的字段名；不传时不生成表单字段 */\n  name?: string\n  /** 值变化时回调（拖动、键盘、点击轨道都会触发） */\n  onValueChange?: (value: Value) => void\n  /** 值提交时回调（松手、键盘操作结束） */\n  onValueCommitted?: (value: Value) => void\n  className?: string\n  style?: React.CSSProperties\n  /** 数字格式化选项，交给 `Intl.NumberFormat`；不传时按 `locale` 与 `step` 推导 */\n  format?: Intl.NumberFormatOptions\n  /**\n   * 数字格式化的区域，`format` 与默认读数都用它\n   * @default \"zh-CN\"\n   */\n  locale?: Intl.LocalesArgument\n  /** 读屏名称，优先于 `label`；都不传时手柄名称是「数值」 */\n  \"aria-label\"?: string\n  /** 标签；是字符串时同时作为手柄的读屏名称 */\n  label?: React.ReactNode\n  /**\n   * 标签与读数的位置：`inside` 放在轨道内两端，`outside` 放在轨道上方一行\n   * @default \"inside\"\n   */\n  labelPlacement?: ValueSliderLabelPlacement\n  /**\n   * 已缓冲到的值（例如视频已加载到第 N 秒），在已选填充下面画一段次级填充；仅单值模式生效\n   */\n  buffer?: number\n  /** 读数与 `aria-valuetext` 的格式化函数，优先于 `format` */\n  formatValue?: (value: number) => string\n  /**\n   * 是否显示读数。区间时显示为 `起点 – 终点`\n   * @default true\n   */\n  showValue?: boolean\n  /** 轨道左端外的图标，随左侧过冲平移 */\n  startIcon?: React.ReactNode\n  /** 轨道右端外的图标，随右侧过冲平移 */\n  endIcon?: React.ReactNode\n  /**\n   * 轨道内的刻度线。数字表示等分段数（画 n - 1 条内部刻度线），数组表示显式刻度值；\n   * 同时决定 `snapToTicks` 的吸附目标。建议刻度落在 `step` 上，不在的会对齐到最近的 `step`\n   */\n  ticks?: number | readonly number[]\n  /**\n   * 指针松手时（拖动或点击轨道）若与最近刻度的距离不超过范围的 3%，吸到该刻度并触发 `onValueChange`；\n   * 键盘逐步移动不吸附\n   * @default false\n   */\n  snapToTicks?: boolean\n  /**\n   * 拖出轨道两端时的最大过冲像素，`0` 关闭弹性\n   * @default 12\n   */\n  elasticity?: number\n  /**\n   * 尺寸档，与 `variant` 一起决定轨道高度：\n   * `bar` 为 sm 28px / default 32px / lg 36px，`compact` 为 18 / 22 / 24px，`slim` 为 6 / 8 / 10px\n   * @default \"default\"\n   */\n  size?: ValueSliderSize\n  /**\n   * 轨道高度档，三档都是同一种方条：`bar` 高轨，标签与读数在轨道里；\n   * `compact` 中等高度，文字仍在轨道里、字号收到 xs；\n   * `slim` 矮轨，标签与读数移到轨道上方（此时忽略 `labelPlacement`），竖线手柄比轨道高出一截\n   * @default \"bar\"\n   */\n  variant?: ValueSliderVariant\n  /**\n   * 色调：填充取 `--flow-tone` 的淡色（`slim` 加深一档），手柄取实色；不传时用前景色\n   */\n  tone?: FlowTone\n  /**\n   * 读数可直接输入：点读数（或在手柄上按回车）换成输入框，回车 / 失焦提交，Esc 放弃。\n   * 输入值会夹在 `min`–`max` 之间并对齐到 `step`；区间模式下不可输入\n   * @default false\n   */\n  editable?: boolean\n  /**\n   * 标记为「已改动」：标签与读数改用前景色，根元素带 `data-modified`。\n   * 控制面板里用来区分改过的参数与默认值\n   * @default false\n   */\n  modified?: boolean\n  /**\n   * 拖动时是否有刻度与碰边的音效（尊重本地静音偏好）\n   * @default false\n   */\n  sound?: boolean\n}\n\ntype ValueSliderTuning = Partial<\n  Pick<\n    ValueSliderProps,\n    | \"elasticity\"\n    | \"size\"\n    | \"variant\"\n    | \"ticks\"\n    | \"snapToTicks\"\n    | \"showValue\"\n    | \"sound\"\n  >\n> & {\n  /** `false` 表示去掉色调 */\n  tone?: FlowTone | false\n  /** 回报示例自己的实际取值（未被覆盖前），面板据此显示当前值 */\n  onReport?: (report: ValueSliderReport) => void\n}\n\ntype ValueSliderReport = {\n  elasticity: number\n  size: ValueSliderSize\n  variant: ValueSliderVariant\n  tone: FlowTone | undefined\n  /** 示例用显式刻度数组时回报 `undefined`，面板显示默认的 0 */\n  ticks: number | undefined\n  snapToTicks: boolean\n  showValue: boolean\n  sound: boolean\n}\n\nexport type {\n  ValueSliderLabelPlacement,\n  ValueSliderProps,\n  ValueSliderReport,\n  ValueSliderSize,\n  ValueSliderTuning,\n  ValueSliderValue,\n  ValueSliderVariant,\n}\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/types.ts"
    },
    {
      "path": "src/registry/ui/value-slider/utils.ts",
      "content": "/** 把数值夹在 [0, 1] 之间。 */\nfunction clamp01(value: number) {\n  if (!Number.isFinite(value)) return 0\n  return Math.min(1, Math.max(0, value))\n}\n\n/**\n * 橡皮筋拉伸量：指针越过轨道边界 `distance` 像素时的视觉拉伸。\n * 曲线是饱和的（越拉越难拉），结果永远小于 `max`，所以轨道不会被拉得没边。\n */\nfunction rubberBand(distance: number, max: number) {\n  if (!Number.isFinite(max) || max <= 0 || distance <= 0) return 0\n  return max * (1 - 1 / (1 + (0.55 * distance) / max))\n}\n\n/** 值在轨道内的百分比（0–100）。 */\nfunction valueToPercent(value: number, min: number, max: number) {\n  const span = max - min\n  if (!Number.isFinite(span) || span <= 0) return 0\n  return Math.min(100, Math.max(0, ((value - min) / span) * 100))\n}\n\n/** step 的小数位数：1 → 0，0.5 → 1，0.05 → 2，用于读数与读屏文本。 */\nfunction stepDecimals(step: number) {\n  if (!Number.isFinite(step) || step <= 0) return 0\n  const text = String(step)\n  const exponential = text.split(\"e-\")[1]\n  if (exponential) return Math.min(6, Number(exponential))\n  const dot = text.indexOf(\".\")\n  return dot === -1 ? 0 : Math.min(6, text.length - dot - 1)\n}\n\n/** 单值 / 区间统一取成升序数组，区间时报文的顺序与 Base UI 一致。 */\nfunction toNumberList(value: number | readonly number[]) {\n  if (typeof value === \"number\") return [value]\n  return [...value].sort((a, b) => a - b)\n}\n\n/**\n * 把 `ticks` 属性换算成刻度值数组（含两端）。\n * 数字表示等分段数，返回 n + 1 个值，其中 n - 1 个落在轨道内部；数组则原样排序、去重并夹在范围内。\n */\nfunction resolveTicks(\n  ticks: number | readonly number[] | undefined,\n  min: number,\n  max: number,\n) {\n  if (ticks === undefined) return []\n  if (typeof ticks !== \"number\") {\n    const list = ticks\n      .filter((item) => Number.isFinite(item) && item >= min && item <= max)\n      .sort((a, b) => a - b)\n    return list.filter((item, index) => index === 0 || item !== list[index - 1])\n  }\n  const count = Math.floor(ticks)\n  if (!Number.isFinite(count) || count < 2) return []\n  return Array.from(\n    { length: count + 1 },\n    (_, index) => min + ((max - min) * index) / count,\n  )\n}\n\n/** 离该值最近的刻度值；没有刻度时返回 undefined。 */\nfunction nearestTick(value: number, ticks: readonly number[]) {\n  let nearest: number | undefined\n  let distance = Infinity\n  for (const tick of ticks) {\n    const next = Math.abs(tick - value)\n    if (next < distance) {\n      distance = next\n      nearest = tick\n    }\n  }\n  return nearest\n}\n\n/** 从 `from` 走到 `to` 的过程中是否越过了某个刻度（含 `to` 所在的刻度，不含 `from`）。 */\nfunction crossedTick(from: number, to: number, ticks: readonly number[]) {\n  const low = Math.min(from, to)\n  const high = Math.max(from, to)\n  return ticks.some((tick) => tick > low && tick <= high)\n}\n\n/**\n * 把值对齐到 `min + n * step` 并夹在 [min, max]，值不是有限数时返回 `undefined`。\n *\n * 浮点误差靠「搬到整数域再算」消除：按 min / step / value 三者里最多的小数位放大成整数，\n * 算出整数序号后再除回去，所以 0.1 这种二进制表示不精确的 step 也不会跑出 0.30000000000000004。\n * 上界直接用 `max`（与 Base UI 一致：即使 `max` 不在步长网格上，键盘与指针也能取到它）。\n *\n * 例：min=0.05 step=0.1 tick=0.15 → 0.15；min=2700 step=100 tick=3650 → 3700。\n */\nfunction alignToStep(value: number, min: number, max: number, step: number) {\n  if (!Number.isFinite(value)) return undefined\n  const clamped = Math.min(max, Math.max(min, value))\n  if (!Number.isFinite(step) || step <= 0) return clamped\n  const decimals = Math.max(\n    stepDecimals(min),\n    stepDecimals(step),\n    stepDecimals(clamped),\n  )\n  const factor = 10 ** decimals\n  const minInt = Math.round(min * factor)\n  const stepInt = Math.max(1, Math.round(step * factor))\n  const valueInt = Math.round(clamped * factor)\n  const alignedInt = minInt + Math.round((valueInt - minInt) / stepInt) * stepInt\n  // 除法得到的是该十进制值最接近的 double，等价于字面量，不需要再 toFixed\n  const aligned = alignedInt / factor\n  return Math.min(max, Math.max(min, aligned))\n}\n\ntype SnapToTicksOptions = {\n  /** 吸附目标（含两端的刻度值） */\n  ticks: readonly number[]\n  min: number\n  max: number\n  /** 吸附后的值要对齐到 `min + n * step`，否则会提交不在步长上的值 */\n  step: number\n  /** 区间模式下两个值之间至少相隔几个 step */\n  minGapSteps?: number\n  /** 与刻度的最大距离，绝对值（由调用方按值域比例算好） */\n  tolerance: number\n}\n\n/**\n * 磁吸：凡是离刻度不超过 `tolerance` 的值，吸到该刻度并按 step 对齐后的位置。\n * 区间模式下吸附后的值必须保持顺序、并与相邻值至少相隔 `minGapSteps` 个 step，\n * 不满足就放弃这个值的吸附（宁可不动，也不要产生倒序或贴合的两个手柄）。\n */\nfunction snapValuesToTicks(\n  values: readonly number[],\n  options: SnapToTicksOptions,\n) {\n  const { ticks, min, max, step, minGapSteps = 0, tolerance } = options\n  const gap = Math.max(0, minGapSteps) * (Number.isFinite(step) ? step : 0)\n  const result = [...values]\n  for (let index = 0; index < values.length; index += 1) {\n    const current = values[index]\n    if (current === undefined) continue\n    const nearest = nearestTick(current, ticks)\n    if (nearest === undefined) continue\n    if (Math.abs(nearest - current) > tolerance) continue\n    const aligned = alignToStep(nearest, min, max, step)\n    if (aligned === undefined) continue\n    const before = result[index - 1]\n    const after = result[index + 1]\n    if (before !== undefined && aligned < before + gap) continue\n    if (after !== undefined && aligned > after - gap) continue\n    result[index] = aligned\n  }\n  return result\n}\n\n/** 单值与区间共用的相等判断，用于磁吸前后比较。 */\nfunction sameValue(\n  a: number | readonly number[],\n  b: number | readonly number[],\n) {\n  if (typeof a === \"number\" || typeof b === \"number\") return a === b\n  const left = a as readonly number[]\n  const right = b as readonly number[]\n  return (\n    left.length === right.length &&\n    left.every((item, index) => item === right[index])\n  )\n}\n\nexport {\n  alignToStep,\n  clamp01,\n  crossedTick,\n  nearestTick,\n  resolveTicks,\n  rubberBand,\n  sameValue,\n  snapValuesToTicks,\n  stepDecimals,\n  toNumberList,\n  valueToPercent,\n}\nexport type { SnapToTicksOptions }\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/utils.ts"
    },
    {
      "path": "src/registry/ui/value-slider/sound.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * 滑块的音效：用 Web Audio 现场合成，不需要音频文件。\n * 刻度是一声很短的高频「嗒」，音高跟着值走；碰到轨道两端是一声低闷的「咚」。\n * 浏览器的自动播放限制见下面的 unlock：手机上第一次按下的声音会在松手时补放。\n */\nlet context: AudioContext | null = null\n/** 音频还没解锁时攒下的声音：解锁后补放，超过 PENDING_TTL 毫秒的就丢掉 */\nlet pending: { play: (ctx: AudioContext) => void; at: number }[] = []\nconst PENDING_TTL = 600\n\nfunction audio() {\n  if (typeof window === \"undefined\") return null\n  if (!context) {\n    const Ctor =\n      window.AudioContext ??\n      (window as unknown as { webkitAudioContext?: typeof AudioContext })\n        .webkitAudioContext\n    if (!Ctor) return null\n    context = new Ctor()\n  }\n  return context\n}\n\nfunction flush() {\n  const ctx = context\n  if (ctx?.state !== \"running\") return\n  removeUnlockListeners()\n  const now = performance.now()\n  const queue = pending\n  pending = []\n  for (const item of queue) if (now - item.at < PENDING_TTL) item.play(ctx)\n}\n\n/**\n * 手机浏览器只认「松手」类的手势解锁音频（pointerup、touchend、click、keydown），\n * 触摸的按下不算，所以手指按下那一刻 AudioContext 还是挂起的。\n * 在页面上监听这些手势：第一次松手就恢复 AudioContext 并补放刚才攒下的声音，之后按下也能直接出声。\n * 老版 iOS 还要在手势里真正播一段声音才算解锁，顺手播一个 1 帧的静音缓冲。\n */\nfunction unlock() {\n  const ctx = audio()\n  if (!ctx) return\n  if (ctx.state === \"running\") {\n    return flush()\n  }\n  const silent = ctx.createBufferSource()\n  silent.buffer = ctx.createBuffer(1, 1, ctx.sampleRate)\n  silent.connect(ctx.destination)\n  silent.start()\n  void ctx.resume().then(flush, () => {})\n}\n\nconst GESTURES = [\"pointerup\", \"touchend\", \"click\", \"keydown\"] as const\nlet listening = false\n\nfunction removeUnlockListeners() {\n  if (!listening) return\n  for (const type of GESTURES) window.removeEventListener(type, unlock, true)\n  listening = false\n}\n\nfunction listenForUnlock() {\n  if (listening) return\n  listening = true\n  for (const type of GESTURES)\n    window.addEventListener(type, unlock, { capture: true, passive: true })\n}\n\n/** 有声就放；还没解锁就先记下，解锁后补放 */\nfunction withAudio(play: (ctx: AudioContext) => void) {\n  const ctx = audio()\n  if (!ctx) return\n  if (ctx.state === \"running\") {\n    removeUnlockListeners()\n    return play(ctx)\n  }\n  listenForUnlock()\n  // 只留最近两声：解锁前拖了一路滑块，补放时不能一下子炸出一串滴答\n  pending = [...pending, { play, at: performance.now() }].slice(-2)\n  void ctx.resume().then(flush, () => {})\n}\n\nconst clamp = (value: number) => Math.min(1, Math.max(0, value))\n\n/** 一段指数衰减的正弦：用来做「咚」的主体。 */\nfunction tone(\n  ctx: AudioContext,\n  from: number,\n  to: number,\n  duration: number,\n  volume: number,\n) {\n  const now = ctx.currentTime\n  const osc = ctx.createOscillator()\n  const gain = ctx.createGain()\n  osc.type = \"sine\"\n  osc.frequency.setValueAtTime(from, now)\n  osc.frequency.exponentialRampToValueAtTime(to, now + duration)\n  gain.gain.setValueAtTime(0.0001, now)\n  gain.gain.exponentialRampToValueAtTime(volume, now + 0.005)\n  gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)\n  osc.connect(gain).connect(ctx.destination)\n  osc.start(now)\n  osc.stop(now + duration + 0.02)\n}\n\n/** 一段带通滤过的噪声：构成「嗒」的颗粒感，长度只有十几毫秒。 */\nfunction noise(\n  ctx: AudioContext,\n  frequency: number,\n  duration: number,\n  volume: number,\n  q = 2.4,\n) {\n  const now = ctx.currentTime\n  const length = Math.max(1, Math.ceil(ctx.sampleRate * duration))\n  const buffer = ctx.createBuffer(1, length, ctx.sampleRate)\n  const data = buffer.getChannelData(0)\n  for (let i = 0; i < length; i++)\n    data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 2\n  const source = ctx.createBufferSource()\n  source.buffer = buffer\n  const filter = ctx.createBiquadFilter()\n  filter.type = \"bandpass\"\n  filter.frequency.value = frequency\n  filter.Q.value = q\n  const gain = ctx.createGain()\n  gain.gain.value = volume\n  source.connect(filter).connect(gain).connect(ctx.destination)\n  source.start(now)\n}\n\n/**\n * 一个刻度的滴答声。`pitch` 0–1 表示值在范围里的位置，越高越尖：\n * 高频带通噪声给「嗒」的触感，叠一声极短的正弦给它音高。\n */\nfunction playTick(pitch = 0.5) {\n  const t = clamp(pitch)\n  withAudio((ctx) => {\n    noise(ctx, 2400 + 2600 * t, 0.011, 0.05 + 0.04 * t, 3)\n    tone(ctx, 1100 + 1000 * t, 900 + 800 * t, 0.012, 0.028 + 0.012 * t)\n  })\n}\n\n/** 碰到轨道两端的一声低闷「咚」：低频下滑 + 一点点噪声让它不空。 */\nfunction playEdge() {\n  withAudio((ctx) => {\n    tone(ctx, 168, 62, 0.15, 0.17)\n    noise(ctx, 420, 0.045, 0.06, 1.1)\n  })\n}\n\n/* ---------- 静音偏好：存在 localStorage，全站带音效的组件共用 ---------- */\n\nconst STORAGE_KEY = \"yiui:sound:muted\"\n/** 旧版本用过的键：读到新键没写过时按它迁移，保证升级后偏好不丢。 */\nconst LEGACY_KEYS = [\"travinsy:sound:muted\", \"isometric-mark:muted\"]\nconst WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]\n/**\n * 同页广播：`storage` 事件只发给「其他」标签页，本页写 localStorage 不会触发它。\n * 带音效的组件各自有一份静音模块（互不 import），所以约定用同一个事件名互相通知。\n */\nconst MUTED_EVENT = \"yiui:sound-muted-change\"\nconst listeners = new Set<() => void>()\n\nfunction readMuted() {\n  try {\n    const current = window.localStorage.getItem(STORAGE_KEY)\n    if (current !== null) return current === \"1\"\n    for (const key of LEGACY_KEYS) {\n      const legacy = window.localStorage.getItem(key)\n      if (legacy === null) continue\n      window.localStorage.setItem(STORAGE_KEY, legacy)\n      for (const oldKey of LEGACY_KEYS) window.localStorage.removeItem(oldKey)\n      return legacy === \"1\"\n    }\n    return false\n  } catch {\n    return false\n  }\n}\n\nfunction setMuted(muted: boolean) {\n  try {\n    window.localStorage.setItem(STORAGE_KEY, muted ? \"1\" : \"0\")\n  } catch {\n    // 隐私模式等存不了时只在本页生效\n  }\n  for (const listener of listeners) listener()\n  // 通知同页其他带音效的组件\n  window.dispatchEvent(new Event(MUTED_EVENT))\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener)\n  const notify = () => listener()\n  const onStorage = (event: StorageEvent) => {\n    if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()\n  }\n  window.addEventListener(\"storage\", onStorage)\n  window.addEventListener(MUTED_EVENT, notify)\n  return () => {\n    listeners.delete(listener)\n    window.removeEventListener(\"storage\", onStorage)\n    window.removeEventListener(MUTED_EVENT, notify)\n  }\n}\n\nconst subscribeDisabled = () => () => {}\n\n/** 读取 / 切换音效静音。无声滑块不订阅全局事件，挂载后再读本地偏好。 */\n\nfunction useValueSliderMuted(enabled = true) {\n  const muted = React.useSyncExternalStore(\n    enabled ? subscribe : subscribeDisabled,\n    readMuted,\n    () => false,\n  )\n  return [muted, setMuted] as const\n}\n\nexport { playEdge, playTick, useValueSliderMuted }\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/sound.ts"
    },
    {
      "path": "src/registry/ui/value-slider/use-elastic-pull.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  animate,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\"\n\nimport { clamp01, rubberBand } from \"./utils\"\n\n/** 打开 / 关闭过冲的弹簧：`visualDuration` 是回到原位的时间，`bounce` 给它一点回弹。 */\nconst RECOIL_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.4,\n  bounce: 0.45,\n} as const\n/** 轨道在过冲时的最大纵向压缩比例。 */\nconst SQUASH_RATIO = 0.12\n\ntype ElasticSide = \"start\" | \"end\"\n\ntype UseElasticPullOptions = {\n  /** 拖出两端的最大过冲像素，`0` 关闭弹性 */\n  elasticity: number\n  /** 禁用时立刻收手：移除监听、停止弹簧、过冲归零 */\n  disabled?: boolean\n  /** 某一侧被拉开时回调，一次手势里每侧只报一次（用来放碰边音效） */\n  onEdge?: (side: ElasticSide) => void\n}\n\ntype ElasticPull = {\n  /** 有符号的拉伸像素：负数往左、正数往右 */\n  pull: MotionValue<number>\n  /** 视觉层的水平位移，和 `scaleX` 配合成「拉住一端」的样子 */\n  x: MotionValue<number>\n  scaleX: MotionValue<number>\n  scaleY: MotionValue<number>\n  startIconX: MotionValue<number>\n  endIconX: MotionValue<number>\n  startIconScale: MotionValue<number>\n  endIconScale: MotionValue<number>\n  /** 当前拉向哪一端，用于根元素的 `data-overshoot` */\n  side: ElasticSide | null\n  /** 挂在 `Slider.Control` 上：只量 Control 的矩形，视觉层自己缩放 */\n  onPointerDown: (event: React.PointerEvent<HTMLElement>) => void\n  /** 键盘在手柄已经位于端点时继续往同方向走，拉一下再弹回来 */\n  pulse: (side: ElasticSide) => void\n}\n\n/**\n * 橡皮筋过冲：指针拖出轨道后把轨道往那一侧拉长，松手用弹簧收回。\n * 全部走 MotionValue，拖动期间不触发 React 渲染；几何量自始至终只来自 Control 的矩形，\n * 所以缩放视觉层不会影响 Base UI 把指针换算成数值。\n */\nfunction useElasticPull({\n  elasticity,\n  disabled = false,\n  onEdge,\n}: UseElasticPullOptions): ElasticPull {\n  const reduced = useReducedMotion() ?? false\n  const pull = useMotionValue(0)\n  // 按下那一刻量到的轨道宽度，只用来把拉伸像素换算成缩放比例\n  const width = useMotionValue(1)\n  const scaleX = useTransform(\n    () => 1 + Math.abs(pull.get()) / Math.max(1, width.get()),\n  )\n  const scaleY = useTransform(\n    () =>\n      1 -\n      SQUASH_RATIO *\n        clamp01(Math.abs(pull.get()) / Math.max(1, elasticity || 1)),\n  )\n  // 固定以中心为原点，再平移半个拉伸量：视觉上等于拉住一端，换边时不需要切换 transform-origin\n  const x = useTransform(pull, (current) => current / 2)\n  const startIconX = useTransform(pull, (current) => Math.min(0, current))\n  const endIconX = useTransform(pull, (current) => Math.max(0, current))\n  const startIconScale = useMotionValue(1)\n  const endIconScale = useMotionValue(1)\n\n  const [side, setSide] = React.useState<ElasticSide | null>(null)\n  const sideRef = React.useRef<ElasticSide | null>(null)\n  // 手势编号：每次按下 / 复位都 +1，弹簧完成时据此判断自己是不是已经被新手势取代\n  const gestureRef = React.useRef(0)\n  const springRef = React.useRef<AnimationPlaybackControls | null>(null)\n  const detachRef = React.useRef<(() => void) | null>(null)\n  // 窗口级监听里要读最新属性，用 ref 保存，免得每帧重建监听\n  const optionsRef = React.useRef({ elasticity, reduced, onEdge })\n  React.useEffect(() => {\n    optionsRef.current = { elasticity, reduced, onEdge }\n  })\n\n  const setSideSafe = React.useCallback((next: ElasticSide | null) => {\n    if (sideRef.current === next) return\n    sideRef.current = next\n    setSide(next)\n  }, [])\n\n  /** 停下正在跑的回弹、过冲归零，并让旧弹簧的完成回调失效。 */\n  const reset = React.useCallback(() => {\n    gestureRef.current += 1\n    springRef.current?.stop()\n    springRef.current = null\n    pull.set(0)\n    setSideSafe(null)\n  }, [pull, setSideSafe])\n\n  const recoil = React.useCallback(() => {\n    const id = gestureRef.current\n    springRef.current?.stop()\n    springRef.current = animate(pull, 0, {\n      ...RECOIL_SPRING,\n      onComplete: () => {\n        // 旧手势的弹簧到期时才回来：不许它动新手势的状态\n        if (gestureRef.current !== id) return\n        springRef.current = null\n        setSideSafe(null)\n      },\n    })\n  }, [pull, setSideSafe])\n\n  const nudgeIcon = React.useCallback(\n    (target: ElasticSide) => {\n      if (reduced) return\n      animate(target === \"start\" ? startIconScale : endIconScale, [1, 1.25, 1], {\n        duration: 0.32,\n        ease: \"easeOut\",\n      })\n    },\n    [reduced, startIconScale, endIconScale],\n  )\n\n  const onPointerDown = React.useCallback(\n    (event: React.PointerEvent<HTMLElement>) => {\n      if (disabled || event.button !== 0) return\n      // 并发指针（另一根手指还按着）时只保留最新这一组：先把上一组的 window 监听解掉，\n      // 否则 detachRef 会被覆盖，旧手势的 move/release 再也解不掉\n      detachRef.current?.()\n      // 新的手势开始：上一段回弹（上一次拖动或键盘脉冲留下的）立刻作废\n      reset()\n      const state = optionsRef.current\n      if (state.reduced || state.elasticity <= 0) return\n      // 用按下这一刻的 Control 矩形量过冲；Control 自己不缩放，之后一直有效\n      const rect = event.currentTarget.getBoundingClientRect()\n      width.set(Math.max(1, rect.width))\n      const pointerId = event.pointerId\n      const nudged = { start: false, end: false }\n\n      const detach = () => {\n        window.removeEventListener(\"pointermove\", move)\n        window.removeEventListener(\"pointerup\", release)\n        window.removeEventListener(\"pointercancel\", release)\n        window.removeEventListener(\"lostpointercapture\", release)\n        window.removeEventListener(\"blur\", release)\n        if (detachRef.current === detach) detachRef.current = null\n      }\n      // 松手、被系统取消、指针捕获丢失、窗口失焦都走这里；重复调用是安全的\n      const release = (native: Event) => {\n        if (\"pointerId\" in native) {\n          const stale = native as PointerEvent\n          if (stale.pointerId !== pointerId) return\n        }\n        detach()\n        if (pull.get() === 0) setSideSafe(null)\n        else recoil()\n      }\n      const move = (native: PointerEvent) => {\n        if (native.pointerId !== pointerId) return\n        const latest = optionsRef.current\n        if (latest.reduced || latest.elasticity <= 0) return\n        const beyondEnd = native.clientX - rect.right\n        const beyondStart = rect.left - native.clientX\n        const next = beyondEnd > 0 ? \"end\" : beyondStart > 0 ? \"start\" : null\n        if (next === null) {\n          setSideSafe(null)\n          if (pull.get() !== 0) pull.set(0)\n          return\n        }\n        // 拖动时别选中旁边的文字\n        native.preventDefault()\n        const stretch = rubberBand(\n          Math.max(beyondEnd, beyondStart),\n          latest.elasticity,\n        )\n        pull.set(next === \"end\" ? stretch : -stretch)\n        setSideSafe(next)\n        if (!nudged[next]) {\n          nudged[next] = true\n          nudgeIcon(next)\n          latest.onEdge?.(next)\n        }\n      }\n\n      window.addEventListener(\"pointermove\", move, { passive: false })\n      window.addEventListener(\"pointerup\", release)\n      window.addEventListener(\"pointercancel\", release)\n      window.addEventListener(\"lostpointercapture\", release)\n      window.addEventListener(\"blur\", release)\n      detachRef.current = detach\n    },\n    [disabled, reset, width, nudgeIcon, pull, recoil, setSideSafe],\n  )\n\n  const pulse = React.useCallback(\n    (target: ElasticSide) => {\n      if (reduced || elasticity <= 0) return\n      reset()\n      setSideSafe(target)\n      nudgeIcon(target)\n      pull.set(target === \"end\" ? elasticity * 0.4 : elasticity * -0.4)\n      recoil()\n    },\n    [\n      reduced,\n      elasticity,\n      reset,\n      setSideSafe,\n      nudgeIcon,\n      pull,\n      recoil,\n    ],\n  )\n\n  // 拖动途中被禁用：立刻收手，别让 window 监听和回弹继续跑\n  React.useEffect(() => {\n    if (!disabled) return\n    reset()\n    detachRef.current?.()\n  }, [disabled, reset])\n\n  React.useEffect(\n    () => () => {\n      gestureRef.current += 1\n      springRef.current?.stop()\n      detachRef.current?.()\n    },\n    [],\n  )\n\n  return {\n    pull,\n    x,\n    scaleX,\n    scaleY,\n    startIconX,\n    endIconX,\n    startIconScale,\n    endIconScale,\n    side,\n    onPointerDown,\n    pulse,\n  }\n}\n\nexport { useElasticPull }\nexport type { ElasticPull, ElasticSide, UseElasticPullOptions }\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/use-elastic-pull.ts"
    },
    {
      "path": "src/registry/ui/value-slider/use-touch-intent.ts",
      "content": "import * as React from \"react\"\n\n/** 触摸时移动超过这么多像素才判断方向 */\nconst TOUCH_SLOP = 6\n\n/**\n * 触摸手势分流：手指按在整条滑块上时先不交给 Base UI，看清意图再说。\n * - 先往上下走：是在滚动页面。滑块是 `touch-action: pan-y`，浏览器接管滚动，值不动\n * - 先往左右走：是在拖动。在当前位置补发一个 pointerdown 给 Control，之后的移动照常由 Base UI 处理\n * - 没怎么动就松手：是点按。在松手处补发按下 + 松开，等同于点轨道跳到该处\n *\n * Base UI 的滑块会在页面滚动时继续跟着手指改值（它在 pointercancel 之后还监听 touchmove），\n * 整条方条又占满宽度，不分流的话手机上划过一排滑块就会把值全改掉。鼠标和笔不受影响。\n */\nfunction useTouchIntent(\n  controlRef: React.RefObject<HTMLElement | null>,\n  disabled: boolean | undefined,\n) {\n  React.useEffect(() => {\n    const control = controlRef.current\n    // 拦截要发生在 Control 之上：Base UI 的 touchstart 直接挂在 Control 上，React 的事件挂在根节点上\n    const gate = control?.parentElement\n    if (!control || !gate || disabled) return\n\n    let pending: {\n      id: number\n      x: number\n      y: number\n      target: EventTarget\n    } | null = null\n\n    const replay = (\n      type: \"pointerdown\" | \"pointerup\",\n      target: EventTarget,\n      from: PointerEvent,\n      pointerId: number,\n    ) =>\n      target.dispatchEvent(\n        new PointerEvent(type, {\n          bubbles: true,\n          cancelable: true,\n          composed: true,\n          clientX: from.clientX,\n          clientY: from.clientY,\n          screenX: from.screenX,\n          screenY: from.screenY,\n          pointerId,\n          pointerType: \"touch\",\n          isPrimary: true,\n          button: 0,\n          buttons: type === \"pointerdown\" ? 1 : 0,\n        }),\n      )\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (\n        event.pointerType !== \"touch\" ||\n        !event.isTrusted ||\n        !control.contains(event.target as Node)\n      )\n        return\n      event.stopPropagation()\n      pending = {\n        id: event.pointerId,\n        x: event.clientX,\n        y: event.clientY,\n        target: event.target as EventTarget,\n      }\n    }\n    // Base UI 收到 touchstart 会自己开始一次按下；交给上面的分流统一处理\n    const onTouchStart = (event: TouchEvent) => {\n      if (control.contains(event.target as Node)) event.stopPropagation()\n    }\n    const onPointerMove = (event: PointerEvent) => {\n      if (!pending || event.pointerId !== pending.id || !event.isTrusted) return\n      const dx = Math.abs(event.clientX - pending.x)\n      const dy = Math.abs(event.clientY - pending.y)\n      if (dx < TOUCH_SLOP && dy < TOUCH_SLOP) return\n      const { target } = pending\n      pending = null\n      // 先往上下走：浏览器会接着滚动页面并发出 pointercancel，这里放手即可\n      if (dy >= dx) return\n      // 用真实的 pointerId：Base UI 会捕获这根手指，过冲动画也按 id 跟踪后续移动\n      replay(\"pointerdown\", target, event, event.pointerId)\n    }\n    const onPointerUp = (event: PointerEvent) => {\n      if (!pending || event.pointerId !== pending.id || !event.isTrusted) return\n      const { target } = pending\n      pending = null\n      // 手指已经抬起，不能再捕获它：pointerId 传 0，Base UI 遇到 0 会跳过 setPointerCapture\n      replay(\"pointerdown\", target, event, 0)\n      replay(\"pointerup\", target, event, 0)\n    }\n    const onPointerCancel = (event: PointerEvent) => {\n      if (pending && event.pointerId === pending.id) pending = null\n    }\n\n    gate.addEventListener(\"pointerdown\", onPointerDown, { capture: true })\n    gate.addEventListener(\"touchstart\", onTouchStart, {\n      capture: true,\n      passive: true,\n    })\n    window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n    window.addEventListener(\"pointerup\", onPointerUp)\n    window.addEventListener(\"pointercancel\", onPointerCancel)\n    return () => {\n      gate.removeEventListener(\"pointerdown\", onPointerDown, { capture: true })\n      gate.removeEventListener(\"touchstart\", onTouchStart, { capture: true })\n      window.removeEventListener(\"pointermove\", onPointerMove)\n      window.removeEventListener(\"pointerup\", onPointerUp)\n      window.removeEventListener(\"pointercancel\", onPointerCancel)\n    }\n  }, [controlRef, disabled])\n}\n\nexport { useTouchIntent }\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/use-touch-intent.ts"
    },
    {
      "path": "src/registry/ui/value-slider/use-handle-dodge.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/** 服务端渲染时没有布局，用 useEffect 代替 useLayoutEffect，避免 React 警告。 */\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect\n\n/** 手柄让位时给文字两边留的余量（像素）。 */\nconst PADDING = 16\n/** 还没量到轨道宽度时，按这个比例判断「手柄压在端点附近」。 */\nconst FIRST_FRAME_RATIO = 15\n\ntype HandleDodge = {\n  /** 挂在轨道上 */\n  trackRef: React.RefObject<HTMLDivElement | null>\n  /** 挂在轨道内的标签上 */\n  labelRef: React.RefObject<HTMLSpanElement | null>\n  /** 挂在轨道内的读数上 */\n  valueRef: React.RefObject<HTMLSpanElement | null>\n  /** 手柄在 `percent` 处时要不要让开文字 */\n  isNear: (percent: number) => boolean\n}\n\n/**\n * 手柄让开轨道两端的标签与读数：量出文字宽度换算成百分比阈值。\n * 首帧（还没量到宽度）先按「靠近端点」处理，量完再按真实阈值，\n * 这样静态 HTML 与首屏也不会把手柄压在文字上。\n */\nfunction useHandleDodge(enabled: boolean): HandleDodge {\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const labelRef = React.useRef<HTMLSpanElement>(null)\n  const valueRef = React.useRef<HTMLSpanElement>(null)\n  const [metrics, setMetrics] = React.useState({ track: 0, label: 0, value: 0 })\n\n  useIsomorphicLayoutEffect(() => {\n    if (!enabled) return\n    const track = trackRef.current\n    if (!track) return\n    const measure = () =>\n      setMetrics((previous) => {\n        const next = {\n          track: track.clientWidth,\n          label: labelRef.current?.offsetWidth ?? 0,\n          value: valueRef.current?.offsetWidth ?? 0,\n        }\n        return previous.track === next.track &&\n          previous.label === next.label &&\n          previous.value === next.value\n          ? previous\n          : next\n      })\n    // 先同步量一次（在浏览器绘制之前就能拿到真实宽度），再由 ResizeObserver 跟进\n    measure()\n    const observer = new ResizeObserver(measure)\n    for (const element of [track, labelRef.current, valueRef.current])\n      if (element) observer.observe(element)\n    return () => observer.disconnect()\n  }, [enabled])\n\n  const ratio = (width: number) =>\n    metrics.track > 0 ? ((width + PADDING) / metrics.track) * 100 : 0\n  const startLimit = ratio(metrics.label)\n  const endLimit = ratio(metrics.value)\n\n  const isNear = (percent: number) => {\n    if (!enabled) return false\n    if (metrics.track === 0)\n      return percent < FIRST_FRAME_RATIO || percent > 100 - FIRST_FRAME_RATIO\n    return (\n      (startLimit > 0 && percent <= startLimit) ||\n      (endLimit > 0 && percent >= 100 - endLimit)\n    )\n  }\n\n  return { trackRef, labelRef, valueRef, isNear }\n}\n\nexport { useHandleDodge }\nexport type { HandleDodge }\n",
      "type": "registry:ui",
      "target": "@ui/value-slider/use-handle-dodge.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)",
      "flow-blue": "oklch(0.56 0.2 258)",
      "flow-green": "oklch(0.62 0.16 150)",
      "flow-amber": "oklch(0.7 0.16 70)",
      "flow-rose": "oklch(0.62 0.21 5)",
      "flow-violet": "oklch(0.56 0.22 292)",
      "flow-cyan": "oklch(0.66 0.12 215)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)",
      "flow-blue": "oklch(0.72 0.15 255)",
      "flow-green": "oklch(0.77 0.17 152)",
      "flow-amber": "oklch(0.82 0.15 82)",
      "flow-rose": "oklch(0.74 0.16 8)",
      "flow-violet": "oklch(0.74 0.15 295)",
      "flow-cyan": "oklch(0.82 0.11 208)"
    }
  },
  "type": "registry:ui"
}
