{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-flow",
  "title": "UI Chat Flow",
  "description": "A messaging composition: an outgoing message types into the composer, the send button presses, and the bubble rises in; the other side shows a typing indicator that swaps into the reply, and reactions pop in. A pure orchestrator — every channel comes from a composed primitive's hook (Input, MessageBubble, TypingIndicator).",
  "dependencies": ["remotion"],
  "registryDependencies": [
    "@remocn/remocn-ui",
    "@remocn/caret",
    "@remocn/message-bubble",
    "@remocn/typing-indicator"
  ],
  "files": [
    {
      "path": "registry/remocn-ui/chat-flow/index.tsx",
      "content": "\"use client\";\n\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { Caret } from \"@/components/remocn/caret\";\nimport {\n  MessageBubble,\n  type MessageBubbleReactionStyle,\n  type MessageBubbleStyle,\n} from \"@/components/remocn/message-bubble\";\nimport { TypingIndicator } from \"@/components/remocn/typing-indicator\";\nimport { useMessageBubbleTransition } from \"@/components/remocn/use-message-bubble-transition\";\nimport {\n  mixOklch,\n  type RemocnTheme,\n  revealedText,\n  useRemocnTheme,\n} from \"@/lib/remocn-ui\";\n\nexport interface ChatMessage {\n  from: \"me\" | \"them\";\n  text: string;\n  reaction?: string;\n}\n\nexport interface ChatContact {\n  name: string;\n  avatar?: string;\n}\n\nexport interface ChatFlowProps {\n  messages?: ChatMessage[];\n  contact?: ChatContact;\n  accentColor?: string;\n  speed?: number;\n  theme?: Partial<RemocnTheme>;\n}\n\nconst MOBILE_WIDTH = 460;\nconst LEAD_IN = 12;\nconst FRAMES_PER_CHAR = 2.2;\nconst MIN_TYPE = 18;\nconst MAX_TYPE = 86;\nconst SEND_GAP = 10;\nconst REVEAL = 14;\nconst REACT_DELAY = 8;\nconst REACT_DUR = 14;\nconst MSG_GAP = 18;\nconst TYPING_MIN = 34;\nconst TYPING_MAX = 70;\nconst TAIL = 28;\nconst PRESS_WINDOW = 7;\n\nconst DEFAULT_MESSAGES: ChatMessage[] = [\n  { from: \"me\", text: \"Hey — ready for the demo?\" },\n  { from: \"them\", text: \"Yep, pushing it live now\", reaction: \"🔥\" },\n  { from: \"me\", text: \"Perfect, sending the link over\", reaction: \"👍\" },\n];\n\nfunction clamp(value: number, lo: number, hi: number): number {\n  return Math.max(lo, Math.min(hi, value));\n}\n\nexport interface ScheduledMessage {\n  index: number;\n  from: \"me\" | \"them\";\n  text: string;\n  reaction?: string;\n  presenceStart: number;\n  typeStart?: number;\n  sendAt?: number;\n  typingStart?: number;\n  revealAt: number;\n  reactAt?: number;\n}\n\nexport interface ChatFlowSchedule {\n  items: ScheduledMessage[];\n  duration: number;\n}\n\nexport function chatFlowSchedule(messages: ChatMessage[]): ChatFlowSchedule {\n  const items: ScheduledMessage[] = [];\n  let cursor = LEAD_IN;\n\n  messages.forEach((message, index) => {\n    const hasReaction =\n      message.reaction !== undefined && message.reaction !== \"\";\n    if (message.from === \"me\") {\n      const typeStart = cursor;\n      const typeDur = clamp(\n        Math.round(message.text.length * FRAMES_PER_CHAR),\n        MIN_TYPE,\n        MAX_TYPE,\n      );\n      const sendAt = typeStart + typeDur + SEND_GAP;\n      const revealAt = sendAt;\n      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;\n      items.push({\n        index,\n        from: \"me\",\n        text: message.text,\n        reaction: hasReaction ? message.reaction : undefined,\n        presenceStart: sendAt - 2,\n        typeStart,\n        sendAt,\n        revealAt,\n        reactAt,\n      });\n      cursor =\n        revealAt +\n        REVEAL +\n        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +\n        MSG_GAP;\n    } else {\n      const typingStart = cursor;\n      const typingDur = clamp(\n        Math.round(message.text.length * FRAMES_PER_CHAR),\n        TYPING_MIN,\n        TYPING_MAX,\n      );\n      const revealAt = typingStart + typingDur;\n      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;\n      items.push({\n        index,\n        from: \"them\",\n        text: message.text,\n        reaction: hasReaction ? message.reaction : undefined,\n        presenceStart: typingStart,\n        typingStart,\n        revealAt,\n        reactAt,\n      });\n      cursor =\n        revealAt +\n        REVEAL +\n        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +\n        MSG_GAP;\n    }\n  });\n\n  const duration = Math.max(cursor - MSG_GAP + TAIL, LEAD_IN + TAIL);\n  return { items, duration };\n}\n\nexport function chatFlowDuration(\n  messages: ChatMessage[] = DEFAULT_MESSAGES,\n  speed = 1,\n): number {\n  const raw = chatFlowSchedule(messages).duration;\n  return Math.ceil(raw / (speed <= 0 ? 1 : speed));\n}\n\nexport function sendPulse(items: ScheduledMessage[], eff: number): number {\n  let best = 0;\n  for (const item of items) {\n    if (item.sendAt === undefined) continue;\n    const distance = Math.abs(eff - item.sendAt);\n    if (distance <= PRESS_WINDOW) {\n      best = Math.max(best, 1 - distance / PRESS_WINDOW);\n    }\n  }\n  return best;\n}\n\nfunction typingBubbleStyle(\n  eff: number,\n  typingStart: number,\n  revealAt: number,\n): MessageBubbleStyle {\n  const inOpacity = interpolate(eff, [typingStart, typingStart + 8], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const outOpacity = interpolate(eff, [revealAt - 6, revealAt], [1, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const translateY = interpolate(eff, [typingStart, typingStart + 8], [10, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  return { opacity: inOpacity * outOpacity, translateY, scale: 1 };\n}\n\nfunction Avatar({\n  contact,\n  theme,\n}: {\n  contact: ChatContact;\n  theme: RemocnTheme;\n}) {\n  const initial = contact.name.trim().charAt(0).toUpperCase();\n  return (\n    <div\n      style={{\n        flexShrink: 0,\n        width: 32,\n        height: 32,\n        minWidth: 32,\n        borderRadius: \"50%\",\n        overflow: \"hidden\",\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: theme.muted,\n        color: theme.mutedForeground,\n        fontSize: 14,\n        fontWeight: 600,\n        fontFamily:\n          \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n      }}\n    >\n      {contact.avatar !== undefined ? (\n        // biome-ignore lint/performance/noImgElement: Remotion output, not a Next.js app — next/image isn't available where this component ships\n        <img\n          src={contact.avatar}\n          alt={contact.name}\n          style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n        />\n      ) : (\n        initial\n      )}\n    </div>\n  );\n}\n\nfunction SendIcon({ color }: { color: string }) {\n  return (\n    <svg width={20} height={20} viewBox=\"0 0 24 24\" fill=\"none\">\n      <path\n        d=\"M12 19V5M12 5l-6 6M12 5l6 6\"\n        stroke={color}\n        strokeWidth={2.2}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction PlusIcon({ color }: { color: string }) {\n  return (\n    <svg width={20} height={20} viewBox=\"0 0 24 24\" fill=\"none\">\n      <path\n        d=\"M12 5v14M5 12h14\"\n        stroke={color}\n        strokeWidth={2.2}\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function ChatFlow({\n  messages = DEFAULT_MESSAGES,\n  contact,\n  accentColor,\n  speed = 1,\n  theme: themeOverride,\n}: ChatFlowProps) {\n  const frame = useCurrentFrame();\n  const eff = frame * speed;\n\n  const themeProp = {\n    ...themeOverride,\n    ...(accentColor ? { primary: accentColor } : {}),\n  };\n  const resolved = useRemocnTheme(themeProp, \"light\");\n\n  const { items } = chatFlowSchedule(messages);\n\n  const activeMe = items.find(\n    (item) =>\n      item.from === \"me\" &&\n      item.typeStart !== undefined &&\n      item.sendAt !== undefined &&\n      eff >= item.typeStart &&\n      eff < item.sendAt,\n  );\n  let composerText = \"\";\n  let typing = false;\n  if (\n    activeMe &&\n    activeMe.typeStart !== undefined &&\n    activeMe.sendAt !== undefined\n  ) {\n    const typeDur = Math.max(\n      activeMe.sendAt - SEND_GAP - activeMe.typeStart,\n      1,\n    );\n    const progress = clamp((eff - activeMe.typeStart) / typeDur, 0, 1);\n    composerText = revealedText(\n      activeMe.text,\n      Math.floor(progress * activeMe.text.length),\n    );\n    typing = true;\n  }\n\n  const sendActive = composerText.length > 0;\n  const sendScale = 1 - 0.16 * sendPulse(items, eff);\n  const present = items.filter((item) => eff >= item.presenceStart);\n\n  const composerBackground = mixOklch(\n    resolved.background,\n    resolved.muted,\n    0.55,\n  );\n\n  return (\n    <div\n      style={{\n        position: \"relative\",\n        width: \"100%\",\n        height: \"100%\",\n        display: \"flex\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n        fontFamily:\n          \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          width: \"100%\",\n          maxWidth: MOBILE_WIDTH,\n          height: \"100%\",\n          padding: \"24px 16px 18px\",\n          boxSizing: \"border-box\",\n        }}\n      >\n        {contact !== undefined && (\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 10,\n              paddingBottom: 16,\n              marginBottom: 8,\n              borderBottom: `1px solid ${resolved.border}`,\n            }}\n          >\n            <Avatar contact={contact} theme={resolved} />\n            <div style={{ display: \"flex\", flexDirection: \"column\", gap: 2 }}>\n              <span\n                style={{\n                  fontSize: 15,\n                  fontWeight: 600,\n                  lineHeight: 1.25,\n                  letterSpacing: \"-0.01em\",\n                  color: resolved.foreground,\n                }}\n              >\n                {contact.name}\n              </span>\n              <span\n                style={{\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  gap: 5,\n                  fontSize: 12,\n                  lineHeight: 1.2,\n                  color: \"oklch(0.62 0.17 150)\",\n                }}\n              >\n                <span\n                  style={{\n                    width: 6,\n                    height: 6,\n                    borderRadius: \"50%\",\n                    background: \"oklch(0.62 0.17 150)\",\n                  }}\n                />\n                online\n              </span>\n            </div>\n          </div>\n        )}\n\n        <div\n          style={{\n            position: \"relative\",\n            flex: 1,\n            minHeight: 0,\n            overflow: \"hidden\",\n            WebkitMaskImage:\n              \"linear-gradient(to bottom, transparent 0, #000 48px, #000 100%)\",\n            maskImage:\n              \"linear-gradient(to bottom, transparent 0, #000 48px, #000 100%)\",\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              justifyContent: \"flex-end\",\n              gap: 20,\n              minHeight: \"100%\",\n              paddingTop: 24,\n              paddingBottom: 22,\n            }}\n          >\n            {present.map((item) => (\n              <ChatRow\n                key={item.index}\n                item={item}\n                eff={eff}\n                speed={speed}\n                contact={contact}\n                themeProp={themeProp}\n                theme={resolved}\n              />\n            ))}\n          </div>\n        </div>\n\n        <div\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: 10,\n            marginTop: 14,\n            padding: 14,\n            borderRadius: 24,\n            background: composerBackground,\n            border: `1px solid ${resolved.border}`,\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"flex-start\",\n              minHeight: 24,\n              fontSize: 15,\n              lineHeight: 1.45,\n              letterSpacing: \"-0.01em\",\n              color: sendActive\n                ? resolved.foreground\n                : resolved.mutedForeground,\n            }}\n          >\n            <span style={{ whiteSpace: \"pre-wrap\", wordBreak: \"break-word\" }}>\n              {sendActive ? composerText : \"Message\"}\n            </span>\n            {typing && (\n              <Caret\n                color={resolved.foreground}\n                height={18}\n                radius={1}\n                blink\n                marginLeft={composerText.length > 0 ? 2 : 0}\n              />\n            )}\n          </div>\n          <div\n            style={{\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"space-between\",\n            }}\n          >\n            <div\n              style={{\n                width: 38,\n                height: 38,\n                borderRadius: \"50%\",\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                border: `1px solid ${resolved.border}`,\n                background: \"transparent\",\n              }}\n            >\n              <PlusIcon color={resolved.mutedForeground} />\n            </div>\n            <div\n              style={{\n                width: 38,\n                height: 38,\n                borderRadius: \"50%\",\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                background: sendActive\n                  ? resolved.primary\n                  : mixOklch(resolved.background, resolved.muted, 0.6),\n                transform: `scale(${sendScale})`,\n              }}\n            >\n              <SendIcon\n                color={\n                  sendActive\n                    ? resolved.primaryForeground\n                    : resolved.mutedForeground\n                }\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ChatRow({\n  item,\n  eff,\n  speed,\n  contact,\n  themeProp,\n  theme,\n}: {\n  item: ScheduledMessage;\n  eff: number;\n  speed: number;\n  contact?: ChatContact;\n  themeProp: Partial<RemocnTheme>;\n  theme: RemocnTheme;\n}) {\n  const { fps } = useVideoConfig();\n  const variant = item.from === \"me\" ? \"outgoing\" : \"incoming\";\n  const showTyping =\n    item.from === \"them\" &&\n    item.typingStart !== undefined &&\n    eff < item.revealAt;\n\n  const bubbleStyle = useMessageBubbleTransition(\n    [{ at: item.revealAt, state: \"visible\", duration: REVEAL }],\n    { speed },\n  );\n\n  let reactionStyle: MessageBubbleReactionStyle | undefined;\n  if (item.reactAt !== undefined) {\n    const reactAt = item.reactAt;\n    const pop = spring({\n      fps,\n      frame: eff - reactAt,\n      config: { damping: 11, stiffness: 220, mass: 0.6 },\n    });\n    const opacity = interpolate(eff, [reactAt, reactAt + 5], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    });\n    reactionStyle = { opacity, scale: pop };\n  }\n\n  const bubbleNode = showTyping ? (\n    <MessageBubble\n      variant=\"incoming\"\n      style={typingBubbleStyle(eff, item.typingStart ?? 0, item.revealAt)}\n      theme={themeProp}\n    >\n      <TypingIndicator color={theme.mutedForeground} />\n    </MessageBubble>\n  ) : (\n    <MessageBubble\n      variant={variant}\n      style={bubbleStyle}\n      reaction={item.reaction}\n      reactionStyle={reactionStyle}\n      theme={themeProp}\n    >\n      {item.text}\n    </MessageBubble>\n  );\n\n  if (item.from === \"them\" && contact !== undefined) {\n    return (\n      <div style={{ display: \"flex\", alignItems: \"flex-end\", gap: 8 }}>\n        <Avatar contact={contact} theme={theme} />\n        <div style={{ flex: 1, minWidth: 0 }}>{bubbleNode}</div>\n      </div>\n    );\n  }\n\n  return bubbleNode;\n}\n",
      "type": "registry:component",
      "target": "components/remocn/chat-flow.tsx"
    }
  ],
  "type": "registry:component"
}
