poetry-ui 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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/README.md +2 -2
  4. data/THIRD_PARTY_NOTICES.md +29 -0
  5. data/app/components/poetry/ui/combobox/component.rb +4 -1
  6. data/app/components/poetry/ui/command/component.rb +3 -2
  7. data/app/components/poetry/ui/context_menu/component.rb +4 -1
  8. data/app/components/poetry/ui/dialog/component.rb +1 -1
  9. data/app/components/poetry/ui/drawer/component.rb +3 -0
  10. data/app/components/poetry/ui/dropdown_menu/component.rb +5 -1
  11. data/app/components/poetry/ui/hover_card/component.rb +3 -2
  12. data/app/components/poetry/ui/menubar/component.rb +4 -3
  13. data/app/components/poetry/ui/menus.rb +21 -2
  14. data/app/components/poetry/ui/message_scroller/component.rb +28 -4
  15. data/app/components/poetry/ui/message_scroller/style.rb +7 -1
  16. data/app/components/poetry/ui/navigation_menu/component.rb +26 -10
  17. data/app/components/poetry/ui/number_field/component.rb +0 -1
  18. data/app/components/poetry/ui/select/component.rb +4 -2
  19. data/app/components/poetry/ui/toast_trigger/component.rb +4 -3
  20. data/app/controllers/poetry/ui/llms_controller.rb +5 -1
  21. data/app/helpers/poetry/ui/form_builder/type_inference.rb +5 -3
  22. data/app/helpers/poetry/ui/form_builder.rb +3 -0
  23. data/config/component_registry.yml +124 -12
  24. data/lib/generators/poetry/agents_section.rb +58 -21
  25. data/lib/generators/poetry/install/install_generator.rb +226 -16
  26. data/lib/generators/poetry/scaffold_templates/scaffold_templates_generator.rb +5 -3
  27. data/lib/generators/poetry/scaffold_templates/templates/_form.html.erb.tt +10 -56
  28. data/lib/generators/poetry/skill/templates/poetry-component/SKILL.md +25 -0
  29. data/lib/generators/poetry/skill/templates/poetry-design/references/audit.md +5 -3
  30. data/lib/generators/poetry/skills_section.rb +33 -5
  31. data/lib/poetry/ui/chat.rb +5 -0
  32. data/lib/poetry/ui/reset_floor.rb +199 -0
  33. data/lib/poetry/ui/themes.rb +1 -1
  34. data/lib/poetry/ui/version.rb +1 -1
  35. data/lib/poetry/ui.rb +51 -6
  36. data/lib/tasks/poetry/check.rake +71 -15
  37. data/lib/tasks/poetry/design.rake +12 -1
  38. data/lib/tasks/poetry/registry.rake +22 -0
  39. data/lib/tasks/poetry/stimulus.rake +29 -0
  40. data/lib/tasks/poetry/verify.rake +18 -1
  41. data/reset/reset.css +154 -0
  42. data/themes/default.css +0 -11
  43. data/themes/luma.css +1 -12
  44. data/themes/lyra.css +1 -12
  45. data/themes/maia.css +1 -12
  46. data/themes/mira.css +1 -12
  47. data/themes/nova.css +1 -12
  48. data/themes/rhea.css +1 -12
  49. data/themes/sera.css +1 -12
  50. data/themes/vega.css +1 -12
  51. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1346a1ff6a62e643e9440c7c42e835d643b55ea2dbcf8bc7938d800964995306
4
- data.tar.gz: a569d21afd89f4f611309bab9c05492bc47d1ff4e9997d1178bcfd8859c840de
3
+ metadata.gz: cfaee0a24a3a61b10a2bf12f2afb0dd5a0b8f93117392f8deb4fbb81da36c984
4
+ data.tar.gz: bf5011451e6d6bd7fe2c7bed1ebc1150f3503d04fc04687f8bd0463d6a1612e4
5
5
  SHA512:
6
- metadata.gz: '01872acf5407b8d3b14bb91c10f33f19b9fca4124f39338d8f88560c86bb6d41913ff50b92ef3dfa3293f4d14500fd3faa67395109a6d6f41a16412ab80c26ed'
7
- data.tar.gz: 4180cbf5784bc923d57da1394c91542959570209ae1f8beb89e68d31e1c80f7ec5c0809fc65f363f6c6b3b025e72851f0ec08614b2020e94bee5df44ca6ccea0
6
+ metadata.gz: 11db0fc73021f75632fbf927e3f807043c7091eeaa049b5d58b90561910e2d521a94abe0f871333484f9f0292e3cc2f25c262c578d4dfb5d43a12591cb33159a
7
+ data.tar.gz: d27e5cb320b82e7f40fca65b3e3898350a5ec6ac8778c88b95fb7f8a11fca29861783a1bc016322e5dab9a583b82586fccc7fd48d905f4f0603029fed700f866
data/CHANGELOG.md CHANGED
@@ -1,6 +1,72 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.1.2] - 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - The install names the theme it puts in the slot: on a first install or an explicit `--theme`, one line says which theme fills `poetry/style-default.css`, that the slot name never changes, and that the file's first line names the theme. The Theming and Installation guides say the same, for the reader who opens a file called default and finds vega.
8
+
9
+ - `poetry:install` and `poetry:skill` say so when the host gitignores `.claude/skills`: the skills still serve that checkout, and the note names the refresh command teammates and CI run (`bin/rails g poetry:skill`) or the alternative of un-ignoring the directory. Asked of git only; no git, no repository, or a tracked directory stay silent.
10
+
11
+ - App components are first-class on the booted surfaces. A component the app defines on the DSL with `helper :name` renders through that helper, `poetry:check` lints it under that name with its own contract, `/poetry/llms.txt` and `llms-full.txt` list it under an App components section with its agent rules, and `poetry:skill` writes a `references/app.md` with a menu line. The AGENTS.md section and the poetry-component skill teach the declaration. The MCP server's `check` tool learns the declared helpers from source, so it agrees the helper exists; its contracts and the runtime skill map stay gem-only (boot-free by design).
12
+
13
+ - `poetry:install --no-preflight`, for an app that imports Tailwind without preflight: vendors the reset floor (`poetry/reset.css`, Tailwind's preflight at zero specificity, generated from the pinned binary and gated against it) into `layer(base)` ahead of the theme, and on a fresh entry writes the split no-preflight import. A plain re-run keeps the floor once installed. Measured against the golden corpus: without preflight two thirds of the components render differently even behind a modern reset, so the floor is what keeps them whole; `POETRY_PREFLIGHT=0 rake test:visual` proves it. The installer and `poetry:check` warn (`preflight-missing`) when an entry imports neither preflight nor the floor.
14
+ - `poetry:design:overrides` counts only a rule against a theme-owned `cn-*` name (a gem dictionary's or the installed fragment's own) as an override. A host kit's own `cn-*` dictionary, or a page helper class in that namespace, is no longer reported as undeclared drift; a blanket declaration written to silence those now shows as stale.
15
+ - `bin/rails poetry:stimulus:manifest` writes the app's controllers manifest (`config/controllers_manifest.json`, the file a gem commits) from `app/javascript/controllers`; registered at boot, a host controller validates like poetry's (the DSL by Symbol at class load, template wiring in `poetry:check`, its API in the registry). A controller the reader cannot describe is named and stays unvalidated until its entry is written by hand in the same file, where regeneration keeps it. `poetry:check` warns (`manifest-stale`) and `poetry:verify` fails when the entries the reader produced no longer match the sources; hand-written entries never count.
16
+ - `bin/rails poetry:registry` writes the app's own components to `config/component_registry.yml`, the path a gem's registry lives at, so the MCP server and the runtime skill find them boot-free with full contracts and agent rules. Opt-in; once written, `poetry:check` warns (`registry-stale`) and `poetry:verify` fails when the file no longer matches the classes.
17
+
18
+ ### Changed
19
+
20
+ - The reset floor's header and the Installation guide say exactly what wins: an unlayered host rule always, a host rule in `layer(base)` when it comes after the floor's import, and `[hidden]` keeps preflight's `!important` on purpose. `poetry:verify` names the remedy when a kit's own `cn-*` class is not in the compiled CSS. The AGENTS.md section spells JSON output as `POETRY_CHECK_JSON=1`.
21
+
22
+ - The upgrade path holds on real hosts: `--charts` is remembered by the vendored `poetry/charts.css`, so a plain re-run refreshes the chart files and a theme switch swaps the chart fragment with the theme; the superseded 0.1.1 tokens import converges however it was written (indented, annotated, or with a carriage return, which it keeps); every `eval_gemfile` spelling is read before `herb` is added; the Claude Code skills are refreshed like the vendored CSS, without an overwrite prompt (`--skip` keeps a hand-edited one); an AGENTS.md begin marker without its end is an error naming the file, a byte-identical section reports `identical`, the section is written in the file's own line endings, and only the first section is replaced.
23
+
24
+ - `poetry:check` hands the runner the app root, so the mailer exemption and the design tier judge a template by its path inside the app; an unreadable file is a warning in the report, not a crash of the task.
25
+
26
+ - `poetry:check` reports `registry-invalid` when `config/component_registry.yml` exists but is not a registry (empty, malformed, the wrong shape), naming `bin/rails poetry:registry` as the fix; the committed registry regenerates with every helper named.
27
+
28
+ - The scaffold form template is the form builder's: `form_with(model:, builder: Poetry::Ui::FormBuilder)` and one `f.input` per attribute, the control inferred from the column, the name and the validations, with the template adding only what the generator knows and the model cannot say (`as: :text` for rich text, `multiple: true` for many attachments, `required: true` from `null: false`). The gem's reference CRUD form now follows the gem's own first rule about forms. A host that installed the templates before keeps its copy (re-runs never overwrite); delete `lib/templates/erb/scaffold/_form.html.erb.tt` and re-run `poetry:scaffold_templates` for the builder version. On the way, two inference gaps in the builder: `has_many_attached` attributes infer a file input (the plural responder), and `website` / `permalink` infer a URL input like `url` does.
29
+
30
+ - The form builder's agent rules say which parts a builder field renders (`field-label`, `field-description` for the hint, `field-error`), so llms.txt's Forms section, the registry, the MCP server and the skill all name the slots a wiring test asserts on; the Testing guide says the same.
31
+
32
+ - `poetry:check` treats mailer templates as email, not pages: under a `*_mailer/` directory or in the mailer layout the raw-color rule stays quiet (an inline hex is the only paint a mail client honours) and the design tier skips them; every other rule runs there as before. The AGENTS.md section says so.
33
+
34
+ - An off-list variant or a missing required option now raises at construction in development and test (poetry-core's runtime values tier) instead of rendering unstyled; the AGENTS.md section says so beside the check.
35
+ - poetry-ui pins its components to `css_mode :tailwind`, and the class-name merger follows: a host's global `BemMerger` (for a kit of its own) never reaches poetry-ui's utility conflict resolution. A host's global `css_mode = :bem` (for a kit of its own on the DSL) no longer strips poetry-ui's styling; the two render side by side. The seeded initializer's comment says so instead of offering `:bem` as a poetry-ui option.
36
+ - `poetry:check` and the AGENTS.md census find registry roots by convention (every loaded engine with a published registry, then the app's own file) and name no gem; poetry-charts is no longer special-cased, and any engine built on the DSL that commits a registry joins the check the same way.
37
+ - `poetry:install` imports `tokens.css` into `layer(theme)`, so a token the host already declares (`--primary`, `--accent`, `--muted`, ...) keeps its value whatever the order in the Tailwind entry, and that value now reaches Poetry's components too. Before, the appended import landed after the host's own `:root` and took the same names over: an app's brand color vanished before a single Poetry component rendered. A re-run rewrites the earlier unlayered import line in place.
38
+ - `poetry:install` reports, before writing anything, every Poetry token name and theme key the app's stylesheets already declare, with the file and line and what Poetry paints with that role. It never blocks. `poetry:check` repeats the report as `token-collision` warnings.
39
+
40
+ - The WebMCP agent-focus rules (`:tool-form-active`, `:tool-submit-active`) leave the nine theme fragments, where they were identical, for poetry-agent's own stylesheet; `poetry:install` vendors it into `layer(base)` when that gem is bundled. A host without poetry-agent no longer carries them, nor the two warnings the CSS optimizer printed for the origin-trial pseudo-classes on every minified build (the Rails Tailwind task minifies by default, so every build and watch run).
41
+
42
+ ### Fixed
43
+
44
+ - `poetry:install --force` no longer rewrites the four files the install seeds once for the host to own (`config/poetry_components.yml`, the initializer, `poetry/base.css`, `poetry/typeset.css`); a re-run with the flag used to reset a host's override declarations and settings to the seed. The vendored artifacts refresh on every run, so the flag is never needed on an upgrade.
45
+ - `poetry:install` no longer adds a second `herb` declaration when the gem is declared in a file the Gemfile pulls in with `eval_gemfile` (a shared Gemfile).
46
+
47
+ ## [0.1.1] - 2026-09-08
48
+
49
+ ### Added
50
+
51
+ - `content_class:` on `poetry_dropdown_menu` and `poetry_context_menu`, on Menubar's `with_menu`, and on every menu family's `with_sub`: the panel's class merge seam, the one Popover, HoverCard and Dialog already had. A dropdown panel opens at its trigger's width (minimum 8rem), so `content_class: "w-56"` is how a menu with icons, shortcuts or long labels gets its width.
52
+ - NavigationMenu `with_item(title, value:, disabled: true)`: an inert trigger (`disabled` + `data-disabled`) that hover, click and the arrows skip. `disabled:` on a link raises.
53
+ - `Poetry::Ui::Chat::Error`, a `Poetry::Core::Error`: `AssistantTurn#continuation_frames` raises it on a turn without an approval pause, where a missing constant raised NameError before.
54
+ - `poetry:install --skip-bundle`.
55
+
56
+ ### Changed
57
+
58
+ - `poetry:install` adds the `herb` gem to the development group (once, never when the Gemfile already declares it) and runs `bundle install`, so `bin/rails poetry:check` works on a fresh app with no manual step. `poetry:check`'s missing-parser message points at the installer.
59
+ - The upstream pin is shadcn@4.21.0. The theme CSS is unchanged between the releases; both fidelity ledgers are re-snapshotted at the new pin.
60
+ - MessageScroller renders `data-pending-scroll` on its root and viewport for `:end` and `:"last-anchor"`; the viewport hides while it is set (visibility, so the layout stays) and the hold lifts without JavaScript. The part contracts declare the state.
61
+ - Select, Combobox and Command group headings are `aria-hidden`: the group's name flows through `aria-labelledby`, so screen readers stop announcing the heading a second time. Menu groups wire `aria-labelledby` to a label rendered inside them and hide it the same way; a label outside any group stays plain text.
62
+ - HoverCard's trigger wiring is `pointerdown->pointerDown` (was `touchstart->touchGuard`), and NumberField's input no longer wires a `focus` action. A copy made with `poetry:add hover_card` or `poetry:add number_field` before this version carries the old wiring and logs a Stimulus "undefined method" error on those events: run `bin/rails g poetry:diff` and update the two lines. Apps on the gem-owned tier need nothing.
63
+ - ToastTrigger names its Button root through `identity:`.
64
+ - NavigationMenu's agent rules state the keyboard contract: ArrowLeft/ArrowRight between triggers and links, ArrowDown opens the focused trigger's panel, Escape closes.
65
+
66
+ ### Fixed
67
+
68
+ - HoverCard: a tap on the trigger clicks through on touch devices (the collapsed sidebar's icons did nothing on a tablet).
69
+ - NumberField: Tab's select-all and a click's caret placement stay native.
4
70
 
5
71
  ## [0.1.0] - 2026-09-05
6
72
 
data/README.md CHANGED
@@ -59,8 +59,8 @@ bin/rails g poetry:add <name> # adds newly-shipped files, never overwrites
59
59
  ```
60
60
 
61
61
  And `rails g scaffold` itself can produce poetry-composed views — a
62
- DataTable index with sanitized URL state, Field-composed forms, and a
63
- matching controller — by installing the app-owned template overrides once:
62
+ DataTable index with sanitized URL state, forms on the poetry form
63
+ builder, and a matching controller — by installing the app-owned template overrides once:
64
64
 
65
65
  ```bash
66
66
  bin/rails g poetry:scaffold_templates
@@ -33,6 +33,35 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
33
  SOFTWARE.
34
34
  ```
35
35
 
36
+ ## Tailwind CSS
37
+
38
+ - Source: https://github.com/tailwindlabs/tailwindcss (MIT)
39
+ - `reset/reset.css` (the reset floor `poetry:install --no-preflight` vendors) is preflight, generated from the pinned tailwindcss binary with every selector wrapped in `:where()`
40
+
41
+ ```
42
+ MIT License
43
+
44
+ Copyright (c) Tailwind Labs, Inc.
45
+
46
+ Permission is hereby granted, free of charge, to any person obtaining a copy
47
+ of this software and associated documentation files (the "Software"), to deal
48
+ in the Software without restriction, including without limitation the rights
49
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
50
+ copies of the Software, and to permit persons to whom the Software is
51
+ furnished to do so, subject to the following conditions:
52
+
53
+ The above copyright notice and this permission notice shall be included in all
54
+ copies or substantial portions of the Software.
55
+
56
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
57
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
58
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
59
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
60
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
61
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
62
+ SOFTWARE.
63
+ ```
64
+
36
65
  ## Base UI
37
66
 
38
67
  - Source: https://github.com/mui/base-ui (MIT)
@@ -236,9 +236,12 @@ module Poetry
236
236
  "#{group_id}-heading"
237
237
  end
238
238
 
239
+ # The group's name flows through aria-labelledby; aria-hidden keeps
240
+ # the text from being read a second time as a stray child of the
241
+ # listbox.
239
242
  def heading_part
240
243
  content_tag(:div, @heading_text, "data-slot" => "combobox-label", "id" => heading_id,
241
- "class" => Style.css(:label))
244
+ "aria-hidden" => "true", "class" => Style.css(:label))
242
245
  end
243
246
  end
244
247
 
@@ -207,10 +207,11 @@ module Poetry
207
207
  end
208
208
 
209
209
  # A styled heading, no ARIA role - the group points at it via
210
- # aria-labelledby.
210
+ # aria-labelledby, and aria-hidden keeps the text from being read a
211
+ # second time as a stray child of the listbox.
211
212
  def heading_part
212
213
  content_tag(:div, @heading_text, "data-slot" => "command-group-heading", "id" => heading_id,
213
- "class" => Style.css(:heading))
214
+ "aria-hidden" => "true", "class" => Style.css(:heading))
214
215
  end
215
216
  end
216
217
 
@@ -135,6 +135,9 @@ module Poetry
135
135
  option :loop, :boolean, default: false, doc: "Wraps arrow-key movement past either end of the menu."
136
136
  option :long_press_delay, :integer, default: 700, doc: "Touch long-press duration in ms before the menu opens."
137
137
  option :disabled, :boolean, default: false, doc: "Inerts the surface - no gesture opens the menu."
138
+ option :content_class, :string,
139
+ doc: "Class merge seam for the menu panel (e.g. content_class: \"w-48\"). Root-level " \
140
+ "class: styles the surface wrapper, not the panel."
138
141
  option :label, :string, doc: "The menu's accessible name (localized fallback when omitted)."
139
142
  option :focusable_surface, :boolean, default: false,
140
143
  doc: "Puts the surface in the tab order and advertises Shift+F10."
@@ -278,7 +281,7 @@ module Poetry
278
281
  "data-slot" => "context-menu-content", (open ? "data-open" : "data-closed") => "",
279
282
  # The initial placement (side: option; re-resolved live by popper).
280
283
  "data-side" => side.to_s, "data-align" => "start",
281
- "class" => css(:content)
284
+ "class" => css(:content, class: content_class)
282
285
  }.merge(stimulus_attributes_for(:content))
283
286
  attrs["hidden"] = true unless open
284
287
  attrs
@@ -169,7 +169,7 @@ module Poetry
169
169
  # block--modifier tokens ARE the host's styling contract.
170
170
  # @api private
171
171
  def html_attributes
172
- return super unless Poetry::Core::Config.current.css_mode == :tailwind
172
+ return super unless css_mode == :tailwind
173
173
 
174
174
  @html_attributes.merge(class: classnames(@html_attributes[:class]))
175
175
  end
@@ -142,6 +142,9 @@ module Poetry
142
142
  tool :open, description: "Open the drawer.", executes: :open, mutating: true
143
143
  tool :close, description: "Close the drawer.", executes: :close, mutating: true
144
144
 
145
+ # The inherited render checks, then the snap-point rules when
146
+ # snap_points: is given.
147
+ # @api private
145
148
  def before_render
146
149
  super
147
150
  validate_snap_points! if snap_points.present?
@@ -143,6 +143,10 @@ module Poetry
143
143
  option :loop, :boolean, default: false, doc: "Arrow-key navigation wraps from the last item back to the first."
144
144
  option :dir, :symbol, doc: "Reading direction; :rtl flips submenu sides and indicators."
145
145
  option :disabled, :boolean, default: false, doc: "Disables the menu trigger button."
146
+ option :content_class, :string,
147
+ doc: "Class merge seam for the menu panel - the panel opens at the trigger's width " \
148
+ "(min 8rem), so content_class: \"w-56\" widens it. Root-level class: styles the " \
149
+ "wrapper, not the panel."
146
150
 
147
151
  validates :side, inclusion: { in: SIDES }
148
152
  validates :align, inclusion: { in: ALIGNS }
@@ -272,7 +276,7 @@ module Poetry
272
276
  "data-slot" => "dropdown-menu-content", (open ? "data-open" : "data-closed") => "",
273
277
  # Initial placement, re-resolved live by popper on open.
274
278
  "data-side" => side, "data-align" => align,
275
- "class" => css(:content)
279
+ "class" => css(:content, class: content_class)
276
280
  }.merge(stimulus_attributes_for(:content))
277
281
  attrs["hidden"] = true unless open
278
282
  attrs
@@ -95,14 +95,15 @@ module Poetry
95
95
  end
96
96
  # The trigger handlers: pointerenter/leave pair
97
97
  # timers (touch excluded), focus opens immediately / blur closes,
98
- # and the touchstart guard (a tap navigates, never focus-opens).
98
+ # and the pointerdown latch (a tap or click never focus-opens; the
99
+ # tap keeps its click, so it navigates).
99
100
  on :trigger do
100
101
  controller :hover_card do
101
102
  action :pointer_enter, on: :pointerenter
102
103
  action :pointer_leave, on: :pointerleave
103
104
  action :focus_open, on: :focus
104
105
  action :blur_close, on: :blur
105
- action :touch_guard, on: :touchstart
106
+ action :pointer_down, on: :pointerdown
106
107
  end
107
108
  controller(:popper) { target :anchor }
108
109
  end
@@ -73,7 +73,7 @@ module Poetry
73
73
  renders_many :menus,
74
74
  doc: "The top-level menus. Each takes with_trigger (the menu button) plus the family item " \
75
75
  "slots (with_item, with_checkbox_item, with_radio_group, with_sub, with_separator, ...); " \
76
- "value: defaults to the menu's position.",
76
+ "value: defaults to the menu's position; content_class: is the panel's class merge seam.",
77
77
  renders: ->(**options) { Menu.new(bar: self, dir: dir, **options) }
78
78
 
79
79
  use_stimulus do
@@ -341,11 +341,12 @@ module Poetry
341
341
  content_tag(:button, Poetry::Core::HTML::Attributes.merged(attrs, options)) { capture(&block) }
342
342
  }
343
343
 
344
- def initialize(bar:, value: nil, disabled: false, dir: nil, **extra_attributes)
344
+ def initialize(bar:, value: nil, disabled: false, dir: nil, content_class: nil, **extra_attributes)
345
345
  super(extra_attributes)
346
346
  @bar = bar
347
347
  @disabled = disabled
348
348
  @dir = dir
349
+ @content_class = content_class
349
350
  position = @bar.register_menu(self)
350
351
  @value = (value || "menu-#{position}").to_s
351
352
  end
@@ -419,7 +420,7 @@ module Poetry
419
420
  "data-slot" => "menubar-content", (open? ? "data-open" : "data-closed") => "",
420
421
  # Initial placement, re-resolved live by popper on open.
421
422
  "data-side" => "bottom", "data-align" => "start",
422
- "class" => Style.css(:content)
423
+ "class" => Style.css(:content, class: @content_class)
423
424
  }.merge(stimulus_attributes(:popper) { |popper| popper.with_target(:content) })
424
425
  attrs["hidden"] = true unless open?
425
426
  content_tag(:div, attrs) { safe_join(items.map(&:to_s)) }
@@ -226,11 +226,28 @@ module Poetry
226
226
 
227
227
  def call
228
228
  attrs = { "data-slot" => "#{family_slot_prefix}-group", "role" => "group" }
229
+ attrs["aria-labelledby"] = label_id if @labelled
229
230
  content_tag(:div, Poetry::Core::HTML::Attributes.merged(attrs, html_attributes)) { safe_join(items.map(&:to_s)) }
230
231
  end
231
232
 
232
233
  private
233
234
 
235
+ # A label inside a group names the group (aria-labelledby) and is
236
+ # aria-hidden, so its text is not read a second time as a stray child
237
+ # of the menu; a label outside any group stays plain text.
238
+ def label_part(**, &)
239
+ @labelled = true
240
+ super("id" => label_id, "aria-hidden" => "true", **, &)
241
+ end
242
+
243
+ def group_id
244
+ @group_id ||= poetry_instance_id("poetry-#{family_slot_prefix}-group")
245
+ end
246
+
247
+ def label_id
248
+ "#{group_id}-label"
249
+ end
250
+
234
251
  def menu_dir
235
252
  @dir
236
253
  end
@@ -311,9 +328,11 @@ module Poetry
311
328
  end
312
329
  }
313
330
 
314
- def initialize(dir: nil, **extra_attributes)
331
+ # content_class: is the sub-panel's class merge seam (with_sub(content_class: "w-40")).
332
+ def initialize(dir: nil, content_class: nil, **extra_attributes)
315
333
  super(extra_attributes)
316
334
  @dir = dir
335
+ @content_class = content_class
317
336
  end
318
337
 
319
338
  def before_render
@@ -363,7 +382,7 @@ module Poetry
363
382
  "id" => content_id, "role" => "menu", "aria-orientation" => "vertical",
364
383
  "aria-labelledby" => trigger_id, "tabindex" => "-1",
365
384
  "data-slot" => "#{family_slot_prefix}-sub-content", "data-closed" => "", "hidden" => true,
366
- "class" => family_style.css(:sub_content)
385
+ "class" => family_style.css(:sub_content, class: @content_class)
367
386
  }.merge(stimulus_attributes(:popper) { |popper| popper.with_target(:content) })
368
387
  content_tag(:div, attrs) { safe_join(items.map(&:to_s)) }
369
388
  end
@@ -9,6 +9,16 @@ module Poetry
9
9
  # a jump-to-latest button. Without JavaScript it is a plain
10
10
  # scrollable region, fully readable.
11
11
  #
12
+ # Opening position: a scroll container always opens at the top, so a
13
+ # transcript opening at the newest message would paint the oldest
14
+ # rows first and jump once the controller runs. For :end and
15
+ # :"last-anchor" the root and the viewport render
16
+ # data-pending-scroll; the viewport's dictionary hides it while the
17
+ # attribute is present (visibility, so layout exists for the scroll)
18
+ # and the controller clears it once the position lands, or at once
19
+ # for an empty transcript. Without JavaScript the hold is lifted
20
+ # (a scripting: none media rule), so the region stays readable.
21
+ #
12
22
  # The content element is the Turbo Stream append target (stable
13
23
  # dom id "<id>-messages"); rows are poetry_message_scroller_item
14
24
  # wrappers keyed by message id. History prepends keep the reading
@@ -92,14 +102,20 @@ module Poetry
92
102
  "data-scrollable" => "overflow exists - carries which edges have room " \
93
103
  "(start, end, or both as a space-separated pair)",
94
104
  "data-autoscrolling" => "a programmatic scroll is settling - the " \
95
- "follow-bottom release is suppressed while set"
105
+ "follow-bottom release is suppressed while set",
106
+ "data-pending-scroll" => "the opening-position hold - rendered for " \
107
+ "default_scroll_position :end / :\"last-anchor\", " \
108
+ "cleared once the position lands (at once for an " \
109
+ "empty transcript)"
96
110
  }
97
111
  part "message-scroller-viewport", "The native scroll region (role=region, " \
98
112
  "focusable) - the controller mirrors the same " \
99
113
  "runtime attributes here",
100
114
  states: {
101
115
  "data-scrollable" => "overflow exists - the same edge tokens as the root",
102
- "data-autoscrolling" => "a programmatic scroll is settling"
116
+ "data-autoscrolling" => "a programmatic scroll is settling",
117
+ "data-pending-scroll" => "the opening-position hold (the root's) - the " \
118
+ "dictionary hides the viewport while it is set"
103
119
  }
104
120
  part "message-scroller-content", "The row container and Turbo Stream append target " \
105
121
  "(stable dom id <id>-messages); role=log announces " \
@@ -120,6 +136,7 @@ module Poetry
120
136
  { "data-slot" => "message-scroller" }
121
137
  .merge(stimulus_attributes_for(:root))
122
138
  .merge(component_data_attributes)
139
+ .merge(pending_scroll_attributes)
123
140
  )
124
141
  end
125
142
 
@@ -129,7 +146,13 @@ module Poetry
129
146
  "class" => css(:viewport), "data-slot" => "message-scroller-viewport",
130
147
  "role" => "region", "tabindex" => "0",
131
148
  "aria-label" => t("poetry.message_scroller.region")
132
- }.merge(stimulus_attributes_for(:viewport))
149
+ }.merge(stimulus_attributes_for(:viewport)).merge(pending_scroll_attributes)
150
+ end
151
+
152
+ # The opening-position hold (the class doc) - the server renders it so
153
+ # the first paint never shows the top of the thread.
154
+ def pending_scroll_attributes
155
+ %i[end last-anchor].include?(default_scroll_position) ? { "data-pending-scroll" => "" } : {}
133
156
  end
134
157
 
135
158
  # @api private
@@ -156,7 +179,8 @@ module Poetry
156
179
  }.merge(stimulus_attributes_for(:jump_button))
157
180
  end
158
181
 
159
- private :root_attributes, :viewport_attributes, :content_attributes, :spacer_attributes, :button_attributes
182
+ private :root_attributes, :viewport_attributes, :content_attributes, :spacer_attributes, :button_attributes,
183
+ :pending_scroll_attributes
160
184
  end
161
185
  end
162
186
  end
@@ -16,8 +16,14 @@ module Poetry
16
16
  # (rightly) rejects classes no build can produce.
17
17
  base "relative flex size-full min-h-0 flex-col overflow-hidden group/message-scroller"
18
18
 
19
+ # data-pending-scroll:invisible = the opening-position hold: the
20
+ # viewport hides (layout intact) until the controller lands the
21
+ # opening position. The noscript pair lifts it when scripts are off -
22
+ # the plain scrollable region stays readable, this component's no-JS
23
+ # contract.
19
24
  element :viewport, "size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable " \
20
- "overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-none"
25
+ "overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-none " \
26
+ "data-pending-scroll:invisible noscript:data-pending-scroll:visible"
21
27
 
22
28
  element :content, "cn-message-scroller-content flex h-max min-h-full flex-col"
23
29
 
@@ -30,27 +30,36 @@ module Poetry
30
30
  "Panel content is poetry_navigation_menu_link entries (active: marks the current page) - " \
31
31
  "never buttons; navigation navigates.",
32
32
  "This is a DISCLOSURE bar: Tab moves through it normally and nothing traps - do not " \
33
- "wire menu/menuitem roles.",
33
+ "wire menu/menuitem roles. Keys: ArrowLeft/ArrowRight step between triggers and links, " \
34
+ "ArrowDown opens the focused trigger's panel (focus stays on the trigger; Tab enters it), " \
35
+ "Escape closes and returns focus to the trigger.",
34
36
  "Rich panels (title + description grids) want viewport: true - the shared morphing " \
35
37
  "card contains and sizes them; the default per-item mode suits simple link lists " \
36
38
  "(the top-nav block shows the viewport pattern)."
37
39
  ].freeze
38
40
 
39
- # One declared bar entry - a link (href:) or a trigger + panel pair.
41
+ # One declared bar entry - a link (href:) or a trigger + panel pair
42
+ # (disabled: renders the trigger inert).
40
43
  # @api private
41
- Entry = Data.define(:title, :value, :href, :panel)
44
+ Entry = Data.define(:title, :value, :href, :panel, :disabled)
42
45
 
43
46
  renders_many :items,
44
- doc: "The bar entries. with_item(title, value:) { panel } declares a trigger + panel; " \
45
- "with_item(title, href:) a top-level link (with_link is the shorthand).",
46
- renders: lambda { |title, value: nil, href: nil, &panel|
47
+ doc: "The bar entries. with_item(title, value:) { panel } declares a trigger + panel " \
48
+ "(disabled: true renders the trigger inert - native disabled plus data-disabled; hover " \
49
+ "and click never open its panel); with_item(title, href:) a top-level link (with_link " \
50
+ "is the shorthand).",
51
+ renders: lambda { |title, value: nil, href: nil, disabled: false, &panel|
47
52
  if href.nil? && panel.nil?
48
53
  raise ArgumentError,
49
54
  "NavigationMenu item #{title.inspect} needs href: (a link) or a panel block"
50
55
  end
56
+ if href && disabled
57
+ raise ArgumentError,
58
+ "NavigationMenu item #{title.inspect}: disabled: applies to a trigger, not a link"
59
+ end
51
60
 
52
61
  entries << Entry.new(title: title, value: value&.to_s || title.to_s.parameterize, href: href,
53
- panel: panel)
62
+ panel: panel, disabled: disabled)
54
63
  nil
55
64
  }
56
65
 
@@ -116,7 +125,9 @@ module Poetry
116
125
  "data-popup-open" => "its panel is open (written with aria-expanded - the chevron " \
117
126
  "rotation hook)",
118
127
  "data-open" => "its panel is open (the controller writes both vocabularies)",
119
- "data-closed" => "its panel is closed (written after the first close)"
128
+ "data-closed" => "its panel is closed (written after the first close)",
129
+ "data-disabled" => "disabled: true - inert (native disabled rides along; hover and " \
130
+ "click never open the panel, the arrows step over it)"
120
131
  }
121
132
  part "navigation-menu-content", "One item's panel - presence-animated; in viewport mode it is " \
122
133
  "adopted into the shared viewport on first activation",
@@ -203,11 +214,16 @@ module Poetry
203
214
 
204
215
  # @api private
205
216
  def trigger_attributes(entry)
206
- {
217
+ attrs = {
207
218
  "type" => "button", "data-slot" => "navigation-menu-trigger",
208
219
  "aria-expanded" => "false", "aria-controls" => panel_id(entry),
209
220
  "class" => "#{css(:trigger)} group"
210
- }.merge(stimulus_attributes_for(:trigger))
221
+ }
222
+ if entry.disabled
223
+ attrs["disabled"] = true # inert: no focus, no click
224
+ attrs["data-disabled"] = "" # the contract's styling hook
225
+ end
226
+ attrs.merge(stimulus_attributes_for(:trigger))
211
227
  end
212
228
 
213
229
  # @api private
@@ -59,7 +59,6 @@ module Poetry
59
59
  target :input
60
60
  action :keydown, on: :keydown
61
61
  action :input, on: :input
62
- action :focus, on: :focus
63
62
  action :blur, on: :blur
64
63
  end
65
64
  end
@@ -626,12 +626,14 @@ module Poetry
626
626
  end
627
627
 
628
628
  # A styled heading, no ARIA role - the group points at it via
629
- # aria-labelledby.
629
+ # aria-labelledby, and aria-hidden keeps the text from being read a
630
+ # second time as a stray child of the listbox (the name still flows
631
+ # through the reference).
630
632
  def label_part
631
633
  return if @label_text.blank?
632
634
 
633
635
  content_tag(:div, @label_text, "data-slot" => "select-label", "id" => label_id,
634
- "class" => Style.css(:label))
636
+ "aria-hidden" => "true", "class" => Style.css(:label))
635
637
  end
636
638
  end
637
639
  end
@@ -65,12 +65,13 @@ module Poetry
65
65
  def button_options
66
66
  wiring = { data: { slot: "toast-trigger" } }
67
67
  .merge(stimulus_attributes_for(:root))
68
- .merge(component_data_attributes)
69
68
 
70
69
  # Caller attributes never clobber the trigger's wiring: both sides
71
70
  # flow through Attributes, so a host data-controller/action
72
- # concatenates instead of silently killing fire-on-click.
73
- { variant: variant, size: size }
71
+ # concatenates instead of silently killing fire-on-click. The
72
+ # Button root wears THIS component's identity through the
73
+ # identity: seam (the raw data-component is reserved).
74
+ { variant: variant, size: size, identity: self.class.component_title }
74
75
  .merge(Poetry::Core::HTML::Attributes.merged(wiring, html_attributes))
75
76
  .symbolize_keys
76
77
  end
@@ -21,7 +21,11 @@ module Poetry
21
21
  # The shared builder (the llms construction rule, extended): the
22
22
  # served text uses the exact registry construction the committed
23
23
  # file is generated from - helpers and blocks sections included.
24
- Poetry::Core::LlmsText.new(registry: Poetry::Ui.registry)
24
+ # The app's own components ride along (their section follows the
25
+ # gem catalog), so an agent reading llms-full.txt sees their agent
26
+ # rules and helpers too.
27
+ Poetry::Core::LlmsText.new(registry: Poetry::Ui.registry,
28
+ host_registry: Poetry::Core::HostComponents.registry(root: Rails.root))
25
29
  end
26
30
  end
27
31
  end
@@ -17,7 +17,7 @@ module Poetry
17
17
  STRING_HEURISTICS = {
18
18
  /password/ => :password,
19
19
  /email/ => :email,
20
- /\burl\b|_url\b/ => :url,
20
+ /\burl\b|_url\b|website|permalink/ => :url,
21
21
  /phone|\btel\b/ => :tel,
22
22
  /search|query/ => :search
23
23
  }.freeze
@@ -43,9 +43,11 @@ module Poetry
43
43
  COLUMN_TYPES.fetch(column, :string)
44
44
  end
45
45
 
46
+ # has_one_attached answers to <name>_attachment, has_many_attached
47
+ # to <name>_attachments; the other two are the classic uploaders.
46
48
  def attachment_attribute?(method)
47
- object.respond_to?("#{method}_attachment") || object.respond_to?("#{method}_attacher") ||
48
- object.respond_to?("remote_#{method}_url")
49
+ object.respond_to?("#{method}_attachment") || object.respond_to?("#{method}_attachments") ||
50
+ object.respond_to?("#{method}_attacher") || object.respond_to?("remote_#{method}_url")
49
51
  end
50
52
 
51
53
  def enum_attribute?(method)
@@ -32,6 +32,9 @@ module Poetry
32
32
  "overrides the presence inference (aria only).",
33
33
  "Hints/placeholders resolve from poetry_form.* i18n (simple_form.* keys keep working " \
34
34
  "as a fallback); pass hint:/placeholder: to override.",
35
+ "Every builder field renders a Field, so wiring tests assert on its parts: the label is " \
36
+ "data-slot=field-label, the hint data-slot=field-description, the error data-slot=field-error, " \
37
+ "with the control inside (never a bare hint or description slot).",
35
38
  "f.submit renders a poetry Button with the Rails i18n label; f.fieldset(legend:)/" \
36
39
  "f.group lay out sections; boolean f.input renders the horizontal Field " \
37
40
  "(switch: true -> the setting row).",