{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper-needle",
  "title": "Stepper Needle",
  "description": "Bounded numeric stepper with a large mono value readout, prominent -/+ buttons, and a labeled history strip: an inertial needle swings to each committed value while the strip advances, etching the last 20 adjustments with the newest mark highlighted.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/stepper-needle/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// NeedleStepper — bounded numeric spinbutton fused to a vertical seismograph\n// drum. A weighted inertial needle (spring k=120 s^-2, zeta=0.45, two visible\n// oscillations, forced-settle 900 ms) swings across a 64px paper strip to\n// each committed value; every commit advances the strip 26px downward over\n// 600 ms ease-out (event-driven, loop sleeps between) and etches a pen trace\n// of the last 20 adjustments as a pruned polyline, alpha fading 0.9 -> 0.2\n// by age index with full clears each frame (no destination-in); the newest\n// etch mark is highlighted (solid dot + halo) and the strip carries a\n// \"history\" label. An --error edge band appears only while the value sits at\n// min/max; clamping fires a hard-stop quiver (±2px, 2 cycles, 180 ms). The\n// committed value renders as a large mono hero input flanked by prominent\n// -/+ buttons. Direct-DOM rAF, no React state on the hot\n// path; all drum ink getComputedStyle-derived at mount and re-derived on\n// documentElement class flips. prefers-reduced-motion: instant needle jumps,\n// static trace, no strip animation.\n// ---------------------------------------------------------------------------\n\nconst PEN_Y = 18; // pen carriage line, px from strip top\nconst PAD_X = 10; // needle travel inset == bound-band width\nconst ADVANCE = 26; // strip feed per committed adjustment, px\nconst SCROLL_MS = 600;\nconst SPRING_K = 120; // s^-2\nconst SPRING_ZETA = 0.45;\nconst SETTLE_MS = 900; // forced-settle deadline\nconst QUIVER_MS = 180;\nconst QUIVER_AMP = 2;\nconst HOLD_DELAY_MS = 400;\nconst HOLD_INTERVAL_MS = 1000 / 12; // 12 steps/s\nconst MAX_TRACE = 20;\n\ntype Vec3 = readonly [number, number, number];\n\nfunction parseColor(raw: string): Vec3 | null {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? null : [r, g, b];\n    }\n    return null;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;\n}\n\nconst rgba = (c: Vec3, a: number) =>\n  `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;\n\ninterface TracePoint {\n  v: number; // committed value — x recomputed at draw time (resize-safe)\n  s: number; // strip-space position, ADVANCE * commit index\n}\n\nexport function NeedleStepper({\n  value,\n  defaultValue = 20,\n  min = 0,\n  max = 100,\n  step = 1,\n  label = \"Value\",\n  unit,\n  onValueChange,\n  disabled = false,\n  className = \"\",\n}: {\n  /** controlled value; omit for uncontrolled */\n  value?: number;\n  /** uncontrolled initial value */\n  defaultValue?: number;\n  /** minimum allowed value */\n  min?: number;\n  /** maximum allowed value */\n  max?: number;\n  /** increment per press/keystroke */\n  step?: number;\n  /** accessible name for the spinbutton */\n  label?: string;\n  /** unit suffix rendered beside the value, e.g. \"°C\" */\n  unit?: string;\n  /** called with the new value on every increment/decrement */\n  onValueChange?: (value: number) => void;\n  /** blocks the increment/decrement controls */\n  disabled?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const drumRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const engineRef = useRef<{\n    commit: (v: number, clamped: boolean) => void;\n    redraw: () => void;\n  } | null>(null);\n\n  const decimals = (() => {\n    const frac = String(step).split(\".\")[1];\n    return frac ? frac.length : 0;\n  })();\n  const clampRound = (v: number) =>\n    Number(Math.min(max, Math.max(min, v)).toFixed(decimals));\n\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = useState(() => clampRound(defaultValue));\n  const current = isControlled ? clampRound(value) : internal;\n  const [text, setText] = useState(() => String(current));\n  // mirrors :hover/:active into data-attrs so the site's synthetic pointer\n  // driver (isTrusted: false) can still demonstrate button feedback —\n  // Chromium never flips real pseudo-classes for script-dispatched events.\n  const [hoverDir, setHoverDir] = useState(0); // -1 | 0 | 1\n  const [pressDir, setPressDir] = useState(0); // -1 | 0 | 1\n\n  const valueRef = useRef(current);\n  valueRef.current = current;\n  const disabledRef = useRef(disabled);\n  disabledRef.current = disabled;\n  const boundsRef = useRef({ min, max });\n  boundsRef.current = { min, max };\n  const holdRef = useRef<{\n    timeout: ReturnType<typeof setTimeout> | null;\n    interval: ReturnType<typeof setInterval> | null;\n  }>({ timeout: null, interval: null });\n\n  // ---- drum engine --------------------------------------------------------\n  useEffect(() => {\n    const drum = drumRef.current;\n    const canvas = canvasRef.current;\n    const root = rootRef.current;\n    if (!drum || !canvas || !root) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    // token-derived ink: read at mount, re-derived on theme class change\n    let fg: Vec3 = [237, 237, 237];\n    let bd: Vec3 = [46, 46, 46];\n    let err: Vec3 = [234, 0, 29];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      bd = parseColor(cs.getPropertyValue(\"--border\")) ?? bd;\n      err = parseColor(cs.getPropertyValue(\"--error\")) ?? err;\n    };\n    derive();\n\n    // hot-path state: locals only, never React state\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let sized = false;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let lastV = valueRef.current;\n    let x = 0; // needle px (assigned real value on first resize)\n    let vx = 0;\n    let springOn = false;\n    let deadline = 0;\n    let qOff = 0;\n    let quiverStart = -1;\n    let commitIdx = 0;\n    const points: TracePoint[] = [{ v: lastV, s: 0 }];\n    let scrollCur = 0;\n    let scrollFrom = 0;\n    let scrollTarget = 0;\n    let scrollStart = -1;\n\n    const xFor = (v: number) => {\n      const { min: lo, max: hi } = boundsRef.current;\n      const span = hi - lo || 1;\n      return PAD_X + ((v - lo) / span) * (w - PAD_X * 2);\n    };\n\n    const draw = () => {\n      if (!sized) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h); // full clear — no accumulating alpha\n      // bound status band (semantic --error, status use only): only drawn on\n      // the edge actually hit, so red always means \"at the limit\"\n      const { min: lo, max: hi } = boundsRef.current;\n      if (lastV <= lo) {\n        ctx.fillStyle = rgba(err, 0.16);\n        ctx.fillRect(0, 0, PAD_X, h);\n      } else if (lastV >= hi) {\n        ctx.fillStyle = rgba(err, 0.16);\n        ctx.fillRect(w - PAD_X, 0, PAD_X, h);\n      }\n      // drum feed lines, fixed in strip space so they roll with the paper\n      ctx.strokeStyle = rgba(bd, 0.45);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      const first = ((((PEN_Y + scrollCur) % ADVANCE) + ADVANCE) % ADVANCE);\n      for (let y = first; y < h; y += ADVANCE) {\n        ctx.moveTo(0, Math.round(y) + 0.5);\n        ctx.lineTo(w, Math.round(y) + 0.5);\n      }\n      ctx.stroke();\n      // pen carriage hairline\n      ctx.strokeStyle = rgba(bd, 0.9);\n      ctx.beginPath();\n      ctx.moveTo(0, PEN_Y + 0.5);\n      ctx.lineTo(w, PEN_Y + 0.5);\n      ctx.stroke();\n      // pen trace: pruned polyline, newest -> oldest, alpha by age index\n      const nx = x + qOff;\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      ctx.lineWidth = 1.75;\n      let px = nx;\n      let py = PEN_Y;\n      const n = points.length;\n      for (let i = n - 1; i >= 0; i--) {\n        const p = points[i];\n        if (!p) continue;\n        const age = n - 1 - i;\n        const a = Math.max(0.2, 0.9 - (0.7 * age) / (MAX_TRACE - 1));\n        const cx = xFor(p.v);\n        const cy = PEN_Y + (scrollCur - p.s);\n        ctx.strokeStyle = rgba(fg, a);\n        ctx.beginPath();\n        ctx.moveTo(px, py);\n        ctx.lineTo(cx, cy);\n        ctx.stroke();\n        ctx.fillStyle = rgba(fg, a);\n        ctx.beginPath();\n        ctx.arc(cx, cy, 1.6, 0, Math.PI * 2);\n        ctx.fill();\n        px = cx;\n        py = cy;\n        if (cy > h + 4) break; // scrolled off the strip — stop early\n      }\n      // newest etch mark highlighted: solid dot + halo ring on the pen line\n      const newest = points[n - 1];\n      if (newest) {\n        const hx = xFor(newest.v);\n        const hy = PEN_Y + (scrollCur - newest.s);\n        ctx.fillStyle = rgba(fg, 1);\n        ctx.beginPath();\n        ctx.arc(hx, hy, 2.4, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.strokeStyle = rgba(fg, 0.35);\n        ctx.lineWidth = 1;\n        ctx.beginPath();\n        ctx.arc(hx, hy, 4.5, 0, Math.PI * 2);\n        ctx.stroke();\n        ctx.lineWidth = 1.75;\n      }\n      // weighted needle: counterweight, stem, tip riding the pen line\n      ctx.strokeStyle = rgba(fg, 0.9);\n      ctx.lineWidth = 1.25;\n      ctx.beginPath();\n      ctx.moveTo(nx, 3);\n      ctx.lineTo(nx, PEN_Y - 6);\n      ctx.stroke();\n      ctx.fillStyle = rgba(fg, 0.9);\n      ctx.beginPath();\n      ctx.arc(nx, 5, 2, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.fillStyle = rgba(fg, 1);\n      ctx.beginPath();\n      ctx.moveTo(nx, PEN_Y);\n      ctx.lineTo(nx - 3, PEN_Y - 7);\n      ctx.lineTo(nx + 3, PEN_Y - 7);\n      ctx.closePath();\n      ctx.fill();\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;\n      last = now;\n      let active = false;\n\n      if (springOn) {\n        const target = xFor(lastV);\n        const c = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n        vx += (-SPRING_K * (x - target) - c * vx) * dt;\n        x += vx * dt;\n        // velocity-epsilon sleep, backstopped by the forced-settle deadline\n        if (now >= deadline || (Math.abs(x - target) < 0.05 && Math.abs(vx) < 2)) {\n          x = target;\n          vx = 0;\n          springOn = false;\n        } else active = true;\n      }\n\n      qOff = 0;\n      if (quiverStart >= 0) {\n        const t = (now - quiverStart) / QUIVER_MS;\n        if (t >= 1) quiverStart = -1;\n        else {\n          qOff = QUIVER_AMP * Math.sin(t * Math.PI * 4) * (1 - t); // 2 cycles\n          active = true;\n        }\n      }\n\n      if (scrollStart >= 0) {\n        const t = (now - scrollStart) / SCROLL_MS;\n        if (t >= 1) {\n          scrollCur = scrollTarget;\n          scrollStart = -1;\n        } else {\n          const e = 1 - Math.pow(1 - t, 3); // ease-out\n          scrollCur = scrollFrom + (scrollTarget - scrollFrom) * e;\n          active = true;\n        }\n      }\n\n      draw();\n      if (active && visible && !document.hidden)\n        raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf && visible && !document.hidden) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const commit = (v: number, clamped: boolean) => {\n      const changed = v !== lastV;\n      lastV = v;\n      if (changed) {\n        commitIdx++;\n        points.push({ v, s: commitIdx * ADVANCE });\n        while (points.length > MAX_TRACE) points.shift(); // pruned list\n        scrollFrom = scrollCur;\n        scrollTarget = commitIdx * ADVANCE;\n        scrollStart = performance.now();\n      }\n      if (reduced) {\n        // instant needle, static trace, no strip animation\n        x = xFor(v);\n        vx = 0;\n        springOn = false;\n        scrollCur = scrollTarget;\n        scrollStart = -1;\n        quiverStart = -1;\n        draw();\n        return;\n      }\n      if (changed) {\n        springOn = true;\n        deadline = performance.now() + SETTLE_MS;\n      }\n      if (clamped) quiverStart = performance.now();\n      wake();\n    };\n\n    const resize = () => {\n      const rect = drum.getBoundingClientRect();\n      if (rect.width < 8 || rect.height < 8) {\n        sized = false; // zero-size guard — never draw into nothing\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      // canvas is a replaced element: inset does not size it — explicit CSS\n      // size plus dpr-scaled backing store, or it renders at dpr scale\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      sized = true;\n      x = xFor(lastV); // px space changed — snap, springs resume on next commit\n      vx = 0;\n      springOn = false;\n      draw();\n    };\n    resize();\n\n    engineRef.current = { commit, redraw: draw };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(drum);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake();\n    });\n    io.observe(root);\n    // live theme re-derive: watch documentElement class flips\n    const mo = new MutationObserver(() => {\n      derive();\n      draw();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      points.length = 0;\n      engineRef.current = null;\n    };\n  }, []);\n\n  // controlled/external value changes ride the same drum path (deduped inside)\n  useEffect(() => {\n    engineRef.current?.commit(current, false);\n  }, [current]);\n\n  useEffect(() => {\n    setText(String(current));\n  }, [current]);\n\n  // ---- value plumbing -----------------------------------------------------\n  const commitValue = (next: number) => {\n    const clamped = next < min || next > max;\n    const v = clampRound(next);\n    if (v !== valueRef.current) {\n      if (!isControlled) setInternal(v);\n      onValueChange?.(v);\n    }\n    setText(String(v));\n    engineRef.current?.commit(v, clamped);\n  };\n\n  const stepBy = (mult: number) => {\n    if (disabledRef.current) return;\n    commitValue(valueRef.current + step * mult);\n  };\n\n  const commitFromText = () => {\n    const parsed = Number(text.replace(\",\", \".\"));\n    if (!Number.isFinite(parsed)) {\n      setText(String(valueRef.current)); // revert garbage\n      return;\n    }\n    commitValue(parsed);\n  };\n\n  const endHold = () => {\n    const hold = holdRef.current;\n    if (hold.timeout !== null) clearTimeout(hold.timeout);\n    if (hold.interval !== null) clearInterval(hold.interval);\n    hold.timeout = null;\n    hold.interval = null;\n    setPressDir(0);\n  };\n  const startHold = (dir: number) => (e: React.PointerEvent) => {\n    e.preventDefault(); // keep focus where it is; no text selection\n    if (disabled) return;\n    endHold();\n    setPressDir(dir);\n    stepBy(dir); // immediate step\n    holdRef.current.timeout = setTimeout(() => {\n      holdRef.current.interval = setInterval(\n        () => stepBy(dir),\n        HOLD_INTERVAL_MS\n      ); // needle rides continuously at 12 steps/s\n    }, HOLD_DELAY_MS);\n  };\n  useEffect(() => endHold, []); // tear down hold-repeat timers on unmount\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    switch (e.key) {\n      case \"ArrowUp\":\n        e.preventDefault();\n        stepBy(e.shiftKey ? 10 : 1);\n        break;\n      case \"ArrowDown\":\n        e.preventDefault();\n        stepBy(e.shiftKey ? -10 : -1);\n        break;\n      case \"Home\":\n        e.preventDefault();\n        commitValue(min);\n        break;\n      case \"End\":\n        e.preventDefault();\n        commitValue(max);\n        break;\n      case \"Enter\":\n        e.preventDefault();\n        commitFromText();\n        break;\n      case \"Escape\":\n        setText(String(valueRef.current));\n        break;\n    }\n  };\n\n  const atMin = current <= min;\n  const atMax = current >= max;\n  const btnBase =\n    \"flex h-11 w-11 shrink-0 select-none items-center justify-center rounded-sm border border-border bg-background text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface\";\n  const btnLive =\n    \"cursor-pointer hover:border-foreground/30 hover:bg-foreground/[0.06] active:bg-foreground/[0.1] data-[hover=true]:border-foreground/30 data-[hover=true]:bg-foreground/[0.06] data-[press=true]:bg-foreground/[0.1]\";\n  const btnDead = \"cursor-default opacity-40\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={`flex overflow-hidden rounded-md border border-border bg-surface ${\n        disabled ? \"opacity-60\" : \"\"\n      } ${className}`}\n    >\n      <div className=\"flex min-w-0 flex-1 flex-col justify-between gap-3 p-4\">\n        <span className=\"font-mono text-[10px] uppercase tracking-[0.2em] text-ns-muted\">\n          {label}\n        </span>\n        <div className=\"flex items-center gap-3\">\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={`Decrease ${label}`}\n            aria-disabled={disabled || atMin}\n            onPointerDown={startHold(-1)}\n            onPointerUp={endHold}\n            onPointerEnter={() => setHoverDir(-1)}\n            onPointerLeave={() => {\n              endHold();\n              setHoverDir((d) => (d === -1 ? 0 : d));\n            }}\n            onPointerCancel={endHold}\n            data-hover={hoverDir === -1}\n            data-press={pressDir === -1}\n            className={`${btnBase} ${disabled || atMin ? btnDead : btnLive}`}\n          >\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" aria-hidden=\"true\">\n              <path\n                d=\"M2.5 7h9\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                fill=\"none\"\n              />\n            </svg>\n          </button>\n          {/* value hero: mono, ch-sized input so the unit hugs the digits */}\n          <div className=\"flex min-w-0 flex-1 items-baseline justify-center gap-1.5\">\n            <input\n              type=\"text\"\n              inputMode=\"numeric\"\n              autoComplete=\"off\"\n              spellCheck={false}\n              role=\"spinbutton\"\n              aria-label={label}\n              aria-valuemin={min}\n              aria-valuemax={max}\n              aria-valuenow={current}\n              disabled={disabled}\n              value={text}\n              onChange={(e) => setText(e.target.value)}\n              onKeyDown={onKeyDown}\n              onBlur={commitFromText}\n              style={{ width: `${Math.max(text.length, 1) + 1}ch` }}\n              className=\"max-w-full rounded-sm border border-transparent bg-transparent text-center font-mono text-3xl font-semibold tracking-tight text-foreground outline-none transition-colors hover:border-border focus-visible:border-ns-accent focus-visible:ring-1 focus-visible:ring-ns-accent disabled:cursor-not-allowed\"\n            />\n            {unit ? (\n              <span className=\"shrink-0 font-mono text-sm text-ns-muted\">\n                {unit}\n              </span>\n            ) : null}\n          </div>\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={`Increase ${label}`}\n            aria-disabled={disabled || atMax}\n            onPointerDown={startHold(1)}\n            onPointerUp={endHold}\n            onPointerEnter={() => setHoverDir(1)}\n            onPointerLeave={() => {\n              endHold();\n              setHoverDir((d) => (d === 1 ? 0 : d));\n            }}\n            onPointerCancel={endHold}\n            data-hover={hoverDir === 1}\n            data-press={pressDir === 1}\n            className={`${btnBase} ${disabled || atMax ? btnDead : btnLive}`}\n          >\n            <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" aria-hidden=\"true\">\n              <path\n                d=\"M7 2.5v9M2.5 7h9\"\n                stroke=\"currentColor\"\n                strokeWidth=\"2\"\n                strokeLinecap=\"round\"\n                fill=\"none\"\n              />\n            </svg>\n          </button>\n        </div>\n        <span className=\"font-mono text-[10px] text-ns-muted\">\n          min {min} · max {max} · step {step}\n        </span>\n      </div>\n      {/* adjustment-history strip: labeled drum, newest etch on the top line */}\n      <div\n        aria-hidden\n        className=\"flex w-20 shrink-0 flex-col self-stretch border-l border-border bg-background\"\n      >\n        <span className=\"border-b border-border py-1 text-center font-mono text-[9px] uppercase tracking-[0.18em] text-ns-muted\">\n          history\n        </span>\n        <div ref={drumRef} className=\"relative min-h-0 flex-1\">\n          <canvas\n            ref={canvasRef}\n            className=\"pointer-events-none absolute left-0 top-0\"\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/stepper-needle.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa",
      "error": "#ea001d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717",
      "error": "#ff6369"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "stepper",
      "spinbutton",
      "input",
      "canvas",
      "physics",
      "form",
      "micro-interaction"
    ],
    "instruction": "Build a bounded numeric stepper whose VALUE is the visual hero, paired with a labeled adjustment-history strip. RENDER: real spinbutton (input type=text inputmode=numeric with role=spinbutton, aria-valuemin/max/now) rendered as a large mono readout (text-3xl, ch-sized width so an optional unit suffix hugs the digits, transparent until hover/focus), flanked by prominent 44px -/+ buttons with bold icons; beside it a ~80px-wide strip column with a tiny mono 'history' header over a canvas drum; canvas is a replaced element so set style.width/height EXPLICITLY plus a dpr-scaled (clamp 2) backing store. MOTION: needle x = value mapped across strip width with 10px travel insets; underdamped spring k=120 s^-2, zeta=0.45 (two visible oscillations), forced-settle deadline 900ms backstopping a velocity-epsilon sleep. Each committed adjustment advances the strip 26px downward over 600ms ease-out (event-driven scroll retarget, never continuous, so the rAF loop sleeps between commits). Pen trace: pruned array of the last 20 (value, strip-position) points drawn as a polyline plus dots each frame after a FULL clearRect, alpha fading 0.9 -> 0.2 by age index — no destination-in fading; the NEWEST etch mark is highlighted with a solid dot plus halo ring so the latest adjustment is unmistakable. An --error band at 16% alpha appears ONLY on the edge currently hit (value at min or max), so red always reads as 'at the limit'; clamping fires a hard-stop needle quiver, +-2px, 2 cycles over 180ms, decaying. Hold-repeat on -/+: 400ms delay then 12 steps/s via setTimeout+setInterval, needle riding continuously; timers cleared on pointerup/leave/cancel and unmount. KEYBOARD: ArrowUp/Down +-step, Shift+Arrow +-10 steps, Home/End to min/max, Escape reverts typing, typed entry clamps and commits on Enter/blur with a proportional swing; buttons aria-disabled at bounds, focus-visible rings accent, hover borders token-relative (foreground/30, never white/NN). INK: needle/trace from --foreground, drum feed lines and pen carriage from --border, bound band from --error — all read via getComputedStyle at mount and re-derived by a MutationObserver on documentElement class changes, then statically redrawn, so both themes render correctly. REDUCED MOTION: needle jumps instantly, trace renders statically, no strip animation, no quiver. PERF: direct-DOM rAF with no React state on the hot path, loop sleeps when spring/quiver/scroll all settle, IntersectionObserver pauses offscreen, zero-size drum guard before drawing, ResizeObserver re-derives px mapping (trace stores values, not px), every listener/observer/timer torn down on unmount."
  },
  "type": "registry:ui"
}