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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Concerns
6
+ # The Options concern provides a DSL for defining typed attributes in components.
7
+ # It extends the basic attribute functionality with support for ActiveModel types,
8
+ # proc defaults, and tracking of which attributes have been explicitly set vs using defaults.
9
+ #
10
+ # The registration, tracking, and hierarchy machinery lives in
11
+ # DeclaredAttributes (shared with Styles); this concern owns the
12
+ # option-specific surface: ActiveModel types and value formats.
13
+ #
14
+ # Unlike Styles, Options:
15
+ # - Do not have variants
16
+ # - Do not generate CSS
17
+ # - Support all ActiveModel types (string, integer, boolean, float, etc.)
18
+ #
19
+ # @example Basic usage with types
20
+ # class MyComponent < Poetry::Core::Component
21
+ # option :title, :string, default: "Untitled"
22
+ # option :count, :integer, default: 0
23
+ # option :enabled, :boolean, default: true
24
+ # end
25
+ #
26
+ # @example Proc defaults that reference other attributes
27
+ # class Card::Component < Poetry::Core::Component
28
+ # option :title, :string, default: "Card"
29
+ # option :aria_label, :string, default: -> { title }
30
+ # end
31
+ #
32
+ # card = Card::Component.new(title: "My Card")
33
+ # card.aria_label # => "My Card" (inherited from title)
34
+ # card.title = "Updated"
35
+ # card.aria_label # => "Updated" (still follows title)
36
+ #
37
+ # card2 = Card::Component.new(title: "Card", aria_label: "Custom Label")
38
+ # card2.title = "Updated"
39
+ # card2.aria_label # => "Custom Label" (explicitly set, doesn't follow title)
40
+ #
41
+ # @example Required attributes
42
+ # class MyComponent < Poetry::Core::Component
43
+ # option :id, :string, required: true
44
+ # end
45
+ #
46
+ # @example Various types
47
+ # class MyComponent < Poetry::Core::Component
48
+ # option :price, :decimal
49
+ # option :score, :float
50
+ # option :created_at, :datetime
51
+ # end
52
+ module Options
53
+ extend ActiveSupport::Concern
54
+ include DeclaredAttributes
55
+
56
+ included do
57
+ class_attribute :registered_options,
58
+ instance_writer: true,
59
+ instance_predicate: false,
60
+ default: nil
61
+ end
62
+
63
+ class_methods do
64
+ # Defines an option attribute for the component.
65
+ #
66
+ # @param name [Symbol, String] the name of the option attribute
67
+ # @param type [Symbol] the ActiveModel type (:string, :integer, :boolean, :float, :decimal, :value, etc.)
68
+ # @param options [Hash] configuration options
69
+ # @option options [Object, Proc] :default the default value (can be a proc for dynamic defaults)
70
+ # @option options [Boolean] :required whether this attribute must be provided
71
+ #
72
+ # @example Static default
73
+ # option :title, :string, default: "Untitled"
74
+ #
75
+ # @example Proc default
76
+ # option :aria_label, :string, default: -> { title }
77
+ #
78
+ # @example Required attribute
79
+ # option :id, :string, required: true
80
+ #
81
+ # @example Boolean attribute
82
+ # option :enabled, :boolean, default: false
83
+ #
84
+ # @example Value format (machine-checkable value contract)
85
+ # option :name, :symbol, required: true, format: :"icon-name"
86
+ # @return [void]
87
+ def option(name, type, **options)
88
+ register_declared_attribute(:option, name, options)
89
+
90
+ required, default_value = extract_declared_defaults(options)
91
+ register_option_format(name, options.delete(:format))
92
+
93
+ attribute(name, type, **options)
94
+ add_option_validations(name, type, required)
95
+ setup_declared_tracking(:option, name, default_value)
96
+ define_type_getter(name, type)
97
+ end
98
+
99
+ # Returns all option attributes that have default values (static or proc).
100
+ #
101
+ # @return [Array<Symbol>] sorted array of attribute names with defaults
102
+ def option_attributes_with_defaults
103
+ declared_attributes_with_defaults(:option)
104
+ end
105
+
106
+ # Returns option attributes that have static (non-proc) default values.
107
+ #
108
+ # @return [Array<Symbol>] sorted array of attribute names with static defaults
109
+ def option_attributes_with_static_defaults
110
+ declared_attributes_with_static_defaults(:option)
111
+ end
112
+
113
+ # Returns option attributes that have proc default values.
114
+ # Proc defaults allow dynamic defaults that can reference other attributes.
115
+ #
116
+ # @return [Array<Symbol>] sorted array of attribute names with proc defaults
117
+ def option_attributes_with_proc_defaults
118
+ declared_attributes_with_proc_defaults(:option)
119
+ end
120
+
121
+ # Returns all option attributes defined on this component and its ancestors.
122
+ #
123
+ # @return [Array<Symbol>] sorted array of all option attribute names
124
+ def option_attributes
125
+ declared_attributes(:option)
126
+ end
127
+
128
+ # The doc: strings declared on this component's options,
129
+ # hierarchy-wide (nearest declaration wins).
130
+ #
131
+ # @return [Hash{Symbol => String}]
132
+ def option_docs
133
+ declared_docs(:option)
134
+ end
135
+
136
+ # Checks if the given name is a defined option attribute.
137
+ #
138
+ # @param name [Symbol, String] the attribute name to check
139
+ # @return [Boolean] true if the attribute is an option attribute
140
+ def has_option_attribute?(name)
141
+ option_attributes.include?(name.to_sym)
142
+ end
143
+
144
+ # Returns the type for a given option attribute.
145
+ #
146
+ # @param name [Symbol, String] the attribute name
147
+ # @return [Symbol, nil] the type of the attribute
148
+ def option_type(name)
149
+ types = collect_option_types_from_hierarchy
150
+ types[name.to_sym]
151
+ end
152
+
153
+ # Returns the declared value format for a given option attribute
154
+ # (e.g. :"icon-name") - the machine-checkable value contract the
155
+ # registry and poetry check read. Nil when the option is free-form.
156
+ #
157
+ # @param name [Symbol, String] the attribute name
158
+ # @return [Symbol, nil] the declared format
159
+ def option_format(name)
160
+ collect_declared_map(:@_option_formats)[name.to_sym]
161
+ end
162
+
163
+ private
164
+
165
+ # Collects option types from the class hierarchy. An ancestor's
166
+ # declaration wins on redeclaration (formats resolve the other
167
+ # way, nearest wins).
168
+ #
169
+ # @return [Hash{Symbol => Symbol}] map of attribute names to types
170
+ def collect_option_types_from_hierarchy
171
+ types = {}
172
+
173
+ declared_hierarchy do |klass|
174
+ klass_types = klass.instance_variable_get(:@_option_types)
175
+ types.merge!(klass_types) if klass_types
176
+ end
177
+
178
+ types
179
+ end
180
+
181
+ # Records a declared value format so option_format can surface it
182
+ # (hierarchy-walked like types, so subclasses inherit it).
183
+ #
184
+ # @param name [Symbol, String] the attribute name
185
+ # @param format [Symbol, nil] the declared format, if any
186
+ def register_option_format(name, format)
187
+ return unless format
188
+
189
+ (@_option_formats ||= {})[name.to_sym] = format.to_sym
190
+ end
191
+
192
+ # Adds validations for the option attribute.
193
+ #
194
+ # @param name [Symbol] the attribute name
195
+ # @param _type [Symbol] the attribute type (unused; kept for signature parity)
196
+ # @param required [Boolean] whether the attribute is required
197
+ def add_option_validations(name, _type, required)
198
+ # Type validation is automatic via ActiveModel::Type
199
+ validates name, presence: true if required
200
+ end
201
+
202
+ # Defines a singleton method to access the type for this attribute.
203
+ #
204
+ # @param name [Symbol] the attribute name
205
+ # @param type [Symbol] the type value
206
+ def define_type_getter(name, type)
207
+ (@_option_types ||= {})[name.to_sym] = type
208
+ define_singleton_method("#{name}_type") { type }
209
+ end
210
+ end
211
+
212
+ # Checks if the given attribute is an option attribute.
213
+ #
214
+ # @param name [Symbol, String] the attribute name to check
215
+ # @return [Boolean] true if the attribute is an option attribute
216
+ def option_attribute?(name)
217
+ self.class.has_option_attribute?(name.to_sym)
218
+ end
219
+
220
+ # Returns all option attributes defined on this component's class.
221
+ #
222
+ # @return [Array<Symbol>] sorted array of all option attribute names
223
+ def option_attributes
224
+ self.class.option_attributes
225
+ end
226
+
227
+ # Returns only the option attributes that have been explicitly set (not using defaults).
228
+ #
229
+ # @return [Array<Symbol>] sorted array of initialized attribute names
230
+ def initialized_option_attributes
231
+ initialized_declared_attributes(registered_options)
232
+ end
233
+
234
+ # Checks if an option attribute has been explicitly initialized.
235
+ #
236
+ # @param name [Symbol, String] the attribute name to check
237
+ # @return [Boolean] true if the attribute was explicitly set
238
+ def option_attribute_initialized?(name)
239
+ declared_attribute_registered?(registered_options, name)
240
+ end
241
+
242
+ # Returns all option attributes with their initialization status.
243
+ #
244
+ # @return [Hash{Symbol => Boolean}] map of attribute names to initialized status
245
+ def option_attributes_status
246
+ option_attributes.to_h do |attr|
247
+ [attr, option_attribute_initialized?(attr)]
248
+ end
249
+ end
250
+
251
+ # Returns a hash of all option attributes with their current values.
252
+ #
253
+ # @return [Hash{Symbol => Object}] map of attribute names to their values
254
+ def options
255
+ option_attributes.to_h do |attr|
256
+ [attr, send(attr)]
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Concerns
6
+ # The part contract: a
7
+ # hand-authored, machine-verified declaration of the component's
8
+ # styling surface - the data-slot parts its DOM exposes, the state
9
+ # attributes each part carries (and when), and the CSS custom
10
+ # properties that seam the part to themes and controllers.
11
+ #
12
+ # part "dialog-content",
13
+ # "The <dialog> panel - the positioning and animation surface",
14
+ # states: {
15
+ # "data-open" => "panel is open (setState pairs it with data-closed)",
16
+ # "data-closed" => "panel is closed or animating out"
17
+ # }
18
+ #
19
+ # Binding such a contract with types alone would keep the keys from
20
+ # drifting while leaving every description and condition as
21
+ # unverified prose. poetry binds the declaration to RENDERED DOM
22
+ # instead - PartContract.verify reconciles it against every preview
23
+ # in both directions (rendered-but-undeclared, declared-but-never-
24
+ # rendered), so the published contract cannot lie about the anatomy.
25
+ #
26
+ # Declarations are OWN-CLASS ONLY, deliberately not inherited: Sheet
27
+ # and Drawer subclass Dialog::Component yet share none of its part
28
+ # names (sheet-content vs dialog-content) - inheritance would leak
29
+ # phantom parts into every subclass with renamed anatomy.
30
+ module Parts
31
+ extend ActiveSupport::Concern
32
+
33
+ # The kebab-case shape a declared part name (data-slot value) must match.
34
+ PART_NAME = /\A[a-z][a-z0-9-]*\z/
35
+ # The data-* shape a declared state attribute must match.
36
+ STATE_ATTRIBUTE = /\Adata-[a-z][a-z0-9-]*\z/
37
+ # A trailing * declares a dynamic family (charts' per-series
38
+ # --color-*), matched by prefix at verify time.
39
+ VAR_NAME = /\A--[a-z][a-z0-9-]*\*?\z/
40
+
41
+ class_methods do
42
+ # Declares one part of the component's rendered anatomy.
43
+ #
44
+ # @param name [String] the part's data-slot value
45
+ # @param description [String] what the part is - docs/agent prose
46
+ # @param states [Hash] state attribute => condition prose, or
47
+ # => { condition: "...", values: %w[...] } for valued
48
+ # attributes (data-side => top/right/bottom/left)
49
+ # @param vars [Hash] CSS custom property => description
50
+ # @return [void]
51
+ # @raise [Poetry::Core::Error] when the class already declares a
52
+ # part of that name
53
+ def part(name, description, states: {}, vars: {})
54
+ definition = Parts.build(self, name, description, states: states, vars: vars)
55
+ own = (@part_definitions ||= [])
56
+ if own.any? { |existing| existing["name"] == definition["name"] }
57
+ raise Poetry::Core::Error, "#{self}: part #{name.inspect} declared twice"
58
+ end
59
+
60
+ own << definition
61
+ end
62
+
63
+ # The declared contract, registry-shaped (plain string keys, so
64
+ # the YAML round-trips byte-identical). Own-class only - see the
65
+ # module docs for why subclasses never inherit anatomy.
66
+ #
67
+ # @return [Array<Hash>]
68
+ def part_definitions
69
+ @part_definitions || []
70
+ end
71
+ end
72
+
73
+ class << self
74
+ # Validates one `part` declaration and returns its registry-shaped
75
+ # hash.
76
+ #
77
+ # @api private
78
+ def build(klass, name, description, states:, vars:)
79
+ validate_name!(klass, name)
80
+ unless description.is_a?(String) && !description.strip.empty?
81
+ raise Poetry::Core::Error, "#{klass}: part #{name.inspect} needs a description"
82
+ end
83
+
84
+ definition = { "name" => name, "description" => description }
85
+ built_states = states.map { |attr, spec| build_state(klass, name, attr, spec) }
86
+ definition["states"] = built_states unless built_states.empty?
87
+ built_vars = vars.map { |var, desc| build_var(klass, name, var, desc) }
88
+ definition["vars"] = built_vars unless built_vars.empty?
89
+ definition
90
+ end
91
+
92
+ private
93
+
94
+ def validate_name!(klass, name)
95
+ return if name.is_a?(String) && name.match?(PART_NAME)
96
+
97
+ raise Poetry::Core::Error,
98
+ "#{klass}: part name #{name.inspect} must be a kebab-case data-slot value"
99
+ end
100
+
101
+ def build_state(klass, part, attr, spec)
102
+ unless attr.is_a?(String) && attr.match?(STATE_ATTRIBUTE)
103
+ raise Poetry::Core::Error,
104
+ "#{klass}: part #{part.inspect} state #{attr.inspect} must be a data-* attribute"
105
+ end
106
+
107
+ condition, values = unpack_state(spec)
108
+ unless condition.is_a?(String) && !condition.strip.empty?
109
+ raise Poetry::Core::Error,
110
+ "#{klass}: part #{part.inspect} state #{attr} needs a condition"
111
+ end
112
+
113
+ state = { "attr" => attr, "condition" => condition }
114
+ if values
115
+ unless values.is_a?(Array) && values.any? && values.all?(String)
116
+ raise Poetry::Core::Error,
117
+ "#{klass}: part #{part.inspect} state #{attr} values must be strings"
118
+ end
119
+
120
+ state["values"] = values
121
+ end
122
+ state
123
+ end
124
+
125
+ def unpack_state(spec)
126
+ return [spec, nil] unless spec.is_a?(Hash)
127
+
128
+ normalized = spec.transform_keys(&:to_s)
129
+ [normalized["condition"], normalized["values"]]
130
+ end
131
+
132
+ def build_var(klass, part, var, description)
133
+ unless var.is_a?(String) && var.match?(VAR_NAME)
134
+ raise Poetry::Core::Error,
135
+ "#{klass}: part #{part.inspect} var #{var.inspect} must be a --custom-property"
136
+ end
137
+ unless description.is_a?(String) && !description.strip.empty?
138
+ raise Poetry::Core::Error,
139
+ "#{klass}: part #{part.inspect} var #{var} needs a description"
140
+ end
141
+
142
+ { "name" => var, "description" => description }
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end