plutonium 0.62.2 → 0.63.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 (255) 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/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,92 @@
1
+ require "rodauth"
2
+
3
+ # Keeps each Rodauth configuration's session state independent of the others, so a
4
+ # person can be signed into more than one portal at a time.
5
+ #
6
+ # Rodauth calls +clear_session+ from +update_session+ on *every* login - including
7
+ # the +remember+ feature's +load_memory+ autologin - to defend against session
8
+ # fixation. rodauth-rails implements that as a full +reset_session+, which drops the
9
+ # entire Rails session rather than just the keys belonging to the configuration
10
+ # doing the login.
11
+ #
12
+ # In a Plutonium app that breaks multi-portal access. Each portal authenticates
13
+ # through its own configuration (+:user+, +:admin+, ...), so signing into one portal
14
+ # evicts every other portal's session. Worse, with +after_login { remember_login }+
15
+ # enabled the +load_memory+ calls in +RodauthApp#route+ then re-authenticate the
16
+ # evicted configuration from its remember cookie on the very next request - and
17
+ # evict the new one in turn. The last +load_memory+ in the route block wins
18
+ # permanently, so the other portal can never hold a session at all.
19
+ #
20
+ # This feature carries the *other* configurations' session entries across the
21
+ # reset. The Rails session id is still rotated and this configuration's own state is
22
+ # still dropped, so session fixation is still defeated; application session data is
23
+ # still cleared, exactly as before.
24
+ #
25
+ # Ownership is decided by +session_key_prefix+, so every configuration that wants to
26
+ # coexist must set one. A configuration without a prefix uses Rodauth's unprefixed
27
+ # default key names - the same names every other unprefixed configuration uses - so
28
+ # there is no way to tell its entries apart and nothing is carried for it.
29
+ module Rodauth
30
+ Feature.define(:session_isolation, :SessionIsolation) do
31
+ def clear_session
32
+ carried = sibling_session_data
33
+ super
34
+ carried.each { |key, value| session[key] = value }
35
+ end
36
+
37
+ private
38
+
39
+ # The session entries owned by the app's other Rodauth configurations.
40
+ #
41
+ # Keys are matched by prefix rather than by asking each configuration to
42
+ # enumerate them. Deriving them from method names (e.g. everything matching
43
+ # /_session_key\z/) would mean blind-sending methods that are not readers at
44
+ # all: +single_session+ exposes +reset_single_session_key+ and
45
+ # +update_single_session_key+ as public auth methods, both of which UPDATE the
46
+ # database with a fresh random key - signing the sibling out, the exact opposite
47
+ # of this feature's purpose.
48
+ def sibling_session_data
49
+ data = session.to_hash
50
+
51
+ sibling_rodauths.each_with_object({}) do |sibling, carried|
52
+ prefix = sibling.session_key_prefix.to_s
53
+ # Unprefixed sibling: its key names are indistinguishable from ours, and
54
+ # carrying them would restore our own pre-login auth state across the reset,
55
+ # defeating the session fixation protection.
56
+ next if prefix.empty?
57
+
58
+ keys = data.keys.select { |key| key.to_s.start_with?(prefix) }
59
+ # Courtesy for a hand-written config that sets an explicit `session_key`
60
+ # alongside its prefix: that value bypasses `convert_session_key` and so is
61
+ # unprefixed. Do NOT read this as endorsing that shape - it leaves the account
62
+ # id rotating separately from every other key, which is a crash waiting to
63
+ # happen (see the note on session_key_prefix in the auth guide). Generated
64
+ # configs set the prefix only, and for them this line never matches.
65
+ account_key = sibling.session_key.to_s
66
+ keys << account_key if data.key?(account_key)
67
+
68
+ keys.each do |key|
69
+ carried[key] = data[key] unless own_session_key?(key)
70
+ end
71
+ end
72
+ end
73
+
74
+ # Guards against a misconfiguration in which a sibling shares our prefix: we must
75
+ # never restore our own entries, or the reset would be a no-op for us.
76
+ def own_session_key?(key)
77
+ key = key.to_s
78
+ return true if key == session_key.to_s
79
+
80
+ prefix = session_key_prefix.to_s
81
+ prefix.present? && key.start_with?(prefix)
82
+ end
83
+
84
+ # Only base-Rodauth methods are called on siblings (+session_key_prefix+ and
85
+ # +session_key+), so configurations that do not enable this feature - or do not
86
+ # descend from the app's base plugin at all - still work.
87
+ def sibling_rodauths
88
+ names = scope.opts[:rodauths].keys - [self.class.configuration_name]
89
+ names.map { |name| scope.rodauth(name) }
90
+ end
91
+ end
92
+ end
@@ -1 +1,2 @@
1
1
  require_relative "features/case_insensitive_login"
2
+ require_relative "features/session_isolation"
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.62.2",
3
+ "version": "0.63.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -40,6 +40,7 @@
40
40
  "cssnano": "^7.0.2",
41
41
  "esbuild": "^0.28.1",
42
42
  "esbuild-plugin-manifest": "^1.0.3",
43
+ "feed": "^6.0.0",
43
44
  "flowbite-typography": "^1.0.5",
44
45
  "medium-zoom": "^1.1.0",
45
46
  "mermaid": "^11.15.0",
data/plutonium.gemspec CHANGED
@@ -12,6 +12,10 @@ Gem::Specification.new do |spec|
12
12
  "definitions, and portals that make building complex apps faster. Built for the AI era with Claude Code skills."
13
13
  spec.homepage = "https://radioactive-labs.github.io/plutonium-core/"
14
14
  spec.license = "MIT"
15
+ # Ruby 3.2 is DEPRECATED and will be dropped in the next release — see the
16
+ # post_install_message. Held here for one more release so 3.2 users can
17
+ # receive the fixes in this one before support ends; raising it in the same
18
+ # release that ships them would strand those users on the old version.
15
19
  spec.required_ruby_version = ">= 3.2.2"
16
20
 
17
21
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
@@ -19,21 +23,22 @@ Gem::Specification.new do |spec|
19
23
  # owner changes). `gem push` will prompt for an OTP at release time.
20
24
  spec.metadata["rubygems_mfa_required"] = "true"
21
25
 
26
+ # Prints on EVERY install: only notices still actionable for someone
27
+ # installing THIS version.
22
28
  spec.post_install_message = <<~MSG
23
- ℹ️ Plutonium breaking change introduced in 0.49.0
29
+ ⚠️ Ruby 3.2 support ends in the NEXT release
24
30
 
25
- Entity-scoped URL helpers and path params were renamed in 0.49.0 from
26
- `<entity>_scope_*` to `<entity>_scoped_*`.
31
+ This is the last Plutonium release that installs on Ruby 3.2.
27
32
 
28
- Examples:
29
- organization_scope_widgets_path → organization_scoped_widgets_path
30
- params[:organization_scope] → params[:organization_scoped]
33
+ Why: pagy patches CVE-2026-54659 (a path-traversal in
34
+ `Pagy::I18n.locale=`) only in >= 43.5.6, and pagy dropped Ruby 3.2 in
35
+ 43.5.0. No pagy version is both patched and installable on 3.2, so
36
+ continuing to support it means shipping a known-vulnerable dependency.
31
37
 
32
- If you are upgrading from 0.48.0 or earlier and reference these helpers or
33
- params directly (e.g. in tests, custom redirects, or hand-written links),
34
- update them to the new names.
38
+ Ruby 3.2 reached end-of-life in March 2026.
35
39
 
36
- Apps that only use `resource_url_for` are unaffected.
40
+ Action: upgrade to Ruby 3.3 or newer before your next Plutonium bump.
41
+ If you stay on 3.2, `bundle update plutonium` will hold you here.
37
42
  MSG
38
43
 
39
44
  spec.metadata["homepage_uri"] = spec.homepage
@@ -53,9 +58,14 @@ Gem::Specification.new do |spec|
53
58
  spec.require_paths = ["lib"]
54
59
 
55
60
  spec.add_dependency "zeitwerk"
56
- spec.add_dependency "rails", ">= 7.2"
61
+ spec.add_dependency "rails", ">= 7.2.3.1"
57
62
  spec.add_dependency "csv" # CSV export; no longer a default gem on Ruby 3.4+
58
63
  spec.add_dependency "listen", "~> 3.8"
64
+ # NOT pinned to >= 43.5.6 (which patches CVE-2026-54659) yet: that version
65
+ # requires Ruby >= 3.3, so pinning it here would silently drop Ruby 3.2 a
66
+ # release early. `~> 43.0` already resolves to a patched 43.6.x on any fresh
67
+ # install under Ruby 3.3+, so the exposure is limited to apps holding an old
68
+ # pagy in their lockfile. Pin it in the release that drops 3.2.
59
69
  spec.add_dependency "pagy", "~> 43.0"
60
70
  spec.add_dependency "rabl", "~> 0.17.0" # TODO: what to do with RABL
61
71
  spec.add_dependency "semantic_range", "~> 3.0"
@@ -83,6 +93,14 @@ Gem::Specification.new do |spec|
83
93
  spec.add_development_dependency "combustion"
84
94
  spec.add_development_dependency "capybara"
85
95
  spec.add_development_dependency "selenium-webdriver"
96
+ # Exercises the Mode B positioning escape hatch (position_on with a block)
97
+ # against a REAL third-party positioning gem, which is the only way the
98
+ # worked example in docs/reference/positioning.md can be kept honest.
99
+ # A development dependency declared here (rather than in the Gemfile) is
100
+ # inherited by every appraisal gemfile via `gemspec path: "../"`, so the
101
+ # dummy app's acts_as_list resource loads on rails-7, 8.0 and 8.1 alike —
102
+ # and never becomes a runtime dependency of the gem.
103
+ spec.add_development_dependency "acts_as_list"
86
104
 
87
105
  # For more information and examples about making a new gem, check out our
88
106
  # guide at: https://bundler.io/guides/creating_gem.html
@@ -803,6 +803,18 @@ html.pu-rail-pinned .icon-rail-pin-expand {
803
803
  overscroll-behavior: contain;
804
804
  }
805
805
 
806
+ /* Full visible viewport height.
807
+ 100vh is the viewport as it would be with the browser's own chrome hidden,
808
+ so on a phone a 100vh box is taller than what the user can actually see and
809
+ its bottom edge sits under the URL bar or the gesture bar. dvh tracks the
810
+ visible area; 100vh stays as the fallback for engines without it. Same
811
+ reasoning as the cap on .icon-rail-flyout-inner above, which is where this
812
+ pattern is already in use. */
813
+ .pu-min-h-viewport {
814
+ min-height: 100vh;
815
+ min-height: 100dvh;
816
+ }
817
+
806
818
  @keyframes pu-rail-flyout-in {
807
819
  from { opacity: 0; transform: translateX(-4px); }
808
820
  to { opacity: 1; transform: translateX(0); }
@@ -1059,7 +1071,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1059
1071
 
1060
1072
  /* Column highlighted as the current drop target */
1061
1073
  .pu-kanban-drop-target {
1062
- outline: 2px solid var(--color-primary-500, #3b82f6);
1074
+ outline: 2px solid theme(colors.primary.500);
1063
1075
  outline-offset: -2px;
1064
1076
  border-radius: var(--pu-radius-md, 0.5rem);
1065
1077
  }
@@ -1068,3 +1080,79 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1068
1080
  .pu-kanban-no-drop {
1069
1081
  opacity: 0.45;
1070
1082
  }
1083
+
1084
+ /* Where the drop will land. Shared by the kanban board and every positioned
1085
+ table/grid (src/js/drag/sortable.js). Parked on <body> and positioned in
1086
+ viewport coordinates, so it is never clipped by a scrolling column or an
1087
+ overflow-x table wrapper. pointer-events:none keeps it out of the way of the
1088
+ dragover hit-testing that positions it. */
1089
+ .pu-drag-marker {
1090
+ position: fixed;
1091
+ z-index: 60;
1092
+ display: none;
1093
+ pointer-events: none;
1094
+ background: theme(colors.primary.500);
1095
+ border-radius: var(--pu-radius-full, 9999px);
1096
+ }
1097
+
1098
+ /* `between` is already centred in the gap the layout leaves, so it only pulls
1099
+ back by half the line's thickness. The two ENDS touch a single item and have
1100
+ to be pushed clear of it — off the top of the first, below the bottom of the
1101
+ last. Tune the end clearance with --pu-drag-marker-gap. */
1102
+ .pu-drag-marker[data-axis="vertical"] {
1103
+ height: 2px;
1104
+ }
1105
+
1106
+ .pu-drag-marker[data-axis="vertical"][data-edge="between"] {
1107
+ margin-top: -1px;
1108
+ }
1109
+
1110
+ .pu-drag-marker[data-axis="vertical"][data-edge="leading"] {
1111
+ margin-top: calc(-1 * var(--pu-drag-marker-gap, 4px));
1112
+ }
1113
+
1114
+ .pu-drag-marker[data-axis="vertical"][data-edge="trailing"] {
1115
+ margin-top: var(--pu-drag-marker-gap, 4px);
1116
+ }
1117
+
1118
+ .pu-drag-marker[data-axis="horizontal"] {
1119
+ width: 2px;
1120
+ }
1121
+
1122
+ .pu-drag-marker[data-axis="horizontal"][data-edge="between"] {
1123
+ margin-left: -1px;
1124
+ }
1125
+
1126
+ .pu-drag-marker[data-axis="horizontal"][data-edge="leading"] {
1127
+ margin-left: calc(-1 * var(--pu-drag-marker-gap, 4px));
1128
+ }
1129
+
1130
+ .pu-drag-marker[data-axis="horizontal"][data-edge="trailing"] {
1131
+ margin-left: var(--pu-drag-marker-gap, 4px);
1132
+ }
1133
+
1134
+ /* Indeterminate progress for an async run with no denominator (opaque work).
1135
+ A short segment sweeping the track: the motion is what says "alive", which a
1136
+ static bar at any width cannot, and a full-width bar would misread as done. */
1137
+ .pu-run-progress-indeterminate {
1138
+ width: 33%;
1139
+ /* 1.4s must match AsyncRunProgress::SWEEP_DURATION_MS, which the server uses
1140
+ to compute the negative animation-delay that keeps the sweep continuous
1141
+ across polls. */
1142
+ animation: pu-run-progress-sweep 1.4s ease-in-out infinite;
1143
+ }
1144
+
1145
+ @keyframes pu-run-progress-sweep {
1146
+ 0% { margin-left: -33%; }
1147
+ 100% { margin-left: 100%; }
1148
+ }
1149
+
1150
+ @media (prefers-reduced-motion: reduce) {
1151
+ /* No sweep to convey liveness, so fall back to a full track rather than a
1152
+ segment frozen at an arbitrary position, which would read as a percentage. */
1153
+ .pu-run-progress-indeterminate {
1154
+ width: 100%;
1155
+ animation: none;
1156
+ opacity: 0.6;
1157
+ }
1158
+ }
@@ -159,10 +159,30 @@
159
159
  declare its own color rule. Specific rules below still win. */
160
160
  color: var(--pu-text);
161
161
  box-shadow: var(--pu-shadow-md);
162
+ /* pu-input (copied onto the panel, see the guard below) would round all
163
+ four corners. Keep the panel square by default — the open-above/below
164
+ rules add back the rounding on the outer edge only. */
165
+ border-radius: 0;
162
166
  transform: scaleY(0);
163
167
  transform-origin: top;
164
168
  }
165
169
 
170
+ /* SlimSelect copies the <select>'s classes onto both .ss-main and .ss-content.
171
+ pu-input / pu-input-(in)valid are trigger styling — on the panel their
172
+ translucent validation tints replace the surface background (worst in dark
173
+ mode, where `.dark .pu-input-invalid` outranks `.ss-content` and the panel
174
+ turns see-through). Re-assert the panel chrome at higher specificity. */
175
+ .ss-content.pu-input,
176
+ .ss-content.pu-input-invalid,
177
+ .ss-content.pu-input-valid,
178
+ .dark .ss-content.pu-input,
179
+ .dark .ss-content.pu-input-invalid,
180
+ .dark .ss-content.pu-input-valid {
181
+ background-color: var(--pu-surface);
182
+ border-color: var(--pu-border);
183
+ color: var(--pu-text);
184
+ }
185
+
166
186
  .ss-content.ss-relative {
167
187
  @apply relative h-full;
168
188
  }
@@ -0,0 +1,112 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="breadcrumbs"
4
+ //
5
+ // Folds the middle of the trail into an overflow menu, but only as far as it
6
+ // has to: segments are measured and dropped left-to-right until the trail fits
7
+ // on one line. The dashboard link and the last segment always survive.
8
+ //
9
+ // This is deliberately width-driven rather than breakpoint-driven — a trail
10
+ // with one short segment fits fine on a phone, and a trail with a long record
11
+ // title can overflow a laptop. A media query can't tell those apart.
12
+ //
13
+ // Every segment is `shrink-0` so measurements reflect natural widths and real
14
+ // overflow is detectable. Only once everything foldable is folded do we let the
15
+ // final segment shrink and ellipsize, as a last resort.
16
+ export default class extends Controller {
17
+ static targets = ["list", "item", "last", "overflow", "menuItem"]
18
+
19
+ connect() {
20
+ // The dropdown controller teleports its menu to <body> while open, which
21
+ // moves these rows out of our scope — hold direct references so a resize
22
+ // mid-open doesn't throw.
23
+ this.menuItems = this.menuItemTargets
24
+
25
+ this.observer = new ResizeObserver(() => this.reflow())
26
+ this.observer.observe(this.element)
27
+ this.reflow()
28
+
29
+ // Web fonts land after first paint and change every measurement.
30
+ document.fonts?.ready.then(() => this.reflow())
31
+ }
32
+
33
+ disconnect() {
34
+ this.observer.disconnect()
35
+ }
36
+
37
+ reflow() {
38
+ // `fonts.ready` can resolve after a Turbo navigation has already torn this
39
+ // element out, where every measurement reads 0 and we would "helpfully"
40
+ // unfold the whole trail in a detached tree.
41
+ if (!this.element.isConnected || !this.hasListTarget) return
42
+
43
+ // Reset to "everything inline" so we measure natural widths, not the
44
+ // previous pass's decisions. Nothing paints mid-reflow, so hiding the
45
+ // control here is invisible even when it ends up shown again below.
46
+ this.itemTargets.forEach((el) => this.#show(el))
47
+ if (this.hasOverflowTarget) this.#hide(this.overflowTarget)
48
+ if (this.hasLastTarget) this.lastTarget.classList.add("shrink-0")
49
+
50
+ if (this.#overflows()) {
51
+ // A trail with a single segment has nothing foldable and so no control.
52
+ if (this.hasOverflowTarget) {
53
+ // The control takes room too, so it has to be in place before we
54
+ // measure whether folding a given segment was enough.
55
+ this.#show(this.overflowTarget)
56
+
57
+ for (const item of this.itemTargets) {
58
+ if (!this.#overflows()) break
59
+ this.#hide(item)
60
+ }
61
+ }
62
+
63
+ // Nothing left to fold and it still doesn't fit: let the last segment
64
+ // ellipsize rather than push the trail off-screen.
65
+ if (this.#overflows() && this.hasLastTarget) {
66
+ this.lastTarget.classList.remove("shrink-0")
67
+ }
68
+ }
69
+
70
+ this.#syncMenu()
71
+ }
72
+
73
+ // Measured on the nav rather than the <ol>, because the nav is the element
74
+ // that actually clips (`overflow-hidden`). A non-scrolling box reports
75
+ // overflow inconsistently once direction is rtl; the clipping box doesn't.
76
+ #overflows() {
77
+ return this.element.scrollWidth > this.element.clientWidth
78
+ }
79
+
80
+ #show(el) {
81
+ el.classList.remove("hidden")
82
+ el.classList.add("flex")
83
+ }
84
+
85
+ #hide(el) {
86
+ el.classList.remove("flex")
87
+ el.classList.add("hidden")
88
+ }
89
+
90
+ // A menu row is shown exactly when its inline twin has been folded away.
91
+ #syncMenu() {
92
+ if (!this.hasOverflowTarget) return
93
+
94
+ const folded = this.itemTargets.map((el) => el.classList.contains("hidden"))
95
+
96
+ this.menuItems.forEach((row, i) => {
97
+ row.classList.toggle("hidden", !folded[i])
98
+ })
99
+
100
+ if (!folded.some(Boolean)) this.#closeOverflowMenu()
101
+ }
102
+
103
+ // The trail grew back far enough that the control is gone. Its menu may
104
+ // still be open — and teleported to <body>, so hiding the control does not
105
+ // hide the menu. Popper would go on positioning it against a display:none
106
+ // trigger, whose rect is all zeros, parking the menu in the viewport corner.
107
+ #closeOverflowMenu() {
108
+ const host = this.overflowTarget.querySelector('[data-controller~="resource-drop-down"]')
109
+ const dropdown = this.application.getControllerForElementAndIdentifier(host, "resource-drop-down")
110
+ if (dropdown?.visible) dropdown.hide()
111
+ }
112
+ }
@@ -56,9 +56,17 @@ export default class extends Controller {
56
56
  const baseUrl = button.dataset.bulkActionUrl
57
57
  const actionName = button.dataset.bulkActionName
58
58
 
59
- // Update URL with selected IDs
59
+ // Update URL with selected IDs.
60
+ //
61
+ // Merged into whatever query the server already put on the URL, not
62
+ // appended with a bare "?" — that discarded every existing param (and
63
+ // produced a malformed second "?" when there was one). The toolbar carries
64
+ // return_to there, which is what sends the user back to this index rather
65
+ // than stranding them on whatever the action redirects to.
60
66
  if (baseUrl) {
61
- button.href = idsParam ? `${baseUrl}?${idsParam}` : baseUrl
67
+ const [path, query] = baseUrl.split("?")
68
+ const merged = [query, idsParam].filter(Boolean).join("&")
69
+ button.href = merged ? `${path}?${merged}` : path
62
70
  }
63
71
 
64
72
  // Show/hide button based on whether action is allowed for all selected records
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { morphTurboFrameElements } from "@hotwired/turbo"
3
+ import { beginDrag, computeDropIndex, endDrag, hideInsertionMarker, showInsertionMarker } from "../drag/sortable"
3
4
 
4
5
  // Connects to data-controller="kanban"
5
6
  //
@@ -435,13 +436,14 @@ export default class extends Controller {
435
436
  if (!card) return
436
437
 
437
438
  this.draggedCard = card
438
- event.dataTransfer.effectAllowed = "move"
439
- // Store the id so native DnD still carries data if the card is dropped
440
- // outside the board (where we won't handle it, but no error).
441
- event.dataTransfer.setData("text/plain", card.dataset.kanbanRecordId)
442
-
443
- // Defer the opacity change so the drag ghost image is captured first.
444
- requestAnimationFrame(() => card.classList.add("pu-kanban-dragging"))
439
+ // Generic mechanics (payload + deferred dragging class) live in
440
+ // ../drag/sortable so the table/grid reorder controller shares them.
441
+ // The payload is the record id, so a card dropped outside the board still
442
+ // carries data natively (we won't handle it, but no error).
443
+ beginDrag(event, card, {
444
+ draggingClass: "pu-kanban-dragging",
445
+ payload: card.dataset.kanbanRecordId
446
+ })
445
447
 
446
448
  // Mark columns that would reject a drop from this card's source column.
447
449
  this.#applyDropHints(card.dataset.kanbanColumnKey)
@@ -459,6 +461,19 @@ export default class extends Controller {
459
461
  event.preventDefault()
460
462
  event.dataTransfer.dropEffect = "move"
461
463
  this.#highlightColumn(column)
464
+
465
+ // The column outline says WHICH column; this says which SLOT within it.
466
+ // Same exclusion and same index function #onDrop uses, so the line marks
467
+ // the position the drop will actually take.
468
+ const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
469
+ .filter(c => c !== this.draggedCard)
470
+ showInsertionMarker(existingCards, computeDropIndex(event.clientY, existingCards), {
471
+ axis: "vertical",
472
+ container: column,
473
+ // Cards sit further apart than table rows, so the table's clearance reads
474
+ // as cramped against a card's edge here.
475
+ gap: "10px"
476
+ })
462
477
  }
463
478
 
464
479
  #onDragLeave(event) {
@@ -467,12 +482,16 @@ export default class extends Controller {
467
482
  // the board wrapper when crossing the edge.
468
483
  if (!this.element.contains(event.relatedTarget)) {
469
484
  this.#clearHighlights()
485
+ hideInsertionMarker()
470
486
  }
471
487
  }
472
488
 
473
489
  async #onDrop(event) {
474
490
  event.preventDefault()
475
491
  this.#clearHighlights()
492
+ // On release, not on dragend: the marker should not outlive the gesture
493
+ // while the move is in flight.
494
+ hideInsertionMarker()
476
495
 
477
496
  if (!this.draggedCard) return
478
497
 
@@ -494,7 +513,9 @@ export default class extends Controller {
494
513
  const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
495
514
  .filter(c => c !== this.draggedCard)
496
515
 
497
- const toIndex = this.#computeDropIndex(event.clientY, existingCards)
516
+ // 0-based insertion index from the cursor's y-position against each card's
517
+ // vertical midpoint (shared geometry — see ../drag/sortable).
518
+ const toIndex = computeDropIndex(event.clientY, existingCards)
498
519
 
499
520
  // Columns that declare an enter_interaction, on a CROSS-column drop:
500
521
  // • immediate (input-less) interaction → commit directly via the normal
@@ -616,7 +637,7 @@ export default class extends Controller {
616
637
  this.#clearHighlights()
617
638
  this.#clearDropHints()
618
639
  if (this.draggedCard) {
619
- this.draggedCard.classList.remove("pu-kanban-dragging")
640
+ endDrag(this.draggedCard, { draggingClass: "pu-kanban-dragging" })
620
641
  this.draggedCard = null
621
642
  }
622
643
  }
@@ -693,18 +714,6 @@ export default class extends Controller {
693
714
 
694
715
  // ─── helpers ─────────────────────────────────────────────────────────────────
695
716
 
696
- // Returns the 0-based insertion index within the destination column by
697
- // comparing the cursor y-position against each card's vertical midpoint.
698
- // The card is inserted before the first card whose midpoint is below the
699
- // cursor, or appended after all cards if the cursor is below every midpoint.
700
- #computeDropIndex(clientY, cards) {
701
- for (let i = 0; i < cards.length; i++) {
702
- const rect = cards[i].getBoundingClientRect()
703
- if (clientY < rect.top + rect.height / 2) return i
704
- }
705
- return cards.length
706
- }
707
-
708
717
  #highlightColumn(column) {
709
718
  this.columnTargets.forEach(c => {
710
719
  c.classList.toggle("pu-kanban-drop-target", c === column)