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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # The pluggable icon-set registry (Lucide default, per-set
6
+ # adapters). An icon set is anything responding to #include?(name),
7
+ # #fetch(name) -> inner SVG markup, and #names. Sets register themselves
8
+ # on require (poetry-lucide does); the active set is selected by
9
+ # `config.icon_library` and can be overridden per render.
10
+ #
11
+ # SECURITY: #fetch's return value is rendered `html_safe` by the Icon
12
+ # component. The shipped sets vendor SVGs sanitized AT VENDOR TIME
13
+ # (poetry-lucide's fetch script strips <script>/<foreignObject>/handlers
14
+ # /external-href <use>/<image>), so render never parses untrusted markup.
15
+ # A custom set registered by a host MUST pre-sanitize its SVGs the same
16
+ # way - the vendored pipeline is the reference; a set that serves raw,
17
+ # attacker-influenced SVG is an XSS sink.
18
+ module Icons
19
+ # A directory of vendored, pre-sanitized icon files - one
20
+ # `<name>.svg` per icon holding the INNER markup (the component owns
21
+ # the <svg> wrapper). Reads are memory-cached; names are validated
22
+ # against a strict format before touching the filesystem (icon names
23
+ # can carry user input - no path traversal).
24
+ class FileSet
25
+ # The kebab-case shape every icon name must match before it touches
26
+ # the filesystem.
27
+ NAME_FORMAT = /\A[a-z0-9][a-z0-9-]*\z/
28
+
29
+ attr_reader :dir
30
+
31
+ # A set over one directory of sanitized SVG files, one file per
32
+ # icon name (`circle-alert.svg`); each icon is read once and cached.
33
+ #
34
+ # @param dir [String, Pathname] the directory holding the SVGs
35
+ def initialize(dir:)
36
+ @dir = Pathname.new(dir)
37
+ @cache = {}
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ # Whether the set has an icon of this name: the name must be
42
+ # well-formed and its SVG present on disk.
43
+ #
44
+ # @param name [Symbol, String] the icon name
45
+ # @return [Boolean]
46
+ def include?(name)
47
+ valid_name?(name) && path_for(name).exist?
48
+ end
49
+
50
+ # The inner SVG markup of one icon, read once and cached.
51
+ #
52
+ # @param name [Symbol, String] the icon name
53
+ # @return [String] the icon's inner SVG markup
54
+ # @raise [Poetry::Core::IconNotFound] for a malformed name, or an
55
+ # unknown one (the message carries a did-you-mean suggestion when
56
+ # one exists; the error's #name and #suggestion carry the parts)
57
+ def fetch(name)
58
+ raise IconNotFound.new("invalid icon name #{name.inspect}", name: name) unless valid_name?(name)
59
+
60
+ unless path_for(name).exist?
61
+ suggestion = Icons.suggest(name, names)
62
+ hint = suggestion ? " - did you mean #{suggestion.to_sym.inspect}?" : ""
63
+ raise IconNotFound.new("unknown icon #{name.inspect} (not in this set)#{hint}",
64
+ name: name, suggestion: suggestion)
65
+ end
66
+
67
+ @mutex.synchronize do
68
+ @cache[name.to_sym] ||= path_for(name).read.strip
69
+ end
70
+ end
71
+
72
+ # Every icon name in the set, sorted.
73
+ #
74
+ # @return [Array<Symbol>]
75
+ def names
76
+ @names ||= @dir.glob("*.svg").map { |path| path.basename(".svg").to_s.to_sym }.sort
77
+ end
78
+
79
+ private
80
+
81
+ def valid_name?(name)
82
+ name.to_s.match?(NAME_FORMAT)
83
+ end
84
+
85
+ def path_for(name)
86
+ @dir.join("#{name}.svg")
87
+ end
88
+ end
89
+
90
+ class << self
91
+ # Did-you-mean for icon names. The reversed-compound form is
92
+ # checked before edit distance: Lucide v1 swapped modifier and noun
93
+ # (alert-circle -> circle-alert, x-circle -> circle-x), a rename class
94
+ # DidYouMean's checker misses every time - the reversal IS the fix.
95
+ #
96
+ # @param name [Symbol, String] the unknown name (underscores tolerated)
97
+ # @param names [Enumerable] the valid names to suggest from
98
+ # @return [String, nil] the closest valid name, or nil
99
+ def suggest(name, names)
100
+ name = name.to_s.tr("_", "-")
101
+ candidates = names.map(&:to_s)
102
+ reversed = name.split("-").reverse.join("-")
103
+ return reversed if reversed != name && candidates.include?(reversed)
104
+
105
+ require "did_you_mean"
106
+ DidYouMean::SpellChecker.new(dictionary: candidates).correct(name).first
107
+ end
108
+
109
+ # The registered icon sets, library key => set object. Icon gems
110
+ # add themselves here on require.
111
+ #
112
+ # @return [Hash{Symbol => Object}]
113
+ def registry
114
+ @registry ||= {}
115
+ end
116
+
117
+ # Registers an icon set under a library key - the extension point
118
+ # an icon gem calls on require. A set is any object responding to
119
+ # `#include?(name)`, `#fetch(name)` (returning inner SVG markup),
120
+ # and `#names`. The set contract: `#fetch` raises
121
+ # {Poetry::Core::IconNotFound} for any name it cannot serve - the
122
+ # Icon component's missing-icon policy rescues exactly that class.
123
+ # See the SECURITY note above for the pre-sanitization requirement
124
+ # on custom sets.
125
+ #
126
+ # @param key [Symbol, String] the library key `config.icon_library`
127
+ # selects the set by
128
+ # @param set [Object] the icon set (a {FileSet} over a directory of
129
+ # vendored SVGs, or any object honoring the same contract)
130
+ # @example Register a vendored set and select it
131
+ # Poetry::Core::Icons.register(:my_icons,
132
+ # Poetry::Core::Icons::FileSet.new(dir: root.join("icons")))
133
+ # # config/initializers/poetry.rb: config.icon_library = :my_icons
134
+ # @return [Object] the set, now registered
135
+ def register(key, set)
136
+ registry[key.to_sym] = set
137
+ end
138
+
139
+ # The set for the given library key, defaulting to
140
+ # config.icon_library. Raises with the fix when unregistered.
141
+ #
142
+ # @param library [Symbol, String, nil] the library key; nil reads
143
+ # `Poetry::Core::Config.current.icon_library`
144
+ # @return [Object] the registered set
145
+ # @raise [Poetry::Core::Error] when no set is registered under the key
146
+ def set(library = nil)
147
+ key = (library || Poetry::Core::Config.current.icon_library).to_sym
148
+ registry.fetch(key) do
149
+ raise Poetry::Core::Error,
150
+ "no icon set registered as #{key.inspect} - require its gem (e.g. poetry-lucide) " \
151
+ "or set config.icon_library to one of: #{registry.keys.inspect}"
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ # Generates llms.txt (the index) and llms-full.txt (full contracts) from
6
+ # the component registry - never hand-maintained, so the LLM-facing docs
7
+ # can't drift from the code.
8
+ #
9
+ # @example
10
+ # Poetry::Core::LlmsText.new(registry: registry).index
11
+ #
12
+ # @api private
13
+ class LlmsText
14
+ PREAMBLE = <<~TEXT
15
+ # Poetry for Rails
16
+
17
+ > poetry is an AI-native, Rails-first component library: accessible,
18
+ > themeable ViewComponents on semantic design tokens. Agents compose
19
+ > from the approved catalog below instead of writing raw markup.
20
+
21
+ Preference hierarchy: tokens > utilities; components > raw markup;
22
+ variants > one-off classes; slots > custom HTML. Render components
23
+ with their `poetry_<name>` helpers.
24
+
25
+ Verify your markup with `poetry check` (rake poetry:check) before it
26
+ renders - it validates components, options, variants, and Stimulus
27
+ wiring against these contracts. Testing doctrine: the Testing guide on the poetry docs site.
28
+ TEXT
29
+
30
+ def initialize(registry:)
31
+ @registry = registry
32
+ end
33
+
34
+ # The lean index: one line per component (+ the blocks catalog).
35
+ def index
36
+ lines = @registry.entries.map do |path, entry|
37
+ "- #{title(path)}: `#{helper(path)}` - #{index_summary(entry)}"
38
+ end
39
+ "#{PREAMBLE}\n## Components\n\n#{lines.join("\n")}\n#{blocks_index}"
40
+ end
41
+
42
+ # The index line's summary: the human description leads (what it is),
43
+ # then the style surface (its variants) in parens so an agent still sees
44
+ # the options at a glance. Description-less entries fall back to the
45
+ # surface alone.
46
+ def index_summary(entry)
47
+ surface = surface_summary(entry)
48
+ return surface unless entry["description"]
49
+
50
+ surface == "no style attributes" ? entry["description"] : "#{entry["description"]} (#{surface})"
51
+ end
52
+
53
+ # The full contracts: props, slots, elements, and agent rules - plus
54
+ # every block's source, so an agent holding this file can start a
55
+ # screen from a vetted composition without another fetch.
56
+ def full
57
+ sections = @registry.entries.map { |path, entry| component_section(path, entry) }
58
+ "#{PREAMBLE}\n#{sections.join("\n")}#{forms_full}#{blocks_full}"
59
+ end
60
+
61
+ # The Forms section (the registry's optional form_builder surface):
62
+ # the model-bound builder rules + method table, so an agent writing a
63
+ # form reaches for f.input before hand-composing Fields.
64
+ def forms_full
65
+ surface = @registry.respond_to?(:form_builder) ? @registry.form_builder : nil
66
+ return "" unless surface&.any?
67
+
68
+ lines = ["\n## Forms (Poetry::Ui::FormBuilder)\n"]
69
+ Array(surface["rules"]).each { |rule| lines << "- #{rule}" }
70
+ if (methods = surface["methods"])&.any?
71
+ lines << "\nBuilder methods:"
72
+ methods.each { |name, summary| lines << "- `f.#{name}` - #{summary}" }
73
+ end
74
+ if (types = surface["input_types"])&.any?
75
+ lines << "\n`f.input as:` values: #{types.join(", ")}."
76
+ end
77
+ "#{lines.join("\n")}\n"
78
+ end
79
+
80
+ private
81
+
82
+ # The helper is poetry_ + the path under the ui/ namespace, so
83
+ # command/dialog -> poetry_command_dialog (not the last-segment
84
+ # poetry_dialog, which collides with the top-level dialog).
85
+ def title(path) = path.split("/").drop(2).join("_")
86
+ def helper(path) = "poetry_#{title(path)}"
87
+
88
+ def surface_summary(entry)
89
+ parts = entry["styles"].map do |style|
90
+ values = style["variants"] ? style["variants"].join("|") : style["type"]
91
+ "#{style["name"]}: #{values}"
92
+ end
93
+ parts.empty? ? "no style attributes" : parts.join("; ")
94
+ end
95
+
96
+ def component_section(path, entry)
97
+ lines = ["## #{title(path)} (`#{helper(path)}`)", ""]
98
+ lines << "#{entry["description"]}\n" if entry["description"]
99
+ lines << "Class: #{entry["class_name"]} - BEM block `#{entry["bem_block"]}`."
100
+ if (hint = entry["requires_content"])
101
+ lines << "Content block REQUIRED (#{hint}) - a blockless call raises."
102
+ end
103
+ (entry["required_slots"] || {}).each do |setter, hint|
104
+ lines << "Slot REQUIRED: with_#{setter} (#{hint}) - a call without it raises."
105
+ end
106
+ (entry["requires_any"] || []).each do |group|
107
+ lines << "REQUIRED - #{any_of_phrase(group)}; a call satisfying none raises."
108
+ end
109
+ lines.concat(prop_lines(entry))
110
+ slots = entry["slots"].map { |slot| slot_summary(slot) }
111
+ lines << "Slots: #{slots.join(", ")}." if slots.any?
112
+ lines.concat(part_lines(entry))
113
+ # The block back-reference: the arrow points UP at the
114
+ # surface agents actually read - a screen containing this component
115
+ # should start from the vetted composition, not from scratch.
116
+ if (blocks = blocks_composing(title(path))).any?
117
+ lines << "In blocks: #{blocks.join(", ")} - for a screen, start from the block " \
118
+ "(MCP compose/describe_block, or `bin/rails g poetry:block`), not from scratch."
119
+ end
120
+ lines.concat(wiring_lines(entry))
121
+ lines.concat(tool_lines(entry))
122
+ (entry["agent_rules"] || []).each { |rule| lines << "- RULE: #{rule}" }
123
+ "#{lines.join("\n")}\n"
124
+ end
125
+
126
+ # The operate surface: the tools an in-page agent may invoke on a
127
+ # rendered instance once the call opts in (`webmcp: "name"`) - the
128
+ # same line describe_component prints, so no surface can lag.
129
+ def tool_lines(entry)
130
+ (entry["tools"] || []).map do |tool|
131
+ schema = tool["inputSchema"] || {}
132
+ required = schema["required"] || []
133
+ params = (schema["properties"] || {}).map do |name, spec|
134
+ facets = [spec["type"]]
135
+ facets << "required" if required.include?(name)
136
+ facets << "one of #{spec["enum"].join("|")}" if spec["enum"]
137
+ "#{name} (#{facets.join(", ")})"
138
+ end
139
+ facets = [tool.dig("annotations", "readOnlyHint") ? "read-only" : "mutating"]
140
+ facets << "params: #{params.join(", ")}" if params.any?
141
+ "- tool #{tool["name"]} (#{facets.join("; ")}) - #{tool["description"]} " \
142
+ "[opt in with webmcp: \"name\" on the call; dispatches #{tool["executes"]}]"
143
+ end
144
+ end
145
+
146
+ # The styling contract: every data-slot part with its state
147
+ # attributes and var seams - DOM-verified by the part-contract tier,
148
+ # so a restyling instruction can only target selectors that provably
149
+ # exist. Restyle via `[data-slot=<part>]` (+ state selectors); never
150
+ # guess at internal markup.
151
+ def part_lines(entry)
152
+ (entry["parts"] || []).map do |part|
153
+ facets = []
154
+ states = (part["states"] || []).map { |state| state_phrase(state) }
155
+ facets << "states: #{states.join("; ")}" if states.any?
156
+ vars = (part["vars"] || []).map { |var| "#{var["name"]} (#{var["description"]})" }
157
+ facets << "vars: #{vars.join("; ")}" if vars.any?
158
+ "- PART `#{part["name"]}` - #{part["description"]}#{" | #{facets.join(" | ")}" if facets.any?}"
159
+ end
160
+ end
161
+
162
+ def state_phrase(state)
163
+ values = state["values"] ? "=#{state["values"].join("|")}" : ""
164
+ "#{state["attr"]}#{values} (#{state["condition"]})"
165
+ end
166
+
167
+ # The any-of contract, phrased once: "one of a content block
168
+ # / with_leading / loading: (hint)".
169
+ def any_of_phrase(group)
170
+ parts = []
171
+ parts << "a content block" if group["content"]
172
+ parts.concat((group["slots"] || []).map { |name| "with_#{name}" })
173
+ parts.concat((group["options"] || []).map { |key| "#{key}:" })
174
+ "one of #{parts.join(" / ")} (#{group["hint"]})"
175
+ end
176
+
177
+ # Block names composing a component (inverted from the blocks
178
+ # catalog), or [] for a registry without blocks.
179
+ def blocks_composing(component_title)
180
+ @blocks_composing ||= (@registry.blocks || {}).each_with_object(Hash.new do |h, k|
181
+ h[k] = []
182
+ end) do |(name, entry), map|
183
+ entry["components"].each { |component| map[component] << "`#{name}`" }
184
+ end
185
+ @blocks_composing[component_title]
186
+ end
187
+
188
+ # The Stimulus wiring surface: the controllers a component
189
+ # renders, each with the targets / values / actions an agent may wire
190
+ # by hand and the events it may listen for.
191
+ # Absent for static components.
192
+ # Element-level when the entry carries the projection (use_stimulus
193
+ # declarations); the controller-level capability view otherwise
194
+ # (charts, external registries).
195
+ def wiring_lines(entry)
196
+ return element_wiring_lines(entry) if entry["stimulus"]
197
+
198
+ (entry["controllers"] || []).map do |controller|
199
+ facets = []
200
+ facets << "targets #{controller["targets"].join(", ")}" if controller["targets"].any?
201
+ facets << "values #{controller["values"].join(", ")}" if controller["values"].any?
202
+ facets << "actions #{controller["actions"].join(", ")}" if controller["actions"].any?
203
+ facets << "events #{controller["events"].join(", ")}" if (controller["events"] || []).any?
204
+ "- WIRING `#{controller["identifier"]}`#{": #{facets.join("; ")}" unless facets.empty?}"
205
+ end
206
+ end
207
+
208
+ def element_wiring_lines(entry)
209
+ entry["stimulus"].map do |element|
210
+ phrases = element["controllers"].map { |wiring| wiring_phrase(wiring) }
211
+ suffix = element["conditional"] ? " (#{element["conditional"]})" : ""
212
+ "- WIRING #{element["element"]}#{suffix}: #{phrases.join(" | ")}"
213
+ end
214
+ end
215
+
216
+ def wiring_phrase(wiring)
217
+ facets = []
218
+ facets << "registers#{" (#{wiring["registers"]})" unless wiring["registers"] == true}" if wiring["registers"]
219
+ if (values = wiring["values"])
220
+ facets << "values #{values.map { |value| conditional_name(value, "name") }.join(", ")}"
221
+ end
222
+ if (actions = wiring["actions"])
223
+ facets << "actions #{actions.map { |action| action_phrase(action) }.join(", ")}"
224
+ end
225
+ if (targets = wiring["targets"])
226
+ facets << "targets #{targets.map { |target| conditional_name(target, "name") }.join(", ")}"
227
+ end
228
+ suffix = wiring["conditional"] ? " (#{wiring["conditional"]})" : ""
229
+ "`#{wiring["identifier"]}`#{suffix} #{facets.join("; ")}".strip
230
+ end
231
+
232
+ def action_phrase(action)
233
+ phrase = action["method"]
234
+ if (on = action["on"])
235
+ events = Array(on).join("/")
236
+ events = "#{events}@#{action["at"]}" if action["at"]
237
+ phrase = "#{phrase} on #{events}"
238
+ end
239
+ action["conditional"] ? "#{phrase} (#{action["conditional"]})" : phrase
240
+ end
241
+
242
+ def conditional_name(item, key)
243
+ item["conditional"] ? "#{item[key]} (#{item["conditional"]})" : item[key]
244
+ end
245
+
246
+ def prop_lines(entry)
247
+ (entry["styles"] + entry["options"]).map do |prop|
248
+ details = []
249
+ details << "one of #{prop["variants"].join("|")}" if prop["variants"]
250
+ details << "default #{prop["default"].inspect}" if prop.key?("default")
251
+ details << "required" if prop["required"]
252
+ details << "format: #{prop["format"]}" if prop["format"]
253
+ line = "- `#{prop["name"]}:` (#{prop["type"]})#{" - #{details.join(", ")}" if details.any?}"
254
+ prop["description"] ? "#{line} - #{prop["description"]}" : line
255
+ end
256
+ end
257
+
258
+ # A typed slot renders another component: the call takes THAT
259
+ # component's props, never a render block (with_icon(name: ...) - the
260
+ # block-form guess an agent would otherwise make, which this line now
261
+ # forecloses). Untyped slots take blocks; many-slots say so; a
262
+ # polymorphic slot lists its with_<type> setters - and when they are
263
+ # all kwargs-only, says so (foreclosing the type-as-argument
264
+ # dispatch no setter has). Each shape that raises at render
265
+ # gets its own sentence: yieldless setters (no |param| - it would be
266
+ # nil), closed keyword signatures (the exact accepted set), and
267
+ # setters that cannot omit their content block.
268
+ def slot_summary(slot)
269
+ qualifiers = []
270
+ qualifiers << slot["description"] if slot["description"]
271
+ qualifiers << "many" if slot["many"]
272
+ if slot["types"]
273
+ convention = kwargs_only_setters?(slot) ? " - one with_<type> setter each, options as keywords" : ""
274
+ qualifiers << "types #{slot["types"].join("|")}#{convention}"
275
+ end
276
+ qualifiers << "takes #{helper(slot["component"])} props, not a block" if slot["component"]
277
+ if (yieldless = slot["yieldless"])
278
+ setters = yieldless.map { |name| "with_#{name}" }.join("/")
279
+ verb = yieldless.size == 1 ? "yields" : "yield"
280
+ qualifiers << "#{setters} #{verb} NOTHING to the block - no |param|, write content directly"
281
+ end
282
+ (slot["setter_kwargs"] || {}).each do |setter, keywords|
283
+ qualifiers << "with_#{setter} keywords: #{keywords.map { |keyword| "#{keyword}:" }.join(", ")} ONLY"
284
+ end
285
+ (slot["required_content"] || {}).each do |setter, hint|
286
+ qualifiers << "with_#{setter} REQUIRES a content block (#{hint})"
287
+ end
288
+ # The nested requirement seam (with_menu
289
+ # without with_trigger raises - stated where agents read).
290
+ (slot["builders"] || {}).each do |setter, surface|
291
+ (surface["required_slots"] || {}).each do |required, hint|
292
+ qualifiers << "each with_#{setter} REQUIRES with_#{required} inside its block (#{hint})"
293
+ end
294
+ end
295
+ "#{slot["name"]}#{" (#{qualifiers.join("; ")})" if qualifiers.any?}"
296
+ end
297
+
298
+ def kwargs_only_setters?(slot)
299
+ args = slot["setter_args"]
300
+ # A type with no tracked arity (rest-signature) is unknowable - the
301
+ # claim only holds when every setter is known kwargs-only.
302
+ args && slot["types"].all? { |type| args[type]&.zero? }
303
+ end
304
+
305
+ # The blocks catalog: vetted composed screens, one
306
+ # altitude above components. The index teaches the decision
307
+ # hierarchy - start a SCREEN from a block, compose atoms for what no
308
+ # block covers.
309
+ def blocks_index
310
+ blocks = @registry.blocks
311
+ return "" if blocks.nil? || blocks.empty?
312
+
313
+ lines = blocks.map do |name, entry|
314
+ "- #{entry["title"]} (`#{name}`): #{entry["description"]} " \
315
+ "[composes: #{entry["components"].join(", ")}]"
316
+ end
317
+ <<~TEXT
318
+
319
+ ## Blocks
320
+
321
+ Blocks are the DEFAULT starting point, not a fallback: route every
322
+ brief through the MCP `compose` tool first - it returns the
323
+ matching block's source ready to adapt, or the component path when
324
+ nothing matches. Without MCP: `bin/rails g poetry:block <name>`
325
+ copies a block into app/views/blocks/ as source the app owns
326
+ (--list to browse). Blocks carry the composed patterns -
327
+ containment, status color-coding, page furniture, realistic
328
+ content - so a screen starts composed, not blank. Page framing
329
+ counts: a section that IS the page's subject keeps its container +
330
+ breathing room (the section blocks demonstrate the wrapper) - a
331
+ bare component at the viewport origin reads cramped.
332
+
333
+ #{lines.join("\n")}
334
+ TEXT
335
+ end
336
+
337
+ def blocks_full
338
+ blocks = @registry.blocks
339
+ return "" if blocks.nil? || blocks.empty?
340
+
341
+ sections = blocks.map do |name, entry|
342
+ source = @registry.source_root.join(entry.fetch("template")).read
343
+ ["## Block: #{entry["title"]} (`#{name}`)", "",
344
+ entry["description"],
345
+ "Composes: #{entry["components"].join(", ")}. " \
346
+ "Generate: `bin/rails g poetry:block #{name}`.",
347
+ "Source (adapt freely - the sample content is meant to be replaced):", "",
348
+ source.sub(/\A<%#\s*poetry:block[^%]*%>\n?/, "").rstrip].join("\n")
349
+ end
350
+ "\n#{sections.join("\n\n")}\n"
351
+ end
352
+ end
353
+ end
354
+ end