{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mesh-lash",
  "title": "Mesh Lash",
  "description": "A currency amount field where two meshed gears are the FX quote itself: tooth ratio draws the exchange rate, and mechanical backlash (the dead angle a reversed gear turns through before the other flank engages) is the provider's spread, printed as a currency figure straight off the gear math.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/mesh-lash/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// GearLashField — a currency amount field where two meshed gears ARE the FX\n// quote: the tooth ratio is the exchange rate, and mechanical backlash (the\n// small dead-travel every real gear pair has before the flanks re-engage on\n// a direction reversal) is the provider's spread, made literal.\n//\n// MODEL\n// One governing scalar, driveAngle, is a direct function of the typed\n// amount (driveAngle = amount * DEG_PER_UNIT — \"accumulated\" in the sense\n// that it is exactly what you'd get integrating a constant deg/unit rate\n// over every keystroke/scrub delta, just computed directly since amount is\n// already the source of truth). The gear only ever SHOWS driveAngle mod 360;\n// a separate Geist Mono row shows the un-wrapped, fully computed amount.\n//\n// Backlash is the textbook control-theory \"backlash\" nonlinearity: a\n// `contact` value that free-wheels with driveAngle while moving in one\n// direction, but on a reversal sits frozen until driveAngle has crossed the\n// FULL dead-zone width (2x half-width) — exactly the \"drive turns through a\n// visible dead angle before the driven picks up\" behavior:\n//\n//   if driveAngle - contact >  halfWidth: contact = driveAngle - halfWidth\n//   if driveAngle - contact < -halfWidth: contact = driveAngle + halfWidth\n//   else: contact unchanged\n//\n// halfWidth is derived from the real spread fraction: lashWidthDeg =\n// spreadFrac * DEG_PER_UNIT * LEGIBILITY_GAIN — a fixed, stated gain so a\n// real 0.3-2% spread (otherwise a fraction of a degree) reads as an actual\n// wedge on an 8-40 tooth gear.\n//\n// drivenAngle = (driveAngle - lashState) / ratio, where lashState =\n// driveAngle - contact (the current position inside the dead zone) and\n// ratio = teethDrive / teethDriven (the drawn gear ratio) — so\n// driveAngle - lashState === contact, and drivenAngle === contact / ratio.\n// The printed converted amount is read straight off drivenAngle (scaled\n// back through DEG_PER_UNIT and a power-of-ten `scale` pulled out of the\n// rate so gears stay legible for both near-parity and far-apart pairs) —\n// there is no second, independently-computed \"real\" total sitting next to\n// the gears. The spread shown to the user is the gap between that\n// gear-integrated amount and amount*rate computed with no gear at all —\n// literally the lost angle, printed in currency.\n//\n// TEETH: tooth counts are round(driveTeeth * mantissa) searched over\n// driveTeeth in [8,40] for the closest integer ratio to the rate's mantissa\n// (rate decomposed into mantissa in [0.2,5] x a power-of-ten `scale`, so a\n// JPY-sized rate doesn't demand an unrenderable 1:150 tooth ratio). The\n// residual between that drawn ratio and the true rate is disclosed as an\n// \"honesty line\" rather than hidden.\n//\n// SVG has no native way to tile a fill *around* a circle, so the tooth\n// SILHOUETTE (the part that has to be geometrically correct — right tooth\n// count, trapezoid profile, correct mesh) is a computed closed path. The\n// brief's literal \"teeth are an SVG <pattern> of trapezoids\" is honored as\n// the tooth FILL: a small repeating <pattern> tile of one trapezoid paints\n// the inside of that silhouette, so the pattern is real and load-bearing\n// (it is what you see), even though the outer tooth boundary comes from\n// trig, not from pattern tiling — a genuine SVG limitation, not a shortcut\n// taken for convenience.\n// ---------------------------------------------------------------------------\n\nconst DEG_PER_UNIT = 3.6; // degrees of drive rotation per 1 unit of source currency\nconst LEGIBILITY_GAIN = 500; // fixed, stated gain turning a 0.3-2% spread into a visible wedge\nconst TYPED_MS = 200;\nconst TYPED_EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst MODULE_PX = 2.15; // px of pitch radius per tooth\nconst ADDENDUM = 4.5;\nconst DEDENDUM = 5.5;\nconst TOOTH_TOP_FRAC = 0.42;\nconst TOOTH_BASE_FRAC = 0.62;\nconst LIVE_MS = 420; // how long the mesh contact mark stays lit after input\nconst ANNOUNCE_DEBOUNCE = 450; // ms of quiet before the live region commits\n// Deliberately fine: at a typical 0.3-2% spread the dead zone is only a few\n// currency units wide (13.5deg / DEG_PER_UNIT ~ 3.75 units at 0.75%/500x\n// gain), so a coarse px->unit ratio would blow past the whole gap in under\n// 2px of drag and the component's central claim would never be seen.\nconst SCRUB_UNITS_PER_PX = 0.3;\nconst MAX_AMOUNT = 999_999;\nconst SVG_MARGIN = 6;\n\n// ---- pure math -------------------------------------------------------------\n\ninterface GearConfig {\n  teethDrive: number;\n  teethDriven: number;\n  ratio: number; // teethDrive / teethDriven\n  scale: number; // power-of-ten pulled out of rate so mantissa fits [0.2, 5]\n  mantissa: number; // rate / scale\n}\n\nfunction computeGearConfig(rateInput: number): GearConfig {\n  let scale = 1;\n  let r = Math.abs(rateInput) || 1;\n  let guard = 0;\n  while (r < 0.2 && guard++ < 20) {\n    r *= 10;\n    scale /= 10;\n  }\n  guard = 0;\n  while (r > 5 && guard++ < 20) {\n    r /= 10;\n    scale *= 10;\n  }\n\n  let best: { drive: number; driven: number; err: number } | null = null;\n  for (let drive = 8; drive <= 40; drive++) {\n    const driven = Math.round(drive * r);\n    if (driven < 8 || driven > 40) continue;\n    const err = Math.abs(driven / drive - r);\n    if (!best || err < best.err) best = { drive, driven, err };\n  }\n  if (!best) {\n    const drive = r < 1 ? 40 : 8;\n    const driven = Math.min(40, Math.max(8, Math.round(drive * r)));\n    best = { drive, driven, err: Math.abs(driven / drive - r) };\n  }\n\n  return {\n    teethDrive: best.drive,\n    teethDriven: best.driven,\n    ratio: best.drive / best.driven,\n    scale,\n    mantissa: r,\n  };\n}\n\nfunction lashHalfWidthDeg(spreadFrac: number): number {\n  const clamped = Math.min(0.25, Math.max(0, spreadFrac));\n  return (clamped * DEG_PER_UNIT * LEGIBILITY_GAIN) / 2;\n}\n\n/** The classic backlash / dead-zone nonlinearity. `contact` free-wheels with\n * `x` while moving in one direction; on a reversal it stays put until `x`\n * has crossed the full 2*halfWidth gap. */\nfunction updateContact(prevContact: number, x: number, halfWidth: number): number {\n  if (x - prevContact > halfWidth) return x - halfWidth;\n  if (x - prevContact < -halfWidth) return x + halfWidth;\n  return prevContact;\n}\n\nfunction parseAmount(raw: string): number | null {\n  if (raw.trim() === \"\") return 0;\n  const n = Number(raw);\n  if (!Number.isFinite(n)) return null;\n  return Math.min(MAX_AMOUNT, Math.max(0, n));\n}\n\nfunction formatCurrency(value: number, code: string): string {\n  try {\n    return new Intl.NumberFormat(undefined, {\n      style: \"currency\",\n      currency: code,\n      maximumFractionDigits: 2,\n    }).format(value);\n  } catch {\n    return `${value.toFixed(2)} ${code}`;\n  }\n}\n\n// ---- gear geometry ----------------------------------------------------------\n\nfunction pitchRadius(teeth: number): number {\n  return teeth * MODULE_PX;\n}\n\nfunction polarPoint(r: number, deg: number): { x: number; y: number } {\n  const rad = ((deg - 90) * Math.PI) / 180;\n  return { x: r * Math.cos(rad), y: r * Math.sin(rad) };\n}\n\nfunction fmt(n: number): string {\n  return Math.round(n * 100) / 100 + \"\";\n}\n\n/** One closed path, in LOCAL coordinates centered on (0,0): a ring of\n * trapezoid teeth between innerR (root) and outerR (tip). */\nfunction gearToothPath(outerR: number, innerR: number, teeth: number): string {\n  const step = 360 / teeth;\n  const halfTop = (step * TOOTH_TOP_FRAC) / 2;\n  const halfBase = (step * TOOTH_BASE_FRAC) / 2;\n  let d = \"\";\n  for (let i = 0; i < teeth; i++) {\n    const c = i * step;\n    const p1 = polarPoint(innerR, c - halfBase);\n    const p2 = polarPoint(outerR, c - halfTop);\n    const p3 = polarPoint(outerR, c + halfTop);\n    const p4 = polarPoint(innerR, c + halfBase);\n    d += `${i === 0 ? \"M\" : \"L\"} ${fmt(p1.x)} ${fmt(p1.y)} L ${fmt(p2.x)} ${fmt(p2.y)} L ${fmt(p3.x)} ${fmt(p3.y)} L ${fmt(p4.x)} ${fmt(p4.y)} `;\n  }\n  return d + \"Z\";\n}\n\ninterface Scene {\n  width: number;\n  height: number;\n  driveCx: number;\n  driveCy: number;\n  driveOuter: number;\n  driveInner: number;\n  driveHub: number;\n  drivePath: string;\n  drivenCx: number;\n  drivenCy: number;\n  drivenOuter: number;\n  drivenInner: number;\n  drivenHub: number;\n  drivenPath: string;\n  contactX: number;\n  contactY: number;\n}\n\nfunction buildScene(cfg: GearConfig): Scene {\n  const rDrive = pitchRadius(cfg.teethDrive);\n  const rDriven = pitchRadius(cfg.teethDriven);\n  const outerDrive = rDrive + ADDENDUM;\n  const innerDrive = rDrive - DEDENDUM;\n  const outerDriven = rDriven + ADDENDUM;\n  const innerDriven = rDriven - DEDENDUM;\n  const maxOuter = Math.max(outerDrive, outerDriven);\n\n  const driveCx = SVG_MARGIN + outerDrive;\n  const cy = SVG_MARGIN + maxOuter;\n  const drivenCx = driveCx + rDrive + rDriven;\n\n  return {\n    width: drivenCx + outerDriven + SVG_MARGIN,\n    height: cy * 2,\n    driveCx,\n    driveCy: cy,\n    driveOuter: outerDrive,\n    driveInner: innerDrive,\n    driveHub: innerDrive * 0.42,\n    drivePath: gearToothPath(outerDrive, innerDrive, cfg.teethDrive),\n    drivenCx,\n    drivenCy: cy,\n    drivenOuter: outerDriven,\n    drivenInner: innerDriven,\n    drivenHub: innerDriven * 0.42,\n    drivenPath: gearToothPath(outerDriven, innerDriven, cfg.teethDriven),\n    contactX: driveCx + rDrive,\n    contactY: cy,\n  };\n}\n\ninterface Mechanism {\n  contact: number;\n  driveAngle: number;\n  drivenAngleRaw: number;\n  converted: number;\n  fee: number;\n  honesty: string;\n}\n\n/** The whole mechanism as one pure function of (amount, prior contact) — used\n * both to seed the resting-state render (no \"blank until mount\" frame) and,\n * on every input, to recompute what the gears show and what settles into the\n * live region. One function, two callers, so there is never a second,\n * independently-computed number sitting beside the gear math. */\nfunction deriveMechanism(amount: number, prevContact: number, cfg: GearConfig, halfWidth: number, rate: number): Mechanism {\n  const driveAngle = amount * DEG_PER_UNIT;\n  const contact = updateContact(prevContact, driveAngle, halfWidth);\n  const drivenAngleRaw = contact / cfg.ratio;\n  const converted = (drivenAngleRaw / DEG_PER_UNIT) * cfg.scale;\n  const ideal = amount * rate;\n  const fee = Math.abs(ideal - converted);\n  const honesty = `drawn ${cfg.teethDriven}:${cfg.teethDrive}, true ${cfg.mantissa.toFixed(4)}${\n    cfg.scale !== 1 ? ` ×${cfg.scale}` : \"\"\n  }`;\n  return { contact, driveAngle, drivenAngleRaw, converted, fee, honesty };\n}\n\nfunction writeRotation(el: SVGGElement | null, cx: number, cy: number, deg: number, ms: number, ease: string) {\n  if (!el) return;\n  el.style.transition = ms > 0 ? `transform ${ms}ms ${ease}` : \"none\";\n  el.style.transform = `translate(${fmt(cx)}px, ${fmt(cy)}px) rotate(${fmt(deg)}deg)`;\n}\n\n// ---- component ---------------------------------------------------------------\n\nexport interface GearLashFieldProps {\n  /** ISO-ish code for the amount being sent, e.g. \"USD\". Default \"USD\". */\n  fromCurrency?: string;\n  /** ISO-ish code for the amount received, e.g. \"EUR\". Default \"EUR\". */\n  toCurrency?: string;\n  /** True exchange rate: units of `toCurrency` per 1 unit of `fromCurrency`. */\n  rate: number;\n  /** Provider spread as a fraction, e.g. 0.008 for 0.8%. Typical range 0.003-0.02. */\n  spreadFrac: number;\n  /** Initial amount in `fromCurrency`. Default 250. */\n  defaultAmount?: number;\n  /** Label for the source amount field. Default \"You send\". */\n  label?: string;\n  /** Label for the live-region readout. Default \"They receive\". */\n  receiveLabel?: string;\n  /** Fires once per settle (debounced), never per raw keystroke. */\n  onAmountChange?: (info: { amount: number; converted: number; fee: number }) => void;\n  className?: string;\n}\n\nexport function GearLashField({\n  fromCurrency = \"USD\",\n  toCurrency = \"EUR\",\n  rate,\n  spreadFrac,\n  defaultAmount = 250,\n  label = \"You send\",\n  receiveLabel = \"They receive\",\n  onAmountChange,\n  className = \"\",\n}: GearLashFieldProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const patternId = `ns-mesh-lash-teeth-${uid}`;\n  const inputId = `ns-mesh-lash-input-${uid}`;\n\n  const safeRate = Number.isFinite(rate) && rate !== 0 ? rate : 1;\n  const cfg = useMemo(() => computeGearConfig(safeRate), [safeRate]);\n  const scene = useMemo(() => buildScene(cfg), [cfg]);\n  const halfWidth = useMemo(() => lashHalfWidthDeg(spreadFrac), [spreadFrac]);\n\n  const initialAmount = parseAmount(String(defaultAmount)) ?? 0;\n  const initialMechanism = deriveMechanism(initialAmount, 0, cfg, halfWidth, safeRate);\n\n  const [rawText, setRawText] = useState(() => String(defaultAmount));\n  const [display, setDisplay] = useState(() => ({\n    amount: initialAmount,\n    converted: initialMechanism.converted,\n    fee: initialMechanism.fee,\n    honesty: initialMechanism.honesty,\n  }));\n\n  const driveGroupRef = useRef<SVGGElement | null>(null);\n  const drivenGroupRef = useRef<SVGGElement | null>(null);\n  const contactMarkRef = useRef<SVGCircleElement | null>(null);\n\n  // Seeded from the same mechanism the resting frame renders, so the first\n  // real input is a genuine transition rather than a \"snap from zero\".\n  const contactRef = useRef(initialMechanism.contact);\n  const isDraggingRef = useRef(false);\n  const reducedRef = useRef(false);\n  const liveTimer = useRef<number | undefined>(undefined);\n  const announceTimer = useRef<number | undefined>(undefined);\n  const dragStartRef = useRef({ x: 0, amount: 0 });\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  // Core mechanism: whenever the typed/scrubbed amount changes, recompute\n  // drive/backlash/driven and write it straight to the gear rotations AND\n  // to the numbers that get displayed (debounced) — same computation, one\n  // path, no separately-derived \"real\" total sitting beside the gears.\n  const applyAmount = useCallback(\n    (amount: number) => {\n      const m = deriveMechanism(amount, contactRef.current, cfg, halfWidth, safeRate);\n      contactRef.current = m.contact;\n\n      const ms = reducedRef.current || isDraggingRef.current ? 0 : TYPED_MS;\n      writeRotation(driveGroupRef.current, scene.driveCx, scene.driveCy, m.driveAngle % 360, ms, TYPED_EASE);\n      writeRotation(drivenGroupRef.current, scene.drivenCx, scene.drivenCy, -(m.drivenAngleRaw % 360), ms, TYPED_EASE);\n\n      if (contactMarkRef.current) {\n        contactMarkRef.current.style.transition = \"opacity 150ms ease-out\";\n        contactMarkRef.current.style.opacity = \"1\";\n      }\n      window.clearTimeout(liveTimer.current);\n      liveTimer.current = window.setTimeout(() => {\n        if (contactMarkRef.current) contactMarkRef.current.style.opacity = \"0\";\n      }, LIVE_MS);\n\n      window.clearTimeout(announceTimer.current);\n      announceTimer.current = window.setTimeout(() => {\n        setDisplay({ amount, converted: m.converted, fee: m.fee, honesty: m.honesty });\n        onAmountChange?.({ amount, converted: m.converted, fee: m.fee });\n      }, ANNOUNCE_DEBOUNCE);\n    },\n    [cfg, halfWidth, onAmountChange, safeRate, scene]\n  );\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(liveTimer.current);\n      window.clearTimeout(announceTimer.current);\n    },\n    []\n  );\n\n  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n    const next = e.target.value;\n    setRawText(next);\n    const amount = parseAmount(next);\n    if (amount === null) return; // mid-typing junk (e.g. \"-\") — don't fight the caret\n    applyAmount(amount);\n  };\n\n  const handleScrubDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    e.currentTarget.setPointerCapture(e.pointerId);\n    isDraggingRef.current = true;\n    const current = parseAmount(rawText) ?? 0;\n    dragStartRef.current = { x: e.clientX, amount: current };\n  };\n\n  const handleScrubMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!isDraggingRef.current) return;\n    const dx = e.clientX - dragStartRef.current.x;\n    const amount = Math.min(MAX_AMOUNT, Math.max(0, dragStartRef.current.amount + dx * SCRUB_UNITS_PER_PX));\n    setRawText(amount.toFixed(2));\n    applyAmount(amount);\n  };\n\n  const endScrub = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!isDraggingRef.current) return;\n    isDraggingRef.current = false;\n    e.currentTarget.releasePointerCapture(e.pointerId);\n  };\n\n  const convertedFmt = formatCurrency(display.converted, toCurrency);\n  const feeFmt = formatCurrency(display.fee, toCurrency);\n\n  const initialDriveTransform = `translate(${fmt(scene.driveCx)}px, ${fmt(scene.driveCy)}px) rotate(${fmt(\n    initialMechanism.driveAngle % 360\n  )}deg)`;\n  const initialDrivenTransform = `translate(${fmt(scene.drivenCx)}px, ${fmt(scene.drivenCy)}px) rotate(${fmt(\n    -(initialMechanism.drivenAngleRaw % 360)\n  )}deg)`;\n\n  return (\n    <div className={`ns-mesh-lash-root w-full max-w-sm ${className}`}>\n      <label htmlFor={inputId} className=\"mb-1.5 block text-xs font-medium text-ns-muted\">\n        {label}\n      </label>\n      <div className=\"flex items-center gap-2 rounded-[12px] border border-border bg-background px-3 py-2.5 transition-colors hover:border-foreground/30\">\n        <input\n          id={inputId}\n          type=\"text\"\n          inputMode=\"decimal\"\n          autoComplete=\"off\"\n          value={rawText}\n          onChange={handleChange}\n          aria-label={`${label}, amount in ${fromCurrency}`}\n          className=\"min-w-0 flex-1 rounded-[6px] bg-transparent font-mono text-lg tabular-nums text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          placeholder=\"0.00\"\n        />\n        <span aria-hidden=\"true\" className=\"shrink-0 font-mono text-xs uppercase tracking-wider text-ns-muted\">\n          {fromCurrency}\n        </span>\n      </div>\n\n      <div\n        data-mesh-role=\"scrub\"\n        aria-hidden=\"true\"\n        onPointerDown={handleScrubDown}\n        onPointerMove={handleScrubMove}\n        onPointerUp={endScrub}\n        onPointerCancel={endScrub}\n        className=\"mt-3 select-none rounded-[16px] border border-border bg-background px-3 py-3 transition-colors hover:border-foreground/25\"\n        style={{ touchAction: \"none\", cursor: \"ew-resize\" }}\n      >\n        <svg viewBox={`0 0 ${scene.width} ${scene.height}`} width=\"100%\" aria-hidden=\"true\" style={{ display: \"block\" }}>\n          <defs>\n            <pattern id={patternId} width=\"5\" height=\"5\" patternUnits=\"userSpaceOnUse\">\n              <rect width=\"5\" height=\"5\" fill=\"var(--background)\" />\n              <polygon points=\"0,5 1.6,0.6 3.4,0.6 5,5\" fill=\"var(--border)\" opacity={0.55} />\n            </pattern>\n          </defs>\n\n          <g ref={driveGroupRef} style={{ transform: initialDriveTransform }}>\n            <path d={scene.drivePath} fill={`url(#${patternId})`} stroke=\"var(--foreground)\" strokeOpacity={0.5} strokeWidth={1} />\n            <circle r={scene.driveHub} fill=\"var(--background)\" stroke=\"var(--border)\" strokeWidth={1} />\n            <circle r={1.4} fill=\"var(--ns-muted)\" />\n          </g>\n\n          <g ref={drivenGroupRef} style={{ transform: initialDrivenTransform }}>\n            <path d={scene.drivenPath} fill={`url(#${patternId})`} stroke=\"var(--foreground)\" strokeOpacity={0.5} strokeWidth={1} />\n            <circle r={scene.drivenHub} fill=\"var(--background)\" stroke=\"var(--border)\" strokeWidth={1} />\n            <circle r={1.4} fill=\"var(--ns-muted)\" />\n          </g>\n\n          <circle\n            ref={contactMarkRef}\n            cx={scene.contactX}\n            cy={scene.contactY}\n            r={1}\n            fill=\"var(--ns-accent)\"\n            style={{ opacity: 0 }}\n          />\n        </svg>\n      </div>\n\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"mt-3 space-y-0.5\">\n        <p className=\"font-mono text-sm tabular-nums text-foreground\">\n          {receiveLabel} <span className=\"font-medium\">{convertedFmt}</span>\n        </p>\n        <p className=\"font-mono text-xs tabular-nums text-ns-muted\">Spread cost {feeFmt}</p>\n        <p className=\"pt-1 font-mono text-[10px] tracking-wide text-ns-muted\" aria-hidden=\"true\">\n          {display.honesty}\n        </p>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/mesh-lash.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "input",
      "currency",
      "fx",
      "exchange-rate",
      "gears",
      "svg",
      "mechanical",
      "spread",
      "fee",
      "form",
      "accessibility"
    ],
    "instruction": "Build <GearLashField fromCurrency? toCurrency? rate spreadFrac defaultAmount? label? receiveLabel? onAmountChange? className?> as an uncontrolled currency-amount field whose gear illustration is the actual FX computation, not a decoration next to one. MECHANISM: one governing scalar driveAngle = amount * DEG_PER_UNIT (3.6 deg/unit — a direct function of the typed amount, mathematically identical to accumulating a constant deg-per-unit rate over every input delta). Backlash is the textbook control-theory dead-zone nonlinearity applied to driveAngle: a `contact` value that free-wheels with driveAngle while it moves in one direction (contact = driveAngle - halfWidth) but, on a direction reversal, stays frozen until driveAngle has crossed the FULL 2*halfWidth gap before it starts moving again (contact = driveAngle + halfWidth once driveAngle - contact < -halfWidth) — this literally reproduces 'the drive turns through a visible dead angle before the driven picks up' on every reversal. halfWidth is derived, not hand-tuned: lashWidthDeg = spreadFrac * DEG_PER_UNIT * LEGIBILITY_GAIN (a fixed, stated gain constant of 500) so a real 0.3-2% spread, which would otherwise be a fraction of a degree, reads as an actual visible wedge of drive rotation. drivenAngle = contact / ratio, where ratio = teethDrive/teethDriven is the DRAWN gear ratio (not the exact rate): decompose rate into mantissa*scale where scale is a power of ten pulled out until mantissa sits in [0.2, 5] (so a far-apart pair like JPY doesn't demand an unrenderable 1:150 tooth ratio), then search driveTeeth over [8,40] for the driveTeeth/drivenTeeth pair (drivenTeeth = round(driveTeeth*mantissa), also clamped to [8,40]) whose ratio is closest to mantissa. HARD CONSTRAINT: the printed converted amount is read straight off drivenAngle — (drivenAngle / DEG_PER_UNIT) * scale — there is no second, independently-computed 'real' total rendered beside the gears; the display IS the computation, so the whole claim (fee equals dead angle) stays checkable. The disclosed spread cost is |amount*rate (the exact, ungeared rate) - that gear-integrated converted amount| — i.e. the gap between what a frictionless conversion would give and what the drawn, backlashed gear pair actually outputs, formatted in `toCurrency`. QUANTIZATION HONESTY: because tooth counts are integers, the drawn ratio only approximates the true rate; print that residual verbatim as a small mono caption, e.g. 'drawn 23:8, true 2.8712' (append '×{scale}' when a power-of-ten was pulled out), so the imprecision is disclosed rather than hidden. TEETH RENDERING: SVG has no primitive for tiling a fill around a circle, so the tooth SILHOUETTE (the geometrically load-bearing part — right tooth count, trapezoid profile, correct mesh) is a computed closed path (a loop over teeth count building four points per tooth — root-left, tip-left, tip-right, root-right — via polar coordinates, tip width and root width each a fixed fraction of the angular pitch so the tooth reads as a trapezoid, not a rectangle or a triangle); the brief's literal request for teeth-as-an-SVG-pattern-of-trapezoids is honored as the tooth FILL — one small repeating <pattern> tile (a single trapezoid polygon on a background rect, both in tokens) painted inside that computed silhouette via fill=url(#pattern), so the pattern is real and load-bearing (it is what paints every tooth) even though the outer boundary itself comes from trig, a genuine SVG limitation rather than a shortcut. Both gears are drawn in local coordinates centered on (0,0) inside a <g>, positioned and rotated in one written style.transform = `translate(cx,cy) rotate(deg)` — rotation is written imperatively to that ref, never through React state on the hot path, exactly mirroring how meshed real gears counter-rotate (the driven gear's transform gets the NEGATIVE of drivenAngle). A single 1px circle in var(--ns-accent) sits at the geometric mesh point (pitch-circle tangent point between the two gear centers) and its opacity is toggled to 1 on every input, fading back to 0 ~420ms after the last change — the only place --ns-accent appears, and only while input is live. MOTION: typed input (onChange on the native input) transitions the gear rotation over 200ms ease-out; scrub input (a pointer drag on the aria-hidden gear scene, data-mesh-role=\"scrub\") writes rotation with transition:none, 1:1 with the pointer, using SCRUB_UNITS_PER_PX to map horizontal drag distance to a currency-unit delta from the amount at pointerdown; both paths funnel through the exact same applyAmount function so there is one source of truth for the mechanism regardless of input mode. A11Y: the source amount is a real <input inputMode=\"decimal\"> with a visible <label htmlFor> plus an aria-label repeating the currency code, and remains the ONLY control users need to operate the whole thing (Tab reaches it, arrow/number keys just work as normal text input — no custom keyboard handling is needed or added); converted amount and spread cost are plain text inside one role=\"status\" aria-live=\"polite\" region, debounced ~450ms after the last input so rapid typing or dragging doesn't spam a screen reader, plus a matching sr-only sentence ('They receive €1,132.09. Spread cost €4.20.') — the visible gear scene and its wrapper are aria-hidden entirely, and scrubbing it is a supplementary pointer affordance only, never the only way to change the amount. REDUCED MOTION: prefers-reduced-motion drops every rotation transition to 0ms (gears jump straight to their final resting angle on every change, mount included) while the honesty line and spread-cost figure still print exactly as computed — no motion is required to read the mechanism, only to watch it. TOKENS: only var(--background), var(--foreground), var(--ns-muted), var(--border), var(--ns-accent) appear anywhere, in markup and in the SVG's pattern/paths alike; no hex, no gradients, no orange. DEMO: a compact card ('Send to a MXN account') wrapping one GearLashField at a realistic USD->MXN remittance rate (17.05) and spread (1.2%) — a pair whose tooth counts land visibly apart (20 drive teeth vs 34 driven), so the ratio itself reads at a glance instead of two near-identical circles — fully interactive with zero synthetic input — the on-site landing card demonstrates itself separately via the autoplay descriptor, which is a `drag` cycle over the gear scrub surface so the round-trip motion (forward, then back) shows off the dead-angle catch-up on the return leg, the component's whole reason for existing."
  },
  "type": "registry:ui"
}