framefields 2.4.2 → 2.5.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 (504) hide show
  1. package/README.md +26 -1
  2. package/dist/audio/index.d.mts +4 -4
  3. package/dist/audio/index.mjs +13 -13
  4. package/dist/{audio-DJeVoE8b.mjs → audio-BluXgSeG.mjs} +1 -1
  5. package/dist/{audio-DJeVoE8b.mjs.map → audio-BluXgSeG.mjs.map} +1 -1
  6. package/dist/{audio-DHuEMOtQ.d.mts → audio-C1A4N3Wh.d.mts} +3 -3
  7. package/dist/{audio-DHuEMOtQ.d.mts.map → audio-C1A4N3Wh.d.mts.map} +1 -1
  8. package/dist/{audio-Bb31xJuX.mjs → audio-CMHpfOoS.mjs} +4 -4
  9. package/dist/{audio-Bb31xJuX.mjs.map → audio-CMHpfOoS.mjs.map} +1 -1
  10. package/dist/cli.d.mts +1 -0
  11. package/dist/cli.mjs +221 -0
  12. package/dist/cli.mjs.map +1 -0
  13. package/dist/components/index.d.mts +12 -0
  14. package/dist/components/index.mjs +49 -0
  15. package/dist/{dist-CspOG4V1.mjs → dist-BBUrGwqd.mjs} +24 -3
  16. package/dist/{dist-CspOG4V1.mjs.map → dist-BBUrGwqd.mjs.map} +1 -1
  17. package/dist/{dist-SB9qfAko.mjs → dist-CQZ5_60A.mjs} +2 -2
  18. package/dist/{dist-SB9qfAko.mjs.map → dist-CQZ5_60A.mjs.map} +1 -1
  19. package/dist/{dist-0QGroRYZ.mjs → dist-C_SMBmHr.mjs} +1 -1
  20. package/dist/{dist-0QGroRYZ.mjs.map → dist-C_SMBmHr.mjs.map} +1 -1
  21. package/dist/{dist-ClyC_d66.mjs → dist-DFeF-ygz.mjs} +117 -40
  22. package/dist/dist-DFeF-ygz.mjs.map +1 -0
  23. package/dist/{dist-CxGfrigI.mjs → dist-QMVy31gR.mjs} +23 -4
  24. package/dist/dist-QMVy31gR.mjs.map +1 -0
  25. package/dist/effects/index.d.mts +5 -5
  26. package/dist/effects/index.mjs +36 -36
  27. package/dist/{effects-BOk_2kzM.mjs → effects-mRe-rLte.mjs} +71 -3
  28. package/dist/effects-mRe-rLte.mjs.map +1 -0
  29. package/dist/fonts/index.d.mts +1 -1
  30. package/dist/fonts/index.mjs +3 -3
  31. package/dist/{fonts-CsMDfqv9.mjs → fonts-wI3CUYHM.mjs} +2 -2
  32. package/dist/{fonts-CsMDfqv9.mjs.map → fonts-wI3CUYHM.mjs.map} +1 -1
  33. package/dist/{gpu-DtT5I4ME.mjs → gpu-I5fdrflL.mjs} +2 -2
  34. package/dist/{gpu-DtT5I4ME.mjs.map → gpu-I5fdrflL.mjs.map} +1 -1
  35. package/dist/{index-BTPLbjt4.d.mts → index-B-KVpJMb.d.mts} +2 -2
  36. package/dist/{index-BTPLbjt4.d.mts.map → index-B-KVpJMb.d.mts.map} +1 -1
  37. package/dist/index-BJe_GEyj.d.mts +5968 -0
  38. package/dist/index-BJe_GEyj.d.mts.map +1 -0
  39. package/dist/{index-D-l-yCv6.d.mts → index-BpdVOlsV.d.mts} +3 -3
  40. package/dist/{index-D-l-yCv6.d.mts.map → index-BpdVOlsV.d.mts.map} +1 -1
  41. package/dist/index-C2f4Hm-p.d.mts +89 -0
  42. package/dist/index-C2f4Hm-p.d.mts.map +1 -0
  43. package/dist/{index-B-55dXDu.d.mts → index-CalU-mpZ.d.mts} +1 -1
  44. package/dist/{index-B-55dXDu.d.mts.map → index-CalU-mpZ.d.mts.map} +1 -1
  45. package/dist/{index-B2Xesdug.d.mts → index-D6-lH1Mx.d.mts} +477 -327
  46. package/dist/index-D6-lH1Mx.d.mts.map +1 -0
  47. package/dist/{index-B3-gwjK6.d.mts → index-DNKM3to5.d.mts} +3 -3
  48. package/dist/{index-B3-gwjK6.d.mts.map → index-DNKM3to5.d.mts.map} +1 -1
  49. package/dist/{index-DujpgUUq.d.mts → index-fymoeGNW.d.mts} +245 -152
  50. package/dist/index-fymoeGNW.d.mts.map +1 -0
  51. package/dist/index.d.mts +13 -4593
  52. package/dist/index.mjs +59 -6654
  53. package/dist/preview/index.d.mts +4 -90
  54. package/dist/preview/index.mjs +12 -12
  55. package/dist/{preview-DyGw2b3_.mjs → preview--DEgW67P.mjs} +3 -3
  56. package/dist/{preview-DyGw2b3_.mjs.map → preview--DEgW67P.mjs.map} +1 -1
  57. package/dist/preview-engine/audio.mjs +1 -1
  58. package/dist/preview-engine/{chunk-FHKBALVI.mjs → chunk-24H3QVYB.mjs} +1 -1
  59. package/dist/preview-engine/{chunk-SWXVITCQ.mjs → chunk-25T3M6MI.mjs} +1 -1
  60. package/dist/preview-engine/chunk-2AV7HRRL.mjs +174 -0
  61. package/dist/preview-engine/{chunk-HNNIVJ2K.mjs → chunk-4EOS6ZWW.mjs} +1 -1
  62. package/dist/preview-engine/{chunk-67SSYK7D.mjs → chunk-4UI3S3UG.mjs} +1 -1
  63. package/dist/preview-engine/{chunk-LLDIDE5Q.mjs → chunk-5N3GW3NE.mjs} +1 -1
  64. package/dist/preview-engine/{chunk-KAQUNOVB.mjs → chunk-5P764TTF.mjs} +1 -1
  65. package/dist/preview-engine/{chunk-WJJIISQJ.mjs → chunk-7BUKVFWB.mjs} +1 -1
  66. package/dist/preview-engine/{chunk-HUKAC5MJ.mjs → chunk-7EHUT5A3.mjs} +1 -1
  67. package/dist/preview-engine/{chunk-34ACEGNW.mjs → chunk-7HTTYP6T.mjs} +1 -1
  68. package/dist/preview-engine/{chunk-6K7US2WD.mjs → chunk-7JYMNGNK.mjs} +1 -1
  69. package/dist/preview-engine/{chunk-NRAZV5OA.mjs → chunk-A5OUYV3I.mjs} +1 -1
  70. package/dist/preview-engine/{chunk-VONDRBA2.mjs → chunk-AJLPRIXB.mjs} +1 -1
  71. package/dist/preview-engine/{chunk-HDGFNBYK.mjs → chunk-ANZDLSSM.mjs} +1 -1
  72. package/dist/preview-engine/{chunk-FFMPC7QA.mjs → chunk-C2TA65CH.mjs} +1 -1
  73. package/dist/preview-engine/{chunk-7QJUC6YY.mjs → chunk-CBQOJAIS.mjs} +14 -14
  74. package/dist/preview-engine/{chunk-GMGGDADT.mjs → chunk-CEIOI4HO.mjs} +1 -1
  75. package/dist/preview-engine/{chunk-RHRMEBNK.mjs → chunk-DKXKOUQR.mjs} +1 -1
  76. package/dist/preview-engine/chunk-DPIED3EG.mjs +1 -0
  77. package/dist/preview-engine/{chunk-QJZKIY4H.mjs → chunk-ENS4KUJV.mjs} +1 -1
  78. package/dist/preview-engine/{chunk-Z6UKUHPU.mjs → chunk-HXHP3UHZ.mjs} +1 -1
  79. package/dist/preview-engine/chunk-I6E3AKLS.mjs +1 -0
  80. package/dist/preview-engine/{chunk-2DDBSI75.mjs → chunk-ILKBHDFB.mjs} +1 -1
  81. package/dist/preview-engine/{chunk-O6NRIXS5.mjs → chunk-JMOJKT5W.mjs} +1 -1
  82. package/dist/preview-engine/{chunk-PZF6T7DM.mjs → chunk-KKBJ7X3T.mjs} +1 -1
  83. package/dist/preview-engine/{chunk-NNQUIVTE.mjs → chunk-KRVXRP5F.mjs} +1 -1
  84. package/dist/preview-engine/{chunk-FLPSWDX4.mjs → chunk-MJN3LOZ7.mjs} +1 -1
  85. package/dist/preview-engine/{chunk-GQLZXCW3.mjs → chunk-MLW6Y4RN.mjs} +1 -1
  86. package/dist/preview-engine/{chunk-KT35IPCF.mjs → chunk-MRDJNDYV.mjs} +1 -1
  87. package/dist/preview-engine/{chunk-RKVB47IA.mjs → chunk-MZAAJXUL.mjs} +1 -1
  88. package/dist/preview-engine/{chunk-FFGBUS76.mjs → chunk-NB55FW7D.mjs} +1 -1
  89. package/dist/preview-engine/{chunk-JX22B2FS.mjs → chunk-NG5WSR3A.mjs} +1 -1
  90. package/dist/preview-engine/{chunk-XOEEGC4Z.mjs → chunk-NVTPJUNG.mjs} +1 -1
  91. package/dist/preview-engine/{chunk-ZKPQGQE3.mjs → chunk-NXKXOSE6.mjs} +1 -1
  92. package/dist/preview-engine/{chunk-WCZ5DKZ4.mjs → chunk-NYEDWUFS.mjs} +1 -1
  93. package/dist/preview-engine/{chunk-WACWOKEP.mjs → chunk-O6LGKH4S.mjs} +1 -1
  94. package/dist/preview-engine/chunk-OLLY3Q7M.mjs +1 -0
  95. package/dist/preview-engine/{chunk-A5OVKZ52.mjs → chunk-OOJAQBS3.mjs} +1 -1
  96. package/dist/preview-engine/{chunk-6AEU4DC7.mjs → chunk-PJN7DD55.mjs} +3 -3
  97. package/dist/preview-engine/{chunk-R26SY5D4.mjs → chunk-QCUGWPYP.mjs} +1 -1
  98. package/dist/preview-engine/{chunk-LK3T65RB.mjs → chunk-QQJPPGH5.mjs} +1 -1
  99. package/dist/preview-engine/{chunk-3ACCCPBN.mjs → chunk-QVUT3QNF.mjs} +1 -1
  100. package/dist/preview-engine/chunk-RNFYEZLL.mjs +1 -0
  101. package/dist/preview-engine/{chunk-GNG4YH2W.mjs → chunk-TA5OBYRR.mjs} +1 -1
  102. package/dist/preview-engine/{chunk-NQZKBFC6.mjs → chunk-V6DAD4S4.mjs} +14 -14
  103. package/dist/preview-engine/{chunk-5RQHNB6Y.mjs → chunk-VRNN6H6Y.mjs} +1 -1
  104. package/dist/preview-engine/{chunk-C6Z7H2RZ.mjs → chunk-VTE3IISK.mjs} +1 -1
  105. package/dist/preview-engine/{chunk-RDP6QROU.mjs → chunk-VZGDX7II.mjs} +1 -1
  106. package/dist/preview-engine/{chunk-DOCSX45C.mjs → chunk-XJ4CQIZZ.mjs} +1 -1
  107. package/dist/preview-engine/{chunk-7LVOSEGN.mjs → chunk-XYRBFLFF.mjs} +1 -1
  108. package/dist/preview-engine/{chunk-LIQA5OOU.mjs → chunk-YMQMPBTL.mjs} +1 -1
  109. package/dist/preview-engine/{chunk-G4J662EL.mjs → chunk-Z4ZO4RVT.mjs} +1 -1
  110. package/dist/preview-engine/{chunk-2GIWWDE4.mjs → chunk-ZFURUTVW.mjs} +1 -1
  111. package/dist/preview-engine/effects.mjs +1 -1
  112. package/dist/preview-engine/framefields.mjs +30 -28
  113. package/dist/preview-engine/player.mjs +5 -5
  114. package/dist/preview-engine/renderers-2CBJYNOC.mjs +1 -0
  115. package/dist/preview-engine/renderers-37CN7O4S.mjs +1 -0
  116. package/dist/preview-engine/renderers-3AZ5PMGC.mjs +1 -0
  117. package/dist/preview-engine/renderers-3UGG2IMP.mjs +1 -0
  118. package/dist/preview-engine/renderers-3VXNHICC.mjs +1 -0
  119. package/dist/preview-engine/{renderers-BDWVXYTW.mjs → renderers-3ZYXHUSW.mjs} +1 -1
  120. package/dist/preview-engine/renderers-4FONYSBA.mjs +1 -0
  121. package/dist/preview-engine/renderers-54I5SFXR.mjs +1 -0
  122. package/dist/preview-engine/renderers-5LXALKB4.mjs +1 -0
  123. package/dist/preview-engine/renderers-5TICPV2K.mjs +1 -0
  124. package/dist/preview-engine/renderers-5YS5WHAV.mjs +1 -0
  125. package/dist/preview-engine/renderers-6UTRYW7Z.mjs +1 -0
  126. package/dist/preview-engine/{renderers-R4WMM6IX.mjs → renderers-6ZCFR7BL.mjs} +1 -1
  127. package/dist/preview-engine/renderers-AYEWAW7F.mjs +1 -0
  128. package/dist/preview-engine/{renderers-75TL7RLS.mjs → renderers-BOT4H6KJ.mjs} +1 -1
  129. package/dist/preview-engine/{renderers-NJLGPPX7.mjs → renderers-C44CEIYB.mjs} +1 -1
  130. package/dist/preview-engine/{renderers-UDOBX433.mjs → renderers-C4G7X7LD.mjs} +1 -1
  131. package/dist/preview-engine/renderers-CLCPP3TZ.mjs +1 -0
  132. package/dist/preview-engine/{renderers-RKZGYWDG.mjs → renderers-DBRC3SQE.mjs} +1 -1
  133. package/dist/preview-engine/{renderers-HZHEGONW.mjs → renderers-DSICQMRF.mjs} +1 -1
  134. package/dist/preview-engine/renderers-DTZ2AER5.mjs +1 -0
  135. package/dist/preview-engine/renderers-DUF2CTX6.mjs +1 -0
  136. package/dist/preview-engine/{renderers-AZYJFJLO.mjs → renderers-EOIMH7UE.mjs} +1 -1
  137. package/dist/preview-engine/renderers-EOMMSENQ.mjs +1 -0
  138. package/dist/preview-engine/{renderers-CK3ZL3GM.mjs → renderers-GSRKNT75.mjs} +1 -1
  139. package/dist/preview-engine/renderers-GUJTPO37.mjs +1 -0
  140. package/dist/preview-engine/{renderers-TCNPEANL.mjs → renderers-HE42AT5T.mjs} +1 -1
  141. package/dist/preview-engine/{renderers-GUPEJK3O.mjs → renderers-JM7JEVBN.mjs} +1 -1
  142. package/dist/preview-engine/{renderers-FKH5S64C.mjs → renderers-JRU2GNPE.mjs} +1 -1
  143. package/dist/preview-engine/{renderers-IHRFEZNP.mjs → renderers-K6J77OI5.mjs} +1 -1
  144. package/dist/preview-engine/{renderers-IJDQJEE5.mjs → renderers-L7EITUCD.mjs} +1 -1
  145. package/dist/preview-engine/renderers-LGUHDWWB.mjs +1 -0
  146. package/dist/preview-engine/renderers-LGXD24IJ.mjs +1 -0
  147. package/dist/preview-engine/renderers-MCTBJEL7.mjs +1 -0
  148. package/dist/preview-engine/renderers-OBXNUQMP.mjs +1 -0
  149. package/dist/preview-engine/renderers-OZGEXW2X.mjs +1 -0
  150. package/dist/preview-engine/{renderers-7CPYTCNW.mjs → renderers-PJGFJGJS.mjs} +1 -1
  151. package/dist/preview-engine/{renderers-SW7FIEKO.mjs → renderers-PPGOYOID.mjs} +1 -1
  152. package/dist/preview-engine/renderers-PUJRHBUW.mjs +1 -0
  153. package/dist/preview-engine/renderers-PVUROEGR.mjs +1 -0
  154. package/dist/preview-engine/renderers-QBVVTQV6.mjs +1 -0
  155. package/dist/preview-engine/{renderers-K7B5PQBT.mjs → renderers-QPHOG7QR.mjs} +1 -1
  156. package/dist/preview-engine/{renderers-WR6MKT5S.mjs → renderers-RV62K5JC.mjs} +1 -1
  157. package/dist/preview-engine/{renderers-UMPOVFRA.mjs → renderers-SBX64ERA.mjs} +1 -1
  158. package/dist/preview-engine/renderers-SCJTTOEJ.mjs +1 -0
  159. package/dist/preview-engine/{renderers-FM6A5YR4.mjs → renderers-SD35PBEM.mjs} +1 -1
  160. package/dist/preview-engine/{renderers-LVLVJHOL.mjs → renderers-SIHL5GLI.mjs} +1 -1
  161. package/dist/preview-engine/renderers-SNUVYUDE.mjs +1 -0
  162. package/dist/preview-engine/renderers-SPW4SWZ2.mjs +228 -0
  163. package/dist/preview-engine/renderers-SRKCF3LD.mjs +1 -0
  164. package/dist/preview-engine/{renderers-GAIUTII6.mjs → renderers-T3S7QVM4.mjs} +1 -1
  165. package/dist/preview-engine/renderers-TBP2EKJ4.mjs +1 -0
  166. package/dist/preview-engine/renderers-UN6YLFHW.mjs +1 -0
  167. package/dist/preview-engine/renderers-UTWKTBZI.mjs +1 -0
  168. package/dist/preview-engine/{renderers-6JZ4HYIU.mjs → renderers-V5MJJH33.mjs} +1 -1
  169. package/dist/preview-engine/{renderers-XKHNBARD.mjs → renderers-V67L3MCY.mjs} +1 -1
  170. package/dist/preview-engine/{renderers-FADYGSLK.mjs → renderers-VYK2BJID.mjs} +1 -1
  171. package/dist/preview-engine/renderers-W6762MNW.mjs +1 -0
  172. package/dist/preview-engine/renderers-W6ZTVA2I.mjs +1 -0
  173. package/dist/preview-engine/renderers-WDV7ENJA.mjs +1 -0
  174. package/dist/preview-engine/{renderers-MBSOKWKB.mjs → renderers-WPBTAU2E.mjs} +1 -1
  175. package/dist/preview-engine/renderers-XP7YFWBV.mjs +1 -0
  176. package/dist/preview-engine/renderers-XVUOO7B6.mjs +1 -0
  177. package/dist/preview-engine/renderers-YPFM5UJO.mjs +1 -0
  178. package/dist/preview-engine/renderers-YW3LBALW.mjs +1 -0
  179. package/dist/preview-engine/{renderers-4AJWVIJG.mjs → renderers-ZAPIBQZC.mjs} +1 -1
  180. package/dist/preview-engine/renderers-ZRR4TUVQ.mjs +1 -0
  181. package/dist/preview-engine/{renderers-TY7DVI7E.mjs → renderers-ZVKXIQ3E.mjs} +1 -1
  182. package/dist/preview-engine/signals.mjs +1 -1
  183. package/dist/{program-C2cJayA1.mjs → program-CEHCeDpG.mjs} +204 -61
  184. package/dist/program-CEHCeDpG.mjs.map +1 -0
  185. package/dist/registry/annotate.json +30 -0
  186. package/dist/registry/app-frame.json +38 -0
  187. package/dist/registry/backdrop.json +37 -0
  188. package/dist/registry/before-after.json +33 -0
  189. package/dist/registry/callout.json +30 -0
  190. package/dist/registry/captions.json +37 -0
  191. package/dist/registry/card-stack.json +30 -0
  192. package/dist/registry/chat-thread.json +32 -0
  193. package/dist/registry/code-block.json +45 -0
  194. package/dist/registry/confetti.json +29 -0
  195. package/dist/registry/counter.json +36 -0
  196. package/dist/registry/device-frame.json +36 -0
  197. package/dist/registry/focus-zoom.json +32 -0
  198. package/dist/registry/index.json +408 -0
  199. package/dist/registry/logo-reveal.json +37 -0
  200. package/dist/registry/lower-third.json +34 -0
  201. package/dist/registry/meter.json +35 -0
  202. package/dist/registry/mouse-pointer.json +44 -0
  203. package/dist/registry/selection.json +31 -0
  204. package/dist/registry/spotlight.json +28 -0
  205. package/dist/registry/terminal.json +40 -0
  206. package/dist/registry/text-reveal.json +39 -0
  207. package/dist/registry/title-card.json +34 -0
  208. package/dist/registry/toasts.json +28 -0
  209. package/dist/registry/touch-point.json +33 -0
  210. package/dist/registry/waveform.json +29 -0
  211. package/dist/registry/word-cycle.json +32 -0
  212. package/dist/renderer/index.d.mts +2 -2
  213. package/dist/renderer/index.mjs +3 -3
  214. package/dist/{renderer-BMKFzfSQ.mjs → renderer--wMbTRFg.mjs} +4 -4
  215. package/dist/{renderer-BMKFzfSQ.mjs.map → renderer--wMbTRFg.mjs.map} +1 -1
  216. package/dist/{renderer-CDUxAtsk.mjs → renderer-3aLw-la1.mjs} +5 -5
  217. package/dist/{renderer-CDUxAtsk.mjs.map → renderer-3aLw-la1.mjs.map} +1 -1
  218. package/dist/renderer-7vCBWQVP.mjs +5 -0
  219. package/dist/{renderer-DILUmb-e.mjs → renderer-AkRxGnFU.mjs} +5 -5
  220. package/dist/{renderer-DILUmb-e.mjs.map → renderer-AkRxGnFU.mjs.map} +1 -1
  221. package/dist/renderer-B-XgGMCq.mjs +5 -0
  222. package/dist/renderer-B0yabPlf.mjs +5 -0
  223. package/dist/{renderer-CoWfz-9p.mjs → renderer-B1yPBJQc.mjs} +74 -74
  224. package/dist/{renderer-CoWfz-9p.mjs.map → renderer-B1yPBJQc.mjs.map} +1 -1
  225. package/dist/{renderer-BPixWnmY.mjs → renderer-B41jTHFs.mjs} +6 -6
  226. package/dist/{renderer-BPixWnmY.mjs.map → renderer-B41jTHFs.mjs.map} +1 -1
  227. package/dist/{renderer-BTd2sWDC.mjs → renderer-B5HG6Czb.mjs} +4 -4
  228. package/dist/{renderer-BTd2sWDC.mjs.map → renderer-B5HG6Czb.mjs.map} +1 -1
  229. package/dist/{renderer-gRMkQabv.mjs → renderer-B5oJN3A5.mjs} +5 -5
  230. package/dist/{renderer-gRMkQabv.mjs.map → renderer-B5oJN3A5.mjs.map} +1 -1
  231. package/dist/renderer-B9MYevrF.mjs +5 -0
  232. package/dist/renderer-BA2Ko4H1.mjs +3 -0
  233. package/dist/renderer-BCGosndi.mjs +5 -0
  234. package/dist/{renderer-CzRqzp8P.mjs → renderer-BEmDSqXg.mjs} +4 -4
  235. package/dist/{renderer-CzRqzp8P.mjs.map → renderer-BEmDSqXg.mjs.map} +1 -1
  236. package/dist/{renderer-Csss5GFQ.mjs → renderer-BG8jNTHr.mjs} +3 -3
  237. package/dist/{renderer-Csss5GFQ.mjs.map → renderer-BG8jNTHr.mjs.map} +1 -1
  238. package/dist/{renderer-BTigwMQp.mjs → renderer-BMnrBPh1.mjs} +4 -4
  239. package/dist/{renderer-BTigwMQp.mjs.map → renderer-BMnrBPh1.mjs.map} +1 -1
  240. package/dist/{renderer-yyUpCeWb.mjs → renderer-BORzpTQP.mjs} +3 -3
  241. package/dist/{renderer-yyUpCeWb.mjs.map → renderer-BORzpTQP.mjs.map} +1 -1
  242. package/dist/{renderer-RDCax5CK.mjs → renderer-BWtI3wKu.mjs} +4 -4
  243. package/dist/{renderer-RDCax5CK.mjs.map → renderer-BWtI3wKu.mjs.map} +1 -1
  244. package/dist/renderer-BXTHDEnd.mjs +5 -0
  245. package/dist/{renderer-G1pMJH5C.mjs → renderer-Baw30QRI.mjs} +3 -3
  246. package/dist/{renderer-G1pMJH5C.mjs.map → renderer-Baw30QRI.mjs.map} +1 -1
  247. package/dist/renderer-BbJoZ-zL.mjs +5 -0
  248. package/dist/{renderer-Dwu_zfdL.mjs → renderer-BbdgwkDB.mjs} +5 -5
  249. package/dist/{renderer-Dwu_zfdL.mjs.map → renderer-BbdgwkDB.mjs.map} +1 -1
  250. package/dist/{renderer-D6BXR7P4.mjs → renderer-BgOEqtZu.mjs} +3 -3
  251. package/dist/{renderer-D6BXR7P4.mjs.map → renderer-BgOEqtZu.mjs.map} +1 -1
  252. package/dist/{renderer-NizuhHsH.mjs → renderer-Bv0-WmP4.mjs} +4 -4
  253. package/dist/{renderer-NizuhHsH.mjs.map → renderer-Bv0-WmP4.mjs.map} +1 -1
  254. package/dist/renderer-BvcqncI2.mjs +3 -0
  255. package/dist/{renderer-BljMOvry.mjs → renderer-Bvhj-05p.mjs} +4 -4
  256. package/dist/{renderer-BljMOvry.mjs.map → renderer-Bvhj-05p.mjs.map} +1 -1
  257. package/dist/renderer-BxAZKOQy.mjs +5 -0
  258. package/dist/{renderer-92eLJg24.mjs → renderer-BxDhx8KM.mjs} +5 -5
  259. package/dist/{renderer-92eLJg24.mjs.map → renderer-BxDhx8KM.mjs.map} +1 -1
  260. package/dist/renderer-BxgkH__7.mjs +5 -0
  261. package/dist/{renderer-7y5Cj8rV.mjs → renderer-C1XZ0fQ-.mjs} +4 -4
  262. package/dist/{renderer-7y5Cj8rV.mjs.map → renderer-C1XZ0fQ-.mjs.map} +1 -1
  263. package/dist/{renderer-UcdqCarR.mjs → renderer-C8KEfda3.mjs} +4 -4
  264. package/dist/{renderer-UcdqCarR.mjs.map → renderer-C8KEfda3.mjs.map} +1 -1
  265. package/dist/{renderer-BSitV56h.mjs → renderer-C9r0-KY4.mjs} +10 -1
  266. package/dist/{renderer-BSitV56h.mjs.map → renderer-C9r0-KY4.mjs.map} +1 -1
  267. package/dist/{renderer-B3e4LSE6.mjs → renderer-CF25oNd3.mjs} +5 -5
  268. package/dist/{renderer-B3e4LSE6.mjs.map → renderer-CF25oNd3.mjs.map} +1 -1
  269. package/dist/renderer-CFEvikBH.mjs +5 -0
  270. package/dist/{renderer-DzSMeQBC.mjs → renderer-CHFUJyul.mjs} +5 -5
  271. package/dist/{renderer-DzSMeQBC.mjs.map → renderer-CHFUJyul.mjs.map} +1 -1
  272. package/dist/{renderer-ByCX4JYg.mjs → renderer-CKGOKBZi.mjs} +5 -5
  273. package/dist/{renderer-ByCX4JYg.mjs.map → renderer-CKGOKBZi.mjs.map} +1 -1
  274. package/dist/{renderer-If8JOkBi.mjs → renderer-CNs1vBUy.mjs} +4 -4
  275. package/dist/{renderer-If8JOkBi.mjs.map → renderer-CNs1vBUy.mjs.map} +1 -1
  276. package/dist/{index-B1XcyMm8.d.mts → renderer-COXKPofU.d.mts} +18 -18
  277. package/dist/renderer-COXKPofU.d.mts.map +1 -0
  278. package/dist/{renderer-CNuVJ4nG.mjs → renderer-COZCwiIr.mjs} +3 -3
  279. package/dist/{renderer-CNuVJ4nG.mjs.map → renderer-COZCwiIr.mjs.map} +1 -1
  280. package/dist/{renderer-V5uEyDFz.mjs → renderer-CPmoA87K.mjs} +4 -4
  281. package/dist/{renderer-V5uEyDFz.mjs.map → renderer-CPmoA87K.mjs.map} +1 -1
  282. package/dist/{renderer-Cw6Sagih.mjs → renderer-CYZb9uoO.mjs} +4 -4
  283. package/dist/{renderer-Cw6Sagih.mjs.map → renderer-CYZb9uoO.mjs.map} +1 -1
  284. package/dist/renderer-C_u9B-_7.mjs +5 -0
  285. package/dist/{renderer-BwpgDLYO.mjs → renderer-CaUJlGoB.mjs} +5 -5
  286. package/dist/{renderer-BwpgDLYO.mjs.map → renderer-CaUJlGoB.mjs.map} +1 -1
  287. package/dist/{renderer-DjwITRvg.mjs → renderer-CcS3Zntx.mjs} +3 -3
  288. package/dist/{renderer-BDCy8NgT.mjs → renderer-CddpdCL3.mjs} +4 -4
  289. package/dist/{renderer-BDCy8NgT.mjs.map → renderer-CddpdCL3.mjs.map} +1 -1
  290. package/dist/{renderer-BV48PFjX.mjs → renderer-Cg-d78nf.mjs} +192 -27
  291. package/dist/renderer-Cg-d78nf.mjs.map +1 -0
  292. package/dist/renderer-Cgx_qzGT.mjs +5 -0
  293. package/dist/{renderer-DScyMHpJ.mjs → renderer-ChNCtBMR.mjs} +3 -3
  294. package/dist/{renderer-DScyMHpJ.mjs.map → renderer-ChNCtBMR.mjs.map} +1 -1
  295. package/dist/renderer-ClOkY1Ny.mjs +5 -0
  296. package/dist/{renderer-DyyIXLQK.mjs → renderer-CrU0kbiB.mjs} +5 -5
  297. package/dist/{renderer-DyyIXLQK.mjs.map → renderer-CrU0kbiB.mjs.map} +1 -1
  298. package/dist/renderer-CtDub_ig.mjs +5 -0
  299. package/dist/{renderer-ut1GqW89.mjs → renderer-CyhSWLKq.mjs} +4 -4
  300. package/dist/{renderer-ut1GqW89.mjs.map → renderer-CyhSWLKq.mjs.map} +1 -1
  301. package/dist/{renderer-DaSxr9fM.mjs → renderer-D-bmSKv4.mjs} +5 -5
  302. package/dist/{renderer-DaSxr9fM.mjs.map → renderer-D-bmSKv4.mjs.map} +1 -1
  303. package/dist/renderer-D0duAhCo.mjs +5 -0
  304. package/dist/{renderer-BBAbZfBl.mjs → renderer-D2tdNoM6.mjs} +6 -6
  305. package/dist/{renderer-BBAbZfBl.mjs.map → renderer-D2tdNoM6.mjs.map} +1 -1
  306. package/dist/renderer-D4wM1dqg.mjs +6 -0
  307. package/dist/renderer-D5U3d09T.mjs +5 -0
  308. package/dist/{renderer-B1QREs7J.mjs → renderer-D86Y5YYi.mjs} +5 -5
  309. package/dist/{renderer-B1QREs7J.mjs.map → renderer-D86Y5YYi.mjs.map} +1 -1
  310. package/dist/{renderer-Cbsnj9vV.mjs → renderer-D8D7dKxq.mjs} +4 -4
  311. package/dist/{renderer-Cbsnj9vV.mjs.map → renderer-D8D7dKxq.mjs.map} +1 -1
  312. package/dist/{renderer-D0vom99O.mjs → renderer-DATAjI6x.mjs} +3 -3
  313. package/dist/{renderer-D0vom99O.mjs.map → renderer-DATAjI6x.mjs.map} +1 -1
  314. package/dist/{renderer-X0eKSD7v.mjs → renderer-DGhCIVI4.mjs} +5 -5
  315. package/dist/{renderer-X0eKSD7v.mjs.map → renderer-DGhCIVI4.mjs.map} +1 -1
  316. package/dist/{renderer-CJ4bxCld.mjs → renderer-DILjs1tz.mjs} +3 -3
  317. package/dist/{renderer-DjBCWxYy.mjs → renderer-DIyzKouj.mjs} +4 -4
  318. package/dist/{renderer-DjBCWxYy.mjs.map → renderer-DIyzKouj.mjs.map} +1 -1
  319. package/dist/{renderer-BwwZB2mt.mjs → renderer-DJ78Bgv7.mjs} +5 -5
  320. package/dist/{renderer-BwwZB2mt.mjs.map → renderer-DJ78Bgv7.mjs.map} +1 -1
  321. package/dist/renderer-DKqFxYfP.mjs +5 -0
  322. package/dist/renderer-DQZNI1zf.mjs +5 -0
  323. package/dist/{renderer-B4RpSd4A.mjs → renderer-DVnrUnkh.mjs} +3 -3
  324. package/dist/{renderer-B4RpSd4A.mjs.map → renderer-DVnrUnkh.mjs.map} +1 -1
  325. package/dist/{renderer-C1E5Rt5q.mjs → renderer-DWtquP0s.mjs} +4 -4
  326. package/dist/{renderer-C1E5Rt5q.mjs.map → renderer-DWtquP0s.mjs.map} +1 -1
  327. package/dist/renderer-DXITcVbS.mjs +5 -0
  328. package/dist/renderer-DY1QgdDC.mjs +5 -0
  329. package/dist/renderer-DYEz06xn.mjs +5 -0
  330. package/dist/renderer-D_2OrSMc.mjs +5 -0
  331. package/dist/renderer-DcCl9CO6.mjs +5 -0
  332. package/dist/{renderer-rXgqgAjw.mjs → renderer-DetTl6bQ.mjs} +3 -3
  333. package/dist/{renderer-rXgqgAjw.mjs.map → renderer-DetTl6bQ.mjs.map} +1 -1
  334. package/dist/{renderer-BjCHW9Jp.mjs → renderer-DfxwJVjW.mjs} +3 -3
  335. package/dist/{renderer-Cv4lchUP.mjs → renderer-DhQZiRL3.mjs} +5 -5
  336. package/dist/{renderer-Cv4lchUP.mjs.map → renderer-DhQZiRL3.mjs.map} +1 -1
  337. package/dist/{renderer-EZp_IIPK.mjs → renderer-Djn4BYtz.mjs} +5 -5
  338. package/dist/{renderer-EZp_IIPK.mjs.map → renderer-Djn4BYtz.mjs.map} +1 -1
  339. package/dist/{renderer-Bkrk0M0r.mjs → renderer-DkS6cblv.mjs} +5 -5
  340. package/dist/{renderer-Bkrk0M0r.mjs.map → renderer-DkS6cblv.mjs.map} +1 -1
  341. package/dist/{renderer-DKfwlE4e.mjs → renderer-DkeJefRk.mjs} +5 -5
  342. package/dist/{renderer-DKfwlE4e.mjs.map → renderer-DkeJefRk.mjs.map} +1 -1
  343. package/dist/renderer-DotZXhrh.mjs +5 -0
  344. package/dist/renderer-DpYDVTzs.mjs +5 -0
  345. package/dist/renderer-DpZxLVQF.mjs +5 -0
  346. package/dist/{renderer-Za4D9hej.mjs → renderer-DqabtpGN.mjs} +5 -5
  347. package/dist/{renderer-Za4D9hej.mjs.map → renderer-DqabtpGN.mjs.map} +1 -1
  348. package/dist/{renderer-DaqWTx7s.mjs → renderer-DqdD8gzX.mjs} +5 -5
  349. package/dist/{renderer-DaqWTx7s.mjs.map → renderer-DqdD8gzX.mjs.map} +1 -1
  350. package/dist/{renderer-DZJJHCtm.mjs → renderer-DstROEvX.mjs} +4 -4
  351. package/dist/{renderer-DZJJHCtm.mjs.map → renderer-DstROEvX.mjs.map} +1 -1
  352. package/dist/{renderer-BjvNEbML.mjs → renderer-DuawUm8D.mjs} +3 -3
  353. package/dist/{renderer-BjvNEbML.mjs.map → renderer-DuawUm8D.mjs.map} +1 -1
  354. package/dist/{renderer-CDOobs18.mjs → renderer-DvIWhLnY.mjs} +5 -5
  355. package/dist/{renderer-CDOobs18.mjs.map → renderer-DvIWhLnY.mjs.map} +1 -1
  356. package/dist/renderer-DvJbouz0.mjs +5 -0
  357. package/dist/{renderer-apeDrNXM.mjs → renderer-Dw2ZFwDT.mjs} +5 -5
  358. package/dist/{renderer-apeDrNXM.mjs.map → renderer-Dw2ZFwDT.mjs.map} +1 -1
  359. package/dist/{renderer-CxqPeATd.d.mts → renderer-Dx28wTi1.d.mts} +2 -2
  360. package/dist/{renderer-CxqPeATd.d.mts.map → renderer-Dx28wTi1.d.mts.map} +1 -1
  361. package/dist/{renderer-CcoUNzlQ.mjs → renderer-DxLkZGUK.mjs} +4 -4
  362. package/dist/{renderer-CcoUNzlQ.mjs.map → renderer-DxLkZGUK.mjs.map} +1 -1
  363. package/dist/{renderer-CeiCJCg4.mjs → renderer-GY7D1oAn.mjs} +4 -4
  364. package/dist/{renderer-CeiCJCg4.mjs.map → renderer-GY7D1oAn.mjs.map} +1 -1
  365. package/dist/{renderer-D2-V9nQq.mjs → renderer-JaSEubRQ.mjs} +4 -4
  366. package/dist/{renderer-D2-V9nQq.mjs.map → renderer-JaSEubRQ.mjs.map} +1 -1
  367. package/dist/{renderer-CqtIbe5n.mjs → renderer-MrvH4L7b.mjs} +4 -4
  368. package/dist/{renderer-CqtIbe5n.mjs.map → renderer-MrvH4L7b.mjs.map} +1 -1
  369. package/dist/{renderer-DO5r7ZV-.mjs → renderer-PNIgmWft.mjs} +2 -2
  370. package/dist/{renderer-DO5r7ZV-.mjs.map → renderer-PNIgmWft.mjs.map} +1 -1
  371. package/dist/renderer-S305xm67.mjs +5 -0
  372. package/dist/renderer-TgUloP-y.mjs +5 -0
  373. package/dist/{renderer-DFJQ6v0F.mjs → renderer-VcPoZAM2.mjs} +3 -3
  374. package/dist/{renderer-DFJQ6v0F.mjs.map → renderer-VcPoZAM2.mjs.map} +1 -1
  375. package/dist/{renderer-DcBGYN3V.mjs → renderer-ZGJWn-_L.mjs} +4 -4
  376. package/dist/{renderer-DcBGYN3V.mjs.map → renderer-ZGJWn-_L.mjs.map} +1 -1
  377. package/dist/{renderer-DTw-W0Yz.mjs → renderer-aXwxbXcE.mjs} +5 -5
  378. package/dist/{renderer-DTw-W0Yz.mjs.map → renderer-aXwxbXcE.mjs.map} +1 -1
  379. package/dist/renderer-gZ5SMvbU.mjs +5 -0
  380. package/dist/{renderer-CCGzT6bX.mjs → renderer-gniIzAsk.mjs} +3 -3
  381. package/dist/renderer-kEpx7pUy.mjs +5 -0
  382. package/dist/{renderer-BkWd4-YO.mjs → renderer-mSlww_MA.mjs} +4 -4
  383. package/dist/{renderer-BkWd4-YO.mjs.map → renderer-mSlww_MA.mjs.map} +1 -1
  384. package/dist/{renderer-B0gPTXhe.mjs → renderer-mWfgWQy_.mjs} +5 -5
  385. package/dist/{renderer-B0gPTXhe.mjs.map → renderer-mWfgWQy_.mjs.map} +1 -1
  386. package/dist/{renderer-BajIOuZH.mjs → renderer-nLPfvY3-.mjs} +2 -2
  387. package/dist/{renderer-BajIOuZH.mjs.map → renderer-nLPfvY3-.mjs.map} +1 -1
  388. package/dist/renderer-oPWZmzua.mjs +5 -0
  389. package/dist/{renderer--ruTGlPo.mjs → renderer-pcpDwv3K.mjs} +4 -4
  390. package/dist/{renderer--ruTGlPo.mjs.map → renderer-pcpDwv3K.mjs.map} +1 -1
  391. package/dist/renderer-qCNOffLy.mjs +5 -0
  392. package/dist/{renderer-DwVOxdzC.mjs → renderer-vPJRRVNN.mjs} +5 -5
  393. package/dist/{renderer-DwVOxdzC.mjs.map → renderer-vPJRRVNN.mjs.map} +1 -1
  394. package/dist/{renderer-JvcR1vYI.mjs → renderer-vQrX7Qsw.mjs} +3 -3
  395. package/dist/{renderer-JvcR1vYI.mjs.map → renderer-vQrX7Qsw.mjs.map} +1 -1
  396. package/dist/{renderer-Bi6W4RSn.mjs → renderer-wld_ZR0-.mjs} +3 -3
  397. package/dist/{renderer-Bi6W4RSn.mjs.map → renderer-wld_ZR0-.mjs.map} +1 -1
  398. package/dist/renderer-xEVoeq12.mjs +5 -0
  399. package/dist/renderer-ypw8fzVD.mjs +5 -0
  400. package/dist/{renderers-CP8CFWDs-DzGXDs5p.mjs → renderers-CP8CFWDs-DgKdGMvS.mjs} +4 -4
  401. package/dist/{renderers-CP8CFWDs-DzGXDs5p.mjs.map → renderers-CP8CFWDs-DgKdGMvS.mjs.map} +1 -1
  402. package/dist/signals/index.d.mts +6 -6
  403. package/dist/signals/index.mjs +8 -8
  404. package/dist/{signals-C5nOrRmn.mjs → signals-Cf9mfNGE.mjs} +4 -4
  405. package/dist/{signals-C5nOrRmn.mjs.map → signals-Cf9mfNGE.mjs.map} +1 -1
  406. package/dist/src-CU_7ui4I.mjs +8373 -0
  407. package/dist/src-CU_7ui4I.mjs.map +1 -0
  408. package/package.json +68 -58
  409. package/dist/dist-ClyC_d66.mjs.map +0 -1
  410. package/dist/dist-CxGfrigI.mjs.map +0 -1
  411. package/dist/effects-BOk_2kzM.mjs.map +0 -1
  412. package/dist/index-B1XcyMm8.d.mts.map +0 -1
  413. package/dist/index-B2Xesdug.d.mts.map +0 -1
  414. package/dist/index-DujpgUUq.d.mts.map +0 -1
  415. package/dist/index.d.mts.map +0 -1
  416. package/dist/index.mjs.map +0 -1
  417. package/dist/preview/index.d.mts.map +0 -1
  418. package/dist/preview-engine/chunk-AQNTIWTK.mjs +0 -1
  419. package/dist/preview-engine/chunk-CPFPY35C.mjs +0 -1
  420. package/dist/preview-engine/chunk-UZBVCYRY.mjs +0 -174
  421. package/dist/preview-engine/chunk-XDMZP3E2.mjs +0 -1
  422. package/dist/preview-engine/chunk-YKMBRUAE.mjs +0 -1
  423. package/dist/preview-engine/renderers-3CXFGRE7.mjs +0 -1
  424. package/dist/preview-engine/renderers-3FC3WCJN.mjs +0 -1
  425. package/dist/preview-engine/renderers-3S2FM33K.mjs +0 -1
  426. package/dist/preview-engine/renderers-53ZTBI3C.mjs +0 -1
  427. package/dist/preview-engine/renderers-5USBVCMQ.mjs +0 -1
  428. package/dist/preview-engine/renderers-65G6BLEA.mjs +0 -1
  429. package/dist/preview-engine/renderers-7XU7BXE7.mjs +0 -1
  430. package/dist/preview-engine/renderers-BFXM32BC.mjs +0 -1
  431. package/dist/preview-engine/renderers-CDXHCWWK.mjs +0 -1
  432. package/dist/preview-engine/renderers-CLUN7WU6.mjs +0 -1
  433. package/dist/preview-engine/renderers-DTB4HEXU.mjs +0 -1
  434. package/dist/preview-engine/renderers-EFKNRLYF.mjs +0 -1
  435. package/dist/preview-engine/renderers-EI6MQ2EL.mjs +0 -228
  436. package/dist/preview-engine/renderers-G2K7ZA5O.mjs +0 -1
  437. package/dist/preview-engine/renderers-GTUO5LIV.mjs +0 -1
  438. package/dist/preview-engine/renderers-HSJIVP57.mjs +0 -1
  439. package/dist/preview-engine/renderers-IHACBWXI.mjs +0 -1
  440. package/dist/preview-engine/renderers-IO3BVVJ7.mjs +0 -1
  441. package/dist/preview-engine/renderers-IR772VSN.mjs +0 -1
  442. package/dist/preview-engine/renderers-L6TRJOXA.mjs +0 -1
  443. package/dist/preview-engine/renderers-N6H6RSF2.mjs +0 -1
  444. package/dist/preview-engine/renderers-O5NFQYRQ.mjs +0 -1
  445. package/dist/preview-engine/renderers-O6V3OEKX.mjs +0 -1
  446. package/dist/preview-engine/renderers-OODA27UH.mjs +0 -1
  447. package/dist/preview-engine/renderers-RIGW33ZI.mjs +0 -1
  448. package/dist/preview-engine/renderers-RUZRBQ4K.mjs +0 -1
  449. package/dist/preview-engine/renderers-SKB2GGKS.mjs +0 -1
  450. package/dist/preview-engine/renderers-T4D2TASM.mjs +0 -1
  451. package/dist/preview-engine/renderers-T74XV36Q.mjs +0 -1
  452. package/dist/preview-engine/renderers-TOPW2UM3.mjs +0 -1
  453. package/dist/preview-engine/renderers-UO5G3Y2G.mjs +0 -1
  454. package/dist/preview-engine/renderers-UOV7VOJJ.mjs +0 -1
  455. package/dist/preview-engine/renderers-UWKSTSTA.mjs +0 -1
  456. package/dist/preview-engine/renderers-VHPAOUTA.mjs +0 -1
  457. package/dist/preview-engine/renderers-YEQRQJFC.mjs +0 -1
  458. package/dist/preview-engine/renderers-YRZ3WODX.mjs +0 -1
  459. package/dist/preview-engine/renderers-YUTFGVRY.mjs +0 -1
  460. package/dist/preview-engine/renderers-YXOTRKPA.mjs +0 -1
  461. package/dist/preview-engine/renderers-ZOU5ARVD.mjs +0 -1
  462. package/dist/preview-engine/renderers-ZW75LQTR.mjs +0 -1
  463. package/dist/program-C2cJayA1.mjs.map +0 -1
  464. package/dist/renderer-BAhZeUUn.mjs +0 -5
  465. package/dist/renderer-BHAE_dKq.mjs +0 -5
  466. package/dist/renderer-BNYZY3FE.mjs +0 -5
  467. package/dist/renderer-BSvubMvI.mjs +0 -5
  468. package/dist/renderer-BV48PFjX.mjs.map +0 -1
  469. package/dist/renderer-B_M_yhTc.mjs +0 -5
  470. package/dist/renderer-B_xSsTuy.mjs +0 -5
  471. package/dist/renderer-BePHurAU.mjs +0 -5
  472. package/dist/renderer-BfZquJrW.mjs +0 -5
  473. package/dist/renderer-BuY5ypB2.mjs +0 -5
  474. package/dist/renderer-BvOQgFHo.mjs +0 -5
  475. package/dist/renderer-C7NkSvUz.mjs +0 -5
  476. package/dist/renderer-CIuxV2q1.mjs +0 -5
  477. package/dist/renderer-CKp97tSQ.mjs +0 -5
  478. package/dist/renderer-CY89dTFR.mjs +0 -5
  479. package/dist/renderer-C_B_4Bl0.mjs +0 -5
  480. package/dist/renderer-Cd87JOjP.mjs +0 -5
  481. package/dist/renderer-Cev6orIK.mjs +0 -6
  482. package/dist/renderer-Cewf6UPh.mjs +0 -5
  483. package/dist/renderer-CnHsMLIv.mjs +0 -5
  484. package/dist/renderer-CoxuAr7b.mjs +0 -5
  485. package/dist/renderer-CrdG7trA.mjs +0 -5
  486. package/dist/renderer-Cu5MFgbk.mjs +0 -5
  487. package/dist/renderer-CuOjhtoo.mjs +0 -5
  488. package/dist/renderer-D2_0g6Oi.mjs +0 -5
  489. package/dist/renderer-D62FwolD.mjs +0 -5
  490. package/dist/renderer-DDNY9N7I.mjs +0 -5
  491. package/dist/renderer-DF_ONJ3v.mjs +0 -5
  492. package/dist/renderer-DbzrVHEE.mjs +0 -5
  493. package/dist/renderer-DlYchp1v.mjs +0 -5
  494. package/dist/renderer-DqcUNP8J.mjs +0 -5
  495. package/dist/renderer-DqvFhEQj.mjs +0 -5
  496. package/dist/renderer-DuuOlYIG.mjs +0 -3
  497. package/dist/renderer-DxBav2bE.mjs +0 -5
  498. package/dist/renderer-GvSRoQje.mjs +0 -5
  499. package/dist/renderer-KP7Ita9g.mjs +0 -5
  500. package/dist/renderer-Pc8dyNXt.mjs +0 -5
  501. package/dist/renderer-ezRCmU09.mjs +0 -3
  502. package/dist/renderer-h6ev1ogx.mjs +0 -5
  503. /package/dist/{chunk-GBe2k3y8.mjs → chunk-D7gtBK74.mjs} +0 -0
  504. /package/dist/{renderer-B-BcgA6d.mjs → renderer-B7fX0C62.mjs} +0 -0
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "mouse-pointer",
3
+ "title": "MousePointer",
4
+ "summary": "An animated pointer that travels between targets, clicks, drags and changes shape, reporting every click as a cue.",
5
+ "tags": [
6
+ "pointer",
7
+ "cursor",
8
+ "demo",
9
+ "ui",
10
+ "interaction",
11
+ "screen recording"
12
+ ],
13
+ "example": "MousePointer({ path: [{ to: [400, 300] }, { to: [900, 520], click: true }] })",
14
+ "parts": [
15
+ "motion",
16
+ "cursor",
17
+ "glyph-<style>",
18
+ "label",
19
+ "trail-<n>",
20
+ "ripple-<n>-<m>"
21
+ ],
22
+ "cues": [
23
+ "arrivals",
24
+ "presses",
25
+ "releases",
26
+ "clicks",
27
+ "drags"
28
+ ],
29
+ "registryDependencies": [],
30
+ "files": [
31
+ {
32
+ "path": "mouse-pointer/glyphs.ts",
33
+ "content": "/**\n * Cursor glyphs, drawn on a 24-unit grid and scaled to `size` px. Each glyph\n * is a few paths painted with the pointer's `color` (fill), `accent`, or as\n * strokes, plus the hotspot: the grid point that lands on the target.\n */\nimport type { Vec2 } from \"framefields/components\";\n\nexport const CURSOR_STYLES = [\n\t\"arrow\",\n\t\"triangle\",\n\t\"chevron\",\n\t\"dot\",\n\t\"ring\",\n\t\"target\",\n\t\"crosshair\",\n\t\"plus\",\n\t\"ibeam\",\n\t\"caret\",\n\t\"square\",\n\t\"diamond\",\n\t\"sparkle\",\n\t\"hand\",\n] as const;\n\nexport type CursorStyle = (typeof CURSOR_STYLES)[number];\n\nexport interface GlyphPart {\n\t/** Absolute path data on the 24-unit grid (M L C Z only). */\n\td: string;\n\t/** `fill` / `accent` are filled shapes; `stroke` / `accent-stroke` are lines. */\n\tpaint: \"fill\" | \"accent\" | \"stroke\" | \"accent-stroke\";\n\t/** Line width in grid units, for stroke paints. */\n\twidth?: number;\n}\n\nexport interface Glyph {\n\tparts: GlyphPart[];\n\t/** The point that lands on the target, in grid units. */\n\thotspot: Vec2;\n\t/** Glyphs that read as a direction rotate with travel when `autoOrient` is on. */\n\tdirectional?: boolean;\n}\n\n/** A closed circle as cubic Béziers (no arcs, so it scales with the rest). */\nfunction circle(cx: number, cy: number, r: number): string {\n\tconst k = 0.5523 * r;\n\treturn [\n\t\t`M${cx + r} ${cy}`,\n\t\t`C${cx + r} ${cy + k} ${cx + k} ${cy + r} ${cx} ${cy + r}`,\n\t\t`C${cx - k} ${cy + r} ${cx - r} ${cy + k} ${cx - r} ${cy}`,\n\t\t`C${cx - r} ${cy - k} ${cx - k} ${cy - r} ${cx} ${cy - r}`,\n\t\t`C${cx + k} ${cy - r} ${cx + r} ${cy - k} ${cx + r} ${cy}`,\n\t\t\"Z\",\n\t].join(\" \");\n}\n\nfunction rect(x0: number, y0: number, x1: number, y1: number): string {\n\treturn `M${x0} ${y0} L${x1} ${y0} L${x1} ${y1} L${x0} ${y1} Z`;\n}\n\nconst CENTER: Vec2 = [12, 12];\n\nlet library: Record<CursorStyle, Glyph> | undefined;\n\n/** The glyph for a style (the library is built on first use). */\nexport function glyphFor(style: CursorStyle): Glyph {\n\tlibrary ??= buildGlyphs();\n\treturn library[style];\n}\n\nfunction buildGlyphs(): Record<CursorStyle, Glyph> {\n\treturn {\n\t\t// The classic pointer: head, notch and tail as one silhouette.\n\t\tarrow: {\n\t\t\tparts: [\n\t\t\t\t{\n\t\t\t\t\td: \"M4 2.5 L4 19.4 L8.3 15.4 L11.2 21.8 L14.3 20.4 L11.5 14.2 L17.4 14.2 Z\",\n\t\t\t\t\tpaint: \"fill\",\n\t\t\t\t},\n\t\t\t],\n\t\t\thotspot: [4, 2.5],\n\t\t},\n\t\t// The arrow's head alone: the pure-shape pointer.\n\t\ttriangle: {\n\t\t\tparts: [{ d: \"M4 2.5 L4 19.6 L17.6 13.9 Z\", paint: \"fill\" }],\n\t\t\thotspot: [4, 2.5],\n\t\t\tdirectional: true,\n\t\t},\n\t\t// A notched dart, the modern design-tool pointer.\n\t\tchevron: {\n\t\t\tparts: [\n\t\t\t\t{ d: \"M4.5 3 L20.2 10.4 L12.7 12.7 L10.4 20.2 Z\", paint: \"fill\" },\n\t\t\t],\n\t\t\thotspot: [4.5, 3],\n\t\t\tdirectional: true,\n\t\t},\n\t\tdot: {\n\t\t\tparts: [{ d: circle(12, 12, 6), paint: \"fill\" }],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tring: {\n\t\t\tparts: [{ d: circle(12, 12, 7.5), paint: \"stroke\", width: 2.4 }],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\ttarget: {\n\t\t\tparts: [\n\t\t\t\t{ d: circle(12, 12, 8), paint: \"stroke\", width: 2 },\n\t\t\t\t{ d: circle(12, 12, 2.6), paint: \"accent\" },\n\t\t\t],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tcrosshair: {\n\t\t\tparts: [\n\t\t\t\t{ d: rect(11.1, 2.5, 12.9, 8.5), paint: \"fill\" },\n\t\t\t\t{ d: rect(11.1, 15.5, 12.9, 21.5), paint: \"fill\" },\n\t\t\t\t{ d: rect(2.5, 11.1, 8.5, 12.9), paint: \"fill\" },\n\t\t\t\t{ d: rect(15.5, 11.1, 21.5, 12.9), paint: \"fill\" },\n\t\t\t\t{ d: circle(12, 12, 1.4), paint: \"accent\" },\n\t\t\t],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tplus: {\n\t\t\tparts: [\n\t\t\t\t{ d: rect(10, 4, 14, 20), paint: \"fill\" },\n\t\t\t\t{ d: rect(4, 10, 20, 14), paint: \"fill\" },\n\t\t\t],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tibeam: {\n\t\t\tparts: [\n\t\t\t\t{ d: rect(11.2, 4.6, 12.8, 19.4), paint: \"fill\" },\n\t\t\t\t{ d: rect(8.8, 3.2, 15.2, 4.9), paint: \"fill\" },\n\t\t\t\t{ d: rect(8.8, 19.1, 15.2, 20.8), paint: \"fill\" },\n\t\t\t],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tcaret: {\n\t\t\tparts: [{ d: rect(11, 3, 13, 21), paint: \"accent\" }],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tsquare: {\n\t\t\tparts: [{ d: rect(6, 6, 18, 18), paint: \"fill\" }],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tdiamond: {\n\t\t\tparts: [{ d: \"M12 3.5 L20.5 12 L12 20.5 L3.5 12 Z\", paint: \"fill\" }],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\tsparkle: {\n\t\t\tparts: [\n\t\t\t\t{\n\t\t\t\t\td: \"M12 2 C12.8 8.4 15.6 11.2 22 12 C15.6 12.8 12.8 15.6 12 22 C11.2 15.6 8.4 12.8 2 12 C8.4 11.2 11.2 8.4 12 2 Z\",\n\t\t\t\t\tpaint: \"accent\",\n\t\t\t\t},\n\t\t\t],\n\t\t\thotspot: CENTER,\n\t\t},\n\t\t// A pointing hand: fingertip is the hotspot.\n\t\thand: {\n\t\t\tparts: [\n\t\t\t\t{\n\t\t\t\t\td: [\n\t\t\t\t\t\t\"M7.2 4.6 C7.2 3.5 8.1 2.6 9.2 2.6 C10.3 2.6 11.2 3.5 11.2 4.6\",\n\t\t\t\t\t\t\"L11.2 10.1 C11.6 9.7 12.2 9.5 12.9 9.6 C13.6 9.7 14.2 10.2 14.4 10.9\",\n\t\t\t\t\t\t\"C14.8 10.5 15.5 10.3 16.2 10.5 C16.9 10.7 17.4 11.3 17.5 12\",\n\t\t\t\t\t\t\"C17.9 11.7 18.5 11.6 19.1 11.8 C19.9 12.1 20.4 12.8 20.4 13.7\",\n\t\t\t\t\t\t\"L20.4 16.4 C20.4 19.5 17.9 22 14.8 22 L12.4 22 C10.6 22 9 21.1 8 19.6\",\n\t\t\t\t\t\t\"L4.8 14.7 C4.3 13.9 4.5 12.9 5.2 12.4 C5.9 11.9 6.8 12 7.2 12.6 Z\",\n\t\t\t\t\t].join(\" \"),\n\t\t\t\t\tpaint: \"fill\",\n\t\t\t\t},\n\t\t\t],\n\t\t\thotspot: [9.2, 2.8],\n\t\t},\n\t};\n}\n"
34
+ },
35
+ {
36
+ "path": "mouse-pointer/index.ts",
37
+ "content": "/**\n * MousePointer: an animated pointer that travels between targets, clicks,\n * drags and changes shape, with exact cues for everything it does.\n *\n * Coordinates are the parent's: the pointer's root covers `area` from 0, 0,\n * so targets resolve where they are drawn.\n */\nimport {\n\ttype AnimatableProp,\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tcontrastRatio,\n\tdefineComponent,\n\tresolveTarget,\n\ttype Target,\n\ttargetPoint,\n\ttargetSchema,\n\ttransformPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport {\n\tCURSOR_STYLES,\n\ttype CursorStyle,\n\ttype Glyph,\n\tglyphFor,\n} from \"./glyphs.js\";\nimport {\n\ttype PlannedWaypoint,\n\ttype PointAt,\n\ttype PointerPlan,\n\tplanPointer,\n} from \"./plan.js\";\n\nexport { CURSOR_STYLES, type CursorStyle } from \"./glyphs.js\";\n\nconst EDGES = [\"left\", \"right\", \"top\", \"bottom\"] as const;\ntype Edge = (typeof EDGES)[number];\n\n/** A waypoint's schema (built on first use). */\nfunction waypointSchema() {\n\tconst frames = z.number().int().min(0);\n\treturn z\n\t\t.object({\n\t\t\t/** Where to go. */\n\t\t\tto: targetSchema(),\n\t\t\t/** Arrival frame (component-local). Default: from distance and `speed`. */\n\t\t\tat: frames.optional(),\n\t\t\t/** Frames to rest after arriving (and clicking). */\n\t\t\tdwell: frames.optional(),\n\t\t\t/** Click on arrival: `true`, a count (2 = double click), or details. */\n\t\t\tclick: z\n\t\t\t\t.union([\n\t\t\t\t\tz.boolean(),\n\t\t\t\t\tz.number().int().min(1).max(3),\n\t\t\t\t\tz.object({\n\t\t\t\t\t\tcount: z.number().int().min(1).max(3).default(1),\n\t\t\t\t\t\thold: frames.optional(),\n\t\t\t\t\t}),\n\t\t\t\t])\n\t\t\t\t.optional(),\n\t\t\t/** Press here and drag to this target. */\n\t\t\tdrag: targetSchema().optional(),\n\t\t\t/** Switch cursor style on arrival, for good. */\n\t\t\tcursor: z.enum(CURSOR_STYLES).optional(),\n\t\t})\n\t\t.strict();\n}\n\nexport type Waypoint = z.input<ReturnType<typeof waypointSchema>>;\n\nexport interface MousePointerCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\tarrivals: ReadonlyArray<{ frame: number; point: Vec2; index: number }>;\n\tpresses: ReadonlyArray<{ frame: number; point: Vec2 }>;\n\treleases: ReadonlyArray<{ frame: number; point: Vec2 }>;\n\tclicks: ReadonlyArray<{\n\t\tframe: number;\n\t\tpoint: Vec2;\n\t\tindex: number;\n\t\ttarget?: string;\n\t}>;\n\tdrags: ReadonlyArray<{ frame: number; end: number; from: Vec2; to: Vec2 }>;\n}\n\nexport interface MousePointerApi {\n\t/** Pointer hotspot in the parent's coordinates at a parent-timeline frame. */\n\tpositionAt(frame: number): Vec2;\n\t/** Cursor style showing at a parent-timeline frame. */\n\tstyleAt(frame: number): CursorStyle;\n}\n\nexport const mousePointer = /* @__PURE__ */ defineComponent({\n\tname: \"MousePointer\",\n\tprops: () => ({\n\t\t/** The performance: where to go, and what to do there. */\n\t\tpath: z.array(waypointSchema()).min(1),\n\t\tstyle: z.enum(CURSOR_STYLES).default(\"arrow\"),\n\t\t/** Glyph size in px (the 24-unit design grid). Default 44 × theme.scale. */\n\t\tsize: z.number().positive().optional(),\n\t\t/** Glyph fill. Default near-black, outlined in white: reads on anything. */\n\t\tcolor: z.string().default(\"#101114\"),\n\t\t/** Secondary color (target dots, sparkles, ripples). Default theme accent. */\n\t\taccent: z.string().optional(),\n\t\toutline: z.union([z.string(), z.literal(false)]).default(\"#ffffff\"),\n\t\t/** Outline width in px. Default size / 16. */\n\t\toutlineWidth: z.number().min(0).optional(),\n\t\t/** `union` outlines the whole silhouette (no seams between parts); `each` outlines parts separately. */\n\t\toutlineMode: z.enum([\"union\", \"each\"]).default(\"union\"),\n\t\tmotion: z.enum([\"natural\", \"swift\", \"lazy\", \"linear\"]).default(\"natural\"),\n\t\t/** Travel-time multiplier: 2 = twice as fast. */\n\t\tspeed: z.number().positive().default(1),\n\t\t/** Idle drift in px while resting (0 = perfectly still). */\n\t\tdrift: z.number().min(0).default(1.5),\n\t\t/** Rotate directional glyphs (triangle, chevron) along travel. */\n\t\tautoOrient: z.boolean().default(false),\n\t\t/** Fading dots behind the pointer while it moves. */\n\t\ttrail: z\n\t\t\t.union([\n\t\t\t\tz.literal(false),\n\t\t\t\tz.object({\n\t\t\t\t\tlength: z.number().int().min(1).max(16).default(6),\n\t\t\t\t\tspacing: z.number().int().min(1).default(1),\n\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t}),\n\t\t\t])\n\t\t\t.default(false),\n\t\t/** How the glyph reacts to a press. */\n\t\tfeedback: z\n\t\t\t.enum([\"press\", \"bounce\", \"pulse\"])\n\t\t\t.or(z.literal(false))\n\t\t\t.default(\"press\"),\n\t\t/** The mark a click leaves where it lands. */\n\t\tripple: z\n\t\t\t.enum([\"ring\", \"fill\", \"pulse\"])\n\t\t\t.or(z.literal(false))\n\t\t\t.default(\"ring\"),\n\t\t/** Style swaps while the hotspot is inside a region. */\n\t\thoverRegions: z\n\t\t\t.array(\n\t\t\t\tz\n\t\t\t\t\t.object({ target: targetSchema(), style: z.enum(CURSOR_STYLES) })\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default([]),\n\t\t/** A name tag that rides beside the pointer (agents, collaborators). */\n\t\tlabel: z\n\t\t\t.union([\n\t\t\t\tz.string(),\n\t\t\t\tz.object({ text: z.string(), color: z.string().optional() }).strict(),\n\t\t\t])\n\t\t\t.optional(),\n\t\t/** Velocity motion blur on travel. `true` = a 45° shutter (a hint of blur that keeps the glyph crisp), or an angle. */\n\t\tmotionBlur: z\n\t\t\t.union([z.boolean(), z.number().min(0).max(360)])\n\t\t\t.default(true),\n\t\t/** Soft drop shadow under the glyph. */\n\t\tshadow: z.boolean().default(true),\n\t\t/** Enter from off-frame on this edge (or from a point) instead of appearing in place. */\n\t\tfrom: z.union([z.enum(EDGES), targetSchema()]).optional(),\n\t\t/** Leave to off-frame on this edge (or to a point) instead of fading away. */\n\t\tto: z.union([z.enum(EDGES), targetSchema()]).optional(),\n\t\t/** The frame the pointer lives in (for edges). Default 1920 × 1080. */\n\t\tarea: z\n\t\t\t.object({ width: z.number().positive(), height: z.number().positive() })\n\t\t\t.default({ width: 1920, height: 1080 }),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst size = props.size ?? 44 * theme.scale;\n\t\tconst k = size / 24;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst outlineWidth = props.outlineWidth ?? size / 16;\n\t\tconst { width: W, height: H } = props.area;\n\n\t\tconst resolvePoint = (target: Target): { point: PointAt; id?: string } => {\n\t\t\tconst resolved = resolveTarget(target, { measure: host.measure, fps });\n\t\t\tconst isText =\n\t\t\t\ttypeof target === \"object\" &&\n\t\t\t\t!Array.isArray(target) &&\n\t\t\t\t\"text\" in target;\n\t\t\tconst node =\n\t\t\t\ttypeof target === \"object\" && !Array.isArray(target) && \"node\" in target\n\t\t\t\t\t? target.node\n\t\t\t\t\t: undefined;\n\t\t\tconst id = typeof node?.id === \"string\" ? node.id : undefined;\n\t\t\tif (resolved.kind === \"static\") {\n\t\t\t\tconst p = targetPoint(resolved, 0, isText);\n\t\t\t\treturn { point: () => p, id };\n\t\t\t}\n\t\t\treturn { point: (frame) => targetPoint(resolved, frame, isText), id };\n\t\t};\n\t\tconst edgePoint = (edge: Edge, near: Vec2): Vec2 => {\n\t\t\tconst off = size * 1.5;\n\t\t\tif (edge === \"left\") return [-off, near[1]];\n\t\t\tif (edge === \"right\") return [W + off, near[1]];\n\t\t\tif (edge === \"top\") return [near[0], -off];\n\t\t\treturn [near[0], H + off];\n\t\t};\n\n\t\tconst waypoints: PlannedWaypoint[] = props.path.map((wp) => {\n\t\t\tconst { point, id } = resolvePoint(wp.to);\n\t\t\tconst click =\n\t\t\t\twp.click === undefined || wp.click === false\n\t\t\t\t\t? undefined\n\t\t\t\t\t: wp.click === true\n\t\t\t\t\t\t? { count: 1 }\n\t\t\t\t\t\t: typeof wp.click === \"number\"\n\t\t\t\t\t\t\t? { count: wp.click }\n\t\t\t\t\t\t\t: wp.click;\n\t\t\treturn {\n\t\t\t\tpoint,\n\t\t\t\tat: wp.at,\n\t\t\t\tdwell: wp.dwell,\n\t\t\t\tclicks: click?.count,\n\t\t\t\thold: click && \"hold\" in click ? click.hold : undefined,\n\t\t\t\tdrag: wp.drag ? resolvePoint(wp.drag).point : undefined,\n\t\t\t\tcursor: wp.cursor,\n\t\t\t\ttargetId: id,\n\t\t\t};\n\t\t});\n\t\tconst firstPoint = waypoints[0]?.point(0) ?? [0, 0];\n\t\tconst lastPoint = waypoints[waypoints.length - 1]?.point(0) ?? firstPoint;\n\t\tconst fromPoint =\n\t\t\tprops.from === undefined\n\t\t\t\t? undefined\n\t\t\t\t: typeof props.from === \"string\"\n\t\t\t\t\t? edgePoint(props.from, firstPoint)\n\t\t\t\t\t: resolvePoint(props.from).point(0);\n\t\tconst toPoint =\n\t\t\tprops.to === undefined\n\t\t\t\t? undefined\n\t\t\t\t: typeof props.to === \"string\"\n\t\t\t\t\t? edgePoint(props.to, lastPoint)\n\t\t\t\t\t: resolvePoint(props.to).point(0);\n\n\t\tconst fade = Math.max(1, Math.round((7 * fps) / 30));\n\t\tconst plan = planPointer({\n\t\t\twaypoints,\n\t\t\tfps,\n\t\t\tstyle: props.style,\n\t\t\tmotion: props.motion,\n\t\t\tspeed: props.speed,\n\t\t\tdrift: props.drift,\n\t\t\tseed: ctx.seed,\n\t\t\tfrom: fromPoint,\n\t\t\tto: toPoint,\n\t\t\thoverRegions: props.hoverRegions.map((r) => {\n\t\t\t\tconst resolved = resolveTarget(r.target, {\n\t\t\t\t\tmeasure: host.measure,\n\t\t\t\t\tfps,\n\t\t\t\t});\n\t\t\t\tif (resolved.kind !== \"static\")\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\"MousePointer: hover regions must be static targets.\",\n\t\t\t\t\t);\n\t\t\t\tconst rects = resolved.rects;\n\t\t\t\tconst x0 = Math.min(...rects.map((q) => q.x));\n\t\t\t\tconst y0 = Math.min(...rects.map((q) => q.y));\n\t\t\t\tconst x1 = Math.max(...rects.map((q) => q.x + q.width));\n\t\t\t\tconst y1 = Math.max(...rects.map((q) => q.y + q.height));\n\t\t\t\treturn {\n\t\t\t\t\trect: { x: x0, y: y0, width: x1 - x0, height: y1 - y0 },\n\t\t\t\t\tstyle: r.style,\n\t\t\t\t};\n\t\t\t}),\n\t\t\tappear: fromPoint ? 0 : fade,\n\t\t\tvanish: toPoint ? 0 : fade,\n\t\t});\n\n\t\t// Visibility: pop in at the start, fade out at the end (unless it\n\t\t// travels on or off frame, which needs no fade).\n\t\tconst visibility = (f: number): number => {\n\t\t\tconst inAt = fromPoint ? 1 : smooth(f / fade);\n\t\t\tconst outAt = toPoint ? 1 : 1 - smooth((f - plan.vanishAt) / fade);\n\t\t\treturn Math.max(0, Math.min(inAt, outAt));\n\t\t};\n\t\tconst appearScale = (f: number): number => {\n\t\t\tif (fromPoint) return 1;\n\t\t\tconst t = f / fade;\n\t\t\treturn t >= 1 ? 1 : 0.6 + 0.4 * backOut(Math.max(0, t));\n\t\t};\n\t\tconst pressScale = pressProfile(plan, props.feedback);\n\n\t\t// ── Glyph boxes, one per style used ──────────────────────────────\n\t\tconst used = [\n\t\t\t...new Set([props.style, ...plan.styles.map((w) => w.style)]),\n\t\t];\n\t\tconst margin = Math.ceil(size * 0.4);\n\t\tconst box = size + margin * 2;\n\n\t\t// Label geometry first: it sizes the moving box.\n\t\tconst labelText =\n\t\t\tprops.label === undefined\n\t\t\t\t? undefined\n\t\t\t\t: typeof props.label === \"string\"\n\t\t\t\t\t? props.label\n\t\t\t\t\t: props.label.text;\n\t\tconst labelFont = Math.round(size * 0.4);\n\t\tconst labelMetrics =\n\t\t\tlabelText === undefined\n\t\t\t\t? undefined\n\t\t\t\t: host.measure(labelText, {\n\t\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\t\tfontSize: labelFont,\n\t\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\t});\n\t\tconst labelPadX = Math.round(labelFont * 0.62);\n\t\tconst labelH = Math.round(labelFont * 1.8);\n\t\tconst labelW = labelMetrics\n\t\t\t? Math.ceil(labelMetrics.width + labelPadX * 2)\n\t\t\t: 0;\n\t\tconst labelX = Math.round(size * 0.46);\n\t\tconst labelY = Math.round(size * 0.6);\n\n\t\t// The moving part is a real box centred on the hotspot (very small\n\t\t// boxes are culled), big enough for the glyph, its shadow and label.\n\t\tconst half = Math.ceil(\n\t\t\tMath.max(\n\t\t\t\tbox,\n\t\t\t\tlabelMetrics ? Math.max(labelX + labelW, labelY + labelH) + 24 : 0,\n\t\t\t),\n\t\t);\n\t\tconst extent = half * 2;\n\t\tconst swap = Math.max(1, Math.round((2 * fps) / 30));\n\t\tconst glyphBoxes: Array<Animatable<LayoutNode>> = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\n\t\tfor (const style of used) {\n\t\t\tconst glyph = glyphFor(style);\n\t\t\tconst [hx, hy] = glyph.hotspot;\n\t\t\tconst weight = (f: number) => styleWeight(plan, style, f, swap);\n\t\t\tconst opacity = samples(plan.length, (f) => visibility(f) * weight(f));\n\t\t\tconst scale = samples(\n\t\t\t\tplan.length,\n\t\t\t\t(f) => appearScale(f) * pressScale(f) * (0.9 + 0.1 * weight(f)),\n\t\t\t);\n\t\t\tconst shapes = glyphShapes(ctx, glyph, style, {\n\t\t\t\tk,\n\t\t\t\tmargin,\n\t\t\t\tcolor: props.color,\n\t\t\t\taccent,\n\t\t\t\toutline: props.outline === false ? null : props.outline,\n\t\t\t\toutlineWidth,\n\t\t\t\tmode: props.outlineMode,\n\t\t\t});\n\t\t\tconst g = host.box({\n\t\t\t\tid: ctx.partId(`glyph-${style}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: half - hx * k - margin,\n\t\t\t\ty: half - hy * k - margin,\n\t\t\t\twidth: box,\n\t\t\t\theight: box,\n\t\t\t\tanchorX: (hx * k + margin) / box,\n\t\t\t\tanchorY: (hy * k + margin) / box,\n\t\t\t\tchildren: shapes,\n\t\t\t});\n\t\t\tg.animate(\n\t\t\t\ttrack(\"opacity\", bakeSamples(opacity, 0, { tolerance: 0.004 })),\n\t\t\t);\n\t\t\tg.animate(track(\"scale\", bakeSamples(scale, 0, { tolerance: 0.002 })));\n\t\t\tif (props.autoOrient && glyph.directional) {\n\t\t\t\tconst angles = unwrap(\n\t\t\t\t\tplan.angles.map((a) => (a * 180) / Math.PI + 135),\n\t\t\t\t);\n\t\t\t\tg.animate(\n\t\t\t\t\ttrack(\"rotation\", bakeSamples(angles, 0, { tolerance: 0.3 })),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (props.shadow) {\n\t\t\t\thost.style(g, {\n\t\t\t\t\tdropShadow: {\n\t\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\t\topacity: 0.34,\n\t\t\t\t\t\tangle: 90,\n\t\t\t\t\t\tdistance: Math.max(1, k * 1.2),\n\t\t\t\t\t\tsize: k * 4,\n\t\t\t\t\t\tspread: 0,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t\tglyphBoxes.push(g);\n\t\t\tparts[`glyph-${style}`] = g;\n\t\t}\n\t\tconst cursor = host.box({\n\t\t\tid: ctx.partId(\"cursor\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: extent,\n\t\t\theight: extent,\n\t\t\tchildren: glyphBoxes,\n\t\t});\n\n\t\t// ── Label ────────────────────────────────────────────────────────\n\t\tconst motionChildren: LayoutNode[] = [cursor];\n\t\tif (labelText !== undefined && labelMetrics) {\n\t\t\tconst bg =\n\t\t\t\t(typeof props.label === \"object\" && props.label.color) || accent;\n\t\t\t// White on a saturated tag reads as a name tag; fall back to dark\n\t\t\t// ink only when white would be illegible.\n\t\t\tconst ink =\n\t\t\t\t(contrastRatio(\"#ffffff\", bg) ?? 0) >= 2.6 ? \"#ffffff\" : \"#0b0c0e\";\n\t\t\tconst label = host.box({\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: half + labelX,\n\t\t\t\ty: half + labelY,\n\t\t\t\twidth: labelW,\n\t\t\t\theight: labelH,\n\t\t\t\tbackground: bg,\n\t\t\t\tborderRadius: labelH / 2,\n\t\t\t\tchildren: [\n\t\t\t\t\thost.text(labelText, {\n\t\t\t\t\t\tid: ctx.partId(\"label-text\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: labelPadX,\n\t\t\t\t\t\ty: Math.round((labelH - labelMetrics.lineHeight) / 2),\n\t\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\t\tfontSize: labelFont,\n\t\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\t\tfill: ink,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\t\t\tlabel.animate(\n\t\t\t\ttrack(\n\t\t\t\t\t\"opacity\",\n\t\t\t\t\tbakeSamples(samples(plan.length, visibility), 0, {\n\t\t\t\t\t\ttolerance: 0.004,\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t);\n\t\t\tif (props.shadow) {\n\t\t\t\thost.style(label, {\n\t\t\t\t\tdropShadow: {\n\t\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\t\topacity: 0.28,\n\t\t\t\t\t\tangle: 90,\n\t\t\t\t\t\tdistance: 2,\n\t\t\t\t\t\tsize: 8,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t\tmotionChildren.push(label);\n\t\t\tparts.label = label;\n\t\t}\n\n\t\tconst motion = host.box({\n\t\t\tid: ctx.partId(\"motion\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: (plan.positions[0]?.[0] ?? 0) - half,\n\t\t\ty: (plan.positions[0]?.[1] ?? 0) - half,\n\t\t\twidth: extent,\n\t\t\theight: extent,\n\t\t\t...(props.motionBlur !== false && {\n\t\t\t\tmotionBlurShutter: props.motionBlur === true ? 45 : props.motionBlur,\n\t\t\t}),\n\t\t\tchildren: motionChildren,\n\t\t});\n\t\tmotion.animate(\n\t\t\ttrack(\n\t\t\t\t\"x\",\n\t\t\t\tbakeSamples(\n\t\t\t\t\tplan.positions.map((p) => p[0] - half),\n\t\t\t\t\t0,\n\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t),\n\t\t\t),\n\t\t\ttrack(\n\t\t\t\t\"y\",\n\t\t\t\tbakeSamples(\n\t\t\t\t\tplan.positions.map((p) => p[1] - half),\n\t\t\t\t\t0,\n\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t),\n\t\t\t),\n\t\t);\n\n\t\t// ── Ripples and trail (they stay where they are drawn) ───────────\n\t\tconst below: LayoutNode[] = [];\n\t\tif (props.ripple !== false) {\n\t\t\tplan.cues.clicks.forEach((click, i) => {\n\t\t\t\tbelow.push(\n\t\t\t\t\t...ripple(\n\t\t\t\t\t\tctx,\n\t\t\t\t\t\tprops.ripple as RippleKind,\n\t\t\t\t\t\tclick.point,\n\t\t\t\t\t\tsize,\n\t\t\t\t\t\taccent,\n\t\t\t\t\t\tclick.frame,\n\t\t\t\t\t\ti,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t});\n\t\t}\n\t\tif (props.trail !== false) {\n\t\t\tconst { length, spacing } = props.trail;\n\t\t\tconst color = props.trail.color ?? accent;\n\t\t\tconst dot = Math.max(3, size * 0.22);\n\t\t\tfor (let g = 1; g <= length; g++) {\n\t\t\t\tconst lag = g * spacing;\n\t\t\t\tconst at = (f: number): Vec2 =>\n\t\t\t\t\tplan.positions[Math.max(0, f - lag)] ?? [0, 0];\n\t\t\t\tconst moving = (f: number) => {\n\t\t\t\t\tconst a = at(f);\n\t\t\t\t\tconst b = at(f - 1);\n\t\t\t\t\treturn Math.min(1, Math.hypot(a[0] - b[0], a[1] - b[1]) / 6);\n\t\t\t\t};\n\t\t\t\tconst ghost = host.shape(\"circle\", {\n\t\t\t\t\tid: ctx.partId(`trail-${g}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: (plan.positions[0]?.[0] ?? 0) - dot / 2,\n\t\t\t\t\ty: (plan.positions[0]?.[1] ?? 0) - dot / 2,\n\t\t\t\t\twidth: dot,\n\t\t\t\t\theight: dot,\n\t\t\t\t\tfillColor: color,\n\t\t\t\t});\n\t\t\t\tghost.animate(\n\t\t\t\t\ttrack(\n\t\t\t\t\t\t\"x\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tsamples(plan.length, (f) => at(f)[0] - dot / 2),\n\t\t\t\t\t\t\t0,\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\ttrack(\n\t\t\t\t\t\t\"y\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tsamples(plan.length, (f) => at(f)[1] - dot / 2),\n\t\t\t\t\t\t\t0,\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\ttrack(\n\t\t\t\t\t\t\"opacity\",\n\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\tsamples(\n\t\t\t\t\t\t\t\tplan.length,\n\t\t\t\t\t\t\t\t(f) => 0.5 * (1 - g / (length + 1)) * moving(f) * visibility(f),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t{ tolerance: 0.01 },\n\t\t\t\t\t\t),\n\t\t\t\t\t),\n\t\t\t\t\ttrack(\"scale\", [[0, 1 - (0.5 * g) / (length + 1)]]),\n\t\t\t\t);\n\t\t\t\tbelow.push(ghost);\n\t\t\t}\n\t\t}\n\n\t\tObject.assign(parts, { motion, cursor });\n\n\t\treturn {\n\t\t\tchildren: [...below, motion],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: plan.length,\n\t\t\torigin: [0, 0],\n\t\t\tcues: plan.cues as MousePointerCues,\n\t\t\tparts,\n\t\t\tapi: (placement): MousePointerApi => ({\n\t\t\t\tpositionAt(frame) {\n\t\t\t\t\tconst local = Math.max(\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tMath.min(plan.length - 1, Math.round(frame - placement.start)),\n\t\t\t\t\t);\n\t\t\t\t\tconst p = plan.positions[local] ?? [0, 0];\n\t\t\t\t\treturn [p[0] + placement.x, p[1] + placement.y];\n\t\t\t\t},\n\t\t\t\tstyleAt(frame) {\n\t\t\t\t\tconst local = Math.round(frame - placement.start);\n\t\t\t\t\treturn (\n\t\t\t\t\t\tplan.styles.find((w) => local >= w.start && local <= w.end)\n\t\t\t\t\t\t\t?.style ?? props.style\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\ntype RippleKind = \"ring\" | \"fill\" | \"pulse\";\n\nconst smooth = (t: number) => {\n\tconst x = Math.max(0, Math.min(1, t));\n\treturn x * x * (3 - 2 * x);\n};\nconst backOut = (t: number, s = 1.7) =>\n\t1 + (s + 1) * (t - 1) ** 3 + s * (t - 1) ** 2;\n\nfunction samples(length: number, fn: (frame: number) => number): number[] {\n\treturn Array.from({ length }, (_, f) => fn(f));\n}\n\nfunction track(prop: AnimatableProp, keys: ReadonlyArray<KeyframeTuple>) {\n\treturn LayerAnimation.keys(prop, keys);\n}\n\n/** Make angle samples continuous so rotation never spins the long way round. */\nfunction unwrap(degrees: number[]): number[] {\n\tconst out: number[] = [];\n\tfor (const d of degrees) {\n\t\tconst prev = out[out.length - 1];\n\t\tif (prev === undefined) {\n\t\t\tout.push(d);\n\t\t\tcontinue;\n\t\t}\n\t\tlet x = d;\n\t\twhile (x - prev > 180) x -= 360;\n\t\twhile (x - prev < -180) x += 360;\n\t\tout.push(x);\n\t}\n\treturn out;\n}\n\n/** 1 while `style` shows, easing across `swap` frames at each change. */\nfunction styleWeight(\n\tplan: PointerPlan,\n\tstyle: CursorStyle,\n\tframe: number,\n\tswap: number,\n): number {\n\tconst win = plan.styles.find((w) => frame >= w.start && frame <= w.end);\n\tif (!win) return 0;\n\tif (win.style === style) {\n\t\tconst sinceStart = win.start === 0 ? swap : frame - win.start;\n\t\treturn smooth(Math.min(1, (sinceStart + 1) / (swap + 1)));\n\t}\n\t// The previous window's style lingers for `swap` frames.\n\tconst idx = plan.styles.indexOf(win);\n\tconst prev = plan.styles[idx - 1];\n\tif (prev?.style === style && frame - win.start < swap) {\n\t\treturn 1 - smooth((frame - win.start + 1) / (swap + 1));\n\t}\n\treturn 0;\n}\n\n/** Glyph scale over time from the press windows. */\nfunction pressProfile(\n\tplan: PointerPlan,\n\tfeedback: \"press\" | \"bounce\" | \"pulse\" | false,\n): (f: number) => number {\n\tif (feedback === false) return () => 1;\n\tconst low = feedback === \"bounce\" ? 0.76 : feedback === \"pulse\" ? 1.16 : 0.84;\n\treturn (f) => {\n\t\tfor (const w of plan.presses) {\n\t\t\tif (f < w.down || f > w.released + 30) continue;\n\t\t\tif (f <= w.pressed)\n\t\t\t\treturn (\n\t\t\t\t\t1 + (low - 1) * smooth((f - w.down) / Math.max(1, w.pressed - w.down))\n\t\t\t\t);\n\t\t\tif (f <= w.up) return low;\n\t\t\tconst t = (f - w.up) / Math.max(1, w.released - w.up);\n\t\t\tif (feedback === \"bounce\") return 1 + (low - 1) * elasticDecay(t);\n\t\t\tif (t >= 1) return 1;\n\t\t\treturn low + (1 - low) * backOut(t, 2.2);\n\t\t}\n\t\treturn 1;\n\t};\n}\n\nconst elasticDecay = (t: number) =>\n\tt >= 3 ? 0 : Math.exp(-3.2 * t) * Math.cos(t * Math.PI * 2.4);\n\ninterface GlyphPaint {\n\tk: number;\n\tmargin: number;\n\tcolor: string;\n\taccent: string;\n\toutline: string | null;\n\toutlineWidth: number;\n\tmode: \"union\" | \"each\";\n}\n\n/** Outline layers (all first, so overlaps merge) then fills. */\nfunction glyphShapes(\n\tctx: Parameters<typeof mousePointer.build>[0],\n\tglyph: Glyph,\n\tstyle: string,\n\tpaint: GlyphPaint,\n): LayoutNode[] {\n\tconst { k, margin } = paint;\n\tconst geometry = {\n\t\tposition: \"absolute\" as const,\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: 24 * k + margin * 2,\n\t\theight: 24 * k + margin * 2,\n\t};\n\tconst outlines: LayoutNode[] = [];\n\tconst fills: LayoutNode[] = [];\n\tglyph.parts.forEach((part, i) => {\n\t\tconst d = transformPathData(part.d, k, margin, margin);\n\t\tconst stroke = part.paint === \"stroke\" || part.paint === \"accent-stroke\";\n\t\tconst colorOf =\n\t\t\tpart.paint === \"accent\" || part.paint === \"accent-stroke\"\n\t\t\t\t? paint.accent\n\t\t\t\t: paint.color;\n\t\tconst lineWidth = (part.width ?? 0) * k;\n\t\tif (paint.outline) {\n\t\t\tconst o = ctx.host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`glyph-${style}-outline-${i}`),\n\t\t\t\t...geometry,\n\t\t\t\td,\n\t\t\t\tfillType: stroke ? \"none\" : \"solid\",\n\t\t\t\tfillColor: paint.outline,\n\t\t\t\tstrokeColor: paint.outline,\n\t\t\t\tstrokeWidth: lineWidth + paint.outlineWidth * 2,\n\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\tstrokeAlign: \"center\",\n\t\t\t});\n\t\t\t(paint.mode === \"union\" ? outlines : fills).push(o);\n\t\t}\n\t\tfills.push(\n\t\t\tctx.host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`glyph-${style}-${i}`),\n\t\t\t\t...geometry,\n\t\t\t\td,\n\t\t\t\t...(stroke\n\t\t\t\t\t? {\n\t\t\t\t\t\t\tfillType: \"none\" as const,\n\t\t\t\t\t\t\tfillColor: colorOf,\n\t\t\t\t\t\t\tstrokeColor: colorOf,\n\t\t\t\t\t\t\tstrokeWidth: lineWidth,\n\t\t\t\t\t\t\tstrokeLineCap: \"round\" as const,\n\t\t\t\t\t\t}\n\t\t\t\t\t: { fillColor: colorOf }),\n\t\t\t}),\n\t\t);\n\t});\n\treturn [...outlines, ...fills];\n}\n\nfunction ripple(\n\tctx: Parameters<typeof mousePointer.build>[0],\n\tkind: RippleKind,\n\tat: Vec2,\n\tsize: number,\n\taccent: string,\n\tframe: number,\n\tindex: number,\n): LayoutNode[] {\n\tconst radius = size * 0.95;\n\tconst life = Math.round((18 * ctx.fps) / 30);\n\tconst make = (delay: number, n: number) => {\n\t\tconst ring = ctx.host.shape(\"circle\", {\n\t\t\tid: ctx.partId(`ripple-${index}-${n}`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: at[0] - radius,\n\t\t\ty: at[1] - radius,\n\t\t\twidth: radius * 2,\n\t\t\theight: radius * 2,\n\t\t\tstartFrame: frame + delay,\n\t\t\tdurationFrames: life,\n\t\t\t...(kind === \"fill\"\n\t\t\t\t? { fillColor: accent }\n\t\t\t\t: {\n\t\t\t\t\t\tfillType: \"none\" as const,\n\t\t\t\t\t\tfillColor: accent,\n\t\t\t\t\t\tstrokeColor: accent,\n\t\t\t\t\t\tstrokeWidth: Math.max(1.5, size / 16),\n\t\t\t\t\t}),\n\t\t});\n\t\tring.animate(\n\t\t\ttrack(\"scale\", [\n\t\t\t\t[0, 0.2],\n\t\t\t\t[life - 2, 1, \"expo.out\"],\n\t\t\t]),\n\t\t\ttrack(\"opacity\", [\n\t\t\t\t[0, kind === \"fill\" ? 0.35 : 0.9],\n\t\t\t\t[life - 1, 0, \"sine.in\"],\n\t\t\t]),\n\t\t);\n\t\treturn ring;\n\t};\n\treturn kind === \"pulse\"\n\t\t? [make(0, 0), make(Math.round((5 * ctx.fps) / 30), 1)]\n\t\t: [make(0, 0)];\n}\n\n/**\n * An animated pointer that travels between targets, clicks, drags and changes\n * shape, reporting every click as a cue.\n */\nexport const MousePointer = /* @__PURE__ */ bindComponent(mousePointer);\n"
38
+ },
39
+ {
40
+ "path": "mouse-pointer/plan.ts",
41
+ "content": "/**\n * The pointer planner: waypoints in, a frame-by-frame performance out\n * (position, press state, cursor style, cues). Pure and deterministic; the\n * component turns the result into keyframes.\n *\n * Timing per waypoint: travel → arrive → (settle, press, release)×clicks or\n * a press-drag-release → dwell → leave for the next one.\n */\nimport { dist, evaluateEase, hashRng, type Vec2 } from \"framefields/components\";\nimport type { CursorStyle } from \"./glyphs.js\";\n\nexport type MotionFeel = \"natural\" | \"swift\" | \"lazy\" | \"linear\";\n\n/** A point that may move (tracked targets) — sampled per frame. */\nexport type PointAt = (frame: number) => Vec2;\n\nexport interface PlannedWaypoint {\n\tpoint: PointAt;\n\t/** Arrival frame; derived from distance when omitted. */\n\tat?: number;\n\t/** Frames to rest after the action. */\n\tdwell?: number;\n\t/** Clicks on arrival. */\n\tclicks?: number;\n\t/** Frames the button stays down per click. */\n\thold?: number;\n\t/** Press here and drag to this point. */\n\tdrag?: PointAt;\n\t/** Cursor style from this waypoint on. */\n\tcursor?: CursorStyle;\n\t/** Id of the target, reported on click cues. */\n\ttargetId?: string;\n}\n\nexport interface HoverRegion {\n\trect: { x: number; y: number; width: number; height: number };\n\tstyle: CursorStyle;\n}\n\nexport interface PlanInput {\n\twaypoints: PlannedWaypoint[];\n\tfps: number;\n\tstyle: CursorStyle;\n\tmotion: MotionFeel;\n\t/** Travel-time multiplier: 2 = twice as fast. */\n\tspeed: number;\n\t/** Idle drift amplitude in px (0 = still). */\n\tdrift: number;\n\tseed: number;\n\t/** Start off-frame here and travel to the first waypoint. */\n\tfrom?: Vec2;\n\t/** End by travelling here (off-frame) after the last waypoint. */\n\tto?: Vec2;\n\thoverRegions: HoverRegion[];\n\t/** Frames the pointer takes to appear / vanish (0 = instant). */\n\tappear: number;\n\tvanish: number;\n}\n\nexport interface PressWindow {\n\t/** Button starts going down. */\n\tdown: number;\n\t/** Fully pressed: the click lands. */\n\tpressed: number;\n\t/** Button starts coming up. */\n\tup: number;\n\t/** Fully released. */\n\treleased: number;\n}\n\nexport interface StyleWindow {\n\tstyle: CursorStyle;\n\tstart: number;\n\tend: number;\n}\n\nexport interface PointerPlan {\n\t/** Frames planned: positions has one entry per frame in [0, length). */\n\tlength: number;\n\tpositions: Vec2[];\n\t/** Travel direction per frame (radians), held while still. */\n\tangles: number[];\n\tpresses: PressWindow[];\n\tstyles: StyleWindow[];\n\t/** When the pointer is fully visible, and when it starts vanishing. */\n\tshown: number;\n\tvanishAt: number;\n\tcues: {\n\t\tarrivals: Array<{ frame: number; point: Vec2; index: number }>;\n\t\tpresses: Array<{ frame: number; point: Vec2 }>;\n\t\treleases: Array<{ frame: number; point: Vec2 }>;\n\t\tclicks: Array<{\n\t\t\tframe: number;\n\t\t\tpoint: Vec2;\n\t\t\tindex: number;\n\t\t\ttarget?: string;\n\t\t}>;\n\t\tdrags: Array<{ frame: number; end: number; from: Vec2; to: Vec2 }>;\n\t};\n}\n\n// Press timing (frames at 30 fps; scaled by fps).\nconst SETTLE = 4;\nconst PRESS = 3;\nconst HOLD = 2;\nconst RELEASE = 5;\nconst CLICK_GAP = 3;\nconst DWELL = 14;\n\n/** Travel time for a distance, from Fitts' law: quick hops, long sweeps that still feel human. */\nexport function travelFrames(\n\tdistance: number,\n\tfps: number,\n\tfeel: MotionFeel,\n\tspeed: number,\n): number {\n\tconst seconds = 0.2 + 0.13 * Math.log2(1 + distance / 24);\n\tconst scale = feel === \"lazy\" ? 1.4 : feel === \"swift\" ? 0.8 : 1;\n\treturn Math.max(\n\t\t4,\n\t\tMath.round((seconds * scale * fps) / Math.max(0.05, speed)),\n\t);\n}\n\n/** Progress along the curve for t ∈ [0, 1]: quick start, long soft landing. */\nfunction progress(feel: MotionFeel, t: number): number {\n\tswitch (feel) {\n\t\tcase \"linear\":\n\t\t\treturn t;\n\t\tcase \"lazy\":\n\t\t\treturn evaluateEase(\"sine.inOut\", t);\n\t\tcase \"swift\":\n\t\t\treturn minimumJerk(t);\n\t\tcase \"natural\":\n\t\t\t// Skewed minimum-jerk: peak speed early, like a real hand.\n\t\t\treturn minimumJerk(t ** 0.78);\n\t}\n}\n\nconst minimumJerk = (t: number) => t * t * t * (10 - 15 * t + 6 * t * t);\n\n/** Quadratic Bézier point. */\nfunction quad(a: Vec2, c: Vec2, b: Vec2, u: number): Vec2 {\n\tconst v = 1 - u;\n\treturn [\n\t\tv * v * a[0] + 2 * v * u * c[0] + u * u * b[0],\n\t\tv * v * a[1] + 2 * v * u * c[1] + u * u * b[1],\n\t];\n}\n\nexport function planPointer(input: PlanInput): PointerPlan {\n\tconst { fps, seed } = input;\n\tconst f = (framesAt30: number) =>\n\t\tMath.max(1, Math.round((framesAt30 * fps) / 30));\n\tif (input.waypoints.length === 0)\n\t\tthrow new Error(\"MousePointer: `path` needs at least one waypoint.\");\n\n\ttype Segment =\n\t\t| {\n\t\t\t\tkind: \"travel\";\n\t\t\t\tstart: number;\n\t\t\t\tend: number;\n\t\t\t\tfrom: PointAt;\n\t\t\t\tto: PointAt;\n\t\t\t\tindex: number;\n\t\t\t\tpressed: boolean;\n\t\t }\n\t\t| {\n\t\t\t\tkind: \"rest\";\n\t\t\t\tstart: number;\n\t\t\t\tend: number;\n\t\t\t\tat: PointAt;\n\t\t\t\tpressed: boolean;\n\t\t };\n\tconst segments: Segment[] = [];\n\tconst presses: PressWindow[] = [];\n\tconst cues: PointerPlan[\"cues\"] = {\n\t\tarrivals: [],\n\t\tpresses: [],\n\t\treleases: [],\n\t\tclicks: [],\n\t\tdrags: [],\n\t};\n\tconst styleChanges: Array<{ frame: number; style: CursorStyle }> = [\n\t\t{ frame: 0, style: input.style },\n\t];\n\n\tlet cursor = 0; // the next free frame\n\tlet here: PointAt = input.from\n\t\t? constant(input.from)\n\t\t: (input.waypoints[0]?.point ?? constant([0, 0]));\n\tconst appear = Math.max(0, input.appear);\n\n\tinput.waypoints.forEach((wp, index) => {\n\t\tconst isFirst = index === 0;\n\t\tconst needsTravel = !(isFirst && !input.from);\n\t\tlet arrival: number;\n\t\tif (needsTravel) {\n\t\t\tconst from = here;\n\t\t\tconst startFrame = cursor;\n\t\t\tconst d = dist(from(startFrame), wp.point(startFrame));\n\t\t\tconst natural =\n\t\t\t\tstartFrame + travelFrames(d, fps, input.motion, input.speed);\n\t\t\tarrival = wp.at ?? natural;\n\t\t\tif (arrival < startFrame + 2) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`MousePointer: waypoint ${index} arrives at frame ${arrival}, but the pointer is busy until ${startFrame}. Leave at least 2 frames to travel.`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tsegments.push({\n\t\t\t\tkind: \"travel\",\n\t\t\t\tstart: startFrame,\n\t\t\t\tend: arrival,\n\t\t\t\tfrom,\n\t\t\t\tto: wp.point,\n\t\t\t\tindex,\n\t\t\t\tpressed: false,\n\t\t\t});\n\t\t} else {\n\t\t\tarrival = wp.at ?? 0;\n\t\t\tif (arrival > 0)\n\t\t\t\tsegments.push({\n\t\t\t\t\tkind: \"rest\",\n\t\t\t\t\tstart: 0,\n\t\t\t\t\tend: arrival,\n\t\t\t\t\tat: wp.point,\n\t\t\t\t\tpressed: false,\n\t\t\t\t});\n\t\t}\n\t\there = wp.point;\n\t\tcues.arrivals.push({ frame: arrival, point: wp.point(arrival), index });\n\t\tif (wp.cursor) styleChanges.push({ frame: arrival, style: wp.cursor });\n\n\t\tlet t = Math.max(arrival, isFirst ? appear : arrival);\n\t\tif (t > arrival)\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: arrival,\n\t\t\t\tend: t,\n\t\t\t\tat: wp.point,\n\t\t\t\tpressed: false,\n\t\t\t});\n\n\t\tconst press = (at: number, hold: number, click: boolean): PressWindow => {\n\t\t\tconst w = {\n\t\t\t\tdown: at,\n\t\t\t\tpressed: at + f(PRESS),\n\t\t\t\tup: at + f(PRESS) + hold,\n\t\t\t\treleased: at + f(PRESS) + hold + f(RELEASE),\n\t\t\t};\n\t\t\tpresses.push(w);\n\t\t\tcues.presses.push({ frame: w.down, point: here(w.down) });\n\t\t\tcues.releases.push({ frame: w.up, point: here(w.up) });\n\t\t\tif (click)\n\t\t\t\tcues.clicks.push({\n\t\t\t\t\tframe: w.pressed,\n\t\t\t\t\tpoint: here(w.pressed),\n\t\t\t\t\tindex,\n\t\t\t\t\ttarget: wp.targetId,\n\t\t\t\t});\n\t\t\treturn w;\n\t\t};\n\n\t\tif (wp.drag) {\n\t\t\tconst settle = t + f(SETTLE);\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: t,\n\t\t\t\tend: settle,\n\t\t\t\tat: wp.point,\n\t\t\t\tpressed: false,\n\t\t\t});\n\t\t\tconst down = settle;\n\t\t\tconst pressedAt = down + f(PRESS);\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: down,\n\t\t\t\tend: pressedAt,\n\t\t\t\tat: wp.point,\n\t\t\t\tpressed: true,\n\t\t\t});\n\t\t\tconst from = wp.point;\n\t\t\tconst to = wp.drag;\n\t\t\tconst d = dist(from(pressedAt), to(pressedAt));\n\t\t\tconst dragEnd =\n\t\t\t\tpressedAt +\n\t\t\t\tMath.round(travelFrames(d, fps, input.motion, input.speed) * 1.25);\n\t\t\tsegments.push({\n\t\t\t\tkind: \"travel\",\n\t\t\t\tstart: pressedAt,\n\t\t\t\tend: dragEnd,\n\t\t\t\tfrom,\n\t\t\t\tto,\n\t\t\t\tindex,\n\t\t\t\tpressed: true,\n\t\t\t});\n\t\t\there = to;\n\t\t\tconst up = dragEnd + f(SETTLE);\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: dragEnd,\n\t\t\t\tend: up,\n\t\t\t\tat: to,\n\t\t\t\tpressed: true,\n\t\t\t});\n\t\t\tpresses.push({ down, pressed: pressedAt, up, released: up + f(RELEASE) });\n\t\t\tcues.presses.push({ frame: down, point: from(down) });\n\t\t\tcues.drags.push({\n\t\t\t\tframe: pressedAt,\n\t\t\t\tend: dragEnd,\n\t\t\t\tfrom: from(pressedAt),\n\t\t\t\tto: to(dragEnd),\n\t\t\t});\n\t\t\tcues.releases.push({ frame: up, point: to(up) });\n\t\t\tt = up + f(RELEASE);\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: up,\n\t\t\t\tend: t,\n\t\t\t\tat: to,\n\t\t\t\tpressed: false,\n\t\t\t});\n\t\t} else if (wp.clicks && wp.clicks > 0) {\n\t\t\tt += f(SETTLE);\n\t\t\tfor (let c = 0; c < wp.clicks; c++) {\n\t\t\t\tconst w = press(t, wp.hold ?? f(HOLD), true);\n\t\t\t\tt = w.released + (c < wp.clicks - 1 ? f(CLICK_GAP) : 0);\n\t\t\t}\n\t\t\t// Pressed: no idle drift may nudge the pointer mid-click.\n\t\t\tsegments.push({\n\t\t\t\tkind: \"rest\",\n\t\t\t\tstart: arrival,\n\t\t\t\tend: t,\n\t\t\t\tat: wp.point,\n\t\t\t\tpressed: true,\n\t\t\t});\n\t\t}\n\n\t\tconst dwellEnd = t + (wp.dwell ?? f(DWELL));\n\t\tsegments.push({\n\t\t\tkind: \"rest\",\n\t\t\tstart: t,\n\t\t\tend: dwellEnd,\n\t\t\tat: here,\n\t\t\tpressed: false,\n\t\t});\n\t\tcursor = dwellEnd;\n\t});\n\n\tif (input.to) {\n\t\tconst from = here;\n\t\tconst exitPoint = constant(input.to);\n\t\tconst d = dist(from(cursor), input.to);\n\t\tconst end = cursor + travelFrames(d, fps, input.motion, input.speed);\n\t\tsegments.push({\n\t\t\tkind: \"travel\",\n\t\t\tstart: cursor,\n\t\t\tend,\n\t\t\tfrom,\n\t\t\tto: exitPoint,\n\t\t\tindex: input.waypoints.length,\n\t\t\tpressed: false,\n\t\t});\n\t\tcursor = end;\n\t}\n\tconst vanishAt = cursor;\n\tconst length = cursor + (input.to ? 0 : Math.max(0, input.vanish)) + 1;\n\n\t// Curve shape per travel segment: a seeded sideways bend, the way a hand\n\t// arcs instead of drawing rulers.\n\tconst bendOf = (index: number) => {\n\t\tif (input.motion === \"linear\") return 0;\n\t\tconst r = hashRng(seed, `bend:${index}`);\n\t\tconst amount =\n\t\t\tinput.motion === \"lazy\"\n\t\t\t\t? r.range(0.1, 0.18)\n\t\t\t\t: input.motion === \"swift\"\n\t\t\t\t\t? r.range(0.03, 0.07)\n\t\t\t\t\t: r.range(0.05, 0.11);\n\t\treturn r.next() < 0.5 ? -amount : amount;\n\t};\n\n\tconst positions: Vec2[] = [];\n\tconst angles: number[] = [];\n\tconst driftRng = hashRng(seed, \"drift\");\n\tconst [ph0, ph1, ph2, ph3] = [0, 1, 2, 3].map(() =>\n\t\tdriftRng.range(0, Math.PI * 2),\n\t) as [number, number, number, number];\n\tlet lastAngle = -Math.PI / 4;\n\n\tfor (let frame = 0; frame < length; frame++) {\n\t\tconst seg = findSegment(segments, frame);\n\t\tlet p: Vec2;\n\t\tlet moving = false;\n\t\tif (!seg) {\n\t\t\tp = here(frame);\n\t\t} else if (seg.kind === \"travel\") {\n\t\t\tconst t =\n\t\t\t\tseg.end > seg.start ? (frame - seg.start) / (seg.end - seg.start) : 1;\n\t\t\tconst a = seg.from(seg.start);\n\t\t\tconst b = seg.to(frame);\n\t\t\tconst len = dist(a, b);\n\t\t\tconst bend = bendOf(seg.index) * len;\n\t\t\tconst mid: Vec2 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];\n\t\t\tconst nx = len > 0 ? -(b[1] - a[1]) / len : 0;\n\t\t\tconst ny = len > 0 ? (b[0] - a[0]) / len : 0;\n\t\t\tconst control: Vec2 = [mid[0] + nx * bend, mid[1] + ny * bend];\n\t\t\tconst u = progress(input.motion, Math.min(1, Math.max(0, t)));\n\t\t\tp = quad(a, control, b, u);\n\t\t\t// A natural landing overshoots a hair past the target and settles.\n\t\t\tif (input.motion === \"natural\" && len > 60) {\n\t\t\t\tconst over = Math.min(6, len * 0.012);\n\t\t\t\tconst k = Math.max(0, (t - 0.62) / 0.38);\n\t\t\t\tconst bump = Math.sin(Math.PI * Math.min(1, k));\n\t\t\t\tp = [\n\t\t\t\t\tp[0] + ((b[0] - a[0]) / len) * over * bump,\n\t\t\t\t\tp[1] + ((b[1] - a[1]) / len) * over * bump,\n\t\t\t\t];\n\t\t\t}\n\t\t\tmoving = t > 0 && t < 1;\n\t\t} else {\n\t\t\tp = seg.at(frame);\n\t\t\t// Idle drift while resting, never while the button is down.\n\t\t\tif (!seg.pressed && input.drift > 0) {\n\t\t\t\tconst ramp = Math.min(\n\t\t\t\t\t1,\n\t\t\t\t\t(frame - seg.start) / 8,\n\t\t\t\t\t(seg.end - frame) / 8,\n\t\t\t\t);\n\t\t\t\tif (ramp > 0) {\n\t\t\t\t\tconst s = frame / fps;\n\t\t\t\t\tconst dx =\n\t\t\t\t\t\tMath.sin(s * 1.7 + ph0) * 0.6 + Math.sin(s * 0.9 + ph1) * 0.4;\n\t\t\t\t\tconst dy =\n\t\t\t\t\t\tMath.sin(s * 1.3 + ph2) * 0.6 + Math.sin(s * 0.7 + ph3) * 0.4;\n\t\t\t\t\tp = [p[0] + dx * input.drift * ramp, p[1] + dy * input.drift * ramp];\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tconst prev = positions[frame - 1];\n\t\tif (moving && prev && dist(prev, p) > 0.5)\n\t\t\tlastAngle = Math.atan2(p[1] - prev[1], p[0] - prev[0]);\n\t\tpositions.push(p);\n\t\tangles.push(lastAngle);\n\t}\n\n\treturn {\n\t\tlength,\n\t\tpositions,\n\t\tangles,\n\t\tpresses,\n\t\tstyles: styleWindows(positions, styleChanges, input.hoverRegions),\n\t\tshown: appear,\n\t\tvanishAt,\n\t\tcues,\n\t};\n}\n\nfunction constant(p: Vec2): PointAt {\n\treturn () => p;\n}\n\nfunction findSegment<T extends { start: number; end: number }>(\n\tsegments: T[],\n\tframe: number,\n): T | undefined {\n\t// Later segments win on shared frames (a rest that starts where travel ends).\n\tlet found: T | undefined;\n\tfor (const s of segments) if (frame >= s.start && frame <= s.end) found = s;\n\treturn found;\n}\n\n/** Style per frame (base style changes + hover regions), as windows. */\nfunction styleWindows(\n\tpositions: Vec2[],\n\tchanges: Array<{ frame: number; style: CursorStyle }>,\n\tregions: HoverRegion[],\n): StyleWindow[] {\n\tconst sorted = [...changes].sort((a, b) => a.frame - b.frame);\n\tconst windows: StyleWindow[] = [];\n\tlet base = sorted[0]?.style ?? \"arrow\";\n\tlet next = 1;\n\tfor (let frame = 0; frame < positions.length; frame++) {\n\t\twhile (next < sorted.length && (sorted[next]?.frame ?? Infinity) <= frame) {\n\t\t\tbase = sorted[next]?.style ?? base;\n\t\t\tnext++;\n\t\t}\n\t\tconst p = positions[frame] as Vec2;\n\t\tconst region = regions.find(\n\t\t\t(r) =>\n\t\t\t\tp[0] >= r.rect.x &&\n\t\t\t\tp[0] <= r.rect.x + r.rect.width &&\n\t\t\t\tp[1] >= r.rect.y &&\n\t\t\t\tp[1] <= r.rect.y + r.rect.height,\n\t\t);\n\t\tconst style = region?.style ?? base;\n\t\tconst last = windows[windows.length - 1];\n\t\tif (last && last.style === style) last.end = frame;\n\t\telse windows.push({ style, start: frame, end: frame });\n\t}\n\treturn windows;\n}\n"
42
+ }
43
+ ]
44
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "selection",
3
+ "title": "Selection",
4
+ "summary": "A text selection or a marquee driven by a MousePointer drag: it follows the pointer glyph by glyph, and lights up covered items.",
5
+ "tags": [
6
+ "selection",
7
+ "highlight",
8
+ "marquee",
9
+ "drag",
10
+ "editor",
11
+ "ui",
12
+ "pointer"
13
+ ],
14
+ "example": "Selection({ target: { text: paragraph, match: \"the key line\" }, pointer })",
15
+ "parts": [
16
+ "line-0",
17
+ "marquee",
18
+ "item-0"
19
+ ],
20
+ "cues": [
21
+ "selected",
22
+ "items"
23
+ ],
24
+ "registryDependencies": [],
25
+ "files": [
26
+ {
27
+ "path": "selection/index.ts",
28
+ "content": "/**\n * Selection: a text selection or a marquee, the way an editor draws them,\n * driven by a `MousePointer` drag. Text selections follow the pointer glyph\n * by glyph; a marquee follows it corner by corner and lights up the items it\n * covers.\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\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\tevaluateEase,\n\tlineShift,\n\trangeRects,\n\ttype Rect,\n\tresolveTarget,\n\tscaleFrames,\n\ttype Target,\n\ttype TargetNode,\n\ttargetSchema,\n\ttextLayout,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport interface SelectionCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The selection is complete (the drag ends). */\n\tselected: ReadonlyArray<{ frame: number }>;\n\t/** Marquee items, as each is first covered. */\n\titems: ReadonlyArray<{ frame: number; index: number }>;\n}\n\n/** What Selection reads from a pointer: `MousePointer` satisfies it. */\nexport interface SelectionPointer {\n\tpositionAt(frame: number): Vec2;\n\tcues?: { drags?: ReadonlyArray<{ frame: number; end: number }> };\n}\n\nexport const selection = /* @__PURE__ */ defineComponent({\n\tname: \"Selection\",\n\tprops: () => ({\n\t\tkind: z.enum([\"text\", \"marquee\"]).default(\"text\"),\n\t\t/** Text to select: `{ text, match? | range? }`. */\n\t\ttarget: z\n\t\t\t.custom<{\n\t\t\t\ttext: object;\n\t\t\t\tmatch?: string | RegExp;\n\t\t\t\trange?: [number, number];\n\t\t\t\tnth?: number;\n\t\t\t}>((v) => typeof v === \"object\" && v !== null && \"text\" in v, {\n\t\t\t\tmessage: \"Expected a text target `{ text, match? | range? }`\",\n\t\t\t})\n\t\t\t.optional(),\n\t\t/** A MousePointer to follow during the drag. */\n\t\tpointer: z\n\t\t\t.custom<SelectionPointer>(\n\t\t\t\t(v) =>\n\t\t\t\t\ttypeof v === \"object\" &&\n\t\t\t\t\tv !== null &&\n\t\t\t\t\ttypeof (v as SelectionPointer).positionAt === \"function\",\n\t\t\t\t{ message: \"Expected a MousePointer (anything with positionAt)\" },\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** Drag frames (parent timeline). Default: the pointer's first drag. */\n\t\tdrag: z\n\t\t\t.object({ frame: z.number().int().min(0), end: z.number().int().min(0) })\n\t\t\t.passthrough()\n\t\t\t.optional(),\n\t\t/** Marquee corners (parent coordinates). Default: the pointer's. */\n\t\tfrom: z.tuple([z.number(), z.number()]).optional(),\n\t\tto: z.tuple([z.number(), z.number()]).optional(),\n\t\t/** Marquee: these light up once covered. */\n\t\titems: z.array(targetSchema()).default([]),\n\t\t/** Frame (parent timeline) the selection disappears. */\n\t\tclear: z.number().int().min(0).optional(),\n\t\tcolor: 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 color = props.color ?? theme.color.accent;\n\t\tconst at0 = ctx.requested.at;\n\t\tconst drag = props.drag ?? props.pointer?.cues?.drags?.[0];\n\t\tif (!drag)\n\t\t\tthrow new Error(\n\t\t\t\t\"Selection: give `drag: { frame, end }`, or a `pointer` that drags.\",\n\t\t\t);\n\t\t// Local frames: the component's own timeline.\n\t\tconst start = drag.frame - at0;\n\t\tconst end = Math.max(start + 1, drag.end - at0);\n\t\tconst progress = (f: number) =>\n\t\t\tevaluateEase(\n\t\t\t\t\"power2.inOut\",\n\t\t\t\tMath.max(0, Math.min(1, (f - start) / (end - start))),\n\t\t\t);\n\t\tconst pointerAt = (f: number) => props.pointer?.positionAt(f + at0);\n\t\tconst fade = scaleFrames(8, fps);\n\t\tconst clear = props.clear !== undefined ? props.clear - at0 : undefined;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tconst cues: SelectionCues = {\n\t\t\tselected: [{ frame: end }],\n\t\t\titems: [] as Array<{ frame: number; index: number }>,\n\t\t};\n\t\tif (props.kind === \"text\") {\n\t\t\tif (!props.target)\n\t\t\t\tthrow new Error(\"Selection: a text selection needs a `target`.\");\n\t\t\tconst t = textLayout(\n\t\t\t\tprops.target as Extract<Target, { text: TargetNode }>,\n\t\t\t\t{ measure: host.measure, fps },\n\t\t\t);\n\t\t\tconst [r0, r1] = t.range;\n\t\t\tconst glyphs = t.metrics.glyphs.filter(\n\t\t\t\t(g) => g.index >= r0 && g.index < r1,\n\t\t\t);\n\t\t\t// The selection's end (a string index) on frame f.\n\t\t\tconst endIndex = (f: number): number => {\n\t\t\t\tif (f <= start) return r0;\n\t\t\t\tconst p = pointerAt(Math.min(f, end));\n\t\t\t\tif (!p) return r0 + Math.round(progress(f) * (r1 - r0));\n\t\t\t\t// The line under the pointer, then the glyph boundary nearest it.\n\t\t\t\tconst lh = t.metrics.lineHeight;\n\t\t\t\tconst lines = [...new Set(glyphs.map((g) => g.line))];\n\t\t\t\tconst want = Math.floor((p[1] - t.y) / lh);\n\t\t\t\tconst line = Math.max(\n\t\t\t\t\tlines[0] as number,\n\t\t\t\t\tMath.min(lines[lines.length - 1] as number, want),\n\t\t\t\t);\n\t\t\t\tconst shift = lineShift(t.metrics, line, t.options);\n\t\t\t\tlet best = r0;\n\t\t\t\tlet bestD = Number.POSITIVE_INFINITY;\n\t\t\t\tfor (const g of glyphs) {\n\t\t\t\t\tif (g.line !== line) continue;\n\t\t\t\t\tfor (const [edge, idx] of [\n\t\t\t\t\t\t[g.x, g.index],\n\t\t\t\t\t\t[g.x + g.width, g.index + 1],\n\t\t\t\t\t] as const) {\n\t\t\t\t\t\tconst d = Math.abs(t.x + shift + edge - p[0]);\n\t\t\t\t\t\tif (d < bestD) {\n\t\t\t\t\t\t\tbestD = d;\n\t\t\t\t\t\t\tbest = idx;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Below the range's lines selects to the end; above, to the start.\n\t\t\t\tif (want > (lines[lines.length - 1] as number)) return r1;\n\t\t\t\tif (want < (lines[0] as number)) return r0;\n\t\t\t\treturn Math.max(r0, Math.min(r1, best));\n\t\t\t};\n\t\t\tconst full = rangeRects(t.metrics, r0, r1, t.options);\n\t\t\tconst lineOf = (rect: Rect) => Math.round(rect.y / t.metrics.lineHeight);\n\t\t\tfull.forEach((rect, i) => {\n\t\t\t\tconst line = lineOf(rect);\n\t\t\t\tconst widthAt = (f: number) => {\n\t\t\t\t\tconst part = rangeRects(t.metrics, r0, endIndex(f), t.options).find(\n\t\t\t\t\t\t(r) => lineOf(r) === line,\n\t\t\t\t\t);\n\t\t\t\t\treturn part ? round(part.x + part.width - rect.x) : 0;\n\t\t\t\t};\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, 0]];\n\t\t\t\tlet last = 0;\n\t\t\t\tfor (let f = start; f <= end; f++) {\n\t\t\t\t\tconst w = widthAt(f);\n\t\t\t\t\tif (w !== last) {\n\t\t\t\t\t\tkeys.push([f, w, \"hold\"]);\n\t\t\t\t\t\tlast = w;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst pad = Math.max(1, 2 * s);\n\t\t\t\tconst box = host.box({\n\t\t\t\t\tid: ctx.partId(`line-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: t.x + rect.x,\n\t\t\t\t\ty: t.y + rect.y + pad,\n\t\t\t\t\twidth: 0,\n\t\t\t\t\theight: rect.height - pad * 2,\n\t\t\t\t\tbackground: withAlpha(color, 0.32),\n\t\t\t\t\tborderRadius: Math.max(1, 3 * s),\n\t\t\t\t});\n\t\t\t\tconst anim = LayerAnimation.create().keys(\"width\", keys);\n\t\t\t\tif (clear !== undefined)\n\t\t\t\t\tanim.keys(\"opacity\", [\n\t\t\t\t\t\t[clear, 1],\n\t\t\t\t\t\t[clear + fade, 0, \"power2.out\"],\n\t\t\t\t\t]);\n\t\t\t\tbox.animate(anim);\n\t\t\t\tparts[`line-${i}`] = box;\n\t\t\t\tchildren.push(box);\n\t\t\t});\n\t\t} else {\n\t\t\tconst from: Vec2 = props.from ?? pointerAt(start) ?? [W * 0.3, H * 0.3];\n\t\t\tconst to: Vec2 = props.to ?? pointerAt(end) ?? [W * 0.7, H * 0.7];\n\t\t\tconst corner = (f: number): Vec2 => {\n\t\t\t\tconst p = props.to ? undefined : pointerAt(Math.min(f, end));\n\t\t\t\tif (p) return p;\n\t\t\t\tconst k = progress(f);\n\t\t\t\treturn [\n\t\t\t\t\tfrom[0] + (to[0] - from[0]) * k,\n\t\t\t\t\tfrom[1] + (to[1] - from[1]) * k,\n\t\t\t\t];\n\t\t\t};\n\t\t\tconst rectAt = (f: number): Rect => {\n\t\t\t\tconst c = corner(f);\n\t\t\t\treturn {\n\t\t\t\t\tx: Math.min(from[0], c[0]),\n\t\t\t\t\ty: Math.min(from[1], c[1]),\n\t\t\t\t\twidth: Math.abs(c[0] - from[0]),\n\t\t\t\t\theight: Math.abs(c[1] - from[1]),\n\t\t\t\t};\n\t\t\t};\n\t\t\tconst frames = Array.from({ length: end - start + 1 }, (_, k) =>\n\t\t\t\trectAt(start + k),\n\t\t\t);\n\t\t\tconst track = (k: keyof Rect) =>\n\t\t\t\tbakeSamples(\n\t\t\t\t\tframes.map((r) => r[k]),\n\t\t\t\t\tstart,\n\t\t\t\t\t{ tolerance: 0.3 },\n\t\t\t\t);\n\t\t\tconst box = host.box({\n\t\t\t\tid: ctx.partId(\"marquee\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: from[0],\n\t\t\t\ty: from[1],\n\t\t\t\twidth: 0,\n\t\t\t\theight: 0,\n\t\t\t\tbackground: withAlpha(color, 0.16),\n\t\t\t\tborderColor: 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\tbox.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", track(\"x\"))\n\t\t\t\t\t.keys(\"y\", track(\"y\"))\n\t\t\t\t\t.keys(\"width\", track(\"width\"))\n\t\t\t\t\t.keys(\"height\", track(\"height\"))\n\t\t\t\t\t// The marquee goes on release; what it selected stays.\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[start, 1, \"hold\"],\n\t\t\t\t\t\t[end, 1],\n\t\t\t\t\t\t[end + fade, 0, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts.marquee = box;\n\n\t\t\tprops.items.forEach((item, index) => {\n\t\t\t\tconst r = boundsOf(\n\t\t\t\t\t(() => {\n\t\t\t\t\t\tconst res = resolveTarget(item, { measure: host.measure, fps });\n\t\t\t\t\t\treturn res.kind === \"static\" ? res.rects : [res.at(end)];\n\t\t\t\t\t})(),\n\t\t\t\t);\n\t\t\t\tconst hits = (m: Rect) =>\n\t\t\t\t\tm.x < r.x + r.width &&\n\t\t\t\t\tm.x + m.width > r.x &&\n\t\t\t\t\tm.y < r.y + r.height &&\n\t\t\t\t\tm.y + m.height > r.y;\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, 0]];\n\t\t\t\tlet on = false;\n\t\t\t\tframes.forEach((m, k) => {\n\t\t\t\t\tconst now = m.width > 0 && m.height > 0 && hits(m);\n\t\t\t\t\tif (now !== on) {\n\t\t\t\t\t\tkeys.push([start + k, now ? 1 : 0, \"hold\"]);\n\t\t\t\t\t\tif (now && !cues.items.some((c) => c.index === index))\n\t\t\t\t\t\t\t(cues.items as Array<{ frame: number; index: number }>).push({\n\t\t\t\t\t\t\t\tframe: start + k,\n\t\t\t\t\t\t\t\tindex,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\ton = now;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t\tif (on && clear !== undefined)\n\t\t\t\t\tkeys.push([clear, 1], [clear + fade, 0, \"power2.out\"]);\n\t\t\t\tconst pad = 4 * s;\n\t\t\t\tconst glow = host.box({\n\t\t\t\t\tid: ctx.partId(`item-${index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: r.x - pad,\n\t\t\t\t\ty: r.y - pad,\n\t\t\t\t\twidth: r.width + pad * 2,\n\t\t\t\t\theight: r.height + pad * 2,\n\t\t\t\t\tbackground: withAlpha(color, 0.18),\n\t\t\t\t\tborderColor: color,\n\t\t\t\t\tborderWidth: Math.max(1.5, 2 * s),\n\t\t\t\t\tborderRadius: theme.radius.sm + pad,\n\t\t\t\t\topacity: 0,\n\t\t\t\t});\n\t\t\t\tglow.animate(LayerAnimation.keys(\"opacity\", keys));\n\t\t\t\tparts[`item-${index}`] = glow;\n\t\t\t\tchildren.push(glow);\n\t\t\t});\n\t\t\tchildren.push(box);\n\t\t}\n\n\t\tconst settle = props.kind === \"marquee\" ? end + fade : end;\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: clear !== undefined ? clear + fade : settle,\n\t\t\topenEnded: clear === undefined,\n\t\t\torigin: [0, 0],\n\t\t\tcues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 100) / 100;\n\n/** `#rrggbb` / `#rgb` at alpha `a`; other colours pass through. */\nfunction withAlpha(color: string, a: number): string {\n\tconst hex = color.trim().replace(/^#/, \"\");\n\tconst full =\n\t\thex.length === 3 ? hex.replace(/./g, (x) => x + x) : hex.slice(0, 6);\n\tif (!/^[0-9a-f]{6}$/i.test(full)) return color;\n\tconst n = Number.parseInt(full, 16);\n\treturn `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;\n}\n\n/**\n * A text selection or a marquee driven by a MousePointer drag: it follows the\n * pointer glyph by glyph, and lights up covered items.\n */\nexport const Selection = /* @__PURE__ */ bindComponent(selection);\n"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "spotlight",
3
+ "title": "Spotlight",
4
+ "summary": "Dims the frame except a region that glides between targets, as a crisp rounded rect or a soft pool of light.",
5
+ "tags": [
6
+ "spotlight",
7
+ "focus",
8
+ "dim",
9
+ "highlight",
10
+ "tutorial",
11
+ "ui"
12
+ ],
13
+ "example": "Spotlight({ targets: [{ at: 0, target: button }, { at: 60, target: menu }] })",
14
+ "parts": [
15
+ "dim",
16
+ "hole"
17
+ ],
18
+ "cues": [
19
+ "moves"
20
+ ],
21
+ "registryDependencies": [],
22
+ "files": [
23
+ {
24
+ "path": "spotlight/index.ts",
25
+ "content": "/**\n * Spotlight: dims the frame except a region that glides between targets.\n * The dim layer is masked by an inverted matte (the hole), keyed between\n * target rects; a `\"full\"` step lifts the dim.\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\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\ttype Rect,\n\tresolveTarget,\n\ttargetSchema,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface SpotlightCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each move of the light, start to settle. */\n\tmoves: ReadonlyArray<{ frame: number; end: number }>;\n}\n\nexport const spotlight = /* @__PURE__ */ defineComponent({\n\tname: \"Spotlight\",\n\tprops: () => ({\n\t\t/** Where the light goes and when; `\"full\"` lifts the dim. */\n\t\ttargets: 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\ttarget: z.union([z.literal(\"full\"), targetSchema()]),\n\t\t\t\t\t\tpadding: z.number().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\t/** How dark the rest gets, 0–1. */\n\t\tdim: z.number().min(0).max(1).default(0.68),\n\t\tcolor: z.string().default(\"#000000\"),\n\t\t/** `rect`: rounded, crisp. `ellipse`: soft-edged pool of light. */\n\t\tshape: z.enum([\"rect\", \"ellipse\"]).default(\"rect\"),\n\t\tradius: z.number().min(0).optional(),\n\t\t/** Soft edge of the ellipse, px. */\n\t\tfeather: z.number().min(0).optional(),\n\t\t/** Frames each move takes. */\n\t\tglide: z.number().int().positive().optional(),\n\t\t/** Frames to follow a moving target before the next step. Default 6 seconds. */\n\t\tfollow: z.number().int().positive().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 glide =\n\t\t\tprops.glide ?? Math.round(themeFrames(theme, \"base\", fps) * 1.6);\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst feather = props.feather ?? 60 * s;\n\t\tconst ellipse = props.shape === \"ellipse\";\n\t\tconst steps = [...props.targets].sort((a, b) => a.at - b.at);\n\n\t\t// The hole's rect at each step (moving targets: one per frame until the next step).\n\t\tconst keys = {\n\t\t\tx: [] as KeyframeTuple[],\n\t\t\ty: [] as KeyframeTuple[],\n\t\t\twidth: [] as KeyframeTuple[],\n\t\t\theight: [] as KeyframeTuple[],\n\t\t};\n\t\tconst dimKeys: KeyframeTuple[] = [[0, 0]];\n\t\tconst moves: Array<{ frame: number; end: number }> = [];\n\t\tlet lit = false;\n\t\tlet current: Rect | null = null;\n\t\tconst grow = (r: Rect, pad: number): Rect => {\n\t\t\t// An ellipse needs room around the rect to contain it, plus its soft edge.\n\t\t\tconst k = ellipse ? Math.SQRT2 : 1;\n\t\t\tconst w = r.width * k + pad * 2 + (ellipse ? feather * 2 : 0);\n\t\t\tconst h = r.height * k + pad * 2 + (ellipse ? feather * 2 : 0);\n\t\t\treturn {\n\t\t\t\tx: r.x + r.width / 2 - w / 2,\n\t\t\t\ty: r.y + r.height / 2 - h / 2,\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t};\n\t\t};\n\t\tconst put = (f: number, r: Rect, ease_?: string) => {\n\t\t\tfor (const k of [\"x\", \"y\", \"width\", \"height\"] as const)\n\t\t\t\tkeys[k].push(ease_ ? [f, r[k], ease_] : [f, r[k]]);\n\t\t};\n\n\t\tsteps.forEach((step, i) => {\n\t\t\tconst next = steps[i + 1]?.at ?? step.at + (props.follow ?? fps * 6);\n\t\t\tif (step.target === \"full\") {\n\t\t\t\tif (lit)\n\t\t\t\t\tdimKeys.push(\n\t\t\t\t\t\t[step.at, props.dim],\n\t\t\t\t\t\t[step.at + glide, 0, \"power2.inOut\"],\n\t\t\t\t\t);\n\t\t\t\tlit = false;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst pad = step.padding ?? 16 * s;\n\t\t\tconst resolved = resolveTarget(step.target, {\n\t\t\t\tmeasure: host.measure,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tconst rectAt = (f: number) =>\n\t\t\t\tgrow(\n\t\t\t\t\tresolved.kind === \"static\"\n\t\t\t\t\t\t? boundsOf(resolved.rects)\n\t\t\t\t\t\t: resolved.at(f),\n\t\t\t\t\tpad,\n\t\t\t\t);\n\t\t\tconst first = rectAt(step.at + glide);\n\t\t\tif (!current) {\n\t\t\t\t// The first light appears where it lands, as the dim comes up.\n\t\t\t\tput(step.at, first);\n\t\t\t} else {\n\t\t\t\tput(step.at, current);\n\t\t\t\tput(step.at + glide, first, ease);\n\t\t\t}\n\t\t\tif (!lit)\n\t\t\t\tdimKeys.push(\n\t\t\t\t\t[step.at, 0],\n\t\t\t\t\t[step.at + glide, props.dim, \"power2.inOut\"],\n\t\t\t\t);\n\t\t\tlit = true;\n\t\t\tmoves.push({ frame: step.at, end: step.at + glide });\n\t\t\tcurrent = first;\n\t\t\tif (resolved.kind === \"dynamic\") {\n\t\t\t\t// Ride the moving target until the next step.\n\t\t\t\tconst from = step.at + glide;\n\t\t\t\tconst samples = Array.from(\n\t\t\t\t\t{ length: Math.max(1, next - from) },\n\t\t\t\t\t(_, k) => rectAt(from + k),\n\t\t\t\t);\n\t\t\t\tfor (const k of [\"x\", \"y\", \"width\", \"height\"] as const)\n\t\t\t\t\tfor (const [f, v] of bakeSamples(\n\t\t\t\t\t\tsamples.map((r) => r[k]),\n\t\t\t\t\t\tfrom,\n\t\t\t\t\t\t{ tolerance: 0.4 },\n\t\t\t\t\t).slice(1))\n\t\t\t\t\t\tkeys[k].push([f, v]);\n\t\t\t\tcurrent = samples[samples.length - 1] ?? first;\n\t\t\t}\n\t\t});\n\n\t\tconst start = current ?? { x: W / 2, y: H / 2, width: 0, height: 0 };\n\t\tconst firstKey = (k: \"x\" | \"y\" | \"width\" | \"height\") =>\n\t\t\t(keys[k][0]?.[1] as number | undefined) ?? start[k];\n\t\tconst radius = props.radius ?? 18 * s;\n\t\tconst hole = host.box({\n\t\t\tid: ctx.partId(\"hole\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: firstKey(\"x\"),\n\t\t\ty: firstKey(\"y\"),\n\t\t\twidth: firstKey(\"width\"),\n\t\t\theight: firstKey(\"height\"),\n\t\t\tbackground: ellipse\n\t\t\t\t? `radial-gradient(closest-side, #ffffff ${Math.max(0, 100 - (feather / Math.max(1, Math.min(firstKey(\"width\"), firstKey(\"height\")) / 2)) * 100).toFixed(1)}%, rgba(255,255,255,0) 100%)`\n\t\t\t\t: \"#ffffff\",\n\t\t\t...(!ellipse && { borderRadius: radius }),\n\t\t});\n\t\thole.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"x\", ascending(keys.x))\n\t\t\t\t.keys(\"y\", ascending(keys.y))\n\t\t\t\t.keys(\"width\", ascending(keys.width))\n\t\t\t\t.keys(\"height\", ascending(keys.height)),\n\t\t);\n\t\tconst shade = host.box({\n\t\t\tid: ctx.partId(\"dim\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tbackground: props.color,\n\t\t\topacity: 0,\n\t\t});\n\t\tshade.animate(LayerAnimation.keys(\"opacity\", ascending(dimKeys)));\n\t\thost.matte(shade, hole, \"alpha-inverted\");\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = { dim: shade, hole };\n\n\t\tconst lastStep = steps[steps.length - 1] as (typeof steps)[number];\n\t\treturn {\n\t\t\tchildren: [shade],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: lastStep.at + glide,\n\t\t\topenEnded: true,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { moves } as SpotlightCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of keys) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out.length > 0 ? out : [[0, 0]];\n}\n\n/**\n * Dims the frame except a region that glides between targets, as a crisp\n * rounded rect or a soft pool of light.\n */\nexport const Spotlight = /* @__PURE__ */ bindComponent(spotlight);\n"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "terminal",
3
+ "title": "Terminal",
4
+ "summary": "A scripted shell session: commands typed with a human rhythm, streaming output, spinners that resolve to check marks, progress bars and scrolling, with a cue for every keystroke.",
5
+ "tags": [
6
+ "terminal",
7
+ "shell",
8
+ "cli",
9
+ "developer",
10
+ "demo",
11
+ "typing"
12
+ ],
13
+ "example": "Terminal({ script: [{ cmd: \"pnpm add framefields\" }, { spinner: \"Installing\", for: 40, done: \"1 package added\" }] })",
14
+ "parts": [
15
+ "window",
16
+ "screen",
17
+ "lines",
18
+ "caret"
19
+ ],
20
+ "cues": [
21
+ "keystrokes",
22
+ "submits",
23
+ "outputs",
24
+ "done"
25
+ ],
26
+ "registryDependencies": [
27
+ "app-frame",
28
+ "counter"
29
+ ],
30
+ "files": [
31
+ {
32
+ "path": "terminal/index.ts",
33
+ "content": "/**\n * Terminal: a scripted shell session. Commands type with a human rhythm,\n * output streams in, spinners resolve to check marks, progress bars fill,\n * and the view scrolls by whole lines, all on cue. Wrapped in an `AppFrame`\n * terminal window by default.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n\ttype TextSpan,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\ttype Theme,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport { appFrame } from \"../app-frame/index.js\";\nimport { counter } from \"../counter/index.js\";\nimport {\n\tplanTerminal,\n\tSCROLL_GLIDE,\n\ttype Span,\n\ttype Step,\n\tspansText,\n} from \"./plan.js\";\n\nexport type { Step as TerminalStep } from \"./plan.js\";\n\nexport interface TerminalCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Every typed character: for key sounds. */\n\tkeystrokes: ReadonlyArray<{ frame: number; index: number; step: number }>;\n\t/** Enter, per command. */\n\tsubmits: ReadonlyArray<{ frame: number; step: number }>;\n\t/** Each output step, first line to last. */\n\toutputs: ReadonlyArray<{ frame: number; end: number; step: number }>;\n\t/** The script has finished. */\n\tdone: ReadonlyArray<{ frame: number }>;\n}\n\n/** The 16-colour palette, from theme tokens where they exist. */\nfunction palette(theme: Theme): Record<string, string> {\n\tconst c = theme.color;\n\treturn {\n\t\tblack: \"#1b1d23\",\n\t\tred: c.negative,\n\t\tgreen: c.positive,\n\t\tyellow: c.highlight,\n\t\tblue: c.accent,\n\t\tmagenta: c.accent2,\n\t\tcyan: \"#4cc9e0\",\n\t\twhite: c.ink,\n\t\tgray: c.muted,\n\t\tdim: c.muted,\n\t\taccent: c.accent,\n\t};\n}\n\nfunction stepSchema() {\n\tconst frames = z.number().int().positive();\n\treturn z.union([\n\t\tz.object({ cmd: z.string(), prompt: z.string().optional() }).strict(),\n\t\tz.object({ output: z.union([z.string(), z.array(z.string())]) }).strict(),\n\t\tz\n\t\t\t.object({ spinner: z.string(), for: frames, done: z.string().optional() })\n\t\t\t.strict(),\n\t\tz.object({ progress: z.string(), for: frames }).strict(),\n\t\tz.object({ pause: frames }).strict(),\n\t\tz.object({ clear: z.literal(true) }).strict(),\n\t]);\n}\n\nexport const terminal = /* @__PURE__ */ defineComponent({\n\tname: \"Terminal\",\n\tprops: () => ({\n\t\tscript: z.array(stepSchema()).min(1),\n\t\tprompt: z.string().default(\"$ \"),\n\t\t/** Typing speed, characters a second (seeded ±30 % jitter). */\n\t\tcps: z.number().positive().default(18),\n\t\t/** Output lines a second, or `\"instant\"`. */\n\t\toutputRate: z\n\t\t\t.union([z.number().positive(), z.literal(\"instant\")])\n\t\t\t.default(40),\n\t\trows: z.number().int().positive().default(16),\n\t\t/** Columns. Default: the widest line, between 40 and 100. */\n\t\tcols: z.number().int().positive().optional(),\n\t\t/** `{green}ok{/}`-style colours (black red green yellow blue magenta cyan white gray dim accent, bold). */\n\t\tmarkup: z.boolean().default(true),\n\t\t/** Wrap in an AppFrame terminal window (`false` for the bare screen). */\n\t\tframe: z\n\t\t\t.union([\n\t\t\t\tz.boolean(),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\ttitle: z.string().optional(),\n\t\t\t\t\t\televation: z.number().int().min(0).max(3).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default(true),\n\t\tcaret: z.enum([\"block\", \"bar\", \"underline\"]).default(\"block\"),\n\t\tfontFamily: z.string().optional(),\n\t\tfontSize: z.number().positive().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 fs = props.fontSize ?? 24 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.mono;\n\t\tconst colors = palette(theme);\n\t\tconst LINE_HEIGHT = 1.5;\n\t\tconst glyph = host.measure(\"M\", {\n\t\t\tfontFamily,\n\t\t\tfontSize: fs,\n\t\t\tlineHeight: LINE_HEIGHT,\n\t\t});\n\t\tconst adv = glyph.width;\n\t\tconst lh = glyph.lineHeight;\n\n\t\tconst plan = planTerminal(props.script as Step[], {\n\t\t\tprompt: props.prompt,\n\t\t\tcps: props.cps,\n\t\t\toutputRate: props.outputRate,\n\t\t\trows: props.rows,\n\t\t\tfps,\n\t\t\tmarkup: props.markup,\n\t\t\trng: ctx.rng(\"typing\"),\n\t\t});\n\t\tconst cols = props.cols ?? Math.min(100, Math.max(40, plan.widest + 2));\n\t\tconst pad = fs * 0.9;\n\t\tconst viewW = cols * adv + pad * 2;\n\t\tconst viewH = props.rows * lh + pad * 2;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst content: LayoutNode[] = [];\n\t\t/** A node drawn from `from` until `until` (local frames). */\n\t\tconst live = (from: number, until: number) => ({\n\t\t\tstartFrame: from,\n\t\t\t...(Number.isFinite(until) && {\n\t\t\t\tdurationFrames: Math.max(1, until - from),\n\t\t\t}),\n\t\t});\n\t\tconst toSpans = (spans: readonly Span[]): TextSpan[] =>\n\t\t\tspans.map((sp) => ({\n\t\t\t\ttext: sp.text,\n\t\t\t\tfill: sp.color\n\t\t\t\t\t? (colors[sp.color] ?? theme.color.ink)\n\t\t\t\t\t: theme.color.ink,\n\t\t\t\t...(sp.bold && { fontWeight: 700 }),\n\t\t\t}));\n\n\t\t// ── Lines ─────────────────────────────────────────────────────────\n\t\tplan.lines.forEach((line, i) => {\n\t\t\tconst node = host.text(spansText(line.spans), {\n\t\t\t\tid: ctx.partId(`line-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: (line.indent ?? 0) * adv,\n\t\t\t\ty: line.row * lh,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tlineHeight: LINE_HEIGHT,\n\t\t\t\tfill: theme.color.ink,\n\t\t\t\tspans: toSpans(line.spans),\n\t\t\t\t...live(line.appear, line.until),\n\t\t\t});\n\t\t\tif (line.typing) {\n\t\t\t\tconst total = spansText(line.spans).length;\n\t\t\t\t// The text track is a reveal amount: ≤ 1 a fraction, > 1 a count.\n\t\t\t\tconst count = (c: number) => (c <= 1 ? c / total : c);\n\t\t\t\tconst shown = line.typing.prompt;\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, count(shown)]];\n\t\t\t\tline.typing.frames.forEach((f, k) => {\n\t\t\t\t\tkeys.push([f - line.appear, count(shown + k + 1), \"hold\"]);\n\t\t\t\t});\n\t\t\t\tnode.animate(LayerAnimation.keys(\"text\" as never, keys));\n\t\t\t}\n\t\t\tcontent.push(node);\n\t\t});\n\n\t\t// ── Spinners, checks, progress ────────────────────────────────────\n\t\tconst cell = (row: number, col: number) => ({ x: col * adv, y: row * lh });\n\t\tplan.spinners.forEach((sp, i) => {\n\t\t\tconst r = fs * 0.32;\n\t\t\tconst c: Vec2 = [adv / 2, lh / 2];\n\t\t\tconst arc: Vec2[] = Array.from({ length: 25 }, (_, k) => {\n\t\t\t\tconst a = (k / 24) * Math.PI * 1.5;\n\t\t\t\treturn [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r];\n\t\t\t});\n\t\t\tconst spinner = host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`spinner-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...cell(sp.row, 0),\n\t\t\t\twidth: adv,\n\t\t\t\theight: lh,\n\t\t\t\td: toPathData(arc),\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: colors.accent,\n\t\t\t\tstrokeWidth: fs * 0.1,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\t...live(sp.from, sp.to),\n\t\t\t});\n\t\t\tspinner.animate(\n\t\t\t\tLayerAnimation.keys(\"rotation\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[sp.to - sp.from, (360 * (sp.to - sp.from)) / (fps * 0.8), \"none\"],\n\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(spinner);\n\t\t});\n\t\tplan.checks.forEach((ck, i) => {\n\t\t\tconst line = plan.lines.find(\n\t\t\t\t(l) => l.kind === \"done\" && l.row === ck.row && l.appear === ck.at,\n\t\t\t);\n\t\t\tconst pts: Vec2[] = [\n\t\t\t\t[adv * 0.12, lh * 0.52],\n\t\t\t\t[adv * 0.4, lh * 0.72],\n\t\t\t\t[adv * 0.9, lh * 0.28],\n\t\t\t];\n\t\t\tconst check = host.shape(\"path\", {\n\t\t\t\tid: ctx.partId(`check-${i}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...cell(ck.row, 0),\n\t\t\t\twidth: adv,\n\t\t\t\theight: lh,\n\t\t\t\td: toPathData(pts),\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: colors.green,\n\t\t\t\tstrokeWidth: fs * 0.12,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\ttrimEnd: 0,\n\t\t\t\t...live(ck.at, line?.until ?? Number.POSITIVE_INFINITY),\n\t\t\t});\n\t\t\tcheck.animate(\n\t\t\t\tLayerAnimation.keys(\"trimEnd\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[Math.max(3, Math.round(fps * 0.2)), 1, \"power2.out\"],\n\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(check);\n\t\t});\n\t\tconst makeCounter = bind(host, counter);\n\t\tplan.progress.forEach((pg, i) => {\n\t\t\tconst line = plan.lines.find(\n\t\t\t\t(l) => l.kind === \"progress\" && l.row === pg.row,\n\t\t\t);\n\t\t\tconst barCols = 24;\n\t\t\tconst x0 = (pg.label.length + 1) * adv;\n\t\t\tconst barW = barCols * adv;\n\t\t\tconst barH = Math.max(4, lh * 0.32);\n\t\t\tconst y = (lh - barH) / 2;\n\t\t\tconst length = pg.to - pg.from;\n\t\t\tconst fill = host.box({\n\t\t\t\tid: ctx.partId(`progress-${i}-fill`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: 0,\n\t\t\t\theight: barH,\n\t\t\t\tbackground: colors.accent,\n\t\t\t\tborderRadius: barH / 2,\n\t\t\t});\n\t\t\tfill.animate(\n\t\t\t\tLayerAnimation.keys(\"width\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[length, barW, \"power1.inOut\"],\n\t\t\t\t]),\n\t\t\t);\n\t\t\tconst track = host.box({\n\t\t\t\tid: ctx.partId(`progress-${i}-track`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: x0,\n\t\t\t\ty,\n\t\t\t\twidth: barW,\n\t\t\t\theight: barH,\n\t\t\t\tbackground: theme.color.line,\n\t\t\t\tborderRadius: barH / 2,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tchildren: [fill],\n\t\t\t});\n\t\t\tconst percent = makeCounter({\n\t\t\t\tid: ctx.partId(`progress-${i}-percent`),\n\t\t\t\tto: 100,\n\t\t\t\tsuffix: \"%\",\n\t\t\t\tstyle: \"count\",\n\t\t\t\tcountDuration: length,\n\t\t\t\tease: \"power1.inOut\",\n\t\t\t\tdigitStagger: 0,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t\tx: x0 + barW + adv,\n\t\t\t\ty:\n\t\t\t\t\t(lh - host.measure(\"0\", { fontFamily, fontSize: fs }).lineHeight) / 2,\n\t\t\t\ttheme,\n\t\t\t});\n\t\t\tcontent.push(\n\t\t\t\thost.box({\n\t\t\t\t\tid: ctx.partId(`progress-${i}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t...cell(pg.row, 0),\n\t\t\t\t\twidth: viewW,\n\t\t\t\t\theight: lh,\n\t\t\t\t\tchildren: [track, percent],\n\t\t\t\t\t...live(pg.from, line?.until ?? Number.POSITIVE_INFINITY),\n\t\t\t\t}),\n\t\t\t);\n\t\t});\n\n\t\t// ── Caret ─────────────────────────────────────────────────────────\n\t\tconst caretW = props.caret === \"bar\" ? Math.max(2, fs * 0.08) : adv;\n\t\tconst caretH =\n\t\t\tprops.caret === \"underline\" ? Math.max(2, fs * 0.1) : fs * 1.15;\n\t\tconst caretDy = props.caret === \"underline\" ? lh * 0.82 : (lh - caretH) / 2;\n\t\tconst xs: KeyframeTuple[] = [];\n\t\tconst ys: KeyframeTuple[] = [];\n\t\tconst on: KeyframeTuple[] = [];\n\t\tconst half = Math.max(1, Math.round(fps * 0.5));\n\t\tplan.caret.forEach(([f, at], k) => {\n\t\t\tif (at) {\n\t\t\t\txs.push([f, at.col * adv, \"hold\"]);\n\t\t\t\tys.push([f, at.row * lh + caretDy, \"hold\"]);\n\t\t\t}\n\t\t\ton.push([f, at ? 1 : 0, \"hold\"]);\n\t\t\t// Idle with the caret up: blink until the next change.\n\t\t\tconst next = plan.caret[k + 1]?.[0] ?? f + fps * 6;\n\t\t\tif (at)\n\t\t\t\tfor (\n\t\t\t\t\tlet b = f + half * 2, shown = false;\n\t\t\t\t\tb < next;\n\t\t\t\t\tb += half, shown = !shown\n\t\t\t\t)\n\t\t\t\t\ton.push([b, shown ? 1 : 0, \"hold\"]);\n\t\t});\n\t\tconst caret = host.box({\n\t\t\tid: ctx.partId(\"caret\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: caretW,\n\t\t\theight: caretH,\n\t\t\tbackground: theme.color.ink,\n\t\t\topacity: 0,\n\t\t});\n\t\tcaret.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"x\", ascending(xs))\n\t\t\t\t.keys(\"y\", ascending(ys))\n\t\t\t\t.keys(\"opacity\", ascending(on)),\n\t\t);\n\t\tcontent.push(caret);\n\t\tparts.caret = caret;\n\n\t\t// ── Screen and scroll ─────────────────────────────────────────────\n\t\t// Whole lines at a time: glide on new output, snap on clear. A scroll\n\t\t// that arrives mid-glide retargets the glide instead of stacking.\n\t\tconst scrollY = (top: number) => pad - top * lh;\n\t\tconst scrollKeys: KeyframeTuple[] = [\n\t\t\t[0, scrollY(plan.scroll[0]?.[1] ?? 0)],\n\t\t];\n\t\tfor (const [f, top, jump] of plan.scroll.slice(1)) {\n\t\t\tconst last = scrollKeys[scrollKeys.length - 1] as KeyframeTuple;\n\t\t\tif (jump)\n\t\t\t\tscrollKeys.push([Math.max(f, last[0] + 1), scrollY(top), \"hold\"]);\n\t\t\telse if (last[0] > f && last[2] !== \"hold\") {\n\t\t\t\tscrollKeys[scrollKeys.length - 1] = [\n\t\t\t\t\tf + SCROLL_GLIDE,\n\t\t\t\t\tscrollY(top),\n\t\t\t\t\tlast[2] as string,\n\t\t\t\t];\n\t\t\t} else {\n\t\t\t\tscrollKeys.push([Math.max(f, last[0] + 1), last[1]]);\n\t\t\t\tscrollKeys.push([\n\t\t\t\t\tMath.max(f, last[0] + 1) + SCROLL_GLIDE,\n\t\t\t\t\tscrollY(top),\n\t\t\t\t\t\"power2.out\",\n\t\t\t\t]);\n\t\t\t}\n\t\t}\n\t\tconst lines = host.box({\n\t\t\tid: ctx.partId(\"lines\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: pad,\n\t\t\twidth: cols * adv,\n\t\t\t// Every row, not just a screenful: children outside a box are culled.\n\t\t\theight: (Math.max(0, ...plan.lines.map((l) => l.row)) + 1) * lh,\n\t\t\tchildren: content,\n\t\t});\n\t\tlines.animate(LayerAnimation.keys(\"y\", ascending(scrollKeys)));\n\t\tconst screen = host.box({\n\t\t\tid: ctx.partId(\"screen\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: viewW,\n\t\t\theight: viewH,\n\t\t\toverflow: \"hidden\",\n\t\t\t...(props.frame === false && {\n\t\t\t\tbackground: theme.color.surface,\n\t\t\t\tborderRadius: theme.radius.md,\n\t\t\t}),\n\t\t\tchildren: [lines],\n\t\t});\n\t\tparts.lines = lines;\n\t\tparts.screen = screen;\n\n\t\tlet root: LayoutNode = screen;\n\t\tlet width = viewW;\n\t\tlet height = viewH;\n\t\tif (props.frame !== false) {\n\t\t\tconst o = props.frame === true ? {} : props.frame;\n\t\t\tconst bar = 40 * s;\n\t\t\tconst frameNode = bind(\n\t\t\t\thost,\n\t\t\t\tappFrame,\n\t\t\t)({\n\t\t\t\tid: ctx.partId(\"window\"),\n\t\t\t\tkind: \"terminal\",\n\t\t\t\twidth: viewW,\n\t\t\t\theight: viewH + bar,\n\t\t\t\tcontent: [screen],\n\t\t\t\tcontentBackground: theme.color.surface,\n\t\t\t\tenter: false,\n\t\t\t\ttheme,\n\t\t\t\t...(o.title !== undefined && { title: o.title }),\n\t\t\t\t...(o.elevation !== undefined && { elevation: o.elevation }),\n\t\t\t});\n\t\t\tparts.window = frameNode;\n\t\t\troot = frameNode;\n\t\t\theight = viewH + bar;\n\t\t\twidth = viewW;\n\t\t}\n\n\t\treturn {\n\t\t\tchildren: [root],\n\t\t\twidth,\n\t\t\theight,\n\t\t\tduration: plan.done + 1,\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tkeystrokes: plan.keystrokes,\n\t\t\t\tsubmits: plan.submits,\n\t\t\t\toutputs: plan.outputs,\n\t\t\t\tdone: [{ frame: plan.done }],\n\t\t\t} as TerminalCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** Keys must ascend: a later key at the same frame replaces the earlier one. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst out: KeyframeTuple[] = [];\n\tfor (const k of keys) {\n\t\twhile (out.length > 0 && (out[out.length - 1] as KeyframeTuple)[0] >= k[0])\n\t\t\tout.pop();\n\t\tout.push(k);\n\t}\n\treturn out.length > 0 ? out : [[0, 0]];\n}\n\n/**\n * A scripted shell session: commands typed with a human rhythm, streaming\n * output, spinners that resolve to check marks, progress bars and scrolling,\n * with a cue for every keystroke.\n */\nexport const Terminal = /* @__PURE__ */ bindComponent(terminal);\n"
34
+ },
35
+ {
36
+ "path": "terminal/plan.ts",
37
+ "content": "/**\n * Terminal planning, kept pure: run the script on a clock and record what\n * appears where and when. Nodes are built from this plan; tests read it.\n */\nimport { type Rng, typingFrames } from \"framefields/components\";\n\nexport type Step =\n\t| { cmd: string; prompt?: string }\n\t| { output: string | string[] }\n\t| { spinner: string; for: number; done?: string }\n\t| { progress: string; for: number }\n\t| { pause: number }\n\t| { clear: true };\n\n/** A run of text in one colour (a palette name) and weight. */\nexport interface Span {\n\ttext: string;\n\tcolor?: string;\n\tbold?: boolean;\n}\n\nexport interface PlannedLine {\n\trow: number;\n\t/** Frame the line appears. */\n\tappear: number;\n\t/** Frame it stops being drawn (scrolled out or cleared); Infinity if never. */\n\tuntil: number;\n\tspans: Span[];\n\tstep: number;\n\tkind: \"cmd\" | \"output\" | \"spinner\" | \"done\" | \"progress\" | \"prompt\";\n\t/** Columns the text starts in (room for a spinner or check mark). */\n\tindent?: number;\n\t/** For commands: characters already shown (the prompt) and each keystroke's frame. */\n\ttyping?: { prompt: number; frames: number[] };\n}\n\nexport interface TerminalPlan {\n\tlines: PlannedLine[];\n\t/** Rotating spinners: row, from frame, to frame. */\n\tspinners: Array<{ row: number; from: number; to: number; step: number }>;\n\t/** Check marks drawn at `at` on `row`, before the text from column 2. */\n\tchecks: Array<{ row: number; at: number; step: number }>;\n\tprogress: Array<{\n\t\trow: number;\n\t\tfrom: number;\n\t\tto: number;\n\t\tlabel: string;\n\t\tstep: number;\n\t}>;\n\t/** First visible row from each frame on; `jump` (a clear) snaps instead of gliding. */\n\tscroll: Array<[frame: number, top: number, jump?: boolean]>;\n\t/** Where the caret is, from each frame on (null = hidden). */\n\tcaret: Array<[frame: number, at: { row: number; col: number } | null]>;\n\tkeystrokes: Array<{ frame: number; index: number; step: number }>;\n\tsubmits: Array<{ frame: number; step: number }>;\n\toutputs: Array<{ frame: number; end: number; step: number }>;\n\tdone: number;\n\t/** Longest line, in characters. */\n\twidest: number;\n}\n\nexport interface PlanOptions {\n\tprompt: string;\n\tcps: number;\n\t/** Lines a second, or every output line at once. */\n\toutputRate: number | \"instant\";\n\trows: number;\n\tfps: number;\n\tmarkup: boolean;\n\trng: Rng;\n}\n\nconst COLORS = new Set([\n\t\"black\",\n\t\"red\",\n\t\"green\",\n\t\"yellow\",\n\t\"blue\",\n\t\"magenta\",\n\t\"cyan\",\n\t\"white\",\n\t\"gray\",\n\t\"dim\",\n\t\"accent\",\n]);\n\n/** `{green}ok{/} done` → spans. `{bold}` sets weight; unknown tags stay literal. */\nexport function parseMarkup(source: string, markup = true): Span[] {\n\tif (!markup) return [{ text: source }];\n\tconst spans: Span[] = [];\n\tconst stack: Array<{ color?: string; bold?: boolean }> = [{}];\n\tlet buffer = \"\";\n\tconst flush = () => {\n\t\tif (!buffer) return;\n\t\tconst top = stack[stack.length - 1] ?? {};\n\t\tspans.push({ text: buffer, ...top });\n\t\tbuffer = \"\";\n\t};\n\tconst re = /\\{(\\/|[a-z]+)\\}/g;\n\tlet last = 0;\n\tfor (const m of source.matchAll(re)) {\n\t\tconst tag = m[1] as string;\n\t\tconst index = m.index ?? 0;\n\t\tbuffer += source.slice(last, index);\n\t\tlast = index + m[0].length;\n\t\tif (tag === \"/\") {\n\t\t\tflush();\n\t\t\tif (stack.length > 1) stack.pop();\n\t\t} else if (tag === \"bold\" || COLORS.has(tag)) {\n\t\t\tflush();\n\t\t\tconst top = stack[stack.length - 1] ?? {};\n\t\t\tstack.push(\n\t\t\t\ttag === \"bold\" ? { ...top, bold: true } : { ...top, color: tag },\n\t\t\t);\n\t\t} else buffer += m[0];\n\t}\n\tbuffer += source.slice(last);\n\tflush();\n\treturn spans;\n}\n\nexport const spansText = (spans: readonly Span[]) =>\n\tspans.map((s) => s.text).join(\"\");\n\n/** Frames between Enter and the first line of output. */\nconst SUBMIT_BEAT = 0.3;\n/** Frames the scroll takes to glide one or more lines. */\nexport const SCROLL_GLIDE = 4;\n/** A check mark replaces these leading glyphs (fonts often lack them). */\nconst DONE_MARK = /^\\s*[✔✓√]\\s*/;\n\nexport function planTerminal(\n\tscript: readonly Step[],\n\to: PlanOptions,\n): TerminalPlan {\n\tconst plan: TerminalPlan = {\n\t\tlines: [],\n\t\tspinners: [],\n\t\tchecks: [],\n\t\tprogress: [],\n\t\tscroll: [[0, 0]],\n\t\tcaret: [[0, null]],\n\t\tkeystrokes: [],\n\t\tsubmits: [],\n\t\toutputs: [],\n\t\tdone: 0,\n\t\twidest: 0,\n\t};\n\tlet t = 0;\n\tlet row = 0;\n\tlet top = 0;\n\tlet typed = 0;\n\tconst perLine = o.outputRate === \"instant\" ? 0 : o.fps / o.outputRate;\n\n\tconst show = (line: Omit<PlannedLine, \"until\" | \"row\">): PlannedLine => {\n\t\tif (row - top >= o.rows) {\n\t\t\ttop = row - o.rows + 1;\n\t\t\tplan.scroll.push([line.appear, top]);\n\t\t}\n\t\tconst planned = { ...line, row, until: Number.POSITIVE_INFINITY };\n\t\tplan.lines.push(planned);\n\t\tplan.widest = Math.max(\n\t\t\tplan.widest,\n\t\t\tspansText(line.spans).length + (line.indent ?? 0),\n\t\t);\n\t\trow++;\n\t\treturn planned;\n\t};\n\tconst caretAt = (frame: number, at: { row: number; col: number } | null) =>\n\t\tplan.caret.push([frame, at]);\n\n\tscript.forEach((step, s) => {\n\t\tif (\"cmd\" in step) {\n\t\t\tconst prompt = step.prompt ?? o.prompt;\n\t\t\tconst line = show({\n\t\t\t\tappear: t,\n\t\t\t\tspans: [{ text: prompt, color: \"accent\" }, { text: step.cmd }],\n\t\t\t\tstep: s,\n\t\t\t\tkind: \"cmd\",\n\t\t\t});\n\t\t\tconst frames = typingFrames(\n\t\t\t\tstep.cmd,\n\t\t\t\to.cps,\n\t\t\t\to.fps,\n\t\t\t\to.rng,\n\t\t\t\tt + Math.round(o.fps * 0.25),\n\t\t\t);\n\t\t\tline.typing = { prompt: prompt.length, frames };\n\t\t\tcaretAt(t, { row: line.row, col: prompt.length });\n\t\t\tframes.forEach((f, i) => {\n\t\t\t\tplan.keystrokes.push({ frame: f, index: typed++, step: s });\n\t\t\t\tcaretAt(f, { row: line.row, col: prompt.length + i + 1 });\n\t\t\t});\n\t\t\tconst submit =\n\t\t\t\t(frames[frames.length - 1] ?? t) + Math.round(o.fps * SUBMIT_BEAT);\n\t\t\tplan.submits.push({ frame: submit, step: s });\n\t\t\tcaretAt(submit, null);\n\t\t\tt = submit + 1;\n\t\t} else if (\"output\" in step) {\n\t\t\tconst lines = Array.isArray(step.output)\n\t\t\t\t? step.output\n\t\t\t\t: step.output.split(\"\\n\");\n\t\t\tconst first = Math.round(t);\n\t\t\tlet at = t;\n\t\t\tfor (const text of lines) {\n\t\t\t\tshow({\n\t\t\t\t\tappear: Math.round(at),\n\t\t\t\t\tspans: parseMarkup(text, o.markup),\n\t\t\t\t\tstep: s,\n\t\t\t\t\tkind: \"output\",\n\t\t\t\t});\n\t\t\t\tat += perLine;\n\t\t\t}\n\t\t\tplan.outputs.push({\n\t\t\t\tframe: first,\n\t\t\t\tend: Math.round(Math.max(t, at - perLine)),\n\t\t\t\tstep: s,\n\t\t\t});\n\t\t\tt = Math.round(at);\n\t\t} else if (\"spinner\" in step) {\n\t\t\tconst from = Math.round(t);\n\t\t\tconst to = from + step.for;\n\t\t\tconst spinning = show({\n\t\t\t\tappear: from,\n\t\t\t\tspans: [{ text: step.spinner }],\n\t\t\t\tindent: 2,\n\t\t\t\tstep: s,\n\t\t\t\tkind: \"spinner\",\n\t\t\t});\n\t\t\tspinning.until = to;\n\t\t\tplan.spinners.push({ row: spinning.row, from, to, step: s });\n\t\t\trow--;\n\t\t\tconst doneText = (step.done ?? step.spinner).replace(DONE_MARK, \"\");\n\t\t\tshow({\n\t\t\t\tappear: to,\n\t\t\t\tspans: parseMarkup(doneText, o.markup),\n\t\t\t\tindent: 2,\n\t\t\t\tstep: s,\n\t\t\t\tkind: \"done\",\n\t\t\t});\n\t\t\tplan.checks.push({ row: spinning.row, at: to, step: s });\n\t\t\tt = to;\n\t\t} else if (\"progress\" in step) {\n\t\t\tconst from = Math.round(t);\n\t\t\tconst line = show({\n\t\t\t\tappear: from,\n\t\t\t\tspans: [{ text: step.progress }],\n\t\t\t\tstep: s,\n\t\t\t\tkind: \"progress\",\n\t\t\t});\n\t\t\tplan.progress.push({\n\t\t\t\trow: line.row,\n\t\t\t\tfrom,\n\t\t\t\tto: from + step.for,\n\t\t\t\tlabel: step.progress,\n\t\t\t\tstep: s,\n\t\t\t});\n\t\t\tplan.widest = Math.max(plan.widest, step.progress.length + 32);\n\t\t\tt = from + step.for;\n\t\t} else if (\"pause\" in step) {\n\t\t\tt += step.pause;\n\t\t} else {\n\t\t\tconst at = Math.round(t);\n\t\t\tfor (const line of plan.lines) line.until = Math.min(line.until, at);\n\t\t\ttop = row;\n\t\t\tplan.scroll.push([at, top, true]);\n\t\t}\n\t});\n\n\tplan.done = Math.round(t);\n\t// A fresh prompt waits at the end.\n\tconst last = show({\n\t\tappear: plan.done,\n\t\tspans: [{ text: o.prompt, color: \"accent\" }],\n\t\tstep: script.length,\n\t\tkind: \"prompt\",\n\t});\n\tcaretAt(plan.done, { row: last.row, col: o.prompt.length });\n\n\t// Lines stop drawing once scrolled out (after the glide), so only a\n\t// screenful of nodes is ever live.\n\tfor (const line of plan.lines) {\n\t\tconst gone = plan.scroll.find(([, top]) => top > line.row);\n\t\tif (gone) line.until = Math.min(line.until, gone[0] + SCROLL_GLIDE);\n\t}\n\treturn plan;\n}\n"
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "text-reveal",
3
+ "title": "TextReveal",
4
+ "summary": "Headlines and body copy that animate in and out by character, word or line: rise, blur, track, mask, slam, scatter, scramble, type, stream, wipe and flip, each ending on exactly the static text.",
5
+ "tags": [
6
+ "text",
7
+ "typography",
8
+ "kinetic type",
9
+ "title",
10
+ "reveal",
11
+ "typewriter"
12
+ ],
13
+ "example": "TextReveal({ text: \"Ship it faster\", style: \"rise\", emphasis: [\"faster\"] })",
14
+ "parts": [
15
+ "text",
16
+ "shake",
17
+ "caret",
18
+ "matte",
19
+ "reels",
20
+ "line-<n>"
21
+ ],
22
+ "cues": [
23
+ "units",
24
+ "revealed",
25
+ "exitStart",
26
+ "keystrokes"
27
+ ],
28
+ "registryDependencies": [],
29
+ "files": [
30
+ {
31
+ "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
+ },
34
+ {
35
+ "path": "text-reveal/plan.ts",
36
+ "content": "/**\n * TextReveal timing, kept pure: which characters form each unit, when each\n * unit starts, the streaming bursts (typing rhythm lives in `core/typing.ts`).\n */\nimport {\n\ttype GlyphUnit,\n\ttype Metrics,\n\trendererChars,\n\ttype Rng,\n\ttype StaggerOrder,\n\tstaggerRanks,\n\twordRanges,\n} from \"framefields/components\";\n\nexport interface Unit {\n\tindex: number;\n\ttext: string;\n\t/** Character range [start, end). */\n\tstart: number;\n\tend: number;\n}\n\nconst isSpace = (c: string) => /\\s/.test(c);\n\n/** The units text animates by. Characters skip whitespace; lines follow measured wrapping. */\nexport function unitsOf(text: string, by: GlyphUnit, metrics: Metrics): Unit[] {\n\tif (by === \"char\") {\n\t\tconst out: Unit[] = [];\n\t\tfor (let i = 0; i < text.length; i++) {\n\t\t\tconst c = text[i] as string;\n\t\t\tif (!isSpace(c))\n\t\t\t\tout.push({ index: out.length, text: c, start: i, end: i + 1 });\n\t\t}\n\t\treturn out;\n\t}\n\tif (by === \"word\") {\n\t\treturn wordRanges(text).map((w) => ({\n\t\t\tindex: w.index,\n\t\t\ttext: w.text,\n\t\t\tstart: w.start,\n\t\t\tend: w.end,\n\t\t}));\n\t}\n\tconst lines = new Map<number, { start: number; end: number }>();\n\tfor (const g of metrics.glyphs) {\n\t\tconst line = lines.get(g.line) ?? {\n\t\t\tstart: Number.POSITIVE_INFINITY,\n\t\t\tend: 0,\n\t\t};\n\t\tline.start = Math.min(line.start, g.index);\n\t\tline.end = Math.max(line.end, g.index + 1);\n\t\tlines.set(g.line, line);\n\t}\n\treturn [...lines.entries()]\n\t\t.sort(([a], [b]) => a - b)\n\t\t.map(([, r], index) => ({\n\t\t\tindex,\n\t\t\ttext: text.slice(r.start, r.end).trim(),\n\t\t\tstart: r.start,\n\t\t\tend: r.end,\n\t\t}));\n}\n\n/** Start frame per unit: rank in `order` × `stagger` (fractional frames allowed). */\nexport function unitStarts(\n\tcount: number,\n\tstagger: number,\n\torder: StaggerOrder,\n\tseed: number,\n\toffset = 0,\n): number[] {\n\treturn staggerRanks(count, order, seed).map((r) => offset + r * stagger);\n}\n\n/**\n * The schedule indexed the way the renderer indexes `by`: per character\n * (spaces take the next unit's start, so they never lag) or per word / line.\n */\nexport function indexedStarts(\n\ttext: string,\n\tby: GlyphUnit,\n\tunits: readonly Unit[],\n\tstarts: readonly number[],\n): number[] {\n\tif (by !== \"char\") return [...starts];\n\t// Built per string index, returned in the renderer's index order.\n\tconst out = new Array<number>(text.length).fill(0);\n\tlet next = starts[starts.length - 1] ?? 0;\n\tfor (let u = units.length - 1, i = text.length - 1; i >= 0; i--) {\n\t\tconst unit = units[u];\n\t\tif (unit && unit.start === i) {\n\t\t\tnext = starts[u] ?? next;\n\t\t\tu--;\n\t\t}\n\t\tout[i] = next;\n\t}\n\treturn rendererChars(text, out);\n}\n\n/**\n * Streaming: words arrive in seeded bursts of 1–3, one frame apart inside a\n * burst, with an uneven pause between bursts (`gap` frames ± 50 %).\n */\nexport function burstStarts(\n\tcount: number,\n\tgap: number,\n\tstep: number,\n\tr: Rng,\n\tstart = 0,\n): number[] {\n\tconst out: number[] = [];\n\tlet t = start;\n\twhile (out.length < count) {\n\t\tconst size = r.int(1, 3);\n\t\tfor (let i = 0; i < size && out.length < count; i++) {\n\t\t\tout.push(Math.round(t));\n\t\t\tt += step;\n\t\t}\n\t\tt += gap * (0.5 + r.next());\n\t}\n\treturn out;\n}\n\n/** Glyphs a scrambling character cycles through: same case or class, plus a few symbols. */\nexport function scrambleSet(char: string): string {\n\tconst symbols = \"#%&*+=/<>?$\";\n\tif (/[A-Z]/.test(char)) return `ABCDEFGHIJKLMNOPQRSTUVWXYZ${symbols}`;\n\tif (/[a-z]/.test(char)) return `abcdefghijklmnopqrstuvwxyz${symbols}`;\n\tif (/[0-9]/.test(char)) return `0123456789${symbols}`;\n\treturn `${symbols}ABCDEFGHJKMNPRSTUVWXYZ`;\n}\n"
37
+ }
38
+ ]
39
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "title-card",
3
+ "title": "TitleCard",
4
+ "summary": "Kicker, headline, rule and sub-line in designed layouts (center, left, stack, split), the headline in any reveal style, on one timeline.",
5
+ "tags": [
6
+ "title",
7
+ "headline",
8
+ "typography",
9
+ "opener",
10
+ "chapter"
11
+ ],
12
+ "example": "TitleCard({ kicker: \"Chapter one\", title: \"Ship it faster\", subtitle: \"What changed in 2.5\" })",
13
+ "parts": [
14
+ "kicker",
15
+ "title",
16
+ "rule",
17
+ "bar",
18
+ "subtitle"
19
+ ],
20
+ "cues": [
21
+ "revealed",
22
+ "settled",
23
+ "exitStart"
24
+ ],
25
+ "registryDependencies": [
26
+ "text-reveal"
27
+ ],
28
+ "files": [
29
+ {
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"
32
+ }
33
+ ]
34
+ }