framefields 2.5.5 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/README.md +11 -0
  2. package/dist/audio/index.mjs +10 -10
  3. package/dist/{audio-C9euVW4K.mjs → audio-QBA2VogL.mjs} +3 -3
  4. package/dist/{audio-C9euVW4K.mjs.map → audio-QBA2VogL.mjs.map} +1 -1
  5. package/dist/cli.mjs +866 -16
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/components/index.d.mts +2 -2
  8. package/dist/components/index.mjs +43 -43
  9. package/dist/{dist-BuirkW-f.mjs → dist-CDwWQYg3.mjs} +2 -2
  10. package/dist/dist-CDwWQYg3.mjs.map +1 -0
  11. package/dist/{dist-DBdxM6xv.mjs → dist-nHCqVk-e.mjs} +3 -3
  12. package/dist/dist-nHCqVk-e.mjs.map +1 -0
  13. package/dist/effects/index.mjs +34 -34
  14. package/dist/{effects-B263lgUi.mjs → effects-C-6I4iYW.mjs} +2 -2
  15. package/dist/{effects-B263lgUi.mjs.map → effects-C-6I4iYW.mjs.map} +1 -1
  16. package/dist/fonts/index.d.mts +1 -1
  17. package/dist/fonts/index.mjs +2 -2
  18. package/dist/{fonts-DNx6ps2p.mjs → fonts-BieW3VWV.mjs} +7 -3
  19. package/dist/fonts-BieW3VWV.mjs.map +1 -0
  20. package/dist/{gpu-BRBIoxMC.mjs → gpu-Bb4WmYYH.mjs} +2 -2
  21. package/dist/{gpu-BRBIoxMC.mjs.map → gpu-Bb4WmYYH.mjs.map} +1 -1
  22. package/dist/index-D-Gnyffj.d.mts.map +1 -1
  23. package/dist/{index-N1tMc5Z9.d.mts → index-E1IrPAm4.d.mts} +629 -203
  24. package/dist/index-E1IrPAm4.d.mts.map +1 -0
  25. package/dist/{index-CalU-mpZ.d.mts → index-j5LGVVbm.d.mts} +3 -1
  26. package/dist/index-j5LGVVbm.d.mts.map +1 -0
  27. package/dist/index.d.mts +3 -3
  28. package/dist/index.mjs +50 -50
  29. package/dist/preview/index.mjs +10 -10
  30. package/dist/{preview-ChR8Z8Fm.mjs → preview-D6jS38Di.mjs} +2 -2
  31. package/dist/{preview-ChR8Z8Fm.mjs.map → preview-D6jS38Di.mjs.map} +1 -1
  32. package/dist/preview-engine/{chunk-ZJLNXN7W.mjs → chunk-AYTP6OGO.mjs} +6 -6
  33. package/dist/preview-engine/chunk-MLAIEIUF.mjs +1974 -0
  34. package/dist/preview-engine/chunk-W3I7LKDV.mjs +5 -0
  35. package/dist/preview-engine/chunk-ZMMNN77R.mjs +3 -0
  36. package/dist/preview-engine/components.mjs +1 -1
  37. package/dist/preview-engine/fonts.mjs +1 -1
  38. package/dist/preview-engine/framefields.mjs +1 -1
  39. package/dist/preview-engine/player.mjs +1 -1
  40. package/dist/preview-engine/{renderers-WZEV464O.mjs → renderers-4G3UOPA7.mjs} +1 -1
  41. package/dist/{program-O2bWX0cs.mjs → program-Bf4yHbHC.mjs} +19 -3
  42. package/dist/program-Bf4yHbHC.mjs.map +1 -0
  43. package/dist/registry/annotate.json +1 -1
  44. package/dist/registry/app-frame.json +1 -1
  45. package/dist/registry/backdrop.json +3 -3
  46. package/dist/registry/before-after.json +1 -1
  47. package/dist/registry/callout.json +1 -1
  48. package/dist/registry/captions.json +7 -1
  49. package/dist/registry/card-stack.json +3 -3
  50. package/dist/registry/chat-thread.json +7 -1
  51. package/dist/registry/code-block.json +8 -2
  52. package/dist/registry/confetti.json +1 -1
  53. package/dist/registry/device-frame.json +2 -1
  54. package/dist/registry/focus-zoom.json +1 -1
  55. package/dist/registry/logo-reveal.json +1 -1
  56. package/dist/registry/lower-third.json +1 -1
  57. package/dist/registry/meter.json +1 -1
  58. package/dist/registry/mouse-pointer.json +1 -1
  59. package/dist/registry/selection.json +3 -3
  60. package/dist/registry/spotlight.json +1 -1
  61. package/dist/registry/terminal.json +7 -1
  62. package/dist/registry/text-reveal.json +2 -1
  63. package/dist/registry/title-card.json +1 -1
  64. package/dist/registry/toasts.json +1 -1
  65. package/dist/registry/touch-point.json +4 -4
  66. package/dist/registry/waveform.json +3 -3
  67. package/dist/registry/word-cycle.json +2 -2
  68. package/dist/renderer/index.mjs +2 -2
  69. package/dist/renderer-4FQmduPw.mjs +5 -0
  70. package/dist/{renderer-DW21AyEv.mjs → renderer-4v6XYAiX.mjs} +3 -3
  71. package/dist/{renderer-DW21AyEv.mjs.map → renderer-4v6XYAiX.mjs.map} +1 -1
  72. package/dist/{renderer-YFHESw3v.mjs → renderer-79mO0b9Y.mjs} +3 -3
  73. package/dist/{renderer-YFHESw3v.mjs.map → renderer-79mO0b9Y.mjs.map} +1 -1
  74. package/dist/{renderer-DtqiWq1m.mjs → renderer-AOZWHGT3.mjs} +3 -3
  75. package/dist/{renderer-DtqiWq1m.mjs.map → renderer-AOZWHGT3.mjs.map} +1 -1
  76. package/dist/{renderer-Dw3K8KT9.mjs → renderer-B1Ox12RF.mjs} +2 -2
  77. package/dist/{renderer-Dw3K8KT9.mjs.map → renderer-B1Ox12RF.mjs.map} +1 -1
  78. package/dist/{renderer-DkM6Ikdf.mjs → renderer-B6vr-B0t.mjs} +3 -3
  79. package/dist/{renderer-DkM6Ikdf.mjs.map → renderer-B6vr-B0t.mjs.map} +1 -1
  80. package/dist/renderer-BBCpRuYU.mjs +5 -0
  81. package/dist/renderer-BBTo_jLO.mjs +5 -0
  82. package/dist/{renderer-CpDaUeCN.mjs → renderer-BDCS7k5X.mjs} +3 -3
  83. package/dist/{renderer-CpDaUeCN.mjs.map → renderer-BDCS7k5X.mjs.map} +1 -1
  84. package/dist/{renderer-DO1TeUqV.mjs → renderer-BFdgbqgW.mjs} +3 -3
  85. package/dist/{renderer-DO1TeUqV.mjs.map → renderer-BFdgbqgW.mjs.map} +1 -1
  86. package/dist/{renderer-CEAmByVW.mjs → renderer-BHgYRSu8.mjs} +2 -2
  87. package/dist/{renderer-CEAmByVW.mjs.map → renderer-BHgYRSu8.mjs.map} +1 -1
  88. package/dist/{renderer-CA_CwisV.mjs → renderer-BKssznKn.mjs} +2 -2
  89. package/dist/{renderer-CA_CwisV.mjs.map → renderer-BKssznKn.mjs.map} +1 -1
  90. package/dist/{renderer-DWG6EXGj.mjs → renderer-BL9vkn6m.mjs} +3 -3
  91. package/dist/{renderer-DWG6EXGj.mjs.map → renderer-BL9vkn6m.mjs.map} +1 -1
  92. package/dist/renderer-BLg66ZgG.mjs +5 -0
  93. package/dist/{renderer-RbMM6Njv.mjs → renderer-BQFxoNzi.mjs} +3 -3
  94. package/dist/{renderer-RbMM6Njv.mjs.map → renderer-BQFxoNzi.mjs.map} +1 -1
  95. package/dist/{renderer-x1xlZaH_.mjs → renderer-BQdn_qGp.mjs} +3 -3
  96. package/dist/{renderer-x1xlZaH_.mjs.map → renderer-BQdn_qGp.mjs.map} +1 -1
  97. package/dist/{renderer-DkOxEQsg.mjs → renderer-BR35Xp9n.mjs} +3 -3
  98. package/dist/{renderer-DkOxEQsg.mjs.map → renderer-BR35Xp9n.mjs.map} +1 -1
  99. package/dist/renderer-BScS8AzG.mjs +5 -0
  100. package/dist/{renderer-C2MaJKfY.mjs → renderer-BTjveb1Y.mjs} +3 -3
  101. package/dist/{renderer-C2MaJKfY.mjs.map → renderer-BTjveb1Y.mjs.map} +1 -1
  102. package/dist/renderer-Bb1cS5mJ.mjs +5 -0
  103. package/dist/{renderer-Dwh0jaor.mjs → renderer-Bbkf5EIi.mjs} +3 -3
  104. package/dist/{renderer-Dwh0jaor.mjs.map → renderer-Bbkf5EIi.mjs.map} +1 -1
  105. package/dist/{renderer-DsnV8tpW.mjs → renderer-BmgyvIC3.mjs} +3 -3
  106. package/dist/{renderer-DsnV8tpW.mjs.map → renderer-BmgyvIC3.mjs.map} +1 -1
  107. package/dist/{renderer-BfQdc3RM.mjs → renderer-Bpve7M6S.mjs} +69 -69
  108. package/dist/{renderer-BfQdc3RM.mjs.map → renderer-Bpve7M6S.mjs.map} +1 -1
  109. package/dist/renderer-Bpwd9zEY.mjs +5 -0
  110. package/dist/{renderer-C5QYI1Wu.mjs → renderer-BsnhAgrB.mjs} +2 -2
  111. package/dist/{renderer-C5QYI1Wu.mjs.map → renderer-BsnhAgrB.mjs.map} +1 -1
  112. package/dist/{renderer-BlU3Sh_0.mjs → renderer-By5xTRqB.mjs} +3 -3
  113. package/dist/{renderer-BlU3Sh_0.mjs.map → renderer-By5xTRqB.mjs.map} +1 -1
  114. package/dist/{renderer-D8m3fuRp.mjs → renderer-BzEQXAdl.mjs} +2 -2
  115. package/dist/{renderer-D8m3fuRp.mjs.map → renderer-BzEQXAdl.mjs.map} +1 -1
  116. package/dist/renderer-Bzyd9xi5.mjs +5 -0
  117. package/dist/{renderer-1NoJLLDa.mjs → renderer-C64g87yU.mjs} +3 -3
  118. package/dist/{renderer-1NoJLLDa.mjs.map → renderer-C64g87yU.mjs.map} +1 -1
  119. package/dist/{renderer-Daqvhwjp.mjs → renderer-C6Wb1TZT.mjs} +3 -3
  120. package/dist/{renderer-Daqvhwjp.mjs.map → renderer-C6Wb1TZT.mjs.map} +1 -1
  121. package/dist/{renderer-BWNQQZ12.mjs → renderer-CHPLHbAs.mjs} +3 -3
  122. package/dist/{renderer-BWNQQZ12.mjs.map → renderer-CHPLHbAs.mjs.map} +1 -1
  123. package/dist/{renderer-B5ZdkcMi.mjs → renderer-CJRu7lLH.mjs} +2 -2
  124. package/dist/{renderer-B5ZdkcMi.mjs.map → renderer-CJRu7lLH.mjs.map} +1 -1
  125. package/dist/{renderer-Bvizxddu.mjs → renderer-CMWUtfwn.mjs} +2 -2
  126. package/dist/{renderer-Bvizxddu.mjs.map → renderer-CMWUtfwn.mjs.map} +1 -1
  127. package/dist/{renderer-C3tdjg8B.mjs → renderer-CRkgyk08.mjs} +3 -3
  128. package/dist/{renderer-C3tdjg8B.mjs.map → renderer-CRkgyk08.mjs.map} +1 -1
  129. package/dist/renderer-CXVssx1k.mjs +5 -0
  130. package/dist/{renderer-DcApNxER.mjs → renderer-CY0BT6ok.mjs} +2 -2
  131. package/dist/{renderer-DcApNxER.mjs.map → renderer-CY0BT6ok.mjs.map} +1 -1
  132. package/dist/renderer-CYFiGLls.mjs +5 -0
  133. package/dist/{renderer-PhjHSS97.mjs → renderer-CYLP8sD6.mjs} +2 -2
  134. package/dist/{renderer-PhjHSS97.mjs.map → renderer-CYLP8sD6.mjs.map} +1 -1
  135. package/dist/{renderer-C6n8y6Pw.mjs → renderer-CY_1u6zi.mjs} +3 -3
  136. package/dist/{renderer-C6n8y6Pw.mjs.map → renderer-CY_1u6zi.mjs.map} +1 -1
  137. package/dist/renderer-C__BvvtP.mjs +5 -0
  138. package/dist/renderer-Ca3YZsoT.mjs +5 -0
  139. package/dist/{renderer-DADTT3ym.mjs → renderer-CcSBjtRa.mjs} +2 -2
  140. package/dist/{renderer-CnclEQdA.mjs → renderer-CgdplRri.mjs} +2 -2
  141. package/dist/{renderer-CnclEQdA.mjs.map → renderer-CgdplRri.mjs.map} +1 -1
  142. package/dist/{renderer-oR1LvHeO.mjs → renderer-Cn9tU_s5.mjs} +2 -2
  143. package/dist/{renderer-oR1LvHeO.mjs.map → renderer-Cn9tU_s5.mjs.map} +1 -1
  144. package/dist/renderer-CnM5iFj9.mjs +5 -0
  145. package/dist/{renderer-DHdFVpdH.mjs → renderer-Cqp_TUl_.mjs} +2 -2
  146. package/dist/{renderer-DHdFVpdH.mjs.map → renderer-Cqp_TUl_.mjs.map} +1 -1
  147. package/dist/{renderer-C8pJFxcL.mjs → renderer-CsOLHKFx.mjs} +2 -2
  148. package/dist/{renderer-C8pJFxcL.mjs.map → renderer-CsOLHKFx.mjs.map} +1 -1
  149. package/dist/{renderer-R5Lg65F8.mjs → renderer-D01RT3Ov.mjs} +2 -2
  150. package/dist/renderer-D1nZrvQx.mjs +5 -0
  151. package/dist/renderer-D6XjVQME.mjs +5 -0
  152. package/dist/{renderer-QQDU1b0Z.mjs → renderer-DAONCf_3.mjs} +3 -3
  153. package/dist/{renderer-QQDU1b0Z.mjs.map → renderer-DAONCf_3.mjs.map} +1 -1
  154. package/dist/{renderer-DTPmvL1i.mjs → renderer-DAfWLGfy.mjs} +3 -3
  155. package/dist/{renderer-DTPmvL1i.mjs.map → renderer-DAfWLGfy.mjs.map} +1 -1
  156. package/dist/{renderer-BvA4sQz5.mjs → renderer-DEeKkljI.mjs} +3 -3
  157. package/dist/{renderer-BvA4sQz5.mjs.map → renderer-DEeKkljI.mjs.map} +1 -1
  158. package/dist/{renderer-YBwN9uyj.mjs → renderer-DFYXjAIT.mjs} +5 -5
  159. package/dist/{renderer-YBwN9uyj.mjs.map → renderer-DFYXjAIT.mjs.map} +1 -1
  160. package/dist/renderer-DIuMnMF0.mjs +5 -0
  161. package/dist/{renderer-Dz4muMyE.mjs → renderer-DJHuPNJx.mjs} +2 -2
  162. package/dist/{renderer-Dz4muMyE.mjs.map → renderer-DJHuPNJx.mjs.map} +1 -1
  163. package/dist/{renderer-H4-8sBjl.mjs → renderer-DJTXhVNh.mjs} +2 -2
  164. package/dist/{renderer-H4-8sBjl.mjs.map → renderer-DJTXhVNh.mjs.map} +1 -1
  165. package/dist/{renderer-BMiZIGkZ.mjs → renderer-DNEFYrQ5.mjs} +2 -2
  166. package/dist/renderer-DUvvM8EF.mjs +5 -0
  167. package/dist/renderer-DWogyuMO.mjs +5 -0
  168. package/dist/renderer-DWsj_LJR.mjs +5 -0
  169. package/dist/{renderer-DGOY-LBR.mjs → renderer-DYJHFHF4.mjs} +2 -2
  170. package/dist/{renderer-DGOY-LBR.mjs.map → renderer-DYJHFHF4.mjs.map} +1 -1
  171. package/dist/{renderer-CVbNiH03.mjs → renderer-DYR4gFEM.mjs} +3 -3
  172. package/dist/{renderer-CVbNiH03.mjs.map → renderer-DYR4gFEM.mjs.map} +1 -1
  173. package/dist/renderer-DYun0DJl.mjs +5 -0
  174. package/dist/renderer-D_6YqD-Z.mjs +5 -0
  175. package/dist/{renderer-B0IJirDs.mjs → renderer-D_UPFbei.mjs} +3 -3
  176. package/dist/{renderer-B0IJirDs.mjs.map → renderer-D_UPFbei.mjs.map} +1 -1
  177. package/dist/renderer-Db9LP86x.mjs +5 -0
  178. package/dist/{renderer-CFRTJ7cT.mjs → renderer-DgRJG_l-.mjs} +2 -2
  179. package/dist/{renderer-CFRTJ7cT.mjs.map → renderer-DgRJG_l-.mjs.map} +1 -1
  180. package/dist/renderer-DhHiC87m.mjs +5 -0
  181. package/dist/{renderer-BZ5uOfoX.mjs → renderer-DiD3r9tx.mjs} +2 -2
  182. package/dist/{renderer-BZ5uOfoX.mjs.map → renderer-DiD3r9tx.mjs.map} +1 -1
  183. package/dist/{renderer-B2no_9zi.mjs → renderer-Dj-y-BtT.mjs} +3 -3
  184. package/dist/{renderer-B2no_9zi.mjs.map → renderer-Dj-y-BtT.mjs.map} +1 -1
  185. package/dist/renderer-Dj5jGAkR.mjs +5 -0
  186. package/dist/{renderer-Bs6BaMLf.mjs → renderer-DlA7kzIs.mjs} +3 -3
  187. package/dist/{renderer-Bs6BaMLf.mjs.map → renderer-DlA7kzIs.mjs.map} +1 -1
  188. package/dist/renderer-DlPUQ7RF.mjs +5 -0
  189. package/dist/renderer-DpIPdgn8.mjs +5 -0
  190. package/dist/renderer-Dv0C9CEt.mjs +6 -0
  191. package/dist/{renderer-CFCR_7bq.mjs → renderer-EwSSuJXj.mjs} +3 -3
  192. package/dist/{renderer-CFCR_7bq.mjs.map → renderer-EwSSuJXj.mjs.map} +1 -1
  193. package/dist/renderer-Iw8fDL-p.mjs +5 -0
  194. package/dist/{renderer-tcS585Iy.mjs → renderer-JJibsJfb.mjs} +2 -2
  195. package/dist/{renderer-tcS585Iy.mjs.map → renderer-JJibsJfb.mjs.map} +1 -1
  196. package/dist/{renderer-CTy0bSsl.mjs → renderer-KppAWfmJ.mjs} +3 -3
  197. package/dist/{renderer-CTy0bSsl.mjs.map → renderer-KppAWfmJ.mjs.map} +1 -1
  198. package/dist/{renderer-CPSg6eEQ.mjs → renderer-LW8MfGjd.mjs} +2 -2
  199. package/dist/{renderer-CPSg6eEQ.mjs.map → renderer-LW8MfGjd.mjs.map} +1 -1
  200. package/dist/{renderer-Cg2xS8h8.mjs → renderer-PMjV8x65.mjs} +2 -2
  201. package/dist/{renderer-Cg2xS8h8.mjs.map → renderer-PMjV8x65.mjs.map} +1 -1
  202. package/dist/renderer-SLI69Mju.mjs +5 -0
  203. package/dist/{renderer-DdkPY4hO.mjs → renderer-UArFa4y9.mjs} +2 -2
  204. package/dist/{renderer-DdkPY4hO.mjs.map → renderer-UArFa4y9.mjs.map} +1 -1
  205. package/dist/{renderer-B7vXnBUo.mjs → renderer-VwKmjuc6.mjs} +3 -3
  206. package/dist/{renderer-B7vXnBUo.mjs.map → renderer-VwKmjuc6.mjs.map} +1 -1
  207. package/dist/renderer-ZVtbb3-P.mjs +5 -0
  208. package/dist/{renderer-Dk0okoS3.mjs → renderer-bb3puodg.mjs} +2 -2
  209. package/dist/{renderer-Dk0okoS3.mjs.map → renderer-bb3puodg.mjs.map} +1 -1
  210. package/dist/{renderer-DVGVcDvB.mjs → renderer-eyvY4Lq4.mjs} +3 -3
  211. package/dist/{renderer-DVGVcDvB.mjs.map → renderer-eyvY4Lq4.mjs.map} +1 -1
  212. package/dist/renderer-gHNCu7H5.mjs +5 -0
  213. package/dist/renderer-gxDNmbtU.mjs +5 -0
  214. package/dist/renderer-hkK4DNRX.mjs +5 -0
  215. package/dist/{renderer-CrKYlipx.mjs → renderer-ij448rvt.mjs} +2 -2
  216. package/dist/{renderer-CrKYlipx.mjs.map → renderer-ij448rvt.mjs.map} +1 -1
  217. package/dist/{renderer-BrKgtYq4.mjs → renderer-j7aUfyeR.mjs} +3 -3
  218. package/dist/{renderer-BrKgtYq4.mjs.map → renderer-j7aUfyeR.mjs.map} +1 -1
  219. package/dist/{renderer-8_d6YNnE.mjs → renderer-kOlfYLtu.mjs} +2 -2
  220. package/dist/{renderer-8_d6YNnE.mjs.map → renderer-kOlfYLtu.mjs.map} +1 -1
  221. package/dist/{renderer-D3FJcRA3.mjs → renderer-l1qJE4z-.mjs} +2 -2
  222. package/dist/{renderer-D3FJcRA3.mjs.map → renderer-l1qJE4z-.mjs.map} +1 -1
  223. package/dist/{renderer-BKJ8cywK.mjs → renderer-ml2LHmRb.mjs} +2 -2
  224. package/dist/{renderer-BKJ8cywK.mjs.map → renderer-ml2LHmRb.mjs.map} +1 -1
  225. package/dist/{renderer-DmTxEeMh.mjs → renderer-mu2mYo5w.mjs} +3 -3
  226. package/dist/{renderer-DmTxEeMh.mjs.map → renderer-mu2mYo5w.mjs.map} +1 -1
  227. package/dist/{renderer-Bwxm-kWL.mjs → renderer-ovkctV5a.mjs} +2 -2
  228. package/dist/renderer-oy6SGDRz.mjs +5 -0
  229. package/dist/{renderer-CqJou_2L.mjs → renderer-qQmoO5Hk.mjs} +2 -2
  230. package/dist/{renderer-CqJou_2L.mjs.map → renderer-qQmoO5Hk.mjs.map} +1 -1
  231. package/dist/renderer-rq7UIcuH.mjs +5 -0
  232. package/dist/{renderer-BStETpqC.mjs → renderer-rv-AKqCl.mjs} +2 -2
  233. package/dist/{renderer-BStETpqC.mjs.map → renderer-rv-AKqCl.mjs.map} +1 -1
  234. package/dist/renderer-xkEJahws.mjs +5 -0
  235. package/dist/{renderer-BNs31kME.mjs → renderer-yF40Pb-n.mjs} +3 -3
  236. package/dist/{renderer-BNs31kME.mjs.map → renderer-yF40Pb-n.mjs.map} +1 -1
  237. package/dist/{renderer-Bag1LFos.mjs → renderer-yFHP9Zj2.mjs} +3 -3
  238. package/dist/{renderer-Bag1LFos.mjs.map → renderer-yFHP9Zj2.mjs.map} +1 -1
  239. package/dist/{renderer-CbxfSmYE.mjs → renderer-zOK5DYYn.mjs} +3 -3
  240. package/dist/{renderer-CbxfSmYE.mjs.map → renderer-zOK5DYYn.mjs.map} +1 -1
  241. package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs → renderers-CP8CFWDs-DPLX5EJs.mjs} +2 -2
  242. package/dist/{renderers-CP8CFWDs-EZZPEbhW.mjs.map → renderers-CP8CFWDs-DPLX5EJs.mjs.map} +1 -1
  243. package/dist/signals/index.mjs +4 -4
  244. package/dist/{src-r7Suenhp.mjs → src-BnkrNj2W.mjs} +612 -117
  245. package/dist/src-BnkrNj2W.mjs.map +1 -0
  246. package/package.json +59 -58
  247. package/dist/dist-BuirkW-f.mjs.map +0 -1
  248. package/dist/dist-DBdxM6xv.mjs.map +0 -1
  249. package/dist/fonts-DNx6ps2p.mjs.map +0 -1
  250. package/dist/index-CalU-mpZ.d.mts.map +0 -1
  251. package/dist/index-N1tMc5Z9.d.mts.map +0 -1
  252. package/dist/preview-engine/chunk-2TCJSHFA.mjs +0 -1974
  253. package/dist/preview-engine/chunk-Q7XC3FI7.mjs +0 -3
  254. package/dist/preview-engine/chunk-ZZAZF6VC.mjs +0 -5
  255. package/dist/program-O2bWX0cs.mjs.map +0 -1
  256. package/dist/renderer-0441BN0U.mjs +0 -5
  257. package/dist/renderer-3qDgFc-W.mjs +0 -5
  258. package/dist/renderer-6AIqTN5Z.mjs +0 -5
  259. package/dist/renderer-B7MZ5R7u.mjs +0 -5
  260. package/dist/renderer-BE2rIA4B.mjs +0 -5
  261. package/dist/renderer-BIQ81ln4.mjs +0 -5
  262. package/dist/renderer-BKm3_Ob3.mjs +0 -5
  263. package/dist/renderer-BNdT1Wwf.mjs +0 -5
  264. package/dist/renderer-BQycs1q_.mjs +0 -5
  265. package/dist/renderer-Bj7MxXfk.mjs +0 -5
  266. package/dist/renderer-BjRAGYYC.mjs +0 -5
  267. package/dist/renderer-BqE2BQ_X.mjs +0 -5
  268. package/dist/renderer-Bz5BMkOE.mjs +0 -5
  269. package/dist/renderer-CDOSfB9H.mjs +0 -5
  270. package/dist/renderer-CT26yQT-.mjs +0 -5
  271. package/dist/renderer-CZeAbz48.mjs +0 -5
  272. package/dist/renderer-D50b7kip.mjs +0 -5
  273. package/dist/renderer-D5LmrO96.mjs +0 -5
  274. package/dist/renderer-DCvqZ5o_.mjs +0 -5
  275. package/dist/renderer-DJGJeGEn.mjs +0 -5
  276. package/dist/renderer-DONvNNXi.mjs +0 -5
  277. package/dist/renderer-DPaSo--Y.mjs +0 -6
  278. package/dist/renderer-DfYYokhr.mjs +0 -5
  279. package/dist/renderer-DlIJhdEM.mjs +0 -5
  280. package/dist/renderer-DqD3nMtv.mjs +0 -5
  281. package/dist/renderer-DrIl-uGt.mjs +0 -5
  282. package/dist/renderer-DyCB3Q1d.mjs +0 -5
  283. package/dist/renderer-DyJ-iSv6.mjs +0 -5
  284. package/dist/renderer-Dzs0jV0G.mjs +0 -5
  285. package/dist/renderer-HIKXw5-Y.mjs +0 -5
  286. package/dist/renderer-Jgt2g8Wp.mjs +0 -5
  287. package/dist/renderer-KnZWmT-M.mjs +0 -5
  288. package/dist/renderer-OMSXddG9.mjs +0 -5
  289. package/dist/renderer-P78xh6OV.mjs +0 -5
  290. package/dist/renderer-gaFP1Kak.mjs +0 -5
  291. package/dist/renderer-lQ8gH7Zd.mjs +0 -5
  292. package/dist/src-r7Suenhp.mjs.map +0 -1
@@ -28,7 +28,7 @@
28
28
  "files": [
29
29
  {
30
30
  "path": "lower-third/index.ts",
31
- "content": "/**\n * LowerThird: the name-and-role bar of interviews and talks. Plates are\n * measured to their text, placed in the safe area's bottom corner, slide in\n * through their own box, and leave on their own after `hold`.\n *\n * Coordinates are the frame's: the root covers the video (default\n * 1920 × 1080 × theme scale, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\ttype Rect,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport const LOWER_THIRD_LAYOUTS = [\"bar\", \"stacked\", \"tag\", \"line\"] as const;\n\n/** Cap height as a share of the font size (Inter and most UI sans faces). */\nconst CAP_HEIGHT = 0.72;\nexport type LowerThirdLayout = (typeof LOWER_THIRD_LAYOUTS)[number];\n\nexport interface LowerThirdCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Fully on screen. */\n\tin: ReadonlyArray<{ frame: number }>;\n\t/** Starts to leave (empty with `stay`). */\n\tout: ReadonlyArray<{ frame: number }>;\n}\n\nfunction safeArea(\n\tW: number,\n\tH: number,\n\tarea: \"portrait\" | \"landscape\" | Rect | undefined,\n): Rect {\n\tif (area && typeof area === \"object\") return area;\n\tconst portrait = area ? area === \"portrait\" : H > W;\n\treturn portrait\n\t\t? { x: W * 0.08, y: H * 0.1, width: W * 0.84, height: H * 0.66 }\n\t\t: { x: W * 0.06, y: H * 0.08, width: W * 0.88, height: H * 0.82 };\n}\n\nexport const lowerThird = /* @__PURE__ */ defineComponent({\n\tname: \"LowerThird\",\n\tprops: () => ({\n\t\tname: z.string().min(1),\n\t\trole: z.string().optional(),\n\t\tlayout: z.enum(LOWER_THIRD_LAYOUTS).default(\"bar\"),\n\t\tside: z.enum([\"left\", \"right\"]).default(\"left\"),\n\t\tsafeArea: z\n\t\t\t.union([\n\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\tz.object({\n\t\t\t\t\tx: z.number(),\n\t\t\t\t\ty: z.number(),\n\t\t\t\t\twidth: z.number(),\n\t\t\t\t\theight: z.number(),\n\t\t\t\t}),\n\t\t\t])\n\t\t\t.optional(),\n\t\t/** Stay on screen instead of leaving after `hold`. */\n\t\tstay: z.boolean().default(false),\n\t\taccent: z.string().optional(),\n\t\tplate: z.string().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 90 },\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 safe = safeArea(W, H, props.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst nameSize = props.nameSize ?? 44 * s;\n\t\tconst roleSize = nameSize * 0.62;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst plateColor = props.plate ?? theme.color.surface;\n\t\tconst right = props.side === \"right\";\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst enter = theme.motion.ease.enter;\n\n\t\tconst nameM = host.measure(props.name, {\n\t\t\tfontFamily,\n\t\t\tfontSize: nameSize,\n\t\t\tfontWeight: 700,\n\t\t});\n\t\tconst roleM = props.role\n\t\t\t? host.measure(props.role, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\tfontWeight: 500,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst padX = nameSize * 0.55;\n\t\tconst padY = nameSize * 0.36;\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\t/** Keys per node and prop, merged into one track each at the end. */\n\t\tconst tracks = new Map<\n\t\t\tAnimatable<LayoutNode>,\n\t\t\tMap<string, KeyframeTuple[]>\n\t\t>();\n\t\tconst key = (\n\t\t\tnode: Animatable<LayoutNode>,\n\t\t\tprop: string,\n\t\t\tkeys: KeyframeTuple[],\n\t\t) => {\n\t\t\tconst byProp = tracks.get(node) ?? new Map<string, KeyframeTuple[]>();\n\t\t\ttracks.set(node, byProp);\n\t\t\tbyProp.set(prop, [...(byProp.get(prop) ?? []), ...keys]);\n\t\t};\n\n\t\tconst text = (\n\t\t\tid: string,\n\t\t\tcontent: string,\n\t\t\tsize: number,\n\t\t\tweight: number,\n\t\t\tfill: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\tfill,\n\t\t\t});\n\n\t\t/**\n\t\t * A plate that slides in through its own box: the background travels\n\t\t * in from the side, its text rises a beat later. Returns the timing.\n\t\t */\n\t\tconst plate = (\n\t\t\tid: string,\n\t\t\trect: Rect,\n\t\t\tbackground: string,\n\t\t\tradius: number,\n\t\t\tcontent: Array<Animatable<LayoutNode>>,\n\t\t\tat: number,\n\t\t) => {\n\t\t\tconst fill = host.box({\n\t\t\t\tid: ctx.partId(`${id}-fill`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: rect.width,\n\t\t\t\theight: rect.height,\n\t\t\t\tbackground,\n\t\t\t\tborderRadius: radius,\n\t\t\t});\n\t\t\tconst travel = rect.width * (right ? 1 : -1);\n\t\t\tconst slide = Math.round(base * 1.1);\n\t\t\tkey(fill, \"x\", [\n\t\t\t\t[at, travel],\n\t\t\t\t[at + slide, 0, enter],\n\t\t\t]);\n\t\t\tconst textAt = at + Math.round(fast * 0.7);\n\t\t\tfor (const node of content) {\n\t\t\t\tconst y0 = (node as unknown as { y: number }).y;\n\t\t\t\tkey(node, \"y\", [\n\t\t\t\t\t[textAt, y0 + rect.height * 0.5],\n\t\t\t\t\t[textAt + base, y0, enter],\n\t\t\t\t]);\n\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t[textAt, 0],\n\t\t\t\t\t[textAt + Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...rect,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: radius,\n\t\t\t\tchildren: [fill, ...content],\n\t\t\t});\n\t\t\tchildren.push(clip);\n\t\t\tparts[id] = clip;\n\t\t\treturn {\n\t\t\t\tclip,\n\t\t\t\tfill,\n\t\t\t\tcontent,\n\t\t\t\ttravel,\n\t\t\t\tsettled: Math.max(at + slide, textAt + base),\n\t\t\t};\n\t\t};\n\n\t\t// ── Layouts ───────────────────────────────────────────────────────\n\t\tconst bottom = safe.y + safe.height;\n\t\tconst xFor = (width: number) =>\n\t\t\tright ? safe.x + safe.width - width : safe.x;\n\t\tconst plates: Array<ReturnType<typeof plate>> = [];\n\t\tlet accentLine: Animatable<LayoutNode> | null = null;\n\t\tconst lineW = Math.max(3, nameSize * 0.09);\n\n\t\tswitch (props.layout) {\n\t\t\tcase \"bar\": {\n\t\t\t\tconst edge = Math.max(4, nameSize * 0.14);\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = padY * 2 + nameM.height + (roleM ? roleM.height * 0.95 : 0);\n\t\t\t\tconst w = innerW + padX * 2 + edge;\n\t\t\t\tconst rect = { x: xFor(w), y: bottom - h, width: w, height: h };\n\t\t\t\tconst tx = right ? padX : padX + edge;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, padY),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tpadY + nameM.height * 0.95,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tconst p = plate(\"plate\", rect, plateColor, theme.radius.sm, content, 0);\n\t\t\t\tconst strip = host.box({\n\t\t\t\t\tid: ctx.partId(\"edge\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: right ? rect.x + rect.width - edge : rect.x,\n\t\t\t\t\ty: rect.y + rect.height,\n\t\t\t\t\twidth: edge,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 0.8);\n\t\t\t\tkey(strip, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, rect.height, enter],\n\t\t\t\t]);\n\t\t\t\tkey(strip, \"y\", [\n\t\t\t\t\t[0, rect.y + rect.height],\n\t\t\t\t\t[grow, rect.y, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(strip);\n\t\t\t\tparts.edge = strip;\n\t\t\t\tplates.push(p);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stacked\": {\n\t\t\t\tconst nameW = nameM.width + padX * 2;\n\t\t\t\tconst nameH = nameM.height + padY * 2;\n\t\t\t\tconst roleH = roleM ? roleM.height + padY * 1.2 : 0;\n\t\t\t\tconst roleW = roleM ? roleM.width + padX * 1.6 : 0;\n\t\t\t\tconst inset = nameSize * 0.4;\n\t\t\t\tconst total = nameH + (roleM ? roleH * 0.9 : 0);\n\t\t\t\tconst top = bottom - total;\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"name-plate\",\n\t\t\t\t\t\t{ x: xFor(nameW), y: top, width: nameW, height: nameH },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\t\t\t700,\n\t\t\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\t\t\tpadX,\n\t\t\t\t\t\t\t\tpadY,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tplates.push(\n\t\t\t\t\t\tplate(\n\t\t\t\t\t\t\t\"role-plate\",\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tx: right ? xFor(roleW) - inset : xFor(roleW) + inset,\n\t\t\t\t\t\t\t\ty: top + nameH - roleH * 0.1,\n\t\t\t\t\t\t\t\twidth: roleW,\n\t\t\t\t\t\t\t\theight: roleH,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\taccent,\n\t\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t\t\t600,\n\t\t\t\t\t\t\t\t\ttheme.color.bg,\n\t\t\t\t\t\t\t\t\tpadX * 0.8,\n\t\t\t\t\t\t\t\t\tpadY * 0.6,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tMath.round(fast * 0.8),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tag\": {\n\t\t\t\t// One line, one node: bold name, a dot, the role a size down.\n\t\t\t\tconst sep = \" · \";\n\t\t\t\tconst sepW = props.role\n\t\t\t\t\t? host.measure(sep, { fontFamily, fontSize: roleSize }).width\n\t\t\t\t\t: 0;\n\t\t\t\tconst h = nameM.height + padY * 1.4;\n\t\t\t\tconst w = nameM.width + (roleM ? sepW + roleM.width : 0) + h;\n\t\t\t\tconst line = host.text(\n\t\t\t\t\tprops.role ? `${props.name}${sep}${props.role}` : props.name,\n\t\t\t\t\t{\n\t\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: h / 2,\n\t\t\t\t\t\ty: padY * 0.7,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: nameSize,\n\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\tspans: [\n\t\t\t\t\t\t\t{ text: props.name, fontWeight: 700, fill: theme.color.ink },\n\t\t\t\t\t\t\t...(props.role\n\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\ttext: `${sep}${props.role}`,\n\t\t\t\t\t\t\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\t\t\t\t\t\t\tfontWeight: 500,\n\t\t\t\t\t\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t\t\t\t\t\t\t// Centred on the name's capitals, not on its baseline: one\n\t\t\t\t\t\t\t\t\t\t\t// line in a pill reads as aligned when the caps share a middle.\n\t\t\t\t\t\t\t\t\t\t\tbaselineShift: -(nameSize - roleSize) * CAP_HEIGHT * 0.5,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t]\n\t\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);\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t{ x: xFor(w), y: bottom - h, width: w, height: h },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t[line],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"line\": {\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = nameM.height + (roleM ? roleM.height : 0);\n\t\t\t\tconst gap = nameSize * 0.4;\n\t\t\t\tconst w = innerW + gap + lineW;\n\t\t\t\tconst x = xFor(w);\n\t\t\t\tconst lineX = right ? x + w - lineW : x;\n\t\t\t\tconst tx = right ? 0 : lineW + gap;\n\t\t\t\taccentLine = host.box({\n\t\t\t\t\tid: ctx.partId(\"line\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lineX,\n\t\t\t\t\ty: bottom,\n\t\t\t\t\twidth: lineW,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: lineW / 2,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 1.1);\n\t\t\t\tkey(accentLine, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, h, enter],\n\t\t\t\t]);\n\t\t\t\tkey(accentLine, \"y\", [\n\t\t\t\t\t[0, bottom],\n\t\t\t\t\t[grow, bottom - h, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(accentLine);\n\t\t\t\tparts.line = accentLine;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\"name\", props.name, nameSize, 700, theme.color.ink, tx, 0),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t500,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tnameM.height,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t// No plate: the text rises through a clear box.\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"text\",\n\t\t\t\t\t\t{ x, y: bottom - h, width: w, height: h },\n\t\t\t\t\t\t\"rgba(0,0,0,0)\",\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tcontent,\n\t\t\t\t\t\tMath.round(fast * 0.6),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst settled = Math.max(...plates.map((p) => p.settled));\n\t\t// ── Out: text first, then the plates slide back the way they came ──\n\t\tlet afterHold = 0;\n\t\tconst outAt = settled + ctx.hold;\n\t\tif (!props.stay) {\n\t\t\tconst fadeOut = Math.round(fast * 0.9);\n\t\t\tconst slide = Math.round(base * 0.9);\n\t\t\tplates.forEach((p, k) => {\n\t\t\t\tconst at = outAt + k * Math.round(fast * 0.4);\n\t\t\t\tfor (const node of p.content)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[at, 1],\n\t\t\t\t\t\t[at + fadeOut, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t\t\tkey(p.fill, \"x\", [\n\t\t\t\t\t[at + fadeOut - 2, 0],\n\t\t\t\t\t[at + fadeOut - 2 + slide, p.travel, theme.motion.ease.exit],\n\t\t\t\t]);\n\t\t\t\tafterHold = Math.max(afterHold, at + fadeOut - 2 + slide - outAt);\n\t\t\t});\n\t\t\tfor (const node of [accentLine, parts.edge])\n\t\t\t\tif (node)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[outAt, 1],\n\t\t\t\t\t\t[outAt + afterHold, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t}\n\t\tfor (const [node, byProp] of tracks) {\n\t\t\tconst a = LayerAnimation.create();\n\t\t\tfor (const [prop, keys] of byProp)\n\t\t\t\ta.keys(\n\t\t\t\t\tprop as \"x\",\n\t\t\t\t\t[...keys].sort((x, y) => x[0] - y[0]),\n\t\t\t\t);\n\t\t\tnode.animate(a);\n\t\t}\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled,\n\t\t\topenEnded: props.stay,\n\t\t\tafterHold,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tin: [{ frame: settled }],\n\t\t\t\tout: props.stay ? [] : [{ frame: outAt }],\n\t\t\t} as LowerThirdCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * The name-and-role bar of interviews and talks: measured plates in the safe\n * area that slide in and leave on their own.\n */\nexport const LowerThird = /* @__PURE__ */ bindComponent(lowerThird);\n"
31
+ "content": "/**\n * LowerThird: the name-and-role bar of interviews and talks. Plates are\n * measured to their text, placed in the safe area's bottom corner, slide in\n * through their own box, and leave on their own after `hold`.\n *\n * Coordinates are the frame's: the root covers the video (default\n * the composition `canvas`, or the given width / height) from 0, 0.\n */\nimport type { LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\televate,\n\tKeyBook,\n\tparseColor,\n\ttype Rect,\n\tsafeArea,\n\tsurfaceFill,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport const LOWER_THIRD_LAYOUTS = [\"bar\", \"stacked\", \"tag\", \"line\"] as const;\n\n/** Cap height as a share of the font size (Inter and most UI sans faces). */\nconst CAP_HEIGHT = 0.72;\nexport type LowerThirdLayout = (typeof LOWER_THIRD_LAYOUTS)[number];\n\nexport interface LowerThirdCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Fully on screen. */\n\tin: ReadonlyArray<{ frame: number }>;\n\t/** Starts to leave (empty with `stay`). */\n\tout: ReadonlyArray<{ frame: number }>;\n}\n\nexport const lowerThird = /* @__PURE__ */ defineComponent({\n\tname: \"LowerThird\",\n\tprops: () => ({\n\t\tname: z.string().min(1),\n\t\trole: z.string().optional(),\n\t\tlayout: z.enum(LOWER_THIRD_LAYOUTS).default(\"bar\"),\n\t\tside: z.enum([\"left\", \"right\"]).default(\"left\"),\n\t\tsafeArea: z\n\t\t\t.union([\n\t\t\t\tz.enum([\"portrait\", \"landscape\"]),\n\t\t\t\tz.object({\n\t\t\t\t\tx: z.number(),\n\t\t\t\t\ty: z.number(),\n\t\t\t\t\twidth: z.number(),\n\t\t\t\t\theight: z.number(),\n\t\t\t\t}),\n\t\t\t])\n\t\t\t.optional(),\n\t\t/** Stay on screen instead of leaving after `hold`. */\n\t\tstay: z.boolean().default(false),\n\t\taccent: z.string().optional(),\n\t\tplate: z.string().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 90 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\n\t\tconst safe = safeArea({ width: W, height: H }, props.safeArea);\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst nameSize = props.nameSize ?? 44 * s;\n\t\tconst roleSize = nameSize * 0.62;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst plateColor = props.plate ?? theme.color.surface;\n\t\tconst right = props.side === \"right\";\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst fast = themeFrames(theme, \"fast\", fps);\n\t\tconst enter = theme.motion.ease.enter;\n\n\t\tconst nameM = host.measure(props.name, {\n\t\t\tfontFamily,\n\t\t\tfontSize: nameSize,\n\t\t\tfontWeight: theme.weight.bold,\n\t\t});\n\t\tconst roleM = props.role\n\t\t\t? host.measure(props.role, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\tfontWeight: theme.weight.medium,\n\t\t\t\t})\n\t\t\t: null;\n\t\tconst padX = nameSize * 0.55;\n\t\tconst padY = nameSize * 0.36;\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\t/** Keys per node and prop, merged into one track each at the end. */\n\t\tconst book = new KeyBook<Animatable<LayoutNode>>();\n\t\tconst key = book.key.bind(book);\n\n\t\tconst text = (\n\t\t\tid: string,\n\t\t\tcontent: string,\n\t\t\tsize: number,\n\t\t\tweight: number,\n\t\t\tfill: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t) =>\n\t\t\thost.text(content, {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: weight,\n\t\t\t\tfill,\n\t\t\t});\n\n\t\t/**\n\t\t * A plate that slides in through its own box: the background travels\n\t\t * in from the side, its text rises a beat later. Returns the timing.\n\t\t */\n\t\tconst plate = (\n\t\t\tid: string,\n\t\t\trect: Rect,\n\t\t\tbackground: string,\n\t\t\tradius: number,\n\t\t\tcontent: Array<Animatable<LayoutNode>>,\n\t\t\tat: number,\n\t\t) => {\n\t\t\tconst fill = host.box({\n\t\t\t\tid: ctx.partId(`${id}-fill`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: rect.width,\n\t\t\t\theight: rect.height,\n\t\t\t\tbackground: surfaceFill(background, theme),\n\t\t\t\tborderRadius: radius,\n\t\t\t\t// The slide is fast: a little smear sells it.\n\t\t\t\tmotionBlurShutter: 90,\n\t\t\t});\n\t\t\tconst travel = rect.width * (right ? 1 : -1);\n\t\t\tconst slide = Math.round(base * 1.1);\n\t\t\tkey(fill, \"x\", [\n\t\t\t\t[at, travel],\n\t\t\t\t[at + slide, 0, enter],\n\t\t\t]);\n\t\t\tconst textAt = at + Math.round(fast * 0.7);\n\t\t\tfor (const node of content) {\n\t\t\t\tconst y0 = (node as unknown as { y: number }).y;\n\t\t\t\tkey(node, \"y\", [\n\t\t\t\t\t[textAt, y0 + rect.height * 0.5],\n\t\t\t\t\t[textAt + base, y0, enter],\n\t\t\t\t]);\n\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t[textAt, 0],\n\t\t\t\t\t[textAt + Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\t...rect,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: radius,\n\t\t\t\tchildren: [fill, ...content],\n\t\t\t});\n\t\t\t// A clear plate (the `line` layout) has nothing to lift.\n\t\t\tif (parseColor(background)?.[3] !== 0) elevate(host, clip, theme, \"low\");\n\t\t\tchildren.push(clip);\n\t\t\tparts[id] = clip;\n\t\t\treturn {\n\t\t\t\tclip,\n\t\t\t\tfill,\n\t\t\t\tcontent,\n\t\t\t\ttravel,\n\t\t\t\tsettled: Math.max(at + slide, textAt + base),\n\t\t\t};\n\t\t};\n\n\t\t// ── Layouts ───────────────────────────────────────────────────────\n\t\tconst bottom = safe.y + safe.height;\n\t\tconst xFor = (width: number) =>\n\t\t\tright ? safe.x + safe.width - width : safe.x;\n\t\tconst plates: Array<ReturnType<typeof plate>> = [];\n\t\tlet accentLine: Animatable<LayoutNode> | null = null;\n\t\tconst lineW = Math.max(3, nameSize * 0.09);\n\n\t\tswitch (props.layout) {\n\t\t\tcase \"bar\": {\n\t\t\t\tconst edge = Math.max(4, nameSize * 0.14);\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = padY * 2 + nameM.height + (roleM ? roleM.height * 0.95 : 0);\n\t\t\t\tconst w = innerW + padX * 2 + edge;\n\t\t\t\tconst rect = { x: xFor(w), y: bottom - h, width: w, height: h };\n\t\t\t\tconst tx = right ? padX : padX + edge;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\n\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\ttheme.weight.bold,\n\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\ttx,\n\t\t\t\t\t\tpadY,\n\t\t\t\t\t),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\ttheme.weight.medium,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tpadY + nameM.height * 0.95,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tconst p = plate(\"plate\", rect, plateColor, theme.radius.sm, content, 0);\n\t\t\t\tconst strip = host.box({\n\t\t\t\t\tid: ctx.partId(\"edge\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: right ? rect.x + rect.width - edge : rect.x,\n\t\t\t\t\ty: rect.y + rect.height,\n\t\t\t\t\twidth: edge,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 0.8);\n\t\t\t\tkey(strip, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, rect.height, enter],\n\t\t\t\t]);\n\t\t\t\tkey(strip, \"y\", [\n\t\t\t\t\t[0, rect.y + rect.height],\n\t\t\t\t\t[grow, rect.y, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(strip);\n\t\t\t\tparts.edge = strip;\n\t\t\t\tplates.push(p);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"stacked\": {\n\t\t\t\tconst nameW = nameM.width + padX * 2;\n\t\t\t\tconst nameH = nameM.height + padY * 2;\n\t\t\t\tconst roleH = roleM ? roleM.height + padY * 1.2 : 0;\n\t\t\t\tconst roleW = roleM ? roleM.width + padX * 1.6 : 0;\n\t\t\t\tconst inset = nameSize * 0.4;\n\t\t\t\tconst total = nameH + (roleM ? roleH * 0.9 : 0);\n\t\t\t\tconst top = bottom - total;\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"name-plate\",\n\t\t\t\t\t\t{ x: xFor(nameW), y: top, width: nameW, height: nameH },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\t\t\ttheme.weight.bold,\n\t\t\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\t\t\tpadX,\n\t\t\t\t\t\t\t\tpadY,\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tplates.push(\n\t\t\t\t\t\tplate(\n\t\t\t\t\t\t\t\"role-plate\",\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tx: right ? xFor(roleW) - inset : xFor(roleW) + inset,\n\t\t\t\t\t\t\t\ty: top + nameH - roleH * 0.1,\n\t\t\t\t\t\t\t\twidth: roleW,\n\t\t\t\t\t\t\t\theight: roleH,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\taccent,\n\t\t\t\t\t\t\ttheme.radius.sm,\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\t\t\ttheme.weight.semibold,\n\t\t\t\t\t\t\t\t\ttheme.color.bg,\n\t\t\t\t\t\t\t\t\tpadX * 0.8,\n\t\t\t\t\t\t\t\t\tpadY * 0.6,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t\tMath.round(fast * 0.8),\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tag\": {\n\t\t\t\t// One line, one node: bold name, a dot, the role a size down.\n\t\t\t\tconst sep = \" · \";\n\t\t\t\tconst sepW = props.role\n\t\t\t\t\t? host.measure(sep, { fontFamily, fontSize: roleSize }).width\n\t\t\t\t\t: 0;\n\t\t\t\tconst h = nameM.height + padY * 1.4;\n\t\t\t\tconst w = nameM.width + (roleM ? sepW + roleM.width : 0) + h;\n\t\t\t\tconst line = host.text(\n\t\t\t\t\tprops.role ? `${props.name}${sep}${props.role}` : props.name,\n\t\t\t\t\t{\n\t\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: h / 2,\n\t\t\t\t\t\ty: padY * 0.7,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: nameSize,\n\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\tspans: [\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttext: props.name,\n\t\t\t\t\t\t\t\tfontWeight: theme.weight.bold,\n\t\t\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t...(props.role\n\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\ttext: `${sep}${props.role}`,\n\t\t\t\t\t\t\t\t\t\t\tfontSize: roleSize,\n\t\t\t\t\t\t\t\t\t\t\tfontWeight: theme.weight.medium,\n\t\t\t\t\t\t\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t\t\t\t\t\t\t\t// Centred on the name's capitals, not on its baseline: one\n\t\t\t\t\t\t\t\t\t\t\t// line in a pill reads as aligned when the caps share a middle.\n\t\t\t\t\t\t\t\t\t\t\tbaselineShift: -(nameSize - roleSize) * CAP_HEIGHT * 0.5,\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t]\n\t\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);\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"tag\",\n\t\t\t\t\t\t{ x: xFor(w), y: bottom - h, width: w, height: h },\n\t\t\t\t\t\tplateColor,\n\t\t\t\t\t\ttheme.radius.pill,\n\t\t\t\t\t\t[line],\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"line\": {\n\t\t\t\tconst innerW = Math.max(nameM.width, roleM?.width ?? 0);\n\t\t\t\tconst h = nameM.height + (roleM ? roleM.height : 0);\n\t\t\t\tconst gap = nameSize * 0.4;\n\t\t\t\tconst w = innerW + gap + lineW;\n\t\t\t\tconst x = xFor(w);\n\t\t\t\tconst lineX = right ? x + w - lineW : x;\n\t\t\t\tconst tx = right ? 0 : lineW + gap;\n\t\t\t\taccentLine = host.box({\n\t\t\t\t\tid: ctx.partId(\"line\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: lineX,\n\t\t\t\t\ty: bottom,\n\t\t\t\t\twidth: lineW,\n\t\t\t\t\theight: 0,\n\t\t\t\t\tbackground: accent,\n\t\t\t\t\tborderRadius: lineW / 2,\n\t\t\t\t});\n\t\t\t\tconst grow = Math.round(base * 1.1);\n\t\t\t\tkey(accentLine, \"height\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[grow, h, enter],\n\t\t\t\t]);\n\t\t\t\tkey(accentLine, \"y\", [\n\t\t\t\t\t[0, bottom],\n\t\t\t\t\t[grow, bottom - h, enter],\n\t\t\t\t]);\n\t\t\t\tchildren.push(accentLine);\n\t\t\t\tparts.line = accentLine;\n\t\t\t\tconst content = [\n\t\t\t\t\ttext(\n\t\t\t\t\t\t\"name\",\n\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\tnameSize,\n\t\t\t\t\t\ttheme.weight.bold,\n\t\t\t\t\t\ttheme.color.ink,\n\t\t\t\t\t\ttx,\n\t\t\t\t\t\t0,\n\t\t\t\t\t),\n\t\t\t\t];\n\t\t\t\tif (props.role && roleM)\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttext(\n\t\t\t\t\t\t\t\"role\",\n\t\t\t\t\t\t\tprops.role,\n\t\t\t\t\t\t\troleSize,\n\t\t\t\t\t\t\ttheme.weight.medium,\n\t\t\t\t\t\t\ttheme.color.muted,\n\t\t\t\t\t\t\ttx,\n\t\t\t\t\t\t\tnameM.height,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t// No plate: the text rises through a clear box.\n\t\t\t\tplates.push(\n\t\t\t\t\tplate(\n\t\t\t\t\t\t\"text\",\n\t\t\t\t\t\t{ x, y: bottom - h, width: w, height: h },\n\t\t\t\t\t\t\"rgba(0,0,0,0)\",\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tcontent,\n\t\t\t\t\t\tMath.round(fast * 0.6),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tconst settled = Math.max(...plates.map((p) => p.settled));\n\t\t// ── Out: text first, then the plates slide back the way they came ──\n\t\tlet afterHold = 0;\n\t\tconst outAt = settled + ctx.hold;\n\t\tif (!props.stay) {\n\t\t\tconst fadeOut = Math.round(fast * 0.9);\n\t\t\tconst slide = Math.round(base * 0.9);\n\t\t\tplates.forEach((p, k) => {\n\t\t\t\tconst at = outAt + k * Math.round(fast * 0.4);\n\t\t\t\tfor (const node of p.content)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[at, 1],\n\t\t\t\t\t\t[at + fadeOut, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t\t\tkey(p.fill, \"x\", [\n\t\t\t\t\t[at + fadeOut - 2, 0],\n\t\t\t\t\t[at + fadeOut - 2 + slide, p.travel, theme.motion.ease.exit],\n\t\t\t\t]);\n\t\t\t\tafterHold = Math.max(afterHold, at + fadeOut - 2 + slide - outAt);\n\t\t\t});\n\t\t\tfor (const node of [accentLine, parts.edge])\n\t\t\t\tif (node)\n\t\t\t\t\tkey(node, \"opacity\", [\n\t\t\t\t\t\t[outAt, 1],\n\t\t\t\t\t\t[outAt + afterHold, 0, \"power2.in\"],\n\t\t\t\t\t]);\n\t\t}\n\t\tbook.apply();\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled,\n\t\t\topenEnded: props.stay,\n\t\t\tafterHold,\n\t\t\torigin: [0, 0],\n\t\t\tcues: {\n\t\t\t\tin: [{ frame: settled }],\n\t\t\t\tout: props.stay ? [] : [{ frame: outAt }],\n\t\t\t} as LowerThirdCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * The name-and-role bar of interviews and talks: measured plates in the safe\n * area that slide in and leave on their own.\n */\nexport const LowerThird = /* @__PURE__ */ bindComponent(lowerThird);\n"
32
32
  }
33
33
  ]
34
34
  }
@@ -29,7 +29,7 @@
29
29
  "files": [
30
30
  {
31
31
  "path": "meter/index.ts",
32
- "content": "/**\n * Meter: progress and scores as a ring, an arc gauge, a bar, lit segments or\n * a star rating, filling on the same value timeline as its `Counter`.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport { counter } from \"../counter/index.js\";\nimport { type ValueKey, valueAt } from \"../counter/plan.js\";\n\nexport const METER_KINDS = [\"ring\", \"arc\", \"bar\", \"segments\", \"stars\"] as const;\nexport type MeterKind = (typeof METER_KINDS)[number];\n\nexport interface MeterCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The fill (and counter) is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** Each segment or star completing (fills only; empties are not cued). */\n\tcrossings: ReadonlyArray<{ frame: number; segment: number }>;\n}\n\n/** Points along a circular arc, angles in degrees (0 = right, clockwise). */\nfunction arcPoints(c: Vec2, r: number, from: number, to: number): Vec2[] {\n\tconst steps = Math.max(8, Math.ceil(Math.abs(to - from) / 6));\n\treturn Array.from({ length: steps + 1 }, (_, k) => {\n\t\tconst a = ((from + ((to - from) * k) / steps) * Math.PI) / 180;\n\t\treturn [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r] as Vec2;\n\t});\n}\n\n/** A five-point star inside a `size` box. */\nfunction starPath(size: number): string {\n\tconst c = size / 2;\n\tconst pts: Vec2[] = Array.from({ length: 10 }, (_, k) => {\n\t\tconst r = k % 2 === 0 ? size * 0.5 : size * 0.21;\n\t\tconst a = -Math.PI / 2 + (k * Math.PI) / 5;\n\t\treturn [c + Math.cos(a) * r, c + Math.sin(a) * r * 1.02 + size * 0.03];\n\t});\n\treturn toPathData(pts, true);\n}\n\nexport const meter = /* @__PURE__ */ defineComponent({\n\tname: \"Meter\",\n\tprops: () => ({\n\t\tvalue: z.number().optional(),\n\t\t/** Multi-step values: `[frame, value, ease?]`, frames ascending. */\n\t\tvalues: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number()]),\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number(), z.string()]),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(1)\n\t\t\t.optional(),\n\t\tfrom: z.number().default(0),\n\t\t/** Default 100 (5 for stars). */\n\t\tmax: z.number().optional(),\n\t\tkind: z.enum(METER_KINDS).default(\"ring\"),\n\t\t/** Cells for `segments` (default 10), stars for `stars` (default `max`). */\n\t\tsegments: z.number().int().positive().optional(),\n\t\t/** Width (bar, segments, stars) or diameter (ring, arc). */\n\t\tsize: z.number().positive().optional(),\n\t\tthickness: z.number().positive().optional(),\n\t\tcounter: z.boolean().optional(),\n\t\tformat: z.string().default(\",d\"),\n\t\tsuffix: z.string().optional(),\n\t\tlabel: z.string().optional(),\n\t\tcolor: z.string().optional(),\n\t\ttrack: z.string().optional(),\n\t\tcountDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst kind = props.kind;\n\t\tconst max = props.max ?? (kind === \"stars\" ? 5 : 100);\n\t\tconst from = props.from;\n\t\tconst ease = props.ease ?? theme.motion.ease.move;\n\t\tconst color = props.color ?? theme.color.accent;\n\t\tconst trackColor = props.track ?? theme.color.line;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tif (props.value === undefined && !props.values)\n\t\t\tthrow new Error(\"Meter: give `value` (or `values`).\");\n\t\tconst keys: ValueKey[] = props.values\n\t\t\t? props.values.map((v) => [...v] as unknown as ValueKey)\n\t\t\t: [\n\t\t\t\t\t[0, from],\n\t\t\t\t\t[\n\t\t\t\t\t\tprops.countDuration ?? themeFrames(theme, \"slow\", fps) * 2,\n\t\t\t\t\t\tprops.value as number,\n\t\t\t\t\t\tease,\n\t\t\t\t\t],\n\t\t\t\t];\n\t\tconst last = (keys[keys.length - 1] as ValueKey)[0];\n\t\tconst fraction = (v: number) =>\n\t\t\tMath.max(0, Math.min(1, (v - from) / (max - from || 1)));\n\t\t/** Keys of `f(fraction)`, on exactly the value's keyframes and eases. */\n\t\tconst fillKeys = (f: (x: number) => number): KeyframeTuple[] =>\n\t\t\tkeys.map((k, i) =>\n\t\t\t\ti === 0\n\t\t\t\t\t? [k[0], f(fraction(k[1]))]\n\t\t\t\t\t: [k[0], f(fraction(k[1])), k[2] ?? ease],\n\t\t\t);\n\t\tconst perFrame = Array.from({ length: last + 1 }, (_, t) =>\n\t\t\tvalueAt(keys, t, ease),\n\t\t);\n\n\t\tconst showCounter = props.counter ?? kind !== \"stars\";\n\t\tconst suffix = props.suffix ?? (max === 100 ? \"%\" : \"\");\n\t\tconst makeCounter = bind(host, counter);\n\t\tconst number = (size: number, align: \"start\" | \"center\" | \"end\") =>\n\t\t\tmakeCounter({\n\t\t\t\tid: ctx.partId(\"counter\"),\n\t\t\t\tvalues: keys.map((k) => [...k]) as never,\n\t\t\t\tformat: props.format,\n\t\t\t\tsuffix,\n\t\t\t\taffixScale: 0.6,\n\t\t\t\tdigitStagger: 0,\n\t\t\t\tease,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: 700,\n\t\t\t\talign,\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\tconst label = (text: string, size: number) =>\n\t\t\thost.text(text, {\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst crossings: Array<{ frame: number; segment: number }> = [];\n\t\tlet W = 0;\n\t\tlet H = 0;\n\n\t\tconst stroke = (\n\t\t\tid: string,\n\t\t\td: string,\n\t\t\tw: number,\n\t\t\th: number,\n\t\t\tpaint: string,\n\t\t\tthickness: number,\n\t\t) =>\n\t\t\thost.shape(\"path\", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t\td,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: paint,\n\t\t\t\tstrokeWidth: thickness,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t});\n\n\t\tswitch (kind) {\n\t\t\tcase \"ring\":\n\t\t\tcase \"arc\": {\n\t\t\t\tconst D = props.size ?? 260 * s;\n\t\t\t\tconst T = props.thickness ?? D * (kind === \"ring\" ? 0.085 : 0.1);\n\t\t\t\tconst r = (D - T) / 2;\n\t\t\t\tconst c: Vec2 = [D / 2, D / 2];\n\t\t\t\t// Ring: a full turn from the top. Arc: 240° from lower left, over the top.\n\t\t\t\tconst [a0, a1] = kind === \"ring\" ? [-90, 269.9] : [150, 390];\n\t\t\t\tconst path = toPathData(arcPoints(c, r, a0, a1));\n\t\t\t\tW = D;\n\t\t\t\tH = kind === \"ring\" ? D : D * 0.86;\n\t\t\t\tconst track = stroke(\"track\", path, W, H, trackColor, T);\n\t\t\t\tconst fill = stroke(\"fill\", path, W, H, color, T);\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"trimEnd\",\n\t\t\t\t\t\tfillKeys((x) => x),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(D * (kind === \"ring\" ? 0.24 : 0.22), \"center\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = (W - nn.width) / 2;\n\t\t\t\t\tnn.y =\n\t\t\t\t\t\t(kind === \"ring\" ? D / 2 : D * 0.48) -\n\t\t\t\t\t\tnn.height / 2 -\n\t\t\t\t\t\t(props.label ? D * 0.04 : 0);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t\tif (props.label) {\n\t\t\t\t\t\tconst size = D * 0.075;\n\t\t\t\t\t\tconst m = host.measure(props.label, { fontFamily, fontSize: size });\n\t\t\t\t\t\tconst l = label(props.label, size);\n\t\t\t\t\t\tObject.assign(l, { x: (W - m.width) / 2, y: nn.y + nn.height });\n\t\t\t\t\t\tchildren.push(l);\n\t\t\t\t\t\tparts.label = l;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"bar\": {\n\t\t\t\tconst w = props.size ?? 640 * s;\n\t\t\t\tconst T = props.thickness ?? 16 * s;\n\t\t\t\tconst top = props.label ? 34 * s : 0;\n\t\t\t\tconst track = host.box({\n\t\t\t\t\tid: ctx.partId(\"track\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tconst fill = host.box({\n\t\t\t\t\tid: ctx.partId(\"fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: 0,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\tfillKeys((x) => x * w),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tW = w;\n\t\t\t\tH = top + T;\n\t\t\t\tif (props.label) {\n\t\t\t\t\tconst l = label(props.label, 24 * s);\n\t\t\t\t\tObject.assign(l, { x: 0, y: 0 });\n\t\t\t\t\tchildren.push(l);\n\t\t\t\t\tparts.label = l;\n\t\t\t\t}\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(Math.max(T * 2.2, 30 * s), \"end\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = w + 18 * s;\n\t\t\t\t\tnn.y = top + T / 2 - nn.height / 2;\n\t\t\t\t\tW = nn.x + nn.width;\n\t\t\t\t\tH = Math.max(H, nn.y + nn.height);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"segments\":\n\t\t\tcase \"stars\": {\n\t\t\t\tconst n = props.segments ?? (kind === \"stars\" ? Math.round(max) : 10);\n\t\t\t\tconst w = props.size ?? (kind === \"stars\" ? 300 : 640) * s;\n\t\t\t\tconst gap = kind === \"stars\" ? w * 0.03 : w * 0.012;\n\t\t\t\tconst cell = (w - gap * (n - 1)) / n;\n\t\t\t\tconst h = kind === \"stars\" ? cell : (props.thickness ?? 22 * s);\n\t\t\t\t// How full each cell is, per frame.\n\t\t\t\tconst fill = (k: number) =>\n\t\t\t\t\tperFrame.map((v) => Math.max(0, Math.min(1, fraction(v) * n - k)));\n\t\t\t\tfor (let k = 0; k < n; k++) {\n\t\t\t\t\tconst x = k * (cell + gap);\n\t\t\t\t\tconst amount = fill(k);\n\t\t\t\t\tconst full = amount.findIndex((a) => a >= 1);\n\t\t\t\t\tif (full >= 0) crossings.push({ frame: full, segment: k });\n\t\t\t\t\tif (kind === \"segments\") {\n\t\t\t\t\t\tconst base = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`cell-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst lit = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`lit-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: color,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Lights on the frame the value reaches the cell, with a small pop.\n\t\t\t\t\t\tconst on = amount.map((a) => (a >= 1 ? 1 : 0));\n\t\t\t\t\t\tconst opacity: KeyframeTuple[] = [[0, on[0] ?? 0]];\n\t\t\t\t\t\tconst scale: KeyframeTuple[] = [[0, 1]];\n\t\t\t\t\t\ton.forEach((v, t) => {\n\t\t\t\t\t\t\tif (t > 0 && v !== on[t - 1]) {\n\t\t\t\t\t\t\t\topacity.push([t, v, \"hold\"]);\n\t\t\t\t\t\t\t\tif (v === 1) scale.push([t, 1.18], [t + 6, 1, \"power2.out\"]);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t\tlit.animate(\n\t\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t\t.keys(\"opacity\", opacity)\n\t\t\t\t\t\t\t\t.keys(\"scale\", ascending(scale)),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, lit);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst d = starPath(cell);\n\t\t\t\t\t\tconst base = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: trackColor,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst star = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-fill`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Fractional ratings fill a star partway: a clip that widens.\n\t\t\t\t\t\tconst clip = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-clip`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: 0,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\t\tchildren: [star],\n\t\t\t\t\t\t});\n\t\t\t\t\t\tclip.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\t\t\tamount.map((a) => a * cell),\n\t\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, clip);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tW = w;\n\t\t\t\tH = h;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst c = number(Math.max(h * 1.4, 30 * s), \"end\");\n\t\t\t\t\tconst cc = c as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tcc.x = w + 18 * s;\n\t\t\t\t\tcc.y = h / 2 - cc.height / 2;\n\t\t\t\t\tW = cc.x + cc.width;\n\t\t\t\t\tH = Math.max(H, cc.y + cc.height);\n\t\t\t\t\tchildren.push(c);\n\t\t\t\t\tparts.counter = c;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// Centring the counter on a shorter row can put it above the top; the\n\t\t// root box clips what's outside it, so move everything down instead.\n\t\tconst top = Math.min(\n\t\t\t0,\n\t\t\t...children.map((n) => (n as unknown as { y?: number }).y ?? 0),\n\t\t);\n\t\tif (top < 0) {\n\t\t\tfor (const n of children) {\n\t\t\t\tconst node = n as unknown as { y?: number };\n\t\t\t\tnode.y = (node.y ?? 0) - top;\n\t\t\t}\n\t\t\tH -= top;\n\t\t}\n\n\t\tconst settled = Math.max(\n\t\t\tlast,\n\t\t\t(\n\t\t\t\tparts.counter as unknown as {\n\t\t\t\t\tcues?: { settled: Array<{ frame: number }> };\n\t\t\t\t}\n\t\t\t)?.cues?.settled[0]?.frame ?? 0,\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled + 1,\n\t\t\topenEnded: true,\n\t\t\tcues: { settled: [{ frame: settled }], crossings } as MeterCues,\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].sort((a, b) => a[0] - b[0])) {\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;\n}\n\n/**\n * Progress and scores as a ring, arc gauge, bar, lit segments or star rating,\n * filling on the same timeline as its counter.\n */\nexport const Meter = /* @__PURE__ */ bindComponent(meter);\n"
32
+ "content": "/**\n * Meter: progress and scores as a ring, an arc gauge, a bar, lit segments or\n * a star rating, filling on the same value timeline as its `Counter`.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tascending,\n\tbakeSamples,\n\tbind,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\ttoPathData,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport { counter } from \"../counter/index.js\";\nimport { type ValueKey, valueAt } from \"../counter/plan.js\";\n\nexport const METER_KINDS = [\"ring\", \"arc\", \"bar\", \"segments\", \"stars\"] as const;\nexport type MeterKind = (typeof METER_KINDS)[number];\n\nexport interface MeterCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The fill (and counter) is at rest. */\n\tsettled: ReadonlyArray<{ frame: number }>;\n\t/** Each segment or star completing (fills only; empties are not cued). */\n\tcrossings: ReadonlyArray<{ frame: number; segment: number }>;\n}\n\n/** Points along a circular arc, angles in degrees (0 = right, clockwise). */\nfunction arcPoints(c: Vec2, r: number, from: number, to: number): Vec2[] {\n\tconst steps = Math.max(8, Math.ceil(Math.abs(to - from) / 6));\n\treturn Array.from({ length: steps + 1 }, (_, k) => {\n\t\tconst a = ((from + ((to - from) * k) / steps) * Math.PI) / 180;\n\t\treturn [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r] as Vec2;\n\t});\n}\n\n/** A five-point star inside a `size` box. */\nfunction starPath(size: number): string {\n\tconst c = size / 2;\n\tconst pts: Vec2[] = Array.from({ length: 10 }, (_, k) => {\n\t\tconst r = k % 2 === 0 ? size * 0.5 : size * 0.21;\n\t\tconst a = -Math.PI / 2 + (k * Math.PI) / 5;\n\t\treturn [c + Math.cos(a) * r, c + Math.sin(a) * r * 1.02 + size * 0.03];\n\t});\n\treturn toPathData(pts, true);\n}\n\nexport const meter = /* @__PURE__ */ defineComponent({\n\tname: \"Meter\",\n\tprops: () => ({\n\t\tvalue: z.number().optional(),\n\t\t/** Multi-step values: `[frame, value, ease?]`, frames ascending. */\n\t\tvalues: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number()]),\n\t\t\t\t\tz.tuple([z.number().int().min(0), z.number(), z.string()]),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(1)\n\t\t\t.optional(),\n\t\tfrom: z.number().default(0),\n\t\t/** Default 100 (5 for stars). */\n\t\tmax: z.number().optional(),\n\t\tkind: z.enum(METER_KINDS).default(\"ring\"),\n\t\t/** Cells for `segments` (default 10), stars for `stars` (default `max`). */\n\t\tsegments: z.number().int().positive().optional(),\n\t\t/** Width (bar, segments, stars) or diameter (ring, arc). */\n\t\tsize: z.number().positive().optional(),\n\t\tthickness: z.number().positive().optional(),\n\t\tcounter: z.boolean().optional(),\n\t\tformat: z.string().default(\",d\"),\n\t\tsuffix: z.string().optional(),\n\t\tlabel: z.string().optional(),\n\t\tcolor: z.string().optional(),\n\t\ttrack: z.string().optional(),\n\t\tcountDuration: z.number().int().positive().optional(),\n\t\tease: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst kind = props.kind;\n\t\tconst max = props.max ?? (kind === \"stars\" ? 5 : 100);\n\t\tconst from = props.from;\n\t\tconst ease = props.ease ?? theme.motion.ease.move;\n\t\tconst color = props.color ?? theme.color.accent;\n\t\tconst trackColor = props.track ?? theme.color.line;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tif (props.value === undefined && !props.values)\n\t\t\tthrow new Error(\"Meter: give `value` (or `values`).\");\n\t\tconst keys: ValueKey[] = props.values\n\t\t\t? props.values.map((v) => [...v] as unknown as ValueKey)\n\t\t\t: [\n\t\t\t\t\t[0, from],\n\t\t\t\t\t[\n\t\t\t\t\t\tprops.countDuration ?? themeFrames(theme, \"slow\", fps) * 2,\n\t\t\t\t\t\tprops.value as number,\n\t\t\t\t\t\tease,\n\t\t\t\t\t],\n\t\t\t\t];\n\t\tconst last = (keys[keys.length - 1] as ValueKey)[0];\n\t\tconst fraction = (v: number) =>\n\t\t\tMath.max(0, Math.min(1, (v - from) / (max - from || 1)));\n\t\t/** Keys of `f(fraction)`, on exactly the value's keyframes and eases. */\n\t\tconst fillKeys = (f: (x: number) => number): KeyframeTuple[] =>\n\t\t\tkeys.map((k, i) =>\n\t\t\t\ti === 0\n\t\t\t\t\t? [k[0], f(fraction(k[1]))]\n\t\t\t\t\t: [k[0], f(fraction(k[1])), k[2] ?? ease],\n\t\t\t);\n\t\tconst perFrame = Array.from({ length: last + 1 }, (_, t) =>\n\t\t\tvalueAt(keys, t, ease),\n\t\t);\n\n\t\tconst showCounter = props.counter ?? kind !== \"stars\";\n\t\tconst suffix = props.suffix ?? (max === 100 ? \"%\" : \"\");\n\t\tconst makeCounter = bind(host, counter);\n\t\tconst number = (size: number, align: \"start\" | \"center\" | \"end\") =>\n\t\t\tmakeCounter({\n\t\t\t\tid: ctx.partId(\"counter\"),\n\t\t\t\tvalues: keys.map((k) => [...k]) as never,\n\t\t\t\tformat: props.format,\n\t\t\t\tsuffix,\n\t\t\t\taffixScale: 0.6,\n\t\t\t\tdigitStagger: 0,\n\t\t\t\tease,\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfontWeight: theme.weight.bold,\n\t\t\t\talign,\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\ttheme,\n\t\t\t\tfps,\n\t\t\t});\n\t\tconst label = (text: string, size: number) =>\n\t\t\thost.text(text, {\n\t\t\t\tid: ctx.partId(\"label\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\n\t\tconst children: LayoutNode[] = [];\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst crossings: Array<{ frame: number; segment: number }> = [];\n\t\tlet W = 0;\n\t\tlet H = 0;\n\n\t\tconst stroke = (\n\t\t\tid: string,\n\t\t\td: string,\n\t\t\tw: number,\n\t\t\th: number,\n\t\t\tpaint: string,\n\t\t\tthickness: number,\n\t\t) =>\n\t\t\thost.shape(\"path\", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: w,\n\t\t\t\theight: h,\n\t\t\t\td,\n\t\t\t\tfillType: \"none\",\n\t\t\t\tstrokeColor: paint,\n\t\t\t\tstrokeWidth: thickness,\n\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t});\n\n\t\tswitch (kind) {\n\t\t\tcase \"ring\":\n\t\t\tcase \"arc\": {\n\t\t\t\tconst D = props.size ?? 260 * s;\n\t\t\t\tconst T = props.thickness ?? D * (kind === \"ring\" ? 0.085 : 0.1);\n\t\t\t\tconst r = (D - T) / 2;\n\t\t\t\tconst c: Vec2 = [D / 2, D / 2];\n\t\t\t\t// Ring: a full turn from the top. Arc: 240° from lower left, over the top.\n\t\t\t\tconst [a0, a1] = kind === \"ring\" ? [-90, 269.9] : [150, 390];\n\t\t\t\tconst path = toPathData(arcPoints(c, r, a0, a1));\n\t\t\t\tW = D;\n\t\t\t\tH = kind === \"ring\" ? D : D * 0.86;\n\t\t\t\tconst track = stroke(\"track\", path, W, H, trackColor, T);\n\t\t\t\tconst fill = stroke(\"fill\", path, W, H, color, T);\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"trimEnd\",\n\t\t\t\t\t\tfillKeys((x) => x),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(D * (kind === \"ring\" ? 0.24 : 0.22), \"center\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = (W - nn.width) / 2;\n\t\t\t\t\tnn.y =\n\t\t\t\t\t\t(kind === \"ring\" ? D / 2 : D * 0.48) -\n\t\t\t\t\t\tnn.height / 2 -\n\t\t\t\t\t\t(props.label ? D * 0.04 : 0);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t\tif (props.label) {\n\t\t\t\t\t\tconst size = D * 0.075;\n\t\t\t\t\t\tconst m = host.measure(props.label, { fontFamily, fontSize: size });\n\t\t\t\t\t\tconst l = label(props.label, size);\n\t\t\t\t\t\tObject.assign(l, { x: (W - m.width) / 2, y: nn.y + nn.height });\n\t\t\t\t\t\tchildren.push(l);\n\t\t\t\t\t\tparts.label = l;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"bar\": {\n\t\t\t\tconst w = props.size ?? 640 * s;\n\t\t\t\tconst T = props.thickness ?? 16 * s;\n\t\t\t\tconst top = props.label ? 34 * s : 0;\n\t\t\t\tconst track = host.box({\n\t\t\t\t\tid: ctx.partId(\"track\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: w,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tconst fill = host.box({\n\t\t\t\t\tid: ctx.partId(\"fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: top,\n\t\t\t\t\twidth: 0,\n\t\t\t\t\theight: T,\n\t\t\t\t\tbackground: color,\n\t\t\t\t\tborderRadius: T / 2,\n\t\t\t\t});\n\t\t\t\tfill.animate(\n\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\tfillKeys((x) => x * w),\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t\tchildren.push(track, fill);\n\t\t\t\tparts.track = track;\n\t\t\t\tparts.fill = fill;\n\t\t\t\tW = w;\n\t\t\t\tH = top + T;\n\t\t\t\tif (props.label) {\n\t\t\t\t\tconst l = label(props.label, 24 * s);\n\t\t\t\t\tObject.assign(l, { x: 0, y: 0 });\n\t\t\t\t\tchildren.push(l);\n\t\t\t\t\tparts.label = l;\n\t\t\t\t}\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst n = number(Math.max(T * 2.2, 30 * s), \"end\");\n\t\t\t\t\tconst nn = n as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tnn.x = w + 18 * s;\n\t\t\t\t\tnn.y = top + T / 2 - nn.height / 2;\n\t\t\t\t\tW = nn.x + nn.width;\n\t\t\t\t\tH = Math.max(H, nn.y + nn.height);\n\t\t\t\t\tchildren.push(n);\n\t\t\t\t\tparts.counter = n;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"segments\":\n\t\t\tcase \"stars\": {\n\t\t\t\tconst n = props.segments ?? (kind === \"stars\" ? Math.round(max) : 10);\n\t\t\t\tconst w = props.size ?? (kind === \"stars\" ? 300 : 640) * s;\n\t\t\t\tconst gap = kind === \"stars\" ? w * 0.03 : w * 0.012;\n\t\t\t\tconst cell = (w - gap * (n - 1)) / n;\n\t\t\t\tconst h = kind === \"stars\" ? cell : (props.thickness ?? 22 * s);\n\t\t\t\t// How full each cell is, per frame.\n\t\t\t\tconst fill = (k: number) =>\n\t\t\t\t\tperFrame.map((v) => Math.max(0, Math.min(1, fraction(v) * n - k)));\n\t\t\t\tfor (let k = 0; k < n; k++) {\n\t\t\t\t\tconst x = k * (cell + gap);\n\t\t\t\t\tconst amount = fill(k);\n\t\t\t\t\tconst full = amount.findIndex((a) => a >= 1);\n\t\t\t\t\tif (full >= 0) crossings.push({ frame: full, segment: k });\n\t\t\t\t\tif (kind === \"segments\") {\n\t\t\t\t\t\tconst base = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`cell-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: trackColor,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst lit = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`lit-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: h,\n\t\t\t\t\t\t\tbackground: color,\n\t\t\t\t\t\t\tborderRadius: Math.min(h, cell) * 0.25,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Lights on the frame the value reaches the cell, with a small pop.\n\t\t\t\t\t\tconst on = amount.map((a) => (a >= 1 ? 1 : 0));\n\t\t\t\t\t\tconst opacity: KeyframeTuple[] = [[0, on[0] ?? 0]];\n\t\t\t\t\t\tconst scale: KeyframeTuple[] = [[0, 1]];\n\t\t\t\t\t\ton.forEach((v, t) => {\n\t\t\t\t\t\t\tif (t > 0 && v !== on[t - 1]) {\n\t\t\t\t\t\t\t\topacity.push([t, v, \"hold\"]);\n\t\t\t\t\t\t\t\tif (v === 1) scale.push([t, 1.18], [t + 6, 1, \"power2.out\"]);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\t\t\t\t\t\tlit.animate(\n\t\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t\t.keys(\"opacity\", opacity)\n\t\t\t\t\t\t\t\t.keys(\"scale\", ascending(scale)),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, lit);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst d = starPath(cell);\n\t\t\t\t\t\tconst base = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: trackColor,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tconst star = host.shape(\"path\", {\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-fill`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: cell,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\td,\n\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\tfillColor: color,\n\t\t\t\t\t\t});\n\t\t\t\t\t\t// Fractional ratings fill a star partway: a clip that widens.\n\t\t\t\t\t\tconst clip = host.box({\n\t\t\t\t\t\t\tid: ctx.partId(`star-${k}-clip`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx,\n\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\twidth: 0,\n\t\t\t\t\t\t\theight: cell,\n\t\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\t\tchildren: [star],\n\t\t\t\t\t\t});\n\t\t\t\t\t\tclip.animate(\n\t\t\t\t\t\t\tLayerAnimation.keys(\n\t\t\t\t\t\t\t\t\"width\",\n\t\t\t\t\t\t\t\tbakeSamples(\n\t\t\t\t\t\t\t\t\tamount.map((a) => a * cell),\n\t\t\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t\t\t{ tolerance: 0.2 },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tchildren.push(base, clip);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tW = w;\n\t\t\t\tH = h;\n\t\t\t\tif (showCounter) {\n\t\t\t\t\tconst c = number(Math.max(h * 1.4, 30 * s), \"end\");\n\t\t\t\t\tconst cc = c as unknown as {\n\t\t\t\t\t\tx: number;\n\t\t\t\t\t\ty: number;\n\t\t\t\t\t\twidth: number;\n\t\t\t\t\t\theight: number;\n\t\t\t\t\t};\n\t\t\t\t\tcc.x = w + 18 * s;\n\t\t\t\t\tcc.y = h / 2 - cc.height / 2;\n\t\t\t\t\tW = cc.x + cc.width;\n\t\t\t\t\tH = Math.max(H, cc.y + cc.height);\n\t\t\t\t\tchildren.push(c);\n\t\t\t\t\tparts.counter = c;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\t// Centring the counter on a shorter row can put it above the top; the\n\t\t// root box clips what's outside it, so move everything down instead.\n\t\tconst top = Math.min(\n\t\t\t0,\n\t\t\t...children.map((n) => (n as unknown as { y?: number }).y ?? 0),\n\t\t);\n\t\tif (top < 0) {\n\t\t\tfor (const n of children) {\n\t\t\t\tconst node = n as unknown as { y?: number };\n\t\t\t\tnode.y = (node.y ?? 0) - top;\n\t\t\t}\n\t\t\tH -= top;\n\t\t}\n\n\t\tconst settled = Math.max(\n\t\t\tlast,\n\t\t\t(\n\t\t\t\tparts.counter as unknown as {\n\t\t\t\t\tcues?: { settled: Array<{ frame: number }> };\n\t\t\t\t}\n\t\t\t)?.cues?.settled[0]?.frame ?? 0,\n\t\t);\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: settled + 1,\n\t\t\topenEnded: true,\n\t\t\tcues: { settled: [{ frame: settled }], crossings } as MeterCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Progress and scores as a ring, arc gauge, bar, lit segments or star rating,\n * filling on the same timeline as its counter.\n */\nexport const Meter = /* @__PURE__ */ bindComponent(meter);\n"
33
33
  }
34
34
  ]
35
35
  }
@@ -34,7 +34,7 @@
34
34
  },
35
35
  {
36
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"
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: the composition `canvas`. */\n\t\tarea: z\n\t\t\t.object({ width: z.number().positive(), height: z.number().positive() })\n\t\t\t.optional(),\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 ?? ctx.canvas;\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: theme.weight.semibold,\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: theme.weight.semibold,\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
38
  },
39
39
  {
40
40
  "path": "mouse-pointer/plan.ts",
@@ -13,9 +13,9 @@
13
13
  ],
14
14
  "example": "Selection({ target: { text: paragraph, match: \"the key line\" }, pointer })",
15
15
  "parts": [
16
- "line-0",
16
+ "line-<n>",
17
17
  "marquee",
18
- "item-0"
18
+ "item-<n>"
19
19
  ],
20
20
  "cues": [
21
21
  "selected",
@@ -25,7 +25,7 @@
25
25
  "files": [
26
26
  {
27
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"
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 * the composition `canvas`, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\tevaluateEase,\n\tlineShift,\n\trangeRects,\n\ttype Rect,\n\tresolveTarget,\n\tround as roundTo,\n\tscaleFrames,\n\ttype Target,\n\ttype TargetNode,\n\ttargetSchema,\n\ttextLayout,\n\ttype Vec2,\n\twithAlpha,\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 ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\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) => roundTo(v, 2);\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
29
  }
30
30
  ]
31
31
  }
@@ -22,7 +22,7 @@
22
22
  "files": [
23
23
  {
24
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"
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 * the composition `canvas`, or the given width / height) from 0, 0.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\ttype Rect,\n\tresolveTarget,\n\tsupersede,\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 ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\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\", track(keys.x))\n\t\t\t\t.keys(\"y\", track(keys.y))\n\t\t\t\t.keys(\"width\", track(keys.width))\n\t\t\t\t.keys(\"height\", track(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\", track(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\n/** Retargeting keys, or a single 0 key when nothing was keyed. */\nconst track = (keys: KeyframeTuple[]): KeyframeTuple[] =>\n\tkeys.length > 0 ? supersede(keys) : [[0, 0]];\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
26
  }
27
27
  ]
28
28
  }
@@ -23,6 +23,12 @@
23
23
  "outputs",
24
24
  "done"
25
25
  ],
26
+ "fonts": [
27
+ {
28
+ "font": "mono",
29
+ "why": "Monospace text for the shell."
30
+ }
31
+ ],
26
32
  "registryDependencies": [
27
33
  "app-frame",
28
34
  "counter"
@@ -30,7 +36,7 @@
30
36
  "files": [
31
37
  {
32
38
  "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"
39
+ "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\tascending,\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: theme.weight.bold }),\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/**\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
40
  },
35
41
  {
36
42
  "path": "terminal/plan.ts",