{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tripleStats",
  "type": "registry:block",
  "title": "Triple Stats",
  "description": "Exactly three peer stats or compact specs from the input with comparable weight. Prefer over bigNumber for three parallel facts. Avoid when one number is the hero or values are just brand/tool names.",
  "dependencies": [
    "react"
  ],
  "registryDependencies": [
    "@vanillasky/backgrounds",
    "@vanillasky/motion",
    "@vanillasky/theme",
    "@vanillasky/typography",
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/scene-templates/infographic-stat-row.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/infographic-stat-row.tsx",
      "content": "/**\n * infographic-stat-row — 3 big stats side by side (portrait: stacked).\n *\n * Each stat has a large number, a short label below, and an optional\n * prefix/unit. Numbers count up from 0. Stats stagger in with bouncy\n * spring. TextOverlay title on top.\n */\n\nimport React from \"react\";\nimport type { VariableField } from \"../video-config\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StatBadgeRow } from \"../primitives/typography/StatBadgeRow\";\nimport { SceneBackground, getMediaBackgroundProps, mediaBackgroundDefaults, mediaBackgroundSchemaFields } from \"./scene-background\";\n\nexport const infographicStatRowSchema: Record<string, VariableField> = {\n  texts: {\n    type: \"string\",\n    label: \"Text\",\n    default: \"By the numbers.\",\n    required: true,\n    description: \"Title text\",\n  },\n  stat1Value: {\n    type: \"string\",\n    label: \"Stat 1 value\",\n    default: \"10K\",\n    required: true,\n    description: \"First stat number (e.g. 10K, 99.9%, $2M)\",\n  },\n  stat1Label: {\n    type: \"string\",\n    label: \"Stat 1 label\",\n    default: \"Users\",\n    required: true,\n    description: \"Short label below the first number\",\n  },\n  stat2Value: {\n    type: \"string\",\n    label: \"Stat 2 value\",\n    default: \"99.9%\",\n    required: true,\n    description: \"Second stat number\",\n  },\n  stat2Label: {\n    type: \"string\",\n    label: \"Stat 2 label\",\n    default: \"Uptime\",\n    required: true,\n    description: \"Short label below the second number\",\n  },\n  stat3Value: {\n    type: \"string\",\n    label: \"Stat 3 value\",\n    default: \"<50ms\",\n    required: true,\n    description: \"Third stat number\",\n  },\n  stat3Label: {\n    type: \"string\",\n    label: \"Stat 3 label\",\n    default: \"Latency\",\n    required: true,\n    description: \"Short label below the third number\",\n  },\n  ...mediaBackgroundSchemaFields,\n  textColor: {\n    type: \"color\",\n    label: \"Text color\",\n    default: \"\",\n    description: \"Override text color (leave empty for auto)\",\n  },\n};\n\nexport const infographicStatRowDefaults: Record<string, unknown> = {\n  texts: \"By the numbers.\",\n  stat1Value: \"10K\",\n  stat1Label: \"Users\",\n  stat2Value: \"99.9%\",\n  stat2Label: \"Uptime\",\n  stat3Value: \"<50ms\",\n  stat3Label: \"Latency\",\n  ...mediaBackgroundDefaults,\n  textColor: \"\",\n};\n\nexport const InfographicStatRowTemplate: React.FC<SceneTemplateProps> = ({\n  variables,\n  style,\n  progress,\n  beatIntensity,\n  width,\n  height,\n  safeZone,\n  sceneDuration,\n  backgroundEffect,\n  isPlaying = true,\n}) => {\n  const { content, font } = resolveTokens(style);\n  const textColor = String(variables.textColor || \"\") || content;\n  const textsRaw = String(variables.texts || \"\");\n\n  const stats = [\n    { value: String(variables.stat1Value || \"\"), label: String(variables.stat1Label || \"\") },\n    { value: String(variables.stat2Value || \"\"), label: String(variables.stat2Label || \"\") },\n    { value: String(variables.stat3Value || \"\"), label: String(variables.stat3Label || \"\") },\n  ];\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        backgroundColor: \"#000\",\n        position: \"relative\",\n        overflow: \"hidden\",\n        fontFamily: font,\n      }}\n    >\n      {/* [slot: background] */}\n      <SceneBackground\n        style={style}\n        progress={progress}\n        sceneDuration={sceneDuration}\n        width={width}\n        height={height}\n        {...getMediaBackgroundProps(variables)}\n        backgroundEffect={backgroundEffect}\n        seed={textsRaw}\n        isPlaying={isPlaying}\n        beatIntensity={beatIntensity}\n      />\n\n      {/* [slot: caption] */}\n      <TemplateText\n        typeTreatment={resolveTokens(style).preset.type}\n        archetype=\"subtle\"\n        text={String(variables.texts ?? \"\")}\n        progress={progress}\n        sceneDuration={sceneDuration ?? 3}\n        width={width}\n        height={height}\n        position=\"top\"\n        sizeRole=\"headline\"\n        safeZone={safeZone}\n        font={font}\n        color={textColor}\n        beatIntensity={beatIntensity}\n      />\n\n      {/* [slot: hero] */}\n      <StatBadgeRow\n        progress={progress}\n        stats={stats}\n        width={width}\n        height={height}\n        font={font}\n        textColor={textColor}\n      />\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/scene-background.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/scene-background.tsx",
      "content": "/**\n * SceneBackground — shared backdrop component for any scene template that\n * wants to support both a brand-color gradient and stock media (Pexels\n * photo / video) as an alternate atmosphere.\n *\n * Usage:\n *   <SceneBackground\n *     style={style}\n *     progress={progress}\n *     sceneDuration={sceneDuration}\n *     width={width}\n *     height={height}\n *     mediaUrl={String(variables.mediaUrl || \"\")}\n *     mediaType={String(variables.mediaType || \"auto\")}\n *     seed={String(variables.texts || \"\")}\n *     isPlaying={isPlaying}\n *   />\n *   ... template's content layered on top\n *\n * Behavior:\n *   - Brand gradient is the always-on backdrop (uses BrandGradientOverlay).\n *   - When mediaUrl is set and mediaType isn't \"gradient\", the photo/video\n *     covers the gradient. Vignette + bottom-half darken give the content\n *     contrast against busy footage.\n *   - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n *     the brand gradient. First-class atmospheric mode.\n *   - When mediaUrl is empty / 404s / Pexels search returned nothing,\n *     gradient shows through cleanly (matches every other gradient-backed\n *     template).\n *\n * Extracted from bg-media.tsx so any template can compose it. bg-media\n * now uses this component too — its \"media is the scene\" identity comes\n * from how it positions the title (centered, full-frame), not from\n * duplicated render logic.\n */\n\nimport React, { useEffect, useRef } from \"react\";\nimport type { GlobalStyle, VariableField } from \"../video-config\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\n\nconst VIDEO_EXTENSIONS = [\".mp4\", \".webm\", \".mov\", \".m4v\", \".avi\"];\n\nfunction isVideoUrl(url: string): boolean {\n  try {\n    const pathname = new URL(url).pathname.toLowerCase();\n    return VIDEO_EXTENSIONS.some((ext) => pathname.endsWith(ext));\n  } catch {\n    const lower = url.toLowerCase();\n    return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));\n  }\n}\n\nexport type ResolvedMediaType = \"photo\" | \"video\" | \"gradient\";\n\nexport type MediaPosition = \"center\" | \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type MediaTreatment = \"subtle\" | \"cinematic\" | \"text-safe\";\n\nconst MEDIA_POSITIONS: Record<MediaPosition, string> = {\n  center: \"center center\",\n  top: \"center top\",\n  bottom: \"center bottom\",\n  left: \"left center\",\n  right: \"right center\",\n};\n\nexport function resolveMediaPosition(value: string): string {\n  return MEDIA_POSITIONS[value as MediaPosition] ?? MEDIA_POSITIONS.center;\n}\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n  return value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n  id: \"vignette\" | \"full-wash\" | \"center-scrim\" | \"bottom-scrim\";\n  background: string;\n  style?: React.CSSProperties;\n}\n\n/** Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS filters. */\nexport function getMediaTreatmentLayers(value: string): MediaTreatmentLayer[] {\n  const treatment = resolveMediaTreatment(value);\n  const vignette: MediaTreatmentLayer = {\n    id: \"vignette\",\n    background:\n      treatment === \"subtle\"\n        ? \"radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.28) 100%)\"\n        : \"radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.55) 80%, rgba(0,0,0,0.75) 100%)\",\n  };\n  if (treatment === \"subtle\") return [vignette];\n\n  const cinematic: MediaTreatmentLayer[] = [\n    vignette,\n    {\n      id: \"center-scrim\",\n      background:\n        treatment === \"text-safe\"\n          ? \"radial-gradient(ellipse 90% 56% at 50% 50%, rgba(0,0,0,0.36) 0%, rgba(0,0,0,0.18) 55%, transparent 84%)\"\n          : \"radial-gradient(ellipse 85% 50% at 50% 50%, rgba(0,0,0,0.22) 0%, rgba(0,0,0,0.10) 50%, transparent 80%)\",\n    },\n    {\n      id: \"bottom-scrim\",\n      background:\n        treatment === \"text-safe\"\n          ? \"linear-gradient(to top, rgba(0,0,0,0.68) 0%, transparent 100%)\"\n          : \"linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)\",\n      style: { top: \"55%\" },\n    },\n  ];\n\n  if (treatment === \"text-safe\") {\n    cinematic.splice(1, 0, {\n      id: \"full-wash\",\n      background: \"rgba(0,0,0,0.30)\",\n    });\n  }\n  return cinematic;\n}\n\nexport function resolveMediaType(\n  mediaType: string,\n  mediaUrl: string,\n): ResolvedMediaType {\n  if (mediaType === \"gradient\") return \"gradient\";\n  if (mediaType === \"video\") return \"video\";\n  if (mediaType === \"photo\") return \"photo\";\n  // \"auto\" — detect from URL extension\n  return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\n/**\n * Standard schema fields any template can spread into its variableSchema\n * to enable media backgrounds. Keeps the field set + descriptions\n * consistent across templates.\n */\nexport const mediaBackgroundSchemaFields: Record<string, VariableField> = {\n  mediaUrl: {\n    type: \"media\",\n    label: \"Background media\",\n    description:\n      \"Optional photo or video URL behind this scene. When set, replaces the brand gradient.\",\n  },\n  mediaKeyword: {\n    type: \"string\",\n    label: \"Background search keyword\",\n    default: \"\",\n    description:\n      \"2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).\",\n  },\n  mediaType: {\n    type: \"enum\",\n    label: \"Background media type\",\n    default: \"auto\",\n    description:\n      \"auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.\",\n    options: [\"auto\", \"photo\", \"video\", \"gradient\"],\n  },\n  mediaPoster: {\n    type: \"string\",\n    label: \"Background poster image\",\n    default: \"\",\n    description:\n      \"Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame.\",\n  },\n  mediaPosition: {\n    type: \"enum\",\n    label: \"Background focal position\",\n    default: \"center\",\n    description:\n      \"Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.\",\n    options: [\"center\", \"top\", \"bottom\", \"left\", \"right\"],\n  },\n  mediaTreatment: {\n    type: \"enum\",\n    label: \"Background contrast treatment\",\n    default: \"cinematic\",\n    description:\n      \"subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.\",\n    options: [\"subtle\", \"cinematic\", \"text-safe\"],\n  },\n};\n\n/** Default values for the media-background fields. Spread into a template's defaults. */\nexport const mediaBackgroundDefaults: Record<string, unknown> = {\n  mediaUrl: \"\",\n  mediaKeyword: \"\",\n  mediaType: \"auto\",\n  mediaPoster: \"\",\n  mediaPosition: \"center\",\n  mediaTreatment: \"cinematic\",\n};\n\nexport function getMediaBackgroundProps(variables: Record<string, unknown>) {\n  return {\n    mediaUrl: String(variables.mediaUrl || \"\"),\n    mediaType: String(variables.mediaType || \"auto\"),\n    mediaPoster: String(variables.mediaPoster || \"\"),\n    mediaPosition: String(variables.mediaPosition || \"center\"),\n    mediaTreatment: String(variables.mediaTreatment || \"cinematic\"),\n  };\n}\n\nexport interface SceneBackgroundProps {\n  style: GlobalStyle;\n  progress: number;\n  sceneDuration?: number;\n  width: number;\n  height: number;\n  mediaUrl?: string;\n  mediaType?: string;\n  /** Still image URL shown while the <video> backdrop decodes its first\n   *  frame. Without it the element renders transparent during the\n   *  ~50–400ms decode window and the gradient flashes through. */\n  mediaPoster?: string;\n  /** Cover-crop focal anchor. Keeps the important edge/subject visible. */\n  mediaPosition?: string;\n  /** Overlay recipe: subtle, cinematic, or stronger text-safe contrast. */\n  mediaTreatment?: string;\n  /** Background motion effect (drift / pulse / Ken Burns). Applied to the photo/video. */\n  backgroundEffect?: string;\n  /** Stable seed for the gradient breathing animation. Pass the scene's\n   *  text content (or any stable string) — it's hashed deterministically. */\n  seed?: number | string;\n  /** Pause video when preview is paused. Defaults to true (export path). */\n  isPlaying?: boolean;\n  beatIntensity?: number;\n}\n\nexport const SceneBackground: React.FC<SceneBackgroundProps> = ({\n  style,\n  progress,\n  sceneDuration,\n  width: _width, // accepted for symmetry; not currently used in render\n  height: _height,\n  mediaUrl = \"\",\n  mediaType = \"auto\",\n  mediaPoster,\n  mediaPosition = \"center\",\n  mediaTreatment = \"cinematic\",\n  backgroundEffect,\n  seed,\n  isPlaying = true,\n  beatIntensity = 0,\n}) => {\n  void _width;\n  void _height;\n  const resolved = resolveMediaType(mediaType, mediaUrl);\n  const showMedia = resolved !== \"gradient\" && !!mediaUrl;\n  const resolvedPosition = resolveMediaPosition(mediaPosition);\n  const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n  const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment);\n\n  const gradSeed =\n    typeof seed === \"number\"\n      ? seed\n      : typeof seed === \"string\"\n        ? seed.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0)\n        : 0;\n\n  const bgTransform = getBackgroundTransform(\n    backgroundEffect,\n    progress,\n    beatIntensity,\n  );\n\n  // Video playback control — same pause/seek logic bg-media used pre-extract.\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const lastProgress = useRef(progress);\n  const videoStarted = useRef(false);\n\n  useEffect(() => {\n    const vid = videoRef.current;\n    if (!vid) return;\n    if (!isPlaying) {\n      vid.pause();\n      videoStarted.current = false;\n      return;\n    }\n    const progressChanged = Math.abs(progress - lastProgress.current) > 0.001;\n    lastProgress.current = progress;\n    if (progressChanged && !videoStarted.current) {\n      vid.playbackRate = 1;\n      vid.currentTime = 0;\n      vid.play().catch(() => {});\n      videoStarted.current = true;\n    } else if (!progressChanged && videoStarted.current) {\n      vid.pause();\n      videoStarted.current = false;\n    }\n  }, [progress, isPlaying]);\n\n  // Release the decoder on unmount. Without this, iOS Safari keeps the\n  // video's decoder buffer alive after the React node is gone — each\n  // scene transition (or play/pause/play cycle that remounts the active\n  // scene) leaks one decoder, eventually crossing the renderer's memory\n  // ceiling and triggering \"A problem repeatedly occurred.\" Same recipe\n  // as #409's CanvasPreview preload cleanup: pause → clear src → load().\n  // Capture the ref at mount-time so the cleanup uses the same node we\n  // mounted (the ref's .current is stale by unmount).\n  useEffect(() => {\n    const vid = videoRef.current;\n    return () => {\n      if (!vid) return;\n      vid.pause();\n      vid.removeAttribute(\"src\");\n      vid.load();\n    };\n  }, []);\n\n  return (\n    <>\n      <BrandGradientOverlay\n        style={style}\n        progress={progress}\n        sceneDuration={sceneDuration}\n        seed={gradSeed}\n      />\n\n      {showMedia &&\n        (resolved === \"video\" ? (\n          <video\n            ref={videoRef}\n            src={mediaUrl}\n            // Poster paints during the decode window so the user sees the\n            // (still) first frame instead of a transparent <video> letting\n            // the brand gradient show through. Pexels returns a thumbnail\n            // image alongside each video; fillPexelsUrls stores it in\n            // `variables.mediaPoster`. Layered defense alongside preload=\"auto\"\n            // below: on desktop the byte preloader makes decode fast, on\n            // mobile (where the preloader skips video pre-mounting to dodge\n            // the iOS Safari memory crash) the poster is the primary shield.\n            poster={mediaPoster || undefined}\n            muted\n            loop\n            playsInline\n            // preload=\"auto\" — without it, browsers default to \"metadata\":\n            // they only load the container/dimensions, not the byte stream\n            // needed to decode frames. The element then renders transparent\n            // until the first decoded frame arrives, letting the brand\n            // gradient flash through whenever a scene mid-playback transitions\n            // to a media backdrop. The parent preloader caches the bytes, but\n            // decoder state is per-element, so the active mount still has to\n            // decode the first frame; \"auto\" kicks that work off the instant\n            // the element mounts.\n            preload=\"auto\"\n            data-media-position={mediaPosition}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              width: \"100%\",\n              height: \"100%\",\n              objectFit: \"cover\",\n              objectPosition: resolvedPosition,\n              transform: bgTransform.transform,\n              transformOrigin: bgTransform.transformOrigin,\n            }}\n          />\n        ) : (\n          <div\n            data-media-position={mediaPosition}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              transform: bgTransform.transform,\n              transformOrigin: bgTransform.transformOrigin,\n              backgroundImage: `url(${mediaUrl})`,\n              backgroundSize: \"cover\",\n              backgroundPosition: resolvedPosition,\n            }}\n          />\n        ))}\n\n      {showMedia &&\n        treatmentLayers.map((layer) => (\n          <div\n            key={layer.id}\n            data-media-treatment={resolvedTreatment}\n            data-media-overlay={layer.id}\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              background: layer.background,\n              pointerEvents: \"none\",\n              ...layer.style,\n            }}\n          />\n        ))}\n    </>\n  );\n};\n"
    },
    {
      "path": "src/lib/primitives/typography/StatBadgeRow.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/typography/StatBadgeRow.tsx",
      "content": "/**\n * StatBadgeRow\n *\n * Three stat tiles laid out side-by-side (landscape) or stacked (portrait).\n * Each tile has a big value + uppercase label below. Tiles stagger in with\n * SPRING_STAT bouncy spring, then cascade-exit (left-slide) in reading\n * order from progress 0.78 onwards.\n *\n * infographic-stat-row.tsx is refactored to consume this component; both\n * paths render byte-identical DOM.\n */\n\nimport React from \"react\";\nimport {\n  interpolate,\n  spring,\n  type SpringConfig,\n} from \"../../motion\";\nimport { withOpacity } from \"../../theme\";\nimport { TOP_TEXT_AREA_RATIO } from \"../../backgrounds\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\n\nconst SPRING_STAT: SpringConfig = { damping: 12, stiffness: 170 };\n\n// ─── Typed component (direct use from templates) ────────────────\n\nexport interface StatBadgeRowProps {\n  progress: number;\n  /** 1-4 stats. Empty values are filtered out. */\n  stats: { value: string; label: string }[];\n  width: number;\n  height: number;\n  font?: string;\n  textColor?: string;\n}\n\nexport const StatBadgeRow: React.FC<StatBadgeRowProps> = ({\n  progress,\n  stats: rawStats,\n  width,\n  height,\n  font = TOKEN_DEFAULTS.font,\n  textColor = \"#ffffff\",\n}) => {\n  const s = Math.min(width, height) / 1080;\n  const stats = rawStats.filter((st) => st.value);\n  const isLandscape = width > height;\n\n  const textAreaHeight = height * TOP_TEXT_AREA_RATIO;\n  const statsAreaTop = textAreaHeight;\n  const statsAreaHeight = height - statsAreaTop;\n\n  const valueFontSize = isLandscape ? 72 * s : 100 * s;\n  const labelFontSize = isLandscape ? 28 * s : 38 * s;\n\n  const statCount = stats.length;\n\n  const getStatPosition = (i: number) => {\n    if (isLandscape) {\n      const spacing = width / (statCount + 1);\n      return {\n        x: spacing * (i + 1),\n        y: statsAreaTop + statsAreaHeight * 0.4,\n      };\n    }\n    const sectionH = statsAreaHeight / (statCount + 0.5);\n    return {\n      x: width / 2,\n      y: statsAreaTop + sectionH * (i + 0.5),\n    };\n  };\n\n  return (\n    <>\n      {stats.map((stat, i) => {\n        const pos = getStatPosition(i);\n        const itemStart = 0.10 + i * 0.12;\n\n        const statP = spring(\n          interpolate(progress, [itemStart, itemStart + 0.12], [0, 1], {\n            extrapolateLeft: \"clamp\",\n            extrapolateRight: \"clamp\",\n          }),\n          SPRING_STAT,\n        );\n        const scaleVal = interpolate(statP, [0, 1], [0.5, 1]);\n        const opacity = interpolate(progress, [itemStart, itemStart + 0.06], [0, 1], {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        });\n\n        const labelDelay = itemStart + 0.06;\n        const labelOpacity = interpolate(progress, [labelDelay, labelDelay + 0.05], [0, 1], {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        });\n        const labelSlideY = interpolate(progress, [labelDelay, labelDelay + 0.06], [10 * s, 0], {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        });\n\n        const EXIT_START = 0.78;\n        const EXIT_DURATION = 0.10;\n        const EXIT_STAGGER = 0.04;\n        const exitStart = EXIT_START + i * EXIT_STAGGER;\n        const exitEnd = exitStart + EXIT_DURATION;\n        const exitRaw = interpolate(progress, [exitStart, exitEnd], [0, 1], {\n          extrapolateLeft: \"clamp\",\n          extrapolateRight: \"clamp\",\n        });\n        const exitEased = exitRaw * exitRaw;\n        const exitTranslateX = -90 * s * exitEased;\n        const exitFade = 1 - exitEased;\n\n        return (\n          <div\n            key={i}\n            style={{\n              position: \"absolute\",\n              left: pos.x,\n              top: pos.y,\n              transform: `translate(-50%, -50%) translate(${exitTranslateX}px, 0) scale(${scaleVal})`,\n              opacity: opacity * exitFade,\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              gap: isLandscape ? 12 * s : 24 * s,\n              backgroundColor: \"rgba(255,255,255,0.15)\",\n              border: \"1.5px solid rgba(255,255,255,0.2)\",\n              borderRadius: isLandscape ? 20 * s : 28 * s,\n              padding: isLandscape ? `${28 * s}px ${24 * s}px` : `${48 * s}px ${56 * s}px`,\n              width: isLandscape ? (width / (statCount + 1)) * 0.85 : undefined,\n              maxWidth: isLandscape ? width * 0.28 : undefined,\n              minWidth: isLandscape ? undefined : width * 0.6,\n            }}\n          >\n            <div\n              style={{\n                fontSize: valueFontSize,\n                fontWeight: 800,\n                color: textColor,\n                fontFamily: font,\n                lineHeight: 1,\n                whiteSpace: \"nowrap\",\n              }}\n            >\n              {stat.value}\n            </div>\n            <div\n              style={{\n                fontSize: labelFontSize,\n                fontWeight: 500,\n                color: withOpacity(textColor, 0.8),\n                fontFamily: font,\n                lineHeight: 1.2,\n                whiteSpace: isLandscape ? \"normal\" : \"nowrap\",\n                textAlign: \"center\" as const,\n                textTransform: \"uppercase\" as const,\n                letterSpacing: isLandscape ? 1 * s : 2 * s,\n                opacity: labelOpacity,\n                transform: `translateY(${labelSlideY}px)`,\n              }}\n            >\n              {stat.label}\n            </div>\n          </div>\n        );\n      })}\n    </>\n  );\n};\n"
    },
    {
      "path": "src/lib/scene-templates/template-text.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/template-text.tsx",
      "content": "/**\n * TemplateText — unified text component for scene templates.\n *\n * Replaces the per-template hand-rolled text rendering with a single component\n * that owns: archetype motion lifecycle (entrance + hold + exit), font sizing,\n * position, beat pulse, and safe zone.\n *\n * Each template declares its constraints (position + sizeRole) at the call site;\n * the user/AI picks the archetype. Templates that can only show text at the top\n * just always pass position=\"top\".\n *\n * Example — a data template (caption above a chart):\n *\n *   <TemplateText\n *     archetype={textArchetype}\n *     text={variables.title}\n *     progress={progress}\n *     sceneDuration={sceneDuration}\n *     width={width}\n *     height={height}\n *     position=\"top\"\n *     sizeRole=\"caption\"\n *   />\n *\n * Example — a media template (full-frame headline):\n *\n *   <TemplateText\n *     archetype={textArchetype}\n *     text={variables.headline}\n *     progress={progress}\n *     sceneDuration={sceneDuration}\n *     width={width}\n *     height={height}\n *     position=\"center\"\n *     sizeRole=\"headline\"\n *     beatIntensity={beatIntensity}\n *   />\n *\n * Note: `textArchetype` is destructured from props (a scene-level\n * field on `SceneTemplateProps`), NOT read from `variables`. Copying\n * the wrong pattern silently no-ops — the executor routes\n * `setSceneVariable(\"textArchetype\", ...)` to the scene-level field,\n * never into variables, so `variables.textArchetype` is always\n * undefined.\n */\n\nimport {\n  renderArchetype,\n  normalizeArchetype,\n  type TextArchetype,\n  type ArchetypeRender,\n} from \"../typography\";\nimport type { TypeTreatment } from \"../theme\";\nimport { renderWithEmoji, planTypewriterEmoji } from \"../emoji/emoji-text\";\nimport { Emoji } from \"../emoji\";\n\nexport type TextPosition = \"top\" | \"center\" | \"bottom\";\nexport type TextSizeRole = \"headline\" | \"caption\" | \"label\";\n\nexport interface SafeZone {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\nexport interface TemplateTextProps {\n  archetype: TextArchetype;\n  text: string;\n  /** Scene progress 0→1. */\n  progress: number;\n  /** Scene duration in seconds — drives entrance/exit phase scaling. */\n  sceneDuration: number;\n  /** Frame width in pixels (1080 in production, smaller in previews). */\n  width: number;\n  /** Frame height in pixels (1920 in production). */\n  height: number;\n  /** Where the text box sits in the frame. Templates declare this. */\n  position?: TextPosition;\n  /** Size envelope. Templates declare this. */\n  sizeRole?: TextSizeRole;\n  /** Preset type treatment — weight/tracking/size/case shift from style.preset. */\n  typeTreatment?: TypeTreatment;\n  /** Padding from frame edges. Defaults to a 24px box. */\n  safeZone?: SafeZone;\n  /** Font family. */\n  font?: string;\n  /** Fill color. */\n  color?: string;\n  /** Beat intensity 0→1 (currently unused — kept for forward compat). */\n  beatIntensity?: number;\n}\n\nconst DEFAULT_SAFE_ZONE: SafeZone = { top: 24, right: 24, bottom: 24, left: 24 };\nconst DEFAULT_FONT =\n  \"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\";\n\n// ─── Typography constants ───────────────────────────────────────\n// All em-based so they scale with font size and behave consistently across\n// font families. Values from print/motion-design conventions:\n//\n//  - Big text (display/headline) gets TIGHTER tracking and TIGHTER leading.\n//    -0.022em (≈ -2.2%) is the sweet spot for 48–88px headlines on most\n//    sans-serifs (Inter, Helvetica, SF Pro, Manrope, Geist).\n//  - Word-spacing kept subtle (≤0.08em). CSS word-spacing is ADDITIVE on\n//    top of the natural space char, so what looks like \"a touch of\n//    rhythm\" in print becomes a visible double-gap on 80px motion\n//    headlines (especially under wordStagger, where each word renders\n//    as an inline-block and the gap between them is preserved). Old\n//    values (0.16/0.18/0.22 em) added ~13–18px per gap on display\n//    type — the \"too much space between words\" symptom.\n//  - Line-height 1.1 for headlines, 1.5 for body — Bringhurst-aligned ratios.\n//  - kern + liga always on so any font's pair-kerning and ligatures fire\n//    consistently (works across Inter, Manrope, SF, IBM Plex, etc.).\nconst TYPO = {\n  headline: {\n    letterSpacing: \"-0.022em\",\n    wordSpacing: \"0.04em\",\n    lineHeight: 1.1,\n  },\n  caption: {\n    letterSpacing: \"-0.012em\",\n    wordSpacing: \"0.06em\",\n    lineHeight: 1.25,\n  },\n  label: {\n    letterSpacing: \"-0.005em\",\n    wordSpacing: \"0.10em\",\n    lineHeight: 1.4,\n  },\n};\nconst FONT_FEATURES = '\"kern\" 1, \"liga\" 1';\n\n// Drop shadow tuned to give crisp edges on retina without muddying text on\n// saturated gradients. Earlier two-layer shadow (1px tight + 16px wide) cast\n// dark halos that made gradient-backed text look smudged. A single barely-\n// there shadow is enough for edge definition; bg-media adds its own dark\n// scrim for legibility over photos, so we don't need to compensate here.\nfunction dropShadowFor(textColor: string): string {\n  const dark = isLikelyDark(textColor);\n  const tone = dark ? \"rgba(255,255,255,0.15)\" : \"rgba(0,0,0,0.2)\";\n  return `0 1px 2px ${tone}`;\n}\n\nfunction isLikelyDark(color: string): boolean {\n  // Crude luminance check — handles #rrggbb and #rgb. Anything we can't parse\n  // (named colors, rgb()) defaults to \"not dark\" so the heavier shadow shows.\n  const m = color.replace(\"#\", \"\");\n  if (m.length === 3) {\n    const r = parseInt(m[0] + m[0], 16);\n    const g = parseInt(m[1] + m[1], 16);\n    const b = parseInt(m[2] + m[2], 16);\n    return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n  }\n  if (m.length === 6) {\n    const r = parseInt(m.slice(0, 2), 16);\n    const g = parseInt(m.slice(2, 4), 16);\n    const b = parseInt(m.slice(4, 6), 16);\n    return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n  }\n  return false;\n}\n// ─── Font sizing matrix ─────────────────────────────────────────\n// Mirrors what production templates actually render today, ported from:\n//   - text-overlay.tsx (headline: 80/64/48 × s_min by char count)\n//   - infographic-steps.tsx (caption: ~44 × s_min capped by layout)\n\n/**\n * Compute heroWord font size for a single word. Each active word fills its\n * own moment — trailer convention. Center can go large (480ref cap); top\n * stays inside the top zone height so it doesn't crash into the animation\n * or data viz below.\n */\nfunction computeHeroFontSize(\n  wordChars: number,\n  position: TextPosition,\n  width: number,\n  height: number,\n  safeZone: SafeZone,\n): number {\n  const chars = Math.max(wordChars, 1);\n  const s_min = Math.min(width, height) / 1080;\n  const widthBudget = (width - safeZone.left - safeZone.right) * 0.92;\n  const pxPerChar = 0.58; // bold sans-serif approximation\n  const widthCap = widthBudget / (chars * pxPerChar);\n\n  if (position === \"center\") {\n    return Math.min(480 * s_min, widthCap);\n  }\n\n  // Top/bottom: keep the word inside its zone (~28% of frame height) with\n  // 80% headroom for entrance overshoot + breathe. Reference target 220ref\n  // so even short words stay big without overflowing the zone.\n  const heightCap = height * 0.28 * 0.8;\n  return Math.min(220 * s_min, widthCap, heightCap);\n}\n\n/**\n * Smooth interpolation between max and min font size based on character count.\n * Avoids the visible \"jump\" you get from bucket boundaries when copy length\n * crosses a threshold (e.g., 25→26 chars dropping headline from 80px to 64px).\n *\n * Exported so templates that lay out their own text can match the headline\n * curve instead of inventing their own bucketed scaling.\n *\n * Returns size at 1080-reference scale; caller multiplies by s_min.\n */\nexport function smoothSize(chars: number, maxChars: number, max: number, min: number): number {\n  const t = Math.max(0, Math.min(1, chars / maxChars));\n  // Slight curve so short text stays at maxSize longer before scaling down.\n  const eased = t * t;\n  return max - (max - min) * eased;\n}\n\nfunction computeFontSize(\n  archetype: TextArchetype,\n  text: string,\n  role: TextSizeRole,\n  position: TextPosition,\n  width: number,\n  height: number,\n  safeZone: SafeZone,\n): { fontSize: number; fontWeight: number } {\n  const s_min = Math.min(width, height) / 1080;\n\n  // heroWord container fontSize uses the longest word as a safe fallback. The\n  // ACTIVE-word size is recomputed per-render in the \"hero\" render branch\n  // (via computeHeroFontSize) so each word fills its own moment optimally —\n  // trailer convention.\n  if (archetype === \"heroWord\") {\n    const longestChars = text\n      .split(/\\s+/)\n      .filter(Boolean)\n      .reduce((m, w) => Math.max(m, w.length), 1);\n    return {\n      fontSize: computeHeroFontSize(longestChars, position, width, height, safeZone),\n      fontWeight: 800,\n    };\n  }\n\n  const chars = text.length;\n\n  // Smooth scaling, minimums set so even long copy stays readable in production\n  // (1080 reference). Numbers tuned to match — but improve on — the previous\n  // bucketed system.\n  if (role === \"headline\") {\n    // Floor 60 (was 48) — matches the typography guideline \"Titles/headlines\n    // 60-86px at 1080\" and lifts long-copy headlines off the body-text floor\n    // that left ProblemSolution-shaped statements feeling small. Max held at\n    // 88 so short, punchy headlines still fill the frame.\n    const refSize = smoothSize(chars, /* maxChars */ 70, /* max */ 88, /* min */ 60);\n    return { fontSize: refSize * s_min, fontWeight: 700 };\n  }\n\n  if (role === \"caption\") {\n    const refSize = smoothSize(chars, 80, 48, 32);\n    return { fontSize: refSize * s_min, fontWeight: 600 };\n  }\n\n  // label\n  const refSize = smoothSize(chars, 80, 34, 24);\n  return { fontSize: refSize * s_min, fontWeight: 500 };\n}\n\n// ─── Positioning ───────────────────────────────────────────────\n\nfunction positionStyle(\n  position: TextPosition,\n  height: number,\n  safeZone: SafeZone,\n): React.CSSProperties {\n  switch (position) {\n    case \"top\":\n      return {\n        top: safeZone.top + height * 0.06,\n        height: height * 0.28,\n        alignItems: \"flex-start\",\n      };\n    case \"bottom\":\n      return {\n        bottom: safeZone.bottom + height * 0.06,\n        height: height * 0.28,\n        alignItems: \"flex-end\",\n      };\n    case \"center\":\n    default:\n      return {\n        top: 0,\n        bottom: 0,\n        alignItems: \"center\",\n      };\n  }\n}\n\n// ─── Component ─────────────────────────────────────────────────\n\nexport const TemplateText: React.FC<TemplateTextProps> = ({\n  archetype: archetypeRaw,\n  text: textRaw,\n  progress,\n  sceneDuration,\n  width,\n  height,\n  position = \"center\",\n  sizeRole = \"headline\",\n  typeTreatment,\n  safeZone = DEFAULT_SAFE_ZONE,\n  font = DEFAULT_FONT,\n  color = \"#FFFFFF\",\n  beatIntensity = 0,\n}) => {\n  // Defensive coerce: TemplateText is downstream of ~16 templates that pass\n  // their own `variables.X` strings. If any one of them passes undefined\n  // (missing variable on a freshly added scene, stale saved config, vibecoded\n  // template not setting a field), the unguarded `.split` / `.length` calls\n  // below crash the entire Studio preview. Treat undefined/non-string as\n  // empty so a single bad scene doesn't take everything down. Warn in dev\n  // so the upstream gap still surfaces.\n  const textSafe = typeof textRaw === \"string\" ? textRaw : \"\";\n  if (textRaw !== undefined && typeof textRaw !== \"string\" && import.meta.env.DEV) {\n    console.warn(\"[TemplateText] received non-string text:\", textRaw);\n  }\n  // `|` is the AI's explicit line-break convention for headline copy\n  // (\"Built for speed.|Designed for you.\"). Convert centrally so EVERY\n  // template that renders text through TemplateText honors it — bg-media\n  // used to convert locally while confetti/emojiBurst/etc. rendered the\n  // pipe literally. Whitespace around the pipe is trimmed so spaced and\n  // unspaced pipes produce identical output. The container's\n  // `white-space: pre-line` renders the resulting `\\n` as a hard break.\n  // Templates that legitimately render pipes (code, terminal commands)\n  // don't flow through TemplateText, so they're unaffected.\n  const text = textSafe.replace(/\\s*\\|\\s*/g, \"\\n\");\n  // Normalize the archetype prop so legacy effect names from old saved\n  // configs (e.g., \"fade-in\") map to a real archetype instead of crashing.\n  const archetype = normalizeArchetype(archetypeRaw);\n  const scale = Math.min(width, height) / 1080;\n  // Motion pacing applies at every size role — a calm video should ease its\n  // captions in too, not just its headlines. (The rest of the treatment is\n  // headline-only; see `tt` below.)\n  const result: ArchetypeRender = renderArchetype(\n    archetype,\n    progress,\n    scale,\n    text,\n    sceneDuration,\n    typeTreatment?.phaseScale ?? 1,\n  );\n  const { fontSize, fontWeight } = computeFontSize(\n    archetype,\n    text,\n    sizeRole,\n    position,\n    width,\n    height,\n    safeZone,\n  );\n\n  // beatIntensity reserved for future use; currently a no-op on text body.\n  void beatIntensity;\n\n  const baseTypo = TYPO[sizeRole];\n  // Preset type treatment. Absent (or the default preset's zero-deltas) leaves\n  // every value exactly as it was, so unpresetted configs are unaffected.\n  const tt = sizeRole === \"headline\" ? typeTreatment : undefined;\n  // Only rewrite a value the preset actually changes — reformatting\n  // letterSpacing with a zero delta would alter the emitted string (and every\n  // stability snapshot) without changing the render.\n  const typo = tt\n    ? {\n        ...baseTypo,\n        ...(tt.trackingDeltaEm !== 0\n          ? {\n              letterSpacing: `${Number(\n                (parseFloat(baseTypo.letterSpacing) + tt.trackingDeltaEm).toFixed(4),\n              )}em`,\n            }\n          : {}),\n        ...(tt.transform ? { textTransform: tt.transform } : {}),\n      }\n    : baseTypo;\n  const presetWeight = tt ? Math.min(900, Math.max(100, fontWeight + tt.weightDelta)) : fontWeight;\n  const presetSize = tt ? fontSize * tt.sizeScale : fontSize;\n  const textShadow = dropShadowFor(color);\n\n  const containerStyle: React.CSSProperties = {\n    position: \"absolute\",\n    left: 0,\n    right: 0,\n    display: \"flex\",\n    justifyContent: \"center\",\n    padding: `0 ${safeZone.right}px 0 ${safeZone.left}px`,\n    color,\n    fontFamily: font,\n    fontWeight: presetWeight,\n    fontSize: presetSize,\n    textAlign: \"center\",\n    pointerEvents: \"none\",\n    fontFeatureSettings: FONT_FEATURES,\n    textRendering: \"optimizeLegibility\",\n    WebkitFontSmoothing: \"antialiased\",\n    MozOsxFontSmoothing: \"grayscale\",\n    // Respect explicit newlines — the centralized `|` → `\\n` conversion\n    // above (and callers passing real newlines) rely on this. Multiple\n    // spaces still collapse normally; only `\\n` and CRLF break.\n    whiteSpace: \"pre-line\",\n    ...(textShadow ? { textShadow } : {}),\n    ...typo,\n    ...positionStyle(position, height, safeZone),\n  };\n\n  if (result.kind === \"block\") {\n    return (\n      <div style={containerStyle}>\n        <div\n          style={{\n            opacity: result.block.opacity,\n            transform: `${result.block.transform}`,\n            // Inherit letter-spacing from the container's typography defaults\n            // unless the archetype explicitly overrides (e.g., for animated tracking).\n            ...(result.block.letterSpacing ? { letterSpacing: result.block.letterSpacing } : {}),\n            ...(result.block.willChange ? { willChange: result.block.willChange } : {}),\n            maxWidth: \"85%\",\n          }}\n        >\n          {renderWithEmoji(result.text, fontSize)}\n        </div>\n      </div>\n    );\n  }\n\n  if (result.kind === \"typewriter\") {\n    // Render every character as its own span so the FULL TEXT always sets the\n    // layout — wrapping is decided by the complete string, not the typed\n    // prefix. The cursor is overlaid with position:absolute from the last\n    // typed char so it doesn't break the word it's inside.\n    const chars = text.split(\"\");\n    // Map cluster-start code-unit indices → mapped emoji-PNG image so color\n    // emoji render as images even in the per-char typewriter reveal. Indexing\n    // stays on text.length (UTF-16 units) so result.visibleChars / charExits\n    // line up exactly; continuation units of a cluster render nothing.\n    const emojiPlan = planTypewriterEmoji(text);\n    const cursorBar = {\n      position: \"absolute\" as const,\n      width: \"0.08em\",\n      height: \"0.88em\",\n      background: \"currentColor\",\n      borderRadius: \"0.01em\",\n      pointerEvents: \"none\" as const,\n    };\n    return (\n      <div style={containerStyle}>\n        <div\n          style={{\n            opacity: result.opacity,\n            maxWidth: \"85%\",\n            whiteSpace: \"pre-wrap\",\n            position: \"relative\",\n          }}\n        >\n          {chars.map((ch, i) => {\n            const isTyped = i < result.visibleChars;\n            const isLastTyped = i === result.visibleChars - 1;\n            const anchorCursor = isLastTyped && result.cursor;\n            const charExit = result.charExits?.[i];\n            const baseOpacity = isTyped ? 1 : 0;\n            const finalOpacity = baseOpacity * (charExit?.opacity ?? 1);\n            // During exit the per-char span needs inline-block so translateX\n            // takes effect; whiteSpace: pre keeps space chars from collapsing.\n            const exitStyle = charExit\n              ? {\n                  display: \"inline-block\" as const,\n                  transform: `translateX(${charExit.translateX}px)`,\n                  whiteSpace: \"pre\" as const,\n                }\n              : null;\n            // Emoji handling: a cluster-start unit renders the mapped PNG; the\n            // cluster's continuation units render nothing (image spans them).\n            const emojiChar = emojiPlan?.starts.get(i);\n            if (emojiPlan?.covered.has(i)) return null;\n            return (\n              <span\n                key={i}\n                style={{\n                  opacity: finalOpacity,\n                  position: anchorCursor ? \"relative\" : \"static\",\n                  ...(exitStyle ?? {}),\n                }}\n              >\n                {emojiChar ? <Emoji char={emojiChar} size={fontSize} /> : ch}\n                {anchorCursor && (\n                  <span\n                    aria-hidden\n                    style={{\n                      ...cursorBar,\n                      left: \"100%\",\n                      top: \"0.08em\",\n                      marginLeft: \"0.12em\",\n                    }}\n                  />\n                )}\n              </span>\n            );\n          })}\n          {result.visibleChars === 0 && result.cursor && (\n            <span\n              aria-hidden\n              style={{\n                ...cursorBar,\n                left: 0,\n                top: \"0.08em\",\n              }}\n            />\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  if (result.kind === \"words\") {\n    // Render words inline-block with REAL space chars between them — word\n    // spacing inherits from the container's typography defaults, matching\n    // every other archetype's wrap behavior.\n    return (\n      <div style={containerStyle}>\n        <div\n          style={{\n            opacity: result.blockOpacity,\n            transform: `${result.blockTransform} `,\n            maxWidth: \"85%\",\n          }}\n        >\n          {result.words.map((w, i) => (\n            <span key={i}>\n              <span\n                style={{\n                  display: \"inline-block\",\n                  opacity: w.style.opacity,\n                  transform: w.style.transform,\n                }}\n              >\n                {renderWithEmoji(w.text, fontSize)}\n              </span>\n              {i < result.words.length - 1 ? \" \" : \"\"}\n            </span>\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  if (result.kind === \"hero\") {\n    // Per-word sizing: each active word fills its own moment optimally.\n    const perWordFontSize = computeHeroFontSize(\n      result.word.length,\n      position,\n      width,\n      height,\n      safeZone,\n    );\n    // Fixed-height slot so words of different sizes don't jump vertically.\n    // Slot is the max possible hero size for this position; lineHeight: 1 on\n    // the inner word locks the glyph box to the font height so flex-center\n    // lands the glyph at the same Y for every word.\n    const slotHeight =\n      position === \"center\" ? 480 * scale : Math.min(220 * scale, height * 0.28 * 0.8);\n    return (\n      <div style={{ ...containerStyle, fontSize: perWordFontSize }}>\n        <div\n          style={{\n            height: slotHeight,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n          }}\n        >\n          <div\n            style={{\n              opacity: result.opacity,\n              transform: `${result.transform} `,\n              lineHeight: 1,\n              ...(result.letterSpacing ? { letterSpacing: result.letterSpacing } : {}),\n            }}\n          >\n            {renderWithEmoji(result.word, perWordFontSize)}\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return null;\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/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/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": "data",
      "tier": "free",
      "register": "typography-led",
      "jobs": [
        "claim",
        "proof"
      ],
      "useWhen": "Exactly three peer stats or compact specs from the input with comparable weight. Prefer over bigNumber for three parallel facts. Avoid when one number is the hero or values are just brand/tool names.",
      "textCanvas": "tight",
      "minDuration": 2,
      "preferredDuration": 3,
      "gates": {
        "requiresStat": true
      },
      "allowsStockMedia": true,
      "variableSchema": {
        "texts": {
          "type": "string",
          "label": "Text",
          "default": "By the numbers.",
          "required": true,
          "description": "Title text"
        },
        "stat1Value": {
          "type": "string",
          "label": "Stat 1 value",
          "default": "10K",
          "required": true,
          "description": "First stat number (e.g. 10K, 99.9%, $2M)"
        },
        "stat1Label": {
          "type": "string",
          "label": "Stat 1 label",
          "default": "Users",
          "required": true,
          "description": "Short label below the first number"
        },
        "stat2Value": {
          "type": "string",
          "label": "Stat 2 value",
          "default": "99.9%",
          "required": true,
          "description": "Second stat number"
        },
        "stat2Label": {
          "type": "string",
          "label": "Stat 2 label",
          "default": "Uptime",
          "required": true,
          "description": "Short label below the second number"
        },
        "stat3Value": {
          "type": "string",
          "label": "Stat 3 value",
          "default": "<50ms",
          "required": true,
          "description": "Third stat number"
        },
        "stat3Label": {
          "type": "string",
          "label": "Stat 3 label",
          "default": "Latency",
          "required": true,
          "description": "Short label below the third number"
        },
        "mediaUrl": {
          "type": "media",
          "label": "Background media",
          "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient."
        },
        "mediaKeyword": {
          "type": "string",
          "label": "Background search keyword",
          "default": "",
          "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl)."
        },
        "mediaType": {
          "type": "enum",
          "label": "Background media type",
          "default": "auto",
          "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
          "options": [
            "auto",
            "photo",
            "video",
            "gradient"
          ]
        },
        "mediaPoster": {
          "type": "string",
          "label": "Background poster image",
          "default": "",
          "description": "Still image URL shown while a video backdrop is decoding its first frame. Auto-filled from Pexels' thumbnail when fillPexelsUrls sets a video mediaUrl. Hides the gradient flash that would otherwise appear in the ~50–400ms gap between a <video> mounting and decoding its first frame."
        },
        "mediaPosition": {
          "type": "enum",
          "label": "Background focal position",
          "default": "center",
          "description": "Controls which part of a photo or video stays visible when cover-cropped. Pick the subject's side or vertical anchor after inspecting the frame.",
          "options": [
            "center",
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "mediaTreatment": {
          "type": "enum",
          "label": "Background contrast treatment",
          "default": "cinematic",
          "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
          "options": [
            "subtle",
            "cinematic",
            "text-safe"
          ]
        },
        "textColor": {
          "type": "color",
          "label": "Text color",
          "default": "",
          "description": "Override text color (leave empty for auto)"
        }
      },
      "defaultVariables": {
        "texts": "By the numbers.",
        "stat1Value": "10K",
        "stat1Label": "Users",
        "stat2Value": "99.9%",
        "stat2Label": "Uptime",
        "stat3Value": "<50ms",
        "stat3Label": "Latency",
        "mediaUrl": "",
        "mediaKeyword": "",
        "mediaType": "auto",
        "mediaPoster": "",
        "mediaPosition": "center",
        "mediaTreatment": "cinematic",
        "textColor": ""
      }
    }
  }
}
