{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pole-shy",
  "title": "Pole Shy",
  "description": "Live cursor name-labels that repel each other like matching magnetic poles, so the person actually typing makes bystander labels visibly yield and drift back.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/pole-shy/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PoleShy — live cursor name-labels that behave as like-pole magnets. Every\n// label carries the same polarity, so labels crowding one line shove each\n// other apart along the baseline with inverse-square falloff, and whoever is\n// actively typing carries a stronger field, so bystander labels yield and\n// drift back. Dominance is expressed by who moves out of whose way, never by\n// size, glow or color — the currently-typing label is styled identically to\n// every other label.\n//\n// MECHANISM: users are grouped into \"bands\" by their integer `row` — only\n// labels sharing a row ever interact, matching how carets on different lines\n// of a real document never fight for the same space. Within a crowded band\n// (>=2 members), a 30fps-throttled loop computes each user's field strength\n// F = BASE * (1 + 2 * exp(-idleMs / DECAY_MS)) — 3x base the instant `typing`\n// is true, decaying back to 1x base over ~1.4s of not typing, so dominance\n// fades rather than snapping off. Every pair in the band repels with\n// Fi*Fj/d^2 along the horizontal axis only; that repulsion is summed as an\n// external force into a critically-damped spring pulling each label back\n// toward its true anchor (offset 0), clamped to +/-48px, and written directly\n// to a ref'd DOM node's transform each tick — refs-only, no per-frame\n// setState. A hairline SVG leader always connects the label's current\n// (possibly displaced) position to its true caret position, so the anchor is\n// never ambiguous even when a label has moved.\n//\n// prefers-reduced-motion drops the spring entirely: crowded-band members\n// render with zero horizontal displacement and instead stack vertically,\n// each still leadered straight down to its own true caret. No physics loop\n// runs at all in that mode.\n// ---------------------------------------------------------------------------\n\nexport interface PoleShyUser {\n  id: string;\n  name: string;\n  /** normalized 0..1 horizontal position of this user's true caret within the band */\n  x: number;\n  /** integer line/row index — the caret's true vertical band. Only users sharing a row repel each other. */\n  row: number;\n  /** true while this user is actively producing keystrokes right now */\n  typing: boolean;\n  /** optional human label of where they are, used only in the \"is typing in ___\" announcement */\n  section?: string;\n}\n\nexport interface PoleShyProps {\n  users: PoleShyUser[];\n  className?: string;\n}\n\nconst ROW_HEIGHT = 64;\nconst LABEL_TOP_IN_ROW = 4;\nconst LABEL_H = 26;\nconst CARET_BOTTOM_GAP = 14;\nconst STACK_GAP = 32;\n\nconst MAX_OFFSET = 72;\nconst DECAY_MS = 1400;\nconst BASE_FIELD = 1;\nconst TYPING_MULT = 3;\n// Hovering a label is this component's own way of asking \"let me read that\n// one\" — it feeds the same field-strength mechanism typing does (so the\n// separation is real physics, not a tooltip layer), decaying back on the\n// same curve once the pointer leaves so it settles rather than snapping.\nconst HOVER_MULT = 5;\nconst SPRING_K = 46; // s^-2\nconst SPRING_ZETA = 0.85;\n// Chosen so two idle (F=1) labels ~50px apart settle a few px off anchor,\n// and a typing/hovered (F=3x) neighbour pushes that toward the clamp: at\n// equilibrium offset ~= REPULSE_K * Fi*Fj / (SPRING_K * d^2). Raised\n// alongside MAX_OFFSET so a crowded band of 4 long names can actually clear\n// each other at full field strength instead of clamping short of it.\nconst REPULSE_K = 2_000_000;\nconst MIN_D = 24; // px, floor on pairwise distance so near-overlap can't slam the clamp in one tick\nconst MAX_VEL = 900; // px/s\nconst TICK_MS = 1000 / 30; // 30fps throttle\n\ninterface LabelPhysics {\n  offset: number;\n  vel: number;\n}\n\ninterface BandInfo {\n  size: number;\n  stackIndex: number;\n}\n\nfunction initials(name: string): string {\n  const parts = name.trim().split(/\\s+/).filter(Boolean);\n  if (parts.length === 0) return \"?\";\n  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n}\n\nexport function PoleShy({ users, className = \"\" }: PoleShyProps) {\n  const [reducedMotion, setReducedMotion] = useState(false);\n  const [size, setSize] = useState({ width: 0, height: 0 });\n  const [announcement, setAnnouncement] = useState(\"\");\n  // Which label is currently under the pointer — the only thing this drives\n  // is field strength (below) and paint order (so the revealed label draws\n  // over its neighbours instead of losing a coin-flip on DOM order).\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  const widthRef = useRef(0);\n  const labelRefs = useRef<Map<string, HTMLDivElement>>(new Map());\n  const leaderRefs = useRef<Map<string, SVGLineElement>>(new Map());\n  const physicsRef = useRef<Map<string, LabelPhysics>>(new Map());\n  const lastTypingAtRef = useRef<Map<string, number>>(new Map());\n  const lastHoverAtRef = useRef<Map<string, number>>(new Map());\n  const hoveredIdRef = useRef<string | null>(null);\n  hoveredIdRef.current = hoveredId;\n  const usersRef = useRef<PoleShyUser[]>(users);\n  usersRef.current = users;\n\n  const prevAnnounceRef = useRef<Map<string, { name: string; typing: boolean }> | null>(null);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const onChange = () => setReducedMotion(mq.matches);\n    onChange();\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      widthRef.current = box.width;\n      setSize((prev) =>\n        prev.width === box.width && prev.height === box.height\n          ? prev\n          : { width: box.width, height: box.height }\n      );\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const maxRow = useMemo(() => users.reduce((m, u) => Math.max(m, u.row), 0), [users]);\n\n  // Per-user band membership: how many users share its row, and (for the\n  // reduced-motion stacked layout only) its order within that band.\n  const bandInfo = useMemo(() => {\n    const byRow = new Map<number, PoleShyUser[]>();\n    for (const u of users) {\n      const arr = byRow.get(u.row) ?? [];\n      arr.push(u);\n      byRow.set(u.row, arr);\n    }\n    const info = new Map<string, BandInfo>();\n    for (const members of byRow.values()) {\n      const sorted = [...members].sort((a, b) => a.x - b.x);\n      sorted.forEach((u, i) => info.set(u.id, { size: members.length, stackIndex: i }));\n    }\n    return info;\n  }, [users]);\n\n  // Row geometry. Under reduced motion a crowded row grows tall enough to\n  // hold its vertical stack instead of the labels ever moving horizontally;\n  // every other row (and every row when motion is allowed) stays a fixed\n  // ROW_HEIGHT since the physics displacement never leaves that band.\n  const rowLayout = useMemo(() => {\n    const bandSizeByRow = new Map<number, number>();\n    for (const u of users) bandSizeByRow.set(u.row, (bandSizeByRow.get(u.row) ?? 0) + 1);\n    const rowCount = maxRow + 1;\n    const tops: number[] = [];\n    const heights: number[] = [];\n    let acc = 0;\n    for (let r = 0; r < rowCount; r++) {\n      const bandSize = bandSizeByRow.get(r) ?? 0;\n      const h =\n        reducedMotion && bandSize >= 2\n          ? Math.max(ROW_HEIGHT, LABEL_TOP_IN_ROW + bandSize * STACK_GAP + LABEL_H)\n          : ROW_HEIGHT;\n      tops.push(acc);\n      heights.push(h);\n      acc += h;\n    }\n    return { tops, heights, total: acc || ROW_HEIGHT };\n  }, [users, maxRow, reducedMotion]);\n\n  function rowTop(row: number) {\n    return rowLayout.tops[row] ?? 0;\n  }\n  function rowCaretY(row: number) {\n    return rowTop(row) + (rowLayout.heights[row] ?? ROW_HEIGHT) - CARET_BOTTOM_GAP;\n  }\n\n  // Prune stale physics/decay state for users who have left, so a long-lived\n  // session with churn doesn't grow these maps forever.\n  useEffect(() => {\n    const ids = new Set(users.map((u) => u.id));\n    for (const id of physicsRef.current.keys()) {\n      if (!ids.has(id)) physicsRef.current.delete(id);\n    }\n    for (const id of lastTypingAtRef.current.keys()) {\n      if (!ids.has(id)) lastTypingAtRef.current.delete(id);\n    }\n    for (const id of lastHoverAtRef.current.keys()) {\n      if (!ids.has(id)) lastHoverAtRef.current.delete(id);\n    }\n    setHoveredId((h) => (h && !ids.has(h) ? null : h));\n  }, [users]);\n\n  // ---- physics loop (skipped entirely under reduced motion) --------------\n  useEffect(() => {\n    if (reducedMotion) return;\n    let raf = 0;\n    let last = 0;\n    let running = true;\n\n    const cAnchor = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n\n    function integrate(st: LabelPhysics, extraAccel: number, dt: number) {\n      const accel = -SPRING_K * st.offset - cAnchor * st.vel + extraAccel;\n      st.vel += accel * dt;\n      if (st.vel > MAX_VEL) st.vel = MAX_VEL;\n      else if (st.vel < -MAX_VEL) st.vel = -MAX_VEL;\n      st.offset += st.vel * dt;\n      if (st.offset > MAX_OFFSET) {\n        st.offset = MAX_OFFSET;\n        if (st.vel > 0) st.vel = 0;\n      } else if (st.offset < -MAX_OFFSET) {\n        st.offset = -MAX_OFFSET;\n        if (st.vel < 0) st.vel = 0;\n      }\n    }\n\n    function apply(id: string, offset: number, trueX: number) {\n      const label = labelRefs.current.get(id);\n      if (label) label.style.transform = `translateX(calc(-50% + ${offset.toFixed(2)}px))`;\n      const leader = leaderRefs.current.get(id);\n      if (leader) leader.setAttribute(\"x1\", (trueX + offset).toFixed(2));\n    }\n\n    function step(now: number, dt: number) {\n      const width = widthRef.current;\n      if (!width) return;\n\n      const byRow = new Map<number, PoleShyUser[]>();\n      for (const u of usersRef.current) {\n        const arr = byRow.get(u.row) ?? [];\n        arr.push(u);\n        byRow.set(u.row, arr);\n      }\n\n      for (const members of byRow.values()) {\n        if (members.length < 2) {\n          // Not (or no longer) crowded — relax any residual offset back to\n          // its true anchor, spring only, no repulsion.\n          for (const u of members) {\n            const st = physicsRef.current.get(u.id);\n            if (!st) continue;\n            if (st.offset === 0 && st.vel === 0) continue;\n            integrate(st, 0, dt);\n            apply(u.id, st.offset, u.x * width);\n          }\n          continue;\n        }\n\n        // Field strength per member: 3x base the instant `typing` is true,\n        // decaying back toward 1x base over DECAY_MS of not typing. Hovering\n        // a label drives the same field through its own independent decay,\n        // so \"let me read that one\" uses the identical repulsion mechanism\n        // as \"I'm typing here\" rather than a second, unrelated affordance.\n        const field = new Map<string, number>();\n        const hovered = hoveredIdRef.current;\n        for (const u of members) {\n          const lastTyping = lastTypingAtRef.current.get(u.id) ?? -Infinity;\n          if (u.typing) lastTypingAtRef.current.set(u.id, now);\n          const typingIdle = u.typing ? 0 : now - lastTyping;\n          const typingMult = 1 + (TYPING_MULT - 1) * Math.exp(-typingIdle / DECAY_MS);\n\n          const isHovered = u.id === hovered;\n          const lastHover = lastHoverAtRef.current.get(u.id) ?? -Infinity;\n          if (isHovered) lastHoverAtRef.current.set(u.id, now);\n          const hoverIdle = isHovered ? 0 : now - lastHover;\n          const hoverMult = 1 + (HOVER_MULT - 1) * Math.exp(-hoverIdle / DECAY_MS);\n\n          field.set(u.id, BASE_FIELD * Math.max(typingMult, hoverMult));\n        }\n\n        for (const u of members) {\n          let st = physicsRef.current.get(u.id);\n          if (!st) {\n            st = { offset: 0, vel: 0 };\n            physicsRef.current.set(u.id, st);\n          }\n          const xi = u.x * width + st.offset;\n          let repel = 0;\n          for (const v of members) {\n            if (v.id === u.id) continue;\n            const vst = physicsRef.current.get(v.id) ?? { offset: 0, vel: 0 };\n            const xj = v.x * width + vst.offset;\n            const raw = xi - xj;\n            const d = Math.max(Math.abs(raw), MIN_D);\n            const dir = raw === 0 ? (u.id < v.id ? 1 : -1) : Math.sign(raw);\n            repel += (dir * REPULSE_K * field.get(u.id)! * field.get(v.id)!) / (d * d);\n          }\n          integrate(st, repel, dt);\n          apply(u.id, st.offset, u.x * width);\n        }\n      }\n    }\n\n    function frame(now: number) {\n      if (!running) return;\n      raf = requestAnimationFrame(frame);\n      if (document.hidden) return;\n      if (last === 0) {\n        last = now;\n        return;\n      }\n      if (now - last < TICK_MS) return;\n      const dt = Math.min((now - last) / 1000, 0.1);\n      last = now;\n      step(now, dt);\n    }\n\n    raf = requestAnimationFrame(frame);\n    return () => {\n      running = false;\n      cancelAnimationFrame(raf);\n    };\n  }, [reducedMotion]);\n\n  // ---- live-region announcements: state changes only, never motion -------\n  useEffect(() => {\n    const prev = prevAnnounceRef.current;\n    const isFirst = prev === null;\n    let msg = \"\";\n\n    if (!isFirst) {\n      const currentIds = new Set(users.map((u) => u.id));\n      for (const [id, was] of prev) {\n        if (!currentIds.has(id)) msg = `${was.name} left`;\n      }\n      for (const u of users) {\n        const before = prev.get(u.id);\n        if (!before) {\n          msg = `${u.name} joined`;\n          continue;\n        }\n        if (!before.typing && u.typing) {\n          msg = u.section ? `${u.name} is typing in ${u.section}` : `${u.name} is typing`;\n        }\n      }\n    }\n\n    prevAnnounceRef.current = new Map(users.map((u) => [u.id, { name: u.name, typing: u.typing }]));\n    if (!isFirst && msg) setAnnouncement(msg);\n  }, [users]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={[\"relative w-full overflow-hidden\", className].join(\" \")}\n      style={{ height: rowLayout.total }}\n    >\n      <svg\n        className=\"pointer-events-none absolute inset-0 h-full w-full\"\n        viewBox={`0 0 ${Math.max(size.width, 1)} ${Math.max(size.height, 1)}`}\n        preserveAspectRatio=\"none\"\n        aria-hidden=\"true\"\n      >\n        {users.map((u) => {\n          const trueX = u.x * size.width;\n          const caretY = rowCaretY(u.row);\n          const info = bandInfo.get(u.id);\n          const stacked = reducedMotion && info && info.size >= 2;\n          const labelTopY = stacked\n            ? rowTop(u.row) + LABEL_TOP_IN_ROW + info!.stackIndex * STACK_GAP\n            : rowTop(u.row) + LABEL_TOP_IN_ROW;\n          const labelBottomY = labelTopY + LABEL_H;\n          return (\n            <g key={u.id}>\n              <line\n                ref={(el) => {\n                  if (el) leaderRefs.current.set(u.id, el);\n                  else leaderRefs.current.delete(u.id);\n                }}\n                x1={trueX}\n                y1={labelBottomY}\n                x2={trueX}\n                y2={caretY}\n                stroke=\"var(--border)\"\n                strokeWidth={1}\n                vectorEffect=\"non-scaling-stroke\"\n              />\n              <circle cx={trueX} cy={caretY} r={3} fill=\"var(--foreground)\" opacity={0.55} />\n            </g>\n          );\n        })}\n      </svg>\n\n      {users.map((u) => {\n        const info = bandInfo.get(u.id);\n        const stacked = reducedMotion && info && info.size >= 2;\n        const top = stacked\n          ? rowTop(u.row) + LABEL_TOP_IN_ROW + info!.stackIndex * STACK_GAP\n          : rowTop(u.row) + LABEL_TOP_IN_ROW;\n        const isHovered = u.id === hoveredId;\n        return (\n          <div\n            key={u.id}\n            ref={(el) => {\n              if (el) labelRefs.current.set(u.id, el);\n              else labelRefs.current.delete(u.id);\n            }}\n            className=\"absolute flex items-center gap-1.5 rounded-full border border-border bg-background px-2 py-1 shadow-sm\"\n            onMouseEnter={() => setHoveredId(u.id)}\n            onMouseLeave={() => setHoveredId((h) => (h === u.id ? null : h))}\n            style={{\n              left: `${u.x * 100}%`,\n              top,\n              transform: \"translateX(-50%)\",\n              whiteSpace: \"nowrap\",\n              // Hovering is this component's own reveal mechanism (stronger\n              // field -> neighbours yield further, see the physics loop\n              // above) — pulling the hovered chip's paint order above its\n              // crowded neighbours is the other half: without it the label\n              // could still lose an arbitrary DOM-order coin-flip against\n              // an un-hovered sibling sitting closer to its own anchor.\n              zIndex: isHovered ? 10 : 1,\n            }}\n          >\n            <span className=\"rounded-full border border-border px-1 font-mono text-[9px] leading-[14px] text-ns-muted\">\n              {initials(u.name)}\n            </span>\n            <span className=\"text-xs text-foreground\">{u.name}</span>\n          </div>\n        );\n      })}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/pole-shy.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "presence",
      "collaboration",
      "cursor",
      "physics",
      "svg",
      "aria-live",
      "multiplayer",
      "typography"
    ],
    "instruction": "Live cursor name-labels that behave as like-pole magnets: every label carries the same polarity, so labels crowding one line shove each other apart along the baseline with inverse-square falloff, and the person currently typing carries a stronger field, so bystander labels yield and drift back — dominance is expressed by who moves out of whose way, never by size, glow or color; the currently-typing label is styled identically to every other label at all times. Props: `users` (required) is an array of {id, name, x (0..1 horizontal position of that user's TRUE caret within the container), row (integer line index — only users sharing a row ever interact, exactly like carets on different lines of a document never fighting for the same space), typing (boolean, true while that user is actively producing keystrokes), section? (optional human label used only in the typing announcement)}. MECHANISM: within a band of 2+ same-row users, a rAF loop throttled to 30fps computes each member's field strength F = 1 (base) times 3 while `typing` is true, or times 5 while the pointer is hovering that label (hover is meant to guarantee legibility on demand, so it's given a stronger pull than the ambient typing signal), each decaying independently back toward 1 over roughly 1.4s of measured idle/unhover time via the same exponential curve (1 + (mult-1)*e^(-idleMs/1400)) — hovering a crowded label is read by the physics exactly like that person typing: it, too, is a repulsion field, not a tooltip, it's just tuned stronger. Every pair in the band repels along the horizontal axis only with Fi*Fj/d^2 (d floored at 14px so near-overlap can't spike to infinity); that repulsion is summed as an external force into a critically-damped spring (k=46 s^-2, zeta=0.85) pulling each label back toward its true anchor at offset 0, clamped to +/-72px, integrated with semi-implicit Euler and written straight to a ref'd DOM node's `transform: translateX()` every tick — refs-only, no per-frame React state, so idle bands (fewer than two members sharing a row) cost nothing beyond a size check. The hovered label's own DOM node is also raised in paint order (z-index) above its band, so it draws over crowded neighbours rather than losing an arbitrary DOM-order tie — text is always in the accessibility tree regardless of paint order, so this is a purely visual legibility fix, not a semantics change. A hairline SVG line (stroke var(--border)) plus a small dot (fill var(--foreground)) always run from the label's current, possibly displaced position down to its true caret coordinate, so the anchor is never ambiguous even mid-displacement — a label can never be drawn somewhere its leader doesn't reconcile. Labels are bordered chips (border var(--border), background var(--background)) holding a small Geist Mono initials badge plus the collaborator's name as real, always-visible text — identity is carried by that text, not by color, and --ns-accent never touches a label. Under prefers-reduced-motion the spring is dropped entirely: crowded-row members render with zero horizontal displacement and instead stack vertically within a row that grows tall enough to hold them, each still leadered straight down to its own true caret — legible instantly, no motion required, and no physics loop runs at all in that mode. An adjacent role=status aria-live=polite region (visually sr-only) announces arrivals, departures, and '<name> is typing' (or '<name> is typing in <section>' when `section` is given) strictly on state change — never once per physics tick, since all repulsion is presentational and the leader guarantees the announced identity's visual position always matches its true one. Zero dependencies, DOM + SVG + CSS only, no canvas. Differs from avatar-stack-flock, which flocks a header's idle avatar row as decorative boids with no tie to document position: pole-shy only ever displaces to resolve a real collision between real caret positions, sleeps whenever a row has fewer than two members, and expresses 'who is typing' through motion deference rather than any static visual difference."
  },
  "type": "registry:ui"
}