{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "overflow-chip-mux",
  "title": "Overflow Chip Mux",
  "description": "The list/tag overflow indicator, replaced with real NES PPU sprite multiplexing: instead of collapsing extra items into a static \"+N\" pill, only 8 chip slots (the hardware's real 8-sprites-per-scanline limit) are ever visible at once, round-robining which contending item occupies each slot on a throttled cadence, exactly how 8-bit console flicker actually worked.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/overflow-chip-mux/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\n\n// ---------------------------------------------------------------------------\n// OverflowChipMux — the list/tag \"+N more\" overflow indicator, replaced with\n// the real NES PPU hardware limit it is named after. The 2C02 PPU can only\n// evaluate 8 hardware sprites per scanline; a 9th object contending for that\n// scanline in a given frame is silently dropped from the frame entirely —\n// a hardware limit, not a software choice. Games with more on-screen objects\n// than that worked around it with SPRITE MULTIPLEXING: round-robin which\n// subset of contending objects gets the 8 available slots each frame,\n// cycling on a schedule so every object gets its turn some fraction of the\n// time. That round-robin rotation — not a static \"+N\" pill — is this\n// component's entire mechanic.\n//\n// SLOT_BUDGET = 8 fixed chip DOM nodes are rendered at all times (keyed by\n// slot index, never by item identity); a swap changes only which item a\n// slot currently shows. Everything about a swap — which slot is chosen, its\n// content, and its leave/arrive animation — runs imperatively off refs, not\n// React state, so ticking never re-renders the tree: no layout thrash, no\n// accessibility-tree churn, and the leave/arrive transition is driven by\n// real CSS transitions rather than a state machine racing a render.\n//\n// Cadence: real 8-bit multiplexing code cycled every few FRAMES (roughly\n// 130ms at 60fps), but reproduced at that literal rate on a screen it reads\n// as noise, not a legible mechanic, to anyone not already told what they're\n// looking at — a real usability failure, not a stylistic choice. This\n// component keeps the same round-robin/FIFO/pin-on-hover mechanic but at a\n// cadence and per-swap animation slow enough to actually watch: one slot\n// changes at a time, its old label visibly slides up and out, then the new\n// label visibly slides up and in, before the row rests again.\n//\n// Round-robin model: each of the SLOT_BUDGET slots tracks an `age` in ticks\n// since it last changed. Every SWAP_INTERVAL_MS, the single oldest\n// non-pinned, currently-idle slot is evicted (the item it held goes to the\n// back of a FIFO queue of every other contending item) and the item at the\n// front of that queue takes its place — exactly one item rotates in/out per\n// tick, not the whole overflow set reshuffling at once, so a full cycle\n// back to the starting arrangement takes N * SWAP_INTERVAL_MS for N items.\n// ---------------------------------------------------------------------------\n\nexport interface OverflowChipItem {\n  id: string;\n  label: string;\n}\n\nexport interface OverflowChipMuxProps {\n  /** contending items — supply MORE than slotBudget to see multiplexing engage */\n  items?: OverflowChipItem[];\n  /** visible slot count — the real PPU per-scanline limit is 8 */\n  slotBudget?: number;\n  /** ms between round-robin swaps — slow enough to visually track one slot at a time */\n  swapIntervalMs?: number;\n  /** accessible label for the chip row's group role */\n  ariaLabel?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** inline styles merged onto the root element */\n  style?: CSSProperties;\n}\n\ninterface Slot {\n  itemIndex: number;\n  pinned: boolean;\n  age: number;\n  phase: \"idle\" | \"out\" | \"in\";\n}\n\nconst SLOT_BUDGET = 8; // NES PPU sprites-per-scanline hardware limit\nconst SWAP_INTERVAL_MS = 1100; // slow enough to watch one slot change at a time\nconst OUT_MS = 220; // outgoing label's visible leave\nconst IN_MS = 260; // incoming label's visible arrive\n\nconst DEFAULT_ITEMS: OverflowChipItem[] = [\n  \"goomba\", \"koopa\", \"piranha-plant\", \"buzzy-beetle\", \"lakitu\", \"spiny\",\n  \"bullet-bill\", \"hammer-bro\", \"boo\", \"cheep-cheep\", \"podoboo\", \"blooper\",\n  \"para-goomba\", \"dry-bones\",\n].map((label, i) => ({ id: `${i}-${label}`, label }));\n\nexport function OverflowChipMux({\n  items = DEFAULT_ITEMS,\n  slotBudget = SLOT_BUDGET,\n  swapIntervalMs = SWAP_INTERVAL_MS,\n  ariaLabel = \"Tags\",\n  className = \"\",\n  style,\n}: OverflowChipMuxProps) {\n  const budget = Math.max(1, Math.min(slotBudget, items.length || 1));\n  const slotsRef = useRef<Slot[]>([]);\n  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  useEffect(() => {\n    if (items.length <= budget) {\n      slotsRef.current = [];\n      return; // no overflow — matches real hardware, which only drops the 9th sprite\n    }\n\n    const slots: Slot[] = Array.from({ length: budget }, (_, i) => ({\n      itemIndex: i,\n      pinned: false,\n      age: budget - i,\n      phase: \"idle\",\n    }));\n    slotsRef.current = slots;\n    let queue = items.slice(budget).map((_, i) => budget + i);\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let interval = 0;\n    const outTimers: number[] = new Array(budget).fill(0);\n    const inTimers: number[] = new Array(budget).fill(0);\n    const rafs: number[] = new Array(budget).fill(0);\n\n    const setLabelText = (i: number, itemIndex: number) => {\n      const el = labelRefs.current[i];\n      const item = items[itemIndex];\n      if (el && item) el.textContent = item.label;\n    };\n\n    // three explicit visual states, driven by real CSS transitions rather\n    // than a keyframe, so \"leave\" and \"arrive\" are genuinely two separate,\n    // watchable motions rather than one blink standing in for both\n    const paint = (i: number, phase: \"idle\" | \"out\" | \"in-start\" | \"in\") => {\n      const el = labelRefs.current[i];\n      if (!el) return;\n      if (phase === \"out\") {\n        el.style.transition = `opacity ${OUT_MS}ms ease-in, transform ${OUT_MS}ms ease-in`;\n        el.style.opacity = \"0\";\n        el.style.transform = \"translateY(-7px)\";\n      } else if (phase === \"in-start\") {\n        el.style.transition = \"none\";\n        el.style.opacity = \"0\";\n        el.style.transform = \"translateY(7px)\";\n      } else if (phase === \"in\") {\n        void el.offsetHeight; // force the in-start style to commit before transitioning\n        el.style.transition = `opacity ${IN_MS}ms ease-out, transform ${IN_MS}ms ease-out`;\n        el.style.opacity = \"1\";\n        el.style.transform = \"translateY(0)\";\n      } else {\n        el.style.transition = \"none\";\n        el.style.opacity = \"1\";\n        el.style.transform = \"translateY(0)\";\n      }\n    };\n\n    const startSwap = (i: number) => {\n      const slot = slots[i];\n      if (!slot) return;\n      slot.phase = \"out\";\n      paint(i, \"out\");\n      outTimers[i] = window.setTimeout(() => {\n        const nextItemIndex = queue.shift();\n        if (nextItemIndex === undefined) return;\n        queue.push(slot.itemIndex);\n        slot.itemIndex = nextItemIndex;\n        setLabelText(i, nextItemIndex);\n        paint(i, \"in-start\");\n        rafs[i] = requestAnimationFrame(() => paint(i, \"in\"));\n        inTimers[i] = window.setTimeout(() => {\n          slot.phase = \"idle\";\n          slot.age = 0;\n        }, IN_MS);\n      }, OUT_MS);\n    };\n\n    const tick = () => {\n      let evictIdx = -1;\n      let maxAge = -1;\n      for (let i = 0; i < slots.length; i++) {\n        const s = slots[i];\n        if (s && s.phase === \"idle\" && !s.pinned) {\n          s.age += 1;\n          if (s.age > maxAge) {\n            maxAge = s.age;\n            evictIdx = i;\n          }\n        }\n      }\n      if (evictIdx === -1 || queue.length === 0) return; // nothing eligible this tick\n      startSwap(evictIdx);\n    };\n\n    const start = () => {\n      window.clearInterval(interval);\n      interval = window.setInterval(tick, swapIntervalMs);\n    };\n    const stop = () => window.clearInterval(interval);\n\n    if (!mq.matches) start();\n\n    const onReducedChange = () => {\n      if (mq.matches) stop();\n      else start();\n    };\n    mq.addEventListener(\"change\", onReducedChange);\n\n    return () => {\n      stop();\n      mq.removeEventListener(\"change\", onReducedChange);\n      for (let i = 0; i < budget; i++) {\n        window.clearTimeout(outTimers[i]);\n        window.clearTimeout(inTimers[i]);\n        const raf = rafs[i];\n        if (raf) cancelAnimationFrame(raf);\n      }\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items, budget, swapIntervalMs]);\n\n  const setPinned = (slotIdx: number, pinned: boolean) => {\n    const slot = slotsRef.current[slotIdx];\n    if (!slot) return;\n    slot.pinned = pinned;\n    if (!pinned) slot.age = 0;\n    const btn = buttonRefs.current[slotIdx];\n    if (btn) {\n      if (pinned) btn.dataset.pinned = \"true\";\n      else delete btn.dataset.pinned;\n    }\n  };\n\n  const visibleCount = Math.min(budget, items.length);\n  const total = items.length;\n\n  return (\n    <div className={`w-full ${className}`} style={style}>\n      <div role=\"group\" aria-label={ariaLabel} className=\"flex flex-wrap gap-1.5\">\n        {Array.from({ length: budget }, (_, i) => {\n          const item = items[i];\n          if (!item) return null;\n          return (\n            <button\n              key={i}\n              type=\"button\"\n              tabIndex={0}\n              ref={(el) => {\n                buttonRefs.current[i] = el;\n              }}\n              onPointerEnter={() => setPinned(i, true)}\n              onPointerLeave={() => setPinned(i, false)}\n              onFocus={() => setPinned(i, true)}\n              onBlur={() => setPinned(i, false)}\n              className=\"ns-ocm-chip inline-flex items-center overflow-hidden rounded-full border border-border bg-background px-2.5 py-1 text-xs text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent data-[pinned]:border-foreground/35\"\n            >\n              <span\n                ref={(el) => {\n                  labelRefs.current[i] = el;\n                }}\n                className=\"inline-block max-w-[16ch] truncate\"\n              >\n                {item.label}\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      <p className=\"mt-2 font-mono text-[11px] text-ns-muted\">\n        {visibleCount} of {total} shown, {total} total\n      </p>\n\n      {/* always in the DOM: the true, fully enumerable list — this is what\n          the \"+N more\" pattern always guaranteed and this replaces */}\n      <ul className=\"sr-only\">\n        {items.map((item) => (\n          <li key={item.id}>{item.label}</li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nOverflowChipMux.displayName = \"OverflowChipMux\";\n\nexport default OverflowChipMux;\n",
      "type": "registry:ui",
      "target": "components/ui/overflow-chip-mux.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": [
      "overflow",
      "tags",
      "chips",
      "nes",
      "sprite-multiplexing",
      "retro",
      "ambient",
      "accessibility"
    ],
    "instruction": "Build <OverflowChipMux items? slotBudget? swapIntervalMs? ariaLabel? className? style?>, a chip row that replaces the generic \"+N more\" overflow pattern with real NES PPU sprite multiplexing. The 2C02 PPU can only evaluate 8 hardware sprites per scanline; a 9th contending object in a frame is silently dropped from that frame entirely (a hardware limit, not a software choice), and games with more on-screen objects than that round-robinned which subset of contenders got the 8 available slots each frame, cycling on a throttled schedule so every object gets its turn some fraction of the time — the well-documented NES 'flicker' artifact. `SLOT_BUDGET` defaults to 8 (the real per-scanline limit) and is the count of chip DOM nodes ALWAYS rendered, keyed by fixed slot index 0..budget-1, never by item identity — a swap changes only which item's label a slot currently shows (text content + a one-shot CSS flicker keyframe replayed via a per-slot `flashKey` remounting an inner span), never which DOM node exists, so there is no layout thrash or accessibility-tree churn on every tick.\n\nRound-robin model: each slot tracks `age` (ticks since it last changed). Every `swapIntervalMs` (default 130ms, ~7.7Hz — a deliberately decimated cadence in the range real multiplexing code used, roughly every 8 frames at 60fps, specifically to stay legible instead of reading as an unreadable strobe), find the single oldest non-pinned slot, evict its item to the back of a FIFO queue holding every other contending item, and pull the item at the front of that queue into the freed slot with age reset to 0 — exactly one item rotates in/out per tick, never a full reshuffle, so a complete cycle back to the starting arrangement takes exactly `items.length * swapIntervalMs`. If `items.length <= slotBudget`, no interval starts at all and every item renders in its own slot with zero rotation — this correctly matches the real hardware, which only starts dropping sprites once a 9th contender shows up on the scanline; the shipped default seed is 14 items specifically so multiplexing is visibly engaged out of the box (a caller testing with a short list would otherwise see nothing alive, which is not a bug in the component).\n\nInteraction: each chip is a focusable `<button type=\"button\">`; `onPointerEnter`/`onFocus` pins its slot (excluded from the eviction search, so it never rotates out while hovered/focused) and `onPointerLeave`/`onBlur` unpins it (age reset to 0, so it re-enters the rotation fresh rather than being evicted immediately for being 'old'). The pin affordance's `focus-visible` ring is the only place `--ns-accent` appears anywhere in this component — the swap/flicker mechanism itself, including the one-shot per-swap flicker keyframe, uses opacity only (0.25 to 1 over 90ms), zero hue, zero accent, exactly per the token rules. All chip fill/border comes from `--foreground`/`--ns-muted`/`--border` via ordinary Tailwind token classes (`border-border`, `bg-background`, `text-foreground`, `text-ns-muted`) — no canvas, no getComputedStyle, nothing to re-derive on theme change.\n\nAccessibility is required, not optional, and is what earns this component the right to replace '+N more' at all: a `sr-only` `<ul>` enumerating every single item (not just the currently-visible 8) is present in the DOM at all times regardless of overflow state, and a visible (non-sr-only) plain-text line below the row always reads `${visibleCount} of ${total} shown, ${total} total` so both sighted and non-sighted users have the true count without depending on the flicker ever being witnessed. `prefers-reduced-motion` never starts the interval at all — the component simply renders its initial build state (slots 0..budget-1 holding items 0..budget-1 in source order, round-robin index 0) and stays there, which is deliberately the SAME code path as the first frame of the animated version, not a separate branch, so the reduced-motion freeze is provably the 'first budget-full pass' frame the spec calls for. A `matchMedia` change listener starts/stops the interval live if the OS preference toggles mid-session. Interval is cleared on unmount and whenever `items`/`slotBudget`/`swapIntervalMs` change (which also rebuilds the round-robin queue from index 0). No dependencies."
  },
  "type": "registry:ui"
}