{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feed-escapement",
  "title": "Feed Escapement",
  "description": "Live feed where a small SVG anchor fork gates arrivals one at a time: a burst becomes a metered tick-tick-tick instead of a dogpile, held open only by each item's own spring-settle, not a schedule.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/feed-escapement/component.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// EscapementTick — a live feed admitted one item at a time by a physical\n// gate, not a scheduler. Arrivals mount immediately as collapsed, aria-hidden\n// list rows (height 0, translateY -8px) — the queue is real DOM, just\n// invisible. A small SVG anchor fork in the gutter rocks +-12deg each time it\n// lets exactly one row through; that row's height/translateY are driven by a\n// single rAF spring (refs only, no React state on the hot path), and the\n// anchor will not rock again until THAT spring's displacement and velocity\n// both fall under epsilon. Because it's real block-level height growing in\n// normal flow, everything already released underneath shifts down together\n// as one connected chain — there is no per-row stagger to schedule. A burst\n// of arrivals becomes calm tick-tick-tick admission instead of a dogpile.\n// aria-live=polite (role=log) + per-row aria-atomic gives screen reader\n// users the same serialized cadence for free. \"Release all\" (button or\n// Escape) flushes the queue instantly, for when metered admission isn't\n// what's wanted.\n// ---------------------------------------------------------------------------\n\nexport interface EscapementEntryInput {\n  title: string;\n  detail?: string;\n  /** display timestamp; defaults to the current time */\n  timestamp?: string;\n}\n\nexport interface EscapementTickHandle {\n  /** enqueue one item behind the escapement; returns its id */\n  arrive: (entry: EscapementEntryInput) => string;\n  /** instantly reveal every pending item, skipping the metered cadence */\n  releaseAll: () => void;\n  /** remove every item and reset the queue */\n  clear: () => void;\n}\n\ntype EntryRec = {\n  id: string;\n  title: string;\n  detail?: string;\n  timestamp: string;\n};\n\ntype SpringNode = {\n  el: HTMLLIElement;\n  natural: number;\n  t: number;\n  v: number;\n  raf: number;\n  last: number;\n};\n\n// spring constants — underdamped just enough for one small settle wobble,\n// not a bounce; tuned against REST_OFFSET/ANCHOR_ANGLE below\nconst SPRING_K = 210;\nconst SPRING_C = 25;\nconst EPS_POS = 0.003;\nconst EPS_VEL = 0.02;\nconst REST_OFFSET = -8; // px, matches the brief's hidden-state translateY\nconst ANCHOR_ANGLE = 12; // deg, the fork's rock amplitude\n\nlet idSeed = 0;\nfunction nextId() {\n  idSeed += 1;\n  return `esc-${idSeed}-${Date.now().toString(36)}`;\n}\n\nfunction nowLabel() {\n  return new Date().toLocaleTimeString([], {\n    hour12: false,\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    second: \"2-digit\",\n  });\n}\n\nfunction AnchorFork({ innerRef }: { innerRef: (el: SVGSVGElement | null) => void }) {\n  return (\n    <svg\n      ref={innerRef}\n      aria-hidden\n      width=\"22\"\n      height=\"22\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      className=\"shrink-0 text-ns-muted\"\n      style={{ transformOrigin: \"12px 12px\" }}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"1.6\" fill=\"currentColor\" />\n      <path d=\"M4.5 12h15\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n      <path\n        d=\"M4.5 12c0 1.8 1 3 2.6 3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M19.5 12c0 1.8-1 3-2.6 3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport const EscapementTick = forwardRef<\n  EscapementTickHandle,\n  {\n    /** extra classes merged onto the rendered root element */\n    className?: string;\n    /** accessible name for the feed. Default \"Live activity feed\". */\n    \"aria-label\"?: string;\n    /** shown while no items have arrived yet */\n    emptyLabel?: string;\n  }\n>(function EscapementTick(\n  {\n    className = \"\",\n    \"aria-label\": ariaLabel = \"Live activity feed\",\n    emptyLabel = \"Waiting for activity…\",\n  },\n  ref\n) {\n  const [entries, setEntries] = useState<EntryRec[]>([]);\n\n  const itemElRef = useRef<Map<string, HTMLLIElement>>(new Map());\n  const pendingRef = useRef<string[]>([]); // ids awaiting release, FIFO\n  const activeIdRef = useRef<string | null>(null);\n  const springRef = useRef<SpringNode | null>(null);\n  const tickDirRef = useRef(1);\n  const anchorElRef = useRef<SVGSVGElement | null>(null);\n  const anchorTimeoutRef = useRef<number | null>(null);\n  const reducedRef = useRef(false);\n  const counterElRef = useRef<HTMLSpanElement>(null);\n  const depthRef = useRef(0);\n  // stable indirection: releaseNext recurses into \"itself\" via this ref so\n  // its own useCallback identity never needs to depend on itself\n  const releaseNextRef = useRef<() => void>(() => {});\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = (e: MediaQueryListEvent) => {\n      reducedRef.current = e.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    return () => {\n      if (springRef.current) cancelAnimationFrame(springRef.current.raf);\n      if (anchorTimeoutRef.current !== null) window.clearTimeout(anchorTimeoutRef.current);\n    };\n  }, []);\n\n  const setCounter = useCallback((n: number) => {\n    depthRef.current = n;\n    const el = counterElRef.current;\n    if (el) el.textContent = String(n);\n  }, []);\n\n  const rockAnchor = useCallback(() => {\n    const el = anchorElRef.current;\n    if (!el || reducedRef.current) return;\n    if (anchorTimeoutRef.current !== null) window.clearTimeout(anchorTimeoutRef.current);\n    tickDirRef.current *= -1;\n    const angle = tickDirRef.current * ANCHOR_ANGLE;\n    el.style.transition = \"transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1)\";\n    el.style.transform = `rotate(${angle}deg)`;\n    anchorTimeoutRef.current = window.setTimeout(() => {\n      el.style.transition = \"transform 260ms cubic-bezier(0.22, 1, 0.36, 1)\";\n      el.style.transform = \"rotate(0deg)\";\n      anchorTimeoutRef.current = null;\n    }, 200);\n  }, []);\n\n  const settleImmediately = useCallback((el: HTMLLIElement) => {\n    el.style.transition = \"none\";\n    el.style.height = \"auto\";\n    el.style.transform = \"translateY(0px)\";\n    el.removeAttribute(\"aria-hidden\");\n  }, []);\n\n  // the escapement gate: exactly one spring runs at a time. It only pulls\n  // the next queued id once displacement AND velocity are both under\n  // epsilon, so cadence follows real settle time, never a fixed offset.\n  const releaseNext = useCallback(() => {\n    if (activeIdRef.current !== null) return; // still settling — gate closed\n    const next = pendingRef.current.shift();\n    if (!next) return;\n    const el = itemElRef.current.get(next);\n    if (!el) {\n      releaseNextRef.current();\n      return;\n    }\n    activeIdRef.current = next;\n    el.removeAttribute(\"aria-hidden\");\n    rockAnchor();\n    const natural = el.scrollHeight;\n    const node: SpringNode = { el, natural, t: 0, v: 0, raf: 0, last: 0 };\n    springRef.current = node;\n\n    const step = (now: number) => {\n      const dt = node.last === 0 ? 1 / 60 : Math.min(0.05, (now - node.last) / 1000);\n      node.last = now;\n      const force = SPRING_K * (1 - node.t) - SPRING_C * node.v;\n      node.v += force * dt;\n      node.t += node.v * dt;\n      node.el.style.height = `${Math.max(0, node.t) * node.natural}px`;\n      node.el.style.transform = `translateY(${((1 - node.t) * REST_OFFSET).toFixed(2)}px)`;\n\n      if (Math.abs(1 - node.t) < EPS_POS && Math.abs(node.v) < EPS_VEL) {\n        node.el.style.height = \"auto\";\n        node.el.style.transform = \"translateY(0px)\";\n        springRef.current = null;\n        activeIdRef.current = null;\n        setCounter(Math.max(0, depthRef.current - 1));\n        releaseNextRef.current();\n        return;\n      }\n      node.raf = requestAnimationFrame(step);\n    };\n    node.raf = requestAnimationFrame(step);\n  }, [rockAnchor, setCounter]);\n\n  releaseNextRef.current = releaseNext;\n\n  const attachItem = useCallback(\n    (id: string, el: HTMLLIElement | null) => {\n      if (!el) return;\n      if (itemElRef.current.get(id) === el) return; // same node, already wired\n      itemElRef.current.set(id, el);\n\n      if (reducedRef.current) {\n        // reduced motion: nothing actually queues, so the depth this item\n        // contributed in arrive() must come right back off — otherwise the\n        // \"queued\" counter climbs forever while every item lands instantly.\n        settleImmediately(el);\n        setCounter(Math.max(0, depthRef.current - 1));\n        return;\n      }\n      el.style.overflow = \"hidden\";\n      el.style.height = \"0px\";\n      el.style.transform = `translateY(${REST_OFFSET}px)`;\n      el.setAttribute(\"aria-hidden\", \"true\");\n      pendingRef.current.push(id);\n      releaseNextRef.current();\n    },\n    [settleImmediately, setCounter]\n  );\n\n  const arrive = useCallback(\n    (entry: EscapementEntryInput) => {\n      const id = nextId();\n      setEntries((prev) => [\n        { id, title: entry.title, detail: entry.detail, timestamp: entry.timestamp ?? nowLabel() },\n        ...prev,\n      ]);\n      setCounter(depthRef.current + 1);\n      return id;\n    },\n    [setCounter]\n  );\n\n  const releaseAll = useCallback(() => {\n    const node = springRef.current;\n    if (node) {\n      cancelAnimationFrame(node.raf);\n      settleImmediately(node.el);\n      springRef.current = null;\n    }\n    activeIdRef.current = null;\n    const remaining = pendingRef.current;\n    pendingRef.current = [];\n    for (const id of remaining) {\n      const el = itemElRef.current.get(id);\n      if (el) settleImmediately(el);\n    }\n    setCounter(0);\n  }, [settleImmediately, setCounter]);\n\n  const clear = useCallback(() => {\n    if (springRef.current) cancelAnimationFrame(springRef.current.raf);\n    springRef.current = null;\n    activeIdRef.current = null;\n    pendingRef.current = [];\n    itemElRef.current.clear();\n    setEntries([]);\n    setCounter(0);\n  }, [setCounter]);\n\n  useImperativeHandle(ref, () => ({ arrive, releaseAll, clear }), [arrive, releaseAll, clear]);\n\n  const onKeyDown = useCallback(\n    (e: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        releaseAll();\n      }\n    },\n    [releaseAll]\n  );\n\n  return (\n    <div\n      onKeyDown={onKeyDown}\n      className={`flex w-full flex-col rounded-md border border-border bg-surface ${className}`}\n    >\n      <div className=\"flex items-center gap-2.5 border-b border-border px-3.5 py-2.5\">\n        <AnchorFork innerRef={(el) => (anchorElRef.current = el)} />\n        <span className=\"font-mono text-[11px] tracking-wide text-ns-muted\">\n          <span ref={counterElRef}>0</span> queued\n        </span>\n        <button\n          type=\"button\"\n          onClick={releaseAll}\n          className=\"ml-auto cursor-pointer rounded-sm border border-border px-2.5 py-1 font-mono text-[10px] tracking-widest text-ns-muted transition-colors duration-150 hover:border-foreground/20 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n        >\n          RELEASE ALL\n        </button>\n      </div>\n      <ul\n        role=\"log\"\n        aria-live=\"polite\"\n        aria-relevant=\"additions\"\n        aria-label={ariaLabel}\n        className=\"flex flex-col overflow-hidden\"\n      >\n        {entries.length === 0 ? (\n          <li className=\"px-3.5 py-6 text-center font-mono text-[11px] text-ns-muted\">\n            {emptyLabel}\n          </li>\n        ) : (\n          entries.map((entry) => (\n            <li\n              key={entry.id}\n              ref={(el) => attachItem(entry.id, el)}\n              aria-atomic=\"true\"\n              className=\"border-b border-border/60 px-3.5 py-2.5 last:border-b-0 will-change-[height,transform]\"\n            >\n              <div className=\"flex items-baseline justify-between gap-3\">\n                <p className=\"truncate text-[13px] text-foreground\">{entry.title}</p>\n                <span className=\"shrink-0 font-mono text-[10px] tabular-nums text-ns-muted\">\n                  {entry.timestamp}\n                </span>\n              </div>\n              {entry.detail ? (\n                <p className=\"mt-0.5 truncate font-mono text-[11px] text-ns-muted\">{entry.detail}</p>\n              ) : null}\n            </li>\n          ))\n        )}\n      </ul>\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/ui/feed-escapement.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "feed",
      "notification",
      "queue",
      "physics",
      "aria-live",
      "log",
      "activity"
    ],
    "instruction": "A live feed (notification center, activity stream, log tail, chat pane) that admits queued arrivals one at a time through a physical gate rather than a scheduler. Arrivals mount immediately as real, hidden DOM rows (height 0, translateY -8px, aria-hidden) — the queue is genuine list items, not a data array waiting off-DOM. A small SVG anchor fork (aria-hidden) sits in the header gutter; each time it releases exactly one row it rocks +-12deg on a spring-eased CSS transition, and it will not rock again until that row's own settle spring — height and translateY driven by a single refs-only rAF spring (k 210, c 25, epsilon 0.003 displacement / 0.02 velocity, underdamped just enough for one small wobble) — has displacement AND velocity both under epsilon. That rest event is what pulls the next queued id off a FIFO, so cadence follows real content height and settle time rather than a fixed stagger offset: a tall row metered the same as a short one takes visibly longer to clear the gate. Because the released row is growing in normal block flow, every row already on screen shifts down together as one connected chain — there is no per-row animation to independently schedule, which is the load-bearing difference from avatar-stack-flock (a cohort of avatars animated together on one scheduled hover-driven formation change): here release N+1 is triggered by the physical rest of item N, one gate, one row at a time, and nothing else in the registry meters feed ingestion this way. A hairline font-mono --ns-muted counter beside the anchor tracks queue depth, decremented only when a row's spring actually settles. The list itself is role=log aria-live=polite with aria-atomic per row, so the serialization screen readers already require lines up naturally with the escapement's own cadence — sighted and screen-reader users get the same metered order for free. A visible RELEASE ALL button, and Escape from anywhere in the component, flushes every pending row to its settled state instantly, for when metered admission isn't what's wanted. prefers-reduced-motion disables the escapement entirely: rows append in their settled state immediately on arrival (no rocking, no spring, no queue), with the depth counter still present and correctly reading zero since nothing is actually held back. No canvas — pure DOM, SVG and CSS."
  },
  "type": "registry:ui"
}