{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-stack-flock",
  "title": "Avatar Stack Flock",
  "description": "Avatar stack that mills as a live boids flock and resolves into the classic overlapping row on hover, with the +N badge appearing only once the formation settles.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/avatar-stack-flock/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FlockStack — team avatar stack that never sits still: at rest the avatars\n// mill as a bounded boids flock (separation / alignment / cohesion + soft\n// walls); hovering or keyboard-focusing the region ramps a seek force toward\n// each avatar's slot in the classic overlapping row, SUMMED into the same\n// three rules, so the tidy \"group photo\" is itself a settled flocking state.\n// Pure DOM — per-frame transforms from a refs-only vector sim, no canvas, no\n// React state on the hot path. Every color is a CSS token class (nothing is\n// read via getComputedStyle, so there is nothing to re-derive on theme\n// change — ring-background / border-border / bg-surface self-adapt). The +N\n// badge fades in only once the formation resolves; once resolved AND settled\n// the rAF loop genuinely sleeps until hover exit. Static resolved row under\n// prefers-reduced-motion.\n// ---------------------------------------------------------------------------\n\nexport interface FlockMember {\n  name: string;\n  /** 1–2 chars; derived from name when omitted */\n  initials?: string;\n  /** optional avatar image url; initials shown otherwise */\n  src?: string;\n}\n\nconst DEFAULT_MEMBERS: FlockMember[] = [\n  { name: \"Mara Chen\" },\n  { name: \"Jonas Weber\" },\n  { name: \"Aiko Tanaka\" },\n  { name: \"Sam Okafor\" },\n  { name: \"Lena Fischer\" },\n  { name: \"Ravi Patel\" },\n  { name: \"Nora Lindqvist\" },\n];\n\nfunction initialsOf(m: FlockMember) {\n  if (m.initials) return m.initials.slice(0, 2).toUpperCase();\n  return m.name\n    .split(/\\s+/)\n    .map((w) => w[0] ?? \"\")\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n}\n\n// deterministic PRNG for the initial scatter (stable across strict-mode runs)\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n// sim constants — px, seconds\nconst SEP_R = 34;\nconst SEP_W = 1.4;\nconst ALI_R = 60;\nconst ALI_W = 0.6;\nconst COH_R = 90;\nconst COH_W = 0.5;\nconst MAX_SPEED = 60; // px/s while milling — keeps the ambient drift calm\nconst SEEK_MAX_SPEED = 480; // px/s ceiling while seeking, ramped in with seekT\nconst MAX_FORCE = 260; // steering clamp, px/s^2\nconst WALL_M = 24; // soft-wall ramp distance\nconst WALL_F = 300; // px/s^2 at the edge\nconst SEEK_W = 2.2; // full seek weight after the ramp\nconst SEEK_RAMP_S = 0.4; // seconds, both directions\nconst ARRIVE_R = 40; // arrival slowdown radius\n\nexport function FlockStack({\n  members = DEFAULT_MEMBERS,\n  overflow = 3,\n  avatarSize = 28,\n  className = \"h-[120px]\",\n  \"aria-label\": ariaLabel,\n}: {\n  /** flocking avatars (<=12 recommended; sim is O(n^2)) */\n  members?: FlockMember[];\n  /** count in the \"+N\" badge shown once the flock resolves */\n  overflow?: number;\n  /** avatar diameter in px */\n  avatarSize?: number;\n  className?: string;\n  \"aria-label\"?: string;\n}) {\n  const regionRef = useRef<HTMLDivElement>(null);\n  const badgeRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n  useEffect(() => {\n    const region = regionRef.current;\n    if (!region) return;\n    const badge = badgeRef.current; // null when overflow <= 0\n    const items = itemRefs.current\n      .slice(0, members.length)\n      .filter((el): el is HTMLDivElement => el !== null);\n    const n = items.length;\n    if (n === 0) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const half = avatarSize / 2;\n\n    // hot-path state — plain locals/arrays, never React state\n    const px: number[] = new Array(n).fill(0);\n    const py: number[] = new Array(n).fill(0);\n    const vx: number[] = new Array(n).fill(0);\n    const vy: number[] = new Array(n).fill(0);\n    const wanderA: number[] = new Array(n).fill(0);\n    const slotX: number[] = new Array(n).fill(0);\n    let slotY = 0;\n    let W = 0;\n    let H = 0;\n    let sized = false;\n\n    const measure = () => {\n      const r = region.getBoundingClientRect();\n      W = r.width;\n      H = r.height;\n      // zero/tiny-size guard: no slots, no sim until we have real bounds\n      sized = W >= avatarSize * 2 && H >= avatarSize + 8;\n      if (!sized) return;\n      const badgeCX = W - 16 - half;\n      // classic -8 px overlap, compressed if the region is narrow\n      const step = Math.min(\n        avatarSize - 8,\n        Math.max(6, (badgeCX - half - 12) / n)\n      );\n      slotY = H / 2;\n      for (let i = 0; i < n; i++) slotX[i] = badgeCX - (n - i) * step;\n      if (badge) {\n        badge.style.transform = `translate3d(${badgeCX - half}px, ${slotY - half}px, 0)`;\n      }\n      // keep agents inside fresh bounds after a resize\n      for (let i = 0; i < n; i++) {\n        px[i] = Math.min(Math.max(px[i] ?? 0, half), W - half);\n        py[i] = Math.min(Math.max(py[i] ?? 0, half), H - half);\n      }\n    };\n    measure();\n\n    // per-avatar hover: lift + tooltip, only once the formation is resolved\n    let resolvedNow = reduced;\n    const detachHover: Array<() => void> = [];\n    items.forEach((el) => {\n      const inner = el.querySelector<HTMLElement>(\"[data-avatar]\");\n      const tip = el.querySelector<HTMLElement>(\"[data-tip]\");\n      const baseZ = el.style.zIndex;\n      const enter = () => {\n        if (!resolvedNow) return;\n        el.style.zIndex = \"40\";\n        if (inner) inner.style.transform = \"translateY(-3px)\";\n        if (tip) tip.style.opacity = \"1\";\n      };\n      const leave = () => {\n        el.style.zIndex = baseZ;\n        if (inner) inner.style.transform = \"\";\n        if (tip) tip.style.opacity = \"0\";\n      };\n      el.addEventListener(\"pointerenter\", enter);\n      el.addEventListener(\"pointerleave\", leave);\n      detachHover.push(() => {\n        el.removeEventListener(\"pointerenter\", enter);\n        el.removeEventListener(\"pointerleave\", leave);\n      });\n    });\n\n    // ------------------------------------------------------------------\n    // reduced motion: static two-state — resolved row, badge always on,\n    // no milling ever; tooltips/lift still work (CSS transitions only)\n    // ------------------------------------------------------------------\n    if (reduced) {\n      const place = () => {\n        measure();\n        if (!sized) return;\n        items.forEach((el, i) => {\n          el.style.transform = `translate3d(${(slotX[i] ?? 0) - half}px, ${slotY - half}px, 0)`;\n          el.style.opacity = \"1\";\n        });\n        if (badge) badge.style.opacity = \"1\";\n      };\n      region.dataset.resolved = \"true\";\n      place();\n      const ro = new ResizeObserver(place);\n      ro.observe(region);\n      return () => {\n        ro.disconnect();\n        detachHover.forEach((f) => f());\n      };\n    }\n\n    // ------------------------------------------------------------------\n    // animated path\n    // ------------------------------------------------------------------\n    const rand = mulberry32(0xf10c5 + n);\n    let seeded = false;\n    const seed = () => {\n      if (seeded || !sized) return;\n      for (let i = 0; i < n; i++) {\n        px[i] = half + 8 + rand() * Math.max(1, W - avatarSize - 16);\n        py[i] = half + 8 + rand() * Math.max(1, H - avatarSize - 16);\n        const a = rand() * Math.PI * 2;\n        vx[i] = Math.cos(a) * MAX_SPEED * 0.5;\n        vy[i] = Math.sin(a) * MAX_SPEED * 0.5;\n        wanderA[i] = a;\n      }\n      items.forEach((el) => {\n        el.style.opacity = \"1\";\n      });\n      seeded = true;\n    };\n    seed();\n\n    let raf = 0;\n    let last = 0;\n    let ioVisible = true;\n    let docVisible = !document.hidden;\n    let hovered = false;\n    let focused = false;\n    let seekT = 0; // 0..1, ramps over SEEK_RAMP_S both ways\n    let sleeping = false;\n\n    const setResolved = (v: boolean) => {\n      if (resolvedNow === v) return;\n      resolvedNow = v;\n      region.dataset.resolved = v ? \"true\" : \"false\";\n      if (badge) badge.style.opacity = v ? \"1\" : \"0\";\n    };\n\n    const clampScale = (x: number, y: number) => {\n      const l = Math.hypot(x, y);\n      return l > MAX_FORCE ? MAX_FORCE / l : 1;\n    };\n\n    function step(now: number) {\n      raf = 0;\n      if (!seeded) seed();\n      if (!sized || !seeded) return; // ResizeObserver wakes us with real bounds\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n\n      const wantSeek = hovered || focused;\n      seekT = Math.min(\n        1,\n        Math.max(0, seekT + ((wantSeek ? 1 : -1) * dt) / SEEK_RAMP_S)\n      );\n      const seekW = SEEK_W * seekT;\n      const mill = 1 - seekT; // milling drive fades as the seek ramps in\n      // the three rules stay summed, attenuated so arrival can actually settle —\n      // must reach exactly 0 at full seek (mirrors `mill`) or a residual\n      // separation force fights the seek force forever at tight slot spacing\n      const flockW = Math.max(0, 1 - seekT);\n      // milling's MAX_SPEED (60 px/s) is deliberately slow for a calm resting\n      // drift, but that same cap made a from-scatter seek arrival take\n      // 5-6+ seconds — far past the 400ms seek-ramp. Ramp a higher travel\n      // ceiling in with seekT so milling speed is untouched at rest and the\n      // seek phase alone gets a real sprint toward the slot.\n      const travelCap = MAX_SPEED + (SEEK_MAX_SPEED - MAX_SPEED) * seekT;\n\n      const damp = Math.pow(0.98, dt * 60);\n      let allNear = true;\n      let allStill = true;\n      // position-only — deliberately NOT the same test as `allNear` (which\n      // also gates on speed): a wake-from-sleep velocity kick can trip a\n      // speed threshold for a frame without the avatar visibly leaving its\n      // slot, and clearing the badge on that alone is exactly the flicker.\n      let anyDeparted = false;\n\n      for (let i = 0; i < n; i++) {\n        const xi = px[i] ?? 0;\n        const yi = py[i] ?? 0;\n        let vxi = vx[i] ?? 0;\n        let vyi = vy[i] ?? 0;\n        let ax = 0;\n        let ay = 0;\n\n        // neighbor accumulation (n <= 12 → O(n^2) is nothing)\n        let sx = 0;\n        let sy = 0;\n        let sc = 0;\n        let alx = 0;\n        let aly = 0;\n        let ac = 0;\n        let cxs = 0;\n        let cys = 0;\n        let cc = 0;\n        for (let j = 0; j < n; j++) {\n          if (j === i) continue;\n          let dx = xi - (px[j] ?? 0);\n          let dy = yi - (py[j] ?? 0);\n          let d = Math.hypot(dx, dy);\n          if (d < 1e-4) {\n            // coincident agents: deterministic nudge, never a zero-length vector\n            dx = i > j ? 0.01 : -0.01;\n            dy = 0;\n            d = 0.01;\n          }\n          if (d < SEP_R) {\n            const f = (1 - d / SEP_R) / d;\n            sx += dx * f;\n            sy += dy * f;\n            sc++;\n          }\n          if (d < ALI_R) {\n            alx += vx[j] ?? 0;\n            aly += vy[j] ?? 0;\n            ac++;\n          }\n          if (d < COH_R) {\n            cxs += px[j] ?? 0;\n            cys += py[j] ?? 0;\n            cc++;\n          }\n        }\n\n        // Reynolds steering: desired velocity − current, clamped to MAX_FORCE\n        if (sc > 0) {\n          const m = Math.hypot(sx, sy);\n          if (m > 1e-5) {\n            const fx = (sx / m) * MAX_SPEED - vxi;\n            const fy = (sy / m) * MAX_SPEED - vyi;\n            const s = clampScale(fx, fy);\n            ax += SEP_W * flockW * fx * s;\n            ay += SEP_W * flockW * fy * s;\n          }\n        }\n        if (ac > 0) {\n          const mvx = alx / ac;\n          const mvy = aly / ac;\n          const m = Math.hypot(mvx, mvy);\n          if (m > 1e-5) {\n            const fx = (mvx / m) * MAX_SPEED - vxi;\n            const fy = (mvy / m) * MAX_SPEED - vyi;\n            const s = clampScale(fx, fy);\n            ax += ALI_W * flockW * fx * s;\n            ay += ALI_W * flockW * fy * s;\n          }\n        }\n        if (cc > 0) {\n          const tx = cxs / cc - xi;\n          const ty = cys / cc - yi;\n          const m = Math.hypot(tx, ty);\n          if (m > 1e-5) {\n            const fx = (tx / m) * MAX_SPEED - vxi;\n            const fy = (ty / m) * MAX_SPEED - vyi;\n            const s = clampScale(fx, fy);\n            ax += COH_W * flockW * fx * s;\n            ay += COH_W * flockW * fy * s;\n          }\n        }\n\n        // milling drive: wander random-walk + cruise thrust, fades with seek\n        if (mill > 0.001) {\n          wanderA[i] = (wanderA[i] ?? 0) + (Math.random() * 2 - 1) * 4 * dt;\n          const wa = wanderA[i] ?? 0;\n          ax += Math.cos(wa) * 28 * mill;\n          ay += Math.sin(wa) * 28 * mill;\n          const sp = Math.hypot(vxi, vyi);\n          if (sp > 1e-3) {\n            const thrust = (MAX_SPEED * 0.65 - sp) * 1.6 * mill;\n            ax += (vxi / sp) * thrust;\n            ay += (vyi / sp) * thrust;\n          } else {\n            ax += Math.cos(wa) * 60 * mill;\n            ay += Math.sin(wa) * 60 * mill;\n          }\n        }\n\n        // soft walls: force ramps to WALL_F inside WALL_M of the region edge\n        const dl = xi - half;\n        if (dl < WALL_M) ax += WALL_F * (1 - Math.max(0, dl) / WALL_M);\n        const dr = W - half - xi;\n        if (dr < WALL_M) ax -= WALL_F * (1 - Math.max(0, dr) / WALL_M);\n        const dtp = yi - half;\n        if (dtp < WALL_M) ay += WALL_F * (1 - Math.max(0, dtp) / WALL_M);\n        const db = H - half - yi;\n        if (db < WALL_M) ay -= WALL_F * (1 - Math.max(0, db) / WALL_M);\n\n        // seek to slot: summed into the same field, arrival slowdown < 40 px\n        const sxT = slotX[i] ?? 0;\n        if (seekW > 0) {\n          const dx = sxT - xi;\n          const dy = slotY - yi;\n          const d = Math.hypot(dx, dy);\n          if (d > 1e-4) {\n            const spd = d < ARRIVE_R ? travelCap * (d / ARRIVE_R) : travelCap;\n            const fx = (dx / d) * spd - vxi;\n            const fy = (dy / d) * spd - vyi;\n            const s = clampScale(fx, fy);\n            ax += seekW * fx * s;\n            ay += seekW * fy * s;\n          }\n        }\n\n        // integrate: damping 0.98/frame normalized, speed cap ramps with seekT\n        vxi = (vxi + ax * dt) * damp;\n        vyi = (vyi + ay * dt) * damp;\n        const sp2 = Math.hypot(vxi, vyi);\n        if (sp2 > travelCap) {\n          const s = travelCap / sp2;\n          vxi *= s;\n          vyi *= s;\n        }\n        let nx = xi + vxi * dt;\n        let ny = yi + vyi * dt;\n\n        // capture assist at full ramp: physics carries the approach, this\n        // closes the last few px so the 2 px / 1 px/s gate is reachable\n        if (seekT > 0.95) {\n          const ddx = sxT - nx;\n          const ddy = slotY - ny;\n          if (Math.hypot(ddx, ddy) < 8) {\n            const k = Math.min(1, 10 * dt);\n            nx += ddx * k;\n            ny += ddy * k;\n            const kd = Math.pow(0.8, dt * 60);\n            vxi *= kd;\n            vyi *= kd;\n          }\n        }\n\n        // hard containment safety net (soft walls do the real work)\n        if (nx < half) {\n          nx = half;\n          vxi = Math.abs(vxi);\n        } else if (nx > W - half) {\n          nx = W - half;\n          vxi = -Math.abs(vxi);\n        }\n        if (ny < half) {\n          ny = half;\n          vyi = Math.abs(vyi);\n        } else if (ny > H - half) {\n          ny = H - half;\n          vyi = -Math.abs(vyi);\n        }\n\n        px[i] = nx;\n        py[i] = ny;\n        vx[i] = vxi;\n        vy[i] = vyi;\n\n        const speed = Math.hypot(vxi, vyi);\n        const slotDist = Math.hypot(sxT - nx, slotY - ny);\n        if (slotDist > 2 || speed >= 1) allNear = false;\n        if (speed >= 0.5) allStill = false;\n        if (slotDist > 2) anyDeparted = true;\n\n        const el = items[i];\n        if (el) {\n          el.style.transform = `translate3d(${nx - half}px, ${ny - half}px, 0)`;\n        }\n      }\n\n      // badge clears only once an avatar has actually left its slot (position,\n      // not the speed-inclusive `allNear`) — not on every momentary\n      // pointer-out, so a hover blip that never visibly moves the row can't\n      // flicker the badge\n      if (resolvedNow && anyDeparted) setResolved(false);\n\n      if (wantSeek && seekT >= 1) {\n        if (allNear) setResolved(true);\n        if (allNear && allStill) {\n          // snap exactly to slots, zero out, and genuinely sleep\n          for (let i = 0; i < n; i++) {\n            const sxT = slotX[i] ?? 0;\n            px[i] = sxT;\n            py[i] = slotY;\n            vx[i] = 0;\n            vy[i] = 0;\n            const el = items[i];\n            if (el) {\n              el.style.transform = `translate3d(${sxT - half}px, ${slotY - half}px, 0)`;\n            }\n          }\n          sleeping = true;\n          last = 0;\n          return;\n        }\n      }\n\n      if (ioVisible && docVisible && !sleeping) {\n        raf = requestAnimationFrame(step);\n      }\n    }\n\n    const wake = () => {\n      sleeping = false;\n      if (raf === 0 && ioVisible && docVisible) {\n        last = 0;\n        raf = requestAnimationFrame(step);\n      }\n    };\n    const pause = () => {\n      if (raf !== 0) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n      last = 0;\n    };\n\n    const onEnter = () => {\n      hovered = true;\n      wake();\n    };\n    const onLeave = () => {\n      hovered = false;\n      wake();\n    };\n    const onFocus = () => {\n      // keyboard focus behaves like hover; pointer clicks don't lock the row\n      focused = region.matches(\":focus-visible\");\n      if (focused) wake();\n    };\n    const onBlur = () => {\n      if (focused) {\n        focused = false;\n        wake();\n      }\n    };\n    const onVis = () => {\n      docVisible = !document.hidden;\n      if (!docVisible) pause();\n      else wake();\n    };\n    region.addEventListener(\"pointerenter\", onEnter);\n    region.addEventListener(\"pointerleave\", onLeave);\n    region.addEventListener(\"focus\", onFocus);\n    region.addEventListener(\"blur\", onBlur);\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // milling is the ambient default, so \"sleep\" also means: pause offscreen\n    const io = new IntersectionObserver((entries) => {\n      ioVisible = entries[0]?.isIntersecting ?? true;\n      if (!ioVisible) pause();\n      else wake();\n    });\n    io.observe(region);\n\n    const ro = new ResizeObserver(() => {\n      measure();\n      seed();\n      wake();\n    });\n    ro.observe(region);\n\n    raf = requestAnimationFrame(step);\n\n    return () => {\n      if (raf !== 0) cancelAnimationFrame(raf);\n      io.disconnect();\n      ro.disconnect();\n      region.removeEventListener(\"pointerenter\", onEnter);\n      region.removeEventListener(\"pointerleave\", onLeave);\n      region.removeEventListener(\"focus\", onFocus);\n      region.removeEventListener(\"blur\", onBlur);\n      document.removeEventListener(\"visibilitychange\", onVis);\n      detachHover.forEach((f) => f());\n    };\n  }, [members, avatarSize]);\n\n  const label =\n    ariaLabel ??\n    `Team: ${members.map((m) => m.name).join(\", \")}${\n      overflow > 0 ? ` and ${overflow} more` : \"\"\n    }`;\n\n  return (\n    <div\n      ref={regionRef}\n      tabIndex={0}\n      role=\"group\"\n      aria-label={label}\n      data-resolved=\"false\"\n      className={`relative w-full rounded-sm border border-border/60 bg-background/40 transition-colors hover:border-foreground/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${className}`}\n    >\n      {members.map((m, i) => (\n        <div\n          key={`${m.name}-${i}`}\n          ref={(el) => {\n            itemRefs.current[i] = el;\n          }}\n          className=\"absolute left-0 top-0 transition-opacity duration-300 will-change-transform\"\n          style={{ width: avatarSize, height: avatarSize, zIndex: i + 1, opacity: 0 }}\n        >\n          <div\n            data-avatar\n            className=\"flex h-full w-full select-none items-center justify-center rounded-full border border-border bg-surface font-mono text-[9px] font-medium text-muted ring-2 ring-background transition-transform duration-200\"\n          >\n            {m.src ? (\n              <img\n                src={m.src}\n                alt=\"\"\n                className=\"h-full w-full rounded-full object-cover\"\n              />\n            ) : (\n              initialsOf(m)\n            )}\n          </div>\n          <div\n            data-tip\n            aria-hidden\n            className=\"pointer-events-none absolute bottom-full left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-sm border border-border bg-surface px-1.5 py-0.5 font-mono text-[10px] text-foreground opacity-0 shadow-sm transition-opacity duration-150\"\n          >\n            {m.name}\n          </div>\n        </div>\n      ))}\n      {overflow > 0 && (\n        <div\n          ref={badgeRef}\n          className=\"absolute left-0 top-0 flex select-none items-center justify-center rounded-full border border-border bg-surface font-mono text-[10px] text-muted ring-2 ring-background transition-opacity duration-200\"\n          style={{\n            width: avatarSize,\n            height: avatarSize,\n            zIndex: members.length + 1,\n            opacity: 0,\n          }}\n        >\n          +{overflow}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/avatar-stack-flock.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "avatar",
      "boids",
      "physics",
      "hover",
      "team",
      "micro-interaction"
    ],
    "instruction": "A team avatar stack that never sits still: 7 DOM avatar circles (28 px, initials, ring-2 ring-background) mill inside a bounded card region as a real boids flock driven by a canvas-free vector sim (separation r34/w1.4, alignment r60/w0.6, cohesion r90/w0.5, max speed 60 px/s, soft-wall 300 px/s^2 ramping inside 24 px of the edge, damping pow(0.98, dt*60)). Hovering or keyboard-focusing the container ramps a per-avatar seek force toward its slot in the classic -8 px overlap right-aligned row from 0 to weight 2.2 over 400 ms, summed into the same three rules with arrival slowdown inside 40 px, so the tidy group photo is itself a settled flocking state. The 60 px/s milling speed cap is deliberately slow for a calm resting drift, so the seek phase ramps in its own, much higher travel-speed ceiling with the same 400 ms curve — idle milling is untouched but a from-scatter hover resolves the row in about a second instead of several. When every agent is within 2 px of its slot at under 1 px/s the +N badge fades in over 200 ms; release ramps the seek off over 400 ms, and the badge only fades out once the row has actually started to leave its slots (not on a momentary pointer-out) as the flock disperses back to milling. Transforms are written per-frame on a refs-only direct-DOM rAF loop that pauses offscreen (IntersectionObserver) and on document.hidden, and genuinely sleeps once resolved and settled below 0.5 px/s until hover exit; ResizeObserver re-derives bounds and slots with zero-size and zero-length-vector guards. Hovering an individual avatar once resolved lifts it 3 px and shows a token-styled name tooltip. All colors are CSS token classes (bg-surface, border-border, ring-background) so both themes self-adapt. prefers-reduced-motion renders the static resolved row with the badge always visible."
  },
  "type": "registry:ui"
}