{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chatMessenger",
  "type": "registry:block",
  "title": "Chat Messenger",
  "description": "Short synthetic Messenger-style conversation when a back-and-forth helps explain a use case, objection, or punchline. Use sparingly because it is long.",
  "dependencies": [
    "react"
  ],
  "registryDependencies": [
    "@vanillasky/motion",
    "@vanillasky/theme",
    "@vanillasky/typography",
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/scene-templates/social-conversation.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/social-conversation.tsx",
      "content": "/**\n * chatMessenger + chatWhatsapp — themed conversation scenes.\n *\n * ConversationThread owns bubble chrome, typing, receipts, chips, timing,\n * orientation, and export-safe emoji rendering. This template translates the\n * editable msg1..msg5 fields into the primitive's message collection.\n */\n\nimport * as React from \"react\";\nimport type { VariableField } from \"../video-config\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { normalizeTextVar } from \"./normalize-var\";\nimport { ConversationThread } from \"../primitives/social/ConversationThread\";\n\nexport const socialConversationSchema: Record<string, VariableField> = {\n  msg1: {\n    type: \"string\",\n    label: \"Message 1 (received)\",\n    default: \"Have you tried it yet?\",\n    required: true,\n    description: \"First message — left/received by default. Append |in or |out to override side. Aim for 3-4 messages total; use 5 with date chips only for a real time gap in a 10s+ scene.\",\n  },\n  msg2: {\n    type: \"string\",\n    label: \"Message 2 (received or sent)\",\n    default: \"It saves me hours every week\",\n    required: true,\n    description: \"Second message — left/received by default. Override with |out for ping-pong.\",\n  },\n  msg3: {\n    type: \"string\",\n    label: \"Message 3 (received)\",\n    default: \"And the team loves it\",\n    required: true,\n    description: \"Third message — left/received by default. Leave blank for a two-message exchange.\",\n  },\n  msg4: {\n    type: \"string\",\n    label: \"Message 4 (sent)\",\n    default: \"Sounds great — sharing access now\",\n    description: \"Fourth message — right/sent by default. Optional; most chats end here.\",\n  },\n  dateChip1: {\n    type: \"string\",\n    label: \"Date chip 1\",\n    default: \"\",\n    description: \"Optional date label before message 4. Use only for a real time gap.\",\n  },\n  dateChip2: {\n    type: \"string\",\n    label: \"Date chip 2\",\n    default: \"\",\n    description: \"Optional date label before message 5. Use only for a real time gap.\",\n  },\n  msg5: {\n    type: \"string\",\n    label: \"Message 5 (sent)\",\n    default: \"\",\n    description: \"Optional fifth message — right/sent by default.\",\n  },\n};\n\nconst sharedMessages = {\n  msg1: \"Have you tried it yet?\",\n  msg2: \"It saves me hours every week\",\n  msg3: \"And the team loves it\",\n  msg4: \"Sounds great — sharing access now\",\n  msg5: \"\",\n  dateChip1: \"\",\n  dateChip2: \"\",\n};\n\nexport const socialMessengerDefaults: Record<string, unknown> = {\n  theme: \"messenger\",\n  ...sharedMessages,\n};\n\nexport const socialWhatsappDefaults: Record<string, unknown> = {\n  theme: \"whatsapp\",\n  ...sharedMessages,\n};\n\nconst DEFAULT_SIDES: Record<number, \"left\" | \"right\"> = {\n  1: \"left\",\n  2: \"left\",\n  3: \"left\",\n  4: \"right\",\n  5: \"right\",\n};\n\nfunction buildMessages(variables: Record<string, unknown>) {\n  const messages: Array<{ author: string; text: string; side: \"left\" | \"right\" }> = [];\n  for (let index = 1; index <= 5; index += 1) {\n    let text = normalizeTextVar(variables[`msg${index}`]).trim();\n    if (!text) continue;\n    let side = DEFAULT_SIDES[index] ?? (index % 2 === 1 ? \"left\" : \"right\");\n    if (text.endsWith(\"|in\")) {\n      side = \"left\";\n      text = text.slice(0, -3).trim();\n    } else if (text.endsWith(\"|out\")) {\n      side = \"right\";\n      text = text.slice(0, -4).trim();\n    }\n    messages.push({ author: side === \"left\" ? \"Customer\" : \"You\", text, side });\n  }\n  return messages;\n}\n\nexport const SocialConversationTemplate: React.FC<SceneTemplateProps> = ({\n  variables,\n  progress,\n  beatIntensity,\n  width,\n  height,\n  sceneDuration,\n  safeZone,\n}) => {\n  const theme = String(variables.theme || \"messenger\").toLowerCase() === \"whatsapp\"\n    ? \"whatsapp\"\n    : \"messenger\";\n\n  return (\n    <div style={{ width, height, position: \"relative\", overflow: \"hidden\", backgroundColor: theme === \"whatsapp\" ? \"#EFE7DC\" : \"#ffffff\" }}>\n      {/* [slot: hero] ConversationThread includes the theme-specific canvas. */}\n      <ConversationThread\n        progress={progress}\n        width={width}\n        height={height}\n        sceneDuration={sceneDuration ?? 8}\n        messages={buildMessages(variables)}\n        theme={theme}\n        dateChip1={String(variables.dateChip1 || \"\")}\n        dateChip2={String(variables.dateChip2 || \"\")}\n        safeZoneTop={Math.max(safeZone.top, height * 0.08)}\n        beatIntensity={beatIntensity}\n      />\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/primitives/social/ConversationThread.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/social/ConversationThread.tsx",
      "content": "/**\n * ConversationThread — multi-bubble chat thread with staggered reveal,\n * typing indicators, and (for WhatsApp) animated read receipts.\n *\n * Lifted from `social-conversation.tsx`. The primitive owns:\n *   - the centered chat column (full-width portrait / ~72% landscape)\n *   - per-message typing → bubble pop sequence\n *   - WhatsApp tick state progression (clock → sent → delivered → read)\n *   - optional date chips inserted between specific messages\n *   - the WhatsApp compose bar (decorative — drawn 1:1 from the source)\n *\n * It does NOT own a SceneBackground gradient/media or a headline overlay.\n * The primitive fills its own backdrop with the theme background color so\n * the bubbles always have correct contrast; if a caller wants the scene\n * gradient to show through, drop this primitive on top of a SceneBackground.\n *\n * The source template parses messages from `msg1..msg5` variables plus a\n * `|in`/`|out` suffix override. This primitive accepts the already-parsed\n * `messages` array so callers can pre-split however they like.\n *\n * Local helpers (`easeOutBack`, `tickStateAt`, `timingForCount`) are\n * inlined because the source defined them locally; they are not exported.\n *\n * Props:\n *  - progress      — scene progress 0..1\n *  - width / height — frame dimensions\n *  - sceneDuration — seconds; drives the 1.5 Hz typing-dot ripple in real time\n *  - messages      — array of `{ author, text, side }`. `author` is\n *                    decorative (not rendered in either theme today) but\n *                    accepted so callers don't lose data when round-tripping.\n *  - theme         — \"whatsapp\" or \"messenger\" (iMessage). Default \"whatsapp\".\n *  - safeZoneTop   — top inset to clear the social overlay UI. Default 8% of height.\n *  - accent        — brand accent (kept for parity; today's chrome is locked\n *                    to WhatsApp/iMessage native palettes for realism)\n *  - beatIntensity — accepted for parity; not currently applied\n */\n\nimport * as React from \"react\";\nimport { interpolate } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\" as const,\n  extrapolateRight: \"clamp\" as const,\n};\n\n/* Reference timeline length — every reveal time is a fraction of this. */\nconst REF_DURATION = 16;\n\n/* easeOutBack — same overshoot curve the source uses for pop-ins. */\nfunction easeOutBack(t: number): number {\n  const c1 = 1.70158;\n  const c3 = c1 + 1;\n  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n}\n\n/* WhatsApp palette */\nconst WA = {\n  bg: \"#EFE7DC\",\n  incomingBubble: \"#FFFFFF\",\n  outgoingBubble: \"#D9F4C7\",\n  text: \"#0E0E0E\",\n  textMuted: \"#6E6E73\",\n  accent: \"#0A8F76\",\n  chipBg: \"#F1E9D8\",\n  chipText: \"#3C342A\",\n  navBg: \"#F1E9DA\",\n  inputBg: \"#FFFFFF\",\n  inputBorder: \"rgba(0,0,0,0.06)\",\n  outgoingTime: \"#5C6B4E\",\n};\n\n/* iMessage palette */\nconst IM = {\n  bg: \"#ffffff\",\n  bubbleOut: \"#007AFF\",\n  bubbleIn: \"#E9E9EB\",\n  textOut: \"#ffffff\",\n  textIn: \"#000000\",\n  dotsColor: \"#8E8E93\",\n};\n\ninterface InternalMessage {\n  side: \"left\" | \"right\";\n  text: string;\n  author: string;\n  typingStart: number;\n  bubbleStart: number;\n  time: string;\n}\n\n/* WhatsApp reference timing — mirrors source 5-message cadence. */\nconst WA_TIMING_5: Array<{ typingStart: number; bubbleStart: number; time: string }> = [\n  { typingStart: 0.6, bubbleStart: 1.4, time: \"21:54\" },\n  { typingStart: 2.2, bubbleStart: 3.0, time: \"21:55\" },\n  { typingStart: 3.8, bubbleStart: 4.8, time: \"21:56\" },\n  { typingStart: 6.2, bubbleStart: 7.2, time: \"12:35\" },\n  { typingStart: 10.0, bubbleStart: 11.0, time: \"11:17\" },\n];\n\nfunction timingForCount(count: number): Array<{ typingStart: number; bubbleStart: number; time: string }> {\n  if (count >= 5) return WA_TIMING_5.slice(0, count);\n  const slot = 12 / count;\n  const out: Array<{ typingStart: number; bubbleStart: number; time: string }> = [];\n  for (let i = 0; i < count; i++) {\n    const start = 0.6 + i * slot;\n    out.push({\n      typingStart: start,\n      bubbleStart: start + slot * 0.5,\n      time: WA_TIMING_5[Math.min(i, 4)].time,\n    });\n  }\n  return out;\n}\n\n/* Read-receipt phases (WhatsApp). */\ntype TickState = \"clock\" | \"sent\" | \"delivered\" | \"read\";\n\nfunction tickStateAt(refTimeSinceSent: number): TickState {\n  if (refTimeSinceSent < 0.5) return \"clock\";\n  if (refTimeSinceSent < 1.1) return \"sent\";\n  if (refTimeSinceSent < 1.8) return \"delivered\";\n  return \"read\";\n}\n\nconst ClockIcon: React.FC<{ s: number }> = ({ s }) => (\n  <svg width={12 * s} height={12 * s} viewBox=\"0 0 12 12\" style={{ marginLeft: 4 * s }}>\n    <circle cx=\"6\" cy=\"6\" r=\"5\" fill=\"none\" stroke=\"#8C8C92\" strokeWidth=\"1.2\" />\n    <path d=\"M6 3 v3.5 l2 1\" stroke=\"#8C8C92\" strokeWidth=\"1.2\" fill=\"none\" strokeLinecap=\"round\" />\n  </svg>\n);\n\nconst Ticks: React.FC<{ state: TickState; s: number }> = ({ state, s }) => {\n  if (state === \"clock\") return <ClockIcon s={s} />;\n  const color = state === \"read\" ? \"#53BDEB\" : \"#8C8C92\";\n  return (\n    <svg width={16 * s} height={11 * s} viewBox=\"0 0 16 11\" style={{ marginLeft: 4 * s }}>\n      <path\n        d=\"M1 6 l3 3 l6 -7\"\n        stroke={color}\n        strokeWidth={1.3}\n        fill=\"none\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        opacity={state === \"sent\" ? 0 : 1}\n      />\n      <path\n        d=\"M5 6 l3 3 l7 -8\"\n        stroke={color}\n        strokeWidth={1.3}\n        fill=\"none\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n};\n\nconst ComposeBar: React.FC<{ s: number }> = ({ s }) => {\n  const stroke = \"#1A1A1A\";\n  return (\n    <div\n      style={{\n        background: WA.navBg,\n        borderTop: \"1px solid rgba(0,0,0,0.05)\",\n        padding: `${10 * s}px ${14 * s}px ${12 * s}px`,\n        display: \"flex\",\n        alignItems: \"center\",\n        gap: 14 * s,\n      }}\n    >\n      <svg width={30 * s} height={30 * s} viewBox=\"0 0 30 30\" style={{ flexShrink: 0 }}>\n        <path d=\"M15 5 v20 M5 15 h20\" stroke={stroke} strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n      </svg>\n      <div\n        style={{\n          flex: 1,\n          height: 40 * s,\n          background: WA.inputBg,\n          borderRadius: 22 * s,\n          position: \"relative\",\n          border: `0.5px solid ${WA.inputBorder}`,\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            right: 12 * s,\n            top: \"50%\",\n            transform: \"translateY(-50%)\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <svg width={28 * s} height={28 * s} viewBox=\"0 0 24 24\" fill=\"none\" style={{ display: \"block\" }}>\n            <path\n              d=\"M3 5 a2 2 0 0 1 2 -2 h10 a2 2 0 0 1 2 2 v6 l-6 6 h-6 a2 2 0 0 1 -2 -2 z M17 11 a4 4 0 0 0 -4 4 v2\"\n              stroke=\"#555\"\n              strokeWidth=\"1.5\"\n              fill=\"none\"\n              strokeLinejoin=\"round\"\n              strokeLinecap=\"round\"\n            />\n          </svg>\n        </div>\n      </div>\n      <svg width={28 * s} height={24 * s} viewBox=\"0 0 28 24\" fill=\"none\" style={{ flexShrink: 0 }}>\n        <path\n          d=\"M3 6 h5 l2 -3 h8 l2 3 h5 a1 1 0 0 1 1 1 v14 a1 1 0 0 1 -1 1 h-22 a1 1 0 0 1 -1 -1 v-14 a1 1 0 0 1 1 -1 z\"\n          stroke={stroke}\n          strokeWidth=\"1.6\"\n          fill=\"none\"\n          strokeLinejoin=\"round\"\n        />\n        <circle cx=\"14\" cy=\"14\" r=\"4.5\" stroke={stroke} strokeWidth=\"1.6\" fill=\"none\" />\n      </svg>\n      <svg width={20 * s} height={26 * s} viewBox=\"0 0 20 26\" fill=\"none\" style={{ flexShrink: 0 }}>\n        <rect x=\"7\" y=\"2\" width=\"6\" height=\"13\" rx=\"3\" stroke={stroke} strokeWidth=\"1.6\" fill=\"none\" />\n        <path\n          d=\"M3 12 a7 7 0 0 0 14 0 M10 19 v4 M7 23 h6\"\n          stroke={stroke}\n          strokeWidth=\"1.6\"\n          fill=\"none\"\n          strokeLinecap=\"round\"\n        />\n      </svg>\n    </div>\n  );\n};\n\nconst TypingBubble: React.FC<{\n  side: \"left\" | \"right\";\n  pop: number;\n  realTimeSeconds: number;\n  s: number;\n  theme: \"whatsapp\" | \"messenger\";\n}> = ({ side, pop, realTimeSeconds, s, theme }) => {\n  if (pop <= 0) return null;\n  const isOut = side === \"right\";\n  const isWA = theme === \"whatsapp\";\n  const dotColor = isWA ? (isOut ? \"#6B8A55\" : \"#8C8C92\") : IM.dotsColor;\n  const bg = isWA\n    ? isOut\n      ? WA.outgoingBubble\n      : WA.incomingBubble\n    : isOut\n      ? IM.bubbleOut\n      : IM.bubbleIn;\n\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        justifyContent: isOut ? \"flex-end\" : \"flex-start\",\n        padding: `${2 * s}px ${(isWA ? 10 : 14) * s}px`,\n        opacity: pop,\n        transform: `scale(${0.9 + 0.1 * pop})`,\n        transformOrigin: isOut ? \"right bottom\" : \"left bottom\",\n      }}\n    >\n      <div\n        style={{\n          background: bg,\n          borderRadius: (isWA ? 14 : 18) * s,\n          ...(isWA\n            ? {\n                borderTopLeftRadius: isOut ? 14 * s : 2 * s,\n                borderTopRightRadius: isOut ? 2 * s : 14 * s,\n              }\n            : {}),\n          padding: `${(isWA ? 10 : 12) * s}px ${(isWA ? 12 : 16) * s}px`,\n          display: \"flex\",\n          gap: 5 * s,\n          alignItems: \"center\",\n          ...(isWA\n            ? { boxShadow: `0 ${1 * s}px ${1 * s}px rgba(0,0,0,0.06)` }\n            : {}),\n        }}\n      >\n        {[0, 1, 2].map((i) => {\n          const phase = (realTimeSeconds * 1.6 + i * 0.2) % 1;\n          const y = Math.sin(phase * Math.PI * 2) * 2.5 * s;\n          const op = 0.45 + 0.55 * Math.max(0, Math.sin(phase * Math.PI));\n          const dotSize = isWA ? 6 * s : 7 * s;\n          return (\n            <div\n              key={i}\n              style={{\n                width: dotSize,\n                height: dotSize,\n                borderRadius: dotSize / 2,\n                background: dotColor,\n                transform: `translateY(${-y}px)`,\n                opacity: op,\n              }}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nconst DateChip: React.FC<{ label: string; chipProgress: number; s: number }> = ({\n  label,\n  chipProgress,\n  s,\n}) => {\n  if (chipProgress <= 0) return null;\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        justifyContent: \"center\",\n        margin: `${12 * s}px 0 ${8 * s}px`,\n        opacity: chipProgress,\n        transform: `scale(${0.88 + 0.12 * chipProgress})`,\n      }}\n    >\n      <div\n        style={{\n          background: WA.chipBg,\n          color: WA.chipText,\n          padding: `${7 * s}px ${16 * s}px`,\n          borderRadius: 9 * s,\n          fontSize: 17 * s,\n          fontWeight: 600,\n          boxShadow: `0 ${1 * s}px ${1.5 * s}px rgba(0,0,0,0.05)`,\n        }}\n      >\n        {label}\n      </div>\n    </div>\n  );\n};\n\nconst IncomingBubble: React.FC<{\n  children: React.ReactNode;\n  time: string;\n  pop: number;\n  s: number;\n}> = ({ children, time, pop, s }) => (\n  <div\n    style={{\n      display: \"flex\",\n      justifyContent: \"flex-start\",\n      padding: `${2 * s}px ${10 * s}px`,\n      opacity: pop,\n      transform: `translateY(${(1 - pop) * 6 * s}px) scale(${0.97 + 0.03 * pop})`,\n      transformOrigin: \"left bottom\",\n    }}\n  >\n    <div\n      style={{\n        maxWidth: \"82%\",\n        background: WA.incomingBubble,\n        borderRadius: 10 * s,\n        borderTopLeftRadius: 2 * s,\n        padding: `${12 * s}px ${16 * s}px ${11 * s}px`,\n        fontSize: 23 * s,\n        lineHeight: `${29 * s}px`,\n        color: WA.text,\n        boxShadow: `0 ${1 * s}px ${1 * s}px rgba(0,0,0,0.06)`,\n        position: \"relative\",\n      }}\n    >\n      <div style={{ paddingRight: 64 * s }}>{children}</div>\n      <div\n        style={{\n          position: \"absolute\",\n          right: 14 * s,\n          bottom: 8 * s,\n          fontSize: 15 * s,\n          color: WA.textMuted,\n        }}\n      >\n        {time}\n      </div>\n    </div>\n  </div>\n);\n\nconst OutgoingBubble: React.FC<{\n  children: React.ReactNode;\n  time: string;\n  tickState: TickState;\n  pop: number;\n  s: number;\n}> = ({ children, time, tickState, pop, s }) => (\n  <div\n    style={{\n      display: \"flex\",\n      justifyContent: \"flex-end\",\n      padding: `${2 * s}px ${10 * s}px`,\n      opacity: pop,\n      transform: `translateY(${(1 - pop) * 8 * s}px) scale(${0.96 + 0.04 * pop})`,\n      transformOrigin: \"right bottom\",\n    }}\n  >\n    <div\n      style={{\n        maxWidth: \"82%\",\n        background: WA.outgoingBubble,\n        borderRadius: 10 * s,\n        borderTopRightRadius: 2 * s,\n        padding: `${12 * s}px ${16 * s}px ${11 * s}px`,\n        fontSize: 23 * s,\n        lineHeight: `${29 * s}px`,\n        color: WA.text,\n        boxShadow: `0 ${1 * s}px ${1 * s}px rgba(0,0,0,0.06)`,\n        position: \"relative\",\n      }}\n    >\n      <div style={{ paddingRight: 88 * s }}>{children}</div>\n      <div\n        style={{\n          position: \"absolute\",\n          right: 14 * s,\n          bottom: 8 * s,\n          fontSize: 15 * s,\n          color: WA.outgoingTime,\n          display: \"flex\",\n          alignItems: \"center\",\n        }}\n      >\n        {time}\n        <Ticks state={tickState} s={s} />\n      </div>\n    </div>\n  </div>\n);\n\nconst CHIP_TIMING = {\n  monday: { start: 5.8, end: 6.2 },\n  yesterday: { start: 9.6, end: 10.0 },\n};\n\nexport interface ConversationThreadProps {\n  progress: number;\n  width: number;\n  height: number;\n  sceneDuration: number;\n  messages: Array<{ author: string; text: string; side: \"left\" | \"right\" }>;\n  theme?: \"whatsapp\" | \"messenger\";\n  /** Optional date chips inserted before specific message indices.\n   *  Default behavior matches the source: chip1 before message 4 (index 3),\n   *  chip2 before message 5 (index 4). Leave blank to suppress. */\n  dateChip1?: string;\n  dateChip2?: string;\n  safeZoneTop?: number;\n  accent?: string;\n  beatIntensity?: number;\n}\n\nexport const ConversationThread: React.FC<ConversationThreadProps> = ({\n  progress,\n  width,\n  height,\n  sceneDuration,\n  messages: rawMessages,\n  theme = \"whatsapp\",\n  dateChip1 = \"\",\n  dateChip2 = \"\",\n  safeZoneTop,\n}) => {\n  const isLandscape = width > height;\n  const colWidth = isLandscape ? width * 0.72 : width;\n  const colLeft = (width - colWidth) / 2;\n  const s = Math.min(width, height) / 534;\n  const isWhatsApp = theme === \"whatsapp\";\n\n  // Apply stripPipe + attach timing\n  const sanitized = rawMessages\n    .map((m) => ({ ...m, text: stripPipe(m.text || \"\").trim() }))\n    .filter((m) => m.text.length > 0);\n  const timing = timingForCount(Math.max(1, sanitized.length));\n  const messages: InternalMessage[] = sanitized.map((m, i) => ({\n    side: m.side,\n    text: m.text,\n    author: m.author,\n    typingStart: timing[i].typingStart,\n    bubbleStart: timing[i].bubbleStart,\n    time: timing[i].time,\n  }));\n\n  const realTimeSeconds = progress * sceneDuration;\n  const refT = progress * REF_DURATION;\n  const topPad = safeZoneTop != null ? safeZoneTop : height * 0.08;\n\n  const chip1Label = stripPipe(dateChip1 || \"\").trim();\n  const chip2Label = stripPipe(dateChip2 || \"\").trim();\n\n  const popIn = (t: number, start: number, dur = 0.4): number => {\n    if (t <= start) return 0;\n    if (t >= start + dur) return 1;\n    return easeOutBack((t - start) / dur);\n  };\n\n  const ramp = (t: number, start: number, end: number): number => {\n    if (t <= start) return 0;\n    if (t >= end) return 1;\n    return (t - start) / (end - start);\n  };\n\n  if (isWhatsApp) {\n    return (\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          background: WA.bg,\n          overflow: \"hidden\",\n          fontFamily: '-apple-system, \"SF Pro Text\", system-ui',\n        }}\n      >\n        <div\n          style={{\n            position: \"absolute\",\n            left: colLeft,\n            width: colWidth,\n            top: 0,\n            height,\n            display: \"flex\",\n            flexDirection: \"column\",\n          }}\n        >\n          <div style={{ flex: 1, position: \"relative\", overflow: \"hidden\" }}>\n            <div\n              style={{\n                position: \"absolute\",\n                left: 0,\n                right: 0,\n                top: 0,\n                display: \"flex\",\n                flexDirection: \"column\",\n                gap: 2 * s,\n                padding: `${topPad}px 0 ${10 * s}px`,\n              }}\n            >\n              {messages.map((msg, i) => {\n                const typingPop = popIn(refT, msg.typingStart, 0.25);\n                const typingFade = interpolate(\n                  refT,\n                  [msg.bubbleStart - 0.1, msg.bubbleStart],\n                  [1, 0],\n                  CLAMP,\n                );\n                const showTyping = refT >= msg.typingStart && refT < msg.bubbleStart;\n                const bubblePop = popIn(refT, msg.bubbleStart, 0.4);\n                const showBubble = refT >= msg.bubbleStart;\n                const isOut = msg.side === \"right\";\n\n                const refSinceSent = refT - msg.bubbleStart - 0.4;\n                const tickState = isOut ? tickStateAt(Math.max(0, refSinceSent)) : \"read\";\n\n                const chipBeforeThis: { label: string; progress: number } | null = (() => {\n                  if (i === 3 && chip1Label) {\n                    return {\n                      label: chip1Label,\n                      progress: ramp(refT, CHIP_TIMING.monday.start, CHIP_TIMING.monday.end),\n                    };\n                  }\n                  if (i === 4 && chip2Label) {\n                    return {\n                      label: chip2Label,\n                      progress: ramp(refT, CHIP_TIMING.yesterday.start, CHIP_TIMING.yesterday.end),\n                    };\n                  }\n                  return null;\n                })();\n\n                return (\n                  <React.Fragment key={i}>\n                    {chipBeforeThis && (\n                      <DateChip\n                        label={chipBeforeThis.label}\n                        chipProgress={chipBeforeThis.progress}\n                        s={s}\n                      />\n                    )}\n\n                    {showTyping && (\n                      <div style={{ opacity: typingPop * typingFade }}>\n                        <TypingBubble\n                          side={msg.side}\n                          pop={typingPop}\n                          realTimeSeconds={realTimeSeconds}\n                          s={s}\n                          theme=\"whatsapp\"\n                        />\n                      </div>\n                    )}\n\n                    {showBubble &&\n                      (isOut ? (\n                        <OutgoingBubble pop={bubblePop} time={msg.time} tickState={tickState} s={s}>\n                          {renderWithEmoji(msg.text, 23 * s)}\n                        </OutgoingBubble>\n                      ) : (\n                        <IncomingBubble pop={bubblePop} time={msg.time} s={s}>\n                          {renderWithEmoji(msg.text, 23 * s)}\n                        </IncomingBubble>\n                      ))}\n                  </React.Fragment>\n                );\n              })}\n\n              <div style={{ height: 12 * s }} />\n            </div>\n          </div>\n\n          <ComposeBar s={s} />\n        </div>\n      </div>\n    );\n  }\n\n  // iMessage render\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        background: IM.bg,\n        overflow: \"hidden\",\n        fontFamily: \"-apple-system, 'SF Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif\",\n      }}\n    >\n      <div\n        style={{\n          position: \"absolute\",\n          left: colLeft,\n          width: colWidth,\n          top: 0,\n          height,\n          padding: `${topPad}px 0 ${20 * s}px`,\n          display: \"flex\",\n          flexDirection: \"column\",\n          gap: 4 * s,\n        }}\n      >\n        {messages.map((msg, i) => {\n          const typingPop = popIn(refT, msg.typingStart, 0.25);\n          const typingFade = interpolate(\n            refT,\n            [msg.bubbleStart - 0.1, msg.bubbleStart],\n            [1, 0],\n            CLAMP,\n          );\n          const showTyping = refT >= msg.typingStart && refT < msg.bubbleStart;\n          const bubblePop = popIn(refT, msg.bubbleStart, 0.4);\n          const showBubble = refT >= msg.bubbleStart;\n          const isOut = msg.side === \"right\";\n\n          return (\n            <React.Fragment key={i}>\n              {showTyping && (\n                <div style={{ opacity: typingPop * typingFade }}>\n                  <TypingBubble\n                    side={msg.side}\n                    pop={typingPop}\n                    realTimeSeconds={realTimeSeconds}\n                    s={s}\n                    theme=\"messenger\"\n                  />\n                </div>\n              )}\n\n              {showBubble && (\n                <div\n                  style={{\n                    display: \"flex\",\n                    justifyContent: isOut ? \"flex-end\" : \"flex-start\",\n                    padding: `${2 * s}px ${14 * s}px`,\n                    opacity: bubblePop,\n                    transform: `translateY(${(1 - bubblePop) * 8 * s}px) scale(${0.96 + 0.04 * bubblePop})`,\n                    transformOrigin: isOut ? \"right bottom\" : \"left bottom\",\n                  }}\n                >\n                  <div\n                    style={{\n                      maxWidth: \"75%\",\n                      background: isOut ? IM.bubbleOut : IM.bubbleIn,\n                      color: isOut ? IM.textOut : IM.textIn,\n                      borderRadius: 22 * s,\n                      padding: `${12 * s}px ${18 * s}px`,\n                      fontSize: 23 * s,\n                      lineHeight: `${29 * s}px`,\n                    }}\n                  >\n                    {renderWithEmoji(msg.text, 23 * s)}\n                  </div>\n                </div>\n              )}\n            </React.Fragment>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/emoji/emoji-text.tsx",
      "type": "registry:component",
      "target": "vanillasky/emoji/emoji-text.tsx",
      "content": "/**\n * EmojiText / renderWithEmoji — split a string into text runs + color-emoji\n * runs, rendering each color emoji as an inline emoji-PNG <img> (via <Emoji>)\n * while leaving everything else as plain text.\n *\n * Why this exists: raw-unicode emoji fall through to the OS emoji font (Mac =\n * Apple, Linux = Noto), so the three render paths (preview / client SVG export\n * / server Puppeteer) disagree. Wrapping every template/AI-supplied string here\n * makes ALL color emoji render as the same mapped PNG in all three.\n *\n * What it does NOT touch: monochrome symbols (★ ✦ ✓ → ↑ …). Those render\n * identically as text across OSes, and imaging them would be wrong. The gate\n * is `emojiToDataUri()` — only clusters that resolve to a PNG in the\n * map become <img>; anything else (monochrome symbols, unmapped/custom\n * emoji, skin-tone variants not in the set) stays raw text.\n *\n * Segmentation: grapheme clusters via Intl.Segmenter so ZWJ sequences\n * (👩‍💻) and skin-tone modifiers (👍🏽) stay single units. Adjacent emoji\n * clusters each render as their own <img> in sequence.\n *\n * Sizing: each emoji <img> is sized to `fontSizePx` (the surrounding font\n * size) so the emoji box matches a glyph box — width/line-count stay\n * ~identical to the raw-text layout, which keeps fitTextSize / archetype\n * char-count measurement valid (all string-based, never DOM-measured).\n */\n\nimport * as React from \"react\";\nimport { Emoji, emojiToDataUri } from \"./index\";\n\n// Coarse \"is this cluster a color-emoji candidate?\" test. Deliberately broad:\n// the real decision is whether emojiToDataUri() resolves it to a mapped PNG.\n// This regex only needs to (a) catch the OS-divergent color ranges and (b)\n// avoid flagging plain text/whitespace so we don't merge text into emoji runs.\n//\n//  - \\u{1F000}-\\u{1FAFF}  — main emoji planes (most pictographs)\n//  - \\u{2600}-\\u{27BF}    — misc symbols + dingbats (color when + FE0F)\n//  - \\u{2B00}-\\u{2BFF}    — stars/arrows block (⭐ lives here)\n//  - \\u{1F1E6}-\\u{1F1FF}  — regional indicators (flags)\n//  - \\u{1F3FB}-\\u{1F3FF}  — skin-tone modifiers\n//  - \\u{FE0F} | \\u{200D} | \\u{20E3} — VS16 / ZWJ / keycap combiners\n//\n// Combining/joiner codepoints (FE0F, ZWJ, keycap) are matched via alternation\n// rather than inside the character class — eslint's\n// no-misleading-character-class flags combining chars inside `[...]`, and\n// alternation is equivalent here (we only test, never capture).\nconst EMOJI_CANDIDATE =\n  /[\\u{1F000}-\\u{1FAFF}\\u{2600}-\\u{27BF}\\u{2B00}-\\u{2BFF}\\u{1F1E6}-\\u{1F1FF}]|[\\u{1F3FB}-\\u{1F3FF}]|\\u{FE0F}|\\u{200D}|\\u{20E3}/u;\n\nfunction isEmojiCandidate(cluster: string): boolean {\n  return EMOJI_CANDIDATE.test(cluster);\n}\n\n/** Split text into grapheme clusters (ZWJ + skin-tone safe). */\nfunction toGraphemes(text: string): string[] {\n  const Seg = (Intl as unknown as { Segmenter?: typeof Intl.Segmenter }).Segmenter;\n  if (Seg) {\n    const seg = new Seg(undefined, { granularity: \"grapheme\" });\n    const out: string[] = [];\n    for (const { segment } of seg.segment(text)) out.push(segment);\n    return out;\n  }\n  // Fallback: code-point spread. Won't keep ZWJ/skin-tone as single units, so\n  // those land as separate clusters; emojiToDataUri's FE0F tolerance + the raw\n  // fallback keep them from breaking. Modern browsers all have Segmenter.\n  return Array.from(text);\n}\n\nexport interface RenderWithEmojiOptions {\n  /** vertical-align passed to each <Emoji>. */\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  /** Extra style merged onto each emoji <img>. */\n  emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Split `text` into an array of React nodes — plain-text strings interleaved\n * with <Emoji> images for every mapped color emoji. Returns a single-element\n * `[text]` fast-path when there are no color emoji (the common case), so\n * non-emoji text pays ~one regex test.\n *\n * @param text         the string to render\n * @param fontSizePx   surrounding font size in px (emoji box = this size)\n */\nexport function renderWithEmoji(\n  text: string,\n  fontSizePx: number,\n  opts?: RenderWithEmojiOptions,\n): React.ReactNode[] {\n  if (!text) return [text];\n  // Cheap bail-out: if the whole string has no emoji-range codepoint, return\n  // it untouched (no segmentation, no array churn) — the hot path for the vast\n  // majority of titles/bodies.\n  if (!EMOJI_CANDIDATE.test(text)) return [text];\n\n  const clusters = toGraphemes(text);\n  const nodes: React.ReactNode[] = [];\n  let textBuf = \"\";\n  let key = 0;\n\n  const flushText = () => {\n    if (textBuf) {\n      nodes.push(textBuf);\n      textBuf = \"\";\n    }\n  };\n\n  for (const cluster of clusters) {\n    const uri = isEmojiCandidate(cluster) ? emojiToDataUri(cluster) : null;\n    if (uri) {\n      flushText();\n      nodes.push(\n        <Emoji\n          key={`e${key++}`}\n          char={cluster}\n          size={fontSizePx}\n          verticalAlign={opts?.verticalAlign}\n          style={opts?.emojiStyle}\n        />,\n      );\n    } else {\n      textBuf += cluster;\n    }\n  }\n  flushText();\n  return nodes;\n}\n\n/**\n * Per-code-unit emoji plan for the typewriter archetype, which reveals text\n * one UTF-16 code unit at a time and indexes by `text.length`. We can't just\n * call renderWithEmoji there (it would re-segment and desync the visibleChars\n * counter), so this maps each cluster's START code-unit index to its emoji-PNG\n * data URI and marks the cluster's CONTINUATION indices as covered (render\n * nothing for them — the start index's <img> already spans the whole cluster).\n *\n * Returns null when the text contains no mapped color emoji (the common case),\n * so the typewriter render keeps its plain per-char path.\n */\nexport interface EmojiTypewriterPlan {\n  /** code-unit index → full emoji cluster char (cluster start). */\n  starts: Map<number, string>;\n  /** code-unit indices that are continuations of a cluster (render nothing). */\n  covered: Set<number>;\n}\n\nexport function planTypewriterEmoji(text: string): EmojiTypewriterPlan | null {\n  if (!text || !EMOJI_CANDIDATE.test(text)) return null;\n  const starts = new Map<number, string>();\n  const covered = new Set<number>();\n  let idx = 0;\n  let found = false;\n  for (const cluster of toGraphemes(text)) {\n    const len = cluster.length; // UTF-16 code units\n    if (isEmojiCandidate(cluster) && emojiToDataUri(cluster)) {\n      found = true;\n      // Store the WHOLE cluster char (not just the start unit) so callers can\n      // pass it straight to <Emoji> for a correct map lookup.\n      starts.set(idx, cluster);\n      for (let k = 1; k < len; k++) covered.add(idx + k);\n    }\n    idx += len;\n  }\n  return found ? { starts, covered } : null;\n}\n\nexport interface EmojiTextProps {\n  /** The text to render, color emoji replaced with emoji-PNG <img>. */\n  children: string;\n  /** Surrounding font size in px — sizes each emoji image to match a glyph. */\n  fontSize: number;\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Inline wrapper around renderWithEmoji. Renders a React.Fragment of text +\n * emoji <img> runs. Use where a component currently renders a raw `{text}`\n * child and you have the surrounding font size in px.\n */\nexport const EmojiText: React.FC<EmojiTextProps> = ({\n  children,\n  fontSize,\n  verticalAlign,\n  emojiStyle,\n}) => {\n  const text = typeof children === \"string\" ? children : \"\";\n  return <>{renderWithEmoji(text, fontSize, { verticalAlign, emojiStyle })}</>;\n};\n"
    },
    {
      "path": "src/lib/emoji/index.tsx",
      "type": "registry:component",
      "target": "vanillasky/emoji/index.tsx",
      "content": "/**\n * Emoji — render emojis as inline PNG data-URI <img> elements.\n *\n * Why images, not the system emoji font: VanillaSky renders the same scene\n * three ways — real-DOM preview, client SVG-as-image export, server Puppeteer.\n * Raw-unicode emojis fall through to whatever emoji font the OS has (Mac=Apple,\n * Linux=Noto), so preview and export disagree. Inlining each emoji as a\n * data-URI <img> renders the SAME PNG in all three. Critically, the\n * client export rasterizes the frame via <img src=\"data:image/svg+xml\">, which\n * can ONLY load inlined data-URI images (not @font-face, not external URLs) —\n * so data URIs are exactly what works there.\n *\n * Source artwork: emoji-datasource-google (Google Noto emoji set, Apache-2.0).\n * The map ships inside the publicly redistributed skill tarball, and Apple's\n * artwork is not licensed for redistribution — Noto's Apache-2.0 is. The set\n * is swappable via the generator's EMOJI_SET constant\n * (scripts/generate-emoji-map.mjs).\n *\n * Bundle: the generated char→dataURI map is ~1.1MB of base64, so it is\n * LAZY-LOADED via dynamic import and never enters the initial app bundle. The\n * import is kicked off eagerly at module load (below) — long before any export\n * or screenshot — so by the time a frame is captured the map has resolved.\n * Until it resolves, <Emoji> renders the raw unicode char, then re-renders to\n * the image when the map lands. Any emoji not in the map also falls back to the\n * raw char, so nothing ever breaks for custom/unmapped emojis.\n */\n\nimport * as React from \"react\";\n\nlet EMOJI_MAP: Record<string, string> | null = null;\nlet loadPromise: Promise<Record<string, string>> | null = null;\nconst subscribers = new Set<() => void>();\n\nfunction loadEmojiMap(): Promise<Record<string, string>> {\n  if (loadPromise) return loadPromise;\n  loadPromise = import(\"./emoji-map.generated\")\n    .then((mod) => {\n      EMOJI_MAP = mod.default;\n      subscribers.forEach((fn) => fn());\n      return EMOJI_MAP;\n    })\n    .catch((err) => {\n      // Network/chunk failure: leave map null → everything falls back to char.\n      console.warn(\"[emoji] failed to load emoji map:\", err);\n      EMOJI_MAP = {};\n      return EMOJI_MAP;\n    });\n  return loadPromise;\n}\n\n// Kick off the load immediately when this module is first imported (app/registry\n// init), so the map is ready well before any export or server screenshot.\nloadEmojiMap();\n\n/**\n * Resolve once the emoji-PNG map has loaded (or definitively failed). Render\n * paths that capture frames MUST await this before their first capture so they\n * never screenshot a frame while emoji are still raw-unicode chars:\n *   - server: Render.tsx awaits before setting window.__ready\n *   - client export: export-video.ts awaits in the media-preload step\n * The preview path doesn't capture, so it can stay lazy (subscribers re-render\n * char → image when the map lands). On chunk failure this resolves to {} so\n * the await never hangs — everything falls back to the raw char.\n */\nexport function ensureEmojiMap(): Promise<Record<string, string>> {\n  return loadEmojiMap();\n}\n\n/**\n * Returns the emoji-PNG data URI for an emoji char, or null if the map hasn't\n * loaded yet or the char isn't in the set (caller should fall back to the char).\n */\nexport function emojiToDataUri(char: string): string | null {\n  if (!EMOJI_MAP) return null;\n  const key = (char || \"\").trim();\n  if (EMOJI_MAP[key]) return EMOJI_MAP[key];\n  // Tolerate a trailing variation selector mismatch (U+FE0F).\n  const stripped = key.replace(/️/g, \"\");\n  if (stripped !== key && EMOJI_MAP[stripped]) return EMOJI_MAP[stripped];\n  return null;\n}\n\n/** Subscribe to map-loaded so a component re-renders char → image when ready. */\nfunction useEmojiMapReady(): boolean {\n  const [, force] = React.useReducer((n: number) => n + 1, 0);\n  React.useEffect(() => {\n    if (EMOJI_MAP) return;\n    const fn = () => force();\n    subscribers.add(fn);\n    loadEmojiMap();\n    return () => {\n      subscribers.delete(fn);\n    };\n  }, []);\n  return EMOJI_MAP != null;\n}\n\nexport interface EmojiProps {\n  /** The emoji character(s), e.g. \"🎉\". */\n  char: string;\n  /** Box size in px — matches the prior glyph's font-size so layout is identical. */\n  size: number;\n  /** vertical-align for inline flow. Defaults to a glyph-like baseline nudge. */\n  verticalAlign?: React.CSSProperties[\"verticalAlign\"];\n  /** Extra styles merged onto the <img> (or fallback <span>). */\n  style?: React.CSSProperties;\n}\n\n/**\n * Renders an emoji as an inline emoji-PNG <img> when available, else the raw\n * unicode char in a same-size box (so scale/opacity/transform are unchanged).\n */\nexport const Emoji: React.FC<EmojiProps> = ({ char, size, verticalAlign = \"-0.15em\", style }) => {\n  useEmojiMapReady();\n  const uri = emojiToDataUri(char);\n\n  if (!uri) {\n    // Fallback: raw char sized to the same box so layout/scale are preserved.\n    return (\n      <span\n        style={{\n          fontSize: size,\n          lineHeight: 1,\n          display: \"inline-block\",\n          ...style,\n        }}\n      >\n        {char}\n      </span>\n    );\n  }\n\n  return (\n    <img\n      src={uri}\n      alt={char}\n      width={size}\n      height={size}\n      style={{\n        width: size,\n        height: size,\n        objectFit: \"contain\",\n        display: \"inline-block\",\n        verticalAlign,\n        ...style,\n      }}\n    />\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/normalize-var.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/normalize-var.ts",
      "content": "/**\n * normalize-var — Defensive parsing for structured template variables.\n *\n * LLMs may pass variables as colon-separated strings (expected), JSON strings,\n * or raw objects. These helpers normalize any format to the colon-separated\n * string that existing template parsers expect.\n */\n\n/**\n * Normalize a structured variable (e.g. notif1, transaction1) to a colon-separated string.\n *\n * Accepts:\n *  - Colon-separated string: \"🔔:App:Hello:1m\" → passed through\n *  - JSON string: '{\"emoji\":\"🔔\",\"app\":\"App\"}' → parsed and mapped\n *  - Object: {emoji: \"🔔\", app: \"App\"} → mapped to colon-separated\n *\n * @param raw - The variable value (string, object, or unknown)\n * @param keyAliases - Array of alias arrays, one per field position.\n *   e.g. [[\"emoji\"], [\"app\", \"title\", \"appName\"], [\"message\", \"body\", \"text\"], [\"time\"]]\n */\nexport function normalizeStructuredVar(\n  raw: unknown,\n  keyAliases: string[][],\n): string {\n  if (typeof raw === \"string\") {\n    const trimmed = raw.trim();\n    if (trimmed.startsWith(\"{\")) {\n      try {\n        const obj = JSON.parse(trimmed);\n        if (typeof obj === \"object\" && obj !== null) {\n          return extractFromObject(obj, keyAliases);\n        }\n      } catch {\n        // Not valid JSON — pass through as regular string\n      }\n    }\n    return raw;\n  }\n\n  if (typeof raw === \"object\" && raw !== null) {\n    return extractFromObject(raw as Record<string, unknown>, keyAliases);\n  }\n\n  return String(raw || \"\");\n}\n\nfunction extractFromObject(\n  obj: Record<string, unknown>,\n  keyAliases: string[][],\n): string {\n  return keyAliases\n    .map((aliases) => {\n      for (const key of aliases) {\n        if (obj[key] !== undefined && obj[key] !== null && obj[key] !== \"\") {\n          return String(obj[key]);\n        }\n      }\n      return \"\";\n    })\n    .join(\":\");\n}\n\n/**\n * Normalize a simple text variable for chat templates (msg1-msg4).\n *\n * Accepts:\n *  - Plain string: \"Hello\" → passed through\n *  - String with side: \"Hello|in\" → passed through\n *  - JSON string: '{\"text\":\"Hello\",\"side\":\"in\"}' → \"Hello|in\"\n *  - Object: {text: \"Hello\", side: \"in\"} → \"Hello|in\"\n */\nexport function normalizeTextVar(raw: unknown): string {\n  if (typeof raw === \"string\") {\n    const trimmed = raw.trim();\n    if (trimmed.startsWith(\"{\")) {\n      try {\n        const obj = JSON.parse(trimmed);\n        if (typeof obj === \"object\" && obj !== null) {\n          return extractTextFromObject(obj);\n        }\n      } catch {\n        // Not valid JSON\n      }\n    }\n    return raw;\n  }\n\n  if (typeof raw === \"object\" && raw !== null) {\n    return extractTextFromObject(raw as Record<string, unknown>);\n  }\n\n  return String(raw || \"\");\n}\n\nfunction extractTextFromObject(obj: Record<string, unknown>): string {\n  const text = String(\n    obj.text || obj.message || obj.body || obj.content || \"\",\n  );\n  const side = String(obj.side || obj.direction || \"\").toLowerCase();\n  if (\n    side === \"in\" ||\n    side === \"left\" ||\n    side === \"incoming\" ||\n    side === \"received\"\n  ) {\n    return text + \"|in\";\n  }\n  if (\n    side === \"out\" ||\n    side === \"right\" ||\n    side === \"outgoing\" ||\n    side === \"sent\"\n  ) {\n    return text + \"|out\";\n  }\n  return text;\n}\n"
    },
    {
      "path": "src/lib/scene-templates/types.ts",
      "type": "registry:lib",
      "target": "vanillasky/scene-templates/types.ts",
      "content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares what variables it needs (auto-shown as input fields in the Studio)\n * and receives universal settings as props.\n *\n * Templates are searchable by AI via description, category, jobs, register,\n * and useWhen guidance.\n * The variable schema enables any LLM to fill in template variables via JSON.\n */\n\nimport type { ResolvedTokens } from \"../theme\";\nimport type { GlobalStyle, VariableField, SafeZone } from \"../video-config\";\n\n/**\n * Props passed to every scene template component.\n *\n * All animation must be driven by `progress` (0→1). No CSS animations,\n * no Framer Motion, no requestAnimationFrame. Use interpolate/spring\n * from animation-utils.ts.\n *\n * Scale factor: use `Math.min(width, height) / 1080` — normalizes to\n * the short edge so visuals are consistent across portrait and landscape.\n */\nexport interface SceneTemplateProps {\n  variables: Record<string, unknown>;\n  style: GlobalStyle;\n  /** 0→1 through the scene's duration */\n  progress: number;\n  /** 0→1 beat pulse intensity */\n  beatIntensity: number;\n  /** 1080 (portrait) or 1920 (landscape) */\n  width: number;\n  /** 1920 (portrait) or 1080 (landscape) */\n  height: number;\n  /** Video-level default text effect (for templates that opt in via usesGlobalTextEffect) */\n  textArchetype?: string;\n  /** How text leaves the scene (fade / shrink / pop / blur-scale). Falls back to a sensible default per textArchetype when undefined. */\n  /** Video-level default background effect (for templates that opt in via usesGlobalBackgroundEffect) */\n  backgroundEffect?: string;\n  /** Platform-aware safe zone insets in pixels — use for text placement */\n  safeZone: SafeZone;\n  /** Scene duration in seconds — use for time-based (not progress-based) animations */\n  sceneDuration?: number;\n  /**\n   * Brand tokens already resolved from `style`. Built-in templates import\n   * resolveTokens directly; an ejected `custom_*` scene can't import anything,\n   * so without this it has no way to reach the same values and ends up\n   * hardcoding white, black and shadows — the body then looks generic next to\n   * a frame that IS using the brand.\n   */\n  tokens?: ResolvedTokens;\n  /**\n   * True when the preview player is actively advancing progress; false when paused.\n   * Templates that play HTML5 <video> elements should pause them when this is false.\n   * Undefined (export capture path) is treated as true.\n   */\n  isPlaying?: boolean;\n}\n\n/**\n * What a template can DO inside a video. A template can serve more than\n * one job — `bigNumber` is `[\"claim\", \"proof\"]`; `media` is\n * `[\"atmosphere\", \"setup\"]`. Used by the chat composer to pick templates\n * by scene-job rather than by the (less useful) category bucket.\n *\n * - `setup` — opens the world, names the subject, frames the question\n * - `claim` — makes a falsifiable statement the rest of the video earns\n * - `proof` — backs a claim with a number, quote, mockup, code, or chart\n * - `atmosphere` — breathing room; ties scenes together visually\n * - `payoff` — punchline, reveal, or satisfying answer to an earlier setup\n * - `punctuation` — short energy beat that breaks pattern or lands a joke\n * - `ask` — pushes the viewer to the next action; closer-territory only\n */\nexport type TemplateJob = \"setup\" | \"claim\" | \"proof\" | \"atmosphere\" | \"payoff\" | \"punctuation\" | \"ask\";\n\n/**\n * The visual register a template lives in — what the *viewer* notices\n * before reading any copy. The chat's diversity rule reads this rather\n * than `category` because two `card-led` bodies look the same to the\n * viewer even when they're a `testimonial` and a `bigNumber`.\n *\n * - `motion-led` — animation IS the content (confetti, emoji rain, charts)\n * - `typography-led` — text fills the frame (bigNumber, ctaLogo, tripleStats)\n * - `device-led` — phone/browser/terminal frame is the focal element\n * - `card-led` — quote/feature/comparison cards\n * - `mockup-led` — full UI surface (chat thread, search results, app feed)\n */\nexport type TemplateRegister =\n  | \"motion-led\"\n  | \"typography-led\"\n  | \"device-led\"\n  | \"card-led\"\n  | \"mockup-led\";\n\n/**\n * A registered scene template.\n */\nexport interface SceneTemplate {\n  id: string;\n  /** Built-in templates leave these blank — DB owns them. Vibecoded\n   *  templates carry their own here since they have no DB row. */\n  label?: string;\n  description?: string;\n  category?: string | null;\n  /** What this template DOES inside a video. 1-3 jobs. See TemplateJob. */\n  jobs?: TemplateJob[];\n  /** The visual register the template lives in. See TemplateRegister. */\n  register?: TemplateRegister;\n  /** Semantic selection guidance: when this template is the right choice. */\n  useWhen?: string;\n  /** Thumbnail URL for visual picker (optional) */\n  thumbnail?: string;\n  /** If true, template consumes the video-level defaultTextArchetype */\n  usesGlobalTextEffect: boolean;\n  /** If true, template uses the video-level defaultTransition */\n  usesGlobalTransition: boolean;\n  /** If true, template uses the video-level defaultBackgroundEffect */\n  usesGlobalBackgroundEffect: boolean;\n  /**\n   * Spatial budget for text effects.\n   * - \"tight\" (default): text shares the frame with UI (mockup, chart). Expressive\n   *   effects are auto-clamped to contained equivalents to prevent clipping.\n   * - \"open\": text is the focal element (bg-* templates). All effects allowed.\n   */\n  textCanvas?: \"tight\" | \"open\";\n  /**\n   * Hard input gates — what this template needs from the user's input to fire\n   * legitimately. The AI uses these to filter out templates that would force\n   * it to invent content (e.g. \\`bigNumber\\` without a real number).\n   *\n   * Set to true when the template's value depends on input that the model\n   * can't fabricate honestly: a stat, a quote, an uploaded screenshot.\n   * Defaults to false (no constraint).\n   */\n  requiresStat?: boolean;\n  requiresQuote?: boolean;\n  requiresScreenshot?: boolean;\n  /** Template can be backed by Pexels stock footage (e.g. \\`media\\`). */\n  allowsStockMedia?: boolean;\n  /** Variable schema — Studio auto-generates inputs from this */\n  variableSchema: Record<string, VariableField>;\n  /** Default values for all variables */\n  defaultVariables: Record<string, unknown>;\n  /** Minimum scene duration in seconds */\n  minDuration?: number;\n  /** Recommended scene duration in seconds */\n  preferredDuration?: number;\n  /** The React component that renders this template */\n  component: React.FC<SceneTemplateProps>;\n}\n\n/**\n * Serializable template metadata (no component) — for edge functions, API, MCP.\n */\nexport interface SceneTemplateMetadata {\n  id: string;\n  usesGlobalTextEffect: boolean;\n  usesGlobalTransition: boolean;\n  usesGlobalBackgroundEffect: boolean;\n  textCanvas?: \"tight\" | \"open\";\n  /** Hard input gates — see SceneTemplate.requiresStat etc. */\n  requiresStat?: boolean;\n  requiresQuote?: boolean;\n  requiresScreenshot?: boolean;\n  allowsStockMedia?: boolean;\n  /** What this template DOES inside a video. 1-3 jobs. See TemplateJob. */\n  jobs?: TemplateJob[];\n  /** The visual register the template lives in. See TemplateRegister. */\n  register?: TemplateRegister;\n  /** Semantic selection guidance: when this template is the right choice. */\n  useWhen?: string;\n  variableSchema: Record<string, VariableField>;\n  defaultVariables: Record<string, unknown>;\n  minDuration?: number;\n  preferredDuration?: number;\n}\n"
    }
  ],
  "meta": {
    "vanillasky": {
      "layer": "template",
      "category": "social",
      "tier": "free",
      "register": "mockup-led",
      "jobs": [
        "proof",
        "setup"
      ],
      "useWhen": "Short synthetic Messenger-style conversation when a back-and-forth helps explain a use case, objection, or punchline. Use sparingly because it is long.",
      "textCanvas": "tight",
      "minDuration": 5,
      "preferredDuration": 8,
      "variableSchema": {
        "msg1": {
          "type": "string",
          "label": "Message 1 (received)",
          "default": "Have you tried it yet?",
          "required": true,
          "description": "First message — left/received by default. Append |in or |out to override side. Aim for 3-4 messages total; use 5 with date chips only for a real time gap in a 10s+ scene."
        },
        "msg2": {
          "type": "string",
          "label": "Message 2 (received or sent)",
          "default": "It saves me hours every week",
          "required": true,
          "description": "Second message — left/received by default. Override with |out for ping-pong."
        },
        "msg3": {
          "type": "string",
          "label": "Message 3 (received)",
          "default": "And the team loves it",
          "required": true,
          "description": "Third message — left/received by default. Leave blank for a two-message exchange."
        },
        "msg4": {
          "type": "string",
          "label": "Message 4 (sent)",
          "default": "Sounds great — sharing access now",
          "description": "Fourth message — right/sent by default. Optional; most chats end here."
        },
        "dateChip1": {
          "type": "string",
          "label": "Date chip 1",
          "default": "",
          "description": "Optional date label before message 4. Use only for a real time gap."
        },
        "dateChip2": {
          "type": "string",
          "label": "Date chip 2",
          "default": "",
          "description": "Optional date label before message 5. Use only for a real time gap."
        },
        "msg5": {
          "type": "string",
          "label": "Message 5 (sent)",
          "default": "",
          "description": "Optional fifth message — right/sent by default."
        }
      },
      "defaultVariables": {
        "theme": "messenger",
        "msg1": "Have you tried it yet?",
        "msg2": "It saves me hours every week",
        "msg3": "And the team loves it",
        "msg4": "Sounds great — sharing access now",
        "msg5": "",
        "dateChip1": "",
        "dateChip2": ""
      }
    }
  }
}
