plutonium 0.62.2 → 0.64.0

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 (256) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/.standard.yml +1 -1
  13. data/CHANGELOG.md +73 -0
  14. data/CLAUDE.md +87 -0
  15. data/Rakefile +34 -0
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +685 -102
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +53 -53
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/app/views/rodauth/_login_form.html.erb +13 -0
  23. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  24. data/docs/.vitepress/config.ts +81 -3
  25. data/docs/.vitepress/theme/blog.data.ts +44 -0
  26. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  27. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  28. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  29. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  30. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  31. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  32. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  33. data/docs/.vitepress/theme/index.ts +8 -4
  34. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  35. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  36. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  37. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  38. data/docs/blog/index.md +13 -0
  39. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  40. data/docs/blog/introducing-plutonium.md +253 -0
  41. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  42. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  43. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  44. data/docs/blog/two-forms-one-dom-id.md +69 -0
  45. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  46. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  47. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  48. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  49. data/docs/guides/authentication.md +73 -0
  50. data/docs/guides/authorization.md +2 -0
  51. data/docs/guides/creating-packages.md +5 -3
  52. data/docs/guides/custom-actions.md +74 -12
  53. data/docs/guides/customizing-ui.md +9 -2
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +7 -5
  56. data/docs/guides/nested-resources.md +11 -1
  57. data/docs/guides/performance.md +104 -0
  58. data/docs/guides/user-invites.md +1 -1
  59. data/docs/guides/wizards.md +9 -1
  60. data/docs/index.md +3 -3
  61. data/docs/public/images/home/tour-actions.png +0 -0
  62. data/docs/public/images/home/tour-async.png +0 -0
  63. data/docs/public/images/home/tour-kanban.png +0 -0
  64. data/docs/public/images/home/tour-tenancy.png +0 -0
  65. data/docs/public/images/home/tour-wizard.png +0 -0
  66. data/docs/public/images/reference/async-progress-page.png +0 -0
  67. data/docs/public/images/reference/async-running-banner.png +0 -0
  68. data/docs/public/templates/experimental.rb +34 -0
  69. data/docs/public/templates/pluton8.rb +14 -0
  70. data/docs/reference/app/portals.md +15 -3
  71. data/docs/reference/auth/accounts.md +19 -0
  72. data/docs/reference/behavior/async-interactions.md +295 -0
  73. data/docs/reference/behavior/controllers.md +17 -4
  74. data/docs/reference/behavior/index.md +7 -1
  75. data/docs/reference/behavior/interactions.md +152 -22
  76. data/docs/reference/configuration.md +5 -0
  77. data/docs/reference/index.md +1 -0
  78. data/docs/reference/kanban/dsl.md +7 -4
  79. data/docs/reference/kanban/index.md +1 -1
  80. data/docs/reference/kanban/positioning.md +26 -4
  81. data/docs/reference/positioning.md +568 -0
  82. data/docs/reference/resource/actions.md +97 -4
  83. data/docs/reference/resource/definition.md +181 -9
  84. data/docs/reference/tenancy/invites.md +1 -1
  85. data/docs/reference/tenancy/nested-resources.md +60 -2
  86. data/docs/reference/ui/assets.md +4 -0
  87. data/docs/reference/ui/components.md +57 -4
  88. data/docs/reference/ui/displays.md +20 -10
  89. data/docs/reference/ui/index.md +1 -1
  90. data/docs/reference/wizard/dsl.md +33 -0
  91. data/docs/reference/wizard/storage-config.md +1 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  93. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  95. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  97. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  98. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  99. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  100. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  101. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  102. data/gemfiles/postgres.gemfile.lock +85 -85
  103. data/gemfiles/rails_7.gemfile.lock +323 -141
  104. data/gemfiles/rails_8.0.gemfile.lock +126 -116
  105. data/gemfiles/rails_8.1.gemfile.lock +127 -117
  106. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  107. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  108. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  109. data/lib/generators/pu/invites/install_generator.rb +3 -3
  110. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  112. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  113. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  114. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  115. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  116. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  118. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  119. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  120. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  121. data/lib/plutonium/action/base.rb +71 -9
  122. data/lib/plutonium/action/interactive.rb +9 -0
  123. data/lib/plutonium/attachments.rb +254 -0
  124. data/lib/plutonium/configuration.rb +82 -1
  125. data/lib/plutonium/core/controller.rb +50 -7
  126. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  127. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  128. data/lib/plutonium/definition/base.rb +51 -0
  129. data/lib/plutonium/definition/display_layout.rb +112 -0
  130. data/lib/plutonium/definition/index_views.rb +8 -7
  131. data/lib/plutonium/definition/input_aliases.rb +38 -0
  132. data/lib/plutonium/definition/page_widths.rb +65 -0
  133. data/lib/plutonium/definition/positioning.rb +126 -0
  134. data/lib/plutonium/definition/sorting.rb +17 -2
  135. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  136. data/lib/plutonium/interaction/README.md +61 -24
  137. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  138. data/lib/plutonium/interaction/async/context.rb +419 -0
  139. data/lib/plutonium/interaction/async/executor.rb +422 -0
  140. data/lib/plutonium/interaction/async/job.rb +80 -0
  141. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  142. data/lib/plutonium/interaction/async/run.rb +394 -0
  143. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  144. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  145. data/lib/plutonium/interaction/base.rb +34 -7
  146. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  147. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  148. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  149. data/lib/plutonium/kanban/board.rb +14 -0
  150. data/lib/plutonium/kanban/column.rb +4 -2
  151. data/lib/plutonium/kanban/dsl.rb +4 -1
  152. data/lib/plutonium/kanban/grouping.rb +9 -22
  153. data/lib/plutonium/kanban/positioning.rb +5 -65
  154. data/lib/plutonium/positioning/config.rb +94 -0
  155. data/lib/plutonium/positioning/model.rb +128 -0
  156. data/lib/plutonium/positioning.rb +25 -86
  157. data/lib/plutonium/railtie.rb +1 -0
  158. data/lib/plutonium/resource/controller.rb +118 -38
  159. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  160. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  161. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  162. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  163. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  164. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  165. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  166. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  167. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  168. data/lib/plutonium/resource/policy.rb +33 -0
  169. data/lib/plutonium/resource/query_object.rb +36 -0
  170. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  171. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  172. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  173. data/lib/plutonium/testing/resource_policy.rb +6 -2
  174. data/lib/plutonium/ui/action_button.rb +12 -7
  175. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  176. data/lib/plutonium/ui/block.rb +21 -1
  177. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  178. data/lib/plutonium/ui/component/methods.rb +5 -0
  179. data/lib/plutonium/ui/component/positionable.rb +112 -0
  180. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  181. data/lib/plutonium/ui/component/section.rb +185 -0
  182. data/lib/plutonium/ui/display/base.rb +13 -1
  183. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  184. data/lib/plutonium/ui/display/components/section.rb +18 -0
  185. data/lib/plutonium/ui/display/resource.rb +141 -22
  186. data/lib/plutonium/ui/display/theme.rb +20 -1
  187. data/lib/plutonium/ui/export_button.rb +1 -1
  188. data/lib/plutonium/ui/form/base.rb +8 -7
  189. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  190. data/lib/plutonium/ui/form/components/section.rb +7 -62
  191. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  192. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  194. data/lib/plutonium/ui/form/query.rb +2 -4
  195. data/lib/plutonium/ui/form/resource.rb +92 -15
  196. data/lib/plutonium/ui/form/theme.rb +17 -0
  197. data/lib/plutonium/ui/form/wizard.rb +25 -1
  198. data/lib/plutonium/ui/grid/card.rb +79 -11
  199. data/lib/plutonium/ui/grid/resource.rb +47 -5
  200. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  201. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  202. data/lib/plutonium/ui/kanban/card.rb +2 -1
  203. data/lib/plutonium/ui/kanban/column.rb +12 -6
  204. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  205. data/lib/plutonium/ui/layout/base.rb +10 -3
  206. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  207. data/lib/plutonium/ui/page/base.rb +19 -0
  208. data/lib/plutonium/ui/page/edit.rb +4 -1
  209. data/lib/plutonium/ui/page/index.rb +69 -18
  210. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  211. data/lib/plutonium/ui/page/new.rb +4 -1
  212. data/lib/plutonium/ui/page/show.rb +27 -10
  213. data/lib/plutonium/ui/page/wizard.rb +10 -1
  214. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  215. data/lib/plutonium/ui/page_width.rb +58 -0
  216. data/lib/plutonium/ui/table/base.rb +34 -1
  217. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  218. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  219. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  220. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  221. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  222. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  223. data/lib/plutonium/ui/table/resource.rb +50 -5
  224. data/lib/plutonium/ui/table/theme.rb +59 -2
  225. data/lib/plutonium/ui/wizard/review.rb +4 -2
  226. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  227. data/lib/plutonium/version.rb +1 -1
  228. data/lib/plutonium/wizard/attachments.rb +32 -197
  229. data/lib/plutonium/wizard/base.rb +6 -1
  230. data/lib/plutonium/wizard/configuration.rb +12 -0
  231. data/lib/plutonium/wizard/controller.rb +14 -0
  232. data/lib/plutonium/wizard/driving.rb +99 -15
  233. data/lib/plutonium/wizard/dsl.rb +23 -0
  234. data/lib/plutonium/wizard/resume.rb +127 -49
  235. data/lib/plutonium/wizard/runner.rb +46 -2
  236. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  237. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  238. data/lib/plutonium.rb +21 -0
  239. data/lib/rodauth/features/session_isolation.rb +92 -0
  240. data/lib/rodauth/plugins.rb +1 -0
  241. data/package.json +2 -1
  242. data/plutonium.gemspec +20 -20
  243. data/src/css/components.css +89 -1
  244. data/src/css/slim_select.css +20 -0
  245. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  246. data/src/js/controllers/bulk_actions_controller.js +10 -2
  247. data/src/js/controllers/kanban_controller.js +30 -21
  248. data/src/js/controllers/positioned_controller.js +452 -0
  249. data/src/js/controllers/register_controllers.js +6 -0
  250. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  251. data/src/js/controllers/run_progress_controller.js +73 -0
  252. data/src/js/drag/sortable.js +186 -0
  253. data/yarn.lock +108 -63
  254. metadata +101 -22
  255. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  256. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -5,6 +5,21 @@ module Plutonium
5
5
  # Prefix used for nested resource routes to disambiguate from user-defined routes
6
6
  NESTED_ROUTE_PREFIX = "nested_"
7
7
 
8
+ # Route default naming which nesting a request arrived through.
9
+ #
10
+ # The router already knows which nested route matched; without this the
11
+ # controller has to work it back out — reading an *_id parameter to find the
12
+ # parent, and scanning the path for a "nested_" segment to find the
13
+ # association. Neither survives a parent that contributes no id (a singular
14
+ # parent has none), and both are guesses about something the route could
15
+ # simply state.
16
+ #
17
+ # The value is the key into resource_route_config_lookup, which already
18
+ # holds the parent class and the association name. Carrying the key rather
19
+ # than the names themselves means nothing has to be looked up by string at
20
+ # request time.
21
+ NESTED_KEY_PARAM = :pu_nested_key
22
+
8
23
  # MapperExtensions module provides additional functionality for route mapping in Plutonium applications.
9
24
  #
10
25
  # This module extends the functionality of Rails' routing mapper to support Plutonium-specific features,
@@ -19,6 +34,10 @@ module Plutonium
19
34
  #
20
35
  # @param resource [Class] The resource class to be registered.
21
36
  # @param options [Hash] Additional options for resource registration.
37
+ # @option options [Array<Symbol>] :associations the associations to draw
38
+ # nested routes for. Omit to draw one for every routable association, or
39
+ # set +Plutonium.configuration.nested_association_routes = :declared+ to
40
+ # make omitting it mean none.
22
41
  # @yield An optional block for additional resource configuration.
23
42
  # @return [void]
24
43
  def register_resource(resource, options = {}, &)
@@ -86,7 +105,7 @@ module Plutonium
86
105
  concern route_config[:concern_name] do
87
106
  send route_config[:route_type], route_config[:route_name], **route_config[:route_options] do
88
107
  instance_exec(&route_config[:block]) if route_config[:block]
89
- define_nested_resource_routes(resource)
108
+ define_nested_resource_routes(resource, route_config[:associations])
90
109
  end
91
110
  end
92
111
  end
@@ -94,12 +113,21 @@ module Plutonium
94
113
  # Defines nested resource routes for a given resource.
95
114
  #
96
115
  # @param resource [Class] The parent resource class.
116
+ # @param declared [Array<Symbol>, nil] the associations named by
117
+ # register_resource, or nil when it named none.
97
118
  # @return [void]
98
- def define_nested_resource_routes(resource)
119
+ def define_nested_resource_routes(resource, declared = nil)
120
+ allowed = nested_route_associations(resource, declared)
121
+
99
122
  # has_many associations use plural routes
100
123
  resource.routable_has_many_associations.each do |assoc_info|
124
+ next if allowed && !allowed.include?(assoc_info[:name])
125
+
101
126
  base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
102
- next unless base_config
127
+ unless base_config
128
+ raise_unregistered_nested_association(resource, assoc_info) if declared
129
+ next
130
+ end
103
131
 
104
132
  # Register with association-based key: "parent_plural/association_name"
105
133
  # Force route_type: :resources — has_many associations always nest as a
@@ -110,30 +138,41 @@ module Plutonium
110
138
  nested_config = base_config.merge(
111
139
  route_type: :resources,
112
140
  association_name: assoc_info[:name],
113
- resource_class: assoc_info[:klass]
141
+ resource_class: assoc_info[:klass],
142
+ parent_class: resource
114
143
  )
115
144
  route_set.resource_route_config_lookup[nested_key] = nested_config
116
145
 
117
- resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
146
+ resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
147
+ **base_config[:route_options].except(:path),
148
+ defaults: {NESTED_KEY_PARAM => nested_key} do
118
149
  instance_exec(&base_config[:block]) if base_config[:block]
119
150
  end
120
151
  end
121
152
 
122
153
  # has_one associations use singular routes
123
154
  resource.routable_has_one_associations.each do |assoc_info|
155
+ next if allowed && !allowed.include?(assoc_info[:name])
156
+
124
157
  base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
125
- next unless base_config
158
+ unless base_config
159
+ raise_unregistered_nested_association(resource, assoc_info) if declared
160
+ next
161
+ end
126
162
 
127
163
  # Register with association-based key and singular route type
128
164
  nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
129
165
  nested_config = base_config.merge(
130
166
  route_type: :resource,
131
167
  association_name: assoc_info[:name],
132
- resource_class: assoc_info[:klass]
168
+ resource_class: assoc_info[:klass],
169
+ parent_class: resource
133
170
  )
134
171
  route_set.resource_route_config_lookup[nested_key] = nested_config
135
172
 
136
- resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
173
+ resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}",
174
+ **base_config[:route_options].except(:path),
175
+ defaults: {NESTED_KEY_PARAM => nested_key} do
137
176
  original_collection = method(:collection)
138
177
  define_singleton_method(:collection) { |&_| } # no-op for singular resources
139
178
  instance_exec(&base_config[:block]) if base_config[:block]
@@ -142,6 +181,51 @@ module Plutonium
142
181
  end
143
182
  end
144
183
 
184
+ # The associations a parent may draw nested routes for.
185
+ #
186
+ # nil means every routable association, which is what `:detected` does for
187
+ # a registration that named none. An array is a closed list, and an empty
188
+ # one is a real answer: `:declared` with nothing named draws nothing.
189
+ #
190
+ # A declared name that is not a routable association fails the boot. The
191
+ # whole point of naming them is to stop guessing which routes exist, so a
192
+ # typo that silently draws one route fewer would defeat it.
193
+ #
194
+ # @param resource [Class] The parent resource class.
195
+ # @param declared [Array<Symbol>, nil] the associations named at registration.
196
+ # @return [Array<Symbol>, nil]
197
+ # @raise [ArgumentError] if a declared name is not a routable association.
198
+ def nested_route_associations(resource, declared)
199
+ if declared.nil?
200
+ return nil if Plutonium.configuration.nested_association_routes == :detected
201
+
202
+ return []
203
+ end
204
+
205
+ routable = (resource.routable_has_many_associations +
206
+ resource.routable_has_one_associations).map { |info| info[:name] }
207
+ unknown = declared - routable
208
+ if unknown.any?
209
+ raise ArgumentError,
210
+ "#{resource} is registered with `associations: #{declared.inspect}`, but " \
211
+ "#{unknown.map(&:inspect).join(", ")} #{(unknown.size == 1) ? "is not a" : "are not"} " \
212
+ "routable #{"association".pluralize(unknown.size)} on it. " \
213
+ "Available: #{routable.empty? ? "(none)" : routable.map(&:inspect).join(", ")}. " \
214
+ "Nested routes are drawn for has_many and has_one only."
215
+ end
216
+
217
+ declared
218
+ end
219
+
220
+ # @raise [ArgumentError] always
221
+ def raise_unregistered_nested_association(resource, assoc_info)
222
+ raise ArgumentError,
223
+ "#{resource} declares `associations: [:#{assoc_info[:name]}]`, but " \
224
+ "#{assoc_info[:klass]} is not registered in this portal, so there is no " \
225
+ "route to nest under it. Add `register_resource #{assoc_info[:klass]}` " \
226
+ "alongside it, or drop :#{assoc_info[:name]} from the list."
227
+ end
228
+
145
229
  # Defines member-level interactive actions.
146
230
  #
147
231
  # @return [void]
@@ -153,6 +237,10 @@ module Plutonium
153
237
  as: :commit_interactive_record_action
154
238
  get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
155
239
  post "kanban_move", action: :kanban_move, as: :kanban_move
240
+ # Drag-reorder drop target for table/grid surfaces. Mounted on every
241
+ # resource (like the kanban routes above); resources whose definition
242
+ # declares no `position_on` answer it with a 404.
243
+ post "reposition", action: :reposition, as: :reposition
156
244
  end
157
245
  end
158
246
 
@@ -205,6 +293,8 @@ module Plutonium
205
293
  as: :wizard_record_action
206
294
  post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_record_action,
207
295
  as: :commit_wizard_record_action
296
+ delete "wizards/:wizard_name(/:token)", action: :cancel_wizard_record_action,
297
+ as: :cancel_wizard_record_action
208
298
  end
209
299
  end
210
300
 
@@ -225,6 +315,8 @@ module Plutonium
225
315
  as: :wizard_resource_action
226
316
  post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_resource_action,
227
317
  as: :commit_wizard_resource_action
318
+ delete "wizards/:wizard_name(/:token)", action: :cancel_wizard_resource_action,
319
+ as: :cancel_wizard_resource_action
228
320
  end
229
321
  end
230
322
 
@@ -151,8 +151,22 @@ module Plutonium
151
151
  route_type: options[:singular] ? :resource : :resources,
152
152
  route_name: route_name,
153
153
  concern_name: concern_name,
154
+ # The associations this resource draws nested routes for, or nil when
155
+ # the registration named none. nil is not "no associations": what
156
+ # silence means is decided by
157
+ # Plutonium.configuration.nested_association_routes.
158
+ associations: options[:associations]&.map(&:to_sym),
154
159
  route_options: {
155
- controller: resource.to_s.pluralize.underscore,
160
+ # model_name.collection, not resource.to_s.pluralize.underscore:
161
+ # the latter reads the RAW class name, ignoring a model that pins
162
+ # its own model_name to escape it — which is exactly what
163
+ # Plutonium::Interaction::Async::Run does, precisely so a namespace this
164
+ # deep inside the gem's own module doesn't leak into every URL and
165
+ # controller path. For every resource that does NOT override
166
+ # model_name, .collection derives the identical path .pluralize.underscore
167
+ # would have (verified: both give "blogging/posts" for Blogging::Post).
168
+ # `controller:` remains available to override either way.
169
+ controller: options[:controller] || resource.model_name.collection,
156
170
  path: options[:singular] ? resource.model_name.singular : resource.model_name.collection,
157
171
  concerns: %i[interactive_resource_actions]
158
172
  },
@@ -109,6 +109,8 @@ module Plutonium
109
109
  as: :"#{helper_name}_wizard", defaults: defaults
110
110
  post "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#update",
111
111
  defaults: defaults
112
+ delete "(/:token)", to: "#{WIZARD_CONTROLLER_NAME}#cancel",
113
+ as: :"#{helper_name}_wizard_cancel", defaults: defaults
112
114
  end
113
115
  end
114
116
 
@@ -174,6 +176,8 @@ module Plutonium
174
176
  as: :"#{helper_name}_wizard", defaults: defaults
175
177
  post "(/:token)/:step", to: "public_wizards#update",
176
178
  defaults: defaults
179
+ delete "(/:token)", to: "public_wizards#cancel",
180
+ as: :"#{helper_name}_wizard_cancel", defaults: defaults
177
181
  end
178
182
  end
179
183
  end
@@ -24,7 +24,11 @@ module Plutonium
24
24
  matrix.each do |action, allowed_roles|
25
25
  roles.each do |role_sym, account_proc|
26
26
  account = instance_exec(&account_proc)
27
- policy = policy_klass.new(record: record, user: account, **policy_context)
27
+ # The record MUST be positional. ActionPolicy::Policy::Core#initialize is
28
+ # `def initialize(record = nil, *)`, so a `record:` keyword is swallowed by
29
+ # the splat and `record` stays nil — every record-dependent predicate would
30
+ # then be asserted against nil.
31
+ policy = policy_klass.new(record, user: account, **policy_context)
28
32
  expected = allowed_roles.include?(role_sym)
29
33
  actual = policy.public_send("#{action}?")
30
34
  assert_equal expected, actual,
@@ -38,7 +42,7 @@ module Plutonium
38
42
  policy_klass = policy_class_for(record)
39
43
  policy_roles.each do |role_sym, account_proc|
40
44
  account = instance_exec(&account_proc)
41
- policy = policy_klass.new(record: record.class, user: account, **policy_context)
45
+ policy = policy_klass.new(record.class, user: account, **policy_context)
42
46
  scope = policy.apply_scope(record.class.all, type: :active_record_relation)
43
47
  assert_kind_of ActiveRecord::Relation, scope, "relation_scope must return AR::Relation for #{role_sym}"
44
48
  end
@@ -49,8 +49,10 @@ module Plutonium
49
49
  def render_link
50
50
  link_to(
51
51
  url_with_return_to,
52
- class: button_classes,
53
- data: {turbo_frame: @action.turbo_frame(current_definition)}.merge(@extra_data)
52
+ @action.link_attributes({
53
+ class: button_classes,
54
+ data: {turbo_frame: @action.turbo_frame(current_definition)}.merge(@extra_data)
55
+ })
54
56
  ) do
55
57
  render_button_content
56
58
  end
@@ -63,13 +65,13 @@ module Plutonium
63
65
  name: :return_to, value: return_to_url,
64
66
  class: "inline-block",
65
67
  data: @extra_data,
66
- form: {
68
+ form: @action.button_attributes({
67
69
  data: {
68
70
  turbo: @action.turbo,
69
71
  turbo_confirm: @action.confirmation.presence,
70
72
  turbo_frame: @action.turbo_frame(current_definition)
71
73
  }
72
- }
74
+ })
73
75
  ) do
74
76
  span(class: button_classes) do
75
77
  render_button_content
@@ -94,8 +96,8 @@ module Plutonium
94
96
  link_attrs[:data][:turbo_confirm] = @action.confirmation if @action.confirmation
95
97
  end
96
98
 
97
- a(**link_attrs) do
98
- render @action.icon.new(class: "w-4 h-4") if @action.icon
99
+ a(**@action.link_attributes(link_attrs)) do
100
+ render @action.icon.new(class: "w-4 h-4 shrink-0") if @action.icon
99
101
  span { @action.label }
100
102
  end
101
103
  end
@@ -157,7 +159,10 @@ module Plutonium
157
159
  if current_turbo_frame && current_parent
158
160
  resource_url_for(current_parent, parent: nil)
159
161
  else
160
- request.original_url
162
+ # current_page_url, not request.original_url: a button re-rendered by
163
+ # a stream-producing POST (the reposition drop) must return the user
164
+ # to the page they are on, never to that POST-only endpoint.
165
+ current_page_url
161
166
  end
162
167
  end
163
168
 
@@ -34,7 +34,7 @@ module Plutonium
34
34
 
35
35
  def render_dropdown_menu
36
36
  div(
37
- class: "hidden absolute right-0 z-50 mt-2 w-48 origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
37
+ class: "hidden absolute right-0 z-50 mt-2 w-max min-w-48 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)] overflow-hidden",
38
38
  style: "box-shadow: var(--pu-shadow-lg)",
39
39
  data: {resource_drop_down_target: "menu"}
40
40
  ) do
@@ -1,10 +1,30 @@
1
1
  module Plutonium
2
2
  module UI
3
+ # A plain card surface to wrap arbitrary content in.
4
+ #
5
+ # This is THE card primitive — it renders `pu-card` rather than hand-rolling
6
+ # a surface. It used to paint its own background, radius and
7
+ # `--pu-shadow-md` inline, which left it subtly out of step with every
8
+ # `pu-card` on the page: no border, and a heavier shadow. Anything wrapping
9
+ # a `pu-card` in a Block then stacked two cards and got a doubled shadow,
10
+ # while anything wrapping bare content got a borderless one — which is why
11
+ # the show page's details and its metadata rail used to sit on visibly
12
+ # different surfaces.
13
+ #
14
+ # It carries no outer margin: spacing between cards belongs to whatever
15
+ # stacks them (a `space-y-*` container), not to the card itself, so a
16
+ # single Block can't decide the rhythm of a list it doesn't know it's in.
17
+ # Extra classes may be merged in (`Block(class: "overflow-hidden")`) so a
18
+ # caller can adjust the card without re-declaring what a card is.
3
19
  class Block < Plutonium::UI::Component::Base
20
+ def initialize(**options)
21
+ @class = options[:class]
22
+ end
23
+
4
24
  def view_template(&)
5
25
  raise ArgumentError, "Block requires a content block" unless block_given?
6
26
 
7
- div class: "relative bg-[var(--pu-surface)] rounded-[var(--pu-radius-lg)] my-3", style: "box-shadow: var(--pu-shadow-md)" do
27
+ div class: tokens("relative pu-card", @class) do
8
28
  yield
9
29
  end
10
30
  end
@@ -4,17 +4,35 @@ module Plutonium
4
4
  include Phlex::Rails::Helpers::ActionName
5
5
  include Phlex::Rails::Helpers::LinkTo
6
6
 
7
+ # Shared styling for an inline breadcrumb label.
8
+ LABEL_CLASSES = "ms-1 md:ms-2 flex items-center min-w-0 text-sm font-medium " \
9
+ "text-[var(--pu-text-muted)] transition-colors"
10
+ LINK_CLASSES = "#{LABEL_CLASSES} hover:text-primary-600"
11
+
12
+ # Styling for the same label when it is rendered as a row in the overflow
13
+ # menu instead of inline. Full-row target, chevron-led to echo the
14
+ # inline separators.
15
+ MENU_LINK_CLASSES = "flex items-center gap-1.5 py-1.5 px-3 text-sm text-[var(--pu-text-muted)] " \
16
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
17
+
18
+ CHEVRON_INLINE_CLASSES = "rtl:rotate-180 block w-3 h-3 mx-1 shrink-0 text-[var(--pu-text-subtle)]"
19
+ CHEVRON_MENU_CLASSES = "rtl:rotate-180 block w-3 h-3 shrink-0 text-[var(--pu-text-subtle)]"
20
+
7
21
  def view_template
22
+ # `overflow-hidden` keeps the un-collapsed trail from spilling in the
23
+ # instant before the controller's first reflow.
8
24
  nav(
9
- class: "flex py-3 mb-2",
10
- aria_label: "Breadcrumb"
25
+ class: "flex min-w-0 py-3 mb-2 overflow-hidden",
26
+ aria_label: "Breadcrumb",
27
+ data: {controller: "breadcrumbs"}
11
28
  ) do
12
29
  ol(
13
- class: "inline-flex items-center gap-1 md:gap-2"
30
+ class: "flex flex-nowrap w-full min-w-0 items-center gap-1 md:gap-2",
31
+ data: {breadcrumbs_target: "list"}
14
32
  ) do
15
33
  render_dashboard_link
16
- render_parent_breadcrumbs if current_parent.present?
17
- render_resource_breadcrumbs if resource_record?
34
+ render_overflow_menu if middle_items.any?
35
+ render_items
18
36
  render_trailing_separator
19
37
  end
20
38
  end
@@ -22,14 +40,43 @@ module Plutonium
22
40
 
23
41
  private
24
42
 
43
+ # Every segment between the dashboard link and the trailing separator, as
44
+ # a list of lambdas taking the CSS classes to render with. Collected up
45
+ # front so each one can be rendered twice — inline, and again as a row in
46
+ # the overflow menu that the controller reveals when it folds the inline
47
+ # twin away.
48
+ def breadcrumb_items
49
+ @breadcrumb_items ||= [].tap do |items|
50
+ collect_parent_breadcrumbs(items) if current_parent.present?
51
+ collect_resource_breadcrumbs(items) if resource_record?
52
+ end
53
+ end
54
+
55
+ # The segments eligible to be folded into the overflow menu. The last one
56
+ # is never foldable — it is the deepest thing the trail knows about, which
57
+ # is the record on new/edit pages but the resource index on show pages,
58
+ # where the record itself is not part of the trail at all.
59
+ def middle_items
60
+ breadcrumb_items[0...-1]
61
+ end
62
+
63
+ def render_items
64
+ last_index = breadcrumb_items.size - 1
65
+ breadcrumb_items.each_with_index do |item, index|
66
+ render_breadcrumb_item(foldable: index < last_index) do
67
+ item.call(LINK_CLASSES)
68
+ end
69
+ end
70
+ end
71
+
25
72
  def render_dashboard_link
26
- li(class: "inline-flex items-center") do
73
+ li(class: "flex items-center shrink-0") do
27
74
  a(
28
75
  href: root_path,
29
76
  class: "inline-flex items-center text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 transition-colors"
30
77
  ) do
31
78
  svg(
32
- class: "w-3 h-3 me-2.5",
79
+ class: "w-3 h-3 shrink-0 sm:me-2.5",
33
80
  aria_hidden: "true",
34
81
  xmlns: "http://www.w3.org/2000/svg",
35
82
  fill: "currentColor",
@@ -40,85 +87,170 @@ module Plutonium
40
87
  "m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"
41
88
  )
42
89
  end
43
- plain " Dashboard "
90
+ span(class: "sr-only sm:not-sr-only") { plain "Dashboard" }
44
91
  end
45
92
  end
46
93
  end
47
94
 
48
- def render_parent_breadcrumbs
49
- # Parent Resource
50
- render_breadcrumb_item do
51
- link_to resource_name_plural(current_parent.class),
52
- resource_url_for(current_parent.class, parent: nil),
53
- class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
95
+ # Builds a segment renderer used in both places a segment can appear:
96
+ # inline in the trail, and — with `leading:` — as a chevron-led row in
97
+ # the overflow menu.
98
+ def segment(label, url = nil)
99
+ ->(classes, leading: false) {
100
+ if url
101
+ link_to(url, class: classes) do
102
+ render_chevron_separator(CHEVRON_MENU_CLASSES) if leading
103
+ span(class: "truncate min-w-0") { plain label }
104
+ end
105
+ else
106
+ span(class: classes) do
107
+ render_chevron_separator(CHEVRON_MENU_CLASSES) if leading
108
+ span(class: "truncate min-w-0") { plain label }
109
+ end
110
+ end
111
+ }
112
+ end
113
+
114
+ def collect_parent_breadcrumbs(items)
115
+ # Parent Resource — an index link, where the parent has one. A parent
116
+ # registered `singular: true` defines no collection route, so asking for
117
+ # one raises on a helper that was never generated.
118
+ unless singular_resource_route_for?(current_parent.class)
119
+ items << segment(
120
+ resource_name_plural(current_parent.class),
121
+ resource_url_for(current_parent.class, parent: nil)
122
+ )
54
123
  end
55
124
 
56
125
  # Parent Itself
57
- render_breadcrumb_item do
58
- link_to display_name_of(current_parent),
59
- resource_url_for(current_parent, parent: nil),
60
- class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
61
- end
126
+ items << segment(
127
+ display_name_of(current_parent),
128
+ resource_url_for(current_parent, parent: nil)
129
+ )
62
130
  end
63
131
 
64
- def render_resource_breadcrumbs
65
- is_singular_route = current_engine.routes.resource_route_config_lookup[resource_class.model_name.plural][:route_type] == :resource
66
-
67
- if is_singular_route
68
- render_singular_resource_breadcrumb
132
+ def collect_resource_breadcrumbs(items)
133
+ # Ask the controller, not the top-level route registry: a has_one
134
+ # association nests as a singular route regardless of how the child was
135
+ # registered, and only the controller's config lookup is keyed by
136
+ # "parent/association" and therefore knows that.
137
+ if singular_resource_context?
138
+ collect_singular_resource_breadcrumb(items)
69
139
  else
70
- render_plural_resource_breadcrumbs
140
+ collect_plural_resource_breadcrumbs(items)
71
141
  end
72
142
  end
73
143
 
74
- def render_singular_resource_breadcrumb
75
- render_breadcrumb_item do
76
- if resource_record!.persisted? && action_name != "show"
77
- link_to resource_name(resource_class),
78
- resource_url_for(resource_record!),
79
- class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
80
- else
81
- span(class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] md:ms-2") do
82
- plain resource_name(resource_class)
83
- end
84
- end
85
- end
144
+ def collect_singular_resource_breadcrumb(items)
145
+ # A singular resource has no index, so the only segment it can add is
146
+ # itself and on `show` the page title already names it, exactly as the
147
+ # plural branch omits the record on its own show page.
148
+ return unless resource_record!.persisted? && action_name != "show"
149
+
150
+ items << segment(resource_name(resource_class), resource_url_for(resource_record!))
86
151
  end
87
152
 
88
- def render_plural_resource_breadcrumbs
89
- # Resource index link
90
- render_breadcrumb_item do
91
- link_to nestable_resource_name_plural(resource_class),
92
- resource_url_for(resource_class),
93
- class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
94
- end
153
+ def collect_plural_resource_breadcrumbs(items)
154
+ # No `parent:` here on purpose: Resource::Controller#resource_url_args_for
155
+ # already defaults it to `current_parent` (and supplies
156
+ # `current_nested_association` with it), so both links nest themselves
157
+ # when the request arrived through a nesting. Passing it explicitly reads
158
+ # like it changes something; it doesn't. The parent segments above pass
159
+ # `parent: nil` precisely to opt OUT of that default.
160
+ items << segment(
161
+ nestable_resource_name_plural(resource_class),
162
+ resource_url_for(resource_class)
163
+ )
95
164
 
96
165
  # Record itself (for non-singular routes only)
97
166
  return unless resource_record!.persisted? && action_name != "show"
98
167
 
99
- render_breadcrumb_item do
100
- link_to display_name_of(resource_record!),
101
- resource_url_for(resource_record!),
102
- class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
168
+ items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
169
+ end
170
+
171
+ # Stands in for whatever the controller folded away, the way GitHub
172
+ # collapses a long path. Starts hidden — it only earns its place once at
173
+ # least one segment has actually been folded.
174
+ def render_overflow_menu
175
+ li(
176
+ class: "hidden items-center shrink-0",
177
+ data: {breadcrumbs_target: "overflow"}
178
+ ) do
179
+ render_chevron_separator
180
+ div(
181
+ class: "flex items-center",
182
+ data: {
183
+ controller: "resource-drop-down",
184
+ resource_drop_down_placement_value: "bottom-start"
185
+ }
186
+ ) do
187
+ render_overflow_trigger
188
+ render_overflow_items
189
+ end
190
+ end
191
+ end
192
+
193
+ def render_overflow_trigger
194
+ button(
195
+ type: "button",
196
+ aria: {label: "Show collapsed breadcrumbs", haspopup: "true", expanded: "false"},
197
+ data: {resource_drop_down_target: "trigger"},
198
+ class: "flex items-center justify-center ms-1 w-6 h-6 rounded-[var(--pu-radius-md)] " \
199
+ "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
200
+ ) do
201
+ render Phlex::TablerIcons::Dots.new(class: "w-4 h-4")
202
+ end
203
+ end
204
+
205
+ # One row per foldable segment. Each starts hidden; the controller reveals
206
+ # exactly those whose inline twin it folded away.
207
+ def render_overflow_items
208
+ div(
209
+ # No margin here — the dropdown controller already offsets the popper
210
+ # off its trigger.
211
+ class: "hidden z-50 py-1 min-w-40 max-w-[70vw] bg-[var(--pu-surface)] " \
212
+ "border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
213
+ style: "box-shadow: var(--pu-shadow-lg)",
214
+ data: {resource_drop_down_target: "menu"}
215
+ ) do
216
+ ul(class: "list-none") do
217
+ middle_items.each do |item|
218
+ li(class: "hidden", data: {breadcrumbs_target: "menuItem"}) do
219
+ item.call(MENU_LINK_CLASSES, leading: true)
220
+ end
221
+ end
222
+ end
103
223
  end
104
224
  end
105
225
 
106
226
  def render_trailing_separator
107
- li(class: "flex items-center") do
227
+ li(class: "flex items-center shrink-0") do
108
228
  render_chevron_separator
109
229
  end
110
230
  end
111
231
 
112
- def render_breadcrumb_item(&)
113
- li(class: "flex items-center") do
114
- render_chevron_separator
115
- yield
232
+ # Segments are `shrink-0` so the controller measures natural widths and
233
+ # can detect real overflow. The last one is additionally `min-w-0` so the
234
+ # controller can drop its `shrink-0` and let it ellipsize once there is
235
+ # nothing left to fold. Its `last` target must stay in lockstep with
236
+ # `middle_items` — the controller pairs inline twins to menu rows by index.
237
+ def render_breadcrumb_item(foldable: false, &)
238
+ if foldable
239
+ li(class: "flex items-center shrink-0", data: {breadcrumbs_target: "item"}) do
240
+ render_chevron_separator
241
+ yield
242
+ end
243
+ else
244
+ li(class: "flex items-center shrink-0 min-w-0", data: {breadcrumbs_target: "last"}) do
245
+ render_chevron_separator
246
+ yield
247
+ end
116
248
  end
117
249
  end
118
250
 
119
- def render_chevron_separator
251
+ def render_chevron_separator(classes = CHEVRON_INLINE_CLASSES)
120
252
  svg(
121
- class: "rtl:rotate-180 block w-3 h-3 mx-1 text-[var(--pu-text-subtle)]",
253
+ class: classes,
122
254
  aria_hidden: "true",
123
255
  xmlns: "http://www.w3.org/2000/svg",
124
256
  fill: "none",