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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +0 -4
- data/app/components/poetry/core/component.rb +246 -7
- data/app/components/poetry/core/concerns/options.rb +1 -0
- data/app/components/poetry/core/concerns/styles.rb +46 -5
- data/app/components/poetry/core/style.rb +32 -1
- data/app/javascript/poetry/core/drawer_controller.js +28 -0
- data/app/javascript/poetry/core/hover_card_controller.js +26 -11
- data/app/javascript/poetry/core/message_scroller_controller.js +82 -4
- data/app/javascript/poetry/core/navigation_menu_controller.js +59 -4
- data/app/javascript/poetry/core/number_field_controller.js +24 -14
- data/config/component_registry.yml +2 -0
- data/config/controllers_manifest.json +2 -3
- data/lib/poetry/core/check/stable_identity.rb +14 -1
- data/lib/poetry/core/check.rb +182 -41
- data/lib/poetry/core/config.rb +15 -5
- data/lib/poetry/core/css/modes.rb +127 -0
- data/lib/poetry/core/css/override_scan.rb +17 -2
- data/lib/poetry/core/css/resolver.rb +21 -4
- data/lib/poetry/core/css/token_collisions.rb +184 -0
- data/lib/poetry/core/engine.rb +22 -0
- data/lib/poetry/core/host_components.rb +159 -0
- data/lib/poetry/core/host_helpers.rb +110 -0
- data/lib/poetry/core/html/attributes.rb +33 -2
- data/lib/poetry/core/llms_text.rb +39 -7
- data/lib/poetry/core/registry.rb +154 -5
- data/lib/poetry/core/skill_text.rb +27 -1
- data/lib/poetry/core/stimulus/declarations.rb +8 -3
- data/lib/poetry/core/stimulus/host_manifest.rb +556 -0
- data/lib/poetry/core/stimulus/manifest.rb +24 -0
- data/lib/poetry/core/tokens/generator.rb +10 -1
- data/lib/poetry/core/version.rb +1 -1
- data/tokens/tailwind-theme.css +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68f26084516bebd43571b5f687d9fb07cce952f00aba8e41448715b66b996fba
|
|
4
|
+
data.tar.gz: d1ec44154a85d4c2b4fda384432f2e73349973dfc21862aeddf06bde0d09303b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 65965b0865ade83c8b778c3ba773c6d1272bd4da2139e68cd8ab5b798903d12e3224d75df0f77f849cd72403e782d5da87ba470a4597d8654c7ab72a6bdc90ea
|
|
7
|
+
data.tar.gz: e3b822469b4ed1253193dd331b9b43b7923aa5626265ec9f6d1cdc6c4112a7306eb93e37f3a7670b9985da406ef804b5eb4b5174b03263004eced90e306e03c3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
330
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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 ||=
|
|
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
|
|