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,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Concerns
6
+ # The use_stimulus contract: a class-level, element-major declaration
7
+ # of the component's Stimulus wiring, replacing both the hand-rolled
8
+ # `<element>_stimulus_attributes` methods and the previous
9
+ # `stimulated_with` DSL (controller-major, root-only - it could not
10
+ # express multi-element wiring, and no component ever adopted it).
11
+ #
12
+ # use_stimulus do
13
+ # on :root do
14
+ # controller :hover_card do
15
+ # register
16
+ # value :open
17
+ # value :open_delay, unless: -> { open_delay.nil? }
18
+ # end
19
+ # controller :popper do
20
+ # register
21
+ # value :side
22
+ # end
23
+ # end
24
+ # on :trigger do
25
+ # controller :hover_card do
26
+ # action :pointer_enter, on: :pointerenter
27
+ # end
28
+ # controller :popper do
29
+ # target :anchor
30
+ # end
31
+ # end
32
+ # end
33
+ #
34
+ # Render side: `stimulus_attributes_for(:trigger)` returns the
35
+ # element's merged attribute hash (public - templates and slot
36
+ # lambdas call it directly); `stimulus_action(:open)` /
37
+ # `stimulus_event(:change)` build validated descriptor strings for
38
+ # forwarding; `stimulus_attributes(:a, :b) { |a, b| ... }` is the
39
+ # escape hatch for wiring too dynamic to declare - every builder
40
+ # shares ONE Attributes instance, so multi-controller merges are
41
+ # correct by construction.
42
+ #
43
+ # Declarations validate against the controllers manifest at CLASS
44
+ # LOAD (unknown controller/value/action/target/event raises at boot,
45
+ # not first render) and are declared once per element: a subclass
46
+ # redeclaring an element REPLACES it wholesale (the Ruby-override
47
+ # intuition; Sheet/Drawer re-controller their roots this way), while
48
+ # `on :root, extend: true` merges into the inherited element
49
+ # (date_field -> time_field adds values). Untouched elements inherit.
50
+ module Stimulus
51
+ extend ActiveSupport::Concern
52
+
53
+ class_methods do
54
+ # Declares (part of) the component's stimulus wiring. Multiple
55
+ # blocks compose additively within a class; shared wiring modules
56
+ # call this from their `included` hook.
57
+ #
58
+ # @yield evaluates the block as a
59
+ # {Poetry::Core::Stimulus::Declarations::RootDSL}:
60
+ # `on :element do controller :name do ... end end`
61
+ # @return [void]
62
+ # @raise [ArgumentError] without a block
63
+ def use_stimulus(&block)
64
+ raise ArgumentError, "use_stimulus requires a block" unless block
65
+
66
+ dsl = Poetry::Core::Stimulus::Declarations::RootDSL.new(name || to_s)
67
+ dsl.instance_exec(&block)
68
+ dsl.elements.each { |element| store_stimulus_element(element) }
69
+ end
70
+
71
+ # This class's own declarations, element name -> Element.
72
+ #
73
+ # @return [Hash{Symbol => Poetry::Core::Stimulus::Declarations::Element}]
74
+ def own_stimulus_elements
75
+ @own_stimulus_elements ||= {}
76
+ end
77
+
78
+ # The effective wiring after inheritance: walk the superclass
79
+ # chain root-first, folding each class's declarations over the
80
+ # inherited set - redeclared elements replace wholesale unless
81
+ # declared with extend: true, which appends to the inherited
82
+ # element's wirings.
83
+ #
84
+ # @return [Hash{Symbol => Poetry::Core::Stimulus::Declarations::Element}]
85
+ def stimulus_elements
86
+ chain = []
87
+ klass = self
88
+ while klass.respond_to?(:own_stimulus_elements)
89
+ chain.unshift(klass)
90
+ klass = klass.superclass
91
+ end
92
+
93
+ chain.each_with_object({}) do |ancestor, resolved|
94
+ ancestor.own_stimulus_elements.each do |name, element|
95
+ resolved[name] =
96
+ if element.extend_inherited && resolved[name]
97
+ Poetry::Core::Stimulus::Declarations::Element.new(
98
+ name: name, extend_inherited: false, conditions: element.conditions,
99
+ wirings: resolved[name].wirings + element.wirings
100
+ )
101
+ else
102
+ element
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ # Every controller identifier declared anywhere on the class, in
109
+ # declaration order - the search space for unqualified
110
+ # stimulus_action / stimulus_event resolution.
111
+ #
112
+ # @return [Array<String>]
113
+ def stimulus_identifiers
114
+ stimulus_elements.values.flat_map { |element| element.wirings.map(&:identifier) }.uniq
115
+ end
116
+
117
+ # Resolves a declaration-style identifier (Symbol suffix, String,
118
+ # or Array) to its full manifest identifier - see
119
+ # {Poetry::Core::Stimulus::Declarations.resolve_identifier}.
120
+ #
121
+ # @param identifier [Symbol, String, Array] the declaration-style identifier
122
+ # @return [String] the full Stimulus identifier
123
+ def resolve_stimulus_identifier(identifier)
124
+ Poetry::Core::Stimulus::Declarations.resolve_identifier(identifier)
125
+ end
126
+
127
+ # The registry-shaped projection of the RESOLVED wiring (post-
128
+ # inheritance, so Sheet publishes sheet controllers) - plain data
129
+ # for the registry, skill text, and docs tables.
130
+ #
131
+ # @return [Array<Hash>] one serialized element per declared element
132
+ def stimulus_definitions
133
+ stimulus_elements.values.map do |element|
134
+ Poetry::Core::Stimulus::Declarations.serialize_element(element)
135
+ end
136
+ end
137
+
138
+ # Descriptor builders are STATIC facts of the declarations, so
139
+ # they exist at class level - helpers, generator templates, and
140
+ # test selectors consume them without an instance. on:/at: build
141
+ # the evented token ("click->id#method"); without on: the bare
142
+ # descriptor (element-default event).
143
+ #
144
+ # @param args [Array<Symbol, String>] `(method)` resolves the method
145
+ # across the declared controllers; `(controller, method)` pins one
146
+ # @param on [Symbol, String, Array<Symbol>, nil] the event(s) to listen for
147
+ # @param at [Symbol, String, nil] the event target (:window, :document)
148
+ # @return [String] the action descriptor ("click->poetry--core--x#open")
149
+ def stimulus_action(*args, on: nil, at: nil)
150
+ controller, method = unpack_stimulus_descriptor_args(args, :action)
151
+ identifier = resolve_stimulus_descriptor(controller, method, kind: :action)
152
+ Poetry::Core::Stimulus::Builder.new(identifier, Poetry::Core::HTML::Attributes.new)
153
+ .action(method, on: on, at: at)
154
+ end
155
+
156
+ # A validated event-name string for listening markup;
157
+ # stimulus_event(:change) resolves across the declared
158
+ # controllers, stimulus_event(:controller, :change) pins one.
159
+ #
160
+ # @param args [Array<Symbol, String>] `(name)` or `(controller, name)`
161
+ # @return [String] the full event name
162
+ def stimulus_event(*args)
163
+ controller, name = unpack_stimulus_descriptor_args(args, :event)
164
+ identifier = resolve_stimulus_descriptor(controller, name, kind: :event)
165
+ Poetry::Core::Stimulus::Declarations.event_name(identifier, name)
166
+ end
167
+
168
+ private
169
+
170
+ def unpack_stimulus_descriptor_args(args, kind)
171
+ case args.size
172
+ when 1 then [nil, args.first]
173
+ when 2 then args
174
+ else
175
+ raise ArgumentError,
176
+ "stimulus_#{kind}(#{kind}) or stimulus_#{kind}(:controller, #{kind})"
177
+ end
178
+ end
179
+
180
+ def resolve_stimulus_descriptor(controller, name, kind:)
181
+ return resolve_stimulus_identifier(controller) if controller
182
+
183
+ identifiers = stimulus_identifiers
184
+ if identifiers.empty?
185
+ raise ArgumentError,
186
+ "no use_stimulus declarations on #{self} - pass the controller: " \
187
+ "stimulus_#{kind}(:controller, #{name.inspect})"
188
+ end
189
+ return identifiers.first if identifiers.size == 1
190
+
191
+ matches = identifiers.select { |id| stimulus_descriptor_match?(id, name, kind) }
192
+ return matches.first if matches.size == 1
193
+
194
+ raise ArgumentError,
195
+ "#{matches.empty? ? "no declared controller defines" : "ambiguous"} " \
196
+ "#{kind} #{name.inspect} (declared: #{identifiers.join(", ")}) - qualify: " \
197
+ "stimulus_#{kind}(:controller, #{name.inspect})"
198
+ end
199
+
200
+ def stimulus_descriptor_match?(identifier, name, kind)
201
+ definition = Poetry::Core::Stimulus::Manifest.definition(identifier)
202
+ return false unless definition
203
+
204
+ case kind
205
+ when :action
206
+ definition.fetch("methods", []).include?(Poetry::Core::Stimulus::Declarations.camelize(name))
207
+ when :event
208
+ # Real emitted names (poetry:<component>:<event> for component
209
+ # events; identifier-prefixed for layer events) - match by the
210
+ # ":<name>" suffix, the same rule event_name resolves by.
211
+ definition.fetch("events", []).any? { |event| event.end_with?(":#{name}") }
212
+ end
213
+ end
214
+
215
+ def store_stimulus_element(element)
216
+ existing = own_stimulus_elements[element.name]
217
+ own_stimulus_elements[element.name] =
218
+ if existing
219
+ Poetry::Core::Stimulus::Declarations::Element.new(
220
+ name: element.name,
221
+ extend_inherited: existing.extend_inherited || element.extend_inherited,
222
+ conditions: element.conditions || existing.conditions,
223
+ wirings: existing.wirings + element.wirings
224
+ )
225
+ else
226
+ element
227
+ end
228
+ end
229
+ end
230
+
231
+ # The declared wiring for one element as a plain attributes hash,
232
+ # ready to merge into the element's tag or forward as component
233
+ # kwargs. Public by design - templates call it, ending the
234
+ # `public :inner_stimulus_attributes` juggling.
235
+ #
236
+ # @param element_name [Symbol, String] a declared element (:root, ...)
237
+ # @return [Hash] the element's data attributes; empty when the
238
+ # element's if:/unless: conditions do not hold
239
+ # @raise [ArgumentError] for an element no use_stimulus block declared
240
+ def stimulus_attributes_for(element_name)
241
+ element = self.class.stimulus_elements[element_name.to_sym]
242
+ unless element
243
+ known = self.class.stimulus_elements.keys
244
+ raise ArgumentError,
245
+ "undeclared stimulus element #{element_name.inspect} on #{self.class}" \
246
+ "#{known.any? ? " - declared: #{known.join(", ")}" : " (no use_stimulus declarations)"}"
247
+ end
248
+ return {} unless stimulus_conditions_met?(element.conditions)
249
+
250
+ attrs = Poetry::Core::HTML::Attributes.new
251
+ element.wirings.each do |wiring|
252
+ next unless stimulus_conditions_met?(wiring.conditions)
253
+
254
+ builder = Poetry::Core::Stimulus::Builder.new(wiring.identifier, attrs)
255
+ wiring.entries.each do |entry|
256
+ apply_stimulus_entry(builder, entry) if stimulus_conditions_met?(entry.conditions)
257
+ end
258
+ end
259
+ # The agent-tool registrar rides the root's shared Attributes
260
+ # instance (Concerns::AgentTools) - a per-instance opt-in.
261
+ apply_webmcp_wiring(attrs) if element_name.to_sym == :root && respond_to?(:apply_webmcp_wiring)
262
+ attrs.to_attributes
263
+ end
264
+
265
+ # The escape hatch for wiring too dynamic to declare: yields one
266
+ # Builder per controller, all sharing ONE Attributes instance.
267
+ # Controllers resolve like declarations (Symbol -> manifest,
268
+ # String/Array -> verbatim).
269
+ #
270
+ # @param controllers [Array<Symbol, String, Array>] one or more
271
+ # controller identifiers
272
+ # @yield [*builders] one {Poetry::Core::Stimulus::Builder} per
273
+ # controller, in order
274
+ # @return [Hash] the accumulated attributes
275
+ # @raise [ArgumentError] with no controllers
276
+ def stimulus_attributes(*controllers)
277
+ raise ArgumentError, "stimulus_attributes needs at least one controller" if controllers.empty?
278
+
279
+ attrs = Poetry::Core::HTML::Attributes.new
280
+ builders = controllers.map do |controller|
281
+ Poetry::Core::Stimulus::Builder.new(
282
+ self.class.resolve_stimulus_identifier(controller), attrs
283
+ )
284
+ end
285
+ yield(*builders) if block_given?
286
+ attrs.to_attributes
287
+ end
288
+
289
+ # Delegates to the class-level builder (descriptors are static
290
+ # facts of the declarations); stimulus_action(:open) resolves
291
+ # across declared controllers, on:/at: build the evented token.
292
+ #
293
+ # @param on [Symbol, String, Array<Symbol>, nil] the event(s) to listen for
294
+ # @param at [Symbol, String, nil] the event target (:window, :document)
295
+ # @return [String] the action descriptor
296
+ def stimulus_action(*, on: nil, at: nil)
297
+ self.class.stimulus_action(*, on: on, at: at)
298
+ end
299
+
300
+ # Delegates to the class-level builder: stimulus_event(:change)
301
+ # resolves across the declared controllers,
302
+ # stimulus_event(:controller, :change) pins one.
303
+ #
304
+ # @return [String] the full event name
305
+ def stimulus_event(*)
306
+ self.class.stimulus_event(*)
307
+ end
308
+
309
+ # Replays one declared Entry onto the element's attribute builder.
310
+ #
311
+ # @api private
312
+ def apply_stimulus_entry(builder, entry)
313
+ case entry.kind
314
+ when :register then builder.register_controller
315
+ when :value then builder.with_value(entry.name, stimulus_value_for(entry))
316
+ when :action then builder.with_action(entry.name, on: entry.on, at: entry.at)
317
+ when :target then builder.with_target(entry.name)
318
+ end
319
+ end
320
+
321
+ # The rendered value of a declared value entry: the literal, or the
322
+ # named/implicit method's return.
323
+ #
324
+ # @api private
325
+ def stimulus_value_for(entry)
326
+ source = entry.source
327
+ case source[:type]
328
+ when :literal then source[:value]
329
+ else send(source[:value])
330
+ end
331
+ end
332
+
333
+ # Whether a declaration's if:/unless: conditions hold for this
334
+ # instance; nil conditions always hold.
335
+ #
336
+ # @param conditions [Hash{Symbol => Symbol, Proc}, nil] the if:/unless: pair
337
+ # @return [Boolean]
338
+ # @api private
339
+ def stimulus_conditions_met?(conditions)
340
+ return true if conditions.nil?
341
+
342
+ if (condition = conditions[:if]) && !evaluate_stimulus_condition(condition)
343
+ return false
344
+ end
345
+ if (condition = conditions[:unless]) && evaluate_stimulus_condition(condition)
346
+ return false
347
+ end
348
+
349
+ true
350
+ end
351
+
352
+ # Evaluates one if:/unless: condition in instance context.
353
+ #
354
+ # @api private
355
+ def evaluate_stimulus_condition(condition)
356
+ condition.is_a?(Proc) ? instance_exec(&condition) : send(condition)
357
+ end
358
+
359
+ private :apply_stimulus_entry, :stimulus_value_for, :stimulus_conditions_met?, :evaluate_stimulus_condition
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The concerns composed into {Poetry::Core::Component}: styles, options,
6
+ # slots, Stimulus wiring, introspection, and part declarations.
7
+ module Concerns
8
+ # The Styles concern provides a powerful DSL for defining style attributes in components.
9
+ # It extends the basic attribute functionality with support for variants, proc defaults,
10
+ # and tracking of which attributes have been explicitly set vs using defaults.
11
+ #
12
+ # The registration, tracking, and hierarchy machinery lives in
13
+ # DeclaredAttributes (shared with Options); this concern owns the
14
+ # style-specific surface: variants, inclusion validation, and CSS
15
+ # emission.
16
+ #
17
+ # @example Basic usage with variants
18
+ # class MyComponent < Poetry::Core::Component
19
+ # style :color, default: :primary, variants: [:primary, :secondary, :success]
20
+ # style :size, default: :md, variants: [:sm, :md, :lg]
21
+ # end
22
+ #
23
+ # @example Proc defaults that reference other attributes
24
+ # class Badge::Component < Poetry::Core::Component
25
+ # style :color, default: :gray, variants: [...colors]
26
+ # style :dot_color, default: -> { color }, variants: [...colors]
27
+ # end
28
+ #
29
+ # badge = Badge::Component.new(color: :red)
30
+ # badge.dot_color # => :red (inherits from color)
31
+ # badge.color = :blue
32
+ # badge.dot_color # => :blue (still follows color)
33
+ #
34
+ # badge2 = Badge::Component.new(color: :red, dot_color: :green)
35
+ # badge2.color = :blue
36
+ # badge2.dot_color # => :green (explicitly set, doesn't follow color)
37
+ #
38
+ # @example Required attributes
39
+ # class MyComponent < Poetry::Core::Component
40
+ # style :type, required: true, variants: [:button, :link]
41
+ # end
42
+ #
43
+ # @example Boolean styles
44
+ # class MyComponent < Poetry::Core::Component
45
+ # style :outlined, variants: :boolean, default: false
46
+ # end
47
+ module Styles
48
+ extend ActiveSupport::Concern
49
+ include DeclaredAttributes
50
+
51
+ # The naming convention joining a component to its sidecar style
52
+ # dictionary (Dot::Component -> Dot::Style).
53
+ STYLE_CLASS_SUFFIX = "::Style"
54
+
55
+ included do
56
+ class_attribute :registered_styles,
57
+ instance_writer: true,
58
+ instance_predicate: false,
59
+ default: nil
60
+ end
61
+
62
+ class_methods do
63
+ # Defines a style attribute for the component.
64
+ #
65
+ # @param name [Symbol, String] the name of the style attribute
66
+ # @param options [Hash] configuration options
67
+ # @option options [Object, Proc] :default the default value (can be a proc for dynamic defaults)
68
+ # @option options [Array<Symbol>, :boolean] :variants the allowed values for this attribute
69
+ # @option options [Boolean] :required whether this attribute must be provided
70
+ #
71
+ # @example Static default
72
+ # style :color, default: :primary, variants: [:primary, :secondary]
73
+ #
74
+ # @example Proc default
75
+ # style :dot_color, default: -> { color }, variants: [:primary, :secondary]
76
+ #
77
+ # @example Required attribute
78
+ # style :type, required: true, variants: [:button, :link]
79
+ #
80
+ # @example Boolean attribute
81
+ # style :outlined, variants: :boolean, default: false
82
+ # @return [void]
83
+ def style(name, **options)
84
+ register_declared_attribute(:style, name, options)
85
+
86
+ variants, required, default_value = extract_style_options(options)
87
+ type = determine_attribute_type(variants)
88
+
89
+ attribute(name, type, **options)
90
+ add_style_validations(name, type, variants, required)
91
+ setup_declared_tracking(:style, name, default_value)
92
+ define_variants_getter(name, variants)
93
+ end
94
+
95
+ # Returns all style attributes that have default values (static or proc).
96
+ #
97
+ # @return [Array<Symbol>] sorted array of attribute names with defaults
98
+ def style_attributes_with_defaults
99
+ declared_attributes_with_defaults(:style)
100
+ end
101
+
102
+ # Returns style attributes that have static (non-proc) default values.
103
+ #
104
+ # @return [Array<Symbol>] sorted array of attribute names with static defaults
105
+ def style_attributes_with_static_defaults
106
+ declared_attributes_with_static_defaults(:style)
107
+ end
108
+
109
+ # Returns style attributes that have proc default values.
110
+ # Proc defaults allow dynamic defaults that can reference other attributes.
111
+ #
112
+ # @return [Array<Symbol>] sorted array of attribute names with proc defaults
113
+ def style_attributes_with_proc_defaults
114
+ declared_attributes_with_proc_defaults(:style)
115
+ end
116
+
117
+ # Returns all style attributes defined on this component and its ancestors.
118
+ #
119
+ # @return [Array<Symbol>] sorted array of all style attribute names
120
+ def style_attributes
121
+ declared_attributes(:style)
122
+ end
123
+
124
+ # The doc: strings declared on this component's styles,
125
+ # hierarchy-wide (nearest declaration wins).
126
+ #
127
+ # @return [Hash{Symbol => String}]
128
+ def style_docs
129
+ declared_docs(:style)
130
+ end
131
+
132
+ # Checks if the given name is a defined style attribute.
133
+ #
134
+ # @param name [Symbol, String] the attribute name to check
135
+ # @return [Boolean] true if the attribute is a style attribute
136
+ def has_style_attribute?(name)
137
+ style_attributes.include?(name.to_sym)
138
+ end
139
+
140
+ # Automatically determines the corresponding Style class for this component.
141
+ # Example: Poetry::Core::Dot::Component -> Poetry::Core::Dot::Style
142
+ #
143
+ # @return [Class, nil] the style class if it exists, nil otherwise
144
+ def style_class
145
+ style_class_name = component_module + STYLE_CLASS_SUFFIX
146
+ style_class_name.constantize
147
+ rescue NameError => e
148
+ log_style_class_not_found(style_class_name, e) if defined?(Rails)
149
+ nil
150
+ end
151
+
152
+ private
153
+
154
+ # Extracts and processes style-specific options from the options hash.
155
+ #
156
+ # @param options [Hash] the original options hash
157
+ # @return [Array<(Object, Boolean, Object)>] variants, required, and default_value
158
+ def extract_style_options(options)
159
+ variants = options.delete(:variants)
160
+ required, default_value = extract_declared_defaults(options)
161
+
162
+ [variants, required, default_value]
163
+ end
164
+
165
+ # Determines the attribute type based on the variants option.
166
+ #
167
+ # @param variants [Object] the variants option value
168
+ # @return [Symbol] :boolean or :symbol
169
+ def determine_attribute_type(variants)
170
+ variants == :boolean ? :boolean : :symbol
171
+ end
172
+
173
+ # Adds validations for the style attribute.
174
+ #
175
+ # @param name [Symbol] the attribute name
176
+ # @param type [Symbol] the attribute type
177
+ # @param variants [Object] the allowed variants
178
+ # @param required [Boolean] whether the attribute is required
179
+ def add_style_validations(name, type, variants, required)
180
+ if type == :boolean
181
+ validates name, inclusion: { in: [true, false] }
182
+ elsif variants
183
+ validates name, inclusion: { in: variants }
184
+ end
185
+ validates name, presence: true if required
186
+ end
187
+
188
+ # Defines a singleton method to access the variants for this attribute.
189
+ #
190
+ # @param name [Symbol] the attribute name
191
+ # @param variants [Object] the variants value
192
+ def define_variants_getter(name, variants)
193
+ define_singleton_method("#{name}_variants") { variants }
194
+ end
195
+
196
+ # Logs when a style class cannot be found.
197
+ #
198
+ # @param style_class_name [String] the name of the style class that was not found
199
+ # @param error [NameError] the error that was raised
200
+ def log_style_class_not_found(style_class_name, error)
201
+ Rails.logger.debug { "Style class not found: #{style_class_name} (#{error.message})" }
202
+ end
203
+ end
204
+
205
+ # Checks if the given attribute is a style attribute.
206
+ #
207
+ # @param name [Symbol, String] the attribute name to check
208
+ # @return [Boolean] true if the attribute is a style attribute
209
+ def style_attribute?(name)
210
+ self.class.has_style_attribute?(name.to_sym)
211
+ end
212
+
213
+ # Returns all style attributes defined on this component's class.
214
+ #
215
+ # @return [Array<Symbol>] sorted array of all style attribute names
216
+ def style_attributes
217
+ self.class.style_attributes
218
+ end
219
+
220
+ # Returns only the style attributes that have been explicitly set (not using defaults).
221
+ #
222
+ # @return [Array<Symbol>] sorted array of initialized attribute names
223
+ def initialized_style_attributes
224
+ initialized_declared_attributes(registered_styles)
225
+ end
226
+
227
+ # Checks if a style attribute has been explicitly initialized.
228
+ #
229
+ # @param name [Symbol, String] the attribute name to check
230
+ # @return [Boolean] true if the attribute was explicitly set
231
+ def style_attribute_initialized?(name)
232
+ declared_attribute_registered?(registered_styles, name)
233
+ end
234
+
235
+ # Returns all style attributes with their initialization status.
236
+ #
237
+ # @return [Hash{Symbol => Boolean}] map of attribute names to initialized status
238
+ def style_attributes_status
239
+ style_attributes.to_h do |attr|
240
+ [attr, style_attribute_initialized?(attr)]
241
+ end
242
+ end
243
+
244
+ # Returns a hash of all style attributes with their current values.
245
+ #
246
+ # @return [Hash{Symbol => Object}] map of attribute names to their values
247
+ def styles
248
+ style_attributes.to_h do |attr|
249
+ [attr, send(attr)]
250
+ end
251
+ end
252
+
253
+ # Returns the style class for this component.
254
+ #
255
+ # @return [Class, nil] the style class if it exists
256
+ def styler
257
+ self.class.style_class
258
+ end
259
+
260
+ # Generates CSS classes based on style attributes and additional options.
261
+ #
262
+ # The emission is governed by `css_mode`: `:tailwind` (default)
263
+ # resolves the style values to utility classes through the sidecar
264
+ # Style dictionary; `:bem` emits the stable BEM token IR instead, for
265
+ # hosts that bring their own CSS (styled against the generated
266
+ # reference stylesheet). Override per call with `css_mode:`, or
267
+ # globally via `Poetry::Core::Config.current.css_mode`.
268
+ #
269
+ # @param element [Symbol, nil] a named element (BEM `block__element`)
270
+ # @param options [Hash] additional style options to merge
271
+ # @yield optional block passed to the style class
272
+ # @return [String, nil] the generated CSS classes; nil in :tailwind
273
+ # mode for a component without a sidecar Style class
274
+ # @raise [Poetry::Core::Error] for a css_mode other than :tailwind
275
+ # or :bem
276
+ def css(element = nil, **options, &)
277
+ mode = options.delete(:css_mode) || Poetry::Core::Config.current.css_mode
278
+
279
+ case mode
280
+ when :tailwind
281
+ # A component with no sidecar Style class has no dictionary -
282
+ # it renders unstyled (caught dogfooding poetry-ui's Icon).
283
+ return nil unless styler
284
+
285
+ style_attributes = styles.merge(options)
286
+ styler.css(element, **style_attributes, &)
287
+ when :bem
288
+ extra = options.delete(:class)
289
+ [bem(element, **options), extra].compact.join(" ")
290
+ else
291
+ raise Poetry::Core::Error, "unknown css_mode #{mode.inspect} (expected :tailwind or :bem)"
292
+ end
293
+ end
294
+
295
+ # The component's BEM block name - the stable, framework-agnostic
296
+ # class contract of the token IR ("poetry/core/dot" -> "poetry-core-dot").
297
+ #
298
+ # @return [String]
299
+ def bem_block
300
+ self.class.component_path.tr("/", "-")
301
+ end
302
+
303
+ # The BEM token IR for this component (the pipeline's Step 2): the
304
+ # block class plus one modifier class per style value - symbols as
305
+ # `block--attr-value`, booleans as presence modifiers (`block--attr`).
306
+ # A named element returns `block__element`.
307
+ #
308
+ # @param element [Symbol, nil] a named element
309
+ # @param overrides [Hash] style overrides merged over the resolved values
310
+ # @return [String] space-separated BEM classes
311
+ def bem(element = nil, **overrides)
312
+ return "#{bem_block}__#{element}" if element
313
+
314
+ styles.merge(overrides).each_with_object([bem_block]) do |(attr, value), tokens|
315
+ next if value.nil? || value == false
316
+
317
+ tokens << (value == true ? "#{bem_block}--#{attr}" : "#{bem_block}--#{attr}-#{value}")
318
+ end.join(" ")
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end