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,217 @@
1
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
2
+
3
+ // The portal-on-open mechanism: move popper
4
+ // content to a stable container (body by default) while open so it can
5
+ // position `absolute` - static under compositor scroll, immune to
6
+ // transformed ancestors - and return it HOME on close, exactly where a
7
+ // placeholder comment marks the origin.
8
+ //
9
+ // THE EVENT BRIDGE (the logical-tree trap): a component-tree portal
10
+ // re-bubbles events from its logical position; a DOM portal does not. A
11
+ // host's data-action on the component ROOT would go deaf to events
12
+ // rising out of portaled content. The bridge restores logical-tree
13
+ // bubbling for poetry's OWN
14
+
15
+ // CustomEvents (the registered list below - NEVER native events, which
16
+ // the dismissal/hotkey layers listen for at document and must see on the
17
+ // real path): propagation is CUT at the content boundary and a clone is
18
+ // re-dispatched from the home position, so above the content the event
19
+ // exists on exactly ONE path - the home one. Cancellation transfers back
20
+ // (the re-dispatch is synchronous, inside the original dispatch), and the
21
+ // real origin element rides the clone as `portalTarget` (the clone's own
22
+ // `target` is the home-side parent - a re-dispatched event cannot keep
23
+ // the original).
24
+ //
25
+ // Restore is guarded: if a Turbo morph replaced the origin while the
26
+ // popup was out, the content is DROPPED, never stranded at body. A lazy
27
+ // turbo:before-cache net force-restores everything still portaled so a
28
+ // snapshot never caches body-level popups (the zombie-popup class).
29
+ const portaled = new Map()
30
+ const bridgeEvents = new Set(["poetry:state-change"])
31
+ let cacheNetInstalled = false
32
+
33
+ /**
34
+ * Adds event names to the bridge list. index.js registers the union of
35
+ * every controller's declared `static events` at boot - the bridge list
36
+ * stays honest against the manifest surface without portal.js importing
37
+ * the controllers (no cycle).
38
+ *
39
+ * @param {Iterable<string>} names - full event names as emitted
40
+ */
41
+ export function registerBridgeEvents(names) {
42
+ for (const name of names) bridgeEvents.add(name)
43
+ }
44
+
45
+ /**
46
+ * The portal-container seam, attribute-shaped: a host scoping themes to
47
+ * a subtree points its overlays at a container inside that scope via
48
+ * data-poetry-portal-container="<element id>".
49
+ *
50
+ * @param {Element | null} root - the element carrying the attribute
51
+ * @returns {Element} the named container, or document.body
52
+ */
53
+ export function resolvePortalContainer(root) {
54
+ const id = root?.getAttribute?.("data-poetry-portal-container")
55
+
56
+ return (id ? document.getElementById(id) : null) ?? document.body
57
+ }
58
+
59
+ /**
60
+ * Whether `content` is currently portaled out (has a live placeholder).
61
+ *
62
+ * @param {Element} content
63
+ * @returns {boolean}
64
+ */
65
+ export function isPortaled(content) {
66
+ return portaled.has(content)
67
+ }
68
+
69
+ /**
70
+ * Containment that follows portals HOME: a node inside portaled content
71
+ * counts as inside `container` when the content's home position does.
72
+ * Focus-scope's trap keys on this - a portaled sub level is outside the
73
+ * root content's subtree but logically inside its tree, and the trap
74
+ * must follow the logical tree or every portaled level reads as outside.
75
+ *
76
+ * @param {Element} container
77
+ * @param {Node | null} node
78
+ * @returns {boolean}
79
+ */
80
+ export function logicallyContains(container, node) {
81
+ let current = node
82
+
83
+ while (current) {
84
+ if (container.contains(current)) return true
85
+
86
+ const content = portaledAncestorOf(current)
87
+
88
+ if (!content) return false
89
+
90
+ current = portaled.get(content).placeholder.parentNode
91
+ }
92
+
93
+ return false
94
+ }
95
+
96
+ function portaledAncestorOf(node) {
97
+ for (let el = node instanceof Element ? node : node?.parentElement; el; el = el.parentElement) {
98
+ if (portaled.has(el)) return el
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Moves `content` to `container`, leaving a placeholder comment at home
106
+ * and wiring the event bridge for the registered list. No-op (false) when
107
+ * already portaled or parentless. The home-effective `dir` is stamped
108
+ * onto undeclared content for the trip, so direction-dependent behavior
109
+ * survives the move (un-stamped on restore).
110
+ *
111
+ * @param {Element} content
112
+ * @param {Object} [options]
113
+ * @param {Element} [options.container=document.body]
114
+ * @returns {boolean} true when the move happened
115
+ */
116
+ export function portalContent(content, { container = document.body } = {}) {
117
+ if (portaled.has(content) || !content.parentNode) return false
118
+
119
+ const placeholder = document.createComment("poetry-portal")
120
+
121
+ content.parentNode.insertBefore(placeholder, content)
122
+
123
+ // Direction survives the move: dir inherits through the DOM, so a body
124
+ // portal would silently flip a locally-RTL subtree back to the document
125
+ // direction - menu arrow semantics (directionOf walks closest [dir])
126
+ // and every CSS logical property inside the popup key on it. Stamp the
127
+ // home-effective dir when the content declares none; restore un-stamps.
128
+ let stampedDir = false
129
+
130
+ if (!content.hasAttribute("dir")) {
131
+ const dir = content.closest("[dir]")?.getAttribute("dir")
132
+
133
+ if (dir) {
134
+ content.setAttribute("dir", dir)
135
+ stampedDir = true
136
+ }
137
+ }
138
+
139
+ container.append(content)
140
+
141
+ const bridges = []
142
+
143
+ for (const type of bridgeEvents) {
144
+ const listener = (event) => bridge(event, placeholder)
145
+
146
+ content.addEventListener(type, listener)
147
+ bridges.push([type, listener])
148
+ }
149
+
150
+ portaled.set(content, { placeholder, bridges, stampedDir })
151
+ installCacheNet()
152
+ return true
153
+ }
154
+
155
+ /**
156
+ * Returns portaled `content` to its placeholder and unwires the bridge.
157
+ * When the origin is gone (a morph replaced it) the content is DROPPED,
158
+ * never stranded at the container.
159
+ *
160
+ * @param {Element} content
161
+ * @returns {boolean} true when the content went home; false when it was
162
+ * not portaled, or had to be dropped
163
+ */
164
+ export function restoreContent(content) {
165
+ const state = portaled.get(content)
166
+
167
+ if (!state) return false
168
+
169
+ portaled.delete(content)
170
+ for (const [type, listener] of state.bridges) content.removeEventListener(type, listener)
171
+ if (state.stampedDir) content.removeAttribute("dir")
172
+
173
+ // Home-aliveness reads the parent ELEMENT, never the comment: dommy's
174
+ // QuickJS DOM has no Comment#isConnected (undefined reads as "origin
175
+ // gone" and silently DROPS live content - the dommy tier caught it).
176
+ // replaceChild over ChildNode.replaceWith for the same reason.
177
+ const home = state.placeholder.parentNode
178
+
179
+ if (home?.isConnected) {
180
+ home.replaceChild(content, state.placeholder)
181
+ return true
182
+ }
183
+
184
+ // The origin is gone (a morph replaced it) - drop, never strand.
185
+ state.placeholder.parentNode?.removeChild(state.placeholder)
186
+ content.remove()
187
+ return false
188
+ }
189
+
190
+ // Bubble-phase on the content node itself, so the consumer's own content
191
+ // listeners (wired at connect, before any portal) always run first.
192
+ function bridge(event, placeholder) {
193
+ const home = placeholder.parentNode
194
+
195
+ if (!home?.isConnected) return
196
+
197
+ event.stopPropagation()
198
+
199
+ const clone = new CustomEvent(event.type, {
200
+ detail: event.detail, bubbles: true,
201
+ cancelable: event.cancelable, composed: event.composed
202
+ })
203
+
204
+ clone.portalTarget = event.target
205
+ home.dispatchEvent(clone)
206
+
207
+ if (clone.defaultPrevented) event.preventDefault()
208
+ }
209
+
210
+ function installCacheNet() {
211
+ if (cacheNetInstalled) return
212
+
213
+ cacheNetInstalled = true
214
+ onBeforeCache(() => {
215
+ for (const content of [...portaled.keys()]) restoreContent(content)
216
+ })
217
+ }
@@ -0,0 +1,210 @@
1
+ import { setState } from "@poetry/controllers/helpers/state"
2
+ import { onBeforeCache } from "@poetry/controllers/helpers/turbo_cache"
3
+
4
+ // Presence: the mount/unmount animation convention - a helper, not a
5
+ // controller (no element ownership here). Exit flips the pair to
6
+ // data-closed and HOLDS the node in the DOM until its CSS exit animation /
7
+ // transition finishes, then hands removal back to the caller via onRemove
8
+ // (this module never removes DOM); enter just flips the pair to data-open
9
+ // so the data-open: animation runs. State writes go through setState so
10
+ // poetry:state-change fires like every other state flip.
11
+
12
+ // Grace (ms) added to the computed animation/transition time before the
13
+ // safety timeout fires - animationend can lag the declared duration.
14
+ const EXIT_TIMEOUT_GRACE = 100
15
+
16
+ // Safety net (ms) when a duration cannot be computed (an animation is
17
+ // declared but reports zero length): never strand a closed node in the DOM.
18
+ const EXIT_TIMEOUT_FALLBACK = 1000
19
+
20
+ // Exits waiting on their CSS animation. A Turbo snapshot must never
21
+ // capture one mid-flight: the owner's onRemove (hidden + layer-controller
22
+ // removal) would land AFTER the cache is taken, so the restored page
23
+ // resurrects a live layer - the click-dead restore class. Every pending
24
+ // exit is flushed synchronously at turbo:before-cache; the dismissable
25
+ // layer also flushes explicitly after dispatching its before-cache
26
+ // dismiss, so exits STARTED by that dismiss complete in the same tick
27
+ // regardless of listener order.
28
+ const pendingExits = new Set()
29
+
30
+ /**
31
+ * Settles every exit still waiting on its CSS animation, synchronously
32
+ * (each runs its onRemove). Wired to turbo:before-cache at module load;
33
+ * the dismissable layer also calls it directly (see above).
34
+ */
35
+ export function flushPendingExits() {
36
+ for (const flush of [...pendingExits]) flush()
37
+ }
38
+
39
+ if (typeof document !== "undefined") onBeforeCache(flushPendingExits)
40
+
41
+ /**
42
+ * The measured-entry/exit hook (the Accordion contract's height
43
+ * mechanism): height keyframes cannot animate to auto, so the keyframe
44
+ * chain reads a CSS var instead. This measures the settled box -
45
+ * temporarily unhiding the element and suppressing its animations so
46
+ * scrollHeight reports the real content height - writes "<n>px" to the
47
+ * custom property, then restores exactly what it changed.
48
+ *
49
+ * @param {HTMLElement} element
50
+ * @param {Object} [options]
51
+ * @param {string} [options.property="--poetry-presence-height"] -
52
+ * overridable because the vendored keyframes read
53
+ * --accordion-panel-height
54
+ * @returns {number} the measured height (px)
55
+ */
56
+ export function measurePresence(element, { property = "--poetry-presence-height" } = {}) {
57
+ const wasHidden = element.hidden
58
+ const previousAnimation = element.style.animation
59
+
60
+ if (wasHidden) element.hidden = false
61
+ element.style.animation = "none" // a mid-flight keyframe must not skew the measure
62
+
63
+ const height = element.scrollHeight
64
+ element.style.setProperty(property, `${height}px`)
65
+
66
+ element.style.animation = previousAnimation
67
+ if (wasHidden) element.hidden = true
68
+
69
+ return height
70
+ }
71
+
72
+ /**
73
+ * Runs the entry: flips the pair to data-open (through setState, so the
74
+ * state-change event fires) wearing data-starting-style for exactly one
75
+ * painted frame after the flip (the two-frame trick below), so CSS
76
+ * transitions can animate FROM the starting declarations. No poetry class
77
+ * consumes the attribute yet - it ships so a future theme layer can adopt
78
+ * the transition idiom without touching JS.
79
+ *
80
+ * @param {HTMLElement} element
81
+ * @param {Object} [options]
82
+ * @param {boolean} [options.measure=false] - measure BEFORE the pair
83
+ * flips, so the entry keyframe can consume the var from its first frame
84
+ * @param {string} [options.property] - forwarded to
85
+ * {@link measurePresence}
86
+ * @returns {HTMLElement} the element
87
+ */
88
+ export function enterPresence(element, { measure = false, property } = {}) {
89
+ if (measure) measurePresence(element, { property })
90
+
91
+ element.removeAttribute("data-ending-style") // an interrupted exit must not linger
92
+ element.setAttribute("data-starting-style", "")
93
+ setState(element, "open")
94
+
95
+ if (typeof requestAnimationFrame === "function") {
96
+ // Two frames: the first paints WITH the attribute, the removal lands
97
+ // on the next - the transition sees both endpoints.
98
+ requestAnimationFrame(() => {
99
+ requestAnimationFrame(() => element.removeAttribute("data-starting-style"))
100
+ })
101
+ } else {
102
+ element.removeAttribute("data-starting-style") // no rAF (bare interpreter): inert either way
103
+ }
104
+
105
+ return element
106
+ }
107
+
108
+ /**
109
+ * Runs the exit: flips the pair to data-closed and HOLDS the node in the
110
+ * DOM until its CSS exit animation/transition finishes, then hands
111
+ * removal back to the caller (this module never removes DOM). onRemove
112
+ * runs at most once, on the first of animationend / transitionend (on the
113
+ * element itself, not a child) or the safety timeout - or synchronously
114
+ * when no exit animation exists. data-ending-style rides the whole exit.
115
+ *
116
+ * @param {HTMLElement} element
117
+ * @param {Object} [options]
118
+ * @param {() => void} [options.onRemove] - the caller's removal step
119
+ * @param {boolean} [options.measure=false] - measure while the element is
120
+ * STILL VISIBLE, before the flip to "closed" starts the exit keyframe
121
+ * @param {string} [options.property] - forwarded to
122
+ * {@link measurePresence}
123
+ * @returns {() => void} cancel() - abandons the wait WITHOUT calling
124
+ * onRemove (an exit interrupted by a re-open)
125
+ */
126
+ export function exitPresence(element, { onRemove, measure = false, property } = {}) {
127
+ if (measure) measurePresence(element, { property })
128
+
129
+ // The exit-window hook: data-ending-style rides the whole exit
130
+ // and leaves with the node's removal (or an interrupting re-open).
131
+ element.setAttribute("data-ending-style", "")
132
+ setState(element, "closed")
133
+
134
+ if (!hasExitAnimation(element)) {
135
+ element.removeAttribute("data-ending-style")
136
+ onRemove?.()
137
+ return () => {}
138
+ }
139
+
140
+ let settled = false
141
+ let timeout = null
142
+
143
+ const cleanup = () => {
144
+ element.removeEventListener("animationend", settle)
145
+ element.removeEventListener("transitionend", settle)
146
+ if (timeout !== null) window.clearTimeout(timeout)
147
+ pendingExits.delete(flush)
148
+ }
149
+
150
+ const settle = (event) => {
151
+ if (event && event.target !== element) return // a child's animation is not ours
152
+ if (settled) return
153
+
154
+ settled = true
155
+ cleanup()
156
+ element.removeAttribute("data-ending-style")
157
+ onRemove?.()
158
+ }
159
+
160
+ const flush = () => settle()
161
+
162
+ element.addEventListener("animationend", settle)
163
+ element.addEventListener("transitionend", settle)
164
+ timeout = window.setTimeout(settle, exitTimeoutFor(element))
165
+ pendingExits.add(flush)
166
+
167
+ // getAnimations().finished is the robust end-detection: it resolves for
168
+ // transitions AND keyframes symmetrically and needs no duration math.
169
+ // Kept ALONGSIDE the listener+timeout paths - jsdom and the dommy tier
170
+ // don't implement getAnimations, and settle() is idempotent.
171
+ if (typeof element.getAnimations === "function") {
172
+ const animations = element.getAnimations()
173
+ if (animations.length > 0) {
174
+ Promise.allSettled(animations.map((animation) => animation.finished))
175
+ .then(() => settle())
176
+ }
177
+ }
178
+
179
+ return () => {
180
+ settled = true
181
+ cleanup()
182
+ element.removeAttribute("data-ending-style")
183
+ }
184
+ }
185
+
186
+ // jsdom (and a bare node) report animationName as "" - treat it as "none".
187
+ function hasExitAnimation(element) {
188
+ const style = getComputedStyle(element)
189
+ const animated = (style.animationName || "none") !== "none"
190
+ const transitioned = maxTimeMs(style.transitionDuration) > 0
191
+ return animated || transitioned
192
+ }
193
+
194
+ function exitTimeoutFor(element) {
195
+ const style = getComputedStyle(element)
196
+ const total = Math.max(
197
+ maxTimeMs(style.animationDuration) + maxTimeMs(style.animationDelay),
198
+ maxTimeMs(style.transitionDuration) + maxTimeMs(style.transitionDelay)
199
+ )
200
+ return total > 0 ? total + EXIT_TIMEOUT_GRACE : EXIT_TIMEOUT_FALLBACK
201
+ }
202
+
203
+ // Longest entry of a comma-separated CSS time list: "0.2s, 50ms" -> 200.
204
+ function maxTimeMs(value = "") {
205
+ return Math.max(0, ...String(value).split(",").map((time) => {
206
+ const parsed = parseFloat(time)
207
+ if (Number.isNaN(parsed)) return 0
208
+ return time.trim().endsWith("ms") ? parsed : parsed * 1000
209
+ }))
210
+ }
@@ -0,0 +1,76 @@
1
+ // The registration guard: Stimulus never errors on a data-controller
2
+ // identifier nothing registered - the element simply stays inert - and one
3
+ // failed import in the host's controllers graph silently takes every poetry
4
+ // controller down with it. After the page is ready (and again on every
5
+ // Turbo navigation) this compares the poetry-prefixed identifiers on the
6
+ // page with the application's registry and warns ONCE per identifier. Host
7
+ // controllers are never inspected (they may lazy-load); poetry's cannot.
8
+
9
+ const PREFIX = "poetry--"
10
+ const warned = new Set()
11
+
12
+ /**
13
+ * The poetry identifiers on the page that `application` has not
14
+ * registered.
15
+ *
16
+ * @param {import("@hotwired/stimulus").Application} application
17
+ * @param {ParentNode} [root=document] - the subtree to inspect
18
+ * @returns {string[]} sorted, deduplicated identifiers
19
+ */
20
+ export function unregisteredPoetryControllers(application, root = document) {
21
+ const registry = application && application.router && application.router.modulesByIdentifier
22
+ if (!registry || !root || !root.querySelectorAll) return []
23
+ const missing = new Set()
24
+ for (const element of root.querySelectorAll("[data-controller]")) {
25
+ for (const identifier of element.getAttribute("data-controller").split(/\s+/)) {
26
+ if (identifier.startsWith(PREFIX) && !registry.has(identifier)) missing.add(identifier)
27
+ }
28
+ }
29
+ return Array.from(missing).sort()
30
+ }
31
+
32
+ /**
33
+ * Warns once per newly-seen unregistered poetry identifier.
34
+ *
35
+ * @param {import("@hotwired/stimulus").Application} application
36
+ * @param {ParentNode} [root=document]
37
+ * @returns {string[]} the identifiers warned about in this call
38
+ */
39
+ export function checkPoetryRegistration(application, root = document) {
40
+ const fresh = unregisteredPoetryControllers(application, root).filter((identifier) => !warned.has(identifier))
41
+ if (fresh.length === 0) return fresh
42
+ fresh.forEach((identifier) => warned.add(identifier))
43
+ console.warn(
44
+ `[poetry] ${fresh.length} poetry controller(s) on this page are not registered on the Stimulus application: ` +
45
+ `${fresh.join(", ")}. The elements stay inert - check the importmap pins and that every poetry gem's ` +
46
+ `register call runs in controllers/index.js.`
47
+ )
48
+ return fresh
49
+ }
50
+
51
+ let scheduled = false
52
+
53
+ /**
54
+ * Runs the check once the DOM is parsed (registration usually happens
55
+ * while the document is still loading) and after every Turbo navigation.
56
+ * Idempotent: the first registrar to call it wires the listeners.
57
+ *
58
+ * @param {import("@hotwired/stimulus").Application} application
59
+ */
60
+ export function guardPoetryRegistration(application) {
61
+ if (scheduled || typeof document === "undefined") return
62
+ scheduled = true
63
+ const run = () => checkPoetryRegistration(application)
64
+ if (document.readyState === "loading") {
65
+ document.addEventListener("DOMContentLoaded", run, { once: true })
66
+ } else {
67
+ setTimeout(run, 0)
68
+ }
69
+ document.addEventListener("turbo:load", run)
70
+ }
71
+
72
+ /** Test seam: forget what has been warned about and re-arm the scheduler. */
73
+ export function resetPoetryRegistrationGuard() {
74
+ warned.clear()
75
+ scheduled = false
76
+ }
@@ -0,0 +1,67 @@
1
+ // Body scroll-lock with scrollbar-width compensation: bare
2
+ // `overflow: hidden` shifts the whole layout by the scrollbar width the
3
+ // moment an overlay opens on a scrollable page.
4
+ // The gap is measured BEFORE locking and paid back as body padding-right.
5
+ // Refcounted so stacked overlays (a dialog opened from a sheet) lock once
6
+ // and restore only when the LAST one closes - per-instance saved values
7
+ // break on out-of-order closes.
8
+ //
9
+ // Why not scrollbar-gutter: stable (this helper's original primary)?
10
+ // Measured live with classic
11
+ // scrollbars: Chrome drops the viewport's rail AND its reserved gutter
12
+ // the moment the viewport's used overflow computes to hidden - whether
13
+ // the pair sits on the root, propagates from body, or the gutter was set
14
+ // permanently - so the page shifted by the scrollbar width anyway (the
15
+ // exact wiggle the strategy existed to stop). The body-padding payback
16
+ // is the only compensation the viewport honors; its known cost is that
17
+ // position:fixed elements aren't compensated - accepted deliberately,
18
+ // not an oversight.
19
+ let locks = 0
20
+ let previous = null
21
+
22
+ /**
23
+ * Locks body scrolling, paying the measured scrollbar gap back as body
24
+ * padding-right so the layout never shifts. Refcounted: stacked overlays
25
+ * lock once; only the first call writes styles.
26
+ */
27
+ export function lockScroll() {
28
+ locks += 1
29
+ if (locks > 1) return
30
+
31
+ const gap = window.innerWidth - document.documentElement.clientWidth
32
+ // Never save a value the lock itself writes: a Turbo-restored snapshot
33
+ // arrives with the serialized "hidden" already inline (no refcount
34
+ // behind it), and saving it would re-freeze scrolling on every later
35
+ // unlock (the poisoned-previous restore class).
36
+ const overflow = document.body.style.overflow
37
+ previous = {
38
+ overflow: overflow === "hidden" ? "" : overflow,
39
+ paddingRight: document.body.style.paddingRight
40
+ }
41
+ if (gap > 0) {
42
+ const current = parseFloat(getComputedStyle(document.body).paddingRight) || 0
43
+ document.body.style.paddingRight = `${current + gap}px`
44
+ }
45
+ document.body.style.overflow = "hidden"
46
+ }
47
+
48
+ /**
49
+ * Releases one scroll lock; the LAST release restores the body's saved
50
+ * overflow and padding-right.
51
+ */
52
+ export function unlockScroll() {
53
+ if (locks === 0) return
54
+
55
+ locks -= 1
56
+ if (locks > 0) return
57
+
58
+ document.body.style.overflow = previous.overflow
59
+ document.body.style.paddingRight = previous.paddingRight
60
+ previous = null
61
+ }
62
+
63
+ /** Test seam: vitest suites run many overlays in one document. */
64
+ export function resetScrollLock() {
65
+ locks = 0
66
+ previous = null
67
+ }