{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"balance-scale","type":"registry:component","title":"Balance scale","description":"Weighs your pros against your cons on a balance that tips and slowly settles.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/balance-scale.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Weight = 1 | 2 | 3;\nexport type Reason = { id: string; text: string; weight: Weight };\nexport type Side = \"pros\" | \"cons\";\nexport type Ledger = Record<Side, Reason[]>;\n\n// Scale geometry, in viewBox units.\nconst VIEW_W = 440;\nconst VIEW_H = 200;\nconst PIVOT_X = 220;\nconst PIVOT_Y = 40;\nconst ARM = 160;\n// Past this the pans would read as falling, not tipping.\nconst MAX_TILT = 14;\n// A difference of about six weight units already tips most of the way.\nconst TILT_SPREAD = 6;\n// Beam: a heavy, lightly damped spring (about 0.6Hz, zeta 0.35), so it sways\n// past the new balance point and takes a few seconds to settle, the way a\n// real balance does. This is the one place a slow settle is the point.\nconst BEAM_K = 14;\nconst BEAM_C = 2.6;\n// Pans hang from pins and swing against the beam's acceleration.\nconst PAN_K = 22;\nconst PAN_C = 2.2;\nconst PAN_COUPLING = 0.8;\n// Enough to see the pans swing, never enough to spill past the frame.\nconst PAN_MAX = 6;\n// Angular kick, deg/s per weight unit, when a weight lands.\nconst THUD = 9;\nconst REST = 0.02;\nconst MAX_ITEMS = 8;\n\nconst BLOCK_W: Record<Weight, number> = { 1: 14, 2: 18, 3: 22 };\nconst BLOCK_H: Record<Weight, number> = { 1: 10, 2: 14, 3: 18 };\n// Where the dish sits below the pin, and where the strings meet its rim.\nconst DISH_Y = 80;\nconst STRING_X = 46;\n\n// The scale is brass, a physical material: a bright edge where the light\n// catches, the body, and the shade. Dimmed a step in dark mode so it sits\n// in the page instead of glowing.\nconst BRASS_HI = \"light-dark(#f2dc9b, #dcc47e)\";\nconst BRASS = \"light-dark(#c9a14a, #b08c3e)\";\nconst BRASS_LO = \"light-dark(#8c6b2b, #6e5322)\";\n// Cast iron weights: near black in both themes, lifted a little in dark\n// mode so they still read against the page.\nconst IRON = \"light-dark(#262626, #4d4d4d)\";\n\nfunction total(items: Reason[]) {\n  return items.reduce((sum, item) => sum + item.weight, 0);\n}\n\nfunction tiltFor(pros: number, cons: number) {\n  // Heavier pros sink the left pan, which is a negative (counter-clockwise)\n  // rotation in SVG.\n  return -MAX_TILT * Math.tanh((pros - cons) / TILT_SPREAD);\n}\n\n// Packs weights into rows on the dish, each row centered and narrower as it\n// rises between the strings.\nfunction pack(items: Reason[]) {\n  const placed: { item: Reason; x: number; y: number }[] = [];\n  let row: Reason[] = [];\n  let base = DISH_Y;\n  const flush = () => {\n    const widths = row.map((r) => BLOCK_W[r.weight]);\n    const span = widths.reduce((a, b) => a + b, 0) + (row.length - 1) * 3;\n    let x = -span / 2;\n    row.forEach((r, i) => {\n      placed.push({ item: r, x: x + widths[i] / 2, y: base });\n      x += widths[i] + 3;\n    });\n    base -= Math.max(...row.map((r) => BLOCK_H[r.weight])) + 1;\n    row = [];\n  };\n  for (const item of items) {\n    const room = (2 * STRING_X * base) / DISH_Y - 12;\n    const used =\n      row.reduce((a, r) => a + BLOCK_W[r.weight] + 3, 0) + BLOCK_W[item.weight];\n    if (row.length && used > room) flush();\n    row.push(item);\n  }\n  if (row.length) flush();\n  return placed;\n}\n\nfunction Scale({ ledger }: { ledger: Ledger }) {\n  const reduceMotion = useReducedMotion();\n  const gid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const beamRef = useRef<SVGGElement>(null);\n  const leftRef = useRef<SVGGElement>(null);\n  const rightRef = useRef<SVGGElement>(null);\n  // Only weights that have landed count, so the beam answers the thud\n  // rather than tilting while a weight is still in the air.\n  const landed = useRef(\n    new Set([...ledger.pros, ...ledger.cons].map((r) => r.id)),\n  );\n  const latest = useRef(ledger);\n  const initialTilt = tiltFor(total(ledger.pros), total(ledger.cons));\n  const [initial] = useState(() => ({\n    beam: `rotate(${initialTilt} ${PIVOT_X} ${PIVOT_Y})`,\n    left: `translate(${PIVOT_X - ARM} ${PIVOT_Y}) rotate(${-initialTilt})`,\n    right: `translate(${PIVOT_X + ARM} ${PIVOT_Y}) rotate(${-initialTilt})`,\n  }));\n  const sim = useRef({\n    angle: initialTilt,\n    vel: 0,\n    swing: 0,\n    swingVel: 0,\n    target: initialTilt,\n    frame: 0,\n  });\n\n  const paint = () => {\n    const s = sim.current;\n    const pan = -s.angle + s.swing;\n    beamRef.current?.setAttribute(\n      \"transform\",\n      `rotate(${s.angle.toFixed(3)} ${PIVOT_X} ${PIVOT_Y})`,\n    );\n    leftRef.current?.setAttribute(\n      \"transform\",\n      `translate(${PIVOT_X - ARM} ${PIVOT_Y}) rotate(${pan.toFixed(3)})`,\n    );\n    rightRef.current?.setAttribute(\n      \"transform\",\n      `translate(${PIVOT_X + ARM} ${PIVOT_Y}) rotate(${pan.toFixed(3)})`,\n    );\n  };\n\n  const run = () => {\n    const s = sim.current;\n    if (s.frame) return;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      const accel = -BEAM_K * (s.angle - s.target) - BEAM_C * s.vel;\n      s.vel += accel * dt;\n      s.angle += s.vel * dt;\n      s.swingVel +=\n        (-PAN_K * s.swing - PAN_C * s.swingVel - PAN_COUPLING * accel) * dt;\n      s.swing = Math.max(\n        -PAN_MAX,\n        Math.min(PAN_MAX, s.swing + s.swingVel * dt),\n      );\n      paint();\n      const still =\n        Math.abs(s.angle - s.target) < REST &&\n        Math.abs(s.vel) < REST &&\n        Math.abs(s.swing) < REST &&\n        Math.abs(s.swingVel) < REST;\n      if (still) {\n        Object.assign(s, { angle: s.target, vel: 0, swing: 0, swingVel: 0 });\n        paint();\n        s.frame = 0;\n        return;\n      }\n      s.frame = requestAnimationFrame(tick);\n    };\n    s.frame = requestAnimationFrame(tick);\n  };\n\n  const retarget = () => {\n    const s = sim.current;\n    // Read through a ref: a weight still falling when its row is removed\n    // lands from a stale render and must not weigh the old ledger.\n    const current = latest.current;\n    const weigh = (items: Reason[]) =>\n      total(\n        reduceMotion ? items : items.filter((r) => landed.current.has(r.id)),\n      );\n    s.target = tiltFor(weigh(current.pros), weigh(current.cons));\n    if (reduceMotion) {\n      cancelAnimationFrame(s.frame);\n      Object.assign(s, {\n        angle: s.target,\n        vel: 0,\n        swing: 0,\n        swingVel: 0,\n        frame: 0,\n      });\n      paint();\n      return;\n    }\n    run();\n  };\n\n  useEffect(() => {\n    latest.current = ledger;\n    retarget();\n    // retarget reads the latest ledger; it only needs to run when it changes.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [ledger, reduceMotion]);\n\n  useEffect(() => {\n    const s = sim.current;\n    return () => cancelAnimationFrame(s.frame);\n  }, []);\n\n  const land = (side: Side, item: Reason) => {\n    if (landed.current.has(item.id)) return;\n    landed.current.add(item.id);\n    sim.current.vel += (side === \"pros\" ? -1 : 1) * THUD * item.weight;\n    retarget();\n  };\n\n  const pan = (side: Side) => (\n    <>\n      {/* Fine chains: dashes read as links at this size. */}\n      <path\n        d={`M0 3L${-STRING_X} ${DISH_Y}M0 3L${STRING_X} ${DISH_Y}`}\n        style={{ stroke: BRASS_LO }}\n        strokeWidth={1.5}\n        strokeDasharray=\"2.5 1.5\"\n        strokeLinecap=\"butt\"\n      />\n      <circle\n        r={4}\n        fill={`url(#${gid}-knob)`}\n        style={{ stroke: BRASS_LO }}\n        strokeWidth={1}\n      />\n      <AnimatePresence initial={false}>\n        {pack(ledger[side]).map(({ item, x, y }) => {\n          const w = BLOCK_W[item.weight];\n          const h = BLOCK_H[item.weight];\n          return (\n            <motion.g\n              key={item.id}\n              initial={reduceMotion ? false : { x, y: y - 36, opacity: 0 }}\n              animate={{ x, y, opacity: 1 }}\n              exit={{\n                opacity: 0,\n                transition: { duration: 0.15, ease: \"easeOut\" },\n              }}\n              transition={{\n                // A drop accelerates, so this one curve is ease-in on purpose.\n                y: { duration: 0.26, ease: [0.55, 0, 1, 0.45] },\n                opacity: { duration: 0.1 },\n                x: { type: \"spring\", duration: 0.35, bounce: 0 },\n              }}\n              onAnimationComplete={() => land(side, item)}\n            >\n              <motion.rect\n                initial={false}\n                animate={{ attrX: -w / 2, attrY: -h, width: w, height: h }}\n                transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n                rx={2}\n                style={{ fill: IRON }}\n                className=\"stroke-background\"\n                strokeWidth={1.5}\n              />\n              {/* The lifting knob that makes a block read as a weight. */}\n              <motion.rect\n                initial={false}\n                animate={{ attrY: -h - 3 }}\n                transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n                x={-3}\n                width={6}\n                height={4}\n                rx={1.5}\n                style={{ fill: IRON }}\n              />\n            </motion.g>\n          );\n        })}\n      </AnimatePresence>\n      {/* The dish: a shallow brass bowl with a bright rolled rim. */}\n      <path\n        d={`M-54 ${DISH_Y}H54Q44 ${DISH_Y + 15} 0 ${DISH_Y + 15}Q-44 ${DISH_Y + 15} -54 ${DISH_Y}Z`}\n        fill={`url(#${gid}-dish)`}\n      />\n      <rect\n        x={-55}\n        y={DISH_Y - 1.5}\n        width={110}\n        height={3}\n        rx={1.5}\n        style={{ fill: BRASS_HI }}\n      />\n    </>\n  );\n\n  return (\n    <svg\n      viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}\n      className=\"block h-auto w-full overflow-visible\"\n      fill=\"none\"\n      strokeLinecap=\"round\"\n      aria-hidden\n    >\n      <defs>\n        {/* Beam and base: lit from above. */}\n        <linearGradient id={`${gid}-bar`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0\" style={{ stopColor: BRASS_HI }} />\n          <stop offset=\"0.45\" style={{ stopColor: BRASS }} />\n          <stop offset=\"1\" style={{ stopColor: BRASS_LO }} />\n        </linearGradient>\n        {/* The column is a cylinder: bright a little left of center. */}\n        <linearGradient id={`${gid}-post`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\">\n          <stop offset=\"0\" style={{ stopColor: BRASS_LO }} />\n          <stop offset=\"0.35\" style={{ stopColor: BRASS_HI }} />\n          <stop offset=\"1\" style={{ stopColor: BRASS_LO }} />\n        </linearGradient>\n        <linearGradient id={`${gid}-dish`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0\" style={{ stopColor: BRASS }} />\n          <stop offset=\"1\" style={{ stopColor: BRASS_LO }} />\n        </linearGradient>\n        <radialGradient id={`${gid}-knob`} cx=\"0.35\" cy=\"0.35\" r=\"0.7\">\n          <stop offset=\"0\" style={{ stopColor: BRASS_HI }} />\n          <stop offset=\"1\" style={{ stopColor: BRASS }} />\n        </radialGradient>\n      </defs>\n      {/* Dial behind the pivot, read by the needle. */}\n      <path\n        d={`M${PIVOT_X - 28} ${PIVOT_Y - 12}A30 30 0 0 1 ${PIVOT_X + 28} ${PIVOT_Y - 12}`}\n        className=\"stroke-foreground/20\"\n        strokeWidth={1}\n      />\n      {[-20, -10, 0, 10, 20].map((deg) => {\n        const a = ((deg - 90) * Math.PI) / 180;\n        const r0 = deg === 0 ? 24 : 26;\n        return (\n          <line\n            key={deg}\n            x1={PIVOT_X + Math.cos(a) * r0}\n            y1={PIVOT_Y + Math.sin(a) * r0}\n            x2={PIVOT_X + Math.cos(a) * 30}\n            y2={PIVOT_Y + Math.sin(a) * 30}\n            className=\"stroke-foreground/30\"\n            strokeWidth={1}\n          />\n        );\n      })}\n      {/* Column, a collar where it meets the base, and a two-step base. */}\n      <rect\n        x={PIVOT_X - 4.5}\n        y={PIVOT_Y}\n        width={9}\n        height={VIEW_H - 22 - PIVOT_Y}\n        fill={`url(#${gid}-post)`}\n      />\n      <rect\n        x={PIVOT_X - 9}\n        y={VIEW_H - 28}\n        width={18}\n        height={8}\n        rx={2}\n        fill={`url(#${gid}-post)`}\n      />\n      <rect\n        x={PIVOT_X - 38}\n        y={VIEW_H - 21}\n        width={76}\n        height={7}\n        rx={3}\n        fill={`url(#${gid}-bar)`}\n      />\n      <rect\n        x={PIVOT_X - 62}\n        y={VIEW_H - 15}\n        width={124}\n        height={10}\n        rx={5}\n        fill={`url(#${gid}-bar)`}\n      />\n      <g ref={beamRef} transform={initial.beam}>\n        {/* The needle: dark steel, so it reads against the dial. */}\n        <line\n          x1={PIVOT_X}\n          y1={PIVOT_Y}\n          x2={PIVOT_X}\n          y2={PIVOT_Y - 24}\n          className=\"stroke-foreground\"\n          strokeWidth={1.5}\n        />\n        {/* Beam: thicker at the pivot, tapering to each end. */}\n        <path\n          d={`M${PIVOT_X - ARM - 4} ${PIVOT_Y - 2.5} L${PIVOT_X} ${PIVOT_Y - 5} L${PIVOT_X + ARM + 4} ${PIVOT_Y - 2.5} L${PIVOT_X + ARM + 4} ${PIVOT_Y + 2.5} L${PIVOT_X} ${PIVOT_Y + 5} L${PIVOT_X - ARM - 4} ${PIVOT_Y + 2.5} Z`}\n          fill={`url(#${gid}-bar)`}\n          strokeLinejoin=\"round\"\n        />\n        <circle\n          cx={PIVOT_X}\n          cy={PIVOT_Y}\n          r={7}\n          fill={`url(#${gid}-knob)`}\n          style={{ stroke: BRASS_LO }}\n          strokeWidth={1}\n        />\n        <circle cx={PIVOT_X} cy={PIVOT_Y} r={2} style={{ fill: BRASS_LO }} />\n        <g ref={leftRef} transform={initial.left}>\n          {pan(\"pros\")}\n        </g>\n        <g ref={rightRef} transform={initial.right}>\n          {pan(\"cons\")}\n        </g>\n      </g>\n    </svg>\n  );\n}\n\nfunction WeightDots({\n  value,\n  onChange,\n  label,\n}: {\n  value: Weight;\n  onChange: (weight: Weight) => void;\n  label: string;\n}) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const choose = (next: Weight) => {\n    onChange(next);\n    refs.current[next - 1]?.focus();\n  };\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={`Weight of ${label}`}\n      className=\"flex shrink-0\"\n    >\n      {([1, 2, 3] as const).map((n) => (\n        <button\n          key={n}\n          ref={(el) => {\n            refs.current[n - 1] = el;\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={value === n}\n          aria-label={[\"Light\", \"Medium\", \"Heavy\"][n - 1]}\n          tabIndex={value === n ? 0 : -1}\n          onClick={() => onChange(n)}\n          onKeyDown={(e) => {\n            const step =\n              e.key === \"ArrowRight\" || e.key === \"ArrowUp\"\n                ? 1\n                : e.key === \"ArrowLeft\" || e.key === \"ArrowDown\"\n                  ? -1\n                  : 0;\n            if (!step) return;\n            e.preventDefault();\n            choose(Math.min(3, Math.max(1, value + step)) as Weight);\n          }}\n          className=\"grid size-7 touch-manipulation place-items-center rounded-full outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n        >\n          {/* Bigger dot, heavier weight: the row reads like the pan. */}\n          <span\n            className={cn(\n              \"rounded-full transition-[background-color] duration-150 ease-out\",\n              [\"size-1.5\", \"size-2\", \"size-2.5\"][n - 1],\n              n <= value ? \"bg-foreground\" : \"bg-foreground/15\",\n            )}\n          />\n        </button>\n      ))}\n    </div>\n  );\n}\n\nfunction Column({\n  side,\n  items,\n  onChange,\n  onAdd,\n}: {\n  side: Side;\n  items: Reason[];\n  onChange: (items: Reason[]) => void;\n  onAdd: (text: string) => void;\n}) {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const [draft, setDraft] = useState(\"\");\n  const title = side === \"pros\" ? \"Pros\" : \"Cons\";\n  const noun = side === \"pros\" ? \"pro\" : \"con\";\n  const full = items.length >= MAX_ITEMS;\n\n  return (\n    <section aria-label={title} className=\"flex min-w-0 flex-col\">\n      <h3 className=\"mb-1.5 flex items-baseline justify-between px-1 text-sm font-medium text-foreground\">\n        {title}\n        <span className=\"text-muted-foreground tabular-nums\">{total(items)}</span>\n      </h3>\n      <ul\n        ref={listRef}\n        // Stacked on a phone, a shorter list keeps both sides near the\n        // scale; the half row showing says there is more to scroll.\n        className=\"relative h-[140px] overflow-y-auto overscroll-contain sm:h-[168px]\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {items.map((item) => (\n            <motion.li\n              key={item.id}\n              layout=\"position\"\n              initial={{ opacity: 0, y: 4, filter: \"blur(4px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{\n                opacity: 0,\n                transition: { duration: 0.12, ease: \"easeOut\" },\n              }}\n              transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n              className=\"flex h-10 items-center gap-0.5 rounded-lg pl-1\"\n            >\n              <span className=\"min-w-0 flex-1 truncate text-sm text-foreground\">\n                {item.text}\n              </span>\n              <WeightDots\n                label={item.text}\n                value={item.weight}\n                onChange={(weight) =>\n                  onChange(\n                    items.map((r) => (r.id === item.id ? { ...r, weight } : r)),\n                  )\n                }\n              />\n              <button\n                type=\"button\"\n                aria-label={`Remove ${item.text}`}\n                onClick={() => {\n                  onChange(items.filter((r) => r.id !== item.id));\n                  inputRef.current?.focus();\n                }}\n                className=\"grid size-7 shrink-0 touch-manipulation place-items-center rounded-full text-muted-foreground outline-hidden transition-[scale,color] duration-150 ease-out hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  className=\"size-3.5\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  strokeLinecap=\"round\"\n                  aria-hidden\n                >\n                  <path d=\"m4.5 4.5 7 7m0-7-7 7\" />\n                </svg>\n              </button>\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n      <input\n        ref={inputRef}\n        value={draft}\n        maxLength={48}\n        disabled={full}\n        aria-label={`Add a ${noun}`}\n        placeholder={full ? \"This pan is full\" : `Add a ${noun}`}\n        onChange={(e) => setDraft(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.key !== \"Enter\") return;\n          e.preventDefault();\n          const text = draft.trim();\n          if (!text) return;\n          onAdd(text);\n          setDraft(\"\");\n          // Show the new row; the list scrolls, never the page.\n          requestAnimationFrame(() =>\n            listRef.current?.scrollTo({ top: listRef.current.scrollHeight }),\n          );\n        }}\n        className=\"mt-2 h-10 w-full min-w-0 rounded-lg bg-background px-3 text-sm text-foreground shadow-raised outline-hidden transition-[box-shadow,opacity] duration-150 ease-out placeholder:text-muted-foreground focus:shadow-[0_0_0_1.5px_var(--foreground)] disabled:opacity-60\"\n      />\n    </section>\n  );\n}\n\nfunction verdictFor(pros: number, cons: number, empty: boolean) {\n  if (empty) return \"Add a pro or a con to start weighing\";\n  const diff = pros - cons;\n  if (diff === 0) return \"Perfectly balanced. Sleep on it.\";\n  const n = Math.abs(diff);\n  return diff > 0\n    ? `Leaning yes: pros outweigh cons by ${n}`\n    : `Leaning no: cons outweigh pros by ${n}`;\n}\n\nexport function BalanceScale({\n  question,\n  value,\n  onChange,\n  className,\n}: {\n  question: string;\n  value: Ledger;\n  onChange: (value: Ledger) => void;\n  className?: string;\n}) {\n  const baseId = useId();\n  const nextId = useRef(0);\n  const pros = total(value.pros);\n  const cons = total(value.cons);\n  const verdict = verdictFor(\n    pros,\n    cons,\n    !value.pros.length && !value.cons.length,\n  );\n\n  const setSide = (side: Side, items: Reason[]) =>\n    onChange({ ...value, [side]: items });\n\n  return (\n    <div className={cn(\"w-[min(520px,100%)]\", className)}>\n      <h2 className=\"text-center text-[17px] font-semibold tracking-tight text-foreground\">\n        {question}\n      </h2>\n      <div className=\"mx-auto mt-1 w-[min(440px,100%)]\">\n        <Scale ledger={value} />\n      </div>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {verdict}\n      </span>\n      <p\n        aria-hidden\n        className=\"relative mb-5 grid h-6 place-items-center text-center text-sm text-muted-foreground\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={verdict}\n            initial={{ opacity: 0, y: 4, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{\n              opacity: 0,\n              transition: { duration: 0.12, ease: \"easeOut\" },\n            }}\n            transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n            className=\"col-start-1 row-start-1\"\n          >\n            {verdict}\n          </motion.span>\n        </AnimatePresence>\n      </p>\n      {/* Side by side from 640px; stacked on a phone, where two columns\n          would cut every reason down to a few letters. */}\n      <div className=\"grid gap-5 sm:grid-cols-2 sm:gap-4\">\n        {([\"pros\", \"cons\"] as const).map((side) => (\n          <Column\n            key={side}\n            side={side}\n            items={value[side]}\n            onChange={(items) => setSide(side, items)}\n            onAdd={(text) =>\n              setSide(side, [\n                ...value[side],\n                { id: `${baseId}-${nextId.current++}`, text, weight: 2 },\n              ])\n            }\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default function BalanceScaleDemo() {\n  const [ledger, setLedger] = useState<Ledger>({\n    pros: [\n      { id: \"p1\", text: \"Higher salary\", weight: 3 },\n      { id: \"p2\", text: \"Sharper team\", weight: 2 },\n      { id: \"p3\", text: \"New stack to learn\", weight: 1 },\n    ],\n    cons: [\n      { id: \"c1\", text: \"Longer commute\", weight: 2 },\n      { id: \"c2\", text: \"Less vacation\", weight: 2 },\n    ],\n  });\n  return (\n    <BalanceScale\n      question=\"Take the new offer?\"\n      value={ledger}\n      onChange={setLedger}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/balance-scale"}