{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-changelog",
  "title": "Changelog — New Chat Components",
  "description": "remocn demo composition \"Changelog — New Chat Components\" — installs the full Remotion composition. Generated with AI from the prompt in demos/chat-changelog/prompt.md.",
  "dependencies": [
    "@remotion/google-fonts",
    "@remotion/transitions",
    "culori",
    "lucide-react",
    "remotion"
  ],
  "registryDependencies": [
    "https://remocn.dev/r/backdrop.json",
    "https://remocn.dev/r/caret.json",
    "https://remocn.dev/r/chat-flow.json",
    "https://remocn.dev/r/imessage-chat-flow.json",
    "https://remocn.dev/r/message-bubble.json",
    "https://remocn.dev/r/remocn-ui.json",
    "https://remocn.dev/r/telegram-chat-flow.json",
    "https://remocn.dev/r/typing-indicator.json"
  ],
  "files": [
    {
      "path": "src/components/remocn/typewriter.tsx",
      "content": "\"use client\";\n\nimport { Caret } from \"@/components/remocn/caret\";\nimport { useTypewriter } from \"@/lib/remocn-ui\";\n\nexport interface TypewriterProps {\n  text: string;\n  cursor?: boolean;\n  charsPerSecond?: number;\n  speed?: number;\n  fontSize?: number;\n  color?: string;\n  cursorColor?: string;\n  fontWeight?: number;\n  className?: string;\n}\n\nexport function Typewriter({\n  text,\n  cursor = true,\n  charsPerSecond = 22,\n  speed = 1,\n  fontSize = 48,\n  color = \"#171717\",\n  cursorColor = \"#171717\",\n  fontWeight = 600,\n  className,\n}: TypewriterProps) {\n  const tw = useTypewriter(text, { cps: charsPerSecond, speed });\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n      }}\n    >\n      <span\n        className={className}\n        style={{\n          fontSize,\n          fontWeight,\n          color,\n          letterSpacing: \"-0.03em\",\n          fontFamily:\n            \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n          whiteSpace: \"pre\",\n        }}\n      >\n        {tw.text}\n        {cursor && (\n          <Caret\n            color={cursorColor}\n            blink={!tw.typing}\n            speed={speed}\n            radius={0}\n            style={{\n              width: \"0.08em\",\n              height: \"1em\",\n              marginLeft: \"0.04em\",\n              verticalAlign: \"text-bottom\",\n            }}\n          />\n        )}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/typewriter.tsx"
    },
    {
      "path": "src/demos/chat-changelog/index.tsx",
      "content": "import React, { type ReactNode } from \"react\";\nimport { AbsoluteFill, Easing, Sequence, interpolate, useCurrentFrame } from \"remotion\";\nimport { demoAsset } from \"@/lib/demo-assets\";\nimport {\n  TransitionSeries,\n  linearTiming,\n  type TransitionPresentation,\n} from \"@remotion/transitions\";\nimport { fade, type FadeProps } from \"@remotion/transitions/fade\";\nimport { loadFont } from \"@remotion/google-fonts/Manrope\";\nimport { loadFont as loadMono } from \"@remotion/google-fonts/JetBrainsMono\";\n\nimport { RemocnUIProvider } from \"@/lib/remocn-ui\";\nimport { Backdrop } from \"@/components/remocn/backdrop\";\nimport { Typewriter } from \"@/components/remocn/typewriter\";\n\n// New chat primitives — the subject of this changelog.\nimport {\n  ChatFlow,\n  chatFlowDuration,\n  type ChatMessage,\n} from \"@/components/remocn/chat-flow\";\nimport {\n  ImessageChatFlow,\n  imessageChatFlowDuration,\n  type ImessageMessage,\n} from \"@/components/remocn/imessage-chat-flow\";\nimport {\n  TelegramChatFlow,\n  telegramChatFlowDuration,\n  type TelegramMessage,\n} from \"@/components/remocn/telegram-chat-flow\";\n\n// Manrope, bound to the CSS variable every remocn component reads.\nconst { fontFamily } = loadFont(\"normal\", {\n  subsets: [\"latin\"],\n  weights: [\"400\", \"500\", \"600\", \"700\", \"800\"],\n});\nconst FONT_STACK = `${fontFamily}, sans-serif`;\n\nconst { fontFamily: monoFamily } = loadMono(\"normal\", {\n  subsets: [\"latin\"],\n  weights: [\"400\", \"500\"],\n});\nconst MONO_STACK = `${monoFamily}, ui-monospace, SFMono-Regular, Menlo, monospace`;\n\nconst INK = \"#fafafa\";\nconst MUTED = \"rgba(250,250,250,0.62)\";\nconst FAINT = \"rgba(250,250,250,0.42)\";\n\n// ---------------------------------------------------------------------------\n// Platform registry — one source of truth for the montage and the counter.\n// Each entry is a self-contained chat-flow scene; the component runs its own\n// internal timeline, so the scene is static and only the chat \"plays\".\n// ---------------------------------------------------------------------------\n// Shared persona across all three skins. The avatar (unavatar.io/x/shadcn) is\n// vendored into public/ and loaded via staticFile so it renders deterministically\n// and offline — remote URLs aren't reliably fetched during a frame capture.\nconst CONTACT = { name: \"shadcn\", avatar: demoAsset(\"shadcn-avatar.png\") };\n\nconst CF_MESSAGES: ChatMessage[] = [\n  { from: \"me\", text: \"remocn ships chat components now?\" },\n  { from: \"them\", text: \"Copy-paste. You own the code.\", reaction: \"🔥\" },\n];\n\nconst IM_MESSAGES: ImessageMessage[] = [\n  { from: \"me\", text: \"iMessage style too?\" },\n  { from: \"them\", text: \"Blue bubbles + tapbacks.\", reaction: \"❤️\" },\n];\n\nconst TG_MESSAGES: TelegramMessage[] = [\n  { from: \"me\", text: \"And Telegram?\", time: \"9:41\" },\n  { from: \"them\", text: \"Same API, telegram skin.\", reaction: \"👍\", time: \"9:41\" },\n];\n\ntype Platform = {\n  /** registry slug — also the counter label and the install target. */\n  name: string;\n  label: string;\n  blurb: string;\n  /** time-multiplier passed to the chat flow (>1 plays faster). */\n  speed: number;\n  /** phone-screen background behind the bubbles. */\n  screen: string;\n  accent: string;\n  render: (speed: number) => ReactNode;\n  /** the flow's natural length, in composition frames. */\n  duration: number;\n};\n\nconst PLATFORMS: Platform[] = [\n  {\n    name: \"chat-flow\",\n    label: \"Chat Flow\",\n    blurb: \"shadcn-style bubbles, a live composer, and reactions — frame-driven.\",\n    speed: 1.35,\n    screen: \"#ffffff\",\n    accent: \"#7c5cff\",\n    render: (s) => (\n      <ChatFlow contact={CONTACT} messages={CF_MESSAGES} speed={s} />\n    ),\n    duration: chatFlowDuration(CF_MESSAGES, 1.35),\n  },\n  {\n    name: \"imessage-chat-flow\",\n    label: \"iMessage\",\n    blurb: \"The blue-bubble look — gray inbound, tapback reactions, the works.\",\n    speed: 1.25,\n    screen: \"#ffffff\",\n    accent: \"#0a7cff\",\n    render: (s) => (\n      <ImessageChatFlow contact={CONTACT} messages={IM_MESSAGES} speed={s} />\n    ),\n    duration: imessageChatFlowDuration(IM_MESSAGES, 1.25),\n  },\n  {\n    name: \"telegram-chat-flow\",\n    label: \"Telegram\",\n    blurb: \"Same message API, a Telegram skin — timestamps, ticks and accent blue.\",\n    speed: 1.2,\n    screen: \"linear-gradient(180deg, #d6dfea 0%, #c6d2e0 100%)\",\n    accent: \"#3390ec\",\n    render: (s) => (\n      <TelegramChatFlow contact={CONTACT} messages={TG_MESSAGES} speed={s} />\n    ),\n    duration: telegramChatFlowDuration(TG_MESSAGES, 1.2),\n  },\n];\n\n// ---------------------------------------------------------------------------\n// Transitions — in-place opacity cross-fades only. No camera movement; each\n// scene resolves on the spot and dissolves on the spot.\n// ---------------------------------------------------------------------------\ntype Trans = { dur: number; presentation: () => TransitionPresentation<FadeProps> };\nconst DISSOLVE: Trans = { dur: 12, presentation: () => fade() };\n\n// One reveal primitive: rise a touch and un-blur in place.\nconst Reveal: React.FC<{ children: ReactNode; delay?: number; y?: number }> = ({\n  children,\n  delay = 0,\n  y = 12,\n}) => {\n  const frame = useCurrentFrame();\n  const p = interpolate(frame, [delay, delay + 20], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n  return (\n    <div\n      style={{\n        opacity: p,\n        transform: `translateY(${(1 - p) * y}px)`,\n        filter: p < 1 ? `blur(${(1 - p) * 8}px)` : undefined,\n      }}\n    >\n      {children}\n    </div>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Speech-bubble mark — each path is normalised to pathLength 1, then \"drawn\"\n// by sweeping its dash offset from hidden (1) to shown (0). A bookend brand\n// glyph for the intro and outro.\n// ---------------------------------------------------------------------------\nconst ChatMark: React.FC<{ size?: number; color?: string }> = ({\n  size = 34,\n  color = INK,\n}) => {\n  const frame = useCurrentFrame();\n  const appear = interpolate(frame, [0, 8], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const draw = interpolate(frame, [2, 32], [1, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.inOut(Easing.cubic),\n  });\n  const dots = interpolate(frame, [22, 34], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke={color}\n      strokeWidth={1.6}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      style={{ opacity: appear }}\n    >\n      <path\n        pathLength={1}\n        strokeDasharray={1}\n        strokeDashoffset={draw}\n        d=\"M7.5 18.5L4 21V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v9a2.5 2.5 0 0 1-2.5 2.5z\"\n      />\n      <g opacity={dots} fill={color} stroke=\"none\">\n        <circle cx={8.5} cy={11} r={1.05} />\n        <circle cx={12} cy={11} r={1.05} />\n        <circle cx={15.5} cy={11} r={1.05} />\n      </g>\n    </svg>\n  );\n};\n\n// Small lock-up: the mark draws itself in beside the wordmark.\nconst Wordmark: React.FC<{ markSize?: number; fontSize?: number }> = ({\n  markSize = 26,\n  fontSize = 21,\n}) => (\n  <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n    <ChatMark size={markSize} />\n    <span\n      style={{\n        fontFamily: FONT_STACK,\n        fontSize,\n        fontWeight: 600,\n        color: \"rgba(250,250,250,0.78)\",\n      }}\n    >\n      remocn\n    </span>\n  </div>\n);\n\n// ---------------------------------------------------------------------------\n// Phone — a device frame whose screen holds a running chat flow.\n// Sized to a real handset aspect: iPhone 16 Pro is 402×874pt (≈2.17:1) and a\n// Pixel ~412×915 (≈2.22:1). 302×656 keeps the narrow, tall proportions of both\n// while fitting inside the 720px canvas.\n// ---------------------------------------------------------------------------\nconst PHONE_W = 302;\nconst PHONE_H = 656;\nconst STATUS_H = 40; // safe-area / status-bar inset above the app header.\n\n// iOS-style status-bar glyphs — dark, on the white inset that matches every\n// app header (chat-flow/iMessage/Telegram all render a white-ish top bar).\nconst SignalIcon: React.FC = () => (\n  <svg width={17} height={11} viewBox=\"0 0 17 11\" fill=\"#000\">\n    <rect x={0} y={7} width={3} height={4} rx={1} />\n    <rect x={4.5} y={5} width={3} height={6} rx={1} />\n    <rect x={9} y={2.5} width={3} height={8.5} rx={1} />\n    <rect x={13.5} y={0} width={3} height={11} rx={1} />\n  </svg>\n);\nconst WifiIcon: React.FC = () => (\n  <svg width={16} height={12} viewBox=\"0 0 16 12\" fill=\"none\">\n    <path d=\"M8 10.2a1.4 1.4 0 1 0 0-2.8 1.4 1.4 0 0 0 0 2.8Z\" fill=\"#000\" />\n    <path\n      d=\"M3.2 5.2A7 7 0 0 1 12.8 5.2M5.2 7.2a4.1 4.1 0 0 1 5.6 0\"\n      stroke=\"#000\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\nconst BatteryIcon: React.FC = () => (\n  <svg width={26} height={12} viewBox=\"0 0 26 12\" fill=\"none\">\n    <rect\n      x={0.5}\n      y={0.5}\n      width={22}\n      height={11}\n      rx={3}\n      stroke=\"#000\"\n      strokeOpacity={0.4}\n    />\n    <rect x={2} y={2} width={17} height={8} rx={1.6} fill=\"#000\" />\n    <rect x={24} y={4} width={1.5} height={4} rx={0.75} fill=\"#000\" fillOpacity={0.4} />\n  </svg>\n);\n\nconst StatusBar: React.FC = () => (\n  <div\n    style={{\n      position: \"relative\",\n      flexShrink: 0,\n      height: STATUS_H,\n      background: \"#ffffff\",\n      display: \"flex\",\n      alignItems: \"center\",\n      justifyContent: \"space-between\",\n      padding: \"0 18px 0 22px\",\n      fontFamily: FONT_STACK,\n      zIndex: 3,\n    }}\n  >\n    <span\n      style={{\n        fontSize: 14,\n        fontWeight: 700,\n        color: \"#000\",\n        letterSpacing: \"0.01em\",\n        fontVariantNumeric: \"tabular-nums\",\n      }}\n    >\n      9:41\n    </span>\n    {/* Dynamic Island */}\n    <div\n      style={{\n        position: \"absolute\",\n        top: 8,\n        left: \"50%\",\n        transform: \"translateX(-50%)\",\n        width: 82,\n        height: 24,\n        borderRadius: 999,\n        background: \"#000\",\n      }}\n    />\n    <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n      <SignalIcon />\n      <WifiIcon />\n      <BatteryIcon />\n    </div>\n  </div>\n);\n\nconst Phone: React.FC<{ screen: string; children: ReactNode }> = ({\n  screen,\n  children,\n}) => (\n  <div\n    style={{\n      width: PHONE_W,\n      height: PHONE_H,\n      borderRadius: 48,\n      padding: 11,\n      background: \"#0a0a0b\",\n      border: \"1px solid rgba(255,255,255,0.1)\",\n      boxShadow:\n        \"0 40px 90px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.6) inset\",\n    }}\n  >\n    <div\n      style={{\n        position: \"relative\",\n        display: \"flex\",\n        flexDirection: \"column\",\n        width: \"100%\",\n        height: \"100%\",\n        borderRadius: 38,\n        overflow: \"hidden\",\n        background: screen,\n      }}\n    >\n      <StatusBar />\n      <div style={{ position: \"relative\", flex: 1, minHeight: 0 }}>{children}</div>\n    </div>\n  </div>\n);\n\n// ---------------------------------------------------------------------------\n// Montage scene — left feature copy, right the live phone.\n// ---------------------------------------------------------------------------\nconst PlatformScene: React.FC<{ platform: Platform; index: number }> = ({\n  platform,\n  index,\n}) => {\n  const frame = useCurrentFrame();\n  const enter = interpolate(frame, [0, 24], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n\n  return (\n    <AbsoluteFill style={{ alignItems: \"center\", justifyContent: \"center\" }}>\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 76 }}>\n        {/* Left: the changelog entry. */}\n        <div style={{ width: 420, fontFamily: FONT_STACK }}>\n          <Reveal delay={2}>\n            <div\n              style={{\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: 9,\n                marginBottom: 22,\n              }}\n            >\n              <span\n                style={{\n                  width: 8,\n                  height: 8,\n                  borderRadius: \"50%\",\n                  background: platform.accent,\n                  boxShadow: `0 0 14px ${platform.accent}`,\n                }}\n              />\n              <span style={{ fontSize: 15, fontWeight: 600, color: MUTED }}>\n                New · {String(index).padStart(2, \"0\")} / {String(PLATFORMS.length).padStart(2, \"0\")}\n              </span>\n            </div>\n          </Reveal>\n\n          <Reveal delay={8} y={16}>\n            <h2\n              style={{\n                margin: 0,\n                fontSize: 58,\n                fontWeight: 700,\n                lineHeight: 1.02,\n                letterSpacing: \"-0.02em\",\n                color: INK,\n              }}\n            >\n              {platform.label}\n            </h2>\n          </Reveal>\n\n          <Reveal delay={18}>\n            <p\n              style={{\n                margin: \"18px 0 0\",\n                fontSize: 20,\n                fontWeight: 400,\n                lineHeight: 1.5,\n                color: MUTED,\n                maxWidth: 380,\n              }}\n            >\n              {platform.blurb}\n            </p>\n          </Reveal>\n\n          <Reveal delay={28}>\n            <div\n              style={{\n                display: \"inline-flex\",\n                alignItems: \"center\",\n                gap: 9,\n                marginTop: 28,\n                height: 40,\n                padding: \"0 16px\",\n                borderRadius: 999,\n                border: \"1px solid rgba(255,255,255,0.12)\",\n                background: \"rgba(255,255,255,0.04)\",\n                fontFamily: MONO_STACK,\n                fontSize: 15,\n              }}\n            >\n              <span style={{ color: FAINT }}>$</span>\n              <span style={{ color: INK }}>shadcn add @remocn/{platform.name}</span>\n            </div>\n          </Reveal>\n        </div>\n\n        {/* Right: the live device. */}\n        <div\n          style={{\n            opacity: enter,\n            transform: `translateY(${(1 - enter) * 28}px) scale(${0.96 + enter * 0.04})`,\n          }}\n        >\n          <Phone screen={platform.screen}>{platform.render(platform.speed)}</Phone>\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Intro & outro\n// ---------------------------------------------------------------------------\nconst INTRO = 100;\nconst OUTRO = 116;\n\nconst HeadlineLine: React.FC<{ children: ReactNode; delay: number }> = ({\n  children,\n  delay,\n}) => {\n  const frame = useCurrentFrame();\n  const p = interpolate(frame, [delay, delay + 22], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n  return (\n    <div\n      style={{\n        opacity: p,\n        transform: `translateY(${(1 - p) * 0.5}em)`,\n        filter: p < 1 ? `blur(${(1 - p) * 9}px)` : undefined,\n      }}\n    >\n      {children}\n    </div>\n  );\n};\n\nconst IntroScene: React.FC = () => {\n  const frame = useCurrentFrame();\n  const kicker = interpolate(frame, [4, 20], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.out(Easing.cubic),\n  });\n  return (\n    <AbsoluteFill style={{ alignItems: \"center\", justifyContent: \"center\" }}>\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          gap: 30,\n        }}\n      >\n        <div\n          style={{\n            opacity: kicker,\n            transform: `translateY(${(1 - kicker) * 8}px)`,\n          }}\n        >\n          <Wordmark markSize={34} fontSize={25} />\n        </div>\n\n        <div\n          style={{\n            textAlign: \"center\",\n            fontFamily: FONT_STACK,\n            fontWeight: 700,\n            fontSize: 92,\n            lineHeight: 1.05,\n            letterSpacing: \"-0.02em\",\n            color: INK,\n          }}\n        >\n          <HeadlineLine delay={10}>New chat</HeadlineLine>\n          <HeadlineLine delay={22}>components</HeadlineLine>\n        </div>\n\n        <div style={{ position: \"relative\", width: 560, height: 44 }}>\n          <Sequence from={48}>\n            <Typewriter\n              text=\"3 messaging UIs, one API\"\n              fontSize={28}\n              fontWeight={500}\n              color={MUTED}\n              cursorColor={MUTED}\n              charsPerSecond={22}\n            />\n          </Sequence>\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n};\n\nconst CopyIcon: React.FC<{ size: number }> = ({ size }) => (\n  <svg\n    width={size}\n    height={size}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth={2}\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <rect width={14} height={14} x={8} y={8} rx={2} ry={2} />\n    <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n  </svg>\n);\n\nconst CheckIcon: React.FC<{ size: number }> = ({ size }) => (\n  <svg\n    width={size}\n    height={size}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth={2.4}\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M20 6 9 17l-5-5\" />\n  </svg>\n);\n\nconst InstallPill: React.FC<{ command: string; delay: number; copyAt: number }> = ({\n  command,\n  delay,\n  copyAt,\n}) => {\n  const frame = useCurrentFrame();\n  const enter = interpolate(frame, [delay, delay + 16], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n  const copied = frame >= copyAt;\n  const pop = interpolate(frame - copyAt, [0, 8], [0.5, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.34, 1.56, 0.64, 1),\n  });\n  return (\n    <div\n      style={{\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        gap: 13,\n        height: 52,\n        padding: \"0 22px\",\n        borderRadius: 999,\n        border: `1px solid rgba(255,255,255,${copied ? 0.22 : 0.14})`,\n        background: \"rgba(255,255,255,0.05)\",\n        backdropFilter: \"blur(10px)\",\n        WebkitBackdropFilter: \"blur(10px)\",\n        boxShadow: \"0 12px 34px rgba(0,0,0,0.3)\",\n        fontFamily: MONO_STACK,\n        fontSize: 18,\n        opacity: enter,\n        transform: `translateY(${(1 - enter) * 12}px)`,\n      }}\n    >\n      <span style={{ color: FAINT }}>$</span>\n      <span style={{ color: INK }}>{command}</span>\n      <span\n        style={{\n          display: \"inline-flex\",\n          marginLeft: 3,\n          color: copied ? \"#4ade80\" : \"rgba(250,250,250,0.55)\",\n          transform: copied ? `scale(${pop})` : \"scale(1)\",\n        }}\n      >\n        {copied ? <CheckIcon size={17} /> : <CopyIcon size={17} />}\n      </span>\n    </div>\n  );\n};\n\nconst OutroScene: React.FC = () => {\n  const frame = useCurrentFrame();\n  const word = interpolate(frame, [6, 30], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.bezier(0.22, 1, 0.36, 1),\n  });\n  const exit = interpolate(frame, [OUTRO - 18, OUTRO], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.in(Easing.cubic),\n  });\n  return (\n    <AbsoluteFill\n      style={{\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        opacity: 1 - exit,\n        transform: `translateY(${-exit * 60}px)`,\n        filter: exit > 0.001 ? `blur(${exit * 16}px)` : undefined,\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          gap: 30,\n        }}\n      >\n        <span\n          style={{\n            fontFamily: FONT_STACK,\n            fontSize: 96,\n            fontWeight: 600,\n            color: INK,\n            lineHeight: 1,\n            letterSpacing: \"-0.02em\",\n            opacity: word,\n            filter: word < 1 ? `blur(${(1 - word) * 12}px)` : undefined,\n            transform: `translateY(${(1 - word) * 10}px)`,\n          }}\n        >\n          remocn\n        </span>\n        <InstallPill\n          command=\"shadcn add @remocn/chat-flow\"\n          delay={52}\n          copyAt={86}\n        />\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Scene assembly\n// ---------------------------------------------------------------------------\ntype SceneSlot = { node: ReactNode; dur: number; trans?: Trans };\n\nconst buildScenes = (): SceneSlot[] => {\n  const slots: SceneSlot[] = [];\n  slots.push({ node: <IntroScene />, dur: INTRO, trans: DISSOLVE });\n  PLATFORMS.forEach((platform, i) => {\n    slots.push({\n      node: <PlatformScene platform={platform} index={i + 1} />,\n      dur: platform.duration,\n      trans: DISSOLVE,\n    });\n  });\n  slots.push({ node: <OutroScene />, dur: OUTRO });\n  return slots;\n};\n\nconst SCENES = buildScenes();\n\nexport const CHAT_CHANGELOG_DURATION =\n  SCENES.reduce((a, s) => a + s.dur, 0) -\n  SCENES.reduce((a, s) => a + (s.trans?.dur ?? 0), 0);\n\n// Composition-frame start of each slot (accounts for transition overlaps).\nconst SCENE_STARTS = (() => {\n  const starts: number[] = [];\n  let acc = 0;\n  for (const s of SCENES) {\n    starts.push(acc);\n    acc += s.dur - (s.trans?.dur ?? 0);\n  }\n  return starts;\n})();\n\n// Slots: 0 = intro, 1..N = platforms, last = outro.\nconst MONTAGE_TAGS = PLATFORMS.map((platform, i) => ({\n  start: SCENE_STARTS[i + 1],\n  index: i + 1,\n  name: platform.name,\n}));\nconst MONTAGE_START = MONTAGE_TAGS[0].start;\nconst MONTAGE_END = SCENE_STARTS[SCENES.length - 1]; // outro start\n\n// Persistent counter — holds still while scenes change beneath it.\nconst SceneCounter: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  let active = 0;\n  for (let i = 0; i < MONTAGE_TAGS.length; i++) {\n    if (frame >= MONTAGE_TAGS[i].start) active = i;\n  }\n  const tag = MONTAGE_TAGS[active];\n\n  const appear = interpolate(frame, [MONTAGE_START - 8, MONTAGE_START + 6], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const out = interpolate(frame, [MONTAGE_END - 12, MONTAGE_END], [1, 0], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const vis = appear * out;\n\n  const valueOpacity = interpolate(frame - tag.start, [0, 7], [0.35, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n    easing: Easing.out(Easing.cubic),\n  });\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        left: 70,\n        bottom: 60,\n        display: \"flex\",\n        alignItems: \"baseline\",\n        gap: 14,\n        opacity: vis,\n        fontFamily: FONT_STACK,\n        color: MUTED,\n      }}\n    >\n      <span\n        style={{\n          fontSize: 22,\n          fontWeight: 700,\n          fontVariantNumeric: \"tabular-nums\",\n          opacity: valueOpacity,\n        }}\n      >\n        {String(tag.index).padStart(2, \"0\")}\n      </span>\n      <span style={{ fontSize: 15, fontWeight: 500, color: FAINT }}>\n        / {String(PLATFORMS.length).padStart(2, \"0\")}\n      </span>\n      <span\n        style={{\n          fontSize: 16,\n          fontWeight: 500,\n          color: \"rgba(250,250,250,0.5)\",\n          opacity: valueOpacity,\n        }}\n      >\n        {tag.name}\n      </span>\n    </div>\n  );\n};\n\n// ---------------------------------------------------------------------------\n// Composition root\n// ---------------------------------------------------------------------------\nexport const ChatChangelogDemo: React.FC = () => {\n  const children: ReactNode[] = [];\n  SCENES.forEach((scene, i) => {\n    children.push(\n      <TransitionSeries.Sequence key={`s-${i}`} durationInFrames={scene.dur}>\n        {scene.node}\n      </TransitionSeries.Sequence>,\n    );\n    if (scene.trans) {\n      children.push(\n        <TransitionSeries.Transition\n          key={`t-${i}`}\n          timing={linearTiming({ durationInFrames: scene.trans.dur })}\n          presentation={scene.trans.presentation()}\n        />,\n      );\n    }\n  });\n\n  return (\n    <RemocnUIProvider>\n      <AbsoluteFill\n        style={{ \"--font-geist-sans\": fontFamily } as React.CSSProperties}\n      >\n        <Backdrop fill={{ type: \"image\", src: demoAsset(\"bg.png\") }} />\n        <AbsoluteFill\n          style={{\n            background:\n              \"radial-gradient(120% 120% at 50% 42%, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.5) 100%)\",\n          }}\n        />\n\n        <TransitionSeries>{children}</TransitionSeries>\n\n        <SceneCounter />\n      </AbsoluteFill>\n    </RemocnUIProvider>\n  );\n};\n",
      "type": "registry:component",
      "target": "demos/chat-changelog/index.tsx"
    },
    {
      "path": "src/lib/demo-assets.ts",
      "content": "import { staticFile } from \"remotion\";\n\n// Demo assets are loaded from absolute URLs so a demo installed into another\n// project via the registry renders identically without copying this repo's\n// public/ directory. The same URLs are used on the site and in local renders,\n// which keeps a single code path (network access is required for rendering).\n//\n// Override with REMOTION_DEMO_ASSETS_BASE — only REMOTION_-prefixed env vars\n// reach Remotion compositions (next.config.js additionally inlines it for the\n// site's <Player>). Two override forms:\n//   - \"local\" — serve straight from this repo's public/ via staticFile();\n//     works in Remotion Studio, CLI renders and the Next dev site. Set it in\n//     the gitignored .env so previews never depend on pushed assets.\n//   - any URL — e.g. a local static server (http://127.0.0.1:8123).\nexport const DEMO_ASSETS_BASE =\n  process.env.REMOTION_DEMO_ASSETS_BASE ||\n  \"https://raw.githubusercontent.com/Remocn/remocn-collections/main/public\";\n\nexport const demoAsset = (path: string): string => {\n  const clean = path.replace(/^\\/+/, \"\");\n  if (DEMO_ASSETS_BASE === \"local\") return staticFile(clean);\n  return `${DEMO_ASSETS_BASE}/${clean}`;\n};\n",
      "type": "registry:component",
      "target": "lib/demo-assets.ts"
    },
    {
      "path": "src/demos/chat-changelog/prompt.md",
      "content": "<!-- TODO(draft): placeholder written by AI — replace with the real prompt used to generate this video -->\n\nMake a changelog video for the new chat components in remocn — chat-flow plus iMessage and Telegram-style flows. Show each one actually playing live inside a phone frame over an image backdrop, so people can see the bubbles and typing indicator animate in each skin. Make the point that all three are built from the same message-bubble and typing-indicator primitives, so it's really one message API with three different skins on top, not three separate implementations. Use remocn's chat components for the phone demos.\n",
      "type": "registry:file",
      "target": "demos/chat-changelog/prompt.md"
    }
  ],
  "docs": "Register the composition in your Remotion Root:\n\n  import { ChatChangelogDemo } from \"@/demos/chat-changelog\";\n  <Composition id=\"chat-changelog\" component={ChatChangelogDemo} durationInFrames={693} fps={30} width={1280} height={720} />\n\nRequires Tailwind v4 wired into Remotion (@remotion/tailwind-v4).\nRender locally: npx remotion render chat-changelog out/chat-changelog.mp4",
  "type": "registry:block"
}