{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "propagate",
  "title": "Propagate",
  "description": "One source card fanning out to a row of screens; bug, fix and recolour cues travel down the lines.",
  "dependencies": [
    "remotion"
  ],
  "registryDependencies": [
    "@jbm/tokens",
    "@jbm/motion-hooks",
    "@jbm/pop",
    "@jbm/paper",
    "@jbm/ui-bits"
  ],
  "files": [
    {
      "path": "registry/jbm/motion/propagate.tsx",
      "content": "import { interpolate, Easing } from \"remotion\"\nimport { color } from \"../lib/tokens\"\nimport { useIn, useProgress, useSec } from \"./hooks\"\nimport { Pop } from \"./pop\"\nimport { Paper, Sticker, type PaperTone } from \"../ui/paper\"\nimport { Badge, PhoneFrame, UiButton, UiCard } from \"../ui/ui-bits\"\n\n/**\n * One source, many screens. A source card holds the piece; ink lines fan out to a grid of small\n * screens that each show the same piece. Cues:\n *   bug      — a cross badge appears on the source and on every screen (the same defect, everywhere)\n *   fix      — the source badge turns into a check; pulses leave along the lines\n *   fixed    — pulses arrive; every screen's badge turns into a check\n *   recolor  — the source piece turns vermilion; pulses leave\n *   recolored— every screen's piece turns vermilion\n * Timing between a cue and its arrival is the pulse's travel time, so it follows the narration.\n */\nconst NEVER = 1e6 // seconds: a cue that never arrives\n\nexport function Propagate({\n  w,\n  h,\n  at,\n  label,\n  targets = 6,\n  bug,\n  fix,\n  fixed,\n  recolor,\n  recolored,\n}: {\n  w: number\n  h: number\n  at: number\n  label?: { text: string; at: number }\n  targets?: number\n  bug?: number\n  fix?: number\n  fixed?: number\n  recolor?: number\n  recolored?: number\n}) {\n  if (!Number.isInteger(targets) || targets < 1)\n    throw new RangeError(\"targets must be a positive integer\")\n  const sec = useSec()\n  const cols = targets <= 6 ? targets : Math.ceil(targets / 2)\n  const rows = Math.ceil(targets / cols)\n  const sw = Math.round(Math.min(440, w * 0.47))\n  const sh = Math.round(sw * 0.45)\n  const top = label ? Math.round(sh * 0.42) : 0 // room for the sticker above the source\n  const rowGap = Math.round(h * 0.04)\n  const colGap = Math.round(w * 0.02)\n  const tw = Math.floor((w - colGap * (cols - 1)) / cols)\n  const th = Math.min(\n    Math.round(tw * 1.6),\n    Math.floor(\n      (h - top - sh - Math.round(h * 0.2) - rowGap * (rows - 1)) / rows\n    )\n  )\n  const gridTop = h - rows * th - (rows - 1) * rowGap\n  const sx = w / 2\n  const sy = top + sh\n\n  const sheet = useIn(at, { damping: 13 })\n  const lines = useProgress(at + 0.25, 1, 0.9)\n  const srcTone: PaperTone = \"ink\"\n  // Every cue is optional; an absent cue is scheduled at NEVER so the hook order stays fixed.\n  const bugP = useIn(bug ?? NEVER, { damping: 10 })\n  const fixP = useIn(fix ?? NEVER, { damping: 10 })\n  const fixedP = useIn(fixed ?? NEVER, { damping: 10 })\n  const recolorP = useProgress(recolor ?? NEVER, 1, 0.35)\n  const recoloredP = useProgress(recolored ?? NEVER, 1, 0.35)\n  const badgesOut = 1 - useProgress((recolor ?? NEVER) - 0.2, 1, 0.4)\n\n  const p1 = propagationProgress(sec, fix, fixed)\n  const p2 = propagationProgress(sec, recolor, recolored)\n\n  const cells = Array.from({ length: targets }).map((_, i) => {\n    const c = i % cols\n    const r = Math.floor(i / cols)\n    const x = cols === 1 ? (w - tw) / 2 : c * (tw + colGap)\n    const y = gridTop + r * (th + rowGap)\n    return { x, y, tx: x + tw / 2, ty: y }\n  })\n  const path = (tx: number, ty: number) => {\n    const my = sy + (ty - sy) * 0.5\n    return {\n      d: `M ${sx} ${sy} C ${sx} ${my}, ${tx} ${my}, ${tx} ${ty}`,\n      pt: (t: number) => bez(sx, sy, sx, my, tx, my, tx, ty, t),\n    }\n  }\n\n  return (\n    <div style={{ position: \"relative\", width: w, height: h, opacity: sheet }}>\n      <svg\n        width={w}\n        height={h}\n        style={{ position: \"absolute\", inset: 0, overflow: \"visible\" }}\n      >\n        {cells.map((cell, i) => {\n          const { d } = path(cell.tx, cell.ty)\n          return (\n            <path\n              key={i}\n              d={d}\n              fill=\"none\"\n              stroke={color.ink}\n              strokeWidth={5}\n              strokeLinecap=\"round\"\n              pathLength={1}\n              strokeDasharray={1}\n              strokeDashoffset={1 - lines}\n            />\n          )\n        })}\n        {cells.map((cell, i) => {\n          const { pt } = path(cell.tx, cell.ty)\n          return [p1, p2].map((pp, k) => {\n            if (pp <= 0 || pp >= 1) return null\n            const [px, py] = pt(pp)\n            return (\n              <circle\n                key={`${i}-${k}`}\n                cx={px}\n                cy={py}\n                r={13}\n                fill={color.accent}\n              />\n            )\n          })\n        })}\n      </svg>\n\n      {/* source */}\n      <div\n        style={{\n          position: \"absolute\",\n          left: (w - sw) / 2,\n          top,\n          transform: `translateY(${(1 - sheet) * -30}px)`,\n        }}\n      >\n        <Paper\n          tone=\"paper\"\n          w={sw}\n          h={sh}\n          radius={28}\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <Stacked\n            w={Math.round(sw * 0.6)}\n            h={Math.round(sw * 0.19)}\n            accent={recolorP}\n            base={srcTone}\n          />\n          <div\n            style={{\n              position: \"absolute\",\n              right: -22,\n              top: -22,\n              opacity: Math.min(bugP, badgesOut),\n              transform: `scale(${bugP})`,\n            }}\n          >\n            <Flip p={fixP} size={Math.round(sw * 0.15)} />\n          </div>\n        </Paper>\n        {label ? (\n          <div\n            style={{\n              position: \"absolute\",\n              left: -Math.round(sw * 0.12),\n              top: -Math.round(sh * 0.36),\n            }}\n          >\n            <Pop at={label.at} from=\"scale\" dist={0}>\n              <Sticker size={Math.round(w * 0.045)} rotate={-4}>\n                {label.text}\n              </Sticker>\n            </Pop>\n          </div>\n        ) : null}\n      </div>\n\n      {/* targets */}\n      {cells.map((cell, i) => (\n        <div\n          key={i}\n          style={{ position: \"absolute\", left: cell.x, top: cell.y }}\n        >\n          <Pop at={at + 0.35 + i * 0.08} from=\"up\" dist={24}>\n            <PhoneFrame\n              w={tw}\n              h={th}\n              rotate={[-2, 1.5, -1, 2, -1.5, 1][i % 6]}\n              gap={Math.round(tw * 0.1)}\n            >\n              <UiCard w={Math.round(tw * 0.66)} h={Math.round(tw * 0.5)} />\n              <Stacked\n                w={Math.round(tw * 0.66)}\n                h={Math.round(tw * 0.22)}\n                accent={recoloredP}\n                base=\"ink\"\n              />\n            </PhoneFrame>\n            <div\n              style={{\n                position: \"absolute\",\n                right: -14,\n                top: -14,\n                opacity: Math.min(bugP, badgesOut),\n                transform: `scale(${bugP})`,\n              }}\n            >\n              <Flip p={fixedP} size={Math.round(tw * 0.2)} />\n            </div>\n          </Pop>\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/** Two buttons stacked; `accent` 0→1 crossfades from the base tone to vermilion. */\nfunction Stacked({\n  w,\n  h,\n  accent,\n  base,\n}: {\n  w: number\n  h: number\n  accent: number\n  base: PaperTone\n}) {\n  return (\n    <div style={{ position: \"relative\", width: w, height: h }}>\n      <UiButton\n        w={w}\n        h={h}\n        tone={base}\n        style={{ position: \"absolute\", inset: 0 }}\n      />\n      <UiButton\n        w={w}\n        h={h}\n        tone=\"accent\"\n        style={{ position: \"absolute\", inset: 0, opacity: accent }}\n      />\n    </div>\n  )\n}\n\n/** Cross badge that flips into a check as `p` goes 0→1. */\nfunction Flip({ p, size }: { p: number; size: number }) {\n  return (\n    <div style={{ position: \"relative\", width: size, height: size }}>\n      <Badge\n        kind=\"x\"\n        size={size}\n        tone=\"ink\"\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: 1 - p,\n          transform: `rotate(${p * 90}deg)`,\n        }}\n      />\n      <Badge\n        kind=\"check\"\n        size={size}\n        tone=\"accent\"\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: p,\n          transform: `scale(${0.6 + 0.4 * p})`,\n        }}\n      />\n    </div>\n  )\n}\n\nfunction bez(\n  x0: number,\n  y0: number,\n  x1: number,\n  y1: number,\n  x2: number,\n  y2: number,\n  x3: number,\n  y3: number,\n  t: number\n): [number, number] {\n  const u = 1 - t\n  const x =\n    u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3\n  const y =\n    u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3\n  return [x, y]\n}\n\n/** Progress follows the exact departure and arrival cues, including short trips. */\nexport function propagationProgress(sec: number, from?: number, to?: number) {\n  if (from === undefined || to === undefined) return -1\n  if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) throw new RangeError(\"Propagation arrival must be later than departure\")\n  return interpolate(sec, [from, to], [0, 1], { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\", easing: Easing.inOut(Easing.cubic) })\n}\n",
      "type": "registry:component",
      "target": "src/jbm/motion/propagate.tsx"
    }
  ],
  "type": "registry:component"
}