poetry-core 0.0.2

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 (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,930 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
4
+ EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
5
+ SCROLL_POSITION_EPSILON,
6
+ areScrollStatesEqual,
7
+ areVisibilityStatesEqual,
8
+ getContentBottom,
9
+ getElementScrollTop,
10
+ getElementTop,
11
+ getElementViewportTop,
12
+ getFirstVisibleMessageItem,
13
+ getFlexGap,
14
+ getLastScrollAnchor,
15
+ getMaxScrollTop,
16
+ getMessageScrollerItems,
17
+ getMessageScrollerScrollable,
18
+ getMessageScrollerVisibilityState,
19
+ getNewScrollAnchor,
20
+ getTailSpacerHeight,
21
+ getUnanchoredScrollAnchor,
22
+ hasMultipleNewScrollAnchors
23
+ } from "@poetry/controllers/helpers/scroller_geometry"
24
+
25
+ // How long (ms) data-autoscrolling stays set during a programmatic scroll
26
+ // before clearing. While set, the follow-bottom RELEASE is suppressed so the
27
+ // auto-scroll animation cannot release itself.
28
+ const AUTOSCROLLING_CLEAR_DELAY = 180
29
+
30
+ // Viewport keys that count as deliberate scroll intent and release follow.
31
+ const USER_SCROLL_KEYS = new Set([
32
+ "ArrowDown",
33
+ "ArrowUp",
34
+ "End",
35
+ "Home",
36
+ "PageDown",
37
+ "PageUp",
38
+ " " // Space key.
39
+ ])
40
+
41
+ // The chat-transcript scroller (the centerpiece of the Gen-UI surface),
42
+ // adapted from an MIT-licensed source (source and license in
43
+ // THIRD_PARTY_NOTICES.md) - a component-tree state machine collapsed
44
+ // into ONE controller: refs become instance fields,
45
+ // useSyncExternalStore stores become data-* attributes + dispatched events
46
+ // (the DOM is the store), and registerMessage is DELETED: the
47
+ // MutationObserver + data-message-id in DOM order IS registration. Rows are
48
+ // content children (Turbo Streams append them; the observer never knows the
49
+ // difference). Geometry lives in helpers/scroller_geometry - pure, tested.
50
+ //
51
+ // Modes (mirrored to data-mode on the root, a poetry addition - a
52
+ // value-carrying attribute (like data-swipe-direction); the mode
53
+ // set is not part of the presence-pair vocabulary):
54
+ // following-bottom autoScroll pinned to the latest message
55
+ // free-scrolling reader scrolled away; position left alone
56
+ // anchored-to-message a turn held at the reading line while a reply streams
57
+ // settling-jump a programmatic jump animating; intent suppressed
58
+ //
59
+ // Viewport scroll/wheel/touchmove/keydown listeners are wired here (passive
60
+ // flags need addEventListener) - do NOT also declare them as data-actions.
61
+ // The jump button IS a data-action: click->...#jump.
62
+ // The component-facing event namespace (the poetry:<component> rule).
63
+ const EVENT_PREFIX = "poetry:message-scroller"
64
+
65
+ export default class extends Controller {
66
+ // The events this controller dispatches (manifest surface;
67
+ // events_declaration.test.js enforces the list stays honest).
68
+ static events = [
69
+ "poetry:message-scroller:mode", "poetry:message-scroller:pinned", "poetry:message-scroller:scrollable",
70
+ "poetry:message-scroller:unpinned", "poetry:message-scroller:visibility"
71
+ ]
72
+
73
+ static targets = ["viewport", "content", "spacer", "button"]
74
+ static values = {
75
+ // Source-faithful default (the contract): the poetry ViewComponent
76
+ // wrapper opts INTO following by rendering the value true.
77
+ autoScroll: { type: Boolean, default: false },
78
+ defaultScrollPosition: { type: String, default: "end" }, // start | end | last-anchor
79
+ preserveScrollOnPrepend: { type: Boolean, default: true },
80
+ trackVisibility: { type: Boolean, default: false },
81
+ scrollEdgeThreshold: { type: Number, default: 8 },
82
+ scrollPreviousItemPeek: { type: Number, default: 64 },
83
+ scrollMargin: { type: Number, default: 0 }
84
+ }
85
+
86
+ /**
87
+ * Builds the ref bag, wires the viewport listeners and the three
88
+ * observers, runs the mount pass (rows counted, defaultScrollPosition
89
+ * applied once), and arms visibility tracking when opted in.
90
+ */
91
+ connect() {
92
+ // The ref bag, as instance fields.
93
+ this.mode = this.autoScrollValue ? "following-bottom" : "free-scrolling"
94
+ this.autoscrolling = false
95
+ this.autoscrollingTimeout = null
96
+ this.stateFrame = null
97
+ this.visibilityFrame = null
98
+ this.itemCount = 0
99
+ this.firstItem = null
100
+ this.streamingTurn = null
101
+ this.prependRestore = null
102
+ this.pendingScrollToMessage = null
103
+ this.defaultScrollPositionApplied = false
104
+ this.spacerHeight = 0
105
+ this.spacerGap = getFlexGap(this.#spacer()?.parentElement ?? null)
106
+ this.handledScrollAnchors = new WeakSet()
107
+ this.visibleMessageIds = new Set()
108
+ this.observedRows = new Set()
109
+ this.visibilityObserver = null
110
+ this.scrollableState = EMPTY_MESSAGE_SCROLLER_SCROLLABLE
111
+ this.visibilityState = EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
112
+
113
+ // Initial mirror, no transition - events fire on transitions only.
114
+ this.element.dataset.mode = this.mode
115
+
116
+ const viewport = this.#viewport()
117
+ this.onScroll = () => this.syncAfterScroll()
118
+ this.onWheel = () => this.userScrollIntent()
119
+ this.onTouchMove = () => this.userScrollIntent()
120
+ this.onKeydown = (event) => this.keydownIntent(event)
121
+ viewport.addEventListener("scroll", this.onScroll, { passive: true })
122
+ viewport.addEventListener("wheel", this.onWheel, { passive: true })
123
+ viewport.addEventListener("touchmove", this.onTouchMove, { passive: true })
124
+ viewport.addEventListener("keydown", this.onKeydown)
125
+
126
+ const content = this.#content()
127
+ this.contentObserver = null
128
+ this.viewportResizeObserver = null
129
+ this.contentResizeObserver = null
130
+
131
+ // MutationObserver on content: streamed / Turbo-Stream-appended /
132
+ // prepended / removed ROWS. Token growth inside an existing row is the
133
+ // content ResizeObserver's job, not this one's.
134
+ if (content && typeof MutationObserver !== "undefined") {
135
+ this.contentObserver = new MutationObserver(() => this.#handleContentChange())
136
+ this.contentObserver.observe(content, { childList: true })
137
+ }
138
+
139
+ if (typeof ResizeObserver !== "undefined") {
140
+ this.viewportResizeObserver = new ResizeObserver(() => this.#handleResize())
141
+ this.viewportResizeObserver.observe(viewport)
142
+
143
+ if (content) {
144
+ this.contentResizeObserver = new ResizeObserver(() => this.#handleResize())
145
+ this.contentResizeObserver.observe(content)
146
+ }
147
+ }
148
+
149
+ // Mount pass: counts rows, applies
150
+ // defaultScrollPosition once, commits scrollable state.
151
+ this.#handleContentChange()
152
+
153
+ if (this.trackVisibilityValue) this.#observeVisibility()
154
+
155
+ this.started = true
156
+ }
157
+
158
+ /**
159
+ * Cancels every frame/timer, disconnects the observers, and unwires the
160
+ * viewport listeners (the body comment holds the stale-id rule).
161
+ */
162
+ disconnect() {
163
+ // Cancel and NULL every frame/timer id - a stale non-null id after a
164
+ // Turbo cache restore / morph reconnect makes the scheduler think a
165
+ // frame is still pending and never reschedule - so teardown nulls,
166
+ // never merely cancels.
167
+ this.started = false
168
+
169
+ if (this.stateFrame !== null) {
170
+ window.cancelAnimationFrame(this.stateFrame)
171
+ this.stateFrame = null
172
+ }
173
+
174
+ if (this.visibilityFrame !== null) {
175
+ window.cancelAnimationFrame(this.visibilityFrame)
176
+ this.visibilityFrame = null
177
+ }
178
+
179
+ if (this.autoscrollingTimeout !== null) {
180
+ window.clearTimeout(this.autoscrollingTimeout)
181
+ this.autoscrollingTimeout = null
182
+ }
183
+
184
+ this.contentObserver?.disconnect()
185
+ this.contentObserver = null
186
+ this.viewportResizeObserver?.disconnect()
187
+ this.viewportResizeObserver = null
188
+ this.contentResizeObserver?.disconnect()
189
+ this.contentResizeObserver = null
190
+ this.visibilityObserver?.disconnect()
191
+ this.visibilityObserver = null
192
+ this.observedRows.clear()
193
+ this.visibleMessageIds.clear()
194
+
195
+ const viewport = this.#viewport()
196
+ viewport.removeEventListener("scroll", this.onScroll)
197
+ viewport.removeEventListener("wheel", this.onWheel)
198
+ viewport.removeEventListener("touchmove", this.onTouchMove)
199
+ viewport.removeEventListener("keydown", this.onKeydown)
200
+ }
201
+
202
+ /**
203
+ * Stimulus value callback: a defaultScrollPosition change re-arms the
204
+ * one-shot apply.
205
+ */
206
+ defaultScrollPositionValueChanged() {
207
+ if (!this.started) return
208
+ this.defaultScrollPositionApplied = false
209
+ }
210
+
211
+ /**
212
+ * Stimulus value callback: re-pin if we were following.
213
+ */
214
+ autoScrollValueChanged() {
215
+ if (!this.started) return
216
+
217
+ if (this.autoScrollValue && this.mode === "following-bottom" && this.itemCount > 0) {
218
+ this.#scrollToEnd({ behavior: "auto" })
219
+ return
220
+ }
221
+
222
+ this.#commitScrollState()
223
+ }
224
+
225
+ // --- viewport handlers (wired in connect; public for outlet callers) ---
226
+
227
+ /**
228
+ * The viewport's scroll handler: commits scrollable state, schedules a
229
+ * visibility sync, and re-captures the prepend anchor.
230
+ */
231
+ syncAfterScroll() {
232
+ this.#commitScrollState()
233
+ this.#scheduleVisibilitySync()
234
+ this.#capturePrependAnchor()
235
+ }
236
+
237
+ /**
238
+ * The wheel/touchmove handler: a deliberate gesture releases
239
+ * auto-follow, turn-anchoring, AND an in-flight programmatic jump so
240
+ * re-pinning never fights the reader.
241
+ */
242
+ userScrollIntent() {
243
+ if (
244
+ this.mode === "following-bottom" ||
245
+ this.mode === "anchored-to-message" ||
246
+ this.mode === "settling-jump"
247
+ ) {
248
+ this.streamingTurn = null
249
+ this.#setMode("free-scrolling", { reason: "user-intent" })
250
+ }
251
+ }
252
+
253
+ /**
254
+ * The viewport's keydown handler: scroll keys count as deliberate
255
+ * intent.
256
+ *
257
+ * @param {KeyboardEvent} event
258
+ */
259
+ keydownIntent(event) {
260
+ if (USER_SCROLL_KEYS.has(event.key)) this.userScrollIntent()
261
+ }
262
+
263
+ // --- actions / commands (the useMessageScroller hook surface) ---
264
+
265
+ /**
266
+ * The jump button's click action. No-op while inactive; blurs so focus
267
+ * is not stranded on a control about to inert itself.
268
+ *
269
+ * @param {MouseEvent} event
270
+ */
271
+ jump(event) {
272
+ const button = event.currentTarget
273
+
274
+ if (button.dataset.active !== "true") return
275
+
276
+ button.blur()
277
+
278
+ const behavior = button.dataset.behavior === "auto" ? "auto" : "smooth"
279
+
280
+ if (button.dataset.direction === "start") this.#scrollToStart({ behavior })
281
+ else this.#scrollToEnd({ behavior })
282
+ }
283
+
284
+ /**
285
+ * Scrolls to the end - callable as a Stimulus action (options via
286
+ * params) or directly with an options object (outlet callers).
287
+ *
288
+ * @param {Event | Object} [eventOrOptions] - behavior: "auto" | "smooth"
289
+ * @returns {boolean} whether a scroll was issued
290
+ */
291
+ scrollToEnd(eventOrOptions = {}) {
292
+ return this.#scrollToEnd(this.#optionsFrom(eventOrOptions))
293
+ }
294
+
295
+ /**
296
+ * Scrolls to the start (scrollToEnd's calling conventions).
297
+ *
298
+ * @param {Event | Object} [eventOrOptions]
299
+ * @returns {boolean} whether a scroll was issued
300
+ */
301
+ scrollToStart(eventOrOptions = {}) {
302
+ return this.#scrollToStart(this.#optionsFrom(eventOrOptions))
303
+ }
304
+
305
+ /**
306
+ * scrollToMessage("id", options) programmatically, or as an action with
307
+ * data-...-message-id-param (align/behavior/scrollMargin params pass
308
+ * through).
309
+ *
310
+ * @param {Event | string} eventOrId
311
+ * @param {Object} [options] - align / behavior / scrollMargin
312
+ * @returns {boolean} true when scrolled - or queued (a miss on an empty
313
+ * transcript queues the request for the next content change)
314
+ */
315
+ scrollToMessage(eventOrId, options) {
316
+ if (typeof eventOrId === "string") return this.#scrollToMessage(eventOrId, options)
317
+
318
+ const { messageId, id, ...rest } = this.#optionsFrom(eventOrId)
319
+
320
+ return this.#scrollToMessage(messageId ?? id, rest)
321
+ }
322
+
323
+ #optionsFrom(eventOrOptions) {
324
+ if (!eventOrOptions) return {}
325
+ if (eventOrOptions.params) return eventOrOptions.params
326
+ if (typeof Event !== "undefined" && eventOrOptions instanceof Event) return {}
327
+ return eventOrOptions
328
+ }
329
+
330
+ // --- elements (targets; viewport falls back to the controller root) ---
331
+
332
+ #viewport() {
333
+ return this.hasViewportTarget ? this.viewportTarget : this.element
334
+ }
335
+
336
+ #content() {
337
+ return this.hasContentTarget ? this.contentTarget : null
338
+ }
339
+
340
+ #spacer() {
341
+ return this.hasSpacerTarget ? this.spacerTarget : null
342
+ }
343
+
344
+ // --- mode machine ---
345
+
346
+ // Mode transitions were internal-ref writes in source; poetry mirrors them
347
+ // to data-mode and dispatches mode / pinned / unpinned.
348
+ #setMode(next, { reason = "scroll-away" } = {}) {
349
+ const previous = this.mode
350
+
351
+ if (previous === next) return
352
+
353
+ this.mode = next
354
+ this.element.dataset.mode = next
355
+ this.dispatch("mode", { prefix: EVENT_PREFIX, detail: { from: previous, to: next, mode: next } })
356
+
357
+ if (next === "following-bottom") this.dispatch("pinned", { prefix: EVENT_PREFIX })
358
+ if (previous === "following-bottom") this.dispatch("unpinned", { prefix: EVENT_PREFIX, detail: { reason } })
359
+ }
360
+
361
+ // Owns the one follow-bottom transition: ARM at the bottom (however you got
362
+ // there), RELEASE on any scroll away (including a scrollbar drag) -
363
+ // suppressed while autoscrolling so a programmatic scroll cannot release
364
+ // itself, and never arming over an in-flight settling-jump.
365
+ #reconcileFollowMode(scrollable) {
366
+ if (this.autoScrollValue && !scrollable.end && this.mode !== "settling-jump") {
367
+ this.#setMode("following-bottom")
368
+ } else if (this.mode === "following-bottom" && scrollable.end && !this.autoscrolling) {
369
+ this.#setMode("free-scrolling", { reason: "scroll-away" })
370
+ }
371
+ }
372
+
373
+ #commitScrollState() {
374
+ const nextState = getMessageScrollerScrollable({
375
+ content: this.#content(),
376
+ scrollEdgeThreshold: this.scrollEdgeThresholdValue,
377
+ spacer: this.#spacer(),
378
+ viewport: this.#viewport()
379
+ })
380
+
381
+ this.#reconcileFollowMode(nextState)
382
+ this.#writeStateAttributes(nextState)
383
+
384
+ if (!areScrollStatesEqual(this.scrollableState, nextState)) {
385
+ this.scrollableState = nextState
386
+ this.dispatch("scrollable", { prefix: EVENT_PREFIX, detail: { ...nextState } })
387
+ }
388
+ }
389
+
390
+ #scheduleStateCommit() {
391
+ if (this.stateFrame !== null) return
392
+
393
+ this.stateFrame = window.requestAnimationFrame(() => {
394
+ this.stateFrame = null
395
+ this.#commitScrollState()
396
+ })
397
+ }
398
+
399
+ #writeStateAttributes(state) {
400
+ const scrollable = [state.start && "start", state.end && "end"].filter(Boolean).join(" ")
401
+
402
+ for (const element of new Set([this.element, this.#viewport()])) {
403
+ if (scrollable) element.setAttribute("data-scrollable", scrollable)
404
+ else element.removeAttribute("data-scrollable")
405
+
406
+ element.toggleAttribute("data-autoscrolling", this.autoscrolling)
407
+ }
408
+
409
+ // The Button part's useSyncExternalStore, collapsed: active = overflow
410
+ // toward the button's direction; inactive => inert + untabbable (no
411
+ // ghost focus stop when the reader is already caught up).
412
+ for (const button of this.buttonTargets) {
413
+ const active = button.dataset.direction === "start" ? state.start : state.end
414
+
415
+ button.dataset.active = active ? "true" : "false"
416
+ button.toggleAttribute("inert", !active)
417
+
418
+ if (active) button.removeAttribute("tabindex")
419
+ else button.setAttribute("tabindex", "-1")
420
+ }
421
+ }
422
+
423
+ // --- visibility (the lazy visibility store, as an explicit opt-in value) ---
424
+
425
+ #scheduleVisibilitySync() {
426
+ if (!this.trackVisibilityValue) return
427
+ if (this.visibilityFrame !== null) return
428
+
429
+ this.visibilityFrame = window.requestAnimationFrame(() => {
430
+ this.visibilityFrame = null
431
+ this.#commitVisibility()
432
+ })
433
+ }
434
+
435
+ #commitVisibility() {
436
+ const nextState = getMessageScrollerVisibilityState({
437
+ content: this.#content(),
438
+ scrollMargin: this.scrollMarginValue,
439
+ scrollPreviousItemPeek: this.scrollPreviousItemPeekValue,
440
+ spacer: this.#spacer(),
441
+ viewport: this.#viewport(),
442
+ visibleMessageIds: this.visibleMessageIds
443
+ })
444
+
445
+ if (!areVisibilityStatesEqual(this.visibilityState, nextState)) {
446
+ this.visibilityState = nextState
447
+ this.dispatch("visibility", {
448
+ prefix: EVENT_PREFIX,
449
+ detail: {
450
+ currentAnchorId: nextState.currentAnchorId,
451
+ visibleMessageIds: [...nextState.visibleMessageIds]
452
+ }
453
+ })
454
+ }
455
+ }
456
+
457
+ #observeVisibility() {
458
+ if (!this.trackVisibilityValue) return
459
+
460
+ if (typeof IntersectionObserver === "undefined") {
461
+ // No observer: getMessageScrollerVisibilityState falls back to rects.
462
+ this.#scheduleVisibilitySync()
463
+ return
464
+ }
465
+
466
+ if (!this.visibilityObserver) {
467
+ this.visibilityObserver = new IntersectionObserver(
468
+ (entries) => {
469
+ for (const entry of entries) {
470
+ const messageId = entry.target.dataset.messageId
471
+
472
+ if (!messageId) continue
473
+
474
+ if (entry.isIntersecting) this.visibleMessageIds.add(messageId)
475
+ else this.visibleMessageIds.delete(messageId)
476
+ }
477
+
478
+ this.#scheduleVisibilitySync()
479
+ },
480
+ {
481
+ root: this.#viewport(),
482
+ // Shrink the root's top edge to the reading line so a previous turn
483
+ // peeking in the scrollMargin + peek band is not reported visible,
484
+ // keeping visibleMessageIds consistent with currentAnchorId.
485
+ rootMargin: `${-(this.scrollMarginValue + this.scrollPreviousItemPeekValue)}px 0px 0px 0px`,
486
+ threshold: [0, 0.01, 0.5, 1]
487
+ }
488
+ )
489
+ }
490
+
491
+ const content = this.#content()
492
+
493
+ if (content) this.#syncObservedRows(getMessageScrollerItems(content, this.#spacer()))
494
+
495
+ this.#scheduleVisibilitySync()
496
+ }
497
+
498
+ // registerMessage's replacement: childList changes ARE registration.
499
+ #syncObservedRows(items) {
500
+ if (!this.visibilityObserver) return
501
+
502
+ for (const element of [...this.observedRows]) {
503
+ if (element.isConnected) continue
504
+
505
+ this.visibilityObserver.unobserve(element)
506
+ this.observedRows.delete(element)
507
+
508
+ const messageId = element.dataset.messageId
509
+ if (messageId) this.visibleMessageIds.delete(messageId)
510
+ }
511
+
512
+ for (const element of items) {
513
+ if (!element.dataset.messageId || this.observedRows.has(element)) continue
514
+
515
+ this.visibilityObserver.observe(element)
516
+ this.observedRows.add(element)
517
+ }
518
+
519
+ this.#scheduleVisibilitySync()
520
+ }
521
+
522
+ // --- content / resize handlers ---
523
+
524
+ #handleContentChange() {
525
+ const content = this.#content()
526
+
527
+ if (!content) return
528
+
529
+ const items = getMessageScrollerItems(content, this.#spacer())
530
+ const previousItemCount = this.itemCount
531
+ const previousFirstItem = this.firstItem
532
+
533
+ this.itemCount = items.length
534
+ this.firstItem = items[0] ?? null
535
+
536
+ // Every path re-captures the prepend anchor afterward, so each branch
537
+ // just returns. Branch order is LOAD-BEARING, ported verbatim:
538
+ // pending-jump, first-content, prepended, appended, updated.
539
+ this.#reconcileScrollPosition(items, previousItemCount, previousFirstItem)
540
+ this.#capturePrependAnchor()
541
+ this.#syncObservedRows(items)
542
+ }
543
+
544
+ #reconcileScrollPosition(items, previousItemCount, previousFirstItem) {
545
+ if (this.#flushPendingScrollToMessage()) return
546
+
547
+ if (previousItemCount === 0) {
548
+ if (this.#applyDefaultScrollPosition()) return
549
+
550
+ if (items.length > 0 && this.autoScrollValue && this.#scrollToEnd({ behavior: "auto" })) {
551
+ return
552
+ }
553
+
554
+ this.#commitScrollState()
555
+ this.#scheduleVisibilitySync()
556
+ return
557
+ }
558
+
559
+ const previousFirstItemIndex = previousFirstItem ? items.indexOf(previousFirstItem) : -1
560
+ const didPrepend = this.preserveScrollOnPrependValue && previousFirstItemIndex > 0
561
+
562
+ if (didPrepend) {
563
+ // Prepended rows are not new appends. Restore the prior scroll position
564
+ // (a no-op where native scroll anchoring already did it).
565
+ this.#restorePrependedAnchor()
566
+ return
567
+ }
568
+
569
+ if (items.length > previousItemCount) {
570
+ const anchor = getNewScrollAnchor(items, previousItemCount)
571
+
572
+ if (anchor) {
573
+ // While following the live end, a batch of several anchored turns
574
+ // arriving at once keeps following the end - not yanking back to
575
+ // anchor the first turn of the batch. A single new anchor still
576
+ // moves to the reading line as usual.
577
+ if (
578
+ this.autoScrollValue &&
579
+ this.mode === "following-bottom" &&
580
+ hasMultipleNewScrollAnchors(items, previousItemCount)
581
+ ) {
582
+ this.#scrollToEnd({ behavior: "auto" })
583
+ return
584
+ }
585
+
586
+ this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
587
+ this.handledScrollAnchors.add(anchor)
588
+ return
589
+ }
590
+ }
591
+
592
+ if (items.length === previousItemCount) {
593
+ // Same row count but an unhandled anchor appeared (attribute added or a
594
+ // morph replaced the node): anchor it once.
595
+ const anchor = getUnanchoredScrollAnchor(items, this.handledScrollAnchors)
596
+
597
+ if (anchor) {
598
+ this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
599
+ this.handledScrollAnchors.add(anchor)
600
+ return
601
+ }
602
+ }
603
+
604
+ // Appends with no new anchor (and content-only updates) fall through:
605
+ // keep following the end if we still are, otherwise just recommit state.
606
+ if (this.mode === "following-bottom" && this.autoScrollValue) {
607
+ this.#scrollToEnd({ behavior: "auto" })
608
+ } else {
609
+ this.#commitScrollState()
610
+ this.#scheduleVisibilitySync()
611
+ }
612
+ }
613
+
614
+ #handleResize() {
615
+ if (this.mode === "following-bottom" && this.autoScrollValue) {
616
+ this.#scrollToEnd({ behavior: "auto" })
617
+ return
618
+ }
619
+
620
+ // Hold the anchored turn in place as content below it resizes (a reply
621
+ // streaming in) - otherwise the shrinking content lets the browser clamp
622
+ // scrollTop and the turn drops.
623
+ if (this.#reanchorToAnchoredMessage()) return
624
+
625
+ this.#scheduleStateCommit()
626
+ this.#scheduleVisibilitySync()
627
+ }
628
+
629
+ #applyDefaultScrollPosition() {
630
+ if (!this.defaultScrollPositionValue || this.defaultScrollPositionApplied || this.itemCount === 0) {
631
+ return false
632
+ }
633
+
634
+ let handled = false
635
+
636
+ if (this.defaultScrollPositionValue === "last-anchor") {
637
+ const content = this.#content()
638
+ const viewport = this.#viewport()
639
+ const anchor = content && viewport
640
+ ? getLastScrollAnchor(getMessageScrollerItems(content, this.#spacer()))
641
+ : null
642
+
643
+ if (!content || !viewport || !anchor) {
644
+ handled = this.#scrollToEnd({ behavior: "auto" })
645
+ } else {
646
+ const anchorTop = getElementTop(anchor, viewport)
647
+ const contentBottom = getContentBottom({ content, spacer: this.#spacer(), viewport })
648
+ // A short last turn already fits below the anchor, so opening at the
649
+ // end shows the whole turn without a blank gap beneath it.
650
+ const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
651
+
652
+ handled = lastTurnFits
653
+ ? this.#scrollToEnd({ behavior: "auto" })
654
+ : this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
655
+ }
656
+ } else {
657
+ handled = this.defaultScrollPositionValue === "end"
658
+ ? this.#scrollToEnd({ behavior: "auto" })
659
+ : this.#scrollToStart({ behavior: "auto" })
660
+ }
661
+
662
+ if (!handled) return false
663
+
664
+ this.defaultScrollPositionApplied = true
665
+
666
+ return true
667
+ }
668
+
669
+ // --- prepend preservation ---
670
+
671
+ #capturePrependAnchor() {
672
+ const content = this.#content()
673
+ const viewport = this.#viewport()
674
+
675
+ if (!content || !viewport) {
676
+ this.prependRestore = null
677
+ return
678
+ }
679
+
680
+ const anchor = getFirstVisibleMessageItem({ content, spacer: this.#spacer(), viewport })
681
+
682
+ this.prependRestore = anchor
683
+ ? { element: anchor, viewportTop: getElementViewportTop(anchor, viewport) }
684
+ : null
685
+ }
686
+
687
+ #restorePrependedAnchor() {
688
+ const anchor = this.prependRestore
689
+ const viewport = this.#viewport()
690
+
691
+ if (!anchor || !viewport || !anchor.element.isConnected) return false
692
+
693
+ // Compare the anchor relative to the VIEWPORT, not the content: native
694
+ // scroll anchoring leaves the viewport-relative position unchanged, so
695
+ // this is a no-op where the browser already handled the prepend and only
696
+ // corrects the scroll where it did not (e.g. Safari) - without trusting a
697
+ // capability flag, which some engines report incorrectly.
698
+ const nextViewportTop = getElementViewportTop(anchor.element, viewport)
699
+ const delta = nextViewportTop - anchor.viewportTop
700
+
701
+ if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) return false
702
+
703
+ viewport.scrollTop += delta
704
+ anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
705
+ this.#scheduleStateCommit()
706
+ this.#scheduleVisibilitySync()
707
+
708
+ return true
709
+ }
710
+
711
+ // --- scroll commands (mechanics, split from the policy above as in source) ---
712
+
713
+ #setAutoScrolling(autoscrolling) {
714
+ if (this.autoscrollingTimeout !== null) {
715
+ window.clearTimeout(this.autoscrollingTimeout)
716
+ this.autoscrollingTimeout = null
717
+ }
718
+
719
+ if (this.autoscrolling !== autoscrolling) {
720
+ this.autoscrolling = autoscrolling
721
+ this.#commitScrollState()
722
+ }
723
+
724
+ if (autoscrolling) {
725
+ this.autoscrollingTimeout = window.setTimeout(() => {
726
+ this.autoscrollingTimeout = null
727
+ this.autoscrolling = false
728
+ this.#commitScrollState()
729
+ }, AUTOSCROLLING_CLEAR_DELAY)
730
+ }
731
+ }
732
+
733
+ // Tail spacer: fakes scroll room below a short anchored turn. Clamped >= 0,
734
+ // ceil'd; visible it gets a negative marginTop compensating the content
735
+ // flex gap so it adds no phantom row gap; hidden at 0.
736
+ #setTailSpacerHeight(height) {
737
+ const spacer = this.#spacer()
738
+
739
+ if (!spacer) return
740
+
741
+ const nextHeight = Math.max(0, Math.ceil(height))
742
+
743
+ if (this.spacerHeight === nextHeight) return
744
+
745
+ this.spacerHeight = nextHeight
746
+ spacer.hidden = nextHeight === 0
747
+ spacer.style.height = `${nextHeight}px`
748
+ spacer.style.marginTop = nextHeight > 0 ? `${-this.spacerGap}px` : ""
749
+ }
750
+
751
+ // Instant scroll under prefers-reduced-motion (follow moves are already
752
+ // behavior: auto in source - this only downgrades requested smooth jumps).
753
+ #resolveBehavior(behavior) {
754
+ if (behavior !== "smooth") return behavior
755
+
756
+ if (
757
+ typeof window.matchMedia === "function" &&
758
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
759
+ ) {
760
+ return "auto"
761
+ }
762
+
763
+ return behavior
764
+ }
765
+
766
+ #scrollToPosition(scrollTop, { behavior = "auto", autoscrolling = false } = {}) {
767
+ const viewport = this.#viewport()
768
+
769
+ if (!viewport) return
770
+
771
+ const nextScrollTop = Math.max(0, scrollTop)
772
+
773
+ // Scrolls within epsilon snap without animating (zoom/HiDPI rounding).
774
+ if (Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON) {
775
+ viewport.scrollTop = nextScrollTop
776
+ this.#commitScrollState()
777
+ return
778
+ }
779
+
780
+ if (autoscrolling) this.#setAutoScrolling(true)
781
+
782
+ if (typeof viewport.scrollTo === "function") {
783
+ viewport.scrollTo({ top: nextScrollTop, behavior: this.#resolveBehavior(behavior) })
784
+ } else {
785
+ viewport.scrollTop = nextScrollTop
786
+ }
787
+
788
+ this.#scheduleStateCommit()
789
+ }
790
+
791
+ #scrollToStart({ behavior = "auto" } = {}) {
792
+ if (!this.#viewport()) return false
793
+
794
+ this.#setTailSpacerHeight(0)
795
+ this.streamingTurn = null
796
+ this.#setMode("free-scrolling", { reason: "user-intent" })
797
+ this.#scrollToPosition(0, { behavior })
798
+ this.#scheduleVisibilitySync()
799
+
800
+ return true
801
+ }
802
+
803
+ #scrollToEnd({ behavior = "auto" } = {}) {
804
+ const viewport = this.#viewport()
805
+
806
+ if (!viewport) return false
807
+
808
+ this.#setTailSpacerHeight(0)
809
+ this.streamingTurn = null
810
+ this.#setMode(this.autoScrollValue ? "following-bottom" : "free-scrolling", {
811
+ reason: "user-intent"
812
+ })
813
+ this.#scrollToPosition(getMaxScrollTop(viewport), { autoscrolling: true, behavior })
814
+ this.#scheduleVisibilitySync()
815
+
816
+ return true
817
+ }
818
+
819
+ #scrollToElement(element, { align = "start", behavior = "auto", scrollMargin = this.scrollMarginValue } = {}, { keepPreviousPeek = false } = {}) {
820
+ const content = this.#content()
821
+ const viewport = this.#viewport()
822
+
823
+ if (!content || !viewport || !content.contains(element)) return false
824
+
825
+ const scrollTop = getElementScrollTop({
826
+ align,
827
+ element,
828
+ // keepPreviousPeek adds the peek to the start margin - the reading line.
829
+ scrollMargin: keepPreviousPeek ? scrollMargin + this.scrollPreviousItemPeekValue : scrollMargin,
830
+ spacer: this.#spacer(),
831
+ viewport
832
+ })
833
+
834
+ const nextSpacerHeight = getTailSpacerHeight({
835
+ content,
836
+ scrollTop,
837
+ spacer: this.#spacer(),
838
+ viewport
839
+ })
840
+
841
+ this.#setTailSpacerHeight(nextSpacerHeight)
842
+ // Seed the prepend anchor with the jump target so a prepend landing before
843
+ // this scroll settles still preserves the jumped-to row; once settled,
844
+ // syncAfterScroll re-captures it from the first visible row.
845
+ this.prependRestore = {
846
+ element,
847
+ viewportTop: getElementViewportTop(element, viewport)
848
+ }
849
+
850
+ this.streamingTurn = keepPreviousPeek ? element : null
851
+ this.#setMode(keepPreviousPeek ? "anchored-to-message" : "settling-jump")
852
+
853
+ this.#scrollToPosition(scrollTop, { behavior })
854
+ this.#scheduleVisibilitySync()
855
+
856
+ return true
857
+ }
858
+
859
+ // Re-run the anchored placement (recompute spacer + scrollTop) so the turn
860
+ // holds the reading line as content below it grows or shrinks.
861
+ #reanchorToAnchoredMessage() {
862
+ const element = this.streamingTurn
863
+
864
+ if (!element || !element.isConnected || this.mode !== "anchored-to-message") {
865
+ return false
866
+ }
867
+
868
+ return this.#scrollToElement(element, { align: "start" }, { keepPreviousPeek: true })
869
+ }
870
+
871
+ // The target row may not be mounted yet (async transcript / lazy frame).
872
+ // A miss on an empty transcript queues the request, flushed on the next
873
+ // content change. An explicit jump marks the mount default as applied so
874
+ // defaultScrollPosition does not override it.
875
+ #scrollToMessage(messageId, options) {
876
+ const element = this.#findMessageElement(messageId)
877
+
878
+ if (!element) {
879
+ if (this.itemCount === 0) {
880
+ this.pendingScrollToMessage = { messageId, options }
881
+ this.defaultScrollPositionApplied = true
882
+
883
+ return true
884
+ }
885
+
886
+ return false
887
+ }
888
+
889
+ this.defaultScrollPositionApplied = true
890
+
891
+ if (this.#scrollToElement(element, options)) {
892
+ this.pendingScrollToMessage = null
893
+ return true
894
+ }
895
+
896
+ this.pendingScrollToMessage = { messageId, options }
897
+
898
+ return true
899
+ }
900
+
901
+ #flushPendingScrollToMessage() {
902
+ const pending = this.pendingScrollToMessage
903
+
904
+ if (!pending) return false
905
+
906
+ const element = this.#findMessageElement(pending.messageId)
907
+
908
+ if (!element) return false
909
+
910
+ if (!this.#scrollToElement(element, pending.options)) return false
911
+
912
+ this.pendingScrollToMessage = null
913
+ this.defaultScrollPositionApplied = true
914
+
915
+ return true
916
+ }
917
+
918
+ // Rows register through the DOM, not a Map: look the id up in DOM order.
919
+ #findMessageElement(messageId) {
920
+ const content = this.#content()
921
+
922
+ if (!content || !messageId) return null
923
+
924
+ for (const item of getMessageScrollerItems(content, this.#spacer())) {
925
+ if (item.dataset.messageId === messageId) return item
926
+ }
927
+
928
+ return null
929
+ }
930
+ }