{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-editor",
  "type": "registry:ui",
  "title": "Spring editor",
  "description": "A spring shaped by hand, as time and bounce (Motion's visualDuration and bounce, Framer's Time and Bounce). The Pad's well with the spring's curve on its floor: drag the cap right for a slower spring and up for more bounce. It says what the spring does in words, a dot crosses a rail on it when you let go, and tier draws litt's limits with a Fits or Change row per rule.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/tag.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/spring-editor.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/spring-editor 2026.10.27. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Spring editor: a spring shaped by hand, as time and bounce (Motion's visualDuration and bounce, Framer's Time and\n * Bounce). It is the Pad with the spring's curve drawn on its floor: the cap's x is the visual time on the curve's\n * own time axis, and its height is the bounce. Drag right and the curve stretches, drag up and it overshoots.\n *\n * - Surface: the Pad's well, the cut (R3): --litt-cut inside the --litt-line-control line with --litt-cut-shade, at the surface\n *   radius. On its floor, inside the same 14px inset the cap keeps, the curve from 0 to 1 in --litt-fg at 1.5px, a\n *   --litt-line-strong baseline and a dashed --litt-line-strong target line, all static chrome. The time axis runs from 0 at\n *   the left to maxTime at the right, so the cap stands on the curve's time axis at the visual time; a dashed\n *   --litt-line-strong line runs down through it, where the curve should look arrived. The curve's height leaves room\n *   for the largest overshoot maxBounce allows. Two words sit inside the corners on plates of the well's floor:\n *   \"Bouncier\" top left and \"Slower\" bottom right, at the caption size in --litt-fg-subtle.\n * - Cap: the Pad's round cap, 20px (--litt-cap, --litt-cap-lift); held, --litt-cap-held and --litt-cap-press and it sinks --litt-press-depth.\n *   Never the accent (R6), never a new size.\n * - Tier: pass tier and litt's limits for it are drawn (rules.ts): beyond the visual-time limit and above the bounce\n *   limit the floor is washed with fg at 5%, so the clear corner is where a spring fits. A short plate names each\n *   limit. Under the readout, one row per rule says Fits or Change (the Tag's success and warning words), as the\n *   Playground's rows do; checks={false} leaves the rows to a panel that lists them itself.\n * - It says its value (R5): the label row ends with the time and the bounce, and a line under the well says what\n *   they do: \"Settles in 420ms and overshoots 3%.\" The same words are both inputs' aria-valuetext.\n * - Preview: a rail under the well with a 12px dot in --litt-fg and a ghost Play button. The dot crosses on the spring\n *   being edited, so you feel what you drew; it plays by itself when a change is committed, and crosses back the next\n *   time, so it never jumps to a start. It is the one place this component moves, and the spring is the person's\n *   own, not a litt tier, because the preview is the point. Reduced motion lands it at once.\n * - Pointer: a press anywhere moves the cap there and starts a drag; the well captures the pointer, so a drag may\n *   leave it. Values snap to 10ms and 0.01. Double-click goes back to defaultMark. data-no-drag and touch-action\n *   none: the well is the cap's, so the whole well is the target.\n * - Keyboard: the Pad's. Two visually hidden range inputs, Time (in the tab order) and Bounce (tabindex -1, still\n *   reachable by a screen reader). ← → change the time, ↑ ↓ the bounce, Shift makes it 10 steps, Page Up and Page Down\n *   move the bounce by 10 steps, Home and End take the time to its ends.\n * - Disabled: everything lies flat, as the Pad's: the well and its plates --litt-surface-2 with the --litt-line hairline, the\n *   cap --litt-surface-2 with a --litt-line edge, the curve --litt-fg-disabled, no pointer and no tab stop. The value and the words\n *   step down to --litt-fg-subtle, as the Slider's value does, because they are still read.\n * - Motion: the cap, its line and the curve follow the pointer and the keys at once, with no easing (R4): a curve\n *   that eased would lag the hand that draws it. The sink is at once, and reduced motion sets its depth to 0.\n */\nimport { useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { motion, type Transition } from \"motion/react\";\nimport { useMotionDial, useTransition } from \"../../lib/litt/motion\";\nimport { overshoot, simulate, type Spring } from \"../../lib/litt/motion/spring\";\nimport { LIMITS, springRules, type SpringTier } from \"../../lib/litt/motion/rules\";\nimport { Button } from \"./button\";\nimport { PlayIcon } from \"./icons\";\nimport { Tag } from \"./tag\";\n\nexport type { Spring, SpringTier };\n\nexport type SpringEditorProps = {\n  /** The label above the editor. Required: it names the group and describes both inputs for screen readers. */\n  label: ReactNode;\n  /** Help text under the editor, linked to both inputs with aria-describedby. */\n  help?: ReactNode;\n  /** The spring, when you control it: time in seconds and bounce from 0 to 1. Pair it with onValueChange. */\n  value?: Spring;\n  /** The spring it starts at when you do not control it. Defaults to litt's expressive spring. */\n  defaultValue?: Spring;\n  /** Called with the new spring on every move of the cap, by pointer or key. */\n  onValueChange?: (value: Spring) => void;\n  /** Called with the final spring when a drag is released, or right after a key moves the cap. Use it to save. */\n  onValueCommit?: (value: Spring) => void;\n  /** The longest visual time, in seconds, at the right edge. The time axis starts at 0. Defaults to 0.8. */\n  maxTime?: number;\n  /** The most bounce, at the top edge. Defaults to 0.5. */\n  maxBounce?: number;\n  /** Checks the spring against one of litt's tiers: draws its limits on the floor and says Fits or Change for each rule under the readout. */\n  tier?: SpringTier;\n  /** With a tier, lists a Fits or Change row per rule under the readout. On by default; turn it off when the panel around it lists the rules itself, as an Inspector's Rules group does. */\n  checks?: boolean;\n  /** Draws a dashed circle marked \"Default\" at this spring. Double-click the well to go back to it. */\n  defaultMark?: Spring;\n  /** Shows the rail under the well where a dot crosses on the spring, with a Play button. On by default. */\n  preview?: boolean;\n  /** Height of the well in px. The width is the container's. */\n  height?: number;\n  /** Makes the editor unavailable: muted, skipped by Tab, and ignoring the pointer. */\n  disabled?: boolean;\n  /** Extra classes for the wrapper, such as a width. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- geometry */\n\nconst INSET = 14; // the cap's radius, the 2px ring and the 2px offset, as the Pad's\nconst MIN_TIME = 0.05;\nconst TIME_STEP = 0.01;\nconst BOUNCE_STEP = 0.01;\nconst VIEW = 1000; // the floor's drawing units; the svg stretches them to the inset box\nconst HEADROOM = 0.08; // air above the largest overshoot, as a fraction of the 0 to 1 travel\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst fixed = (n: number) => Number(n.toFixed(2));\nconst snapTo = (raw: number, lo: number, hi: number, step: number) => fixed(clamp(Math.round(raw / step) * step, lo, hi));\n/** The CSS for a centre `f` of the way along an edge, kept `INSET` clear of both ends. No measuring. */\nconst along = (f: number) => `calc(${INSET}px + (100% - ${2 * INSET}px) * ${f})`;\n\nconst ms = (s: number) => `${Math.round(s * 1000)}ms`;\nconst num = (v: number) => String(+v.toFixed(2));\n\n/** What a spring does, in words: the settle time and how far it passes the target. */\nfunction describe(spring: Spring) {\n  const { pts, settle } = simulate(spring);\n  const over = Math.round(Math.max(0, ...pts.map(([, x]) => x - 1)) * 100);\n  const settles = `Settles in ${Math.round(settle * 1000)}ms`;\n  return { pts, short: `${ms(spring.time)}, bounce ${num(spring.bounce)}`, effect: over ? `${settles} and overshoots ${over}%` : `${settles} with no overshoot` };\n}\n\n/* ----------------------------------------------------------------- styles */\n\nconst well = \"relative w-full touch-none select-none overflow-hidden rounded-litt-surface forced-colors:border \";\nconst wellOn = \"bg-litt-cut shadow-[inset_0_0_0_1px_var(--litt-line-control),var(--litt-cut-shade)]\";\nconst wellOff = \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\";\nconst thumb = \"pointer-events-none absolute block size-5 -translate-x-1/2 rounded-litt-pill forced-colors:border \";\nconst thumbUp = \"-translate-y-1/2 bg-litt-cap shadow-(--litt-cap-lift) forced-colors:bg-[Highlight]\";\nconst thumbHeld = \"translate-y-[calc(var(--litt-press-depth)-50%)] bg-litt-cap-held shadow-(--litt-cap-press) forced-colors:bg-[Highlight]\";\nconst thumbOff = \"-translate-y-1/2 bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\";\nconst word = \"pointer-events-none absolute px-1 text-litt-caption font-normal whitespace-nowrap\";\nconst wash = \"pointer-events-none absolute bg-[color-mix(in_oklab,var(--litt-fg)_5%,transparent)]\";\n\n/* ----------------------------------------------------------------- the preview */\n\n/** A dot that crosses a rail on the spring. `run` flips it to the other end; a new run starts from where it is. */\nfunction Preview({ spring, run, disabled }: { spring: Spring; run: number; disabled?: boolean }) {\n  const { reduced } = useMotionDial();\n  const still = useTransition(\"instant\");\n  const own: Transition = { type: \"spring\", visualDuration: spring.time, bounce: spring.bounce }; // litt-allow: the preview runs on the spring being edited\n  return (\n    <div aria-hidden=\"true\" className=\"relative h-3 min-w-0 flex-1\">\n      <span className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-litt-line-strong\" />\n      <motion.span\n        className=\"absolute inset-y-0 left-0 w-[calc(100%-12px)]\"\n        initial={false}\n        animate={{ x: run % 2 ? \"100%\" : \"0%\" }}\n        transition={reduced ? still : own}\n      >\n        <span className={\"absolute top-0 left-0 size-3 rounded-litt-pill \" + (disabled ? \"bg-litt-fg-disabled\" : \"bg-litt-fg\")} />\n      </motion.span>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- SpringEditor */\n\n/** A spring drawn by hand: drag the cap right for a slower spring and up for more bounce, and watch its curve and a dot follow. */\nexport function SpringEditor({\n  label,\n  help,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  maxTime = 0.8,\n  maxBounce = 0.5,\n  tier,\n  checks = true,\n  defaultMark,\n  preview = true,\n  height = 160,\n  disabled = false,\n  className = \"\",\n}: SpringEditorProps) {\n  const id = useId().replace(/[^\\w-]/g, \"\");\n  const labelId = `${id}-label`;\n  const helpId = help ? `${id}-help` : undefined;\n  const effectId = `${id}-effect`;\n  const defaultId = defaultMark ? `${id}-default` : undefined;\n\n  const { dial } = useMotionDial();\n  const [inner, setInner] = useState<Spring>(() => defaultValue ?? dial.expressive);\n  const value = valueProp ?? inner;\n  const { pts, short, effect } = useMemo(() => describe(value), [value]);\n  const say = `${short}. ${effect}.`;\n\n  const timeInput = useRef<HTMLInputElement>(null);\n  const [dragging, setDragging] = useState(false);\n  const [keys, setKeys] = useState(false);\n  const [run, setRun] = useState(0);\n  const dragRef = useRef(false);\n  const lastRef = useRef<Spring>(value);\n\n  const move = (next: Spring) => {\n    if (next.time === value.time && next.bounce === value.bounce) return false;\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n    return true;\n  };\n  const commit = (next: Spring) => {\n    onValueCommit?.(next);\n    if (preview) setRun((r) => r + 1);\n  };\n  const shape = (time: number, bounce: number): Spring => ({\n    time: snapTo(time, MIN_TIME, maxTime, TIME_STEP),\n    bounce: snapTo(bounce, 0, maxBounce, BOUNCE_STEP),\n  });\n\n  /* ---- pointer */\n\n  const fromPointer = (e: PointerEvent<HTMLDivElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const fx = clamp((e.clientX - box.left - INSET) / Math.max(1, box.width - 2 * INSET), 0, 1);\n    const fy = clamp((box.bottom - INSET - e.clientY) / Math.max(1, box.height - 2 * INSET), 0, 1);\n    return shape(fx * maxTime, fy * maxBounce);\n  };\n  const drag = (e: PointerEvent<HTMLDivElement>) => {\n    const next = fromPointer(e);\n    lastRef.current = next;\n    move(next);\n  };\n  const end = () => {\n    if (!dragRef.current) return;\n    dragRef.current = false;\n    setDragging(false);\n    commit(lastRef.current);\n  };\n\n  /* ---- keyboard */\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || e.altKey || e.ctrlKey || e.metaKey) return;\n    const n = e.shiftKey ? 10 : 1;\n    const { time, bounce } = value;\n    let next: Spring | null = null;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        next = shape(time - n * TIME_STEP, bounce);\n        break;\n      case \"ArrowRight\":\n        next = shape(time + n * TIME_STEP, bounce);\n        break;\n      case \"ArrowDown\":\n        next = shape(time, bounce - n * BOUNCE_STEP);\n        break;\n      case \"ArrowUp\":\n        next = shape(time, bounce + n * BOUNCE_STEP);\n        break;\n      case \"PageDown\":\n        next = shape(time, bounce - 10 * BOUNCE_STEP);\n        break;\n      case \"PageUp\":\n        next = shape(time, bounce + 10 * BOUNCE_STEP);\n        break;\n      case \"Home\":\n        next = shape(MIN_TIME, bounce);\n        break;\n      case \"End\":\n        next = shape(maxTime, bounce);\n        break;\n    }\n    if (!next) return;\n    e.preventDefault();\n    setKeys(true);\n    if (move(next)) commit(next);\n  };\n\n  const adjust = (axis: \"time\" | \"bounce\", raw: string) => {\n    const next = axis === \"time\" ? shape(Number(raw), value.bounce) : shape(value.time, Number(raw));\n    if (move(next)) commit(next);\n  };\n\n  const onFocus = (e: FocusEvent<HTMLInputElement>) => {\n    if (!dragRef.current) setKeys(e.currentTarget.matches(\":focus-visible\"));\n  };\n\n  /* ---- what is drawn */\n\n  /* The curve's height leaves room for the most overshoot maxBounce allows. Overshoot follows the bounce alone. */\n  const top = useMemo(() => 1 + overshoot({ time: 0.3, bounce: maxBounce }) + HEADROOM, [maxBounce]);\n  const toY = (x: number) => (VIEW * (1 - x / top)).toFixed(1);\n  const shown = pts.filter(([t], i) => t <= maxTime || i === 0);\n  /* The simulation stops once the spring has settled; from there it rests on the target to the edge. */\n  const rest = shown[shown.length - 1]![0] < maxTime ? `L${VIEW} ${toY(1)}` : \"\";\n  const d = shown.map(([t, x], i) => `${i ? \"L\" : \"M\"}${((t / maxTime) * VIEW).toFixed(1)} ${toY(x)}`).join(\"\") + rest;\n  const fx = value.time / maxTime;\n  const fy = value.bounce / maxBounce;\n\n  /* The tier's limits, as fractions of each axis. A limit past the edge is not drawn. */\n  const limitX = tier ? LIMITS.springMs / 1000 / maxTime : 1;\n  const limitY = tier ? (tier === \"responsive\" ? 0 : LIMITS.bounce / maxBounce) : 1;\n  const rules = tier && checks ? springRules(value, tier) : [];\n\n  const describedBy = [labelId, helpId, effectId, defaultId].filter(Boolean).join(\" \");\n  const plate = disabled ? \"bg-litt-surface-2 text-litt-fg-disabled\" : \"bg-litt-cut text-litt-fg-subtle\";\n  const ink = disabled ? \"stroke-litt-fg-disabled\" : \"stroke-litt-fg\";\n  const chrome = disabled ? \"stroke-litt-line\" : \"stroke-litt-line-strong\";\n\n  const inputs = (axis: \"time\" | \"bounce\") => ({\n    type: \"range\" as const,\n    min: axis === \"time\" ? MIN_TIME : 0,\n    max: axis === \"time\" ? maxTime : maxBounce,\n    step: axis === \"time\" ? TIME_STEP : BOUNCE_STEP,\n    value: value[axis],\n    disabled,\n    \"aria-label\": axis === \"time\" ? \"Time\" : \"Bounce\",\n    \"aria-describedby\": describedBy,\n    \"aria-valuetext\": say,\n    onChange: (e: ChangeEvent<HTMLInputElement>) => adjust(axis, e.target.value),\n    onKeyDown,\n    onFocus,\n    onBlur: () => setKeys(false),\n  });\n\n  return (\n    <div className={[\"grid gap-2\", className].join(\" \")}>\n      <div className=\"flex items-baseline justify-between gap-4\">\n        <span id={labelId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n          {label}\n        </span>\n        <span aria-hidden=\"true\" className={\"text-litt-small tabular-nums \" + (disabled ? \"text-litt-fg-subtle\" : dragging ? \"text-litt-fg\" : \"text-litt-fg-muted\")}>\n          {short}\n        </span>\n      </div>\n      <div role=\"group\" aria-labelledby={labelId} aria-disabled={disabled || undefined}>\n        <div\n          data-no-drag=\"\"\n          className={well + (disabled ? wellOff + \" cursor-not-allowed\" : wellOn + \" cursor-pointer\")}\n          style={{ height }}\n          onMouseDown={(e) => {\n            if (!disabled) e.preventDefault();\n          }}\n          onPointerDown={(e) => {\n            if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n            e.currentTarget.setPointerCapture(e.pointerId);\n            dragRef.current = true;\n            setDragging(true);\n            setKeys(false);\n            drag(e);\n            timeInput.current?.focus({ preventScroll: true });\n          }}\n          onPointerMove={(e) => {\n            if (dragRef.current) drag(e);\n          }}\n          onPointerUp={end}\n          onPointerCancel={end}\n          onLostPointerCapture={end}\n          onDoubleClick={() => {\n            if (disabled || !defaultMark) return;\n            if (move(defaultMark)) commit(defaultMark);\n          }}\n        >\n          {tier && limitX < 1 && (\n            <span aria-hidden=\"true\" className={wash + \" inset-y-0 right-0\"} style={{ left: along(limitX) }} />\n          )}\n          {tier && limitY < 1 && (\n            <span\n              aria-hidden=\"true\"\n              className={wash + \" top-0 left-0\"}\n              style={{ right: limitX < 1 ? `calc(100% - ${along(limitX)})` : 0, bottom: `calc(${INSET}px + (100% - ${2 * INSET}px) * ${limitY} + ${limitY === 0 ? 4 : 0}px)` }}\n            />\n          )}\n\n          {/* The floor: a baseline, the target, the curve. Static chrome, stretched to the inset box. */}\n          <svg\n            aria-hidden=\"true\"\n            focusable=\"false\"\n            viewBox={`0 0 ${VIEW} ${VIEW}`}\n            preserveAspectRatio=\"none\"\n            className=\"pointer-events-none absolute overflow-visible\"\n            style={{ left: INSET, top: INSET, width: `calc(100% - ${2 * INSET}px)`, height: `calc(100% - ${2 * INSET}px)` }}\n          >\n            <path d={`M0 ${VIEW}H${VIEW}`} className={chrome} strokeWidth={1} vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            <path d={`M0 ${toY(1)}H${VIEW}`} className={chrome} strokeWidth={1} strokeDasharray=\"2 3\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            <path d={d} className={ink} strokeWidth={1.5} strokeLinejoin=\"round\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n          </svg>\n\n          {/* Down through the cap: where the curve should look arrived. */}\n          <span\n            aria-hidden=\"true\"\n            className={\"pointer-events-none absolute inset-y-0 w-0 border-l border-dashed \" + (disabled ? \"border-litt-line\" : \"border-litt-line-strong\")}\n            style={{ left: along(fx) }}\n          />\n\n          {tier && limitX < 1 && (\n            <span aria-hidden=\"true\" className={word + \" top-1 ml-1 \" + plate} style={{ left: along(limitX) }}>\n              {LIMITS.springMs}ms\n            </span>\n          )}\n          {tier === \"expressive\" && limitY < 1 && (\n            <span\n              aria-hidden=\"true\"\n              className={word + \" right-1 translate-y-1/2 \" + plate}\n              style={{ bottom: `calc(${INSET}px + (100% - ${2 * INSET}px) * ${limitY})` }}\n            >\n              {num(LIMITS.bounce)}\n            </span>\n          )}\n\n          {defaultMark && (\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2\"\n              style={{ left: along(defaultMark.time / maxTime), top: along(1 - defaultMark.bounce / maxBounce) }}\n            >\n              <svg width={20} height={20} viewBox=\"0 0 20 20\" focusable=\"false\" className=\"block overflow-visible\">\n                <circle cx={10} cy={10} r={9.5} fill=\"none\" strokeWidth={1} pathLength={24} strokeDasharray=\"1.5 1.5\" className={disabled ? \"stroke-litt-fg-disabled\" : \"stroke-litt-fg-subtle\"} />\n              </svg>\n            </span>\n          )}\n\n          <span aria-hidden=\"true\" className={word + \" top-1 left-1 \" + plate}>\n            Bouncier\n          </span>\n          <span aria-hidden=\"true\" className={word + \" right-1 bottom-1 \" + plate}>\n            Slower\n          </span>\n\n          <input ref={timeInput} className=\"sr-only\" {...inputs(\"time\")} />\n          <input className=\"sr-only\" tabIndex={-1} aria-orientation=\"vertical\" {...inputs(\"bounce\")} />\n          {defaultId && (\n            <span id={defaultId} className=\"sr-only\">\n              Default: {ms(defaultMark!.time)}, bounce {num(defaultMark!.bounce)}. Double-click to go back to it.\n            </span>\n          )}\n\n          <span\n            aria-hidden=\"true\"\n            className={thumb + (disabled ? thumbOff : dragging ? thumbHeld : thumbUp) + (keys ? \" outline-2 outline-offset-2 outline-litt-focus\" : \"\")}\n            style={{ left: along(fx), top: along(1 - fy) }}\n          />\n        </div>\n      </div>\n\n      <p id={effectId} className={\"text-litt-small \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\")}>\n        {effect}.\n      </p>\n\n      {preview && (\n        <div className=\"flex items-center gap-3\">\n          <Button size=\"sm\" variant=\"ghost\" icon={<PlayIcon />} disabled={disabled} onClick={() => setRun((r) => r + 1)} className=\"-ml-3\">\n            Play\n          </Button>\n          <Preview spring={value} run={run} disabled={disabled} />\n        </div>\n      )}\n\n      {rules.length > 0 && (\n        <ul className=\"grid gap-2\" aria-label={`Against the ${tier} tier`}>\n          {rules.map((r) => (\n            <li key={r.text} className=\"flex items-start gap-2 text-litt-small\">\n              <Tag status={r.ok ? \"success\" : \"warning\"}>{r.ok ? \"Fits\" : \"Change\"}</Tag>\n              <span className={\"pt-0.5 \" + (r.ok ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\")}>{r.text}</span>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      {help && (\n        <p id={helpId} className=\"text-litt-small text-litt-fg-subtle\">\n          {help}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/spring-editor.tsx"
    }
  ],
  "docs": "Import SpringEditor from components/litt/spring-editor. Give it a label and hold the spring ({ time, bounce }) in state with value and onValueChange. Pass tier=\"expressive\" or \"responsive\" to check it against litt's motion rules, and defaultMark for the default. Time and Bounce are range inputs for the keyboard and screen readers.",
  "meta": {
    "version": "2026.10.27",
    "released": "2026-10-27",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
