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,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # Base component class for all Poetry components.
6
+ #
7
+ # This class serves as the foundation for all Poetry components, providing:
8
+ # - ActiveModel integration for attributes, assignment, and validations
9
+ # - Style attribute management with variants and proc defaults
10
+ # - HTML attribute handling and merging
11
+ # - Translation and wrapping helpers
12
+ # - Classname merging functionality
13
+ # - Component metadata and identification methods
14
+ #
15
+ # @example Basic component usage
16
+ # class MyComponent < Poetry::Core::Component
17
+ # style :color, default: :primary, variants: [:primary, :secondary]
18
+ # style :size, default: :md, variants: [:sm, :md, :lg]
19
+ # end
20
+ #
21
+ # component = MyComponent.new(color: :secondary, size: :lg, class: "custom-class")
22
+ # component.color # => :secondary
23
+ # component.size # => :lg
24
+ # component.html_attributes # => { class: "... custom-class" }
25
+ #
26
+ # @example Component with proc defaults
27
+ # class Badge < Poetry::Core::Component
28
+ # style :color, default: :gray, variants: [:gray, :red, :blue]
29
+ # style :dot_color, default: -> { color }, variants: [:gray, :red, :blue]
30
+ # end
31
+ #
32
+ # badge = Badge.new(color: :red)
33
+ # badge.dot_color # => :red (inherits from color)
34
+ #
35
+ # @see Poetry::Core::Concerns::Styles
36
+ # @see Poetry::Core::Concerns::Options
37
+ class Component < ViewComponent::Base
38
+ include ActiveModel::Attributes
39
+ include ActiveModel::AttributeAssignment
40
+ include ActiveModel::Validations
41
+ include Poetry::Core::Contrib::WrappedHelper
42
+ include Poetry::Core::Concerns::Styles
43
+ include Poetry::Core::Concerns::Options
44
+ include Poetry::Core::Concerns::Stimulus
45
+ include Poetry::Core::Concerns::Introspection
46
+ include Poetry::Core::Concerns::Parts
47
+ include Poetry::Core::Concerns::AgentTools
48
+
49
+ # Implementation-detail component classes (a family's inner Item /
50
+ # Group / Sub / Menu classes) inherit the full Component machinery
51
+ # without becoming PUBLISHED components: registry discovery skips
52
+ # internal components, and everything derived from the registry
53
+ # (agent surface, llms, contract gates, docs) follows. Inherited, so
54
+ # a subclass of an internal component stays internal.
55
+ class_attribute :internal_component, default: false, instance_predicate: false
56
+
57
+ class << self
58
+ # Marks this class (and its descendants) as an implementation
59
+ # detail - full machinery, no registry entry.
60
+ #
61
+ # @example An inner class of a component family
62
+ # class DropdownMenu::Item::Component < Poetry::Core::Component
63
+ # internal_component!
64
+ # end
65
+ # @return [void]
66
+ def internal_component!
67
+ self.internal_component = true
68
+ end
69
+ end
70
+
71
+ class << self
72
+ # The current Poetry::Core configuration - a shortcut to
73
+ # {Poetry::Core::Config.current} for components and their templates.
74
+ #
75
+ # @return [Poetry::Core::Config] the current configuration instance
76
+ def config
77
+ Poetry::Core::Config.current
78
+ end
79
+
80
+ # Returns the component name in underscored path format.
81
+ # Removes the "::Component" or "Component" suffix and converts to snake_case.
82
+ #
83
+ # @return [String] the component path (e.g., "poetry/core/dot")
84
+ # @example
85
+ # Poetry::Core::Dot::Component.component_path # => "poetry/core/dot"
86
+ # Poetry::Core::Button::Component.component_path # => "poetry/core/button"
87
+ def component_path
88
+ name.sub(/(::Component|Component)$/, "").underscore # poetry/core/dot
89
+ end
90
+
91
+ # Returns the component module name without the "::Component" suffix.
92
+ #
93
+ # @return [String] the module name (e.g., "Poetry::Core::Dot")
94
+ # @example
95
+ # Poetry::Core::Dot::Component.component_module # => "Poetry::Core::Dot"
96
+ def component_module
97
+ name.sub(/::Component$/, "")
98
+ end
99
+
100
+ # Returns the component identifier with path segments joined by double dashes.
101
+ # Useful for CSS class names and HTML data attributes.
102
+ #
103
+ # @return [String] the component identifier (e.g., "poetry--core--dot")
104
+ # @example
105
+ # Poetry::Core::Dot::Component.component_identifier # => "poetry--core--dot"
106
+ # Poetry::Core::Button::Component.component_identifier # => "poetry--core--button"
107
+ def component_identifier
108
+ component_path.split("/").join("--") # poetry--core--dot
109
+ end
110
+
111
+ # Returns the last segment of the component path as the title.
112
+ #
113
+ # @return [String] the component title (e.g., "dot")
114
+ # @example
115
+ # Poetry::Core::Dot::Component.component_title # => "dot"
116
+ # Poetry::Core::Button::Component.component_title # => "button"
117
+ def component_title
118
+ component_path.split("/").last # dot
119
+ end
120
+
121
+ # Declares that this component cannot render without a content block,
122
+ # with a hint naming what the block is (Avatar: "the initials
123
+ # fallback"). ONE declaration feeds both enforcement layers: the
124
+ # component raises via #ensure_content! at render, and the registry
125
+ # emits `requires_content` so poetry check flags the omission
126
+ # statically.
127
+ #
128
+ # @param hint [String] what the content block is, for the error message
129
+ # @return [void]
130
+ # @example
131
+ # class Avatar::Component < Poetry::Core::Component
132
+ # requires_content "the initials fallback"
133
+ # end
134
+ def requires_content(hint)
135
+ @required_content = hint
136
+ end
137
+
138
+ # The declared content-block hint, inherited like other DSL state.
139
+ #
140
+ # @return [String, nil]
141
+ def required_content
142
+ return @required_content if defined?(@required_content)
143
+
144
+ superclass.respond_to?(:required_content) ? superclass.required_content : nil
145
+ end
146
+ end
147
+
148
+ # The self-identification markup contract, the convention every
149
+ # component follows: `data-component` on the component root maps live DOM
150
+ # back to the component that rendered it - the hook agents, the
151
+ # Verifier, and the browser-verification loop key on.
152
+ #
153
+ # @return [Hash] e.g. { "data-component" => "button" }
154
+ def component_data_attributes
155
+ { "data-component" => self.class.component_title }
156
+ end
157
+
158
+ # `data-slot` for a named part of the component's anatomy
159
+ # (skeleton parts carry their role: icon, label, spinner, ...).
160
+ #
161
+ # @param part [Symbol, String] the anatomy part name
162
+ # @return [Hash] e.g. { "data-slot" => "icon" }
163
+ def slot_data_attributes(part)
164
+ { "data-slot" => part.to_s }
165
+ end
166
+
167
+ # Enforces the class-level requires_content declaration - call from
168
+ # before_render. The message is built from the declaration so the
169
+ # runtime raise and the registry's static contract can never disagree.
170
+ #
171
+ # @return [void]
172
+ # @raise [ArgumentError] when the component was called without a
173
+ # content block
174
+ def ensure_content!
175
+ return if content?
176
+
177
+ raise ArgumentError, "#{self.class.component_module.demodulize} requires a content block " \
178
+ "(#{self.class.required_content})"
179
+ end
180
+
181
+ # Indicates whether this component instance is persisted.
182
+ # Always returns false as components are not persisted entities.
183
+ #
184
+ # @return [Boolean] always returns false
185
+ def persisted?
186
+ false
187
+ end
188
+
189
+ # Initializes a new component instance with the given attributes.
190
+ #
191
+ # This method:
192
+ # 1. Initializes the registered_styles set for tracking explicitly set attributes
193
+ # 2. Marks style attributes with static defaults as initialized
194
+ # 3. Tracks which style attributes are being explicitly initialized via parameters
195
+ # 4. Separates component attributes from HTML attributes
196
+ # 5. Assigns the component attributes to their respective instance variables
197
+ #
198
+ # @param attributes [Hash] the attributes to initialize the component with
199
+ # @option attributes [Symbol, String] style attributes defined via the `style` DSL
200
+ # @option attributes [Symbol, String] HTML attributes (e.g., :class, :id, :data)
201
+ #
202
+ # @example Initialize with style attributes
203
+ # component = MyComponent.new(color: :primary, size: :lg)
204
+ #
205
+ # @example Initialize with HTML attributes
206
+ # component = MyComponent.new(class: "my-class", data: { controller: "example" })
207
+ #
208
+ # @example Initialize with both
209
+ # component = MyComponent.new(color: :primary, class: "my-class")
210
+ #
211
+ # The base component intentionally does not chain to ViewComponent::Base#initialize:
212
+ # it fully manages its own ActiveModel-backed attribute setup.
213
+ def initialize(attributes = {}) # rubocop:disable Lint/MissingSuper
214
+ # key: is universal semantic identity, not an HTML attribute -
215
+ # extracted here so it never renders literally. Not an ActiveModel
216
+ # option (yet): keeping it out of prop_definitions defers the
217
+ # registry/check surface decision to a later migration.
218
+ @stable_key = attributes[:key] || attributes["key"]
219
+ attributes = attributes.except(:key, "key") if @stable_key
220
+
221
+ # webmcp: is the per-instance opt-in of the agent-tool contract
222
+ # (Concerns::AgentTools) - universal like key:, never an HTML
223
+ # attribute; the root's Stimulus wiring carries the registration.
224
+ @webmcp = attributes[:webmcp] || attributes["webmcp"]
225
+ attributes = attributes.except(:webmcp, "webmcp") unless @webmcp.nil?
226
+
227
+ # Initialize a fresh Set for this instance
228
+ self.registered_styles = Set.new
229
+ self.registered_options = Set.new
230
+
231
+ # First, mark all style attributes with static defaults as initialized
232
+ # (proc defaults should NOT be marked as initialized, so they get evaluated lazily)
233
+ self.class.style_attributes_with_static_defaults.each do |attr|
234
+ registered_styles << attr.to_sym
235
+ end
236
+
237
+ # First, mark all option attributes with static defaults as initialized
238
+ # (proc defaults should NOT be marked as initialized, so they get evaluated lazily)
239
+ self.class.option_attributes_with_static_defaults.each do |attr|
240
+ registered_options << attr.to_sym
241
+ end
242
+
243
+ # Then track which style attributes are being explicitly initialized
244
+ attributes.each_key do |key|
245
+ registered_styles << key.to_sym if self.class.has_style_attribute?(key)
246
+
247
+ # Then track which option attributes are being explicitly initialized
248
+ registered_options << key.to_sym if self.class.has_option_attribute?(key)
249
+ end
250
+
251
+ @attributes = self.class._default_attributes.deep_dup
252
+ html_attrs = attributes.with_indifferent_access.except(*attribute_names)
253
+ @html_attributes = Poetry::Core::HTML::Attributes.new(html_attrs)
254
+
255
+ assign_attributes attributes.with_indifferent_access.slice(*attribute_names)
256
+ end
257
+
258
+ # Returns all component attributes, ensuring proc defaults are evaluated.
259
+ #
260
+ # This method overrides ActiveModel's attributes method to trigger evaluation
261
+ # of any proc-based default values that haven't been explicitly set.
262
+ #
263
+ # @return [Hash] the component's attributes with all defaults evaluated
264
+ def attributes
265
+ # Trigger evaluation of proc defaults that haven't been explicitly set
266
+ # This ensures they appear in the attributes hash
267
+ self.class.style_attributes_with_proc_defaults.each do |attr|
268
+ # Access the attribute to trigger proc evaluation if needed
269
+ send(attr) if respond_to?(attr) && !style_attribute_initialized?(attr)
270
+ end
271
+
272
+ # Trigger evaluation of option proc defaults that haven't been explicitly set
273
+ self.class.option_attributes_with_proc_defaults.each do |attr|
274
+ # Access the attribute to trigger proc evaluation if needed
275
+ send(attr) if respond_to?(attr) && !option_attribute_initialized?(attr)
276
+ end
277
+
278
+ super
279
+ end
280
+
281
+ # Returns HTML attributes with merged CSS classes.
282
+ #
283
+ # Combines the component's CSS classes (from the `css` method) with any
284
+ # additional classes passed via the `:class` HTML attribute.
285
+ #
286
+ # @return [Hash] HTML attributes with merged class names
287
+ # @example
288
+ # component = MyComponent.new(class: "custom-class")
289
+ # component.html_attributes # => { class: "component-base-class custom-class" }
290
+ def html_attributes
291
+ # Set, never merge: Attributes#merge folds a class through the merger
292
+ # against the caller's original, so every non-utility token the caller
293
+ # passed (a cn-* hook) came out twice. The resolved string already
294
+ # carries the caller's classes once, where they win conflicts.
295
+ attributes = @html_attributes.deep_dup
296
+ attributes[:class] = classnames(css, @html_attributes[:class])
297
+ attributes
298
+ end
299
+
300
+ # The caller-supplied semantic identity (key:), if any.
301
+ #
302
+ # @return [Object, nil]
303
+ attr_reader :stable_key
304
+
305
+ # The instance-id ladder: an explicit caller root id wins; a key:
306
+ # derives a stable component-namespaced token (Turbo morph pairs it
307
+ # across renders, cached fragments stay composable); otherwise
308
+ # random - unkeyed components over-replace under morph, they never
309
+ # falsely retain. Call sites memoize (`@instance_id ||=`); this
310
+ # stays pure.
311
+ #
312
+ # @param prefix [String] the component-namespaced id prefix
313
+ # @return [String] the resolved DOM id
314
+ def poetry_instance_id(prefix)
315
+ explicit = @html_attributes["id"].presence
316
+ return explicit.to_s if explicit
317
+
318
+ token = Poetry::Core::StableId.key_token(stable_key)
319
+ return "#{prefix}-#{token}" if token
320
+
321
+ sequence = Poetry::Core::StableId.next_sequence_token
322
+ return "#{prefix}-#{sequence}" if sequence
323
+
324
+ "#{prefix}-#{SecureRandom.hex(8)}"
325
+ end
326
+
327
+ # Merges multiple class name values into a single string.
328
+ #
329
+ # Uses the configured classname merger (typically Tailwind Merge) to
330
+ # intelligently combine CSS class names, handling conflicts and duplicates.
331
+ #
332
+ # @param classnames [Array<String, nil>] class names to merge
333
+ # @return [String] the merged class names
334
+ # @example
335
+ # classnames("text-red-500", "text-blue-500") # => "text-blue-500"
336
+ def classnames(*classnames)
337
+ self.class.config.classname_merger.merge(*classnames)
338
+ end
339
+
340
+ # HTML-safe JSON for embedding in a `<script type="application/json">`
341
+ # data island. Escapes the script-terminating characters (`<` `>` `&`,
342
+ # plus the JS line separators U+2028/U+2029) to their JSON `\uXXXX`
343
+ # forms, INDEPENDENT of the host's
344
+ # `ActiveSupport.escape_html_entities_in_json` setting: that flag
345
+ # defaults to true (which escapes them for us) but is host-overridable
346
+ # to false (legitimately, e.g. in API apps), and a component must not
347
+ # depend on a global it neither sets nor checks. `</script>` closes a
348
+ # script element regardless of its `type`, so an unescaped value
349
+ # carrying it would break out of the island into live HTML. Idempotent
350
+ # when the host already escapes (the `\uXXXX` forms carry no literal
351
+ # `<`/`>`/`&`), and JSON.parse decodes the escapes back to the original
352
+ # text. Accepts a pre-serialized JSON string or any `to_json`-able object.
353
+ #
354
+ # @param json [String, Object] serialized JSON, or an object to serialize
355
+ # @return [ActiveSupport::SafeBuffer] escaped, HTML-safe JSON text
356
+ def script_json(json)
357
+ json = json.to_json unless json.is_a?(String)
358
+ json.gsub(/[<>&

]/) { |char| format('\u%04x', char.ord) }.html_safe
359
+ end
360
+
361
+ # Reduces a value to a token safe for a DOM id and a CSS selector:
362
+ # `[A-Za-z0-9_-]` only. A user-controlled id would otherwise break out
363
+ # of the `<style>` block or the id attribute it is interpolated into.
364
+ # Returns nil when nothing safe remains (callers fall back to a random
365
+ # token), preserving the id attribute / JS-selector match by using the
366
+ # same reduced value on both sides.
367
+ #
368
+ # @param value [Object] the requested id
369
+ # @return [String, nil] the safe token, or nil if empty
370
+ def dom_id_token(value)
371
+ value.to_s.gsub(/[^A-Za-z0-9_-]/, "").presence
372
+ end
373
+
374
+ # Renders the component to an HTML string.
375
+ #
376
+ # Creates a minimal controller and view context to render the component
377
+ # outside of a normal request cycle. Useful for testing and debugging.
378
+ #
379
+ # @return [String] the rendered HTML
380
+ # @example
381
+ # component = MyComponent.new(color: :primary)
382
+ # component.to_html # => "<div class=\"...\">...</div>"
383
+ def to_html
384
+ controller = ActionController::Base.new
385
+ controller.request = ActionDispatch::TestRequest.create
386
+ render_in(controller.view_context)
387
+ end
388
+ end
389
+ end
390
+ end