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
@@ -14,8 +14,8 @@
14
14
  ],
15
15
  "example": "CardStack({ cards: [{ title: \"Fast\" }, { title: \"Typed\" }, { title: \"Yours\" }] })",
16
16
  "parts": [
17
- "card-0",
18
- "card-0-clip",
17
+ "card-<n>",
18
+ "card-<n>-clip",
19
19
  "tilt"
20
20
  ],
21
21
  "cues": [
@@ -25,7 +25,7 @@
25
25
  "files": [
26
26
  {
27
27
  "path": "card-stack/index.ts",
28
- "content": "/**\n * CardStack: cards in a depth stack that shuffle. In `cycle` the front card\n * leaves (flies, drops, zooms or flips away) and returns to the back while\n * the rest step forward; in `deal` cards land one at a time on top of the\n * pile; in `fan` the stack spreads into a hand and gathers again.\n *\n * Draw order is fixed by the tree (the renderer has no animated z-order), so\n * in `cycle` every card sits in a clip that shows only the strip peeking out\n * above the card in front of it: whichever card is drawn later, a card\n * behind can never paint over one in front. The leaving card flies sideways,\n * clear of the stack, before it slots in at the back. The card coming to the\n * front shows whole at once: everything drawn after it is either the card\n * leaving (in front of it anyway) or a card already clipped at the back.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface CardStackCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** A move starts; `index` is the card coming to the front. */\n\tchanges: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nconst SIMPLE = z\n\t.object({\n\t\ttitle: z.string(),\n\t\tbody: z.string().optional(),\n\t\timage: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t})\n\t.strict();\ntype SimpleCard = z.infer<typeof SIMPLE>;\n\n/** Slot geometry: card k behind the front is this much smaller and higher. */\nexport const SHRINK = 0.06;\n\nexport const cardStack = /* @__PURE__ */ defineComponent({\n\tname: \"CardStack\",\n\tprops: () => ({\n\t\tcards: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tSIMPLE,\n\t\t\t\t\tz.custom<LayoutNode>(\n\t\t\t\t\t\t(v) => typeof v === \"object\" && v !== null && \"kind\" in v,\n\t\t\t\t\t\t{ message: \"Expected a layer or { title, body?, image? }\" },\n\t\t\t\t\t),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(2),\n\t\tmode: z.enum([\"cycle\", \"deal\", \"fan\"]).default(\"cycle\"),\n\t\t/** Frames between moves. Default 1.5 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\tcycles: z.number().int().positive().default(1),\n\t\t/** Cards shown behind the front one. */\n\t\tvisible: z.number().int().min(0).max(8).default(3),\n\t\t/** How the front card leaves in `cycle`. */\n\t\tleave: z.enum([\"fly\", \"drop\", \"zoom\", \"flip\"]).default(\"fly\"),\n\t\t/** Where `fly` sends the card, and which way `drop` tips it. */\n\t\tflyTo: z.enum([\"up\", \"left\", \"right\"]).default(\"left\"),\n\t\t/** Degrees between neighbouring cards in `fan`. */\n\t\tfanAngle: z.number().min(0).max(30).default(8),\n\t\tcardWidth: z.number().positive().optional(),\n\t\tcardHeight: z.number().positive().optional(),\n\t\ttilt: z\n\t\t\t.union([\n\t\t\t\tz.literal(false),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\trotateX: z.number().default(12),\n\t\t\t\t\t\trotateY: z.number().default(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default({ rotateX: 12, rotateY: 0 }),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst cw = props.cardWidth ?? 640 * s;\n\t\tconst ch = props.cardHeight ?? 400 * s;\n\t\tconst n = props.cards.length;\n\t\tconst every = props.every ?? Math.round(fps * 1.5);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst tr = Math.min(every - 1, Math.round(base * 1.8));\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst step = 26 * s;\n\t\tconst depth = Math.min(props.visible, n - 1);\n\t\tconst fan = props.mode === \"fan\";\n\t\t// In a fan, cards shrink a little and overlap by this much of their width.\n\t\tconst fanScale = 0.82;\n\t\tconst fanStep = cw * fanScale * 0.42;\n\t\tconst W =\n\t\t\tctx.requested.width ??\n\t\t\t(fan\n\t\t\t\t? Math.max(cw * 1.5, cw * fanScale + fanStep * (n - 1) + cw * 0.3)\n\t\t\t\t: cw * 1.5);\n\t\tconst H = ctx.requested.height ?? ch + step * depth + ch * 0.4;\n\t\tconst front = { x: (W - cw) / 2, y: H - ch - ch * 0.2 };\n\t\tconst pose = (k: number) => {\n\t\t\tconst shown = Math.min(k, depth);\n\t\t\treturn {\n\t\t\t\ty: front.y - shown * step,\n\t\t\t\tscale: 1 - SHRINK * shown,\n\t\t\t\topacity: k <= depth ? 1 : 0,\n\t\t\t\tdim: Math.min(0.6, 0.14 * shown),\n\t\t\t\t// Local height of the strip that peeks above the card in front.\n\t\t\t\tclip: k === 0 ? ch : step / (1 - SHRINK * shown),\n\t\t\t};\n\t\t};\n\n\t\t// ── Timeline: each card's slot at each move ──────────────────────\n\t\tconst cycle = props.mode === \"cycle\";\n\t\tconst deal = props.mode === \"deal\";\n\t\t// A fan spreads and gathers once per cycle.\n\t\tconst moves = cycle ? n * props.cycles : fan ? 2 * props.cycles : n - 1;\n\t\tconst changes = Array.from({ length: moves }, (_, m) => ({\n\t\t\tframe: (m + 1) * every,\n\t\t\tindex: cycle ? (m + 1) % n : fan ? 0 : m + 1,\n\t\t}));\n\t\tconst side = props.flyTo === \"right\" ? 1 : -1;\n\t\t// Where the leaving card ends (relative to the front slot), how, and\n\t\t// how much of the move it takes.\n\t\tconst leave: {\n\t\t\tto: Partial<Record<\"x\" | \"y\" | \"rotation\" | \"rotateX\" | \"scale\", number>>;\n\t\t\tease: string;\n\t\t\tshare: number;\n\t\t\t/** Share of its exit it stays opaque, so the next card doesn't ghost through. */\n\t\t\tsolid: number;\n\t\t} = {\n\t\t\tfly: {\n\t\t\t\tto:\n\t\t\t\t\tprops.flyTo === \"up\"\n\t\t\t\t\t\t? { y: -ch * 1.3, rotation: -4 }\n\t\t\t\t\t\t: { x: side * cw * 1.1, y: -ch * 0.15, rotation: side * 10 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.5,\n\t\t\t},\n\t\t\t// Falls out of the bottom, tipping toward `flyTo`.\n\t\t\tdrop: {\n\t\t\t\tto: { x: side * cw * 0.12, y: ch * 1.1, rotation: side * 14 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.6,\n\t\t\t\tsolid: 0.55,\n\t\t\t},\n\t\t\t// Toward the viewer, fading as it passes.\n\t\t\tzoom: {\n\t\t\t\tto: { y: ch * 0.06, scale: 1.22 },\n\t\t\t\tease: \"power2.out\",\n\t\t\t\tshare: 0.45,\n\t\t\t\tsolid: 0,\n\t\t\t},\n\t\t\t// Folds back over its top edge.\n\t\t\tflip: {\n\t\t\t\tto: { y: -ch * 0.04, rotateX: 90, scale: 0.96 },\n\t\t\t\tease: \"sine.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.9,\n\t\t\t},\n\t\t}[props.leave];\n\t\t// The fan: an arc, centred, outer cards turned and lowered.\n\t\tconst fanPose = (c: number) => {\n\t\t\tconst off = c - (n - 1) / 2;\n\t\t\treturn {\n\t\t\t\tx: front.x + off * fanStep,\n\t\t\t\ty: front.y + ch * 0.06 + Math.abs(off) ** 2 * ch * 0.02,\n\t\t\t\trotation: off * props.fanAngle,\n\t\t\t\tscale: fanScale,\n\t\t\t\tdim: 0,\n\t\t\t\topacity: 1,\n\t\t\t};\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst nodes: LayoutNode[] = [];\n\t\tprops.cards.forEach((card, c) => {\n\t\t\tconst tracks = {\n\t\t\t\tx: [] as KeyframeTuple[],\n\t\t\t\ty: [] as KeyframeTuple[],\n\t\t\t\tscale: [] as KeyframeTuple[],\n\t\t\t\trotation: [] as KeyframeTuple[],\n\t\t\t\trotateX: [] as KeyframeTuple[],\n\t\t\t\topacity: [] as KeyframeTuple[],\n\t\t\t\tdim: [] as KeyframeTuple[],\n\t\t\t\tclip: [] as KeyframeTuple[],\n\t\t\t};\n\t\t\tconst put = (\n\t\t\t\tf: number,\n\t\t\t\tk: number,\n\t\t\t\te?: string,\n\t\t\t\talso?: Partial<Record<keyof typeof tracks, number>>,\n\t\t\t) => {\n\t\t\t\tconst p = pose(k);\n\t\t\t\tconst v = {\n\t\t\t\t\tx: front.x,\n\t\t\t\t\ty: p.y,\n\t\t\t\t\tscale: p.scale,\n\t\t\t\t\trotation: 0,\n\t\t\t\t\trotateX: 0,\n\t\t\t\t\topacity: p.opacity,\n\t\t\t\t\tdim: p.dim,\n\t\t\t\t\tclip: p.clip,\n\t\t\t\t\t...also,\n\t\t\t\t};\n\t\t\t\tfor (const key of Object.keys(tracks) as Array<keyof typeof tracks>)\n\t\t\t\t\ttracks[key].push(e ? [f, round(v[key]), e] : [f, round(v[key])]);\n\t\t\t};\n\t\t\tif (cycle) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\tconst k = (((c - m) % n) + n) % n;\n\t\t\t\t\tif (k === 0) {\n\t\t\t\t\t\t// Leaves, then slots in at the back unseen and fades up.\n\t\t\t\t\t\tconst away = t + Math.round(tr * leave.share);\n\t\t\t\t\t\tconst to = leave.to;\n\t\t\t\t\t\tput(t, 0);\n\t\t\t\t\t\tconst solid = t + Math.round((away - t) * leave.solid);\n\t\t\t\t\t\tif (solid > t) tracks.opacity.push([solid, 1]);\n\t\t\t\t\t\tput(away, 0, leave.ease, {\n\t\t\t\t\t\t\tx: front.x + (to.x ?? 0),\n\t\t\t\t\t\t\ty: front.y + (to.y ?? 0),\n\t\t\t\t\t\t\trotation: to.rotation ?? 0,\n\t\t\t\t\t\t\trotateX: to.rotateX ?? 0,\n\t\t\t\t\t\t\tscale: to.scale ?? 1,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tput(away + 1, n - 1, \"hold\", { opacity: 0 });\n\t\t\t\t\t\tput(t + tr, n - 1, \"power2.out\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Steps forward once the leaving card is under way. Coming to\n\t\t\t\t\t\t// the front, it shows whole from the start.\n\t\t\t\t\t\tconst whole = k === 1 ? { clip: ch } : undefined;\n\t\t\t\t\t\tput(t, k);\n\t\t\t\t\t\tif (whole) tracks.clip.push([t + 1, ch, \"hold\"]);\n\t\t\t\t\t\tput(t + Math.round(tr * 0.25), k, undefined, whole);\n\t\t\t\t\t\tput(t + tr, k - 1, ease);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} else if (fan) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\t// Spreads on even moves, gathers on odd ones, the front card first.\n\t\t\t\t\tconst at = t + c * Math.round(fps * 0.04);\n\t\t\t\t\tconst spread = m % 2 === 0;\n\t\t\t\t\tput(at, c, undefined, spread ? undefined : fanPose(c));\n\t\t\t\t\tif (spread) put(at + tr, c, theme.motion.ease.snap, fanPose(c));\n\t\t\t\t\telse put(at + tr, c, ease);\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Deal: card c lands on top at move c (card 0 is already down).\n\t\t\t\tconst landAt = c === 0 ? 0 : (changes[c - 1]?.frame as number);\n\t\t\t\tif (c > 0) {\n\t\t\t\t\tput(0, 0, undefined, {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt, 0, \"hold\", {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt + tr, 0, theme.motion.ease.enter);\n\t\t\t\t} else put(0, 0);\n\t\t\t\t// Pushed back one slot by every later card.\n\t\t\t\tchanges.slice(c).forEach(({ frame: t }, j) => {\n\t\t\t\t\tput(t, j);\n\t\t\t\t\tput(t + tr, j + 1, ease);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst content = isSimple(card) ? simpleCard(ctx, card, c, cw, ch) : card;\n\t\t\tconst dim = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-dim`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tbackground: \"#000000\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdim.animate(LayerAnimation.keys(\"opacity\", ascending(tracks.dim)));\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-clip`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\tchildren: [content, dim],\n\t\t\t});\n\t\t\tif (cycle)\n\t\t\t\tclip.animate(LayerAnimation.keys(\"height\", ascending(tracks.clip)));\n\t\t\tconst node = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: front.x,\n\t\t\t\ty: pose(c).y,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 0,\n\t\t\t\tchildren: [clip],\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"y\", ascending(tracks.y))\n\t\t\t\t.keys(\"scale\", ascending(tracks.scale))\n\t\t\t\t.keys(\"opacity\", ascending(tracks.opacity));\n\t\t\tif (tracks.x.some((k) => k[1] !== front.x))\n\t\t\t\tanim.keys(\"x\", ascending(tracks.x));\n\t\t\tif (tracks.rotation.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotation\", ascending(tracks.rotation));\n\t\t\tif (tracks.rotateX.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotateX\", ascending(tracks.rotateX));\n\t\t\tnode.animate(anim);\n\t\t\thost.style(node, {\n\t\t\t\tdropShadow: {\n\t\t\t\t\tcolor: \"#000000\",\n\t\t\t\t\topacity: 0.35,\n\t\t\t\t\tangle: 90,\n\t\t\t\t\tdistance: 12 * s,\n\t\t\t\t\tsize: 34 * s,\n\t\t\t\t},\n\t\t\t});\n\t\t\tparts[`card-${c}`] = node;\n\t\t\tparts[`card-${c}-clip`] = clip;\n\t\t\tnodes.push(node);\n\t\t});\n\t\t// Front-most last: cycle and fan start with card 0 in front, deal ends\n\t\t// with the last on top.\n\t\tconst ordered = deal ? nodes : [...nodes].reverse();\n\t\tconst children = props.tilt\n\t\t\t? [\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(\"tilt\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\theight: H,\n\t\t\t\t\t\tperspective: 1600 * s,\n\t\t\t\t\t\trotateX: props.tilt.rotateX,\n\t\t\t\t\t\trotateY: props.tilt.rotateY,\n\t\t\t\t\t\tchildren: ordered,\n\t\t\t\t\t}),\n\t\t\t\t]\n\t\t\t: ordered;\n\t\tif (props.tilt) parts.tilt = children[0] as Animatable<LayoutNode>;\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: (changes.at(-1)?.frame ?? 0) + tr,\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as CardStackCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst round = (v: number) => Math.round(v * 1000) / 1000;\n\nconst isSimple = (card: unknown): card is SimpleCard =>\n\ttypeof card === \"object\" &&\n\tcard !== null &&\n\t\"title\" in card &&\n\t!(\"kind\" in card);\n\n/** Later keys on a shared frame win; frames ascend. */\nfunction ascending(keys: KeyframeTuple[]): KeyframeTuple[] {\n\tconst byFrame = new Map<number, KeyframeTuple>();\n\tfor (const k of keys) byFrame.set(k[0] as number, k);\n\treturn [...byFrame.values()].sort(\n\t\t(a, b) => (a[0] as number) - (b[0] as number),\n\t);\n}\n\n/** A card drawn from the theme: optional image on top, title, body, accent bar. */\nfunction simpleCard(\n\tctx: Parameters<(typeof cardStack)[\"build\"]>[0],\n\tcard: SimpleCard,\n\tc: number,\n\tw: number,\n\th: number,\n): LayoutNode {\n\tconst { host, theme } = ctx;\n\tconst s = theme.scale;\n\tconst pad = 36 * s;\n\tconst imageH = card.image ? h * 0.48 : 0;\n\tconst kids: LayoutNode[] = [];\n\tif (card.image)\n\t\tkids.push(\n\t\t\thost.media(card.image, {\n\t\t\t\tid: ctx.partId(`card-${c}-image`),\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: imageH,\n\t\t\t\tfit: \"cover\",\n\t\t\t}),\n\t\t);\n\tkids.push(\n\t\thost.box({\n\t\t\tid: ctx.partId(`card-${c}-accent`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad,\n\t\t\twidth: 44 * s,\n\t\t\theight: 5 * s,\n\t\t\tbackground: card.accent ?? theme.color.accent,\n\t\t\tborderRadius: 3 * s,\n\t\t}),\n\t\thost.text(card.title, {\n\t\t\tid: ctx.partId(`card-${c}-title`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad + 22 * s,\n\t\t\twidth: w - pad * 2,\n\t\t\tfontFamily: theme.font.display,\n\t\t\tfontSize: 40 * s,\n\t\t\tfontWeight: 700,\n\t\t\tfill: theme.color.ink,\n\t\t}),\n\t);\n\tif (card.body)\n\t\tkids.push(\n\t\t\thost.text(card.body, {\n\t\t\t\tid: ctx.partId(`card-${c}-body`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: imageH + pad + 80 * s,\n\t\t\t\twidth: w - pad * 2,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: 24 * s,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t}),\n\t\t);\n\treturn host.box({\n\t\tid: ctx.partId(`card-${c}-face`),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: theme.color.raised,\n\t\tborderRadius: theme.radius.lg,\n\t\tborderColor: theme.color.line,\n\t\tborderWidth: Math.max(1, s),\n\t\tstrokeAlign: \"inside\",\n\t\tchildren: kids,\n\t});\n}\n\n/**\n * Cards in a depth stack that shuffle: the front card flies, drops, zooms or\n * flips away and returns to the back; cards are dealt onto the pile; or the\n * stack fans out into a hand.\n */\nexport const CardStack = /* @__PURE__ */ bindComponent(cardStack);\n"
28
+ "content": "/**\n * CardStack: cards in a depth stack that shuffle. In `cycle` the front card\n * leaves (flies, drops, zooms or flips away) and returns to the back while\n * the rest step forward; in `deal` cards land one at a time on top of the\n * pile; in `fan` the stack spreads into a hand and gathers again.\n *\n * Draw order is fixed by the tree (the renderer has no animated z-order), so\n * in `cycle` every card sits in a clip that shows only the strip peeking out\n * above the card in front of it: whichever card is drawn later, a card\n * behind can never paint over one in front. The leaving card flies sideways,\n * clear of the stack, before it slots in at the back. The card coming to the\n * front shows whole at once: everything drawn after it is either the card\n * leaving (in front of it anyway) or a card already clipped at the back.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tascending,\n\tbindComponent,\n\tdefineComponent,\n\televate,\n\tround,\n\tsurfaceFill,\n\tthemeFrames,\n\tz,\n} from \"framefields/components\";\n\nexport interface CardStackCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** A move starts; `index` is the card coming to the front. */\n\tchanges: ReadonlyArray<{ frame: number; index: number }>;\n}\n\nconst SIMPLE = z\n\t.object({\n\t\ttitle: z.string(),\n\t\tbody: z.string().optional(),\n\t\timage: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t})\n\t.strict();\ntype SimpleCard = z.infer<typeof SIMPLE>;\n\n/** Slot geometry: card k behind the front is this much smaller and higher. */\nexport const SHRINK = 0.06;\n\nexport const cardStack = /* @__PURE__ */ defineComponent({\n\tname: \"CardStack\",\n\tprops: () => ({\n\t\tcards: z\n\t\t\t.array(\n\t\t\t\tz.union([\n\t\t\t\t\tSIMPLE,\n\t\t\t\t\tz.custom<LayoutNode>(\n\t\t\t\t\t\t(v) => typeof v === \"object\" && v !== null && \"kind\" in v,\n\t\t\t\t\t\t{ message: \"Expected a layer or { title, body?, image? }\" },\n\t\t\t\t\t),\n\t\t\t\t]),\n\t\t\t)\n\t\t\t.min(2),\n\t\tmode: z.enum([\"cycle\", \"deal\", \"fan\"]).default(\"cycle\"),\n\t\t/** Frames between moves. Default 1.5 seconds. */\n\t\tevery: z.number().int().positive().optional(),\n\t\tcycles: z.number().int().positive().default(1),\n\t\t/** Cards shown behind the front one. */\n\t\tvisible: z.number().int().min(0).max(8).default(3),\n\t\t/** How the front card leaves in `cycle`. */\n\t\tleave: z.enum([\"fly\", \"drop\", \"zoom\", \"flip\"]).default(\"fly\"),\n\t\t/** Where `fly` sends the card, and which way `drop` tips it. */\n\t\tflyTo: z.enum([\"up\", \"left\", \"right\"]).default(\"left\"),\n\t\t/** Degrees between neighbouring cards in `fan`. */\n\t\tfanAngle: z.number().min(0).max(30).default(8),\n\t\tcardWidth: z.number().positive().optional(),\n\t\tcardHeight: z.number().positive().optional(),\n\t\ttilt: z\n\t\t\t.union([\n\t\t\t\tz.literal(false),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\trotateX: z.number().default(12),\n\t\t\t\t\t\trotateY: z.number().default(0),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default({ rotateX: 12, rotateY: 0 }),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 30 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst cw = props.cardWidth ?? 640 * s;\n\t\tconst ch = props.cardHeight ?? 400 * s;\n\t\tconst n = props.cards.length;\n\t\tconst every = props.every ?? Math.round(fps * 1.5);\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst tr = Math.min(every - 1, Math.round(base * 1.8));\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst step = 26 * s;\n\t\tconst depth = Math.min(props.visible, n - 1);\n\t\tconst fan = props.mode === \"fan\";\n\t\t// In a fan, cards shrink a little and overlap by this much of their width.\n\t\tconst fanScale = 0.82;\n\t\tconst fanStep = cw * fanScale * 0.42;\n\t\tconst W =\n\t\t\tctx.requested.width ??\n\t\t\t(fan\n\t\t\t\t? Math.max(cw * 1.5, cw * fanScale + fanStep * (n - 1) + cw * 0.3)\n\t\t\t\t: cw * 1.5);\n\t\tconst H = ctx.requested.height ?? ch + step * depth + ch * 0.4;\n\t\tconst front = { x: (W - cw) / 2, y: H - ch - ch * 0.2 };\n\t\tconst pose = (k: number) => {\n\t\t\tconst shown = Math.min(k, depth);\n\t\t\treturn {\n\t\t\t\ty: front.y - shown * step,\n\t\t\t\tscale: 1 - SHRINK * shown,\n\t\t\t\topacity: k <= depth ? 1 : 0,\n\t\t\t\tdim: Math.min(0.6, 0.14 * shown),\n\t\t\t\t// Local height of the strip that peeks above the card in front.\n\t\t\t\tclip: k === 0 ? ch : step / (1 - SHRINK * shown),\n\t\t\t};\n\t\t};\n\n\t\t// ── Timeline: each card's slot at each move ──────────────────────\n\t\tconst cycle = props.mode === \"cycle\";\n\t\tconst deal = props.mode === \"deal\";\n\t\t// A fan spreads and gathers once per cycle.\n\t\tconst moves = cycle ? n * props.cycles : fan ? 2 * props.cycles : n - 1;\n\t\tconst changes = Array.from({ length: moves }, (_, m) => ({\n\t\t\tframe: (m + 1) * every,\n\t\t\tindex: cycle ? (m + 1) % n : fan ? 0 : m + 1,\n\t\t}));\n\t\tconst side = props.flyTo === \"right\" ? 1 : -1;\n\t\t// Where the leaving card ends (relative to the front slot), how, and\n\t\t// how much of the move it takes.\n\t\tconst leave: {\n\t\t\tto: Partial<Record<\"x\" | \"y\" | \"rotation\" | \"rotateX\" | \"scale\", number>>;\n\t\t\tease: string;\n\t\t\tshare: number;\n\t\t\t/** Share of its exit it stays opaque, so the next card doesn't ghost through. */\n\t\t\tsolid: number;\n\t\t} = {\n\t\t\tfly: {\n\t\t\t\tto:\n\t\t\t\t\tprops.flyTo === \"up\"\n\t\t\t\t\t\t? { y: -ch * 1.3, rotation: -4 }\n\t\t\t\t\t\t: { x: side * cw * 1.1, y: -ch * 0.15, rotation: side * 10 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.5,\n\t\t\t},\n\t\t\t// Falls out of the bottom, tipping toward `flyTo`.\n\t\t\tdrop: {\n\t\t\t\tto: { x: side * cw * 0.12, y: ch * 1.1, rotation: side * 14 },\n\t\t\t\tease: \"power2.in\",\n\t\t\t\tshare: 0.6,\n\t\t\t\tsolid: 0.55,\n\t\t\t},\n\t\t\t// Toward the viewer, fading as it passes.\n\t\t\tzoom: {\n\t\t\t\tto: { y: ch * 0.06, scale: 1.22 },\n\t\t\t\tease: \"power2.out\",\n\t\t\t\tshare: 0.45,\n\t\t\t\tsolid: 0,\n\t\t\t},\n\t\t\t// Folds back over its top edge.\n\t\t\tflip: {\n\t\t\t\tto: { y: -ch * 0.04, rotateX: 90, scale: 0.96 },\n\t\t\t\tease: \"sine.in\",\n\t\t\t\tshare: 0.55,\n\t\t\t\tsolid: 0.9,\n\t\t\t},\n\t\t}[props.leave];\n\t\t// The fan: an arc, centred, outer cards turned and lowered.\n\t\tconst fanPose = (c: number) => {\n\t\t\tconst off = c - (n - 1) / 2;\n\t\t\treturn {\n\t\t\t\tx: front.x + off * fanStep,\n\t\t\t\ty: front.y + ch * 0.06 + Math.abs(off) ** 2 * ch * 0.02,\n\t\t\t\trotation: off * props.fanAngle,\n\t\t\t\tscale: fanScale,\n\t\t\t\tdim: 0,\n\t\t\t\topacity: 1,\n\t\t\t};\n\t\t};\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst nodes: LayoutNode[] = [];\n\t\tprops.cards.forEach((card, c) => {\n\t\t\tconst tracks = {\n\t\t\t\tx: [] as KeyframeTuple[],\n\t\t\t\ty: [] as KeyframeTuple[],\n\t\t\t\tscale: [] as KeyframeTuple[],\n\t\t\t\trotation: [] as KeyframeTuple[],\n\t\t\t\trotateX: [] as KeyframeTuple[],\n\t\t\t\topacity: [] as KeyframeTuple[],\n\t\t\t\tdim: [] as KeyframeTuple[],\n\t\t\t\tclip: [] as KeyframeTuple[],\n\t\t\t};\n\t\t\tconst put = (\n\t\t\t\tf: number,\n\t\t\t\tk: number,\n\t\t\t\te?: string,\n\t\t\t\talso?: Partial<Record<keyof typeof tracks, number>>,\n\t\t\t) => {\n\t\t\t\tconst p = pose(k);\n\t\t\t\tconst v = {\n\t\t\t\t\tx: front.x,\n\t\t\t\t\ty: p.y,\n\t\t\t\t\tscale: p.scale,\n\t\t\t\t\trotation: 0,\n\t\t\t\t\trotateX: 0,\n\t\t\t\t\topacity: p.opacity,\n\t\t\t\t\tdim: p.dim,\n\t\t\t\t\tclip: p.clip,\n\t\t\t\t\t...also,\n\t\t\t\t};\n\t\t\t\tfor (const key of Object.keys(tracks) as Array<keyof typeof tracks>)\n\t\t\t\t\ttracks[key].push(e ? [f, round(v[key]), e] : [f, round(v[key])]);\n\t\t\t};\n\t\t\tif (cycle) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\tconst k = (((c - m) % n) + n) % n;\n\t\t\t\t\tif (k === 0) {\n\t\t\t\t\t\t// Leaves, then slots in at the back unseen and fades up.\n\t\t\t\t\t\tconst away = t + Math.round(tr * leave.share);\n\t\t\t\t\t\tconst to = leave.to;\n\t\t\t\t\t\tput(t, 0);\n\t\t\t\t\t\tconst solid = t + Math.round((away - t) * leave.solid);\n\t\t\t\t\t\tif (solid > t) tracks.opacity.push([solid, 1]);\n\t\t\t\t\t\tput(away, 0, leave.ease, {\n\t\t\t\t\t\t\tx: front.x + (to.x ?? 0),\n\t\t\t\t\t\t\ty: front.y + (to.y ?? 0),\n\t\t\t\t\t\t\trotation: to.rotation ?? 0,\n\t\t\t\t\t\t\trotateX: to.rotateX ?? 0,\n\t\t\t\t\t\t\tscale: to.scale ?? 1,\n\t\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t\t});\n\t\t\t\t\t\tput(away + 1, n - 1, \"hold\", { opacity: 0 });\n\t\t\t\t\t\tput(t + tr, n - 1, \"power2.out\");\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Steps forward once the leaving card is under way. Coming to\n\t\t\t\t\t\t// the front, it shows whole from the start.\n\t\t\t\t\t\tconst whole = k === 1 ? { clip: ch } : undefined;\n\t\t\t\t\t\tput(t, k);\n\t\t\t\t\t\tif (whole) tracks.clip.push([t + 1, ch, \"hold\"]);\n\t\t\t\t\t\tput(t + Math.round(tr * 0.25), k, undefined, whole);\n\t\t\t\t\t\tput(t + tr, k - 1, ease);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} else if (fan) {\n\t\t\t\tput(0, c);\n\t\t\t\tchanges.forEach(({ frame: t }, m) => {\n\t\t\t\t\t// Spreads on even moves, gathers on odd ones, the front card first.\n\t\t\t\t\tconst at = t + c * Math.round(fps * 0.04);\n\t\t\t\t\tconst spread = m % 2 === 0;\n\t\t\t\t\tput(at, c, undefined, spread ? undefined : fanPose(c));\n\t\t\t\t\tif (spread) put(at + tr, c, theme.motion.ease.snap, fanPose(c));\n\t\t\t\t\telse put(at + tr, c, ease);\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Deal: card c lands on top at move c (card 0 is already down).\n\t\t\t\tconst landAt = c === 0 ? 0 : (changes[c - 1]?.frame as number);\n\t\t\t\tif (c > 0) {\n\t\t\t\t\tput(0, 0, undefined, {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt, 0, \"hold\", {\n\t\t\t\t\t\ty: front.y - ch * 1.4,\n\t\t\t\t\t\trotation: 9,\n\t\t\t\t\t\topacity: 0,\n\t\t\t\t\t});\n\t\t\t\t\tput(landAt + tr, 0, theme.motion.ease.enter);\n\t\t\t\t} else put(0, 0);\n\t\t\t\t// Pushed back one slot by every later card.\n\t\t\t\tchanges.slice(c).forEach(({ frame: t }, j) => {\n\t\t\t\t\tput(t, j);\n\t\t\t\t\tput(t + tr, j + 1, ease);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst content = isSimple(card) ? simpleCard(ctx, card, c, cw, ch) : card;\n\t\t\tconst dim = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-dim`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tbackground: \"#000000\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\topacity: 0,\n\t\t\t});\n\t\t\tdim.animate(LayerAnimation.keys(\"opacity\", ascending(tracks.dim)));\n\t\t\tconst clip = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}-clip`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\toverflow: \"hidden\",\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t\tchildren: [content, dim],\n\t\t\t});\n\t\t\tif (cycle)\n\t\t\t\tclip.animate(LayerAnimation.keys(\"height\", ascending(tracks.clip)));\n\t\t\tconst node = host.box({\n\t\t\t\tid: ctx.partId(`card-${c}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: front.x,\n\t\t\t\ty: pose(c).y,\n\t\t\t\twidth: cw,\n\t\t\t\theight: ch,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 0,\n\t\t\t\t// Blur only shows on fast moves: the card leaving, not the step forward.\n\t\t\t\tmotionBlurShutter: 60,\n\t\t\t\tchildren: [clip],\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create()\n\t\t\t\t.keys(\"y\", ascending(tracks.y))\n\t\t\t\t.keys(\"scale\", ascending(tracks.scale))\n\t\t\t\t.keys(\"opacity\", ascending(tracks.opacity));\n\t\t\tif (tracks.x.some((k) => k[1] !== front.x))\n\t\t\t\tanim.keys(\"x\", ascending(tracks.x));\n\t\t\tif (tracks.rotation.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotation\", ascending(tracks.rotation));\n\t\t\tif (tracks.rotateX.some((k) => k[1] !== 0))\n\t\t\t\tanim.keys(\"rotateX\", ascending(tracks.rotateX));\n\t\t\tnode.animate(anim);\n\t\t\televate(host, node, theme, \"mid\");\n\t\t\tparts[`card-${c}`] = node;\n\t\t\tparts[`card-${c}-clip`] = clip;\n\t\t\tnodes.push(node);\n\t\t});\n\t\t// Front-most last: cycle and fan start with card 0 in front, deal ends\n\t\t// with the last on top.\n\t\tconst ordered = deal ? nodes : [...nodes].reverse();\n\t\tconst children = props.tilt\n\t\t\t? [\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(\"tilt\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: W,\n\t\t\t\t\t\theight: H,\n\t\t\t\t\t\tperspective: 1600 * s,\n\t\t\t\t\t\trotateX: props.tilt.rotateX,\n\t\t\t\t\t\trotateY: props.tilt.rotateY,\n\t\t\t\t\t\tchildren: ordered,\n\t\t\t\t\t}),\n\t\t\t\t]\n\t\t\t: ordered;\n\t\tif (props.tilt) parts.tilt = children[0] as Animatable<LayoutNode>;\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: (changes.at(-1)?.frame ?? 0) + tr,\n\t\t\topenEnded: true,\n\t\t\tcues: { changes } as CardStackCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\nconst isSimple = (card: unknown): card is SimpleCard =>\n\ttypeof card === \"object\" &&\n\tcard !== null &&\n\t\"title\" in card &&\n\t!(\"kind\" in card);\n\n/** A card drawn from the theme: optional image on top, title, body, accent bar. */\nfunction simpleCard(\n\tctx: Parameters<(typeof cardStack)[\"build\"]>[0],\n\tcard: SimpleCard,\n\tc: number,\n\tw: number,\n\th: number,\n): LayoutNode {\n\tconst { host, theme } = ctx;\n\tconst s = theme.scale;\n\tconst pad = 36 * s;\n\tconst imageH = card.image ? h * 0.48 : 0;\n\tconst kids: LayoutNode[] = [];\n\tif (card.image)\n\t\tkids.push(\n\t\t\thost.media(card.image, {\n\t\t\t\tid: ctx.partId(`card-${c}-image`),\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: imageH,\n\t\t\t\tfit: \"cover\",\n\t\t\t}),\n\t\t);\n\tkids.push(\n\t\thost.box({\n\t\t\tid: ctx.partId(`card-${c}-accent`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad,\n\t\t\twidth: 44 * s,\n\t\t\theight: 5 * s,\n\t\t\tbackground: card.accent ?? theme.color.accent,\n\t\t\tborderRadius: 3 * s,\n\t\t}),\n\t\thost.text(card.title, {\n\t\t\tid: ctx.partId(`card-${c}-title`),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad,\n\t\t\ty: imageH + pad + 22 * s,\n\t\t\twidth: w - pad * 2,\n\t\t\tfontFamily: theme.font.display,\n\t\t\tfontSize: 40 * s,\n\t\t\tfontWeight: theme.weight.bold,\n\t\t\tfill: theme.color.ink,\n\t\t}),\n\t);\n\tif (card.body)\n\t\tkids.push(\n\t\t\thost.text(card.body, {\n\t\t\t\tid: ctx.partId(`card-${c}-body`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: imageH + pad + 80 * s,\n\t\t\t\twidth: w - pad * 2,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: 24 * s,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t}),\n\t\t);\n\treturn host.box({\n\t\tid: ctx.partId(`card-${c}-face`),\n\t\tposition: \"absolute\",\n\t\tx: 0,\n\t\ty: 0,\n\t\twidth: w,\n\t\theight: h,\n\t\tbackground: surfaceFill(theme.color.raised, theme),\n\t\tborderRadius: theme.radius.lg,\n\t\tborderColor: theme.color.line,\n\t\tborderWidth: Math.max(1, s),\n\t\tstrokeAlign: \"inside\",\n\t\tchildren: kids,\n\t});\n}\n\n/**\n * Cards in a depth stack that shuffle: the front card flies, drops, zooms or\n * flips away and returns to the back; cards are dealt onto the pile; or the\n * stack fans out into a hand.\n */\nexport const CardStack = /* @__PURE__ */ bindComponent(cardStack);\n"
29
29
  }
30
30
  ]
31
31
  }
@@ -23,11 +23,17 @@
23
23
  "messages",
24
24
  "typing"
25
25
  ],
26
+ "fonts": [
27
+ {
28
+ "font": "emoji",
29
+ "why": "Reactions (❤️ 🎉 👍) and messages are often emoji."
30
+ }
31
+ ],
26
32
  "registryDependencies": [],
27
33
  "files": [
28
34
  {
29
35
  "path": "chat-thread/index.ts",
30
- "content": "/**\n * ChatThread: a messaging conversation. Bubbles arrive from either side\n * with a spring, the other side types first (three bouncing dots), and the\n * thread scrolls to keep the newest message in view. Names, avatars,\n * timestamps, reactions and images included.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\trangeRects,\n\tspringKeys,\n\tz,\n} from \"framefields/components\";\n\nexport interface ChatThreadCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each message as it lands. */\n\tmessages: ReadonlyArray<{ frame: number; index: number; from: string }>;\n\t/** Each typing indicator, shown to removed. */\n\ttyping: ReadonlyArray<{ frame: number; end: number; from: string }>;\n}\n\nfunction messageSchema() {\n\treturn z\n\t\t.object({\n\t\t\tfrom: z.string(),\n\t\t\ttext: z.string().optional(),\n\t\t\t/** An image (path or URL) instead of, or above, text. */\n\t\t\timage: z.string().optional(),\n\t\t\t/** Image width / height. */\n\t\t\taspect: z\n\t\t\t\t.number()\n\t\t\t\t.positive()\n\t\t\t\t.default(4 / 3),\n\t\t\t/** Frame it lands. Default: after the previous one has been read. */\n\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t/** A reaction pinned to the bubble (an emoji or short text). */\n\t\t\treaction: z.string().optional(),\n\t\t\t/** A small timestamp under the bubble. */\n\t\t\ttime: z.string().optional(),\n\t\t})\n\t\t.strict()\n\t\t.refine((m) => m.text !== undefined || m.image !== undefined, {\n\t\t\tmessage: \"A message needs `text` or `image`.\",\n\t\t});\n}\n\nconst SPRING = { stiffness: 260, damping: 20 };\n/** Livelier, for reactions. */\nconst POP = { stiffness: 320, damping: 14 };\n\nexport const chatThread = /* @__PURE__ */ defineComponent({\n\tname: \"ChatThread\",\n\tprops: () => ({\n\t\tmessages: z.array(messageSchema()).min(1),\n\t\t/** Who is \"me\" (right side, accent bubbles). Default the first sender. */\n\t\tme: z.string().optional(),\n\t\t/** Display names and bubble colours per sender. */\n\t\tparticipants: z\n\t\t\t.record(\n\t\t\t\tz.string(),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tname: z.string().optional(),\n\t\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t\t\t/** A picture (path or URL); without one the avatar shows initials. */\n\t\t\t\t\t\tavatar: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default({}),\n\t\t/**\n\t\t * Avatars beside each run of bubbles, at its newest one. Default:\n\t\t * `\"others\"` when any participant has an `avatar`, else `\"none\"`.\n\t\t */\n\t\tavatars: z.enum([\"others\", \"everyone\", \"none\"]).optional(),\n\t\t/** Names above the first bubble of each run (default: when more than two people talk). */\n\t\tnames: z.boolean().optional(),\n\t\t/** Others type before they send. */\n\t\ttyping: z.boolean().default(true),\n\t\t/** Reading speed between messages, characters a second. */\n\t\treadCps: z.number().positive().default(28),\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 W = ctx.requested.width ?? 720 * s;\n\t\tconst H = ctx.requested.height ?? 960 * s;\n\t\tconst fs = props.fontSize ?? 30 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst me = props.me ?? props.messages[0]?.from ?? \"me\";\n\t\tconst senders = [...new Set(props.messages.map((m) => m.from))];\n\t\tconst showNames = props.names ?? senders.length > 2;\n\t\tconst avatars =\n\t\t\tprops.avatars ??\n\t\t\t(Object.values(props.participants).some((p) => p.avatar)\n\t\t\t\t? \"others\"\n\t\t\t\t: \"none\");\n\t\tconst hasAvatar = (from: string) =>\n\t\t\tavatars === \"everyone\" || (avatars === \"others\" && from !== me);\n\t\tconst pad = fs * 0.8;\n\t\tconst padX = fs * 0.62;\n\t\tconst padY = fs * 0.42;\n\t\tconst avatarSize = fs * 1.5;\n\t\t// Bubbles on a side with avatars move in by this much.\n\t\tconst inset = (from: string) =>\n\t\t\thasAvatar(from) ? avatarSize + fs * 0.3 : 0;\n\t\tconst maxW = Math.min(\n\t\t\tW * 0.74,\n\t\t\tW - pad * 2 - Math.max(...senders.map(inset)),\n\t\t);\n\t\tconst radius = fs * 0.9;\n\t\tconst smallFs = fs * 0.62;\n\n\t\t// ── Timing ────────────────────────────────────────────────────────\n\t\tconst landed: number[] = [];\n\t\tconst typingWindows: Array<{\n\t\t\tframe: number;\n\t\t\tend: number;\n\t\t\tfrom: string;\n\t\t\tindex: number;\n\t\t}> = [];\n\t\tlet t = Math.round(fps * 0.2);\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tif (prev && m.at === undefined) {\n\t\t\t\tconst read = ((prev.text?.length ?? 12) / props.readCps) * fps;\n\t\t\t\tt += Math.round(Math.min(fps * 2.5, Math.max(fps * 0.5, read)));\n\t\t\t}\n\t\t\tlet at = m.at ?? t;\n\t\t\tif (props.typing && m.from !== me && m.at === undefined) {\n\t\t\t\tconst typingFor = Math.round(\n\t\t\t\t\tMath.min(2.2, Math.max(0.7, (m.text?.length ?? 10) / 14)) * fps,\n\t\t\t\t);\n\t\t\t\ttypingWindows.push({\n\t\t\t\t\tframe: at,\n\t\t\t\t\tend: at + typingFor,\n\t\t\t\t\tfrom: m.from,\n\t\t\t\t\tindex: i,\n\t\t\t\t});\n\t\t\t\tat += typingFor;\n\t\t\t}\n\t\t\tlanded.push(at);\n\t\t\tt = at;\n\t\t});\n\n\t\t// ── Layout (top to bottom) ────────────────────────────────────────\n\t\tconst measure = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\t...(width !== undefined && { width }),\n\t\t\t});\n\t\tinterface Slot {\n\t\t\tindex: number;\n\t\t\ty: number;\n\t\t\twidth: number;\n\t\t\theight: number;\n\t\t\tright: boolean;\n\t\t\tnameY?: number;\n\t\t\ttimeY?: number;\n\t\t\ttextH: number;\n\t\t\timageH: number;\n\t\t}\n\t\tconst slots: Slot[] = [];\n\t\tlet y = pad;\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tconst right = m.from === me;\n\t\t\tconst newRun = !prev || prev.from !== m.from;\n\t\t\tif (prev) y += newRun ? fs * 0.75 : fs * 0.22;\n\t\t\t// A reaction sits on the bubble's top corner; leave it room.\n\t\t\tif (prev && m.reaction) y += fs * 0.5;\n\t\t\tlet nameY: number | undefined;\n\t\t\tif (showNames && newRun && !right) {\n\t\t\t\tnameY = y;\n\t\t\t\ty += measure(\"Ag\", smallFs).height + fs * 0.12;\n\t\t\t}\n\t\t\tconst inner = maxW - padX * 2;\n\t\t\tlet textW = 0;\n\t\t\tlet textH = 0;\n\t\t\tif (m.text) {\n\t\t\t\tconst one = measure(m.text, fs);\n\t\t\t\tif (one.width <= inner) {\n\t\t\t\t\ttextW = one.width;\n\t\t\t\t\ttextH = one.height;\n\t\t\t\t} else {\n\t\t\t\t\tconst wrapped = measure(m.text, fs, inner);\n\t\t\t\t\ttextW = Math.max(\n\t\t\t\t\t\t...rangeRects(wrapped, 0, m.text.length, {\n\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\t\twidth: inner,\n\t\t\t\t\t\t}).map((r) => r.width),\n\t\t\t\t\t);\n\t\t\t\t\ttextH = wrapped.height;\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst imageW = m.image ? maxW : 0;\n\t\t\tconst imageH = m.image ? imageW / m.aspect : 0;\n\t\t\tconst width = m.image ? maxW : textW + padX * 2;\n\t\t\tconst height = imageH + (m.text ? textH + padY * 2 : 0);\n\t\t\tslots.push({ index: i, y, width, height, right, nameY, textH, imageH });\n\t\t\ty += height;\n\t\t\tif (m.time) {\n\t\t\t\t(slots[slots.length - 1] as Slot).timeY = y + fs * 0.12;\n\t\t\t\ty += measure(m.time, smallFs * 0.9).height + fs * 0.12;\n\t\t\t}\n\t\t});\n\n\t\t// ── Nodes ─────────────────────────────────────────────────────────\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst content: LayoutNode[] = [];\n\t\tconst colorOf = (from: string) =>\n\t\t\tprops.participants[from]?.color ??\n\t\t\t(from === me ? theme.color.accent : theme.color.raised);\n\t\tconst ink = (from: string) =>\n\t\t\tfrom === me && !props.participants[from]?.color\n\t\t\t\t? \"#ffffff\"\n\t\t\t\t: theme.color.ink;\n\t\tconst fade = Math.max(2, Math.round(fps * 0.12));\n\t\t// The typing bubble: three dots in a pill, where the message will land.\n\t\tconst dot = fs * 0.24;\n\t\tconst typingW = dot * 3 + dot * 1.1 * 2 + padX * 2;\n\t\tconst typingH = Math.max(fs + padY * 2 - fs * 0.3, dot + padY * 2);\n\t\tconst typingOf = (index: number) =>\n\t\t\ttypingWindows.find((w) => w.index === index);\n\n\t\tslots.forEach((slot) => {\n\t\t\tconst m = props.messages[slot.index] as (typeof props.messages)[number];\n\t\t\tconst at = landed[slot.index] as number;\n\t\t\tconst x = slot.right\n\t\t\t\t? W - pad - inset(m.from) - slot.width\n\t\t\t\t: pad + inset(m.from);\n\t\t\tconst children: LayoutNode[] = [];\n\t\t\tif (m.image) {\n\t\t\t\tchildren.push(\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(`image-${slot.index}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\thost.media(m.image, {\n\t\t\t\t\t\t\t\tid: ctx.partId(`media-${slot.index}`),\n\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst typed = typingOf(slot.index);\n\t\t\t// A typed text reply grows out of its typing bubble: same place,\n\t\t\t// same colour, so the dots become the message.\n\t\t\tconst morph = typed !== undefined && !m.image;\n\t\t\tif (m.text) {\n\t\t\t\tconst ty = slot.imageH + padY;\n\t\t\t\tconst text = host.text(m.text, {\n\t\t\t\t\tid: ctx.partId(`text-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX,\n\t\t\t\t\ty: ty,\n\t\t\t\t\twidth: slot.width - padX * 2 + 1,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfill: ink(m.from),\n\t\t\t\t});\n\t\t\t\t// The words settle in just after their bubble opens.\n\t\t\t\tconst t0 = at + (morph ? Math.round(fps * 0.12) : 1);\n\t\t\t\ttext.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[t0, 0],\n\t\t\t\t\t\t\t[t0 + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t])\n\t\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t\t[0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0 + Math.round(fps * 0.3), ty, \"power3.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tchildren.push(text);\n\t\t\t}\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`bubble-${slot.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: slot.width,\n\t\t\t\theight: slot.height,\n\t\t\t\tbackground: m.image && !m.text ? \"rgba(0,0,0,0)\" : colorOf(m.from),\n\t\t\t\tborderRadius: radius,\n\t\t\t\t// Springs grow from the corner the bubble comes from.\n\t\t\t\tanchorX: slot.right ? 1 : 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren,\n\t\t\t});\n\t\t\tif (morph)\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"width\", springKeys(at, typingW, slot.width, fps, SPRING))\n\t\t\t\t\t\t.keys(\"height\", springKeys(at, typingH, slot.height, fps, SPRING))\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"borderRadius\",\n\t\t\t\t\t\t\tspringKeys(at, typingH / 2, radius, fps, SPRING),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[at, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\telse {\n\t\t\t\t// Sent bubbles rise from below, as if from the compose field;\n\t\t\t\t// received ones pop from their corner.\n\t\t\t\tconst [scale, lift] = slot.right ? [0.86, fs * 1.1] : [0.6, fs * 0.5];\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(at, scale, 1, fps, SPRING))\n\t\t\t\t\t\t.keys(\"y\", springKeys(at, slot.y + lift, slot.y, fps, SPRING))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[at, 0],\n\t\t\t\t\t\t\t[at + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t}\n\t\t\tcontent.push(bubble);\n\t\t\tparts[`bubble-${slot.index}`] = bubble;\n\n\t\t\tconst small = (\n\t\t\t\tid: string,\n\t\t\t\ttext: string,\n\t\t\t\tsize: number,\n\t\t\t\ttx: number,\n\t\t\t\tty: number,\n\t\t\t\tfrom = at,\n\t\t\t) => {\n\t\t\t\tconst node = host.text(text, {\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: tx,\n\t\t\t\t\ty: ty,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: size,\n\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t});\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[from, 0],\n\t\t\t\t\t\t[from + Math.round(fps * 0.25), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(node);\n\t\t\t};\n\t\t\tif (slot.nameY !== undefined)\n\t\t\t\tsmall(\n\t\t\t\t\t`name-${slot.index}`,\n\t\t\t\t\tprops.participants[m.from]?.name ?? m.from,\n\t\t\t\t\tsmallFs,\n\t\t\t\t\tx + padX * 0.5,\n\t\t\t\t\tslot.nameY,\n\t\t\t\t\t// With the run's typing dots, when it starts with them.\n\t\t\t\t\ttyped?.frame ?? at,\n\t\t\t\t);\n\t\t\tif (slot.timeY !== undefined && m.time) {\n\t\t\t\tconst tw = measure(m.time, smallFs * 0.9).width;\n\t\t\t\tsmall(\n\t\t\t\t\t`time-${slot.index}`,\n\t\t\t\t\tm.time,\n\t\t\t\t\tsmallFs * 0.9,\n\t\t\t\t\tslot.right ? x + slot.width - tw - padX * 0.3 : x + padX * 0.3,\n\t\t\t\t\tslot.timeY,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (m.reaction) {\n\t\t\t\t// Hearts are drawn, so they show without an emoji font; anything\n\t\t\t\t// else is text (emoji need one registered).\n\t\t\t\tconst heart = /^(❤️?|♥️?|heart)$/u.test(m.reaction.trim());\n\t\t\t\tconst rfs = fs * 0.9;\n\t\t\t\tconst rm = measure(m.reaction, rfs);\n\t\t\t\tconst rh = fs * 1.5;\n\t\t\t\tconst rw = heart ? rh : Math.max(rh, rm.width + fs * 0.6);\n\t\t\t\tconst rAt = at + Math.round(fps * 0.6);\n\t\t\t\tconst border = Math.max(2, fs * 0.1);\n\t\t\t\tconst hs = rh * 0.46;\n\t\t\t\t// On the outer top corner, like a tapback.\n\t\t\t\tconst pill = host.box({\n\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: slot.right ? x - rw * 0.45 : x + slot.width - rw * 0.55,\n\t\t\t\t\ty: slot.y - rh * 0.6,\n\t\t\t\t\twidth: rw,\n\t\t\t\t\theight: rh,\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tborderRadius: rh / 2,\n\t\t\t\t\tborderColor: theme.color.bg,\n\t\t\t\t\tborderWidth: border,\n\t\t\t\t\tanchorX: 0.5,\n\t\t\t\t\tanchorY: 1,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\theart\n\t\t\t\t\t\t\t? host.shape(\"path\", {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-heart`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: (rw - hs) / 2,\n\t\t\t\t\t\t\t\t\ty: (rh - hs * 0.92) / 2 + hs * 0.04,\n\t\t\t\t\t\t\t\t\twidth: hs,\n\t\t\t\t\t\t\t\t\theight: hs * 0.92,\n\t\t\t\t\t\t\t\t\td: heartPath(hs),\n\t\t\t\t\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\t\t\t\t\tfillColor: theme.color.negative,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: host.text(m.reaction, {\n\t\t\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-text`),\n\t\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\t\tx: (rw - rm.width) / 2,\n\t\t\t\t\t\t\t\t\ty: (rh - rm.height) / 2,\n\t\t\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\t\t\tfontSize: rfs,\n\t\t\t\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\t\tpill.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(rAt, 0, 1, fps, POP))\n\t\t\t\t\t\t.keys(\"rotation\", springKeys(rAt, -24, 0, fps, POP))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[rAt, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(pill);\n\t\t\t}\n\t\t});\n\n\t\t// Typing indicators sit where the message will land.\n\t\ttypingWindows.forEach((w, k) => {\n\t\t\tconst slot = slots[w.index] as Slot;\n\t\t\tconst bw = typingW;\n\t\t\tconst bh = typingH;\n\t\t\tconst dots: LayoutNode[] = [0, 1, 2].map((d) => {\n\t\t\t\tconst node = host.box({\n\t\t\t\t\tid: ctx.partId(`typing-${k}-dot-${d}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX + d * dot * 2.1,\n\t\t\t\t\ty: (bh - dot) / 2,\n\t\t\t\t\twidth: dot,\n\t\t\t\t\theight: dot,\n\t\t\t\t\tbackground: theme.color.muted,\n\t\t\t\t\tborderRadius: dot / 2,\n\t\t\t\t});\n\t\t\t\t// A wave through the dots, one bounce every 0.6 s.\n\t\t\t\tconst period = Math.round(fps * 0.6);\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, (bh - dot) / 2]];\n\t\t\t\tconst glow: KeyframeTuple[] = [[0, 0.45]];\n\t\t\t\tfor (\n\t\t\t\t\tlet f = w.frame + d * Math.round(period / 5);\n\t\t\t\t\tf < w.end;\n\t\t\t\t\tf += period\n\t\t\t\t) {\n\t\t\t\t\tkeys.push(\n\t\t\t\t\t\t[f, (bh - dot) / 2],\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tf + Math.round(period / 4),\n\t\t\t\t\t\t\t(bh - dot) / 2 - dot * 0.7,\n\t\t\t\t\t\t\t\"sine.out\",\n\t\t\t\t\t\t],\n\t\t\t\t\t\t[f + Math.round(period / 2), (bh - dot) / 2, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t\t// Each dot brightens at the top of its bounce.\n\t\t\t\t\tglow.push(\n\t\t\t\t\t\t[f, 0.45],\n\t\t\t\t\t\t[f + Math.round(period / 4), 1, \"sine.out\"],\n\t\t\t\t\t\t[f + Math.round(period / 2), 0.45, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"y\", ascending(keys))\n\t\t\t\t\t\t.keys(\"opacity\", ascending(glow)),\n\t\t\t\t);\n\t\t\t\treturn node;\n\t\t\t});\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`typing-${k}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad + inset(w.from),\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: bw,\n\t\t\t\theight: bh,\n\t\t\t\tbackground: colorOf(w.from),\n\t\t\t\tborderRadius: bh / 2,\n\t\t\t\tanchorX: 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren: dots,\n\t\t\t});\n\t\t\tbubble.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"scale\", springKeys(w.frame, 0.5, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[w.frame, 1, \"hold\"],\n\t\t\t\t\t\t[w.end, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(bubble);\n\t\t});\n\n\t\tconst avatar = (\n\t\t\tfrom: string,\n\t\t\tid: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t): Animatable<LayoutNode> => {\n\t\t\tconst p = props.participants[from];\n\t\t\tconst size = avatarSize;\n\t\t\tconst base = {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\" as const,\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tborderRadius: size / 2,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 1,\n\t\t\t};\n\t\t\tif (p?.avatar)\n\t\t\t\treturn host.box({\n\t\t\t\t\t...base,\n\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\thost.media(p.avatar, {\n\t\t\t\t\t\t\tid: ctx.partId(`${id}-image`),\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: size,\n\t\t\t\t\t\t\theight: size,\n\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\tconst letters = initials(p?.name ?? from);\n\t\t\tconst ifs = size * (letters.length > 1 ? 0.38 : 0.44);\n\t\t\tconst m = host.measure(letters, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: ifs,\n\t\t\t\tfontWeight: 600,\n\t\t\t});\n\t\t\treturn host.box({\n\t\t\t\t...base,\n\t\t\t\tbackground: colorOf(from),\n\t\t\t\tchildren: [\n\t\t\t\t\thost.text(letters, {\n\t\t\t\t\t\tid: ctx.partId(`${id}-initials`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: (size - m.width) / 2,\n\t\t\t\t\t\ty: (size - m.height) / 2,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: ifs,\n\t\t\t\t\t\tfontWeight: 600,\n\t\t\t\t\t\tfill: ink(from),\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\t\t};\n\n\t\t// One avatar per run of bubbles: it arrives with the run's typing dots\n\t\t// or first bubble and moves down to each newer bubble, iMessage style.\n\t\tconst runs: number[][] = [];\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst run = runs[runs.length - 1];\n\t\t\tif (run && props.messages[run[0] as number]?.from === m.from) run.push(i);\n\t\t\telse runs.push([i]);\n\t\t});\n\t\truns.forEach((run) => {\n\t\t\tconst from = props.messages[run[0] as number]?.from ?? \"\";\n\t\t\tif (!hasAvatar(from)) return;\n\t\t\tconst r = run[0] as number;\n\t\t\tconst stops: Array<[number, number]> = [];\n\t\t\tfor (const i of run) {\n\t\t\t\tconst slot = slots[i] as Slot;\n\t\t\t\tconst w = typingWindows.find((tw) => tw.index === i);\n\t\t\t\tif (w) stops.push([w.frame, slot.y + typingH - avatarSize]);\n\t\t\t\tstops.push([landed[i] as number, slot.y + slot.height - avatarSize]);\n\t\t\t}\n\t\t\tconst [start, startY] = stops[0] as [number, number];\n\t\t\t// Same spring as the bubbles, so it stays level with their bottoms.\n\t\t\tconst y: KeyframeTuple[] = [[0, startY]];\n\t\t\tlet current = startY;\n\t\t\tfor (const [f, to] of stops.slice(1)) {\n\t\t\t\tif (to === current) continue;\n\t\t\t\ty.push(...springKeys(f, current, to, fps, SPRING));\n\t\t\t\tcurrent = to;\n\t\t\t}\n\t\t\tconst node = avatar(\n\t\t\t\tfrom,\n\t\t\t\t`avatar-${r}`,\n\t\t\t\tfrom === me ? W - pad - avatarSize : pad,\n\t\t\t\tstartY,\n\t\t\t);\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"y\", ascending(y))\n\t\t\t\t\t.keys(\"scale\", springKeys(start, 0.4, 1, fps, SPRING))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[start, 0],\n\t\t\t\t\t\t[start + Math.max(2, Math.round(fps * 0.12)), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(node);\n\t\t\tparts[`avatar-${r}`] = node;\n\t\t});\n\n\t\t// ── Scroll: keep the newest bubble (or typing dots) in view ───────\n\t\tconst events = [\n\t\t\t...slots.map((sl) => ({\n\t\t\t\tframe: landed[sl.index] as number,\n\t\t\t\tbottom: sl.y + sl.height + (sl.timeY !== undefined ? fs : 0),\n\t\t\t})),\n\t\t\t...typingWindows.map((w) => ({\n\t\t\t\tframe: w.frame,\n\t\t\t\tbottom: (slots[w.index] as Slot).y + fs * 1.6,\n\t\t\t})),\n\t\t].sort((a, b) => a.frame - b.frame);\n\t\tconst scroll: KeyframeTuple[] = [[0, 0]];\n\t\tlet top = 0;\n\t\tconst glide = Math.round(fps * 0.35);\n\t\tfor (const e of events) {\n\t\t\tconst want = Math.min(0, H - pad - e.bottom);\n\t\t\tif (want >= top) continue;\n\t\t\tconst last = scroll[scroll.length - 1] as KeyframeTuple;\n\t\t\tif (e.frame > last[0]) scroll.push([e.frame, top]);\n\t\t\tscroll.push([Math.max(e.frame, last[0]) + glide, want, \"power3.out\"]);\n\t\t\ttop = want;\n\t\t}\n\t\tconst column = host.box({\n\t\t\tid: ctx.partId(\"column\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: Math.max(H, y + pad),\n\t\t\tchildren: content,\n\t\t});\n\t\tcolumn.animate(LayerAnimation.keys(\"y\", ascending(scroll)));\n\t\tconst viewport = host.box({\n\t\t\tid: ctx.partId(\"viewport\"),\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\toverflow: \"hidden\",\n\t\t\tchildren: [column],\n\t\t});\n\t\tparts.column = column;\n\t\tparts.viewport = viewport;\n\n\t\tconst lastAt = Math.max(...landed);\n\t\treturn {\n\t\t\tchildren: [viewport],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: lastAt + Math.round(fps * 0.8),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tmessages: landed.map((frame, index) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tindex,\n\t\t\t\t\tfrom: props.messages[index]?.from ?? \"\",\n\t\t\t\t})),\n\t\t\t\ttyping: typingWindows.map(({ frame, end, from }) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tend,\n\t\t\t\t\tfrom,\n\t\t\t\t})),\n\t\t\t} as ChatThreadCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** \"Ana Lima\" → \"AL\", \"kai\" → \"K\". */\nfunction initials(name: string): string {\n\tconst words = name.trim().split(/\\s+/u).filter(Boolean);\n\tconst first = (w: string | undefined) => [...(w ?? \"\")][0] ?? \"\";\n\treturn (\n\t\tfirst(words[0]) + (words.length > 1 ? first(words[words.length - 1]) : \"\")\n\t).toUpperCase();\n}\n\n/** A heart `size` wide, top-left at 0, 0 (two lobes and a point). */\nfunction heartPath(size: number): string {\n\tconst w = size;\n\tconst h = size * 0.92;\n\treturn [\n\t\t`M${w / 2} ${h}`,\n\t\t`C${w * 0.18} ${h * 0.72} 0 ${h * 0.5} 0 ${h * 0.28}`,\n\t\t`C0 ${h * 0.1} ${w * 0.14} 0 ${w * 0.28} 0`,\n\t\t`C${w * 0.39} 0 ${w * 0.47} ${h * 0.07} ${w / 2} ${h * 0.16}`,\n\t\t`C${w * 0.53} ${h * 0.07} ${w * 0.61} 0 ${w * 0.72} 0`,\n\t\t`C${w * 0.86} 0 ${w} ${h * 0.1} ${w} ${h * 0.28}`,\n\t\t`C${w} ${h * 0.5} ${w * 0.82} ${h * 0.72} ${w / 2} ${h} Z`,\n\t].join(\" \");\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 * A messaging conversation: bubbles that spring in from either side, typing\n * dots before replies, names, avatars, timestamps, reactions, images and\n * auto-scroll.\n */\nexport const ChatThread = /* @__PURE__ */ bindComponent(chatThread);\n"
36
+ "content": "/**\n * ChatThread: a messaging conversation. Bubbles arrive from either side\n * with a spring, the other side types first (three bouncing dots), and the\n * thread scrolls to keep the newest message in view. Names, avatars,\n * timestamps, reactions and images included.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\trangeRects,\n\tspringKeys,\n\tsupersede,\n\tsurfaceFill,\n\tz,\n} from \"framefields/components\";\n\nexport interface ChatThreadCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Each message as it lands. */\n\tmessages: ReadonlyArray<{ frame: number; index: number; from: string }>;\n\t/** Each typing indicator, shown to removed. */\n\ttyping: ReadonlyArray<{ frame: number; end: number; from: string }>;\n}\n\nfunction messageSchema() {\n\treturn z\n\t\t.object({\n\t\t\tfrom: z.string(),\n\t\t\ttext: z.string().optional(),\n\t\t\t/** An image (path or URL) instead of, or above, text. */\n\t\t\timage: z.string().optional(),\n\t\t\t/** Image width / height. */\n\t\t\taspect: z\n\t\t\t\t.number()\n\t\t\t\t.positive()\n\t\t\t\t.default(4 / 3),\n\t\t\t/** Frame it lands. Default: after the previous one has been read. */\n\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t/**\n\t\t\t * A reaction pinned to the bubble: an emoji or short text (`\"heart\"`\n\t\t\t * means ❤️). Emoji draw from the color emoji font `framefields add`\n\t\t\t * installs (`registerFonts()` in the generated `fonts.ts`).\n\t\t\t */\n\t\t\treaction: z.string().optional(),\n\t\t\t/** A small timestamp under the bubble. */\n\t\t\ttime: z.string().optional(),\n\t\t})\n\t\t.strict()\n\t\t.refine((m) => m.text !== undefined || m.image !== undefined, {\n\t\t\tmessage: \"A message needs `text` or `image`.\",\n\t\t});\n}\n\nexport const chatThread = /* @__PURE__ */ defineComponent({\n\tname: \"ChatThread\",\n\tprops: () => ({\n\t\tmessages: z.array(messageSchema()).min(1),\n\t\t/** Who is \"me\" (right side, accent bubbles). Default the first sender. */\n\t\tme: z.string().optional(),\n\t\t/** Display names and bubble colours per sender. */\n\t\tparticipants: z\n\t\t\t.record(\n\t\t\t\tz.string(),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\tname: z.string().optional(),\n\t\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t\t\t/** A picture (path or URL); without one the avatar shows initials. */\n\t\t\t\t\t\tavatar: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default({}),\n\t\t/**\n\t\t * Avatars beside each run of bubbles, at its newest one. Default:\n\t\t * `\"others\"` when any participant has an `avatar`, else `\"none\"`.\n\t\t */\n\t\tavatars: z.enum([\"others\", \"everyone\", \"none\"]).optional(),\n\t\t/** Names above the first bubble of each run (default: when more than two people talk). */\n\t\tnames: z.boolean().optional(),\n\t\t/** Others type before they send. */\n\t\ttyping: z.boolean().default(true),\n\t\t/** Reading speed between messages, characters a second. */\n\t\treadCps: z.number().positive().default(28),\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\t// Bubbles overshoot a little; reactions bounce.\n\t\tconst { base: spring, pop } = theme.motion.spring;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 720 * s;\n\t\tconst H = ctx.requested.height ?? 960 * s;\n\t\tconst fs = props.fontSize ?? 30 * s;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.body;\n\t\tconst me = props.me ?? props.messages[0]?.from ?? \"me\";\n\t\tconst senders = [...new Set(props.messages.map((m) => m.from))];\n\t\tconst showNames = props.names ?? senders.length > 2;\n\t\tconst avatars =\n\t\t\tprops.avatars ??\n\t\t\t(Object.values(props.participants).some((p) => p.avatar)\n\t\t\t\t? \"others\"\n\t\t\t\t: \"none\");\n\t\tconst hasAvatar = (from: string) =>\n\t\t\tavatars === \"everyone\" || (avatars === \"others\" && from !== me);\n\t\tconst pad = fs * 0.8;\n\t\tconst padX = fs * 0.62;\n\t\tconst padY = fs * 0.42;\n\t\tconst avatarSize = fs * 1.5;\n\t\t// Bubbles on a side with avatars move in by this much.\n\t\tconst inset = (from: string) =>\n\t\t\thasAvatar(from) ? avatarSize + fs * 0.3 : 0;\n\t\tconst maxW = Math.min(\n\t\t\tW * 0.74,\n\t\t\tW - pad * 2 - Math.max(...senders.map(inset)),\n\t\t);\n\t\tconst radius = fs * 0.9;\n\t\tconst smallFs = fs * 0.62;\n\n\t\t// ── Timing ────────────────────────────────────────────────────────\n\t\tconst landed: number[] = [];\n\t\tconst typingWindows: Array<{\n\t\t\tframe: number;\n\t\t\tend: number;\n\t\t\tfrom: string;\n\t\t\tindex: number;\n\t\t}> = [];\n\t\tlet t = Math.round(fps * 0.2);\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tif (prev && m.at === undefined) {\n\t\t\t\tconst read = ((prev.text?.length ?? 12) / props.readCps) * fps;\n\t\t\t\tt += Math.round(Math.min(fps * 2.5, Math.max(fps * 0.5, read)));\n\t\t\t}\n\t\t\tlet at = m.at ?? t;\n\t\t\tif (props.typing && m.from !== me && m.at === undefined) {\n\t\t\t\tconst typingFor = Math.round(\n\t\t\t\t\tMath.min(2.2, Math.max(0.7, (m.text?.length ?? 10) / 14)) * fps,\n\t\t\t\t);\n\t\t\t\ttypingWindows.push({\n\t\t\t\t\tframe: at,\n\t\t\t\t\tend: at + typingFor,\n\t\t\t\t\tfrom: m.from,\n\t\t\t\t\tindex: i,\n\t\t\t\t});\n\t\t\t\tat += typingFor;\n\t\t\t}\n\t\t\tlanded.push(at);\n\t\t\tt = at;\n\t\t});\n\n\t\t// ── Layout (top to bottom) ────────────────────────────────────────\n\t\tconst measure = (text: string, size: number, width?: number) =>\n\t\t\thost.measure(text, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: size,\n\t\t\t\t...(width !== undefined && { width }),\n\t\t\t});\n\t\tinterface Slot {\n\t\t\tindex: number;\n\t\t\ty: number;\n\t\t\twidth: number;\n\t\t\theight: number;\n\t\t\tright: boolean;\n\t\t\tnameY?: number;\n\t\t\ttimeY?: number;\n\t\t\ttextH: number;\n\t\t\timageH: number;\n\t\t}\n\t\tconst slots: Slot[] = [];\n\t\tlet y = pad;\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst prev = props.messages[i - 1];\n\t\t\tconst right = m.from === me;\n\t\t\tconst newRun = !prev || prev.from !== m.from;\n\t\t\tif (prev) y += newRun ? fs * 0.75 : fs * 0.22;\n\t\t\t// A reaction sits on the bubble's top corner; leave it room.\n\t\t\tif (prev && m.reaction) y += fs * 0.5;\n\t\t\tlet nameY: number | undefined;\n\t\t\tif (showNames && newRun && !right) {\n\t\t\t\tnameY = y;\n\t\t\t\ty += measure(\"Ag\", smallFs).height + fs * 0.12;\n\t\t\t}\n\t\t\tconst inner = maxW - padX * 2;\n\t\t\tlet textW = 0;\n\t\t\tlet textH = 0;\n\t\t\tif (m.text) {\n\t\t\t\tconst one = measure(m.text, fs);\n\t\t\t\tif (one.width <= inner) {\n\t\t\t\t\ttextW = one.width;\n\t\t\t\t\ttextH = one.height;\n\t\t\t\t} else {\n\t\t\t\t\tconst wrapped = measure(m.text, fs, inner);\n\t\t\t\t\ttextW = Math.max(\n\t\t\t\t\t\t...rangeRects(wrapped, 0, m.text.length, {\n\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\tfontSize: fs,\n\t\t\t\t\t\t\twidth: inner,\n\t\t\t\t\t\t}).map((r) => r.width),\n\t\t\t\t\t);\n\t\t\t\t\ttextH = wrapped.height;\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst imageW = m.image ? maxW : 0;\n\t\t\tconst imageH = m.image ? imageW / m.aspect : 0;\n\t\t\tconst width = m.image ? maxW : textW + padX * 2;\n\t\t\tconst height = imageH + (m.text ? textH + padY * 2 : 0);\n\t\t\tslots.push({ index: i, y, width, height, right, nameY, textH, imageH });\n\t\t\ty += height;\n\t\t\tif (m.time) {\n\t\t\t\t(slots[slots.length - 1] as Slot).timeY = y + fs * 0.12;\n\t\t\t\ty += measure(m.time, smallFs * 0.9).height + fs * 0.12;\n\t\t\t}\n\t\t});\n\n\t\t// ── Nodes ─────────────────────────────────────────────────────────\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst content: LayoutNode[] = [];\n\t\tconst colorOf = (from: string) =>\n\t\t\tprops.participants[from]?.color ??\n\t\t\t(from === me ? theme.color.accent : theme.color.raised);\n\t\tconst ink = (from: string) =>\n\t\t\tfrom === me && !props.participants[from]?.color\n\t\t\t\t? \"#ffffff\"\n\t\t\t\t: theme.color.ink;\n\t\tconst fade = Math.max(2, Math.round(fps * 0.12));\n\t\t// The typing bubble: three dots in a pill, where the message will land.\n\t\tconst dot = fs * 0.24;\n\t\tconst typingW = dot * 3 + dot * 1.1 * 2 + padX * 2;\n\t\tconst typingH = Math.max(fs + padY * 2 - fs * 0.3, dot + padY * 2);\n\t\tconst typingOf = (index: number) =>\n\t\t\ttypingWindows.find((w) => w.index === index);\n\n\t\tslots.forEach((slot) => {\n\t\t\tconst m = props.messages[slot.index] as (typeof props.messages)[number];\n\t\t\tconst at = landed[slot.index] as number;\n\t\t\tconst x = slot.right\n\t\t\t\t? W - pad - inset(m.from) - slot.width\n\t\t\t\t: pad + inset(m.from);\n\t\t\tconst children: LayoutNode[] = [];\n\t\t\tif (m.image) {\n\t\t\t\tchildren.push(\n\t\t\t\t\thost.box({\n\t\t\t\t\t\tid: ctx.partId(`image-${slot.index}`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\t\tborderRadius: radius,\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\thost.media(m.image, {\n\t\t\t\t\t\t\t\tid: ctx.partId(`media-${slot.index}`),\n\t\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\t\t\twidth: slot.width,\n\t\t\t\t\t\t\t\theight: slot.imageH,\n\t\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst typed = typingOf(slot.index);\n\t\t\t// A typed text reply grows out of its typing bubble: same place,\n\t\t\t// same colour, so the dots become the message.\n\t\t\tconst morph = typed !== undefined && !m.image;\n\t\t\tif (m.text) {\n\t\t\t\tconst ty = slot.imageH + padY;\n\t\t\t\tconst text = host.text(m.text, {\n\t\t\t\t\tid: ctx.partId(`text-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX,\n\t\t\t\t\ty: ty,\n\t\t\t\t\twidth: slot.width - padX * 2 + 1,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: fs,\n\t\t\t\t\tfill: ink(m.from),\n\t\t\t\t});\n\t\t\t\t// The words settle in just after their bubble opens.\n\t\t\t\tconst t0 = at + (morph ? Math.round(fps * 0.12) : 1);\n\t\t\t\ttext.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[t0, 0],\n\t\t\t\t\t\t\t[t0 + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t])\n\t\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t\t[0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0, ty + fs * 0.2],\n\t\t\t\t\t\t\t[t0 + Math.round(fps * 0.3), ty, \"power3.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tchildren.push(text);\n\t\t\t}\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`bubble-${slot.index}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx,\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: slot.width,\n\t\t\t\theight: slot.height,\n\t\t\t\tbackground:\n\t\t\t\t\tm.image && !m.text\n\t\t\t\t\t\t? \"rgba(0,0,0,0)\"\n\t\t\t\t\t\t: surfaceFill(colorOf(m.from), theme),\n\t\t\t\tborderRadius: radius,\n\t\t\t\t// Springs grow from the corner the bubble comes from.\n\t\t\t\tanchorX: slot.right ? 1 : 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren,\n\t\t\t});\n\t\t\tif (morph)\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"width\", springKeys(at, typingW, slot.width, fps, spring))\n\t\t\t\t\t\t.keys(\"height\", springKeys(at, typingH, slot.height, fps, spring))\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"borderRadius\",\n\t\t\t\t\t\t\tspringKeys(at, typingH / 2, radius, fps, spring),\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[at, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\telse {\n\t\t\t\t// Sent bubbles rise from below, as if from the compose field;\n\t\t\t\t// received ones pop from their corner.\n\t\t\t\tconst [scale, lift] = slot.right ? [0.86, fs * 1.1] : [0.6, fs * 0.5];\n\t\t\t\tbubble.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(at, scale, 1, fps, spring))\n\t\t\t\t\t\t.keys(\"y\", springKeys(at, slot.y + lift, slot.y, fps, spring))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[at, 0],\n\t\t\t\t\t\t\t[at + fade, 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t}\n\t\t\tcontent.push(bubble);\n\t\t\tparts[`bubble-${slot.index}`] = bubble;\n\n\t\t\tconst small = (\n\t\t\t\tid: string,\n\t\t\t\ttext: string,\n\t\t\t\tsize: number,\n\t\t\t\ttx: number,\n\t\t\t\tty: number,\n\t\t\t\tfrom = at,\n\t\t\t) => {\n\t\t\t\tconst node = host.text(text, {\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: tx,\n\t\t\t\t\ty: ty,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: size,\n\t\t\t\t\tfill: theme.color.muted,\n\t\t\t\t});\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[from, 0],\n\t\t\t\t\t\t[from + Math.round(fps * 0.25), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(node);\n\t\t\t};\n\t\t\tif (slot.nameY !== undefined)\n\t\t\t\tsmall(\n\t\t\t\t\t`name-${slot.index}`,\n\t\t\t\t\tprops.participants[m.from]?.name ?? m.from,\n\t\t\t\t\tsmallFs,\n\t\t\t\t\tx + padX * 0.5,\n\t\t\t\t\tslot.nameY,\n\t\t\t\t\t// With the run's typing dots, when it starts with them.\n\t\t\t\t\ttyped?.frame ?? at,\n\t\t\t\t);\n\t\t\tif (slot.timeY !== undefined && m.time) {\n\t\t\t\tconst tw = measure(m.time, smallFs * 0.9).width;\n\t\t\t\tsmall(\n\t\t\t\t\t`time-${slot.index}`,\n\t\t\t\t\tm.time,\n\t\t\t\t\tsmallFs * 0.9,\n\t\t\t\t\tslot.right ? x + slot.width - tw - padX * 0.3 : x + padX * 0.3,\n\t\t\t\t\tslot.timeY,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (m.reaction) {\n\t\t\t\t// Text, so emoji draw from the registered color emoji font\n\t\t\t\t// (installed with this component; see `fonts.ts`).\n\t\t\t\tconst reaction = m.reaction.trim() === \"heart\" ? \"❤️\" : m.reaction;\n\t\t\t\t// Emoji draw a little past their advance, so a lone one is set\n\t\t\t\t// smaller to keep the pill's margin.\n\t\t\t\tconst emojiOnly = !/[\\p{L}\\p{N}]/u.test(reaction);\n\t\t\t\tconst rfs = fs * (emojiOnly ? 0.74 : 0.9);\n\t\t\t\tconst rm = measure(reaction, rfs);\n\t\t\t\tconst rh = fs * 1.5;\n\t\t\t\tconst rw = Math.max(rh, rm.width + fs * 0.6);\n\t\t\t\tconst rAt = at + Math.round(fps * 0.6);\n\t\t\t\tconst border = Math.max(2, fs * 0.1);\n\t\t\t\t// On the outer top corner, like a tapback.\n\t\t\t\tconst pill = host.box({\n\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: slot.right ? x - rw * 0.45 : x + slot.width - rw * 0.55,\n\t\t\t\t\ty: slot.y - rh * 0.6,\n\t\t\t\t\twidth: rw,\n\t\t\t\t\theight: rh,\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tborderRadius: rh / 2,\n\t\t\t\t\tborderColor: theme.color.bg,\n\t\t\t\t\tborderWidth: border,\n\t\t\t\t\tanchorX: 0.5,\n\t\t\t\t\tanchorY: 1,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\thost.text(reaction, {\n\t\t\t\t\t\t\tid: ctx.partId(`reaction-${slot.index}-text`),\n\t\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\t\tx: (rw - rm.width) / 2,\n\t\t\t\t\t\t\ty: (rh - rm.height) / 2,\n\t\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\t\tfontSize: rfs,\n\t\t\t\t\t\t\tfill: theme.color.ink,\n\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\t\tpill.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"scale\", springKeys(rAt, 0, 1, fps, pop))\n\t\t\t\t\t\t.keys(\"rotation\", springKeys(rAt, -24, 0, fps, pop))\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t\t[rAt, 1, \"hold\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tcontent.push(pill);\n\t\t\t}\n\t\t});\n\n\t\t// Typing indicators sit where the message will land.\n\t\ttypingWindows.forEach((w, k) => {\n\t\t\tconst slot = slots[w.index] as Slot;\n\t\t\tconst bw = typingW;\n\t\t\tconst bh = typingH;\n\t\t\tconst dots: LayoutNode[] = [0, 1, 2].map((d) => {\n\t\t\t\tconst node = host.box({\n\t\t\t\t\tid: ctx.partId(`typing-${k}-dot-${d}`),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: padX + d * dot * 2.1,\n\t\t\t\t\ty: (bh - dot) / 2,\n\t\t\t\t\twidth: dot,\n\t\t\t\t\theight: dot,\n\t\t\t\t\tbackground: theme.color.muted,\n\t\t\t\t\tborderRadius: dot / 2,\n\t\t\t\t});\n\t\t\t\t// A wave through the dots, one bounce every 0.6 s.\n\t\t\t\tconst period = Math.round(fps * 0.6);\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, (bh - dot) / 2]];\n\t\t\t\tconst glow: KeyframeTuple[] = [[0, 0.45]];\n\t\t\t\tfor (\n\t\t\t\t\tlet f = w.frame + d * Math.round(period / 5);\n\t\t\t\t\tf < w.end;\n\t\t\t\t\tf += period\n\t\t\t\t) {\n\t\t\t\t\tkeys.push(\n\t\t\t\t\t\t[f, (bh - dot) / 2],\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\tf + Math.round(period / 4),\n\t\t\t\t\t\t\t(bh - dot) / 2 - dot * 0.7,\n\t\t\t\t\t\t\t\"sine.out\",\n\t\t\t\t\t\t],\n\t\t\t\t\t\t[f + Math.round(period / 2), (bh - dot) / 2, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t\t// Each dot brightens at the top of its bounce.\n\t\t\t\t\tglow.push(\n\t\t\t\t\t\t[f, 0.45],\n\t\t\t\t\t\t[f + Math.round(period / 4), 1, \"sine.out\"],\n\t\t\t\t\t\t[f + Math.round(period / 2), 0.45, \"sine.in\"],\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tnode.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\"y\", supersede(keys))\n\t\t\t\t\t\t.keys(\"opacity\", supersede(glow)),\n\t\t\t\t);\n\t\t\t\treturn node;\n\t\t\t});\n\t\t\tconst bubble = host.box({\n\t\t\t\tid: ctx.partId(`typing-${k}`),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad + inset(w.from),\n\t\t\t\ty: slot.y,\n\t\t\t\twidth: bw,\n\t\t\t\theight: bh,\n\t\t\t\tbackground: colorOf(w.from),\n\t\t\t\tborderRadius: bh / 2,\n\t\t\t\tanchorX: 0,\n\t\t\t\tanchorY: 1,\n\t\t\t\tchildren: dots,\n\t\t\t});\n\t\t\tbubble.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"scale\", springKeys(w.frame, 0.5, 1, fps, spring))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[w.frame, 1, \"hold\"],\n\t\t\t\t\t\t[w.end, 0, \"hold\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(bubble);\n\t\t});\n\n\t\tconst avatar = (\n\t\t\tfrom: string,\n\t\t\tid: string,\n\t\t\tx: number,\n\t\t\ty: number,\n\t\t): Animatable<LayoutNode> => {\n\t\t\tconst p = props.participants[from];\n\t\t\tconst size = avatarSize;\n\t\t\tconst base = {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\" as const,\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\twidth: size,\n\t\t\t\theight: size,\n\t\t\t\tborderRadius: size / 2,\n\t\t\t\tanchorX: 0.5,\n\t\t\t\tanchorY: 1,\n\t\t\t};\n\t\t\tif (p?.avatar)\n\t\t\t\treturn host.box({\n\t\t\t\t\t...base,\n\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\tbackground: theme.color.raised,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\thost.media(p.avatar, {\n\t\t\t\t\t\t\tid: ctx.partId(`${id}-image`),\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: size,\n\t\t\t\t\t\t\theight: size,\n\t\t\t\t\t\t\tfit: \"cover\",\n\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t});\n\t\t\tconst letters = initials(p?.name ?? from);\n\t\t\tconst ifs = size * (letters.length > 1 ? 0.38 : 0.44);\n\t\t\tconst m = host.measure(letters, {\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: ifs,\n\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t});\n\t\t\treturn host.box({\n\t\t\t\t...base,\n\t\t\t\tbackground: colorOf(from),\n\t\t\t\tchildren: [\n\t\t\t\t\thost.text(letters, {\n\t\t\t\t\t\tid: ctx.partId(`${id}-initials`),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: (size - m.width) / 2,\n\t\t\t\t\t\ty: (size - m.height) / 2,\n\t\t\t\t\t\tfontFamily,\n\t\t\t\t\t\tfontSize: ifs,\n\t\t\t\t\t\tfontWeight: theme.weight.semibold,\n\t\t\t\t\t\tfill: ink(from),\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\t\t};\n\n\t\t// One avatar per run of bubbles: it arrives with the run's typing dots\n\t\t// or first bubble and moves down to each newer bubble, iMessage style.\n\t\tconst runs: number[][] = [];\n\t\tprops.messages.forEach((m, i) => {\n\t\t\tconst run = runs[runs.length - 1];\n\t\t\tif (run && props.messages[run[0] as number]?.from === m.from) run.push(i);\n\t\t\telse runs.push([i]);\n\t\t});\n\t\truns.forEach((run) => {\n\t\t\tconst from = props.messages[run[0] as number]?.from ?? \"\";\n\t\t\tif (!hasAvatar(from)) return;\n\t\t\tconst r = run[0] as number;\n\t\t\tconst stops: Array<[number, number]> = [];\n\t\t\tfor (const i of run) {\n\t\t\t\tconst slot = slots[i] as Slot;\n\t\t\t\tconst w = typingWindows.find((tw) => tw.index === i);\n\t\t\t\tif (w) stops.push([w.frame, slot.y + typingH - avatarSize]);\n\t\t\t\tstops.push([landed[i] as number, slot.y + slot.height - avatarSize]);\n\t\t\t}\n\t\t\tconst [start, startY] = stops[0] as [number, number];\n\t\t\t// Same spring as the bubbles, so it stays level with their bottoms.\n\t\t\tconst y: KeyframeTuple[] = [[0, startY]];\n\t\t\tlet current = startY;\n\t\t\tfor (const [f, to] of stops.slice(1)) {\n\t\t\t\tif (to === current) continue;\n\t\t\t\ty.push(...springKeys(f, current, to, fps, spring));\n\t\t\t\tcurrent = to;\n\t\t\t}\n\t\t\tconst node = avatar(\n\t\t\t\tfrom,\n\t\t\t\t`avatar-${r}`,\n\t\t\t\tfrom === me ? W - pad - avatarSize : pad,\n\t\t\t\tstartY,\n\t\t\t);\n\t\t\tnode.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"y\", supersede(y))\n\t\t\t\t\t.keys(\"scale\", springKeys(start, 0.4, 1, fps, spring))\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[0, 0],\n\t\t\t\t\t\t[start, 0],\n\t\t\t\t\t\t[start + Math.max(2, Math.round(fps * 0.12)), 1, \"power2.out\"],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tcontent.push(node);\n\t\t\tparts[`avatar-${r}`] = node;\n\t\t});\n\n\t\t// ── Scroll: keep the newest bubble (or typing dots) in view ───────\n\t\tconst events = [\n\t\t\t...slots.map((sl) => ({\n\t\t\t\tframe: landed[sl.index] as number,\n\t\t\t\tbottom: sl.y + sl.height + (sl.timeY !== undefined ? fs : 0),\n\t\t\t})),\n\t\t\t...typingWindows.map((w) => ({\n\t\t\t\tframe: w.frame,\n\t\t\t\tbottom: (slots[w.index] as Slot).y + fs * 1.6,\n\t\t\t})),\n\t\t].sort((a, b) => a.frame - b.frame);\n\t\tconst scroll: KeyframeTuple[] = [[0, 0]];\n\t\tlet top = 0;\n\t\tconst glide = Math.round(fps * 0.35);\n\t\tfor (const e of events) {\n\t\t\tconst want = Math.min(0, H - pad - e.bottom);\n\t\t\tif (want >= top) continue;\n\t\t\tconst last = scroll[scroll.length - 1] as KeyframeTuple;\n\t\t\tif (e.frame > last[0]) scroll.push([e.frame, top]);\n\t\t\tscroll.push([Math.max(e.frame, last[0]) + glide, want, \"power3.out\"]);\n\t\t\ttop = want;\n\t\t}\n\t\tconst column = host.box({\n\t\t\tid: ctx.partId(\"column\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: 0,\n\t\t\ty: 0,\n\t\t\twidth: W,\n\t\t\theight: Math.max(H, y + pad),\n\t\t\tchildren: content,\n\t\t});\n\t\tcolumn.animate(LayerAnimation.keys(\"y\", supersede(scroll)));\n\t\tconst viewport = host.box({\n\t\t\tid: ctx.partId(\"viewport\"),\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\toverflow: \"hidden\",\n\t\t\tchildren: [column],\n\t\t});\n\t\tparts.column = column;\n\t\tparts.viewport = viewport;\n\n\t\tconst lastAt = Math.max(...landed);\n\t\treturn {\n\t\t\tchildren: [viewport],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: lastAt + Math.round(fps * 0.8),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tmessages: landed.map((frame, index) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tindex,\n\t\t\t\t\tfrom: props.messages[index]?.from ?? \"\",\n\t\t\t\t})),\n\t\t\t\ttyping: typingWindows.map(({ frame, end, from }) => ({\n\t\t\t\t\tframe,\n\t\t\t\t\tend,\n\t\t\t\t\tfrom,\n\t\t\t\t})),\n\t\t\t} as ChatThreadCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/** \"Ana Lima\" → \"AL\", \"kai\" → \"K\". */\nfunction initials(name: string): string {\n\tconst words = name.trim().split(/\\s+/u).filter(Boolean);\n\tconst first = (w: string | undefined) => [...(w ?? \"\")][0] ?? \"\";\n\treturn (\n\t\tfirst(words[0]) + (words.length > 1 ? first(words[words.length - 1]) : \"\")\n\t).toUpperCase();\n}\n\n/**\n * A messaging conversation: bubbles that spring in from either side, typing\n * dots before replies, names, avatars, timestamps, reactions, images and\n * auto-scroll.\n */\nexport const ChatThread = /* @__PURE__ */ bindComponent(chatThread);\n"
31
37
  }
32
38
  ]
33
39
  }
@@ -25,17 +25,23 @@
25
25
  "focus",
26
26
  "diff"
27
27
  ],
28
+ "fonts": [
29
+ {
30
+ "font": "mono",
31
+ "why": "Monospace text for code."
32
+ }
33
+ ],
28
34
  "registryDependencies": [
29
35
  "app-frame"
30
36
  ],
31
37
  "files": [
32
38
  {
33
39
  "path": "code-block/index.ts",
34
- "content": "/**\n * CodeBlock: code that reads like an editor and moves like an explanation.\n * Syntax colours, typing, a focus band that walks the lines being\n * discussed, and diffs where kept lines glide to their new places while\n * removed lines fold away and new ones arrive.\n */\nimport {\n\ttype AnimatableProp,\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\tthemeFrames,\n\ttypingFrames,\n\tz,\n} from \"framefields/components\";\nimport { appFrame } from \"../app-frame/index.js\";\nimport { diffLines, expandTabs, focusSet, Track } from \"./plan.js\";\nimport {\n\tCODE_LANGS,\n\ttype Token,\n\ttype TokenKind,\n\ttokenize,\n} from \"./tokenize.js\";\n\nexport {\n\tCODE_LANGS,\n\ttype CodeLang,\n\ttype Token,\n\ttype TokenKind,\n} from \"./tokenize.js\";\n\nexport interface CodeBlockCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Every typed character (`typing`): for key sounds. */\n\tkeystrokes: ReadonlyArray<{ frame: number; line: number }>;\n\t/** Each line's first frame on screen (1-based `line`). */\n\tlines: ReadonlyArray<{ frame: number; line: number }>;\n\t/** Each focus step. */\n\tfocus: ReadonlyArray<{ frame: number; lines: number[] }>;\n\t/** The diff, start to settle. */\n\tdiff: ReadonlyArray<{ frame: number; end: number }>;\n}\n\n/** Token colours from theme tokens. */\nfunction palette(theme: Theme): Record<TokenKind, string> {\n\tconst c = theme.color;\n\treturn {\n\t\tplain: c.ink,\n\t\tkeyword: c.accent2,\n\t\tstring: c.positive,\n\t\tnumber: c.highlight,\n\t\tcomment: c.muted,\n\t\tfunction: c.accent,\n\t\ttype: \"#4cc9e0\",\n\t\tproperty: c.ink,\n\t\tpunctuation: c.muted,\n\t\toperator: c.muted,\n\t};\n}\n\n/** `#rrggbb` (or `#rgb`) at `alpha`, for washes and bands. */\nfunction tint(color: string, alpha: number): string {\n\tconst m = color.trim().match(/^#([\\da-f]{3}|[\\da-f]{6})$/i);\n\tif (!m?.[1]) return color;\n\tconst h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join(\"\") : m[1];\n\tconst [r, g, b] = [0, 2, 4].map((i) =>\n\t\tNumber.parseInt(h.slice(i, i + 2), 16),\n\t);\n\treturn `rgba(${r},${g},${b},${alpha})`;\n}\n\nfunction tokenSchema() {\n\treturn z.array(\n\t\tz.array(\n\t\t\tz\n\t\t\t\t.object({\n\t\t\t\t\ttext: z.string(),\n\t\t\t\t\tkind: z\n\t\t\t\t\t\t.enum([\n\t\t\t\t\t\t\t\"plain\",\n\t\t\t\t\t\t\t\"keyword\",\n\t\t\t\t\t\t\t\"string\",\n\t\t\t\t\t\t\t\"number\",\n\t\t\t\t\t\t\t\"comment\",\n\t\t\t\t\t\t\t\"function\",\n\t\t\t\t\t\t\t\"type\",\n\t\t\t\t\t\t\t\"property\",\n\t\t\t\t\t\t\t\"punctuation\",\n\t\t\t\t\t\t\t\"operator\",\n\t\t\t\t\t\t])\n\t\t\t\t\t\t.optional(),\n\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t})\n\t\t\t\t.strict(),\n\t\t),\n\t);\n}\n\nexport const codeBlock = /* @__PURE__ */ defineComponent({\n\tname: \"CodeBlock\",\n\tprops: () => ({\n\t\tcode: z.string(),\n\t\tlang: z.enum(CODE_LANGS).default(\"ts\"),\n\t\t/** Your own highlighter's output, one token list per line (overrides `lang`). */\n\t\ttokens: tokenSchema().optional(),\n\t\ttyping: 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\tcps: z.number().positive().optional(),\n\t\t\t\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default(false),\n\t\t/** 1-based lines, as a list or `{ from, to }`; `[]` clears. */\n\t\tfocus: 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\tlines: z.union([\n\t\t\t\t\t\t\tz.array(z.number().int().positive()),\n\t\t\t\t\t\t\tz\n\t\t\t\t\t\t\t\t.object({\n\t\t\t\t\t\t\t\t\tfrom: z.number().int().positive(),\n\t\t\t\t\t\t\t\t\tto: z.number().int().positive(),\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.strict(),\n\t\t\t\t\t\t]),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default([]),\n\t\tdiff: z\n\t\t\t.object({\n\t\t\t\tto: z.string(),\n\t\t\t\tat: z.number().int().min(0),\n\t\t\t\tduration: z.number().int().positive().optional(),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.optional(),\n\t\tlineNumbers: z.boolean().default(true),\n\t\ttabSize: z.number().int().positive().default(2),\n\t\t/** Wrap in an AppFrame window (with a title such as the file name). */\n\t\tframe: z\n\t\t\t.union([z.boolean(), z.object({ title: z.string().optional() }).strict()])\n\t\t\t.default(false),\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 LINE_HEIGHT = 1.5;\n\t\tconst m = 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 = m.width;\n\t\tconst lh = m.lineHeight;\n\t\tconst colors = palette(theme);\n\n\t\tconst before = expandTabs(props.code, props.tabSize).split(\"\\n\");\n\t\tconst after = props.diff\n\t\t\t? expandTabs(props.diff.to, props.tabSize).split(\"\\n\")\n\t\t\t: null;\n\t\tconst tokensOf = (lines: string[], own?: Token[][]) =>\n\t\t\town ?? tokenize(lines.join(\"\\n\"), props.lang);\n\t\tconst beforeTokens = tokensOf(\n\t\t\tbefore,\n\t\t\tprops.tokens as Token[][] | undefined,\n\t\t);\n\t\tconst afterTokens = after ? tokensOf(after) : null;\n\n\t\t// Fixed size up front: the widest line and the most lines, before and after.\n\t\tconst rows = Math.max(before.length, after?.length ?? 0);\n\t\tconst cols = Math.max(\n\t\t\t1,\n\t\t\t...before.map((l) => l.length),\n\t\t\t...(after ?? []).map((l) => l.length),\n\t\t);\n\t\tconst pad = fs;\n\t\tconst gutter = props.lineNumbers ? (String(rows).length + 2) * adv : 0;\n\t\tconst W = pad * 2 + gutter + cols * adv;\n\t\tconst H = pad * 2 + rows * lh;\n\t\tconst rowY = (i: number) => pad + i * lh;\n\t\tconst textX = pad + gutter;\n\n\t\tconst spans = (line: Token[]): TextSpan[] =>\n\t\t\tline.map((t) => ({\n\t\t\t\ttext: t.text,\n\t\t\t\tfill: t.color ?? colors[t.kind ?? \"plain\"],\n\t\t\t}));\n\t\tconst lineNode = (id: string, tokens: Token[], text: string, row: number) =>\n\t\t\thost.text(text || \" \", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: textX,\n\t\t\t\ty: rowY(row),\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tlineHeight: LINE_HEIGHT,\n\t\t\t\tfill: colors.plain,\n\t\t\t\t...(text && { spans: spans(tokens) }),\n\t\t\t});\n\t\tconst numberNode = (id: string, n: number, row: number) =>\n\t\t\thost.text(String(n), {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: rowY(row),\n\t\t\t\twidth: gutter - adv * 1.5,\n\t\t\t\talign: \"end\",\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.muted,\n\t\t\t});\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst rowsLayer: LayoutNode[] = [];\n\t\tconst washes: LayoutNode[] = [];\n\t\tconst opacity = new Map<LayoutNode, Track>();\n\t\tconst fade = (node: LayoutNode, initial = 1) => {\n\t\t\tlet t = opacity.get(node);\n\t\t\tif (!t) {\n\t\t\t\tt = new Track(initial);\n\t\t\t\topacity.set(node, t);\n\t\t\t}\n\t\t\treturn t;\n\t\t};\n\t\tconst animations = new Map<LayoutNode, LayerAnimation>();\n\t\tconst animOf = (node: LayoutNode) => {\n\t\t\tlet a = animations.get(node);\n\t\t\tif (!a) {\n\t\t\t\ta = LayerAnimation.create();\n\t\t\t\tanimations.set(node, a);\n\t\t\t}\n\t\t\treturn a;\n\t\t};\n\n\t\tconst beforeNodes = before.map((text, i) =>\n\t\t\tlineNode(`line-${i}`, beforeTokens[i] ?? [], text, i),\n\t\t);\n\t\tconst beforeNumbers = props.lineNumbers\n\t\t\t? before.map((_, i) => numberNode(`number-${i}`, i + 1, i))\n\t\t\t: [];\n\t\trowsLayer.push(...beforeNumbers, ...beforeNodes);\n\n\t\t// ── Typing ────────────────────────────────────────────────────────\n\t\tconst keystrokes: Array<{ frame: number; line: number }> = [];\n\t\tconst lineCues: Array<{ frame: number; line: number }> = before.map(\n\t\t\t(_, i) => ({ frame: 0, line: i + 1 }),\n\t\t);\n\t\tlet end = 0;\n\t\tif (props.typing !== false) {\n\t\t\tconst o = props.typing === true ? {} : props.typing;\n\t\t\tconst r = ctx.rng(\"typing\");\n\t\t\tlet t = o.at ?? 0;\n\t\t\tconst caretKeys: { x: KeyframeTuple[]; y: KeyframeTuple[] } = {\n\t\t\t\tx: [],\n\t\t\t\ty: [],\n\t\t\t};\n\t\t\tbefore.forEach((text, i) => {\n\t\t\t\tconst indent = text.length - text.trimStart().length;\n\t\t\t\tconst node = beforeNodes[i] as Animatable<LayoutNode>;\n\t\t\t\tconst number = beforeNumbers[i];\n\t\t\t\tlineCues[i] = { frame: Math.round(t), line: i + 1 };\n\t\t\t\tif (number) fade(number, 0).to(Math.round(t), Math.round(t), 1);\n\t\t\t\tcaretKeys.x.push([Math.round(t), textX + indent * adv, \"hold\"]);\n\t\t\t\tcaretKeys.y.push([Math.round(t), rowY(i), \"hold\"]);\n\t\t\t\t// Indentation appears with the line (editors auto-indent).\n\t\t\t\tconst typed = text.slice(indent);\n\t\t\t\tconst frames = typed\n\t\t\t\t\t? typingFrames(typed, o.cps ?? 28, fps, r, Math.round(t))\n\t\t\t\t\t: [];\n\t\t\t\tconst count = (c: number) =>\n\t\t\t\t\ttext.length > 1 && c <= 1 ? c / text.length : c;\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, 0]];\n\t\t\t\tif (indent > 0 || !typed)\n\t\t\t\t\tkeys.push([\n\t\t\t\t\t\tMath.round(t),\n\t\t\t\t\t\tcount(Math.max(indent, typed ? indent : 1)),\n\t\t\t\t\t\t\"hold\",\n\t\t\t\t\t]);\n\t\t\t\tframes.forEach((f, k) => {\n\t\t\t\t\tkeys.push([f, count(indent + k + 1), \"hold\"]);\n\t\t\t\t\tkeystrokes.push({ frame: f, line: i + 1 });\n\t\t\t\t\tcaretKeys.x.push([f, textX + (indent + k + 1) * adv, \"hold\"]);\n\t\t\t\t\tcaretKeys.y.push([f, rowY(i), \"hold\"]);\n\t\t\t\t});\n\t\t\t\tanimOf(node).keys(\"text\" as AnimatableProp, ascending(keys));\n\t\t\t\tt = (frames[frames.length - 1] ?? t) + Math.round(fps * 0.18);\n\t\t\t});\n\t\t\tend = Math.round(t);\n\t\t\tconst caret = host.box({\n\t\t\t\tid: ctx.partId(\"caret\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: textX,\n\t\t\t\ty: rowY(0),\n\t\t\t\twidth: Math.max(2, fs * 0.09),\n\t\t\t\theight: lh,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t});\n\t\t\tconst half = Math.max(1, Math.round(fps * 0.5));\n\t\t\tconst blink: KeyframeTuple[] = [[0, 1]];\n\t\t\tfor (\n\t\t\t\tlet f = end + half, on = false;\n\t\t\t\tf < end + half * 6;\n\t\t\t\tf += half, on = !on\n\t\t\t)\n\t\t\t\tblink.push([f, on ? 1 : 0, \"hold\"]);\n\t\t\tblink.push([end + half * 6, 0, \"hold\"]);\n\t\t\tcaret.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", ascending(caretKeys.x))\n\t\t\t\t\t.keys(\"y\", ascending(caretKeys.y))\n\t\t\t\t\t.keys(\"opacity\", blink),\n\t\t\t);\n\t\t\tparts.caret = caret;\n\t\t\trowsLayer.push(caret);\n\t\t}\n\n\t\t// ── Diff ──────────────────────────────────────────────────────────\n\t\tconst diffCue: Array<{ frame: number; end: number }> = [];\n\t\tlet afterNodes: LayoutNode[] = beforeNodes;\n\t\tif (props.diff && after && afterTokens) {\n\t\t\tconst T = props.diff.at;\n\t\t\tconst D =\n\t\t\t\tprops.diff.duration ??\n\t\t\t\tMath.round(themeFrames(theme, \"slow\", fps) * 1.2);\n\t\t\tconst move = theme.motion.ease.move;\n\t\t\tconst plan = diffLines(before, after);\n\t\t\tafterNodes = new Array<LayoutNode>(after.length);\n\t\t\tconst wash = (\n\t\t\t\tid: string,\n\t\t\t\trow: number,\n\t\t\t\tcolor: string,\n\t\t\t\tfrom: number,\n\t\t\t\tto: number,\n\t\t\t) => {\n\t\t\t\tconst box = host.box({\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: pad * 0.4,\n\t\t\t\t\ty: rowY(row),\n\t\t\t\t\twidth: W - pad * 0.8,\n\t\t\t\t\theight: lh,\n\t\t\t\t\tbackground: tint(color, 0.22),\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tfade(box, 0)\n\t\t\t\t\t.to(from, from + 4, 1)\n\t\t\t\t\t.to(to - 10, to, 0, \"power2.in\");\n\t\t\t\twashes.push(box);\n\t\t\t};\n\t\t\tfor (const [i, j] of plan.kept) {\n\t\t\t\tconst node = beforeNodes[i] as Animatable<LayoutNode>;\n\t\t\t\tlet moving: LayoutNode = node;\n\t\t\t\tif ((before[i] as string) !== (after[j] as string)) {\n\t\t\t\t\t// Re-indented: the new text takes over as it starts to move.\n\t\t\t\t\tconst next = lineNode(\n\t\t\t\t\t\t`line-${i}-to-${j}`,\n\t\t\t\t\t\tafterTokens[j] ?? [],\n\t\t\t\t\t\tafter[j] as string,\n\t\t\t\t\t\ti,\n\t\t\t\t\t);\n\t\t\t\t\tfade(next, 0).to(T, T, 1);\n\t\t\t\t\tfade(node).to(T, T, 0);\n\t\t\t\t\trowsLayer.push(next);\n\t\t\t\t\tmoving = next;\n\t\t\t\t}\n\t\t\t\tif (i !== j)\n\t\t\t\t\tanimOf(moving).keys(\"y\", [\n\t\t\t\t\t\t[T, rowY(i)],\n\t\t\t\t\t\t[T + D, rowY(j), move],\n\t\t\t\t\t]);\n\t\t\t\tafterNodes[j] = moving;\n\t\t\t}\n\t\t\tfor (const i of plan.removed) {\n\t\t\t\tconst node = beforeNodes[i] as LayoutNode;\n\t\t\t\tfade(node).to(T, T + Math.round(D * 0.38), 0, \"power2.in\");\n\t\t\t\tanimOf(node).keys(\"x\", [\n\t\t\t\t\t[T, textX],\n\t\t\t\t\t[T + Math.round(D * 0.38), textX - fs * 0.6, \"power2.in\"],\n\t\t\t\t]);\n\t\t\t\twash(\n\t\t\t\t\t`wash-removed-${i}`,\n\t\t\t\t\ti,\n\t\t\t\t\ttheme.color.negative,\n\t\t\t\t\tT,\n\t\t\t\t\tT + Math.round(D * 0.6),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const j of plan.added) {\n\t\t\t\tconst node = lineNode(\n\t\t\t\t\t`added-${j}`,\n\t\t\t\t\tafterTokens[j] ?? [],\n\t\t\t\t\tafter[j] as string,\n\t\t\t\t\tj,\n\t\t\t\t);\n\t\t\t\tconst from = T + Math.round(D * 0.48);\n\t\t\t\tfade(node, 0).to(from, T + D, 1);\n\t\t\t\tanimOf(node).keys(\"x\", [\n\t\t\t\t\t[from, textX + fs * 0.8],\n\t\t\t\t\t[T + D, textX, theme.motion.ease.enter],\n\t\t\t\t]);\n\t\t\t\twash(\n\t\t\t\t\t`wash-added-${j}`,\n\t\t\t\t\tj,\n\t\t\t\t\ttheme.color.positive,\n\t\t\t\t\tfrom,\n\t\t\t\t\tT + D + Math.round(fps * 0.8),\n\t\t\t\t);\n\t\t\t\trowsLayer.push(node);\n\t\t\t\tafterNodes[j] = node;\n\t\t\t}\n\t\t\tif (props.lineNumbers) {\n\t\t\t\tfor (const n of beforeNumbers) fade(n).to(T, T + 6, 0);\n\t\t\t\tafter.forEach((_, j) => {\n\t\t\t\t\tconst n = numberNode(`number-after-${j}`, j + 1, j);\n\t\t\t\t\tfade(n, 0).to(T + Math.round(D * 0.5), T + D, 1);\n\t\t\t\t\trowsLayer.push(n);\n\t\t\t\t});\n\t\t\t}\n\t\t\tdiffCue.push({ frame: T, end: T + D });\n\t\t\tend = Math.max(end, T + D + Math.round(fps * 0.8));\n\t\t}\n\n\t\t// ── Focus ─────────────────────────────────────────────────────────\n\t\tconst focusCues: Array<{ frame: number; lines: number[] }> = [];\n\t\tconst band = host.box({\n\t\t\tid: ctx.partId(\"band\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad * 0.4,\n\t\t\ty: rowY(0),\n\t\t\twidth: W - pad * 0.8,\n\t\t\theight: lh,\n\t\t\tbackground: tint(theme.color.accent, 0.14),\n\t\t\tborderRadius: theme.radius.sm,\n\t\t});\n\t\tconst bandY = new Track(rowY(0));\n\t\tconst bandH = new Track(lh);\n\t\tconst bandOn = new Track(0);\n\t\tconst glide = Math.max(4, themeFrames(theme, \"base\", fps) / 2);\n\t\tfor (const step of [...props.focus].sort((a, b) => a.at - b.at)) {\n\t\t\tconst afterDiff = props.diff !== undefined && step.at >= props.diff.at;\n\t\t\tconst nodes = afterDiff ? afterNodes : beforeNodes;\n\t\t\tconst set = focusSet(step.lines, nodes.length);\n\t\t\tconst f = step.at;\n\t\t\tnodes.forEach((node, i) => {\n\t\t\t\tfade(node).to(f, f + glide, set.size === 0 || set.has(i) ? 1 : 0.32);\n\t\t\t});\n\t\t\tif (set.size > 0) {\n\t\t\t\tconst lo = Math.min(...set);\n\t\t\t\tconst hi = Math.max(...set);\n\t\t\t\tbandY.to(f, f + glide, rowY(lo) - lh * 0.1, theme.motion.ease.move);\n\t\t\t\tbandH.to(\n\t\t\t\t\tf,\n\t\t\t\t\tf + glide,\n\t\t\t\t\t(hi - lo + 1) * lh + lh * 0.2,\n\t\t\t\t\ttheme.motion.ease.move,\n\t\t\t\t);\n\t\t\t\tbandOn.to(f, f + glide, 1);\n\t\t\t} else bandOn.to(f, f + glide, 0);\n\t\t\tfocusCues.push({ frame: f, lines: [...set].map((i) => i + 1) });\n\t\t\tend = Math.max(end, f + glide);\n\t\t}\n\t\tband.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"y\", bandY.keys())\n\t\t\t\t.keys(\"height\", bandH.keys())\n\t\t\t\t.keys(\"opacity\", bandOn.keys()),\n\t\t);\n\t\tparts.band = band;\n\n\t\tfor (const [node, track] of opacity)\n\t\t\tanimOf(node).keys(\"opacity\", track.keys());\n\t\tfor (const [node, anim] of animations)\n\t\t\t(node as Animatable<LayoutNode>).animate(anim);\n\n\t\tconst inner = host.box({\n\t\t\tid: ctx.partId(\"code\"),\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\toverflow: \"hidden\",\n\t\t\t...(props.frame === false && {\n\t\t\t\tbackground: theme.color.surface,\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t}),\n\t\t\tchildren: [band, ...washes, ...rowsLayer],\n\t\t});\n\t\tparts.code = inner;\n\n\t\tlet root: LayoutNode = inner;\n\t\tlet height = H;\n\t\tif (props.frame !== false) {\n\t\t\tconst o = props.frame === true ? {} : props.frame;\n\t\t\tconst bar = 46 * 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: \"window\",\n\t\t\t\twidth: W,\n\t\t\t\theight: H + bar,\n\t\t\t\tcontent: [inner],\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});\n\t\t\tparts.window = frameNode;\n\t\t\troot = frameNode;\n\t\t\theight = H + bar;\n\t\t}\n\n\t\treturn {\n\t\t\tchildren: [root],\n\t\t\twidth: W,\n\t\t\theight,\n\t\t\tduration: Math.max(1, end),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tkeystrokes,\n\t\t\t\tlines: lineCues,\n\t\t\t\tfocus: focusCues,\n\t\t\t\tdiff: diffCue,\n\t\t\t} as CodeBlockCues,\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;\n}\n\n/**\n * Code that reads like an editor and moves like an explanation: syntax colours,\n * typing, a focus band that walks the lines, and diffs where kept lines glide\n * to their new places.\n */\nexport const CodeBlock = /* @__PURE__ */ bindComponent(codeBlock);\n"
40
+ "content": "/**\n * CodeBlock: code that reads like an editor and moves like an explanation.\n * Syntax colours, typing, a focus band that walks the lines being\n * discussed, and diffs where kept lines glide to their new places while\n * removed lines fold away and new ones arrive.\n */\nimport {\n\ttype AnimatableProp,\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\tthemeFrames,\n\tTrack,\n\ttypingFrames,\n\twithAlpha,\n\tz,\n} from \"framefields/components\";\nimport { appFrame } from \"../app-frame/index.js\";\nimport { diffLines, expandTabs, focusSet } from \"./plan.js\";\nimport {\n\tCODE_LANGS,\n\ttype Token,\n\ttype TokenKind,\n\ttokenize,\n} from \"./tokenize.js\";\n\nexport {\n\tCODE_LANGS,\n\ttype CodeLang,\n\ttype Token,\n\ttype TokenKind,\n} from \"./tokenize.js\";\n\nexport interface CodeBlockCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** Every typed character (`typing`): for key sounds. */\n\tkeystrokes: ReadonlyArray<{ frame: number; line: number }>;\n\t/** Each line's first frame on screen (1-based `line`). */\n\tlines: ReadonlyArray<{ frame: number; line: number }>;\n\t/** Each focus step. */\n\tfocus: ReadonlyArray<{ frame: number; lines: number[] }>;\n\t/** The diff, start to settle. */\n\tdiff: ReadonlyArray<{ frame: number; end: number }>;\n}\n\n/** Token colours from theme tokens, then `theme.syntax` on top. */\nfunction palette(theme: Theme): Record<TokenKind, string> {\n\tconst c = theme.color;\n\tconst light = theme.mode === \"light\";\n\treturn {\n\t\tplain: c.ink,\n\t\tkeyword: c.accent2,\n\t\tstring: c.positive,\n\t\t// The highlight yellow and a bright teal wash out on light grounds.\n\t\tnumber: light ? \"#a65a00\" : c.highlight,\n\t\tcomment: c.muted,\n\t\tfunction: c.accent,\n\t\ttype: light ? \"#0e7c91\" : \"#4cc9e0\",\n\t\tproperty: c.ink,\n\t\tpunctuation: c.muted,\n\t\toperator: c.muted,\n\t\t...theme.syntax,\n\t};\n}\n\nfunction tokenSchema() {\n\treturn z.array(\n\t\tz.array(\n\t\t\tz\n\t\t\t\t.object({\n\t\t\t\t\ttext: z.string(),\n\t\t\t\t\tkind: z\n\t\t\t\t\t\t.enum([\n\t\t\t\t\t\t\t\"plain\",\n\t\t\t\t\t\t\t\"keyword\",\n\t\t\t\t\t\t\t\"string\",\n\t\t\t\t\t\t\t\"number\",\n\t\t\t\t\t\t\t\"comment\",\n\t\t\t\t\t\t\t\"function\",\n\t\t\t\t\t\t\t\"type\",\n\t\t\t\t\t\t\t\"property\",\n\t\t\t\t\t\t\t\"punctuation\",\n\t\t\t\t\t\t\t\"operator\",\n\t\t\t\t\t\t])\n\t\t\t\t\t\t.optional(),\n\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t})\n\t\t\t\t.strict(),\n\t\t),\n\t);\n}\n\nexport const codeBlock = /* @__PURE__ */ defineComponent({\n\tname: \"CodeBlock\",\n\tprops: () => ({\n\t\tcode: z.string(),\n\t\tlang: z.enum(CODE_LANGS).default(\"ts\"),\n\t\t/** Your own highlighter's output, one token list per line (overrides `lang`). */\n\t\ttokens: tokenSchema().optional(),\n\t\ttyping: 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\tcps: z.number().positive().optional(),\n\t\t\t\t\t\tat: z.number().int().min(0).optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.default(false),\n\t\t/** 1-based lines, as a list or `{ from, to }`; `[]` clears. */\n\t\tfocus: 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\tlines: z.union([\n\t\t\t\t\t\t\tz.array(z.number().int().positive()),\n\t\t\t\t\t\t\tz\n\t\t\t\t\t\t\t\t.object({\n\t\t\t\t\t\t\t\t\tfrom: z.number().int().positive(),\n\t\t\t\t\t\t\t\t\tto: z.number().int().positive(),\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.strict(),\n\t\t\t\t\t\t]),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.default([]),\n\t\tdiff: z\n\t\t\t.object({\n\t\t\t\tto: z.string(),\n\t\t\t\tat: z.number().int().min(0),\n\t\t\t\tduration: z.number().int().positive().optional(),\n\t\t\t})\n\t\t\t.strict()\n\t\t\t.optional(),\n\t\tlineNumbers: z.boolean().default(true),\n\t\ttabSize: z.number().int().positive().default(2),\n\t\t/** Wrap in an AppFrame window (with a title such as the file name). */\n\t\tframe: z\n\t\t\t.union([z.boolean(), z.object({ title: z.string().optional() }).strict()])\n\t\t\t.default(false),\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 LINE_HEIGHT = 1.5;\n\t\tconst m = 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 = m.width;\n\t\tconst lh = m.lineHeight;\n\t\tconst colors = palette(theme);\n\n\t\tconst before = expandTabs(props.code, props.tabSize).split(\"\\n\");\n\t\tconst after = props.diff\n\t\t\t? expandTabs(props.diff.to, props.tabSize).split(\"\\n\")\n\t\t\t: null;\n\t\tconst tokensOf = (lines: string[], own?: Token[][]) =>\n\t\t\town ?? tokenize(lines.join(\"\\n\"), props.lang);\n\t\tconst beforeTokens = tokensOf(\n\t\t\tbefore,\n\t\t\tprops.tokens as Token[][] | undefined,\n\t\t);\n\t\tconst afterTokens = after ? tokensOf(after) : null;\n\n\t\t// Fixed size up front: the widest line and the most lines, before and after.\n\t\tconst rows = Math.max(before.length, after?.length ?? 0);\n\t\tconst cols = Math.max(\n\t\t\t1,\n\t\t\t...before.map((l) => l.length),\n\t\t\t...(after ?? []).map((l) => l.length),\n\t\t);\n\t\tconst pad = fs;\n\t\tconst gutter = props.lineNumbers ? (String(rows).length + 2) * adv : 0;\n\t\tconst W = pad * 2 + gutter + cols * adv;\n\t\tconst H = pad * 2 + rows * lh;\n\t\tconst rowY = (i: number) => pad + i * lh;\n\t\tconst textX = pad + gutter;\n\n\t\tconst spans = (line: Token[]): TextSpan[] =>\n\t\t\tline.map((t) => ({\n\t\t\t\ttext: t.text,\n\t\t\t\tfill: t.color ?? colors[t.kind ?? \"plain\"],\n\t\t\t}));\n\t\tconst lineNode = (id: string, tokens: Token[], text: string, row: number) =>\n\t\t\thost.text(text || \" \", {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: textX,\n\t\t\t\ty: rowY(row),\n\t\t\t\tfontFamily,\n\t\t\t\tfontSize: fs,\n\t\t\t\tlineHeight: LINE_HEIGHT,\n\t\t\t\tfill: colors.plain,\n\t\t\t\t...(text && { spans: spans(tokens) }),\n\t\t\t});\n\t\tconst numberNode = (id: string, n: number, row: number) =>\n\t\t\thost.text(String(n), {\n\t\t\t\tid: ctx.partId(id),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: pad,\n\t\t\t\ty: rowY(row),\n\t\t\t\twidth: gutter - adv * 1.5,\n\t\t\t\talign: \"end\",\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.muted,\n\t\t\t});\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst rowsLayer: LayoutNode[] = [];\n\t\tconst washes: LayoutNode[] = [];\n\t\tconst opacity = new Map<LayoutNode, Track>();\n\t\tconst fade = (node: LayoutNode, initial = 1) => {\n\t\t\tlet t = opacity.get(node);\n\t\t\tif (!t) {\n\t\t\t\tt = new Track(initial);\n\t\t\t\topacity.set(node, t);\n\t\t\t}\n\t\t\treturn t;\n\t\t};\n\t\tconst animations = new Map<LayoutNode, LayerAnimation>();\n\t\tconst animOf = (node: LayoutNode) => {\n\t\t\tlet a = animations.get(node);\n\t\t\tif (!a) {\n\t\t\t\ta = LayerAnimation.create();\n\t\t\t\tanimations.set(node, a);\n\t\t\t}\n\t\t\treturn a;\n\t\t};\n\n\t\tconst beforeNodes = before.map((text, i) =>\n\t\t\tlineNode(`line-${i}`, beforeTokens[i] ?? [], text, i),\n\t\t);\n\t\tconst beforeNumbers = props.lineNumbers\n\t\t\t? before.map((_, i) => numberNode(`number-${i}`, i + 1, i))\n\t\t\t: [];\n\t\trowsLayer.push(...beforeNumbers, ...beforeNodes);\n\n\t\t// ── Typing ────────────────────────────────────────────────────────\n\t\tconst keystrokes: Array<{ frame: number; line: number }> = [];\n\t\tconst lineCues: Array<{ frame: number; line: number }> = before.map(\n\t\t\t(_, i) => ({ frame: 0, line: i + 1 }),\n\t\t);\n\t\tlet end = 0;\n\t\tif (props.typing !== false) {\n\t\t\tconst o = props.typing === true ? {} : props.typing;\n\t\t\tconst r = ctx.rng(\"typing\");\n\t\t\tlet t = o.at ?? 0;\n\t\t\tconst caretKeys: { x: KeyframeTuple[]; y: KeyframeTuple[] } = {\n\t\t\t\tx: [],\n\t\t\t\ty: [],\n\t\t\t};\n\t\t\tbefore.forEach((text, i) => {\n\t\t\t\tconst indent = text.length - text.trimStart().length;\n\t\t\t\tconst node = beforeNodes[i] as Animatable<LayoutNode>;\n\t\t\t\tconst number = beforeNumbers[i];\n\t\t\t\tlineCues[i] = { frame: Math.round(t), line: i + 1 };\n\t\t\t\tif (number) fade(number, 0).to(Math.round(t), Math.round(t), 1);\n\t\t\t\tcaretKeys.x.push([Math.round(t), textX + indent * adv, \"hold\"]);\n\t\t\t\tcaretKeys.y.push([Math.round(t), rowY(i), \"hold\"]);\n\t\t\t\t// Indentation appears with the line (editors auto-indent).\n\t\t\t\tconst typed = text.slice(indent);\n\t\t\t\tconst frames = typed\n\t\t\t\t\t? typingFrames(typed, o.cps ?? 28, fps, r, Math.round(t))\n\t\t\t\t\t: [];\n\t\t\t\tconst count = (c: number) =>\n\t\t\t\t\ttext.length > 1 && c <= 1 ? c / text.length : c;\n\t\t\t\tconst keys: KeyframeTuple[] = [[0, 0]];\n\t\t\t\tif (indent > 0 || !typed)\n\t\t\t\t\tkeys.push([\n\t\t\t\t\t\tMath.round(t),\n\t\t\t\t\t\tcount(Math.max(indent, typed ? indent : 1)),\n\t\t\t\t\t\t\"hold\",\n\t\t\t\t\t]);\n\t\t\t\tframes.forEach((f, k) => {\n\t\t\t\t\tkeys.push([f, count(indent + k + 1), \"hold\"]);\n\t\t\t\t\tkeystrokes.push({ frame: f, line: i + 1 });\n\t\t\t\t\tcaretKeys.x.push([f, textX + (indent + k + 1) * adv, \"hold\"]);\n\t\t\t\t\tcaretKeys.y.push([f, rowY(i), \"hold\"]);\n\t\t\t\t});\n\t\t\t\tanimOf(node).keys(\"text\" as AnimatableProp, ascending(keys));\n\t\t\t\tt = (frames[frames.length - 1] ?? t) + Math.round(fps * 0.18);\n\t\t\t});\n\t\t\tend = Math.round(t);\n\t\t\tconst caret = host.box({\n\t\t\t\tid: ctx.partId(\"caret\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: textX,\n\t\t\t\ty: rowY(0),\n\t\t\t\twidth: Math.max(2, fs * 0.09),\n\t\t\t\theight: lh,\n\t\t\t\tbackground: theme.color.accent,\n\t\t\t});\n\t\t\tconst half = Math.max(1, Math.round(fps * 0.5));\n\t\t\tconst blink: KeyframeTuple[] = [[0, 1]];\n\t\t\tfor (\n\t\t\t\tlet f = end + half, on = false;\n\t\t\t\tf < end + half * 6;\n\t\t\t\tf += half, on = !on\n\t\t\t)\n\t\t\t\tblink.push([f, on ? 1 : 0, \"hold\"]);\n\t\t\tblink.push([end + half * 6, 0, \"hold\"]);\n\t\t\tcaret.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"x\", ascending(caretKeys.x))\n\t\t\t\t\t.keys(\"y\", ascending(caretKeys.y))\n\t\t\t\t\t.keys(\"opacity\", blink),\n\t\t\t);\n\t\t\tparts.caret = caret;\n\t\t\trowsLayer.push(caret);\n\t\t}\n\n\t\t// ── Diff ──────────────────────────────────────────────────────────\n\t\tconst diffCue: Array<{ frame: number; end: number }> = [];\n\t\tlet afterNodes: LayoutNode[] = beforeNodes;\n\t\tif (props.diff && after && afterTokens) {\n\t\t\tconst T = props.diff.at;\n\t\t\tconst D =\n\t\t\t\tprops.diff.duration ??\n\t\t\t\tMath.round(themeFrames(theme, \"slow\", fps) * 1.2);\n\t\t\tconst move = theme.motion.ease.move;\n\t\t\tconst plan = diffLines(before, after);\n\t\t\tafterNodes = new Array<LayoutNode>(after.length);\n\t\t\tconst wash = (\n\t\t\t\tid: string,\n\t\t\t\trow: number,\n\t\t\t\tcolor: string,\n\t\t\t\tfrom: number,\n\t\t\t\tto: number,\n\t\t\t) => {\n\t\t\t\tconst box = host.box({\n\t\t\t\t\tid: ctx.partId(id),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: pad * 0.4,\n\t\t\t\t\ty: rowY(row),\n\t\t\t\t\twidth: W - pad * 0.8,\n\t\t\t\t\theight: lh,\n\t\t\t\t\tbackground: withAlpha(color, 0.22),\n\t\t\t\t\tborderRadius: theme.radius.sm,\n\t\t\t\t});\n\t\t\t\tfade(box, 0)\n\t\t\t\t\t.to(from, from + 4, 1)\n\t\t\t\t\t.to(to - 10, to, 0, \"power2.in\");\n\t\t\t\twashes.push(box);\n\t\t\t};\n\t\t\tfor (const [i, j] of plan.kept) {\n\t\t\t\tconst node = beforeNodes[i] as Animatable<LayoutNode>;\n\t\t\t\tlet moving: LayoutNode = node;\n\t\t\t\tif ((before[i] as string) !== (after[j] as string)) {\n\t\t\t\t\t// Re-indented: the new text takes over as it starts to move.\n\t\t\t\t\tconst next = lineNode(\n\t\t\t\t\t\t`line-${i}-to-${j}`,\n\t\t\t\t\t\tafterTokens[j] ?? [],\n\t\t\t\t\t\tafter[j] as string,\n\t\t\t\t\t\ti,\n\t\t\t\t\t);\n\t\t\t\t\tfade(next, 0).to(T, T, 1);\n\t\t\t\t\tfade(node).to(T, T, 0);\n\t\t\t\t\trowsLayer.push(next);\n\t\t\t\t\tmoving = next;\n\t\t\t\t}\n\t\t\t\tif (i !== j)\n\t\t\t\t\tanimOf(moving).keys(\"y\", [\n\t\t\t\t\t\t[T, rowY(i)],\n\t\t\t\t\t\t[T + D, rowY(j), move],\n\t\t\t\t\t]);\n\t\t\t\tafterNodes[j] = moving;\n\t\t\t}\n\t\t\tfor (const i of plan.removed) {\n\t\t\t\tconst node = beforeNodes[i] as LayoutNode;\n\t\t\t\tfade(node).to(T, T + Math.round(D * 0.38), 0, \"power2.in\");\n\t\t\t\tanimOf(node).keys(\"x\", [\n\t\t\t\t\t[T, textX],\n\t\t\t\t\t[T + Math.round(D * 0.38), textX - fs * 0.6, \"power2.in\"],\n\t\t\t\t]);\n\t\t\t\twash(\n\t\t\t\t\t`wash-removed-${i}`,\n\t\t\t\t\ti,\n\t\t\t\t\ttheme.color.negative,\n\t\t\t\t\tT,\n\t\t\t\t\tT + Math.round(D * 0.6),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const j of plan.added) {\n\t\t\t\tconst node = lineNode(\n\t\t\t\t\t`added-${j}`,\n\t\t\t\t\tafterTokens[j] ?? [],\n\t\t\t\t\tafter[j] as string,\n\t\t\t\t\tj,\n\t\t\t\t);\n\t\t\t\tconst from = T + Math.round(D * 0.48);\n\t\t\t\tfade(node, 0).to(from, T + D, 1);\n\t\t\t\tanimOf(node).keys(\"x\", [\n\t\t\t\t\t[from, textX + fs * 0.8],\n\t\t\t\t\t[T + D, textX, theme.motion.ease.enter],\n\t\t\t\t]);\n\t\t\t\twash(\n\t\t\t\t\t`wash-added-${j}`,\n\t\t\t\t\tj,\n\t\t\t\t\ttheme.color.positive,\n\t\t\t\t\tfrom,\n\t\t\t\t\tT + D + Math.round(fps * 0.8),\n\t\t\t\t);\n\t\t\t\trowsLayer.push(node);\n\t\t\t\tafterNodes[j] = node;\n\t\t\t}\n\t\t\tif (props.lineNumbers) {\n\t\t\t\tfor (const n of beforeNumbers) fade(n).to(T, T + 6, 0);\n\t\t\t\tafter.forEach((_, j) => {\n\t\t\t\t\tconst n = numberNode(`number-after-${j}`, j + 1, j);\n\t\t\t\t\tfade(n, 0).to(T + Math.round(D * 0.5), T + D, 1);\n\t\t\t\t\trowsLayer.push(n);\n\t\t\t\t});\n\t\t\t}\n\t\t\tdiffCue.push({ frame: T, end: T + D });\n\t\t\tend = Math.max(end, T + D + Math.round(fps * 0.8));\n\t\t}\n\n\t\t// ── Focus ─────────────────────────────────────────────────────────\n\t\tconst focusCues: Array<{ frame: number; lines: number[] }> = [];\n\t\tconst band = host.box({\n\t\t\tid: ctx.partId(\"band\"),\n\t\t\tposition: \"absolute\",\n\t\t\tx: pad * 0.4,\n\t\t\ty: rowY(0),\n\t\t\twidth: W - pad * 0.8,\n\t\t\theight: lh,\n\t\t\tbackground: withAlpha(theme.color.accent, 0.14),\n\t\t\tborderRadius: theme.radius.sm,\n\t\t});\n\t\tconst bandY = new Track(rowY(0));\n\t\tconst bandH = new Track(lh);\n\t\tconst bandOn = new Track(0);\n\t\tconst glide = Math.max(4, themeFrames(theme, \"base\", fps) / 2);\n\t\tfor (const step of [...props.focus].sort((a, b) => a.at - b.at)) {\n\t\t\tconst afterDiff = props.diff !== undefined && step.at >= props.diff.at;\n\t\t\tconst nodes = afterDiff ? afterNodes : beforeNodes;\n\t\t\tconst set = focusSet(step.lines, nodes.length);\n\t\t\tconst f = step.at;\n\t\t\tnodes.forEach((node, i) => {\n\t\t\t\tfade(node).to(f, f + glide, set.size === 0 || set.has(i) ? 1 : 0.32);\n\t\t\t});\n\t\t\tif (set.size > 0) {\n\t\t\t\tconst lo = Math.min(...set);\n\t\t\t\tconst hi = Math.max(...set);\n\t\t\t\tbandY.to(f, f + glide, rowY(lo) - lh * 0.1, theme.motion.ease.move);\n\t\t\t\tbandH.to(\n\t\t\t\t\tf,\n\t\t\t\t\tf + glide,\n\t\t\t\t\t(hi - lo + 1) * lh + lh * 0.2,\n\t\t\t\t\ttheme.motion.ease.move,\n\t\t\t\t);\n\t\t\t\tbandOn.to(f, f + glide, 1);\n\t\t\t} else bandOn.to(f, f + glide, 0);\n\t\t\tfocusCues.push({ frame: f, lines: [...set].map((i) => i + 1) });\n\t\t\tend = Math.max(end, f + glide);\n\t\t}\n\t\tband.animate(\n\t\t\tLayerAnimation.create()\n\t\t\t\t.keys(\"y\", bandY.keys())\n\t\t\t\t.keys(\"height\", bandH.keys())\n\t\t\t\t.keys(\"opacity\", bandOn.keys()),\n\t\t);\n\t\tparts.band = band;\n\n\t\tfor (const [node, track] of opacity)\n\t\t\tanimOf(node).keys(\"opacity\", track.keys());\n\t\tfor (const [node, anim] of animations)\n\t\t\t(node as Animatable<LayoutNode>).animate(anim);\n\n\t\tconst inner = host.box({\n\t\t\tid: ctx.partId(\"code\"),\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\toverflow: \"hidden\",\n\t\t\t...(props.frame === false && {\n\t\t\t\tbackground: theme.color.surface,\n\t\t\t\tborderRadius: theme.radius.lg,\n\t\t\t}),\n\t\t\tchildren: [band, ...washes, ...rowsLayer],\n\t\t});\n\t\tparts.code = inner;\n\n\t\tlet root: LayoutNode = inner;\n\t\tlet height = H;\n\t\tif (props.frame !== false) {\n\t\t\tconst o = props.frame === true ? {} : props.frame;\n\t\t\tconst bar = 46 * 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: \"window\",\n\t\t\t\twidth: W,\n\t\t\t\theight: H + bar,\n\t\t\t\tcontent: [inner],\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});\n\t\t\tparts.window = frameNode;\n\t\t\troot = frameNode;\n\t\t\theight = H + bar;\n\t\t}\n\n\t\treturn {\n\t\t\tchildren: [root],\n\t\t\twidth: W,\n\t\t\theight,\n\t\t\tduration: Math.max(1, end),\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\tkeystrokes,\n\t\t\t\tlines: lineCues,\n\t\t\t\tfocus: focusCues,\n\t\t\t\tdiff: diffCue,\n\t\t\t} as CodeBlockCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Code that reads like an editor and moves like an explanation: syntax colours,\n * typing, a focus band that walks the lines, and diffs where kept lines glide\n * to their new places.\n */\nexport const CodeBlock = /* @__PURE__ */ bindComponent(codeBlock);\n"
35
41
  },
36
42
  {
37
43
  "path": "code-block/plan.ts",
38
- "content": "/**\n * CodeBlock planning, kept pure: tab expansion, the line diff, focus ranges,\n * and opacity tracks built from timed transitions.\n */\nimport type { KeyframeTuple } from \"framefields\";\n\nexport function expandTabs(code: string, size: number): string {\n\treturn code.replace(/\\t/g, \" \".repeat(size));\n}\n\nexport interface LineDiff {\n\t/** [old index, new index] of lines that stay (in order). */\n\tkept: Array<[number, number]>;\n\tremoved: number[];\n\tadded: number[];\n}\n\n/**\n * Longest common subsequence on trimmed lines: re-indented lines count as\n * kept, so they glide instead of being deleted and re-added.\n */\nexport function diffLines(\n\ta: readonly string[],\n\tb: readonly string[],\n): LineDiff {\n\tconst A = a.map((l) => l.trim());\n\tconst B = b.map((l) => l.trim());\n\tconst n = A.length;\n\tconst m = B.length;\n\tconst lcs = Array.from({ length: n + 1 }, () =>\n\t\tnew Array<number>(m + 1).fill(0),\n\t);\n\tfor (let i = n - 1; i >= 0; i--)\n\t\tfor (let j = m - 1; j >= 0; j--)\n\t\t\t(lcs[i] as number[])[j] =\n\t\t\t\tA[i] === B[j]\n\t\t\t\t\t? ((lcs[i + 1] as number[])[j + 1] as number) + 1\n\t\t\t\t\t: Math.max(\n\t\t\t\t\t\t\t(lcs[i + 1] as number[])[j] as number,\n\t\t\t\t\t\t\t(lcs[i] as number[])[j + 1] as number,\n\t\t\t\t\t\t);\n\tconst kept: Array<[number, number]> = [];\n\tlet i = 0;\n\tlet j = 0;\n\twhile (i < n && j < m) {\n\t\tif (A[i] === B[j]) {\n\t\t\tkept.push([i, j]);\n\t\t\ti++;\n\t\t\tj++;\n\t\t} else if (\n\t\t\t((lcs[i + 1] as number[])[j] as number) >=\n\t\t\t((lcs[i] as number[])[j + 1] as number)\n\t\t)\n\t\t\ti++;\n\t\telse j++;\n\t}\n\tconst keptA = new Set(kept.map(([x]) => x));\n\tconst keptB = new Set(kept.map(([, y]) => y));\n\treturn {\n\t\tkept,\n\t\tremoved: a.map((_, k) => k).filter((k) => !keptA.has(k)),\n\t\tadded: b.map((_, k) => k).filter((k) => !keptB.has(k)),\n\t};\n}\n\n/** 1-based lines, as a list or `{ from, to }` → 0-based indices inside the code. */\nexport function focusSet(\n\tlines: readonly number[] | { from: number; to: number },\n\tcount: number,\n): Set<number> {\n\tconst list =\n\t\t\"from\" in lines\n\t\t\t? Array.from(\n\t\t\t\t\t{ length: Math.max(0, lines.to - lines.from + 1) },\n\t\t\t\t\t(_, k) => lines.from + k,\n\t\t\t\t)\n\t\t\t: lines;\n\treturn new Set(list.map((l) => l - 1).filter((l) => l >= 0 && l < count));\n}\n\n/** A value track built from transitions: each eases from wherever the value is to `to`. */\nexport class Track {\n\tprivate changes: Array<{\n\t\tstart: number;\n\t\tend: number;\n\t\tto: number;\n\t\tease: string;\n\t}> = [];\n\n\tconstructor(private readonly initial: number) {}\n\n\tto(start: number, end: number, value: number, ease = \"power2.out\"): this {\n\t\tthis.changes.push({ start, end: Math.max(start, end), to: value, ease });\n\t\treturn this;\n\t}\n\n\t/** Ascending keyframes; a change that starts mid-transition starts from its target. */\n\tkeys(): KeyframeTuple[] {\n\t\tconst keys: KeyframeTuple[] = [[0, this.initial]];\n\t\tlet value = this.initial;\n\t\tfor (const c of [...this.changes].sort((x, y) => x.start - y.start)) {\n\t\t\tconst last = keys[keys.length - 1] as KeyframeTuple;\n\t\t\tconst start = Math.max(c.start, last[0] + (c.end === c.start ? 1 : 0));\n\t\t\tif (start > last[0]) keys.push([start, value]);\n\t\t\tconst end = Math.max(c.end, start);\n\t\t\tif (end === start) keys[keys.length - 1] = [start, c.to, \"hold\"];\n\t\t\telse keys.push([end, c.to, c.ease]);\n\t\t\tvalue = c.to;\n\t\t}\n\t\t// Drop duplicate frames (a later key wins).\n\t\tconst out: KeyframeTuple[] = [];\n\t\tfor (const k of keys) {\n\t\t\twhile (\n\t\t\t\tout.length > 0 &&\n\t\t\t\t(out[out.length - 1] as KeyframeTuple)[0] >= k[0]\n\t\t\t)\n\t\t\t\tout.pop();\n\t\t\tout.push(k);\n\t\t}\n\t\treturn out;\n\t}\n}\n"
44
+ "content": "/**\n * CodeBlock planning, kept pure: tab expansion, the line diff, focus ranges,\n * and opacity tracks built from timed transitions.\n */\n\nexport function expandTabs(code: string, size: number): string {\n\treturn code.replace(/\\t/g, \" \".repeat(size));\n}\n\nexport interface LineDiff {\n\t/** [old index, new index] of lines that stay (in order). */\n\tkept: Array<[number, number]>;\n\tremoved: number[];\n\tadded: number[];\n}\n\n/**\n * Longest common subsequence on trimmed lines: re-indented lines count as\n * kept, so they glide instead of being deleted and re-added.\n */\nexport function diffLines(\n\ta: readonly string[],\n\tb: readonly string[],\n): LineDiff {\n\tconst A = a.map((l) => l.trim());\n\tconst B = b.map((l) => l.trim());\n\tconst n = A.length;\n\tconst m = B.length;\n\tconst lcs = Array.from({ length: n + 1 }, () =>\n\t\tnew Array<number>(m + 1).fill(0),\n\t);\n\tfor (let i = n - 1; i >= 0; i--)\n\t\tfor (let j = m - 1; j >= 0; j--)\n\t\t\t(lcs[i] as number[])[j] =\n\t\t\t\tA[i] === B[j]\n\t\t\t\t\t? ((lcs[i + 1] as number[])[j + 1] as number) + 1\n\t\t\t\t\t: Math.max(\n\t\t\t\t\t\t\t(lcs[i + 1] as number[])[j] as number,\n\t\t\t\t\t\t\t(lcs[i] as number[])[j + 1] as number,\n\t\t\t\t\t\t);\n\tconst kept: Array<[number, number]> = [];\n\tlet i = 0;\n\tlet j = 0;\n\twhile (i < n && j < m) {\n\t\tif (A[i] === B[j]) {\n\t\t\tkept.push([i, j]);\n\t\t\ti++;\n\t\t\tj++;\n\t\t} else if (\n\t\t\t((lcs[i + 1] as number[])[j] as number) >=\n\t\t\t((lcs[i] as number[])[j + 1] as number)\n\t\t)\n\t\t\ti++;\n\t\telse j++;\n\t}\n\tconst keptA = new Set(kept.map(([x]) => x));\n\tconst keptB = new Set(kept.map(([, y]) => y));\n\treturn {\n\t\tkept,\n\t\tremoved: a.map((_, k) => k).filter((k) => !keptA.has(k)),\n\t\tadded: b.map((_, k) => k).filter((k) => !keptB.has(k)),\n\t};\n}\n\n/** 1-based lines, as a list or `{ from, to }` → 0-based indices inside the code. */\nexport function focusSet(\n\tlines: readonly number[] | { from: number; to: number },\n\tcount: number,\n): Set<number> {\n\tconst list =\n\t\t\"from\" in lines\n\t\t\t? Array.from(\n\t\t\t\t\t{ length: Math.max(0, lines.to - lines.from + 1) },\n\t\t\t\t\t(_, k) => lines.from + k,\n\t\t\t\t)\n\t\t\t: lines;\n\treturn new Set(list.map((l) => l - 1).filter((l) => l >= 0 && l < count));\n}\n"
39
45
  },
40
46
  {
41
47
  "path": "code-block/tokenize.ts",
@@ -23,7 +23,7 @@
23
23
  "files": [
24
24
  {
25
25
  "path": "confetti/index.ts",
26
- "content": "/**\n * Confetti: celebration bursts of paper, ribbons and sparkles that arc up,\n * flutter down and, with a floor, settle. Simulated on the GPU (one particle\n * system per palette colour and origin), deterministic per seed; bursts can\n * come straight from another component's cues.\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 type { LayoutNode } from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\ttype ParticleOptions,\n\tresolveTarget,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport interface ConfettiCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\tbursts: ReadonlyArray<{ frame: number; origin: Vec2 }>;\n}\n\nexport const confetti = /* @__PURE__ */ defineComponent({\n\tname: \"Confetti\",\n\tprops: () => ({\n\t\tbursts: 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\t/** Where it bursts from: a point, rect, node or text. */\n\t\t\t\t\t\torigin: targetSchema(),\n\t\t\t\t\t\t/** Paper pieces in this burst. */\n\t\t\t\t\t\tcount: z.number().int().positive().default(140),\n\t\t\t\t\t\t/** Launch direction, degrees (−90 = up). */\n\t\t\t\t\t\tdirection: z.number().default(-90),\n\t\t\t\t\t\t/** Cone width, degrees. */\n\t\t\t\t\t\tspread: z.number().min(0).max(360).default(70),\n\t\t\t\t\t\t/** Launch strength multiplier. */\n\t\t\t\t\t\tpower: z.number().positive().default(1),\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/** Paper colours. Default: the theme's accents. */\n\t\tpalette: z.array(z.string()).min(1).optional(),\n\t\t/** px/s². */\n\t\tgravity: z.number().min(0).optional(),\n\t\t/** A floor that pieces land on: `true` for the frame's bottom, or a y. */\n\t\tfloor: z.union([z.boolean(), z.number()]).default(false),\n\t\t/** Fine streamers among the paper. */\n\t\tribbons: z.boolean().default(true),\n\t\t/** A flash of glints at each burst. */\n\t\tsparkles: z.boolean().default(true),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? 1920 * s;\n\t\tconst H = ctx.requested.height ?? 1080 * s;\n\t\tconst c = theme.color;\n\t\tconst palette = props.palette ?? [\n\t\t\tc.accent,\n\t\t\tc.accent2,\n\t\t\tc.highlight,\n\t\t\tc.positive,\n\t\t\tc.negative,\n\t\t];\n\t\tconst gravity = props.gravity ?? 1500 * s;\n\t\tconst floor =\n\t\t\tprops.floor === false\n\t\t\t\t? undefined\n\t\t\t\t: props.floor === true\n\t\t\t\t\t? H - 6 * s\n\t\t\t\t\t: props.floor;\n\t\tconst lifetime: [number, number] =\n\t\t\tfloor === undefined ? [2.6, 4] : [5, 6.5];\n\n\t\t// Bursts that share an origin share systems.\n\t\tconst origins = new Map<\n\t\t\tstring,\n\t\t\t{ point: Vec2; bursts: Array<(typeof props.bursts)[number]> }\n\t\t>();\n\t\tconst cues: Array<{ frame: number; origin: Vec2 }> = [];\n\t\tfor (const b of props.bursts) {\n\t\t\tconst point = targetPoint(\n\t\t\t\tresolveTarget(b.origin, { measure: host.measure, fps }),\n\t\t\t\tb.at,\n\t\t\t);\n\t\t\tconst key = `${point[0].toFixed(1)},${point[1].toFixed(1)},${b.direction},${b.spread},${b.power}`;\n\t\t\tconst entry = origins.get(key) ?? { point, bursts: [] };\n\t\t\tentry.bursts.push(b);\n\t\t\torigins.set(key, entry);\n\t\t\tcues.push({ frame: b.at, origin: point });\n\t\t}\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tlet n = 0;\n\t\tconst system = (\n\t\t\tname: string,\n\t\t\to: Omit<ParticleOptions, \"x\" | \"y\" | \"width\" | \"height\" | \"id\" | \"seed\">,\n\t\t) => {\n\t\t\tconst node = host.particles({\n\t\t\t\tid: ctx.partId(name),\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\tseed: ctx.seed * 101 + n++,\n\t\t\t\t...o,\n\t\t\t});\n\t\t\tparts[name] = node;\n\t\t\tchildren.push(node);\n\t\t};\n\n\t\t[...origins.values()].forEach(({ point, bursts }, o) => {\n\t\t\tconst first = bursts[0] as (typeof bursts)[number];\n\t\t\tconst emitter = (share: number) => ({\n\t\t\t\tshape: \"point\" as const,\n\t\t\t\tx: point[0],\n\t\t\t\ty: point[1],\n\t\t\t\trate: 0,\n\t\t\t\tbursts: bursts.map((b) => ({\n\t\t\t\t\tframe: b.at,\n\t\t\t\t\tcount: Math.max(1, Math.round(b.count * share)),\n\t\t\t\t})),\n\t\t\t});\n\t\t\tconst total = Math.max(...bursts.map((b) => b.count));\n\t\t\tconst launch = {\n\t\t\t\tdirection: first.direction,\n\t\t\t\tspread: first.spread,\n\t\t\t\tspeed: [1100 * s * first.power, 2100 * s * first.power] as [\n\t\t\t\t\tnumber,\n\t\t\t\t\tnumber,\n\t\t\t\t],\n\t\t\t\tgravity,\n\t\t\t\tdrag: 1.1,\n\t\t\t\t// Paper wanders as it falls: curl noise reads as flutter.\n\t\t\t\tnoise: { strength: 260 * s, scale: 0.004 / s, speed: 1.4 },\n\t\t\t\tlifetime,\n\t\t\t\t...(floor !== undefined && { floor, bounce: 0.08 }),\n\t\t\t};\n\t\t\tpalette.forEach((color, k) => {\n\t\t\t\tsystem(`paper-${o}-${k}`, {\n\t\t\t\t\t...launch,\n\t\t\t\t\tmax: Math.ceil(total / palette.length) * bursts.length + 8,\n\t\t\t\t\temitter: emitter(1 / palette.length),\n\t\t\t\t\tshape: \"square\",\n\t\t\t\t\tsize: [13 * s, 11 * s],\n\t\t\t\t\tsizeJitter: 0.45,\n\t\t\t\t\tfade: [0.01, floor === undefined ? 0.25 : 0.12],\n\t\t\t\t\tcolor,\n\t\t\t\t});\n\t\t\t});\n\t\t\tif (props.ribbons)\n\t\t\t\tsystem(`ribbons-${o}`, {\n\t\t\t\t\t...launch,\n\t\t\t\t\tmax: Math.ceil(total * 0.2) * bursts.length + 8,\n\t\t\t\t\temitter: emitter(0.2),\n\t\t\t\t\tshape: \"spark\",\n\t\t\t\t\tstretch: 0.03,\n\t\t\t\t\tsize: [5 * s, 4 * s],\n\t\t\t\t\tsizeJitter: 0.3,\n\t\t\t\t\tfade: [0.01, 0.3],\n\t\t\t\t\tcolor: palette.slice(0, 3),\n\t\t\t\t});\n\t\t\tif (props.sparkles)\n\t\t\t\tsystem(`sparkles-${o}`, {\n\t\t\t\t\tmax: 80 * bursts.length,\n\t\t\t\t\temitter: emitter(0.35),\n\t\t\t\t\tdirection: first.direction,\n\t\t\t\t\tspread: Math.min(360, first.spread * 1.6),\n\t\t\t\t\tspeed: [300 * s, 900 * s],\n\t\t\t\t\tgravity: gravity * 0.15,\n\t\t\t\t\tdrag: 2.4,\n\t\t\t\t\tlifetime: [0.35, 0.8],\n\t\t\t\t\tshape: \"soft\",\n\t\t\t\t\tsize: [14 * s, 0],\n\t\t\t\t\tsizeJitter: 0.5,\n\t\t\t\t\tfade: [0, 0.5],\n\t\t\t\t\tblend: \"add\",\n\t\t\t\t\tcolor: [\"#ffffff\", c.highlight],\n\t\t\t\t});\n\t\t});\n\n\t\tconst last = Math.max(...props.bursts.map((b) => b.at));\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last + Math.ceil(lifetime[1] * fps),\n\t\t\torigin: [0, 0],\n\t\t\tcues: { bursts: cues } as ConfettiCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Celebration bursts of paper, ribbons and sparkles on the GPU that arc up,\n * flutter down and can settle on a floor; bursts can follow cues.\n */\nexport const Confetti = /* @__PURE__ */ bindComponent(confetti);\n"
26
+ "content": "/**\n * Confetti: celebration bursts of paper, ribbons and sparkles that arc up,\n * flutter down and, with a floor, settle. Simulated on the GPU (one particle\n * system per palette colour and origin), deterministic per seed; bursts can\n * come straight from another component's cues.\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\ttype ParticleOptions,\n\tresolveTarget,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\n\nexport interface ConfettiCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\tbursts: ReadonlyArray<{ frame: number; origin: Vec2 }>;\n}\n\nexport const confetti = /* @__PURE__ */ defineComponent({\n\tname: \"Confetti\",\n\tprops: () => ({\n\t\tbursts: 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\t/** Where it bursts from: a point, rect, node or text. */\n\t\t\t\t\t\torigin: targetSchema(),\n\t\t\t\t\t\t/** Paper pieces in this burst. */\n\t\t\t\t\t\tcount: z.number().int().positive().default(140),\n\t\t\t\t\t\t/** Launch direction, degrees (−90 = up). */\n\t\t\t\t\t\tdirection: z.number().default(-90),\n\t\t\t\t\t\t/** Cone width, degrees. */\n\t\t\t\t\t\tspread: z.number().min(0).max(360).default(70),\n\t\t\t\t\t\t/** Launch strength multiplier. */\n\t\t\t\t\t\tpower: z.number().positive().default(1),\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/** Paper colours. Default: the theme's accents. */\n\t\tpalette: z.array(z.string()).min(1).optional(),\n\t\t/** px/s². */\n\t\tgravity: z.number().min(0).optional(),\n\t\t/** A floor that pieces land on: `true` for the frame's bottom, or a y. */\n\t\tfloor: z.union([z.boolean(), z.number()]).default(false),\n\t\t/** Fine streamers among the paper. */\n\t\tribbons: z.boolean().default(true),\n\t\t/** A flash of glints at each burst. */\n\t\tsparkles: z.boolean().default(true),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tconst s = theme.scale;\n\t\tconst W = ctx.requested.width ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\n\t\tconst c = theme.color;\n\t\tconst palette = props.palette ?? [\n\t\t\tc.accent,\n\t\t\tc.accent2,\n\t\t\tc.highlight,\n\t\t\tc.positive,\n\t\t\tc.negative,\n\t\t];\n\t\tconst gravity = props.gravity ?? 1500 * s;\n\t\tconst floor =\n\t\t\tprops.floor === false\n\t\t\t\t? undefined\n\t\t\t\t: props.floor === true\n\t\t\t\t\t? H - 6 * s\n\t\t\t\t\t: props.floor;\n\t\tconst lifetime: [number, number] =\n\t\t\tfloor === undefined ? [2.6, 4] : [5, 6.5];\n\n\t\t// Bursts that share an origin share systems.\n\t\tconst origins = new Map<\n\t\t\tstring,\n\t\t\t{ point: Vec2; bursts: Array<(typeof props.bursts)[number]> }\n\t\t>();\n\t\tconst cues: Array<{ frame: number; origin: Vec2 }> = [];\n\t\tfor (const b of props.bursts) {\n\t\t\tconst point = targetPoint(\n\t\t\t\tresolveTarget(b.origin, { measure: host.measure, fps }),\n\t\t\t\tb.at,\n\t\t\t);\n\t\t\tconst key = `${point[0].toFixed(1)},${point[1].toFixed(1)},${b.direction},${b.spread},${b.power}`;\n\t\t\tconst entry = origins.get(key) ?? { point, bursts: [] };\n\t\t\tentry.bursts.push(b);\n\t\t\torigins.set(key, entry);\n\t\t\tcues.push({ frame: b.at, origin: point });\n\t\t}\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\tlet n = 0;\n\t\tconst system = (\n\t\t\tname: string,\n\t\t\to: Omit<ParticleOptions, \"x\" | \"y\" | \"width\" | \"height\" | \"id\" | \"seed\">,\n\t\t) => {\n\t\t\tconst node = host.particles({\n\t\t\t\tid: ctx.partId(name),\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\tseed: ctx.seed * 101 + n++,\n\t\t\t\t...o,\n\t\t\t});\n\t\t\tparts[name] = node;\n\t\t\tchildren.push(node);\n\t\t};\n\n\t\t[...origins.values()].forEach(({ point, bursts }, o) => {\n\t\t\tconst first = bursts[0] as (typeof bursts)[number];\n\t\t\tconst emitter = (share: number) => ({\n\t\t\t\tshape: \"point\" as const,\n\t\t\t\tx: point[0],\n\t\t\t\ty: point[1],\n\t\t\t\trate: 0,\n\t\t\t\tbursts: bursts.map((b) => ({\n\t\t\t\t\tframe: b.at,\n\t\t\t\t\tcount: Math.max(1, Math.round(b.count * share)),\n\t\t\t\t})),\n\t\t\t});\n\t\t\tconst total = Math.max(...bursts.map((b) => b.count));\n\t\t\tconst launch = {\n\t\t\t\tdirection: first.direction,\n\t\t\t\tspread: first.spread,\n\t\t\t\tspeed: [1100 * s * first.power, 2100 * s * first.power] as [\n\t\t\t\t\tnumber,\n\t\t\t\t\tnumber,\n\t\t\t\t],\n\t\t\t\tgravity,\n\t\t\t\tdrag: 1.1,\n\t\t\t\t// Paper wanders as it falls: curl noise reads as flutter.\n\t\t\t\tnoise: { strength: 260 * s, scale: 0.004 / s, speed: 1.4 },\n\t\t\t\tlifetime,\n\t\t\t\t...(floor !== undefined && { floor, bounce: 0.08 }),\n\t\t\t};\n\t\t\tpalette.forEach((color, k) => {\n\t\t\t\tsystem(`paper-${o}-${k}`, {\n\t\t\t\t\t...launch,\n\t\t\t\t\tmax: Math.ceil(total / palette.length) * bursts.length + 8,\n\t\t\t\t\temitter: emitter(1 / palette.length),\n\t\t\t\t\tshape: \"square\",\n\t\t\t\t\tsize: [13 * s, 11 * s],\n\t\t\t\t\tsizeJitter: 0.45,\n\t\t\t\t\tfade: [0.01, floor === undefined ? 0.25 : 0.12],\n\t\t\t\t\tcolor,\n\t\t\t\t});\n\t\t\t});\n\t\t\tif (props.ribbons)\n\t\t\t\tsystem(`ribbons-${o}`, {\n\t\t\t\t\t...launch,\n\t\t\t\t\tmax: Math.ceil(total * 0.2) * bursts.length + 8,\n\t\t\t\t\temitter: emitter(0.2),\n\t\t\t\t\tshape: \"spark\",\n\t\t\t\t\tstretch: 0.03,\n\t\t\t\t\tsize: [5 * s, 4 * s],\n\t\t\t\t\tsizeJitter: 0.3,\n\t\t\t\t\tfade: [0.01, 0.3],\n\t\t\t\t\tcolor: palette.slice(0, 3),\n\t\t\t\t});\n\t\t\tif (props.sparkles)\n\t\t\t\tsystem(`sparkles-${o}`, {\n\t\t\t\t\tmax: 80 * bursts.length,\n\t\t\t\t\temitter: emitter(0.35),\n\t\t\t\t\tdirection: first.direction,\n\t\t\t\t\tspread: Math.min(360, first.spread * 1.6),\n\t\t\t\t\tspeed: [300 * s, 900 * s],\n\t\t\t\t\tgravity: gravity * 0.15,\n\t\t\t\t\tdrag: 2.4,\n\t\t\t\t\tlifetime: [0.35, 0.8],\n\t\t\t\t\tshape: \"soft\",\n\t\t\t\t\tsize: [14 * s, 0],\n\t\t\t\t\tsizeJitter: 0.5,\n\t\t\t\t\tfade: [0, 0.5],\n\t\t\t\t\tblend: \"add\",\n\t\t\t\t\tcolor: [\"#ffffff\", c.highlight],\n\t\t\t\t});\n\t\t});\n\n\t\tconst last = Math.max(...props.bursts.map((b) => b.at));\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: last + Math.ceil(lifetime[1] * fps),\n\t\t\torigin: [0, 0],\n\t\t\tcues: { bursts: cues } as ConfettiCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * Celebration bursts of paper, ribbons and sparkles on the GPU that arc up,\n * flutter down and can settle on a floor; bursts can follow cues.\n */\nexport const Confetti = /* @__PURE__ */ bindComponent(confetti);\n"
27
27
  }
28
28
  ]
29
29
  }
@@ -23,7 +23,8 @@
23
23
  "buttons",
24
24
  "base",
25
25
  "shadow",
26
- "tilt"
26
+ "tilt",
27
+ "overlay"
27
28
  ],
28
29
  "cues": [],
29
30
  "registryDependencies": [],
@@ -22,7 +22,7 @@
22
22
  "files": [
23
23
  {
24
24
  "path": "focus-zoom/index.ts",
25
- "content": "/**\n * FocusZoom: a camera over 2D content that pushes in on what matters and\n * eases back out — the auto-zoom of polished product demos. Give it shots,\n * or let it follow a MousePointer and frame each burst of clicks.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\tresolveTarget,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport {\n\ttype ClickEvent,\n\ttype ShotWindow,\n\tshotsFromClicks,\n\tsolveCamera,\n} from \"./plan.js\";\n\n/** What `follow` needs from a pointer (a MousePointer node has it). */\nexport interface Followable {\n\tpositionAt(frame: number): Vec2;\n\tcues: { clicks: ReadonlyArray<{ frame: number; point: Vec2 }> };\n}\n\n/** A target, or \"full\" for the whole view. */\nfunction shotTargetSchema() {\n\treturn z.union([z.literal(\"full\"), targetSchema()]);\n}\n\nexport interface FocusZoomCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** One per shot: push starts (`frame`), lands (`settled`), lets go (`end`). */\n\tshots: ReadonlyArray<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tend: number;\n\t\tzoom: number;\n\t\tcenter: Vec2;\n\t}>;\n}\n\nexport interface FocusZoomApi {\n\t/** A content point → where it shows on screen (parent coordinates) at a parent frame. */\n\ttoScreen(point: Vec2, frame: number): Vec2;\n\t/** Camera at a parent frame: the content point at the centre, and the zoom. */\n\tcameraAt(frame: number): { center: Vec2; zoom: number };\n}\n\n// Shutter angles. Camera moves scale everything at once, so even a little\n// blur reads strongly: keep the default restrained.\nconst SMEAR = { subtle: 24, standard: 60, heavy: 140 } as const;\n\nexport const focusZoom = /* @__PURE__ */ defineComponent({\n\tname: \"FocusZoom\",\n\tprops: () => ({\n\t\t/** What the camera films, laid out at full size in the view's coordinates. */\n\t\tcontent: z.custom<LayoutNode | LayoutNode[]>(\n\t\t\t(v) => typeof v === \"object\" && v !== null,\n\t\t),\n\t\t/** Manual shots: from `at` until the next shot (or `hold` frames). */\n\t\tshots: 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: shotTargetSchema(),\n\t\t\t\t\t\tzoom: z.number().min(1).optional(),\n\t\t\t\t\t\thold: z.number().int().positive().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** Follow a pointer: push in around each burst of clicks. */\n\t\tfollow: z\n\t\t\t.custom<Followable>(\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 Followable).positionAt === \"function\",\n\t\t\t\t{\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Expected a MousePointer (anything with positionAt and cues.clicks)\",\n\t\t\t\t},\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** Zoom for a shot without its own. */\n\t\tzoom: z.number().min(1).default(1.8),\n\t\tmaxZoom: z.number().min(1).default(2.5),\n\t\t/** Frames the push starts before a click (it always lands before the click). Default: about a second. */\n\t\tlead: z.number().int().min(0).optional(),\n\t\t/** Frames without a click before easing back out. */\n\t\tidle: z.number().int().min(0).default(40),\n\t\t/** 0 = quick camera moves, 1 = slow, soft ones. */\n\t\tsmoothing: z.number().min(0).max(1).default(0.5),\n\t\t/** Space kept around click points / targets. */\n\t\tpadding: z.number().min(0).default(160),\n\t\t/** Never show past the content's edges. */\n\t\tclamp: z.boolean().default(true),\n\t\t/** When zoomed, keep the view inside this region (e.g. the app window). */\n\t\tbounds: shotTargetSchema().optional(),\n\t\t/** Motion blur while the camera moves. */\n\t\tsmear: z\n\t\t\t.union([z.literal(false), z.enum([\"subtle\", \"standard\", \"heavy\"])])\n\t\t\t.default(\"subtle\"),\n\t\t/** Frames to solve. Default: through the last shot plus its return. */\n\t\tlength: 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 W = ctx.requested.width ?? 1920 * theme.scale;\n\t\tconst H = ctx.requested.height ?? 1080 * theme.scale;\n\t\t// By default the push starts early enough to run at its natural pace.\n\t\tconst lead =\n\t\t\tprops.lead ?? Math.round((0.55 + 0.75 * props.smoothing) * fps) + 2;\n\n\t\tconst shots: ShotWindow[] = [];\n\t\tif (props.follow) {\n\t\t\tconst pointer = props.follow;\n\t\t\t// Click frames are in the pointer's parent timeline — the content —\n\t\t\t// which starts with this component.\n\t\t\tconst clicks: ClickEvent[] = pointer.cues.clicks.map((c) => ({\n\t\t\t\tframe: c.frame,\n\t\t\t\tpoint: c.point,\n\t\t\t}));\n\t\t\tshots.push(\n\t\t\t\t...shotsFromClicks(clicks, {\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tlead,\n\t\t\t\t\tidle: props.idle,\n\t\t\t\t\tzoom: props.zoom,\n\t\t\t\t\tmaxZoom: props.maxZoom,\n\t\t\t\t\tpadding: props.padding,\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\t\tconst manual = [...(props.shots ?? [])].sort((a, b) => a.at - b.at);\n\t\tmanual.forEach((shot, i) => {\n\t\t\tconst next = manual[i + 1];\n\t\t\tconst end =\n\t\t\t\tshot.hold !== undefined\n\t\t\t\t\t? shot.at + shot.hold\n\t\t\t\t\t: (next?.at ?? shot.at + Math.round(fps * 3));\n\t\t\tif (shot.target === \"full\") {\n\t\t\t\tshots.push({ start: shot.at, end, zoom: 1, center: [W / 2, H / 2] });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst resolved = resolveTarget(shot.target, {\n\t\t\t\tmeasure: host.measure,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tif (resolved.kind === \"dynamic\") {\n\t\t\t\tshots.push({\n\t\t\t\t\tstart: shot.at,\n\t\t\t\t\tend,\n\t\t\t\t\tzoom: shot.zoom ?? props.zoom,\n\t\t\t\t\tcenter: (f) => targetPoint(resolved, f),\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst r = boundsOf(resolved.rects);\n\t\t\tconst fit = Math.min(\n\t\t\t\tW / (r.width + props.padding * 2),\n\t\t\t\tH / (r.height + props.padding * 2),\n\t\t\t);\n\t\t\tconst zoom = Math.min(\n\t\t\t\tprops.maxZoom,\n\t\t\t\tshot.zoom ?? Math.max(1, Math.min(props.zoom, fit)),\n\t\t\t);\n\t\t\tshots.push({\n\t\t\t\tstart: shot.at,\n\t\t\t\tend,\n\t\t\t\tzoom,\n\t\t\t\tcenter: [r.x + r.width / 2, r.y + r.height / 2],\n\t\t\t});\n\t\t});\n\t\tshots.sort((a, b) => a.start - b.start);\n\n\t\tconst lastEnd = Math.max(0, ...shots.map((s) => s.end));\n\t\tconst length = props.length ?? lastEnd + Math.round(fps * 1.5) + 1;\n\t\tlet bounds:\n\t\t\t| { x: number; y: number; width: number; height: number }\n\t\t\t| undefined;\n\t\tif (props.bounds !== undefined && props.bounds !== \"full\") {\n\t\t\tconst resolved = resolveTarget(props.bounds, {\n\t\t\t\tmeasure: host.measure,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tif (resolved.kind !== \"static\")\n\t\t\t\tthrow new Error(\"FocusZoom: `bounds` must be a static target.\");\n\t\t\tbounds = boundsOf(resolved.rects);\n\t\t}\n\t\tconst plan = solveCamera({\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tlength,\n\t\t\tfps,\n\t\t\tshots,\n\t\t\tsmoothing: props.smoothing,\n\t\t\tclamp: props.clamp,\n\t\t\tbounds,\n\t\t});\n\n\t\tconst camera = host.box({\n\t\t\tid: ctx.partId(\"camera\"),\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\tanchorX: 0,\n\t\t\tanchorY: 0,\n\t\t\t...(props.smear !== false && { motionBlurShutter: SMEAR[props.smear] }),\n\t\t\tchildren: Array.isArray(props.content) ? props.content : [props.content],\n\t\t});\n\t\tcamera.animate(\n\t\t\tLayerAnimation.keys(\"x\", bakeSamples(plan.x, 0, { tolerance: 0.15 })),\n\t\t\tLayerAnimation.keys(\"y\", bakeSamples(plan.y, 0, { tolerance: 0.15 })),\n\t\t\tLayerAnimation.keys(\n\t\t\t\t\"scale\",\n\t\t\t\tbakeSamples(plan.scale, 0, { tolerance: 0.0004 }),\n\t\t\t),\n\t\t);\n\t\tconst clip = host.box({\n\t\t\tid: ctx.partId(\"clip\"),\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\toverflow: \"hidden\",\n\t\t\tchildren: [camera],\n\t\t});\n\n\t\tconst stateAt = (local: number) =>\n\t\t\tplan.states[\n\t\t\t\tMath.max(0, Math.min(plan.states.length - 1, Math.round(local)))\n\t\t\t] ?? { cx: W / 2, cy: H / 2, zoom: 1 };\n\t\treturn {\n\t\t\tchildren: [clip],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: null,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { shots: plan.shots } as FocusZoomCues,\n\t\t\tparts: { camera, clip },\n\t\t\tapi: (placement): FocusZoomApi => ({\n\t\t\t\ttoScreen(point, frame) {\n\t\t\t\t\tconst s = stateAt(frame - placement.start);\n\t\t\t\t\treturn [\n\t\t\t\t\t\tplacement.x + W / 2 + (point[0] - s.cx) * s.zoom,\n\t\t\t\t\t\tplacement.y + H / 2 + (point[1] - s.cy) * s.zoom,\n\t\t\t\t\t];\n\t\t\t\t},\n\t\t\t\tcameraAt(frame) {\n\t\t\t\t\tconst s = stateAt(frame - placement.start);\n\t\t\t\t\treturn { center: [s.cx, s.cy], zoom: s.zoom };\n\t\t\t\t},\n\t\t\t}),\n\t\t};\n\t},\n});\n\n/**\n * A camera over 2D content that pushes in on each burst of pointer clicks (or\n * on given shots) and eases back out, clamped to the content.\n */\nexport const FocusZoom = /* @__PURE__ */ bindComponent(focusZoom);\n"
25
+ "content": "/**\n * FocusZoom: a camera over 2D content that pushes in on what matters and\n * eases back out — the auto-zoom of polished product demos. Give it shots,\n * or let it follow a MousePointer and frame each burst of clicks.\n */\nimport { LayerAnimation, type LayoutNode } from \"framefields\";\nimport {\n\tbakeSamples,\n\tbindComponent,\n\tboundsOf,\n\tdefineComponent,\n\tresolveTarget,\n\ttargetPoint,\n\ttargetSchema,\n\ttype Vec2,\n\tz,\n} from \"framefields/components\";\nimport {\n\ttype ClickEvent,\n\ttype ShotWindow,\n\tshotsFromClicks,\n\tsolveCamera,\n} from \"./plan.js\";\n\n/** What `follow` needs from a pointer (a MousePointer node has it). */\nexport interface Followable {\n\tpositionAt(frame: number): Vec2;\n\tcues: { clicks: ReadonlyArray<{ frame: number; point: Vec2 }> };\n}\n\n/** A target, or \"full\" for the whole view. */\nfunction shotTargetSchema() {\n\treturn z.union([z.literal(\"full\"), targetSchema()]);\n}\n\nexport interface FocusZoomCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** One per shot: push starts (`frame`), lands (`settled`), lets go (`end`). */\n\tshots: ReadonlyArray<{\n\t\tframe: number;\n\t\tsettled: number;\n\t\tend: number;\n\t\tzoom: number;\n\t\tcenter: Vec2;\n\t}>;\n}\n\nexport interface FocusZoomApi {\n\t/** A content point → where it shows on screen (parent coordinates) at a parent frame. */\n\ttoScreen(point: Vec2, frame: number): Vec2;\n\t/** Camera at a parent frame: the content point at the centre, and the zoom. */\n\tcameraAt(frame: number): { center: Vec2; zoom: number };\n}\n\n// Shutter angles. Camera moves scale everything at once, so even a little\n// blur reads strongly: keep the default restrained.\nconst SMEAR = { subtle: 24, standard: 60, heavy: 140 } as const;\n\nexport const focusZoom = /* @__PURE__ */ defineComponent({\n\tname: \"FocusZoom\",\n\tprops: () => ({\n\t\t/** What the camera films, laid out at full size in the view's coordinates. */\n\t\tcontent: z.custom<LayoutNode | LayoutNode[]>(\n\t\t\t(v) => typeof v === \"object\" && v !== null,\n\t\t),\n\t\t/** Manual shots: from `at` until the next shot (or `hold` frames). */\n\t\tshots: 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: shotTargetSchema(),\n\t\t\t\t\t\tzoom: z.number().min(1).optional(),\n\t\t\t\t\t\thold: z.number().int().positive().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** Follow a pointer: push in around each burst of clicks. */\n\t\tfollow: z\n\t\t\t.custom<Followable>(\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 Followable).positionAt === \"function\",\n\t\t\t\t{\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Expected a MousePointer (anything with positionAt and cues.clicks)\",\n\t\t\t\t},\n\t\t\t)\n\t\t\t.optional(),\n\t\t/** Zoom for a shot without its own. */\n\t\tzoom: z.number().min(1).default(1.8),\n\t\tmaxZoom: z.number().min(1).default(2.5),\n\t\t/** Frames the push starts before a click (it always lands before the click). Default: about a second. */\n\t\tlead: z.number().int().min(0).optional(),\n\t\t/** Frames without a click before easing back out. */\n\t\tidle: z.number().int().min(0).default(40),\n\t\t/** 0 = quick camera moves, 1 = slow, soft ones. */\n\t\tsmoothing: z.number().min(0).max(1).default(0.5),\n\t\t/** Space kept around click points / targets. */\n\t\tpadding: z.number().min(0).default(160),\n\t\t/** Never show past the content's edges. */\n\t\tclamp: z.boolean().default(true),\n\t\t/** When zoomed, keep the view inside this region (e.g. the app window). */\n\t\tbounds: shotTargetSchema().optional(),\n\t\t/** Motion blur while the camera moves. */\n\t\tsmear: z\n\t\t\t.union([z.literal(false), z.enum([\"subtle\", \"standard\", \"heavy\"])])\n\t\t\t.default(\"subtle\"),\n\t\t/** Frames to solve. Default: through the last shot plus its return. */\n\t\tlength: z.number().int().positive().optional(),\n\t}),\n\tdefaults: { enter: false, exit: false },\n\tbuild(ctx, props) {\n\t\tconst { host, fps } = ctx;\n\t\tconst W = ctx.requested.width ?? ctx.canvas.width;\n\t\tconst H = ctx.requested.height ?? ctx.canvas.height;\n\t\t// By default the push starts early enough to run at its natural pace.\n\t\tconst lead =\n\t\t\tprops.lead ?? Math.round((0.55 + 0.75 * props.smoothing) * fps) + 2;\n\n\t\tconst shots: ShotWindow[] = [];\n\t\tif (props.follow) {\n\t\t\tconst pointer = props.follow;\n\t\t\t// Click frames are in the pointer's parent timeline — the content —\n\t\t\t// which starts with this component.\n\t\t\tconst clicks: ClickEvent[] = pointer.cues.clicks.map((c) => ({\n\t\t\t\tframe: c.frame,\n\t\t\t\tpoint: c.point,\n\t\t\t}));\n\t\t\tshots.push(\n\t\t\t\t...shotsFromClicks(clicks, {\n\t\t\t\t\twidth: W,\n\t\t\t\t\theight: H,\n\t\t\t\t\tlead,\n\t\t\t\t\tidle: props.idle,\n\t\t\t\t\tzoom: props.zoom,\n\t\t\t\t\tmaxZoom: props.maxZoom,\n\t\t\t\t\tpadding: props.padding,\n\t\t\t\t}),\n\t\t\t);\n\t\t}\n\t\tconst manual = [...(props.shots ?? [])].sort((a, b) => a.at - b.at);\n\t\tmanual.forEach((shot, i) => {\n\t\t\tconst next = manual[i + 1];\n\t\t\tconst end =\n\t\t\t\tshot.hold !== undefined\n\t\t\t\t\t? shot.at + shot.hold\n\t\t\t\t\t: (next?.at ?? shot.at + Math.round(fps * 3));\n\t\t\tif (shot.target === \"full\") {\n\t\t\t\tshots.push({ start: shot.at, end, zoom: 1, center: [W / 2, H / 2] });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst resolved = resolveTarget(shot.target, {\n\t\t\t\tmeasure: host.measure,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tif (resolved.kind === \"dynamic\") {\n\t\t\t\tshots.push({\n\t\t\t\t\tstart: shot.at,\n\t\t\t\t\tend,\n\t\t\t\t\tzoom: shot.zoom ?? props.zoom,\n\t\t\t\t\tcenter: (f) => targetPoint(resolved, f),\n\t\t\t\t});\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst r = boundsOf(resolved.rects);\n\t\t\tconst fit = Math.min(\n\t\t\t\tW / (r.width + props.padding * 2),\n\t\t\t\tH / (r.height + props.padding * 2),\n\t\t\t);\n\t\t\tconst zoom = Math.min(\n\t\t\t\tprops.maxZoom,\n\t\t\t\tshot.zoom ?? Math.max(1, Math.min(props.zoom, fit)),\n\t\t\t);\n\t\t\tshots.push({\n\t\t\t\tstart: shot.at,\n\t\t\t\tend,\n\t\t\t\tzoom,\n\t\t\t\tcenter: [r.x + r.width / 2, r.y + r.height / 2],\n\t\t\t});\n\t\t});\n\t\tshots.sort((a, b) => a.start - b.start);\n\n\t\tconst lastEnd = Math.max(0, ...shots.map((s) => s.end));\n\t\tconst length = props.length ?? lastEnd + Math.round(fps * 1.5) + 1;\n\t\tlet bounds:\n\t\t\t| { x: number; y: number; width: number; height: number }\n\t\t\t| undefined;\n\t\tif (props.bounds !== undefined && props.bounds !== \"full\") {\n\t\t\tconst resolved = resolveTarget(props.bounds, {\n\t\t\t\tmeasure: host.measure,\n\t\t\t\tfps,\n\t\t\t});\n\t\t\tif (resolved.kind !== \"static\")\n\t\t\t\tthrow new Error(\"FocusZoom: `bounds` must be a static target.\");\n\t\t\tbounds = boundsOf(resolved.rects);\n\t\t}\n\t\tconst plan = solveCamera({\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tlength,\n\t\t\tfps,\n\t\t\tshots,\n\t\t\tsmoothing: props.smoothing,\n\t\t\tclamp: props.clamp,\n\t\t\tbounds,\n\t\t});\n\n\t\tconst camera = host.box({\n\t\t\tid: ctx.partId(\"camera\"),\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\tanchorX: 0,\n\t\t\tanchorY: 0,\n\t\t\t...(props.smear !== false && { motionBlurShutter: SMEAR[props.smear] }),\n\t\t\tchildren: Array.isArray(props.content) ? props.content : [props.content],\n\t\t});\n\t\tcamera.animate(\n\t\t\tLayerAnimation.keys(\"x\", bakeSamples(plan.x, 0, { tolerance: 0.15 })),\n\t\t\tLayerAnimation.keys(\"y\", bakeSamples(plan.y, 0, { tolerance: 0.15 })),\n\t\t\tLayerAnimation.keys(\n\t\t\t\t\"scale\",\n\t\t\t\tbakeSamples(plan.scale, 0, { tolerance: 0.0004 }),\n\t\t\t),\n\t\t);\n\t\tconst clip = host.box({\n\t\t\tid: ctx.partId(\"clip\"),\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\toverflow: \"hidden\",\n\t\t\tchildren: [camera],\n\t\t});\n\n\t\tconst stateAt = (local: number) =>\n\t\t\tplan.states[\n\t\t\t\tMath.max(0, Math.min(plan.states.length - 1, Math.round(local)))\n\t\t\t] ?? { cx: W / 2, cy: H / 2, zoom: 1 };\n\t\treturn {\n\t\t\tchildren: [clip],\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: null,\n\t\t\torigin: [0, 0],\n\t\t\tcues: { shots: plan.shots } as FocusZoomCues,\n\t\t\tparts: { camera, clip },\n\t\t\tapi: (placement): FocusZoomApi => ({\n\t\t\t\ttoScreen(point, frame) {\n\t\t\t\t\tconst s = stateAt(frame - placement.start);\n\t\t\t\t\treturn [\n\t\t\t\t\t\tplacement.x + W / 2 + (point[0] - s.cx) * s.zoom,\n\t\t\t\t\t\tplacement.y + H / 2 + (point[1] - s.cy) * s.zoom,\n\t\t\t\t\t];\n\t\t\t\t},\n\t\t\t\tcameraAt(frame) {\n\t\t\t\t\tconst s = stateAt(frame - placement.start);\n\t\t\t\t\treturn { center: [s.cx, s.cy], zoom: s.zoom };\n\t\t\t\t},\n\t\t\t}),\n\t\t};\n\t},\n});\n\n/**\n * A camera over 2D content that pushes in on each burst of pointer clicks (or\n * on given shots) and eases back out, clamped to the content.\n */\nexport const FocusZoom = /* @__PURE__ */ bindComponent(focusZoom);\n"
26
26
  },
27
27
  {
28
28
  "path": "focus-zoom/plan.ts",
@@ -31,7 +31,7 @@
31
31
  "files": [
32
32
  {
33
33
  "path": "logo-reveal/index.ts",
34
- "content": "/**\n * LogoReveal: the end-card lockup. A mark (image, SVG or path data) and a\n * wordmark arrive as one move, then a light sweeps across the name. Path\n * marks can draw themselves on; names can be extruded in 3D.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tdrawOnKeys,\n\tglyphAnimator,\n\trendererChars,\n\tscheduleExpression,\n\tspringKeys,\n\tthemeFrames,\n\ttransformPathData,\n\tz,\n} from \"framefields/components\";\n\nexport const LOGO_STYLES = [\"slide\", \"pop\", \"draw\", \"rise\"] as const;\nexport type LogoStyle = (typeof LOGO_STYLES)[number];\nexport const LOGO_LAYOUTS = [\"row\", \"stack\"] as const;\nexport type LogoLayout = (typeof LOGO_LAYOUTS)[number];\n\nexport interface LogoRevealCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The mark lands. */\n\thit: ReadonlyArray<{ frame: number }>;\n\t/** The lockup is complete. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** The glint starts (empty without `glint`). */\n\tglint: ReadonlyArray<{ frame: number }>;\n}\n\nconst POP = { stiffness: 260, damping: 14 };\nconst SWING = { stiffness: 120, damping: 14 };\n\nexport const logoReveal = /* @__PURE__ */ defineComponent({\n\tname: \"LogoReveal\",\n\tprops: () => ({\n\t\t/** An image / SVG file, or path data in its own viewBox. */\n\t\tmark: z\n\t\t\t.union([\n\t\t\t\tz.string().min(1),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\td: z.string().min(1),\n\t\t\t\t\t\tviewBox: z.tuple([z.number().positive(), z.number().positive()]),\n\t\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.optional(),\n\t\tname: z.string().min(1).optional(),\n\t\ttagline: z.string().min(1).optional(),\n\t\tlayout: z.enum(LOGO_LAYOUTS).default(\"row\"),\n\t\tstyle: z.enum(LOGO_STYLES).default(\"slide\"),\n\t\tglint: z.boolean().default(true),\n\t\t/** Above 0, the name is extruded text this deep (px). */\n\t\tdepth: z.number().min(0).default(0),\n\t\tmaterial: z.enum([\"lit\", \"chrome\", \"glass\"]).default(\"lit\"),\n\t\tmarkSize: z.number().positive().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tgap: z.number().min(0).optional(),\n\t\tcolor: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontWeight: z.number().int().min(100).max(900).default(800),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 60 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tif (!props.mark && !props.name)\n\t\t\tthrow new Error(\"LogoReveal: give a `mark`, a `name`, or both.\");\n\t\tconst s = theme.scale;\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst ink = props.color ?? theme.color.ink;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst ns = props.nameSize ?? 120 * s;\n\t\tconst m = props.mark ? (props.markSize ?? 150 * s) : 0;\n\t\tconst gap = props.mark && props.name ? (props.gap ?? ns * 0.32) : 0;\n\t\tconst stack = props.layout === \"stack\";\n\t\tconst solid = props.depth > 0;\n\t\tconst pathMark = typeof props.mark === \"object\" ? props.mark : null;\n\t\t// Image marks can't draw on: they pop.\n\t\tconst style: LogoStyle =\n\t\t\tprops.style === \"draw\" && !pathMark ? \"pop\" : props.style;\n\n\t\t// ── Geometry ─────────────────────────────────────────────────────\n\t\tconst nameM = props.name\n\t\t\t? host.measure(props.name, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t})\n\t\t\t: { width: 0, height: 0 };\n\t\tconst tagSize = Math.max(18 * s, ns * 0.26);\n\t\tconst tagM = props.tagline\n\t\t\t? host.measure(props.tagline, {\n\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\tfontSize: tagSize,\n\t\t\t\t})\n\t\t\t: { width: 0, height: 0 };\n\t\tconst tagGap = props.tagline ? ns * 0.12 : 0;\n\t\tconst textW = Math.max(nameM.width, tagM.width);\n\t\tconst textH = nameM.height + tagGap + tagM.height;\n\t\tconst W = stack ? Math.max(m, textW) : m + gap + textW;\n\t\tconst H = stack ? m + gap + textH : Math.max(m, textH);\n\t\tconst markAt = stack ? { x: (W - m) / 2, y: 0 } : { x: 0, y: (H - m) / 2 };\n\t\tconst nameAt = stack\n\t\t\t? { x: (W - nameM.width) / 2, y: m + gap }\n\t\t\t: { x: m + gap, y: (H - textH) / 2 };\n\t\tconst tagAt = stack\n\t\t\t? { x: (W - tagM.width) / 2, y: nameAt.y + nameM.height + tagGap }\n\t\t\t: { x: nameAt.x, y: nameAt.y + nameM.height + tagGap };\n\n\t\t// ── Timeline ─────────────────────────────────────────────────────\n\t\tconst drawFrames = Math.round(base * 2.2);\n\t\tconst glide = Math.round(base * 1.5);\n\t\tconst glideAt = style === \"slide\" ? Math.round(base * 1.1) : 0;\n\t\tconst hit = !props.mark\n\t\t\t? 0\n\t\t\t: style === \"draw\"\n\t\t\t\t? drawFrames\n\t\t\t\t: style === \"rise\"\n\t\t\t\t\t? base\n\t\t\t\t\t: Math.round(base * 0.75);\n\t\tconst nameStart = !props.mark\n\t\t\t? 0\n\t\t\t: style === \"slide\"\n\t\t\t\t? glideAt\n\t\t\t\t: style === \"draw\"\n\t\t\t\t\t? Math.round(drawFrames * 0.6)\n\t\t\t\t\t: style === \"rise\"\n\t\t\t\t\t\t? Math.round(base * 0.35)\n\t\t\t\t\t\t: Math.round(base * 0.55);\n\t\tconst chars = props.name?.length ?? 0;\n\t\tconst stagger = Math.max(1, Math.round(fps / 30) * 1.5);\n\t\tconst letters = style === \"pop\" || style === \"draw\";\n\t\tconst nameDone = !props.name\n\t\t\t? hit\n\t\t\t: solid\n\t\t\t\t? nameStart + base * 2\n\t\t\t\t: style === \"slide\"\n\t\t\t\t\t? glideAt + glide\n\t\t\t\t\t: letters\n\t\t\t\t\t\t? nameStart + Math.round(stagger * Math.max(0, chars - 1)) + base\n\t\t\t\t\t\t: nameStart + base;\n\t\tconst tagStart = Math.max(nameStart, nameDone - Math.round(base * 0.4));\n\t\tconst revealed = Math.max(\n\t\t\thit,\n\t\t\tnameDone,\n\t\t\tprops.tagline ? tagStart + base : 0,\n\t\t);\n\t\tconst glintAt = revealed + Math.round(base * 0.3);\n\t\tconst glintFrames = Math.round(base * 1.8);\n\t\tconst glints = props.glint && props.name && !solid;\n\t\tconst end = glints ? glintAt + glintFrames : revealed;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\t// Drawn last: the name wipes out from behind it.\n\t\tlet markNode: LayoutNode | undefined;\n\n\t\t// ── Mark ─────────────────────────────────────────────────────────\n\t\tif (props.mark) {\n\t\t\tconst markColor = pathMark?.color ?? accent;\n\t\t\tconst markKids: LayoutNode[] = [];\n\t\t\tif (pathMark) {\n\t\t\t\tconst [vw, vh] = pathMark.viewBox;\n\t\t\t\tconst k = m / Math.max(vw, vh);\n\t\t\t\tconst d = transformPathData(\n\t\t\t\t\tpathMark.d,\n\t\t\t\t\tk,\n\t\t\t\t\t(m - vw * k) / 2,\n\t\t\t\t\t(m - vh * k) / 2,\n\t\t\t\t);\n\t\t\t\tconst fill = host.shape(\"path\", {\n\t\t\t\t\tid: ctx.partId(\"mark-fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\td,\n\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\tfillColor: markColor,\n\t\t\t\t});\n\t\t\t\tparts[\"mark-fill\"] = fill;\n\t\t\t\tif (style === \"draw\") {\n\t\t\t\t\tconst outline = host.shape(\"path\", {\n\t\t\t\t\t\tid: ctx.partId(\"mark-outline\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: m,\n\t\t\t\t\t\theight: m,\n\t\t\t\t\t\td,\n\t\t\t\t\t\tfillType: \"none\",\n\t\t\t\t\t\tstrokeColor: markColor,\n\t\t\t\t\t\tstrokeWidth: Math.max(2, 3 * s),\n\t\t\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\t\t});\n\t\t\t\t\toutline.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"trimEnd\", drawOnKeys(0, drawFrames, \"power2.inOut\"))\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[drawFrames, 1],\n\t\t\t\t\t\t\t\t[drawFrames + base, 0, \"power2.out\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tfill.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t\t[Math.round(drawFrames * 0.7), 0],\n\t\t\t\t\t\t\t[drawFrames, 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tmarkKids.push(fill, outline);\n\t\t\t\t\tparts[\"mark-outline\"] = outline;\n\t\t\t\t} else markKids.push(fill);\n\t\t\t} else {\n\t\t\t\tconst image = host.media(props.mark as string, {\n\t\t\t\t\tid: ctx.partId(\"mark-image\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\tfit: \"contain\",\n\t\t\t\t});\n\t\t\t\tmarkKids.push(image);\n\t\t\t\tparts[\"mark-image\"] = image;\n\t\t\t}\n\t\t\tconst centred = stack\n\t\t\t\t? { x: markAt.x, y: (H - m) / 2 }\n\t\t\t\t: { x: (W - m) / 2, y: markAt.y };\n\t\t\tconst slide = style === \"slide\" && !!props.name;\n\t\t\tconst mark = host.box({\n\t\t\t\tid: ctx.partId(\"mark\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: slide ? centred.x : markAt.x,\n\t\t\t\ty: slide ? centred.y : markAt.y,\n\t\t\t\twidth: m,\n\t\t\t\theight: m,\n\t\t\t\tchildren: markKids,\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create();\n\t\t\tconst scale: KeyframeTuple[] = [];\n\t\t\tif (style === \"slide\" || style === \"pop\")\n\t\t\t\tscale.push(...(springKeys(0, 0, 1, fps, POP) as KeyframeTuple[]));\n\t\t\tif (glints) {\n\t\t\t\t// A small bump as the light passes.\n\t\t\t\tif (scale.length === 0) scale.push([0, 1]);\n\t\t\t\tscale.push(\n\t\t\t\t\t[glintAt, 1],\n\t\t\t\t\t[glintAt + Math.round(base * 0.4), 1.06, \"power2.out\"],\n\t\t\t\t\t[glintAt + Math.round(base * 1.2), 1, \"power2.inOut\"],\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (scale.length > 0) anim.keys(\"scale\", scale);\n\t\t\tif (slide) {\n\t\t\t\tanim.keys(stack ? \"y\" : \"x\", [\n\t\t\t\t\t[glideAt, stack ? centred.y : centred.x],\n\t\t\t\t\t[glideAt + glide, stack ? markAt.y : markAt.x, ease],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tif (style === \"rise\") {\n\t\t\t\tanim.keys(\"opacity\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tmark.animate(anim);\n\t\t\tparts.mark = mark;\n\t\t\tif (style === \"rise\") {\n\t\t\t\t// Rises through its own clip.\n\t\t\t\tmark.y = m;\n\t\t\t\tmark.x = 0;\n\t\t\t\tmark.animate(\n\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t[0, m],\n\t\t\t\t\t\t[base, 0, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tconst clip = host.box({\n\t\t\t\t\tid: ctx.partId(\"mark-clip\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: markAt.x,\n\t\t\t\t\ty: markAt.y,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\tchildren: [mark],\n\t\t\t\t});\n\t\t\t\tparts[\"mark-clip\"] = clip;\n\t\t\t\tmarkNode = clip;\n\t\t\t} else markNode = mark;\n\t\t}\n\n\t\t// ── Name ─────────────────────────────────────────────────────────\n\t\tif (props.name) {\n\t\t\tif (solid) {\n\t\t\t\tconst chrome = props.material === \"chrome\";\n\t\t\t\tconst name = host.extrudedText({\n\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\ttext: props.name,\n\t\t\t\t\tx: nameAt.x + nameM.width / 2,\n\t\t\t\t\ty: nameAt.y + nameM.height / 2,\n\t\t\t\t\tdepth: props.depth,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t\tfill: chrome ? \"#e8ecf2\" : ink,\n\t\t\t\t\tbevelColor: chrome ? \"#9aa3ad\" : accent,\n\t\t\t\t\tmaterial: props.material === \"glass\" ? \"glass\" : \"lit\",\n\t\t\t\t\t...(chrome && { metallic: 1, roughness: 0.18, shininess: 96 }),\n\t\t\t\t});\n\t\t\t\tname.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"rotateY\",\n\t\t\t\t\t\t\tspringKeys(nameStart, -70, 0, fps, SWING) as KeyframeTuple[],\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[nameStart, 0],\n\t\t\t\t\t\t\t[nameStart + Math.round(base * 0.5), 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tparts.name = name;\n\t\t\t\tchildren.push(name);\n\t\t\t} else {\n\t\t\t\tconst animators =\n\t\t\t\t\tletters || glints\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t...(letters\n\t\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\t\t\t\t\"rise\",\n\t\t\t\t\t\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstarts: rendererChars(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tArray.from({ length: chars }, (_, i) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMath.round(i * stagger),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: base,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: theme.motion.ease.enter,\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t{ y: ns * 0.35, opacity: 0 },\n\t\t\t\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: []),\n\t\t\t\t\t\t\t\t...(glints\n\t\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\t\t\t\t\"glint\",\n\t\t\t\t\t\t\t\t\t\t\t\tglintExpression(\n\t\t\t\t\t\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\t\t\t\t\t\tglintAt - nameStart,\n\t\t\t\t\t\t\t\t\t\t\t\t\tglintFrames,\n\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t{ fillColor: \"#ffffff\" },\n\t\t\t\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: []),\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst wipe = style === \"slide\" && !!props.mark;\n\t\t\t\tconst text = host.text(props.name, {\n\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: wipe ? (stack ? 0 : -nameM.width - gap) : 0,\n\t\t\t\t\ty: wipe && stack ? -nameM.height : 0,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t\tfill: ink,\n\t\t\t\t\t...(animators && { animators: animators as never }),\n\t\t\t\t\tstartFrame: nameStart,\n\t\t\t\t});\n\t\t\t\tif (wipe)\n\t\t\t\t\ttext.animate(\n\t\t\t\t\t\tLayerAnimation.keys(stack ? \"y\" : \"x\", [\n\t\t\t\t\t\t\t[0, stack ? -nameM.height : -nameM.width - gap],\n\t\t\t\t\t\t\t[glide, 0, ease],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\telse if (style === \"rise\" || (!props.mark && style === \"slide\"))\n\t\t\t\t\ttext.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t[0, nameM.height],\n\t\t\t\t\t\t\t[base, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t// The name sits in a clip: it wipes out from behind the mark or rises.\n\t\t\t\tconst clip = host.box({\n\t\t\t\t\tid: ctx.partId(\"name-clip\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: nameAt.x,\n\t\t\t\t\ty: nameAt.y,\n\t\t\t\t\twidth: nameM.width,\n\t\t\t\t\theight: nameM.height,\n\t\t\t\t\t...(!letters && { overflow: \"hidden\" as const }),\n\t\t\t\t\tchildren: [text],\n\t\t\t\t});\n\t\t\t\tparts.name = text;\n\t\t\t\tparts[\"name-clip\"] = clip;\n\t\t\t\tchildren.push(clip);\n\t\t\t}\n\t\t}\n\n\t\t// ── Tagline ──────────────────────────────────────────────────────\n\t\tif (props.tagline) {\n\t\t\tconst tag = host.text(props.tagline, {\n\t\t\t\tid: ctx.partId(\"tagline\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: tagAt.x,\n\t\t\t\ty: tagAt.y,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: tagSize,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\t\t\ttag.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[tagStart, 0],\n\t\t\t\t\t\t[tagStart + base, 1, \"power2.out\"],\n\t\t\t\t\t])\n\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t[tagStart, tagAt.y + 12 * s],\n\t\t\t\t\t\t[tagStart + base, tagAt.y, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts.tagline = tag;\n\t\t\tchildren.push(tag);\n\t\t}\n\t\tif (markNode) children.push(markNode);\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: end,\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\thit: props.mark ? [{ frame: hit }] : [],\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\tglint: glints ? [{ frame: glintAt }] : [],\n\t\t\t} as LogoRevealCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * A soft band of light crossing the name left to right: each character\n * peaks as the band passes its position (frame is the name's local frame).\n */\nfunction glintExpression(text: string, start: number, frames: number): string {\n\tconst n = Math.max(1, text.length - 1);\n\tconst peaks = rendererChars(\n\t\ttext,\n\t\tArray.from(\n\t\t\t{ length: text.length },\n\t\t\t(_, i) => Math.round((start + (i / n) * frames * 0.7) * 100) / 100,\n\t\t),\n\t);\n\tconst width = Math.max(2, frames * 0.18);\n\treturn `((P)=>{const d=abs(frame-(P[textIndex]??-999));return d<${width}?(1-d/${width})*0.85:0;})([${peaks.join(\",\")}])`;\n}\n\n/**\n * The end-card lockup: a mark and a wordmark arriving as one move, drawn on,\n * popped or wiped, with a glint across the name; names can be extruded in 3D.\n */\nexport const LogoReveal = /* @__PURE__ */ bindComponent(logoReveal);\n"
34
+ "content": "/**\n * LogoReveal: the end-card lockup. A mark (image, SVG or path data) and a\n * wordmark arrive as one move, then a light sweeps across the name. Path\n * marks can draw themselves on; names can be extruded in 3D.\n */\nimport {\n\ttype KeyframeTuple,\n\tLayerAnimation,\n\ttype LayoutNode,\n} from \"framefields\";\nimport {\n\ttype Animatable,\n\tbindComponent,\n\tdefineComponent,\n\tdrawOnKeys,\n\tglyphAnimator,\n\trendererChars,\n\tscheduleExpression,\n\tspringKeys,\n\tthemeFrames,\n\ttransformPathData,\n\tz,\n} from \"framefields/components\";\n\nexport const LOGO_STYLES = [\"slide\", \"pop\", \"draw\", \"rise\"] as const;\nexport type LogoStyle = (typeof LOGO_STYLES)[number];\nexport const LOGO_LAYOUTS = [\"row\", \"stack\"] as const;\nexport type LogoLayout = (typeof LOGO_LAYOUTS)[number];\n\nexport interface LogoRevealCues {\n\t[name: string]: ReadonlyArray<{ frame: number }>;\n\t/** The mark lands. */\n\thit: ReadonlyArray<{ frame: number }>;\n\t/** The lockup is complete. */\n\trevealed: ReadonlyArray<{ frame: number }>;\n\t/** The glint starts (empty without `glint`). */\n\tglint: ReadonlyArray<{ frame: number }>;\n}\n\nconst SWING = { stiffness: 120, damping: 14 };\n\nexport const logoReveal = /* @__PURE__ */ defineComponent({\n\tname: \"LogoReveal\",\n\tprops: () => ({\n\t\t/** An image / SVG file, or path data in its own viewBox. */\n\t\tmark: z\n\t\t\t.union([\n\t\t\t\tz.string().min(1),\n\t\t\t\tz\n\t\t\t\t\t.object({\n\t\t\t\t\t\td: z.string().min(1),\n\t\t\t\t\t\tviewBox: z.tuple([z.number().positive(), z.number().positive()]),\n\t\t\t\t\t\tcolor: z.string().optional(),\n\t\t\t\t\t})\n\t\t\t\t\t.strict(),\n\t\t\t])\n\t\t\t.optional(),\n\t\tname: z.string().min(1).optional(),\n\t\ttagline: z.string().min(1).optional(),\n\t\tlayout: z.enum(LOGO_LAYOUTS).default(\"row\"),\n\t\tstyle: z.enum(LOGO_STYLES).default(\"slide\"),\n\t\tglint: z.boolean().default(true),\n\t\t/** Above 0, the name is extruded text this deep (px). */\n\t\tdepth: z.number().min(0).default(0),\n\t\tmaterial: z.enum([\"lit\", \"chrome\", \"glass\"]).default(\"lit\"),\n\t\tmarkSize: z.number().positive().optional(),\n\t\tnameSize: z.number().positive().optional(),\n\t\tgap: z.number().min(0).optional(),\n\t\tcolor: z.string().optional(),\n\t\taccent: z.string().optional(),\n\t\tfontFamily: z.string().optional(),\n\t\tfontWeight: z.number().int().min(100).max(900).default(800),\n\t}),\n\tdefaults: { enter: false, exit: false, hold: 60 },\n\tbuild(ctx, props) {\n\t\tconst { host, theme, fps } = ctx;\n\t\tif (!props.mark && !props.name)\n\t\t\tthrow new Error(\"LogoReveal: give a `mark`, a `name`, or both.\");\n\t\tconst s = theme.scale;\n\t\tconst base = themeFrames(theme, \"base\", fps);\n\t\tconst ease = theme.motion.ease.move;\n\t\tconst accent = props.accent ?? theme.color.accent;\n\t\tconst ink = props.color ?? theme.color.ink;\n\t\tconst fontFamily = props.fontFamily ?? theme.font.display;\n\t\tconst ns = props.nameSize ?? 120 * s;\n\t\tconst m = props.mark ? (props.markSize ?? 150 * s) : 0;\n\t\tconst gap = props.mark && props.name ? (props.gap ?? ns * 0.32) : 0;\n\t\tconst stack = props.layout === \"stack\";\n\t\tconst solid = props.depth > 0;\n\t\tconst pathMark = typeof props.mark === \"object\" ? props.mark : null;\n\t\t// Image marks can't draw on: they pop.\n\t\tconst style: LogoStyle =\n\t\t\tprops.style === \"draw\" && !pathMark ? \"pop\" : props.style;\n\n\t\t// ── Geometry ─────────────────────────────────────────────────────\n\t\tconst nameM = props.name\n\t\t\t? host.measure(props.name, {\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t})\n\t\t\t: { width: 0, height: 0 };\n\t\tconst tagSize = Math.max(18 * s, ns * 0.26);\n\t\tconst tagM = props.tagline\n\t\t\t? host.measure(props.tagline, {\n\t\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\t\tfontSize: tagSize,\n\t\t\t\t})\n\t\t\t: { width: 0, height: 0 };\n\t\tconst tagGap = props.tagline ? ns * 0.12 : 0;\n\t\tconst textW = Math.max(nameM.width, tagM.width);\n\t\tconst textH = nameM.height + tagGap + tagM.height;\n\t\tconst W = stack ? Math.max(m, textW) : m + gap + textW;\n\t\tconst H = stack ? m + gap + textH : Math.max(m, textH);\n\t\tconst markAt = stack ? { x: (W - m) / 2, y: 0 } : { x: 0, y: (H - m) / 2 };\n\t\tconst nameAt = stack\n\t\t\t? { x: (W - nameM.width) / 2, y: m + gap }\n\t\t\t: { x: m + gap, y: (H - textH) / 2 };\n\t\tconst tagAt = stack\n\t\t\t? { x: (W - tagM.width) / 2, y: nameAt.y + nameM.height + tagGap }\n\t\t\t: { x: nameAt.x, y: nameAt.y + nameM.height + tagGap };\n\n\t\t// ── Timeline ─────────────────────────────────────────────────────\n\t\tconst drawFrames = Math.round(base * 2.2);\n\t\tconst glide = Math.round(base * 1.5);\n\t\tconst glideAt = style === \"slide\" ? Math.round(base * 1.1) : 0;\n\t\tconst hit = !props.mark\n\t\t\t? 0\n\t\t\t: style === \"draw\"\n\t\t\t\t? drawFrames\n\t\t\t\t: style === \"rise\"\n\t\t\t\t\t? base\n\t\t\t\t\t: Math.round(base * 0.75);\n\t\tconst nameStart = !props.mark\n\t\t\t? 0\n\t\t\t: style === \"slide\"\n\t\t\t\t? glideAt\n\t\t\t\t: style === \"draw\"\n\t\t\t\t\t? Math.round(drawFrames * 0.6)\n\t\t\t\t\t: style === \"rise\"\n\t\t\t\t\t\t? Math.round(base * 0.35)\n\t\t\t\t\t\t: Math.round(base * 0.55);\n\t\tconst chars = props.name?.length ?? 0;\n\t\tconst stagger = Math.max(1, Math.round(fps / 30) * 1.5);\n\t\tconst letters = style === \"pop\" || style === \"draw\";\n\t\tconst nameDone = !props.name\n\t\t\t? hit\n\t\t\t: solid\n\t\t\t\t? nameStart + base * 2\n\t\t\t\t: style === \"slide\"\n\t\t\t\t\t? glideAt + glide\n\t\t\t\t\t: letters\n\t\t\t\t\t\t? nameStart + Math.round(stagger * Math.max(0, chars - 1)) + base\n\t\t\t\t\t\t: nameStart + base;\n\t\tconst tagStart = Math.max(nameStart, nameDone - Math.round(base * 0.4));\n\t\tconst revealed = Math.max(\n\t\t\thit,\n\t\t\tnameDone,\n\t\t\tprops.tagline ? tagStart + base : 0,\n\t\t);\n\t\tconst glintAt = revealed + Math.round(base * 0.3);\n\t\tconst glintFrames = Math.round(base * 1.8);\n\t\tconst glints = props.glint && props.name && !solid;\n\t\tconst end = glints ? glintAt + glintFrames : revealed;\n\n\t\tconst parts: Record<string, Animatable<LayoutNode>> = {};\n\t\tconst children: LayoutNode[] = [];\n\t\t// Drawn last: the name wipes out from behind it.\n\t\tlet markNode: LayoutNode | undefined;\n\n\t\t// ── Mark ─────────────────────────────────────────────────────────\n\t\tif (props.mark) {\n\t\t\tconst markColor = pathMark?.color ?? accent;\n\t\t\tconst markKids: LayoutNode[] = [];\n\t\t\tif (pathMark) {\n\t\t\t\tconst [vw, vh] = pathMark.viewBox;\n\t\t\t\tconst k = m / Math.max(vw, vh);\n\t\t\t\tconst d = transformPathData(\n\t\t\t\t\tpathMark.d,\n\t\t\t\t\tk,\n\t\t\t\t\t(m - vw * k) / 2,\n\t\t\t\t\t(m - vh * k) / 2,\n\t\t\t\t);\n\t\t\t\tconst fill = host.shape(\"path\", {\n\t\t\t\t\tid: ctx.partId(\"mark-fill\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\td,\n\t\t\t\t\tfillType: \"solid\",\n\t\t\t\t\tfillColor: markColor,\n\t\t\t\t});\n\t\t\t\tparts[\"mark-fill\"] = fill;\n\t\t\t\tif (style === \"draw\") {\n\t\t\t\t\tconst outline = host.shape(\"path\", {\n\t\t\t\t\t\tid: ctx.partId(\"mark-outline\"),\n\t\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\t\tx: 0,\n\t\t\t\t\t\ty: 0,\n\t\t\t\t\t\twidth: m,\n\t\t\t\t\t\theight: m,\n\t\t\t\t\t\td,\n\t\t\t\t\t\tfillType: \"none\",\n\t\t\t\t\t\tstrokeColor: markColor,\n\t\t\t\t\t\tstrokeWidth: Math.max(2, 3 * s),\n\t\t\t\t\t\tstrokeLineCap: \"round\",\n\t\t\t\t\t\tstrokeLineJoin: \"round\",\n\t\t\t\t\t});\n\t\t\t\t\toutline.animate(\n\t\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t\t.keys(\"trimEnd\", drawOnKeys(0, drawFrames, \"power2.inOut\"))\n\t\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t\t[drawFrames, 1],\n\t\t\t\t\t\t\t\t[drawFrames + base, 0, \"power2.out\"],\n\t\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tfill.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"opacity\", [\n\t\t\t\t\t\t\t[Math.round(drawFrames * 0.7), 0],\n\t\t\t\t\t\t\t[drawFrames, 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t\tmarkKids.push(fill, outline);\n\t\t\t\t\tparts[\"mark-outline\"] = outline;\n\t\t\t\t} else markKids.push(fill);\n\t\t\t} else {\n\t\t\t\tconst image = host.media(props.mark as string, {\n\t\t\t\t\tid: ctx.partId(\"mark-image\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: 0,\n\t\t\t\t\ty: 0,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\tfit: \"contain\",\n\t\t\t\t});\n\t\t\t\tmarkKids.push(image);\n\t\t\t\tparts[\"mark-image\"] = image;\n\t\t\t}\n\t\t\tconst centred = stack\n\t\t\t\t? { x: markAt.x, y: (H - m) / 2 }\n\t\t\t\t: { x: (W - m) / 2, y: markAt.y };\n\t\t\tconst slide = style === \"slide\" && !!props.name;\n\t\t\tconst mark = host.box({\n\t\t\t\tid: ctx.partId(\"mark\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: slide ? centred.x : markAt.x,\n\t\t\t\ty: slide ? centred.y : markAt.y,\n\t\t\t\twidth: m,\n\t\t\t\theight: m,\n\t\t\t\tchildren: markKids,\n\t\t\t});\n\t\t\tconst anim = LayerAnimation.create();\n\t\t\tconst scale: KeyframeTuple[] = [];\n\t\t\tif (style === \"slide\" || style === \"pop\")\n\t\t\t\tscale.push(\n\t\t\t\t\t...(springKeys(\n\t\t\t\t\t\t0,\n\t\t\t\t\t\t0,\n\t\t\t\t\t\t1,\n\t\t\t\t\t\tfps,\n\t\t\t\t\t\ttheme.motion.spring.pop,\n\t\t\t\t\t) as KeyframeTuple[]),\n\t\t\t\t);\n\t\t\tif (glints) {\n\t\t\t\t// A small bump as the light passes.\n\t\t\t\tif (scale.length === 0) scale.push([0, 1]);\n\t\t\t\tscale.push(\n\t\t\t\t\t[glintAt, 1],\n\t\t\t\t\t[glintAt + Math.round(base * 0.4), 1.06, \"power2.out\"],\n\t\t\t\t\t[glintAt + Math.round(base * 1.2), 1, \"power2.inOut\"],\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (scale.length > 0) anim.keys(\"scale\", scale);\n\t\t\tif (slide) {\n\t\t\t\tanim.keys(stack ? \"y\" : \"x\", [\n\t\t\t\t\t[glideAt, stack ? centred.y : centred.x],\n\t\t\t\t\t[glideAt + glide, stack ? markAt.y : markAt.x, ease],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tif (style === \"rise\") {\n\t\t\t\tanim.keys(\"opacity\", [\n\t\t\t\t\t[0, 0],\n\t\t\t\t\t[Math.round(base * 0.6), 1, \"power2.out\"],\n\t\t\t\t]);\n\t\t\t}\n\t\t\tmark.animate(anim);\n\t\t\tparts.mark = mark;\n\t\t\tif (style === \"rise\") {\n\t\t\t\t// Rises through its own clip.\n\t\t\t\tmark.y = m;\n\t\t\t\tmark.x = 0;\n\t\t\t\tmark.animate(\n\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t[0, m],\n\t\t\t\t\t\t[base, 0, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tconst clip = host.box({\n\t\t\t\t\tid: ctx.partId(\"mark-clip\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: markAt.x,\n\t\t\t\t\ty: markAt.y,\n\t\t\t\t\twidth: m,\n\t\t\t\t\theight: m,\n\t\t\t\t\toverflow: \"hidden\",\n\t\t\t\t\tchildren: [mark],\n\t\t\t\t});\n\t\t\t\tparts[\"mark-clip\"] = clip;\n\t\t\t\tmarkNode = clip;\n\t\t\t} else markNode = mark;\n\t\t}\n\n\t\t// ── Name ─────────────────────────────────────────────────────────\n\t\tif (props.name) {\n\t\t\tif (solid) {\n\t\t\t\tconst chrome = props.material === \"chrome\";\n\t\t\t\tconst name = host.extrudedText({\n\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\ttext: props.name,\n\t\t\t\t\tx: nameAt.x + nameM.width / 2,\n\t\t\t\t\ty: nameAt.y + nameM.height / 2,\n\t\t\t\t\tdepth: props.depth,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t\tfill: chrome ? \"#e8ecf2\" : ink,\n\t\t\t\t\tbevelColor: chrome ? \"#9aa3ad\" : accent,\n\t\t\t\t\tmaterial: props.material === \"glass\" ? \"glass\" : \"lit\",\n\t\t\t\t\t...(chrome && { metallic: 1, roughness: 0.18, shininess: 96 }),\n\t\t\t\t});\n\t\t\t\tname.animate(\n\t\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t\t.keys(\n\t\t\t\t\t\t\t\"rotateY\",\n\t\t\t\t\t\t\tspringKeys(nameStart, -70, 0, fps, SWING) as KeyframeTuple[],\n\t\t\t\t\t\t)\n\t\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t\t[nameStart, 0],\n\t\t\t\t\t\t\t[nameStart + Math.round(base * 0.5), 1, \"power2.out\"],\n\t\t\t\t\t\t]),\n\t\t\t\t);\n\t\t\t\tparts.name = name;\n\t\t\t\tchildren.push(name);\n\t\t\t} else {\n\t\t\t\tconst animators =\n\t\t\t\t\tletters || glints\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t...(letters\n\t\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\t\t\t\t\"rise\",\n\t\t\t\t\t\t\t\t\t\t\t\tscheduleExpression(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"char\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstarts: rendererChars(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tArray.from({ length: chars }, (_, i) =>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tMath.round(i * stagger),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tduration: base,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tease: theme.motion.ease.enter,\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"enter\",\n\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t{ y: ns * 0.35, opacity: 0 },\n\t\t\t\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: []),\n\t\t\t\t\t\t\t\t...(glints\n\t\t\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\t\tglyphAnimator(\n\t\t\t\t\t\t\t\t\t\t\t\t\"glint\",\n\t\t\t\t\t\t\t\t\t\t\t\tglintExpression(\n\t\t\t\t\t\t\t\t\t\t\t\t\tprops.name,\n\t\t\t\t\t\t\t\t\t\t\t\t\tglintAt - nameStart,\n\t\t\t\t\t\t\t\t\t\t\t\t\tglintFrames,\n\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t{ fillColor: \"#ffffff\" },\n\t\t\t\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: []),\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst wipe = style === \"slide\" && !!props.mark;\n\t\t\t\tconst text = host.text(props.name, {\n\t\t\t\t\tid: ctx.partId(\"name\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: wipe ? (stack ? 0 : -nameM.width - gap) : 0,\n\t\t\t\t\ty: wipe && stack ? -nameM.height : 0,\n\t\t\t\t\tfontFamily,\n\t\t\t\t\tfontSize: ns,\n\t\t\t\t\tfontWeight: props.fontWeight,\n\t\t\t\t\tfill: ink,\n\t\t\t\t\t...(animators && { animators: animators as never }),\n\t\t\t\t\tstartFrame: nameStart,\n\t\t\t\t});\n\t\t\t\tif (wipe)\n\t\t\t\t\ttext.animate(\n\t\t\t\t\t\tLayerAnimation.keys(stack ? \"y\" : \"x\", [\n\t\t\t\t\t\t\t[0, stack ? -nameM.height : -nameM.width - gap],\n\t\t\t\t\t\t\t[glide, 0, ease],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\telse if (style === \"rise\" || (!props.mark && style === \"slide\"))\n\t\t\t\t\ttext.animate(\n\t\t\t\t\t\tLayerAnimation.keys(\"y\", [\n\t\t\t\t\t\t\t[0, nameM.height],\n\t\t\t\t\t\t\t[base, 0, theme.motion.ease.enter],\n\t\t\t\t\t\t]),\n\t\t\t\t\t);\n\t\t\t\t// The name sits in a clip: it wipes out from behind the mark or rises.\n\t\t\t\tconst clip = host.box({\n\t\t\t\t\tid: ctx.partId(\"name-clip\"),\n\t\t\t\t\tposition: \"absolute\",\n\t\t\t\t\tx: nameAt.x,\n\t\t\t\t\ty: nameAt.y,\n\t\t\t\t\twidth: nameM.width,\n\t\t\t\t\theight: nameM.height,\n\t\t\t\t\t...(!letters && { overflow: \"hidden\" as const }),\n\t\t\t\t\tchildren: [text],\n\t\t\t\t});\n\t\t\t\tparts.name = text;\n\t\t\t\tparts[\"name-clip\"] = clip;\n\t\t\t\tchildren.push(clip);\n\t\t\t}\n\t\t}\n\n\t\t// ── Tagline ──────────────────────────────────────────────────────\n\t\tif (props.tagline) {\n\t\t\tconst tag = host.text(props.tagline, {\n\t\t\t\tid: ctx.partId(\"tagline\"),\n\t\t\t\tposition: \"absolute\",\n\t\t\t\tx: tagAt.x,\n\t\t\t\ty: tagAt.y,\n\t\t\t\tfontFamily: theme.font.body,\n\t\t\t\tfontSize: tagSize,\n\t\t\t\tfill: theme.color.muted,\n\t\t\t});\n\t\t\ttag.animate(\n\t\t\t\tLayerAnimation.create()\n\t\t\t\t\t.keys(\"opacity\", [\n\t\t\t\t\t\t[tagStart, 0],\n\t\t\t\t\t\t[tagStart + base, 1, \"power2.out\"],\n\t\t\t\t\t])\n\t\t\t\t\t.keys(\"y\", [\n\t\t\t\t\t\t[tagStart, tagAt.y + 12 * s],\n\t\t\t\t\t\t[tagStart + base, tagAt.y, theme.motion.ease.enter],\n\t\t\t\t\t]),\n\t\t\t);\n\t\t\tparts.tagline = tag;\n\t\t\tchildren.push(tag);\n\t\t}\n\t\tif (markNode) children.push(markNode);\n\n\t\treturn {\n\t\t\tchildren,\n\t\t\twidth: W,\n\t\t\theight: H,\n\t\t\tduration: end,\n\t\t\topenEnded: true,\n\t\t\tcues: {\n\t\t\t\thit: props.mark ? [{ frame: hit }] : [],\n\t\t\t\trevealed: [{ frame: revealed }],\n\t\t\t\tglint: glints ? [{ frame: glintAt }] : [],\n\t\t\t} as LogoRevealCues,\n\t\t\tparts,\n\t\t};\n\t},\n});\n\n/**\n * A soft band of light crossing the name left to right: each character\n * peaks as the band passes its position (frame is the name's local frame).\n */\nfunction glintExpression(text: string, start: number, frames: number): string {\n\tconst n = Math.max(1, text.length - 1);\n\tconst peaks = rendererChars(\n\t\ttext,\n\t\tArray.from(\n\t\t\t{ length: text.length },\n\t\t\t(_, i) => Math.round((start + (i / n) * frames * 0.7) * 100) / 100,\n\t\t),\n\t);\n\tconst width = Math.max(2, frames * 0.18);\n\treturn `((P)=>{const d=abs(frame-(P[textIndex]??-999));return d<${width}?(1-d/${width})*0.85:0;})([${peaks.join(\",\")}])`;\n}\n\n/**\n * The end-card lockup: a mark and a wordmark arriving as one move, drawn on,\n * popped or wiped, with a glint across the name; names can be extruded in 3D.\n */\nexport const LogoReveal = /* @__PURE__ */ bindComponent(logoReveal);\n"
35
35
  }
36
36
  ]
37
37
  }