{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pricing-scale",
  "title": "Pricing Scale",
  "description": "Pricing section rendered as a literal balance scale: two tier cards hang as pans from a canvas beam, billing toggle and feature checkmarks add weight, and beam-torque spring physics settles with one inertial overshoot to show which tier objectively outweighs the other.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/pricing-scale/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CounterpoiseTiers — two pricing tiers rendered as the pans of a literal\n// balance scale. A Canvas 2D layer draws the fulcrum, beam, and chains as 2px\n// strokes; the pans are real DOM pricing cards hung from the beam ends and\n// positioned every frame via offset transforms from the container center.\n// Billing toggle and feature checkboxes add weight (weight = price/10 +\n// enabled feature count); the beam angle integrates a torque spring\n// (θ'' = -42(θ - θ_target) - 5.5θ') for exactly one visible overshoot, with a\n// hard 2s forced-settle deadline so stacked rapid toggles always resolve.\n// Idle default look is a ±0.4° ambient sway (~6s period, jittered per mount\n// plus a slow secondary harmonic so it never reads as a metronome), suppressed\n// for 4s after any interaction. A mono verdict caption under the fulcrum updates\n// on settle, never mid-swing. Direct-DOM rAF hot path, refs only.\n// ---------------------------------------------------------------------------\n\nexport type CounterpoiseFeature = { label: string; included: boolean };\n\nexport type CounterpoiseTier = {\n  name: string;\n  tagline: string;\n  /** USD per month at monthly billing */\n  monthlyPrice: number;\n  cta: string;\n  /** primary tier gets the --ns-accent CTA; accent appears nowhere else */\n  primary?: boolean;\n  features: CounterpoiseFeature[];\n};\n\nconst STARTER: CounterpoiseTier = {\n  name: \"Starter\",\n  tagline: \"For weekend projects\",\n  monthlyPrice: 19,\n  cta: \"Start free\",\n  features: [\n    { label: \"3 projects\", included: true },\n    { label: \"Community support\", included: true },\n    { label: \"1 GB storage\", included: true },\n    { label: \"Custom domain\", included: false },\n    { label: \"API access\", included: false },\n    { label: \"Usage analytics\", included: false },\n  ],\n};\n\nconst PRO: CounterpoiseTier = {\n  name: \"Pro\",\n  tagline: \"For teams with deadlines\",\n  monthlyPrice: 49,\n  cta: \"Go Pro\",\n  primary: true,\n  features: [\n    { label: \"Unlimited projects\", included: true },\n    { label: \"Priority support\", included: true },\n    { label: \"100 GB storage\", included: true },\n    { label: \"Custom domains\", included: true },\n    { label: \"API access\", included: true },\n    { label: \"SSO + audit log\", included: false },\n  ],\n};\n\n// -- physics + geometry constants -------------------------------------------\nconst STIFF = 42; // s^-2 — spring stiffness\nconst DAMP = 5.5; // s^-1 — one visible overshoot, settle < 1.4s\nconst DEADLINE_MS = 2000; // forced-settle snap\nconst MAX_TILT = (9 * Math.PI) / 180; // tilt clamp ±9°\nconst TILT_PER_UNIT = 0.028; // rad per weight unit of imbalance\nconst AMB_AMP = (0.4 * Math.PI) / 180; // idle sway ±0.4°\nconst AMB_PERIOD = 6000; // base sway period (jittered per mount, see engine)\nconst SUPPRESS_MS = 4000; // sway suppressed after any interaction\nconst OMEGA_EPS = 0.001; // rad/s sleep threshold\nconst THETA_EPS = 0.0008; // rad — \"at target\" threshold\nconst PIVOT_Y = 46; // beam pivot y inside the stage\nconst CHAIN = 46; // chain length from beam end to card top\nconst LAG_GAIN = 0.3; // pan swing lag vs angular velocity (level at rest)\nconst LAG_MAX = 0.09; // rad — pan lag clamp during hard swings\n\ntype Model = { wL: number; wR: number; verdict: string };\ntype Engine = { retarget: () => void; setBias: (bias: number) => void };\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\nexport function CounterpoiseTiers({\n  left = STARTER,\n  right = PRO,\n  annualMultiplier = 0.8,\n  className = \"\",\n}: {\n  /** tier hung from the left pan */\n  left?: CounterpoiseTier;\n  /** tier hung from the right pan */\n  right?: CounterpoiseTier;\n  /** annual billing price multiplier (0.8 = 20% off) */\n  annualMultiplier?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [billing, setBilling] = useState<\"monthly\" | \"annual\">(\"monthly\");\n  const [checkedL, setCheckedL] = useState<boolean[]>(() =>\n    left.features.map((f) => f.included)\n  );\n  const [checkedR, setCheckedR] = useState<boolean[]>(() =>\n    right.features.map((f) => f.included)\n  );\n\n  const stageRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const wrapLRef = useRef<HTMLDivElement>(null);\n  const wrapRRef = useRef<HTMLDivElement>(null);\n  const cardLRef = useRef<HTMLDivElement>(null);\n  const cardRRef = useRef<HTMLDivElement>(null);\n  const captionRef = useRef<HTMLParagraphElement>(null);\n  const engineRef = useRef<Engine | null>(null);\n  const modelRef = useRef<Model>({ wL: 0, wR: 0, verdict: \"\" });\n\n  const priceOf = (t: CounterpoiseTier) =>\n    billing === \"annual\"\n      ? Math.round(t.monthlyPrice * annualMultiplier)\n      : t.monthlyPrice;\n  const pL = priceOf(left);\n  const pR = priceOf(right);\n\n  // weight = monthlyPrice/10 + enabled feature count — the beam's whole truth\n  const model = useMemo<Model>(() => {\n    const cL = checkedL.filter(Boolean).length;\n    const cR = checkedR.filter(Boolean).length;\n    const wL = pL / 10 + cL;\n    const wR = pR / 10 + cR;\n    const d = wL - wR;\n    let verdict: string;\n    if (Math.abs(d) < 0.05) {\n      verdict = `${left.name} and ${right.name} are perfectly balanced`;\n    } else {\n      const heavy = d > 0 ? left : right;\n      const light = d > 0 ? right : left;\n      const featDiff = d > 0 ? cL - cR : cR - cL;\n      const priceDiff = d > 0 ? pL - pR : pR - pL;\n      verdict =\n        featDiff > 0\n          ? `${heavy.name} outweighs ${light.name} by ${featDiff} feature${featDiff === 1 ? \"\" : \"s\"}`\n          : `${heavy.name} outweighs ${light.name} by $${priceDiff}/mo of heft`;\n    }\n    return { wL, wR, verdict };\n  }, [checkedL, checkedR, pL, pR, left, right]);\n  modelRef.current = model;\n\n  // any weight change re-targets the beam through the same spring\n  useEffect(() => {\n    engineRef.current?.retarget();\n  }, [model]);\n\n  // -- engine: canvas + spring + ambient sway, all outside React -------------\n  useEffect(() => {\n    const stage = stageRef.current;\n    const canvas = canvasRef.current;\n    const wrapL = wrapLRef.current;\n    const wrapR = wrapRRef.current;\n    const cardL = cardLRef.current;\n    const cardR = cardRRef.current;\n    const caption = captionRef.current;\n    if (!stage || !canvas || !wrapL || !wrapR || !cardL || !cardR || !caption)\n      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    // -- inks parsed from tokens at mount, re-derived live on theme flip -----\n    let inkFg = \"#ededed\";\n    let inkBorder = \"#2e2e2e\";\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      const pick = (name: string, fb: string) => {\n        const v = cs.getPropertyValue(name).trim();\n        return v || fb;\n      };\n      inkFg = pick(\"--foreground\", \"#ededed\");\n      inkBorder = pick(\"--border\", \"#2e2e2e\");\n    };\n    derive();\n\n    // -- geometry (recomputed on resize, guarded against zero size) ----------\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let cx = 0;\n    let armL = 0; // beam half-span\n    let cardW = 0;\n    let groundY = 0;\n    let sized = false;\n\n    const resize = () => {\n      const rect = stage.getBoundingClientRect();\n      if (rect.width < 40) {\n        sized = false; // zero-size guard — loop no-ops until real\n        return;\n      }\n      const nw = rect.width;\n      const nCardW = Math.round(clamp((nw - 90) / 2, 150, 288));\n      // conditional writes only — keeps the ResizeObserver from looping\n      if (wrapL.style.width !== `${nCardW}px`) {\n        wrapL.style.width = `${nCardW}px`;\n        wrapR.style.width = `${nCardW}px`;\n      }\n      const cardH = Math.max(cardL.offsetHeight, cardR.offsetHeight);\n      const nArm = Math.min((nw - nCardW) / 2 - 6, nCardW / 2 + 40);\n      const maxDrop = nArm * Math.sin(MAX_TILT);\n      const nGroundY = Math.round(PIVOT_Y + maxDrop + CHAIN + cardH + 14);\n      const nh = nGroundY + 40;\n      w = nw;\n      h = nh;\n      cardW = nCardW;\n      armL = nArm;\n      groundY = nGroundY;\n      cx = w / 2;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      const heightPx = `${h}px`;\n      if (stage.style.height !== heightPx) stage.style.height = heightPx;\n      caption.style.top = `${groundY + 12}px`;\n      // canvas is a replaced element: explicit style w/h + dpr backing store\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      canvas.style.width = `${w}px`;\n      canvas.style.height = `${h}px`;\n      wrapL.style.transformOrigin = `50% ${-CHAIN}px`;\n      wrapR.style.transformOrigin = `50% ${-CHAIN}px`;\n      sized = true;\n    };\n    resize();\n\n    // -- full clear + restroke every tick: no accumulation compositing -------\n    const render = (thVis: number) => {\n      if (!sized) return;\n      const cos = Math.cos(thVis);\n      const sin = Math.sin(thVis);\n      // pans counter-rotate against the beam to hang level; angular velocity\n      // adds a clamped pendulum lag so the swing reads as inertia\n      const rotDeg =\n        (-clamp(om * LAG_GAIN, -LAG_MAX, LAG_MAX) * 180) / Math.PI;\n\n      // pan cards: OFFSET transforms from the container center, never\n      // absolute canvas coordinates\n      const ends: [number, number][] = [];\n      const tops: [number, number][] = [];\n      for (const s of [-1, 1] as const) {\n        const ex = cx + s * armL * cos;\n        const ey = PIVOT_Y + s * armL * sin;\n        const x = ex - cardW / 2;\n        const y = ey + CHAIN;\n        ends.push([ex, ey]);\n        tops.push([x, y]);\n        const wrap = s === -1 ? wrapL : wrapR;\n        wrap.style.transform = `translate3d(${x}px, ${y}px, 0) rotate(${rotDeg}deg)`;\n        if (wrap.style.opacity !== \"1\") wrap.style.opacity = \"1\";\n      }\n\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineWidth = 2;\n      ctx.lineCap = \"round\";\n\n      // fulcrum column + base + hatch — --border ink\n      ctx.strokeStyle = inkBorder;\n      ctx.beginPath();\n      ctx.moveTo(cx - 5, PIVOT_Y + 8);\n      ctx.lineTo(cx - 15, groundY);\n      ctx.moveTo(cx + 5, PIVOT_Y + 8);\n      ctx.lineTo(cx + 15, groundY);\n      ctx.moveTo(cx - 44, groundY);\n      ctx.lineTo(cx + 44, groundY);\n      for (let i = 0; i < 7; i++) {\n        const hx = cx - 38 + i * 12;\n        ctx.moveTo(hx + 8, groundY + 1);\n        ctx.lineTo(hx, groundY + 9);\n      }\n      // chains: beam end down to the pan card's top corners\n      for (const s of [0, 1] as const) {\n        const [ex, ey] = ends[s]!;\n        const [tx, ty] = tops[s]!;\n        ctx.moveTo(ex, ey + 3);\n        ctx.lineTo(tx + 14, ty + 2);\n        ctx.moveTo(ex, ey + 3);\n        ctx.lineTo(tx + cardW - 14, ty + 2);\n      }\n      ctx.stroke();\n\n      // beam + pivot + end hooks — --foreground ink\n      ctx.strokeStyle = inkFg;\n      ctx.beginPath();\n      ctx.moveTo(ends[0]![0], ends[0]![1]);\n      ctx.lineTo(ends[1]![0], ends[1]![1]);\n      ctx.stroke();\n      ctx.beginPath();\n      ctx.arc(cx, PIVOT_Y, 4.5, 0, Math.PI * 2);\n      ctx.stroke();\n      for (const [ex, ey] of ends) {\n        ctx.beginPath();\n        ctx.arc(ex, ey + 1, 2.5, 0, Math.PI * 2);\n        ctx.stroke();\n      }\n    };\n\n    // -- spring state: locals only, never React state ------------------------\n    let th = 0;\n    let om = 0;\n    let bias = 0; // hover preview tilt bias, ±0.3°\n    let target = 0;\n    let raf = 0;\n    let last = 0;\n    let onscreen = true;\n    let pendingSettle = true;\n    let interactionAt = performance.now();\n    let deadlineAt = interactionAt + DEADLINE_MS;\n    let ramp = 0; // ambient sway ramp 0..1\n    let wakeTimer = 0;\n    // ambient sway jitter — randomized once per mount so the idle sway never\n    // reads as a metronomic, perfectly-repeating cycle\n    const ambPeriod = AMB_PERIOD * (0.9 + Math.random() * 0.2); // 5400-6600ms\n    const ambPhase = Math.random() * Math.PI * 2;\n    const amb2Period = ambPeriod * 2.37; // slow secondary harmonic, non-integer ratio\n    const amb2Phase = Math.random() * Math.PI * 2;\n\n    const computeTarget = () => {\n      const m = modelRef.current;\n      return clamp(\n        (m.wR - m.wL) * TILT_PER_UNIT + bias,\n        -MAX_TILT,\n        MAX_TILT\n      );\n    };\n\n    const setCaption = () => {\n      caption.textContent = modelRef.current.verdict;\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (!onscreen || document.hidden || !sized) {\n        last = 0;\n        return; // paused offscreen/hidden — observers wake us\n      }\n      const dt = last === 0 ? 1 / 60 : Math.min(0.033, (now - last) / 1000);\n      last = now;\n      const suppressed = now - interactionAt < SUPPRESS_MS;\n\n      // beam torque spring, semi-implicit Euler\n      om += (-STIFF * (th - target) - DAMP * om) * dt;\n      th += om * dt;\n      // forced-settle deadline: stacked rapid toggles always resolve\n      if (pendingSettle && now >= deadlineAt) {\n        th = target;\n        om = 0;\n      }\n      const settled =\n        Math.abs(om) < OMEGA_EPS && Math.abs(th - target) < THETA_EPS;\n      if (pendingSettle && settled) {\n        pendingSettle = false;\n        setCaption(); // verdict lands on settle, never mid-swing\n      }\n\n      // ambient sway: the default look, suppressed 4s after any interaction\n      ramp = suppressed\n        ? Math.max(0, ramp - dt / 0.4)\n        : Math.min(1, ramp + dt / 1.5);\n      const sway =\n        (Math.sin((now / ambPeriod) * Math.PI * 2 + ambPhase) +\n          0.35 * Math.sin((now / amb2Period) * Math.PI * 2 + amb2Phase)) /\n        1.35;\n      const thVis = th + ramp * AMB_AMP * sway;\n      render(thVis);\n\n      // sleep: settled, no pending target, and sway suppressed — schedule a\n      // timed wake for when the suppression window ends\n      if (!pendingSettle && settled && suppressed && ramp === 0) {\n        const remain = SUPPRESS_MS - (now - interactionAt);\n        window.clearTimeout(wakeTimer);\n        wakeTimer = window.setTimeout(wake, Math.max(16, remain + 30));\n        last = 0;\n        return; // raf stays 0 — genuinely asleep\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf && !reduced) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const retarget = () => {\n      target = computeTarget();\n      interactionAt = performance.now();\n      deadlineAt = interactionAt + DEADLINE_MS;\n      pendingSettle = true;\n      if (reduced) {\n        // discrete: beam rendered instantly at the settled angle, no sway\n        th = target;\n        om = 0;\n        pendingSettle = false;\n        render(th);\n        setCaption();\n        return;\n      }\n      wake();\n    };\n\n    engineRef.current = {\n      retarget,\n      setBias: (b: number) => {\n        bias = b;\n        retarget();\n      },\n    };\n\n    // initial state\n    target = computeTarget();\n    if (reduced) {\n      th = target;\n      pendingSettle = false;\n      render(th);\n      setCaption();\n    } else {\n      render(0); // hung level, then the spring swings it to the verdict\n      wake();\n    }\n\n    // -- observers -----------------------------------------------------------\n    const ro = new ResizeObserver(() => {\n      resize();\n      render(th);\n    });\n    ro.observe(stage);\n    ro.observe(cardL);\n    ro.observe(cardR);\n    const io = new IntersectionObserver((entries) => {\n      onscreen = entries[0]?.isIntersecting ?? true;\n      if (onscreen) wake();\n    });\n    io.observe(stage);\n    const mo = new MutationObserver(() => {\n      derive(); // live theme re-derive\n      render(th);\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      raf = 0;\n      window.clearTimeout(wakeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      engineRef.current = null;\n    };\n    // geometry + physics are prop-independent; weights flow through modelRef\n  }, []);\n\n  const discountLabel = `−${Math.round((1 - annualMultiplier) * 100)}%`;\n\n  const segClass = (active: boolean) =>\n    active\n      ? \"rounded-[4px] border border-border bg-background px-3 py-1.5 font-mono text-[11px] tracking-widest text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n      : \"rounded-[4px] border border-transparent px-3 py-1.5 font-mono text-[11px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\";\n\n  const renderCard = (side: \"left\" | \"right\") => {\n    const tier = side === \"left\" ? left : right;\n    const checked = side === \"left\" ? checkedL : checkedR;\n    const setChecked = side === \"left\" ? setCheckedL : setCheckedR;\n    const price = side === \"left\" ? pL : pR;\n    const weight = side === \"left\" ? model.wL : model.wR;\n    const wrapRef = side === \"left\" ? wrapLRef : wrapRRef;\n    const cardRef = side === \"left\" ? cardLRef : cardRRef;\n    // hover previews a +0.3° tilt bias toward this pan\n    const hoverBias = (side === \"left\" ? -1 : 1) * (0.3 * Math.PI) / 180;\n\n    return (\n      <div\n        ref={wrapRef}\n        className=\"absolute left-0 top-0 will-change-transform\"\n        style={{ opacity: 0 }}\n      >\n        <div\n          ref={cardRef}\n          onPointerEnter={() => engineRef.current?.setBias(hoverBias)}\n          onPointerLeave={() => engineRef.current?.setBias(0)}\n          className=\"flex flex-col gap-4 rounded-md border border-border bg-surface p-5 transition-[translate,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-lg hover:shadow-black/25\"\n        >\n          <header className=\"flex items-start justify-between gap-2\">\n            <div>\n              <h3 className=\"font-mono text-xs tracking-widest text-foreground uppercase\">\n                {tier.name}\n              </h3>\n              <p className=\"mt-1 text-xs text-ns-muted\">{tier.tagline}</p>\n            </div>\n            <span className=\"rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-wider text-ns-muted\">\n              wt {weight.toFixed(1)}\n            </span>\n          </header>\n          <div>\n            <div className=\"flex items-baseline gap-1\">\n              <span className=\"text-3xl font-semibold tracking-tight tabular-nums text-foreground\">\n                ${price}\n              </span>\n              <span className=\"text-sm text-ns-muted\">/mo</span>\n            </div>\n            <p className=\"mt-1 h-3.5 font-mono text-[10px] tracking-widest text-ns-muted uppercase\">\n              {billing === \"annual\" ? \"billed annually\" : \"billed monthly\"}\n            </p>\n          </div>\n          <ul className=\"flex flex-col gap-2.5\">\n            {tier.features.map((f, i) => (\n              <li key={f.label}>\n                <label className=\"group flex cursor-pointer items-center gap-2.5\">\n                  <span className=\"relative inline-flex h-4 w-4 shrink-0\">\n                    <input\n                      type=\"checkbox\"\n                      checked={checked[i] ?? false}\n                      onChange={() =>\n                        setChecked((prev) => {\n                          const next = [...prev];\n                          next[i] = !next[i];\n                          return next;\n                        })\n                      }\n                      className=\"peer h-4 w-4 cursor-pointer appearance-none rounded-[4px] border border-border bg-transparent transition-colors duration-150 checked:border-foreground checked:bg-foreground hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n                    />\n                    <svg\n                      aria-hidden\n                      viewBox=\"0 0 12 12\"\n                      fill=\"none\"\n                      className=\"pointer-events-none absolute inset-0 m-auto h-3 w-3 text-background opacity-0 transition-opacity duration-150 peer-checked:opacity-100\"\n                    >\n                      <path\n                        d=\"M2.5 6.5 5 9l4.5-5.5\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.8\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                      />\n                    </svg>\n                  </span>\n                  <span\n                    className={`text-[13px] transition-colors duration-150 ${\n                      checked[i] ? \"text-foreground\" : \"text-ns-muted\"\n                    }`}\n                  >\n                    {f.label}\n                  </span>\n                </label>\n              </li>\n            ))}\n          </ul>\n          {tier.primary ? (\n            <button\n              type=\"button\"\n              className=\"mt-1 rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n            >\n              {tier.cta}\n            </button>\n          ) : (\n            <button\n              type=\"button\"\n              className=\"mt-1 rounded-sm border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors duration-200 hover:border-foreground/25 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n            >\n              {tier.cta}\n            </button>\n          )}\n        </div>\n      </div>\n    );\n  };\n\n  return (\n    <div className={`relative w-full ${className}`}>\n      <div className=\"mb-8 flex justify-center\">\n        <div\n          role=\"group\"\n          aria-label=\"Billing period\"\n          className=\"inline-flex items-center gap-0.5 rounded-sm border border-border bg-surface p-0.5\"\n        >\n          <button\n            type=\"button\"\n            aria-pressed={billing === \"monthly\"}\n            onClick={() => setBilling(\"monthly\")}\n            className={segClass(billing === \"monthly\")}\n          >\n            MONTHLY\n          </button>\n          <button\n            type=\"button\"\n            aria-pressed={billing === \"annual\"}\n            onClick={() => setBilling(\"annual\")}\n            className={segClass(billing === \"annual\")}\n          >\n            ANNUAL {discountLabel}\n          </button>\n        </div>\n      </div>\n      <div ref={stageRef} className=\"relative\" style={{ height: 560 }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"pointer-events-none absolute left-0 top-0\"\n        />\n        {renderCard(\"left\")}\n        {renderCard(\"right\")}\n        <p\n          ref={captionRef}\n          aria-live=\"polite\"\n          className=\"absolute left-1/2 max-w-full -translate-x-1/2 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[11px] tracking-widest text-ns-muted uppercase\"\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/pricing-scale.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-ns-accent-hover": "var(--ns-accent-hover)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "ns-accent-hover": "#0059d1",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "pricing",
      "canvas",
      "physics",
      "spring",
      "balance-scale",
      "section",
      "interactive",
      "ambient"
    ],
    "instruction": "Two pricing tiers rendered as literal balance-scale pans hanging from a canvas beam; billing toggle and feature checkmarks add weight and the beam settles with inertial overshoot to show which tier objectively outweighs the other, with idle ambient sway. RENDERING: a Canvas 2D layer draws fulcrum, beam, and chains as 2px strokes (--foreground beam/pivot/hooks, --border chains and fulcrum hatch), fully cleared and restroked every tick — no accumulation compositing; the two pans are real DOM pricing cards (bg-surface, rounded-md, 1px border) hung from the beam ends and positioned every frame via OFFSET transforms from the container center (translate from beam-end position + chain length, plus a small angular-velocity-based lag rotation (-clamp(ω·0.3, ±0.09 rad) about the chain attachment, zero at rest) so pans read as swinging with inertia rather than rigidly tracking θ), never absolute canvas coordinates. Canvas sized with explicit style.width/height plus a devicePixelRatio-clamped backing store. PHYSICS: beam angle integrated per frame with semi-implicit Euler: th'' = -42(th - target) - 5.5 th' (stiffness 42 s^-2, damping 5.5 s^-1 → exactly one visible overshoot, settle < 1.4s), hard forced-settle deadline snap at 2.0s so stacked rapid toggles always resolve, tilt clamped ±9°. target = clamp(0.028·(weightR - weightL)); weight = monthlyPrice/10 + enabledFeatureCount. IDLE AMBIENT (default look): ±0.4° sway, ~6s period jittered ±10% per mount plus a slow low-amplitude secondary harmonic (so it never repeats identically), ramped in and suppressed for 4s after any interaction. INTERACTION: monthly/annual segmented toggle (rounded-sm) rescales both prices and re-targets the beam; six real feature checkboxes per tier add/remove weight through the same spring; hovering a tier card lifts it 2px (token shadow, border step) and previews a +0.3° tilt bias toward it; primary CTA in --ns-accent, secondary ghost — accent appears nowhere else except focus rings. A font-mono caption under the fulcrum states the verdict ('Pro outweighs Starter by 2 features') and updates on settle, never mid-swing. REDUCED MOTION: beam and cards rendered instantly at the settled angle, no sway, discrete updates. PERF: direct-DOM rAF hot path (refs only, no React state per frame); loop sleeps when |omega| < 0.001 rad/s, no spring target pending, and sway is suppressed, with a timed wake when suppression ends; IntersectionObserver pauses offscreen and document.hidden pauses; zero-size container guard; canvas inks parsed from getComputedStyle tokens at mount with a MutationObserver on documentElement class re-deriving live; all listeners/observers/rAF/timers torn down on unmount. Demo is a full two-column pricing section: headline, muted subcopy, the scale as visual spine, Starter $19/mo vs Pro $49/mo with believable feature lists and CTAs, muted mono footnote."
  },
  "type": "registry:ui"
}