poetry-core 0.1.0 → 0.1.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +0 -4
  4. data/app/components/poetry/core/component.rb +246 -7
  5. data/app/components/poetry/core/concerns/options.rb +1 -0
  6. data/app/components/poetry/core/concerns/styles.rb +46 -5
  7. data/app/components/poetry/core/style.rb +32 -1
  8. data/app/javascript/poetry/core/drawer_controller.js +28 -0
  9. data/app/javascript/poetry/core/hover_card_controller.js +26 -11
  10. data/app/javascript/poetry/core/message_scroller_controller.js +82 -4
  11. data/app/javascript/poetry/core/navigation_menu_controller.js +59 -4
  12. data/app/javascript/poetry/core/number_field_controller.js +24 -14
  13. data/config/component_registry.yml +2 -0
  14. data/config/controllers_manifest.json +2 -3
  15. data/lib/poetry/core/check/stable_identity.rb +14 -1
  16. data/lib/poetry/core/check.rb +182 -41
  17. data/lib/poetry/core/config.rb +15 -5
  18. data/lib/poetry/core/css/modes.rb +127 -0
  19. data/lib/poetry/core/css/override_scan.rb +17 -2
  20. data/lib/poetry/core/css/resolver.rb +21 -4
  21. data/lib/poetry/core/css/token_collisions.rb +184 -0
  22. data/lib/poetry/core/engine.rb +22 -0
  23. data/lib/poetry/core/host_components.rb +159 -0
  24. data/lib/poetry/core/host_helpers.rb +110 -0
  25. data/lib/poetry/core/html/attributes.rb +33 -2
  26. data/lib/poetry/core/llms_text.rb +39 -7
  27. data/lib/poetry/core/registry.rb +154 -5
  28. data/lib/poetry/core/skill_text.rb +27 -1
  29. data/lib/poetry/core/stimulus/declarations.rb +8 -3
  30. data/lib/poetry/core/stimulus/host_manifest.rb +556 -0
  31. data/lib/poetry/core/stimulus/manifest.rb +24 -0
  32. data/lib/poetry/core/tokens/generator.rb +10 -1
  33. data/lib/poetry/core/version.rb +1 -1
  34. data/tokens/tailwind-theme.css +1 -1
  35. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec837c1c31e39d20d47e278d7ae7dde0ff26f036f17e8bc33dcf81abd6daafdd
4
- data.tar.gz: 650c0d9053220a0500ae619402dd92515248b0b6bfe960f85e874ace979f36af
3
+ metadata.gz: 68f26084516bebd43571b5f687d9fb07cce952f00aba8e41448715b66b996fba
4
+ data.tar.gz: d1ec44154a85d4c2b4fda384432f2e73349973dfc21862aeddf06bde0d09303b
5
5
  SHA512:
6
- metadata.gz: d9e82544dcb308ba9feea37d8de5f2272bae3be011c59601c584f6b2b90f1156f6bfef8864d03b0d17c62308eba42e8f82572acfc55a7dd8ce3510c95266ca8a
7
- data.tar.gz: b35b3d54fa5f02ebd98357a1c93cf4b89a0969a45c1793e15e782d23921cb670a52889fc888e0204dbde640bc0a83176c54ea8f285031f41802b12bbadbd45de
6
+ metadata.gz: 65965b0865ade83c8b778c3ba773c6d1272bd4da2139e68cd8ab5b798903d12e3224d75df0f77f849cd72403e782d5da87ba470a4597d8654c7ab72a6bdc90ea
7
+ data.tar.gz: e3b822469b4ed1253193dd331b9b43b7923aa5626265ec9f6d1cdc6c4112a7306eb93e37f3a7670b9985da406ef804b5eb4b5174b03263004eced90e306e03c3
data/CHANGELOG.md CHANGED
@@ -1,6 +1,53 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.1.2] - 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - `helper :name` on a component: an application's (or an engine's) own component written on the DSL names its view helper. The engine defines that helper on Action View at boot and on every reload, and `Poetry::Core::HostComponents` discovers the app's components by convention (under `app/`, outside the `poetry/` namespace, published), builds their registry live, and reads their declared helpers boot-free from source. The registry entry carries `helper`; `poetry check` lints a declared helper under its own name (options, variants, arity, stable identity); llms.txt and the generated skill take an app registry and render an App components section and a `references/app.md`.
8
+ - The runtime values tier. A component built with an off-list style value or a missing required option raises at construction in development and test, naming the allowed values with a did-you-mean, and logs in production while rendering as before (the passthrough guard's posture, at the same seam, so it holds whatever a component does in `before_render`). Only the DSL's own validations are enforced; a component's custom validators keep their own policy. An omitted style is nil, not an off-list value (`allow_nil` on the inclusion validations). The check is a direct lookup over the declared vocabulary (`style` variants, `required`, and `validates ... inclusion: { in: [...] }` on a declared attribute), not an ActiveModel validation pass.
9
+ - Render cost. Measured on 200 Buttons in one view context, a Button went from 0.122 ms to 0.089 ms: the values tier is a direct lookup instead of a validation pass, construction converts the caller's attributes once, and the Style resolver memoises its dictionary walk per variant tuple (bounded by the variant space, cleared when the dictionary changes). The remaining time is ViewComponent's own render (about forty percent) and attribute assignment (a quarter).
10
+ - The class-name merger follows the kit's CSS mode (`Poetry::Core::CSS::Modes.merger_for`): a `:bem` kit merges with a `BemMerger`, a `:tailwind` kit with a `TailwindMerger`. The global `classname_merger` is honoured whenever it is of the right kind, so a customised Tailwind merger still reaches poetry-ui and a host's BEM merger still reaches its BEM kit; a mismatch falls back to the stock merger of the mode. A host that set the global mode to `:bem` without a merger now gets the BEM merger on its kit, the pairing the guide always asked for. Attribute objects keep their assigned merger across copies.
11
+ - `css_mode` is decided per kit. A component class declares it (`css_mode :bem`, inherited), or a kit pins its namespace (`Poetry::Core::CSS::Modes.pin("Acme::Ui", :bem)`); the global `Config.current.css_mode` is the default for kits that declare nothing, and a `css_mode:` keyword on a `css` call still wins over all of them. poetry-ui and poetry-charts pin `:tailwind`, so a host's global never reaches them and a BEM kit on the DSL renders beside them in one app. Before, the global was the only switch and setting it to `:bem` silently stripped poetry-ui's styling.
12
+ - `OverrideScan` takes `owned:`, the theme-owned `cn-*` names; only a rule against one of those is an override. A host's own `cn-*` classes (a kit's dictionary, a page helper) are never findings. Nil keeps the old meaning (every `cn-*` name).
13
+ - The host controllers manifest. `Poetry::Core::Stimulus::HostManifest` reads `app/javascript/controllers` (static targets, values in both forms, classes, `static events`, the class body's own methods) and writes `config/controllers_manifest.json`, the file a gem commits; the engine registers every loaded engine's and the app's manifest by convention before components load. A host controller then validates like poetry's: `use_stimulus` by Symbol at class load, template wiring in `poetry check` (the value-attribute rule now covers any registered controller), and its API in the registry's controllers section. The reader masks comments and string, template and regex literals before it reads structure, so a comment inside `static values`, a brace in a string, a one-line method body or an arrow-function class field all read correctly; it scrubs stray bytes and a BOM. An entry is complete or absent: a parent that is another host controller (relative import) or a poetry controller (`@poetry/controllers`, file or barrel import; charts and agent by their packages) merges child-first; an unresolvable parent, a computed static, a getter or a spread yields no entry and a named skip. A hand-written entry for a skipped identifier survives regeneration and never reads as stale (the gate compares only the controllers the reader read). Events are validated only from a literal `static events`; a controller that only calls `dispatch()` keeps them unknown, never empty. A Symbol declaration resolves to a gem's namespaced controller before a host controller of the same bare name, so a host `tabs_controller.js` never hijacks `controller :tabs`; the unknown-Symbol message names `bin/rails poetry:stimulus:manifest`. `Manifest.register_roots`, `Manifest.forget`; `Registry.gem_roots(registry: false)` lists every bundled root for the manifest walk.
14
+ - Registry roots by convention. `Poetry::Core::Registry.roots` (booted: every loaded engine whose root carries a published registry, then the app when it committed one), `.gem_roots` (boot-free, from the bundle, plus an app root), and `.merged` (one view over several roots, block templates resolved to absolute paths). A registry can mark itself `internal: true` and consumers skip it; poetry-core's own is now marked, since its building blocks have no helpers. `HostComponents.committed_state` reports the app's committed registry as missing, fresh, or stale against the live build.
15
+ - `Poetry::Core::CSS::TokenCollisions`: scans a host app's stylesheets for declarations of Poetry's token names (`--primary`, `--accent`, `--radius`, ...) and Tailwind theme keys (`--color-*`, `--radius-*`), and reports each with its location and what the role paints in Poetry's components. `poetry:install` and `poetry:check` run it.
16
+
17
+ ### Changed
18
+
19
+ - A namespace pin follows the dictionary: a component's mode is its own declaration, else the pin of the root-most pinned ancestor (`Modes.inherited_for`), else the global, so a host subclass of a poetry-ui component stays Tailwind inside a BEM-pinned namespace while a host component written on the DSL takes the pin.
20
+ - A dictionary merges by its own mode at every level: the resolver takes its class merger from the Style's sidecar component (its declaration, its kit's pin, or the global), so a host's global BEM merger never reaches a Tailwind kit's element-level joins, not only its root. A style redeclared without variants is open, whatever the parent listed: an inherited inclusion validator no longer narrows it. The "Style class not found" debug line is logged once per class.
21
+
22
+ - `poetry check` validates every descriptor of a `data-action`, not only the last (a bogus first descriptor was invisible); a `data-<controller>-<name>-value` or `-target` attribute resolves to the controller the element declares when several registered identifiers fit (`foo` and `foo-bar`), a plain `data-foo-value` is the host's own attribute, a host controller's targets validate like poetry's, and an attribute whose value carries ERB skips the wiring rules instead of type-checking its fragments. A declared app helper matches bare calls only (`u.badge` is somebody's method); the stable-identity pass agrees. A file the runner cannot read or decode is one `unreadable` warning naming it, never the end of the sweep. `Check.mail_template?(path, root:)` judges the path inside the app root (an app checked out under a folder called `acme_mailer` keeps its pages) and knows Devise's `devise/mailer/`.
23
+
24
+ - Every registry names every helper. A gem entry carries its `poetry_` convention name and an app entry the name it declared with `helper :name`; `Registry.helper_for(path, entry)` is the one resolver every surface reads (llms.txt, the check, the skill, the MCP server), so an app component that declared no helper is listed by class and no surface invents a `poetry_` name for it. The committed gem registries regenerate with the added key and nothing else.
25
+ - Host helpers are checked before any is defined: a clash (a name Action View, poetry-ui, or the app's own `ApplicationController` helpers already carry, private methods included) or two components declaring one name raises naming both sides, leaves the module untouched, and the next reload after the fix starts clean. `Registry.read_file` is the one validated reader of a committed registry file; an empty, malformed, or wrong-shaped file reads as no registry (`Registry.invalid_at?`) instead of raising in whichever surface opened it first. The boot-free `HostComponents.declared_helpers` parses component sources with Prism, so a mention in a string, heredoc, or comment is not a declaration and `helper(:x)`, `helper "x"`, and `self.helper :x` are. `poetry:registry` leaves an engine's own components to that engine's registry.
26
+
27
+ - `poetry check` leaves a mailer template's colors alone: under a `*_mailer/` directory or in the mailer layout, an inline hex or a color literal in a utility class is the only paint an email client honours (no stylesheet, no tokens), so the raw-color rule stays quiet there while every other rule runs as on a page. `Check.lint` takes `mail:` for a source string; the runner derives it from the path (`Check.mail_template?`).
28
+
29
+ - The generated Tailwind theme mapping is `@theme inline default`. A host `@theme` value for the same key (its own `--color-primary`, a `--radius-sm` it set before Poetry arrived) wins whether it is declared before or after the mapping; Poetry's value still applies wherever the host set nothing. Before, the mapping replaced the host's keys wholesale, so every `rounded-sm` in an existing app changed size.
30
+
31
+ ## [0.1.1] - 2026-09-08
32
+
33
+ ### Added
34
+
35
+ - `identity:` on every component: the one sanctioned way for a component that renders as another component's root to name it (its `data-component`). Universal like `key:`, never an HTML attribute.
36
+ - `poetry check` rule `reserved-attribute`: `data-component` passed through a helper, as a string key or `data: { component: }`, is an error.
37
+
38
+ ### Changed
39
+
40
+ - The passthrough contract is enforced at render. A keyword that is not an option still renders as an HTML attribute on the root, but a near miss of a declared option (`varient:`) raises in development and test with a did-you-mean and logs in production; `data-component` is never overridable (raised in development and test, dropped in production); `data-slot` stays open as a composition seam. `poetry check`'s `unknown-option` finding is an error, no longer a warning.
41
+ - `poetry--core--hover-card`: `touchGuard` (on touchstart) is replaced by `pointerDown` (on pointerdown). `poetry--core--number-field`: the `focus` action is removed. A host that wired either by hand updates the action strings; poetry-ui's components already have.
42
+ - `css_mode :bem` is documented as the mode for kits authored on the DSL that write their own templates; poetry-ui is Tailwind-native and not a `:bem` consumer.
43
+
44
+ ### Fixed
45
+
46
+ - HoverCard: a tap on the trigger keeps its click on touch devices. The touchstart guard cancelled the click; a pointerdown latch held through the tap's compatibility mouse events replaces it.
47
+ - MessageScroller: `data-pending-scroll` holds the root and the viewport until the opening position (`end` or `last-anchor`) is applied, releases at once for an empty transcript, is stripped when a Turbo morph re-stamps it, and is re-armed at `turbo:before-cache`, so a server-rendered or restored transcript never shows the top of the thread first.
48
+ - NumberField: a sideways trackpad gesture over a wheel-enabled field scrolls the page instead of stepping, Shift on the horizontal axis steps large, and an event with no movement is neither stepped nor cancelled. Focus keeps the browser's own selection (Tab selects the value, a click places the caret); the steppers park the caret at the end.
49
+ - NavigationMenu: a disabled trigger never opens, by hover or click, and the arrows step over it; ArrowDown on a trigger opens its panel with focus staying on the trigger; removing the open trigger's item, as a morph can, closes the bar and drops its orphaned panel.
50
+ - Drawer: the click that follows a press inside the panel never dismisses, so a drag against the clamp released over the backdrop no longer closes the sheet.
4
51
 
5
52
  ## [0.1.0] - 2026-09-05
6
53
 
data/README.md CHANGED
@@ -20,10 +20,6 @@ After checking out the repo, run `bin/setup` to install dependencies, then `bund
20
20
 
21
21
  Releases publish to [RubyGems.org](https://rubygems.org) via GitHub Actions OIDC **trusted publishing** (no API keys). Run `bundle exec rake "version:bump[X.Y.Z]"` (sets `Poetry::Core::VERSION` and package.json together; `rake version:verify` in the default chain fails on drift), commit, then push a `vX.Y.Z` tag — the `Release` workflow checks the tag against the version and publishes the gem.
22
22
 
23
- ## Lineage
24
-
25
- poetry-core evolves the framework layer of `view_component_plus` (the `Plus` gem) — itself a synthesis of view_component-contrib and fox_tail.
26
-
27
23
  ## License
28
24
 
29
25
  Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -54,6 +54,14 @@ module Poetry
54
54
  # a subclass of an internal component stays internal.
55
55
  class_attribute :internal_component, default: false, instance_predicate: false
56
56
 
57
+ # The declared vocabulary the runtime values tier checks:
58
+ # name => { variants: [...] or nil, required: true/false }, recorded
59
+ # by `style` and `option` at declaration (copy-on-write per class, so
60
+ # a subclass's declarations never leak up). A direct lookup, not an
61
+ # ActiveModel validation pass - measured at nine percent of a
62
+ # Button's render when it ran through `valid?`.
63
+ class_attribute :declared_values, instance_accessor: false, default: {}.freeze
64
+
57
65
  class << self
58
66
  # Marks this class (and its descendants) as an implementation
59
67
  # detail - full machinery, no registry entry.
@@ -66,6 +74,86 @@ module Poetry
66
74
  def internal_component!
67
75
  self.internal_component = true
68
76
  end
77
+
78
+ # The vocabulary the runtime values tier checks, computed once per
79
+ # class at first construction (the class body is complete by then):
80
+ # the recorded `style`/`option` declarations plus every inclusion
81
+ # validator with an Array (`validates :side, inclusion: { in: SIDES }`
82
+ # is how options declare a closed vocabulary). Method validators are
83
+ # not vocabulary and stay the component's own (Icon's unknown-name
84
+ # policy).
85
+ #
86
+ # @return [Hash{Symbol => Hash}]
87
+ # @api private
88
+ def runtime_vocabulary
89
+ @runtime_vocabulary ||= begin
90
+ vocabulary = declared_values.dup
91
+ names = attribute_names.map(&:to_sym)
92
+ validators.each do |validator|
93
+ next unless validator.is_a?(ActiveModel::Validations::InclusionValidator)
94
+ next unless (allowed = validator.options[:in]).is_a?(Array)
95
+
96
+ validator.attributes.each do |attribute|
97
+ attribute = attribute.to_sym
98
+ next unless names.include?(attribute)
99
+
100
+ spec = vocabulary[attribute] || { variants: nil, required: false }
101
+ # A style declared open (no variants) stays open: an inherited
102
+ # inclusion validator from a parent's closed declaration never
103
+ # narrows it again.
104
+ next if spec[:open]
105
+
106
+ vocabulary[attribute] = spec.merge(variants: allowed).freeze unless spec[:variants]
107
+ end
108
+ end
109
+ vocabulary.freeze
110
+ end
111
+ end
112
+
113
+ # Records a declared attribute's vocabulary for the runtime values
114
+ # tier ({#guard_declared_values}).
115
+ #
116
+ # @param name [Symbol]
117
+ # @param variants [Array, nil] the closed vocabulary, nil for open values
118
+ # @param required [Boolean]
119
+ # @return [void]
120
+ # @api private
121
+ def record_declared_value(name, variants:, required:, open: false)
122
+ spec = { variants: variants, required: required, open: open }.freeze
123
+ self.declared_values = declared_values.merge(name.to_sym => spec).freeze
124
+ end
125
+
126
+ # Names the view helper that renders this component - for a
127
+ # component the host application (or an engine) defines on the
128
+ # DSL. The poetry-core engine defines the method on Action View at
129
+ # boot and on every reload, and the app's registry carries the name
130
+ # so `poetry check`, llms.txt and the generated skill know the
131
+ # helper. Own-class only: a subclass declares its own or has none.
132
+ # The gems' components do not declare one; their helpers follow the
133
+ # `poetry_<name>` convention.
134
+ #
135
+ # @example
136
+ # class Demo::Badge::Component < Poetry::Core::Component
137
+ # helper :demo_badge
138
+ # end
139
+ # # <%= demo_badge(tone: :loud) { "New" } %>
140
+ # @param name [Symbol, String] a Ruby method name
141
+ # @return [void]
142
+ # @raise [ArgumentError] when the name is not a plain method name
143
+ def helper(name)
144
+ name = name.to_s
145
+ unless name.match?(/\A[a-z_][a-z0-9_]*\z/)
146
+ raise ArgumentError, "helper name #{name.inspect} must be a plain method name (a-z, 0-9, _)"
147
+ end
148
+
149
+ @helper_name = name
150
+ end
151
+
152
+ # The declared helper name, or nil (the gems' components; a
153
+ # subclass that declares none).
154
+ #
155
+ # @return [String, nil]
156
+ attr_reader :helper_name
69
157
  end
70
158
 
71
159
  class << self
@@ -145,14 +233,36 @@ module Poetry
145
233
  end
146
234
  end
147
235
 
236
+ # Keywords that are never options and never typos - the passthrough
237
+ # vocabulary the static check exempts too (Check::Catalog::PASSTHROUGH).
238
+ PASSTHROUGH_KEYS = %w[class id key webmcp identity data aria role style].freeze
239
+
240
+ # HTML attribute names a caller passes through on purpose. Exempt from
241
+ # the near-miss guard so `for:` never reads as a typo of a `form`
242
+ # option, `size:` on a component without a size axis stays the native
243
+ # attribute, and so on. Only keys that are NOT declared options reach
244
+ # the guard, so a declared option of the same name is untouched.
245
+ HTML_ATTRIBUTE_KEYS = %w[
246
+ accept accesskey action alt autocapitalize autocomplete autofocus checked cite cols colspan
247
+ contenteditable crossorigin datetime decoding dir dirname disabled download draggable enctype
248
+ enterkeyhint for form headers height hidden href hreflang inert inputmode is itemid itemprop
249
+ itemref itemscope itemtype label lang list loading max maxlength method min minlength multiple
250
+ name nonce novalidate open part pattern ping placeholder popover readonly referrerpolicy rel
251
+ required reversed rows rowspan scope selected size sizes slot span spellcheck src srcset start
252
+ step tabindex target title translate type value width wrap
253
+ ].freeze
254
+
148
255
  # The self-identification markup contract, the convention every
149
256
  # component follows: `data-component` on the component root maps live DOM
150
257
  # back to the component that rendered it - the hook agents, the
151
258
  # Verifier, and the browser-verification loop key on.
152
259
  #
260
+ # A component rendering as another's root passes identity: (the
261
+ # composition seam), and the root wears that name instead.
262
+ #
153
263
  # @return [Hash] e.g. { "data-component" => "button" }
154
264
  def component_data_attributes
155
- { "data-component" => self.class.component_title }
265
+ { "data-component" => (@identity || self.class.component_title).to_s }
156
266
  end
157
267
 
158
268
  # `data-slot` for a named part of the component's anatomy
@@ -224,6 +334,14 @@ module Poetry
224
334
  @webmcp = attributes[:webmcp] || attributes["webmcp"]
225
335
  attributes = attributes.except(:webmcp, "webmcp") unless @webmcp.nil?
226
336
 
337
+ # identity: is the composition seam for a component that renders AS
338
+ # another component's root (ToastTrigger renders as a Button): the
339
+ # inner root wears the outer's data-component. Universal like key:,
340
+ # never an HTML attribute - and the ONLY sanctioned way to set
341
+ # data-component (the raw attribute is reserved, see guard_passthrough).
342
+ @identity = attributes[:identity] || attributes["identity"]
343
+ attributes = attributes.except(:identity, "identity") unless @identity.nil?
344
+
227
345
  # Initialize a fresh Set for this instance
228
346
  self.registered_styles = Set.new
229
347
  self.registered_options = Set.new
@@ -249,12 +367,124 @@ module Poetry
249
367
  end
250
368
 
251
369
  @attributes = self.class._default_attributes.deep_dup
252
- html_attrs = attributes.with_indifferent_access.except(*attribute_names)
253
- @html_attributes = Poetry::Core::HTML::Attributes.new(html_attrs)
370
+ given = attributes.with_indifferent_access
371
+ names = attribute_names
372
+ @html_attributes = Poetry::Core::HTML::Attributes.new(guard_passthrough(given.except(*names)))
373
+ # The root merges classes with the kit's merger (its CSS mode's).
374
+ @html_attributes.classname_merger = classname_merger
375
+
376
+ assign_attributes given.slice(*names)
377
+ guard_declared_values
378
+ end
379
+
380
+ # The passthrough contract, enforced at the seam. A keyword that is not
381
+ # an option renders as an HTML attribute on the root (title:, tabindex:,
382
+ # colspan:) - so a NEAR-MISS of a declared option (varient:) would
383
+ # silently render a bogus attribute while the default applied. That
384
+ # raises in development and test with the did-you-mean the static check
385
+ # gives, and only logs in production (the render still succeeds).
386
+ # data-component is the component's own identity - the hook agents,
387
+ # the Verifier and the browser loop key on - and is never overridable:
388
+ # dropped in production, raised the same way in development and test.
389
+ # data-slot is NOT reserved: re-slotting an embedded root is the
390
+ # composition seam a part contract allows; re-identifying one goes
391
+ # through identity:, the sanctioned spelling.
392
+ #
393
+ # @param html_attrs [ActiveSupport::HashWithIndifferentAccess]
394
+ # @return [ActiveSupport::HashWithIndifferentAccess] the attributes, minus a reserved override
395
+ def guard_passthrough(html_attrs)
396
+ problems = []
397
+
398
+ if html_attrs.key?("data-component")
399
+ problems << "data-component is #{passthrough_owner}'s own identity attribute and is never overridable"
400
+ html_attrs = html_attrs.except("data-component")
401
+ end
402
+ data = html_attrs["data"]
403
+ if data.respond_to?(:key?) && data.key?("component")
404
+ problems << "data: { component: } is #{passthrough_owner}'s own identity attribute and is never overridable"
405
+ html_attrs["data"] = data.except("component")
406
+ end
254
407
 
255
- assign_attributes attributes.with_indifferent_access.slice(*attribute_names)
408
+ html_attrs.each_key do |key|
409
+ next if PASSTHROUGH_KEYS.include?(key) || HTML_ATTRIBUTE_KEYS.include?(key) || key.include?("-")
410
+
411
+ suggestion = option_suggestion(key)
412
+ next unless suggestion
413
+
414
+ problems << "#{passthrough_owner} has no option #{key}: (did you mean #{suggestion}:?) - " \
415
+ "unknown keywords render as HTML attributes"
416
+ end
417
+ return html_attrs if problems.empty?
418
+
419
+ raise ArgumentError, problems.join("; ") if strict_passthrough?
420
+
421
+ Rails.logger&.warn("poetry: #{problems.join("; ")}") if defined?(Rails) && Rails.respond_to?(:logger)
422
+ html_attrs
423
+ end
424
+
425
+ # The did-you-mean against this component's declared options and
426
+ # styles, the same checker the static check runs.
427
+ def option_suggestion(key)
428
+ require "did_you_mean"
429
+ DidYouMean::SpellChecker.new(dictionary: self.class.attribute_names.map(&:to_s)).correct(key.to_s).first
256
430
  end
257
431
 
432
+ # The component named in a passthrough problem - an anonymous class
433
+ # (a test double) has no path to title.
434
+ def passthrough_owner
435
+ self.class.name ? self.class.component_title : "this component"
436
+ end
437
+
438
+ # The runtime values tier, beside the passthrough guard: the declared
439
+ # vocabulary (`style ... variants:`, `required:`) is checked at
440
+ # construction, where every component - gem or app, whatever it does
441
+ # in before_render - passes. Off-list values raise in development and
442
+ # test with the allowed values and a did-you-mean; in production they
443
+ # log and render as before. `poetry check` catches the same mistakes
444
+ # in templates statically; this tier catches the value that arrives
445
+ # from data. A direct lookup over {.declared_values}; a component's
446
+ # custom validators keep their own policy.
447
+ def guard_declared_values
448
+ problems = nil
449
+ self.class.runtime_vocabulary.each do |name, spec|
450
+ value = public_send(name)
451
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
452
+ (problems ||= []) << "#{passthrough_owner} requires #{name}:" if spec[:required]
453
+ elsif spec[:variants] && !spec[:variants].include?(value)
454
+ (problems ||= []) << off_list_problem(name, value, spec[:variants])
455
+ end
456
+ end
457
+ return unless problems
458
+
459
+ message = problems.join("; ")
460
+ raise ArgumentError, message if strict_passthrough?
461
+
462
+ Rails.logger&.warn("poetry: #{message}") if defined?(Rails) && Rails.respond_to?(:logger)
463
+ end
464
+
465
+ # The message a developer reads for an off-list value: the declared
466
+ # vocabulary and a did-you-mean.
467
+ def off_list_problem(name, value, allowed)
468
+ "#{passthrough_owner} #{name}: #{value.inspect} is not one of " \
469
+ "#{allowed.map(&:inspect).join(", ")}#{variant_suggestion(value, allowed)}"
470
+ end
471
+
472
+ def variant_suggestion(value, allowed)
473
+ return "" if allowed.empty? || value.nil?
474
+
475
+ require "did_you_mean"
476
+ suggestion = DidYouMean::SpellChecker.new(dictionary: allowed.map(&:to_s)).correct(value.to_s).first
477
+ suggestion ? " (did you mean #{suggestion.to_sym.inspect}?)" : ""
478
+ end
479
+
480
+ # Raise (development, test) or log (everything else).
481
+ def strict_passthrough?
482
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
483
+ end
484
+
485
+ private :guard_passthrough, :option_suggestion, :passthrough_owner, :strict_passthrough?,
486
+ :guard_declared_values, :off_list_problem, :variant_suggestion
487
+
258
488
  # Returns all component attributes, ensuring proc defaults are evaluated.
259
489
  #
260
490
  # This method overrides ActiveModel's attributes method to trigger evaluation
@@ -326,15 +556,24 @@ module Poetry
326
556
 
327
557
  # Merges multiple class name values into a single string.
328
558
  #
329
- # Uses the configured classname merger (typically Tailwind Merge) to
330
- # intelligently combine CSS class names, handling conflicts and duplicates.
559
+ # Uses the kit's classname merger (Tailwind Merge in :tailwind mode,
560
+ # the BEM merger in :bem mode - {Poetry::Core::CSS::Modes.merger_for})
561
+ # to combine CSS class names, handling conflicts and duplicates.
331
562
  #
332
563
  # @param classnames [Array<String, nil>] class names to merge
333
564
  # @return [String] the merged class names
334
565
  # @example
335
566
  # classnames("text-red-500", "text-blue-500") # => "text-blue-500"
336
567
  def classnames(*classnames)
337
- self.class.config.classname_merger.merge(*classnames)
568
+ classname_merger.merge(*classnames)
569
+ end
570
+
571
+ # The class-name merger this component merges with: the one that
572
+ # matches its CSS mode ({Poetry::Core::CSS::Modes.merger_for}).
573
+ #
574
+ # @return [#merge]
575
+ def classname_merger
576
+ Poetry::Core::CSS::Modes.merger_for(css_mode)
338
577
  end
339
578
 
340
579
  # HTML-safe JSON for embedding in a `<script type="application/json">`
@@ -195,6 +195,7 @@ module Poetry
195
195
  # @param _type [Symbol] the attribute type (unused; kept for signature parity)
196
196
  # @param required [Boolean] whether the attribute is required
197
197
  def add_option_validations(name, _type, required)
198
+ record_declared_value(name, variants: nil, required: required)
198
199
  # Type validation is automatic via ActiveModel::Type
199
200
  validates name, presence: true if required
200
201
  end
@@ -53,6 +53,8 @@ module Poetry
53
53
  STYLE_CLASS_SUFFIX = "::Style"
54
54
 
55
55
  included do
56
+ # The kit-level mode declaration (`css_mode :bem`), inherited.
57
+ class_attribute :declared_css_mode, instance_accessor: false, default: nil
56
58
  class_attribute :registered_styles,
57
59
  instance_writer: true,
58
60
  instance_predicate: false,
@@ -60,6 +62,28 @@ module Poetry
60
62
  end
61
63
 
62
64
  class_methods do
65
+ # The CSS mode this component renders in, declared per kit
66
+ # (inherited by subclasses): `css_mode :bem` on a kit's base
67
+ # class, and every component in it emits the BEM token IR. With
68
+ # no argument, the resolved mode: the declaration, else the mode
69
+ # pinned for the namespace ({Poetry::Core::CSS::Modes} - poetry-ui
70
+ # pins `:tailwind`), else the global
71
+ # `Poetry::Core::Config.current.css_mode`. A `css_mode:` keyword
72
+ # on a `css` call still wins over all of them.
73
+ #
74
+ # @example A kit that brings its own CSS
75
+ # class Acme::Ui::Base < Poetry::Core::Component
76
+ # css_mode :bem
77
+ # end
78
+ # @param mode [Symbol, nil] :tailwind or :bem to declare; nil to read
79
+ # @return [Symbol] the declared or resolved mode
80
+ # @raise [Poetry::Core::Error] for an unknown mode
81
+ def css_mode(mode = nil)
82
+ return self.declared_css_mode = Poetry::Core::CSS::Modes.validate!(mode) unless mode.nil?
83
+
84
+ declared_css_mode || Poetry::Core::CSS::Modes.inherited_for(self) || Poetry::Core::Config.current.css_mode
85
+ end
86
+
63
87
  # Defines a style attribute for the component.
64
88
  #
65
89
  # @param name [Symbol, String] the name of the style attribute
@@ -145,7 +169,12 @@ module Poetry
145
169
  style_class_name = component_module + STYLE_CLASS_SUFFIX
146
170
  style_class_name.constantize
147
171
  rescue NameError => e
148
- log_style_class_not_found(style_class_name, e) if defined?(Rails)
172
+ # Once per class: a component with no dictionary (Icon) is a
173
+ # design, not a line per render in the development log.
174
+ unless @style_class_miss_logged
175
+ @style_class_miss_logged = true
176
+ log_style_class_not_found(style_class_name, e) if defined?(Rails)
177
+ end
149
178
  nil
150
179
  end
151
180
 
@@ -177,10 +206,14 @@ module Poetry
177
206
  # @param variants [Object] the allowed variants
178
207
  # @param required [Boolean] whether the attribute is required
179
208
  def add_style_validations(name, type, variants, required)
209
+ record_declared_value(name, variants: type == :boolean ? nil : variants, required: required,
210
+ open: type != :boolean && variants.nil?)
211
+ # nil is an omitted value, not an off-list one; `required` adds
212
+ # presence for the styles that may not be omitted.
180
213
  if type == :boolean
181
- validates name, inclusion: { in: [true, false] }
214
+ validates name, inclusion: { in: [true, false] }, allow_nil: true
182
215
  elsif variants
183
- validates name, inclusion: { in: variants }
216
+ validates name, inclusion: { in: variants }, allow_nil: true
184
217
  end
185
218
  validates name, presence: true if required
186
219
  end
@@ -274,7 +307,7 @@ module Poetry
274
307
  # @raise [Poetry::Core::Error] for a css_mode other than :tailwind
275
308
  # or :bem
276
309
  def css(element = nil, **options, &)
277
- mode = options.delete(:css_mode) || Poetry::Core::Config.current.css_mode
310
+ mode = options.delete(:css_mode) || css_mode
278
311
 
279
312
  case mode
280
313
  when :tailwind
@@ -288,10 +321,18 @@ module Poetry
288
321
  extra = options.delete(:class)
289
322
  [bem(element, **options), extra].compact.join(" ")
290
323
  else
291
- raise Poetry::Core::Error, "unknown css_mode #{mode.inspect} (expected :tailwind or :bem)"
324
+ Poetry::Core::CSS::Modes.validate!(mode)
292
325
  end
293
326
  end
294
327
 
328
+ # The CSS mode this instance renders in (the class's resolved mode;
329
+ # see the class-level `css_mode`).
330
+ #
331
+ # @return [Symbol] :tailwind or :bem
332
+ def css_mode
333
+ self.class.css_mode
334
+ end
335
+
295
336
  # The component's BEM block name - the stable, framework-agnostic
296
337
  # class contract of the token IR ("poetry/core/dot" -> "poetry-core-dot").
297
338
  #
@@ -27,7 +27,38 @@ module Poetry
27
27
  #
28
28
  # @return [CSS::Resolver]
29
29
  def resolver
30
- @resolver ||= superclass.respond_to?(:resolver) ? superclass.resolver.dup : CSS::Resolver.new
30
+ @resolver ||= begin
31
+ dictionary = superclass.respond_to?(:resolver) ? superclass.resolver.dup : CSS::Resolver.new
32
+ dictionary.owner = self
33
+ dictionary
34
+ end
35
+ end
36
+
37
+ # The class merger for this dictionary's mode: the sidecar
38
+ # component's resolved `css_mode` (its own declaration, its kit's
39
+ # pin, or the global), else this class's own namespace pin, else the
40
+ # global mode - so `Acme::Pill::Style` under a `:bem` pin merges like
41
+ # BEM while poetry-ui's dictionaries merge like Tailwind whatever the
42
+ # host set globally.
43
+ #
44
+ # @return [Object] a class-name merger
45
+ def merger
46
+ component = sidecar_component
47
+ mode = if component.respond_to?(:css_mode)
48
+ component.css_mode
49
+ else
50
+ CSS::Modes.inherited_for(self) || Poetry::Core::Config.current.css_mode
51
+ end
52
+ CSS::Modes.merger_for(mode)
53
+ end
54
+
55
+ # The `X::Component` beside this `X::Style`, when it exists.
56
+ def sidecar_component
57
+ return nil unless name&.end_with?("::Style")
58
+
59
+ Object.const_get("#{name.delete_suffix("::Style")}::Component")
60
+ rescue NameError
61
+ nil
31
62
  end
32
63
 
33
64
  # -- The dictionary DSL (delegates to the resolver) --------------------
@@ -49,6 +49,10 @@ export default class DrawerController extends DialogController {
49
49
  }
50
50
 
51
51
  #swipe = null
52
+ // Where the last press landed (inside the panel or on the backdrop) -
53
+ // the click that follows a press is never a backdrop dismissal when the
54
+ // press started inside, however far the pointer travelled since.
55
+ #pressInside = false
52
56
  #closing = false
53
57
  #snapIndex = 0
54
58
 
@@ -114,6 +118,29 @@ export default class DrawerController extends DialogController {
114
118
  })
115
119
  }
116
120
 
121
+ /**
122
+ * The inherited backdrop click, minus the ghost: a press that started
123
+ * inside the panel can end over the backdrop (a drag against the
124
+ * clamp, a slip off an inner control) and the click the browser then
125
+ * fires is a backdrop click by target and coordinates, never by intent.
126
+ *
127
+ * @param {MouseEvent} event
128
+ */
129
+ backdropClose(event) {
130
+ if (this.#pressInside) return
131
+
132
+ super.backdropClose(event)
133
+ }
134
+
135
+ // The dialog's own inside test (a press on the backdrop targets the
136
+ // <dialog> too - only the rect tells them apart).
137
+ #insidePanel(event) {
138
+ const rect = this.dialogTarget.getBoundingClientRect()
139
+
140
+ return rect.top <= event.clientY && event.clientY <= rect.bottom &&
141
+ rect.left <= event.clientX && event.clientX <= rect.right
142
+ }
143
+
117
144
  // --- the swipe -----------------------------------------------------------
118
145
 
119
146
  /**
@@ -124,6 +151,7 @@ export default class DrawerController extends DialogController {
124
151
  * @param {PointerEvent} event
125
152
  */
126
153
  swipeStart(event) {
154
+ this.#pressInside = this.#insidePanel(event)
127
155
  if (event.button !== 0 && event.pointerType === "mouse") return
128
156
  if (this.#closing) return
129
157