{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-tuner",
  "title": "Spring tuner",
  "description": "Spring tuner is a production-ready utilities component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/utilities/spring-tuner.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { BJORK_PALETTE, type BjorkTone } from \"@/components/bjork-ui/_core/palette\";\nimport {\n  dampingRatio,\n  overshoot,\n  settleTime,\n  springAt,\n  type SpringConfig,\n} from \"@/components/bjork-ui/_core/spring\";\nimport { springs } from \"@/components/bjork-ui/_core/motion\";\nimport { useBjorkTone } from \"@/components/bjork-ui/_core/tone\";\nimport { BJORK_SURFACE } from \"@/components/bjork-ui/_core/surface\";\nimport { LiveRegion } from \"@/components/bjork-ui/_core/a11y\";\nimport { useVisibleLoop } from \"@/components/bjork-ui/_core/loop\";\n\nexport type SpringTransition = { type: \"spring\" } & SpringConfig;\n\nexport interface SpringTunerRanges {\n  stiffness: [number, number];\n  damping: [number, number];\n  mass: [number, number];\n}\n\nexport interface SpringTunerProps {\n  value?: SpringConfig;\n  defaultValue?: SpringConfig;\n  onChange?: (config: SpringConfig) => void;\n  onCopy?: (code: string) => void;\n  presets?: Record<string, SpringConfig>;\n  ranges?: SpringTunerRanges;\n  showCode?: boolean;\n  /** How many previous configs to trace behind the current curve. Capped at 5. */\n  ghosts?: number;\n  /** Seeds the ghost traces, most recent first. Used for posed previews. */\n  defaultGhosts?: SpringConfig[];\n  /** Replaces the preview lane. Receives the current config as a framer-motion transition. */\n  children?: (transition: SpringTransition) => ReactNode;\n  tone?: BjorkTone;\n  /** Cycles the presets every 2.5s until the user interacts. Ignored when `value` is controlled. */\n  attract?: boolean;\n  className?: string;\n}\n\n// Plot coordinates. The SVG is drawn in a 320 x 180 viewBox and scales to its column.\nconst VIEW_W = 320;\nconst VIEW_H = 180;\nconst PAD_X = 12;\nconst Y_ONE = VIEW_H * 0.3; // 1 sits 30% from the top\nconst Y_ZERO = VIEW_H - 12; // 0 sits 12px above the bottom\nconst SCALE_Y = Y_ZERO - Y_ONE; // viewBox px per unit of travel\nconst TOP_LIMIT = 6; // handle and curve stop 6px below the top edge of the plot\nconst SAMPLES = 120;\nconst MAX_GHOSTS = 5;\nconst GHOST_OPACITY = [0.5, 0.35, 0.25, 0.15, 0.08] as const;\n\nconst PUCK_TRAVEL = 140;\nconst LANE_START = 30; // marker centre, from the lane's left edge\nconst LANE_END = LANE_START + PUCK_TRAVEL;\n\nconst DEFAULT_CONFIG: SpringConfig = { stiffness: 300, damping: 30, mass: 0.8 };\nconst DEFAULT_RANGES: SpringTunerRanges = {\n  stiffness: [50, 1000],\n  damping: [5, 80],\n  mass: [0.2, 3],\n};\nconst DEFAULT_PRESETS: Record<string, SpringConfig> = {\n  press: springs.press,\n  snappy: springs.snappy,\n  standard: springs.standard,\n  soft: springs.soft,\n  settle: springs.settle,\n};\n\nconst ATTRACT_INTERVAL = 2.5;\nconst ATTRACT_IDLE_S = 4;\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, n));\n}\n\nfunction sameConfig(a: SpringConfig, b: SpringConfig) {\n  return a.stiffness === b.stiffness && a.damping === b.damping && a.mass === b.mass;\n}\n\nfunction normalize(c: SpringConfig, ranges: SpringTunerRanges): SpringConfig {\n  return {\n    stiffness: clamp(Math.round(c.stiffness), ranges.stiffness[0], ranges.stiffness[1]),\n    damping: clamp(Math.round(c.damping * 2) / 2, ranges.damping[0], ranges.damping[1]),\n    mass: clamp(Math.round(c.mass * 100) / 100, ranges.mass[0], ranges.mass[1]),\n  };\n}\n\nfunction trim(n: number) {\n  return String(Number(n.toFixed(3)));\n}\n\nfunction formatTransition(c: SpringConfig) {\n  return `{ type: \"spring\", stiffness: ${trim(c.stiffness)}, damping: ${trim(c.damping)}, mass: ${trim(c.mass)} }`;\n}\n\nfunction xOf(t: number, span: number) {\n  return PAD_X + (t / span) * (VIEW_W - PAD_X * 2);\n}\n\nfunction tOf(x: number, span: number) {\n  return ((x - PAD_X) / (VIEW_W - PAD_X * 2)) * span;\n}\n\nfunction yOf(v: number) {\n  return Y_ZERO - v * SCALE_Y;\n}\n\nfunction tickStep(span: number) {\n  if (span <= 2) return 0.1;\n  if (span <= 6) return 0.5;\n  return 1;\n}\n\n// Curve for a config over the shared time axis. `zone` is the area between the curve and y = 1 where it is above 1.\nfunction curvePaths(c: SpringConfig, span: number) {\n  const pts: { x: number; y: number; v: number }[] = [];\n  for (let i = 0; i < SAMPLES; i++) {\n    const t = (i / (SAMPLES - 1)) * span;\n    const v = springAt(t, c, 0, 1, 0);\n    pts.push({ x: xOf(t, span), y: yOf(v), v });\n  }\n  const line = pts.map((p, i) => `${i === 0 ? \"M\" : \"L\"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(\" \");\n  if (!pts.some((p) => p.v > 1)) return { line, zone: \"\" };\n  const top = pts.map((p) => `${p.x.toFixed(2)} ${yOf(Math.max(p.v, 1)).toFixed(2)}`);\n  const base = pts.map((p) => `${p.x.toFixed(2)} ${yOf(1).toFixed(2)}`).reverse();\n  const zone = `M${top.join(\" L\")} L${base.join(\" L\")} Z`;\n  return { line, zone };\n}\n\nconst PARAMS = [\n  { key: \"stiffness\", label: \"stiffness\" },\n  { key: \"damping\", label: \"damping\" },\n  { key: \"mass\", label: \"mass\" },\n] as const;\n\n// Token with a palette fallback, so the component keeps its tone outside the site.\nfunction tok(name: string, fallback: string) {\n  return `var(--bjork-${name}, ${fallback})`;\n}\n\nexport function SpringTuner({\n  value,\n  defaultValue,\n  onChange,\n  onCopy,\n  presets = DEFAULT_PRESETS,\n  ranges = DEFAULT_RANGES,\n  showCode = true,\n  ghosts = MAX_GHOSTS,\n  defaultGhosts,\n  children,\n  tone,\n  attract = false,\n  className,\n}: SpringTunerProps) {\n  const resolvedTone = useBjorkTone(tone);\n  const p = BJORK_PALETTE[resolvedTone];\n  const reduced = useReducedMotion();\n  const idBase = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const plotRef = useRef<HTMLDivElement>(null);\n\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = useState<SpringConfig>(defaultValue ?? DEFAULT_CONFIG);\n  const current = value ?? internal;\n  const { stiffness, damping, mass } = current;\n\n  const [history, setHistory] = useState<SpringConfig[]>(defaultGhosts ?? []);\n  const [copied, setCopied] = useState(false);\n  const copiedTimer = useRef<number | null>(null);\n  useEffect(() => () => {\n    if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);\n  }, []);\n\n  // Gesture bookkeeping. A gesture (one drag, one held key, one preset click) pushes its starting config\n  // into the ghost history once, when it ends with a different config.\n  const gestureStart = useRef<SpringConfig | null>(null);\n  const latest = useRef<SpringConfig>(current);\n  const inputSeen = useRef(false);\n  const drag = useRef<{ left: number; top: number; width: number; height: number; span: number } | null>(null);\n\n  const ghostCount = Math.min(Math.max(0, ghosts), MAX_GHOSTS);\n\n  const settle = useMemo(\n    () => settleTime({ stiffness, damping, mass }),\n    [stiffness, damping, mass],\n  );\n  const zeta = dampingRatio(current);\n  const os = overshoot(current);\n  const underdamped = zeta < 1;\n  const peakTime = underdamped\n    ? Math.PI / (Math.sqrt(stiffness / mass) * Math.sqrt(1 - zeta * zeta))\n    : settle;\n  const peakValue = underdamped ? 1 + os : 1;\n  // Spec: the axis covers 1.1 x settleTime. A peak can fall after the 0.1% settle band when its overshoot is\n  // tiny, so the axis also covers the peak. Otherwise the handle would sit off the plot.\n  const span = 1.1 * Math.max(settle, peakTime);\n\n  const currentCurve = useMemo(() => curvePaths(current, span), [current, span]);\n  const ghostLines = useMemo(\n    () => history.slice(0, ghostCount).map((c) => curvePaths(c, span).line),\n    [history, ghostCount, span],\n  );\n  const ticks = useMemo(() => {\n    const step = tickStep(span);\n    const out: number[] = [];\n    for (let t = step; t < span - 1e-9; t += step) out.push(xOf(t, span));\n    return out;\n  }, [span]);\n\n  const code = formatTransition(current);\n\n  const markInput = () => {\n    inputSeen.current = true;\n  };\n\n  const startGesture = (c: SpringConfig) => {\n    if (gestureStart.current === null) {\n      gestureStart.current = c;\n      latest.current = c;\n    }\n  };\n\n  const endGesture = () => {\n    const start = gestureStart.current;\n    gestureStart.current = null;\n    if (start && !sameConfig(start, latest.current)) {\n      setHistory((h) => [start, ...h].slice(0, MAX_GHOSTS));\n    }\n  };\n\n  const commit = (next: SpringConfig) => {\n    latest.current = next;\n    if (!isControlled) setInternal(next);\n    onChange?.(next);\n  };\n\n  // Puck: spring from end to end on every config change (120ms debounce). Each run alternates direction.\n  const puckX = useMotionValue(0);\n  const puckNext = useRef(PUCK_TRAVEL);\n  const playPuck = useCallback(\n    (c: SpringConfig) => {\n      const to = puckNext.current;\n      puckNext.current = to === PUCK_TRAVEL ? 0 : PUCK_TRAVEL;\n      if (reduced) {\n        puckX.set(to);\n        return;\n      }\n      animate(puckX, to, {\n        type: \"spring\",\n        stiffness: c.stiffness,\n        damping: c.damping,\n        mass: c.mass,\n        restDelta: 0.01,\n        restSpeed: 0.01,\n      });\n    },\n    [puckX, reduced],\n  );\n\n  useEffect(() => {\n    const id = window.setTimeout(() => playPuck({ stiffness, damping, mass }), 120);\n    return () => window.clearTimeout(id);\n  }, [stiffness, damping, mass, playPuck]);\n\n  // Attract: cycle the presets, paused while the user is interacting (4s idle to resume).\n  const attractIndex = useRef(-1);\n  const attractTime = useRef(0);\n  const idleFor = useRef(ATTRACT_IDLE_S);\n  const attractOn = attract && !reduced && !isControlled;\n  useVisibleLoop(\n    rootRef,\n    (dt) => {\n      if (inputSeen.current) {\n        inputSeen.current = false;\n        idleFor.current = 0;\n      } else {\n        idleFor.current += dt;\n      }\n      if (idleFor.current < ATTRACT_IDLE_S) {\n        attractTime.current = 0;\n        return true;\n      }\n      attractTime.current += dt;\n      if (attractTime.current < ATTRACT_INTERVAL) return true;\n      attractTime.current = 0;\n      const keys = Object.keys(presets);\n      if (keys.length === 0) return false;\n      attractIndex.current = (attractIndex.current + 1) % keys.length;\n      startGesture(current);\n      commit({ ...presets[keys[attractIndex.current]] });\n      endGesture();\n      return true;\n    },\n    { enabled: attractOn },\n  );\n\n  // Plot drag. The pointer sets overshoot (y) and peak time (x). Stiffness and damping follow from them.\n  const updateFromPointer = (clientX: number, clientY: number) => {\n    const d = drag.current;\n    if (!d) return;\n    const vx = ((clientX - d.left) / d.width) * VIEW_W;\n    const vy = ((clientY - d.top) / d.height) * VIEW_H;\n    const yClamped = clamp(vy, TOP_LIMIT, Y_ZERO);\n    const yValue = (Y_ZERO - yClamped) / SCALE_Y;\n    const os = clamp(yValue - 1, 0.0005, 0.9);\n    const lnOs = Math.log(os);\n    const z = -lnOs / Math.sqrt(Math.PI * Math.PI + lnOs * lnOs);\n    const tp = clamp(tOf(vx, d.span), 0.03, 2);\n    const m = latest.current.mass;\n    const wn = Math.PI / (tp * Math.sqrt(1 - z * z));\n    const k = wn * wn * m;\n    const dm = 2 * z * Math.sqrt(k * m);\n    const next = normalize({ stiffness: k, damping: dm, mass: m }, ranges);\n    if (!sameConfig(next, latest.current)) commit(next);\n  };\n\n  const onPlotPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    const el = plotRef.current;\n    if (!el) return;\n    e.preventDefault();\n    markInput();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    const r = el.getBoundingClientRect();\n    drag.current = { left: r.left, top: r.top, width: r.width, height: r.height, span };\n    startGesture(current);\n    updateFromPointer(e.clientX, e.clientY);\n  };\n\n  const onPlotPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    if (!drag.current) return;\n    updateFromPointer(e.clientX, e.clientY);\n  };\n\n  const onPlotPointerEnd = (e: PointerEvent<HTMLDivElement>) => {\n    if (!drag.current) return;\n    drag.current = null;\n    if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n      e.currentTarget.releasePointerCapture(e.pointerId);\n    }\n    endGesture();\n  };\n\n  const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const step: Record<string, [keyof SpringConfig, number]> = {\n      // Deliberately flipped from the plan text: ArrowUp lowers damping, which raises overshoot, so the key\n      // moves the handle the same way a drag up does.\n      ArrowUp: [\"damping\", -1],\n      ArrowDown: [\"damping\", 1],\n      ArrowRight: [\"stiffness\", 10],\n      ArrowLeft: [\"stiffness\", -10],\n    };\n    const hit = step[e.key];\n    if (!hit) return;\n    e.preventDefault();\n    markInput();\n    startGesture(current);\n    const [key, delta] = hit;\n    const base = latest.current;\n    const next = normalize({ ...base, [key]: base[key] + delta }, ranges);\n    if (!sameConfig(next, base)) commit(next);\n  };\n\n  const onHandleKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (e.key.startsWith(\"Arrow\")) endGesture();\n  };\n\n  const onRangeChange = (key: keyof SpringConfig) => (e: ChangeEvent<HTMLInputElement>) => {\n    const base = latest.current;\n    const next = normalize({ ...base, [key]: Number(e.target.value) }, ranges);\n    if (!sameConfig(next, base)) commit(next);\n  };\n\n  const onPreset = (c: SpringConfig) => {\n    markInput();\n    startGesture(current);\n    commit({ ...c });\n    endGesture();\n  };\n\n  const onCopyClick = async () => {\n    markInput();\n    try {\n      await navigator.clipboard.writeText(code);\n      onCopy?.(code);\n      setCopied(true);\n      if (copiedTimer.current !== null) window.clearTimeout(copiedTimer.current);\n      copiedTimer.current = window.setTimeout(() => {\n        copiedTimer.current = null;\n        setCopied(false);\n      }, 1200);\n    } catch {\n      // Clipboard is blocked or unavailable. Nothing to report.\n    }\n  };\n\n  // Handle position, in plot percentages.\n  const handleX = (xOf(peakTime, span) / VIEW_W) * 100;\n  const handleY = (clamp(yOf(peakValue), TOP_LIMIT, Y_ZERO) / VIEW_H) * 100;\n  const overshootPct = os * 100;\n  const handleValueText = underdamped\n    ? `overshoot ${overshootPct.toFixed(1)}%, peak ${Math.round(peakTime * 1000)}ms`\n    : `overshoot 0.0%, settle ${Math.round(settle * 1000)}ms`;\n\n  const values: Record<(typeof PARAMS)[number][\"key\"], number> = { stiffness, damping, mass };\n  const transition: SpringTransition = { type: \"spring\", stiffness, damping, mass };\n  const isPresetActive = (c: SpringConfig) => sameConfig(c, current);\n\n  const textColor = tok(\"text\", p.text);\n  const textMedium = tok(\"text-medium\", p.textMedium);\n  const textMuted = tok(\"text-muted\", p.textMuted);\n  const textSoft = tok(\"text-soft\", p.textSoft);\n  const border = tok(\"border\", p.border);\n  const borderStrong = tok(\"border-strong\", p.borderStrong);\n  const accent = tok(\"accent\", p.accent);\n  const surface = tok(\"surface\", p.surface);\n\n  const rangeVars = {\n    \"--sp-accent\": accent,\n    \"--sp-surface\": surface,\n    \"--sp-rail\": borderStrong,\n  } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      data-spring-tuner=\"\"\n      style={{\n        ...rangeVars,\n        background: surface,\n        borderColor: border,\n        boxShadow: BJORK_SURFACE[resolvedTone].shadowSurface,\n        color: textColor,\n      }}\n      className={cn(\n        \"@container relative flex w-[560px] max-w-full min-h-[300px] flex-col gap-3 rounded-[20px] border p-4 select-none\",\n        className,\n      )}\n    >\n      <div className=\"grid gap-6 @[520px]:grid-cols-[304px_200px]\">\n        <div className=\"flex min-w-0 flex-col gap-2\">\n          <div\n            ref={plotRef}\n            onPointerDown={onPlotPointerDown}\n            onPointerMove={onPlotPointerMove}\n            onPointerUp={onPlotPointerEnd}\n            onPointerCancel={onPlotPointerEnd}\n            className=\"relative aspect-[320/180] w-full cursor-crosshair touch-none\"\n          >\n            <svg\n              viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}\n              className=\"block h-full w-full overflow-hidden\"\n              aria-hidden=\"true\"\n              focusable=\"false\"\n            >\n              {/* Hairlines: y = 0 solid, y = 1 dashed 2/3. y = 1 sits on a half pixel for crisp 1px rendering. */}\n              <line x1={0} x2={VIEW_W} y1={Y_ZERO + 0.5} y2={Y_ZERO + 0.5} stroke={p.borderStrong} strokeWidth={1} />\n              <line\n                x1={0}\n                x2={VIEW_W}\n                y1={Y_ONE + 0.5}\n                y2={Y_ONE + 0.5}\n                stroke={p.textFaint}\n                strokeWidth={1}\n                strokeDasharray=\"2 3\"\n              />\n              {ticks.map((x) => (\n                <line key={x} x1={x} x2={x} y1={Y_ZERO} y2={Y_ZERO + 4} stroke={p.textFaint} strokeWidth={1} />\n              ))}\n              {ghostLines.map((d, i) => (\n                <path\n                  key={i}\n                  d={d}\n                  fill=\"none\"\n                  stroke={p.textMuted}\n                  strokeOpacity={GHOST_OPACITY[i]}\n                  strokeWidth={1}\n                />\n              ))}\n              {currentCurve.zone && <path d={currentCurve.zone} fill={p.accentSoft} />}\n              <path d={currentCurve.line} fill=\"none\" stroke={p.text} strokeWidth={1.75} strokeLinejoin=\"round\" />\n            </svg>\n\n            <div\n              role=\"slider\"\n              tabIndex={0}\n              aria-label=\"Overshoot\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={Math.round(overshootPct * 10) / 10}\n              aria-valuetext={handleValueText}\n              onKeyDown={onHandleKeyDown}\n              onKeyUp={onHandleKeyUp}\n              onBlur={endGesture}\n              style={{ left: `${handleX}%`, top: `${handleY}%` }}\n              className=\"absolute flex size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--sp-surface)] active:cursor-grabbing\"\n            >\n              <span\n                aria-hidden=\"true\"\n                className=\"block size-2 rounded-full\"\n                style={{ background: accent, boxShadow: `0 0 0 2px ${surface}` }}\n              />\n            </div>\n          </div>\n\n          <p className=\"font-mono text-[11px] tabular-nums\" style={{ color: textSoft }}>\n            {`ζ ${zeta.toFixed(2)} · settle ${Math.round(settle * 1000)}ms · overshoot ${overshootPct.toFixed(1)}%`}\n          </p>\n        </div>\n\n        <div className=\"flex min-w-0 flex-col gap-3\">\n          <div role=\"group\" aria-label=\"Presets\" className=\"flex flex-wrap gap-1.5\">\n            {Object.entries(presets).map(([name, c]) => {\n              const active = isPresetActive(c);\n              return (\n                <button\n                  key={name}\n                  type=\"button\"\n                  aria-pressed={active}\n                  onClick={() => onPreset(c)}\n                  className=\"h-6 rounded-[7px] border px-2 font-mono text-[11px] transition-[transform,background-color,color] duration-[140ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--sp-surface)]\"\n                  style={{\n                    borderColor: active ? tok(\"accent-muted\", p.accentMuted) : border,\n                    background: active ? tok(\"accent-soft\", p.accentSoft) : \"transparent\",\n                    color: active ? p.accentInk : textMedium,\n                  }}\n                >\n                  {name}\n                </button>\n              );\n            })}\n          </div>\n\n          {typeof children === \"function\" ? (\n            children(transition)\n          ) : (\n            <div className=\"flex flex-col gap-1\">\n              <div className=\"flex h-4 items-center justify-between\">\n                <span className=\"font-mono text-[11px] uppercase tracking-[0.04em]\" style={{ color: textMuted }}>\n                  Preview\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => playPuck(current)}\n                  className=\"font-mono text-[11px] rounded-[5px] px-1 transition-[transform] duration-[140ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--sp-surface)]\"\n                  style={{ color: p.accentInk }}\n                >\n                  Play\n                </button>\n              </div>\n              <div aria-hidden=\"true\" className=\"relative h-10 w-full\">\n                <span\n                  className=\"absolute top-1/2 h-px -translate-y-1/2\"\n                  style={{ left: LANE_START, width: PUCK_TRAVEL, background: p.hair }}\n                />\n                <span\n                  className=\"absolute top-[17px] size-1.5 rounded-full\"\n                  style={{ left: LANE_START - 3, background: borderStrong }}\n                />\n                <span\n                  className=\"absolute top-[17px] size-1.5 rounded-full\"\n                  style={{ left: LANE_END - 3, background: borderStrong }}\n                />\n                <motion.span\n                  className=\"absolute left-[16px] top-[6px] size-7 rounded-full\"\n                  style={{ x: puckX, background: textColor }}\n                />\n              </div>\n            </div>\n          )}\n\n          <div className=\"flex flex-col gap-2\">\n            {PARAMS.map(({ key, label }) => {\n              const [lo, hi] = ranges[key];\n              const v = values[key];\n              const step = key === \"damping\" ? 0.5 : key === \"mass\" ? 0.01 : 1;\n              const fill = ((v - lo) / (hi - lo)) * 100;\n              const id = `${idBase}-${key}`;\n              return (\n                <div key={key} className=\"grid grid-cols-[64px_minmax(0,1fr)_48px] items-center gap-2\">\n                  <label\n                    htmlFor={id}\n                    className=\"font-mono text-[11px] uppercase tracking-[0.04em]\"\n                    style={{ color: textMuted }}\n                  >\n                    {label}\n                  </label>\n                  <input\n                    id={id}\n                    type=\"range\"\n                    min={lo}\n                    max={hi}\n                    step={step}\n                    value={v}\n                    aria-label={label[0].toUpperCase() + label.slice(1)}\n                    aria-valuetext={`${trim(v)} ${label}`}\n                    onChange={onRangeChange(key)}\n                    onPointerDown={() => {\n                      markInput();\n                      startGesture(current);\n                    }}\n                    onPointerUp={endGesture}\n                    onPointerCancel={endGesture}\n                    onBlur={endGesture}\n                    onKeyDown={() => {\n                      markInput();\n                      startGesture(current);\n                    }}\n                    onKeyUp={endGesture}\n                    style={{\n                      [\"--fill\" as string]: `${fill}%`,\n                      background: `linear-gradient(to right, ${accent} var(--fill), ${borderStrong} var(--fill)) center / 100% 2px no-repeat`,\n                    }}\n                    className=\"h-3 w-full cursor-pointer appearance-none rounded-full bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--sp-surface)] [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[color:var(--sp-accent)] [&::-moz-range-thumb]:shadow-[0_0_0_2px_var(--sp-surface)] [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-3 [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[color:var(--sp-accent)] [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_var(--sp-surface)]\"\n                  />\n                  <output\n                    htmlFor={id}\n                    className=\"text-right font-mono text-[12px] tabular-nums\"\n                    style={{ color: textColor }}\n                  >\n                    {trim(v)}\n                  </output>\n                </div>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n\n      {showCode && (\n        <div\n          className=\"mt-auto flex items-center justify-between gap-3 rounded-[10px] px-3 py-2\"\n          style={{ background: surface }}\n        >\n          <code className=\"min-w-0 break-words font-mono text-[12px] leading-5\" style={{ color: textMedium }}>\n            {code}\n          </code>\n          <button\n            type=\"button\"\n            onClick={onCopyClick}\n            className=\"h-7 shrink-0 rounded-[8px] border px-3 font-mono text-[11px] transition-[transform] duration-[140ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--sp-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--sp-surface)]\"\n            style={{ borderColor: border, color: textColor }}\n          >\n            {copied ? \"Copied\" : \"Copy\"}\n          </button>\n          <LiveRegion message={copied ? \"Copied\" : \"\"} />\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/utilities/spring-tuner.tsx"
    },
    {
      "path": "lib/utils.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/palette.ts",
      "content": "export type BjorkTone = \"dark\" | \"light\";\n\n// Canvas, WebGL and SVG attribute colours. DOM code uses the CSS tokens with these as fallbacks.\nexport const BJORK_PALETTE = {\n  dark: {\n    bg: \"#050505\", stage: \"#111111\", surface: \"#121212\", raised: \"#161616\", active: \"#202020\",\n    border: \"#232323\", borderStrong: \"#343434\",\n    text: \"#ededed\", textMedium: \"rgba(237,237,237,0.72)\", textMuted: \"rgba(237,237,237,0.6)\",\n    textSoft: \"rgba(237,237,237,0.5)\", textFaint: \"rgba(237,237,237,0.22)\", hair: \"rgba(237,237,237,0.12)\",\n    accent: \"#ec5c13\", accentInk: \"#ec5c13\", accentFill: \"#b84a12\", accentSoft: \"rgba(236,92,19,0.12)\", accentMuted: \"rgba(236,92,19,0.24)\",\n    accentFg: \"#fff2ea\", success: \"#4cc38a\", warning: \"#f2b544\", error: \"#ff5c4d\",\n  },\n  light: {\n    bg: \"#f7f5ef\", stage: \"#f7f5ef\", surface: \"#fffcf6\", raised: \"#f5efe3\", active: \"#efe7d8\",\n    border: \"#eee6db\", borderStrong: \"#e1d7c8\",\n    text: \"#171717\", textMedium: \"rgba(23,23,23,0.78)\", textMuted: \"rgba(23,23,23,0.68)\",\n    textSoft: \"rgba(23,23,23,0.62)\", textFaint: \"rgba(23,23,23,0.22)\", hair: \"rgba(23,23,23,0.10)\",\n    accent: \"#ec7d43\", accentInk: \"#b4531f\", accentFill: \"#ec7d43\", accentSoft: \"rgba(236,125,67,0.10)\", accentMuted: \"rgba(236,125,67,0.20)\",\n    accentFg: \"#3f2112\", success: \"#1f8a55\", warning: \"#a86b00\", error: \"#c8361f\",\n  },\n} as const;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/palette.ts"
    },
    {
      "path": "components/bjork-ui/_core/spring.ts",
      "content": "// Pure spring math, no React. Units: stiffness k, damping d (coefficient), mass m, time in seconds.\n\nexport interface SpringConfig {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface SpringState {\n  x: number;\n  v: number;\n}\n\n// Damping ratio zeta = d / (2 * sqrt(k * m)).\nexport function dampingRatio(c: SpringConfig): number {\n  return c.damping / (2 * Math.sqrt(c.stiffness * c.mass));\n}\n\n// Exact solution of y'' = -(k/m) y - (d/m) y' for displacement y0 and velocity v0 at time t.\n// Returns displacement and velocity together so stepping can be exact, not integrated.\nfunction solveLinear(t: number, c: SpringConfig, y0: number, v0: number): { y: number; v: number } {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n\n  if (Math.abs(z - 1) < 1e-9) {\n    // Critically damped.\n    const e = Math.exp(-w0 * t);\n    const b = v0 + w0 * y0;\n    return { y: e * (y0 + b * t), v: e * (b - w0 * (y0 + b * t)) };\n  }\n  if (z < 1) {\n    // Underdamped.\n    const wd = w0 * Math.sqrt(1 - z * z);\n    const a = z * w0;\n    const B = (v0 + a * y0) / wd;\n    const e = Math.exp(-a * t);\n    const cos = Math.cos(wd * t);\n    const sin = Math.sin(wd * t);\n    return {\n      y: e * (y0 * cos + B * sin),\n      v: e * (-a * (y0 * cos + B * sin) + wd * (-y0 * sin + B * cos)),\n    };\n  }\n  // Overdamped.\n  const s = w0 * Math.sqrt(z * z - 1);\n  const r1 = -z * w0 + s;\n  const r2 = -z * w0 - s;\n  const B = (v0 - r1 * y0) / (r2 - r1);\n  const A = y0 - B;\n  const e1 = Math.exp(r1 * t);\n  const e2 = Math.exp(r2 * t);\n  return { y: A * e1 + B * e2, v: A * r1 * e1 + B * r2 * e2 };\n}\n\n// Analytic position at time t for a spring from `from` to `to` with initial velocity v0.\nexport function springAt(t: number, c: SpringConfig, from = 0, to = 1, v0 = 0): number {\n  return to + solveLinear(t, c, from - to, v0).y;\n}\n\n// Overshoot as a fraction of the total travel. Zero when critically or overdamped.\nexport function overshoot(c: SpringConfig): number {\n  const z = dampingRatio(c);\n  if (z >= 1) return 0;\n  return Math.exp((-Math.PI * z) / Math.sqrt(1 - z * z));\n}\n\n// Seconds until the spring stays within epsilon of its target (fraction of travel).\n// Found by scanning backwards from a bound derived from the slowest decay rate.\nexport function settleTime(c: SpringConfig, epsilon = 0.001): number {\n  const w0 = Math.sqrt(c.stiffness / c.mass);\n  const z = dampingRatio(c);\n  const slowest = z < 1 ? z * w0 : (z - Math.sqrt(z * z - 1)) * w0;\n  const tEnd = (Math.log(1 / epsilon) / slowest) * 1.5 + 0.05;\n  const steps = 4000;\n  const dt = tEnd / steps;\n  const band = epsilon;\n  let last = 0;\n  for (let i = 0; i <= steps; i++) {\n    const t = i * dt;\n    if (Math.abs(springAt(t, c, 1, 0, 0)) >= band) last = t;\n  }\n  return last + dt;\n}\n\n// Advances the state by dt towards `target`. Uses the exact solution of the linear spring\n// (no integration), so it tracks springAt to rounding error at any step size and stays stable\n// for stiff configs where a fixed-step integrator drifts.\nexport function stepSpring(s: SpringState, target: number, c: SpringConfig, dt: number): void {\n  if (dt <= 0) return;\n  const r = solveLinear(dt, c, s.x - target, s.v);\n  s.x = target + r.y;\n  s.v = r.v;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/spring.ts"
    },
    {
      "path": "components/bjork-ui/_core/motion.ts",
      "content": "// Shared springs, easings and durations. Components import these instead of inventing values.\n\nexport const springs = {\n  press: { type: \"spring\", stiffness: 500, damping: 30, mass: 0.6 },\n  snappy: { type: \"spring\", stiffness: 400, damping: 25, mass: 1 },\n  standard: { type: \"spring\", stiffness: 300, damping: 30, mass: 0.8 },\n  soft: { type: \"spring\", stiffness: 200, damping: 25, mass: 1.2 },\n  settle: { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 },\n  blurIn: { type: \"spring\", stiffness: 400, damping: 28, mass: 0.6 },\n} as const;\n\nexport const ease = {\n  out: [0.23, 1, 0.32, 1],\n  inOut: [0.77, 0, 0.175, 1],\n  drawer: [0.32, 0.72, 0, 1],\n} as const;\n\nexport const easeCss = {\n  out: \"cubic-bezier(0.23,1,0.32,1)\",\n  inOut: \"cubic-bezier(0.77,0,0.175,1)\",\n  drawer: \"cubic-bezier(0.32,0.72,0,1)\",\n} as const;\n\nexport const durations = { press: 140, tooltip: 160, menu: 200, panel: 280 } as const; // ms\n\n// Cubic bezier easing for canvas and rAF code. Returns progress y for input x in [0, 1].\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number): (t: number) => number {\n  const cx = 3 * x1;\n  const bx = 3 * (x2 - x1) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * y1;\n  const by = 3 * (y2 - y1) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (s: number) => ((ax * s + bx) * s + cx) * s;\n  const sampleY = (s: number) => ((ay * s + by) * s + cy) * s;\n  const sampleDX = (s: number) => (3 * ax * s + 2 * bx) * s + cx;\n\n  const solveX = (x: number) => {\n    let s = x;\n    for (let i = 0; i < 8; i++) {\n      const err = sampleX(s) - x;\n      if (Math.abs(err) < 1e-6) return s;\n      const d = sampleDX(s);\n      if (Math.abs(d) < 1e-6) break;\n      s -= err / d;\n    }\n    let lo = 0;\n    let hi = 1;\n    s = x;\n    while (lo < hi) {\n      const v = sampleX(s);\n      if (Math.abs(v - x) < 1e-6) return s;\n      if (x > v) lo = s;\n      else hi = s;\n      s = (lo + hi) / 2;\n      if (hi - lo < 1e-7) break;\n    }\n    return s;\n  };\n\n  return (t: number) => {\n    if (t <= 0) return 0;\n    if (t >= 1) return 1;\n    return sampleY(solveX(t));\n  };\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/motion.ts"
    },
    {
      "path": "components/bjork-ui/_core/tone.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { useTheme } from \"next-themes\";\nimport type { BjorkTone } from \"./palette\";\n\nconst subscribeNoop = () => () => {};\nconst getMounted = () => true;\nconst getServerMounted = () => false;\n\n/**\n * Resolves the tone for a component. An explicit `tone` always wins.\n * Otherwise it follows next-themes' resolved theme, but only after mount,\n * so the server render and first client render agree on dark.\n */\nexport function useBjorkTone(tone?: BjorkTone): BjorkTone {\n  const { resolvedTheme } = useTheme();\n  const mounted = useSyncExternalStore(subscribeNoop, getMounted, getServerMounted);\n  if (tone) return tone;\n  if (!mounted) return \"dark\";\n  return resolvedTheme === \"light\" ? \"light\" : \"dark\";\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/tone.ts"
    },
    {
      "path": "components/bjork-ui/_core/surface.ts",
      "content": "import type { CSSProperties } from \"react\";\nimport type { BjorkTone } from \"./palette\";\n\n// The library surface (CANON.md §1). Values mirror app/globals.css :root and .dark. Change both together.\nexport const BJORK_SURFACE = {\n  light: {\n    surface: \"#fffcf6\", surfaceMuted: \"rgba(250, 246, 237, 0.94)\", surfaceHover: \"#f5efe3\", surfaceActive: \"#efe7d8\",\n    panel: \"#f8f2e7\", menu: \"rgba(255, 252, 246, 0.98)\", field: \"rgba(255, 252, 246, 0.96)\",\n    fieldMuted: \"rgba(248, 242, 231, 0.82)\", fieldInset: \"rgba(239, 231, 216, 0.72)\",\n    border: \"#eee6db\", borderMuted: \"#f5ede2\", borderStrong: \"#e1d7c8\",\n    shadowSurface: \"inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)\",\n    shadowSoft: \"inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)\",\n    shadowPanel: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)\",\n    shadowMenu: \"inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)\",\n    shadowInset: \"inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)\",\n  },\n  dark: {\n    surface: \"#121212\", surfaceMuted: \"rgba(22, 22, 22, 0.92)\", surfaceHover: \"#161616\", surfaceActive: \"#202020\",\n    panel: \"#0d0d0d\", menu: \"rgba(18, 18, 18, 0.98)\", field: \"rgba(18, 18, 18, 0.95)\",\n    fieldMuted: \"rgba(18, 18, 18, 0.55)\", fieldInset: \"rgba(9, 9, 9, 0.75)\",\n    border: \"#232323\", borderMuted: \"#1c1c1c\", borderStrong: \"#343434\",\n    shadowSurface: \"inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)\",\n    shadowSoft: \"inset 0 1px 0 rgba(255, 255, 255, 0.045)\",\n    shadowPanel: \"inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)\",\n    shadowMenu: \"inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)\",\n    shadowInset: \"inset 0 1px 10px rgba(0, 0, 0, 0.45)\",\n  },\n} as const;\n\n/** Writes the surface tokens for a pinned tone as --bjork-* custom properties. Spread first; kit-specific keys may override. */\nexport function surfaceVars(tone: BjorkTone): CSSProperties {\n  const s = BJORK_SURFACE[tone];\n  return {\n    \"--bjork-surface\": s.surface, \"--bjork-surface-muted\": s.surfaceMuted, \"--bjork-surface-hover\": s.surfaceHover,\n    \"--bjork-surface-active\": s.surfaceActive, \"--bjork-panel\": s.panel, \"--bjork-menu\": s.menu, \"--bjork-field\": s.field,\n    \"--bjork-field-muted\": s.fieldMuted, \"--bjork-field-inset\": s.fieldInset, \"--bjork-border\": s.border,\n    \"--bjork-border-muted\": s.borderMuted, \"--bjork-border-strong\": s.borderStrong,\n    \"--bjork-shadow-surface\": s.shadowSurface, \"--bjork-shadow-soft\": s.shadowSoft, \"--bjork-shadow-panel\": s.shadowPanel,\n    \"--bjork-shadow-menu\": s.shadowMenu, \"--bjork-shadow-inset\": s.shadowInset,\n  } as CSSProperties;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/surface.ts"
    },
    {
      "path": "components/bjork-ui/_core/a11y.tsx",
      "content": "import type { ReactNode } from \"react\";\n\n// Text for assistive tech only.\nexport function VisuallyHidden({ children }: { children: ReactNode }) {\n  return <span className=\"sr-only\">{children}</span>;\n}\n\n// Announces `message` when it changes. Same text re-rendered does not re-announce.\nexport function LiveRegion({\n  message,\n  politeness = \"polite\",\n}: {\n  message: string;\n  politeness?: \"polite\" | \"assertive\";\n}) {\n  return (\n    <span role=\"status\" aria-live={politeness} aria-atomic=\"true\" className=\"sr-only\">\n      {message}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/a11y.tsx"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Utilities",
    "registryUrl": "https://ui.isaiahbjork.com/spring-tuner.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/spring-tuner.json"
  },
  "type": "registry:component"
}