{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carbon-ply-fade",
  "title": "Carbon Ply Fade",
  "description": "Activity feed rendered as a carbon-paper multi-part form: one strike lands on the top ply at full density and echoes down through 4-5 contiguous, fully-visible ply rows, each registering 90ms after the one above at 0.68x the density, matching real pressure-copy force dissipation through a stack.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/carbon-ply-fade/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CarbonPlyFade — an activity feed rendered as a carbon-paper multi-part\n// form. Ply 1 through ply N are stacked as full-width, fully-visible rows\n// inside one bordered form (a real stack, contiguous rows rather than\n// overlapping cards — overlap would bury a lower ply's text under the\n// opaque ply above it, which is the one thing that must stay legible: the\n// falloff has to be readable on every ply at once). Each row is nudged\n// ~2px right per ply below it (the spec's \"2px right per ply behind it\")\n// to read as a peeled multi-part slip. A \"strike\" (one event) lands on ply\n// 1 at full density and propagates down through the stack 90ms per row,\n// each ply's registered density = the one above it x0.68 — the real\n// force-dissipation falloff of a pressure-copy stack, not a chemical or\n// optical fade. A ply's content flashes to its peak density INSTANTLY on\n// arrival and holds there (carbon transfer is permanent per pull, it does\n// not fade back out); only the row itself gives a small physical squash\n// (scaleY 0.985, 60ms) and spring-back (140ms) on impact. Between strikes\n// every ply is static for the remaining ~700ms of the 1.1s cadence.\n// Geometry (row height, offset, font size, ply count) derives from the\n// container's smaller dimension so it reads at card scale. Hovering ply 1\n// pauses the cadence without touching what's already registered; the\n// strike never uses --ns-accent, density is luminance-only. prefers-\n// reduced-motion freezes on a fully-propagated strike, 90ms x3 after it\n// lands, every ply settled and none mid-transit.\n// ---------------------------------------------------------------------------\n\nconst STRIKE_INTERVAL_MS = 1100; // top-of-cadence, one strike per interval\nconst PROPAGATION_DELAY_MS = 90; // per-ply registration delay below the one above\nconst FALLOFF_RATIO = 0.68; // ply(N) peak density = ply(N-1) peak density * this\nconst MIN_OPACITY = 0.12; // floor so a deep ply reads \"faint\", never \"absent\"\nconst ROW_HEIGHT = 40; // px, one ply's readable row height at card scale, before scale\nconst MIN_PLY_COUNT = 4;\nconst MAX_PLY_COUNT = 5;\nconst OFFSET_X = 2; // px per ply, horizontal peel, before scale\nconst COMPRESS_MS = 60;\nconst SPRING_BACK_MS = 140;\n\nconst DEFAULT_EVENTS = [\n  \"Invoice #4471 → Billing, Ops, Archive\",\n  \"Order #8825 → Warehouse, Shipping\",\n  \"Ticket #221 → Support, Escalations\",\n  \"Memo 6B → Legal, Compliance, Records\",\n  \"Claim #503 → Underwriting, Adjuster\",\n];\n\nfunction opacityForPly(i: number): number {\n  return Math.max(MIN_OPACITY, Math.pow(FALLOFF_RATIO, i));\n}\n\ntype PlyNode = {\n  cardEl: HTMLDivElement | null;\n  textEl: HTMLParagraphElement | null;\n};\n\nexport interface CarbonPlyFadeProps {\n  className?: string;\n  /** accessible name for the live region carried by ply 1. Default \"Recent activity\". */\n  \"aria-label\"?: string;\n  /** rotating pool of event labels the strikes pull from; defaults to a built-in set */\n  events?: string[];\n}\n\nexport function CarbonPlyFade({\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Recent activity\",\n  events = DEFAULT_EVENTS,\n}: CarbonPlyFadeProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const plyRefs = useRef<PlyNode[]>([]);\n  const timeoutsRef = useRef<number[]>([]);\n  const strikeTimeoutRef = useRef<number | null>(null);\n  const eventIdxRef = useRef(-1);\n  const pausedRef = useRef(false);\n  const reducedRef = useRef(false);\n  const runStrikeRef = useRef<() => void>(() => {});\n  const [plyCount, setPlyCount] = useState(MIN_PLY_COUNT);\n  const [scale, setScale] = useState(1);\n\n  const plyIndices = useMemo(() => Array.from({ length: plyCount }, (_, i) => i), [plyCount]);\n\n  const clearAllTimeouts = useCallback(() => {\n    for (const id of timeoutsRef.current) window.clearTimeout(id);\n    timeoutsRef.current = [];\n    if (strikeTimeoutRef.current !== null) {\n      window.clearTimeout(strikeTimeoutRef.current);\n      strikeTimeoutRef.current = null;\n    }\n  }, []);\n\n  const registerPly = useCallback((i: number, text: string) => {\n    const ply = plyRefs.current[i];\n    if (!ply?.textEl || !ply.cardEl) return;\n    ply.textEl.textContent = text;\n    ply.textEl.style.opacity = String(opacityForPly(i));\n    const row = ply.cardEl;\n    row.style.transition = `transform ${COMPRESS_MS}ms ease-out`;\n    row.style.transform = \"scaleY(0.985)\";\n    const bounce = window.setTimeout(() => {\n      row.style.transition = `transform ${SPRING_BACK_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1)`;\n      row.style.transform = \"scaleY(1)\";\n    }, COMPRESS_MS);\n    timeoutsRef.current.push(bounce);\n  }, []);\n\n  const runStrike = useCallback(() => {\n    if (pausedRef.current) {\n      strikeTimeoutRef.current = window.setTimeout(() => runStrikeRef.current(), STRIKE_INTERVAL_MS);\n      return;\n    }\n    eventIdxRef.current = (eventIdxRef.current + 1) % events.length;\n    const text = events[eventIdxRef.current];\n    for (let i = 0; i < plyRefs.current.length; i++) {\n      const id = window.setTimeout(() => registerPly(i, text), i * PROPAGATION_DELAY_MS);\n      timeoutsRef.current.push(id);\n    }\n    strikeTimeoutRef.current = window.setTimeout(() => runStrikeRef.current(), STRIKE_INTERVAL_MS);\n  }, [events, registerPly]);\n\n  runStrikeRef.current = runStrike;\n\n  // reduced motion: paint one fully-propagated, fully-settled strike and\n  // never schedule another — the deliberately chosen non-t0 freeze frame.\n  const paintReducedFrame = useCallback(() => {\n    const text = events[0] ?? \"\";\n    plyRefs.current.forEach((ply, i) => {\n      if (!ply?.textEl || !ply.cardEl) return;\n      ply.textEl.textContent = text;\n      ply.textEl.style.opacity = String(opacityForPly(i));\n      ply.cardEl.style.transition = \"none\";\n      ply.cardEl.style.transform = \"scaleY(1)\";\n    });\n  }, [events]);\n\n  // geometry: ply count and scale derive from the container's smaller\n  // dimension so this reads at card scale rather than at a fixed pixel size.\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const measure = () => {\n      const rect = el.getBoundingClientRect();\n      const smaller = Math.min(rect.width, rect.height || rect.width);\n      const nextScale = Math.max(0.8, Math.min(1.15, smaller / 300));\n      const usableHeight = rect.height || ROW_HEIGHT * MIN_PLY_COUNT;\n      const fitCount = Math.floor(usableHeight / (ROW_HEIGHT * nextScale));\n      const nextCount = Math.max(MIN_PLY_COUNT, Math.min(MAX_PLY_COUNT, fitCount || MIN_PLY_COUNT));\n      setScale(nextScale);\n      setPlyCount(nextCount);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\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      clearAllTimeouts();\n      if (e.matches) {\n        paintReducedFrame();\n      } else {\n        eventIdxRef.current = -1;\n        runStrikeRef.current();\n      }\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, [clearAllTimeouts, paintReducedFrame]);\n\n  useEffect(() => {\n    clearAllTimeouts();\n    if (reducedRef.current) {\n      paintReducedFrame();\n      return;\n    }\n    eventIdxRef.current = -1;\n    runStrikeRef.current();\n    return clearAllTimeouts;\n    // plyCount/scale change the geometry every registered ply reads, and a\n    // resize mid-cascade should restart cleanly rather than leave stale\n    // in-flight timeouts targeting the old scale.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [plyCount, scale, clearAllTimeouts, paintReducedFrame]);\n\n  const onPointerEnter = useCallback(() => {\n    pausedRef.current = true;\n  }, []);\n  const onPointerLeave = useCallback(() => {\n    pausedRef.current = false;\n  }, []);\n\n  return (\n    <div\n      ref={containerRef}\n      className={`flex h-full w-full items-start justify-center p-4 ${className}`}\n      style={{ minHeight: ROW_HEIGHT * MIN_PLY_COUNT }}\n    >\n      <div className=\"w-full max-w-sm overflow-hidden rounded-md border border-border bg-background\">\n        {plyIndices.map((i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              plyRefs.current[i] = { cardEl: el, textEl: plyRefs.current[i]?.textEl ?? null };\n            }}\n            onPointerEnter={i === 0 ? onPointerEnter : undefined}\n            onPointerLeave={i === 0 ? onPointerLeave : undefined}\n            className={`flex items-center px-3.5 ${i < plyCount - 1 ? \"border-b border-border/60\" : \"\"}`}\n            style={{\n              height: ROW_HEIGHT * scale,\n              transformOrigin: \"50% 50%\",\n              paddingLeft: 14 + OFFSET_X * i * scale,\n            }}\n            aria-hidden={i === 0 ? undefined : true}\n            {...(i === 0 ? { role: \"status\", \"aria-live\": \"polite\", \"aria-label\": ariaLabel } : {})}\n          >\n            <p\n              ref={(el) => {\n                plyRefs.current[i] = { cardEl: plyRefs.current[i]?.cardEl ?? null, textEl: el };\n              }}\n              className=\"truncate font-mono text-[12px] text-foreground\"\n              style={{ opacity: opacityForPly(i) }}\n            >\n              {\" \"}\n            </p>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/carbon-ply-fade.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "feed",
      "notification",
      "fanout",
      "physics",
      "carbon-paper",
      "aria-live",
      "activity"
    ],
    "instruction": "An activity feed reimagined as a carbon-paper multi-part form: one bordered panel containing 4-5 contiguous, fully-visible ply rows (border-b between them, no overlap) rather than overlapping offset cards — overlap would bury a lower ply's text under the opaque ply above it, which defeats the one thing that has to stay legible: the falloff must be readable on every ply at once. Each row is nudged ~2px right per ply below it via paddingLeft (the spec's '2px right per ply behind it'; the spec's ~6px DOWN offset is expressed as the row's own 40px height/pitch rather than a literal 6px overlap-producing translate, a deliberate deviation so four ply rows of real text can coexist on screen — noted here rather than silently kept). Row height and the horizontal nudge are scaled by a factor derived from the container's smaller dimension via ResizeObserver, clamped 0.8-1.15, so the stack reads at card scale. Ply count is available height divided by the 40px row height, clamped 4-5; on a standard card-scale container this settles at exactly 4. Every 1.1s a 'strike' event fires: the component advances to the next label in a built-in (or caller-supplied) rotating event list and, for each ply i from 0 (top) to plyCount-1, schedules a registration at i*90ms after the strike starts — the real mechanical delay of force traveling down a ply stack. When a ply's registration fires its paragraph's textContent is set to the new event label and its opacity is set instantly (no transition — carbon transfer does not fade in) to Math.max(0.12, 0.68**i), so ply 1 is always --foreground at 100%, ply 2 ~68%, ply 3 ~46%, ply 4 ~31% (light theme: --foreground 31% over --background is ~2.2:1, faint but present and clearly above --border's ~1.1:1, so the falloff reads as 'faint' rather than 'gone'). The 12% floor guards a hypothetical 5th ply (0.68**4 ~= 21%) rather than ever engaging at the default 4-ply depth. The ply row itself gets a small physical squash on impact — transform scaleY(0.985) over a 60ms ease-out transition, then a 140ms cubic-bezier(0.34,1.56,0.64,1) spring back to scaleY(1) — read as the row compressing under the strike. Between strikes every ply holds its last registered text and opacity unchanged for the remaining ~700ms of the cadence; nothing fades back out. Hovering ply 1 (onPointerEnter/onPointerLeave) sets a paused ref that skips advancing to the next event while still letting the current strike's cascade finish and letting the interval re-check itself every 1.1s, so a viewer can read the current stack without it being overwritten mid-look, and un-hovering resumes rotation cleanly. Ply 1 alone carries role=status aria-live=polite aria-label so assistive tech announces only the freshest copy; plies 2 and up are aria-hidden, decorative echoes of the same event. The strike flash is luminance-only (opacity of --foreground text, a border-border row divider, a bg-background fill) — --ns-accent never appears. prefers-reduced-motion clears every scheduled timeout and paints one fully-propagated, fully-settled strike directly (all plies at their falloff opacity, no scaleY transition, no further scheduling) rather than freezing mid-cascade. No canvas, no dependencies — pure DOM, refs-only imperative style writes (no per-frame React state) for the strike/registration/squash path, React state only for plyCount and scale from the resize observer."
  },
  "type": "registry:ui"
}