@vanillaskyai/video 0.8.8 → 0.10.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 (173) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/PUBLIC-API.md +5 -8
  3. package/dist/{builtin-server-CUYPIDE7.js → builtin-server-W4PLJUZ5.js} +2 -2
  4. package/dist/{catalog-types-BIhSpOWK.d.ts → catalog-types-WTbLP6Jh.d.ts} +5 -1
  5. package/dist/chapter-title-YRLIQY3V.js +7 -0
  6. package/dist/check-runtime.js +5 -14
  7. package/dist/{chunk-AITKH6QT.js → chunk-2HFO22K2.js} +15 -40
  8. package/dist/chunk-4YM2M62S.js +13 -0
  9. package/dist/chunk-4ZJLPHBV.js +684 -0
  10. package/dist/chunk-77VY4O7A.js +390 -0
  11. package/dist/{chunk-MMUXVA47.js → chunk-7M56IUUX.js} +9 -8
  12. package/dist/chunk-7NZOMTAL.js +63 -0
  13. package/dist/{chunk-2DOLF4QN.js → chunk-AUPC6MDK.js} +12 -14
  14. package/dist/{chunk-352ZZCET.js → chunk-EGVQODKU.js} +2 -2
  15. package/dist/{chunk-2USVB3CY.js → chunk-ELVOT2FQ.js} +284 -177
  16. package/dist/chunk-EWWCQFTI.js +18 -0
  17. package/dist/{chunk-KT3M7KDL.js → chunk-HKUI5BTB.js} +3 -3
  18. package/dist/chunk-IIN5M5HW.js +697 -0
  19. package/dist/{chunk-LMG3GHLL.js → chunk-IQMYK5DX.js} +29 -3
  20. package/dist/{chunk-OWBT45EN.js → chunk-IR44XKBI.js} +1 -1
  21. package/dist/{chunk-MXZSDGZQ.js → chunk-NMFDC2W2.js} +4 -6
  22. package/dist/{chunk-5SLENAJW.js → chunk-PAJ44TFW.js} +1 -2
  23. package/dist/{chunk-L7QGGKJ2.js → chunk-PDQFQIQW.js} +1 -1
  24. package/dist/{chunk-WJ6FLKNC.js → chunk-R3XAOMKP.js} +14 -0
  25. package/dist/{chunk-2OKB27TG.js → chunk-RXTN2CW6.js} +2 -2
  26. package/dist/{chunk-XWWLKRNU.js → chunk-TVIU23OM.js} +38 -49
  27. package/dist/chunk-XX4GXKTX.js +35 -0
  28. package/dist/chunk-Z67Q5KUW.js +16 -0
  29. package/dist/{chunk-3VRMKFM7.js → chunk-ZDJNFA52.js} +11 -24
  30. package/dist/cinema-media-C7OUPNSH.js +12 -0
  31. package/dist/cli.js +18 -9
  32. package/dist/comparison-GNZOR7MF.js +37 -0
  33. package/dist/compose-video-TBFTONUC.js +12 -0
  34. package/dist/editorial-timeline-EMV5335S.js +43 -0
  35. package/dist/{events-BQ6z264n.d.ts → events-B4YCc4vc.d.ts} +1 -1
  36. package/dist/index.d.ts +4 -6
  37. package/dist/index.js +3 -7
  38. package/dist/key-figure-P37VK425.js +30 -0
  39. package/dist/{kit-tQnCPMOM.d.ts → kit-DrRpdn0p.d.ts} +2 -2
  40. package/dist/mobile-message-OF7T62B7.js +50 -0
  41. package/dist/quote-T6WYLN7A.js +33 -0
  42. package/dist/react.d.ts +21 -11
  43. package/dist/react.js +585 -826
  44. package/dist/scene-video-backdrop-PCTARAAJ.js +7 -0
  45. package/dist/server.d.ts +7 -8
  46. package/dist/server.js +179 -84
  47. package/dist/{system-prompt-RRXIWDDD.js → system-prompt-SYRT5DWM.js} +1 -1
  48. package/dist/template-catalog.d.ts +392 -16
  49. package/dist/template-catalog.js +1 -1
  50. package/dist/templates.d.ts +5 -5
  51. package/dist/test.d.ts +3 -3
  52. package/dist/test.js +18 -18
  53. package/dist/text-stream-LD364KBI.js +9 -0
  54. package/dist/{types-DWFO6qca.d.ts → types-CdBmNTGD.d.ts} +1 -1
  55. package/dist/{types-CEh9eUFu.d.ts → types-DdZw4GRQ.d.ts} +17 -48
  56. package/dist/validate-4OUD2CLX.js +10 -0
  57. package/docs/architecture.md +1 -1
  58. package/docs/concepts.md +1 -1
  59. package/docs/custom-templates.md +13 -28
  60. package/docs/customization.md +11 -59
  61. package/docs/getting-started.md +2 -2
  62. package/docs/immersive-interface.md +3 -4
  63. package/docs/maintainers/cinematic-migration.md +38 -0
  64. package/docs/media-and-audio.md +14 -14
  65. package/docs/motion-and-effects.md +6 -14
  66. package/docs/persistence.md +3 -3
  67. package/docs/production.md +11 -9
  68. package/docs/provider-integration.md +9 -16
  69. package/docs/reference/design-system.html +1 -1
  70. package/docs/reference/protocol.md +14 -4
  71. package/docs/testing.md +1 -1
  72. package/package.json +6 -3
  73. package/registry/items/backgrounds.json +1 -1
  74. package/registry/items/chapterTitle.json +80 -0
  75. package/registry/items/cinemaMedia.json +136 -0
  76. package/registry/items/comparison.json +168 -0
  77. package/registry/items/editorialTimeline.json +172 -0
  78. package/registry/items/keyFigure.json +162 -0
  79. package/registry/items/mobileMessage.json +153 -0
  80. package/registry/items/quote.json +161 -0
  81. package/registry/items/template-context.json +1 -1
  82. package/registry/items/theme.json +1 -1
  83. package/starters/video-chat/README.md +24 -26
  84. package/starters/video-chat/package.json +1 -1
  85. package/starters/video-chat/providers/video.ts +6 -5
  86. package/starters/video-chat/stock.ts +35 -151
  87. package/styles/fonts/OFL.txt +93 -0
  88. package/styles/fonts/manifest.json +66 -0
  89. package/styles/fonts/roboto-v51-cyrillic-ext.woff2 +0 -0
  90. package/styles/fonts/roboto-v51-cyrillic.woff2 +0 -0
  91. package/styles/fonts/roboto-v51-greek-ext.woff2 +0 -0
  92. package/styles/fonts/roboto-v51-greek.woff2 +0 -0
  93. package/styles/fonts/roboto-v51-latin-ext.woff2 +0 -0
  94. package/styles/fonts/roboto-v51-latin.woff2 +0 -0
  95. package/styles/fonts/roboto-v51-math.woff2 +0 -0
  96. package/styles/fonts/roboto-v51-symbols.woff2 +0 -0
  97. package/styles/fonts/roboto-v51-vietnamese.woff2 +0 -0
  98. package/styles/fonts/roboto.css +91 -0
  99. package/styles/video-chat.css +30 -22
  100. package/dist/bg-confetti-HITCGLGD.js +0 -89
  101. package/dist/bg-emoji-VU4UZYZN.js +0 -150
  102. package/dist/bg-media-L34PDQXJ.js +0 -106
  103. package/dist/brand-message-FUJ4STFO.js +0 -256
  104. package/dist/chart-bar-EGPHJATL.js +0 -310
  105. package/dist/chart-counter-5FFWQNZO.js +0 -306
  106. package/dist/chart-progress-ring-JMXQLMLC.js +0 -302
  107. package/dist/chunk-4UNKZEAT.js +0 -3575
  108. package/dist/chunk-5CDAM24P.js +0 -31
  109. package/dist/chunk-AOWSXU2K.js +0 -18
  110. package/dist/chunk-CJ7F2CE6.js +0 -104
  111. package/dist/chunk-FRN6WKHA.js +0 -185
  112. package/dist/chunk-G5JPHBX7.js +0 -336
  113. package/dist/chunk-L5BTTCZV.js +0 -97
  114. package/dist/chunk-P22HHFDK.js +0 -43
  115. package/dist/chunk-PP3MCPN4.js +0 -1998
  116. package/dist/chunk-QAAO7OAY.js +0 -19
  117. package/dist/chunk-S7HFLYNN.js +0 -35
  118. package/dist/chunk-W3VDIBGR.js +0 -217
  119. package/dist/chunk-XGRA2MUV.js +0 -242
  120. package/dist/chunk-XTOXFMLM.js +0 -105
  121. package/dist/chunk-YJJC4N4D.js +0 -761
  122. package/dist/compose-video-2NFH4DZY.js +0 -13
  123. package/dist/cta-logo-YR7LYUR5.js +0 -212
  124. package/dist/cta-media-DQJBKUO2.js +0 -221
  125. package/dist/incoming-call-54KQ7O5A.js +0 -323
  126. package/dist/infographic-before-after-ROS52GOW.js +0 -460
  127. package/dist/infographic-feature-list-E7MGUDAN.js +0 -272
  128. package/dist/infographic-problem-solution-3S6NGO5N.js +0 -343
  129. package/dist/infographic-stat-row-SYB7AB7H.js +0 -247
  130. package/dist/infographic-steps-KCE6JWE2.js +0 -406
  131. package/dist/prompt-input-MHX4O42G.js +0 -239
  132. package/dist/reaction-SLEGR3BE.js +0 -122
  133. package/dist/scene-video-backdrop-XFPX4O3I.js +0 -10
  134. package/dist/showcase-code-2HLAVKBM.js +0 -357
  135. package/dist/showcase-phone-Q4HEO4LH.js +0 -278
  136. package/dist/showcase-terminal-DL45LUFG.js +0 -312
  137. package/dist/showcase-web-2QZO73FZ.js +0 -396
  138. package/dist/social-conversation-75SD2637.js +0 -695
  139. package/dist/social-milestone-GEFUQSBF.js +0 -296
  140. package/dist/social-notification-FJ5VHVLK.js +0 -247
  141. package/dist/social-review-stack-KXQCKFRP.js +0 -242
  142. package/dist/social-testimonial-JZ7CHC7S.js +0 -287
  143. package/dist/social-tweet-UCDXM25F.js +0 -298
  144. package/dist/text-stream-54LXABJW.js +0 -10
  145. package/dist/validate-T7GBU2YF.js +0 -8
  146. package/registry/items/barChart.json +0 -245
  147. package/registry/items/beforeAfter.json +0 -180
  148. package/registry/items/bigNumber.json +0 -232
  149. package/registry/items/brandMessage.json +0 -164
  150. package/registry/items/cardList.json +0 -214
  151. package/registry/items/chatMessenger.json +0 -151
  152. package/registry/items/chatWhatsapp.json +0 -151
  153. package/registry/items/codeEditor.json +0 -191
  154. package/registry/items/confetti.json +0 -107
  155. package/registry/items/ctaLogo.json +0 -167
  156. package/registry/items/ctaMedia.json +0 -200
  157. package/registry/items/emojiBurst.json +0 -101
  158. package/registry/items/incomingCall.json +0 -177
  159. package/registry/items/media.json +0 -187
  160. package/registry/items/milestone.json +0 -212
  161. package/registry/items/notification.json +0 -189
  162. package/registry/items/phoneMockup.json +0 -261
  163. package/registry/items/problemSolution.json +0 -193
  164. package/registry/items/progressRing.json +0 -206
  165. package/registry/items/promptInput.json +0 -155
  166. package/registry/items/reaction.json +0 -176
  167. package/registry/items/reviewStack.json +0 -231
  168. package/registry/items/steps.json +0 -215
  169. package/registry/items/terminal.json +0 -219
  170. package/registry/items/testimonial.json +0 -171
  171. package/registry/items/tripleStats.json +0 -235
  172. package/registry/items/tweet.json +0 -210
  173. package/registry/items/webMockup.json +0 -280
@@ -1,215 +0,0 @@
1
- {
2
- "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
- "name": "steps",
4
- "type": "registry:block",
5
- "title": "Steps",
6
- "description": "Two or three ordered workflow steps reveal as a vertical sequence.",
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/infographic-steps.tsx",
20
- "type": "registry:component",
21
- "target": "vanillasky/scene-templates/infographic-steps.tsx",
22
- "content": "/**\n * infographic-steps — vertical step-by-step process visualization.\n *\n * Two or three steps form a connected vertical timeline in portrait or a\n * horizontal row in landscape. Animation: the connector draws in reading order,\n * the circles pop in together, and the editable labels follow. Exit cascade\n * slides each step left and fades in reading order.\n *\n * Block structure (docs/blocks.md):\n * background — brand atmosphere or supplied/stock media (SceneBackground)\n * caption — TemplateText headline in the top text area (subtle archetype)\n * hero — StepsList primitive (timeline + labels + cascade exit)\n */\n\nimport React from \"react\";\nimport { parseList } from \"../parse-list\";\nimport type { SceneTemplateProps } from \"./types\";\nimport { resolveTokens } from \"../theme\";\nimport { TemplateText } from \"./template-text\";\nimport { StepsList } from \"../primitives/infographic/StepsList\";\nimport { SceneBackground, getMediaBackgroundProps, hasSceneMedia } from \"./scene-background\";\n\nexport const InfographicStepsTemplate: React.FC<SceneTemplateProps> = ({\n variables,\n style,\n progress,\n motionProgress = progress,\n beatIntensity,\n width,\n height,\n textArchetype,\n safeZone,\n sceneDuration,\n backgroundEffect,\n isPlaying,\n}) => {\n const { primary, foreground, font } = resolveTokens(style);\n const textColor = foreground;\n const textsRaw = String(variables.texts || \"\");\n\n const stepLabels = parseList(variables.steps, 3);\n const stepEmojis = parseList(variables.stepEmojis, 3);\n\n // textArchetype is intentionally unused — the headline always uses the\n // subtle archetype so the step cascade carries the motion.\n void textArchetype;\n\n return (\n <div\n style={{\n width,\n height,\n backgroundColor: \"var(--vanillasky-template-surface, #000)\",\n position: \"relative\",\n overflow: \"hidden\",\n fontFamily: font,\n }}\n >\n {/* [slot: background] Brand atmosphere or cinematic supplied/stock media. */}\n <SceneBackground\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n width={width}\n height={height}\n {...getMediaBackgroundProps(variables)}\n backgroundEffect={backgroundEffect}\n seed={textsRaw}\n isPlaying={isPlaying}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: caption] Headline — top text area */}\n <TemplateText\n overMedia={hasSceneMedia(variables)}\n motionProgress={motionProgress}\n typeTreatment={resolveTokens(style).preset.type}\n archetype=\"subtle\"\n text={String(variables.texts ?? \"\")}\n progress={progress}\n sceneDuration={sceneDuration ?? 3}\n width={width}\n height={height}\n position=\"top\"\n sizeRole=\"headline\"\n safeZone={safeZone}\n font={font}\n color={textColor}\n beatIntensity={beatIntensity}\n />\n\n {/* [slot: hero] Step circles + titles — shared primitive */}\n <StepsList\n progress={motionProgress}\n width={width}\n height={height}\n steps={stepLabels.map((title) => ({ title }))}\n stepEmojis={stepEmojis}\n accent={primary}\n textColor={textColor}\n font={font}\n beatIntensity={beatIntensity}\n safeZone={safeZone}\n />\n </div>\n );\n};\n"
23
- },
24
- {
25
- "path": "src/visual-system/scene-templates/media-source.ts",
26
- "type": "registry:lib",
27
- "target": "vanillasky/scene-templates/media-source.ts",
28
- "content": "/**\n * How a scene's backdrop resolves from its variables. Pure, React-free, and\n * deliberately a leaf module: the player's media preloader shares it so the\n * question \"is this scene backed by a photo, a video, or the brand gradient?\"\n * has exactly one answer in the codebase.\n */\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\n/** Apple mobile browsers share WebKit's conservative video-decoder ceiling. */\nexport function limitsConcurrentVideoDecoders(): boolean {\n if (typeof navigator === \"undefined\") return false;\n return /iPad|iPhone|iPod/.test(navigator.userAgent) ||\n (navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1);\n}\n\nexport function resolveMediaType(\n mediaType: string,\n mediaUrl: string,\n): ResolvedMediaType {\n if (mediaType === \"gradient\") return \"gradient\";\n if (mediaType === \"video\") return \"video\";\n if (mediaType === \"photo\") return \"photo\";\n // \"auto\" — detect from URL extension\n return mediaUrl && isVideoUrl(mediaUrl) ? \"video\" : \"photo\";\n}\n\n/**\n * True when the scene actually renders a photo or video backdrop — i.e. a\n * mediaUrl is set and the template has not been pinned to the brand gradient.\n * Templates use it to switch their type onto the media legibility recipe.\n */\nexport function hasSceneMedia(variables: Record<string, unknown>): boolean {\n return (\n String(variables.mediaUrl || \"\").trim() !== \"\" &&\n String(variables.mediaType || \"auto\") !== \"gradient\"\n );\n}\n"
29
- },
30
- {
31
- "path": "src/visual-system/scene-templates/scene-background.tsx",
32
- "type": "registry:component",
33
- "target": "vanillasky/scene-templates/scene-background.tsx",
34
- "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. Legibility is then split between two instruments:\n * eased scrims shaped to where the template's copy sits (`textAnchor`),\n * and a per-glyph halo on the type itself (MEDIA_TEXT_SHADOW). Neither\n * alone can hold white type over a blown-out highlight without flattening\n * the picture; together they do it at roughly half the darkening.\n * - mediaType=\"gradient\" deliberately ignores mediaUrl and renders only\n * the brand gradient. First-class atmospheric mode.\n * - When mediaUrl is empty, 404s, is blocked, or Pexels search returned\n * nothing, the gradient shows through cleanly and no scrim is painted —\n * a scrim over a bare gradient is just a muddy gradient. Enforced, not\n * assumed: the media has to load before anything darkens for it.\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, useState } from \"react\";\nimport {\n hasSceneMedia,\n resolveMediaType,\n type ResolvedMediaType,\n} from \"./media-source\";\nimport type { TemplateStyle } from \"../template-context\";\nimport { BrandGradientOverlay } from \"../backgrounds\";\nimport { getBackgroundTransform } from \"../backgrounds\";\nimport { useExternalVideoBackdrop } from \"./external-video-backdrop\";\nimport { resolveMediaPosition, type MediaPosition } from \"./media-position\";\nimport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\n\nexport { hasSceneMedia, resolveMediaType };\nexport { resolveMediaPosition } from \"./media-position\";\nexport { SceneVideoBackdrop } from \"./scene-video-backdrop\";\nexport type { ResolvedMediaType };\n\nexport type { MediaPosition };\nexport type MediaTreatment = \"none\" | \"subtle\" | \"cinematic\" | \"text-safe\";\n\nexport function resolveMediaTreatment(value: string): MediaTreatment {\n return value === \"none\" || value === \"subtle\" || value === \"text-safe\" ? value : \"cinematic\";\n}\n\nexport interface MediaTreatmentLayer {\n id: \"vignette\" | \"center-scrim\" | \"bottom-scrim\";\n background: string;\n style?: React.CSSProperties;\n}\n\n/**\n * Where the template puts its type. The scrim is shaped to the copy, not to\n * the frame: darkening picture the type never touches costs contrast in the\n * photo and buys no legibility. \"full\" is the conservative default for\n * templates that have not declared an anchor.\n */\nexport type MediaTextAnchor = \"center\" | \"bottom\" | \"full\";\n\n/**\n * Smoothstep-sampled alpha stops between `start`% and `end`% of the gradient\n * box, held at full strength before `start` and after `end`.\n *\n * A two-stop `rgba(0,0,0,a) → transparent` scrim ramps alpha linearly, so it\n * ends with a constant slope. Lateral inhibition in the eye amplifies that\n * slope discontinuity into a visible band — the grey bar cutting across the\n * frame that makes an overlay read as an overlay. Smoothstep flattens the\n * curve at both ends, so the scrim holds where the type sits and then leaves\n * without an edge: the same peak coverage over the copy, noticeably less of\n * the picture spent getting there.\n */\nconst SCRIM_STOP_COUNT = 7;\n\nfunction smoothstep(t: number): number {\n return t * t * (3 - 2 * t);\n}\n\nfunction easedStops(\n peakAlpha: number,\n start: number,\n end: number,\n direction: \"fade-out\" | \"fade-in\",\n): string {\n const alphaAt = (t: number): string => {\n const eased = direction === \"fade-out\" ? 1 - smoothstep(t) : smoothstep(t);\n return `rgba(0,0,0,${Number((peakAlpha * eased).toFixed(3))})`;\n };\n const stops: string[] = [];\n if (start > 0) stops.push(`${alphaAt(0)} 0%`);\n for (let i = 0; i < SCRIM_STOP_COUNT; i += 1) {\n const t = i / (SCRIM_STOP_COUNT - 1);\n const position = Number((start + (end - start) * t).toFixed(2));\n stops.push(`${alphaAt(t)} ${position}%`);\n }\n if (end < 100) stops.push(`${alphaAt(1)} 100%`);\n return stops.join(\", \");\n}\n\n/**\n * Export-safe contrast recipes. Overlays only: SVG capture cannot rely on CSS\n * filters, so a blur-behind-text plate is off the table.\n *\n * The scrims deliberately stop short of solving legibility on their own. A\n * uniform darkening strong enough to carry white type over a blown-out sky\n * needs roughly 0.8 alpha — at that point the photo is a texture, not a\n * picture. The cheaper half of the job belongs to the type: a per-glyph halo\n * (MEDIA_TEXT_SHADOW) buys local contrast exactly where it is needed and\n * costs the image nothing. Scrim for the plate, halo for the glyph.\n */\nexport function getMediaTreatmentLayers(\n value: string,\n anchor: MediaTextAnchor = \"full\",\n): MediaTreatmentLayer[] {\n const treatment = resolveMediaTreatment(value);\n // Nothing over the picture at all. Every other treatment exists to carry\n // type across a photograph; where the scene has no type - a generated clip\n // that is the whole point of the beat - even a vignette is something the\n // viewer did not ask to look through.\n if (treatment === \"none\") return [];\n const vignette: MediaTreatmentLayer = {\n id: \"vignette\",\n background:\n treatment === \"subtle\"\n ? `radial-gradient(ellipse at center, ${easedStops(0.28, 45, 100, \"fade-in\")})`\n : `radial-gradient(ellipse at center, ${easedStops(0.72, 32, 100, \"fade-in\")})`,\n };\n if (treatment === \"subtle\") return [vignette];\n\n const textSafe = treatment === \"text-safe\";\n const layers: MediaTreatmentLayer[] = [vignette];\n\n if (anchor !== \"bottom\") {\n layers.push({\n id: \"center-scrim\",\n background: textSafe\n ? `radial-gradient(ellipse 92% 58% at 50% 50%, ${easedStops(0.46, 34, 90, \"fade-out\")})`\n : `radial-gradient(ellipse 88% 52% at 50% 50%, ${easedStops(0.26, 30, 88, \"fade-out\")})`,\n });\n }\n\n if (anchor !== \"center\") {\n layers.push({\n id: \"bottom-scrim\",\n background: `linear-gradient(to top, ${easedStops(textSafe ? 0.64 : 0.5, 8, 100, \"fade-out\")})`,\n style: { top: \"55%\" },\n });\n }\n\n return layers;\n}\n\n/**\n * Whether the backdrop is actually painting, which is what decides if a scrim\n * is earned. \"pending\" is a browser-only state: static and export renders\n * never run effects and never wait on a network, so they start (and stay)\n * ready and their output is unchanged.\n */\ntype MediaPaintState = \"pending\" | \"ready\" | \"failed\";\n\nfunction initialMediaPaint(\n wantsMedia: boolean,\n resolved: ResolvedMediaType,\n mediaUrl: string,\n mediaPoster: string | undefined,\n): MediaPaintState {\n if (typeof window === \"undefined\") return \"ready\";\n if (!wantsMedia) return \"ready\";\n // A poster paints the video's frame immediately, so the scene is already\n // showing footage even though the stream is still decoding.\n if (resolved === \"video\") return mediaPoster ? \"ready\" : \"pending\";\n if (typeof Image === \"undefined\") return \"ready\";\n // Preloaded or browser-cached media decodes synchronously. Reporting it\n // ready on the first render keeps the common mid-playback case free of a\n // gradient-then-photo flicker.\n const cached = new Image();\n cached.src = mediaUrl;\n return cached.complete && cached.naturalWidth > 0 ? \"ready\" : \"pending\";\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 /** Where this template's copy sits, so the scrim is shaped to the type\n * instead of to the frame. Defaults to \"full\" (scrim both the middle and\n * the lower third) for templates that have not declared an anchor. */\n textAnchor?: MediaTextAnchor;\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 textAnchor = \"full\",\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 wantsMedia = resolved !== \"gradient\" && !!mediaUrl;\n const externalVideoBackdrop = useExternalVideoBackdrop();\n const hasExternalVideoBackdrop = externalVideoBackdrop !== false && resolved === \"video\";\n const externalVideoFailed = externalVideoBackdrop === \"fallback\" && resolved === \"video\";\n const externalVideoReady = externalVideoBackdrop === \"ready\" && resolved === \"video\";\n\n // A scrim exists to hold type against footage. Until the footage is on\n // screen there is nothing to hold it against, so the scrim would just be\n // darkening the brand gradient it was never meant to touch — the scene\n // reads as a muddy, vignetted version of the gradient scenes beside it.\n // That window is not rare: it covers the whole load, and it never ends for\n // a dead URL, a blocked host, or an empty stock search.\n //\n // So the media has to paint before anything darkens for it. Both edges of\n // the swap land on the same commit — scrim and picture appear together,\n // and the fallback is the clean gradient the docs always promised.\n const [mediaPaint, setMediaPaint] = useState<MediaPaintState>(() =>\n initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster),\n );\n useEffect(() => {\n setMediaPaint(initialMediaPaint(wantsMedia, resolved, mediaUrl, mediaPoster));\n // Video reports its own paint through onLoadedData / onError below.\n if (!wantsMedia || resolved !== \"photo\") return;\n if (typeof Image === \"undefined\") return;\n let cancelled = false;\n const probe = new Image();\n probe.onload = () => {\n if (!cancelled) setMediaPaint(\"ready\");\n };\n probe.onerror = () => {\n if (!cancelled) setMediaPaint(\"failed\");\n };\n probe.src = mediaUrl;\n if (probe.complete) setMediaPaint(probe.naturalWidth > 0 ? \"ready\" : \"failed\");\n return () => {\n cancelled = true;\n probe.onload = null;\n probe.onerror = null;\n };\n }, [mediaUrl, mediaPoster, resolved, wantsMedia]);\n\n // The element stays mounted while pending — that is what loads it. Only a\n // confirmed failure takes it back out.\n const showMedia = wantsMedia && mediaPaint !== \"failed\";\n const showTreatment = wantsMedia && mediaPaint === \"ready\";\n const resolvedPosition = resolveMediaPosition(mediaPosition);\n const resolvedTreatment = resolveMediaTreatment(mediaTreatment);\n const treatmentLayers = getMediaTreatmentLayers(resolvedTreatment, textAnchor);\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 return (\n <>\n {(!hasExternalVideoBackdrop || externalVideoFailed) && (\n <BrandGradientOverlay\n style={style}\n progress={progress}\n sceneDuration={sceneDuration}\n seed={gradSeed}\n />\n )}\n\n {showMedia && !hasExternalVideoBackdrop &&\n (resolved === \"video\" ? (\n <SceneVideoBackdrop\n mediaUrl={mediaUrl}\n mediaPoster={mediaPoster}\n mediaPosition={mediaPosition}\n backgroundEffect={backgroundEffect}\n progress={progress}\n beatIntensity={beatIntensity}\n isPlaying={isPlaying}\n onReady={() => setMediaPaint(\"ready\")}\n onError={() => setMediaPaint(\"failed\")}\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 {(hasExternalVideoBackdrop ? externalVideoReady : showTreatment) && !externalVideoFailed &&\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"
35
- },
36
- {
37
- "path": "src/visual-system/primitives/infographic/StepsList.tsx",
38
- "type": "registry:component",
39
- "target": "vanillasky/primitives/infographic/StepsList.tsx",
40
- "content": "/**\n * StepsList — the `steps` template composes this component (background and\n * headline stay in the template), so the two can never drift.\n *\n * Numbered (or emoji) step circles form a compact timeline in portrait and\n * a horizontal sequence in landscape, with a shared bouncy spring pop-in,\n * title fade-up, and a left-cascade exit. The three steps enter together so\n * the full sequence remains visible as one composition.\n *\n * Layout owns its own absolute positioning — drop into a scene that\n * already paints a background + headline above. The primitive renders\n * the step blocks only.\n */\n\nimport * as React from \"react\";\nimport {\n interpolate,\n spring,\n type SpringConfig,\n} from \"../../motion\";\nimport {\n withOpacity,\n autoTextColor,\n} from \"../../theme\";\nimport { TOP_TEXT_AREA_RATIO } from \"../../backgrounds\";\nimport { fitTextSize, stripPipe } from \"../../typography\";\nimport { TOKEN_DEFAULTS } from \"../../theme\";\nimport { Emoji } from \"../../emoji\";\nimport type { SafeZone } from \"../../template-context\";\n\n// Bouncy pop — matches Remotion reference (damping 8, stiffness 200)\nconst SPRING_POP: SpringConfig = { damping: 8, stiffness: 200 };\n// Snappy slide — for titles\nconst SPRING_TITLE: SpringConfig = { damping: 18, stiffness: 200 };\n\n// ─── Typed component (direct use from templates) ────────────────\n\n/**\n * Props for StepsList.\n *\n * - `progress` (required): scene progress 0→1 driving all animations.\n * - `width` / `height` (required): scene viewport. Drives portrait/landscape\n * layout switch and the orientation-aware scale factor.\n * - `steps` (required): 1–4 step entries. Only `title` is used in the\n * verbatim layout; `description` is accepted for forward-compat but not\n * rendered (matches the source template). Truncated to 4.\n * - `stepEmojis` (optional): per-step emoji glyph; falls back to a numbered\n * circle (`1`, `2`, `3`, `4`) when entry is empty / array shorter than `steps`.\n * - `accent` (optional, default `#00e5a0`): brand accent — number color +\n * radial-glow tint + circle box-shadow.\n * - `textColor` (optional, default `#ffffff`): step-title color.\n * - `font` (optional, default `Inter`).\n * - `beatIntensity` (optional, default `0`): accepted for API parity with\n * other primitives; currently unused (source doesn't beat-modulate steps).\n */\nexport interface StepsListProps {\n progress: number;\n width: number;\n height: number;\n steps: Array<{ title: string; description?: string }>;\n stepEmojis?: string[];\n accent?: string;\n textColor?: string;\n font?: string;\n beatIntensity?: number;\n safeZone?: SafeZone;\n}\n\nexport interface StepsLayoutItem {\n x: number;\n y: number;\n circleRadius: number;\n labelLeft: number;\n labelTop: number;\n labelWidth: number;\n labelFontSize: number;\n}\n\nexport interface StepsLayout {\n mode: \"portrait-timeline\" | \"landscape-row\";\n items: StepsLayoutItem[];\n}\n\n/**\n * Safe-zone-aware geometry shared by the renderer and regression tests.\n * Four portrait steps use a compact editorial timeline rather than four\n * oversized centered badges, leaving a predictable text column and bottom\n * breathing room for social-player chrome.\n */\nexport function computeStepsLayout({\n width,\n height,\n labels,\n safeZone = { top: 0, right: 0, bottom: 0, left: 0 },\n}: {\n width: number;\n height: number;\n labels: string[];\n safeZone?: SafeZone;\n}): StepsLayout {\n const stepCount = Math.max(1, Math.min(labels.length, 4));\n const s = Math.min(width, height) / 1080;\n const textAreaBottom = Math.max(height * TOP_TEXT_AREA_RATIO, safeZone.top);\n\n if (width > height) {\n const availableWidth = Math.max(1, width - safeZone.left - safeZone.right);\n const sectionWidth = availableWidth / stepCount;\n const circleRadius = Math.min(104 * s, sectionWidth * 0.25);\n const y = Math.min(\n height * 0.55,\n height - safeZone.bottom - circleRadius - 86 * s,\n );\n\n return {\n mode: \"landscape-row\",\n items: labels.slice(0, 4).map((label, index) => {\n const x = safeZone.left + sectionWidth * (index + 0.5);\n const labelWidth = sectionWidth * 0.76;\n return {\n x,\n y,\n circleRadius,\n labelLeft: x - labelWidth / 2,\n labelTop: y + circleRadius + 20 * s,\n labelWidth,\n labelFontSize: fitTextSize(label, 42 * s, labelWidth, { minScale: 0.72 }),\n };\n }),\n };\n }\n\n const contentTop = textAreaBottom;\n const contentBottom = Math.max(contentTop + 1, height - safeZone.bottom);\n const rowHeight = (contentBottom - contentTop) / stepCount;\n const circleRadius = Math.min(82 * s, rowHeight * 0.27);\n const x = safeZone.left + circleRadius + 48 * s;\n const labelLeft = x + circleRadius + 42 * s;\n const labelWidth = Math.max(1, width - safeZone.right - labelLeft);\n\n return {\n mode: \"portrait-timeline\",\n items: labels.slice(0, 4).map((label, index) => {\n const y = contentTop + rowHeight * (index + 0.5);\n return {\n x,\n y,\n circleRadius,\n labelLeft,\n labelTop: y - circleRadius,\n labelWidth,\n labelFontSize: fitTextSize(label, 50 * s, labelWidth, { minScale: 0.68 }),\n };\n }),\n };\n}\n\nexport const StepsList: React.FC<StepsListProps> = ({\n progress,\n width,\n height,\n steps,\n stepEmojis = [],\n accent = TOKEN_DEFAULTS.primary,\n textColor = \"#ffffff\",\n font = TOKEN_DEFAULTS.font,\n beatIntensity = 0,\n safeZone,\n}) => {\n void beatIntensity;\n const s = Math.min(width, height) / 1080;\n\n const stepLabels = steps.slice(0, 4).map((st) => stripPipe(String(st.title || \"\")));\n const hasEmojis = stepEmojis.some((e) => (e || \"\").length > 0);\n const isLandscape = width > height;\n const layout = computeStepsLayout({ width, height, labels: stepLabels, safeZone });\n const connectorPairs = layout.items.slice(0, -1).map((item, index) => ({\n item,\n next: layout.items[index + 1],\n }));\n const lineProgress = interpolate(progress, [0.07, 0.62], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n // ── Animation timing ──────────────────────────────────────────\n const firstStep = 0.08;\n\n // ── Exit cascade — slide left + fade in reading order ────────\n const EXIT_START = 0.78;\n const EXIT_DURATION = 0.10;\n const EXIT_STAGGER = 0.04;\n const exitForIndex = (i: number) => {\n const start = EXIT_START + i * EXIT_STAGGER;\n const raw = interpolate(progress, [start, start + EXIT_DURATION], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n const eased = raw * raw; // ease-in\n return { translateX: -90 * s * eased, fade: 1 - eased };\n };\n\n return (\n <>\n {connectorPairs.map(({ item, next }, index) => {\n const exit = exitForIndex(index);\n const segmentProgress = interpolate(\n lineProgress,\n [index / connectorPairs.length, (index + 1) / connectorPairs.length],\n [0, 1],\n { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" },\n );\n\n return (\n <div\n key={`step-connector-${index}`}\n style={layout.mode === \"portrait-timeline\" ? {\n position: \"absolute\",\n left: item.x - 2 * s,\n top: item.y + item.circleRadius,\n width: 4 * s,\n height: Math.max(\n 0,\n next.y - next.circleRadius - (item.y + item.circleRadius),\n ),\n borderRadius: 999,\n background: `linear-gradient(to bottom, ${withOpacity(accent, 0.9)}, ${withOpacity(textColor, 0.22)})`,\n transform: `translateX(${exit.translateX}px) scaleY(${segmentProgress})`,\n transformOrigin: \"top\",\n opacity: 0.75 * exit.fade,\n } : {\n position: \"absolute\",\n left: item.x + item.circleRadius,\n top: item.y - 2 * s,\n width: Math.max(\n 0,\n next.x - next.circleRadius - (item.x + item.circleRadius),\n ),\n height: 4 * s,\n borderRadius: 999,\n background: `linear-gradient(to right, ${withOpacity(accent, 0.9)}, ${withOpacity(textColor, 0.22)})`,\n transform: `translateX(${exit.translateX}px) scaleX(${segmentProgress})`,\n transformOrigin: \"left\",\n opacity: 0.75 * exit.fade,\n }}\n />\n );\n })}\n\n {layout.items.map((pos, i) => {\n const circleRadius = pos.circleRadius;\n const circleSize = circleRadius * 2;\n const emojiFontSize = circleRadius * (isLandscape ? 0.9 : 0.8);\n const numberFontSize = circleRadius * 0.7;\n const itemStart = firstStep;\n\n const circleP = spring(\n interpolate(progress, [itemStart, itemStart + 0.12], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n }),\n SPRING_POP,\n );\n const circleScale = interpolate(circleP, [0, 1], [0, 1]);\n\n const contentDelay = itemStart + 0.03;\n const contentOpacity = interpolate(progress, [contentDelay, contentDelay + 0.05], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n const titleDelay = itemStart + 0.05;\n const titleP = spring(\n interpolate(progress, [titleDelay, titleDelay + 0.10], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n }),\n SPRING_TITLE,\n );\n const titleSlide = isLandscape\n ? (1 - titleP) * 40 * s\n : (1 - titleP) * 120 * s;\n const titleOpacity = interpolate(progress, [titleDelay, titleDelay + 0.05], [0, 1], {\n extrapolateLeft: \"clamp\",\n extrapolateRight: \"clamp\",\n });\n\n const emoji = hasEmojis ? (stepEmojis[i] || \"\") : \"\";\n const glowOpacity = circleScale * 0.2;\n const exit = exitForIndex(i);\n\n return (\n <div\n key={`step-${i}`}\n data-template-item=\"steps\"\n style={{\n position: \"absolute\",\n top: pos.y - circleRadius,\n left: 0,\n width,\n height: circleSize,\n transform: `translateX(${exit.translateX}px)`,\n opacity: exit.fade,\n }}\n >\n {/* Radial glow */}\n <div\n style={{\n position: \"absolute\",\n top: -circleRadius * 0.6,\n left: pos.x - circleRadius * 1.75,\n width: circleRadius * 3.5,\n height: circleRadius * 3.5,\n borderRadius: \"50%\",\n background: `radial-gradient(circle, ${withOpacity(accent, 0.4)} 0%, transparent 70%)`,\n opacity: glowOpacity,\n pointerEvents: \"none\",\n }}\n />\n\n {/* Circle — numbered mode fills solid accent with an\n auto-contrast numeral: the old accent-on-translucent\n combo went illegible whenever accent ≈ background.\n Emoji mode keeps the translucent circle (emoji glyphs\n carry their own color). Matches infographic-steps.tsx. */}\n <div\n style={{\n position: \"absolute\",\n left: pos.x - circleRadius,\n top: 0,\n width: circleSize,\n height: circleSize,\n borderRadius: \"50%\",\n backgroundColor: emoji ? \"rgba(255,255,255,0.15)\" : accent,\n border: \"2px solid rgba(255,255,255,0.2)\",\n display: \"flex\",\n justifyContent: \"center\",\n alignItems: \"center\",\n transform: `scale(${circleScale})`,\n boxShadow: `0 0 ${20 * s}px ${withOpacity(accent, glowOpacity)}`,\n }}\n >\n {emoji ? (\n <Emoji char={emoji} size={emojiFontSize} verticalAlign=\"middle\" style={{ opacity: contentOpacity }} />\n ) : (\n <span\n style={{\n fontSize: numberFontSize,\n fontWeight: 800,\n color: autoTextColor(accent),\n fontFamily: font,\n lineHeight: 1,\n opacity: contentOpacity,\n }}\n >\n {i + 1}\n </span>\n )}\n </div>\n\n {/* Title — beside the portrait timeline or below the landscape row. */}\n <div\n style={{\n position: \"absolute\",\n left: pos.labelLeft,\n top: pos.labelTop - (pos.y - circleRadius),\n width: pos.labelWidth,\n height: isLandscape ? 80 * s : circleSize,\n display: \"flex\",\n alignItems: isLandscape ? \"flex-start\" : \"center\",\n justifyContent: isLandscape ? \"center\" : \"flex-start\",\n fontSize: pos.labelFontSize,\n fontWeight: 600,\n color: textColor,\n fontFamily: font,\n letterSpacing: -0.3,\n textAlign: isLandscape ? \"center\" : \"left\",\n opacity: titleOpacity,\n transform: isLandscape ? `translateY(${titleSlide}px)` : `translateX(${titleSlide}px)`,\n textShadow: \"0 1px 6px rgba(0,0,0,0.1)\",\n lineHeight: 1.12,\n whiteSpace: \"normal\",\n }}\n >\n {stepLabels[i]}\n </div>\n </div>\n );\n })}\n </>\n );\n};\n"
41
- },
42
- {
43
- "path": "src/visual-system/emoji/index.tsx",
44
- "type": "registry:component",
45
- "target": "vanillasky/emoji/index.tsx",
46
- "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"
47
- },
48
- {
49
- "path": "src/visual-system/scene-templates/template-text.tsx",
50
- "type": "registry:component",
51
- "target": "vanillasky/scene-templates/template-text.tsx",
52
- "content": "/**\n * TemplateText — unified text component for scene templates.\n *\n * Replaces the per-template hand-rolled text rendering with a single component\n * that owns: archetype motion lifecycle (entrance + hold + exit), font sizing,\n * position, beat pulse, and safe zone.\n *\n * Each template declares its constraints (position + sizeRole) at the call site;\n * the user/AI picks the archetype. Templates that can only show text at the top\n * just always pass position=\"top\".\n *\n * Example — a data template (caption above a chart):\n *\n * <TemplateText\n * archetype={textArchetype}\n * text={variables.title}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * position=\"top\"\n * sizeRole=\"caption\"\n * />\n *\n * Example — a media template (full-frame headline):\n *\n * <TemplateText\n * archetype={textArchetype}\n * text={variables.headline}\n * progress={progress}\n * sceneDuration={sceneDuration}\n * width={width}\n * height={height}\n * position=\"center\"\n * sizeRole=\"headline\"\n * beatIntensity={beatIntensity}\n * />\n *\n * Note: `textArchetype` is destructured from props (a scene-level\n * field on `SceneTemplateProps`), NOT read from `variables`. Copying\n * the wrong pattern silently no-ops — the executor routes\n * `setSceneVariable(\"textArchetype\", ...)` to the scene-level field,\n * never into variables, so `variables.textArchetype` is always\n * undefined.\n */\n\nimport {\n renderArchetype,\n normalizeArchetype,\n type TextArchetype,\n type ArchetypeRender,\n} from \"../typography\";\nimport { MEDIA_TEXT_SHADOW } from \"../theme\";\nimport type { TypeTreatment } from \"../theme\";\nimport { renderWithEmoji, planTypewriterEmoji } from \"../emoji/emoji-text\";\nimport { Emoji } from \"../emoji\";\n\nexport type TextPosition = \"top\" | \"center\" | \"bottom\";\nexport type TextSizeRole = \"headline\" | \"caption\" | \"label\";\n\nexport interface SafeZone {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\nexport interface TemplateTextProps {\n archetype: TextArchetype;\n text: string;\n /** Scene progress 0→1. */\n progress: number;\n /** Presentation clock; VideoFrame keeps it on the complete scene timeline. */\n motionProgress?: number;\n /** Scene duration in seconds — drives entrance/exit phase scaling. */\n sceneDuration: number;\n /** Frame width in pixels (1080 in production, smaller in previews). */\n width: number;\n /** Frame height in pixels (1920 in production). */\n height: number;\n /** Where the text box sits in the frame. Templates declare this. */\n position?: TextPosition;\n /** Size envelope. Templates declare this. */\n sizeRole?: TextSizeRole;\n /** Preset type treatment — weight/tracking/size/case shift from style.preset. */\n typeTreatment?: TypeTreatment;\n /** Padding from frame edges. Defaults to a 24px box. */\n safeZone?: SafeZone;\n /** Font family. */\n font?: string;\n /** Fill color. */\n color?: string;\n /** Beat intensity 0→1 (currently unused — kept for forward compat). */\n beatIntensity?: number;\n /** True when this text renders over a photo or video backdrop. Swaps the\n * gradient-tuned hairline shadow for the media halo, which is what keeps\n * the scrim behind it light enough to leave the picture intact. */\n overMedia?: boolean;\n}\n\nconst DEFAULT_SAFE_ZONE: SafeZone = { top: 24, right: 24, bottom: 24, left: 24 };\nconst DEFAULT_FONT =\n \"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif\";\n\n// ─── Typography constants ───────────────────────────────────────\n// All em-based so they scale with font size and behave consistently across\n// font families. Values from print/motion-design conventions:\n//\n// - Big text (display/headline) gets TIGHTER tracking and TIGHTER leading.\n// -0.022em (≈ -2.2%) is the sweet spot for 48–88px headlines on most\n// sans-serifs (Inter, Helvetica, SF Pro, Manrope, Geist).\n// - Word-spacing kept subtle (≤0.08em). CSS word-spacing is ADDITIVE on\n// top of the natural space char, so what looks like \"a touch of\n// rhythm\" in print becomes a visible double-gap on 80px motion\n// headlines (especially under wordStagger, where each word renders\n// as an inline-block and the gap between them is preserved). Old\n// values (0.16/0.18/0.22 em) added ~13–18px per gap on display\n// type — the \"too much space between words\" symptom.\n// - Line-height 1.1 for headlines, 1.5 for body — Bringhurst-aligned ratios.\n// - kern + liga always on so any font's pair-kerning and ligatures fire\n// consistently (works across Inter, Manrope, SF, IBM Plex, etc.).\nconst TYPO = {\n headline: {\n letterSpacing: \"-0.022em\",\n wordSpacing: \"0.04em\",\n lineHeight: 1.1,\n },\n caption: {\n letterSpacing: \"-0.012em\",\n wordSpacing: \"0.06em\",\n lineHeight: 1.25,\n },\n label: {\n letterSpacing: \"-0.005em\",\n wordSpacing: \"0.10em\",\n lineHeight: 1.4,\n },\n};\nconst FONT_FEATURES = '\"kern\" 1, \"liga\" 1';\n\n// Drop shadow tuned to give crisp edges on retina without muddying text on\n// saturated gradients. Earlier two-layer shadow (1px tight + 16px wide) cast\n// dark halos that made gradient-backed text look smudged. A single barely-\n// there shadow is enough for edge definition. Text over a photo or video\n// takes MEDIA_TEXT_SHADOW instead — see `overMedia`.\nfunction dropShadowFor(textColor: string): string {\n const dark = isLikelyDark(textColor);\n const tone = dark ? \"rgba(255,255,255,0.15)\" : \"rgba(0,0,0,0.2)\";\n return `0 1px 2px ${tone}`;\n}\n\nfunction isLikelyDark(color: string): boolean {\n // Crude luminance check — handles #rrggbb and #rgb. Anything we can't parse\n // (named colors, rgb()) defaults to \"not dark\" so the heavier shadow shows.\n const m = color.replace(\"#\", \"\");\n if (m.length === 3) {\n const r = parseInt(m[0] + m[0], 16);\n const g = parseInt(m[1] + m[1], 16);\n const b = parseInt(m[2] + m[2], 16);\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n }\n if (m.length === 6) {\n const r = parseInt(m.slice(0, 2), 16);\n const g = parseInt(m.slice(2, 4), 16);\n const b = parseInt(m.slice(4, 6), 16);\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n }\n return false;\n}\n// ─── Font sizing matrix ─────────────────────────────────────────\n// Mirrors what production templates actually render today, ported from:\n// - text-overlay.tsx (headline: 80/64/48 × s_min by char count)\n// - infographic-steps.tsx (caption: ~44 × s_min capped by layout)\n\n/**\n * Compute heroWord font size for a single word. Each active word fills its\n * own moment — trailer convention. Center can go large (480ref cap); top\n * stays inside the top zone height so it doesn't crash into the animation\n * or data viz below.\n */\nfunction computeHeroFontSize(\n wordChars: number,\n position: TextPosition,\n width: number,\n height: number,\n safeZone: SafeZone,\n): number {\n const chars = Math.max(wordChars, 1);\n const s_min = Math.min(width, height) / 1080;\n const widthBudget = (width - safeZone.left - safeZone.right) * 0.92;\n const pxPerChar = 0.58; // bold sans-serif approximation\n const widthCap = widthBudget / (chars * pxPerChar);\n\n if (position === \"center\") {\n return Math.min(480 * s_min, widthCap);\n }\n\n // Top/bottom: keep the word inside its zone (~28% of frame height) with\n // 80% headroom for entrance overshoot + breathe. Reference target 220ref\n // so even short words stay big without overflowing the zone.\n const heightCap = height * 0.28 * 0.8;\n return Math.min(220 * s_min, widthCap, heightCap);\n}\n\n/**\n * Smooth interpolation between max and min font size based on character count.\n * Avoids the visible \"jump\" you get from bucket boundaries when copy length\n * crosses a threshold (e.g., 25→26 chars dropping headline from 80px to 64px).\n *\n * Exported so templates that lay out their own text can match the headline\n * curve instead of inventing their own bucketed scaling.\n *\n * Returns size at 1080-reference scale; caller multiplies by s_min.\n */\nexport function smoothSize(chars: number, maxChars: number, max: number, min: number): number {\n const t = Math.max(0, Math.min(1, chars / maxChars));\n // Slight curve so short text stays at maxSize longer before scaling down.\n const eased = t * t;\n return max - (max - min) * eased;\n}\n\nfunction computeFontSize(\n archetype: TextArchetype,\n text: string,\n role: TextSizeRole,\n position: TextPosition,\n width: number,\n height: number,\n safeZone: SafeZone,\n): { fontSize: number; fontWeight: number } {\n const s_min = Math.min(width, height) / 1080;\n\n // heroWord container fontSize uses the longest word as a safe fallback. The\n // ACTIVE-word size is recomputed per-render in the \"hero\" render branch\n // (via computeHeroFontSize) so each word fills its own moment optimally —\n // trailer convention.\n if (archetype === \"heroWord\") {\n const longestChars = text\n .split(/\\s+/)\n .filter(Boolean)\n .reduce((m, w) => Math.max(m, w.length), 1);\n return {\n fontSize: computeHeroFontSize(longestChars, position, width, height, safeZone),\n fontWeight: 800,\n };\n }\n\n const chars = text.length;\n\n // Smooth scaling, minimums set so even long copy stays readable in production\n // (1080 reference). Numbers tuned to match — but improve on — the previous\n // bucketed system.\n if (role === \"headline\") {\n // Floor 60 (was 48) — matches the typography guideline \"Titles/headlines\n // 60-86px at 1080\" and lifts long-copy headlines off the body-text floor\n // that left ProblemSolution-shaped statements feeling small. Max held at\n // 88 so short, punchy headlines still fill the frame.\n const refSize = smoothSize(chars, /* maxChars */ 70, /* max */ 88, /* min */ 60);\n return { fontSize: refSize * s_min, fontWeight: 700 };\n }\n\n if (role === \"caption\") {\n const refSize = smoothSize(chars, 80, 48, 32);\n return { fontSize: refSize * s_min, fontWeight: 600 };\n }\n\n // label\n const refSize = smoothSize(chars, 80, 34, 24);\n return { fontSize: refSize * s_min, fontWeight: 500 };\n}\n\n// ─── Positioning ───────────────────────────────────────────────\n\nfunction positionStyle(\n position: TextPosition,\n height: number,\n safeZone: SafeZone,\n): React.CSSProperties {\n switch (position) {\n case \"top\":\n return {\n top: safeZone.top + height * 0.06,\n height: height * 0.28,\n alignItems: \"flex-start\",\n };\n case \"bottom\":\n return {\n bottom: safeZone.bottom + height * 0.06,\n height: height * 0.28,\n alignItems: \"flex-end\",\n };\n case \"center\":\n default:\n return {\n top: 0,\n bottom: 0,\n alignItems: \"center\",\n };\n }\n}\n\n// ─── Component ─────────────────────────────────────────────────\n\nexport const TemplateText: React.FC<TemplateTextProps> = ({\n archetype: archetypeRaw,\n text: textRaw,\n progress,\n motionProgress = progress,\n sceneDuration,\n width,\n height,\n position = \"center\",\n sizeRole = \"headline\",\n typeTreatment,\n safeZone = DEFAULT_SAFE_ZONE,\n font = DEFAULT_FONT,\n color = \"#FFFFFF\",\n beatIntensity = 0,\n overMedia = false,\n}) => {\n // Defensive coerce: TemplateText is downstream of ~16 templates that pass\n // their own `variables.X` strings. If any one of them passes undefined\n // (missing variable on a freshly added scene, stale saved config, custom\n // template not setting a field), the unguarded `.split` / `.length` calls\n // below crash the entire preview. Treat undefined/non-string as\n // empty so a single bad scene doesn't take everything down. Warn in dev\n // so the upstream gap still surfaces.\n const textSafe = typeof textRaw === \"string\" ? textRaw : \"\";\n const isDevelopment = (import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV;\n if (textRaw !== undefined && typeof textRaw !== \"string\" && isDevelopment) {\n console.warn(\"[TemplateText] received non-string text:\", textRaw);\n }\n // `|` is the AI's explicit line-break convention for headline copy\n // (\"Built for speed.|Designed for you.\"). Convert centrally so EVERY\n // template that renders text through TemplateText honors it — bg-media\n // used to convert locally while confetti/emojiBurst/etc. rendered the\n // pipe literally. Whitespace around the pipe is trimmed so spaced and\n // unspaced pipes produce identical output. The container's\n // `white-space: pre-line` renders the resulting `\\n` as a hard break.\n // Templates that legitimately render pipes (code, terminal commands)\n // don't flow through TemplateText, so they're unaffected.\n const text = textSafe.replace(/\\s*\\|\\s*/g, \"\\n\");\n // Normalize the archetype prop so unknown names fall back safely.\n const archetype = normalizeArchetype(archetypeRaw);\n const scale = Math.min(width, height) / 1080;\n // Motion pacing applies at every size role — a calm video should ease its\n // captions in too, not just its headlines. (The rest of the treatment is\n // headline-only; see `tt` below.)\n const result: ArchetypeRender = renderArchetype(\n archetype,\n progress,\n scale,\n text,\n sceneDuration,\n typeTreatment?.phaseScale ?? 1,\n motionProgress,\n );\n const { fontSize, fontWeight } = computeFontSize(\n archetype,\n text,\n sizeRole,\n position,\n width,\n height,\n safeZone,\n );\n\n // beatIntensity reserved for future use; currently a no-op on text body.\n void beatIntensity;\n\n const baseTypo = TYPO[sizeRole];\n // Preset type treatment. Absent (or the default preset's zero-deltas) leaves\n // every value exactly as it was, so unpresetted configs are unaffected.\n const tt = sizeRole === \"headline\" ? typeTreatment : undefined;\n // Only rewrite a value the preset actually changes — reformatting\n // letterSpacing with a zero delta would alter the emitted string (and every\n // stability snapshot) without changing the render.\n const typo = tt\n ? {\n ...baseTypo,\n ...(tt.trackingDeltaEm !== 0\n ? {\n letterSpacing: `${Number(\n (parseFloat(baseTypo.letterSpacing) + tt.trackingDeltaEm).toFixed(4),\n )}em`,\n }\n : {}),\n ...(tt.transform ? { textTransform: tt.transform } : {}),\n }\n : baseTypo;\n const presetWeight = tt ? Math.min(900, Math.max(100, fontWeight + tt.weightDelta)) : fontWeight;\n const presetSize = tt ? fontSize * tt.sizeScale : fontSize;\n // Type over a photograph is light type, whatever the brand's foreground is.\n // Everything under it assumes so: the vignette and scrims darken the plate,\n // and MEDIA_TEXT_SHADOW is a dark halo sized to hold white at 4.5:1. A brand\n // with a dark foreground otherwise puts black words over footage with a\n // black glow behind them, which is unreadable on any frame.\n const overMediaColor = overMedia && isLikelyDark(color) ? \"#FFFFFF\" : color;\n const textShadow = overMedia ? MEDIA_TEXT_SHADOW : dropShadowFor(color);\n\n const containerStyle: React.CSSProperties = {\n position: \"absolute\",\n left: 0,\n right: 0,\n display: \"flex\",\n justifyContent: \"center\",\n padding: `0 ${safeZone.right}px 0 ${safeZone.left}px`,\n color: overMediaColor,\n fontFamily: font,\n fontWeight: presetWeight,\n fontSize: presetSize,\n textAlign: \"center\",\n pointerEvents: \"none\",\n fontFeatureSettings: FONT_FEATURES,\n textRendering: \"optimizeLegibility\",\n WebkitFontSmoothing: \"antialiased\",\n MozOsxFontSmoothing: \"grayscale\",\n // Respect explicit newlines — the centralized `|` → `\\n` conversion\n // above (and callers passing real newlines) rely on this. Multiple\n // spaces still collapse normally; only `\\n` and CRLF break.\n whiteSpace: \"pre-line\",\n ...(textShadow ? { textShadow } : {}),\n ...typo,\n ...positionStyle(position, height, safeZone),\n };\n\n if (result.kind === \"block\") {\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.block.opacity,\n transform: `${result.block.transform}`,\n // Inherit letter-spacing from the container's typography defaults\n // unless the archetype explicitly overrides (e.g., for animated tracking).\n ...(result.block.letterSpacing ? { letterSpacing: result.block.letterSpacing } : {}),\n ...(result.block.willChange ? { willChange: result.block.willChange } : {}),\n maxWidth: \"85%\",\n }}\n >\n {renderWithEmoji(result.text, fontSize)}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"typewriter\") {\n // Render every character as its own span so the FULL TEXT always sets the\n // layout — wrapping is decided by the complete string, not the typed\n // prefix. The cursor is overlaid with position:absolute from the last\n // typed char so it doesn't break the word it's inside.\n const chars = text.split(\"\");\n // Map cluster-start code-unit indices → full emoji graphemes so emoji use\n // the native font even in the per-char typewriter reveal. Indexing\n // stays on text.length (UTF-16 units) so result.visibleChars / charExits\n // line up exactly; continuation units of a cluster render nothing.\n const emojiPlan = planTypewriterEmoji(text);\n const cursorBar = {\n position: \"absolute\" as const,\n width: \"0.08em\",\n height: \"0.88em\",\n background: \"currentColor\",\n borderRadius: \"0.01em\",\n pointerEvents: \"none\" as const,\n };\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.opacity,\n maxWidth: \"85%\",\n whiteSpace: \"pre-wrap\",\n position: \"relative\",\n }}\n >\n {chars.map((ch, i) => {\n const isTyped = i < result.visibleChars;\n const isLastTyped = i === result.visibleChars - 1;\n const anchorCursor = isLastTyped && result.cursor;\n const charExit = result.charExits?.[i];\n const baseOpacity = isTyped ? 1 : 0;\n const finalOpacity = baseOpacity * (charExit?.opacity ?? 1);\n // During exit the per-char span needs inline-block so translateX\n // takes effect; whiteSpace: pre keeps space chars from collapsing.\n const exitStyle = charExit\n ? {\n display: \"inline-block\" as const,\n transform: `translateX(${charExit.translateX}px)`,\n whiteSpace: \"pre\" as const,\n }\n : null;\n // Emoji handling: a cluster-start unit renders the full grapheme;\n // its continuation units render nothing.\n const emojiChar = emojiPlan?.starts.get(i);\n if (emojiPlan?.covered.has(i)) return null;\n return (\n <span\n key={i}\n style={{\n opacity: finalOpacity,\n position: anchorCursor ? \"relative\" : \"static\",\n ...(exitStyle ?? {}),\n }}\n >\n {emojiChar ? <Emoji char={emojiChar} size={fontSize} /> : ch}\n {anchorCursor && (\n <span\n aria-hidden\n style={{\n ...cursorBar,\n left: \"100%\",\n top: \"0.08em\",\n marginLeft: \"0.12em\",\n }}\n />\n )}\n </span>\n );\n })}\n {result.visibleChars === 0 && result.cursor && (\n <span\n aria-hidden\n style={{\n ...cursorBar,\n left: 0,\n top: \"0.08em\",\n }}\n />\n )}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"words\") {\n // Render words inline-block with REAL space chars between them — word\n // spacing inherits from the container's typography defaults, matching\n // every other archetype's wrap behavior.\n return (\n <div style={containerStyle}>\n <div\n style={{\n opacity: result.blockOpacity,\n transform: `${result.blockTransform} `,\n maxWidth: \"85%\",\n }}\n >\n {result.words.map((w, i) => (\n <span key={i}>\n <span\n style={{\n display: \"inline-block\",\n opacity: w.style.opacity,\n transform: w.style.transform,\n }}\n >\n {renderWithEmoji(w.text, fontSize)}\n </span>\n {i < result.words.length - 1 ? \" \" : \"\"}\n </span>\n ))}\n </div>\n </div>\n );\n }\n\n if (result.kind === \"hero\") {\n // Per-word sizing: each active word fills its own moment optimally.\n const perWordFontSize = computeHeroFontSize(\n result.word.length,\n position,\n width,\n height,\n safeZone,\n );\n // Fixed-height slot so words of different sizes don't jump vertically.\n // Slot is the max possible hero size for this position; lineHeight: 1 on\n // the inner word locks the glyph box to the font height so flex-center\n // lands the glyph at the same Y for every word.\n const slotHeight =\n position === \"center\" ? 480 * scale : Math.min(220 * scale, height * 0.28 * 0.8);\n return (\n <div style={{ ...containerStyle, fontSize: perWordFontSize }}>\n <div\n style={{\n height: slotHeight,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n }}\n >\n <div\n style={{\n opacity: result.opacity,\n transform: `${result.transform} `,\n lineHeight: 1,\n ...(result.letterSpacing ? { letterSpacing: result.letterSpacing } : {}),\n }}\n >\n {renderWithEmoji(result.word, perWordFontSize)}\n </div>\n </div>\n </div>\n );\n }\n\n return null;\n};\n"
53
- },
54
- {
55
- "path": "src/visual-system/emoji/emoji-text.tsx",
56
- "type": "registry:component",
57
- "target": "vanillasky/emoji/emoji-text.tsx",
58
- "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"
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
- "path": "src/visual-system/parse-list.ts",
68
- "type": "registry:lib",
69
- "target": "vanillasky/parse-list.ts",
70
- "content": "// ─── List variable parser ──────────────────────────────────────\n//\n// Single source of truth for parsing list-shaped template variables\n// (items, texts, words, steps, itemEmojis, oldItems, newItems, etc.)\n//\n// Accepts any of:\n// - string[] → returned as-is (trimmed, filtered)\n// - { label | name | text | title | value }[] → text extracted\n// - JSON-encoded array string → parsed then normalized\n// - comma-separated string → split on commas\n// - single string / number / boolean → wrapped as single-item array\n// - null / undefined → []\n//\n// Never splits on semicolons. A single item may contain any punctuation.\n// Use this everywhere instead of ad-hoc `.split(\",\")`.\n\nexport function parseList(value: unknown, max?: number): string[] {\n if (value === null || value === undefined) return [];\n\n let arr: unknown[];\n\n if (Array.isArray(value)) {\n arr = value;\n } else if (typeof value === \"string\") {\n const trimmed = value.trim();\n if (!trimmed) return [];\n // Try JSON array first — supports real array output from AI\n if (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\")) {\n try {\n const parsed = JSON.parse(trimmed);\n arr = Array.isArray(parsed) ? parsed : [trimmed];\n } catch {\n arr = trimmed.split(\",\");\n }\n } else {\n // Accept a compact comma-separated list.\n arr = trimmed.split(\",\");\n }\n } else if (typeof value === \"number\" || typeof value === \"boolean\") {\n arr = [value];\n } else if (typeof value === \"object\") {\n arr = [value];\n } else {\n return [];\n }\n\n const out = arr\n .map((item) => {\n if (item === null || item === undefined) return \"\";\n if (typeof item === \"string\") return item.trim();\n if (typeof item === \"number\" || typeof item === \"boolean\") return String(item);\n if (typeof item === \"object\") {\n const o = item as Record<string, unknown>;\n const picked =\n o.label ?? o.name ?? o.text ?? o.title ?? o.description ?? o.value;\n return picked !== undefined ? String(picked).trim() : \"\";\n }\n return String(item).trim();\n })\n .filter((s) => s.length > 0);\n\n return typeof max === \"number\" ? out.slice(0, max) : out;\n}\n"
71
- }
72
- ],
73
- "meta": {
74
- "vanillasky": {
75
- "layer": "template",
76
- "tier": "free",
77
- "register": "card-led",
78
- "jobs": [
79
- "setup",
80
- "proof"
81
- ],
82
- "useWhen": "Two or three grounded actions form a real sequence, workflow, or how-it-works explanation.",
83
- "textCanvas": "tight",
84
- "minDuration": 2,
85
- "preferredDuration": 3.5,
86
- "schema": {
87
- "type": "object",
88
- "properties": {
89
- "texts": {
90
- "type": "string",
91
- "title": "Text",
92
- "description": "Title text",
93
- "default": "How it works.",
94
- "maxLength": 48
95
- },
96
- "steps": {
97
- "type": "array",
98
- "title": "Steps",
99
- "description": "JSON array of 2 or 3 step labels. Send only the steps the evidence supports — never pad the sequence to reach three. Keep each label to 1-2 words and at most 18 characters. Send as an array so labels with internal punctuation render correctly. Example: [\"Capture\", \"Detect\", \"Notify\"]",
100
- "items": {
101
- "type": "string"
102
- },
103
- "minItems": 2,
104
- "maxItems": 3,
105
- "default": [
106
- "Describe",
107
- "Preview",
108
- "Export"
109
- ]
110
- },
111
- "stepEmojis": {
112
- "type": "array",
113
- "title": "Step emojis",
114
- "description": "JSON array of emojis, one per step (optional). Leave empty for numbered circles. Example: [\"✍️\", \"👀\", \"🚀\"]",
115
- "examples": [
116
- [
117
- "✍️",
118
- "👀",
119
- "🚀"
120
- ]
121
- ],
122
- "items": {
123
- "type": "string"
124
- },
125
- "maxItems": 3,
126
- "default": []
127
- },
128
- "mediaUrl": {
129
- "type": "string",
130
- "title": "Background media",
131
- "description": "Optional photo or video URL behind this scene. When set, replaces the brand gradient.",
132
- "format": "uri",
133
- "default": ""
134
- },
135
- "mediaKeyword": {
136
- "type": "string",
137
- "title": "Background search keyword",
138
- "description": "2-4 word English term for stock-media search (auto-fills mediaUrl).",
139
- "format": "stock-media-keyword",
140
- "default": ""
141
- },
142
- "mediaType": {
143
- "type": "string",
144
- "title": "Background media type",
145
- "description": "auto detects photo/video from URL. 'gradient' is a deliberate mode — atmospheric brand-color scene with no stock footage.",
146
- "enum": [
147
- "auto",
148
- "photo",
149
- "video",
150
- "gradient"
151
- ],
152
- "default": "auto"
153
- },
154
- "mediaPoster": {
155
- "type": "string",
156
- "title": "Background poster image",
157
- "description": "Still image URL shown while a video backdrop is decoding its first frame. A media resolver can supply a poster alongside 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.",
158
- "format": "uri",
159
- "default": ""
160
- },
161
- "mediaPosition": {
162
- "type": "string",
163
- "title": "Background focal position",
164
- "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.",
165
- "enum": [
166
- "center",
167
- "top",
168
- "bottom",
169
- "left",
170
- "right"
171
- ],
172
- "default": "center"
173
- },
174
- "mediaTreatment": {
175
- "type": "string",
176
- "title": "Background contrast treatment",
177
- "description": "none leaves the picture untouched, for a scene carrying no type; subtle preserves a visual hero; cinematic adds balanced contrast; text-safe adds a stronger wash for copy-heavy scenes.",
178
- "enum": [
179
- "none",
180
- "subtle",
181
- "cinematic",
182
- "text-safe"
183
- ],
184
- "default": "cinematic"
185
- }
186
- },
187
- "required": [
188
- "texts",
189
- "steps"
190
- ],
191
- "additionalProperties": false,
192
- "x-vanillasky": {
193
- "allowsStockMedia": true
194
- }
195
- },
196
- "label": "Steps",
197
- "description": "Two or three ordered workflow steps reveal as a vertical sequence.",
198
- "family": "Explainers",
199
- "avoidWhen": "Order does not matter, fewer than two steps exist, or the content is a parallel feature list.",
200
- "usesGlobalTextEffect": false,
201
- "usesGlobalTransition": true,
202
- "usesGlobalBackgroundEffect": true,
203
- "timing": {
204
- "contentFields": [
205
- "steps"
206
- ],
207
- "contentUnit": "items"
208
- },
209
- "transitionTiming": {
210
- "entryReadyProgress": 0.2,
211
- "holdProgress": 0.7
212
- }
213
- }
214
- }
215
- }