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,422 @@
1
+ // Scroll geometry for the message-scroller, adapted from an MIT-licensed
2
+ // source (source and license in THIRD_PARTY_NOTICES.md): pure functions
3
+ // of {viewport, content, spacer, rects} - no Stimulus, no state. This is
4
+ // the jsdom-testable half; the controller owns the policy that decides
5
+ // when to call these. The constants encode deliberate fixes - treat them
6
+ // as pinned values, not tunables.
7
+
8
+ /**
9
+ * Two fractional scrollTop values within this range are treated as equal,
10
+ * to absorb zoom and HiDPI rounding drift.
11
+ */
12
+ export const SCROLL_POSITION_EPSILON = 0.5
13
+
14
+ /** Initial scrollable snapshot before measurement. */
15
+ export const EMPTY_MESSAGE_SCROLLER_SCROLLABLE = Object.freeze({
16
+ start: false,
17
+ end: false
18
+ })
19
+
20
+ /** Initial visibility snapshot: nothing tracked, no current anchor. */
21
+ export const EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE = Object.freeze({
22
+ currentAnchorId: null,
23
+ visibleMessageIds: Object.freeze([])
24
+ })
25
+
26
+ /**
27
+ * Whether the scroller has scroll room past each edge, beyond the
28
+ * configured threshold.
29
+ *
30
+ * @param {Object} args
31
+ * @param {HTMLElement | null} args.content
32
+ * @param {number} args.scrollEdgeThreshold - px of slack that still
33
+ * counts as "at the edge"
34
+ * @param {HTMLElement | null} args.spacer
35
+ * @param {HTMLElement | null} args.viewport
36
+ * @returns {{ start: boolean, end: boolean }}
37
+ */
38
+ export function getMessageScrollerScrollable({ content, scrollEdgeThreshold, spacer, viewport }) {
39
+ if (!viewport || !content) return EMPTY_MESSAGE_SCROLLER_SCROLLABLE
40
+
41
+ const contentBottom = getContentBottom({ content, spacer, viewport })
42
+
43
+ return {
44
+ start: viewport.scrollTop > scrollEdgeThreshold,
45
+ end: contentBottom - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The visible message ids (top-to-bottom) and the current anchor - the
51
+ * last anchor row to have reached the reading line (the comments inside
52
+ * hold the line rules).
53
+ *
54
+ * @param {Object} args
55
+ * @param {HTMLElement | null} args.content
56
+ * @param {number} args.scrollMargin
57
+ * @param {number} args.scrollPreviousItemPeek
58
+ * @param {HTMLElement | null} args.spacer
59
+ * @param {HTMLElement | null} args.viewport
60
+ * @param {Set<string>} args.visibleMessageIds - the observer's current
61
+ * set (consulted when IntersectionObserver exists)
62
+ * @returns {{ currentAnchorId: string | null, visibleMessageIds: string[] }}
63
+ */
64
+ export function getMessageScrollerVisibilityState({
65
+ content,
66
+ scrollMargin,
67
+ scrollPreviousItemPeek,
68
+ spacer,
69
+ viewport,
70
+ visibleMessageIds
71
+ }) {
72
+ if (!content || !viewport) return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
73
+
74
+ const viewportRect = viewport.getBoundingClientRect()
75
+ // The reading line sits scrollPreviousItemPeek below scrollMargin: anchored
76
+ // turns land there with the previous turn peeking above. A row only peeking
77
+ // in that band has not been read down to yet, so it counts as neither
78
+ // visible nor current.
79
+ const lineTop = viewportRect.top + scrollMargin + scrollPreviousItemPeek
80
+ const trackByLayout = typeof IntersectionObserver === "undefined"
81
+
82
+ const visible = []
83
+ let currentAnchorId = null
84
+
85
+ // Walk rows in document order so visible ids come out top-to-bottom.
86
+ for (const item of getMessageScrollerItems(content, spacer)) {
87
+ const messageId = item.dataset.messageId
88
+ if (!messageId) continue
89
+
90
+ const isAnchor = item.dataset.scrollAnchor === "true"
91
+ // Anchors need a rect to place the current line; non-anchors lean on the
92
+ // observer set (or a rect in the no-observer fallback).
93
+ const rect = isAnchor || trackByLayout ? item.getBoundingClientRect() : null
94
+
95
+ const isVisible = trackByLayout && rect
96
+ ? rect.bottom > lineTop && rect.top < viewportRect.bottom
97
+ : visibleMessageIds.has(messageId)
98
+
99
+ if (isVisible) visible.push(messageId)
100
+
101
+ // Current is the last anchor to have reached the reading line: the turn
102
+ // you scrolled to (placed at the line) wins over newer turns lower down,
103
+ // the previous turn peeking above the line has been passed, and it stays
104
+ // current even after its header scrolls above the viewport.
105
+ if (isAnchor && rect && rect.top <= lineTop + SCROLL_POSITION_EPSILON) {
106
+ currentAnchorId = messageId
107
+ }
108
+ }
109
+
110
+ if (visible.length === 0 && currentAnchorId === null) {
111
+ return EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
112
+ }
113
+
114
+ return { currentAnchorId, visibleMessageIds: visible }
115
+ }
116
+
117
+ /**
118
+ * The collection is DOM order: rows are content's element children minus
119
+ * the tail spacer - membership is a filter over live children, never a
120
+ * registration step.
121
+ *
122
+ * @param {HTMLElement} content
123
+ * @param {HTMLElement | null} spacer
124
+ * @returns {HTMLElement[]}
125
+ */
126
+ export function getMessageScrollerItems(content, spacer) {
127
+ return Array.from(content.children).filter(
128
+ (child) => child instanceof HTMLElement && child !== spacer
129
+ )
130
+ }
131
+
132
+ /**
133
+ * The first anchor row appended after `previousItemCount` items existed.
134
+ *
135
+ * @param {HTMLElement[]} items
136
+ * @param {number} previousItemCount
137
+ * @returns {HTMLElement | null}
138
+ */
139
+ export function getNewScrollAnchor(items, previousItemCount) {
140
+ for (let index = previousItemCount; index < items.length; index++) {
141
+ const item = items[index]
142
+ if (item?.dataset.scrollAnchor === "true") return item
143
+ }
144
+ return null
145
+ }
146
+
147
+ /**
148
+ * The first anchor row not yet in `handledAnchors`.
149
+ *
150
+ * @param {HTMLElement[]} items
151
+ * @param {Set<HTMLElement>} handledAnchors
152
+ * @returns {HTMLElement | null}
153
+ */
154
+ export function getUnanchoredScrollAnchor(items, handledAnchors) {
155
+ for (const item of items) {
156
+ if (item.dataset.scrollAnchor === "true" && !handledAnchors.has(item)) return item
157
+ }
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Whether more than one anchor row arrived after `previousItemCount`
163
+ * items existed (a multi-turn batch).
164
+ *
165
+ * @param {HTMLElement[]} items
166
+ * @param {number} previousItemCount
167
+ * @returns {boolean}
168
+ */
169
+ export function hasMultipleNewScrollAnchors(items, previousItemCount) {
170
+ let count = 0
171
+
172
+ for (let index = previousItemCount; index < items.length; index++) {
173
+ const item = items[index]
174
+ if (item?.dataset.scrollAnchor !== "true") continue
175
+
176
+ count += 1
177
+ if (count > 1) return true
178
+ }
179
+
180
+ return false
181
+ }
182
+
183
+ /**
184
+ * The last anchor row in the collection.
185
+ *
186
+ * @param {HTMLElement[]} items
187
+ * @returns {HTMLElement | null}
188
+ */
189
+ export function getLastScrollAnchor(items) {
190
+ for (let index = items.length - 1; index >= 0; index--) {
191
+ const item = items[index]
192
+ if (item?.dataset.scrollAnchor === "true") return item
193
+ }
194
+ return null
195
+ }
196
+
197
+ /**
198
+ * The topmost message row intersecting the viewport.
199
+ *
200
+ * @param {Object} args
201
+ * @param {HTMLElement} args.content
202
+ * @param {HTMLElement | null} args.spacer
203
+ * @param {HTMLElement} args.viewport
204
+ * @returns {HTMLElement | null}
205
+ */
206
+ export function getFirstVisibleMessageItem({ content, spacer, viewport }) {
207
+ const viewportRect = viewport.getBoundingClientRect()
208
+
209
+ for (const item of getMessageScrollerItems(content, spacer)) {
210
+ if (!item.dataset.messageId) continue
211
+
212
+ const rect = item.getBoundingClientRect()
213
+ if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) return item
214
+ }
215
+
216
+ return null
217
+ }
218
+
219
+ /**
220
+ * Target scrollTop that aligns `element` to the viewport inset (content
221
+ * block padding respected).
222
+ *
223
+ * @param {Object} args
224
+ * @param {"start" | "center" | "end" | "nearest"} args.align
225
+ * @param {HTMLElement} args.element
226
+ * @param {number} args.scrollMargin
227
+ * @param {HTMLElement | null} args.spacer
228
+ * @param {HTMLElement} args.viewport
229
+ * @returns {number} the target scrollTop (the caller clamps)
230
+ */
231
+ export function getElementScrollTop({ align, element, scrollMargin, spacer, viewport }) {
232
+ const elementTop = getElementTop(element, viewport)
233
+ const elementHeight = element.getBoundingClientRect().height
234
+ const contentPadding = getContentBlockPadding(spacer)
235
+
236
+ if (align === "center") {
237
+ const insetHeight = Math.max(
238
+ 0,
239
+ viewport.clientHeight - contentPadding.start - contentPadding.end
240
+ )
241
+
242
+ return elementTop - contentPadding.start - (insetHeight - elementHeight) / 2 - scrollMargin
243
+ }
244
+
245
+ if (align === "end") {
246
+ return elementTop - viewport.clientHeight + elementHeight + contentPadding.end + scrollMargin
247
+ }
248
+
249
+ if (align === "nearest") {
250
+ const elementBottom = elementTop + elementHeight
251
+ const viewportTop = viewport.scrollTop + contentPadding.start
252
+ const viewportBottom = viewport.scrollTop + viewport.clientHeight - contentPadding.end
253
+
254
+ if (elementTop >= viewportTop && elementBottom <= viewportBottom) {
255
+ return viewport.scrollTop
256
+ }
257
+
258
+ if (elementTop < viewportTop) {
259
+ return elementTop - contentPadding.start - scrollMargin
260
+ }
261
+
262
+ return elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin
263
+ }
264
+
265
+ return elementTop - contentPadding.start - scrollMargin
266
+ }
267
+
268
+ /**
269
+ * `element`'s top in the viewport's content space (independent of the
270
+ * current scroll position).
271
+ *
272
+ * @param {HTMLElement} element
273
+ * @param {HTMLElement} viewport
274
+ * @returns {number} px
275
+ */
276
+ export function getElementTop(element, viewport) {
277
+ const elementRect = element.getBoundingClientRect()
278
+ const viewportRect = viewport.getBoundingClientRect()
279
+
280
+ return elementRect.top - viewportRect.top + viewport.scrollTop
281
+ }
282
+
283
+ /**
284
+ * `element`'s top relative to the viewport's current visual top.
285
+ *
286
+ * @param {HTMLElement} element
287
+ * @param {HTMLElement} viewport
288
+ * @returns {number} px
289
+ */
290
+ export function getElementViewportTop(element, viewport) {
291
+ return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
292
+ }
293
+
294
+ /**
295
+ * Scroll room the tail spacer must fake below the last row so the
296
+ * requested scrollTop is reachable. Caller clamps/ceils (see the
297
+ * controller).
298
+ *
299
+ * @param {Object} args
300
+ * @param {HTMLElement} args.content
301
+ * @param {number} args.scrollTop - the position being made reachable
302
+ * @param {HTMLElement | null} args.spacer
303
+ * @param {HTMLElement} args.viewport
304
+ * @returns {number} px (possibly negative - the caller clamps)
305
+ */
306
+ export function getTailSpacerHeight({ content, scrollTop, spacer, viewport }) {
307
+ const contentBottom = getContentBottom({ content, spacer, viewport })
308
+
309
+ return scrollTop + viewport.clientHeight - contentBottom
310
+ }
311
+
312
+ /**
313
+ * Lowest row bottom in content space plus block padding, EXCLUDING the
314
+ * tail spacer - the spacer must never make the jump button appear.
315
+ *
316
+ * @param {Object} args
317
+ * @param {HTMLElement} args.content
318
+ * @param {HTMLElement | null} args.spacer
319
+ * @param {HTMLElement} args.viewport
320
+ * @returns {number} px
321
+ */
322
+ export function getContentBottom({ content, spacer, viewport }) {
323
+ const items = getMessageScrollerItems(content, spacer)
324
+ const padding = getBlockPadding(content)
325
+ const viewportRect = viewport.getBoundingClientRect()
326
+ const scrollTop = viewport.scrollTop
327
+ let contentBottom = padding.start + padding.end
328
+
329
+ for (const item of items) {
330
+ const rect = item.getBoundingClientRect()
331
+
332
+ contentBottom = Math.max(
333
+ contentBottom,
334
+ rect.bottom - viewportRect.top + scrollTop + padding.end
335
+ )
336
+ }
337
+
338
+ return contentBottom
339
+ }
340
+
341
+ /**
342
+ * The viewport's maximum scrollTop (never negative).
343
+ *
344
+ * @param {HTMLElement} viewport
345
+ * @returns {number} px
346
+ */
347
+ export function getMaxScrollTop(viewport) {
348
+ return Math.max(0, viewport.scrollHeight - viewport.clientHeight)
349
+ }
350
+
351
+ function getBlockPadding(element) {
352
+ const style = window.getComputedStyle(element)
353
+
354
+ return {
355
+ end: readCssPixel(style.paddingBlockEnd || style.paddingBottom),
356
+ start: readCssPixel(style.paddingBlockStart || style.paddingTop)
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Block padding of the spacer's parent (the content element); zeros
362
+ * without a spacer.
363
+ *
364
+ * @param {HTMLElement | null} spacer
365
+ * @returns {{ start: number, end: number }} px
366
+ */
367
+ export function getContentBlockPadding(spacer) {
368
+ const content = spacer?.parentElement
369
+ if (!content) return { end: 0, start: 0 }
370
+
371
+ return getBlockPadding(content)
372
+ }
373
+
374
+ /**
375
+ * The element's effective row gap (0 for "normal" or no element).
376
+ *
377
+ * @param {HTMLElement | null} element
378
+ * @returns {number} px
379
+ */
380
+ export function getFlexGap(element) {
381
+ if (!element) return 0
382
+
383
+ const style = window.getComputedStyle(element)
384
+ const gap = style.rowGap === "normal" ? style.gap : style.rowGap
385
+
386
+ return readCssPixel(gap)
387
+ }
388
+
389
+ function readCssPixel(value) {
390
+ if (!value) return 0
391
+
392
+ const number = Number.parseFloat(value)
393
+
394
+ return Number.isFinite(number) ? number : 0
395
+ }
396
+
397
+ /**
398
+ * Value equality for two scrollable snapshots.
399
+ *
400
+ * @param {{ start: boolean, end: boolean }} current
401
+ * @param {{ start: boolean, end: boolean }} next
402
+ * @returns {boolean}
403
+ */
404
+ export function areScrollStatesEqual(current, next) {
405
+ return current.start === next.start && current.end === next.end
406
+ }
407
+
408
+ /**
409
+ * Value equality for two visibility snapshots (anchor + ordered ids).
410
+ *
411
+ * @param {{ currentAnchorId: string | null, visibleMessageIds: string[] }} current
412
+ * @param {{ currentAnchorId: string | null, visibleMessageIds: string[] }} next
413
+ * @returns {boolean}
414
+ */
415
+ export function areVisibilityStatesEqual(current, next) {
416
+ if (current.currentAnchorId !== next.currentAnchorId) return false
417
+ if (current.visibleMessageIds.length !== next.visibleMessageIds.length) return false
418
+
419
+ return current.visibleMessageIds.every(
420
+ (messageId, index) => messageId === next.visibleMessageIds[index]
421
+ )
422
+ }
@@ -0,0 +1,80 @@
1
+ // The controllable-state convention (the DOM is the store): runtime
2
+ // state is a set of presence-boolean data attributes - written here,
3
+ // styled by CSS variants (data-open:..., via the vendored bridge
4
+ // variants that also match the older data-state="open|closed" value
5
+ // form), owned by whichever layer set it (a Stimulus Value default, the
6
+ // DOM, the URL, or a server re-render).
7
+ //
8
+ // Keys are PAIRS (or triples): setting one member writes its attribute
9
+ // and removes its counterparts. The negative popup/panel/pressed/selected
10
+ // keys only remove, deliberately: there is no data-popup-closed -
11
+ // absence IS the state.
12
+ /** Vocabulary key -> its writes: { add: attribute | null, remove: [attributes] }. */
13
+ export const VOCABULARY = {
14
+ open: { add: "data-open", remove: ["data-closed"] },
15
+ closed: { add: "data-closed", remove: ["data-open"] },
16
+ "popup-open": { add: "data-popup-open", remove: [] },
17
+ "popup-closed": { add: null, remove: ["data-popup-open"] },
18
+ "panel-open": { add: "data-panel-open", remove: [] },
19
+ "panel-closed": { add: null, remove: ["data-panel-open"] },
20
+ checked: { add: "data-checked", remove: ["data-unchecked", "data-indeterminate"] },
21
+ unchecked: { add: "data-unchecked", remove: ["data-checked", "data-indeterminate"] },
22
+ indeterminate: { add: "data-indeterminate", remove: ["data-checked", "data-unchecked"] },
23
+ pressed: { add: "data-pressed", remove: [] },
24
+ active: { add: "data-active", remove: [] },
25
+ inactive: { add: null, remove: ["data-active"] },
26
+ unpressed: { add: null, remove: ["data-pressed"] },
27
+ selected: { add: "data-selected", remove: [] },
28
+ unselected: { add: null, remove: ["data-selected"] }
29
+ }
30
+
31
+ // Derivation order: the open/closed pair first (the common ask), then the
32
+ // checked family, then the bare booleans. Attribute-only "closed" states
33
+ // (popup/panel/pressed/selected negatives) derive as undefined - callers
34
+ // that care test the attribute directly (hasAttribute("data-popup-open")).
35
+ const DERIVATION = [
36
+ ["data-open", "open"], ["data-closed", "closed"],
37
+ ["data-checked", "checked"], ["data-unchecked", "unchecked"],
38
+ ["data-indeterminate", "indeterminate"],
39
+ ["data-popup-open", "popup-open"], ["data-panel-open", "panel-open"],
40
+ ["data-pressed", "pressed"], ["data-selected", "selected"], ["data-active", "active"]
41
+ ]
42
+
43
+ /**
44
+ * The vocabulary key `element` currently wears, per the derivation order
45
+ * above. Attribute-only negative states (the popup/panel/pressed/selected
46
+ * negatives) have no positive attribute to find and derive as undefined -
47
+ * callers that care test the attribute directly.
48
+ *
49
+ * @param {Element} element
50
+ * @returns {string | undefined}
51
+ */
52
+ export function stateOf(element) {
53
+ for (const [attribute, key] of DERIVATION) {
54
+ if (element.hasAttribute(attribute)) return key
55
+ }
56
+ return undefined
57
+ }
58
+
59
+ /**
60
+ * Writes vocabulary key `key` onto `element`: sets its add-attribute (when
61
+ * the key has one), removes its counterparts, and announces the flip as a
62
+ * bubbling `poetry:state-change` CustomEvent carrying `{ state: key }`.
63
+ *
64
+ * @param {Element} element
65
+ * @param {string} key - a {@link VOCABULARY} key
66
+ * @returns {string} the key that was written
67
+ * @throws {Error} on a key outside the vocabulary
68
+ */
69
+ export function setState(element, key) {
70
+ const writes = VOCABULARY[key]
71
+ if (!writes) throw new Error(`poetry state: unknown vocabulary key ${key}`)
72
+
73
+ if (writes.add) element.setAttribute(writes.add, "")
74
+ for (const attribute of writes.remove) element.removeAttribute(attribute)
75
+
76
+ element.dispatchEvent(
77
+ new CustomEvent("poetry:state-change", { detail: { state: key }, bubbles: true })
78
+ )
79
+ return key
80
+ }
@@ -0,0 +1,56 @@
1
+ // Tabbable-candidate walk: the shared filter behind focus-scope and
2
+ // the Dialog trap. Candidates in DOM order, minus disabled / hidden /
3
+ // tabindex=-1 / inert-subtree elements.
4
+
5
+ const CANDIDATE_SELECTOR = [
6
+ "a[href]",
7
+ "button",
8
+ "input",
9
+ "select",
10
+ "textarea",
11
+ "[tabindex]",
12
+ '[contenteditable="true"]',
13
+ "audio[controls]",
14
+ "video[controls]"
15
+ ].join(", ")
16
+
17
+ /**
18
+ * The tabbable elements under `container`, in DOM order: candidate
19
+ * tags/tabindexes minus disabled / hidden / tabindex=-1 / inert-subtree /
20
+ * type=hidden elements, with each radio group collapsed to its single
21
+ * real tab stop (see below).
22
+ *
23
+ * @param {Element} container
24
+ * @returns {Element[]}
25
+ */
26
+ export function tabbableWithin(container) {
27
+ const candidates = Array.from(container.querySelectorAll(CANDIDATE_SELECTOR)).filter(
28
+ (element) =>
29
+ !element.disabled &&
30
+ !element.hidden &&
31
+ element.getAttribute("tabindex") !== "-1" &&
32
+ element.closest("[inert]") === null &&
33
+ element.type !== "hidden"
34
+ )
35
+
36
+ return candidates.filter((element) => isRadioTabStop(element, candidates))
37
+ }
38
+
39
+ // A radio GROUP is one tab stop, not one per radio - the platform's own
40
+ // Tab rule: the checked radio represents the group; an all-unchecked group is
41
+ // represented by its first radio. Without this, a dialog trap treats every
42
+ // radio as an edge candidate and Shift+Tab at the "first" tabbable is
43
+ // wrong whenever a radio group sits at either end of the scope.
44
+ function isRadioTabStop(element, candidates) {
45
+ if (element.type !== "radio" || !element.name) return true
46
+
47
+ const group = candidates.filter(
48
+ (candidate) =>
49
+ candidate.type === "radio" &&
50
+ candidate.name === element.name &&
51
+ candidate.form === element.form
52
+ )
53
+ const checked = group.find((candidate) => candidate.checked)
54
+
55
+ return checked ? element === checked : element === group[0]
56
+ }
@@ -0,0 +1,21 @@
1
+ // Turbo snapshots the page BEFORE caching it (turbo:before-cache), and an
2
+ // overlay still open at that moment serializes INTO the snapshot: the
3
+ // restoration visit then renders a de-modalized zombie dialog (the open
4
+ // attribute survives HTML serialization, top-layer/modal state does not)
5
+ // over a body whose inline scroll-lock / pointer-events-scrim styles came
6
+ // back frozen - a page that can never scroll, or never be clicked, again
7
+ // (both reproduced live against the docs app). Overlay controllers
8
+ // subscribe their synchronous teardown here; no-op when Turbo is absent.
9
+ /**
10
+ * Subscribes `callback` to Turbo's before-cache moment - the last
11
+ * synchronous chance to tear an overlay down before the snapshot is
12
+ * taken. A page without Turbo simply never fires it.
13
+ *
14
+ * @param {(event: Event) => void} callback - must finish synchronously;
15
+ * the snapshot is taken right after the event dispatch completes
16
+ * @returns {() => void} unsubscribe
17
+ */
18
+ export function onBeforeCache(callback) {
19
+ document.addEventListener("turbo:before-cache", callback)
20
+ return () => document.removeEventListener("turbo:before-cache", callback)
21
+ }
@@ -0,0 +1,85 @@
1
+ // The APG typeahead buffer, shared: printable keys accumulate into a
2
+ // search buffer that resets after a timeout (1s default), matching wraps
3
+ // from the current item, and a repeated same-letter buffer cycles matches.
4
+ // Extracted VERBATIM from menu_controller.js so the menu family and the
5
+ // Select listbox run the identical algorithm - the buffer/timer state
6
+ // lives in the instance this factory returns, one per consuming
7
+ // controller.
8
+
9
+ /**
10
+ * The label an item types against: data-text-value overrides textContent
11
+ * (icon-rich content declares its typed label explicitly).
12
+ *
13
+ * @param {Element} item
14
+ * @returns {string} the trimmed label ("" when neither source exists)
15
+ */
16
+ export function typeaheadLabel(item) {
17
+ return (item.dataset.textValue ?? item.textContent ?? "").trim()
18
+ }
19
+
20
+ /**
21
+ * One typeahead instance for a consuming controller - the buffer/timer
22
+ * state lives in the returned object (the module header holds the
23
+ * algorithm).
24
+ *
25
+ * @returns {{ pending: () => boolean, reset: () => void,
26
+ * search: (key: string, items: Element[], options?: Object) => Element | null }}
27
+ */
28
+ export function createTypeahead() {
29
+ let buffer = ""
30
+ let timer = null
31
+
32
+ return {
33
+ /**
34
+ * A live buffer means Space extends the search instead of activating.
35
+ * @returns {boolean}
36
+ */
37
+ pending() {
38
+ return buffer !== ""
39
+ },
40
+
41
+ /** Clears the buffer and cancels its reset timer. */
42
+ reset() {
43
+ window.clearTimeout(timer)
44
+ timer = null
45
+ buffer = ""
46
+ },
47
+
48
+ /**
49
+ * The match walk: a repeated same-letter buffer cycles matches;
50
+ * a growing buffer keeps the current item first so continued typing
51
+ * stays put while it still matches; single-letter search excludes the
52
+ * current item so it always advances. The consumer decides what a
53
+ * match means (menus focus it; a closed Select trigger commits it).
54
+ *
55
+ * @param {string} key - the printable key just typed
56
+ * @param {Element[]} items - ENABLED items only (disabled filtering
57
+ * is the consumer's collection contract)
58
+ * @param {Object} [options]
59
+ * @param {Element | null} [options.active=null] - the item the walk
60
+ * starts from
61
+ * @param {number} [options.timeout=1000] - ms before the buffer resets
62
+ * @param {(item: Element) => string} [options.labelOf=typeaheadLabel]
63
+ * @returns {Element | null} the matched item
64
+ */
65
+ search(key, items, { active = null, timeout = 1000, labelOf = typeaheadLabel } = {}) {
66
+ window.clearTimeout(timer)
67
+ timer = window.setTimeout(() => { buffer = "" }, timeout)
68
+ buffer += key
69
+
70
+ if (items.length === 0) return null
71
+
72
+ const repeated = buffer.length > 1 && Array.from(buffer).every((char) => char === buffer[0])
73
+ const search = (repeated ? buffer[0] : buffer).toLowerCase()
74
+ const currentIndex = Math.max(items.indexOf(active), 0)
75
+
76
+ let ordered = items.map((_, offset) => (currentIndex + offset) % items.length)
77
+
78
+ if (search.length === 1) ordered = ordered.filter((index) => items[index] !== active)
79
+
80
+ const match = ordered.find((index) => labelOf(items[index]).toLowerCase().startsWith(search))
81
+
82
+ return match === undefined ? null : items[match]
83
+ }
84
+ }
85
+ }