framefields 2.5.5 → 2.6.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 (292) hide show
  1. package/README.md +11 -0
  2. package/dist/audio/index.mjs +10 -10
  3. package/dist/{audio-C9euVW4K.mjs → audio-QBA2VogL.mjs} +3 -3
  4. package/dist/{audio-C9euVW4K.mjs.map → audio-QBA2VogL.mjs.map} +1 -1
  5. package/dist/cli.mjs +866 -16
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/components/index.d.mts +2 -2
  8. package/dist/components/index.mjs +43 -43
  9. package/dist/{dist-BuirkW-f.mjs → dist-CDwWQYg3.mjs} +2 -2
  10. package/dist/dist-CDwWQYg3.mjs.map +1 -0
  11. package/dist/{dist-DBdxM6xv.mjs → dist-nHCqVk-e.mjs} +3 -3
  12. package/dist/dist-nHCqVk-e.mjs.map +1 -0
  13. package/dist/effects/index.mjs +34 -34
  14. package/dist/{effects-B263lgUi.mjs → effects-C-6I4iYW.mjs} +2 -2
  15. package/dist/{effects-B263lgUi.mjs.map → effects-C-6I4iYW.mjs.map} +1 -1
  16. package/dist/fonts/index.d.mts +1 -1
  17. package/dist/fonts/index.mjs +2 -2
  18. package/dist/{fonts-DNx6ps2p.mjs → fonts-BieW3VWV.mjs} +7 -3
  19. package/dist/fonts-BieW3VWV.mjs.map +1 -0
  20. package/dist/{gpu-BRBIoxMC.mjs → gpu-Bb4WmYYH.mjs} +2 -2
  21. package/dist/{gpu-BRBIoxMC.mjs.map → gpu-Bb4WmYYH.mjs.map} +1 -1
  22. package/dist/index-D-Gnyffj.d.mts.map +1 -1
  23. package/dist/{index-N1tMc5Z9.d.mts → index-E1IrPAm4.d.mts} +629 -203
  24. package/dist/index-E1IrPAm4.d.mts.map +1 -0
  25. package/dist/{index-CalU-mpZ.d.mts → index-j5LGVVbm.d.mts} +3 -1
  26. package/dist/index-j5LGVVbm.d.mts.map +1 -0
  27. package/dist/index.d.mts +3 -3
  28. package/dist/index.mjs +50 -50
  29. package/dist/preview/index.mjs +10 -10
  30. package/dist/{preview-ChR8Z8Fm.mjs → preview-D6jS38Di.mjs} +2 -2
  31. package/dist/{preview-ChR8Z8Fm.mjs.map → preview-D6jS38Di.mjs.map} +1 -1
  32. package/dist/preview-engine/{chunk-ZJLNXN7W.mjs → chunk-AYTP6OGO.mjs} +6 -6
  33. package/dist/preview-engine/chunk-MLAIEIUF.mjs +1974 -0
  34. package/dist/preview-engine/chunk-W3I7LKDV.mjs +5 -0
  35. package/dist/preview-engine/chunk-ZMMNN77R.mjs +3 -0
  36. package/dist/preview-engine/components.mjs +1 -1
  37. package/dist/preview-engine/fonts.mjs +1 -1
  38. package/dist/preview-engine/framefields.mjs +1 -1
  39. package/dist/preview-engine/player.mjs +1 -1
  40. package/dist/preview-engine/{renderers-WZEV464O.mjs → renderers-4G3UOPA7.mjs} +1 -1
  41. package/dist/{program-O2bWX0cs.mjs → program-Bf4yHbHC.mjs} +19 -3
  42. package/dist/program-Bf4yHbHC.mjs.map +1 -0
  43. package/dist/registry/annotate.json +1 -1
  44. package/dist/registry/app-frame.json +1 -1
  45. package/dist/registry/backdrop.json +3 -3
  46. package/dist/registry/before-after.json +1 -1
  47. package/dist/registry/callout.json +1 -1
  48. package/dist/registry/captions.json +7 -1
  49. package/dist/registry/card-stack.json +3 -3
  50. package/dist/registry/chat-thread.json +7 -1
  51. package/dist/registry/code-block.json +8 -2
  52. package/dist/registry/confetti.json +1 -1
  53. package/dist/registry/device-frame.json +2 -1
  54. package/dist/registry/focus-zoom.json +1 -1
  55. package/dist/registry/logo-reveal.json +1 -1
  56. package/dist/registry/lower-third.json +1 -1
  57. package/dist/registry/meter.json +1 -1
  58. package/dist/registry/mouse-pointer.json +1 -1
  59. package/dist/registry/selection.json +3 -3
  60. package/dist/registry/spotlight.json +1 -1
  61. package/dist/registry/terminal.json +7 -1
  62. package/dist/registry/text-reveal.json +2 -1
  63. package/dist/registry/title-card.json +1 -1
  64. package/dist/registry/toasts.json +1 -1
  65. package/dist/registry/touch-point.json +4 -4
  66. package/dist/registry/waveform.json +3 -3
  67. package/dist/registry/word-cycle.json +2 -2
  68. package/dist/renderer/index.mjs +2 -2
  69. package/dist/renderer-4FQmduPw.mjs +5 -0
  70. package/dist/{renderer-DW21AyEv.mjs → renderer-4v6XYAiX.mjs} +3 -3
  71. package/dist/{renderer-DW21AyEv.mjs.map → renderer-4v6XYAiX.mjs.map} +1 -1
  72. package/dist/{renderer-YFHESw3v.mjs → renderer-79mO0b9Y.mjs} +3 -3
  73. package/dist/{renderer-YFHESw3v.mjs.map → renderer-79mO0b9Y.mjs.map} +1 -1
  74. package/dist/{renderer-DtqiWq1m.mjs → renderer-AOZWHGT3.mjs} +3 -3
  75. package/dist/{renderer-DtqiWq1m.mjs.map → renderer-AOZWHGT3.mjs.map} +1 -1
  76. package/dist/{renderer-Dw3K8KT9.mjs → renderer-B1Ox12RF.mjs} +2 -2
  77. package/dist/{renderer-Dw3K8KT9.mjs.map → renderer-B1Ox12RF.mjs.map} +1 -1
  78. package/dist/{renderer-DkM6Ikdf.mjs → renderer-B6vr-B0t.mjs} +3 -3
  79. package/dist/{renderer-DkM6Ikdf.mjs.map → renderer-B6vr-B0t.mjs.map} +1 -1
  80. package/dist/renderer-BBCpRuYU.mjs +5 -0
  81. package/dist/renderer-BBTo_jLO.mjs +5 -0
  82. package/dist/{renderer-CpDaUeCN.mjs → renderer-BDCS7k5X.mjs} +3 -3
  83. package/dist/{renderer-CpDaUeCN.mjs.map → renderer-BDCS7k5X.mjs.map} +1 -1
  84. package/dist/{renderer-DO1TeUqV.mjs → renderer-BFdgbqgW.mjs} +3 -3
  85. package/dist/{renderer-DO1TeUqV.mjs.map → renderer-BFdgbqgW.mjs.map} +1 -1
  86. package/dist/{renderer-CEAmByVW.mjs → renderer-BHgYRSu8.mjs} +2 -2
  87. package/dist/{renderer-CEAmByVW.mjs.map → renderer-BHgYRSu8.mjs.map} +1 -1
  88. package/dist/{renderer-CA_CwisV.mjs → renderer-BKssznKn.mjs} +2 -2
  89. package/dist/{renderer-CA_CwisV.mjs.map → renderer-BKssznKn.mjs.map} +1 -1
  90. package/dist/{renderer-DWG6EXGj.mjs → renderer-BL9vkn6m.mjs} +3 -3
  91. package/dist/{renderer-DWG6EXGj.mjs.map → renderer-BL9vkn6m.mjs.map} +1 -1
  92. package/dist/renderer-BLg66ZgG.mjs +5 -0
  93. package/dist/{renderer-RbMM6Njv.mjs → renderer-BQFxoNzi.mjs} +3 -3
  94. package/dist/{renderer-RbMM6Njv.mjs.map → renderer-BQFxoNzi.mjs.map} +1 -1
  95. package/dist/{renderer-x1xlZaH_.mjs → renderer-BQdn_qGp.mjs} +3 -3
  96. package/dist/{renderer-x1xlZaH_.mjs.map → renderer-BQdn_qGp.mjs.map} +1 -1
  97. package/dist/{renderer-DkOxEQsg.mjs → renderer-BR35Xp9n.mjs} +3 -3
  98. package/dist/{renderer-DkOxEQsg.mjs.map → renderer-BR35Xp9n.mjs.map} +1 -1
  99. package/dist/renderer-BScS8AzG.mjs +5 -0
  100. package/dist/{renderer-C2MaJKfY.mjs → renderer-BTjveb1Y.mjs} +3 -3
  101. package/dist/{renderer-C2MaJKfY.mjs.map → renderer-BTjveb1Y.mjs.map} +1 -1
  102. package/dist/renderer-Bb1cS5mJ.mjs +5 -0
  103. package/dist/{renderer-Dwh0jaor.mjs → renderer-Bbkf5EIi.mjs} +3 -3
  104. package/dist/{renderer-Dwh0jaor.mjs.map → renderer-Bbkf5EIi.mjs.map} +1 -1
  105. package/dist/{renderer-DsnV8tpW.mjs → renderer-BmgyvIC3.mjs} +3 -3
  106. package/dist/{renderer-DsnV8tpW.mjs.map → renderer-BmgyvIC3.mjs.map} +1 -1
  107. package/dist/{renderer-BfQdc3RM.mjs → renderer-Bpve7M6S.mjs} +69 -69
  108. package/dist/{renderer-BfQdc3RM.mjs.map → renderer-Bpve7M6S.mjs.map} +1 -1
  109. package/dist/renderer-Bpwd9zEY.mjs +5 -0
  110. package/dist/{renderer-C5QYI1Wu.mjs → renderer-BsnhAgrB.mjs} +2 -2
  111. package/dist/{renderer-C5QYI1Wu.mjs.map → renderer-BsnhAgrB.mjs.map} +1 -1
  112. package/dist/{renderer-BlU3Sh_0.mjs → renderer-By5xTRqB.mjs} +3 -3
  113. package/dist/{renderer-BlU3Sh_0.mjs.map → renderer-By5xTRqB.mjs.map} +1 -1
  114. package/dist/{renderer-D8m3fuRp.mjs → renderer-BzEQXAdl.mjs} +2 -2
  115. package/dist/{renderer-D8m3fuRp.mjs.map → renderer-BzEQXAdl.mjs.map} +1 -1
  116. package/dist/renderer-Bzyd9xi5.mjs +5 -0
  117. package/dist/{renderer-1NoJLLDa.mjs → renderer-C64g87yU.mjs} +3 -3
  118. package/dist/{renderer-1NoJLLDa.mjs.map → renderer-C64g87yU.mjs.map} +1 -1
  119. package/dist/{renderer-Daqvhwjp.mjs → renderer-C6Wb1TZT.mjs} +3 -3
  120. package/dist/{renderer-Daqvhwjp.mjs.map → renderer-C6Wb1TZT.mjs.map} +1 -1
  121. package/dist/{renderer-BWNQQZ12.mjs → renderer-CHPLHbAs.mjs} +3 -3
  122. package/dist/{renderer-BWNQQZ12.mjs.map → renderer-CHPLHbAs.mjs.map} +1 -1
  123. package/dist/{renderer-B5ZdkcMi.mjs → renderer-CJRu7lLH.mjs} +2 -2
  124. package/dist/{renderer-B5ZdkcMi.mjs.map → renderer-CJRu7lLH.mjs.map} +1 -1
  125. package/dist/{renderer-Bvizxddu.mjs → renderer-CMWUtfwn.mjs} +2 -2
  126. package/dist/{renderer-Bvizxddu.mjs.map → renderer-CMWUtfwn.mjs.map} +1 -1
  127. package/dist/{renderer-C3tdjg8B.mjs → renderer-CRkgyk08.mjs} +3 -3
  128. package/dist/{renderer-C3tdjg8B.mjs.map → renderer-CRkgyk08.mjs.map} +1 -1
  129. package/dist/renderer-CXVssx1k.mjs +5 -0
  130. package/dist/{renderer-DcApNxER.mjs → renderer-CY0BT6ok.mjs} +2 -2
  131. package/dist/{renderer-DcApNxER.mjs.map → renderer-CY0BT6ok.mjs.map} +1 -1
  132. package/dist/renderer-CYFiGLls.mjs +5 -0
  133. package/dist/{renderer-PhjHSS97.mjs → renderer-CYLP8sD6.mjs} +2 -2
  134. package/dist/{renderer-PhjHSS97.mjs.map → renderer-CYLP8sD6.mjs.map} +1 -1
  135. package/dist/{renderer-C6n8y6Pw.mjs → renderer-CY_1u6zi.mjs} +3 -3
  136. package/dist/{renderer-C6n8y6Pw.mjs.map → renderer-CY_1u6zi.mjs.map} +1 -1
  137. package/dist/renderer-C__BvvtP.mjs +5 -0
  138. package/dist/renderer-Ca3YZsoT.mjs +5 -0
  139. package/dist/{renderer-DADTT3ym.mjs → renderer-CcSBjtRa.mjs} +2 -2
  140. package/dist/{renderer-CnclEQdA.mjs → renderer-CgdplRri.mjs} +2 -2
  141. package/dist/{renderer-CnclEQdA.mjs.map → renderer-CgdplRri.mjs.map} +1 -1
  142. package/dist/{renderer-oR1LvHeO.mjs → renderer-Cn9tU_s5.mjs} +2 -2
  143. package/dist/{renderer-oR1LvHeO.mjs.map → renderer-Cn9tU_s5.mjs.map} +1 -1
  144. package/dist/renderer-CnM5iFj9.mjs +5 -0
  145. package/dist/{renderer-DHdFVpdH.mjs → renderer-Cqp_TUl_.mjs} +2 -2
  146. package/dist/{renderer-DHdFVpdH.mjs.map → renderer-Cqp_TUl_.mjs.map} +1 -1
  147. package/dist/{renderer-C8pJFxcL.mjs → renderer-CsOLHKFx.mjs} +2 -2
  148. package/dist/{renderer-C8pJFxcL.mjs.map → renderer-CsOLHKFx.mjs.map} +1 -1
  149. package/dist/{renderer-R5Lg65F8.mjs → renderer-D01RT3Ov.mjs} +2 -2
  150. package/dist/renderer-D1nZrvQx.mjs +5 -0
  151. package/dist/renderer-D6XjVQME.mjs +5 -0
  152. package/dist/{renderer-QQDU1b0Z.mjs → renderer-DAONCf_3.mjs} +3 -3
  153. package/dist/{renderer-QQDU1b0Z.mjs.map → renderer-DAONCf_3.mjs.map} +1 -1
  154. package/dist/{renderer-DTPmvL1i.mjs → renderer-DAfWLGfy.mjs} +3 -3
  155. package/dist/{renderer-DTPmvL1i.mjs.map → renderer-DAfWLGfy.mjs.map} +1 -1
  156. package/dist/{renderer-BvA4sQz5.mjs → renderer-DEeKkljI.mjs} +3 -3
  157. package/dist/{renderer-BvA4sQz5.mjs.map → renderer-DEeKkljI.mjs.map} +1 -1
  158. package/dist/{renderer-YBwN9uyj.mjs → renderer-DFYXjAIT.mjs} +5 -5
  159. package/dist/{renderer-YBwN9uyj.mjs.map → renderer-DFYXjAIT.mjs.map} +1 -1
  160. package/dist/renderer-DIuMnMF0.mjs +5 -0
  161. package/dist/{renderer-Dz4muMyE.mjs → renderer-DJHuPNJx.mjs} +2 -2
  162. package/dist/{renderer-Dz4muMyE.mjs.map → renderer-DJHuPNJx.mjs.map} +1 -1
  163. package/dist/{renderer-H4-8sBjl.mjs → renderer-DJTXhVNh.mjs} +2 -2
  164. package/dist/{renderer-H4-8sBjl.mjs.map → renderer-DJTXhVNh.mjs.map} +1 -1
  165. package/dist/{renderer-BMiZIGkZ.mjs → renderer-DNEFYrQ5.mjs} +2 -2
  166. package/dist/renderer-DUvvM8EF.mjs +5 -0
  167. package/dist/renderer-DWogyuMO.mjs +5 -0
  168. package/dist/renderer-DWsj_LJR.mjs +5 -0
  169. package/dist/{renderer-DGOY-LBR.mjs → renderer-DYJHFHF4.mjs} +2 -2
  170. package/dist/{renderer-DGOY-LBR.mjs.map → renderer-DYJHFHF4.mjs.map} +1 -1
  171. package/dist/{renderer-CVbNiH03.mjs → renderer-DYR4gFEM.mjs} +3 -3
  172. package/dist/{renderer-CVbNiH03.mjs.map → renderer-DYR4gFEM.mjs.map} +1 -1
  173. package/dist/renderer-DYun0DJl.mjs +5 -0
  174. package/dist/renderer-D_6YqD-Z.mjs +5 -0
  175. package/dist/{renderer-B0IJirDs.mjs → renderer-D_UPFbei.mjs} +3 -3
  176. package/dist/{renderer-B0IJirDs.mjs.map → renderer-D_UPFbei.mjs.map} +1 -1
  177. package/dist/renderer-Db9LP86x.mjs +5 -0
  178. package/dist/{renderer-CFRTJ7cT.mjs → renderer-DgRJG_l-.mjs} +2 -2
  179. package/dist/{renderer-CFRTJ7cT.mjs.map → renderer-DgRJG_l-.mjs.map} +1 -1
  180. package/dist/renderer-DhHiC87m.mjs +5 -0
  181. package/dist/{renderer-BZ5uOfoX.mjs → renderer-DiD3r9tx.mjs} +2 -2
  182. package/dist/{renderer-BZ5uOfoX.mjs.map → renderer-DiD3r9tx.mjs.map} +1 -1
  183. package/dist/{renderer-B2no_9zi.mjs → renderer-Dj-y-BtT.mjs} +3 -3
  184. package/dist/{renderer-B2no_9zi.mjs.map → renderer-Dj-y-BtT.mjs.map} +1 -1
  185. package/dist/renderer-Dj5jGAkR.mjs +5 -0
  186. package/dist/{renderer-Bs6BaMLf.mjs → renderer-DlA7kzIs.mjs} +3 -3
  187. package/dist/{renderer-Bs6BaMLf.mjs.map → renderer-DlA7kzIs.mjs.map} +1 -1
  188. package/dist/renderer-DlPUQ7RF.mjs +5 -0
  189. package/dist/renderer-DpIPdgn8.mjs +5 -0
  190. package/dist/renderer-Dv0C9CEt.mjs +6 -0
  191. package/dist/{renderer-CFCR_7bq.mjs → renderer-EwSSuJXj.mjs} +3 -3
  192. package/dist/{renderer-CFCR_7bq.mjs.map → renderer-EwSSuJXj.mjs.map} +1 -1
  193. package/dist/renderer-Iw8fDL-p.mjs +5 -0
  194. package/dist/{renderer-tcS585Iy.mjs → renderer-JJibsJfb.mjs} +2 -2
  195. package/dist/{renderer-tcS585Iy.mjs.map → renderer-JJibsJfb.mjs.map} +1 -1
  196. package/dist/{renderer-CTy0bSsl.mjs → renderer-KppAWfmJ.mjs} +3 -3
  197. package/dist/{renderer-CTy0bSsl.mjs.map → renderer-KppAWfmJ.mjs.map} +1 -1
  198. package/dist/{renderer-CPSg6eEQ.mjs → renderer-LW8MfGjd.mjs} +2 -2
  199. package/dist/{renderer-CPSg6eEQ.mjs.map → renderer-LW8MfGjd.mjs.map} +1 -1
  200. package/dist/{renderer-Cg2xS8h8.mjs → renderer-PMjV8x65.mjs} +2 -2
  201. package/dist/{renderer-Cg2xS8h8.mjs.map → renderer-PMjV8x65.mjs.map} +1 -1
  202. package/dist/renderer-SLI69Mju.mjs +5 -0
  203. package/dist/{renderer-DdkPY4hO.mjs → renderer-UArFa4y9.mjs} +2 -2
  204. package/dist/{renderer-DdkPY4hO.mjs.map → renderer-UArFa4y9.mjs.map} +1 -1
  205. package/dist/{renderer-B7vXnBUo.mjs → renderer-VwKmjuc6.mjs} +3 -3
  206. package/dist/{renderer-B7vXnBUo.mjs.map → renderer-VwKmjuc6.mjs.map} +1 -1
  207. package/dist/renderer-ZVtbb3-P.mjs +5 -0
  208. package/dist/{renderer-Dk0okoS3.mjs → renderer-bb3puodg.mjs} +2 -2
  209. package/dist/{renderer-Dk0okoS3.mjs.map → renderer-bb3puodg.mjs.map} +1 -1
  210. package/dist/{renderer-DVGVcDvB.mjs → renderer-eyvY4Lq4.mjs} +3 -3
  211. package/dist/{renderer-DVGVcDvB.mjs.map → renderer-eyvY4Lq4.mjs.map} +1 -1
  212. package/dist/renderer-gHNCu7H5.mjs +5 -0
  213. package/dist/renderer-gxDNmbtU.mjs +5 -0
  214. package/dist/renderer-hkK4DNRX.mjs +5 -0
  215. package/dist/{renderer-CrKYlipx.mjs → renderer-ij448rvt.mjs} +2 -2
  216. package/dist/{renderer-CrKYlipx.mjs.map → renderer-ij448rvt.mjs.map} +1 -1
  217. package/dist/{renderer-BrKgtYq4.mjs → renderer-j7aUfyeR.mjs} +3 -3
  218. package/dist/{renderer-BrKgtYq4.mjs.map → renderer-j7aUfyeR.mjs.map} +1 -1
  219. package/dist/{renderer-8_d6YNnE.mjs → renderer-kOlfYLtu.mjs} +2 -2
  220. package/dist/{renderer-8_d6YNnE.mjs.map → renderer-kOlfYLtu.mjs.map} +1 -1
  221. package/dist/{renderer-D3FJcRA3.mjs → renderer-l1qJE4z-.mjs} +2 -2
  222. package/dist/{renderer-D3FJcRA3.mjs.map → renderer-l1qJE4z-.mjs.map} +1 -1
  223. package/dist/{renderer-BKJ8cywK.mjs → renderer-ml2LHmRb.mjs} +2 -2
  224. package/dist/{renderer-BKJ8cywK.mjs.map → renderer-ml2LHmRb.mjs.map} +1 -1
  225. package/dist/{renderer-DmTxEeMh.mjs → renderer-mu2mYo5w.mjs} +3 -3
  226. package/dist/{renderer-DmTxEeMh.mjs.map → renderer-mu2mYo5w.mjs.map} +1 -1
  227. package/dist/{renderer-Bwxm-kWL.mjs → renderer-ovkctV5a.mjs} +2 -2
  228. package/dist/renderer-oy6SGDRz.mjs +5 -0
  229. package/dist/{renderer-CqJou_2L.mjs → renderer-qQmoO5Hk.mjs} +2 -2
  230. package/dist/{renderer-CqJou_2L.mjs.map → renderer-qQmoO5Hk.mjs.map} +1 -1
  231. package/dist/renderer-rq7UIcuH.mjs +5 -0
  232. package/dist/{renderer-BStETpqC.mjs → renderer-rv-AKqCl.mjs} +2 -2
  233. package/dist/{renderer-BStETpqC.mjs.map → renderer-rv-AKqCl.mjs.map} +1 -1
  234. package/dist/renderer-xkEJahws.mjs +5 -0
  235. package/dist/{renderer-BNs31kME.mjs → renderer-yF40Pb-n.mjs} +3 -3
  236. package/dist/{renderer-BNs31kME.mjs.map → renderer-yF40Pb-n.mjs.map} +1 -1
  237. package/dist/{renderer-Bag1LFos.mjs → renderer-yFHP9Zj2.mjs} +3 -3
  238. package/dist/{renderer-Bag1LFos.mjs.map → renderer-yFHP9Zj2.mjs.map} +1 -1
  239. package/dist/{renderer-CbxfSmYE.mjs → renderer-zOK5DYYn.mjs} +3 -3
  240. package/dist/{renderer-CbxfSmYE.mjs.map → renderer-zOK5DYYn.mjs.map} +1 -1
  241. package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs → renderers-CP8CFWDs-DPLX5EJs.mjs} +2 -2
  242. package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs.map → renderers-CP8CFWDs-DPLX5EJs.mjs.map} +1 -1
  243. package/dist/signals/index.mjs +4 -4
  244. package/dist/{src-r7Suenhp.mjs → src-BnkrNj2W.mjs} +612 -117
  245. package/dist/src-BnkrNj2W.mjs.map +1 -0
  246. package/package.json +59 -58
  247. package/dist/dist-BuirkW-f.mjs.map +0 -1
  248. package/dist/dist-DBdxM6xv.mjs.map +0 -1
  249. package/dist/fonts-DNx6ps2p.mjs.map +0 -1
  250. package/dist/index-CalU-mpZ.d.mts.map +0 -1
  251. package/dist/index-N1tMc5Z9.d.mts.map +0 -1
  252. package/dist/preview-engine/chunk-2TCJSHFA.mjs +0 -1974
  253. package/dist/preview-engine/chunk-Q7XC3FI7.mjs +0 -3
  254. package/dist/preview-engine/chunk-ZZAZF6VC.mjs +0 -5
  255. package/dist/program-O2bWX0cs.mjs.map +0 -1
  256. package/dist/renderer-0441BN0U.mjs +0 -5
  257. package/dist/renderer-3qDgFc-W.mjs +0 -5
  258. package/dist/renderer-6AIqTN5Z.mjs +0 -5
  259. package/dist/renderer-B7MZ5R7u.mjs +0 -5
  260. package/dist/renderer-BE2rIA4B.mjs +0 -5
  261. package/dist/renderer-BIQ81ln4.mjs +0 -5
  262. package/dist/renderer-BKm3_Ob3.mjs +0 -5
  263. package/dist/renderer-BNdT1Wwf.mjs +0 -5
  264. package/dist/renderer-BQycs1q_.mjs +0 -5
  265. package/dist/renderer-Bj7MxXfk.mjs +0 -5
  266. package/dist/renderer-BjRAGYYC.mjs +0 -5
  267. package/dist/renderer-BqE2BQ_X.mjs +0 -5
  268. package/dist/renderer-Bz5BMkOE.mjs +0 -5
  269. package/dist/renderer-CDOSfB9H.mjs +0 -5
  270. package/dist/renderer-CT26yQT-.mjs +0 -5
  271. package/dist/renderer-CZeAbz48.mjs +0 -5
  272. package/dist/renderer-D50b7kip.mjs +0 -5
  273. package/dist/renderer-D5LmrO96.mjs +0 -5
  274. package/dist/renderer-DCvqZ5o_.mjs +0 -5
  275. package/dist/renderer-DJGJeGEn.mjs +0 -5
  276. package/dist/renderer-DONvNNXi.mjs +0 -5
  277. package/dist/renderer-DPaSo--Y.mjs +0 -6
  278. package/dist/renderer-DfYYokhr.mjs +0 -5
  279. package/dist/renderer-DlIJhdEM.mjs +0 -5
  280. package/dist/renderer-DqD3nMtv.mjs +0 -5
  281. package/dist/renderer-DrIl-uGt.mjs +0 -5
  282. package/dist/renderer-DyCB3Q1d.mjs +0 -5
  283. package/dist/renderer-DyJ-iSv6.mjs +0 -5
  284. package/dist/renderer-Dzs0jV0G.mjs +0 -5
  285. package/dist/renderer-HIKXw5-Y.mjs +0 -5
  286. package/dist/renderer-Jgt2g8Wp.mjs +0 -5
  287. package/dist/renderer-KnZWmT-M.mjs +0 -5
  288. package/dist/renderer-OMSXddG9.mjs +0 -5
  289. package/dist/renderer-P78xh6OV.mjs +0 -5
  290. package/dist/renderer-gaFP1Kak.mjs +0 -5
  291. package/dist/renderer-lQ8gH7Zd.mjs +0 -5
  292. package/dist/src-r7Suenhp.mjs.map +0 -1
@@ -17,6 +17,7 @@
17
17
  "caret",
18
18
  "matte",
19
19
  "reels",
20
+ "units",
20
21
  "line-<n>"
21
22
  ],
22
23
  "cues": [
@@ -29,7 +30,7 @@
29
30
  "files": [
30
31
  {
31
32
  "path": "text-reveal/index.ts",
32
- "content": "/**\n * TextReveal: headlines and body copy that animate in (and out) by\n * character, word or line. One timing model, many looks. Glyph styles run on\n * the GPU through baked per-unit schedules, so a paragraph stays one text\n * node and its final frame is exactly the static text.\n */\nimport {\n\ttype AnimatableProp,\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n\ttype TextNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tconstantExpression,\n\tdefineComponent,\n\tevaluateEase,\n\tfindRange,\n\ttype GlyphAnimator,\n\tglyphAnimator,\n\ttype GlyphProps,\n\ttype GlyphUnit,\n\tlineShift,\n\ttype MeasureOptions,\n\ttype Metrics,\n\trangeRects,\n\ttype Rect,\n\trendererChars,\n\tscaleFrames,\n\tscheduleExpression,\n\ttype Theme,\n\tthemeFrames,\n\ttypingFrames,\n\ttype UnitSchedule,\n\tz,\n} from \"framefields/components\";\nimport {\n\tburstStarts,\n\tindexedStarts,\n\tscrambleSet,\n\tunitStarts,\n\tunitsOf,\n} from \"./plan.js\";\n\nexport const REVEAL_STYLES = [\n\t\"rise\",\n\t\"blur\",\n\t\"track\",\n\t\"mask\",\n\t\"slam\",\n\t\"scatter\",\n\t\"scramble\",\n\t\"type\",\n\t\"stream\",\n\t\"wipe\",\n\t\"flip\",\n] as const;\nexport type RevealStyle = (typeof REVEAL_STYLES)[number];\n\n/** Styles that can also play out (`exitStyle`). */\nexport const EXIT_STYLES = [\n\t\"rise\",\n\t\"blur\",\n\t\"track\",\n\t\"mask\",\n\t\"scatter\",\n\t\"type\",\n\t\"stream\",\n\t\"wipe\",\n\t\"flip\",\n] as const;\nexport type ExitStyle = (typeof EXIT_STYLES)[number];\n\nconst ORDERS = [\"forward\", \"reverse\", \"center\", \"edges\", \"random\"] as const;\n\ninterface StyleDefaults {\n\tby: GlyphUnit;\n\t/** Frames between units at 30 fps. */\n\tstagger: number;\n\tspeed: keyof Theme[\"motion\"][\"speed\"];\n\t/** Multiplier on the speed token. */\n\tlength: number;\n\tease: string;\n}\n\nconst STYLE: Record<RevealStyle, StyleDefaults> = {\n\trise: {\n\t\tby: \"word\",\n\t\tstagger: 3,\n\t\tspeed: \"base\",\n\t\tlength: 1.4,\n\t\tease: \"expo.out\",\n\t},\n\tblur: {\n\t\tby: \"char\",\n\t\tstagger: 1.2,\n\t\tspeed: \"slow\",\n\t\tlength: 1,\n\t\tease: \"power3.out\",\n\t},\n\ttrack: {\n\t\tby: \"line\",\n\t\tstagger: 0,\n\t\tspeed: \"slow\",\n\t\tlength: 1.5,\n\t\tease: \"expo.out\",\n\t},\n\tmask: {\n\t\tby: \"line\",\n\t\tstagger: 5,\n\t\tspeed: \"base\",\n\t\tlength: 1.5,\n\t\tease: \"expo.out\",\n\t},\n\tslam: { by: \"word\", stagger: 7, speed: \"fast\", length: 1, ease: \"power3.in\" },\n\tscatter: {\n\t\tby: \"char\",\n\t\tstagger: 0.8,\n\t\tspeed: \"slow\",\n\t\tlength: 1.2,\n\t\tease: \"expo.out\",\n\t},\n\tscramble: {\n\t\tby: \"char\",\n\t\tstagger: 1.5,\n\t\tspeed: \"base\",\n\t\tlength: 1,\n\t\tease: \"none\",\n\t},\n\ttype: { by: \"char\", stagger: 0, speed: \"fast\", length: 1, ease: \"none\" },\n\tstream: {\n\t\tby: \"word\",\n\t\tstagger: 6,\n\t\tspeed: \"slow\",\n\t\tlength: 1,\n\t\tease: \"power2.out\",\n\t},\n\twipe: {\n\t\tby: \"line\",\n\t\tstagger: 0,\n\t\tspeed: \"slow\",\n\t\tlength: 1.4,\n\t\tease: \"power2.inOut\",\n\t},\n\tflip: {\n\t\tby: \"char\",\n\t\tstagger: 1.5,\n\t\tspeed: \"base\",\n\t\tlength: 1.2,\n\t\tease: \"back.out(1.4)\",\n\t},\n};\n\n/** What each glyph style pushes at full weight (font-size units, degrees). */\nfunction glyphFrom(style: RevealStyle, fs: number, exit: boolean): GlyphProps {\n\tswitch (style) {\n\t\tcase \"rise\":\n\t\t\treturn { y: (exit ? -0.32 : 0.42) * fs, opacity: 0 };\n\t\tcase \"blur\":\n\t\t\treturn { blur: 0.16 * fs, scale: 1.18, opacity: 0 };\n\t\tcase \"flip\":\n\t\t\treturn { rotationX: exit ? -90 : 90, opacity: 0 };\n\t\tdefault:\n\t\t\treturn { opacity: 0 };\n\t}\n}\n\nexport interface TextRevealCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Per unit: starts moving (`frame`) and comes to rest (`settled`). */\n\tunits: ReadonlyArray<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tindex: number;\n\t\ttext: string;\n\t}>;\n\t/** The whole text is at rest. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** The exit starts (empty without `exitStyle`). */\n\texitStart: ReadonlyArray<{ frame: number }>;\n\t/** Each typed character (`type`) or arriving word (`stream`): for key sounds. */\n\tkeystrokes: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nexport interface TextRevealApi {\n\t/**\n\t * Rects (parent coordinates) that a match covers, one per line: hand\n\t * them to `Annotate` or a camera. Without `match`, the whole text.\n\t */\n\trects(match?: string | RegExp, nth?: number): Rect[];\n}\n\ntype TextProps = Partial<\n\tPick<\n\t\tTextNode,\n\t\t| \"fontFamily\"\n\t\t| \"fontSize\"\n\t\t| \"fontWeight\"\n\t\t| \"fill\"\n\t\t| \"align\"\n\t\t| \"lineHeight\"\n\t\t| \"letterSpacing\"\n\t\t| \"direction\"\n\t\t| \"width\"\n\t>\n>;\n\nconst ALIGN = { start: \"left\", center: \"center\", end: \"right\" } as const;\n\nexport const textReveal = /* @__PURE__ */ defineComponent({\n\tname: \"TextReveal\",\n\tprops: () => ({\n\t\ttext: z.string().min(1),\n\t\tstyle: z.enum(REVEAL_STYLES).default(\"rise\"),\n\t\t/** Unit of animation. Default per style. */\n\t\tby: z.enum([\"char\", \"word\", \"line\"]).optional(),\n\t\t/** Frames between units. Default per style. */\n\t\tstagger: z.number().min(0).optional(),\n\t\torder: z.enum(ORDERS).default(\"forward\"),\n\t\t/** Frames one unit's move takes. Default per style, from the theme. */\n\t\tunitDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\t/** Play out after `hold`, unit by unit. */\n\t\texitStyle: z.union([z.enum(EXIT_STYLES), z.literal(false)]).default(false),\n\t\t/** Typing speed for `type`, characters a second. */\n\t\tcps: z.number().positive().default(24),\n\t\t/** Caret for `type` and `stream`. */\n\t\tcaret: z.boolean().default(true),\n\t\t/** Words or patterns drawn in the theme's accent. */\n\t\temphasis: z.array(z.union([z.string(), z.instanceof(RegExp)])).default([]),\n\t\taccent: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontWeight: z.union([z.number(), z.string()]).optional(),\n\t\tfill: z.string().optional(),\n\t\talign: z.enum([\"start\", \"center\", \"end\"]).default(\"start\"),\n\t\tlineHeight: z.number().positive().optional(),\n\t\tletterSpacing: z.number().optional(),\n\t\tdirection: z.enum([\"ltr\", \"rtl\", \"auto\"]).optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst text = props.text;\n\t\tconst fs = props.fontSize ?? 96 * theme.scale;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst registered = host.fonts();\n\t\tif (registered.length > 0 && !registered.includes(fontFamily)) {\n\t\t\tthrow new Error(\n\t\t\t\t`TextReveal: font \"${fontFamily}\" is not registered. Registered: ${registered.join(\", \")}.`,\n\t\t\t);\n\t\t}\n\t\tconst wrap = ctx.requested.width;\n\t\tconst textProps: TextProps = {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs,\n\t\t\tfill: props.fill ?? theme.color.ink,\n\t\t\talign: props.align,\n\t\t\t...(props.fontWeight !== undefined && { fontWeight: props.fontWeight }),\n\t\t\t...(props.lineHeight !== undefined && { lineHeight: props.lineHeight }),\n\t\t\t...(props.letterSpacing !== undefined && {\n\t\t\t\tletterSpacing: props.letterSpacing,\n\t\t\t}),\n\t\t\t...(props.direction !== undefined && { direction: props.direction }),\n\t\t\t...(wrap !== undefined && { width: wrap }),\n\t\t};\n\t\tconst measureOptions: MeasureOptions = {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs,\n\t\t\tfontWeight: props.fontWeight,\n\t\t\tletterSpacing: props.letterSpacing,\n\t\t\tlineHeight: props.lineHeight,\n\t\t\twidth: wrap,\n\t\t\talign: ALIGN[props.align],\n\t\t\tdirection: props.direction,\n\t\t};\n\t\tconst m = host.measure(text, measureOptions);\n\t\tconst W = wrap ?? m.width;\n\t\tconst H = m.height;\n\n\t\tconst style = STYLE[props.style];\n\t\tconst by: GlyphUnit = props.by ?? style.by;\n\t\t// Stagger stays fractional: units may start between frames.\n\t\tconst perFps = (v: number) => (v * fps) / 30;\n\t\tconst stagger = perFps(props.stagger ?? style.stagger);\n\t\tconst duration =\n\t\t\tprops.unitDuration ??\n\t\t\tMath.max(\n\t\t\t\t1,\n\t\t\t\tMath.round(themeFrames(theme, style.speed, fps) * style.length),\n\t\t\t);\n\t\tconst ease = props.ease ?? style.ease;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst animators: GlyphAnimator[] = [];\n\t\tconst keystrokes: Array<{ frame: number; index: number }> = [];\n\t\tlet unitCues: TextRevealCues[\"units\"] = [];\n\t\tlet revealed = 0;\n\n\t\t// Emphasis is static colour, first in the stack so streaming ink mixes over it.\n\t\tconst emphasised = emphasisWeights(text, props.emphasis);\n\t\tif (emphasised) {\n\t\t\tanimators.push(\n\t\t\t\tglyphAnimator(\n\t\t\t\t\t\"emphasis\",\n\t\t\t\t\tconstantExpression(\"char\", rendererChars(text, emphasised)),\n\t\t\t\t\t{\n\t\t\t\t\t\tfillColor: props.accent ?? theme.color.accent,\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\n\t\tconst units = unitsOf(text, by, m);\n\t\tconst schedule = (starts: number[], dur = duration, e = ease) => ({\n\t\t\tstarts: indexedStarts(text, by, units, starts),\n\t\t\tduration: dur,\n\t\t\tease: e,\n\t\t});\n\t\tconst cuesFor = (starts: number[], dur: number) =>\n\t\t\tunits.map((u, i) => ({\n\t\t\t\tframe: Math.round(starts[i] ?? 0),\n\t\t\t\tsettled: Math.round((starts[i] ?? 0) + dur),\n\t\t\t\tindex: u.index,\n\t\t\t\ttext: u.text,\n\t\t\t}));\n\n\t\t// Tracking widens a line: unwrapped text gets a box with room for it,\n\t\t// placed so `align` holds while it grows.\n\t\tconst tracks =\n\t\t\twrap === undefined &&\n\t\t\t(props.style === \"track\" || props.exitStyle === \"track\");\n\t\tconst room = tracks ? 0.5 * fs * Math.max(0, text.length - 1) : 0;\n\t\tconst shift =\n\t\t\tprops.align === \"center\" ? 0.5 : props.align === \"end\" ? 1 : 0;\n\t\tconst textNode = host.text(text, {\n\t\t\t...textProps,\n\t\t\t...(tracks && { width: W + room }),\n\t\t\tid: ctx.partId(\"text\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: -room * shift,\n\t\t\ty: 0,\n\t\t});\n\t\tparts.text = textNode;\n\t\t// What carries the glyphs: the text node, or a wrapper around it (null when lines are split).\n\t\tlet main: LayoutNode | null = textNode;\n\t\tlet lines: MaskLine[] = [];\n\t\tlet sweep: ReturnType<typeof wipeMatte> | null = null;\n\n\t\t// ── Enter ────────────────────────────────────────────────────────\n\t\tswitch (props.style) {\n\t\t\tcase \"rise\":\n\t\t\tcase \"blur\":\n\t\t\tcase \"flip\": {\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\tscheduleExpression(by, schedule(starts), \"enter\"),\n\t\t\t\t\t\tglyphFrom(props.style, fs, false),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"slam\": {\n\t\t\t\t// Each unit is its own node so it scales around its own centre;\n\t\t\t\t// the paragraph takes over the frame the last one lands.\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tconst { width: _w, align: _a, ...unitProps } = textProps;\n\t\t\t\tconst pieces = units.map((u, i) => {\n\t\t\t\t\tconst rect = rangeRects(m, u.start, u.end, measureOptions)[0];\n\t\t\t\t\tconst s = starts[i] ?? 0;\n\t\t\t\t\tconst accented =\n\t\t\t\t\t\temphasised?.slice(u.start, u.end).every((v) => v === 1) ?? false;\n\t\t\t\t\tconst piece = host.text(u.text, {\n\t\t\t\t\t\t...unitProps,\n\t\t\t\t\t\t...(accented && { fill: props.accent ?? theme.color.accent }),\n\t\t\t\t\t\tid: ctx.partId(`unit-${i}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: rect?.x ?? 0,\n\t\t\t\t\t\ty: rect?.y ?? 0,\n\t\t\t\t\t});\n\t\t\t\t\tpiece.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"scale\", [\n\t\t\t\t\t\t\t\t[s, 2.4],\n\t\t\t\t\t\t\t\t[s + duration, 1, ease],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[s, 0],\n\t\t\t\t\t\t\t\t[s + Math.max(1, Math.round(duration * 0.45)), 1],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\treturn piece;\n\t\t\t\t});\n\t\t\t\tconst landed = Math.max(0, ...unitCues.map((u) => u.settled));\n\t\t\t\tconst group = host.box({\n\t\t\t\t\tid: ctx.partId(\"units\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tchildren: pieces,\n\t\t\t\t});\n\t\t\t\tgroup.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 1],\n\t\t\t\t\t\t[landed, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[landed, 1, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\t// A short landing shake as each unit hits.\n\t\t\t\tconst shake = host.box({\n\t\t\t\t\tid: ctx.partId(\"shake\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tchildren: [group, textNode],\n\t\t\t\t});\n\t\t\t\tshake.animate(...slamShake(unitCues, fs, ctx.rng(\"slam\")));\n\t\t\t\tparts.units = group;\n\t\t\t\tparts.shake = shake;\n\t\t\t\tmain = shake;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"scatter\": {\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tconst sched = schedule(starts);\n\t\t\t\tconst per = (key: string) => {\n\t\t\t\t\tconst q = ctx.rng(key);\n\t\t\t\t\treturn rendererChars(\n\t\t\t\t\t\ttext,\n\t\t\t\t\t\tArray.from({ length: text.length }, () => q.spread(1)),\n\t\t\t\t\t);\n\t\t\t\t};\n\t\t\t\tconst rx = per(\"scatter-x\");\n\t\t\t\tconst ry = per(\"scatter-y\");\n\t\t\t\tconst rr = per(\"scatter-r\");\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"enter-x\", scheduleExpression(by, sched, \"enter\", rx), {\n\t\t\t\t\t\tx: 1.1 * fs,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter-y\", scheduleExpression(by, sched, \"enter\", ry), {\n\t\t\t\t\t\ty: 0.9 * fs,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter-r\", scheduleExpression(by, sched, \"enter\", rr), {\n\t\t\t\t\t\trotation: 120,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter\", scheduleExpression(by, sched, \"enter\"), {\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\tblur: 0.06 * fs,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stream\": {\n\t\t\t\tconst words = unitsOf(text, \"word\", m);\n\t\t\t\tconst step = scaleFrames(1, fps);\n\t\t\t\tconst starts = burstStarts(\n\t\t\t\t\twords.length,\n\t\t\t\t\tperFps(props.stagger ?? style.stagger),\n\t\t\t\t\tstep,\n\t\t\t\t\tctx.rng(\"stream\"),\n\t\t\t\t);\n\t\t\t\tconst fade = Math.max(2, themeFrames(theme, \"fast\", fps));\n\t\t\t\tconst ink = props.unitDuration ?? themeFrames(theme, \"slow\", fps);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t{ starts, duration: fade, ease: \"power2.out\" },\n\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\t{ opacity: 0, y: 0.08 * fs },\n\t\t\t\t\t),\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"ink\",\n\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t{ starts, duration: ink, ease: \"sine.inOut\" },\n\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\t{ fillColor: theme.color.muted },\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = words.map((w, i) => ({\n\t\t\t\t\tframe: starts[i] ?? 0,\n\t\t\t\t\tsettled: (starts[i] ?? 0) + ink,\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: w.text,\n\t\t\t\t}));\n\t\t\t\twords.forEach((w, i) => {\n\t\t\t\t\tkeystrokes.push({ frame: starts[i] ?? 0, index: w.index });\n\t\t\t\t});\n\t\t\t\tif (props.caret) {\n\t\t\t\t\tconst ends = words.map((w) => w.end);\n\t\t\t\t\tconst caret = buildCaret(ctx, m, measureOptions, fs, {\n\t\t\t\t\t\tat: starts,\n\t\t\t\t\t\tcounts: ends,\n\t\t\t\t\t\tuntil: (starts[starts.length - 1] ?? 0) + ink,\n\t\t\t\t\t\tshape: \"block\",\n\t\t\t\t\t});\n\t\t\t\t\tchildren.push(caret);\n\t\t\t\t\tparts.caret = caret;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"type\": {\n\t\t\t\tconst frames = typingFrames(text, props.cps, fps, ctx.rng(\"type\"));\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"text\" as AnimatableProp,\n\t\t\t\t\t\tcountKeys(text.length, frames),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tframes.forEach((f, i) => {\n\t\t\t\t\tif (!/\\s/.test(text[i] ?? \"\"))\n\t\t\t\t\t\tkeystrokes.push({ frame: f, index: i });\n\t\t\t\t});\n\t\t\t\tunitCues = keystrokes.map((k) => ({\n\t\t\t\t\tframe: k.frame,\n\t\t\t\t\tsettled: k.frame,\n\t\t\t\t\tindex: k.index,\n\t\t\t\t\ttext: text[k.index] ?? \"\",\n\t\t\t\t}));\n\t\t\t\trevealed = (frames[frames.length - 1] ?? 0) + 1;\n\t\t\t\tif (props.caret) {\n\t\t\t\t\tconst caret = buildCaret(ctx, m, measureOptions, fs, {\n\t\t\t\t\t\tat: frames,\n\t\t\t\t\t\tcounts: frames.map((_, i) => i + 1),\n\t\t\t\t\t\tuntil: revealed + ctx.hold,\n\t\t\t\t\t\tshape: \"bar\",\n\t\t\t\t\t});\n\t\t\t\t\tchildren.push(caret);\n\t\t\t\t\tparts.caret = caret;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"track\": {\n\t\t\t\tconst ls = props.letterSpacing ?? 0;\n\t\t\t\tconst wide = ls + 0.5 * fs;\n\t\t\t\tconst fade = Math.round(duration * 0.5);\n\t\t\t\tconst keys: KeyframeTuple[] = [\n\t\t\t\t\t[0, wide],\n\t\t\t\t\t[duration, ls, ease],\n\t\t\t\t];\n\t\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t\t.keys(\"letterSpacing\", keys)\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[fade, 1, \"power2.out\"],\n\t\t\t\t\t]);\n\t\t\t\ttextNode.animate(anim);\n\t\t\t\tunitCues = [{ frame: 0, settled: duration, index: 0, text }];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"mask\": {\n\t\t\t\tlines = maskLines(ctx, text, m, measureOptions, textProps, fs);\n\t\t\t\tconst starts = unitStarts(lines.length, stagger, props.order, ctx.seed);\n\t\t\t\tlines.forEach((line, i) => {\n\t\t\t\t\tconst s = starts[i] ?? 0;\n\t\t\t\t\tline.text.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t[s, line.restY + line.travel],\n\t\t\t\t\t\t\t[s + duration, line.restY, ease],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tchildren.push(line.clip);\n\t\t\t\t\tparts[`line-${i}`] = line.clip;\n\t\t\t\t});\n\t\t\t\tunitCues = lines.map((l, i) => ({\n\t\t\t\t\tframe: Math.round(starts[i] ?? 0),\n\t\t\t\t\tsettled: Math.round((starts[i] ?? 0) + duration),\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttext: l.content,\n\t\t\t\t}));\n\t\t\t\tmain = null;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"wipe\": {\n\t\t\t\tconst w = wipeMatte(ctx, W, H, fs, \"hidden\");\n\t\t\t\tsweep = w;\n\t\t\t\tw.matte.animate(\n\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t[0, w.hidden],\n\t\t\t\t\t\t[duration, w.shown, ease],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\thost.matte(textNode, w.matte);\n\t\t\t\tparts.matte = w.matte;\n\t\t\t\t// The front trails a little blur over the glyphs it is uncovering.\n\t\t\t\tconst xs = rendererChars(text, glyphCenters(text, m, measureOptions));\n\t\t\t\tconst front = Array.from({ length: duration + 1 }, (_, f) =>\n\t\t\t\t\tw.front(\n\t\t\t\t\t\tw.hidden + (w.shown - w.hidden) * evaluateEase(ease, f / duration),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"wipe-blur\",\n\t\t\t\t\t\t`((X,T)=>{const f=clamp(frame,0,${duration});const k=floor(f);const front=T[k]+((T[k+1]??T[k])-T[k])*(f-k);return clamp(1-(front-(X[textIndex]??0))/${round(w.feather)},0,1);})(${list(xs)},${list(front)})`,\n\t\t\t\t\t\t{ blur: 0.12 * fs },\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = [{ frame: 0, settled: duration, index: 0, text }];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"scramble\": {\n\t\t\t\tconst result = scramble(ctx, text, m, measureOptions, textProps, {\n\t\t\t\t\tstagger,\n\t\t\t\t\tcycle: duration,\n\t\t\t\t\torder: props.order,\n\t\t\t\t});\n\t\t\t\tchildren.push(result.reels);\n\t\t\t\tparts.reels = result.reels;\n\t\t\t\t// The real text takes over the moment the last glyph locks.\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[result.done, 1, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tunitCues = result.cues;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (unitCues.length > 0 && props.style !== \"type\")\n\t\t\trevealed = Math.max(...unitCues.map((u) => u.settled));\n\t\t// The text is at rest only once the last landing shake dies out.\n\t\tif (props.style === \"slam\") revealed += SHAKE.length - 1;\n\n\t\t// ── Exit ─────────────────────────────────────────────────────────\n\t\tlet afterHold = 0;\n\t\tconst exitStart = revealed + ctx.hold;\n\t\tif (props.exitStyle !== false) {\n\t\t\tconst out = props.exitStyle;\n\t\t\tconst outStyle = STYLE[out];\n\t\t\tconst outBy: GlyphUnit = props.by ?? outStyle.by;\n\t\t\tconst outUnits = unitsOf(text, outBy, m);\n\t\t\tconst outStagger = perFps((props.stagger ?? outStyle.stagger) * 0.5);\n\t\t\tconst outDur = Math.max(\n\t\t\t\t1,\n\t\t\t\tMath.round(\n\t\t\t\t\tthemeFrames(theme, outStyle.speed, fps) * outStyle.length * 0.75,\n\t\t\t\t),\n\t\t\t);\n\t\t\tconst outEase = theme.motion.ease.exit;\n\t\t\tconst starts = unitStarts(\n\t\t\t\toutUnits.length,\n\t\t\t\toutStagger,\n\t\t\t\tprops.order,\n\t\t\t\tctx.seed,\n\t\t\t\texitStart,\n\t\t\t);\n\t\t\tconst sched: UnitSchedule = {\n\t\t\t\tstarts: indexedStarts(text, outBy, outUnits, starts),\n\t\t\t\tduration: outDur,\n\t\t\t\tease: outEase,\n\t\t\t};\n\t\t\tafterHold = Math.round(Math.max(...starts) - exitStart + outDur);\n\t\t\tswitch (out) {\n\t\t\t\tcase \"rise\":\n\t\t\t\tcase \"blur\":\n\t\t\t\tcase \"flip\":\n\t\t\t\tcase \"scatter\":\n\t\t\t\tcase \"stream\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"exit\",\n\t\t\t\t\t\t\tscheduleExpression(outBy, sched, \"exit\"),\n\t\t\t\t\t\t\tout === \"scatter\"\n\t\t\t\t\t\t\t\t? { y: -0.6 * fs, opacity: 0, blur: 0.06 * fs }\n\t\t\t\t\t\t\t\t: glyphFrom(out, fs, true),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"type\": {\n\t\t\t\t\t// Backspace, twice as fast as typing.\n\t\t\t\t\tconst per = Math.max(1, Math.round(fps / (props.cps * 2)));\n\t\t\t\t\tconst keys: KeyframeTuple[] = [];\n\t\t\t\t\tfor (\n\t\t\t\t\t\tlet c = text.length - 1, f = exitStart + per;\n\t\t\t\t\t\tc >= 0;\n\t\t\t\t\t\tc--, f += per\n\t\t\t\t\t)\n\t\t\t\t\t\tkeys.push([f, c === 1 ? 1 / text.length : c, \"hold\"]);\n\t\t\t\t\ttextNode.animate(LayerAnimation.keys(\"text\" as AnimatableProp, keys));\n\t\t\t\t\tafterHold = per * text.length + 1;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"track\": {\n\t\t\t\t\tconst ls = props.letterSpacing ?? 0;\n\t\t\t\t\ttextNode.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"letterSpacing\", [\n\t\t\t\t\t\t\t\t[exitStart, ls],\n\t\t\t\t\t\t\t\t[exitStart + outDur, ls + 0.4 * fs, outEase],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[exitStart, 1],\n\t\t\t\t\t\t\t\t[exitStart + outDur, 0, outEase],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tafterHold = outDur;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"wipe\": {\n\t\t\t\t\tif (!sweep) {\n\t\t\t\t\t\tsweep = wipeMatte(ctx, W, H, fs, \"shown\");\n\t\t\t\t\t\thost.matte(textNode, sweep.matte);\n\t\t\t\t\t\tparts.matte = sweep.matte;\n\t\t\t\t\t}\n\t\t\t\t\tsweep.matte.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t\t[exitStart, sweep.shown],\n\t\t\t\t\t\t\t[exitStart + outDur, sweep.gone, outEase],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tafterHold = outDur;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"mask\": {\n\t\t\t\t\tif (lines.length === 0)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'TextReveal: exitStyle \"mask\" needs style \"mask\" (it splits the lines).',\n\t\t\t\t\t\t);\n\t\t\t\t\tconst outStarts = unitStarts(\n\t\t\t\t\t\tlines.length,\n\t\t\t\t\t\toutStagger,\n\t\t\t\t\t\tprops.order,\n\t\t\t\t\t\tctx.seed,\n\t\t\t\t\t\texitStart,\n\t\t\t\t\t);\n\t\t\t\t\tlines.forEach((line, i) => {\n\t\t\t\t\t\tconst s = outStarts[i] ?? exitStart;\n\t\t\t\t\t\tline.text.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t\t[s, line.restY],\n\t\t\t\t\t\t\t\t[s + outDur, line.restY - line.travel, outEase],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t\t);\n\t\t\t\t\t});\n\t\t\t\t\tafterHold = Math.round(Math.max(...outStarts) - exitStart + outDur);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (animators.length > 0)\n\t\t\t(textNode as unknown as { animators: GlyphAnimator[] }).animators =\n\t\t\t\tanimators;\n\t\tif (main) children.unshift(main);\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, revealed),\n\t\t\topenEnded: true,\n\t\t\tafterHold,\n\t\t\tcues: {\n\t\t\t\tunits: unitCues,\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\texitStart: props.exitStyle === false ? [] : [{ frame: exitStart }],\n\t\t\t\tkeystrokes,\n\t\t\t} as TextRevealCues,\n\t\t\tparts,\n\t\t\tapi: (placement): TextRevealApi => ({\n\t\t\t\trects(match, nth = 0) {\n\t\t\t\t\tconst range: [number, number] | null =\n\t\t\t\t\t\tmatch === undefined\n\t\t\t\t\t\t\t? [0, text.length]\n\t\t\t\t\t\t\t: findRange(text, match, nth);\n\t\t\t\t\tif (!range)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`TextReveal.rects: \"${String(match)}\" not found in \"${text}\".`,\n\t\t\t\t\t\t);\n\t\t\t\t\treturn rangeRects(m, range[0], range[1], measureOptions).map((q) => ({\n\t\t\t\t\t\t...q,\n\t\t\t\t\t\tx: q.x + placement.x,\n\t\t\t\t\t\ty: q.y + placement.y,\n\t\t\t\t\t}));\n\t\t\t\t},\n\t\t\t}),\n\t\t};\n\t},\n});\n\n// ── Helpers ──────────────────────────────────────────────────────────\n\nconst round = (v: number) => Math.round(v * 10000) / 10000;\nconst list = (values: readonly number[]) => `[${values.map(round).join(\",\")}]`;\n\n/** 1 for characters inside an emphasis match, else 0 (null when nothing matches). */\nfunction emphasisWeights(\n\ttext: string,\n\tpatterns: ReadonlyArray<string | RegExp>,\n): number[] | null {\n\tif (patterns.length === 0) return null;\n\tconst w = new Array<number>(text.length).fill(0);\n\tfor (const p of patterns) {\n\t\tfor (let nth = 0; ; nth++) {\n\t\t\tconst range = findRange(text, p, nth);\n\t\t\tif (!range || range[1] <= range[0]) break;\n\t\t\tfor (let i = range[0]; i < range[1]; i++) w[i] = 1;\n\t\t}\n\t}\n\treturn w.some((v) => v > 0) ? w : null;\n}\n\n/** Text-reveal count keys: ≤ 1 is a fraction, > 1 a character count. */\nfunction countKeys(length: number, frames: readonly number[]): KeyframeTuple[] {\n\tconst keys: KeyframeTuple[] = [[0, 0]];\n\tframes.forEach((f, i) => {\n\t\tconst c = i + 1;\n\t\tkeys.push([f, c === 1 ? 1 / length : c, \"hold\"]);\n\t});\n\treturn keys;\n}\n\n/** Landing shake, per frame after a unit lands (× 0.045 em). */\nconst SHAKE = [0, 1, -0.55, 0.3, -0.12, 0];\n\nfunction slamShake(\n\tunits: ReadonlyArray<{ settled: number }>,\n\tfs: number,\n\tr: { spread(n: number): number },\n): LayerAnimation[] {\n\tconst end = Math.max(0, ...units.map((u) => u.settled)) + 8;\n\tconst xs = new Array<number>(end + 1).fill(0);\n\tconst ys = new Array<number>(end + 1).fill(0);\n\tconst a = 0.045 * fs;\n\tconst profile = SHAKE;\n\tfor (const u of units) {\n\t\tconst dx = r.spread(1);\n\t\tprofile.forEach((p, k) => {\n\t\t\tconst f = u.settled + k;\n\t\t\tif (f <= end) {\n\t\t\t\txs[f] = (xs[f] ?? 0) + a * p * dx;\n\t\t\t\tys[f] = (ys[f] ?? 0) + a * p * 0.6;\n\t\t\t}\n\t\t});\n\t}\n\treturn [\n\t\t// Tight tolerance: the text must come to rest exactly where it belongs.\n\t\tLayerAnimation.keys(\"x\", bakeSamples(xs, 0, { tolerance: 0.005 })),\n\t\tLayerAnimation.keys(\"y\", bakeSamples(ys, 0, { tolerance: 0.005 })),\n\t];\n}\n\ninterface Ctx {\n\thost: import(\"framefields/components\").Host;\n\ttheme: Theme;\n\tfps: number;\n\tseed: number;\n\tpartId(part: string): string;\n\trng(key: string | number): import(\"framefields/components\").Rng;\n}\n\n/** Where the caret sits after `count` characters: x, and the line's top. */\nfunction caretAt(\n\tcount: number,\n\tm: Metrics,\n\toptions: MeasureOptions,\n): [number, number] {\n\tlet last: Metrics[\"glyphs\"][number] | undefined;\n\tfor (const g of m.glyphs)\n\t\tif (g.index < count && (!last || g.index > last.index)) last = g;\n\tif (!last) return [lineShift(m, 0, options), 0];\n\tconst shift = lineShift(m, last.line, options);\n\treturn [\n\t\tshift + last.x + last.width - (options.letterSpacing ?? 0),\n\t\tlast.line * m.lineHeight,\n\t];\n}\n\nfunction buildCaret(\n\tctx: Ctx,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\tfs: number,\n\to: {\n\t\tat: readonly number[];\n\t\tcounts: readonly number[];\n\t\tuntil: number;\n\t\tshape: \"bar\" | \"block\";\n\t},\n): Animatable<LayoutNode> {\n\tconst w = o.shape === \"bar\" ? Math.max(2, 0.06 * fs) : 0.42 * fs;\n\tconst h = o.shape === \"bar\" ? 1.05 * fs : 0.42 * fs;\n\tconst dy =\n\t\to.shape === \"bar\" ? (m.lineHeight - h) / 2 : m.lineHeight * 0.52 - h / 2;\n\tconst gap = o.shape === \"bar\" ? 0.04 * fs : 0.18 * fs;\n\tconst [x0, y0] = caretAt(0, m, options);\n\tconst xs: KeyframeTuple[] = [[0, x0 + (o.shape === \"bar\" ? 0 : -w)]];\n\tconst ys: KeyframeTuple[] = [[0, y0 + dy]];\n\to.at.forEach((f, i) => {\n\t\tconst [x, y] = caretAt(o.counts[i] ?? 0, m, options);\n\t\txs.push([f, x + gap, \"hold\"]);\n\t\tys.push([f, y + dy, \"hold\"]);\n\t});\n\tconst caret = ctx.host.box({\n\t\tid: ctx.partId(\"caret\"),\n\t\tposition: \"absolute\",\n\t\tx: x0,\n\t\ty: y0 + dy,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: ctx.theme.color.accent,\n\t\tborderRadius: o.shape === \"bar\" ? w / 2 : w * 0.18,\n\t});\n\t// Solid while text arrives, then a few blinks, then gone.\n\tconst last = o.at[o.at.length - 1] ?? 0;\n\tconst half = Math.max(1, Math.round(ctx.fps * 0.5));\n\tconst blink: KeyframeTuple[] = [[0, 1]];\n\tlet on = false;\n\tfor (let f = last + half; f < o.until; f += half, on = !on)\n\t\tblink.push([f, on ? 1 : 0, \"hold\"]);\n\tblink.push([Math.max(last + 1, o.until), 0, \"hold\"]);\n\tcaret.animate(\n\t\tLayerAnimation.create().keys(\"x\", xs).keys(\"y\", ys).keys(\"opacity\", blink),\n\t);\n\treturn caret;\n}\n\n/** Centre x of every character (spaces included), wrapped and aligned. */\nfunction glyphCenters(\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n): number[] {\n\tconst xs = new Array<number>(text.length).fill(0);\n\tfor (const g of m.glyphs)\n\t\txs[g.index] = lineShift(m, g.line, options) + g.x + g.width / 2;\n\treturn xs;\n}\n\n/**\n * A soft-edged matte for `wipe`: transparent → opaque → opaque → transparent,\n * `feather` px ramps. `hidden` / `shown` / `gone` are its x for each phase.\n */\nfunction wipeMatte(\n\tctx: Ctx,\n\tW: number,\n\tH: number,\n\tfs: number,\n\tinitial: \"hidden\" | \"shown\",\n) {\n\tconst feather = Math.max(24, 0.9 * fs);\n\tconst pad = fs * 0.5;\n\tconst total = W + feather * 2;\n\tconst a = ((feather / total) * 100).toFixed(3);\n\tconst b = (((feather + W) / total) * 100).toFixed(3);\n\tconst hidden = -total;\n\tconst shown = -feather;\n\tconst matte = ctx.host.box({\n\t\tid: ctx.partId(\"matte\"),\n\t\tposition: \"absolute\",\n\t\tx: initial === \"hidden\" ? hidden : shown,\n\t\ty: -pad,\n\t\twidth: total,\n\t\theight: H + pad * 2,\n\t\tbackground: `linear-gradient(90deg, rgba(255,255,255,0) 0%, #ffffff ${a}%, #ffffff ${b}%, rgba(255,255,255,0) 100%)`,\n\t});\n\treturn {\n\t\tmatte,\n\t\tfeather,\n\t\ttotal,\n\t\thidden,\n\t\tshown,\n\t\tgone: W + feather,\n\t\t/** Where visibility reaches zero, for a matte at `x`. */\n\t\tfront: (x: number) => x + total,\n\t};\n}\n\ninterface MaskLine {\n\tclip: Animatable<LayoutNode>;\n\ttext: Animatable<TextNode>;\n\tcontent: string;\n\trestY: number;\n\ttravel: number;\n}\n\n/** One clipped text node per measured line, each at its wrapped position. */\nfunction maskLines(\n\tctx: Ctx,\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\ttextProps: TextProps,\n\tfs: number,\n): MaskLine[] {\n\tconst pad = Math.round(fs * 0.18);\n\tconst { width: _w, align: _a, ...lineProps } = textProps;\n\treturn unitsOf(text, \"line\", m).map((u, i) => {\n\t\tconst first = m.glyphs.find(\n\t\t\t(g) => g.index >= u.start && !/\\s/.test(g.char),\n\t\t);\n\t\tconst x = lineShift(m, i, options) + (first?.x ?? 0);\n\t\tconst content = text.slice(u.start, u.end).trim();\n\t\tconst lineText = ctx.host.text(content, {\n\t\t\t...lineProps,\n\t\t\tid: ctx.partId(`line-${i}-text`),\n\t\t\tposition: \"absolute\",\n\t\t\tx,\n\t\t\ty: pad,\n\t\t});\n\t\tconst clip = ctx.host.box({\n\t\t\tid: ctx.partId(`line-${i}`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: i * m.lineHeight - pad,\n\t\t\twidth: Math.max(m.width, options.width ?? 0) + fs,\n\t\t\theight: m.lineHeight + pad * 2,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: [lineText],\n\t\t});\n\t\treturn {\n\t\t\tclip,\n\t\t\ttext: lineText,\n\t\t\tcontent,\n\t\t\trestY: pad,\n\t\t\ttravel: m.lineHeight + pad,\n\t\t};\n\t});\n}\n\n/**\n * Scramble: each character is a clipped vertical reel of seeded glyphs that\n * steps every couple of frames and lands on the real glyph. Reels are as\n * wide as the real glyph's advance, so the line never jitters.\n */\nfunction scramble(\n\tctx: Ctx,\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\ttextProps: TextProps,\n\to: { stagger: number; cycle: number; order: (typeof ORDERS)[number] },\n) {\n\tconst units = unitsOf(text, \"char\", m);\n\tconst starts = unitStarts(units.length, o.stagger, o.order, ctx.seed);\n\tconst step = Math.max(1, scaleFrames(2, ctx.fps));\n\tconst { width: _w, align: _a, letterSpacing: _l, ...glyphProps } = textProps;\n\tconst reels: LayoutNode[] = [];\n\tconst cues: Array<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tindex: number;\n\t\ttext: string;\n\t}> = [];\n\tlet done = 0;\n\tunits.forEach((u, i) => {\n\t\tconst g = m.glyphs.find((q) => q.index === u.start);\n\t\tif (!g) return;\n\t\tconst appear = Math.round((starts[i] ?? 0) * 0.5);\n\t\tconst lock = Math.round((starts[i] ?? 0) + o.cycle);\n\t\tconst r = ctx.rng(`scramble:${u.start}`);\n\t\tconst set = scrambleSet(u.text);\n\t\tconst count = Math.max(1, Math.floor((lock - appear) / step));\n\t\tconst glyphs = Array.from({ length: count }, () => r.pick([...set]));\n\t\tglyphs.push(u.text);\n\t\tconst advance = g.width - (options.letterSpacing ?? 0);\n\t\tconst reel = ctx.host.text(glyphs.join(\"\\n\"), {\n\t\t\t...glyphProps,\n\t\t\tid: ctx.partId(`reel-${u.start}`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: advance,\n\t\t\talign: \"center\",\n\t\t});\n\t\tconst ys: KeyframeTuple[] = [[0, 0]];\n\t\tfor (let k = 1; k < count; k++)\n\t\t\tys.push([appear + k * step, -k * m.lineHeight, \"hold\"]);\n\t\tys.push([lock, -count * m.lineHeight, \"hold\"]);\n\t\treel.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"y\", ys)\n\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t[appear, 0],\n\t\t\t\t\t[appear + 3, 1],\n\t\t\t\t]),\n\t\t);\n\t\treels.push(\n\t\t\tctx.host.box({\n\t\t\t\tid: ctx.partId(`cell-${u.start}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: lineShift(m, g.line, options) + g.x,\n\t\t\t\ty: g.line * m.lineHeight,\n\t\t\t\twidth: advance,\n\t\t\t\theight: m.lineHeight,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: [reel],\n\t\t\t}),\n\t\t);\n\t\tcues.push({ frame: appear, settled: lock, index: u.index, text: u.text });\n\t\tdone = Math.max(done, lock);\n\t});\n\tconst group = ctx.host.box({\n\t\tid: ctx.partId(\"reels\"),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: m.width,\n\t\theight: m.height,\n\t\tchildren: reels,\n\t});\n\tgroup.animate(\n\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t[0, 1],\n\t\t\t[done, 0, \"hold\"],\n\t\t]),\n\t);\n\treturn { reels: group, cues, done };\n}\n\n/**\n * Headlines and body copy that animate in and out by character, word or line:\n * rise, blur, track, mask, slam, scatter, scramble, type, stream, wipe and\n * flip, each ending on exactly the static text.\n */\nexport const TextReveal = /* @__PURE__ */ bindComponent(textReveal);\n"
33
+ "content": "/**\n * TextReveal: headlines and body copy that animate in (and out) by\n * character, word or line. One timing model, many looks. Glyph styles run on\n * the GPU through baked per-unit schedules, so a paragraph stays one text\n * node and its final frame is exactly the static text.\n */\nimport {\n\ttype AnimatableProp,\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n\ttype TextNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tconstantExpression,\n\tdefineComponent,\n\tevaluateEase,\n\tfindRange,\n\ttype GlyphAnimator,\n\tglyphAnimator,\n\ttype GlyphProps,\n\ttype GlyphUnit,\n\tlineShift,\n\ttype MeasureOptions,\n\ttype Metrics,\n\trangeRects,\n\ttype Rect,\n\trendererChars,\n\tround as roundTo,\n\tscaleFrames,\n\tscheduleExpression,\n\ttype Theme,\n\tthemeFrames,\n\ttypingFrames,\n\ttype UnitSchedule,\n\tz,\n} from \"framefields/components\";\nimport {\n\tburstStarts,\n\tindexedStarts,\n\tscrambleSet,\n\tunitStarts,\n\tunitsOf,\n} from \"./plan.js\";\n\nexport const REVEAL_STYLES = [\n\t\"rise\",\n\t\"blur\",\n\t\"track\",\n\t\"mask\",\n\t\"slam\",\n\t\"scatter\",\n\t\"scramble\",\n\t\"type\",\n\t\"stream\",\n\t\"wipe\",\n\t\"flip\",\n] as const;\nexport type RevealStyle = (typeof REVEAL_STYLES)[number];\n\n/** Styles that can also play out (`exitStyle`). */\nexport const EXIT_STYLES = [\n\t\"rise\",\n\t\"blur\",\n\t\"track\",\n\t\"mask\",\n\t\"scatter\",\n\t\"type\",\n\t\"stream\",\n\t\"wipe\",\n\t\"flip\",\n] as const;\nexport type ExitStyle = (typeof EXIT_STYLES)[number];\n\nconst ORDERS = [\"forward\", \"reverse\", \"center\", \"edges\", \"random\"] as const;\n\ninterface StyleDefaults {\n\tby: GlyphUnit;\n\t/** Frames between units at 30 fps. */\n\tstagger: number;\n\tspeed: keyof Theme[\"motion\"][\"speed\"];\n\t/** Multiplier on the speed token. */\n\tlength: number;\n\tease: string;\n}\n\nconst STYLE: Record<RevealStyle, StyleDefaults> = {\n\trise: {\n\t\tby: \"word\",\n\t\tstagger: 3,\n\t\tspeed: \"base\",\n\t\tlength: 1.4,\n\t\tease: \"expo.out\",\n\t},\n\tblur: {\n\t\tby: \"char\",\n\t\tstagger: 1.2,\n\t\tspeed: \"slow\",\n\t\tlength: 1,\n\t\tease: \"power3.out\",\n\t},\n\ttrack: {\n\t\tby: \"line\",\n\t\tstagger: 0,\n\t\tspeed: \"slow\",\n\t\tlength: 1.5,\n\t\tease: \"expo.out\",\n\t},\n\tmask: {\n\t\tby: \"line\",\n\t\tstagger: 5,\n\t\tspeed: \"base\",\n\t\tlength: 1.5,\n\t\tease: \"expo.out\",\n\t},\n\tslam: { by: \"word\", stagger: 7, speed: \"fast\", length: 1, ease: \"power3.in\" },\n\tscatter: {\n\t\tby: \"char\",\n\t\tstagger: 0.8,\n\t\tspeed: \"slow\",\n\t\tlength: 1.2,\n\t\tease: \"expo.out\",\n\t},\n\tscramble: {\n\t\tby: \"char\",\n\t\tstagger: 1.5,\n\t\tspeed: \"base\",\n\t\tlength: 1,\n\t\tease: \"none\",\n\t},\n\ttype: { by: \"char\", stagger: 0, speed: \"fast\", length: 1, ease: \"none\" },\n\tstream: {\n\t\tby: \"word\",\n\t\tstagger: 6,\n\t\tspeed: \"slow\",\n\t\tlength: 1,\n\t\tease: \"power2.out\",\n\t},\n\twipe: {\n\t\tby: \"line\",\n\t\tstagger: 0,\n\t\tspeed: \"slow\",\n\t\tlength: 1.4,\n\t\tease: \"power2.inOut\",\n\t},\n\tflip: {\n\t\tby: \"char\",\n\t\tstagger: 1.5,\n\t\tspeed: \"base\",\n\t\tlength: 1.2,\n\t\tease: \"back.out(1.4)\",\n\t},\n};\n\n/** What each glyph style pushes at full weight (font-size units, degrees). */\nfunction glyphFrom(style: RevealStyle, fs: number, exit: boolean): GlyphProps {\n\tswitch (style) {\n\t\tcase \"rise\":\n\t\t\treturn { y: (exit ? -0.32 : 0.42) * fs, opacity: 0 };\n\t\tcase \"blur\":\n\t\t\treturn { blur: 0.16 * fs, scale: 1.18, opacity: 0 };\n\t\tcase \"flip\":\n\t\t\treturn { rotationX: exit ? -90 : 90, opacity: 0 };\n\t\tdefault:\n\t\t\treturn { opacity: 0 };\n\t}\n}\n\nexport interface TextRevealCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Per unit: starts moving (`frame`) and comes to rest (`settled`). */\n\tunits: ReadonlyArray<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tindex: number;\n\t\ttext: string;\n\t}>;\n\t/** The whole text is at rest. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** The exit starts (empty without `exitStyle`). */\n\texitStart: ReadonlyArray<{ frame: number }>;\n\t/** Each typed character (`type`) or arriving word (`stream`): for key sounds. */\n\tkeystrokes: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nexport interface TextRevealApi {\n\t/**\n\t * Rects (parent coordinates) that a match covers, one per line: hand\n\t * them to `Annotate` or a camera. Without `match`, the whole text.\n\t */\n\trects(match?: string | RegExp, nth?: number): Rect[];\n}\n\ntype TextProps = Partial<\n\tPick<\n\t\tTextNode,\n\t\t| \"fontFamily\"\n\t\t| \"fontSize\"\n\t\t| \"fontWeight\"\n\t\t| \"fill\"\n\t\t| \"align\"\n\t\t| \"lineHeight\"\n\t\t| \"letterSpacing\"\n\t\t| \"direction\"\n\t\t| \"width\"\n\t>\n>;\n\nconst ALIGN = { start: \"left\", center: \"center\", end: \"right\" } as const;\n\nexport const textReveal = /* @__PURE__ */ defineComponent({\n\tname: \"TextReveal\",\n\tprops: () => ({\n\t\ttext: z.string().min(1),\n\t\tstyle: z.enum(REVEAL_STYLES).default(\"rise\"),\n\t\t/** Unit of animation. Default per style. */\n\t\tby: z.enum([\"char\", \"word\", \"line\"]).optional(),\n\t\t/** Frames between units. Default per style. */\n\t\tstagger: z.number().min(0).optional(),\n\t\torder: z.enum(ORDERS).default(\"forward\"),\n\t\t/** Frames one unit's move takes. Default per style, from the theme. */\n\t\tunitDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\t/** Play out after `hold`, unit by unit. */\n\t\texitStyle: z.union([z.enum(EXIT_STYLES), z.literal(false)]).default(false),\n\t\t/** Typing speed for `type`, characters a second. */\n\t\tcps: z.number().positive().default(24),\n\t\t/** Caret for `type` and `stream`. */\n\t\tcaret: z.boolean().default(true),\n\t\t/** Words or patterns drawn in the theme's accent. */\n\t\temphasis: z.array(z.union([z.string(), z.instanceof(RegExp)])).default([]),\n\t\taccent: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontWeight: z.union([z.number(), z.string()]).optional(),\n\t\tfill: z.string().optional(),\n\t\talign: z.enum([\"start\", \"center\", \"end\"]).default(\"start\"),\n\t\tlineHeight: z.number().positive().optional(),\n\t\tletterSpacing: z.number().optional(),\n\t\tdirection: z.enum([\"ltr\", \"rtl\", \"auto\"]).optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst text = props.text;\n\t\tconst fs = props.fontSize ?? 96 * theme.scale;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst registered = host.fonts();\n\t\tif (registered.length > 0 && !registered.includes(fontFamily)) {\n\t\t\tthrow new Error(\n\t\t\t\t`TextReveal: font \"${fontFamily}\" is not registered. Registered: ${registered.join(\", \")}.`,\n\t\t\t);\n\t\t}\n\t\tconst wrap = ctx.requested.width;\n\t\tconst textProps: TextProps = {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs,\n\t\t\tfill: props.fill ?? theme.color.ink,\n\t\t\talign: props.align,\n\t\t\t...(props.fontWeight !== undefined && { fontWeight: props.fontWeight }),\n\t\t\t...(props.lineHeight !== undefined && { lineHeight: props.lineHeight }),\n\t\t\t...(props.letterSpacing !== undefined && {\n\t\t\t\tletterSpacing: props.letterSpacing,\n\t\t\t}),\n\t\t\t...(props.direction !== undefined && { direction: props.direction }),\n\t\t\t...(wrap !== undefined && { width: wrap }),\n\t\t};\n\t\tconst measureOptions: MeasureOptions = {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs,\n\t\t\tfontWeight: props.fontWeight,\n\t\t\tletterSpacing: props.letterSpacing,\n\t\t\tlineHeight: props.lineHeight,\n\t\t\twidth: wrap,\n\t\t\talign: ALIGN[props.align],\n\t\t\tdirection: props.direction,\n\t\t};\n\t\tconst m = host.measure(text, measureOptions);\n\t\tconst W = wrap ?? m.width;\n\t\tconst H = m.height;\n\n\t\tconst style = STYLE[props.style];\n\t\tconst by: GlyphUnit = props.by ?? style.by;\n\t\t// Stagger stays fractional: units may start between frames.\n\t\tconst perFps = (v: number) => (v * fps) / 30;\n\t\tconst stagger = perFps(props.stagger ?? style.stagger);\n\t\tconst duration =\n\t\t\tprops.unitDuration ??\n\t\t\tMath.max(\n\t\t\t\t1,\n\t\t\t\tMath.round(themeFrames(theme, style.speed, fps) * style.length),\n\t\t\t);\n\t\tconst ease = props.ease ?? style.ease;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst animators: GlyphAnimator[] = [];\n\t\tconst keystrokes: Array<{ frame: number; index: number }> = [];\n\t\tlet unitCues: TextRevealCues[\"units\"] = [];\n\t\tlet revealed = 0;\n\n\t\t// Emphasis is static colour, first in the stack so streaming ink mixes over it.\n\t\tconst emphasised = emphasisWeights(text, props.emphasis);\n\t\tif (emphasised) {\n\t\t\tanimators.push(\n\t\t\t\tglyphAnimator(\n\t\t\t\t\t\"emphasis\",\n\t\t\t\t\tconstantExpression(\"char\", rendererChars(text, emphasised)),\n\t\t\t\t\t{\n\t\t\t\t\t\tfillColor: props.accent ?? theme.color.accent,\n\t\t\t\t\t},\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\n\t\tconst units = unitsOf(text, by, m);\n\t\tconst schedule = (starts: number[], dur = duration, e = ease) => ({\n\t\t\tstarts: indexedStarts(text, by, units, starts),\n\t\t\tduration: dur,\n\t\t\tease: e,\n\t\t});\n\t\tconst cuesFor = (starts: number[], dur: number) =>\n\t\t\tunits.map((u, i) => ({\n\t\t\t\tframe: Math.round(starts[i] ?? 0),\n\t\t\t\tsettled: Math.round((starts[i] ?? 0) + dur),\n\t\t\t\tindex: u.index,\n\t\t\t\ttext: u.text,\n\t\t\t}));\n\n\t\t// Tracking widens a line: unwrapped text gets a box with room for it,\n\t\t// placed so `align` holds while it grows.\n\t\tconst tracks =\n\t\t\twrap === undefined &&\n\t\t\t(props.style === \"track\" || props.exitStyle === \"track\");\n\t\tconst room = tracks ? 0.5 * fs * Math.max(0, text.length - 1) : 0;\n\t\tconst shift =\n\t\t\tprops.align === \"center\" ? 0.5 : props.align === \"end\" ? 1 : 0;\n\t\tconst textNode = host.text(text, {\n\t\t\t...textProps,\n\t\t\t...(tracks && { width: W + room }),\n\t\t\tid: ctx.partId(\"text\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: -room * shift,\n\t\t\ty: 0,\n\t\t});\n\t\tparts.text = textNode;\n\t\t// What carries the glyphs: the text node, or a wrapper around it (null when lines are split).\n\t\tlet main: LayoutNode | null = textNode;\n\t\tlet lines: MaskLine[] = [];\n\t\tlet sweep: ReturnType<typeof wipeMatte> | null = null;\n\n\t\t// ── Enter ────────────────────────────────────────────────────────\n\t\tswitch (props.style) {\n\t\t\tcase \"rise\":\n\t\t\tcase \"blur\":\n\t\t\tcase \"flip\": {\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\tscheduleExpression(by, schedule(starts), \"enter\"),\n\t\t\t\t\t\tglyphFrom(props.style, fs, false),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"slam\": {\n\t\t\t\t// Each unit is its own node so it scales around its own centre;\n\t\t\t\t// the paragraph takes over the frame the last one lands.\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tconst { width: _w, align: _a, ...unitProps } = textProps;\n\t\t\t\tconst pieces = units.map((u, i) => {\n\t\t\t\t\tconst rect = rangeRects(m, u.start, u.end, measureOptions)[0];\n\t\t\t\t\tconst s = starts[i] ?? 0;\n\t\t\t\t\tconst accented =\n\t\t\t\t\t\temphasised?.slice(u.start, u.end).every((v) => v === 1) ?? false;\n\t\t\t\t\tconst piece = host.text(u.text, {\n\t\t\t\t\t\t...unitProps,\n\t\t\t\t\t\t...(accented && { fill: props.accent ?? theme.color.accent }),\n\t\t\t\t\t\tid: ctx.partId(`unit-${i}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: rect?.x ?? 0,\n\t\t\t\t\t\ty: rect?.y ?? 0,\n\t\t\t\t\t});\n\t\t\t\t\tpiece.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"scale\", [\n\t\t\t\t\t\t\t\t[s, 2.4],\n\t\t\t\t\t\t\t\t[s + duration, 1, ease],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[s, 0],\n\t\t\t\t\t\t\t\t[s + Math.max(1, Math.round(duration * 0.45)), 1],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\treturn piece;\n\t\t\t\t});\n\t\t\t\tconst landed = Math.max(0, ...unitCues.map((u) => u.settled));\n\t\t\t\tconst group = host.box({\n\t\t\t\t\tid: ctx.partId(\"units\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tchildren: pieces,\n\t\t\t\t});\n\t\t\t\tgroup.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 1],\n\t\t\t\t\t\t[landed, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[landed, 1, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\t// A short landing shake as each unit hits.\n\t\t\t\tconst shake = host.box({\n\t\t\t\t\tid: ctx.partId(\"shake\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tchildren: [group, textNode],\n\t\t\t\t});\n\t\t\t\tshake.animate(...slamShake(unitCues, fs, ctx.rng(\"slam\")));\n\t\t\t\tparts.units = group;\n\t\t\t\tparts.shake = shake;\n\t\t\t\tmain = shake;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"scatter\": {\n\t\t\t\tconst starts = unitStarts(units.length, stagger, props.order, ctx.seed);\n\t\t\t\tconst sched = schedule(starts);\n\t\t\t\tconst per = (key: string) => {\n\t\t\t\t\tconst q = ctx.rng(key);\n\t\t\t\t\treturn rendererChars(\n\t\t\t\t\t\ttext,\n\t\t\t\t\t\tArray.from({ length: text.length }, () => q.spread(1)),\n\t\t\t\t\t);\n\t\t\t\t};\n\t\t\t\tconst rx = per(\"scatter-x\");\n\t\t\t\tconst ry = per(\"scatter-y\");\n\t\t\t\tconst rr = per(\"scatter-r\");\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\"enter-x\", scheduleExpression(by, sched, \"enter\", rx), {\n\t\t\t\t\t\tx: 1.1 * fs,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter-y\", scheduleExpression(by, sched, \"enter\", ry), {\n\t\t\t\t\t\ty: 0.9 * fs,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter-r\", scheduleExpression(by, sched, \"enter\", rr), {\n\t\t\t\t\t\trotation: 120,\n\t\t\t\t\t}),\n\t\t\t\t\tglyphAnimator(\"enter\", scheduleExpression(by, sched, \"enter\"), {\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\tblur: 0.06 * fs,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t\tunitCues = cuesFor(starts, duration);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stream\": {\n\t\t\t\tconst words = unitsOf(text, \"word\", m);\n\t\t\t\tconst step = scaleFrames(1, fps);\n\t\t\t\tconst starts = burstStarts(\n\t\t\t\t\twords.length,\n\t\t\t\t\tperFps(props.stagger ?? style.stagger),\n\t\t\t\t\tstep,\n\t\t\t\t\tctx.rng(\"stream\"),\n\t\t\t\t);\n\t\t\t\tconst fade = Math.max(2, themeFrames(theme, \"fast\", fps));\n\t\t\t\tconst ink = props.unitDuration ?? themeFrames(theme, \"slow\", fps);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t{ starts, duration: fade, ease: \"power2.out\" },\n\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\t{ opacity: 0, y: 0.08 * fs },\n\t\t\t\t\t),\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"ink\",\n\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\"word\",\n\t\t\t\t\t\t\t{ starts, duration: ink, ease: \"sine.inOut\" },\n\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t),\n\t\t\t\t\t\t{ fillColor: theme.color.muted },\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = words.map((w, i) => ({\n\t\t\t\t\tframe: starts[i] ?? 0,\n\t\t\t\t\tsettled: (starts[i] ?? 0) + ink,\n\t\t\t\t\tindex: w.index,\n\t\t\t\t\ttext: w.text,\n\t\t\t\t}));\n\t\t\t\twords.forEach((w, i) => {\n\t\t\t\t\tkeystrokes.push({ frame: starts[i] ?? 0, index: w.index });\n\t\t\t\t});\n\t\t\t\tif (props.caret) {\n\t\t\t\t\tconst ends = words.map((w) => w.end);\n\t\t\t\t\tconst caret = buildCaret(ctx, m, measureOptions, fs, {\n\t\t\t\t\t\tat: starts,\n\t\t\t\t\t\tcounts: ends,\n\t\t\t\t\t\tuntil: (starts[starts.length - 1] ?? 0) + ink,\n\t\t\t\t\t\tshape: \"block\",\n\t\t\t\t\t});\n\t\t\t\t\tchildren.push(caret);\n\t\t\t\t\tparts.caret = caret;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"type\": {\n\t\t\t\tconst frames = typingFrames(text, props.cps, fps, ctx.rng(\"type\"));\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"text\" as AnimatableProp,\n\t\t\t\t\t\tcountKeys(text.length, frames),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tframes.forEach((f, i) => {\n\t\t\t\t\tif (!/\\s/.test(text[i] ?? \"\"))\n\t\t\t\t\t\tkeystrokes.push({ frame: f, index: i });\n\t\t\t\t});\n\t\t\t\tunitCues = keystrokes.map((k) => ({\n\t\t\t\t\tframe: k.frame,\n\t\t\t\t\tsettled: k.frame,\n\t\t\t\t\tindex: k.index,\n\t\t\t\t\ttext: text[k.index] ?? \"\",\n\t\t\t\t}));\n\t\t\t\trevealed = (frames[frames.length - 1] ?? 0) + 1;\n\t\t\t\tif (props.caret) {\n\t\t\t\t\tconst caret = buildCaret(ctx, m, measureOptions, fs, {\n\t\t\t\t\t\tat: frames,\n\t\t\t\t\t\tcounts: frames.map((_, i) => i + 1),\n\t\t\t\t\t\tuntil: revealed + ctx.hold,\n\t\t\t\t\t\tshape: \"bar\",\n\t\t\t\t\t});\n\t\t\t\t\tchildren.push(caret);\n\t\t\t\t\tparts.caret = caret;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"track\": {\n\t\t\t\tconst ls = props.letterSpacing ?? 0;\n\t\t\t\tconst wide = ls + 0.5 * fs;\n\t\t\t\tconst fade = Math.round(duration * 0.5);\n\t\t\t\tconst keys: KeyframeTuple[] = [\n\t\t\t\t\t[0, wide],\n\t\t\t\t\t[duration, ls, ease],\n\t\t\t\t];\n\t\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t\t.keys(\"letterSpacing\", keys)\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[fade, 1, \"power2.out\"],\n\t\t\t\t\t]);\n\t\t\t\ttextNode.animate(anim);\n\t\t\t\tunitCues = [{ frame: 0, settled: duration, index: 0, text }];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"mask\": {\n\t\t\t\tlines = maskLines(ctx, text, m, measureOptions, textProps, fs);\n\t\t\t\tconst starts = unitStarts(lines.length, stagger, props.order, ctx.seed);\n\t\t\t\tlines.forEach((line, i) => {\n\t\t\t\t\tconst s = starts[i] ?? 0;\n\t\t\t\t\tline.text.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t[s, line.restY + line.travel],\n\t\t\t\t\t\t\t[s + duration, line.restY, ease],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tchildren.push(line.clip);\n\t\t\t\t\tparts[`line-${i}`] = line.clip;\n\t\t\t\t});\n\t\t\t\tunitCues = lines.map((l, i) => ({\n\t\t\t\t\tframe: Math.round(starts[i] ?? 0),\n\t\t\t\t\tsettled: Math.round((starts[i] ?? 0) + duration),\n\t\t\t\t\tindex: i,\n\t\t\t\t\ttext: l.content,\n\t\t\t\t}));\n\t\t\t\tmain = null;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"wipe\": {\n\t\t\t\tconst w = wipeMatte(ctx, W, H, fs, \"hidden\");\n\t\t\t\tsweep = w;\n\t\t\t\tw.matte.animate(\n\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t[0, w.hidden],\n\t\t\t\t\t\t[duration, w.shown, ease],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\thost.matte(textNode, w.matte);\n\t\t\t\tparts.matte = w.matte;\n\t\t\t\t// The front trails a little blur over the glyphs it is uncovering.\n\t\t\t\tconst xs = rendererChars(text, glyphCenters(text, m, measureOptions));\n\t\t\t\tconst front = Array.from({ length: duration + 1 }, (_, f) =>\n\t\t\t\t\tw.front(\n\t\t\t\t\t\tw.hidden + (w.shown - w.hidden) * evaluateEase(ease, f / duration),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tanimators.push(\n\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\"wipe-blur\",\n\t\t\t\t\t\t`((X,T)=>{const f=clamp(frame,0,${duration});const k=floor(f);const front=T[k]+((T[k+1]??T[k])-T[k])*(f-k);return clamp(1-(front-(X[textIndex]??0))/${round(w.feather)},0,1);})(${list(xs)},${list(front)})`,\n\t\t\t\t\t\t{ blur: 0.12 * fs },\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tunitCues = [{ frame: 0, settled: duration, index: 0, text }];\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"scramble\": {\n\t\t\t\tconst result = scramble(ctx, text, m, measureOptions, textProps, {\n\t\t\t\t\tstagger,\n\t\t\t\t\tcycle: duration,\n\t\t\t\t\torder: props.order,\n\t\t\t\t});\n\t\t\t\tchildren.push(result.reels);\n\t\t\t\tparts.reels = result.reels;\n\t\t\t\t// The real text takes over the moment the last glyph locks.\n\t\t\t\ttextNode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[result.done, 1, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tunitCues = result.cues;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif (unitCues.length > 0 && props.style !== \"type\")\n\t\t\trevealed = Math.max(...unitCues.map((u) => u.settled));\n\t\t// The text is at rest only once the last landing shake dies out.\n\t\tif (props.style === \"slam\") revealed += SHAKE.length - 1;\n\n\t\t// ── Exit ─────────────────────────────────────────────────────────\n\t\tlet afterHold = 0;\n\t\tconst exitStart = revealed + ctx.hold;\n\t\tif (props.exitStyle !== false) {\n\t\t\tconst out = props.exitStyle;\n\t\t\tconst outStyle = STYLE[out];\n\t\t\tconst outBy: GlyphUnit = props.by ?? outStyle.by;\n\t\t\tconst outUnits = unitsOf(text, outBy, m);\n\t\t\tconst outStagger = perFps((props.stagger ?? outStyle.stagger) * 0.5);\n\t\t\tconst outDur = Math.max(\n\t\t\t\t1,\n\t\t\t\tMath.round(\n\t\t\t\t\tthemeFrames(theme, outStyle.speed, fps) * outStyle.length * 0.75,\n\t\t\t\t),\n\t\t\t);\n\t\t\tconst outEase = theme.motion.ease.exit;\n\t\t\tconst starts = unitStarts(\n\t\t\t\toutUnits.length,\n\t\t\t\toutStagger,\n\t\t\t\tprops.order,\n\t\t\t\tctx.seed,\n\t\t\t\texitStart,\n\t\t\t);\n\t\t\tconst sched: UnitSchedule = {\n\t\t\t\tstarts: indexedStarts(text, outBy, outUnits, starts),\n\t\t\t\tduration: outDur,\n\t\t\t\tease: outEase,\n\t\t\t};\n\t\t\tafterHold = Math.round(Math.max(...starts) - exitStart + outDur);\n\t\t\tswitch (out) {\n\t\t\t\tcase \"rise\":\n\t\t\t\tcase \"blur\":\n\t\t\t\tcase \"flip\":\n\t\t\t\tcase \"scatter\":\n\t\t\t\tcase \"stream\":\n\t\t\t\t\tanimators.push(\n\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\"exit\",\n\t\t\t\t\t\t\tscheduleExpression(outBy, sched, \"exit\"),\n\t\t\t\t\t\t\tout === \"scatter\"\n\t\t\t\t\t\t\t\t? { y: -0.6 * fs, opacity: 0, blur: 0.06 * fs }\n\t\t\t\t\t\t\t\t: glyphFrom(out, fs, true),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"type\": {\n\t\t\t\t\t// Backspace, twice as fast as typing.\n\t\t\t\t\tconst per = Math.max(1, Math.round(fps / (props.cps * 2)));\n\t\t\t\t\tconst keys: KeyframeTuple[] = [];\n\t\t\t\t\tfor (\n\t\t\t\t\t\tlet c = text.length - 1, f = exitStart + per;\n\t\t\t\t\t\tc >= 0;\n\t\t\t\t\t\tc--, f += per\n\t\t\t\t\t)\n\t\t\t\t\t\tkeys.push([f, c === 1 ? 1 / text.length : c, \"hold\"]);\n\t\t\t\t\ttextNode.animate(LayerAnimation.keys(\"text\" as AnimatableProp, keys));\n\t\t\t\t\tafterHold = per * text.length + 1;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"track\": {\n\t\t\t\t\tconst ls = props.letterSpacing ?? 0;\n\t\t\t\t\ttextNode.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"letterSpacing\", [\n\t\t\t\t\t\t\t\t[exitStart, ls],\n\t\t\t\t\t\t\t\t[exitStart + outDur, ls + 0.4 * fs, outEase],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[exitStart, 1],\n\t\t\t\t\t\t\t\t[exitStart + outDur, 0, outEase],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tafterHold = outDur;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"wipe\": {\n\t\t\t\t\tif (!sweep) {\n\t\t\t\t\t\tsweep = wipeMatte(ctx, W, H, fs, \"shown\");\n\t\t\t\t\t\thost.matte(textNode, sweep.matte);\n\t\t\t\t\t\tparts.matte = sweep.matte;\n\t\t\t\t\t}\n\t\t\t\t\tsweep.matte.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t\t[exitStart, sweep.shown],\n\t\t\t\t\t\t\t[exitStart + outDur, sweep.gone, outEase],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tafterHold = outDur;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"mask\": {\n\t\t\t\t\tif (lines.length === 0)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'TextReveal: exitStyle \"mask\" needs style \"mask\" (it splits the lines).',\n\t\t\t\t\t\t);\n\t\t\t\t\tconst outStarts = unitStarts(\n\t\t\t\t\t\tlines.length,\n\t\t\t\t\t\toutStagger,\n\t\t\t\t\t\tprops.order,\n\t\t\t\t\t\tctx.seed,\n\t\t\t\t\t\texitStart,\n\t\t\t\t\t);\n\t\t\t\t\tlines.forEach((line, i) => {\n\t\t\t\t\t\tconst s = outStarts[i] ?? exitStart;\n\t\t\t\t\t\tline.text.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t\t[s, line.restY],\n\t\t\t\t\t\t\t\t[s + outDur, line.restY - line.travel, outEase],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t\t);\n\t\t\t\t\t});\n\t\t\t\t\tafterHold = Math.round(Math.max(...outStarts) - exitStart + outDur);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (animators.length > 0)\n\t\t\t(textNode as unknown as { animators: GlyphAnimator[] }).animators =\n\t\t\t\tanimators;\n\t\tif (main) children.unshift(main);\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, revealed),\n\t\t\topenEnded: true,\n\t\t\tafterHold,\n\t\t\tcues: {\n\t\t\t\tunits: unitCues,\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\texitStart: props.exitStyle === false ? [] : [{ frame: exitStart }],\n\t\t\t\tkeystrokes,\n\t\t\t} as TextRevealCues,\n\t\t\tparts,\n\t\t\tapi: (placement): TextRevealApi => ({\n\t\t\t\trects(match, nth = 0) {\n\t\t\t\t\tconst range: [number, number] | null =\n\t\t\t\t\t\tmatch === undefined\n\t\t\t\t\t\t\t? [0, text.length]\n\t\t\t\t\t\t\t: findRange(text, match, nth);\n\t\t\t\t\tif (!range)\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`TextReveal.rects: \"${String(match)}\" not found in \"${text}\".`,\n\t\t\t\t\t\t);\n\t\t\t\t\treturn rangeRects(m, range[0], range[1], measureOptions).map((q) => ({\n\t\t\t\t\t\t...q,\n\t\t\t\t\t\tx: q.x + placement.x,\n\t\t\t\t\t\ty: q.y + placement.y,\n\t\t\t\t\t}));\n\t\t\t\t},\n\t\t\t}),\n\t\t};\n\t},\n});\n\n// ── Helpers ──────────────────────────────────────────────────────────\n\nconst round = (v: number) => roundTo(v, 4);\nconst list = (values: readonly number[]) => `[${values.map(round).join(\",\")}]`;\n\n/** 1 for characters inside an emphasis match, else 0 (null when nothing matches). */\nfunction emphasisWeights(\n\ttext: string,\n\tpatterns: ReadonlyArray<string | RegExp>,\n): number[] | null {\n\tif (patterns.length === 0) return null;\n\tconst w = new Array<number>(text.length).fill(0);\n\tfor (const p of patterns) {\n\t\tfor (let nth = 0; ; nth++) {\n\t\t\tconst range = findRange(text, p, nth);\n\t\t\tif (!range || range[1] <= range[0]) break;\n\t\t\tfor (let i = range[0]; i < range[1]; i++) w[i] = 1;\n\t\t}\n\t}\n\treturn w.some((v) => v > 0) ? w : null;\n}\n\n/** Text-reveal count keys: ≤ 1 is a fraction, > 1 a character count. */\nfunction countKeys(length: number, frames: readonly number[]): KeyframeTuple[] {\n\tconst keys: KeyframeTuple[] = [[0, 0]];\n\tframes.forEach((f, i) => {\n\t\tconst c = i + 1;\n\t\tkeys.push([f, c === 1 ? 1 / length : c, \"hold\"]);\n\t});\n\treturn keys;\n}\n\n/** Landing shake, per frame after a unit lands (× 0.045 em). */\nconst SHAKE = [0, 1, -0.55, 0.3, -0.12, 0];\n\nfunction slamShake(\n\tunits: ReadonlyArray<{ settled: number }>,\n\tfs: number,\n\tr: { spread(n: number): number },\n): LayerAnimation[] {\n\tconst end = Math.max(0, ...units.map((u) => u.settled)) + 8;\n\tconst xs = new Array<number>(end + 1).fill(0);\n\tconst ys = new Array<number>(end + 1).fill(0);\n\tconst a = 0.045 * fs;\n\tconst profile = SHAKE;\n\tfor (const u of units) {\n\t\tconst dx = r.spread(1);\n\t\tprofile.forEach((p, k) => {\n\t\t\tconst f = u.settled + k;\n\t\t\tif (f <= end) {\n\t\t\t\txs[f] = (xs[f] ?? 0) + a * p * dx;\n\t\t\t\tys[f] = (ys[f] ?? 0) + a * p * 0.6;\n\t\t\t}\n\t\t});\n\t}\n\treturn [\n\t\t// Tight tolerance: the text must come to rest exactly where it belongs.\n\t\tLayerAnimation.keys(\"x\", bakeSamples(xs, 0, { tolerance: 0.005 })),\n\t\tLayerAnimation.keys(\"y\", bakeSamples(ys, 0, { tolerance: 0.005 })),\n\t];\n}\n\ninterface Ctx {\n\thost: import(\"framefields/components\").Host;\n\ttheme: Theme;\n\tfps: number;\n\tseed: number;\n\tpartId(part: string): string;\n\trng(key: string | number): import(\"framefields/components\").Rng;\n}\n\n/** Where the caret sits after `count` characters: x, and the line's top. */\nfunction caretAt(\n\tcount: number,\n\tm: Metrics,\n\toptions: MeasureOptions,\n): [number, number] {\n\tlet last: Metrics[\"glyphs\"][number] | undefined;\n\tfor (const g of m.glyphs)\n\t\tif (g.index < count && (!last || g.index > last.index)) last = g;\n\tif (!last) return [lineShift(m, 0, options), 0];\n\tconst shift = lineShift(m, last.line, options);\n\treturn [\n\t\tshift + last.x + last.width - (options.letterSpacing ?? 0),\n\t\tlast.line * m.lineHeight,\n\t];\n}\n\nfunction buildCaret(\n\tctx: Ctx,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\tfs: number,\n\to: {\n\t\tat: readonly number[];\n\t\tcounts: readonly number[];\n\t\tuntil: number;\n\t\tshape: \"bar\" | \"block\";\n\t},\n): Animatable<LayoutNode> {\n\tconst w = o.shape === \"bar\" ? Math.max(2, 0.06 * fs) : 0.42 * fs;\n\tconst h = o.shape === \"bar\" ? 1.05 * fs : 0.42 * fs;\n\tconst dy =\n\t\to.shape === \"bar\" ? (m.lineHeight - h) / 2 : m.lineHeight * 0.52 - h / 2;\n\tconst gap = o.shape === \"bar\" ? 0.04 * fs : 0.18 * fs;\n\tconst [x0, y0] = caretAt(0, m, options);\n\tconst xs: KeyframeTuple[] = [[0, x0 + (o.shape === \"bar\" ? 0 : -w)]];\n\tconst ys: KeyframeTuple[] = [[0, y0 + dy]];\n\to.at.forEach((f, i) => {\n\t\tconst [x, y] = caretAt(o.counts[i] ?? 0, m, options);\n\t\txs.push([f, x + gap, \"hold\"]);\n\t\tys.push([f, y + dy, \"hold\"]);\n\t});\n\tconst caret = ctx.host.box({\n\t\tid: ctx.partId(\"caret\"),\n\t\tposition: \"absolute\",\n\t\tx: x0,\n\t\ty: y0 + dy,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: ctx.theme.color.accent,\n\t\tborderRadius: o.shape === \"bar\" ? w / 2 : w * 0.18,\n\t});\n\t// Solid while text arrives, then a few blinks, then gone.\n\tconst last = o.at[o.at.length - 1] ?? 0;\n\tconst half = Math.max(1, Math.round(ctx.fps * 0.5));\n\tconst blink: KeyframeTuple[] = [[0, 1]];\n\tlet on = false;\n\tfor (let f = last + half; f < o.until; f += half, on = !on)\n\t\tblink.push([f, on ? 1 : 0, \"hold\"]);\n\tblink.push([Math.max(last + 1, o.until), 0, \"hold\"]);\n\tcaret.animate(\n\t\tLayerAnimation.create().keys(\"x\", xs).keys(\"y\", ys).keys(\"opacity\", blink),\n\t);\n\treturn caret;\n}\n\n/** Centre x of every character (spaces included), wrapped and aligned. */\nfunction glyphCenters(\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n): number[] {\n\tconst xs = new Array<number>(text.length).fill(0);\n\tfor (const g of m.glyphs)\n\t\txs[g.index] = lineShift(m, g.line, options) + g.x + g.width / 2;\n\treturn xs;\n}\n\n/**\n * A soft-edged matte for `wipe`: transparent → opaque → opaque → transparent,\n * `feather` px ramps. `hidden` / `shown` / `gone` are its x for each phase.\n */\nfunction wipeMatte(\n\tctx: Ctx,\n\tW: number,\n\tH: number,\n\tfs: number,\n\tinitial: \"hidden\" | \"shown\",\n) {\n\tconst feather = Math.max(24, 0.9 * fs);\n\tconst pad = fs * 0.5;\n\tconst total = W + feather * 2;\n\tconst a = ((feather / total) * 100).toFixed(3);\n\tconst b = (((feather + W) / total) * 100).toFixed(3);\n\tconst hidden = -total;\n\tconst shown = -feather;\n\tconst matte = ctx.host.box({\n\t\tid: ctx.partId(\"matte\"),\n\t\tposition: \"absolute\",\n\t\tx: initial === \"hidden\" ? hidden : shown,\n\t\ty: -pad,\n\t\twidth: total,\n\t\theight: H + pad * 2,\n\t\tbackground: `linear-gradient(90deg, rgba(255,255,255,0) 0%, #ffffff ${a}%, #ffffff ${b}%, rgba(255,255,255,0) 100%)`,\n\t});\n\treturn {\n\t\tmatte,\n\t\tfeather,\n\t\ttotal,\n\t\thidden,\n\t\tshown,\n\t\tgone: W + feather,\n\t\t/** Where visibility reaches zero, for a matte at `x`. */\n\t\tfront: (x: number) => x + total,\n\t};\n}\n\ninterface MaskLine {\n\tclip: Animatable<LayoutNode>;\n\ttext: Animatable<TextNode>;\n\tcontent: string;\n\trestY: number;\n\ttravel: number;\n}\n\n/** One clipped text node per measured line, each at its wrapped position. */\nfunction maskLines(\n\tctx: Ctx,\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\ttextProps: TextProps,\n\tfs: number,\n): MaskLine[] {\n\tconst pad = Math.round(fs * 0.18);\n\tconst { width: _w, align: _a, ...lineProps } = textProps;\n\treturn unitsOf(text, \"line\", m).map((u, i) => {\n\t\tconst first = m.glyphs.find(\n\t\t\t(g) => g.index >= u.start && !/\\s/.test(g.char),\n\t\t);\n\t\tconst x = lineShift(m, i, options) + (first?.x ?? 0);\n\t\tconst content = text.slice(u.start, u.end).trim();\n\t\tconst lineText = ctx.host.text(content, {\n\t\t\t...lineProps,\n\t\t\tid: ctx.partId(`line-${i}-text`),\n\t\t\tposition: \"absolute\",\n\t\t\tx,\n\t\t\ty: pad,\n\t\t});\n\t\tconst clip = ctx.host.box({\n\t\t\tid: ctx.partId(`line-${i}`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: i * m.lineHeight - pad,\n\t\t\twidth: Math.max(m.width, options.width ?? 0) + fs,\n\t\t\theight: m.lineHeight + pad * 2,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: [lineText],\n\t\t});\n\t\treturn {\n\t\t\tclip,\n\t\t\ttext: lineText,\n\t\t\tcontent,\n\t\t\trestY: pad,\n\t\t\ttravel: m.lineHeight + pad,\n\t\t};\n\t});\n}\n\n/**\n * Scramble: each character is a clipped vertical reel of seeded glyphs that\n * steps every couple of frames and lands on the real glyph. Reels are as\n * wide as the real glyph's advance, so the line never jitters.\n */\nfunction scramble(\n\tctx: Ctx,\n\ttext: string,\n\tm: Metrics,\n\toptions: MeasureOptions,\n\ttextProps: TextProps,\n\to: { stagger: number; cycle: number; order: (typeof ORDERS)[number] },\n) {\n\tconst units = unitsOf(text, \"char\", m);\n\tconst starts = unitStarts(units.length, o.stagger, o.order, ctx.seed);\n\tconst step = Math.max(1, scaleFrames(2, ctx.fps));\n\tconst { width: _w, align: _a, letterSpacing: _l, ...glyphProps } = textProps;\n\tconst reels: LayoutNode[] = [];\n\tconst cues: Array<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tindex: number;\n\t\ttext: string;\n\t}> = [];\n\tlet done = 0;\n\tunits.forEach((u, i) => {\n\t\tconst g = m.glyphs.find((q) => q.index === u.start);\n\t\tif (!g) return;\n\t\tconst appear = Math.round((starts[i] ?? 0) * 0.5);\n\t\tconst lock = Math.round((starts[i] ?? 0) + o.cycle);\n\t\tconst r = ctx.rng(`scramble:${u.start}`);\n\t\tconst set = scrambleSet(u.text);\n\t\tconst count = Math.max(1, Math.floor((lock - appear) / step));\n\t\tconst glyphs = Array.from({ length: count }, () => r.pick([...set]));\n\t\tglyphs.push(u.text);\n\t\tconst advance = g.width - (options.letterSpacing ?? 0);\n\t\tconst reel = ctx.host.text(glyphs.join(\"\\n\"), {\n\t\t\t...glyphProps,\n\t\t\tid: ctx.partId(`reel-${u.start}`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: advance,\n\t\t\talign: \"center\",\n\t\t});\n\t\tconst ys: KeyframeTuple[] = [[0, 0]];\n\t\tfor (let k = 1; k < count; k++)\n\t\t\tys.push([appear + k * step, -k * m.lineHeight, \"hold\"]);\n\t\tys.push([lock, -count * m.lineHeight, \"hold\"]);\n\t\treel.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"y\", ys)\n\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t[appear, 0],\n\t\t\t\t\t[appear + 3, 1],\n\t\t\t\t]),\n\t\t);\n\t\treels.push(\n\t\t\tctx.host.box({\n\t\t\t\tid: ctx.partId(`cell-${u.start}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: lineShift(m, g.line, options) + g.x,\n\t\t\t\ty: g.line * m.lineHeight,\n\t\t\t\twidth: advance,\n\t\t\t\theight: m.lineHeight,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: [reel],\n\t\t\t}),\n\t\t);\n\t\tcues.push({ frame: appear, settled: lock, index: u.index, text: u.text });\n\t\tdone = Math.max(done, lock);\n\t});\n\tconst group = ctx.host.box({\n\t\tid: ctx.partId(\"reels\"),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: m.width,\n\t\theight: m.height,\n\t\tchildren: reels,\n\t});\n\tgroup.animate(\n\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t[0, 1],\n\t\t\t[done, 0, \"hold\"],\n\t\t]),\n\t);\n\treturn { reels: group, cues, done };\n}\n\n/**\n * Headlines and body copy that animate in and out by character, word or line:\n * rise, blur, track, mask, slam, scatter, scramble, type, stream, wipe and\n * flip, each ending on exactly the static text.\n */\nexport const TextReveal = /* @__PURE__ */ bindComponent(textReveal);\n"
33
34
  },
34
35
  {
35
36
  "path": "text-reveal/plan.ts",
@@ -28,7 +28,7 @@
28
28
  "files": [
29
29
  {
30
30
  "path": "title-card/index.ts",
31
- "content": "/**\n * TitleCard: kicker, headline, rule and sub-line in designed layouts, on\n * one timeline. The text parts are `TextReveal`s (the headline in any reveal\n * style), the rule draws, and the whole card can play out.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\nimport {\n\tEXIT_STYLES,\n\tREVEAL_STYLES,\n\ttextReveal,\n} from \"../text-reveal/index.js\";\n\nexport const TITLE_LAYOUTS = [\"center\", \"left\", \"stack\", \"split\"] as const;\nexport type TitleLayout = (typeof TITLE_LAYOUTS)[number];\n\nexport interface TitleCardCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The headline is at rest. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** Every part is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** The card starts to play out (empty without `exitStyle`). */\n\texitStart: ReadonlyArray<{ frame: number }>;\n}\n\nexport const titleCard = /* @__PURE__ */ defineComponent({\n\tname: \"TitleCard\",\n\tprops: () => ({\n\t\ttitle: z.string().min(1),\n\t\tkicker: z.string().optional(),\n\t\tsubtitle: z.string().optional(),\n\t\tlayout: z.enum(TITLE_LAYOUTS).default(\"center\"),\n\t\t/** The headline's reveal style. */\n\t\treveal: z.enum(REVEAL_STYLES).default(\"rise\"),\n\t\temphasis: z.array(z.union([z.string(), z.instanceof(RegExp)])).default([]),\n\t\trule: z.boolean().default(true),\n\t\texitStyle: z.union([z.enum(EXIT_STYLES), z.literal(false)]).default(false),\n\t\ttitleSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 45 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1600 * s;\n\t\tconst size = props.titleSize ?? 120 * s;\n\t\tconst kickerSize = Math.max(16 * s, size * 0.22);\n\t\tconst subSize = Math.max(20 * s, size * 0.34);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst reveal = bind(host, textReveal);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst layout = props.layout;\n\t\tconst centered = layout === \"center\";\n\t\tconst align = centered ? \"center\" : \"start\";\n\n\t\t// Columns: split puts the headline left, the rest right of a rule.\n\t\tconst bar = layout === \"stack\" ? Math.max(4 * s, size * 0.06) : 0;\n\t\tconst barGap = layout === \"stack\" ? size * 0.32 : 0;\n\t\tconst titleW = layout === \"split\" ? W * 0.56 : W - bar - barGap;\n\t\tconst sideX = W * 0.62;\n\t\tconst sideW = W - sideX;\n\t\tconst left = bar + barGap;\n\n\t\tconst measure = (\n\t\t\ttext: string,\n\t\t\tfontSize: number,\n\t\t\twidth: number,\n\t\t\tweight?: number,\n\t\t) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize,\n\t\t\t\tfontWeight: weight,\n\t\t\t\twidth,\n\t\t\t\talign: centered ? \"center\" : \"left\",\n\t\t\t});\n\t\tconst titleM = measure(props.title, size, titleW, 700);\n\t\tconst kickerText = props.kicker?.toUpperCase();\n\t\t// Measured with its tracking, so a centred kicker is really centred.\n\t\tconst kickerM = kickerText\n\t\t\t? host.measure(kickerText, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: kickerSize,\n\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\tletterSpacing: kickerSize * 0.16,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst subM = props.subtitle\n\t\t\t? measure(\n\t\t\t\t\tprops.subtitle,\n\t\t\t\t\tsubSize,\n\t\t\t\t\tlayout === \"split\" ? sideW : centered ? W * 0.8 : titleW,\n\t\t\t\t)\n\t\t\t: null;\n\n\t\tconst gap = size * 0.28;\n\t\tconst ruleW =\n\t\t\tlayout === \"split\" ? Math.max(2, 3 * s) : Math.min(W * 0.12, 160 * s);\n\t\tconst ruleH = layout === \"split\" ? 0 : Math.max(3, 4 * s);\n\t\tconst ruleGap = props.rule && layout !== \"split\" ? gap : 0;\n\n\t\t// ── Vertical layout ───────────────────────────────────────────────\n\t\tlet y = 0;\n\t\tconst place: Record<\n\t\t\t\"kicker\" | \"title\" | \"rule\" | \"subtitle\",\n\t\t\t{ x: number; y: number }\n\t\t> = {\n\t\t\tkicker: { x: 0, y: 0 },\n\t\t\ttitle: { x: 0, y: 0 },\n\t\t\trule: { x: 0, y: 0 },\n\t\t\tsubtitle: { x: 0, y: 0 },\n\t\t};\n\t\tlet height: number;\n\t\tif (layout === \"split\") {\n\t\t\tplace.title = { x: 0, y: 0 };\n\t\t\tconst side =\n\t\t\t\t(kickerM ? kickerM.height + gap * 0.6 : 0) + (subM?.height ?? 0);\n\t\t\tconst top = Math.max(0, (titleM.height - side) / 2);\n\t\t\tplace.kicker = { x: sideX, y: top };\n\t\t\tplace.subtitle = {\n\t\t\t\tx: sideX,\n\t\t\t\ty: top + (kickerM ? kickerM.height + gap * 0.6 : 0),\n\t\t\t};\n\t\t\tplace.rule = { x: W * 0.59, y: 0 };\n\t\t\theight = Math.max(titleM.height, side);\n\t\t} else {\n\t\t\tif (kickerM) {\n\t\t\t\tplace.kicker = { x: centered ? (W - kickerM.width) / 2 : left, y };\n\t\t\t\ty += kickerM.height + gap * 0.5;\n\t\t\t}\n\t\t\tplace.title = { x: centered ? 0 : left, y };\n\t\t\ty += titleM.height;\n\t\t\tif (props.rule) {\n\t\t\t\ty += ruleGap * 0.6;\n\t\t\t\tplace.rule = { x: centered ? (W - ruleW) / 2 : left, y };\n\t\t\t\ty += ruleH + ruleGap * 0.6;\n\t\t\t} else y += gap * 0.5;\n\t\t\tif (subM) {\n\t\t\t\tplace.subtitle = { x: centered ? W * 0.1 : left, y };\n\t\t\t\ty += subM.height;\n\t\t\t}\n\t\t\theight = y;\n\t\t}\n\n\t\t// ── Timeline ──────────────────────────────────────────────────────\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst titleAt = kickerText ? Math.round(fast * 1.2) : 0;\n\t\tconst title = reveal({\n\t\t\tid: ctx.partId(\"title\"),\n\t\t\ttext: props.title,\n\t\t\tstyle: props.reveal,\n\t\t\tat: titleAt,\n\t\t\t...place.title,\n\t\t\twidth: titleW,\n\t\t\talign,\n\t\t\tfontFamily,\n\t\t\tfontSize: size,\n\t\t\tfontWeight: 700,\n\t\t\temphasis: props.emphasis,\n\t\t\ttheme,\n\t\t\tseed: ctx.seed,\n\t\t\tfps,\n\t\t});\n\t\tconst revealed = title.cues.revealed[0]?.frame ?? titleAt;\n\t\tconst followAt = Math.max(titleAt, revealed - Math.round(base * 0.6));\n\n\t\tlet settled = revealed;\n\t\tconst fadeable: Array<Animatable<LayoutNode>> = [];\n\t\tif (kickerText && kickerM) {\n\t\t\tconst kicker = reveal({\n\t\t\t\tid: ctx.partId(\"kicker\"),\n\t\t\t\ttext: kickerText,\n\t\t\t\tstyle: \"track\",\n\t\t\t\t...place.kicker,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: kickerSize,\n\t\t\t\tfontWeight: 600,\n\t\t\t\tletterSpacing: kickerSize * 0.16,\n\t\t\t\tfill: theme.color.accent,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren.push(kicker);\n\t\t\tparts.kicker = kicker;\n\t\t\tfadeable.push(kicker);\n\t\t\tsettled = Math.max(settled, kicker.cues.revealed[0]?.frame ?? 0);\n\t\t}\n\t\tchildren.push(title);\n\t\tparts.title = title;\n\n\t\tif (props.rule) {\n\t\t\tconst draw = Math.round(base * 1.3);\n\t\t\tconst rule = host.box({\n\t\t\t\tid: ctx.partId(\"rule\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...place.rule,\n\t\t\t\twidth: layout === \"split\" ? ruleW : 0,\n\t\t\t\theight: layout === \"split\" ? 0 : ruleH,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t\tborderRadius: ruleH / 2,\n\t\t\t});\n\t\t\tconst along = layout === \"split\" ? \"height\" : \"width\";\n\t\t\tconst full = layout === \"split\" ? height : ruleW;\n\t\t\trule.animate(\n\t\t\t\tLayerAnimation.keys(along, [\n\t\t\t\t\t[followAt, 0],\n\t\t\t\t\t[followAt + draw, full, theme.motion.ease.enter],\n\t\t\t\t]),\n\t\t\t);\n\t\t\t// Centred rules grow from the middle.\n\t\t\tif (centered)\n\t\t\t\trule.animate(\n\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t[followAt, W / 2],\n\t\t\t\t\t\t[followAt + draw, place.rule.x, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\tchildren.push(rule);\n\t\t\tparts.rule = rule;\n\t\t\tfadeable.push(rule);\n\t\t\tsettled = Math.max(settled, followAt + draw);\n\t\t}\n\t\tif (layout === \"stack\") {\n\t\t\tconst draw = Math.round(base * 1.5);\n\t\t\tconst accentBar = host.box({\n\t\t\t\tid: ctx.partId(\"bar\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: bar,\n\t\t\t\theight: 0,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t\tborderRadius: bar / 2,\n\t\t\t});\n\t\t\taccentBar.animate(\n\t\t\t\tLayerAnimation.keys(\"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[draw, height, theme.motion.ease.enter],\n\t\t\t\t]),\n\t\t\t);\n\t\t\tchildren.push(accentBar);\n\t\t\tparts.bar = accentBar;\n\t\t\tfadeable.push(accentBar);\n\t\t\tsettled = Math.max(settled, draw);\n\t\t}\n\t\tif (props.subtitle && subM) {\n\t\t\tconst sub = reveal({\n\t\t\t\tid: ctx.partId(\"subtitle\"),\n\t\t\t\ttext: props.subtitle,\n\t\t\t\tstyle: \"rise\",\n\t\t\t\tby: \"line\",\n\t\t\t\tat: followAt + Math.round(fast * 0.5),\n\t\t\t\t...place.subtitle,\n\t\t\t\twidth: layout === \"split\" ? sideW : centered ? W * 0.8 : titleW,\n\t\t\t\talign,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: subSize,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren.push(sub);\n\t\t\tparts.subtitle = sub;\n\t\t\tfadeable.push(sub);\n\t\t\tsettled = Math.max(settled, sub.cues.revealed[0]?.frame ?? 0);\n\t\t}\n\n\t\t// ── Exit ──────────────────────────────────────────────────────────\n\t\tlet afterHold = 0;\n\t\tconst exitStart = settled + ctx.hold;\n\t\tif (props.exitStyle !== false) {\n\t\t\t// Rebuild the headline with its exit lined up to the card's.\n\t\t\tconst leaving = reveal({\n\t\t\t\tid: ctx.partId(\"title\"),\n\t\t\t\ttext: props.title,\n\t\t\t\tstyle: props.reveal,\n\t\t\t\texitStyle: props.exitStyle,\n\t\t\t\thold: exitStart - revealed,\n\t\t\t\tat: titleAt,\n\t\t\t\t...place.title,\n\t\t\t\twidth: titleW,\n\t\t\t\talign,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: 700,\n\t\t\t\temphasis: props.emphasis,\n\t\t\t\ttheme,\n\t\t\t\tseed: ctx.seed,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren[children.indexOf(title)] = leaving;\n\t\t\tparts.title = leaving;\n\t\t\tafterHold = Math.max(1, leaving.span.end - exitStart);\n\t\t\tconst out = Math.min(afterHold, Math.round(base * 0.8));\n\t\t\tfor (const node of fadeable)\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[exitStart, 1],\n\t\t\t\t\t\t[exitStart + out, 0, theme.motion.ease.exit],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight,\n\t\t\tduration: settled,\n\t\t\topenEnded: true,\n\t\t\tafterHold,\n\t\t\tcues: {\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\tsettled: [{ frame: settled }],\n\t\t\t\texitStart: props.exitStyle === false ? [] : [{ frame: exitStart }],\n\t\t\t} as TitleCardCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Kicker, headline, rule and sub-line in designed layouts (center, left, stack,\n * split), the headline in any reveal style, on one timeline.\n */\nexport const TitleCard = /* @__PURE__ */ bindComponent(titleCard);\n"
31
+ "content": "/**\n * TitleCard: kicker, headline, rule and sub-line in designed layouts, on\n * one timeline. The text parts are `TextReveal`s (the headline in any reveal\n * style), the rule draws, and the whole card can play out.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\nimport {\n\tEXIT_STYLES,\n\tREVEAL_STYLES,\n\ttextReveal,\n} from \"../text-reveal/index.js\";\n\nexport const TITLE_LAYOUTS = [\"center\", \"left\", \"stack\", \"split\"] as const;\nexport type TitleLayout = (typeof TITLE_LAYOUTS)[number];\n\nexport interface TitleCardCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The headline is at rest. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** Every part is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** The card starts to play out (empty without `exitStyle`). */\n\texitStart: ReadonlyArray<{ frame: number }>;\n}\n\nexport const titleCard = /* @__PURE__ */ defineComponent({\n\tname: \"TitleCard\",\n\tprops: () => ({\n\t\ttitle: z.string().min(1),\n\t\tkicker: z.string().optional(),\n\t\tsubtitle: z.string().optional(),\n\t\tlayout: z.enum(TITLE_LAYOUTS).default(\"center\"),\n\t\t/** The headline's reveal style. */\n\t\treveal: z.enum(REVEAL_STYLES).default(\"rise\"),\n\t\temphasis: z.array(z.union([z.string(), z.instanceof(RegExp)])).default([]),\n\t\trule: z.boolean().default(true),\n\t\texitStyle: z.union([z.enum(EXIT_STYLES), z.literal(false)]).default(false),\n\t\ttitleSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 45 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1600 * s;\n\t\tconst size = props.titleSize ?? 120 * s;\n\t\tconst kickerSize = Math.max(16 * s, size * 0.22);\n\t\tconst subSize = Math.max(20 * s, size * 0.34);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst reveal = bind(host, textReveal);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst layout = props.layout;\n\t\tconst centered = layout === \"center\";\n\t\tconst align = centered ? \"center\" : \"start\";\n\n\t\t// Columns: split puts the headline left, the rest right of a rule.\n\t\tconst bar = layout === \"stack\" ? Math.max(4 * s, size * 0.06) : 0;\n\t\tconst barGap = layout === \"stack\" ? size * 0.32 : 0;\n\t\tconst titleW = layout === \"split\" ? W * 0.56 : W - bar - barGap;\n\t\tconst sideX = W * 0.62;\n\t\tconst sideW = W - sideX;\n\t\tconst left = bar + barGap;\n\n\t\tconst measure = (\n\t\t\ttext: string,\n\t\t\tfontSize: number,\n\t\t\twidth: number,\n\t\t\tweight?: number,\n\t\t) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize,\n\t\t\t\tfontWeight: weight,\n\t\t\t\twidth,\n\t\t\t\talign: centered ? \"center\" : \"left\",\n\t\t\t});\n\t\tconst titleM = measure(props.title, size, titleW, 700);\n\t\tconst kickerText = props.kicker?.toUpperCase();\n\t\t// Measured with its tracking, so a centred kicker is really centred.\n\t\tconst kickerM = kickerText\n\t\t\t? host.measure(kickerText, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: kickerSize,\n\t\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t\t\tletterSpacing: kickerSize * 0.16,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst subM = props.subtitle\n\t\t\t? measure(\n\t\t\t\t\tprops.subtitle,\n\t\t\t\t\tsubSize,\n\t\t\t\t\tlayout === \"split\" ? sideW : centered ? W * 0.8 : titleW,\n\t\t\t\t)\n\t\t\t: null;\n\n\t\tconst gap = size * 0.28;\n\t\tconst ruleW =\n\t\t\tlayout === \"split\" ? Math.max(2, 3 * s) : Math.min(W * 0.12, 160 * s);\n\t\tconst ruleH = layout === \"split\" ? 0 : Math.max(3, 4 * s);\n\t\tconst ruleGap = props.rule && layout !== \"split\" ? gap : 0;\n\n\t\t// ── Vertical layout ───────────────────────────────────────────────\n\t\tlet y = 0;\n\t\tconst place: Record<\n\t\t\t\"kicker\" | \"title\" | \"rule\" | \"subtitle\",\n\t\t\t{ x: number; y: number }\n\t\t> = {\n\t\t\tkicker: { x: 0, y: 0 },\n\t\t\ttitle: { x: 0, y: 0 },\n\t\t\trule: { x: 0, y: 0 },\n\t\t\tsubtitle: { x: 0, y: 0 },\n\t\t};\n\t\tlet height: number;\n\t\tif (layout === \"split\") {\n\t\t\tplace.title = { x: 0, y: 0 };\n\t\t\tconst side =\n\t\t\t\t(kickerM ? kickerM.height + gap * 0.6 : 0) + (subM?.height ?? 0);\n\t\t\tconst top = Math.max(0, (titleM.height - side) / 2);\n\t\t\tplace.kicker = { x: sideX, y: top };\n\t\t\tplace.subtitle = {\n\t\t\t\tx: sideX,\n\t\t\t\ty: top + (kickerM ? kickerM.height + gap * 0.6 : 0),\n\t\t\t};\n\t\t\tplace.rule = { x: W * 0.59, y: 0 };\n\t\t\theight = Math.max(titleM.height, side);\n\t\t} else {\n\t\t\tif (kickerM) {\n\t\t\t\tplace.kicker = { x: centered ? (W - kickerM.width) / 2 : left, y };\n\t\t\t\ty += kickerM.height + gap * 0.5;\n\t\t\t}\n\t\t\tplace.title = { x: centered ? 0 : left, y };\n\t\t\ty += titleM.height;\n\t\t\tif (props.rule) {\n\t\t\t\ty += ruleGap * 0.6;\n\t\t\t\tplace.rule = { x: centered ? (W - ruleW) / 2 : left, y };\n\t\t\t\ty += ruleH + ruleGap * 0.6;\n\t\t\t} else y += gap * 0.5;\n\t\t\tif (subM) {\n\t\t\t\tplace.subtitle = { x: centered ? W * 0.1 : left, y };\n\t\t\t\ty += subM.height;\n\t\t\t}\n\t\t\theight = y;\n\t\t}\n\n\t\t// ── Timeline ──────────────────────────────────────────────────────\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst titleAt = kickerText ? Math.round(fast * 1.2) : 0;\n\t\tconst title = reveal({\n\t\t\tid: ctx.partId(\"title\"),\n\t\t\ttext: props.title,\n\t\t\tstyle: props.reveal,\n\t\t\tat: titleAt,\n\t\t\t...place.title,\n\t\t\twidth: titleW,\n\t\t\talign,\n\t\t\tfontFamily,\n\t\t\tfontSize: size,\n\t\t\tfontWeight: theme.weight.bold,\n\t\t\temphasis: props.emphasis,\n\t\t\ttheme,\n\t\t\tseed: ctx.seed,\n\t\t\tfps,\n\t\t});\n\t\tconst revealed = title.cues.revealed[0]?.frame ?? titleAt;\n\t\tconst followAt = Math.max(titleAt, revealed - Math.round(base * 0.6));\n\n\t\tlet settled = revealed;\n\t\tconst fadeable: Array<Animatable<LayoutNode>> = [];\n\t\tif (kickerText && kickerM) {\n\t\t\tconst kicker = reveal({\n\t\t\t\tid: ctx.partId(\"kicker\"),\n\t\t\t\ttext: kickerText,\n\t\t\t\tstyle: \"track\",\n\t\t\t\t...place.kicker,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: kickerSize,\n\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t\tletterSpacing: kickerSize * 0.16,\n\t\t\t\tfill: theme.color.accent,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren.push(kicker);\n\t\t\tparts.kicker = kicker;\n\t\t\tfadeable.push(kicker);\n\t\t\tsettled = Math.max(settled, kicker.cues.revealed[0]?.frame ?? 0);\n\t\t}\n\t\tchildren.push(title);\n\t\tparts.title = title;\n\n\t\tif (props.rule) {\n\t\t\tconst draw = Math.round(base * 1.3);\n\t\t\tconst rule = host.box({\n\t\t\t\tid: ctx.partId(\"rule\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...place.rule,\n\t\t\t\twidth: layout === \"split\" ? ruleW : 0,\n\t\t\t\theight: layout === \"split\" ? 0 : ruleH,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t\tborderRadius: ruleH / 2,\n\t\t\t});\n\t\t\tconst along = layout === \"split\" ? \"height\" : \"width\";\n\t\t\tconst full = layout === \"split\" ? height : ruleW;\n\t\t\trule.animate(\n\t\t\t\tLayerAnimation.keys(along, [\n\t\t\t\t\t[followAt, 0],\n\t\t\t\t\t[followAt + draw, full, theme.motion.ease.enter],\n\t\t\t\t]),\n\t\t\t);\n\t\t\t// Centred rules grow from the middle.\n\t\t\tif (centered)\n\t\t\t\trule.animate(\n\t\t\t\t\tLayerAnimation.keys(\"x\", [\n\t\t\t\t\t\t[followAt, W / 2],\n\t\t\t\t\t\t[followAt + draw, place.rule.x, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\tchildren.push(rule);\n\t\t\tparts.rule = rule;\n\t\t\tfadeable.push(rule);\n\t\t\tsettled = Math.max(settled, followAt + draw);\n\t\t}\n\t\tif (layout === \"stack\") {\n\t\t\tconst draw = Math.round(base * 1.5);\n\t\t\tconst accentBar = host.box({\n\t\t\t\tid: ctx.partId(\"bar\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: bar,\n\t\t\t\theight: 0,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t\tborderRadius: bar / 2,\n\t\t\t});\n\t\t\taccentBar.animate(\n\t\t\t\tLayerAnimation.keys(\"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[draw, height, theme.motion.ease.enter],\n\t\t\t\t]),\n\t\t\t);\n\t\t\tchildren.push(accentBar);\n\t\t\tparts.bar = accentBar;\n\t\t\tfadeable.push(accentBar);\n\t\t\tsettled = Math.max(settled, draw);\n\t\t}\n\t\tif (props.subtitle && subM) {\n\t\t\tconst sub = reveal({\n\t\t\t\tid: ctx.partId(\"subtitle\"),\n\t\t\t\ttext: props.subtitle,\n\t\t\t\tstyle: \"rise\",\n\t\t\t\tby: \"line\",\n\t\t\t\tat: followAt + Math.round(fast * 0.5),\n\t\t\t\t...place.subtitle,\n\t\t\t\twidth: layout === \"split\" ? sideW : centered ? W * 0.8 : titleW,\n\t\t\t\talign,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: subSize,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren.push(sub);\n\t\t\tparts.subtitle = sub;\n\t\t\tfadeable.push(sub);\n\t\t\tsettled = Math.max(settled, sub.cues.revealed[0]?.frame ?? 0);\n\t\t}\n\n\t\t// ── Exit ──────────────────────────────────────────────────────────\n\t\tlet afterHold = 0;\n\t\tconst exitStart = settled + ctx.hold;\n\t\tif (props.exitStyle !== false) {\n\t\t\t// Rebuild the headline with its exit lined up to the card's.\n\t\t\tconst leaving = reveal({\n\t\t\t\tid: ctx.partId(\"title\"),\n\t\t\t\ttext: props.title,\n\t\t\t\tstyle: props.reveal,\n\t\t\t\texitStyle: props.exitStyle,\n\t\t\t\thold: exitStart - revealed,\n\t\t\t\tat: titleAt,\n\t\t\t\t...place.title,\n\t\t\t\twidth: titleW,\n\t\t\t\talign,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: theme.weight.bold,\n\t\t\t\temphasis: props.emphasis,\n\t\t\t\ttheme,\n\t\t\t\tseed: ctx.seed,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tchildren[children.indexOf(title)] = leaving;\n\t\t\tparts.title = leaving;\n\t\t\tafterHold = Math.max(1, leaving.span.end - exitStart);\n\t\t\tconst out = Math.min(afterHold, Math.round(base * 0.8));\n\t\t\tfor (const node of fadeable)\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[exitStart, 1],\n\t\t\t\t\t\t[exitStart + out, 0, theme.motion.ease.exit],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight,\n\t\t\tduration: settled,\n\t\t\topenEnded: true,\n\t\t\tafterHold,\n\t\t\tcues: {\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\tsettled: [{ frame: settled }],\n\t\t\t\texitStart: props.exitStyle === false ? [] : [{ frame: exitStart }],\n\t\t\t} as TitleCardCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Kicker, headline, rule and sub-line in designed layouts (center, left, stack,\n * split), the headline in any reveal style, on one timeline.\n */\nexport const TitleCard = /* @__PURE__ */ bindComponent(titleCard);\n"
32
32
  }
33
33
  ]
34
34
  }
@@ -22,7 +22,7 @@
22
22
  "files": [
23
23
  {
24
24
  "path": "toasts/index.ts",
25
- "content": "/**\n * Toasts: notifications arriving in a corner stack. The newest sits at the\n * edge, older ones are pushed away and compress as the stack grows, and\n * each one slides out when it is dismissed while the rest close the gap.\n * The stack is solved offline and every move is a baked spring.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tdefineComponent,\n\tspringAt,\n\ttype Theme,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport const TOAST_CORNERS = [\n\t\"top-right\",\n\t\"top-left\",\n\t\"bottom-right\",\n\t\"bottom-left\",\n\t\"top-center\",\n\t\"bottom-center\",\n] as const;\nexport type ToastCorner = (typeof TOAST_CORNERS)[number];\nexport const TOAST_KINDS = [\"info\", \"success\", \"warning\", \"error\"] as const;\nexport type ToastKind = (typeof TOAST_KINDS)[number];\n\nexport interface ToastsCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\tarrivals: ReadonlyArray<{ frame: number; index: number }>;\n\tdismissals: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nconst SPRING = { stiffness: 230, damping: 24 };\n\n/** Per-frame values that spring to each new target from wherever they are. */\nexport function springTrack(\n\tchanges: ReadonlyArray<readonly [frame: number, target: number]>,\n\tinitial: number,\n\tlength: number,\n\tfps: number,\n\toptions = SPRING,\n): number[] {\n\tconst sorted = [...changes].sort((a, b) => a[0] - b[0]);\n\tconst out = new Array<number>(length + 1);\n\tlet from = initial;\n\tlet to = initial;\n\tlet since = 0;\n\tlet k = 0;\n\tfor (let f = 0; f <= length; f++) {\n\t\twhile (\n\t\t\tk < sorted.length &&\n\t\t\t(sorted[k] as readonly [number, number])[0] <= f\n\t\t) {\n\t\t\t// Restart from the current value toward the new target.\n\t\t\tfrom = from + (to - from) * springAt((f - since) / fps, options);\n\t\t\tto = (sorted[k] as readonly [number, number])[1];\n\t\t\tsince = (sorted[k] as readonly [number, number])[0];\n\t\t\tk++;\n\t\t}\n\t\tout[f] = from + (to - from) * springAt((f - since) / fps, options);\n\t}\n\treturn out;\n}\n\n/** A small icon per kind: a coloured disc with a drawn mark. */\nfunction icon(\n\tkind: ToastKind,\n\tsize: number,\n\ttheme: Theme,\n): { color: string; mark: string } {\n\tconst p = (pts: Vec2[]) =>\n\t\ttoPathData(pts.map(([x, y]) => [x * size, y * size] as Vec2));\n\tswitch (kind) {\n\t\tcase \"success\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.positive,\n\t\t\t\tmark: p([\n\t\t\t\t\t[0.28, 0.52],\n\t\t\t\t\t[0.44, 0.67],\n\t\t\t\t\t[0.73, 0.36],\n\t\t\t\t]),\n\t\t\t};\n\t\tcase \"error\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.negative,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.33, 0.33],\n\t\t\t\t\t[0.67, 0.67],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.67, 0.33],\n\t\t\t\t\t[0.33, 0.67],\n\t\t\t\t])}`,\n\t\t\t};\n\t\tcase \"warning\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.highlight,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.5, 0.27],\n\t\t\t\t\t[0.5, 0.56],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.5, 0.72],\n\t\t\t\t\t[0.5, 0.73],\n\t\t\t\t])}`,\n\t\t\t};\n\t\tcase \"info\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.accent,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.5, 0.27],\n\t\t\t\t\t[0.5, 0.28],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.5, 0.44],\n\t\t\t\t\t[0.5, 0.73],\n\t\t\t\t])}`,\n\t\t\t};\n\t}\n}\n\nexport const toasts = /* @__PURE__ */ defineComponent({\n\tname: \"Toasts\",\n\tprops: () => ({\n\t\titems: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tat: z.number().int().min(0),\n\t\t\t\t\t\ttitle: z.string(),\n\t\t\t\t\t\tbody: z.string().optional(),\n\t\t\t\t\t\tkind: z.enum(TOAST_KINDS).default(\"info\"),\n\t\t\t\t\t\t/** Frames on screen. Default 4 seconds. */\n\t\t\t\t\t\tduration: z.number().int().positive().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.min(1),\n\t\tcorner: z.enum(TOAST_CORNERS).default(\"top-right\"),\n\t\t/** Most toasts shown at once; older ones fade into the stack. */\n\t\tmax: z.number().int().positive().default(3),\n\t\ttoastWidth: z.number().positive().optional(),\n\t\tmargin: z.number().min(0).optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst tw = props.toastWidth ?? 440 * s;\n\t\tconst margin = props.margin ?? 36 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst titleFs = 22 * s;\n\t\tconst bodyFs = 19 * s;\n\t\tconst pad = 18 * s;\n\t\tconst iconSize = 30 * s;\n\t\tconst gap = 12 * s;\n\t\tconst top = props.corner.startsWith(\"top\");\n\t\tconst side = props.corner.endsWith(\"right\")\n\t\t\t? 1\n\t\t\t: props.corner.endsWith(\"left\")\n\t\t\t\t? -1\n\t\t\t\t: 0;\n\t\tconst x =\n\t\t\tside === 1 ? W - margin - tw : side === -1 ? margin : (W - tw) / 2;\n\n\t\t// ── Sizes ─────────────────────────────────────────────────────────\n\t\tconst textX = pad + iconSize + pad * 0.8;\n\t\tconst textW = tw - textX - pad;\n\t\tconst items = props.items.map((item, index) => {\n\t\t\tconst titleM = host.measure(item.title, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: titleFs,\n\t\t\t\tfontWeight: 600,\n\t\t\t\twidth: textW,\n\t\t\t});\n\t\t\tconst bodyM = item.body\n\t\t\t\t? host.measure(item.body, {\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: bodyFs,\n\t\t\t\t\t\twidth: textW,\n\t\t\t\t\t})\n\t\t\t\t: null;\n\t\t\tconst height =\n\t\t\t\tMath.max(iconSize, titleM.height + (bodyM ? bodyM.height + 4 * s : 0)) +\n\t\t\t\tpad * 2;\n\t\t\treturn {\n\t\t\t\t...item,\n\t\t\t\tindex,\n\t\t\t\theight,\n\t\t\t\ttitleH: titleM.height,\n\t\t\t\tend: item.at + (item.duration ?? fps * 4),\n\t\t\t};\n\t\t});\n\n\t\t// ── The stack at every event ──────────────────────────────────────\n\t\tconst events = [...new Set(items.flatMap((t) => [t.at, t.end]))].sort(\n\t\t\t(a, b) => a - b,\n\t\t);\n\t\tconst exitFrames = Math.round(fps * 0.5);\n\t\tconst length =\n\t\t\tMath.max(...items.map((t) => t.end)) + exitFrames + Math.round(fps * 0.4);\n\t\tconst yChanges = new Map<number, Array<[number, number]>>();\n\t\tconst scaleChanges = new Map<number, Array<[number, number]>>();\n\t\tconst fadeChanges = new Map<number, Array<[number, number]>>();\n\t\tconst push = (\n\t\t\tm: Map<number, Array<[number, number]>>,\n\t\t\ti: number,\n\t\t\tf: number,\n\t\t\tv: number,\n\t\t) => {\n\t\t\tconst list = m.get(i) ?? [];\n\t\t\tlist.push([f, v]);\n\t\t\tm.set(i, list);\n\t\t};\n\t\tfor (const f of events) {\n\t\t\tconst live = items\n\t\t\t\t.filter((t) => t.at <= f && f < t.end)\n\t\t\t\t.sort((a, b) => b.at - a.at || b.index - a.index);\n\t\t\tlet offset = 0;\n\t\t\tlive.forEach((t, k) => {\n\t\t\t\t// Older toasts tuck in: smaller, closer, and gone past `max`.\n\t\t\t\tconst scale = 1 - Math.min(k, props.max) * 0.035;\n\t\t\t\tconst y = top ? margin + offset : H - margin - offset - t.height;\n\t\t\t\tpush(yChanges, t.index, f, y);\n\t\t\t\tpush(scaleChanges, t.index, f, scale);\n\t\t\t\tpush(fadeChanges, t.index, f, k < props.max ? 1 : 0);\n\t\t\t\toffset += t.height * scale + gap * scale;\n\t\t\t});\n\t\t}\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tfor (const t of items) {\n\t\t\tconst ys = yChanges.get(t.index) ?? [[t.at, margin]];\n\t\t\tconst firstY = ys[0]?.[1] ?? margin;\n\t\t\tconst yTrack = springTrack(ys, firstY, length, fps);\n\t\t\tconst scaleTrack = springTrack(\n\t\t\t\tscaleChanges.get(t.index) ?? [],\n\t\t\t\t1,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t);\n\t\t\tconst fade = fadeChanges.get(t.index) ?? [];\n\t\t\t// Slide in from the edge; slide back out when dismissed.\n\t\t\tconst away = side === 0 ? 0 : side * (tw + margin);\n\t\t\tconst xTrack = springTrack(\n\t\t\t\t[\n\t\t\t\t\t[t.at, 0],\n\t\t\t\t\t[t.end, away],\n\t\t\t\t],\n\t\t\t\taway,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t\t{ stiffness: 200, damping: 26 },\n\t\t\t);\n\t\t\tconst opacity = springTrack(\n\t\t\t\t[[t.at, 1], ...fade.filter(([f]) => f > t.at), [t.end, 0]],\n\t\t\t\t0,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t\t{ stiffness: 300, damping: 40 },\n\t\t\t).map((v) => Math.max(0, Math.min(1, v)));\n\t\t\tconst glyph = icon(t.kind, iconSize, theme);\n\t\t\tconst disc = host.box({\n\t\t\t\tid: ctx.partId(`toast-${t.index}-icon`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: pad,\n\t\t\t\twidth: iconSize,\n\t\t\t\theight: iconSize,\n\t\t\t\tbackground: glyph.color,\n\t\t\t\tborderRadius: iconSize / 2,\n\t\t\t});\n\t\t\tconst mark = host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`toast-${t.index}-mark`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: pad,\n\t\t\t\twidth: iconSize,\n\t\t\t\theight: iconSize,\n\t\t\t\td: glyph.mark,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: \"#ffffff\",\n\t\t\t\tstrokeWidth: iconSize * 0.11,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t});\n\t\t\tconst kids: LayoutNode[] = [\n\t\t\t\tdisc,\n\t\t\t\tmark,\n\t\t\t\thost.text(t.title, {\n\t\t\t\t\tid: ctx.partId(`toast-${t.index}-title`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: textX,\n\t\t\t\t\ty: pad,\n\t\t\t\t\twidth: textW,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: titleFs,\n\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t}),\n\t\t\t];\n\t\t\tif (t.body)\n\t\t\t\tkids.push(\n\t\t\t\t\thost.text(t.body, {\n\t\t\t\t\t\tid: ctx.partId(`toast-${t.index}-body`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: textX,\n\t\t\t\t\t\ty: pad + t.titleH + 4 * s,\n\t\t\t\t\t\twidth: textW,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: bodyFs,\n\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\tconst card = host.box({\n\t\t\t\tid: ctx.partId(`toast-${t.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: firstY,\n\t\t\t\twidth: tw,\n\t\t\t\theight: t.height,\n\t\t\t\tbackground: theme.color.raised,\n\t\t\t\tborderRadius: theme.radius.md,\n\t\t\t\tborderColor: theme.color.line,\n\t\t\t\tborderWidth: Math.max(1, s),\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: top ? 0 : 1,\n\t\t\t\tchildren: kids,\n\t\t\t});\n\t\t\thost.style(card, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.35,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 10 * s,\n\t\t\t\t\tsize: 30 * s,\n\t\t\t\t},\n\t\t\t});\n\t\t\tconst from = Math.max(0, t.at - 1);\n\t\t\tconst slice = (v: number[]) => v.slice(from);\n\t\t\tcard.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"y\", bakeSamples(slice(yTrack), from, { tolerance: 0.3 }))\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"x\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tslice(xTrack).map((d) => x + d),\n\t\t\t\t\t\t\tfrom,\n\t\t\t\t\t\t\t{ tolerance: 0.3 },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"scale\",\n\t\t\t\t\t\tbakeSamples(slice(scaleTrack), from, { tolerance: 0.001 }),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t...(from > 0 ? [[0, 0] as [number, number]] : []),\n\t\t\t\t\t\t...bakeSamples(slice(opacity), from, { tolerance: 0.01 }),\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts[`toast-${t.index}`] = card;\n\t\t\tchildren.push(card);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: length,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tarrivals: items.map((t) => ({ frame: t.at, index: t.index })),\n\t\t\t\tdismissals: items.map((t) => ({ frame: t.end, index: t.index })),\n\t\t\t} as ToastsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Notifications arriving into a corner stack that pushes older ones away and\n * compresses as it grows; each slides out when dismissed.\n */\nexport const Toasts = /* @__PURE__ */ bindComponent(toasts);\n"
25
+ "content": "/**\n * Toasts: notifications arriving in a corner stack. The newest sits at the\n * edge, older ones are pushed away and compress as the stack grows, and\n * each one slides out when it is dismissed while the rest close the gap.\n * The stack is solved offline and every move is a baked spring.\n *\n * Coordinates are the frame's: the root covers the video (default\n * the composition `canvas`, or the given width / height) from 0, 0.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tdefineComponent,\n\televate,\n\tspringTrack,\n\tsurfaceFill,\n\ttype Theme,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport const TOAST_CORNERS = [\n\t\"top-right\",\n\t\"top-left\",\n\t\"bottom-right\",\n\t\"bottom-left\",\n\t\"top-center\",\n\t\"bottom-center\",\n] as const;\nexport type ToastCorner = (typeof TOAST_CORNERS)[number];\nexport const TOAST_KINDS = [\"info\", \"success\", \"warning\", \"error\"] as const;\nexport type ToastKind = (typeof TOAST_KINDS)[number];\n\nexport interface ToastsCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\tarrivals: ReadonlyArray<{ frame: number; index: number }>;\n\tdismissals: ReadonlyArray<{ frame: number; index: number }>;\n}\n\n/** A small icon per kind: a coloured disc with a drawn mark. */\nfunction icon(\n\tkind: ToastKind,\n\tsize: number,\n\ttheme: Theme,\n): { color: string; mark: string } {\n\tconst p = (pts: Vec2[]) =>\n\t\ttoPathData(pts.map(([x, y]) => [x * size, y * size] as Vec2));\n\tswitch (kind) {\n\t\tcase \"success\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.positive,\n\t\t\t\tmark: p([\n\t\t\t\t\t[0.28, 0.52],\n\t\t\t\t\t[0.44, 0.67],\n\t\t\t\t\t[0.73, 0.36],\n\t\t\t\t]),\n\t\t\t};\n\t\tcase \"error\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.negative,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.33, 0.33],\n\t\t\t\t\t[0.67, 0.67],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.67, 0.33],\n\t\t\t\t\t[0.33, 0.67],\n\t\t\t\t])}`,\n\t\t\t};\n\t\tcase \"warning\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.highlight,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.5, 0.27],\n\t\t\t\t\t[0.5, 0.56],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.5, 0.72],\n\t\t\t\t\t[0.5, 0.73],\n\t\t\t\t])}`,\n\t\t\t};\n\t\tcase \"info\":\n\t\t\treturn {\n\t\t\t\tcolor: theme.color.accent,\n\t\t\t\tmark: `${p([\n\t\t\t\t\t[0.5, 0.27],\n\t\t\t\t\t[0.5, 0.28],\n\t\t\t\t])} ${p([\n\t\t\t\t\t[0.5, 0.44],\n\t\t\t\t\t[0.5, 0.73],\n\t\t\t\t])}`,\n\t\t\t};\n\t}\n}\n\nexport const toasts = /* @__PURE__ */ defineComponent({\n\tname: \"Toasts\",\n\tprops: () => ({\n\t\titems: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tat: z.number().int().min(0),\n\t\t\t\t\t\ttitle: z.string(),\n\t\t\t\t\t\tbody: z.string().optional(),\n\t\t\t\t\t\tkind: z.enum(TOAST_KINDS).default(\"info\"),\n\t\t\t\t\t\t/** Frames on screen. Default 4 seconds. */\n\t\t\t\t\t\tduration: z.number().int().positive().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.min(1),\n\t\tcorner: z.enum(TOAST_CORNERS).default(\"top-right\"),\n\t\t/** Most toasts shown at once; older ones fade into the stack. */\n\t\tmax: z.number().int().positive().default(3),\n\t\ttoastWidth: z.number().positive().optional(),\n\t\tmargin: z.number().min(0).optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\n\t\tconst tw = props.toastWidth ?? 440 * s;\n\t\tconst margin = props.margin ?? 36 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst titleFs = 22 * s;\n\t\tconst bodyFs = 19 * s;\n\t\tconst pad = 18 * s;\n\t\tconst iconSize = 30 * s;\n\t\tconst gap = 12 * s;\n\t\tconst top = props.corner.startsWith(\"top\");\n\t\tconst side = props.corner.endsWith(\"right\")\n\t\t\t? 1\n\t\t\t: props.corner.endsWith(\"left\")\n\t\t\t\t? -1\n\t\t\t\t: 0;\n\t\tconst x =\n\t\t\tside === 1 ? W - margin - tw : side === -1 ? margin : (W - tw) / 2;\n\n\t\t// ── Sizes ─────────────────────────────────────────────────────────\n\t\tconst textX = pad + iconSize + pad * 0.8;\n\t\tconst textW = tw - textX - pad;\n\t\tconst items = props.items.map((item, index) => {\n\t\t\tconst titleM = host.measure(item.title, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: titleFs,\n\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t\twidth: textW,\n\t\t\t});\n\t\t\tconst bodyM = item.body\n\t\t\t\t? host.measure(item.body, {\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: bodyFs,\n\t\t\t\t\t\twidth: textW,\n\t\t\t\t\t})\n\t\t\t\t: null;\n\t\t\tconst height =\n\t\t\t\tMath.max(iconSize, titleM.height + (bodyM ? bodyM.height + 4 * s : 0)) +\n\t\t\t\tpad * 2;\n\t\t\treturn {\n\t\t\t\t...item,\n\t\t\t\tindex,\n\t\t\t\theight,\n\t\t\t\ttitleH: titleM.height,\n\t\t\t\tend: item.at + (item.duration ?? fps * 4),\n\t\t\t};\n\t\t});\n\n\t\t// ── The stack at every event ──────────────────────────────────────\n\t\tconst events = [...new Set(items.flatMap((t) => [t.at, t.end]))].sort(\n\t\t\t(a, b) => a - b,\n\t\t);\n\t\tconst exitFrames = Math.round(fps * 0.5);\n\t\tconst length =\n\t\t\tMath.max(...items.map((t) => t.end)) + exitFrames + Math.round(fps * 0.4);\n\t\tconst yChanges = new Map<number, Array<[number, number]>>();\n\t\tconst scaleChanges = new Map<number, Array<[number, number]>>();\n\t\tconst fadeChanges = new Map<number, Array<[number, number]>>();\n\t\tconst push = (\n\t\t\tm: Map<number, Array<[number, number]>>,\n\t\t\ti: number,\n\t\t\tf: number,\n\t\t\tv: number,\n\t\t) => {\n\t\t\tconst list = m.get(i) ?? [];\n\t\t\tlist.push([f, v]);\n\t\t\tm.set(i, list);\n\t\t};\n\t\tfor (const f of events) {\n\t\t\tconst live = items\n\t\t\t\t.filter((t) => t.at <= f && f < t.end)\n\t\t\t\t.sort((a, b) => b.at - a.at || b.index - a.index);\n\t\t\tlet offset = 0;\n\t\t\tlive.forEach((t, k) => {\n\t\t\t\t// Older toasts tuck in: smaller, closer, and gone past `max`.\n\t\t\t\tconst scale = 1 - Math.min(k, props.max) * 0.035;\n\t\t\t\tconst y = top ? margin + offset : H - margin - offset - t.height;\n\t\t\t\tpush(yChanges, t.index, f, y);\n\t\t\t\tpush(scaleChanges, t.index, f, scale);\n\t\t\t\tpush(fadeChanges, t.index, f, k < props.max ? 1 : 0);\n\t\t\t\toffset += t.height * scale + gap * scale;\n\t\t\t});\n\t\t}\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tfor (const t of items) {\n\t\t\tconst ys = yChanges.get(t.index) ?? [[t.at, margin]];\n\t\t\tconst firstY = ys[0]?.[1] ?? margin;\n\t\t\tconst settle = theme.motion.spring.settle;\n\t\t\tconst yTrack = springTrack(ys, firstY, length, fps, settle);\n\t\t\tconst scaleTrack = springTrack(\n\t\t\t\tscaleChanges.get(t.index) ?? [],\n\t\t\t\t1,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t\tsettle,\n\t\t\t);\n\t\t\tconst fade = fadeChanges.get(t.index) ?? [];\n\t\t\t// Slide in from the edge; slide back out when dismissed.\n\t\t\tconst away = side === 0 ? 0 : side * (tw + margin);\n\t\t\tconst xTrack = springTrack(\n\t\t\t\t[\n\t\t\t\t\t[t.at, 0],\n\t\t\t\t\t[t.end, away],\n\t\t\t\t],\n\t\t\t\taway,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t\t{ stiffness: 200, damping: 26 },\n\t\t\t);\n\t\t\tconst opacity = springTrack(\n\t\t\t\t[[t.at, 1], ...fade.filter(([f]) => f > t.at), [t.end, 0]],\n\t\t\t\t0,\n\t\t\t\tlength,\n\t\t\t\tfps,\n\t\t\t\t{ stiffness: 300, damping: 40 },\n\t\t\t).map((v) => Math.max(0, Math.min(1, v)));\n\t\t\tconst glyph = icon(t.kind, iconSize, theme);\n\t\t\tconst disc = host.box({\n\t\t\t\tid: ctx.partId(`toast-${t.index}-icon`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: pad,\n\t\t\t\twidth: iconSize,\n\t\t\t\theight: iconSize,\n\t\t\t\tbackground: glyph.color,\n\t\t\t\tborderRadius: iconSize / 2,\n\t\t\t});\n\t\t\tconst mark = host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`toast-${t.index}-mark`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: pad,\n\t\t\t\twidth: iconSize,\n\t\t\t\theight: iconSize,\n\t\t\t\td: glyph.mark,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: \"#ffffff\",\n\t\t\t\tstrokeWidth: iconSize * 0.11,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t});\n\t\t\tconst kids: LayoutNode[] = [\n\t\t\t\tdisc,\n\t\t\t\tmark,\n\t\t\t\thost.text(t.title, {\n\t\t\t\t\tid: ctx.partId(`toast-${t.index}-title`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: textX,\n\t\t\t\t\ty: pad,\n\t\t\t\t\twidth: textW,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: titleFs,\n\t\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t}),\n\t\t\t];\n\t\t\tif (t.body)\n\t\t\t\tkids.push(\n\t\t\t\t\thost.text(t.body, {\n\t\t\t\t\t\tid: ctx.partId(`toast-${t.index}-body`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: textX,\n\t\t\t\t\t\ty: pad + t.titleH + 4 * s,\n\t\t\t\t\t\twidth: textW,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: bodyFs,\n\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\tconst card = host.box({\n\t\t\t\tid: ctx.partId(`toast-${t.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: firstY,\n\t\t\t\twidth: tw,\n\t\t\t\theight: t.height,\n\t\t\t\tbackground: surfaceFill(theme.color.raised, theme),\n\t\t\t\tborderRadius: theme.radius.md,\n\t\t\t\tborderColor: theme.color.line,\n\t\t\t\tborderWidth: Math.max(1, s),\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: top ? 0 : 1,\n\t\t\t\tmotionBlurShutter: 60,\n\t\t\t\tchildren: kids,\n\t\t\t});\n\t\t\televate(host, card, theme, \"mid\");\n\t\t\tconst from = Math.max(0, t.at - 1);\n\t\t\tconst slice = (v: number[]) => v.slice(from);\n\t\t\tcard.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"y\", bakeSamples(slice(yTrack), from, { tolerance: 0.3 }))\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"x\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tslice(xTrack).map((d) => x + d),\n\t\t\t\t\t\t\tfrom,\n\t\t\t\t\t\t\t{ tolerance: 0.3 },\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\n\t\t\t\t\t\t\"scale\",\n\t\t\t\t\t\tbakeSamples(slice(scaleTrack), from, { tolerance: 0.001 }),\n\t\t\t\t\t)\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t...(from > 0 ? [[0, 0] as [number, number]] : []),\n\t\t\t\t\t\t...bakeSamples(slice(opacity), from, { tolerance: 0.01 }),\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts[`toast-${t.index}`] = card;\n\t\t\tchildren.push(card);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: length,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tarrivals: items.map((t) => ({ frame: t.at, index: t.index })),\n\t\t\t\tdismissals: items.map((t) => ({ frame: t.end, index: t.index })),\n\t\t\t} as ToastsCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Notifications arriving into a corner stack that pushes older ones away and\n * compresses as it grows; each slides out when dismissed.\n */\nexport const Toasts = /* @__PURE__ */ bindComponent(toasts);\n"
26
26
  }
27
27
  ]
28
28
  }
@@ -13,9 +13,9 @@
13
13
  ],
14
14
  "example": "TouchPoint({ touches: [{ to: phone.at(196, 700) }, { to: phone.at(196, 600), swipe: phone.at(196, 200) }] })",
15
15
  "parts": [
16
- "touch-0",
17
- "touch-0-ripple-0",
18
- "touch-0-trail-0"
16
+ "touch-<n>",
17
+ "touch-<n>-ripple-<k>",
18
+ "touch-<n>-trail-<k>"
19
19
  ],
20
20
  "cues": [
21
21
  "taps",
@@ -27,7 +27,7 @@
27
27
  "files": [
28
28
  {
29
29
  "path": "touch-point/index.ts",
30
- "content": "/**\n * TouchPoint: fingers on glass, drawn the way screen recordings show\n * touches: a soft disc that lands, presses and lifts, with a ripple. Taps,\n * double taps, long presses and swipes. Between touches the finger is\n * lifted, so nothing travels: each touch is its own disc.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tdist,\n\tresolveTarget,\n\tscaleFrames,\n\ttype Target,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport interface TouchPointCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each tap's touch-down. */\n\ttaps: ReadonlyArray<{ frame: number; point: Vec2 }>;\n\t/** Long presses, down to lift. */\n\tpresses: ReadonlyArray<{ frame: number; end: number; point: Vec2 }>;\n\t/** Swipes, from the start of the move to its end. */\n\tswipes: ReadonlyArray<{ frame: number; end: number; from: Vec2; to: Vec2 }>;\n\t/** Every touch-down. */\n\ttouches: ReadonlyArray<{ frame: number; point: Vec2 }>;\n}\n\n/** One planned touch, in local frames. */\nexport interface PlannedTouch {\n\tpoint: Vec2;\n\t/** Disc starts landing. */\n\tlead: number;\n\t/** Taps: down frames; presses and swipes: one. */\n\tdowns: number[];\n\t/** Swipe: move window and destination. */\n\tswipe?: { start: number; end: number; to: Vec2 };\n\t/** Finger leaves the glass. */\n\tlift: number;\n\tkind: \"tap\" | \"press\" | \"swipe\";\n\tpressEnd?: number;\n}\n\nexport const touchPoint = /* @__PURE__ */ defineComponent({\n\tname: \"TouchPoint\",\n\tprops: () => ({\n\t\ttouches: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tto: targetSchema(),\n\t\t\t\t\t\t/** Touch-down frame. Default: after the previous touch. */\n\t\t\t\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t\t\t\t/** Tap count. */\n\t\t\t\t\t\ttap: z.number().int().positive().optional(),\n\t\t\t\t\t\t/** Long press: frames held. */\n\t\t\t\t\t\tpress: z.number().int().positive().optional(),\n\t\t\t\t\t\t/** Drag to here and lift. */\n\t\t\t\t\t\tswipe: targetSchema().optional(),\n\t\t\t\t\t\t/** Frames to wait after this touch. */\n\t\t\t\t\t\tdwell: z.number().int().min(0).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.min(1),\n\t\tsize: z.number().positive().optional(),\n\t\tcolor: z.string().default(\"#ffffff\"),\n\t\topacity: z.number().min(0).max(1).default(0.55),\n\t\tripple: z.boolean().default(true),\n\t\ttrail: z.boolean().default(true),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst size = props.size ?? 64 * s;\n\t\tconst f = (n: number) => scaleFrames(n, fps);\n\t\tconst pointOf = (t: Target, at: number) =>\n\t\t\ttargetPoint(resolveTarget(t, { measure: host.measure, fps }), at);\n\n\t\tconst plan = planTouches(\n\t\t\tprops.touches.map((t) => ({\n\t\t\t\tat: t.at,\n\t\t\t\ttap: t.tap,\n\t\t\t\tpress: t.press,\n\t\t\t\tdwell: t.dwell,\n\t\t\t\tpoint: (frame: number) => pointOf(t.to, frame),\n\t\t\t\tswipe: t.swipe\n\t\t\t\t\t? (frame: number) => pointOf(t.swipe as Target, frame)\n\t\t\t\t\t: undefined,\n\t\t\t})),\n\t\t\tfps,\n\t\t);\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst ringFrames = f(16);\n\t\tconst lead = f(4);\n\t\tplan.forEach((touch, i) => {\n\t\t\tconst press: KeyframeTuple[] = [\n\t\t\t\t[touch.lead, 1.25],\n\t\t\t\t[touch.lead + lead, 1, \"power2.out\"],\n\t\t\t];\n\t\t\tfor (const d of touch.downs.slice(1)) {\n\t\t\t\t// Between taps the finger lifts a little and comes back down.\n\t\t\t\tpress.push([d - f(3), 1.1, \"power2.out\"], [d, 1, \"power2.in\"]);\n\t\t\t}\n\t\t\tif (touch.kind === \"press\" && touch.pressEnd !== undefined)\n\t\t\t\tpress.push([touch.pressEnd, 0.88, \"sine.inOut\"]);\n\t\t\tpress.push(\n\t\t\t\t[touch.lift, touch.kind === \"press\" ? 0.88 : 1],\n\t\t\t\t[touch.lift + f(6), 1.2, \"power2.out\"],\n\t\t\t);\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t[touch.lead, 0],\n\t\t\t\t\t[touch.lead + lead, props.opacity, \"power2.out\"],\n\t\t\t\t\t[touch.lift, props.opacity],\n\t\t\t\t\t[touch.lift + f(6), 0, \"power2.in\"],\n\t\t\t\t])\n\t\t\t\t.keys(\"scale\", ascending(press));\n\t\t\tif (touch.swipe) {\n\t\t\t\tconst { start, end, to } = touch.swipe;\n\t\t\t\tanim\n\t\t\t\t\t.keys(\"x\", [\n\t\t\t\t\t\t[start, touch.point[0] - size / 2],\n\t\t\t\t\t\t[end, to[0] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t])\n\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t[start, touch.point[1] - size / 2],\n\t\t\t\t\t\t[end, to[1] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t]);\n\t\t\t}\n\t\t\tconst disc = host.box({\n\t\t\t\tid: ctx.partId(`touch-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tbackground: `radial-gradient(closest-side, ${props.color} 0%, ${props.color} 72%, rgba(255,255,255,0) 100%)`,\n\t\t\t\tborderRadius: size / 2,\n\t\t\t\tborderColor: props.color,\n\t\t\t\tborderWidth: Math.max(1, 1.5 * s),\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdisc.animate(anim);\n\t\t\thost.style(disc, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.25,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 2 * s,\n\t\t\t\t\tsize: 10 * s,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Ghosts that lag behind a swipe: a short fading streak.\n\t\t\tif (touch.swipe && props.trail) {\n\t\t\t\tconst { start, end, to } = touch.swipe;\n\t\t\t\t[2, 4, 6].forEach((lag, g) => {\n\t\t\t\t\tconst ghost = host.box({\n\t\t\t\t\t\tid: ctx.partId(`touch-${i}-trail-${g}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\t\t\twidth: size,\n\t\t\t\t\t\theight: size,\n\t\t\t\t\t\tbackground: props.color,\n\t\t\t\t\t\tborderRadius: size / 2,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\tscale: 0.85 - g * 0.15,\n\t\t\t\t\t});\n\t\t\t\t\tconst o = props.opacity * (0.35 - g * 0.1);\n\t\t\t\t\tghost.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"x\", [\n\t\t\t\t\t\t\t\t[start + f(lag), touch.point[0] - size / 2],\n\t\t\t\t\t\t\t\t[end + f(lag), to[0] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t\t\t[start + f(lag), touch.point[1] - size / 2],\n\t\t\t\t\t\t\t\t[end + f(lag), to[1] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[start, 0],\n\t\t\t\t\t\t\t\t[start + f(lag), o],\n\t\t\t\t\t\t\t\t[end + f(lag), 0, \"power2.in\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tparts[`touch-${i}-trail-${g}`] = ghost;\n\t\t\t\t\tchildren.push(ghost);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (props.ripple && touch.kind !== \"swipe\") {\n\t\t\t\tconst rings =\n\t\t\t\t\ttouch.kind === \"press\" ? [touch.downs[0] as number] : touch.downs;\n\t\t\t\trings.forEach((d, r) => {\n\t\t\t\t\tconst ring = host.box({\n\t\t\t\t\t\tid: ctx.partId(`touch-${i}-ripple-${r}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\t\t\twidth: size,\n\t\t\t\t\t\theight: size,\n\t\t\t\t\t\tborderRadius: size / 2,\n\t\t\t\t\t\tborderColor: props.color,\n\t\t\t\t\t\tborderWidth: Math.max(1.5, 2.5 * s),\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tconst span =\n\t\t\t\t\t\ttouch.kind === \"press\"\n\t\t\t\t\t\t\t? Math.max(ringFrames, (touch.pressEnd ?? d) - d)\n\t\t\t\t\t\t\t: ringFrames;\n\t\t\t\t\tring.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"scale\", [\n\t\t\t\t\t\t\t\t[d, 0.6],\n\t\t\t\t\t\t\t\t[d + span, 2.2, \"power2.out\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t[d, 0.7, \"hold\"],\n\t\t\t\t\t\t\t\t[d + span, 0, \"power1.out\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tparts[`touch-${i}-ripple-${r}`] = ring;\n\t\t\t\t\tchildren.push(ring);\n\t\t\t\t});\n\t\t\t}\n\t\t\tparts[`touch-${i}`] = disc;\n\t\t\tchildren.push(disc);\n\t\t});\n\n\t\tconst last = Math.max(\n\t\t\t...plan.map((t) => t.lift + f(6)),\n\t\t\t...plan.map((t) => (t.downs.at(-1) ?? 0) + ringFrames),\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\ttaps: plan.flatMap((t) =>\n\t\t\t\t\tt.kind === \"tap\"\n\t\t\t\t\t\t? t.downs.map((frame) => ({ frame, point: t.point }))\n\t\t\t\t\t\t: [],\n\t\t\t\t),\n\t\t\t\tpresses: plan\n\t\t\t\t\t.filter((t) => t.kind === \"press\")\n\t\t\t\t\t.map((t) => ({\n\t\t\t\t\t\tframe: t.downs[0] as number,\n\t\t\t\t\t\tend: t.lift,\n\t\t\t\t\t\tpoint: t.point,\n\t\t\t\t\t})),\n\t\t\t\tswipes: plan.flatMap((t) =>\n\t\t\t\t\tt.swipe\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tframe: t.swipe.start,\n\t\t\t\t\t\t\t\t\tend: t.swipe.end,\n\t\t\t\t\t\t\t\t\tfrom: t.point,\n\t\t\t\t\t\t\t\t\tto: t.swipe.to,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: [],\n\t\t\t\t),\n\t\t\t\ttouches: plan.flatMap((t) =>\n\t\t\t\t\tt.downs.map((frame) => ({ frame, point: t.point })),\n\t\t\t\t),\n\t\t\t} as TouchPointCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\ninterface TouchInput {\n\tat?: number;\n\ttap?: number;\n\tpress?: number;\n\tdwell?: number;\n\tpoint: (frame: number) => Vec2;\n\tswipe?: (frame: number) => Vec2;\n}\n\n/** Touch timing: lead-in, downs, presses, swipe moves and lifts. Pure. */\nexport function planTouches(\n\ttouches: readonly TouchInput[],\n\tfps: number,\n): PlannedTouch[] {\n\tconst f = (n: number) => scaleFrames(n, fps);\n\tconst lead = f(4);\n\tlet free = lead;\n\treturn touches.map((t) => {\n\t\tconst down = t.at ?? free;\n\t\tconst point = t.point(down);\n\t\tlet lift: number;\n\t\tlet out: PlannedTouch;\n\t\tif (t.swipe) {\n\t\t\tconst start = down + f(3);\n\t\t\tconst to0 = t.swipe(start);\n\t\t\tconst d = dist(point, to0);\n\t\t\tconst move = Math.max(\n\t\t\t\tf(6),\n\t\t\t\tMath.round((0.16 + 0.1 * Math.log2(1 + d / 40)) * fps),\n\t\t\t);\n\t\t\tconst end = start + move;\n\t\t\tlift = end + f(2);\n\t\t\tout = {\n\t\t\t\tpoint,\n\t\t\t\tlead: Math.max(0, down - lead),\n\t\t\t\tdowns: [down],\n\t\t\t\tswipe: { start, end, to: t.swipe(end) },\n\t\t\t\tlift,\n\t\t\t\tkind: \"swipe\",\n\t\t\t};\n\t\t} else if (t.press) {\n\t\t\tlift = down + t.press;\n\t\t\tout = {\n\t\t\t\tpoint,\n\t\t\t\tlead: Math.max(0, down - lead),\n\t\t\t\tdowns: [down],\n\t\t\t\tlift,\n\t\t\t\tkind: \"press\",\n\t\t\t\tpressEnd: down + Math.min(t.press, f(8)),\n\t\t\t};\n\t\t} else {\n\t\t\tconst n = t.tap ?? 1;\n\t\t\tconst downs = Array.from({ length: n }, (_, k) => down + k * f(9));\n\t\t\tlift = (downs.at(-1) as number) + f(4);\n\t\t\tout = { point, lead: Math.max(0, down - lead), downs, lift, kind: \"tap\" };\n\t\t}\n\t\tfree = lift + f(6) + (t.dwell ?? f(14)) + lead;\n\t\treturn out;\n\t});\n}\n\n/** Later keys on a shared frame win; frames ascend. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst byFrame = new Map<number, KeyframeTuple>();\n\tfor (const k of keys) byFrame.set(k[0] as number, k);\n\treturn [...byFrame.values()].sort(\n\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t);\n}\n\n/**\n * Fingers on glass: taps, double taps, long presses and swipes as soft discs\n * with ripples, for mobile demos in a DeviceFrame.\n */\nexport const TouchPoint = /* @__PURE__ */ bindComponent(touchPoint);\n"
30
+ "content": "/**\n * TouchPoint: fingers on glass, drawn the way screen recordings show\n * touches: a soft disc that lands, presses and lifts, with a ripple. Taps,\n * double taps, long presses and swipes. Between touches the finger is\n * lifted, so nothing travels: each touch is its own disc.\n *\n * Coordinates are the frame's: the root covers the video (default\n * the composition `canvas`, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tascending,\n\tbindComponent,\n\tdefineComponent,\n\tdist,\n\tresolveTarget,\n\tscaleFrames,\n\ttype Target,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport interface TouchPointCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each tap's touch-down. */\n\ttaps: ReadonlyArray<{ frame: number; point: Vec2 }>;\n\t/** Long presses, down to lift. */\n\tpresses: ReadonlyArray<{ frame: number; end: number; point: Vec2 }>;\n\t/** Swipes, from the start of the move to its end. */\n\tswipes: ReadonlyArray<{ frame: number; end: number; from: Vec2; to: Vec2 }>;\n\t/** Every touch-down. */\n\ttouches: ReadonlyArray<{ frame: number; point: Vec2 }>;\n}\n\n/** One planned touch, in local frames. */\nexport interface PlannedTouch {\n\tpoint: Vec2;\n\t/** Disc starts landing. */\n\tlead: number;\n\t/** Taps: down frames; presses and swipes: one. */\n\tdowns: number[];\n\t/** Swipe: move window and destination. */\n\tswipe?: { start: number; end: number; to: Vec2 };\n\t/** Finger leaves the glass. */\n\tlift: number;\n\tkind: \"tap\" | \"press\" | \"swipe\";\n\tpressEnd?: number;\n}\n\nexport const touchPoint = /* @__PURE__ */ defineComponent({\n\tname: \"TouchPoint\",\n\tprops: () => ({\n\t\ttouches: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tto: targetSchema(),\n\t\t\t\t\t\t/** Touch-down frame. Default: after the previous touch. */\n\t\t\t\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t\t\t\t/** Tap count. */\n\t\t\t\t\t\ttap: z.number().int().positive().optional(),\n\t\t\t\t\t\t/** Long press: frames held. */\n\t\t\t\t\t\tpress: z.number().int().positive().optional(),\n\t\t\t\t\t\t/** Drag to here and lift. */\n\t\t\t\t\t\tswipe: targetSchema().optional(),\n\t\t\t\t\t\t/** Frames to wait after this touch. */\n\t\t\t\t\t\tdwell: z.number().int().min(0).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.min(1),\n\t\tsize: z.number().positive().optional(),\n\t\tcolor: z.string().default(\"#ffffff\"),\n\t\topacity: z.number().min(0).max(1).default(0.55),\n\t\tripple: z.boolean().default(true),\n\t\ttrail: z.boolean().default(true),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\n\t\tconst size = props.size ?? 64 * s;\n\t\tconst f = (n: number) => scaleFrames(n, fps);\n\t\tconst pointOf = (t: Target, at: number) =>\n\t\t\ttargetPoint(resolveTarget(t, { measure: host.measure, fps }), at);\n\n\t\tconst plan = planTouches(\n\t\t\tprops.touches.map((t) => ({\n\t\t\t\tat: t.at,\n\t\t\t\ttap: t.tap,\n\t\t\t\tpress: t.press,\n\t\t\t\tdwell: t.dwell,\n\t\t\t\tpoint: (frame: number) => pointOf(t.to, frame),\n\t\t\t\tswipe: t.swipe\n\t\t\t\t\t? (frame: number) => pointOf(t.swipe as Target, frame)\n\t\t\t\t\t: undefined,\n\t\t\t})),\n\t\t\tfps,\n\t\t);\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst ringFrames = f(16);\n\t\tconst lead = f(4);\n\t\tplan.forEach((touch, i) => {\n\t\t\tconst press: KeyframeTuple[] = [\n\t\t\t\t[touch.lead, 1.25],\n\t\t\t\t[touch.lead + lead, 1, \"power2.out\"],\n\t\t\t];\n\t\t\tfor (const d of touch.downs.slice(1)) {\n\t\t\t\t// Between taps the finger lifts a little and comes back down.\n\t\t\t\tpress.push([d - f(3), 1.1, \"power2.out\"], [d, 1, \"power2.in\"]);\n\t\t\t}\n\t\t\tif (touch.kind === \"press\" && touch.pressEnd !== undefined)\n\t\t\t\tpress.push([touch.pressEnd, 0.88, \"sine.inOut\"]);\n\t\t\tpress.push(\n\t\t\t\t[touch.lift, touch.kind === \"press\" ? 0.88 : 1],\n\t\t\t\t[touch.lift + f(6), 1.2, \"power2.out\"],\n\t\t\t);\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t[touch.lead, 0],\n\t\t\t\t\t[touch.lead + lead, props.opacity, \"power2.out\"],\n\t\t\t\t\t[touch.lift, props.opacity],\n\t\t\t\t\t[touch.lift + f(6), 0, \"power2.in\"],\n\t\t\t\t])\n\t\t\t\t.keys(\"scale\", ascending(press));\n\t\t\tif (touch.swipe) {\n\t\t\t\tconst { start, end, to } = touch.swipe;\n\t\t\t\tanim\n\t\t\t\t\t.keys(\"x\", [\n\t\t\t\t\t\t[start, touch.point[0] - size / 2],\n\t\t\t\t\t\t[end, to[0] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t])\n\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t[start, touch.point[1] - size / 2],\n\t\t\t\t\t\t[end, to[1] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t]);\n\t\t\t}\n\t\t\tconst disc = host.box({\n\t\t\t\tid: ctx.partId(`touch-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tbackground: `radial-gradient(closest-side, ${props.color} 0%, ${props.color} 72%, rgba(255,255,255,0) 100%)`,\n\t\t\t\tborderRadius: size / 2,\n\t\t\t\tborderColor: props.color,\n\t\t\t\tborderWidth: Math.max(1, 1.5 * s),\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdisc.animate(anim);\n\t\t\thost.style(disc, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.25,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 2 * s,\n\t\t\t\t\tsize: 10 * s,\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Ghosts that lag behind a swipe: a short fading streak.\n\t\t\tif (touch.swipe && props.trail) {\n\t\t\t\tconst { start, end, to } = touch.swipe;\n\t\t\t\t[2, 4, 6].forEach((lag, g) => {\n\t\t\t\t\tconst ghost = host.box({\n\t\t\t\t\t\tid: ctx.partId(`touch-${i}-trail-${g}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\t\t\twidth: size,\n\t\t\t\t\t\theight: size,\n\t\t\t\t\t\tbackground: props.color,\n\t\t\t\t\t\tborderRadius: size / 2,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\tscale: 0.85 - g * 0.15,\n\t\t\t\t\t});\n\t\t\t\t\tconst o = props.opacity * (0.35 - g * 0.1);\n\t\t\t\t\tghost.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"x\", [\n\t\t\t\t\t\t\t\t[start + f(lag), touch.point[0] - size / 2],\n\t\t\t\t\t\t\t\t[end + f(lag), to[0] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t\t\t[start + f(lag), touch.point[1] - size / 2],\n\t\t\t\t\t\t\t\t[end + f(lag), to[1] - size / 2, \"power2.inOut\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[start, 0],\n\t\t\t\t\t\t\t\t[start + f(lag), o],\n\t\t\t\t\t\t\t\t[end + f(lag), 0, \"power2.in\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tparts[`touch-${i}-trail-${g}`] = ghost;\n\t\t\t\t\tchildren.push(ghost);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (props.ripple && touch.kind !== \"swipe\") {\n\t\t\t\tconst rings =\n\t\t\t\t\ttouch.kind === \"press\" ? [touch.downs[0] as number] : touch.downs;\n\t\t\t\trings.forEach((d, r) => {\n\t\t\t\t\tconst ring = host.box({\n\t\t\t\t\t\tid: ctx.partId(`touch-${i}-ripple-${r}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: touch.point[0] - size / 2,\n\t\t\t\t\t\ty: touch.point[1] - size / 2,\n\t\t\t\t\t\twidth: size,\n\t\t\t\t\t\theight: size,\n\t\t\t\t\t\tborderRadius: size / 2,\n\t\t\t\t\t\tborderColor: props.color,\n\t\t\t\t\t\tborderWidth: Math.max(1.5, 2.5 * s),\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tconst span =\n\t\t\t\t\t\ttouch.kind === \"press\"\n\t\t\t\t\t\t\t? Math.max(ringFrames, (touch.pressEnd ?? d) - d)\n\t\t\t\t\t\t\t: ringFrames;\n\t\t\t\t\tring.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"scale\", [\n\t\t\t\t\t\t\t\t[d, 0.6],\n\t\t\t\t\t\t\t\t[d + span, 2.2, \"power2.out\"],\n\t\t\t\t\t\t\t])\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t\t[d, 0.7, \"hold\"],\n\t\t\t\t\t\t\t\t[d + span, 0, \"power1.out\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tparts[`touch-${i}-ripple-${r}`] = ring;\n\t\t\t\t\tchildren.push(ring);\n\t\t\t\t});\n\t\t\t}\n\t\t\tparts[`touch-${i}`] = disc;\n\t\t\tchildren.push(disc);\n\t\t});\n\n\t\tconst last = Math.max(\n\t\t\t...plan.map((t) => t.lift + f(6)),\n\t\t\t...plan.map((t) => (t.downs.at(-1) ?? 0) + ringFrames),\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\ttaps: plan.flatMap((t) =>\n\t\t\t\t\tt.kind === \"tap\"\n\t\t\t\t\t\t? t.downs.map((frame) => ({ frame, point: t.point }))\n\t\t\t\t\t\t: [],\n\t\t\t\t),\n\t\t\t\tpresses: plan\n\t\t\t\t\t.filter((t) => t.kind === \"press\")\n\t\t\t\t\t.map((t) => ({\n\t\t\t\t\t\tframe: t.downs[0] as number,\n\t\t\t\t\t\tend: t.lift,\n\t\t\t\t\t\tpoint: t.point,\n\t\t\t\t\t})),\n\t\t\t\tswipes: plan.flatMap((t) =>\n\t\t\t\t\tt.swipe\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tframe: t.swipe.start,\n\t\t\t\t\t\t\t\t\tend: t.swipe.end,\n\t\t\t\t\t\t\t\t\tfrom: t.point,\n\t\t\t\t\t\t\t\t\tto: t.swipe.to,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: [],\n\t\t\t\t),\n\t\t\t\ttouches: plan.flatMap((t) =>\n\t\t\t\t\tt.downs.map((frame) => ({ frame, point: t.point })),\n\t\t\t\t),\n\t\t\t} as TouchPointCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\ninterface TouchInput {\n\tat?: number;\n\ttap?: number;\n\tpress?: number;\n\tdwell?: number;\n\tpoint: (frame: number) => Vec2;\n\tswipe?: (frame: number) => Vec2;\n}\n\n/** Touch timing: lead-in, downs, presses, swipe moves and lifts. Pure. */\nexport function planTouches(\n\ttouches: readonly TouchInput[],\n\tfps: number,\n): PlannedTouch[] {\n\tconst f = (n: number) => scaleFrames(n, fps);\n\tconst lead = f(4);\n\tlet free = lead;\n\treturn touches.map((t) => {\n\t\tconst down = t.at ?? free;\n\t\tconst point = t.point(down);\n\t\tlet lift: number;\n\t\tlet out: PlannedTouch;\n\t\tif (t.swipe) {\n\t\t\tconst start = down + f(3);\n\t\t\tconst to0 = t.swipe(start);\n\t\t\tconst d = dist(point, to0);\n\t\t\tconst move = Math.max(\n\t\t\t\tf(6),\n\t\t\t\tMath.round((0.16 + 0.1 * Math.log2(1 + d / 40)) * fps),\n\t\t\t);\n\t\t\tconst end = start + move;\n\t\t\tlift = end + f(2);\n\t\t\tout = {\n\t\t\t\tpoint,\n\t\t\t\tlead: Math.max(0, down - lead),\n\t\t\t\tdowns: [down],\n\t\t\t\tswipe: { start, end, to: t.swipe(end) },\n\t\t\t\tlift,\n\t\t\t\tkind: \"swipe\",\n\t\t\t};\n\t\t} else if (t.press) {\n\t\t\tlift = down + t.press;\n\t\t\tout = {\n\t\t\t\tpoint,\n\t\t\t\tlead: Math.max(0, down - lead),\n\t\t\t\tdowns: [down],\n\t\t\t\tlift,\n\t\t\t\tkind: \"press\",\n\t\t\t\tpressEnd: down + Math.min(t.press, f(8)),\n\t\t\t};\n\t\t} else {\n\t\t\tconst n = t.tap ?? 1;\n\t\t\tconst downs = Array.from({ length: n }, (_, k) => down + k * f(9));\n\t\t\tlift = (downs.at(-1) as number) + f(4);\n\t\t\tout = { point, lead: Math.max(0, down - lead), downs, lift, kind: \"tap\" };\n\t\t}\n\t\tfree = lift + f(6) + (t.dwell ?? f(14)) + lead;\n\t\treturn out;\n\t});\n}\n\n/**\n * Fingers on glass: taps, double taps, long presses and swipes as soft discs\n * with ripples, for mobile demos in a DeviceFrame.\n */\nexport const TouchPoint = /* @__PURE__ */ bindComponent(touchPoint);\n"
31
31
  }
32
32
  ]
33
33
  }
@@ -13,8 +13,8 @@
13
13
  ],
14
14
  "example": "Waveform({ audio: await analyzeAudio(\"voice.wav\", { fps: 30 }), kind: \"mirror\" })",
15
15
  "parts": [
16
- "bar-0",
17
- "spoke-0"
16
+ "bar-<n>",
17
+ "spoke-<n>"
18
18
  ],
19
19
  "cues": [
20
20
  "beats"
@@ -23,7 +23,7 @@
23
23
  "files": [
24
24
  {
25
25
  "path": "waveform/index.ts",
26
- "content": "/**\n * Waveform: an audiogram. Bars, mirrored bars, dots or a ring that move\n * with a track's spectrum. The audio is analysed offline into `AudioLevels`\n * (plain data); each bar is bound to its band as a signal, so a minute of\n * audio costs no keyframes and every frame seeks exactly.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\taudioLevelsSchema,\n\tbindComponent,\n\tdefineComponent,\n\tresampleBands,\n\tsmoothRelease,\n\tz,\n} from \"framefields/components\";\n\nexport const WAVEFORM_KINDS = [\"bars\", \"mirror\", \"dots\", \"radial\"] as const;\nexport type WaveformKind = (typeof WAVEFORM_KINDS)[number];\n\nexport interface WaveformCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Onsets in the audio (kicks, plosives). */\n\tbeats: ReadonlyArray<{ frame: number }>;\n}\n\nexport const waveform = /* @__PURE__ */ defineComponent({\n\tname: \"Waveform\",\n\tprops: () => ({\n\t\t/** From `analyzeAudio(source, { fps })`. */\n\t\taudio: audioLevelsSchema(),\n\t\t/** Composition frame the track starts on. Default: the component's `at`. */\n\t\taudioStart: z.number().int().min(0).optional(),\n\t\tkind: z.enum(WAVEFORM_KINDS).default(\"mirror\"),\n\t\t/** Bars shown. Default: one per band. */\n\t\tbars: z.number().int().min(1).max(256).optional(),\n\t\t/** Space between bars, as a fraction of a bar's pitch. */\n\t\tgap: z.number().min(0).max(0.95).default(0.45),\n\t\t/** Bar corner radius. Default: round. */\n\t\tradius: z.number().min(0).optional(),\n\t\t/** Smallest bar, as a fraction of full height. */\n\t\tfloor: z.number().min(0).max(1).default(0.06),\n\t\tgain: z.number().positive().default(1),\n\t\t/** Frames a bar takes to fall from a peak. */\n\t\tsmoothing: z.number().int().min(0).default(2),\n\t\t/** One colour, or a pair graded across the bars. */\n\t\tcolor: z.union([z.string(), z.tuple([z.string(), z.string()])]).optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst audio = props.audio;\n\t\tif (Math.abs(audio.fps - fps) > 1e-6)\n\t\t\tthrow new Error(\n\t\t\t\t`Waveform: the audio was analysed at ${audio.fps} fps but the component runs at ${fps}. Analyse with { fps: ${fps} }.`,\n\t\t\t);\n\t\tconst kind = props.kind;\n\t\tconst radial = kind === \"radial\";\n\t\tconst W = ctx.requested.width ?? (radial ? 420 : 800) * s;\n\t\tconst H = ctx.requested.height ?? (radial ? 420 : 200) * s;\n\t\tconst start = props.audioStart ?? ctx.requested.at;\n\t\tconst count = props.bars ?? audio.bands.length;\n\t\tconst mirror = kind === \"mirror\";\n\t\t// Mirrored bars show each band twice, lowest in the middle.\n\t\tconst bands = resampleBands(\n\t\t\taudio.bands,\n\t\t\tmirror ? Math.ceil(count / 2) : count,\n\t\t);\n\t\tconst floor = props.floor;\n\t\tconst level = (band: number) =>\n\t\t\tsmoothRelease(\n\t\t\t\t(bands[band] ?? []).map((v) => Math.min(1, v * props.gain)),\n\t\t\t\tprops.smoothing,\n\t\t\t).map((v) => Math.round((floor + (1 - floor) * v) * 1000) / 1000);\n\t\t// Signals read the composition's frame: the floor before the track and after it.\n\t\tconst levels = bands.map((_, b) => [\n\t\t\t...new Array<number>(start).fill(floor),\n\t\t\t...level(b),\n\t\t\tfloor,\n\t\t]);\n\t\tconst signals = new Map<string, object>();\n\t\t/** One signal per band and mapping (`v` is the level, floor..1), shared by bars. */\n\t\tconst bound = (band: number, key: string, map: (v: number) => number) => {\n\t\t\tconst id = `${band}:${key}`;\n\t\t\tlet signal = signals.get(id);\n\t\t\tif (!signal) {\n\t\t\t\tsignal = host.signal(\n\t\t\t\t\t(levels[band] as number[]).map((v) => Math.round(map(v) * 100) / 100),\n\t\t\t\t\tfps,\n\t\t\t\t);\n\t\t\t\tsignals.set(id, signal);\n\t\t\t}\n\t\t\treturn signal;\n\t\t};\n\t\tconst colorOf = (i: number) => {\n\t\t\tconst c = props.color ?? theme.color.accent;\n\t\t\treturn typeof c === \"string\"\n\t\t\t\t? c\n\t\t\t\t: mix(c[0], c[1], count > 1 ? i / (count - 1) : 0);\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst pitch = (radial ? 0 : W) / count;\n\t\tconst bw = Math.max(1, pitch * (1 - props.gap));\n\t\tfor (let i = 0; i < count; i++) {\n\t\t\tconst band = mirror\n\t\t\t\t? Math.min(bands.length - 1, Math.floor(Math.abs(i - (count - 1) / 2)))\n\t\t\t\t: i;\n\t\t\tconst color = colorOf(i);\n\t\t\tif (radial) {\n\t\t\t\tconst D = Math.min(W, H);\n\t\t\t\tconst inner = D * 0.26;\n\t\t\t\tconst len = D / 2 - inner;\n\t\t\t\tconst thick = Math.max(\n\t\t\t\t\t2 * s,\n\t\t\t\t\t((2 * Math.PI * inner) / count) * (1 - props.gap),\n\t\t\t\t);\n\t\t\t\t// Grows outward from the ring: its bottom edge stays on the ring.\n\t\t\t\tconst bottom = D / 2 - inner;\n\t\t\t\tconst bar = host.box({\n\t\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: D / 2 - thick / 2,\n\t\t\t\t\ty: bottom - len * floor,\n\t\t\t\t\twidth: thick,\n\t\t\t\t\theight: len * floor,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: props.radius ?? thick / 2,\n\t\t\t\t});\n\t\t\t\tbar.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.signal(\n\t\t\t\t\t\t\t\"height\",\n\t\t\t\t\t\t\tbound(band, \"radial-h\", (v) => len * v),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.signal(\n\t\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\t\tbound(band, \"radial-y\", (v) => bottom - len * v),\n\t\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tconst spoke = host.box({\n\t\t\t\t\tid: ctx.partId(`spoke-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: (W - D) / 2,\n\t\t\t\t\ty: (H - D) / 2,\n\t\t\t\t\twidth: D,\n\t\t\t\t\theight: D,\n\t\t\t\t\trotation: (360 * i) / count,\n\t\t\t\t\tchildren: [bar],\n\t\t\t\t});\n\t\t\t\tparts[`bar-${i}`] = bar;\n\t\t\t\tparts[`spoke-${i}`] = spoke;\n\t\t\t\tchildren.push(spoke);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (kind === \"dots\") {\n\t\t\t\tconst size = Math.min(bw, H);\n\t\t\t\tconst dot = host.box({\n\t\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: i * pitch + (pitch - size) / 2,\n\t\t\t\t\ty: (H - size) / 2,\n\t\t\t\t\twidth: size,\n\t\t\t\t\theight: size,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: props.radius ?? size / 2,\n\t\t\t\t\tscale: floor,\n\t\t\t\t});\n\t\t\t\tdot.animate(\n\t\t\t\t\tLayerAnimation.create().signal(\n\t\t\t\t\t\t\"scale\",\n\t\t\t\t\t\tbound(band, \"dot\", (v) => v),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tparts[`bar-${i}`] = dot;\n\t\t\t\tchildren.push(dot);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst up = kind === \"bars\";\n\t\t\tconst bar = host.box({\n\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: i * pitch + (pitch - bw) / 2,\n\t\t\t\ty: up ? H * (1 - floor) : (H * (1 - floor)) / 2,\n\t\t\t\twidth: bw,\n\t\t\t\theight: H * floor,\n\t\t\t\tbackground: color,\n\t\t\t\tborderRadius: props.radius ?? bw / 2,\n\t\t\t});\n\t\t\tbar.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.signal(\n\t\t\t\t\t\t\"height\",\n\t\t\t\t\t\tbound(band, \"h\", (v) => H * v),\n\t\t\t\t\t)\n\t\t\t\t\t.signal(\n\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\tbound(band, up ? \"y-up\" : \"y-mid\", (v) =>\n\t\t\t\t\t\t\tup ? H * (1 - v) : (H * (1 - v)) / 2,\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t);\n\t\t\tparts[`bar-${i}`] = bar;\n\t\t\tchildren.push(bar);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, audio.frames),\n\t\t\tcues: {\n\t\t\t\tbeats: audio.beats.map((frame) => ({\n\t\t\t\t\tframe: frame + start - ctx.requested.at,\n\t\t\t\t})),\n\t\t\t} as WaveformCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** Mix two `#rrggbb` colours (`t` 0 → a, 1 → b); other formats: `a`. */\nfunction mix(a: string, b: string, t: number): string {\n\tconst rgb = (c: string) => {\n\t\tconst h = c.replace(/^#/, \"\");\n\t\tconst full = h.length === 3 ? h.replace(/./g, (x) => x + x) : h.slice(0, 6);\n\t\treturn /^[0-9a-f]{6}$/i.test(full)\n\t\t\t? [0, 2, 4].map((k) => Number.parseInt(full.slice(k, k + 2), 16))\n\t\t\t: null;\n\t};\n\tconst x = rgb(a);\n\tconst y = rgb(b);\n\tif (!x || !y) return a;\n\treturn `#${x\n\t\t.map((v, k) =>\n\t\t\tMath.round(v + ((y[k] as number) - v) * t)\n\t\t\t\t.toString(16)\n\t\t\t\t.padStart(2, \"0\"),\n\t\t)\n\t\t.join(\"\")}`;\n}\n\n/**\n * An audiogram: bars, mirrored bars, dots or a ring that move with a track's\n * spectrum, analysed offline and bound as signals.\n */\nexport const Waveform = /* @__PURE__ */ bindComponent(waveform);\n"
26
+ "content": "/**\n * Waveform: an audiogram. Bars, mirrored bars, dots or a ring that move\n * with a track's spectrum. The audio is analysed offline into `AudioLevels`\n * (plain data); each bar is bound to its band as a signal, so a minute of\n * audio costs no keyframes and every frame seeks exactly.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\taudioLevelsSchema,\n\tbindComponent,\n\tdefineComponent,\n\tmix,\n\tresampleBands,\n\tsmoothRelease,\n\tz,\n} from \"framefields/components\";\n\nexport const WAVEFORM_KINDS = [\"bars\", \"mirror\", \"dots\", \"radial\"] as const;\nexport type WaveformKind = (typeof WAVEFORM_KINDS)[number];\n\nexport interface WaveformCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Onsets in the audio (kicks, plosives). */\n\tbeats: ReadonlyArray<{ frame: number }>;\n}\n\nexport const waveform = /* @__PURE__ */ defineComponent({\n\tname: \"Waveform\",\n\tprops: () => ({\n\t\t/** From `analyzeAudio(source, { fps })`. */\n\t\taudio: audioLevelsSchema(),\n\t\t/** Composition frame the track starts on. Default: the component's `at`. */\n\t\taudioStart: z.number().int().min(0).optional(),\n\t\tkind: z.enum(WAVEFORM_KINDS).default(\"mirror\"),\n\t\t/** Bars shown. Default: one per band. */\n\t\tbars: z.number().int().min(1).max(256).optional(),\n\t\t/** Space between bars, as a fraction of a bar's pitch. */\n\t\tgap: z.number().min(0).max(0.95).default(0.45),\n\t\t/** Bar corner radius. Default: round. */\n\t\tradius: z.number().min(0).optional(),\n\t\t/** Smallest bar, as a fraction of full height. */\n\t\tfloor: z.number().min(0).max(1).default(0.06),\n\t\tgain: z.number().positive().default(1),\n\t\t/** Frames a bar takes to fall from a peak. */\n\t\tsmoothing: z.number().int().min(0).default(2),\n\t\t/** One colour, or a pair graded across the bars. */\n\t\tcolor: z.union([z.string(), z.tuple([z.string(), z.string()])]).optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst audio = props.audio;\n\t\tif (Math.abs(audio.fps - fps) > 1e-6)\n\t\t\tthrow new Error(\n\t\t\t\t`Waveform: the audio was analysed at ${audio.fps} fps but the component runs at ${fps}. Analyse with { fps: ${fps} }.`,\n\t\t\t);\n\t\tconst kind = props.kind;\n\t\tconst radial = kind === \"radial\";\n\t\tconst W = ctx.requested.width ?? (radial ? 420 : 800) * s;\n\t\tconst H = ctx.requested.height ?? (radial ? 420 : 200) * s;\n\t\tconst start = props.audioStart ?? ctx.requested.at;\n\t\tconst count = props.bars ?? audio.bands.length;\n\t\tconst mirror = kind === \"mirror\";\n\t\t// Mirrored bars show each band twice, lowest in the middle.\n\t\tconst bands = resampleBands(\n\t\t\taudio.bands,\n\t\t\tmirror ? Math.ceil(count / 2) : count,\n\t\t);\n\t\tconst floor = props.floor;\n\t\tconst level = (band: number) =>\n\t\t\tsmoothRelease(\n\t\t\t\t(bands[band] ?? []).map((v) => Math.min(1, v * props.gain)),\n\t\t\t\tprops.smoothing,\n\t\t\t).map((v) => Math.round((floor + (1 - floor) * v) * 1000) / 1000);\n\t\t// Signals read the composition's frame: the floor before the track and after it.\n\t\tconst levels = bands.map((_, b) => [\n\t\t\t...new Array<number>(start).fill(floor),\n\t\t\t...level(b),\n\t\t\tfloor,\n\t\t]);\n\t\tconst signals = new Map<string, object>();\n\t\t/** One signal per band and mapping (`v` is the level, floor..1), shared by bars. */\n\t\tconst bound = (band: number, key: string, map: (v: number) => number) => {\n\t\t\tconst id = `${band}:${key}`;\n\t\t\tlet signal = signals.get(id);\n\t\t\tif (!signal) {\n\t\t\t\tsignal = host.signal(\n\t\t\t\t\t(levels[band] as number[]).map((v) => Math.round(map(v) * 100) / 100),\n\t\t\t\t\tfps,\n\t\t\t\t);\n\t\t\t\tsignals.set(id, signal);\n\t\t\t}\n\t\t\treturn signal;\n\t\t};\n\t\tconst colorOf = (i: number) => {\n\t\t\tconst c = props.color ?? theme.color.accent;\n\t\t\treturn typeof c === \"string\"\n\t\t\t\t? c\n\t\t\t\t: mix(c[0], c[1], count > 1 ? i / (count - 1) : 0);\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst pitch = (radial ? 0 : W) / count;\n\t\tconst bw = Math.max(1, pitch * (1 - props.gap));\n\t\tfor (let i = 0; i < count; i++) {\n\t\t\tconst band = mirror\n\t\t\t\t? Math.min(bands.length - 1, Math.floor(Math.abs(i - (count - 1) / 2)))\n\t\t\t\t: i;\n\t\t\tconst color = colorOf(i);\n\t\t\tif (radial) {\n\t\t\t\tconst D = Math.min(W, H);\n\t\t\t\tconst inner = D * 0.26;\n\t\t\t\tconst len = D / 2 - inner;\n\t\t\t\tconst thick = Math.max(\n\t\t\t\t\t2 * s,\n\t\t\t\t\t((2 * Math.PI * inner) / count) * (1 - props.gap),\n\t\t\t\t);\n\t\t\t\t// Grows outward from the ring: its bottom edge stays on the ring.\n\t\t\t\tconst bottom = D / 2 - inner;\n\t\t\t\tconst bar = host.box({\n\t\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: D / 2 - thick / 2,\n\t\t\t\t\ty: bottom - len * floor,\n\t\t\t\t\twidth: thick,\n\t\t\t\t\theight: len * floor,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: props.radius ?? thick / 2,\n\t\t\t\t});\n\t\t\t\tbar.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.signal(\n\t\t\t\t\t\t\t\"height\",\n\t\t\t\t\t\t\tbound(band, \"radial-h\", (v) => len * v),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.signal(\n\t\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\t\tbound(band, \"radial-y\", (v) => bottom - len * v),\n\t\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tconst spoke = host.box({\n\t\t\t\t\tid: ctx.partId(`spoke-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: (W - D) / 2,\n\t\t\t\t\ty: (H - D) / 2,\n\t\t\t\t\twidth: D,\n\t\t\t\t\theight: D,\n\t\t\t\t\trotation: (360 * i) / count,\n\t\t\t\t\tchildren: [bar],\n\t\t\t\t});\n\t\t\t\tparts[`bar-${i}`] = bar;\n\t\t\t\tparts[`spoke-${i}`] = spoke;\n\t\t\t\tchildren.push(spoke);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (kind === \"dots\") {\n\t\t\t\tconst size = Math.min(bw, H);\n\t\t\t\tconst dot = host.box({\n\t\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: i * pitch + (pitch - size) / 2,\n\t\t\t\t\ty: (H - size) / 2,\n\t\t\t\t\twidth: size,\n\t\t\t\t\theight: size,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: props.radius ?? size / 2,\n\t\t\t\t\tscale: floor,\n\t\t\t\t});\n\t\t\t\tdot.animate(\n\t\t\t\t\tLayerAnimation.create().signal(\n\t\t\t\t\t\t\"scale\",\n\t\t\t\t\t\tbound(band, \"dot\", (v) => v),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tparts[`bar-${i}`] = dot;\n\t\t\t\tchildren.push(dot);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tconst up = kind === \"bars\";\n\t\t\tconst bar = host.box({\n\t\t\t\tid: ctx.partId(`bar-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: i * pitch + (pitch - bw) / 2,\n\t\t\t\ty: up ? H * (1 - floor) : (H * (1 - floor)) / 2,\n\t\t\t\twidth: bw,\n\t\t\t\theight: H * floor,\n\t\t\t\tbackground: color,\n\t\t\t\tborderRadius: props.radius ?? bw / 2,\n\t\t\t});\n\t\t\tbar.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.signal(\n\t\t\t\t\t\t\"height\",\n\t\t\t\t\t\tbound(band, \"h\", (v) => H * v),\n\t\t\t\t\t)\n\t\t\t\t\t.signal(\n\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\tbound(band, up ? \"y-up\" : \"y-mid\", (v) =>\n\t\t\t\t\t\t\tup ? H * (1 - v) : (H * (1 - v)) / 2,\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t);\n\t\t\tparts[`bar-${i}`] = bar;\n\t\t\tchildren.push(bar);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, audio.frames),\n\t\t\tcues: {\n\t\t\t\tbeats: audio.beats.map((frame) => ({\n\t\t\t\t\tframe: frame + start - ctx.requested.at,\n\t\t\t\t})),\n\t\t\t} as WaveformCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * An audiogram: bars, mirrored bars, dots or a ring that move with a track's\n * spectrum, analysed offline and bound as signals.\n */\nexport const Waveform = /* @__PURE__ */ bindComponent(waveform);\n"
27
27
  }
28
28
  ]
29
29
  }
@@ -14,7 +14,7 @@
14
14
  "parts": [
15
15
  "before",
16
16
  "slot",
17
- "word-0",
17
+ "word-<n>",
18
18
  "after",
19
19
  "pill",
20
20
  "underline"
@@ -26,7 +26,7 @@
26
26
  "files": [
27
27
  {
28
28
  "path": "word-cycle/index.ts",
29
- "content": "/**\n * WordCycle: one slot in a line cycles through words. The slot resizes to\n * each word's measured width and the rest of the line glides to fit, so\n * the sentence always reads as set type, never as a gap.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport const WORD_TRANSITIONS = [\"roll\", \"fade\", \"slide\", \"type\"] as const;\nexport type WordTransition = (typeof WORD_TRANSITIONS)[number];\nexport const WORD_SLOTS = [\"plain\", \"pill\", \"underline\"] as const;\nexport type WordSlot = (typeof WORD_SLOTS)[number];\n\nexport interface WordCycleCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each new word starts arriving. */\n\tchanges: ReadonlyArray<{ frame: number; index: number; word: string }>;\n}\n\nexport const wordCycle = /* @__PURE__ */ defineComponent({\n\tname: \"WordCycle\",\n\tprops: () => ({\n\t\t/** One line with one `{}` marking the slot. */\n\t\ttext: z\n\t\t\t.string()\n\t\t\t.refine((t) => t.split(\"{}\").length === 2 && !t.includes(\"\\n\"), {\n\t\t\t\tmessage: 'Expected one line with exactly one \"{}\" slot',\n\t\t\t}),\n\t\twords: z.array(z.string().min(1)).min(2),\n\t\t/** Frames each word stays. Default 1.6 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\t/** Passes through `words`; the last word stays. */\n\t\tcycles: z.number().int().positive().default(1),\n\t\ttransition: z.enum(WORD_TRANSITIONS).default(\"roll\"),\n\t\talign: z.enum([\"left\", \"center\", \"right\"]).default(\"left\"),\n\t\tslot: z.enum(WORD_SLOTS).default(\"plain\"),\n\t\tcolor: z.string().optional(),\n\t\tslotColor: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontWeight: z.number().int().min(100).max(900).default(800),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst fs = props.fontSize ?? 96 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst ink = props.color ?? theme.color.ink;\n\t\tconst accent = props.slotColor ?? theme.color.accent;\n\t\tconst every = props.every ?? Math.round(fps * 1.6);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst pill = props.slot === \"pill\";\n\n\t\t// ── Measure ──────────────────────────────────────────────────────\n\t\tconst font = { fontFamily, fontSize: fs, fontWeight: props.fontWeight };\n\t\tconst measure = (t: string) => host.measure(t, font);\n\t\tconst [rawBefore = \"\", rawAfter = \"\"] = props.text.split(\"{}\");\n\t\tconst before = rawBefore.trimEnd();\n\t\tconst after = rawAfter.trimStart();\n\t\t// Spaces at the slot are measured, never trusted to a trailing space.\n\t\tconst space = measure(\"a a\").width - measure(\"aa\").width;\n\t\tconst lineH = measure(\"Ag\").height;\n\t\tconst gapB = rawBefore !== before && before ? space : 0;\n\t\tconst gapA = rawAfter !== after && after ? space : 0;\n\t\tconst wB = before ? measure(before).width : 0;\n\t\tconst wA = after ? measure(after).width : 0;\n\t\tconst pad = pill ? fs * 0.24 : 0;\n\t\tconst widths = props.words.map((w) => measure(w).width);\n\t\tconst slotW = (i: number) => (widths[i] as number) + pad * 2;\n\t\tconst lineW = (i: number) => wB + gapB + slotW(i) + gapA + wA;\n\t\tconst W = Math.max(...props.words.map((_, i) => lineW(i)));\n\t\tconst H = lineH + (props.slot === \"underline\" ? fs * 0.14 : 0);\n\t\tconst offset = (i: number) =>\n\t\t\tprops.align === \"left\"\n\t\t\t\t? 0\n\t\t\t\t: props.align === \"center\"\n\t\t\t\t\t? (W - lineW(i)) / 2\n\t\t\t\t\t: W - lineW(i);\n\n\t\t// ── Timeline ─────────────────────────────────────────────────────\n\t\tconst n = props.words.length;\n\t\tconst shown = Array.from({ length: n * props.cycles }, (_, k) => k % n);\n\t\tconst tr = Math.min(every - 1, props.transition === \"type\" ? 0 : base);\n\t\t// For `type`: each word deletes, then types (frames per character).\n\t\tconst del = Math.max(1, Math.round(fps / 30));\n\t\tconst typ = Math.max(1, Math.round((fps / 30) * 2));\n\t\tconst changes = shown.slice(1).map((index, k) => ({\n\t\t\tframe: (k + 1) * every,\n\t\t\tindex,\n\t\t\tword: props.words[index] as string,\n\t\t}));\n\t\tconst lastChange = changes[changes.length - 1]?.frame ?? 0;\n\t\tconst lastIndex = shown[shown.length - 1] as number;\n\t\tconst settle =\n\t\t\tprops.transition === \"type\"\n\t\t\t\t? lastChange +\n\t\t\t\t\t(props.words[shown[shown.length - 2] as number] as string).length *\n\t\t\t\t\t\tdel +\n\t\t\t\t\t(props.words[lastIndex] as string).length * typ\n\t\t\t\t: lastChange + tr;\n\n\t\t// Slot geometry over time: [frame, slot width] steps, eased (or per char for `type`).\n\t\tconst slotKeys: KeyframeTuple[] = [[0, slotW(0)]];\n\t\tconst lineKeys: KeyframeTuple[] = [[0, offset(0)]];\n\t\tif (props.transition === \"type\") {\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tconst out = props.words[prev] as string;\n\t\t\t\tconst inc = props.words[c.index] as string;\n\t\t\t\tconst mo = measure(out);\n\t\t\t\tconst mi = measure(inc);\n\t\t\t\tfor (let k = 1; k <= out.length; k++) {\n\t\t\t\t\tconst f = c.frame + k * del;\n\t\t\t\t\tconst w = mo.caretX(out.length - k) + pad * 2;\n\t\t\t\t\tslotKeys.push([f, w, \"hold\"]);\n\t\t\t\t}\n\t\t\t\tconst typeAt = c.frame + out.length * del;\n\t\t\t\tfor (let k = 1; k <= inc.length; k++) {\n\t\t\t\t\tslotKeys.push([typeAt + k * typ, mi.caretX(k) + pad * 2, \"hold\"]);\n\t\t\t\t}\n\t\t\t\t// The line re-centres once, as the new word lands.\n\t\t\t\tlineKeys.push(\n\t\t\t\t\t[typeAt, offset(prev)],\n\t\t\t\t\t[typeAt + inc.length * typ, offset(c.index), ease],\n\t\t\t\t);\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t} else {\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tslotKeys.push(\n\t\t\t\t\t[c.frame, slotW(prev)],\n\t\t\t\t\t[c.frame + tr, slotW(c.index), ease],\n\t\t\t\t);\n\t\t\t\tlineKeys.push(\n\t\t\t\t\t[c.frame, offset(prev)],\n\t\t\t\t\t[c.frame + tr, offset(c.index), ease],\n\t\t\t\t);\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t}\n\t\tconst add = (keys: KeyframeTuple[], by: number): KeyframeTuple[] =>\n\t\t\tkeys.map(([f, v, e]) =>\n\t\t\t\te ? [f, round((v as number) + by), e] : [f, round((v as number) + by)],\n\t\t\t) as KeyframeTuple[];\n\t\tconst sum = (a: KeyframeTuple[], b: KeyframeTuple[], by = 0) => {\n\t\t\t// After-text x = line offset + slot width (+ constants): key both tracks' frames.\n\t\t\tconst frames = [...new Set([...a, ...b].map((k) => k[0] as number))].sort(\n\t\t\t\t(x, y) => x - y,\n\t\t\t);\n\t\t\tconst at = (keys: KeyframeTuple[], f: number) => valueAt(keys, f);\n\t\t\treturn frames.map((f) => {\n\t\t\t\tconst k = [...a, ...b].find((key) => key[0] === f && key[2]);\n\t\t\t\tconst v = round(at(a, f) + at(b, f) + by);\n\t\t\t\treturn k ? ([f, v, k[2]] as KeyframeTuple) : ([f, v] as KeyframeTuple);\n\t\t\t});\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst text = (name: string, content: string, x: number, fill: string) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(name),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: 0,\n\t\t\t\t...font,\n\t\t\t\tfill,\n\t\t\t});\n\t\tconst moving = props.align !== \"left\";\n\n\t\tif (before) {\n\t\t\tconst node = text(\"before\", before, offset(0), ink);\n\t\t\tif (moving) node.animate(LayerAnimation.keys(\"x\", lineKeys));\n\t\t\tparts.before = node;\n\t\t\tchildren.push(node);\n\t\t}\n\n\t\t// Slot decoration: a pill behind, or an underline under, sized like the slot.\n\t\tconst slotX = wB + gapB;\n\t\tif (props.slot !== \"plain\") {\n\t\t\tconst deco = host.box({\n\t\t\t\tid: ctx.partId(pill ? \"pill\" : \"underline\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: offset(0) + slotX,\n\t\t\t\ty: pill ? lineH * 0.06 : lineH + fs * 0.02,\n\t\t\t\twidth: slotW(0),\n\t\t\t\theight: pill ? lineH * 0.9 : Math.max(3, fs * 0.07),\n\t\t\t\tbackground: accent,\n\t\t\t\tborderRadius: pill ? lineH * 0.45 : fs * 0.035,\n\t\t\t});\n\t\t\tdeco.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", add(lineKeys, slotX))\n\t\t\t\t\t.keys(\"width\", slotKeys),\n\t\t\t);\n\t\t\tparts[pill ? \"pill\" : \"underline\"] = deco;\n\t\t\tchildren.push(deco);\n\t\t}\n\n\t\t// Words sit in a clip as wide as the slot, so a roll never shows outside it.\n\t\tconst wordFill = pill ? theme.color.bg : accent;\n\t\tconst wordNodes = props.words.map((word, i) => {\n\t\t\tconst node = text(`word-${i}`, word, pad, wordFill);\n\t\t\tif (i !== 0) node.opacity = 0;\n\t\t\tconst anim = LayerAnimation.create();\n\t\t\tconst y: KeyframeTuple[] = [];\n\t\t\tconst x: KeyframeTuple[] = [];\n\t\t\tconst opacity: KeyframeTuple[] = [];\n\t\t\tconst reveal: KeyframeTuple[] = [];\n\t\t\tconst park = props.transition === \"roll\" ? lineH : 0;\n\t\t\tconst visibleAt0 = i === 0;\n\t\t\ty.push([0, visibleAt0 ? 0 : park]);\n\t\t\topacity.push([0, visibleAt0 ? 1 : 0]);\n\t\t\tif (props.transition === \"type\") reveal.push([0, visibleAt0 ? 1 : 0]);\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tconst f = c.frame;\n\t\t\t\tif (c.index === i) {\n\t\t\t\t\t// Arrives.\n\t\t\t\t\tif (props.transition === \"roll\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f - 1, park, \"hold\"],\n\t\t\t\t\t\t\t[f, park, \"hold\"],\n\t\t\t\t\t\t\t[f + tr, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 1, \"hold\"]);\n\t\t\t\t\t} else if (props.transition === \"fade\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f, fs * 0.12, \"hold\"],\n\t\t\t\t\t\t\t[f + tr, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 0, \"hold\"], [f + tr, 1, \"power2.out\"]);\n\t\t\t\t\t} else if (props.transition === \"slide\") {\n\t\t\t\t\t\tx.push(\n\t\t\t\t\t\t\t[f, pad + slotW(c.index), \"hold\"],\n\t\t\t\t\t\t\t[f + tr, pad, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 1, \"hold\"]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst out = props.words[prev] as string;\n\t\t\t\t\t\tconst typeAt = f + out.length * del;\n\t\t\t\t\t\topacity.push([typeAt, 1, \"hold\"]);\n\t\t\t\t\t\tfor (let k = 1; k <= word.length; k++)\n\t\t\t\t\t\t\treveal.push([typeAt + k * typ, k / word.length, \"hold\"]);\n\t\t\t\t\t}\n\t\t\t\t} else if (prev === i) {\n\t\t\t\t\t// Leaves.\n\t\t\t\t\tif (props.transition === \"roll\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f, 0],\n\t\t\t\t\t\t\t[f + tr, -lineH, theme.motion.ease.exit],\n\t\t\t\t\t\t\t[f + tr + 1, park, \"hold\"],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f + tr, 1], [f + tr + 1, 0, \"hold\"]);\n\t\t\t\t\t} else if (props.transition === \"fade\") {\n\t\t\t\t\t\ty.push([f, 0], [f + tr, -fs * 0.12, theme.motion.ease.exit]);\n\t\t\t\t\t\topacity.push([f, 1], [f + tr, 0, \"power2.in\"]);\n\t\t\t\t\t} else if (props.transition === \"slide\") {\n\t\t\t\t\t\tx.push(\n\t\t\t\t\t\t\t[f, pad],\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\tf + tr,\n\t\t\t\t\t\t\t\tpad - (widths[i] as number) - pad * 2,\n\t\t\t\t\t\t\t\ttheme.motion.ease.exit,\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f + tr, 1], [f + tr + 1, 0, \"hold\"]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfor (let k = 1; k <= word.length; k++)\n\t\t\t\t\t\t\treveal.push([\n\t\t\t\t\t\t\t\tf + k * del,\n\t\t\t\t\t\t\t\t(word.length - k) / word.length,\n\t\t\t\t\t\t\t\t\"hold\",\n\t\t\t\t\t\t\t]);\n\t\t\t\t\t\topacity.push([f + word.length * del, 0, \"hold\"]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t\tif (y.length > 1) anim.keys(\"y\", ascending(y));\n\t\t\tif (x.length > 0) anim.keys(\"x\", ascending([[0, pad], ...x]));\n\t\t\tif (opacity.length > 1) anim.keys(\"opacity\", ascending(opacity));\n\t\t\tif (reveal.length > 1) anim.keys(\"text\", ascending(reveal));\n\t\t\tnode.animate(anim);\n\t\t\tparts[`word-${i}`] = node;\n\t\t\treturn node;\n\t\t});\n\t\tconst clip = host.box({\n\t\t\tid: ctx.partId(\"slot\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: offset(0) + slotX,\n\t\t\ty: 0,\n\t\t\twidth: slotW(0),\n\t\t\theight: H,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: wordNodes,\n\t\t});\n\t\tclip.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"x\", add(lineKeys, slotX))\n\t\t\t\t.keys(\"width\", slotKeys),\n\t\t);\n\t\tparts.slot = clip;\n\t\tchildren.push(clip);\n\n\t\tif (after) {\n\t\t\tconst node = text(\n\t\t\t\t\"after\",\n\t\t\t\tafter,\n\t\t\t\toffset(0) + slotX + slotW(0) + gapA,\n\t\t\t\tink,\n\t\t\t);\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.keys(\"x\", sum(lineKeys, slotKeys, slotX + gapA)),\n\t\t\t);\n\t\t\tparts.after = node;\n\t\t\tchildren.push(node);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, settle),\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as WordCycleCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\n/** Later keys on a shared frame win; frames ascend. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst byFrame = new Map<number, KeyframeTuple>();\n\tfor (const k of keys) byFrame.set(k[0] as number, k);\n\treturn [...byFrame.values()].sort(\n\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t);\n}\n\n/** A keyed track's value at `f` (linear between keys, `hold` steps). */\nfunction valueAt(keys: readonly KeyframeTuple[], f: number): number {\n\tconst first = keys[0] as KeyframeTuple;\n\tif (f <= (first[0] as number)) return first[1] as number;\n\tfor (let i = 0; i < keys.length - 1; i++) {\n\t\tconst [f0, v0] = keys[i] as KeyframeTuple;\n\t\tconst [f1, v1, e] = keys[i + 1] as KeyframeTuple;\n\t\tif (f >= (f0 as number) && f <= (f1 as number)) {\n\t\t\tif (e === \"hold\") return f === f1 ? (v1 as number) : (v0 as number);\n\t\t\tconst t = (f - (f0 as number)) / ((f1 as number) - (f0 as number));\n\t\t\treturn (v0 as number) + ((v1 as number) - (v0 as number)) * t;\n\t\t}\n\t}\n\treturn keys[keys.length - 1]?.[1] as number;\n}\n\n/**\n * One slot in a line cycles through words: rolled, faded, slid or retyped,\n * while the slot resizes to each word and the line glides to fit.\n */\nexport const WordCycle = /* @__PURE__ */ bindComponent(wordCycle);\n"
29
+ "content": "/**\n * WordCycle: one slot in a line cycles through words. The slot resizes to\n * each word's measured width and the rest of the line glides to fit, so\n * the sentence always reads as set type, never as a gap.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tascending,\n\tbindComponent,\n\tdefineComponent,\n\tround,\n\tthemeFrames,\n\tvalueAt,\n\tz,\n} from \"framefields/components\";\n\nexport const WORD_TRANSITIONS = [\"roll\", \"fade\", \"slide\", \"type\"] as const;\nexport type WordTransition = (typeof WORD_TRANSITIONS)[number];\nexport const WORD_SLOTS = [\"plain\", \"pill\", \"underline\"] as const;\nexport type WordSlot = (typeof WORD_SLOTS)[number];\n\nexport interface WordCycleCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each new word starts arriving. */\n\tchanges: ReadonlyArray<{ frame: number; index: number; word: string }>;\n}\n\nexport const wordCycle = /* @__PURE__ */ defineComponent({\n\tname: \"WordCycle\",\n\tprops: () => ({\n\t\t/** One line with one `{}` marking the slot. */\n\t\ttext: z\n\t\t\t.string()\n\t\t\t.refine((t) => t.split(\"{}\").length === 2 && !t.includes(\"\\n\"), {\n\t\t\t\tmessage: 'Expected one line with exactly one \"{}\" slot',\n\t\t\t}),\n\t\twords: z.array(z.string().min(1)).min(2),\n\t\t/** Frames each word stays. Default 1.6 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\t/** Passes through `words`; the last word stays. */\n\t\tcycles: z.number().int().positive().default(1),\n\t\ttransition: z.enum(WORD_TRANSITIONS).default(\"roll\"),\n\t\talign: z.enum([\"left\", \"center\", \"right\"]).default(\"left\"),\n\t\tslot: z.enum(WORD_SLOTS).default(\"plain\"),\n\t\tcolor: z.string().optional(),\n\t\tslotColor: z.string().optional(),\n\t\tfontSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontWeight: z.number().int().min(100).max(900).default(800),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst fs = props.fontSize ?? 96 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst ink = props.color ?? theme.color.ink;\n\t\tconst accent = props.slotColor ?? theme.color.accent;\n\t\tconst every = props.every ?? Math.round(fps * 1.6);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst pill = props.slot === \"pill\";\n\n\t\t// ── Measure ──────────────────────────────────────────────────────\n\t\tconst font = { fontFamily, fontSize: fs, fontWeight: props.fontWeight };\n\t\tconst measure = (t: string) => host.measure(t, font);\n\t\tconst [rawBefore = \"\", rawAfter = \"\"] = props.text.split(\"{}\");\n\t\tconst before = rawBefore.trimEnd();\n\t\tconst after = rawAfter.trimStart();\n\t\t// Spaces at the slot are measured, never trusted to a trailing space.\n\t\tconst space = measure(\"a a\").width - measure(\"aa\").width;\n\t\tconst lineH = measure(\"Ag\").height;\n\t\tconst gapB = rawBefore !== before && before ? space : 0;\n\t\tconst gapA = rawAfter !== after && after ? space : 0;\n\t\tconst wB = before ? measure(before).width : 0;\n\t\tconst wA = after ? measure(after).width : 0;\n\t\tconst pad = pill ? fs * 0.24 : 0;\n\t\tconst widths = props.words.map((w) => measure(w).width);\n\t\tconst slotW = (i: number) => (widths[i] as number) + pad * 2;\n\t\tconst lineW = (i: number) => wB + gapB + slotW(i) + gapA + wA;\n\t\tconst W = Math.max(...props.words.map((_, i) => lineW(i)));\n\t\tconst H = lineH + (props.slot === \"underline\" ? fs * 0.14 : 0);\n\t\tconst offset = (i: number) =>\n\t\t\tprops.align === \"left\"\n\t\t\t\t? 0\n\t\t\t\t: props.align === \"center\"\n\t\t\t\t\t? (W - lineW(i)) / 2\n\t\t\t\t\t: W - lineW(i);\n\n\t\t// ── Timeline ─────────────────────────────────────────────────────\n\t\tconst n = props.words.length;\n\t\tconst shown = Array.from({ length: n * props.cycles }, (_, k) => k % n);\n\t\tconst tr = Math.min(every - 1, props.transition === \"type\" ? 0 : base);\n\t\t// For `type`: each word deletes, then types (frames per character).\n\t\tconst del = Math.max(1, Math.round(fps / 30));\n\t\tconst typ = Math.max(1, Math.round((fps / 30) * 2));\n\t\tconst changes = shown.slice(1).map((index, k) => ({\n\t\t\tframe: (k + 1) * every,\n\t\t\tindex,\n\t\t\tword: props.words[index] as string,\n\t\t}));\n\t\tconst lastChange = changes[changes.length - 1]?.frame ?? 0;\n\t\tconst lastIndex = shown[shown.length - 1] as number;\n\t\tconst settle =\n\t\t\tprops.transition === \"type\"\n\t\t\t\t? lastChange +\n\t\t\t\t\t(props.words[shown[shown.length - 2] as number] as string).length *\n\t\t\t\t\t\tdel +\n\t\t\t\t\t(props.words[lastIndex] as string).length * typ\n\t\t\t\t: lastChange + tr;\n\n\t\t// Slot geometry over time: [frame, slot width] steps, eased (or per char for `type`).\n\t\tconst slotKeys: KeyframeTuple[] = [[0, slotW(0)]];\n\t\tconst lineKeys: KeyframeTuple[] = [[0, offset(0)]];\n\t\tif (props.transition === \"type\") {\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tconst out = props.words[prev] as string;\n\t\t\t\tconst inc = props.words[c.index] as string;\n\t\t\t\tconst mo = measure(out);\n\t\t\t\tconst mi = measure(inc);\n\t\t\t\tfor (let k = 1; k <= out.length; k++) {\n\t\t\t\t\tconst f = c.frame + k * del;\n\t\t\t\t\tconst w = mo.caretX(out.length - k) + pad * 2;\n\t\t\t\t\tslotKeys.push([f, w, \"hold\"]);\n\t\t\t\t}\n\t\t\t\tconst typeAt = c.frame + out.length * del;\n\t\t\t\tfor (let k = 1; k <= inc.length; k++) {\n\t\t\t\t\tslotKeys.push([typeAt + k * typ, mi.caretX(k) + pad * 2, \"hold\"]);\n\t\t\t\t}\n\t\t\t\t// The line re-centres once, as the new word lands.\n\t\t\t\tlineKeys.push(\n\t\t\t\t\t[typeAt, offset(prev)],\n\t\t\t\t\t[typeAt + inc.length * typ, offset(c.index), ease],\n\t\t\t\t);\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t} else {\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tslotKeys.push(\n\t\t\t\t\t[c.frame, slotW(prev)],\n\t\t\t\t\t[c.frame + tr, slotW(c.index), ease],\n\t\t\t\t);\n\t\t\t\tlineKeys.push(\n\t\t\t\t\t[c.frame, offset(prev)],\n\t\t\t\t\t[c.frame + tr, offset(c.index), ease],\n\t\t\t\t);\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t}\n\t\tconst add = (keys: KeyframeTuple[], by: number): KeyframeTuple[] =>\n\t\t\tkeys.map(([f, v, e]) =>\n\t\t\t\te ? [f, round((v as number) + by), e] : [f, round((v as number) + by)],\n\t\t\t) as KeyframeTuple[];\n\t\tconst sum = (a: KeyframeTuple[], b: KeyframeTuple[], by = 0) => {\n\t\t\t// After-text x = line offset + slot width (+ constants): key both tracks' frames.\n\t\t\tconst frames = [...new Set([...a, ...b].map((k) => k[0] as number))].sort(\n\t\t\t\t(x, y) => x - y,\n\t\t\t);\n\t\t\tconst at = (keys: KeyframeTuple[], f: number) => valueAt(keys, f);\n\t\t\treturn frames.map((f) => {\n\t\t\t\tconst k = [...a, ...b].find((key) => key[0] === f && key[2]);\n\t\t\t\tconst v = round(at(a, f) + at(b, f) + by);\n\t\t\t\treturn k ? ([f, v, k[2]] as KeyframeTuple) : ([f, v] as KeyframeTuple);\n\t\t\t});\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst text = (name: string, content: string, x: number, fill: string) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(name),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: 0,\n\t\t\t\t...font,\n\t\t\t\tfill,\n\t\t\t});\n\t\tconst moving = props.align !== \"left\";\n\n\t\tif (before) {\n\t\t\tconst node = text(\"before\", before, offset(0), ink);\n\t\t\tif (moving) node.animate(LayerAnimation.keys(\"x\", lineKeys));\n\t\t\tparts.before = node;\n\t\t\tchildren.push(node);\n\t\t}\n\n\t\t// Slot decoration: a pill behind, or an underline under, sized like the slot.\n\t\tconst slotX = wB + gapB;\n\t\tif (props.slot !== \"plain\") {\n\t\t\tconst deco = host.box({\n\t\t\t\tid: ctx.partId(pill ? \"pill\" : \"underline\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: offset(0) + slotX,\n\t\t\t\ty: pill ? lineH * 0.06 : lineH + fs * 0.02,\n\t\t\t\twidth: slotW(0),\n\t\t\t\theight: pill ? lineH * 0.9 : Math.max(3, fs * 0.07),\n\t\t\t\tbackground: accent,\n\t\t\t\tborderRadius: pill ? lineH * 0.45 : fs * 0.035,\n\t\t\t});\n\t\t\tdeco.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", add(lineKeys, slotX))\n\t\t\t\t\t.keys(\"width\", slotKeys),\n\t\t\t);\n\t\t\tparts[pill ? \"pill\" : \"underline\"] = deco;\n\t\t\tchildren.push(deco);\n\t\t}\n\n\t\t// Words sit in a clip as wide as the slot, so a roll never shows outside it.\n\t\tconst wordFill = pill ? theme.color.bg : accent;\n\t\tconst wordNodes = props.words.map((word, i) => {\n\t\t\tconst node = text(`word-${i}`, word, pad, wordFill);\n\t\t\tif (i !== 0) node.opacity = 0;\n\t\t\tconst anim = LayerAnimation.create();\n\t\t\tconst y: KeyframeTuple[] = [];\n\t\t\tconst x: KeyframeTuple[] = [];\n\t\t\tconst opacity: KeyframeTuple[] = [];\n\t\t\tconst reveal: KeyframeTuple[] = [];\n\t\t\tconst park = props.transition === \"roll\" ? lineH : 0;\n\t\t\tconst visibleAt0 = i === 0;\n\t\t\ty.push([0, visibleAt0 ? 0 : park]);\n\t\t\topacity.push([0, visibleAt0 ? 1 : 0]);\n\t\t\tif (props.transition === \"type\") reveal.push([0, visibleAt0 ? 1 : 0]);\n\t\t\tlet prev = 0;\n\t\t\tfor (const c of changes) {\n\t\t\t\tconst f = c.frame;\n\t\t\t\tif (c.index === i) {\n\t\t\t\t\t// Arrives.\n\t\t\t\t\tif (props.transition === \"roll\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f - 1, park, \"hold\"],\n\t\t\t\t\t\t\t[f, park, \"hold\"],\n\t\t\t\t\t\t\t[f + tr, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 1, \"hold\"]);\n\t\t\t\t\t} else if (props.transition === \"fade\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f, fs * 0.12, \"hold\"],\n\t\t\t\t\t\t\t[f + tr, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 0, \"hold\"], [f + tr, 1, \"power2.out\"]);\n\t\t\t\t\t} else if (props.transition === \"slide\") {\n\t\t\t\t\t\tx.push(\n\t\t\t\t\t\t\t[f, pad + slotW(c.index), \"hold\"],\n\t\t\t\t\t\t\t[f + tr, pad, theme.motion.ease.enter],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f, 1, \"hold\"]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst out = props.words[prev] as string;\n\t\t\t\t\t\tconst typeAt = f + out.length * del;\n\t\t\t\t\t\topacity.push([typeAt, 1, \"hold\"]);\n\t\t\t\t\t\tfor (let k = 1; k <= word.length; k++)\n\t\t\t\t\t\t\treveal.push([typeAt + k * typ, k / word.length, \"hold\"]);\n\t\t\t\t\t}\n\t\t\t\t} else if (prev === i) {\n\t\t\t\t\t// Leaves.\n\t\t\t\t\tif (props.transition === \"roll\") {\n\t\t\t\t\t\ty.push(\n\t\t\t\t\t\t\t[f, 0],\n\t\t\t\t\t\t\t[f + tr, -lineH, theme.motion.ease.exit],\n\t\t\t\t\t\t\t[f + tr + 1, park, \"hold\"],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f + tr, 1], [f + tr + 1, 0, \"hold\"]);\n\t\t\t\t\t} else if (props.transition === \"fade\") {\n\t\t\t\t\t\ty.push([f, 0], [f + tr, -fs * 0.12, theme.motion.ease.exit]);\n\t\t\t\t\t\topacity.push([f, 1], [f + tr, 0, \"power2.in\"]);\n\t\t\t\t\t} else if (props.transition === \"slide\") {\n\t\t\t\t\t\tx.push(\n\t\t\t\t\t\t\t[f, pad],\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\tf + tr,\n\t\t\t\t\t\t\t\tpad - (widths[i] as number) - pad * 2,\n\t\t\t\t\t\t\t\ttheme.motion.ease.exit,\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t);\n\t\t\t\t\t\topacity.push([f + tr, 1], [f + tr + 1, 0, \"hold\"]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfor (let k = 1; k <= word.length; k++)\n\t\t\t\t\t\t\treveal.push([\n\t\t\t\t\t\t\t\tf + k * del,\n\t\t\t\t\t\t\t\t(word.length - k) / word.length,\n\t\t\t\t\t\t\t\t\"hold\",\n\t\t\t\t\t\t\t]);\n\t\t\t\t\t\topacity.push([f + word.length * del, 0, \"hold\"]);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tprev = c.index;\n\t\t\t}\n\t\t\tif (y.length > 1) anim.keys(\"y\", ascending(y));\n\t\t\tif (x.length > 0) anim.keys(\"x\", ascending([[0, pad], ...x]));\n\t\t\tif (opacity.length > 1) anim.keys(\"opacity\", ascending(opacity));\n\t\t\tif (reveal.length > 1) anim.keys(\"text\", ascending(reveal));\n\t\t\tnode.animate(anim);\n\t\t\tparts[`word-${i}`] = node;\n\t\t\treturn node;\n\t\t});\n\t\tconst clip = host.box({\n\t\t\tid: ctx.partId(\"slot\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: offset(0) + slotX,\n\t\t\ty: 0,\n\t\t\twidth: slotW(0),\n\t\t\theight: H,\n\t\t\toverflow: \"hidden\",\n\t\t\tchildren: wordNodes,\n\t\t});\n\t\tclip.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"x\", add(lineKeys, slotX))\n\t\t\t\t.keys(\"width\", slotKeys),\n\t\t);\n\t\tparts.slot = clip;\n\t\tchildren.push(clip);\n\n\t\tif (after) {\n\t\t\tconst node = text(\n\t\t\t\t\"after\",\n\t\t\t\tafter,\n\t\t\t\toffset(0) + slotX + slotW(0) + gapA,\n\t\t\t\tink,\n\t\t\t);\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.keys(\"x\", sum(lineKeys, slotKeys, slotX + gapA)),\n\t\t\t);\n\t\t\tparts.after = node;\n\t\t\tchildren.push(node);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: Math.max(1, settle),\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as WordCycleCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * One slot in a line cycles through words: rolled, faded, slid or retyped,\n * while the slot resizes to each word and the line glides to fit.\n */\nexport const WordCycle = /* @__PURE__ */ bindComponent(wordCycle);\n"
30
30
  }
31
31
  ]
32
32
  }
@@ -1,5 +1,5 @@
1
1
  import "../chunk-D7gtBK74.mjs";
2
- import { R as initHeadlessWebGPU, x as describeGpu } from "../dist-BuirkW-f.mjs";
3
- import { a as HeadlessMediaRenderer, c as renderSemaphore, i as resetRenderCaches, n as renderFrameGrid, o as HeadlessWebGPURenderer, r as resolveSamplePoints, s as formatQaReport, t as computeGridLayout } from "../renderer-BfQdc3RM.mjs";
2
+ import { R as initHeadlessWebGPU, x as describeGpu } from "../dist-CDwWQYg3.mjs";
3
+ import { a as HeadlessMediaRenderer, c as renderSemaphore, i as resetRenderCaches, n as renderFrameGrid, o as HeadlessWebGPURenderer, r as resolveSamplePoints, s as formatQaReport, t as computeGridLayout } from "../renderer-Bpve7M6S.mjs";
4
4
 
5
5
  export { HeadlessMediaRenderer, HeadlessWebGPURenderer, computeGridLayout, describeGpu, formatQaReport, initHeadlessWebGPU, renderFrameGrid, renderSemaphore, resetRenderCaches, resolveSamplePoints };
@@ -0,0 +1,5 @@
1
+ import "./chunk-D7gtBK74.mjs";
2
+ import "./dist-CDwWQYg3.mjs";
3
+ import { t as renderers_default } from "./renderer-ij448rvt.mjs";
4
+
5
+ export { renderers_default as default };
@@ -1,7 +1,7 @@
1
1
  import "./chunk-D7gtBK74.mjs";
2
2
  import { n as defineRenderer } from "./renderer-BgmO1Dzw.mjs";
3
- import "./dist-BuirkW-f.mjs";
4
- import { c as drawTextureToTarget, l as fragmentBindGroupLayout, o as drawChildToTexture, s as drawFullscreenPass, t as FULLSCREEN_QUAD_WGSL, u as perDevice } from "./gpu-BRBIoxMC.mjs";
3
+ import "./dist-CDwWQYg3.mjs";
4
+ import { c as drawTextureToTarget, l as fragmentBindGroupLayout, o as drawChildToTexture, s as drawFullscreenPass, t as FULLSCREEN_QUAD_WGSL, u as perDevice } from "./gpu-Bb4WmYYH.mjs";
5
5
 
6
6
  //#region ../../nodes/node-op-art/dist/renderers-BYCqPRmD.mjs
7
7
  const OP = "OpArt";
@@ -230,4 +230,4 @@ var renderers_default = defineRenderer({ WebGPURenderer: OpArtWebGPURenderer });
230
230
 
231
231
  //#endregion
232
232
  export { OpArtWebGPURenderer, renderers_default as default };
233
- //# sourceMappingURL=renderer-DW21AyEv.mjs.map
233
+ //# sourceMappingURL=renderer-4v6XYAiX.mjs.map