@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,205 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "milestone",
4
+ "type": "registry:block",
5
+ "title": "Milestone",
6
+ "description": "A large count-up achievement with an optional celebratory badge.",
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-milestone.tsx",
20
+ "type": "registry:component",
21
+ "target": "vanillasky/scene-templates/social-milestone.tsx",
22
+ "content": "/**\n * social-milestone — follower/subscriber count rolling up to a milestone with celebration.\n *\n * Converted from Remotion FollowerMilestone. Counter rolls up to target number,\n * glow intensifies, then confetti burst + celebration badge pop on hit.\n *\n * Block structure (docs/blocks.md):\n * background — SceneBackground (brand gradient / Pexels media + scrims)\n * hero — MilestoneBadge primitive (rolling number + glow + confetti\n * + celebration pill)\n * caption — the uppercase label inside MilestoneBadge (no TemplateText)\n */\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { SceneBackground, getMediaBackgroundProps } from \"./scene-background\";\nimport { stripPipe } from \"../typography\";\nimport { MilestoneBadge } from \"../primitives/social/MilestoneBadge\";\n\nexport const SocialMilestoneTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n beatIntensity,\n width,\n height,\n sceneDuration,\n isPlaying = true,\n backgroundEffect,\n}) => {\n // Use the canonical semantic accent token.\n const { primary, secondary, foreground, surfaceElevated, font } = resolveTokens(style);\n\n const label = stripPipe(String(variables.label || \"Followers\"));\n const gradSeed = label.split(\"\").reduce((acc: number, c: string) => acc + c.charCodeAt(0), 0);\n const targetNumber = Number(variables.targetNumber) || 10000;\n const rawStart = variables.startNumber != null ? Number(variables.startNumber) : undefined;\n const badgeText = stripPipe(String(variables.badgeText || \"\"));\n const badgeEmoji = String(variables.badgeEmoji || \"🎉\");\n\n // Media-mode legibility: when a Pexels photo/video is behind, MilestoneBadge\n // forces the label to full white and stacks a stronger drop-shadow on\n // number + label so they punch through busy footage. SceneBackground\n // already adds a vignette + bottom scrim. Mirrors bigNumber's treatment.\n const hasMedia = !!String(variables.mediaUrl || \"\").trim() &&\n String(variables.mediaType || \"auto\") !== \"gradient\";\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"#000\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n }}\n >\n {/* [slot: background] Gradient background — supports Pexels media when mediaUrl is set */}\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 {/* [slot: hero] Rolling counter + label + confetti + celebration pill —\n shared primitive (owns the milestone timing windows) */}\n <MilestoneBadge\n progress={progress}\n width={width}\n height={height}\n targetNumber={targetNumber}\n label={label}\n startNumber={rawStart}\n badgeText={badgeText}\n badgeEmoji={badgeEmoji}\n accent={primary}\n hasMedia={hasMedia}\n foreground={foreground}\n surfaceElevated={surfaceElevated}\n beatIntensity={beatIntensity}\n confettiBgColor={secondary}\n />\n </div>\n );\n};\n"
23
+ },
24
+ {
25
+ "path": "src/visual-system/primitives/social/MilestoneBadge.tsx",
26
+ "type": "registry:component",
27
+ "target": "vanillasky/primitives/social/MilestoneBadge.tsx",
28
+ "content": "/**\n * MilestoneBadge — numeric achievement badge with rolling counter, glow,\n * confetti burst, and celebration pill.\n *\n * The\n * template composes this component, so the two can no longer drift.\n * The primitive owns:\n * - the big rolling number with auto-fit font scale\n * - the uppercase label above\n * - the radial glow that ramps up as the count nears target\n * - the confetti burst (via shared ConfettiLayer) that fires on hit\n * - the celebration pill (\"🎉 10K Followers!\") that pops in on hit\n *\n * It does NOT own the SceneBackground gradient/media — that stays in the\n * scene composer so the same badge can render over brand gradient or\n * Pexels footage.\n *\n * Distinct from CountUpNumber: this primitive has badge framing\n * (label + pill + confetti) baked in.\n *\n * Props:\n * - progress — scene progress 0..1\n * - width / height — frame dimensions\n * - targetNumber — milestone to reach\n * - label — uppercase label above the number (e.g. \"Followers\")\n * - prefix / suffix — optional decoration around the number (e.g. \"$\", \"+\", \"K\")\n * - startNumber — counter origin. Defaults to ~98% of target so it\n * always rolls UP visibly.\n * - badgeText — celebration pill text (e.g. \"10K Followers!\")\n * - badgeEmoji — emoji shown in the pill (default 🎉)\n * - accent — brand accent driving the glow color (default \"#00e5a0\")\n * - hasMedia — when true, use the media-safe text treatment\n * (full white label + drop shadows). Defaults to false.\n * - foreground — resolved semantic foreground for gradient mode;\n * media mode uses its deliberate scrim-safe white.\n * - surfaceElevated — opaque semantic surface for the celebration pill.\n * - beatIntensity — optional 0..1 audio reactivity (subtle scale pop)\n * - confettiBgColor — optional confetti hue-filter input (parity with source)\n */\n\nimport * as React from \"react\";\nimport {\n interpolate,\n spring,\n SPRING_SMOOTH,\n SPRING_BOUNCY,\n} from \"../../motion\";\nimport { accessibleTextColor, withOpacity } from \"../../theme\";\nimport { stripPipe } from \"../../typography\";\nimport { Emoji } from \"../../emoji\";\nimport { renderWithEmoji } from \"../../emoji/emoji-text\";\nimport { ConfettiLayer } from \"../../scene-templates/confetti-layer\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\n\nconst CLAMP = {\n extrapolateLeft: \"clamp\" as const,\n extrapolateRight: \"clamp\" as const,\n};\n\nexport interface MilestoneBadgeProps {\n progress: number;\n width: number;\n height: number;\n targetNumber: number;\n label: string;\n prefix?: string;\n suffix?: string;\n startNumber?: number;\n badgeText?: string;\n badgeEmoji?: string;\n badgeColor?: string;\n accent?: string;\n hasMedia?: boolean;\n /** Resolved semantic foreground for text on the main background. */\n foreground?: string;\n /** Resolved semantic elevated surface for the celebration pill. */\n surfaceElevated?: string;\n beatIntensity?: number;\n confettiBgColor?: string;\n}\n\nexport const MilestoneBadge: React.FC<MilestoneBadgeProps> = ({\n progress,\n width,\n height,\n targetNumber,\n label: rawLabel,\n prefix = \"\",\n suffix = \"\",\n startNumber,\n badgeText: rawBadgeText = \"\",\n badgeEmoji = \"🎉\",\n accent = TOKEN_DEFAULTS.primary,\n hasMedia = false,\n foreground = TOKEN_DEFAULTS.foreground,\n surfaceElevated = TOKEN_DEFAULTS.surfaceElevated,\n beatIntensity = 0,\n confettiBgColor,\n}) => {\n const dim = Math.min(width, height);\n const s = dim / 1080;\n\n const label = stripPipe(rawLabel || \"\");\n const badgeText = stripPipe(rawBadgeText || \"\");\n\n const safeTarget = Number.isFinite(targetNumber) ? targetNumber : 0;\n const safeStart =\n startNumber != null && Number.isFinite(startNumber)\n ? startNumber\n : Math.max(0, Math.round(safeTarget * 0.98));\n\n const mainTextColor = hasMedia ? \"#FFFFFF\" : foreground;\n const pillTextColor = accessibleTextColor(surfaceElevated, foreground);\n const mediaTextShadow = hasMedia\n ? \"0 2px 8px rgba(0,0,0,0.55), 0 6px 16px rgba(0,0,0,0.35)\"\n : undefined;\n\n // ── Timing (mirror social-milestone source) ────────────────────\n // Counter rolls from progress 0.056 to 0.556\n const followerCount = Math.round(\n interpolate(progress, [0.056, 0.556], [safeStart, safeTarget], CLAMP),\n );\n const hasHitTarget = followerCount >= safeTarget;\n\n const numberScale = spring(\n interpolate(progress, [0, 0.25], [0, 1], CLAMP),\n SPRING_SMOOTH,\n );\n const glowIntensity = interpolate(progress, [0.33, 0.556], [0, 1], CLAMP);\n const labelOpacity = interpolate(progress, [0, 0.167], [0, 1], CLAMP);\n\n const badgeP = hasHitTarget\n ? spring(\n interpolate(progress, [0.556, 0.85], [0, 1], CLAMP),\n SPRING_BOUNCY,\n )\n : 0;\n const badgeScale = interpolate(badgeP, [0, 0.5, 1], [0, 1.2, 1], CLAMP);\n const badgeOpacity = badgeP;\n\n const confettiActive = hasHitTarget;\n const confettiProgress = confettiActive\n ? Math.max(0, (progress - 0.556) / 0.444)\n : 0;\n\n const beatScale = 1 + beatIntensity * 0.02;\n\n const numberDisplay = `${prefix}${followerCount.toLocaleString()}${suffix}`;\n // Auto-scale thresholds match social-milestone (the tuned source of truth).\n const fontSize =\n numberDisplay.length > 9\n ? dim * 0.09\n : numberDisplay.length > 6\n ? dim * 0.12\n : dim * 0.16;\n\n return (\n <div\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n }}\n >\n {/* Confetti — shared 200-particle layer, fires on milestone hit */}\n {confettiActive && (\n <ConfettiLayer\n progress={confettiProgress}\n width={width}\n height={height}\n beatIntensity={beatIntensity}\n bgColor={hasMedia ? undefined : confettiBgColor || accent}\n />\n )}\n\n <div\n style={{\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n gap: dim * 0.015,\n transform: `scale(${numberScale * beatScale})`,\n position: \"relative\",\n zIndex: 1,\n width: \"100%\",\n }}\n >\n {/* Label */}\n {label && (\n <div\n style={{\n color: mainTextColor,\n fontSize: dim * 0.032,\n fontWeight: 500,\n textTransform: \"uppercase\",\n letterSpacing: dim * 0.004,\n opacity: labelOpacity,\n textAlign: \"center\",\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {label}\n </div>\n )}\n\n {/* Big number */}\n <div\n style={{\n color: mainTextColor,\n fontSize,\n fontWeight: 800,\n letterSpacing: dim * -0.004,\n lineHeight: 1,\n position: \"relative\",\n maxWidth: \"90%\",\n textAlign: \"center\",\n ...(mediaTextShadow ? { textShadow: mediaTextShadow } : {}),\n }}\n >\n {/* Glow behind number */}\n <div\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n width: dim * 0.5,\n height: dim * 0.2,\n borderRadius: \"50%\",\n background: `radial-gradient(ellipse, ${withOpacity(accent, glowIntensity * 0.2)} 0%, ${withOpacity(accent, glowIntensity * 0.05)} 50%, transparent 70%)`,\n pointerEvents: \"none\",\n }}\n />\n <span style={{ position: \"relative\" }}>{numberDisplay}</span>\n </div>\n\n {/* Celebration badge */}\n {badgeText && (\n <div\n style={{\n position: \"absolute\",\n top: \"100%\",\n left: \"50%\",\n transform: `translateX(-50%) scale(${badgeScale})`,\n opacity: badgeOpacity,\n backgroundColor: surfaceElevated,\n border: \"1px solid rgba(255,255,255,0.15)\",\n borderRadius: 100 * s,\n padding: `${dim * 0.015}px ${dim * 0.037}px`,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: dim * 0.011,\n whiteSpace: \"nowrap\",\n marginTop: dim * 0.02,\n }}\n >\n <Emoji char={badgeEmoji} size={dim * 0.045} verticalAlign=\"middle\" />\n <span\n style={{\n color: pillTextColor,\n fontSize: dim * 0.036,\n fontWeight: 700,\n letterSpacing: dim * 0.0005,\n }}\n >\n {renderWithEmoji(badgeText, dim * 0.036)}\n </span>\n </div>\n )}\n </div>\n </div>\n );\n};\n"
29
+ },
30
+ {
31
+ "path": "src/visual-system/scene-templates/confetti-layer.tsx",
32
+ "type": "registry:component",
33
+ "target": "vanillasky/scene-templates/confetti-layer.tsx",
34
+ "content": "/**\n * confetti-layer — reusable confetti burst layer.\n *\n * Extracted from bg-confetti.tsx so any host template can compose the same\n * physics on top of its own backdrop. Used by:\n * - bg-confetti.tsx (the standalone celebration template — gradient bg)\n * - bg-media.tsx (conditional via the `confetti` boolean — confetti\n * layered over photo / video / gradient)\n *\n * Physics: 200 particles, even angular fan-out (360°) from center.\n * Particles move RADIALLY outward — both x and y follow the burst angle so\n * each particle exits the frame in its launch direction (top, sides, bottom\n * corners). A very gentle gravity tugs them slightly downward over time so\n * the motion still reads as natural, but it's never strong enough to reverse\n * an upward trajectory before the particle exits the frame. 3D tumble\n * approximated via scaleX flip. Progress-driven — deterministic for export.\n */\n\nimport React from \"react\";\nimport { getCelebrationParticleTiming } from \"./celebration-particle-timing\";\n\nconst PARTICLE_COUNT = 200;\n\n// Confetti palette spans the full hue wheel; filtered at runtime to drop any\n// color too close in hue to the background (and would disappear).\nconst COLORS = [\"#ef4444\", \"#ff6b6b\", \"#f97316\", \"#fb923c\", \"#ffd93d\", \"#34d399\", \"#6bcbff\", \"#818cf8\", \"#a78bfa\", \"#f472b6\", \"#ffffff\"];\n\nfunction seededRandom(seed: number): number {\n const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;\n return x - Math.floor(x);\n}\n\n/** Outward sweep — softer ease-out quad. Less front-loaded than cubic so\n * the burst reads as a graceful arc rather than an explosion-and-stop. */\nfunction easeOutQuad(p: number): number {\n return 1 - (1 - p) * (1 - p);\n}\n\nfunction hexHue(hex: string): number {\n const r = parseInt(hex.slice(1, 3), 16) / 255;\n const g = parseInt(hex.slice(3, 5), 16) / 255;\n const b = parseInt(hex.slice(5, 7), 16) / 255;\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n const d = max - min;\n if (d === 0) return 0;\n let h = 0;\n if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n else if (max === g) h = ((b - r) / d + 2) / 6;\n else h = ((r - g) / d + 4) / 6;\n return h * 360;\n}\n\nfunction hueDistance(a: number, b: number): number {\n const d = Math.abs(a - b) % 360;\n return d > 180 ? 360 - d : d;\n}\n\nexport interface ConfettiLayerProps {\n progress: number;\n width: number;\n height: number;\n beatIntensity?: number;\n /**\n * Optional background tone for hue-filtering. When provided, palette\n * colors within 35° of this hue are dropped so confetti doesn't blend\n * into a same-hue gradient. Pass the bg's dominant color (typically\n * secondary or accent). Omit for media-bg cases (photos/videos have no\n * single dominant color); the full palette will be used.\n */\n bgColor?: string;\n}\n\nexport const ConfettiLayer: React.FC<ConfettiLayerProps> = ({\n progress,\n width,\n height,\n beatIntensity = 0,\n bgColor,\n}) => {\n const s = Math.min(width, height) / 1080;\n /* Radial burst — each particle flies out in its launch direction until\n * it leaves the frame. Reach is sized off the LONG edge so even\n * particles aimed along the long axis (top/bottom in portrait, sides\n * in landscape) clear the frame edge by progress=1. Half-diagonal of\n * a 9:16 frame ≈ 0.61 * longEdge, so 0.95 gives ~35% headroom past\n * the corner — particles exit cleanly with a few hundred px of trail. */\n const BURST_RADIUS = Math.max(width, height) * 0.95;\n\n // Filter palette only if a bgColor is provided (gradient context). Over\n // photos/videos we keep the full palette since there's no single bg hue.\n let allColors = COLORS;\n if (bgColor) {\n const bgHue = hexHue(bgColor);\n const filtered = COLORS.filter(c => c === \"#ffffff\" || hueDistance(hexHue(c), bgHue) > 35);\n if (filtered.length >= 4) allColors = filtered;\n }\n\n return (\n <>\n {Array.from({ length: PARTICLE_COUNT }, (_, i) => {\n const seed = i + 1;\n\n // Even angular distribution — single biggest \"feel\" win vs random.\n const angle = (i / PARTICLE_COUNT) * Math.PI * 2;\n\n // Per-particle variance\n const forceFactor = 0.75 + seededRandom(seed * 1) * 0.5; // 0.75..1.25\n const durationFactor = 1.1 + seededRandom(seed * 3) * 0.6; // 1.1..1.7 — slow arc\n const color = allColors[i % allColors.length];\n const isCircle = seededRandom(seed * 4) > 0.85;\n const startRot = seededRandom(seed * 5) * 360;\n const rotSpeed = (seededRandom(seed * 6) - 0.5) * 1440;\n const flipSpeed = 3 + seededRandom(seed * 7) * 4;\n const flipPhase = seededRandom(seed * 8) * Math.PI * 2;\n const widthFactor = 0.45 + seededRandom(seed * 9) * 0.6;\n const heightFactor = 0.7 + seededRandom(seed * 10) * 0.6;\n\n const timing = getCelebrationParticleTiming(progress, i, durationFactor);\n if (!timing) return null;\n const p = timing.progress;\n\n // Radial burst — particles fly outward in their launch direction.\n // Outward distance eases out so they decelerate slightly near the\n // frame edge but don't reverse. A small quadratic gravity term tugs\n // them downward over time without overriding upward velocity before\n // they exit the frame.\n const reach = BURST_RADIUS * forceFactor;\n const radialP = easeOutQuad(p);\n const gravity = height * 0.08 * p * p;\n const x = width * 0.5 + Math.cos(angle) * reach * radialP;\n const y = height * 0.5 + Math.sin(angle) * reach * radialP + gravity;\n\n const flip = Math.cos(p * Math.PI * flipSpeed + flipPhase);\n const rot = startRot + rotSpeed * p;\n\n const baseSize = (8 + seededRandom(seed * 11) * 10) * s;\n const w = baseSize * widthFactor;\n const h = isCircle ? w : baseSize * heightFactor;\n\n const popScale = Math.min(1, p * 30);\n const particleOpacity = p < 0.85 ? 1 : Math.max(0, 1 - (p - 0.85) / 0.15);\n const opacity = particleOpacity * timing.opacity;\n const beatPulse = 1 + beatIntensity * 0.15;\n\n if (opacity <= 0) return null;\n if (y > height + 120 || y < -120 || x < -120 || x > width + 120) return null;\n\n return (\n <div\n key={i}\n style={{\n position: \"absolute\",\n left: x - w / 2,\n top: y - h / 2,\n width: w,\n height: h,\n borderRadius: isCircle ? \"50%\" : 1,\n backgroundColor: color,\n opacity,\n transform: `rotate(${rot}deg) scaleX(${flip}) scale(${popScale * beatPulse})`,\n pointerEvents: \"none\",\n }}\n />\n );\n })}\n </>\n );\n};\n"
35
+ },
36
+ {
37
+ "path": "src/visual-system/scene-templates/celebration-particle-timing.ts",
38
+ "type": "registry:lib",
39
+ "target": "vanillasky/scene-templates/celebration-particle-timing.ts",
40
+ "content": "const SECOND_WAVE_INTERVAL = 4;\nconst SECOND_WAVE_START = 0.33;\nconst SECOND_WAVE_DURATION = 1;\nconst EXIT_FADE_START = 0.76;\nconst EXIT_FADE_END = 0.85;\n\nexport interface CelebrationParticleTiming {\n progress: number;\n opacity: number;\n}\n\n/**\n * Keeps most particles on the opening burst while delaying a restrained\n * quarter-sized cohort into the back half of the scene. The final fade is\n * scene-relative so particles always clear before the copy exits, regardless\n * of their individual physics duration.\n */\nexport function getCelebrationParticleTiming(\n sceneProgress: number,\n particleIndex: number,\n durationFactor: number,\n): CelebrationParticleTiming | null {\n if (sceneProgress >= EXIT_FADE_END) return null;\n\n const isSecondWave = particleIndex % SECOND_WAVE_INTERVAL === 0;\n if (isSecondWave && sceneProgress <= SECOND_WAVE_START) return null;\n\n const waveProgress = isSecondWave\n ? (sceneProgress - SECOND_WAVE_START) / SECOND_WAVE_DURATION\n : sceneProgress;\n const progress = Math.min(1, Math.max(0, waveProgress / durationFactor));\n const opacity = sceneProgress <= EXIT_FADE_START\n ? 1\n : (EXIT_FADE_END - sceneProgress) / (EXIT_FADE_END - EXIT_FADE_START);\n\n return { progress, opacity };\n}\n"
41
+ },
42
+ {
43
+ "path": "src/visual-system/emoji/emoji-text.tsx",
44
+ "type": "registry:component",
45
+ "target": "vanillasky/emoji/emoji-text.tsx",
46
+ "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"
47
+ },
48
+ {
49
+ "path": "src/visual-system/emoji/index.tsx",
50
+ "type": "registry:component",
51
+ "target": "vanillasky/emoji/index.tsx",
52
+ "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"
53
+ },
54
+ {
55
+ "path": "src/visual-system/scene-templates/scene-background.tsx",
56
+ "type": "registry:component",
57
+ "target": "vanillasky/scene-templates/scene-background.tsx",
58
+ "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"
59
+ },
60
+ {
61
+ "path": "src/visual-system/scene-templates/types.ts",
62
+ "type": "registry:lib",
63
+ "target": "vanillasky/scene-templates/types.ts",
64
+ "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"
65
+ }
66
+ ],
67
+ "meta": {
68
+ "vanillasky": {
69
+ "layer": "template",
70
+ "tier": "free",
71
+ "register": "typography-led",
72
+ "jobs": [
73
+ "proof",
74
+ "payoff"
75
+ ],
76
+ "useWhen": "An exact grounded achievement count marks users, revenue, years, installs, customers, or adoption.",
77
+ "textCanvas": "open",
78
+ "minDuration": 2.5,
79
+ "preferredDuration": 4,
80
+ "schema": {
81
+ "type": "object",
82
+ "properties": {
83
+ "label": {
84
+ "type": "string",
85
+ "title": "Label",
86
+ "description": "Short label above the number (e.g. Followers, Subscribers, Downloads; 32 characters maximum).",
87
+ "minLength": 1,
88
+ "maxLength": 32,
89
+ "default": "Followers"
90
+ },
91
+ "targetNumber": {
92
+ "type": "number",
93
+ "title": "Target number",
94
+ "description": "The milestone number to reach",
95
+ "default": 10000
96
+ },
97
+ "startNumber": {
98
+ "type": "number",
99
+ "title": "Start number",
100
+ "description": "Number the counter starts rolling from. Defaults to 0.",
101
+ "default": 0
102
+ },
103
+ "badgeText": {
104
+ "type": "string",
105
+ "title": "Badge text",
106
+ "description": "Concise celebration badge text that pops in at the milestone (32 characters maximum).",
107
+ "maxLength": 32,
108
+ "examples": [
109
+ "10K Followers!"
110
+ ],
111
+ "default": ""
112
+ },
113
+ "badgeEmoji": {
114
+ "type": "string",
115
+ "title": "Badge emoji",
116
+ "description": "Emoji shown in the celebration badge",
117
+ "format": "emoji",
118
+ "maxLength": 16,
119
+ "default": "🎉"
120
+ },
121
+ "mediaUrl": {
122
+ "type": "string",
123
+ "title": "Background media",
124
+ "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
125
+ "format": "uri",
126
+ "default": ""
127
+ },
128
+ "mediaKeyword": {
129
+ "type": "string",
130
+ "title": "Background search keyword",
131
+ "description": "2-4 word English term for Pexels stock-footage search (auto-fills mediaUrl).",
132
+ "format": "stock-media-keyword",
133
+ "default": ""
134
+ },
135
+ "mediaType": {
136
+ "type": "string",
137
+ "title": "Background media type",
138
+ "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
139
+ "enum": [
140
+ "auto",
141
+ "photo",
142
+ "video",
143
+ "gradient"
144
+ ],
145
+ "default": "auto"
146
+ },
147
+ "mediaPoster": {
148
+ "type": "string",
149
+ "title": "Background poster image",
150
+ "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.",
151
+ "format": "uri",
152
+ "default": ""
153
+ },
154
+ "mediaPosition": {
155
+ "type": "string",
156
+ "title": "Background focal position",
157
+ "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.",
158
+ "enum": [
159
+ "center",
160
+ "top",
161
+ "bottom",
162
+ "left",
163
+ "right"
164
+ ],
165
+ "default": "center"
166
+ },
167
+ "mediaTreatment": {
168
+ "type": "string",
169
+ "title": "Background contrast treatment",
170
+ "description": "subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
171
+ "enum": [
172
+ "subtle",
173
+ "cinematic",
174
+ "text-safe"
175
+ ],
176
+ "default": "cinematic"
177
+ }
178
+ },
179
+ "required": [
180
+ "label",
181
+ "targetNumber"
182
+ ],
183
+ "additionalProperties": false,
184
+ "x-vanillasky": {
185
+ "requiresStat": true,
186
+ "allowsStockMedia": true
187
+ }
188
+ },
189
+ "label": "Milestone",
190
+ "description": "A large count-up achievement with an optional celebratory badge.",
191
+ "family": "Data & metrics",
192
+ "avoidWhen": "The value is a release number, price, unbounded percentage, or not an achievement count.",
193
+ "usesGlobalTextEffect": false,
194
+ "usesGlobalTransition": false,
195
+ "usesGlobalBackgroundEffect": false,
196
+ "timing": {
197
+ "contentFields": [
198
+ "label",
199
+ "badgeText"
200
+ ],
201
+ "contentUnit": "words"
202
+ }
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "motion",
4
+ "type": "registry:lib",
5
+ "title": "Motion",
6
+ "description": "One deterministic progress-driven API, from raw curves to composed effects.",
7
+ "dependencies": [
8
+ "react"
9
+ ],
10
+ "files": [
11
+ {
12
+ "path": "src/visual-system/motion/index.ts",
13
+ "type": "registry:lib",
14
+ "target": "vanillasky/motion/index.ts",
15
+ "content": "/**\n * Unified motion surface — ONE import for both motion vocabularies:\n *\n * - Core curves (src/visual-system/motion/curves.ts): `interpolate`,\n * `spring`, `SPRING_SMOOTH/SNAPPY/BOUNCY/CRISP`, `Easing`, `stagger`,\n * `cubicBezier`. Used by every built-in template.\n * - Motion stdlib (src/visual-system/motion/effects.ts): the export-verified\n * high-level helpers — `phase`, `staggerWindow`, `cascade`, `typewriter`,\n * `countUp`, `punch`, `glow`, `meshGradient`, `particles`, `burst`,\n * `sweep`, `drift`, `orbit`, `EASE`, and friends.\n *\n * This is the stable entrypoint for templates, custom scenes, and the public\n * registry. Keep implementation files behind this facade.\n */\n\nexport * from \"./curves\";\nexport * from \"./effects\";\n"
16
+ },
17
+ {
18
+ "path": "src/visual-system/motion/effects.ts",
19
+ "type": "registry:lib",
20
+ "target": "vanillasky/motion/effects.ts",
21
+ "content": "/**\n * High-level, export-safe motion effects for project-owned scene templates.\n * available to project-owned scene templates through the motion entry point.\n *\n * Why this exists: handing a code-writing model raw CSS makes it reinvent\n * basics, badly. These helpers encode the craft moves of Remotion-grade\n * pieces (springy staggers, eased counters, layered glows, seeded particle\n * fields, path draws) as single calls the model composes.\n *\n * Hard rules — every helper in this file:\n * - is PURE and DETERMINISTIC: same args → same output, always.\n * - touches NO DOM, NO globals, NO Date/Math.random/timers.\n * - is progress-driven: animation state comes in as `progress` (0..1).\n * - emits only export-safe CSS: no `filter`, no transitions, no animations.\n * (The export pipeline rasterizes SVG-as-image — see CLAUDE.md.)\n *\n * Naming is deliberately collision-resistant: multi-word identifiers a model\n * won't reach for as a loop variable (round-2 eval found `s`/`dim` shadowed\n * by `.map((s, si) => …)` params, producing NaN geometry).\n *\n * Keep every helper deterministic and compatible with the preview and export\n * paths.\n */\n\nimport type { CSSProperties } from \"react\";\nimport { cubicBezier } from \"./curves\";\n\nexport type EasingFn = (t: number) => number;\n\n/**\n * Re-export of the canonical cubic-bezier solver (also `Easing.bezier`).\n * cubicBezier(0.16, 1, 0.3, 1) === EASE.crispEnter\n */\nexport { cubicBezier };\n\n// ─── internals ───────────────────────────────────────────────────\n\nfunction clamp01(t: number): number {\n return t < 0 ? 0 : t > 1 ? 1 : t;\n}\n\n/** Hex → rgba with alpha. Non-hex colors pass through unchanged. */\nfunction alpha(color: string, a: number): string {\n const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(color);\n if (!m) return color;\n const h = m[1];\n const full = h.length === 3 ? h.split(\"\").map((c) => c + c).join(\"\") : h;\n const r = parseInt(full.slice(0, 2), 16);\n const g = parseInt(full.slice(2, 4), 16);\n const b = parseInt(full.slice(4, 6), 16);\n return `rgba(${r},${g},${b},${Math.round(clamp01(a) * 1000) / 1000})`;\n}\n\n// ─── timing / physics ────────────────────────────────────────────\n\n/**\n * Named easing presets. Enter on the ease-OUT family, exit on ease-IN.\n * crispEnter / editorial / pop are the canonical VanillaSky curves from\n * docs/motion-library.md §1.\n */\nexport const EASE: Record<string, EasingFn> = {\n linear: (t) => t,\n outExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),\n outQuint: (t) => 1 - Math.pow(1 - t, 5),\n outCubic: (t) => 1 - Math.pow(1 - t, 3),\n /** Decelerating overshoot — lands from beyond 1. Great for card arrivals. */\n outBack: (t) => {\n if (t <= 0) return 0;\n if (t >= 1) return 1;\n const c1 = 1.70158;\n const c3 = c1 + 1;\n return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\n },\n inQuad: (t) => t * t,\n inCubic: (t) => t * t * t,\n inOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),\n /** Pulls back below 0 before launching. Use for wind-up exits/entrances. */\n anticipate: (t) => {\n if (t <= 0) return 0;\n if (t >= 1) return 1;\n const c = 2.0;\n return t * t * ((c + 1) * t - c);\n },\n /** CRISP_ENTER — UI slide-ins, tight purposeful reveals. */\n crispEnter: cubicBezier(0.16, 1, 0.3, 1),\n /** EDITORIAL — calm holds, photo reveals, chart draws. */\n editorial: cubicBezier(0.45, 0, 0.55, 1),\n /** POP — stat pops, single-element arrivals with small overshoot. */\n pop: cubicBezier(0.34, 1.56, 0.64, 1),\n};\n\n/**\n * Remap scene progress to a local 0..1 inside a [start, end] window,\n * clamped, optionally eased. The backbone of multi-beat scenes.\n *\n * const t = phase(progress, [0.2, 0.6], EASE.crispEnter);\n */\nexport function phase(\n progress: number,\n window: readonly [number, number],\n easing?: EasingFn,\n): number {\n const [start, end] = window;\n if (end <= start) return progress >= end ? 1 : 0;\n const t = clamp01((progress - start) / (end - start));\n return easing ? easing(t) : t;\n}\n\nexport interface StaggerWindowOptions {\n /** 0 = strictly sequential, 1 = all items animate together. Default 0.6. */\n overlap?: number;\n /** Animation order: \"start\" (default), \"end\" (reverse), \"center\" (outward), \"random\" (seeded shuffle). */\n from?: \"start\" | \"end\" | \"center\" | \"random\";\n /** Seed for from: \"random\" — same seed → same order every frame. Default \"stagger\". */\n seed?: string | number;\n easing?: EasingFn;\n}\n\n/**\n * Per-item progress for staggered groups with OVERLAPPING windows — unlike\n * `stagger()`, which is delay-based. Item 0 starts at progress 0; the last\n * item ends exactly at 1, so the stagger always fills the scene.\n *\n * items.map((item, i) => {\n * const t = staggerWindow(progress, i, items.length, { overlap: 0.6, easing: EASE.pop });\n * return <div style={{ opacity: Math.min(1, t), transform: `translateY(${(1 - t) * 40}px)` }}>…</div>;\n * })\n */\nexport function staggerWindow(\n progress: number,\n index: number,\n count: number,\n options?: StaggerWindowOptions,\n): number {\n const { overlap = 0.6, from = \"start\", seed = \"stagger\", easing } = options ?? {};\n const n = Math.max(1, Math.floor(count));\n const ov = clamp01(overlap);\n let order = Math.min(Math.max(index, 0), n - 1);\n if (from === \"end\") order = n - 1 - order;\n else if (from === \"center\") order = Math.abs(order - (n - 1) / 2) * 2;\n else if (from === \"random\") order = rand01(seed, order) * (n - 1);\n const widthFrac = 1 / (1 + (n - 1) * (1 - ov));\n const step = n > 1 ? (1 - widthFrac) / (n - 1) : 0;\n const t = clamp01((progress - order * step) / widthFrac);\n return easing ? easing(t) : t;\n}\n\nexport interface PunchOptions {\n /** Progress at which the punch peaks. Default 0.5. */\n at?: number;\n /** Window width of the punch. Default 0.25. */\n width?: number;\n /** Peak extra scale, e.g. 0.12 → 1 → 1.12 → 1. Default 0.12. */\n amount?: number;\n}\n\n/**\n * A scale multiplier that bumps 1 → 1+amount → 1 around a moment.\n * Use for beat hits and emphasis: `transform: \\`scale(${punch(progress, { at: 0.8 })})\\``.\n */\nexport function punch(progress: number, options?: PunchOptions): number {\n const { at = 0.5, width = 0.25, amount = 0.12 } = options ?? {};\n if (width <= 0) return 1;\n const t = (progress - (at - width / 2)) / width;\n if (t <= 0 || t >= 1) return 1;\n return 1 + amount * Math.sin(t * Math.PI);\n}\n\n// ─── typography ──────────────────────────────────────────────────\n\nexport interface CascadeOptions {\n /** Split unit. Default \"word\". */\n by?: \"word\" | \"char\";\n /** Window overlap between items (see staggerWindow). Default 0.7. */\n overlap?: number;\n easing?: EasingFn;\n /** Entrance travel in px. Default 28. */\n distance?: number;\n /** Travel direction. Default \"up\" (rises into place). */\n direction?: \"up\" | \"down\";\n from?: \"start\" | \"end\" | \"center\";\n}\n\nexport interface CascadeItem {\n /** The word or character. */\n item: string;\n /** Eased local progress 0..1 (may overshoot 1 with springy easings). */\n t: number;\n /** Ready-to-spread inline style: opacity + translate + scale punch-in. */\n style: CSSProperties;\n}\n\n/**\n * Per-word / per-char cascade — the word-cascade archetype as one call.\n * Returns ready-to-render spans:\n *\n * cascade(title, progress).map(({ item, style }, i) => (\n * <span key={i} style={style}>{item}</span>\n * ))\n */\nexport function cascade(text: string, progress: number, options?: CascadeOptions): CascadeItem[] {\n const { by = \"word\", overlap = 0.7, easing = EASE.pop, distance = 28, direction = \"up\", from = \"start\" } =\n options ?? {};\n const items =\n by === \"char\" ? Array.from(String(text)) : String(text).split(/\\s+/).filter((w) => w.length > 0);\n const n = items.length;\n const dir = direction === \"down\" ? -1 : 1;\n return items.map((item, i) => {\n const t = staggerWindow(progress, i, n, { overlap, from, easing });\n const style: CSSProperties = {\n display: \"inline-block\",\n whiteSpace: \"pre\",\n opacity: clamp01(t * 1.4),\n transform: `translateY(${((1 - t) * distance * dir).toFixed(2)}px) scale(${(0.88 + 0.12 * t).toFixed(3)})`,\n ...(by === \"word\" && i < n - 1 ? { marginRight: \"0.26em\" } : null),\n };\n return { item, t, style };\n });\n}\n\nexport interface TypewriterOptions {\n /**\n * Progress range over which typing happens (held after). Default [0, 0.7].\n * (Named `range`, not `window` — the validator bans the `window` token.)\n */\n range?: readonly [number, number];\n easing?: EasingFn;\n /** Cursor blinks per scene. Default 6. Set 0 to hide. */\n blinks?: number;\n}\n\nexport interface TypewriterResult {\n /** The visible slice — render this directly. */\n text: string;\n /** True when the block cursor should be visible this frame. */\n cursorOn: boolean;\n done: boolean;\n}\n\n/**\n * Typewriter via string slicing (never per-char opacity — it reads as fade,\n * not typing). Deterministic blink derived from progress.\n *\n * const tw = typewriter(cmd, progress);\n * <span>{tw.text}{tw.cursorOn ? \"▋\" : \" \"}</span>\n */\nexport function typewriter(text: string, progress: number, options?: TypewriterOptions): TypewriterResult {\n const { range: win = [0, 0.7], easing = EASE.linear, blinks = 6 } = options ?? {};\n const chars = Array.from(String(text));\n const t = phase(progress, win, easing);\n const visible = t >= 1 ? chars.length : Math.floor(t * chars.length);\n const done = visible >= chars.length;\n const cursorOn = blinks > 0 && Math.floor(clamp01(progress) * blinks * 2) % 2 === 0;\n return { text: chars.slice(0, visible).join(\"\"), cursorOn, done };\n}\n\nexport interface CountUpOptions {\n start?: number;\n /** Decimal places. Defaults to 1 if target is non-integer, else 0. */\n decimals?: number;\n easing?: EasingFn;\n /** \"plain\" → 12,847 · \"compact\" → 12.8K. Default \"plain\". */\n format?: \"plain\" | \"compact\";\n prefix?: string;\n suffix?: string;\n}\n\nexport interface CountUpResult {\n value: number;\n /** Formatted display string including prefix/suffix. */\n text: string;\n /** Scale multiplier with a landing punch near the end — apply via transform. */\n scale: number;\n}\n\n/**\n * Eased counter with deterministic formatting (no locale dependence) and a\n * scale punch as the number lands.\n *\n * const { text, scale } = countUp(progress, 12847, { format: \"compact\", suffix: \" users\" });\n * <div style={{ transform: `scale(${scale})` }}>{text}</div>\n */\nexport function countUp(progress: number, target: number, options?: CountUpOptions): CountUpResult {\n const {\n start = 0,\n easing = EASE.outExpo,\n format = \"plain\",\n prefix = \"\",\n suffix = \"\",\n } = options ?? {};\n const decimals = options?.decimals ?? (Number.isInteger(target) ? 0 : 1);\n const e = easing(clamp01(progress));\n const value = start + (target - start) * e;\n let body: string;\n if (format === \"compact\" && Math.abs(value) >= 1000) {\n const units: Array<[number, string]> = [\n [1e9, \"B\"],\n [1e6, \"M\"],\n [1e3, \"K\"],\n ];\n const [div, unit] = units.find(([d]) => Math.abs(value) >= d) as [number, string];\n const scaled = value / div;\n body = `${(Math.round(scaled * 10) / 10).toFixed(Math.abs(scaled) >= 100 ? 0 : 1)}${unit}`;\n } else {\n const fixed = value.toFixed(decimals);\n const [int, frac] = fixed.split(\".\");\n const sign = int.startsWith(\"-\") ? \"-\" : \"\";\n const digits = sign ? int.slice(1) : int;\n let grouped = \"\";\n for (let i = 0; i < digits.length; i++) {\n if (i > 0 && (digits.length - i) % 3 === 0) grouped += \",\";\n grouped += digits[i];\n }\n body = `${sign}${grouped}${frac ? `.${frac}` : \"\"}`;\n }\n const scale = punch(progress, { at: 0.88, width: 0.24, amount: 0.08 });\n return { value, text: `${prefix}${body}${suffix}`, scale };\n}\n\n// ─── layout ──────────────────────────────────────────────────────\n\n/**\n * Absolute-center an element at (xPct%, yPct%) of its container — the\n * translate(-50%,-50%) idiom done right. Extra transforms compose AFTER\n * the centering translate so scale/rotate don't break the anchor.\n *\n * <div style={{ ...center(50, 42, `scale(${pop})`), width: 600 }}>…</div>\n */\nexport function center(xPct = 50, yPct = 50, extraTransform = \"\"): CSSProperties {\n return {\n position: \"absolute\",\n left: `${xPct}%`,\n top: `${yPct}%`,\n transform: `translate(-50%, -50%)${extraTransform ? ` ${extraTransform}` : \"\"}`,\n };\n}\n\n// ─── light / depth (no CSS filter — export-safe) ─────────────────\n\nexport interface GlowOptions {\n /** Diameter in px — scale with sceneScale, e.g. 520 * sceneScale. Default 480. */\n size?: number;\n /** CSS position of the glow center. Defaults \"50%\" / \"50%\". */\n x?: string;\n y?: string;\n /** Core opacity 0..1. Default 0.4. */\n intensity?: number;\n}\n\n/**\n * A layered radial-gradient glow blob (absolutely positioned, pointer-inert).\n * The export-safe replacement for `filter: blur()` light.\n *\n * <div style={glow(accent, { size: 600 * sceneScale, y: \"42%\" })} />\n */\nexport function glow(color: string, options?: GlowOptions): CSSProperties {\n const { size = 480, x = \"50%\", y = \"50%\", intensity = 0.4 } = options ?? {};\n return {\n position: \"absolute\",\n left: x,\n top: y,\n width: size,\n height: size,\n transform: \"translate(-50%, -50%)\",\n borderRadius: \"50%\",\n pointerEvents: \"none\",\n // `closest-side` — the default (farthest-corner) only reaches transparent at\n // the box edge, so a large bloom can show a faint rectangular seam.\n background: `radial-gradient(circle closest-side, ${alpha(color, intensity)} 0%, ${alpha(\n color,\n intensity * 0.45,\n )} 32%, transparent 72%)`,\n };\n}\n\n/**\n * Layered box-shadow stack for believable depth (single shadows look flat).\n * elevation 1 (card resting) … 5 (floating hero). Returns the boxShadow string.\n */\nexport function softShadow(elevation = 3, color = \"#000\"): string {\n const e = Math.min(5, Math.max(1, elevation));\n return [\n `0 ${2 * e}px ${4 * e}px ${alpha(color, 0.16)}`,\n `0 ${6 * e}px ${16 * e}px ${alpha(color, 0.2)}`,\n `0 ${12 * e}px ${40 * e}px ${alpha(color, 0.24)}`,\n ].join(\", \");\n}\n\n/**\n * Full-bleed darkened-edges overlay (focus pull). strength 0..1.\n * Render LAST inside the body so it sits above content, or depth \"foreground\".\n */\nexport function vignette(strength = 0.5): CSSProperties {\n return {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n background: `radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0) 52%, rgba(0,0,0,${\n Math.round(clamp01(strength) * 100) / 100\n }) 100%)`,\n };\n}\n\n/**\n * Deterministic mesh-gradient background string — 2-4 colors as soft radial\n * blobs at seeded positions over a base layer. Use as `background`.\n *\n * background: meshGradient([accent, secondary, \"#1a1040\"], \"hero\")\n */\nexport function meshGradient(colors: string[], seed: string | number = 1): string {\n const list = colors.length > 0 ? colors.slice(0, 4) : [\"#222\"];\n const layers = list.map((c, i) => {\n const cx = Math.round(12 + rand01(seed, i * 3) * 76);\n const cy = Math.round(10 + rand01(seed, i * 3 + 1) * 80);\n const r = Math.round(40 + rand01(seed, i * 3 + 2) * 35);\n return `radial-gradient(circle at ${cx}% ${cy}%, ${alpha(c, 0.85)} 0%, transparent ${r}%)`;\n });\n layers.push(`linear-gradient(180deg, ${alpha(list[0], 0.5)} 0%, rgba(0,0,0,0.9) 100%)`);\n return layers.join(\", \");\n}\n\n/**\n * Film-grain texture overlay — a pre-rasterized 96×96 seeded-noise PNG tile\n * (data URI). Why PNG and not SVG feTurbulence: SVG <filter> elements do NOT\n * execute when a data-URI SVG is rasterized inside the export pipeline's\n * SVG-as-image pass. PNG data URIs render identically in\n * preview and export.\n *\n * The tile was generated once from rand01(\"vanillasky-grain\", i) — fully\n * deterministic. `seed` shifts the tile offset so layered grains never align.\n */\nconst GRAIN_TILE_PX = 96;\nconst GRAIN_TILE_URI =\n \"data:image/png;base64,\" +\n \"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\";\n\nexport function grain(opacity = 0.08, seed: string | number = 2): CSSProperties {\n const ox = Math.floor(rand01(seed, 0) * GRAIN_TILE_PX);\n const oy = Math.floor(rand01(seed, 1) * GRAIN_TILE_PX);\n return {\n position: \"absolute\",\n inset: 0,\n pointerEvents: \"none\",\n opacity: clamp01(opacity),\n backgroundImage: `url(\"${GRAIN_TILE_URI}\")`,\n backgroundRepeat: \"repeat\",\n backgroundPosition: `${ox}px ${oy}px`,\n backgroundSize: `${GRAIN_TILE_PX}px ${GRAIN_TILE_PX}px`,\n };\n}\n\nexport interface DriftOptions {\n /** Total x travel in px over the scene. Default 0. */\n x?: number;\n /** Total y travel in px over the scene. Default 0. */\n y?: number;\n easing?: EasingFn;\n /** Extra transform appended after the drift translate. */\n extraTransform?: string;\n}\n\n/**\n * Slow continuous travel — returns a transform string. Layer different\n * x/y per element for parallax depth (background drifts less than focal).\n *\n * transform: drift(progress, { y: -40 * sceneScale })\n */\nexport function drift(progress: number, options?: DriftOptions): string {\n const { x = 0, y = 0, easing = EASE.editorial, extraTransform = \"\" } = options ?? {};\n const e = easing(clamp01(progress));\n return `translate(${(x * e).toFixed(2)}px, ${(y * e).toFixed(2)}px)${\n extraTransform ? ` ${extraTransform}` : \"\"\n }`;\n}\n\n// ─── shape / SVG ─────────────────────────────────────────────────\n\n/**\n * Stroke draw-on for SVG paths/circles/polylines. Pass the path length\n * (circle: 2πr). Spread onto the SVG element's style or attributes.\n *\n * <path d=\"…\" style={{ ...pathDraw(progress, 1200), stroke: accent, fill: \"none\" }} />\n */\nexport function pathDraw(\n progress: number,\n pathLength: number,\n easing: EasingFn = EASE.editorial,\n): { strokeDasharray: number; strokeDashoffset: number } {\n const e = easing(clamp01(progress));\n return { strokeDasharray: pathLength, strokeDashoffset: pathLength * (1 - e) };\n}\n\nexport interface OrbitOptions {\n /** Orbit center. */\n cx: number;\n cy: number;\n radius: number;\n /** Full revolutions over the scene. Default 1. */\n turns?: number;\n /** Start angle in degrees, -90 = 12 o'clock. Default -90. */\n startDeg?: number;\n clockwise?: boolean;\n}\n\n/**\n * Position on a circular orbit at the given progress.\n * Returns { x, y, deg } — deg is the current angle (use for rotate()).\n */\nexport function orbit(progress: number, options: OrbitOptions): { x: number; y: number; deg: number } {\n const { cx, cy, radius, turns = 1, startDeg = -90, clockwise = true } = options;\n const deg = startDeg + (clockwise ? 1 : -1) * clamp01(progress) * turns * 360;\n const rad = (deg * Math.PI) / 180;\n return { x: cx + Math.cos(rad) * radius, y: cy + Math.sin(rad) * radius, deg };\n}\n\n/**\n * Interpolate between two same-length point lists → SVG `points` string\n * for <polygon>/<polyline> morphs.\n *\n * <polygon points={morph(progress, triangle, star, EASE.crispEnter)} fill={accent} />\n */\nexport function morph(\n progress: number,\n fromPoints: ReadonlyArray<readonly [number, number]>,\n toPoints: ReadonlyArray<readonly [number, number]>,\n easing: EasingFn = EASE.inOutCubic,\n): string {\n const e = easing(clamp01(progress));\n const n = Math.min(fromPoints.length, toPoints.length);\n const pts: string[] = [];\n for (let i = 0; i < n; i++) {\n const x = fromPoints[i][0] + (toPoints[i][0] - fromPoints[i][0]) * e;\n const y = fromPoints[i][1] + (toPoints[i][1] - fromPoints[i][1]) * e;\n pts.push(`${x.toFixed(2)},${y.toFixed(2)}`);\n }\n return pts.join(\" \");\n}\n\nexport type SweepDirection = \"left\" | \"right\" | \"up\" | \"down\" | \"open\";\n\n/**\n * Masked wipe reveal via clip-path inset (verified export-safe).\n * \"left\" reveals left→right, \"open\" reveals center-out.\n *\n * <div style={{ ...sweep(progress, \"left\"), position: \"relative\" }}>…</div>\n */\nexport function sweep(\n progress: number,\n direction: SweepDirection = \"left\",\n easing: EasingFn = EASE.crispEnter,\n): { clipPath: string } {\n const e = clamp01(easing(clamp01(progress)));\n const rest = ((1 - e) * 100).toFixed(2);\n switch (direction) {\n case \"right\":\n return { clipPath: `inset(0% 0% 0% ${rest}%)` };\n case \"up\":\n return { clipPath: `inset(${rest}% 0% 0% 0%)` };\n case \"down\":\n return { clipPath: `inset(0% 0% ${rest}% 0%)` };\n case \"open\":\n return { clipPath: `inset(0% ${(parseFloat(rest) / 2).toFixed(2)}% 0% ${(parseFloat(rest) / 2).toFixed(2)}%)` };\n case \"left\":\n default:\n return { clipPath: `inset(0% ${rest}% 0% 0%)` };\n }\n}\n\n// ─── particles ───────────────────────────────────────────────────\n\n/**\n * Deterministic pseudo-random 0..1 from a seed + stream index.\n * FNV-1a + avalanche — same (seed, index) always yields the same value.\n * THE replacement for Math.random in custom scenes.\n */\nexport function rand01(seed: string | number, index = 0): number {\n let h = 2166136261 >>> 0;\n const str = String(seed);\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619) >>> 0;\n }\n h = (h + Math.imul(index + 1, 0x9e3779b9)) >>> 0;\n h ^= h >>> 16;\n h = Math.imul(h, 0x85ebca6b) >>> 0;\n h ^= h >>> 13;\n h = Math.imul(h, 0xc2b2ae35) >>> 0;\n h ^= h >>> 16;\n return (h >>> 0) / 4294967296;\n}\n\nexport interface ParticlesOptions {\n /** \"float\" ambient bobbing · \"rise\" upward stream · \"rain\" downward. Default \"float\". */\n mode?: \"float\" | \"rise\" | \"rain\";\n /** Travel speed multiplier. Default 1. */\n speed?: number;\n /** Particle size range in px (scale with sceneScale at call site). Default [4, 10]. */\n sizeRange?: readonly [number, number];\n}\n\nexport interface Particle {\n /** Position as 0..1 fractions of the field — multiply by width/height. */\n x: number;\n y: number;\n /** Size in px (from sizeRange). */\n size: number;\n /** 0..1 — already fades near field edges, no popping. */\n opacity: number;\n /** Degrees. */\n rotation: number;\n}\n\n/**\n * Seeded ambient particle field. Deterministic: same (count, seed, progress)\n * → identical field. Map to absolutely-positioned divs:\n *\n * particles(progress, 24, \"stars\").map((p, i) => (\n * <div key={i} style={{ position: \"absolute\", left: p.x * width, top: p.y * height,\n * width: p.size, height: p.size, borderRadius: \"50%\", background: accent, opacity: p.opacity }} />\n * ))\n */\nexport function particles(\n progress: number,\n count: number,\n seed: string | number,\n options?: ParticlesOptions,\n): Particle[] {\n const { mode = \"float\", speed = 1, sizeRange = [4, 10] } = options ?? {};\n const p = clamp01(progress);\n const out: Particle[] = [];\n for (let i = 0; i < count; i++) {\n const baseX = rand01(seed, i * 7);\n const baseY = rand01(seed, i * 7 + 1);\n const sizeT = rand01(seed, i * 7 + 2);\n const phase0 = rand01(seed, i * 7 + 3);\n const wobble = 0.015 + rand01(seed, i * 7 + 4) * 0.025;\n const x = baseX + Math.sin((p * speed + phase0) * Math.PI * 3) * wobble;\n let y: number;\n if (mode === \"rise\") y = (((baseY - p * speed * 0.35) % 1) + 1) % 1;\n else if (mode === \"rain\") y = (baseY + p * speed * 0.35) % 1;\n else y = baseY + Math.sin((p * speed + phase0) * Math.PI * 2) * wobble * 1.5;\n const edgeFade = clamp01(Math.sin(Math.PI * clamp01(y)) * 2);\n out.push({\n x,\n y,\n size: sizeRange[0] + (sizeRange[1] - sizeRange[0]) * sizeT,\n opacity: edgeFade * (0.45 + 0.55 * rand01(seed, i * 7 + 5)),\n rotation: (phase0 * 360 + p * speed * 240) % 360,\n });\n }\n return out;\n}\n\nexport interface BurstOptions {\n /** Cone width in degrees. Default 360 (full radial burst). */\n spreadDeg?: number;\n /** Cone center direction in degrees, -90 = up. Default -90. */\n originDeg?: number;\n /** Downward pull applied over time (0 = none). Default 0.35. */\n gravity?: number;\n easing?: EasingFn;\n}\n\nexport interface BurstParticle {\n /** Offset from origin in UNIT radius — multiply by px radius at call site. */\n dx: number;\n dy: number;\n rotation: number;\n scale: number;\n opacity: number;\n}\n\n/**\n * Seeded radial burst (confetti / celebration). Particles fly outward from a\n * shared origin as progress runs 0→1, with gravity and a fade tail.\n *\n * burst(phase(progress, [0.55, 1]), 32, \"confetti\").map((b, i) => (\n * <div key={i} style={{ position: \"absolute\",\n * left: width / 2 + b.dx * minDim * 0.42, top: height * 0.45 + b.dy * minDim * 0.42,\n * width: 12, height: 12, background: i % 2 ? accent : secondary,\n * opacity: b.opacity, transform: `rotate(${b.rotation}deg) scale(${b.scale})` }} />\n * ))\n */\nexport function burst(\n progress: number,\n count: number,\n seed: string | number,\n options?: BurstOptions,\n): BurstParticle[] {\n const { spreadDeg = 360, originDeg = -90, gravity = 0.35, easing = EASE.outCubic } = options ?? {};\n const e = easing(clamp01(progress));\n const out: BurstParticle[] = [];\n for (let i = 0; i < count; i++) {\n const jitter = rand01(seed, i * 5) * 0.8;\n const angleDeg = originDeg - spreadDeg / 2 + (spreadDeg * (i + jitter)) / Math.max(1, count);\n const rad = (angleDeg * Math.PI) / 180;\n const dist = e * (0.5 + 0.5 * rand01(seed, i * 5 + 1));\n out.push({\n dx: Math.cos(rad) * dist,\n dy: Math.sin(rad) * dist + gravity * e * e,\n rotation: (rand01(seed, i * 5 + 2) * 720 * e) % 360,\n scale: 1 - 0.45 * e * rand01(seed, i * 5 + 3),\n opacity: e < 0.7 ? 1 : clamp01((1 - e) / 0.3),\n });\n }\n return out;\n}\n"
22
+ },
23
+ {
24
+ "path": "src/visual-system/motion/curves.ts",
25
+ "type": "registry:lib",
26
+ "target": "vanillasky/motion/curves.ts",
27
+ "content": "/**\n * Animation utilities — interpolate, spring, easing.\n * Pure math functions, no external dependencies.\n * Inspired by Remotion's API but fully independent.\n */\n\n// ─── Easing functions ────────────────────────────────────────────\n\n/**\n * CSS-style cubic-bezier curve → easing function (t: 0..1 → eased value).\n * Deterministic Newton-Raphson with bisection fallback — same approach as\n * the browser's cubic-bezier(). y values outside [0, 1] produce overshoot.\n *\n * docs/motion-library.md §1 references curves as `Easing.bezier(...)` —\n * this is that function (also exposed as `Easing.bezier`).\n */\nexport function cubicBezier(\n x1: number,\n y1: number,\n x2: number,\n y2: number,\n): (t: number) => number {\n const cx = 3 * x1;\n const bx = 3 * (x2 - x1) - cx;\n const ax = 1 - cx - bx;\n const cy = 3 * y1;\n const by = 3 * (y2 - y1) - cy;\n const ay = 1 - cy - by;\n const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;\n const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;\n const sampleDX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;\n return (x: number): number => {\n if (x <= 0) return 0;\n if (x >= 1) return 1;\n let t = x;\n for (let i = 0; i < 8; i++) {\n const err = sampleX(t) - x;\n if (Math.abs(err) < 1e-6) return sampleY(t);\n const d = sampleDX(t);\n if (Math.abs(d) < 1e-6) break;\n t -= err / d;\n }\n // Bisection fallback — sampleX is monotonic on [0, 1] for valid curves.\n let lo = 0;\n let hi = 1;\n t = x;\n for (let i = 0; i < 24; i++) {\n const v = sampleX(t);\n if (Math.abs(v - x) < 1e-6) break;\n if (v < x) lo = t;\n else hi = t;\n t = (lo + hi) / 2;\n }\n return sampleY(t);\n };\n}\n\nexport const Easing = {\n /** Easing.bezier(0.16, 1, 0.3, 1) — the canonical curve syntax from docs/motion-library.md. */\n bezier: cubicBezier,\n // Curves\n linear: (t: number) => t,\n quad: (t: number) => t * t,\n cubic: (t: number) => t * t * t,\n sin: (t: number) => 1 - Math.cos((t * Math.PI) / 2),\n exp: (t: number) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),\n circle: (t: number) => 1 - Math.sqrt(1 - t * t),\n\n // Directional modifiers\n in: (fn: (t: number) => number) => fn,\n out:\n (fn: (t: number) => number) =>\n (t: number) =>\n 1 - fn(1 - t),\n inOut:\n (fn: (t: number) => number) =>\n (t: number) =>\n t < 0.5 ? fn(t * 2) / 2 : 1 - fn((1 - t) * 2) / 2,\n} as const;\n\n// ─── Interpolate ─────────────────────────────────────────────────\n\ninterface InterpolateOptions {\n easing?: (t: number) => number;\n extrapolateLeft?: \"clamp\" | \"extend\";\n extrapolateRight?: \"clamp\" | \"extend\";\n}\n\n/**\n * Maps a value from one range to another with optional easing and clamping.\n *\n * @example\n * interpolate(0.5, [0, 1], [0, 100]) // 50\n * interpolate(progress, [0, 0.7], [0, 10000], { extrapolateRight: \"clamp\" })\n */\nexport function interpolate(\n value: number,\n inputRange: readonly [number, ...number[]],\n outputRange: readonly [number, ...number[]],\n options?: InterpolateOptions,\n): number {\n const { easing, extrapolateLeft = \"extend\", extrapolateRight = \"extend\" } = options ?? {};\n\n // Find the segment\n let i = 0;\n for (; i < inputRange.length - 2; i++) {\n if (value < inputRange[i + 1]) break;\n }\n\n const inputMin = inputRange[i];\n const inputMax = inputRange[i + 1];\n const outputMin = outputRange[i];\n const outputMax = outputRange[i + 1];\n\n // Normalize to 0-1\n let t = inputMax === inputMin ? 0 : (value - inputMin) / (inputMax - inputMin);\n\n // Clamp\n if (t < 0 && extrapolateLeft === \"clamp\") t = 0;\n if (t > 1 && extrapolateRight === \"clamp\") t = 1;\n\n // Apply easing\n if (easing && t >= 0 && t <= 1) {\n t = easing(t);\n }\n\n return outputMin + t * (outputMax - outputMin);\n}\n\n// ─── Spring ──────────────────────────────────────────────────────\n\nexport interface SpringConfig {\n damping?: number;\n stiffness?: number;\n mass?: number;\n}\n\n// Presets\nexport const SPRING_SMOOTH: SpringConfig = { damping: 26, stiffness: 170 };\nexport const SPRING_SNAPPY: SpringConfig = { damping: 20, stiffness: 300 };\nexport const SPRING_BOUNCY: SpringConfig = { damping: 10, stiffness: 180 };\n// Lower damping than SNAPPY + higher stiffness → subtle overshoot, quick settle.\n// Sits between SNAPPY (no overshoot) and BOUNCY (lots) for a \"punchy but not silly\" feel.\nexport const SPRING_CRISP: SpringConfig = { damping: 14, stiffness: 320 };\n\n/**\n * Spring physics simulation. Converts linear progress (0→1) to\n * spring-eased progress with overshoot and settle.\n *\n * @param progress - Linear progress 0→1\n * @param config - Spring physical properties\n * @returns Spring-eased value (may overshoot 1 with low damping)\n *\n * @example\n * const scale = spring(progress, SPRING_BOUNCY); // bouncy entrance\n * const opacity = spring(progress, SPRING_SMOOTH); // smooth fade\n */\nexport function spring(progress: number, config?: SpringConfig): number {\n if (progress <= 0) return 0;\n if (progress >= 1) {\n // For high-damping (no overshoot) configs, settle at 1\n const { damping = 26 } = config ?? {};\n if (damping >= 20) return 1;\n }\n\n const { damping = 26, stiffness = 170, mass = 1 } = config ?? {};\n\n // Simulate spring physics at the given progress point\n // We run a fixed number of iterations to find the spring value\n const omega = Math.sqrt(stiffness / mass);\n const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n\n // Scale progress to a time value (spring needs ~1-3s to settle)\n const t = progress * 3.5;\n\n let value: number;\n\n if (zeta < 1) {\n // Underdamped (bouncy)\n const omegaD = omega * Math.sqrt(1 - zeta * zeta);\n value = 1 - Math.exp(-zeta * omega * t) * (Math.cos(omegaD * t) + (zeta * omega / omegaD) * Math.sin(omegaD * t));\n } else if (zeta === 1) {\n // Critically damped\n value = 1 - Math.exp(-omega * t) * (1 + omega * t);\n } else {\n // Overdamped\n const s1 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));\n const s2 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));\n value = 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1);\n }\n\n return value;\n}\n\n// ─── Stagger helper ──────────────────────────────────────────────\n\n/**\n * Returns a progress value for a staggered animation item.\n * Maps overall progress to per-item progress with delay.\n *\n * @param progress - Overall scene progress (0→1)\n * @param index - Item index (0-based)\n * @param total - Total number of items\n * @param staggerDelay - Delay between items (0→1 scale, default 0.08)\n * @param startAt - When the first item should start appearing (default 0.1)\n *\n * @example\n * items.map((item, i) => {\n * const itemProgress = stagger(progress, i, items.length);\n * const opacity = spring(itemProgress, SPRING_SMOOTH);\n * return <div style={{ opacity }}>{item}</div>;\n * })\n */\nexport function stagger(\n progress: number,\n index: number,\n total: number,\n staggerDelay = 0.08,\n startAt = 0.1,\n): number {\n const itemStart = startAt + index * staggerDelay;\n const available = 1 - startAt - (total - 1) * staggerDelay;\n const itemDuration = Math.max(0.1, available);\n return Math.max(0, Math.min(1, (progress - itemStart) / itemDuration));\n}\n"
28
+ }
29
+ ],
30
+ "meta": {
31
+ "vanillasky": {
32
+ "layer": "lib",
33
+ "tier": "free",
34
+ "domain": "motion",
35
+ "level": "foundation",
36
+ "audiences": [
37
+ "custom-scenes",
38
+ "react-developers"
39
+ ],
40
+ "dependencies": [],
41
+ "useWhen": "Use it whenever a value changes with scene progress; start with a composed helper, then drop to interpolation or springs when the scene needs custom choreography.",
42
+ "avoidWhen": "Do not use browser timers, CSS transitions, requestAnimationFrame, or Math.random inside a renderable scene."
43
+ }
44
+ }
45
+ }