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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- 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
|