@vanillaskyai/video 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +201 -0
  3. package/PUBLIC-API.md +276 -0
  4. package/README.md +152 -0
  5. package/SECURITY.md +22 -0
  6. package/SUPPORT.md +28 -0
  7. package/bin/vanillasky.js +3 -0
  8. package/dist/bg-confetti-WNQXS7ZS.js +89 -0
  9. package/dist/bg-emoji-ETI47GLS.js +150 -0
  10. package/dist/bg-media-SVUZAMGX.js +99 -0
  11. package/dist/brand-message-F3RD4P3P.js +253 -0
  12. package/dist/builtin-server-F7YXRVCH.js +8 -0
  13. package/dist/catalog-types-BIhSpOWK.d.ts +74 -0
  14. package/dist/chart-bar-CNSC7BNK.js +304 -0
  15. package/dist/chart-counter-MCRHKN77.js +299 -0
  16. package/dist/chart-progress-ring-RMI4A7ZK.js +296 -0
  17. package/dist/check-runtime.d.ts +15 -0
  18. package/dist/check-runtime.js +102 -0
  19. package/dist/chunk-2E6T633S.js +27 -0
  20. package/dist/chunk-352ZZCET.js +83 -0
  21. package/dist/chunk-73NTSFFI.js +81 -0
  22. package/dist/chunk-A2X45FET.js +46 -0
  23. package/dist/chunk-ABPKM4GK.js +187 -0
  24. package/dist/chunk-ABZACD4G.js +216 -0
  25. package/dist/chunk-BCRUUJ6A.js +756 -0
  26. package/dist/chunk-CHLGW22F.js +3551 -0
  27. package/dist/chunk-CJ7F2CE6.js +104 -0
  28. package/dist/chunk-CR7FE7BW.js +109 -0
  29. package/dist/chunk-E7CL7UPB.js +109 -0
  30. package/dist/chunk-EE6PK6XC.js +18 -0
  31. package/dist/chunk-ERY4S6QV.js +385 -0
  32. package/dist/chunk-EVG2ZYIY.js +125 -0
  33. package/dist/chunk-FNAEQ7QO.js +155 -0
  34. package/dist/chunk-FQ47WXAJ.js +38 -0
  35. package/dist/chunk-FSG2PYWG.js +336 -0
  36. package/dist/chunk-GHJEAP4O.js +504 -0
  37. package/dist/chunk-GRMUV7QY.js +262 -0
  38. package/dist/chunk-IFOW65Z6.js +205 -0
  39. package/dist/chunk-IXCUY3B3.js +786 -0
  40. package/dist/chunk-L5BTTCZV.js +97 -0
  41. package/dist/chunk-LMG3GHLL.js +107 -0
  42. package/dist/chunk-OOPIETLW.js +101 -0
  43. package/dist/chunk-P22HHFDK.js +43 -0
  44. package/dist/chunk-QAAO7OAY.js +19 -0
  45. package/dist/chunk-S7HFLYNN.js +35 -0
  46. package/dist/chunk-SPVTJH3F.js +24 -0
  47. package/dist/chunk-W2XJKG6C.js +3177 -0
  48. package/dist/chunk-XB7LH4J2.js +301 -0
  49. package/dist/chunk-XGRA2MUV.js +242 -0
  50. package/dist/chunk-XTOXFMLM.js +105 -0
  51. package/dist/chunk-YC5MFXA5.js +132 -0
  52. package/dist/cli.d.ts +1 -0
  53. package/dist/cli.js +1681 -0
  54. package/dist/compose-video-CN3VSWST.js +13 -0
  55. package/dist/cta-logo-SVA5GP2A.js +209 -0
  56. package/dist/cta-media-E7VLLK42.js +215 -0
  57. package/dist/events-_wfjbmEp.d.ts +81 -0
  58. package/dist/incoming-call-62MFZL3Z.js +320 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.js +14 -0
  61. package/dist/infographic-before-after-AXCDAWND.js +460 -0
  62. package/dist/infographic-feature-list-GW5GUE6O.js +266 -0
  63. package/dist/infographic-problem-solution-7NURJANA.js +340 -0
  64. package/dist/infographic-stat-row-J7IZR6ZN.js +241 -0
  65. package/dist/infographic-steps-VSZM6F5M.js +400 -0
  66. package/dist/kit-BMNcHqa9.d.ts +79 -0
  67. package/dist/prompt-input-LOUCRXCY.js +236 -0
  68. package/dist/react.d.ts +81 -0
  69. package/dist/react.js +835 -0
  70. package/dist/reaction-QG4CYZLQ.js +119 -0
  71. package/dist/server.d.ts +141 -0
  72. package/dist/server.js +498 -0
  73. package/dist/showcase-code-RUTFZR3H.js +351 -0
  74. package/dist/showcase-phone-6CP6XEU5.js +272 -0
  75. package/dist/showcase-terminal-MGAEQWPR.js +306 -0
  76. package/dist/showcase-web-Y4W2HVRJ.js +390 -0
  77. package/dist/social-conversation-75SD2637.js +695 -0
  78. package/dist/social-milestone-XI2QE5BO.js +292 -0
  79. package/dist/social-notification-2UOHQCUY.js +244 -0
  80. package/dist/social-review-stack-FDYZGZTJ.js +239 -0
  81. package/dist/social-testimonial-OQ7N52GJ.js +284 -0
  82. package/dist/social-tweet-5OLCNPPC.js +295 -0
  83. package/dist/state-DZcKuS32.d.ts +3 -0
  84. package/dist/system-prompt-A6UXNTPK.js +8 -0
  85. package/dist/template-catalog.d.ts +24 -0
  86. package/dist/template-catalog.js +6 -0
  87. package/dist/templates.d.ts +25 -0
  88. package/dist/templates.js +19 -0
  89. package/dist/test.d.ts +149 -0
  90. package/dist/test.js +272 -0
  91. package/dist/text-stream-6XHC7J47.js +10 -0
  92. package/dist/types-B-PZR6G_.d.ts +208 -0
  93. package/dist/validate-T7GBU2YF.js +8 -0
  94. package/docs/agent-integration.md +39 -0
  95. package/docs/architecture.md +84 -0
  96. package/docs/branding-and-personalization.md +87 -0
  97. package/docs/concepts.md +106 -0
  98. package/docs/custom-templates.md +329 -0
  99. package/docs/customization.md +141 -0
  100. package/docs/errors.md +70 -0
  101. package/docs/getting-started.md +111 -0
  102. package/docs/input-and-first-scene.md +41 -0
  103. package/docs/integrate-nextjs.md +79 -0
  104. package/docs/media-and-audio.md +69 -0
  105. package/docs/motion-and-effects.md +107 -0
  106. package/docs/persistence.md +95 -0
  107. package/docs/production.md +122 -0
  108. package/docs/prompt-and-input.md +185 -0
  109. package/docs/provider-integration.md +115 -0
  110. package/docs/reference/protocol.md +96 -0
  111. package/docs/reference/provider-adapters.md +158 -0
  112. package/docs/responsive-orientation.md +37 -0
  113. package/docs/security.md +38 -0
  114. package/docs/streaming-protocol.md +15 -0
  115. package/docs/testing.md +137 -0
  116. package/docs/use-cases.md +59 -0
  117. package/examples/custom-template/README.md +19 -0
  118. package/examples/custom-template/minimal-text.tsx +82 -0
  119. package/examples/custom-template/structured-data.tsx +104 -0
  120. package/examples/custom-template/supplied-media.tsx +92 -0
  121. package/examples/nextjs-quickstart/.env.example +2 -0
  122. package/examples/nextjs-quickstart/README.md +22 -0
  123. package/examples/nextjs-quickstart/next-env.d.ts +4 -0
  124. package/examples/nextjs-quickstart/package.json +24 -0
  125. package/examples/nextjs-quickstart/src/app/api/video/route.ts +22 -0
  126. package/examples/nextjs-quickstart/src/app/layout.tsx +5 -0
  127. package/examples/nextjs-quickstart/src/app/page.tsx +31 -0
  128. package/examples/nextjs-quickstart/tsconfig.json +26 -0
  129. package/package.json +167 -0
  130. package/registry/items/backgrounds.json +52 -0
  131. package/registry/items/barChart.json +237 -0
  132. package/registry/items/beforeAfter.json +180 -0
  133. package/registry/items/bigNumber.json +225 -0
  134. package/registry/items/brandMessage.json +158 -0
  135. package/registry/items/cardList.json +206 -0
  136. package/registry/items/chatMessenger.json +151 -0
  137. package/registry/items/chatWhatsapp.json +151 -0
  138. package/registry/items/codeEditor.json +184 -0
  139. package/registry/items/confetti.json +107 -0
  140. package/registry/items/ctaLogo.json +160 -0
  141. package/registry/items/ctaMedia.json +193 -0
  142. package/registry/items/emojiBurst.json +101 -0
  143. package/registry/items/incomingCall.json +170 -0
  144. package/registry/items/media.json +179 -0
  145. package/registry/items/milestone.json +205 -0
  146. package/registry/items/motion.json +45 -0
  147. package/registry/items/notification.json +182 -0
  148. package/registry/items/phoneMockup.json +254 -0
  149. package/registry/items/problemSolution.json +186 -0
  150. package/registry/items/progressRing.json +199 -0
  151. package/registry/items/promptInput.json +148 -0
  152. package/registry/items/reaction.json +170 -0
  153. package/registry/items/reviewStack.json +224 -0
  154. package/registry/items/steps.json +207 -0
  155. package/registry/items/template-context.json +31 -0
  156. package/registry/items/terminal.json +212 -0
  157. package/registry/items/testimonial.json +164 -0
  158. package/registry/items/theme.json +47 -0
  159. package/registry/items/tripleStats.json +228 -0
  160. package/registry/items/tweet.json +203 -0
  161. package/registry/items/typography.json +47 -0
  162. package/registry/items/webMockup.json +273 -0
@@ -0,0 +1,182 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "notification",
4
+ "type": "registry:block",
5
+ "title": "Notification",
6
+ "description": "A compact app notification card appears over a branded background.",
7
+ "dependencies": [
8
+ "react"
9
+ ],
10
+ "registryDependencies": [
11
+ "@vanillasky/backgrounds",
12
+ "@vanillasky/motion",
13
+ "@vanillasky/theme",
14
+ "@vanillasky/typography",
15
+ "@vanillasky/template-context"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "src/visual-system/scene-templates/social-notification.tsx",
20
+ "type": "registry:component",
21
+ "target": "vanillasky/scene-templates/social-notification.tsx",
22
+ "content": "/**\n * social-notification — iOS-style notification card on a media background.\n *\n * Card visuals + motion live in the NotificationCard primitive\n * (src/visual-system/primitives/social/NotificationCard.tsx). This template wires the\n * scene background + variable schema + primitive together.\n */\n\nimport * as React from \"react\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { NotificationCard } from \"../primitives/social/NotificationCard\";\nimport { resolveTokens } from \"./tokens\";\n\nexport const SocialNotificationTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n const { font } = resolveTokens(style);\n const message = String(variables.message || \"\");\n const gradSeed = message.split(\"\").reduce((acc, c) => acc + c.charCodeAt(0), 0);\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] Gradient / media backdrop */}\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={gradSeed}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Notification card — NotificationCard primitive\n (the message is the content; no separate caption slot) */}\n <NotificationCard\n progress={progress}\n motionProgress={motionProgress}\n appName={String(variables.appName || \"Reminder\")}\n appIcon={String(variables.appIcon || \"🔔\")}\n message={message}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n />\n </div>\n );\n};\n"
23
+ },
24
+ {
25
+ "path": "src/visual-system/primitives/social/NotificationCard.tsx",
26
+ "type": "registry:component",
27
+ "target": "vanillasky/primitives/social/NotificationCard.tsx",
28
+ "content": "/**\n * NotificationCard\n *\n * iOS-style glassmorphic notification card. Header (icon + app name + \"now\"),\n * then word-by-word message reveal. Card scales/lifts in via spring.\n *\n * social-notification.tsx is refactored to consume this primitive; both\n * paths render visually identical output.\n */\n\nimport * as React from \"react\";\nimport { interpolate, spring, SPRING_SMOOTH } from \"../../motion\";\nimport { stripPipe } from \"../../typography\";\nimport { Emoji } from \"../../emoji\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\n\nconst CLAMP = { extrapolateLeft: \"clamp\" as const, extrapolateRight: \"clamp\" as const };\n\n// ─── Typed component (direct use from templates) ────────────────\n\nexport interface NotificationCardProps {\n progress: number;\n /** Transition-safe presentation clock; semantic scene time stays on progress. */\n motionProgress?: number;\n appName: string;\n /** Single emoji icon. Default 🔔. */\n appIcon?: string;\n message: string;\n width: number;\n height: number;\n beatIntensity?: number;\n}\n\nexport const NotificationCard: React.FC<NotificationCardProps> = ({\n progress,\n motionProgress = progress,\n appName: rawAppName,\n appIcon: rawAppIcon,\n message: rawMessage,\n width,\n height,\n beatIntensity = 0,\n}) => {\n const dim = Math.min(width, height);\n const appName = stripPipe(rawAppName);\n const appIcon = (rawAppIcon || \"🔔\").trim() || \"🔔\";\n const message = rawMessage;\n const presentationProgress = Math.max(progress, motionProgress);\n\n const cardSpring = spring(interpolate(presentationProgress, [0.05, 0.20], [0, 1], CLAMP), SPRING_SMOOTH);\n const cardOpacity = interpolate(presentationProgress, [0.05, 0.15], [0, 1], CLAMP);\n const cardScale = interpolate(cardSpring, [0, 1], [0.95, 1]);\n const cardLift = (1 - cardSpring) * 12;\n\n const headerOpacity = interpolate(presentationProgress, [0.18, 0.30], [0, 1], CLAMP);\n\n const words = message.split(/\\s+/).filter(Boolean);\n const wordStart = 0.30;\n const wordEnd = 0.70;\n const wordSlice = (wordEnd - wordStart) / Math.max(words.length, 1);\n\n const cardW = Math.min(width * 0.88, dim * 0.9);\n const cardPad = dim * 0.038;\n const cardRadius = dim * 0.025;\n const iconSize = dim * 0.045;\n const iconRadius = iconSize * 0.28;\n const iconFont = iconSize * 0.66;\n const appFontSize = dim * 0.030;\n const timeFontSize = dim * 0.026;\n const msgFontSize = dim * 0.040;\n\n const beatGlow = 1 + beatIntensity * 0.3;\n\n return (\n <div\n data-notification-card=\"true\"\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: (width - cardW) / 2,\n width: cardW,\n transform: `translateY(calc(-50% + ${cardLift}px))`,\n opacity: cardOpacity,\n }}\n >\n <div\n style={{\n backgroundColor: \"rgba(245,242,238,0.92)\",\n borderRadius: cardRadius,\n padding: cardPad,\n boxShadow: `0 ${dim * 0.012}px ${dim * 0.05 * beatGlow}px rgba(0,0,0,0.18)`,\n transform: `scale(${cardScale})`,\n transformOrigin: \"center top\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: dim * 0.018,\n }}\n >\n <div\n data-notification-header=\"true\"\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: dim * 0.014,\n opacity: headerOpacity,\n }}\n >\n <div\n style={{\n width: iconSize,\n height: iconSize,\n borderRadius: iconRadius,\n backgroundColor: \"rgba(0,0,0,0.06)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n lineHeight: 1,\n fontSize: iconFont,\n }}\n >\n <Emoji char={appIcon} size={iconFont} verticalAlign=\"middle\" />\n </div>\n\n <span\n style={{\n fontSize: appFontSize,\n fontWeight: 600,\n color: \"rgba(0,0,0,0.65)\",\n letterSpacing: 0.2,\n }}\n >\n {appName}\n </span>\n\n <span\n style={{\n fontSize: timeFontSize,\n color: \"rgba(0,0,0,0.4)\",\n marginLeft: \"auto\",\n }}\n >\n now\n </span>\n </div>\n\n <div\n style={{\n fontSize: msgFontSize,\n fontWeight: 400,\n color: \"#1c1c1e\",\n lineHeight: 1.28,\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: `0 ${dim * 0.009}px`,\n }}\n >\n {words.map((word, wi) => {\n const wordP = interpolate(\n presentationProgress,\n [wordStart + wi * wordSlice, wordStart + wi * wordSlice + wordSlice * 0.6],\n [0, 1],\n CLAMP,\n );\n return (\n <span\n key={wi}\n style={{\n opacity: wordP,\n transform: `translateY(${(1 - wordP) * 4}px)`,\n display: \"inline-block\",\n }}\n >\n {renderWithEmoji(word, msgFontSize)}\n </span>\n );\n })}\n </div>\n </div>\n </div>\n );\n};\n"
29
+ },
30
+ {
31
+ "path": "src/visual-system/emoji/emoji-text.tsx",
32
+ "type": "registry:component",
33
+ "target": "vanillasky/emoji/emoji-text.tsx",
34
+ "content": "/**\n * EmojiText / renderWithEmoji — split a string into text runs + emoji runs,\n * rendering each emoji with the viewer's native emoji font while leaving\n * everything else as plain 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 span in sequence.\n */\n\nimport * as React from \"react\";\nimport { Emoji, isNativeEmoji } from \"./index\";\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. 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 span. */\n emojiStyle?: React.CSSProperties;\n}\n\n/**\n * Split `text` into an array of React nodes — plain-text strings interleaved\n * with <Emoji> spans for every emoji cluster. Returns a single-element\n * `[text]` fast-path when there are no 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 (!isNativeEmoji(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 if (isNativeEmoji(cluster)) {\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 the full emoji\n * and marks the cluster's CONTINUATION indices as covered (render nothing for\n * them — the start index's span already contains the whole cluster).\n *\n * Returns null when the text contains no 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 || !isNativeEmoji(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 (isNativeEmoji(cluster)) {\n found = true;\n // Store the WHOLE cluster char (not just the start unit) so callers can\n // pass it straight to <Emoji> without splitting a grapheme cluster.\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 with emoji in the native operating-system font. */\n children: string;\n /** Surrounding font size in px — sizes each emoji 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 spans. 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"
35
+ },
36
+ {
37
+ "path": "src/visual-system/emoji/index.tsx",
38
+ "type": "registry:component",
39
+ "target": "vanillasky/emoji/index.tsx",
40
+ "content": "import * as React from \"react\";\n\nconst NATIVE_EMOJI_FONT = \"Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif\";\nconst NATIVE_EMOJI = /\\p{Emoji_Presentation}|\\p{Regional_Indicator}|\\u{FE0F}|\\u{20E3}/u;\n\n/** True for emoji-presentation graphemes, not plain symbols such as ★ or ✓. */\nexport function isNativeEmoji(char: string): boolean {\n return NATIVE_EMOJI.test(char);\n}\n\nexport interface EmojiProps {\n /** The emoji character or grapheme cluster, e.g. \"🎉\" or \"👩‍💻\". */\n char: string;\n /** Font size in px. */\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 native emoji span. */\n style?: React.CSSProperties;\n}\n\n/** Render emoji with the viewer's OS font while preserving text-style symbols. */\nexport const Emoji: React.FC<EmojiProps> = ({ char, size, verticalAlign = \"-0.15em\", style }) => {\n const nativeEmoji = isNativeEmoji(char);\n return (\n <span\n role={nativeEmoji ? \"img\" : undefined}\n aria-label={nativeEmoji ? char : undefined}\n style={{\n ...(nativeEmoji ? { fontFamily: NATIVE_EMOJI_FONT } : {}),\n fontSize: size,\n lineHeight: 1,\n display: \"inline-block\",\n verticalAlign,\n ...style,\n }}\n >\n {char}\n </span>\n );\n};\n"
41
+ },
42
+ {
43
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
44
+ "type": "registry:component",
45
+ "target": "vanillasky/scene-templates/scene-background.tsx",
46
+ "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 { TemplateStyle } from \"../template-context\";\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\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: TemplateStyle;\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"
47
+ },
48
+ {
49
+ "path": "src/visual-system/scene-templates/types.ts",
50
+ "type": "registry:lib",
51
+ "target": "vanillasky/scene-templates/types.ts",
52
+ "content": "/**\n * Scene template types.\n *\n * A template is a reusable React component that defines how a scene looks.\n * It declares the variables an editor or model may provide\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 { SafeZone, TemplateStyle } from \"../template-context\";\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: TemplateStyle;\n /** 0→1 through the scene's duration */\n progress: number;\n /** Presentation clock. Active templates receive the same complete 0→1 timeline as progress. */\n motionProgress?: 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"
53
+ }
54
+ ],
55
+ "meta": {
56
+ "vanillasky": {
57
+ "layer": "template",
58
+ "tier": "free",
59
+ "register": "card-led",
60
+ "jobs": [
61
+ "setup",
62
+ "proof"
63
+ ],
64
+ "useWhen": "A real alert, reminder, approval, completion, update, or ready-state is a product moment.",
65
+ "textCanvas": "tight",
66
+ "minDuration": 2,
67
+ "preferredDuration": 3,
68
+ "schema": {
69
+ "type": "object",
70
+ "properties": {
71
+ "appName": {
72
+ "type": "string",
73
+ "title": "App name",
74
+ "description": "App name shown in the notification header (e.g. Reminder, Lume, Linear, GitHub; 24 characters maximum).",
75
+ "minLength": 1,
76
+ "maxLength": 24,
77
+ "default": "Reminder"
78
+ },
79
+ "appIcon": {
80
+ "type": "string",
81
+ "title": "App icon",
82
+ "description": "Single emoji used as the app icon. Override the default 🔔 to retheme: 📅 reminder, ✅ task done, 💌 message, 🎉 celebration, ⚡ system event.",
83
+ "format": "emoji",
84
+ "maxLength": 16,
85
+ "default": "🔔"
86
+ },
87
+ "message": {
88
+ "type": "string",
89
+ "title": "Message",
90
+ "description": "The notification body. Keep it to a short alert of at most 100 characters; 1-2 lines reads best.",
91
+ "minLength": 1,
92
+ "maxLength": 100,
93
+ "default": "Believe in your dreams, they will lead you."
94
+ },
95
+ "mediaUrl": {
96
+ "type": "string",
97
+ "title": "Background media",
98
+ "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
99
+ "format": "uri",
100
+ "default": ""
101
+ },
102
+ "mediaKeyword": {
103
+ "type": "string",
104
+ "title": "Background search keyword",
105
+ "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).",
106
+ "format": "stock-media-keyword",
107
+ "default": ""
108
+ },
109
+ "mediaType": {
110
+ "type": "string",
111
+ "title": "Background media type",
112
+ "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
113
+ "enum": [
114
+ "auto",
115
+ "photo",
116
+ "video",
117
+ "gradient"
118
+ ],
119
+ "default": "auto"
120
+ },
121
+ "mediaPoster": {
122
+ "type": "string",
123
+ "title": "Background poster image",
124
+ "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.",
125
+ "format": "uri",
126
+ "default": ""
127
+ },
128
+ "mediaPosition": {
129
+ "type": "string",
130
+ "title": "Background focal position",
131
+ "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.",
132
+ "enum": [
133
+ "center",
134
+ "top",
135
+ "bottom",
136
+ "left",
137
+ "right"
138
+ ],
139
+ "default": "center"
140
+ },
141
+ "mediaTreatment": {
142
+ "type": "string",
143
+ "title": "Background contrast treatment",
144
+ "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
145
+ "enum": [
146
+ "subtle",
147
+ "cinematic",
148
+ "text-safe"
149
+ ],
150
+ "default": "cinematic"
151
+ }
152
+ },
153
+ "required": [
154
+ "appName",
155
+ "message"
156
+ ],
157
+ "additionalProperties": false,
158
+ "x-vanillasky": {
159
+ "allowsStockMedia": true
160
+ }
161
+ },
162
+ "label": "Notification",
163
+ "description": "A compact app notification card appears over a branded background.",
164
+ "family": "Social & messaging",
165
+ "avoidWhen": "The message is a broad announcement, generic explanation, or not naturally notification-shaped.",
166
+ "usesGlobalTextEffect": false,
167
+ "usesGlobalTransition": true,
168
+ "usesGlobalBackgroundEffect": true,
169
+ "timing": {
170
+ "contentFields": [
171
+ "appName",
172
+ "message"
173
+ ],
174
+ "contentUnit": "words"
175
+ },
176
+ "transitionTiming": {
177
+ "entryReadyProgress": 0.35,
178
+ "holdProgress": 0.7
179
+ }
180
+ }
181
+ }
182
+ }