{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "webMockup",
  "type": "registry:block",
  "title": "Web Mockup",
  "description": "Uploaded landscape or square desktop/web screenshots, dashboard views, or browser/tablet product surfaces. Use one scene for up to three same-orientation screens.",
  "dependencies": [
    "react"
  ],
  "registryDependencies": [
    "@vanillasky/backgrounds",
    "@vanillasky/motion",
    "@vanillasky/theme",
    "@vanillasky/typography",
    "@vanillasky/video-config"
  ],
  "files": [
    {
      "path": "src/lib/scene-templates/showcase-web.tsx",
      "type": "registry:component",
      "target": "vanillasky/scene-templates/showcase-web.tsx",
      "content": "/**\n * showcase-web — block-shaped browser/tablet product reveal.\n *\n * The template owns the outer scene and caption. SceneBackground owns the\n * atmosphere, WebMockup owns the device entrance, and ProductSurface (inside\n * WebMockup) owns screenshot focus, camera motion, and feature annotations.\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 type { TextArchetype } from \"../typography\";\nimport { WebMockup } from \"../primitives/devices/WebMockup\";\nimport {\n  productSurfaceDefaults,\n  productSurfaceSchemaFields,\n  resolveProductSurfaceMotion,\n} from \"../primitives/devices/product-surface-config\";\nimport {\n  mediaBackgroundDefaults,\n  mediaBackgroundSchemaFields,\n  SceneBackground, getMediaBackgroundProps,\n} from \"./scene-background\";\n\nexport const showcaseWebSchema: Record<string, VariableField> = {\n  frame: {\n    type: \"enum\",\n    label: \"Device frame\",\n    default: \"browser\",\n    options: [\"browser\", \"tablet\"],\n    description: \"browser shows chrome and an address bar; tablet uses a clean product frame.\",\n  },\n  texts: {\n    type: \"string\",\n    label: \"Text\",\n    default: \"See it in action.\",\n    required: true,\n    description: \"Title text shown above the product.\",\n  },\n  screenMediaUrl: {\n    type: \"media\",\n    label: \"Screenshot\",\n    description: \"Product screenshot shown inside the browser or tablet frame.\",\n  },\n  screen1Url: {\n    type: \"media\",\n    label: \"Screen 1\",\n    description: \"Optional second product screen. Setting it enables slides mode.\",\n  },\n  screen2Url: {\n    type: \"media\",\n    label: \"Screen 2\",\n    description: \"Optional third product screen for slides mode.\",\n  },\n  ...productSurfaceSchemaFields,\n  addressBarUrl: {\n    type: \"string\",\n    label: \"Address bar URL\",\n    default: \"yourapp.com\",\n    description: \"URL shown in browser chrome; ignored by the tablet frame.\",\n  },\n  textColor: {\n    type: \"color\",\n    label: \"Text color\",\n    default: \"\",\n    description: \"Override text color; leave empty to use the brand-aware default.\",\n  },\n  ...mediaBackgroundSchemaFields,\n};\n\nexport const showcaseWebDefaults: Record<string, unknown> = {\n  frame: \"browser\",\n  texts: \"See it in action.\",\n  screenMediaUrl: \"\",\n  screen1Url: \"\",\n  screen2Url: \"\",\n  ...productSurfaceDefaults,\n  addressBarUrl: \"yourapp.com\",\n  textColor: \"\",\n  ...mediaBackgroundDefaults,\n};\n\nexport const ShowcaseWebTemplate: React.FC<SceneTemplateProps> = ({\n  variables,\n  style,\n  progress,\n  beatIntensity,\n  width,\n  height,\n  textArchetype,\n  safeZone,\n  sceneDuration,\n  backgroundEffect,\n  isPlaying,\n}) => {\n  const tokens = resolveTokens(style);\n  const { accent, secondary, content, font } = tokens;\n  const textColor = String(variables.textColor || \"\") || content;\n  const screenMediaUrl = String(variables.screenMediaUrl || \"\");\n  const extraScreens = [\n    String(variables.screen1Url || \"\"),\n    String(variables.screen2Url || \"\"),\n  ].filter(Boolean);\n  const screens = extraScreens.length > 0 && screenMediaUrl\n    ? [screenMediaUrl, ...extraScreens]\n    : extraScreens;\n  const frame = String(variables.frame || \"browser\").toLowerCase() === \"tablet\"\n    ? \"tablet\"\n    : \"browser\";\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] Brand gradient or cinematic media atmosphere. */}\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={String(variables.texts || \"\")}\n        isPlaying={isPlaying}\n        beatIntensity={beatIntensity}\n      />\n\n      {/* [slot: caption] Shared top headline. */}\n      <TemplateText\n        typeTreatment={tokens.preset.type}\n        archetype={(textArchetype as TextArchetype) ?? \"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] Device frame + shared ProductSurface treatment. */}\n      <WebMockup\n        progress={progress}\n        width={width}\n        height={height}\n        frame={frame}\n        screenMediaUrl={screenMediaUrl}\n        screens={screens}\n        addressBarUrl={String(variables.addressBarUrl || \"yourapp.com\")}\n        font={font}\n        accent={accent}\n        secondary={secondary}\n        bg={style.brandKit?.bg}\n        beatIntensity={beatIntensity}\n        screenFit={variables.screenFit === \"contain\" ? \"contain\" : \"cover\"}\n        screenFocusX={Number(variables.screenFocusX ?? 50)}\n        screenFocusY={Number(variables.screenFocusY ?? 50)}\n        screenMotion={resolveProductSurfaceMotion(variables.screenMotion)}\n        screenCalloutText={String(variables.screenCalloutText || \"\")}\n        screenCalloutX={Number(variables.screenCalloutX ?? 70)}\n        screenCalloutY={Number(variables.screenCalloutY ?? 35)}\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/devices/product-surface-config.ts",
      "type": "registry:lib",
      "target": "vanillasky/primitives/devices/product-surface-config.ts",
      "content": "import type { VariableField } from \"../../video-config\";\n\nexport type ProductSurfaceFit = \"cover\" | \"contain\";\nexport type ProductSurfaceMotion = \"still\" | \"pushIn\" | \"pan\";\n\nexport const productSurfaceSchemaFields: Record<string, VariableField> = {\n  screenFit: {\n    type: \"enum\",\n    label: \"Screenshot fit\",\n    default: \"cover\",\n    options: [\"cover\", \"contain\"],\n    description: \"Use cover for immersive product detail; contain when the full interface must remain visible.\",\n  },\n  screenFocusX: {\n    type: \"number\",\n    label: \"Horizontal focus (0-100)\",\n    default: 50,\n    description: \"Horizontal percentage of the screenshot to keep in focus. Values are clamped to 0-100.\",\n  },\n  screenFocusY: {\n    type: \"number\",\n    label: \"Vertical focus (0-100)\",\n    default: 50,\n    description: \"Vertical percentage of the screenshot to keep in focus. Values are clamped to 0-100.\",\n  },\n  screenMotion: {\n    type: \"enum\",\n    label: \"Screenshot motion\",\n    default: \"pushIn\",\n    options: [\"still\", \"pushIn\", \"pan\"],\n    description: \"Single-screen treatment: pushIn is the professional default; use pan for wide interfaces and still when motion would distract. Multi-screen slides stay still so motions do not compete.\",\n  },\n  screenCalloutText: {\n    type: \"string\",\n    label: \"Feature callout\",\n    default: \"\",\n    description: \"Optional 2-4 word annotation anchored to a product detail. Leave empty rather than narrating the headline twice.\",\n  },\n  screenCalloutX: {\n    type: \"number\",\n    label: \"Callout horizontal position (0-100)\",\n    default: 70,\n    description: \"Horizontal percentage of the product surface for the callout anchor.\",\n  },\n  screenCalloutY: {\n    type: \"number\",\n    label: \"Callout vertical position (0-100)\",\n    default: 35,\n    description: \"Vertical percentage of the product surface for the callout anchor.\",\n  },\n};\n\nexport const productSurfaceDefaults: Record<string, unknown> = {\n  screenFit: \"cover\",\n  screenFocusX: 50,\n  screenFocusY: 50,\n  screenMotion: \"pushIn\",\n  screenCalloutText: \"\",\n  screenCalloutX: 70,\n  screenCalloutY: 35,\n};\n\nexport function resolveProductSurfaceMotion(value: unknown): ProductSurfaceMotion {\n  return value === \"still\" || value === \"pan\" ? value : \"pushIn\";\n}\n"
    },
    {
      "path": "src/lib/primitives/devices/WebMockup.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/devices/WebMockup.tsx",
      "content": "/**\n * WebMockup\n *\n * Web mockup that can render as a browser window (traffic-light dots +\n * address bar) OR a tablet (clean rounded frame). Same 3D tilt entrance\n * + slide-up pattern as PhoneFrame, but 16:9 instead of 9:16.\n * Single-screen mode or multi-screen slides. Screenshot rendering is\n * delegated to ProductSurface so templates and custom scenes get the same\n * crop, camera-motion, and annotation behavior.\n *\n * Prop API:\n *   - progress       : scene progress 0→1 (required)\n *   - width/height   : frame dimensions (required)\n *   - frame          : \"browser\" | \"tablet\" (default \"browser\")\n *   - screenMediaUrl : single screen URL (used when `screens` is empty)\n *   - screens        : multi-screen slide strip (when set, used as the\n *                      sliding strip; if screenMediaUrl is also set,\n *                      callers should prepend it themselves — matches\n *                      PhoneFrame's contract)\n *   - addressBarUrl  : URL text shown in browser chrome (default \"yourapp.com\")\n *   - font           : font family for placeholder text (default \"Inter\")\n *   - accent         : reserved brand accent — not currently used inside\n *                      the device chrome; kept for API parity\n *   - beatIntensity  : beat pulse 0→1 (default 0)\n */\n\nimport * as React from \"react\";\nimport { interpolate, spring, SPRING_SMOOTH } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\nimport { WebScreenFill } from \"./DesignedScreenFill\";\nimport { ProductSurface } from \"./ProductSurface\";\nimport type { ProductSurfaceFit, ProductSurfaceMotion } from \"./product-surface-config\";\n\nconst CLAMP = { extrapolateLeft: \"clamp\" as const, extrapolateRight: \"clamp\" as const };\n\n// ─── Typed component ────────────────────────────────────────────\n\nexport interface WebMockupProps {\n  /** Scene progress 0→1 */\n  progress: number;\n  /** Frame width */\n  width: number;\n  /** Frame height */\n  height: number;\n  /** Device frame style. Defaults to \"browser\". */\n  frame?: \"browser\" | \"tablet\";\n  /** Single screen URL (used when `screens` is empty). Leave empty for placeholder. */\n  screenMediaUrl?: string;\n  /** Multi-screen mode — sliding strip of screens. When set, ignores screenMediaUrl. */\n  screens?: string[];\n  /** URL shown in browser chrome (browser frame only). Default \"yourapp.com\". */\n  addressBarUrl?: string;\n  /** Font family for placeholder text. Default \"Inter\". */\n  font?: string;\n  /** Reserved brand accent (not used in chrome today). Default \"#00e5a0\". */\n  accent?: string;\n  /** Secondary brand color for the designed empty state. */\n  secondary?: string;\n  /** Optional brand background for the designed empty state. */\n  bg?: string;\n  /** Beat pulse 0→1 */\n  beatIntensity?: number;\n  screenFit?: ProductSurfaceFit;\n  screenFocusX?: number;\n  screenFocusY?: number;\n  screenMotion?: ProductSurfaceMotion;\n  screenCalloutText?: string;\n  screenCalloutX?: number;\n  screenCalloutY?: number;\n}\n\nexport const WebMockup: React.FC<WebMockupProps> = ({\n  progress,\n  width,\n  height,\n  frame = \"browser\",\n  screenMediaUrl = \"\",\n  screens = [],\n  addressBarUrl = \"yourapp.com\",\n  font = TOKEN_DEFAULTS.font,\n  accent = TOKEN_DEFAULTS.accent,\n  secondary = \"#00b5e5\",\n  bg,\n  beatIntensity = 0,\n  screenFit = \"cover\",\n  screenFocusX = 50,\n  screenFocusY = 50,\n  screenMotion = \"pushIn\",\n  screenCalloutText = \"\",\n  screenCalloutX = 70,\n  screenCalloutY = 35,\n}) => {\n  const s = Math.min(width, height) / 1080;\n  const isBrowser = frame !== \"tablet\";\n  const addressBarUrlClean = stripPipe(String(addressBarUrl || \"yourapp.com\"));\n\n  const slidesMode = screens.length > 0;\n\n  // ── Device dimensions — fit in both orientations ──────────────\n  const deviceWidth = Math.min(width * 0.85, height * 0.55 * (16 / 9));\n  const chromeHeight = isBrowser ? 44 * s : 0;\n  const contentHeight = deviceWidth * (9 / 16);\n  const deviceHeight = chromeHeight + contentHeight;\n  const borderRadius = isBrowser ? 12 * s : 24 * s;\n  const borderWidth = isBrowser ? 0 : 6 * s;\n\n  // ── 3D perspective tilt entrance ────────────────────────────────\n  const enterRange: [number, number] = slidesMode ? [0.02, 0.35] : [0.02, 0.85];\n  const enterP = spring(\n    interpolate(progress, enterRange, [0, 1], CLAMP),\n    { damping: 28, stiffness: 80 },\n  );\n  const rotateX = (1 - enterP) * 70;\n  const deviceY = (1 - enterP) * 150 * s;\n  const deviceScale = interpolate(enterP, [0, 1], [0.85, 1]);\n  const deviceOpacity = interpolate(progress, [0.02, 0.1], [0, 1], CLAMP);\n\n  // ── Screen slide offsets (slides mode only) ───────────────────\n  const slideCount = screens.length;\n  let slideOffset = 0;\n  if (slidesMode && slideCount >= 2) {\n    const transitionWidth = 0.1;\n\n    let totalSlides = 0;\n    for (let i = 1; i < slideCount; i++) {\n      const boundary = i / slideCount;\n      const start = boundary - transitionWidth / 2;\n      const end = boundary + transitionWidth / 2;\n      totalSlides += spring(\n        interpolate(progress, [start, end], [0, 1], CLAMP),\n        SPRING_SMOOTH,\n      );\n    }\n    slideOffset = -totalSlides * deviceWidth;\n  }\n\n  const beatScale = slidesMode ? 1 + beatIntensity * 0.01 : 1 + beatIntensity * 0.015;\n\n  // ── Traffic light dot sizes ───────────────────────────────────\n  const dotSize = 12 * s;\n  const dotGap = 8 * s;\n\n  const placeholder = (\n    <WebScreenFill\n      accent={accent}\n      secondary={secondary}\n      bg={bg}\n      font={font}\n      s={s}\n      w={deviceWidth}\n      h={contentHeight}\n      progress={progress}\n    />\n  );\n\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        top: height * 0.35,\n        left: (width - deviceWidth) / 2,\n        width: deviceWidth,\n        height: deviceHeight,\n        perspective: `${1200 * s}px`,\n      }}\n    >\n      <div\n        style={{\n          width: deviceWidth,\n          height: deviceHeight,\n          transform: `rotateX(${rotateX}deg) translateY(${deviceY}px) scale(${deviceScale * beatScale})`,\n          transformOrigin: \"center bottom\",\n          opacity: deviceOpacity,\n        }}\n      >\n        {/* Browser frame */}\n        <div\n          style={{\n            width: deviceWidth,\n            height: deviceHeight,\n            borderRadius,\n            overflow: \"hidden\",\n            position: \"relative\",\n            boxShadow: `0 ${20 * s}px ${60 * s}px rgba(0,0,0,0.35)`,\n          }}\n        >\n          {/* Chrome bar — browser frame only */}\n          {isBrowser && (\n            <div\n              style={{\n                width: \"100%\",\n                height: 44 * s,\n                backgroundColor: \"#e8e8e8\",\n                display: \"flex\",\n                alignItems: \"center\",\n                paddingLeft: 16 * s,\n                paddingRight: 16 * s,\n                gap: 0,\n                position: \"relative\",\n                zIndex: 2,\n              }}\n            >\n              {/* Traffic light dots */}\n              <div style={{ display: \"flex\", gap: dotGap, flexShrink: 0 }}>\n                <div style={{ width: dotSize, height: dotSize, borderRadius: \"50%\", backgroundColor: \"#ff5f57\" }} />\n                <div style={{ width: dotSize, height: dotSize, borderRadius: \"50%\", backgroundColor: \"#febc2e\" }} />\n                <div style={{ width: dotSize, height: dotSize, borderRadius: \"50%\", backgroundColor: \"#28c840\" }} />\n              </div>\n\n              {/* Address bar */}\n              <div\n                style={{\n                  flex: 1,\n                  marginLeft: 16 * s,\n                  height: 28 * s,\n                  backgroundColor: \"#ffffff\",\n                  borderRadius: 6 * s,\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  paddingLeft: 12 * s,\n                  paddingRight: 12 * s,\n                  overflow: \"hidden\",\n                }}\n              >\n                {/* Lock icon */}\n                <svg\n                  width={14 * s}\n                  height={14 * s}\n                  viewBox=\"0 0 14 14\"\n                  fill=\"none\"\n                  style={{ flexShrink: 0, marginRight: 6 * s }}\n                >\n                  <rect x=\"2\" y=\"6\" width=\"10\" height=\"7\" rx=\"1.5\" fill=\"#999\" />\n                  <path d=\"M4.5 6V4.5a2.5 2.5 0 015 0V6\" stroke=\"#999\" strokeWidth=\"1.5\" fill=\"none\" strokeLinecap=\"round\" />\n                </svg>\n                <span\n                  style={{\n                    fontSize: 13 * s,\n                    fontFamily: \"-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\",\n                    color: \"#666\",\n                    whiteSpace: \"nowrap\",\n                    overflow: \"hidden\",\n                    textOverflow: \"ellipsis\",\n                  }}\n                >\n                  {addressBarUrlClean}\n                </span>\n              </div>\n            </div>\n          )}\n\n          {/* Tablet border frame overlay — tablet frame only */}\n          {!isBrowser && (\n            <div\n              style={{\n                position: \"absolute\",\n                inset: 0,\n                borderRadius,\n                border: `${borderWidth * 1.5}px solid rgba(220,220,220,0.6)`,\n                pointerEvents: \"none\",\n                zIndex: 2,\n              }}\n            />\n          )}\n\n          {/* Screen content */}\n          {slidesMode ? (\n            <div\n              style={{\n                position: \"absolute\",\n                top: chromeHeight,\n                left: 0,\n                width: deviceWidth * screens.length,\n                height: contentHeight,\n                display: \"flex\",\n                transform: `translateX(${slideOffset}px)`,\n              }}\n            >\n              {screens.map((url, i) => (\n                <div\n                  key={i}\n                  style={{\n                    width: deviceWidth,\n                    height: contentHeight,\n                    flexShrink: 0,\n                  }}\n                >\n                  <ProductSurface\n                    mediaUrl={url}\n                    progress={progress}\n                    width={deviceWidth}\n                    height={contentHeight}\n                    fit={screenFit}\n                    focusX={screenFocusX}\n                    focusY={screenFocusY}\n                    motion=\"still\"\n                    accent={accent}\n                    font={font}\n                    placeholder={placeholder}\n                  />\n                </div>\n              ))}\n            </div>\n          ) : (\n            <div style={{ position: \"absolute\", top: chromeHeight, left: 0, width: deviceWidth, height: contentHeight }}>\n              <ProductSurface\n                mediaUrl={screenMediaUrl}\n                progress={progress}\n                width={deviceWidth}\n                height={contentHeight}\n                fit={screenFit}\n                focusX={screenFocusX}\n                focusY={screenFocusY}\n                motion={screenMotion}\n                calloutText={screenCalloutText}\n                calloutX={screenCalloutX}\n                calloutY={screenCalloutY}\n                accent={accent}\n                font={font}\n                placeholder={placeholder}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n"
    },
    {
      "path": "src/lib/primitives/devices/ProductSurface.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/devices/ProductSurface.tsx",
      "content": "/**\n * ProductSurface — shared screenshot treatment for product mockups.\n *\n * Device primitives own their chrome and entrance. ProductSurface owns what\n * happens inside the screen: crop/focus, subtle camera motion, and an optional\n * feature callout. This keeps phone and web mockups visually consistent while\n * leaving every user-facing choice editable through a spreadable schema.\n */\n\nimport type { ReactNode } from \"react\";\nimport { interpolate, spring, SPRING_SMOOTH } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\nimport {\n  resolveProductSurfaceMotion,\n  type ProductSurfaceFit,\n  type ProductSurfaceMotion,\n} from \"./product-surface-config\";\n\nexport interface ProductSurfaceProps {\n  mediaUrl?: string;\n  progress: number;\n  width: number;\n  height: number;\n  fit?: ProductSurfaceFit;\n  focusX?: number;\n  focusY?: number;\n  motion?: ProductSurfaceMotion;\n  calloutText?: string;\n  calloutX?: number;\n  calloutY?: number;\n  accent?: string;\n  font?: string;\n  placeholder?: ReactNode;\n}\n\nfunction clampPercent(value: number, fallback: number): number {\n  if (!Number.isFinite(value)) return fallback;\n  return Math.min(100, Math.max(0, value));\n}\n\nexport const ProductSurface: React.FC<ProductSurfaceProps> = ({\n  mediaUrl = \"\",\n  progress,\n  width,\n  height,\n  fit = \"cover\",\n  focusX = 50,\n  focusY = 50,\n  motion = \"pushIn\",\n  calloutText = \"\",\n  calloutX = 70,\n  calloutY = 35,\n  accent = TOKEN_DEFAULTS.accent,\n  font = TOKEN_DEFAULTS.font,\n  placeholder,\n}) => {\n  const resolvedFit: ProductSurfaceFit = fit === \"contain\" ? \"contain\" : \"cover\";\n  const resolvedMotion = resolveProductSurfaceMotion(motion);\n  const resolvedFocusX = clampPercent(Number(focusX), 50);\n  const resolvedFocusY = clampPercent(Number(focusY), 50);\n  const resolvedCalloutX = clampPercent(Number(calloutX), 70);\n  const resolvedCalloutY = clampPercent(Number(calloutY), 35);\n  const cleanCallout = stripPipe(String(calloutText || \"\")).trim();\n  const s = Math.min(width, height) / 1080;\n\n  const cameraProgress = interpolate(progress, [0.12, 0.94], [0, 1], {\n    extrapolateLeft: \"clamp\",\n    extrapolateRight: \"clamp\",\n  });\n  const cameraTransform = resolvedMotion === \"pushIn\"\n    ? `scale(${1 + cameraProgress * 0.06})`\n    : resolvedMotion === \"pan\"\n      ? `translateX(${2.5 - cameraProgress * 5}%) scale(1.08)`\n      : \"none\";\n\n  const calloutProgress = spring(\n    interpolate(progress, [0.42, 0.62], [0, 1], {\n      extrapolateLeft: \"clamp\",\n      extrapolateRight: \"clamp\",\n    }),\n    SPRING_SMOOTH,\n  );\n  const calloutPointsLeft = resolvedCalloutX > 58;\n  const calloutFontSize = Math.max(18, 36 * s);\n  const dotSize = Math.max(11, 22 * s);\n  const labelOffset = Math.max(18, 34 * s);\n\n  return (\n    <div\n      data-product-surface=\"true\"\n      data-camera-motion={resolvedMotion}\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        overflow: \"hidden\",\n        backgroundColor: \"#0b1020\",\n      }}\n    >\n      {mediaUrl ? (\n        <img\n          src={mediaUrl}\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            width: \"100%\",\n            height: \"100%\",\n            display: \"block\",\n            objectFit: resolvedFit,\n            objectPosition: `${resolvedFocusX}% ${resolvedFocusY}%`,\n            transform: cameraTransform,\n            transformOrigin: `${resolvedFocusX}% ${resolvedFocusY}%`,\n          }}\n        />\n      ) : placeholder ?? null}\n\n      {mediaUrl && cleanCallout ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: `${resolvedCalloutX}%`,\n            top: `${resolvedCalloutY}%`,\n            opacity: calloutProgress,\n            transform: `translate(-50%, -50%) scale(${0.82 + calloutProgress * 0.18})`,\n            transformOrigin: \"center\",\n            pointerEvents: \"none\",\n            zIndex: 2,\n          }}\n        >\n          <div\n            style={{\n              width: dotSize,\n              height: dotSize,\n              borderRadius: \"50%\",\n              backgroundColor: accent,\n              border: `${Math.max(2, 4 * s)}px solid #ffffff`,\n              boxShadow: `0 0 0 ${Math.max(3, 8 * s)}px ${accent}55, 0 ${Math.max(4, 10 * s)}px ${Math.max(10, 26 * s)}px rgba(0,0,0,0.32)`,\n            }}\n          />\n          <div\n            style={{\n              position: \"absolute\",\n              top: \"50%\",\n              ...(calloutPointsLeft ? { right: labelOffset } : { left: labelOffset }),\n              transform: \"translateY(-50%)\",\n              maxWidth: width * 0.48,\n              padding: `${Math.max(5, 10 * s)}px ${Math.max(8, 16 * s)}px`,\n              borderRadius: Math.max(7, 14 * s),\n              backgroundColor: \"rgba(7,10,18,0.82)\",\n              border: \"1px solid rgba(255,255,255,0.22)\",\n              boxShadow: `0 ${Math.max(5, 12 * s)}px ${Math.max(14, 34 * s)}px rgba(0,0,0,0.28)`,\n              color: \"#ffffff\",\n              fontFamily: font,\n              fontSize: calloutFontSize,\n              fontWeight: 650,\n              lineHeight: 1.05,\n              letterSpacing: \"-0.01em\",\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            {renderWithEmoji(cleanCallout, calloutFontSize)}\n          </div>\n        </div>\n      ) : null}\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/primitives/devices/DesignedScreenFill.tsx",
      "type": "registry:component",
      "target": "vanillasky/primitives/devices/DesignedScreenFill.tsx",
      "content": "/**\n * DesignedScreenFill — brand-tinted \"designed product UI\" used to fill a device\n * screen when no real screenshot is supplied.\n *\n * Two variants:\n *  - WebScreenFill   → SaaS-dashboard look (header bar + sidebar + stat cards + chart)\n *  - PhoneScreenFill → mobile-app look (status bar + header + list cards + tab bar)\n *\n * Render constraints (template rules): inline styles only, NO external images,\n * NO CSS `filter`, deterministic from `progress` (no rAF / transitions). Must\n * render identically across preview / client-SVG export / server Puppeteer.\n */\n\nimport { interpolate } from \"../../motion\";\n\n// ─── Local color helpers (self-contained — no hex deps) ─────────────────────\nfunction parseHex(hex: string): [number, number, number] | null {\n  const m = String(hex).match(/^#([0-9a-f]{3,8})$/i);\n  if (!m) return null;\n  const h = m[1];\n  if (h.length === 3) {\n    return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)];\n  }\n  if (h.length >= 6) {\n    return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];\n  }\n  return null;\n}\n\nfunction rgba(hex: string, a: number): string {\n  const p = parseHex(hex);\n  if (!p) return hex;\n  return `rgba(${p[0]},${p[1]},${p[2]},${a})`;\n}\n\nfunction isDark(hex: string): boolean {\n  const p = parseHex(hex);\n  if (!p) return false;\n  const [r, g, b] = p;\n  return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;\n}\n\nexport interface ScreenFillProps {\n  /** Brand accent (primary). */\n  accent: string;\n  /** Brand secondary (second chart series / chips). */\n  secondary: string;\n  /** Brand background — decides whether the surface reads light or dark. */\n  bg?: string;\n  font: string;\n  /** Per-template scale factor (Math.min(w,h)/1080). */\n  s: number;\n  /** Width of the screen area in px. */\n  w: number;\n  /** Height of the screen area in px. */\n  h: number;\n  /** Scene progress 0→1 (drives a calm chart/progress grow). */\n  progress: number;\n}\n\n// Surface palette derived from whether the brand bg reads dark.\nfunction surface(bg: string | undefined) {\n  const dark = bg ? isDark(bg) : false;\n  return dark\n    ? {\n        dark,\n        page: \"#15171c\",\n        panel: \"#1e2128\",\n        line: \"rgba(255,255,255,0.07)\",\n        block: \"rgba(255,255,255,0.10)\",\n        blockSoft: \"rgba(255,255,255,0.055)\",\n      }\n    : {\n        dark,\n        page: \"#f5f6f8\",\n        panel: \"#ffffff\",\n        line: \"rgba(15,20,30,0.07)\",\n        block: \"rgba(15,20,30,0.10)\",\n        blockSoft: \"rgba(15,20,30,0.05)\",\n      };\n}\n\nconst stroke = (s: number, c: string) => `${Math.max(1, Math.round(1 * s))}px solid ${c}`;\n\n// ─── Web dashboard fill ─────────────────────────────────────────────────────\nexport const WebScreenFill: React.FC<ScreenFillProps> = ({ accent, secondary, bg, font, s, w, h, progress }) => {\n  const c = surface(bg);\n  const pad = Math.round(h * 0.055);\n  const headerH = Math.round(h * 0.13);\n  const sidebarW = Math.round(w * 0.18);\n  const grow = interpolate(progress, [0.1, 0.7], [0, 1], { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" });\n\n  // Fixed bar heights (no Math.random — deterministic).\n  const bars = [0.55, 0.78, 0.4, 0.92, 0.66, 0.84, 0.5];\n  const chartH = h - headerH - pad * 2 - Math.round(h * 0.22) - Math.round(h * 0.04);\n\n  const card = (i: number) => {\n    const tint = [accent, secondary, accent][i % 3];\n    return (\n      <div\n        key={i}\n        style={{\n          flex: 1,\n          height: Math.round(h * 0.22),\n          backgroundColor: c.panel,\n          borderRadius: Math.round(12 * s),\n          border: stroke(s, c.line),\n          padding: Math.round(h * 0.03),\n          display: \"flex\",\n          flexDirection: \"column\",\n          justifyContent: \"space-between\",\n          boxSizing: \"border-box\",\n        }}\n      >\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: Math.round(8 * s) }}>\n          <div style={{ width: Math.round(h * 0.05), height: Math.round(h * 0.05), borderRadius: Math.round(7 * s), backgroundColor: rgba(tint, c.dark ? 0.28 : 0.16) }} />\n          <div style={{ width: \"42%\", height: Math.round(h * 0.018), borderRadius: 99, backgroundColor: c.blockSoft }} />\n        </div>\n        <div style={{ width: \"62%\", height: Math.round(h * 0.05), borderRadius: Math.round(5 * s), backgroundColor: c.block }} />\n        <div style={{ width: \"34%\", height: Math.round(h * 0.016), borderRadius: 99, backgroundColor: rgba(tint, c.dark ? 0.7 : 0.55) }} />\n      </div>\n    );\n  };\n\n  return (\n    <div style={{ width: \"100%\", height: \"100%\", backgroundColor: c.page, fontFamily: font, display: \"flex\", flexDirection: \"column\", boxSizing: \"border-box\", overflow: \"hidden\" }}>\n      {/* Top header bar — brand accent */}\n      <div\n        style={{\n          height: headerH,\n          backgroundColor: accent,\n          display: \"flex\",\n          alignItems: \"center\",\n          paddingLeft: pad,\n          paddingRight: pad,\n          gap: Math.round(10 * s),\n          flexShrink: 0,\n        }}\n      >\n        <div style={{ width: Math.round(h * 0.05), height: Math.round(h * 0.05), borderRadius: Math.round(8 * s), backgroundColor: \"rgba(255,255,255,0.92)\" }} />\n        <div style={{ width: Math.round(w * 0.18), height: Math.round(h * 0.022), borderRadius: 99, backgroundColor: \"rgba(255,255,255,0.85)\" }} />\n        <div style={{ flex: 1 }} />\n        <div style={{ width: Math.round(w * 0.1), height: Math.round(h * 0.04), borderRadius: 99, backgroundColor: \"rgba(255,255,255,0.22)\" }} />\n        <div style={{ width: Math.round(h * 0.06), height: Math.round(h * 0.06), borderRadius: \"50%\", backgroundColor: \"rgba(255,255,255,0.85)\" }} />\n      </div>\n\n      <div style={{ flex: 1, display: \"flex\", minHeight: 0 }}>\n        {/* Sidebar */}\n        <div style={{ width: sidebarW, backgroundColor: c.panel, borderRight: stroke(s, c.line), padding: pad, display: \"flex\", flexDirection: \"column\", gap: Math.round(h * 0.028), boxSizing: \"border-box\", flexShrink: 0 }}>\n          {[0, 1, 2, 3, 4].map((i) => (\n            <div key={i} style={{ display: \"flex\", alignItems: \"center\", gap: Math.round(8 * s) }}>\n              <div style={{ width: Math.round(h * 0.032), height: Math.round(h * 0.032), borderRadius: Math.round(5 * s), backgroundColor: i === 0 ? accent : c.block }} />\n              <div style={{ flex: 1, height: Math.round(h * 0.016), borderRadius: 99, backgroundColor: i === 0 ? rgba(accent, 0.55) : c.blockSoft }} />\n            </div>\n          ))}\n        </div>\n\n        {/* Content */}\n        <div style={{ flex: 1, padding: pad, display: \"flex\", flexDirection: \"column\", gap: pad, minWidth: 0, boxSizing: \"border-box\" }}>\n          <div style={{ display: \"flex\", gap: pad }}>{[0, 1, 2].map(card)}</div>\n\n          {/* Chart panel */}\n          <div style={{ flex: 1, backgroundColor: c.panel, borderRadius: Math.round(12 * s), border: stroke(s, c.line), padding: Math.round(h * 0.03), display: \"flex\", flexDirection: \"column\", boxSizing: \"border-box\", minHeight: 0 }}>\n            <div style={{ width: \"30%\", height: Math.round(h * 0.02), borderRadius: 99, backgroundColor: c.block, marginBottom: Math.round(h * 0.03) }} />\n            <div style={{ flex: 1, display: \"flex\", alignItems: \"flex-end\", gap: Math.round(w * 0.018), minHeight: 0 }}>\n              {bars.map((bh, i) => (\n                <div key={i} style={{ flex: 1, height: Math.max(2, Math.round(chartH * bh * grow)), borderRadius: Math.round(4 * s), backgroundColor: i % 2 === 0 ? accent : rgba(secondary, c.dark ? 0.85 : 0.7) }} />\n              ))}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\n// ─── Phone app fill ──────────────────────────────────────────────────────────\nexport const PhoneScreenFill: React.FC<ScreenFillProps> = ({ accent, secondary, bg, font, s, w, h, progress }) => {\n  const c = surface(bg);\n  const pad = Math.round(w * 0.07);\n  const statusH = Math.round(h * 0.04);\n  const headerH = Math.round(h * 0.14);\n  const grow = interpolate(progress, [0.12, 0.7], [0, 1], { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" });\n  const onAccent = \"rgba(255,255,255,\";\n\n  const listItem = (i: number) => {\n    const tint = [accent, secondary][i % 2];\n    return (\n      <div\n        key={i}\n        style={{\n          backgroundColor: c.panel,\n          borderRadius: Math.round(16 * s),\n          border: stroke(s, c.line),\n          padding: Math.round(w * 0.05),\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: Math.round(w * 0.045),\n          boxSizing: \"border-box\",\n        }}\n      >\n        <div style={{ width: Math.round(w * 0.13), height: Math.round(w * 0.13), borderRadius: Math.round(12 * s), backgroundColor: rgba(tint, c.dark ? 0.3 : 0.18), flexShrink: 0 }} />\n        <div style={{ flex: 1, display: \"flex\", flexDirection: \"column\", gap: Math.round(h * 0.012) }}>\n          <div style={{ width: `${70 - i * 8}%`, height: Math.round(h * 0.018), borderRadius: 99, backgroundColor: c.block }} />\n          <div style={{ width: `${48 - i * 4}%`, height: Math.round(h * 0.014), borderRadius: 99, backgroundColor: c.blockSoft }} />\n        </div>\n        <div style={{ width: Math.round(w * 0.12), height: Math.round(h * 0.026), borderRadius: 99, backgroundColor: rgba(tint, c.dark ? 0.6 : 0.5) }} />\n      </div>\n    );\n  };\n\n  return (\n    <div style={{ width: \"100%\", height: \"100%\", backgroundColor: c.page, fontFamily: font, display: \"flex\", flexDirection: \"column\", boxSizing: \"border-box\", overflow: \"hidden\" }}>\n      {/* Status bar */}\n      <div style={{ height: statusH, display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", paddingLeft: pad, paddingRight: pad, flexShrink: 0 }}>\n        <div style={{ width: Math.round(w * 0.1), height: Math.round(h * 0.012), borderRadius: 99, backgroundColor: c.block }} />\n        <div style={{ display: \"flex\", gap: Math.round(4 * s) }}>\n          <div style={{ width: Math.round(w * 0.04), height: Math.round(h * 0.012), borderRadius: 2, backgroundColor: c.block }} />\n          <div style={{ width: Math.round(w * 0.03), height: Math.round(h * 0.012), borderRadius: 2, backgroundColor: c.block }} />\n        </div>\n      </div>\n\n      {/* Header — brand accent */}\n      <div style={{ height: headerH, backgroundColor: accent, padding: pad, display: \"flex\", flexDirection: \"column\", justifyContent: \"center\", gap: Math.round(h * 0.018), flexShrink: 0 }}>\n        <div style={{ width: \"44%\", height: Math.round(h * 0.02), borderRadius: 99, backgroundColor: `${onAccent}0.5)` }} />\n        <div style={{ width: \"70%\", height: Math.round(h * 0.032), borderRadius: Math.round(5 * s), backgroundColor: `${onAccent}0.92)` }} />\n      </div>\n\n      {/* Highlight / progress card */}\n      <div style={{ padding: pad, paddingBottom: 0 }}>\n        <div style={{ backgroundColor: c.panel, borderRadius: Math.round(16 * s), border: stroke(s, c.line), padding: Math.round(w * 0.05), display: \"flex\", flexDirection: \"column\", gap: Math.round(h * 0.018), boxSizing: \"border-box\" }}>\n          <div style={{ width: \"50%\", height: Math.round(h * 0.016), borderRadius: 99, backgroundColor: c.blockSoft }} />\n          <div style={{ width: \"100%\", height: Math.round(h * 0.012), borderRadius: 99, backgroundColor: c.blockSoft, position: \"relative\", overflow: \"hidden\" }}>\n            <div style={{ position: \"absolute\", inset: 0, width: `${Math.round(grow * 72)}%`, borderRadius: 99, backgroundColor: accent }} />\n          </div>\n        </div>\n      </div>\n\n      {/* List items */}\n      <div style={{ flex: 1, padding: pad, display: \"flex\", flexDirection: \"column\", gap: Math.round(h * 0.018), minHeight: 0 }}>\n        {[0, 1, 2, 3].map(listItem)}\n      </div>\n\n      {/* Bottom tab bar */}\n      <div style={{ height: Math.round(h * 0.075), backgroundColor: c.panel, borderTop: stroke(s, c.line), display: \"flex\", alignItems: \"center\", justifyContent: \"space-around\", flexShrink: 0 }}>\n        {[0, 1, 2, 3].map((i) => (\n          <div key={i} style={{ width: Math.round(w * 0.06), height: Math.round(w * 0.06), borderRadius: Math.round(7 * s), backgroundColor: i === 0 ? accent : c.block }} />\n        ))}\n      </div>\n    </div>\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/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": "device",
      "tier": "free",
      "register": "device-led",
      "jobs": [
        "proof",
        "atmosphere"
      ],
      "useWhen": "Uploaded landscape or square desktop/web screenshots, dashboard views, or browser/tablet product surfaces. Use one scene for up to three same-orientation screens.",
      "textCanvas": "tight",
      "minDuration": 2,
      "preferredDuration": 3,
      "gates": {
        "requiresScreenshot": true
      },
      "variableSchema": {
        "frame": {
          "type": "enum",
          "label": "Device frame",
          "default": "browser",
          "options": [
            "browser",
            "tablet"
          ],
          "description": "browser shows chrome and an address bar; tablet uses a clean product frame."
        },
        "texts": {
          "type": "string",
          "label": "Text",
          "default": "See it in action.",
          "required": true,
          "description": "Title text shown above the product."
        },
        "screenMediaUrl": {
          "type": "media",
          "label": "Screenshot",
          "description": "Product screenshot shown inside the browser or tablet frame."
        },
        "screen1Url": {
          "type": "media",
          "label": "Screen 1",
          "description": "Optional second product screen. Setting it enables slides mode."
        },
        "screen2Url": {
          "type": "media",
          "label": "Screen 2",
          "description": "Optional third product screen for slides mode."
        },
        "screenFit": {
          "type": "enum",
          "label": "Screenshot fit",
          "default": "cover",
          "options": [
            "cover",
            "contain"
          ],
          "description": "Use cover for immersive product detail; contain when the full interface must remain visible."
        },
        "screenFocusX": {
          "type": "number",
          "label": "Horizontal focus (0-100)",
          "default": 50,
          "description": "Horizontal percentage of the screenshot to keep in focus. Values are clamped to 0-100."
        },
        "screenFocusY": {
          "type": "number",
          "label": "Vertical focus (0-100)",
          "default": 50,
          "description": "Vertical percentage of the screenshot to keep in focus. Values are clamped to 0-100."
        },
        "screenMotion": {
          "type": "enum",
          "label": "Screenshot motion",
          "default": "pushIn",
          "options": [
            "still",
            "pushIn",
            "pan"
          ],
          "description": "Single-screen treatment: pushIn is the professional default; use pan for wide interfaces and still when motion would distract. Multi-screen slides stay still so motions do not compete."
        },
        "screenCalloutText": {
          "type": "string",
          "label": "Feature callout",
          "default": "",
          "description": "Optional 2-4 word annotation anchored to a product detail. Leave empty rather than narrating the headline twice."
        },
        "screenCalloutX": {
          "type": "number",
          "label": "Callout horizontal position (0-100)",
          "default": 70,
          "description": "Horizontal percentage of the product surface for the callout anchor."
        },
        "screenCalloutY": {
          "type": "number",
          "label": "Callout vertical position (0-100)",
          "default": 35,
          "description": "Vertical percentage of the product surface for the callout anchor."
        },
        "addressBarUrl": {
          "type": "string",
          "label": "Address bar URL",
          "default": "yourapp.com",
          "description": "URL shown in browser chrome; ignored by the tablet frame."
        },
        "textColor": {
          "type": "color",
          "label": "Text color",
          "default": "",
          "description": "Override text color; leave empty to use the brand-aware default."
        },
        "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"
          ]
        }
      },
      "defaultVariables": {
        "frame": "browser",
        "texts": "See it in action.",
        "screenMediaUrl": "",
        "screen1Url": "",
        "screen2Url": "",
        "screenFit": "cover",
        "screenFocusX": 50,
        "screenFocusY": 50,
        "screenMotion": "pushIn",
        "screenCalloutText": "",
        "screenCalloutX": 70,
        "screenCalloutY": 35,
        "addressBarUrl": "yourapp.com",
        "textColor": "",
        "mediaUrl": "",
        "mediaKeyword": "",
        "mediaType": "auto",
        "mediaPoster": "",
        "mediaPosition": "center",
        "mediaTreatment": "cinematic"
      }
    }
  }
}
