@cardstack/boxel-cli 0.11.0-unstable.1 → 0.11.0-unstable.3

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 (177) hide show
  1. package/bundled-realms/pretui/components/agent-mark.gts +98 -0
  2. package/bundled-realms/pretui/components/agent-question.gts +340 -37
  3. package/bundled-realms/pretui/components/answer-footer.gts +249 -0
  4. package/bundled-realms/pretui/components/card-embed.gts +523 -0
  5. package/bundled-realms/pretui/components/card-pills.gts +725 -0
  6. package/bundled-realms/pretui/components/code-block.gts +87 -45
  7. package/bundled-realms/pretui/components/collapsed-turn.gts +133 -12
  8. package/bundled-realms/pretui/components/compaction-chip.gts +81 -67
  9. package/bundled-realms/pretui/components/composer-cap.gts +516 -0
  10. package/bundled-realms/pretui/components/context-ring.gts +108 -0
  11. package/bundled-realms/pretui/components/diff-block.gts +1002 -31
  12. package/bundled-realms/pretui/components/diff-stat.gts +102 -0
  13. package/bundled-realms/pretui/components/jump-chip.gts +86 -53
  14. package/bundled-realms/pretui/components/markdown-stream.gts +799 -0
  15. package/bundled-realms/pretui/components/markdown-surface.gts +71 -0
  16. package/bundled-realms/pretui/components/plan-mark.gts +147 -0
  17. package/bundled-realms/pretui/components/prose.gts +185 -8
  18. package/bundled-realms/pretui/components/prose.md +6 -8
  19. package/bundled-realms/pretui/components/quick-look.gts +377 -0
  20. package/bundled-realms/pretui/components/reactions.gts +136 -0
  21. package/bundled-realms/pretui/components/recommendation.gts +67 -36
  22. package/bundled-realms/pretui/components/result-card.gts +58 -22
  23. package/bundled-realms/pretui/components/segmented-control.gts +13 -8
  24. package/bundled-realms/pretui/components/session-prep.gts +12 -11
  25. package/bundled-realms/pretui/components/sliding-highlight.gts +8 -3
  26. package/bundled-realms/pretui/components/speaker-label.gts +132 -0
  27. package/bundled-realms/pretui/components/state-glyph.gts +121 -0
  28. package/bundled-realms/pretui/components/step-list.gts +62 -60
  29. package/bundled-realms/pretui/components/stick-to-bottom.gts +1150 -0
  30. package/bundled-realms/pretui/components/system-line.gts +143 -0
  31. package/bundled-realms/pretui/components/task-checklist.gts +184 -0
  32. package/bundled-realms/pretui/components/thinking.gts +63 -94
  33. package/bundled-realms/pretui/components/tool-change-table.gts +285 -0
  34. package/bundled-realms/pretui/components/tool-gate.gts +462 -0
  35. package/bundled-realms/pretui/components/tool-group.gts +920 -0
  36. package/bundled-realms/pretui/components/tool-icon.ts +64 -0
  37. package/bundled-realms/pretui/components/tool-meta.gts +82 -0
  38. package/bundled-realms/pretui/components/tool-plan.gts +197 -0
  39. package/bundled-realms/pretui/components/tool-row.gts +2087 -0
  40. package/bundled-realms/pretui/components/turn-changes.gts +227 -0
  41. package/bundled-realms/pretui/components/user-bubble.gts +575 -0
  42. package/bundled-realms/pretui/components/work-item.gts +185 -48
  43. package/bundled-realms/pretui/docs/scroll-anchoring-protocol.md +136 -0
  44. package/bundled-realms/pretui/internal/markdown-projection.ts +438 -0
  45. package/bundled-realms/pretui/internal/mount-queue.ts +79 -0
  46. package/bundled-realms/pretui/internal/tool-phrases.ts +168 -0
  47. package/bundled-realms/pretui/package.json +2 -1
  48. package/bundled-test-harness/assets/{ai-module-creation-test-beRNwH-e.js → ai-module-creation-test-B6FQKqIl.js} +1 -1
  49. package/bundled-test-harness/assets/{app-QAuw0l8v.js → app-KpPqdyX3.js} +4 -4
  50. package/bundled-test-harness/assets/{binary-upload-dedupe-test-bI8T0QLl.js → binary-upload-dedupe-test-DN00y1tM.js} +1 -1
  51. package/bundled-test-harness/assets/{can-invoke-test-DVaU_yRT.js → can-invoke-test-Bl-EUFM3.js} +1 -1
  52. package/bundled-test-harness/assets/{card-chooser-paging-test-DnxMnXKR.js → card-chooser-paging-test-BNuZRHX3.js} +1 -1
  53. package/bundled-test-harness/assets/{card-chooser-test-DUSnLr-u.js → card-chooser-test-wKfPOOT4.js} +1 -1
  54. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-GBUr3tFK.js → card-def-field-def-relationships-test-CduQ6zan.js} +1 -1
  55. package/bundled-test-harness/assets/{card-delete-test-CQveO1VC.js → card-delete-test-DKxolj5o.js} +1 -1
  56. package/bundled-test-harness/assets/{check-correctness-test-D2M2p66N.js → check-correctness-test-B-kgHztp.js} +1 -1
  57. package/bundled-test-harness/assets/choreo-BEHW-NOc.js +2 -0
  58. package/bundled-test-harness/assets/{choreo-CJfBb_EY.js → choreo-C2cpCfhA.js} +164 -13
  59. package/bundled-test-harness/assets/{choreo-feature-reel-test-D2OrygZv.js → choreo-feature-reel-test-CPI_6nkT.js} +2 -2
  60. package/bundled-test-harness/assets/{choreo-gallery-w07xqtO-.js → choreo-gallery-BxA9cyzF.js} +3 -3
  61. package/bundled-test-harness/assets/{choreo-gallery-build-order-test-yQg4JER6.js → choreo-gallery-build-order-test-CLVoseAJ.js} +1 -1
  62. package/bundled-test-harness/assets/{choreo-gallery-compositor-test-C2THCXJN.js → choreo-gallery-compositor-test-BpT5Ia6B.js} +1 -1
  63. package/bundled-test-harness/assets/{choreo-gallery-cover-test-D4T_Tk4s.js → choreo-gallery-cover-test-DL_fdSsh.js} +1 -1
  64. package/bundled-test-harness/assets/{choreo-gallery-crossing-stage-test-tHUPJ-Hy.js → choreo-gallery-crossing-stage-test-BcKHhpOK.js} +2 -2
  65. package/bundled-test-harness/assets/{choreo-gallery-demos-alive-test-BOtBOnEZ.js → choreo-gallery-demos-alive-test-KufLhXG8.js} +1 -1
  66. package/bundled-test-harness/assets/{choreo-gallery-drift-test-D_6U1Z2c.js → choreo-gallery-drift-test-Cx58rYIA.js} +1 -1
  67. package/bundled-test-harness/assets/{choreo-gallery-escort-test-DDBU0n4C.js → choreo-gallery-escort-test-Wz-emmID.js} +1 -1
  68. package/bundled-test-harness/assets/{choreo-gallery-films-test-CgW1NSdr.js → choreo-gallery-films-test-BFrVs5WW.js} +2 -2
  69. package/bundled-test-harness/assets/{choreo-gallery-filter-test-DUIsNwdO.js → choreo-gallery-filter-test-DCKQkhwe.js} +1 -1
  70. package/bundled-test-harness/assets/{choreo-gallery-fold-test-Chzlxvdr.js → choreo-gallery-fold-test-UtpDgpG9.js} +1 -1
  71. package/bundled-test-harness/assets/{choreo-gallery-guide-pages-test-DOWnqALT.js → choreo-gallery-guide-pages-test-rXDU9JLM.js} +1 -1
  72. package/bundled-test-harness/assets/{choreo-gallery-guides-test-Dra2J4LJ.js → choreo-gallery-guides-test-DfVFdIuU.js} +3 -3
  73. package/bundled-test-harness/assets/{choreo-gallery-hang-test-zzn1eiB-.js → choreo-gallery-hang-test-Cj7MbkFf.js} +1 -1
  74. package/bundled-test-harness/assets/{choreo-gallery-highlight-test-CW99hncQ.js → choreo-gallery-highlight-test-D6CF3IYZ.js} +2 -2
  75. package/bundled-test-harness/assets/{choreo-gallery-inline-edit-test-CYFm1kUz.js → choreo-gallery-inline-edit-test-LfHWsbqc.js} +1 -1
  76. package/bundled-test-harness/assets/{choreo-gallery-interruption-test-CYIBrKV9.js → choreo-gallery-interruption-test-CvQQAgTp.js} +2 -2
  77. package/bundled-test-harness/assets/{choreo-gallery-lone-stack-test-DL8R3SG7.js → choreo-gallery-lone-stack-test-D7Zci7fm.js} +1 -1
  78. package/bundled-test-harness/assets/{choreo-gallery-navigation-test-Dcv04kDx.js → choreo-gallery-navigation-test-DB1EnBEW.js} +1 -1
  79. package/bundled-test-harness/assets/{choreo-gallery-playhead-transport-test-Cfz7GsXl.js → choreo-gallery-playhead-transport-test-CtBlc6AJ.js} +1 -1
  80. package/bundled-test-harness/assets/{choreo-gallery-poplayout-subtree-test-CwoklBkW.js → choreo-gallery-poplayout-subtree-test-BQbmjsvI.js} +1 -1
  81. package/bundled-test-harness/assets/{choreo-gallery-presence-modes-test-BDCIZzJ0.js → choreo-gallery-presence-modes-test-CIdXVG1K.js} +1 -1
  82. package/bundled-test-harness/assets/{choreo-gallery-presentation-test-DlyiHwFf.js → choreo-gallery-presentation-test-C_rPFp29.js} +1 -1
  83. package/bundled-test-harness/assets/{choreo-gallery-reduced-motion-test-DVz5SKNR.js → choreo-gallery-reduced-motion-test-CH5w8Lw-.js} +2 -2
  84. package/bundled-test-harness/assets/{choreo-gallery-sheet-standalone-test-CpD_-BvS.js → choreo-gallery-sheet-standalone-test-CDgI4mGX.js} +1 -1
  85. package/bundled-test-harness/assets/{choreo-gallery-sheet-test-BjvAKHDL.js → choreo-gallery-sheet-test-5lsFX7Rk.js} +1 -1
  86. package/bundled-test-harness/assets/{choreo-gallery-site-test-oq_7y_PN.js → choreo-gallery-site-test-DbMa2xeC.js} +2 -2
  87. package/bundled-test-harness/assets/{choreo-gallery-stage-Bzr4_8Xd.js → choreo-gallery-stage-b3Sk86hp.js} +2 -2
  88. package/bundled-test-harness/assets/{choreo-gallery-stage-keyboard-test-a83d2euJ.js → choreo-gallery-stage-keyboard-test-ds6fT5Eg.js} +1 -1
  89. package/bundled-test-harness/assets/{choreo-gallery-stages-test-C9Sl1kH6.js → choreo-gallery-stages-test-ahioKCct.js} +2 -2
  90. package/bundled-test-harness/assets/{choreo-gallery-subdivision-test-BVVnkOwk.js → choreo-gallery-subdivision-test-g7anTDhT.js} +1 -1
  91. package/bundled-test-harness/assets/{choreo-gallery-table-plan-test-B0YdYEA0.js → choreo-gallery-table-plan-test-Bozc4uUf.js} +1 -1
  92. package/bundled-test-harness/assets/{choreo-gallery-theater-test-BLA147xw.js → choreo-gallery-theater-test-kE535d-b.js} +3 -3
  93. package/bundled-test-harness/assets/{choreo-gallery-tuning-test-DSXiNzCh.js → choreo-gallery-tuning-test-6rHkQBnZ.js} +1 -1
  94. package/bundled-test-harness/assets/{choreo-gallery-widget-room-test-BI574PdO.js → choreo-gallery-widget-room-test-0Vq-uTo1.js} +2 -2
  95. package/bundled-test-harness/assets/{choreo-gallery-wires-test-B6Mz2WZl.js → choreo-gallery-wires-test-CdtnAWYO.js} +1 -1
  96. package/bundled-test-harness/assets/clip-BLfuHi8g.js +2 -0
  97. package/bundled-test-harness/assets/{clip-C_pXjVvY.js → clip-q1QE5f73.js} +1 -1
  98. package/bundled-test-harness/assets/{codeblocks-test-AdolhdCA.js → codeblocks-test-BDD7qK_V.js} +1 -1
  99. package/bundled-test-harness/assets/{compaction-test-DvyAQq56.js → compaction-test-BweolkhG.js} +1 -1
  100. package/bundled-test-harness/assets/{container-DIF_9lZ9.css → container-B5B6u-xm.css} +29 -22
  101. package/bundled-test-harness/assets/{container-DvvqmNpg.js → container-BnJjIM6J.js} +9 -6
  102. package/bundled-test-harness/assets/{create-listing-modal-test-D95JfQab.js → create-listing-modal-test-FvcAKpIa.js} +1 -1
  103. package/bundled-test-harness/assets/{debug-message-test-BcNtj7Wr.js → debug-message-test-CJ18Evt7.js} +1 -1
  104. package/bundled-test-harness/assets/{externals-6ZWftYIb.js → externals-BWjhmCD4.js} +12 -12
  105. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-5lnN1pv2.js → fallback-banner-live-delete-test-C2H3BrT-.js} +1 -1
  106. package/bundled-test-harness/assets/{fallback-banner-test-DFb_m_Kq.js → fallback-banner-test-C10aYjkj.js} +1 -1
  107. package/bundled-test-harness/assets/{fallback-models-test-p3YIwj1L.js → fallback-models-test-BxoPiaGF.js} +1 -1
  108. package/bundled-test-harness/assets/{file-attachment-test-CmyjNWRR.js → file-attachment-test-DeKyzYaz.js} +1 -1
  109. package/bundled-test-harness/assets/film-B68PjRlr.js +2 -0
  110. package/bundled-test-harness/assets/{film-D7vtJO5q.js → film-CeUpnHN_.js} +6 -6
  111. package/bundled-test-harness/assets/{film-DN0A8JHx.js → film-DBZqyqW4.js} +6 -6
  112. package/bundled-test-harness/assets/{general-test-CX-TFDuX.js → general-test-BewzrpoB.js} +1 -1
  113. package/bundled-test-harness/assets/{glimmer-motion-and-choreo-in-cards-test-BHte2t9G.js → glimmer-motion-and-choreo-in-cards-test-HA-INN3d.js} +14 -14
  114. package/bundled-test-harness/assets/{glimmer-motion-and-choreo-test-BPNVeIio.js → glimmer-motion-and-choreo-test-BZ0ZuuAw.js} +3 -3
  115. package/bundled-test-harness/assets/{index-query-engine-test-Dz7Q4Xmk.js → index-query-engine-test-BtI8Y2LR.js} +1 -1
  116. package/bundled-test-harness/assets/{instance-filter-matcher-test-BoLU3bVg.js → instance-filter-matcher-test-BHXHGHjE.js} +1 -1
  117. package/bundled-test-harness/assets/{main-Dlgp8c7v.js → main-CqAHZ2kK.js} +1 -1
  118. package/bundled-test-harness/assets/{message-tool-test-CfjRSJvt.js → message-tool-test-NMaPJFsd.js} +1 -1
  119. package/bundled-test-harness/assets/{model-cost-tier-test-mZG84Uso.js → model-cost-tier-test-BgbfN2LH.js} +1 -1
  120. package/bundled-test-harness/assets/{operation-lowering-test-B0KpI1xu.js → operation-lowering-test-C4IaOjsA.js} +1 -1
  121. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-KWS8lq16.js → operator-mode-card-chooser-test-Bwe47Rn7.js} +1 -1
  122. package/bundled-test-harness/assets/{operator-mode-links-test-CViFt0Ws.js → operator-mode-links-test-BU4jkC-B.js} +1 -1
  123. package/bundled-test-harness/assets/{operator-mode-test-D0gZM2yw.js → operator-mode-test-Bh0rGM9z.js} +1 -1
  124. package/bundled-test-harness/assets/{operator-mode-ui-test-CEvHJ9DS.js → operator-mode-ui-test-whgUw0ju.js} +1 -1
  125. package/bundled-test-harness/assets/{overlay-menu-items-test-Pw80RXsr.js → overlay-menu-items-test-Cwn1vr8q.js} +1 -1
  126. package/bundled-test-harness/assets/{overlays-BnOM360u.js → overlays-BOzLbVDK.js} +1 -1
  127. package/bundled-test-harness/assets/{overlays-kBVfa4i8.js → overlays-C_PuGDGh.js} +1 -1
  128. package/bundled-test-harness/assets/{past-sessions-test-CW0-Bjav.js → past-sessions-test-Bh24N-Kz.js} +1 -1
  129. package/bundled-test-harness/assets/{permission-checking-realm-test-D5uXQypS.js → permission-checking-realm-test-DKnnAy0f.js} +1 -1
  130. package/bundled-test-harness/assets/{plate-CFvCDv2t.js → plate-BL7UtyKE.js} +1 -1
  131. package/bundled-test-harness/assets/plate-CqG3igCJ.js +2 -0
  132. package/bundled-test-harness/assets/player-BJfsgdSI.js +2 -0
  133. package/bundled-test-harness/assets/{player-DzuYjrTU.js → player-Da_CIsp_.js} +1 -1
  134. package/bundled-test-harness/assets/{realm-config-policy-standing-test-Btatheni.js → realm-config-policy-standing-test-DngdYypq.js} +1 -1
  135. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-Dh5zW13o.js → realm-config-routing-rule-edit-test-_rkFU8SN.js} +1 -1
  136. package/bundled-test-harness/assets/{realm-config-settings-test-BJa8gv_C.js → realm-config-settings-test-CR9NH0Zn.js} +1 -1
  137. package/bundled-test-harness/assets/{reasoning-test-2Zp37cQB.js → reasoning-test-0lH10kRl.js} +1 -1
  138. package/bundled-test-harness/assets/{room-message-test-BsrON13e.js → room-message-test-C-PddE4Y.js} +1 -1
  139. package/bundled-test-harness/assets/{run-BMyvn_UL.js → run-D4n4LPcK.js} +96 -6
  140. package/bundled-test-harness/assets/{run-realm-code-BxktQf6R.js → run-realm-code-C8N7DtN-.js} +1 -1
  141. package/bundled-test-harness/assets/{run-realm-code-test-7grpTos-.js → run-realm-code-test-DBdp147q.js} +1 -1
  142. package/bundled-test-harness/assets/{schema-generation-test-CM9ggQji.js → schema-generation-test-CB1Hb7GQ.js} +1 -1
  143. package/bundled-test-harness/assets/{scrolling-test-BER00htB.js → scrolling-test-3PhVin8h.js} +1 -1
  144. package/bundled-test-harness/assets/{searchable-option-test-BBr5BPow.js → searchable-option-test-BAdOiL4U.js} +1 -1
  145. package/bundled-test-harness/assets/{sending-test-BJGobs2E.js → sending-test-KSmu1tSJ.js} +1 -1
  146. package/bundled-test-harness/assets/{skills-test-GPOsHRN6.js → skills-test-D-dlcFpd.js} +1 -1
  147. package/bundled-test-harness/assets/{src-Df148w_b.js → src-B_DEhPze.js} +4 -4
  148. package/bundled-test-harness/assets/{src-Cf2wv-Le.js → src-BoTgwqUA.js} +2 -2
  149. package/bundled-test-harness/assets/{store-test-QvvHimzH.js → store-test-CEW7xQjZ.js} +1 -1
  150. package/bundled-test-harness/assets/{test-support-rNHuieEc.js → test-support-FXDPpIl3.js} +1 -1
  151. package/bundled-test-harness/assets/{test-support-C6N_2X-l.js → test-support-VX6ukQNR.js} +1 -1
  152. package/bundled-test-harness/assets/{tests-CmsQ-Z_3.js → tests-QYqqa6nv.js} +84 -84
  153. package/bundled-test-harness/assets/{text-input-validator-test-CYYH2N0L.js → text-input-validator-test-D8iNOWep.js} +1 -1
  154. package/bundled-test-harness/assets/{titles-B1pTaIfP.js → titles-DFJj5O0E.js} +1 -1
  155. package/bundled-test-harness/assets/titles-xGlgu7ys.js +2 -0
  156. package/bundled-test-harness/assets/{to-device-streaming-test-Ctqa0bVp.js → to-device-streaming-test-trCX-505.js} +1 -1
  157. package/bundled-test-harness/assets/{token-usage-test-BQVbK3X0.js → token-usage-test-C6sULooH.js} +1 -1
  158. package/bundled-test-harness/assets/{tools-DU8Rc15s.js → tools-UJfD-_al.js} +1 -1
  159. package/bundled-test-harness/assets/{tools-test-B13yvEKE.js → tools-test-Brav31AV.js} +1 -1
  160. package/bundled-test-harness/assets/{worker-DHBEBihX.js → worker-9ph9_Lpv.js} +31 -26
  161. package/bundled-test-harness/index.html +8 -8
  162. package/bundled-test-harness/tests/index.html +8 -8
  163. package/bundled-types/choreo/beacons.ts +16 -4
  164. package/bundled-types/choreo/changeset.ts +25 -5
  165. package/bundled-types/choreo/choreo.gts +245 -3
  166. package/bundled-types/choreo/far.ts +39 -0
  167. package/bundled-types/choreo/index.ts +2 -0
  168. package/bundled-types/choreo/run.ts +104 -1
  169. package/bundled-types/host-app/lib/realm-runner/worker.ts +8 -51
  170. package/bundled-types/runtime-common/realm-runner-script.d.ts +3 -0
  171. package/package.json +1 -1
  172. package/bundled-test-harness/assets/choreo-B3Z9QhDH.js +0 -2
  173. package/bundled-test-harness/assets/clip-BjcXuTwN.js +0 -2
  174. package/bundled-test-harness/assets/film-DMDPP7ma.js +0 -2
  175. package/bundled-test-harness/assets/plate-66qnQ-M9.js +0 -2
  176. package/bundled-test-harness/assets/player-pygg1PwA.js +0 -2
  177. package/bundled-test-harness/assets/titles-Ccpcl-jq.js +0 -2
@@ -0,0 +1,1150 @@
1
+ // Pretui — StickToBottom: the scroll container of a live transcript. Follows new content while the reader is at the end, lets go the moment they read elsewhere, and keeps their line still when older history loads above.
2
+ import Component from '@glimmer/component';
3
+ import { tracked } from '@glimmer/tracking';
4
+ import { modifier } from 'ember-modifier';
5
+ import type Owner from '@ember/owner';
6
+
7
+ // ── The follow model ─────────────────────────────────────────────────────
8
+ // Two states, and the asymmetry between them is the design:
9
+ //
10
+ // following content growth scrolls to the end, instantly (a smooth
11
+ // catch-up animation behind a stream reads as lag)
12
+ // detached content growth leaves the viewport alone
13
+ //
14
+ // Detaching is easy on purpose: any upward user scroll of more than half a
15
+ // pixel, a wheel-up, or selecting text while the content is still changing.
16
+ // Re-pinning is deliberate: reaching within `@threshold` of the end, or a
17
+ // `scrollToEnd()` call. So a one-pixel nudge up lets go, and a collapse that
18
+ // shortens the content still clamps the reader to the end.
19
+ //
20
+ // No timer anywhere (realm law, and a re-arming timer would hang
21
+ // `await settled()`). The component's own scrolls are recognised by the
22
+ // position they were aiming at, not by a time window, and that record is
23
+ // cleared by the first scroll event that does not match it.
24
+
25
+ const UNPIN_EPSILON = 0.5;
26
+
27
+ // One device pixel in CSS pixels: the finest step a scroll can show.
28
+ const devicePixel = () => 1 / (globalThis.devicePixelRatio || 1);
29
+ // A scroll the component writes lands on a whole device pixel. A target
30
+ // between two let the browser round it one way and the next correction
31
+ // the other: the transcript shivered a pixel down and back while a row
32
+ // ran.
33
+ const toDevicePixel = (value: number) => {
34
+ let px = devicePixel();
35
+ return Math.round(value / px) * px;
36
+ };
37
+ // How long a reader's own move up from the end is remembered: nothing
38
+ // follows them back down (not the threshold, not growth, not a revision)
39
+ // until it passes, so momentum landing near the end doesn't undo it.
40
+ const UP_INTENT_MS = 1500;
41
+ const OWN_SCROLL_SLACK = 2;
42
+ const TRANSCRIPT_ID = 'data-transcript-id';
43
+ // How long after the reader's wheel, touch or key a scroll still counts as
44
+ // theirs (a wheel's momentum, a key's smooth scroll)
45
+ const INPUT_MS = 600;
46
+ // How long the element the reader pressed (a toggle, a choice, Undo) stays
47
+ // the anchor: what it opens or closes moves around it, not it
48
+ const PRESS_MS = 2000;
49
+ // How long the app's own navigation target (reveal) stays the anchor
50
+ const NAVIGATE_MS = 2000;
51
+ // The reading line: where in the viewport the reader's attention is taken
52
+ // to be, as a fraction of its height from the top
53
+ const READING_LINE = 1 / 3;
54
+ // Anchor candidates: messages, rows, steps, answer blocks. Picking stops at
55
+ // the first marked element under the reading line that fits in the view
56
+ const ANCHOR_ATTR = 'data-scroll-anchor';
57
+ const SCROLL_KEYS = new Set([
58
+ 'ArrowUp',
59
+ 'ArrowDown',
60
+ 'PageUp',
61
+ 'PageDown',
62
+ 'Home',
63
+ 'End',
64
+ ' ',
65
+ ]);
66
+ const NESTED_REGION = '[data-scroll-region]';
67
+
68
+ export type FollowChangeSource = 'user' | 'api';
69
+
70
+ export interface StickToBottomApi {
71
+ /** Whether content growth currently scrolls to the end. */
72
+ readonly following: boolean;
73
+ /** The scrolling viewport, once rendered. Not tracked: read it from a
74
+ * modifier or an action, not from a template. */
75
+ readonly scrollElement: HTMLElement | undefined;
76
+ /** Scroll to the end and follow again. `smooth` degrades to `auto` under
77
+ * reduced motion. */
78
+ scrollToEnd(behavior?: ScrollBehavior): void;
79
+ /** Bring the row carrying `data-transcript-id="<id>"` into view and stop
80
+ * following. Returns false when no such row is in the DOM. */
81
+ reveal(id: string, opts?: { block?: 'start' | 'center' }): boolean;
82
+ /** Bring an element in the content into view and stop following; it is
83
+ * the anchor while the scroll lands. The app's way to show something (a
84
+ * gate to review, the first unread message), instead of its own
85
+ * scrollIntoView, which would fight the anchor. */
86
+ navigate(
87
+ element: HTMLElement,
88
+ opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
89
+ ): void;
90
+ /** Hold the anchor now: one step of the scroll anchoring protocol. A host
91
+ * with a motion layer calls it after the render pass commits and before
92
+ * the motion layer measures, so the scroll lands before anything
93
+ * measures. */
94
+ settle(): void;
95
+ /** With `@holdTail`: rows that arrived after the reader detached, held
96
+ * back from the list until they return to the end. 0 otherwise. */
97
+ readonly pendingCount: number;
98
+ /** With `@holdTail`: `items` cut at the last row the reader had when they
99
+ * detached; the whole list otherwise. Use it as a helper:
100
+ * `{{#each (scroll.visibleItems this.rows) …}}` */
101
+ visibleItems<T extends { id: string }>(items: T[]): T[];
102
+ }
103
+
104
+ export interface StickToBottomSignature {
105
+ Args: {
106
+ /** Controlled following state. Pair with `@onFollowChange`. */
107
+ following?: boolean;
108
+ /** Uncontrolled initial state. Default true. */
109
+ defaultFollowing?: boolean;
110
+ /** Fires when following changes. `by` says whether the reader caused it
111
+ * (`user`) or a `scrollToEnd` / `reveal` call did (`api`). */
112
+ onFollowChange?: (following: boolean, by: FollowChangeSource) => void;
113
+ /** The content is still changing (a response is streaming). While
114
+ * active, selecting text detaches; when it turns false, a following
115
+ * viewport settles on the final end. */
116
+ active?: boolean;
117
+ /** Distance from the end, in px, that counts as "at the end" when
118
+ * re-pinning. Default 8. */
119
+ threshold?: number;
120
+ /** Called once each time the reader scrolls up into `@startMargin` of the
121
+ * top. The reader's line is held still across whatever it prepends. */
122
+ onReachStart?: () => Promise<void> | void;
123
+ /** Distance from the top, in px, that triggers `@onReachStart`. Default 400. */
124
+ startMargin?: number;
125
+ /** Accessible name. Supplying one promotes the viewport to a `region`. */
126
+ label?: string;
127
+ /** Hold the tail while the reader is away: rows arriving after they
128
+ * detach stay out of the list (render `scroll.visibleItems(rows)`) and
129
+ * are counted in `scroll.pendingCount`, then land together when they
130
+ * return to the end. Needs `@itemIds`. */
131
+ holdTail?: boolean;
132
+ /** the ids of the caller's rows, in order. Read with `@holdTail` */
133
+ itemIds?: string[];
134
+ /** Any value that changes when the content changes (a message lands, a
135
+ * row grows). While following, a change pins to the end in the same
136
+ * render commit, before anything measures the frame after the render,
137
+ * so a motion layer sees the follow as part of the pass instead of a
138
+ * jump after it. Without it, growth is pinned by the resize observer. */
139
+ revision?: unknown;
140
+ /** Turn on the prominence ramp inside the transcript: settled work
141
+ * (ToolRow, ToolGroup, WorkItem) recedes to this opacity. true means
142
+ * 0.45 */
143
+ recede?: boolean | number;
144
+ };
145
+ Blocks: {
146
+ default: [StickToBottomApi];
147
+ /** Rendered over the viewport, positioned against the component's root:
148
+ * the JumpChip slot. */
149
+ overlay: [StickToBottomApi];
150
+ };
151
+ Element: HTMLDivElement;
152
+ }
153
+
154
+ function prefersReducedMotion(): boolean {
155
+ return (
156
+ typeof window !== 'undefined' &&
157
+ typeof window.matchMedia === 'function' &&
158
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
159
+ );
160
+ }
161
+
162
+ type RecordedAnchor =
163
+ | { kind: 'end'; gap: number }
164
+ | {
165
+ kind: 'navigation' | 'pressed' | 'reading';
166
+ chain: { el: HTMLElement; offset: number; top: number }[];
167
+ };
168
+
169
+ function distanceFromEnd(el: HTMLElement): number {
170
+ return el.scrollHeight - el.clientHeight - el.scrollTop;
171
+ }
172
+
173
+ /** Installs every listener and observer once, for the component's lifetime.
174
+ * It takes only the component, which never changes, so it never re-runs. */
175
+ const follow = modifier((viewport: HTMLElement, [owner]: [StickToBottom]) =>
176
+ owner.install(viewport),
177
+ );
178
+
179
+ /** Sets one CSS custom property on the element, or removes it, without
180
+ * touching the rest of its style attribute (which may be the caller's). */
181
+ const customProperty = modifier(
182
+ (el: HTMLElement, [name, value]: [string, string | undefined]) => {
183
+ if (value === undefined) {
184
+ el.style.removeProperty(name);
185
+ } else {
186
+ el.style.setProperty(name, value);
187
+ }
188
+ return () => el.style.removeProperty(name);
189
+ },
190
+ );
191
+
192
+ /** Reports a value to a callback on install and on every change, so a
193
+ * component can react to an argument changing without a side-effecting
194
+ * getter. */
195
+ const watch = modifier(
196
+ (
197
+ _el: HTMLElement,
198
+ [callback, value]: [(value: unknown) => void, unknown],
199
+ ) => {
200
+ callback(value);
201
+ },
202
+ );
203
+
204
+ /**
205
+ * The scroll owner of a live transcript.
206
+ *
207
+ * ```hbs
208
+ * <StickToBottom @active={{this.generating}} @label='Conversation'>
209
+ * <:default as |scroll|>…rows, each carrying data-transcript-id…</:default>
210
+ * <:overlay as |scroll|>
211
+ * <JumpChip @visible={{not scroll.following}} @onJump={{fn scroll.scrollToEnd 'smooth'}} />
212
+ * </:overlay>
213
+ * </StickToBottom>
214
+ * ```
215
+ *
216
+ * Its own scrolls are recognised by the position they aim at, not by a timer
217
+ * window. Something the reader opens that grows the content out of view
218
+ * detaches instead of being pulled back to the end. Holding the reader's
219
+ * line when history loads above is part of the component, not left to each
220
+ * caller. The rules it holds are ADR 012.
221
+ */
222
+ export class StickToBottom extends Component<StickToBottomSignature> {
223
+ @tracked private internalFollowing = this.args.defaultFollowing ?? true;
224
+ @tracked anchoring = false;
225
+ /** with @holdTail: the last row id the reader had when they detached */
226
+ @tracked private heldAfter: string | undefined;
227
+
228
+ private viewport: HTMLElement | undefined;
229
+ private content: HTMLElement | undefined;
230
+ /** scrollTop the component itself is scrolling to, until a scroll event
231
+ * shows something else moved the viewport. */
232
+ private ownTarget: number | undefined;
233
+ /** whether the scroll to `ownTarget` is a smooth one, which spans frames */
234
+ private ownSmooth = false;
235
+ private get smoothInFlight(): boolean {
236
+ return this.ownSmooth && this.ownTarget !== undefined;
237
+ }
238
+ private lastTop = 0;
239
+ /** when the reader last moved up from the end themselves */
240
+ private leftAt = -Infinity;
241
+ private get holdingIntent() {
242
+ return performance.now() - this.leftAt < UP_INTENT_MS;
243
+ }
244
+ private lastActive = false;
245
+ private reachPending = false;
246
+ private wasInStartMargin = false;
247
+ /** the anchor held through the next settle: what it is, the element (and
248
+ * its marked ancestors, to fall back on if it leaves), its offset from
249
+ * the viewport's top and its top, by layout, or the gap to the end when it
250
+ * is the end */
251
+ private recorded: RecordedAnchor | undefined;
252
+ /** the element the reader last pressed in the content, and when */
253
+ private pressed: { el: HTMLElement; at: number } | undefined;
254
+ /** the element the app last brought into view, and when */
255
+ private navigation: { el: HTMLElement; at: number } | undefined;
256
+ /** when the reader last scrolled by hand (wheel, touch, a scrolling key)
257
+ * and whether a pointer is down on the viewport (its scrollbar, a drag):
258
+ * only those moves are theirs */
259
+ private inputAt = -Infinity;
260
+ /** the content's and viewport's heights at the last scroll */
261
+ private lastHeights: [number, number] = [0, 0];
262
+ private sizeChanged(el: HTMLElement): boolean {
263
+ return (
264
+ el.scrollHeight !== this.lastHeights[0] ||
265
+ el.clientHeight !== this.lastHeights[1]
266
+ );
267
+ }
268
+ private pointerHeld = false;
269
+ private get readerScrolling(): boolean {
270
+ return this.pointerHeld || performance.now() - this.inputAt < INPUT_MS;
271
+ }
272
+ private noteInput = () => {
273
+ this.inputAt = performance.now();
274
+ // the reader's hand on the scroll takes over from what they pressed and
275
+ // from any navigation: from here the reading line is the anchor
276
+ this.pressed = undefined;
277
+ this.navigation = undefined;
278
+ };
279
+ // A press on the viewport itself is its scrollbar (or a drag on its
280
+ // bare edge): a scroll that follows is the reader's. A press on the
281
+ // content is a click: what it opens or closes is not a scroll, so a
282
+ // collapse that pulls the position up must not let go of the end.
283
+ private onPointerDown = (event: PointerEvent) => {
284
+ if (event.target === this.viewport) {
285
+ this.pointerHeld = true;
286
+ this.noteInput();
287
+ return;
288
+ }
289
+ this.press(event.target);
290
+ };
291
+ private onPointerRelease = () => {
292
+ if (this.pointerHeld) {
293
+ this.pointerHeld = false;
294
+ this.noteInput();
295
+ }
296
+ };
297
+ // Listened for on the owner document: after a click on text that cannot
298
+ // take focus, a scrolling key's target is the body, and the browser
299
+ // scrolls the viewport the click was in.
300
+ private onScrollKey = (event: KeyboardEvent) => {
301
+ let target = event.target;
302
+ let inViewport =
303
+ target instanceof Node && Boolean(this.viewport?.contains(target));
304
+ let onBody =
305
+ target === this.viewport?.ownerDocument.body ||
306
+ target === this.viewport?.ownerDocument.documentElement;
307
+ if (!inViewport && !onBody) {
308
+ return;
309
+ }
310
+ if (inViewport && (event.key === 'Enter' || event.key === ' ')) {
311
+ // a press on a focused control: Space on the viewport itself scrolls
312
+ if (target !== this.viewport) {
313
+ this.press(target);
314
+ return;
315
+ }
316
+ }
317
+ if (SCROLL_KEYS.has(event.key)) {
318
+ this.noteInput();
319
+ }
320
+ };
321
+ // Focus that lands in the content, other than by a pointer press, makes
322
+ // the focused element the anchor: the browser scrolls it into view (Tab,
323
+ // a skip link, the app's focus()), and that scroll is not a clamp to undo.
324
+ // The anchor is recorded again at the next settle or scroll, after the
325
+ // browser's scroll has landed.
326
+ private onFocusIn = (event: FocusEvent) => {
327
+ let target = event.target;
328
+ if (!(target instanceof HTMLElement) || !this.content?.contains(target)) {
329
+ return;
330
+ }
331
+ let pressed = this.pressed;
332
+ if (
333
+ pressed &&
334
+ performance.now() - pressed.at < INPUT_MS &&
335
+ target.contains(pressed.el)
336
+ ) {
337
+ return;
338
+ }
339
+ this.noteInput();
340
+ this.navigation = { el: target, at: performance.now() };
341
+ this.recorded = undefined;
342
+ };
343
+
344
+ /** The reader pressed something in the content: it is the anchor while
345
+ * what it opens or closes lands. */
346
+ private press(target: EventTarget | null) {
347
+ if (!(target instanceof HTMLElement) || !this.content?.contains(target)) {
348
+ return;
349
+ }
350
+ this.pressed = { el: target, at: performance.now() };
351
+ this.record();
352
+ }
353
+
354
+ readonly api: StickToBottomApi;
355
+
356
+ constructor(owner: Owner, args: StickToBottomSignature['Args']) {
357
+ super(owner, args);
358
+ let component = this;
359
+ this.api = {
360
+ get following() {
361
+ return component.following;
362
+ },
363
+ get scrollElement() {
364
+ return component.viewport;
365
+ },
366
+ scrollToEnd: (behavior?: ScrollBehavior) => this.scrollToEnd(behavior),
367
+ settle: () => this.settle(),
368
+ navigate: (
369
+ element: HTMLElement,
370
+ opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
371
+ ) => this.navigate(element, opts),
372
+ reveal: (id: string, opts?: { block?: 'start' | 'center' }) =>
373
+ this.reveal(id, opts),
374
+ get pendingCount() {
375
+ return component.pendingCount;
376
+ },
377
+ visibleItems: <T extends { id: string }>(items: T[]) =>
378
+ component.visibleItems(items),
379
+ };
380
+ }
381
+
382
+ get following(): boolean {
383
+ return this.args.following ?? this.internalFollowing;
384
+ }
385
+ get threshold(): number {
386
+ return this.args.threshold ?? 8;
387
+ }
388
+ get startMargin(): number {
389
+ return this.args.startMargin ?? 400;
390
+ }
391
+ get role(): string | undefined {
392
+ return this.args.label ? 'region' : undefined;
393
+ }
394
+ get recedeOpacity(): string | undefined {
395
+ let recede = this.args.recede;
396
+ if (recede === undefined || recede === false) {
397
+ return undefined;
398
+ }
399
+ return String(recede === true ? 0.45 : Math.min(1, Math.max(0, recede)));
400
+ }
401
+
402
+ // ── holding the tail ───────────────────────────────────────────────────
403
+
404
+ private get holding(): boolean {
405
+ return (
406
+ Boolean(this.args.holdTail) &&
407
+ !this.following &&
408
+ this.heldAfter !== undefined
409
+ );
410
+ }
411
+ get pendingCount(): number {
412
+ if (!this.holding) {
413
+ return 0;
414
+ }
415
+ let ids = this.args.itemIds ?? [];
416
+ let index = ids.indexOf(this.heldAfter!);
417
+ return index < 0 ? 0 : ids.length - index - 1;
418
+ }
419
+ visibleItems<T extends { id: string }>(items: T[]): T[] {
420
+ if (!this.holding) {
421
+ return items;
422
+ }
423
+ let index = items.findIndex((item) => item.id === this.heldAfter);
424
+ return index < 0 ? items : items.slice(0, index + 1);
425
+ }
426
+
427
+ // ── state changes ──────────────────────────────────────────────────────
428
+
429
+ private setFollowing(next: boolean, by: FollowChangeSource) {
430
+ if (next === this.following) {
431
+ return;
432
+ }
433
+ if (this.args.following === undefined) {
434
+ this.internalFollowing = next;
435
+ }
436
+ if (this.args.holdTail) {
437
+ // leaving: freeze the list at what the reader had; returning: release
438
+ let ids = this.args.itemIds ?? [];
439
+ this.heldAfter = next ? undefined : ids[ids.length - 1];
440
+ }
441
+ this.args.onFollowChange?.(next, by);
442
+ }
443
+
444
+ /** Programmatic scroll; the scroll event it causes is recognised as ours. */
445
+ private scrollTo(top: number, behavior: ScrollBehavior = 'auto') {
446
+ let el = this.viewport;
447
+ if (!el) {
448
+ return;
449
+ }
450
+ let max = Math.max(0, el.scrollHeight - el.clientHeight);
451
+ let target = toDevicePixel(Math.min(Math.max(0, top), max));
452
+ if (
453
+ Math.abs(el.scrollTop - target) < Math.max(UNPIN_EPSILON, devicePixel())
454
+ ) {
455
+ // no scroll event will come, so leave no record waiting for one
456
+ return;
457
+ }
458
+ this.ownTarget = target;
459
+ this.ownSmooth = behavior === 'smooth' && !prefersReducedMotion();
460
+ if (this.ownSmooth) {
461
+ el.scrollTo({ top: target, behavior: 'smooth' });
462
+ } else {
463
+ // assigning scrollTop bypasses any CSS scroll-behavior: smooth
464
+ el.scrollTop = target;
465
+ }
466
+ }
467
+
468
+ private pinToEnd() {
469
+ let el = this.viewport;
470
+ if (el && distanceFromEnd(el) > UNPIN_EPSILON) {
471
+ this.scrollTo(el.scrollHeight);
472
+ }
473
+ }
474
+
475
+ scrollToEnd = (behavior?: ScrollBehavior) => {
476
+ let el = this.viewport;
477
+ if (!el) {
478
+ return;
479
+ }
480
+ this.setFollowing(true, 'api');
481
+ this.scrollTo(el.scrollHeight, behavior ?? 'auto');
482
+ };
483
+
484
+ reveal = (id: string, opts?: { block?: 'start' | 'center' }): boolean => {
485
+ let el = this.viewport;
486
+ let target = this.findRow(id);
487
+ if (!el || !target) {
488
+ return false;
489
+ }
490
+ this.setFollowing(false, 'api');
491
+ this.navigation = { el: target, at: performance.now() };
492
+ let offset = this.offsetOf(target);
493
+ if (opts?.block === 'center') {
494
+ offset -=
495
+ (el.clientHeight -
496
+ target.getBoundingClientRect().height / this.scale()) /
497
+ 2;
498
+ }
499
+ this.scrollTo(el.scrollTop + offset);
500
+ return true;
501
+ };
502
+
503
+ navigate = (
504
+ element: HTMLElement,
505
+ opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
506
+ ) => {
507
+ let el = this.viewport;
508
+ if (!el || !this.content?.contains(element)) {
509
+ return;
510
+ }
511
+ this.setFollowing(false, 'api');
512
+ this.navigation = { el: element, at: performance.now() };
513
+ let offset = this.offsetOf(element);
514
+ if (opts?.block === 'center') {
515
+ offset -=
516
+ (el.clientHeight -
517
+ element.getBoundingClientRect().height / this.scale()) /
518
+ 2;
519
+ }
520
+ this.scrollTo(el.scrollTop + offset, opts?.behavior ?? 'auto');
521
+ };
522
+
523
+ /** Ratio of rendered to layout size. Bounding rects come back transformed
524
+ * (a zoomed or scaled ancestor), scrollTop does not, so every rect delta is
525
+ * divided by this before it is added to a scroll position. */
526
+ private scale(): number {
527
+ let el = this.viewport;
528
+ if (!el || el.offsetHeight === 0) {
529
+ return 1;
530
+ }
531
+ let rendered = el.getBoundingClientRect().height;
532
+ return rendered > 0 ? rendered / el.offsetHeight : 1;
533
+ }
534
+
535
+ /** A row's distance below the viewport's top edge, in layout pixels. */
536
+ private offsetOf(row: HTMLElement): number {
537
+ let el = this.viewport;
538
+ if (!el) {
539
+ return 0;
540
+ }
541
+ return (
542
+ (row.getBoundingClientRect().top - el.getBoundingClientRect().top) /
543
+ this.scale()
544
+ );
545
+ }
546
+
547
+ private findRow(id: string): HTMLElement | undefined {
548
+ let el = this.viewport;
549
+ if (!el) {
550
+ return undefined;
551
+ }
552
+ for (let row of Array.from(
553
+ el.querySelectorAll<HTMLElement>(`[${TRANSCRIPT_ID}]`),
554
+ )) {
555
+ if (row.getAttribute(TRANSCRIPT_ID) === id) {
556
+ return row;
557
+ }
558
+ }
559
+ return undefined;
560
+ }
561
+
562
+ // ── argument watchers ──────────────────────────────────────────────────
563
+
564
+ activeChanged = (value: unknown) => {
565
+ let active = Boolean(value);
566
+ if (this.lastActive && !active) {
567
+ // the stream settled: hold the anchor on its final layout
568
+ this.settle({ inRender: true });
569
+ }
570
+ this.lastActive = active;
571
+ };
572
+
573
+ // the content changed in this render: the anchor is held in the same
574
+ // commit (not the frame after, when the resize observer would), so a
575
+ // motion layer measuring this pass sees the result, not a jump after it
576
+ revisionChanged = () => {
577
+ this.settle({ inRender: true });
578
+ };
579
+
580
+ followingArgChanged = (value: unknown) => {
581
+ // a controlling caller switching following back on means "go to the end"
582
+ if (value === true) {
583
+ this.pinToEnd();
584
+ }
585
+ };
586
+
587
+ // ── listeners ──────────────────────────────────────────────────────────
588
+
589
+ private onScroll = () => {
590
+ let el = this.viewport;
591
+ if (!el) {
592
+ return;
593
+ }
594
+ let top = el.scrollTop;
595
+ let movedUp = top < this.lastTop - UNPIN_EPSILON;
596
+
597
+ let own = false;
598
+ if (this.ownTarget !== undefined) {
599
+ let before = this.lastTop - this.ownTarget;
600
+ let now = top - this.ownTarget;
601
+ if (Math.abs(now) <= OWN_SCROLL_SLACK) {
602
+ own = true;
603
+ this.ownTarget = undefined;
604
+ } else if (!movedUp && now < 0) {
605
+ // still on its way down to the target
606
+ own = true;
607
+ } else if (
608
+ this.ownSmooth &&
609
+ !this.readerScrolling &&
610
+ Math.sign(now) === Math.sign(before) &&
611
+ Math.abs(now) < Math.abs(before)
612
+ ) {
613
+ // a smooth scroll still on its way up to the target
614
+ own = true;
615
+ } else {
616
+ this.ownTarget = undefined;
617
+ }
618
+ }
619
+
620
+ if (!own && this.readerScrolling) {
621
+ // a touch's momentum, or a wheel's, scrolls on after the last input
622
+ // event: each of its scrolls keeps the move the reader's
623
+ this.inputAt = performance.now();
624
+ }
625
+
626
+ if (!own) {
627
+ // the reader's own move up wins, even a small one near the end, and
628
+ // is remembered for a moment
629
+ if (movedUp && !this.readerScrolling && this.sizeChanged(el)) {
630
+ // the position fell without the reader's hand because the layout
631
+ // moved: content shrank or the viewport grew. The anchor recorded
632
+ // before it is still the one to hold; settle puts it back. (A move
633
+ // up with no size change is a scroll someone asked for, the app's
634
+ // scrollIntoView included.)
635
+ this.lastTop = top;
636
+ this.settle();
637
+ this.writeEdges();
638
+ return;
639
+ } else if (movedUp) {
640
+ this.leftAt = performance.now();
641
+ this.setFollowing(false, 'user');
642
+ } else if (
643
+ distanceFromEnd(el) <= this.threshold &&
644
+ // the reader scrolling back down to the end follows at once; the
645
+ // end arriving under them by itself (growth, a reflow) waits out
646
+ // their move up
647
+ (top > this.lastTop + UNPIN_EPSILON || !this.holdingIntent)
648
+ ) {
649
+ this.setFollowing(true, 'user');
650
+ }
651
+ }
652
+
653
+ let inStartMargin = top <= this.startMargin;
654
+ if (inStartMargin && !this.wasInStartMargin && movedUp) {
655
+ void this.reachStart();
656
+ }
657
+ this.wasInStartMargin = inStartMargin;
658
+ this.lastTop = top;
659
+ this.lastHeights = [el.scrollHeight, el.clientHeight];
660
+ // a scroll on a layout that changed since the anchor was recorded (a
661
+ // late event from our own earlier scroll, landing after content grew)
662
+ // must not take the changed layout as the new baseline: hold the
663
+ // anchor through the change first
664
+ if (this.layoutChangedSinceRecord(el)) {
665
+ this.settle();
666
+ } else {
667
+ this.record();
668
+ }
669
+ this.writeEdges();
670
+ };
671
+
672
+ /** the content's and viewport's heights when the anchor was recorded */
673
+ private recordedHeights: [number, number] = [0, 0];
674
+ private layoutChangedSinceRecord(el: HTMLElement): boolean {
675
+ return (
676
+ el.scrollHeight !== this.recordedHeights[0] ||
677
+ el.clientHeight !== this.recordedHeights[1]
678
+ );
679
+ }
680
+
681
+ private onWheel = (event: WheelEvent) => {
682
+ this.noteInput();
683
+ if (event.deltaY >= 0) {
684
+ return;
685
+ }
686
+ let target = event.target instanceof Element ? event.target : null;
687
+ let nested = target?.closest(NESTED_REGION);
688
+ if (nested && nested !== this.viewport && this.viewport?.contains(nested)) {
689
+ return;
690
+ }
691
+ let el = this.viewport;
692
+ if (el && el.scrollHeight - el.clientHeight > 1) {
693
+ this.leftAt = performance.now();
694
+ this.setFollowing(false, 'user');
695
+ }
696
+ };
697
+
698
+ private onPointerUp = () => {
699
+ if (!this.args.active) {
700
+ return;
701
+ }
702
+ let selection =
703
+ typeof window !== 'undefined' ? window.getSelection() : null;
704
+ if (
705
+ selection &&
706
+ !selection.isCollapsed &&
707
+ selection.anchorNode &&
708
+ this.viewport?.contains(selection.anchorNode)
709
+ ) {
710
+ this.setFollowing(false, 'user');
711
+ }
712
+ };
713
+
714
+ private onResize = () => {
715
+ let el = this.viewport;
716
+ if (!el) {
717
+ return;
718
+ }
719
+ this.settle();
720
+ this.lastTop = el.scrollTop;
721
+ // a frame's scroll events run before its resize callbacks, so a clamp
722
+ // that a shrink caused still compares against the heights before it
723
+ this.lastHeights = [el.scrollHeight, el.clientHeight];
724
+ this.writeEdges();
725
+ };
726
+
727
+ // ── the anchor ─────────────────────────────────────────────────────────
728
+ // One invariant, held in every settle: one element keeps its distance
729
+ // from the viewport's top, by layout, from one settle to the next; or,
730
+ // when the anchor is the end, the content's bottom keeps its distance from
731
+ // the viewport's bottom. The anchor is chosen by priority:
732
+ //
733
+ // 1. the app's navigation target (reveal), for a moment after it
734
+ // 2. the element the reader pressed (a toggle, a choice), for a moment
735
+ // after, unless they have scrolled since
736
+ // 3. the end, while following
737
+ // 4. the reading line: the element a third of the way down the view
738
+ //
739
+ // Positions are read from layout (offsets), which a motion layer's
740
+ // transforms do not move, so the correction and the motion never fight.
741
+ // See packages/pretui/docs/scroll-anchoring-protocol.md.
742
+
743
+ /** an element's top by layout, which transforms do not move */
744
+ private layoutTop(el: HTMLElement): number {
745
+ let y = 0;
746
+ for (
747
+ let e: HTMLElement | null = el;
748
+ e;
749
+ e = e.offsetParent as HTMLElement | null
750
+ ) {
751
+ y += e.offsetTop;
752
+ }
753
+ return y;
754
+ }
755
+
756
+ /** The element under the reading line: down through the content to the
757
+ * first marked anchor that fits in the view, or the smallest element the
758
+ * line crosses. */
759
+ private readingElement(): HTMLElement | undefined {
760
+ let el = this.viewport;
761
+ let content = this.content;
762
+ if (!el || !content) {
763
+ return undefined;
764
+ }
765
+ let view = el.getBoundingClientRect();
766
+ let line = view.top + view.height * READING_LINE;
767
+ let pick: HTMLElement | undefined;
768
+ let level: Element = content;
769
+ for (let depth = 0; depth < 10; depth++) {
770
+ // in flow only: a motion layer's overlays sit absolutely over
771
+ // everything and are never what the reader reads
772
+ let inFlow = (child: Element) =>
773
+ getComputedStyle(child).position !== 'absolute';
774
+ let next: HTMLElement | undefined;
775
+ let below: HTMLElement | undefined;
776
+ for (let child of Array.from(level.children)) {
777
+ let rect = child.getBoundingClientRect();
778
+ if (rect.height === 0) {
779
+ continue;
780
+ }
781
+ if (rect.top <= line && rect.bottom > line && inFlow(child)) {
782
+ next = child as HTMLElement;
783
+ break;
784
+ }
785
+ if (!below && rect.top > line && inFlow(child)) {
786
+ below = child as HTMLElement;
787
+ }
788
+ }
789
+ // the line falls in a gap: the first element below it
790
+ next ??= below;
791
+ if (!next) {
792
+ break;
793
+ }
794
+ pick = next;
795
+ let rect = next.getBoundingClientRect();
796
+ if (next.hasAttribute(ANCHOR_ATTR) && rect.height <= view.height) {
797
+ break;
798
+ }
799
+ if (rect.top > line) {
800
+ break;
801
+ }
802
+ level = next;
803
+ }
804
+ return pick;
805
+ }
806
+
807
+ /** The anchor the next settle holds, by the priority above. */
808
+ private chooseAnchor():
809
+ | { kind: 'end' }
810
+ | { kind: 'navigation' | 'pressed' | 'reading'; el: HTMLElement }
811
+ | undefined {
812
+ let now = performance.now();
813
+ let navigation = this.navigation;
814
+ if (
815
+ navigation &&
816
+ navigation.el.isConnected &&
817
+ now - navigation.at < NAVIGATE_MS
818
+ ) {
819
+ return { kind: 'navigation', el: navigation.el };
820
+ }
821
+ let pressed = this.pressed;
822
+ if (pressed && pressed.el.isConnected && now - pressed.at < PRESS_MS) {
823
+ return { kind: 'pressed', el: pressed.el };
824
+ }
825
+ if (this.following) {
826
+ return { kind: 'end' };
827
+ }
828
+ let reading = this.readingElement();
829
+ return reading ? { kind: 'reading', el: reading } : undefined;
830
+ }
831
+
832
+ /** Record the anchor and where it sits now. */
833
+ private record() {
834
+ let el = this.viewport;
835
+ let anchor = this.chooseAnchor();
836
+ if (el) {
837
+ this.recordedHeights = [el.scrollHeight, el.clientHeight];
838
+ }
839
+ if (!el || !anchor) {
840
+ this.recorded = undefined;
841
+ return;
842
+ }
843
+ if (anchor.kind === 'end') {
844
+ this.recorded = { kind: 'end', gap: distanceFromEnd(el) };
845
+ return;
846
+ }
847
+ // the element and its marked ancestors: if what was pressed leaves the
848
+ // DOM (a choice collapsing into its answer), its row holds instead
849
+ let chain: HTMLElement[] = [anchor.el];
850
+ for (
851
+ let up = anchor.el.parentElement?.closest<HTMLElement>(
852
+ `[${ANCHOR_ATTR}]`,
853
+ );
854
+ up && this.content?.contains(up);
855
+ up = up.parentElement?.closest<HTMLElement>(`[${ANCHOR_ATTR}]`)
856
+ ) {
857
+ chain.push(up);
858
+ }
859
+ this.recorded = {
860
+ kind: anchor.kind,
861
+ chain: chain.map((node) => {
862
+ let top = this.layoutTop(node);
863
+ return { el: node, offset: top - el.scrollTop, top };
864
+ }),
865
+ };
866
+ }
867
+
868
+ /** Hold the recorded anchor through whatever changed since, then record
869
+ * again. Runs at every render commit, every resize, and when a stream
870
+ * settles. `inRender` when a render commit calls it: a change to
871
+ * following then waits for the commit to end, so the caller's
872
+ * `@onFollowChange` never updates state the render has already read. */
873
+ private settle(opts?: { inRender?: boolean }) {
874
+ let el = this.viewport;
875
+ let recorded = this.recorded;
876
+ if (!el) {
877
+ return;
878
+ }
879
+ let minDrift = Math.max(UNPIN_EPSILON, devicePixel());
880
+ if (this.smoothInFlight) {
881
+ // The component's own smooth scroll is on its way: writing a position
882
+ // now would cut it short. Its target moves by what layout moved.
883
+ let target = this.ownTarget!;
884
+ if (this.following) {
885
+ target = el.scrollHeight;
886
+ } else if (recorded && recorded.kind !== 'end') {
887
+ let held = recorded.chain.find((entry) => entry.el.isConnected);
888
+ if (held) {
889
+ target += this.layoutTop(held.el) - held.top;
890
+ }
891
+ }
892
+ let max = Math.max(0, el.scrollHeight - el.clientHeight);
893
+ if (Math.abs(Math.min(target, max) - this.ownTarget!) >= minDrift) {
894
+ this.scrollTo(target, 'smooth');
895
+ }
896
+ } else if (recorded?.kind === 'end') {
897
+ if (this.following) {
898
+ this.pinToEnd();
899
+ }
900
+ } else if (recorded) {
901
+ let held = recorded.chain.find((entry) => entry.el.isConnected);
902
+ if (held) {
903
+ // While the reader scrolls, the position is theirs: a settle can run
904
+ // between their scroll and its scroll event (a fast wheel), and
905
+ // holding the recorded offset would take their last step back. Only
906
+ // what layout moved is held. Otherwise the offset is, which also
907
+ // undoes a clamp when content shrank.
908
+ let drift = this.readerScrolling
909
+ ? this.layoutTop(held.el) - held.top
910
+ : this.layoutTop(held.el) - el.scrollTop - held.offset;
911
+ // less than a device pixel of drift is rounding, not movement
912
+ if (Math.abs(drift) >= minDrift) {
913
+ this.scrollTo(el.scrollTop + drift);
914
+ }
915
+ }
916
+ // a reader at the end who opened something, or moved focus to
917
+ // something, that grew past it is reading it now, not following the
918
+ // end
919
+ if (
920
+ recorded.kind !== 'reading' &&
921
+ this.following &&
922
+ distanceFromEnd(el) > this.threshold
923
+ ) {
924
+ this.detachForAnchor(Boolean(opts?.inRender));
925
+ }
926
+ }
927
+ this.record();
928
+ }
929
+
930
+ private detachForAnchor(inRender: boolean) {
931
+ if (!inRender) {
932
+ this.setFollowing(false, 'user');
933
+ return;
934
+ }
935
+ queueMicrotask(() => {
936
+ if (this.isDestroying || this.isDestroyed) {
937
+ return;
938
+ }
939
+ let el = this.viewport;
940
+ if (el && this.following && distanceFromEnd(el) > this.threshold) {
941
+ this.setFollowing(false, 'user');
942
+ }
943
+ });
944
+ }
945
+
946
+ private async reachStart() {
947
+ let load = this.args.onReachStart;
948
+ if (!load || this.reachPending) {
949
+ return;
950
+ }
951
+ this.reachPending = true;
952
+ this.anchoring = true;
953
+ this.record();
954
+ try {
955
+ await load();
956
+ } catch (error) {
957
+ // the caller's load failed: the reader can scroll to the start again
958
+ // to retry, and the error is reported, not left unhandled
959
+ console.error(error);
960
+ } finally {
961
+ // The prepend may already be laid out, or may land on the next resize:
962
+ // hold the reader's line now and once more a frame later.
963
+ this.settle();
964
+ let release = () => {
965
+ this.settle();
966
+ this.reachPending = false;
967
+ this.anchoring = false;
968
+ };
969
+ if (typeof requestAnimationFrame === 'function') {
970
+ requestAnimationFrame(release);
971
+ } else {
972
+ release();
973
+ }
974
+ }
975
+ }
976
+
977
+ // ── edges and focus ────────────────────────────────────────────────────
978
+
979
+ /** Written as attributes, read only by CSS: a scroll costs no render. */
980
+ private writeEdges() {
981
+ let el = this.viewport;
982
+ if (!el) {
983
+ return;
984
+ }
985
+ let scrollable = el.scrollHeight - el.clientHeight > 1;
986
+ el.dataset['startY'] = scrollable && el.scrollTop > 1 ? 'clipped' : 'flush';
987
+ // keyboard-reachable exactly when there is something to scroll
988
+ if (scrollable) {
989
+ if (el.getAttribute('tabindex') === null) {
990
+ el.setAttribute('tabindex', '0');
991
+ }
992
+ } else if (el.getAttribute('tabindex') === '0') {
993
+ el.removeAttribute('tabindex');
994
+ }
995
+ }
996
+
997
+ // ── lifetime ───────────────────────────────────────────────────────────
998
+
999
+ install(viewport: HTMLElement): () => void {
1000
+ this.viewport = viewport;
1001
+ this.content =
1002
+ (viewport.querySelector(
1003
+ '[data-stick-to-bottom-content]',
1004
+ ) as HTMLElement | null) ?? undefined;
1005
+
1006
+ viewport.addEventListener('scroll', this.onScroll, { passive: true });
1007
+ viewport.addEventListener('wheel', this.onWheel, { passive: true });
1008
+ viewport.addEventListener('pointerup', this.onPointerUp);
1009
+ viewport.addEventListener('pointerdown', this.onPointerDown);
1010
+ viewport.addEventListener('touchmove', this.noteInput, { passive: true });
1011
+ viewport.addEventListener('focusin', this.onFocusIn);
1012
+ let doc = viewport.ownerDocument;
1013
+ doc.addEventListener('keydown', this.onScrollKey);
1014
+ window.addEventListener('pointerup', this.onPointerRelease);
1015
+ window.addEventListener('pointercancel', this.onPointerRelease);
1016
+
1017
+ let observer: ResizeObserver | undefined;
1018
+ if (typeof ResizeObserver !== 'undefined') {
1019
+ observer = new ResizeObserver(this.onResize);
1020
+ observer.observe(viewport);
1021
+ if (this.content) {
1022
+ observer.observe(this.content);
1023
+ }
1024
+ }
1025
+
1026
+ if (this.following) {
1027
+ this.pinToEnd();
1028
+ }
1029
+ this.record();
1030
+ this.lastTop = viewport.scrollTop;
1031
+ this.lastHeights = [viewport.scrollHeight, viewport.clientHeight];
1032
+ this.wasInStartMargin = viewport.scrollTop <= this.startMargin;
1033
+ this.writeEdges();
1034
+
1035
+ return () => {
1036
+ viewport.removeEventListener('scroll', this.onScroll);
1037
+ viewport.removeEventListener('wheel', this.onWheel);
1038
+ viewport.removeEventListener('pointerup', this.onPointerUp);
1039
+ viewport.removeEventListener('pointerdown', this.onPointerDown);
1040
+ viewport.removeEventListener('touchmove', this.noteInput);
1041
+ viewport.removeEventListener('focusin', this.onFocusIn);
1042
+ doc.removeEventListener('keydown', this.onScrollKey);
1043
+ window.removeEventListener('pointerup', this.onPointerRelease);
1044
+ window.removeEventListener('pointercancel', this.onPointerRelease);
1045
+ observer?.disconnect();
1046
+ this.viewport = undefined;
1047
+ this.content = undefined;
1048
+ };
1049
+ }
1050
+
1051
+ <template>
1052
+ <div
1053
+ class='pretui-stick-to-bottom'
1054
+ {{customProperty '--pretui-recede-opacity' this.recedeOpacity}}
1055
+ data-holding={{if this.holding 'true'}}
1056
+ data-following={{if this.following 'true' 'false'}}
1057
+ data-test-pretui-stick-to-bottom
1058
+ ...attributes
1059
+ >
1060
+ <div
1061
+ class='pretui-stick-to-bottom-viewport'
1062
+ role={{this.role}}
1063
+ aria-label={{@label}}
1064
+ aria-busy={{if this.anchoring 'true'}}
1065
+ data-following={{if this.following 'true' 'false'}}
1066
+ data-test-pretui-stick-to-bottom-viewport
1067
+ {{follow this}}
1068
+ {{watch this.activeChanged @active}}
1069
+ {{watch this.followingArgChanged @following}}
1070
+ {{watch this.revisionChanged @revision}}
1071
+ >
1072
+ <div
1073
+ class='pretui-stick-to-bottom-content'
1074
+ data-stick-to-bottom-content
1075
+ data-test-pretui-stick-to-bottom-content
1076
+ >
1077
+ {{yield this.api}}
1078
+ </div>
1079
+ </div>
1080
+ <div class='pretui-stick-to-bottom-edge' aria-hidden='true'></div>
1081
+ {{#if (has-block 'overlay')}}
1082
+ <div
1083
+ class='pretui-stick-to-bottom-overlay'
1084
+ data-test-pretui-stick-to-bottom-overlay
1085
+ >
1086
+ {{yield this.api to='overlay'}}
1087
+ </div>
1088
+ {{/if}}
1089
+ </div>
1090
+ <style scoped>
1091
+ @layer PretComponent {
1092
+ .pretui-stick-to-bottom {
1093
+ position: relative;
1094
+ display: flex;
1095
+ flex-direction: column;
1096
+ min-height: 0;
1097
+ height: 100%;
1098
+ }
1099
+ .pretui-stick-to-bottom-viewport {
1100
+ flex: 1 1 auto;
1101
+ min-height: 0;
1102
+ overflow-x: hidden;
1103
+ overflow-y: auto;
1104
+ overscroll-behavior: contain;
1105
+ scrollbar-width: thin;
1106
+ scrollbar-gutter: stable;
1107
+ /* The component owns the scroll position: following, it pins
1108
+ the end; detached, it holds the anchor (settle). The browser
1109
+ must not anchor as well. */
1110
+ overflow-anchor: none;
1111
+ }
1112
+ .pretui-stick-to-bottom-viewport:focus-visible {
1113
+ outline: 2px solid var(--ring);
1114
+ outline-offset: -2px;
1115
+ }
1116
+ .pretui-stick-to-bottom-content {
1117
+ display: flow-root;
1118
+ }
1119
+ .pretui-stick-to-bottom-edge {
1120
+ position: absolute;
1121
+ inset-inline: 0;
1122
+ inset-block-start: 0;
1123
+ block-size: var(--pretui-stick-to-bottom-fade, var(--boxel-sp-lg));
1124
+ pointer-events: none;
1125
+ opacity: 0;
1126
+ background: linear-gradient(
1127
+ to bottom,
1128
+ var(--pretui-stick-to-bottom-ground, var(--card)),
1129
+ transparent
1130
+ );
1131
+ transition: opacity 140ms linear;
1132
+ }
1133
+ .pretui-stick-to-bottom-viewport[data-start-y='clipped']
1134
+ ~ .pretui-stick-to-bottom-edge {
1135
+ opacity: 1;
1136
+ }
1137
+ /* The overlay adds no box: its children position against the root,
1138
+ which is what JumpChip expects of its nearest positioned ancestor. */
1139
+ .pretui-stick-to-bottom-overlay {
1140
+ display: contents;
1141
+ }
1142
+ @media (prefers-reduced-motion: reduce) {
1143
+ .pretui-stick-to-bottom-edge {
1144
+ transition: none;
1145
+ }
1146
+ }
1147
+ }
1148
+ </style>
1149
+ </template>
1150
+ }