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,506 @@
1
+ # Native drag-and-drop for positioned resources
2
+
3
+ **Date:** 2026-07-31
4
+ **Status:** Design approved, pending implementation plan
5
+
6
+ ## Problem
7
+
8
+ `Plutonium::Positioning` (`lib/plutonium/positioning.rb`) is already standalone and
9
+ kanban-independent. A model calls `positioned_on :position, scope: :project_id` and gets
10
+ automatic position assignment on create, `reposition!(prev_record:, next_record:)` with
11
+ fractional midpoints and gap rebalancing, and a `t.position` migration helper.
12
+
13
+ Nothing consumes it except kanban. A resource whose model is positioned still renders an
14
+ index table with no way to reorder it. The drag-and-drop machinery that would do so exists,
15
+ but is welded into `src/js/controllers/kanban_controller.js` (717 lines) and
16
+ `Plutonium::Resource::Controllers::KanbanActions`.
17
+
18
+ This design exposes reordering on ordinary collection surfaces — index tables, nested
19
+ association tables, and grid/card views — and extracts the drag mechanics so kanban and the
20
+ new surfaces share one implementation.
21
+
22
+ ## Goals
23
+
24
+ - A resource opts in with one line in its definition and gets drag-to-reorder on every
25
+ collection surface.
26
+ - The reposition endpoint is a first-class *hidden action*: real route, real policy
27
+ predicate, rendered nowhere.
28
+ - One drag implementation, shared with kanban.
29
+ - One positioning strategy machine, shared with kanban — including the block escape hatch
30
+ that lets `acts_as_list`, `positioning`, `ranked-model`, or anything else own the write.
31
+ - No regression to kanban's behaviour.
32
+
33
+ ## Non-goals
34
+
35
+ - Cross-page dragging. Reordering is within-page only.
36
+ - Realtime broadcast of reorders. Kanban has it; tables do not, and the design does not
37
+ preclude adding it later.
38
+ - Touch drag. Native HTML5 DnD does not fire on touch devices. This limitation is inherited
39
+ from the existing kanban implementation and is not addressed here.
40
+
41
+ ---
42
+
43
+ ## 1. Hidden actions
44
+
45
+ ### Current state
46
+
47
+ `Action::Base` carries a one-off `@kanban_drop` flag (`lib/plutonium/action/base.rb:26`,
48
+ `:84`, `:145`) meaning "this action exists for its route, policy predicate, and params
49
+ machinery, but must never render." Its only producer is
50
+ `lib/plutonium/definition/index_views.rb:148`, which auto-registers a column's
51
+ `enter_interaction`.
52
+
53
+ Four render sites filter on it:
54
+
55
+ | Site | Selects |
56
+ |---|---|
57
+ | `lib/plutonium/ui/page/index.rb:36` | resource actions |
58
+ | `lib/plutonium/ui/page/show.rb:18` | record actions |
59
+ | `lib/plutonium/ui/table/resource.rb:163` | row actions |
60
+ | `lib/plutonium/ui/grid/card.rb:303` | card actions |
61
+
62
+ Two further sites do **not** filter — both bulk-action selectors:
63
+
64
+ | Site | Selects |
65
+ |---|---|
66
+ | `lib/plutonium/ui/grid/resource.rb:95` | `.select { \|k, a\| a.bulk_action? }` |
67
+ | `lib/plutonium/ui/table/resource.rb:189` | `.select { \|k, a\| a.bulk_action? && a.condition_met?(view_context) }` |
68
+
69
+ Today that gap is plugged by an `ArgumentError` in `Plutonium::Kanban::Column`
70
+ (`lib/plutonium/kanban/column.rb:38`) which rejects collection-shaped `enter_interaction`s
71
+ specifically so they cannot be classified as bulk actions and leak into the bulk bar.
72
+
73
+ ### Change
74
+
75
+ Rename the concept to `hidden`, and expose it on the action DSL:
76
+
77
+ ```ruby
78
+ action :reposition, hidden: true
79
+ action :ping_webhook, interaction: PingWebhook, hidden: true
80
+ ```
81
+
82
+ A hidden action gets its route, its `name?` policy predicate, and — if interactive — its
83
+ form and param extraction. It renders in no toolbar, row dropdown, card, or bulk bar.
84
+
85
+ - `Action::Base#kanban_drop?` becomes `#hidden?`; `kanban_drop:` is removed from
86
+ `initialize` and `to_options`. It is internal, so there is no deprecation path.
87
+ - All six sites above filter on `!a.hidden?`, including both bulk-action selectors. The
88
+ `ArgumentError` in `Kanban::Column` stays as a defence-in-depth check with its comment
89
+ updated — it is no longer the only thing preventing the leak.
90
+ - `index_views.rb:148` passes `hidden: true`.
91
+
92
+ `hidden?` is a **display** gate, not an authorization boundary — the same contract as
93
+ `condition:` (`lib/plutonium/action/base.rb:98`). Authorization stays in the policy.
94
+
95
+ ---
96
+
97
+ ## 2. Shared positioning strategy
98
+
99
+ `Plutonium::Kanban::Positioning::Config` (`lib/plutonium/kanban/positioning.rb`) already
100
+ implements exactly the strategy machine tables need — three modes, built by kanban's
101
+ `position_on` DSL (`lib/plutonium/kanban/dsl.rb:46`):
102
+
103
+ | Mode | Built by | Ordering | Write |
104
+ |---|---|---|---|
105
+ | A `:delegate` | `position_on` / `position_on :attr` | `reorder(attr)` | `record.reposition!(prev_record:, next_record:)` |
106
+ | B `:block` | `position_on(:attr) { \|move\| … }` | `reorder(attr)` | the block, given a `Move` |
107
+ | C `:disabled` | `position_on false` | unchanged | no-op |
108
+
109
+ Mode B is the escape hatch for other positioning gems. The framework orders by the declared
110
+ attribute and delegates the write entirely:
111
+
112
+ ```ruby
113
+ position_on :position do |move|
114
+ move.record.insert_at(move.index + 1) # acts_as_list
115
+ end
116
+ ```
117
+
118
+ It is promoted out of the kanban namespace to `Plutonium::Positioning::Config`, alongside
119
+ `Plutonium::Positioning::Move`. Both the kanban block and the definition build the same
120
+ Config. `Move#column` is `nil` outside a board; every other field is identical.
121
+ `Plutonium::Kanban::Positioning` becomes a thin alias so kanban's existing `position_on`
122
+ surface is unchanged.
123
+
124
+ `column` is defaulted on `Config#reposition!`, not on `Move` itself. `Move` has exactly one
125
+ construction site — inside `reposition!` — so defaulting the member rather than the parameter
126
+ would mean every caller passing an explicit `column: nil` to satisfy a required keyword,
127
+ which is the opposite of what the default is for.
128
+
129
+ This mirrors the JS drag-core extraction (§5.1) — same reasoning, same shape: one machine,
130
+ two consumers.
131
+
132
+ ### 2.0 Namespace layout
133
+
134
+ `Plutonium::Positioning` becomes a **pure namespace**; the ActiveRecord concern moves to
135
+ `Plutonium::Positioning::Model`.
136
+
137
+ | Constant | Kind | Notes |
138
+ |---|---|---|
139
+ | `Plutonium::Positioning` | namespace | `EPSILON`, `.position_between`, `.gap_exhausted?` |
140
+ | `Plutonium::Positioning::Model` | AR concern | `positioned_on`, `reposition!`, `backfill_positions!` |
141
+ | `Plutonium::Positioning::Config` | strategy | the three modes |
142
+ | `Plutonium::Positioning::Move` | value object | Mode B block argument |
143
+ | `Plutonium::Positioning::MigrationHelpers` | AR table-definition mixin | `t.position` |
144
+
145
+ This is a **breaking change**: `include Plutonium::Positioning` becomes
146
+ `include Plutonium::Positioning::Model`. The gem is pre-1.0 and the update is mechanical
147
+ (two dummy models, two test files, four doc locations).
148
+
149
+ The reason is constant leakage. A concern that users `include` into their models puts every
150
+ constant nested inside it into those models' constant lookup — so with `Config` directly
151
+ under the concern, a bare `Config` written inside such a model resolves to Plutonium's and
152
+ silently shadows the application's own:
153
+
154
+ ```ruby
155
+ class Task < ApplicationRecord
156
+ include Plutonium::Positioning # the old shape
157
+ def self.thing = Config # => Plutonium::Positioning::Config, not ::Config
158
+ end
159
+ ```
160
+
161
+ `Config` and `Move` are both names an application is likely to own. Making the namespace pure
162
+ means nothing Plutonium-owned enters a user's ancestor chain at all, which is why this is
163
+ preferred over the cheaper fix of renaming the constants to something less collision-prone.
164
+
165
+ `MigrationHelpers` stays on the namespace: the railtie mixes it into ActiveRecord's
166
+ table-definition classes (`lib/plutonium/railtie.rb:77-78`), never into user models, so it
167
+ leaks nowhere.
168
+
169
+ ## 2.1 `position_on` — the definition DSL
170
+
171
+ ```ruby
172
+ class TaskDefinition < Plutonium::Resource::Definition
173
+ position_on
174
+ end
175
+
176
+ class Task < ApplicationRecord
177
+ include Plutonium::Positioning::Model
178
+ positioned_on :position, scope: :project_id
179
+ end
180
+ ```
181
+
182
+ Deliberately the **same verb as kanban's**, not a new one. The signature is identical:
183
+
184
+ ```ruby
185
+ position_on # Mode A, attribute :position
186
+ position_on :sort_order # Mode A, custom attribute
187
+ position_on(:rank) { |move| … } # Mode B, another gem owns the write
188
+ position_on false # Mode C, ordering off
189
+ ```
190
+
191
+ Reusing the verb keeps the framework at **two** positioning verbs rather than three:
192
+ `positioned_on` on the model (how positions are stored) and `position_on` everywhere in the
193
+ definition layer (what is orderable). A third name would have been one more thing to learn
194
+ and one more near-miss to typo.
195
+
196
+ `positioned_on` and `position_on` differ by two letters, which is a genuine footgun — but a
197
+ **pre-existing** one, since both already coexist today. Reusing `position_on` does not
198
+ worsen it; introducing a third verb would have.
199
+
200
+ In **Mode A** the model remains the single owner of *how* positions are stored — column and
201
+ scope. The definition restates neither; `positioned_on`'s `scope:` is never duplicated at the
202
+ definition layer. A boot-time error fires if the model does not `include Plutonium::Positioning::Model`.
203
+
204
+ In **Mode B** that check does not apply — the model need not include the concern at all, since
205
+ it never calls `reposition!`. The attribute is still required, because ordering and the
206
+ drag-enabled check (§4) key off it.
207
+
208
+ `position_on` expands to:
209
+
210
+ ```ruby
211
+ sort <attribute>
212
+ default_sort <attribute>, :asc
213
+ action :reposition, hidden: true
214
+ # + drag affordances on the resource's collection surfaces
215
+ ```
216
+
217
+ Registering `sort <attribute>` is load-bearing, not cosmetic. Dragging is only permitted when
218
+ the collection is ordered by that attribute (§4); without a registered sort, `?sort=position`
219
+ is not a permitted sort and there is no route back out of the disabled state.
220
+
221
+ Each expansion is a normal declaration and can be overridden after the fact. A definition
222
+ that writes `default_sort :name` after `position_on` gets the disabled-grip state by default,
223
+ which is a legitimate choice.
224
+
225
+ Mode C registers nothing: no sort, no action, no grips. It exists so a resource can inherit a
226
+ positioned definition and switch ordering off.
227
+
228
+ ### 2.2 Board inheritance
229
+
230
+ A kanban board inherits the definition's `position_on`; a `position_on` inside the
231
+ `kanban do…end` block overrides it for the board only.
232
+
233
+ ```ruby
234
+ class TaskDefinition < Plutonium::Resource::Definition
235
+ position_on :position # table, grid, nested tables, AND the board
236
+
237
+ kanban do
238
+ columns :todo, :doing, :done
239
+ end
240
+ end
241
+ ```
242
+
243
+ This is what makes the shared verb honest. Without inheritance, `position_on` would mean two
244
+ different things depending on which scope it was written in — worse than having two different
245
+ names. With it, there is one concept, declared once, overridable where it needs to differ.
246
+
247
+ It mirrors `show_in`, which already works this way: `Board#show_in_for(definition)`
248
+ (`lib/plutonium/kanban/board.rb:37`) is `@show_in || definition.show_in`.
249
+
250
+ **Implementation note.** A board currently *always* has a config —
251
+ `Plutonium::Kanban::DSL` seeds `@position_config = Positioning::Config.default` in its
252
+ constructor (`lib/plutonium/kanban/dsl.rb:18`) — so "not declared" and "declared as the
253
+ default" are indistinguishable. That seed must become `nil`, with resolution moving to a new
254
+ `Board#position_config_for(definition)`:
255
+
256
+ ```ruby
257
+ def position_config_for(definition)
258
+ @position_config || definition.defined_position_config || Positioning::Config.default
259
+ end
260
+ ```
261
+
262
+ Resolution must be **lazy**, not resolved at board-build time. `kanban` eagerly compiles the
263
+ board at class-load (`lib/plutonium/definition/index_views.rb:121`), so a board built before
264
+ a later `position_on` line would silently miss it — making the declaration order-dependent,
265
+ which is exactly the kind of footgun this feature should not ship with.
266
+
267
+ The five `board.position_config` call sites in `KanbanActions` (lines 136, 259, 581, 733, 784)
268
+ become `board.position_config_for(current_definition)`. This is kanban's third touchpoint in
269
+ this change, and the most mechanical of the three.
270
+
271
+ ### Scope on nested resources
272
+
273
+ If `Comment` is `positioned_on :position` with no scope but `CommentDefinition` is nested
274
+ under `Post`, positions are global across all comments. Reordering within one post still
275
+ behaves correctly — a fractional insert between two visible neighbours lands in the right
276
+ place regardless. Only a rebalance renumbers more rows than the author expects.
277
+
278
+ This is a documented contract, not a runtime check. No boot warning, no derivation of scope
279
+ from the request. `scope:` is the model author's job, covered in the docs and the
280
+ `plutonium-resource` skill.
281
+
282
+ ---
283
+
284
+ ## 3. Server — the reposition endpoint
285
+
286
+ `POST <member>/reposition`, params `{prev_id:, next_id:}` — the ids of the dropped row's
287
+ visible neighbours. Either is nullable for a drop at an end of the list.
288
+
289
+ New concern `Plutonium::Resource::Controllers::PositionActions`, routed from
290
+ `lib/plutonium/routing/mapper_extensions.rb` alongside the kanban routes (lines 154-155).
291
+
292
+ ### Flow
293
+
294
+ 1. `record = current_authorized_scope.find(params[:id])` — satisfies the scope verifier.
295
+ 2. `authorize_current! record, to: :reposition?`. `reposition?` is added to
296
+ `Plutonium::Resource::Policy` defaulting to `update?`.
297
+ 3. Resolve `prev_id` and `next_id` **within the same authorized scope**. A neighbour id that
298
+ does not resolve is treated as drift, not as `nil` — a `nil` would silently mean "drop at
299
+ the end", which is a different and wrong outcome.
300
+ 4. `config.reposition!(record:, column: nil, prev_record:, next_record:, index:)` — the shared
301
+ Config from §2, so Mode A and Mode B are dispatched identically to how kanban does it. In
302
+ Mode A this reaches `record.reposition!`, which already handles the exhausted-gap rebalance
303
+ internally (`lib/plutonium/positioning.rb:88`).
304
+ 5. Respond per §3.1.
305
+
306
+ ### 3.1 Response
307
+
308
+ The client has already moved the row optimistically, so the common case needs no payload.
309
+
310
+ | Case | Response |
311
+ |---|---|
312
+ | Mode A, clean drop, no rebalance, both neighbours resolved | `204 No Content` |
313
+ | Mode A, `reposition!` triggered a rebalance | `turbo_stream.update` the tbody |
314
+ | Mode B, any successful drop | `turbo_stream.update` the tbody |
315
+ | A neighbour id did not resolve in scope (drift) | `turbo_stream.update` the tbody |
316
+ | Policy denial | `403` + unchanged tbody + toast |
317
+ | Validation failure / record gone | `422` + unchanged tbody + toast |
318
+
319
+ Mode B **always** reconciles. The block is an opaque write — the framework cannot know what it
320
+ did, and gems in this space routinely renumber the whole group on every move (`acts_as_list`
321
+ does). Optimistically returning `204` there would leave the client's view stale in the common
322
+ case rather than the rare one, so Mode B trades the empty-response optimisation for
323
+ correctness.
324
+
325
+ Rejections mirror `render_kanban_rejection` (`kanban_actions.rb:803`): re-render the
326
+ collection unchanged so the row snaps back, and append a toast explaining why.
327
+
328
+ ### 3.2 Rebalance signal
329
+
330
+ The Mode A row above needs to know whether `reposition!` rebalanced. Today it returns the
331
+ result of `update!` and discards that fact (`lib/plutonium/positioning.rb:84-94`).
332
+
333
+ `reposition!` will return a small result value carrying `rebalanced?` rather than have the
334
+ controller re-derive it by comparing positions before and after — which would be both racy
335
+ and a duplication of the concern's own gap logic.
336
+
337
+ `Config#reposition!` surfaces this: `rebalanced?` in Mode A, and unconditionally `true` in
338
+ Mode B (per §3.1), so the controller has one thing to branch on and no mode-awareness of its
339
+ own.
340
+
341
+ ### 3.3 Rescues
342
+
343
+ Mirror the kanban handlers (`kanban_actions.rb:331-372`), which encode several non-obvious
344
+ lessons worth preserving:
345
+
346
+ - `rescue ::ActionPolicy::Unauthorized` — the leading `::` is required. `Plutonium::ActionPolicy`
347
+ exists, so a bare constant resolves to that namespace and never matches.
348
+ - `ActiveRecord::RecordNotFound` — the row was destroyed between render and drop.
349
+ - `ActiveRecord::RecordInvalid` — a model callback left the record invalid.
350
+
351
+ Each streams a snap-back rather than letting an HTML error page get morphed into the table.
352
+ Where a rescue fires before `authorize_current!` has bumped its counter, call
353
+ `skip_verify_authorize_current!`.
354
+
355
+ ---
356
+
357
+ ## 4. When dragging is permitted
358
+
359
+ Dragging is enabled only when the collection's effective ordering is the attribute declared
360
+ on `position_on` (§2.1) — `:position` by default, whatever the author named otherwise.
361
+
362
+ | State | Behaviour |
363
+ |---|---|
364
+ | Ordered by the declared attribute | Grips live |
365
+ | Sorted by any other column | Grips disabled |
366
+ | Search / filter / scope active, ordered by the attribute | Grips live |
367
+ | Page 2+ | Grips live, within-page only |
368
+ | Mode C (`position_on false`) | No grips at all |
369
+
370
+ Filters stay draggable. Dropping between two visible neighbours is well-defined even when
371
+ rows are hidden between them — the record lands somewhere among the hidden rows, exactly as
372
+ kanban behaves on a filtered board.
373
+
374
+ A foreign sort is different in kind, not degree: under a name sort the visible neighbours are
375
+ in arbitrary position order, so `prev` may hold a *higher* position than `next` and
376
+ `position_between` would produce a value that is not between them. Disabling is a
377
+ correctness requirement, not a UX preference.
378
+
379
+ ### Escaping the disabled state
380
+
381
+ A disabled grip is a `<button>` that applies the position sort when clicked, labelled
382
+ "Sort by position to reorder". The disabled affordance is itself the way to re-enable it.
383
+
384
+ This is why `position_on` registers `sort <attribute>`. It also sidesteps a real problem: the
385
+ position column is a decimal nobody wants rendered, and
386
+ `lib/plutonium/ui/table/resource.rb:154` only produces a sort control
387
+ (`current_query_object.sort_params_for(name)`) for *displayed* columns. There is no header to
388
+ click, so the grip carries the control instead.
389
+
390
+ ---
391
+
392
+ ## 5. Client
393
+
394
+ ### 5.1 Shared core
395
+
396
+ Extract the pure drag mechanics from `kanban_controller.js` into `src/js/drag/sortable.js`:
397
+ dragstart/dragover/drop wiring, drop-indicator placement, insertion-index computation, and
398
+ snap-back. It knows nothing about columns, WIP limits, or boards.
399
+
400
+ Consumers:
401
+
402
+ - `src/js/controllers/positioned_controller.js` (new) — tables, nested tables, grids.
403
+ Registered in `src/js/controllers/register_controllers.js`.
404
+ - `src/js/controllers/kanban_controller.js` (migrated) — retains everything board-specific:
405
+ columns, collapse, lazy frames, WIP, drop interactions, realtime broadcast.
406
+
407
+ The extraction is strictly behaviour-preserving. No drag semantics change while code moves.
408
+ Kanban's existing suite is the regression gate.
409
+
410
+ ### 5.2 The affordance
411
+
412
+ The grip lives inside the row's **first cell**, in its left padding. There is no dedicated
413
+ grip column.
414
+
415
+ ```
416
+ ┌──────┬───────────────┬─────────┐
417
+ │ ☐ │ Name │ Status │
418
+ ├──────┼───────────────┼─────────┤
419
+ │ ☐ │ Ship v2 │ Open │ idle — grip hidden
420
+ │ ☐ │☷ Fix login │ Open │ hovered — grip revealed
421
+ │ ☐ │ Write docs │ Done │ idle
422
+ └──────┴───────────────┴─────────┘
423
+ ```
424
+
425
+ - Revealed on row hover; always visible on keyboard focus.
426
+ - **Only the grip carries `draggable="true"`**, never the `<tr>`.
427
+
428
+ That last point is the whole reason for this shape. `draggable="true"` on an element
429
+ disables text selection within it in every major browser — on a kanban card that costs
430
+ nothing, but on a data table it would silently remove the ability to select and copy a cell
431
+ value. A draggable row would also fight `row_click_controller.js`
432
+ (`src/js/controllers/row_click_controller.js:16`), which already makes the entire row the
433
+ Show affordance: a drag that starts and drops in place fires a click and navigates away.
434
+
435
+ Kanban keeps whole-card dragging. The inconsistency is deliberate — neither concern applies
436
+ to a card — and should be stated in the docs rather than smoothed over.
437
+
438
+ ### 5.3 Keyboard support
439
+
440
+ The grip is a real `<button>`, so it is focusable and reachable by tab. When focused,
441
+ `ArrowUp` / `ArrowDown` move the row one slot, posting to the same reposition endpoint with
442
+ the appropriate neighbours.
443
+
444
+ Native HTML5 DnD is mouse-only, so without this the feature is unusable by keyboard. The
445
+ cost is small because the endpoint and neighbour computation already exist.
446
+
447
+ ---
448
+
449
+ ## 6. Surfaces
450
+
451
+ | Surface | Affordance | Notes |
452
+ |---|---|---|
453
+ | Index table | Hover grip in first cell | Grip doubles as sort-by-position when disabled |
454
+ | Nested / association table | Same | Neighbours resolve within the parent-scoped `current_authorized_scope` |
455
+ | Grid / card index | Hover grip on the card | `lib/plutonium/ui/grid/card.rb` |
456
+ | Kanban board | Unchanged — whole card | Migrated onto the shared core, behaviour identical |
457
+
458
+ ---
459
+
460
+ ## 7. Testing
461
+
462
+ - `test/plutonium/positioning_test.rb` — extend for the `rebalanced?` result value (§3.2).
463
+ - `test/plutonium/kanban/positioning_test.rb` — must pass unchanged against the promoted
464
+ `Plutonium::Positioning::Config` (§2), proving the move is behaviour-preserving. New cases
465
+ for `Move#column` being nil off-board, and for `position_on` building each of the three modes.
466
+ - Mode B integration: a dummy-app resource backed by a non-Plutonium positioning strategy,
467
+ asserting the block receives a correct `Move` and that the response always reconciles (§3.1).
468
+ - New hidden-action unit tests: route and policy predicate live; absent from all six render
469
+ sites, explicitly including both bulk-action selectors
470
+ (`lib/plutonium/ui/grid/resource.rb:95`, `lib/plutonium/ui/table/resource.rb:189`), which
471
+ are the gap this change closes.
472
+ - New `reposition` controller tests: authorization, neighbour-outside-scope drift, rebalance
473
+ reconciliation, each rejection path's snap-back.
474
+ - Kanban's existing suite is the regression gate for the JS extraction — it must pass
475
+ unchanged.
476
+ - System test driving a real drag in `test/dummy`, plus a keyboard reorder.
477
+
478
+ ## Risks
479
+
480
+ **Kanban is touched three times** — the `Positioning::Config` promotion (§2), the lazy
481
+ `position_config_for` resolution (§2.2), and the JS drag-core extraction (§5.1). All three
482
+ reach into a large, working, heavily-tested feature. They are independent of one another,
483
+ which is the mitigation: each is behaviour-preserving, each has kanban's existing suite as
484
+ its gate, and any can be abandoned without the others.
485
+
486
+ They are not equally risky. The Config promotion is a namespace move behind an alias, and
487
+ `position_config_for` is a mechanical change across five call sites — both are low. The JS
488
+ extraction is the outlier, because it touches drag behaviour itself. If it proves invasive,
489
+ shipping `positioned_controller.js` standalone and migrating kanban separately remains a
490
+ valid fallback, at the cost of two drag implementations that will drift.
491
+
492
+ **`default_sort` override is implicit.** `position_on` silently changes a resource's default
493
+ ordering from `:id, :desc` (`lib/plutonium/definition/sorting.rb:25`) to `:position, :asc`.
494
+ This is the correct default, but it is action at a distance and must be documented
495
+ prominently.
496
+
497
+ ## Documentation
498
+
499
+ - `docs/reference/` — a positioning page covering `positioned_on`, `position_on`, the model/
500
+ definition split, the three modes, and the nested-scope contract from §2.1.
501
+ - A worked Mode B example for at least one third-party gem (`acts_as_list` is the most
502
+ common), since "can I use my existing positioning gem" is the first question this feature
503
+ will attract.
504
+ - Hidden actions in the actions reference.
505
+ - Update the `plutonium-resource` and `plutonium-ui` skills.
506
+ - Note the deliberate table-vs-kanban affordance difference (§5.2).