{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-metaball-merge",
  "title": "Status Metaball Merge",
  "description": "Group membership drawn as an implicit surface: each item is a charge in an inverse-square field, and a marching-squares isoline at F = 1 is stroked as a bare outline, so items in the same group share one necked contour and an item that leaves visibly pinches off into its own closed loop.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/status-metaball-merge/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// MetaballMerge — group membership drawn as an IMPLICIT SURFACE. Every item is\n// a charge in an inverse-square scalar field, F(x,y) = Σ rᵢ² / (dᵢ² + 1e-3),\n// and the only thing ever painted is the marching-squares isoline at F = 1,\n// stroked as a bare hairline. Nothing is filled, so the frame stays almost\n// entirely empty and the SHAPE carries the whole message: charges close enough\n// together share one necked contour, and an item that leaves visibly pinches\n// off into its own closed loop. No legend, no caption, no colour code.\n// ---------------------------------------------------------------------------\n\nexport interface MetaballItem {\n  id: string;\n  label: string;\n  /** starts inside the group (default true) */\n  member?: boolean;\n}\n\nexport interface MetaballMergeProps {\n  /** the mergeable blobs */\n  items?: MetaballItem[];\n  /** names the group for assistive tech */\n  label?: string;\n  /** called with the full list of selected (merged) ids after any toggle */\n  onChange?: (memberIds: string[]) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_ITEMS: MetaballItem[] = [\n  { id: \"mara\", label: \"Mara Chen\" },\n  { id: \"jonas\", label: \"Jonas Weber\" },\n  { id: \"aiko\", label: \"Aiko Tanaka\" },\n  { id: \"ravi\", label: \"Ravi Patel\", member: false },\n];\n\n// --- field / isoline ------------------------------------------------------\nconst GRID = 4; // px lattice for marching squares\nconst ISO = 1.0; // threshold — a lone charge's contour is exactly its radius\nconst EPS = 1e-3; // softening, keeps F finite at a charge centre\nconst STROKE_W = 1.25;\nconst STROKE_A = 0.85;\n\n// --- layout ---------------------------------------------------------------\nconst IN_F = 0.5; // member anchor, as a fraction of the cluster radius Rc\nconst OUT_F = 1.35; // non-member anchor — comfortably past the merge distance\nconst FIT = 0.47; // outermost anchor + its radius, as a fraction of min(w,h)\n// Charge radius is derived from the member-to-member chord rather than picked\n// by eye: two equal charges a chord S apart neck at half-width\n// √(2r² − S²/4), so r = 0.4·S puts the waist at ~0.66r — thin enough to read\n// as a genuine neck, thick enough that the ±6% ambient breathing never\n// severs it. Member slots sit one ARC step apart, so that chord is constant.\nconst R_OF_CHORD = 0.4;\nconst HOVER_SWELL = 1.18;\n\n// --- motion ---------------------------------------------------------------\nconst K = 120; // spring stiffness, s^-2\nconst ZETA = 1.0; // critically damped\nconst C = 2 * ZETA * Math.sqrt(K);\nconst ORBIT_R = 3; // px ambient orbit radius\nconst ORBIT_HZ = 0.11;\nconst BREATHE = 0.06; // ±6% radius\nconst BREATHE_HZ = 0.07;\nconst DT_MAX = 0.05;\nconst SETTLED = 0.02;\nconst TAU = Math.PI * 2;\n\n// 16-case marching-squares segment table; corner bits are TL=1 TR=2 BR=4 BL=8\n// and an edge index is 0=top 1=right 2=bottom 3=left. Cases 5 and 10 are the\n// saddles and are resolved from the cell-centre sample instead.\nconst CASES: number[][] = [\n  [],\n  [3, 0],\n  [0, 1],\n  [3, 1],\n  [1, 2],\n  [],\n  [0, 2],\n  [3, 2],\n  [2, 3],\n  [2, 0],\n  [],\n  [2, 1],\n  [1, 3],\n  [1, 0],\n  [0, 3],\n  [],\n];\n\nfunction hash01(s: string): number {\n  let h = 2166136261;\n  for (let i = 0; i < s.length; i++) {\n    h ^= s.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return ((h >>> 0) % 10000) / 10000;\n}\n\n/** shortest signed angle from a to b, in (-π, π] */\nfunction angleDelta(a: number, b: number): number {\n  let d = (b - a) % TAU;\n  if (d > Math.PI) d -= TAU;\n  if (d <= -Math.PI) d += TAU;\n  return d;\n}\n\ntype Charge = {\n  id: string;\n  home: number; // the item's own direction from the centroid\n  slot: number; // the direction it is currently anchored along\n  dist: number; // anchor distance as a fraction of Rc\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  r: number;\n  vr: number;\n  phase: number;\n  breathPhase: number;\n  member: boolean;\n  hover: boolean;\n};\n\nexport function MetaballMerge({\n  items = DEFAULT_ITEMS,\n  label = \"Group\",\n  onChange,\n  className = \"\",\n}: MetaballMergeProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [members, setMembers] = useState<string[]>(() =>\n    items.filter((it) => it.member !== false).map((it) => it.id)\n  );\n  const membersRef = useRef(members);\n  membersRef.current = members;\n  const engineRef = useRef<{\n    sync: () => void;\n    setHover: (index: number, on: boolean) => void;\n  } | null>(null);\n\n  useEffect(() => {\n    engineRef.current?.sync();\n  }, [members]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) 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    const n = items.length;\n    // Member slots are one ARC_STEP apart on the inner circle. 2π/(n+2)\n    // guarantees the arc still has a 3-step gap even with every item inside,\n    // so the cluster is always an open chain and never closes into a ring\n    // with a hole punched through the middle.\n    const ARC_STEP = TAU / (n + 2);\n    const CHORD = 2 * IN_F * Math.sin(ARC_STEP / 2); // in units of Rc\n    const R_F = R_OF_CHORD * CHORD; // charge radius, in units of Rc\n\n    const charges: Charge[] = items.map((it, i) => {\n      const home = -Math.PI / 2 + (TAU * i) / Math.max(1, n);\n      return {\n        id: it.id,\n        home,\n        slot: home,\n        dist: OUT_F,\n        x: 0,\n        y: 0,\n        vx: 0,\n        vy: 0,\n        r: 0,\n        vr: 0,\n        phase: hash01(it.id) * TAU,\n        breathPhase: hash01(it.id + \"~b\") * TAU,\n        member: false,\n        hover: false,\n      };\n    });\n\n    let ink = \"currentColor\";\n    let w = 0;\n    let h = 0;\n    let cx = 0;\n    let cy = 0;\n    let rc = 0;\n    let gw = 0;\n    let gh = 0;\n    let field = new Float32Array(0);\n    let sized = false;\n    let seeded = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n\n    const readInk = () => {\n      ink = getComputedStyle(canvas).color;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 8 || rect.height < 8) {\n        sized = false;\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      cx = w / 2;\n      cy = h / 2;\n      // the outermost anchor plus its own radius has to stay inside the box\n      rc = (FIT * Math.min(w, h)) / (OUT_F + R_F);\n      gw = Math.floor(w / GRID) + 1;\n      gh = Math.floor(h / GRID) + 1;\n      if (field.length !== gw * gh) field = new Float32Array(gw * gh);\n      sized = true;\n    };\n\n    // Membership decides BOTH the anchor distance and which slot the item\n    // takes: members are packed onto consecutive slots of an arc centred on\n    // their own mean direction, so every member is a neighbour of another\n    // member and the group is always one connected contour — never two\n    // \"grouped\" items sitting too far apart to merge. Non-members keep their\n    // own direction and are pushed out to 1.35·Rc.\n    const relayout = () => {\n      const set = membersRef.current;\n      for (const c of charges) c.member = set.includes(c.id);\n      const mem = charges.filter((c) => c.member);\n      for (const c of charges) {\n        if (!c.member) {\n          c.slot = c.home;\n          c.dist = OUT_F;\n        }\n      }\n      if (mem.length === 0) return;\n      let sx = 0;\n      let sy = 0;\n      for (const c of mem) {\n        sx += Math.cos(c.home);\n        sy += Math.sin(c.home);\n      }\n      const mean =\n        sx === 0 && sy === 0 ? charges[0]!.home : Math.atan2(sy, sx);\n      // keep the members' original circular order so slots never cross\n      mem.sort((a, b) => angleDelta(mean, a.home) - angleDelta(mean, b.home));\n      const half = (mem.length - 1) / 2;\n      mem.forEach((c, k) => {\n        c.slot = mean + (k - half) * ARC_STEP;\n        c.dist = IN_F;\n      });\n    };\n\n    const targetOf = (c: Charge) => ({\n      x: cx + Math.cos(c.slot) * c.dist * rc,\n      y: cy + Math.sin(c.slot) * c.dist * rc,\n      r: R_F * rc * (c.hover ? HOVER_SWELL : 1),\n    });\n\n    const seed = () => {\n      for (const c of charges) {\n        const tg = targetOf(c);\n        c.x = tg.x;\n        c.y = tg.y;\n        c.r = tg.r;\n        c.vx = c.vy = c.vr = 0;\n      }\n      seeded = true;\n    };\n\n    // integrate every spring one step; returns true while anything still moves\n    const step = (dt: number) => {\n      let moving = false;\n      for (const c of charges) {\n        const tg = targetOf(c);\n        const ax = -K * (c.x - tg.x) - C * c.vx;\n        const ay = -K * (c.y - tg.y) - C * c.vy;\n        const ar = -K * (c.r - tg.r) - C * c.vr;\n        c.vx += ax * dt;\n        c.vy += ay * dt;\n        c.vr += ar * dt;\n        c.x += c.vx * dt;\n        c.y += c.vy * dt;\n        c.r += c.vr * dt;\n        if (\n          Math.abs(c.x - tg.x) > SETTLED ||\n          Math.abs(c.y - tg.y) > SETTLED ||\n          Math.abs(c.r - tg.r) > SETTLED ||\n          Math.abs(c.vx) > SETTLED ||\n          Math.abs(c.vy) > SETTLED ||\n          Math.abs(c.vr) > SETTLED\n        ) {\n          moving = true;\n        }\n      }\n      return moving;\n    };\n\n    // resolved charge positions/radii for this frame, ambient pulse folded in\n    const px = new Float64Array(n);\n    const py = new Float64Array(n);\n    const pr2 = new Float64Array(n);\n\n    const resolve = (time: number) => {\n      for (let i = 0; i < n; i++) {\n        const c = charges[i]!;\n        if (reduced) {\n          px[i] = c.x;\n          py[i] = c.y;\n          pr2[i] = c.r * c.r;\n          continue;\n        }\n        const a = TAU * ORBIT_HZ * time + c.phase;\n        px[i] = c.x + Math.cos(a) * ORBIT_R;\n        py[i] = c.y + Math.sin(a) * ORBIT_R;\n        const br =\n          c.r * (1 + BREATHE * Math.sin(TAU * BREATHE_HZ * time + c.breathPhase));\n        pr2[i] = br * br;\n      }\n    };\n\n    const fieldAt = (x: number, y: number) => {\n      let f = 0;\n      for (let i = 0; i < n; i++) {\n        const dx = x - px[i]!;\n        const dy = y - py[i]!;\n        f += pr2[i]! / (dx * dx + dy * dy + EPS);\n      }\n      return f;\n    };\n\n    const render = () => {\n      if (!sized) return;\n      ctx.clearRect(0, 0, w, h);\n\n      for (let gy = 0; gy < gh; gy++) {\n        const y = gy * GRID;\n        const row = gy * gw;\n        for (let gx = 0; gx < gw; gx++) {\n          field[row + gx] = fieldAt(gx * GRID, y);\n        }\n      }\n\n      const path = new Path2D();\n      // linear interpolation along a cell edge — a midpoint placement here is\n      // what makes a marching-squares contour look visibly stair-stepped\n      const lerp = (fa: number, fb: number) => {\n        const d = fb - fa;\n        if (Math.abs(d) < 1e-9) return 0.5;\n        const tx = (ISO - fa) / d;\n        return tx < 0 ? 0 : tx > 1 ? 1 : tx;\n      };\n\n      for (let gy = 0; gy < gh - 1; gy++) {\n        const r0 = gy * gw;\n        const r1 = r0 + gw;\n        const y0 = gy * GRID;\n        const y1 = y0 + GRID;\n        for (let gx = 0; gx < gw - 1; gx++) {\n          const f00 = field[r0 + gx]!;\n          const f10 = field[r0 + gx + 1]!;\n          const f11 = field[r1 + gx + 1]!;\n          const f01 = field[r1 + gx]!;\n          let mask = 0;\n          if (f00 >= ISO) mask |= 1;\n          if (f10 >= ISO) mask |= 2;\n          if (f11 >= ISO) mask |= 4;\n          if (f01 >= ISO) mask |= 8;\n          if (mask === 0 || mask === 15) continue;\n\n          const x0 = gx * GRID;\n          const x1 = x0 + GRID;\n\n          let segs = CASES[mask]!;\n          if (mask === 5 || mask === 10) {\n            // saddle: the cell-centre sample decides whether the two inside\n            // corners are joined through the middle or genuinely separate,\n            // which is what makes a neck pinch instead of resolving as an X\n            const inside = fieldAt(x0 + GRID / 2, y0 + GRID / 2) >= ISO;\n            if (mask === 5) segs = inside ? [0, 1, 2, 3] : [3, 0, 1, 2];\n            else segs = inside ? [3, 0, 1, 2] : [0, 1, 2, 3];\n          }\n\n          for (let s = 0; s < segs.length; s += 2) {\n            const ea = segs[s]!;\n            const eb = segs[s + 1]!;\n            for (let k = 0; k < 2; k++) {\n              const e = k === 0 ? ea : eb;\n              let ptx = 0;\n              let pty = 0;\n              if (e === 0) {\n                ptx = x0 + lerp(f00, f10) * GRID;\n                pty = y0;\n              } else if (e === 1) {\n                ptx = x1;\n                pty = y0 + lerp(f10, f11) * GRID;\n              } else if (e === 2) {\n                ptx = x1 - lerp(f11, f01) * GRID;\n                pty = y1;\n              } else {\n                ptx = x0;\n                pty = y1 - lerp(f01, f00) * GRID;\n              }\n              if (k === 0) path.moveTo(ptx, pty);\n              else path.lineTo(ptx, pty);\n            }\n          }\n        }\n      }\n\n      ctx.strokeStyle = ink;\n      ctx.lineWidth = STROKE_W;\n      ctx.lineJoin = \"round\";\n      ctx.lineCap = \"round\";\n      ctx.globalAlpha = STROKE_A;\n      ctx.stroke(path);\n      ctx.globalAlpha = 1;\n    };\n\n    const drawStill = () => {\n      if (!sized) return;\n      resolve(0);\n      render();\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      const moving = step(dt);\n      resolve(t);\n      render();\n      // the loop only sleeps when the springs have settled AND there is no\n      // ambient pulse left to render — i.e. never, unless motion is reduced\n      raf = document.hidden || (reduced && !moving) ? 0 : requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (reduced || raf) return;\n      last = 0;\n      raf = requestAnimationFrame(loop);\n    };\n\n    const sync = () => {\n      relayout();\n      if (!sized) return;\n      if (reduced) {\n        // no ambient motion, no glide: jump the anchors and repaint one frame\n        seed();\n        drawStill();\n      } else {\n        start();\n      }\n    };\n\n    engineRef.current = {\n      sync,\n      setHover: (index, on) => {\n        // reduced motion: the swell is a pointer-driven animation, so it is\n        // dropped entirely rather than jumped to — the chip's own focus ring\n        // still carries the cue\n        if (reduced) return;\n        const c = charges[index];\n        if (!c) return;\n        c.hover = on;\n        start();\n      },\n    };\n\n    const ro = new ResizeObserver(() => {\n      const before = sized;\n      resize();\n      if (!sized) return;\n      if (!before || !seeded) seed();\n      if (reduced) drawStill();\n      else start();\n    });\n    ro.observe(canvas);\n\n    const mo = new MutationObserver(() => {\n      readInk();\n      if (reduced) drawStill();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const onVis = () => {\n      if (!document.hidden) start();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    readInk();\n    relayout();\n    resize();\n    if (sized) seed();\n    if (reduced) drawStill();\n    else start();\n\n    return () => {\n      engineRef.current = null;\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [items]);\n\n  const toggle = (id: string) => {\n    setMembers((prev) => {\n      const next = prev.includes(id)\n        ? prev.filter((m) => m !== id)\n        : [...prev, id];\n      onChange?.(next);\n      return next;\n    });\n  };\n\n  const count = members.length;\n\n  return (\n    <div className={`flex w-full flex-col gap-4 ${className}`}>\n      <div className=\"relative w-full\" style={{ aspectRatio: \"6 / 5\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden\n          className=\"absolute inset-0 block h-full w-full text-foreground\"\n        />\n      </div>\n\n      <div className=\"flex flex-col items-center gap-3\">\n        <div\n          role=\"group\"\n          aria-label={label}\n          className=\"flex flex-wrap items-center justify-center gap-2\"\n        >\n          {items.map((it, i) => {\n            const isMember = members.includes(it.id);\n            return (\n              <button\n                key={it.id}\n                type=\"button\"\n                aria-pressed={isMember}\n                onClick={() => toggle(it.id)}\n                onPointerEnter={() => engineRef.current?.setHover(i, true)}\n                onPointerLeave={() => engineRef.current?.setHover(i, false)}\n                onFocus={() => engineRef.current?.setHover(i, true)}\n                onBlur={() => engineRef.current?.setHover(i, false)}\n                className={`flex items-center gap-2 rounded-full border px-3 py-1.5 font-mono text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${\n                  isMember\n                    ? \"border-foreground/40 text-foreground\"\n                    : \"border-border text-ns-muted\"\n                }`}\n              >\n                <span\n                  aria-hidden\n                  className={`size-1.5 rounded-full ${\n                    isMember ? \"bg-foreground\" : \"bg-border\"\n                  }`}\n                />\n                {it.label}\n              </button>\n            );\n          })}\n        </div>\n\n        <p\n          aria-hidden\n          data-merged={count}\n          className=\"font-mono text-[10px] uppercase tracking-[0.18em] text-ns-muted\"\n        >\n          {count} of {items.length} in {label}\n        </p>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {`${count} ${count === 1 ? \"item\" : \"items\"} grouped`}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/status-metaball-merge.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": [
      "status",
      "canvas",
      "metaball",
      "marching-squares",
      "isoline",
      "grouping",
      "membership",
      "aria-live"
    ],
    "instruction": "Build <MetaballMerge items? label? onChange? className?> as a bare-outline metaball grouping widget: a <canvas> field box above a row of chip buttons. FIELD: every item is a charge, and the scalar field is the classic inverse-square metaball sum F(x, y) = Σᵢ rᵢ² / ((x − xᵢ)² + (y − yᵢ)² + 1e-3) — the 1e-3 softening is what keeps F finite at a charge centre. Because the threshold is exactly 1.0, rᵢ is literally the radius a lone charge's contour would have, and two equal charges merge once they are closer than 2√2·r ≈ 2.83·r; membership is therefore not a colour or a badge, it is whether the surface connects. ISOLINE: marching squares on a 4 px lattice over the panel's field box. F is sampled into a reused Float32Array (reallocated only when the grid size changes), then per cell a 4-bit corner mask (TL=1 TR=2 BR=4 BL=8) is built against the threshold and indexed into the standard 16-case segment table, edges numbered 0=top 1=right 2=bottom 3=left. Every segment endpoint is placed by LINEAR interpolation along its edge, t = (1 − Fa) / (Fb − Fa) clamped to [0,1] — never at the edge midpoint, or the contour comes out visibly stair-stepped at this lattice size. The two saddle cases (mask 5 and 10) are disambiguated by re-evaluating F at the cell centre: if the centre is inside, the two inside corners are joined through the cell and the outside corners are isolated instead, so a neck resolves as a genuine pinch rather than an X. All segments accumulate into one Path2D and are stroked ONCE per frame at lineWidth 1.25 in the canvas's own colour, globalAlpha 0.85 — one stroke call, no fill, no shadow, no glow, which is what keeps the frame almost entirely empty. LAYOUT: each item has a chip button below the canvas and an anchor around the cluster centroid; a member's anchor sits at 0.5·Rc (well inside the merge distance) and a non-member's at 1.35·Rc (well outside it — the gap is about 1.5× the merge distance even with the ambient breathing at its peak), with Rc = 0.47·min(w,h) / (1.35 + r/Rc) so the outermost loop plus its own radius always stays in the box. Members are packed onto consecutive slots of an arc one ARC_STEP = 2π/(n+2) apart, centred on the members' own mean direction and kept in their original circular order — that spacing guarantees the group is always ONE connected chain (never two 'grouped' items too far apart to merge) and that the arc always keeps a three-step gap, so it can never close into a ring with a hole punched through the middle. Non-members keep their own direction. The charge radius is derived from that constant member-to-member chord S rather than picked by eye: since the waist half-width between two equal charges is √(2r² − S²/4), r = 0.4·S puts the neck at about 0.66·r — thin enough to read as a deliberate waist, thick enough that the ambient breathing never severs it. AMBIENT PULSE: each charge's centre orbits its anchor on a 3 px circle at 0.11 Hz with an FNV-hash-derived per-item phase, and rᵢ breathes ±6% at 0.07 Hz on a second hash-derived phase, so the neck between merged blobs visibly thins and thickens on a slow beat with jitter and nothing ever holds a dead pose. STATE: toggling a chip retargets that item's anchor and the charge glides there on a critically damped spring (k = 120 s⁻², ζ = 1, so c = 2√k), so the contour necks, thins and pinches into a separate closed loop over roughly 500 ms — at exactly the moment the item stops belonging. HOVER/FOCUS: pointerenter or keyboard focus on a chip raises that item's rᵢ by 18% on the same spring, so its blob swells and its neck to the cluster fattens — the identity cue is 'which lobe grew', which needs no legend; pointerleave/blur springs it straight back. TOKENS: the canvas is the medium, so ink is read from getComputedStyle(canvas).color at mount (the canvas carries text-foreground) and re-derived on a documentElement class MutationObserver, so a theme flip is live and no colour literal is ever baked in; chips use --border/--foreground and --ns-accent appears only on the chip focus ring. PERF/A11Y: dpr clamped to 2, a ResizeObserver with a zero-size guard, one rAF loop that is the sole canvas writer and pauses on document.hidden, and a sleep condition that stops the loop once every spring is under epsilon AND the ambient pulse is disabled. The canvas is aria-hidden; the chips are real <button aria-pressed> controls inside a role=group, each with a visible accent focus ring, and a visually hidden aria-live=polite region announces 'N items grouped' on every toggle, so the canvas is never the only channel. prefers-reduced-motion: no rAF loop at all — no ambient orbit, no breathing and no pointer/focus swell (the chip's own focus ring carries that cue instead), and a toggle jumps the anchors to their new positions and redraws exactly one frame — the merged-versus-split contour still reads perfectly with zero motion. Props: items ({ id, label, member? }[], default a four-person approver set), label (names the group for assistive tech), onChange (member ids), className. Zero dependencies."
  },
  "type": "registry:ui"
}