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
@@ -0,0 +1,1787 @@
1
+ # Positioned Drag-and-Drop Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Give any Plutonium resource whose model is positioned native drag-to-reorder on its index table, nested association tables, and grid/card views — reusing kanban's positioning strategy machine and drag mechanics rather than duplicating them.
6
+
7
+ **Architecture:** `Plutonium::Kanban::Positioning::Config` is promoted to `Plutonium::Positioning::Config` so kanban's `position_on` and a new definition-level `position_on` DSL build the same three-mode strategy (delegate / block / disabled). A new `hidden: true` action flag generalises the existing one-off `kanban_drop?`, giving the reposition endpoint a route and policy predicate without rendering a button. Drag mechanics are extracted from `kanban_controller.js` into a shared `src/js/drag/sortable.js` consumed by both kanban and a new `positioned_controller.js`.
8
+
9
+ **Tech Stack:** Ruby/Rails (Rails 7, 8.0, 8.1 via Appraisal), Minitest, Phlex components, Stimulus, native HTML5 drag-and-drop, Turbo Streams, TailwindCSS 4.
10
+
11
+ **User Verification:** NO — no user verification required. The spec asks for a framework feature; verification is by automated test plus a system test driving a real drag.
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md`
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ **Created**
20
+
21
+ | File | Responsibility |
22
+ |---|---|
23
+ | `lib/plutonium/positioning/config.rb` | The three-mode strategy machine + `Move` value object. Framework-wide, dependency-free. |
24
+ | `lib/plutonium/positioning/model.rb` | The ActiveRecord concern, moved out of the namespace so its constants stop leaking into user models. |
25
+ | `lib/plutonium/definition/positioning.rb` | The `position_on` definition DSL and its expansion. |
26
+ | `lib/plutonium/resource/controllers/position_actions.rb` | The `reposition` endpoint. |
27
+ | `lib/plutonium/ui/table/components/drag_handle.rb` | The grip Phlex component (table + grid). |
28
+ | `src/js/drag/sortable.js` | Shared native-DnD mechanics, no framework knowledge. |
29
+ | `src/js/controllers/positioned_controller.js` | Stimulus controller for tables/grids. |
30
+ | `test/plutonium/positioning/config_test.rb` | Config modes. |
31
+ | `test/plutonium/definition/positioning_test.rb` | `position_on` DSL expansion. |
32
+ | `test/plutonium/action/hidden_action_test.rb` | Hidden-action render exclusion. |
33
+ | `test/plutonium/resource/controllers/position_actions_test.rb` | Endpoint behaviour. |
34
+ | `test/system/positioned_drag_test.rb` | Real drag + keyboard reorder. |
35
+
36
+ **Modified**
37
+
38
+ | File | Change |
39
+ |---|---|
40
+ | `lib/plutonium/positioning.rb` | Becomes a pure namespace (`EPSILON`, `.position_between`, `.gap_exhausted?`, `MigrationHelpers`); requires config + model. |
41
+ | `test/dummy/app/models/task.rb`, `kitchen_sink.rb`, `test/plutonium/positioning_test.rb`, `test/positioning_postgres_check.rb`, `docs/reference/kanban/{positioning,dsl}.md`, `docs/guides/kanban.md` | `include Plutonium::Positioning` → `::Model`. |
42
+ | `lib/plutonium/kanban/positioning.rb` | Becomes a thin alias to the promoted constants. |
43
+ | `lib/plutonium/action/base.rb:26,84,145` | `kanban_drop` → `hidden`. |
44
+ | `lib/plutonium/definition/index_views.rb:148` | Passes `hidden: true`. |
45
+ | `lib/plutonium/definition/base.rb:28-40` | `include Positioning`. |
46
+ | `lib/plutonium/ui/page/index.rb:36`, `page/show.rb:18`, `table/resource.rb:163`, `grid/card.rb:303` | `kanban_drop?` → `hidden?`. |
47
+ | `lib/plutonium/ui/table/resource.rb:189`, `grid/resource.rb:95` | Add the missing `!a.hidden?` filter. |
48
+ | `lib/plutonium/ui/table/resource.rb:~152` | Wrap the first column's block to emit the grip. |
49
+ | `lib/plutonium/resource/policy.rb` | Add `reposition?`. |
50
+ | `lib/plutonium/routing/mapper_extensions.rb:~155` | Add the `reposition` member route. |
51
+ | `src/js/controllers/kanban_controller.js` | Consumes `sortable.js`. |
52
+ | `src/js/controllers/register_controllers.js` | Register `positioned`. |
53
+
54
+ ---
55
+
56
+ ## Task Ordering Rationale
57
+
58
+ Tasks 1–2 (Config promotion, rebalance signal) are pure refactors of existing tested code and land first so everything downstream builds on the final shapes. Task 3 (hidden actions) is independent and could run in parallel. Tasks 4–6 build the server feature. Tasks 7–10 build the client. Task 11 verifies end-to-end.
59
+
60
+ **Kanban is touched in Tasks 1, 4, and 6.** All three are behaviour-preserving, and kanban's existing suite is the gate for each:
61
+
62
+ | Task | Kanban change | Risk |
63
+ |---|---|---|
64
+ | 1 | `Positioning::Config` moves namespace, aliased back | Low — pure namespace move |
65
+ | 4 | `position_config` resolution becomes lazy via `position_config_for` | Low — mechanical, 5 call sites |
66
+ | 6 | Drag mechanics extracted to a shared module | **Highest** — touches drag behaviour itself |
67
+
68
+ Task 6 is the one to abandon first if it proves invasive; Tasks 1–5 stand without it (see spec Risks).
69
+
70
+ ---
71
+
72
+ ### Task 1: Promote `Positioning::Config` out of the kanban namespace
73
+
74
+ **Goal:** One strategy machine, in `Plutonium::Positioning`, with kanban's public surface unchanged.
75
+
76
+ **Files:**
77
+ - Create: `lib/plutonium/positioning/config.rb`
78
+ - Modify: `lib/plutonium/kanban/positioning.rb` (becomes an alias)
79
+ - Modify: `lib/plutonium/positioning.rb` (require the new file)
80
+ - Test: `test/plutonium/positioning/config_test.rb` (new)
81
+ - Test: `test/plutonium/kanban/positioning_test.rb` (must pass unchanged)
82
+
83
+ **Acceptance Criteria:**
84
+ - [ ] `Plutonium::Positioning::Config` and `Plutonium::Positioning::Move` exist with the three modes
85
+ - [ ] `Plutonium::Kanban::Positioning::Config` still resolves, to the same object
86
+ - [ ] `Move` accepts `column:` and defaults it to `nil`
87
+ - [ ] `test/plutonium/kanban/positioning_test.rb` passes **unmodified**
88
+
89
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` → all pass, 0 failures
90
+
91
+ **Steps:**
92
+
93
+ - [ ] **Step 1: Write the failing test for the promoted constants**
94
+
95
+ Create `test/plutonium/positioning/config_test.rb`:
96
+
97
+ ```ruby
98
+ # frozen_string_literal: true
99
+
100
+ require "test_helper"
101
+
102
+ module Plutonium
103
+ module Positioning
104
+ class ConfigTest < Minitest::Test
105
+ def test_kanban_namespace_still_resolves_to_the_promoted_class
106
+ assert_same Plutonium::Positioning::Config, Plutonium::Kanban::Positioning::Config
107
+ assert_same Plutonium::Positioning::Move, Plutonium::Kanban::Positioning::Move
108
+ end
109
+
110
+ def test_move_column_defaults_to_nil_off_board
111
+ move = Plutonium::Positioning::Move.new(
112
+ record: :rec, prev: nil, next: nil, index: 0
113
+ )
114
+ assert_nil move.column
115
+ end
116
+
117
+ def test_disabled_mode_leaves_the_relation_untouched
118
+ relation = Object.new
119
+ config = Plutonium::Positioning::Config.disabled
120
+ assert config.disabled?
121
+ assert_same relation, config.order(relation)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ - [ ] **Step 2: Run it and watch it fail**
129
+
130
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
131
+ Expected: FAIL — `NameError: uninitialized constant Plutonium::Positioning::Config`
132
+
133
+ - [ ] **Step 3: Move the code**
134
+
135
+ Create `lib/plutonium/positioning/config.rb` containing the `Config` class and `Move` currently in `lib/plutonium/kanban/positioning.rb`, namespaced under `Plutonium::Positioning`. Two changes from the original:
136
+
137
+ ```ruby
138
+ # frozen_string_literal: true
139
+
140
+ module Plutonium
141
+ module Positioning
142
+ # Value object passed to Mode B blocks, carrying the full drop context.
143
+ Move = Data.define(:record, :column, :prev, :next, :index)
144
+
145
+ # ... Config class verbatim from lib/plutonium/kanban/positioning.rb,
146
+ # including .default / .attribute / .with_block / .disabled,
147
+ # #disabled? / #order / #reposition!
148
+ end
149
+ end
150
+ ```
151
+
152
+ Keep every comment from the original — they document the three modes and are the reason this class is comprehensible.
153
+
154
+ - [ ] **Step 4: Turn the kanban file into an alias**
155
+
156
+ Replace the body of `lib/plutonium/kanban/positioning.rb`:
157
+
158
+ ```ruby
159
+ # frozen_string_literal: true
160
+
161
+ require "plutonium/positioning"
162
+
163
+ module Plutonium
164
+ module Kanban
165
+ # Kanban's positioning strategy is the framework-wide one — see
166
+ # Plutonium::Positioning::Config. This alias preserves the original
167
+ # namespace so `position_on` and every existing reference keep working.
168
+ module Positioning
169
+ Config = Plutonium::Positioning::Config
170
+ Move = Plutonium::Positioning::Move
171
+ end
172
+ end
173
+ end
174
+ ```
175
+
176
+ - [ ] **Step 5: Require the config from the positioning concern**
177
+
178
+ At the top of `lib/plutonium/positioning.rb`, after the `frozen_string_literal` comment:
179
+
180
+ ```ruby
181
+ require "plutonium/positioning/config"
182
+ ```
183
+
184
+ - [ ] **Step 6: Run both suites**
185
+
186
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
187
+ Expected: PASS
188
+
189
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb`
190
+ Expected: PASS, **with the file unmodified** — this is what proves the move is behaviour-preserving.
191
+
192
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/dsl_test.rb`
193
+ Expected: PASS
194
+
195
+ - [ ] **Step 7: Commit**
196
+
197
+ ```bash
198
+ git add lib/plutonium/positioning/config.rb lib/plutonium/positioning.rb \
199
+ lib/plutonium/kanban/positioning.rb test/plutonium/positioning/config_test.rb
200
+ git commit -m "refactor(positioning): promote Config out of the kanban namespace"
201
+ ```
202
+
203
+ ---
204
+
205
+ ### Task 2: `reposition!` reports whether it rebalanced
206
+
207
+ **Goal:** The controller can tell a clean drop from one that renumbered the group, without re-deriving it.
208
+
209
+ **Files:**
210
+ - Modify: `lib/plutonium/positioning/model.rb:64-77` (`reposition!` — moved here in Task 1)
211
+ - Modify: `lib/plutonium/positioning/config.rb` (`#reposition!` return value)
212
+ - Test: `test/plutonium/positioning_test.rb`
213
+ - Test: `test/plutonium/positioning/config_test.rb`
214
+
215
+ **Acceptance Criteria:**
216
+ - [ ] `reposition!` returns an object responding to `rebalanced?`
217
+ - [ ] `rebalanced?` is `true` only when the gap was exhausted and the group was renumbered
218
+ - [ ] `Config#reposition!` returns `rebalanced?` in Mode A, `true` in Mode B, `false` in Mode C
219
+ - [ ] Existing positioning tests still pass
220
+
221
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` → all pass
222
+
223
+ **Steps:**
224
+
225
+ - [ ] **Step 1: Write the failing tests**
226
+
227
+ Append to `test/plutonium/positioning_test.rb`, inside the existing class (it already builds `@item_class` on `positioning_test_items` with `positioned_on :position, scope: :status`):
228
+
229
+ ```ruby
230
+ def test_reposition_reports_no_rebalance_on_a_normal_move
231
+ a = @item_class.create!(status: "todo")
232
+ b = @item_class.create!(status: "todo")
233
+ c = @item_class.create!(status: "todo")
234
+
235
+ result = c.reposition!(prev_record: a, next_record: b)
236
+
237
+ refute result.rebalanced?
238
+ end
239
+
240
+ def test_reposition_reports_a_rebalance_when_the_gap_is_exhausted
241
+ a = @item_class.create!(status: "todo")
242
+ b = @item_class.create!(status: "todo")
243
+ c = @item_class.create!(status: "todo")
244
+
245
+ # Collapse the gap below EPSILON (1e-6) so the midpoint would collide.
246
+ a.update_column(:position, 1.0)
247
+ b.update_column(:position, 1.0 + 1e-9)
248
+
249
+ result = c.reposition!(prev_record: a.reload, next_record: b.reload)
250
+
251
+ assert result.rebalanced?
252
+ end
253
+ ```
254
+
255
+ And in `test/plutonium/positioning/config_test.rb`:
256
+
257
+ ```ruby
258
+ def test_block_mode_always_reports_rebalanced
259
+ # A Mode B block is an opaque write — the framework cannot know what it did,
260
+ # so it always forces reconciliation. See spec §3.1.
261
+ config = Plutonium::Positioning::Config.with_block(:position, ->(_move) { :ignored })
262
+ result = config.reposition!(
263
+ record: Object.new, prev_record: nil, next_record: nil, index: 0
264
+ )
265
+ assert result
266
+ end
267
+
268
+ def test_disabled_mode_never_reports_rebalanced
269
+ config = Plutonium::Positioning::Config.disabled
270
+ result = config.reposition!(
271
+ record: Object.new, prev_record: nil, next_record: nil, index: 0
272
+ )
273
+ refute result
274
+ end
275
+ ```
276
+
277
+ - [ ] **Step 2: Run and watch them fail**
278
+
279
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb -n /rebalance/`
280
+ Expected: FAIL — `NoMethodError: undefined method 'rebalanced?'`
281
+
282
+ - [ ] **Step 3: Return a result from `reposition!`**
283
+
284
+ In `lib/plutonium/positioning.rb`, add the result type to the namespace (alongside `EPSILON` and the module functions — NOT inside `Model`, so it does not enter user models' constant lookup):
285
+
286
+ ```ruby
287
+ # Outcome of a #reposition! call. `rebalanced?` is true when the gap
288
+ # between the neighbours was exhausted and the whole scope group had to
289
+ # be renumbered — the caller needs to know because every other row's
290
+ # position changed, so any cached client-side view of the list is stale.
291
+ Result = Data.define(:rebalanced) do
292
+ def rebalanced? = rebalanced
293
+ end
294
+ ```
295
+
296
+ Then rewrite `reposition!` in `lib/plutonium/positioning/model.rb` (currently lines 64-77) to track and return it:
297
+
298
+ ```ruby
299
+ def reposition!(prev_record:, next_record:)
300
+ col = self.class.positioning_column
301
+ prev_val = prev_record&.public_send(col)
302
+ next_val = next_record&.public_send(col)
303
+ rebalanced = false
304
+ if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
305
+ rebalance_scope_group!
306
+ rebalanced = true
307
+ prev_val = prev_record&.reload&.public_send(col)
308
+ next_val = next_record&.reload&.public_send(col)
309
+ end
310
+ update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
311
+ Result.new(rebalanced:)
312
+ end
313
+ ```
314
+
315
+ - [ ] **Step 4: Surface it from `Config#reposition!`**
316
+
317
+ In `lib/plutonium/positioning/config.rb`, change `#reposition!` to return a boolean meaning "the caller must reconcile":
318
+
319
+ ```ruby
320
+ # Persist the new position for a dropped record. Returns true when the
321
+ # caller must reconcile its view of the list because positions other
322
+ # than this record's may have changed.
323
+ #
324
+ # Mode A: whether reposition! had to rebalance the scope group.
325
+ # Mode B: always true — the block is an opaque write and gems in this
326
+ # space routinely renumber the whole group (acts_as_list does).
327
+ # Mode C: always false — nothing was written.
328
+ def reposition!(record:, column:, prev_record:, next_record:, index:)
329
+ case @mode
330
+ when :delegate
331
+ record.reposition!(prev_record:, next_record:).rebalanced?
332
+ when :block
333
+ @block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
334
+ true
335
+ when :disabled
336
+ false
337
+ end
338
+ end
339
+ ```
340
+
341
+ - [ ] **Step 5: Run the tests**
342
+
343
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb`
344
+ Expected: PASS
345
+
346
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning/config_test.rb`
347
+ Expected: PASS
348
+
349
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb`
350
+ Expected: PASS — kanban ignores the return value, so this must be unaffected.
351
+
352
+ - [ ] **Step 6: Commit**
353
+
354
+ ```bash
355
+ git add lib/plutonium/positioning.rb lib/plutonium/positioning/config.rb \
356
+ test/plutonium/positioning_test.rb test/plutonium/positioning/config_test.rb
357
+ git commit -m "feat(positioning): report whether reposition! rebalanced the group"
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Task 3: Hidden actions
363
+
364
+ **Goal:** `hidden: true` is a general action flag; `kanban_drop` disappears; both bulk-action selectors stop leaking hidden actions.
365
+
366
+ **Files:**
367
+ - Modify: `lib/plutonium/action/base.rb:26,84,145`
368
+ - Modify: `lib/plutonium/definition/index_views.rb:148`
369
+ - Modify: `lib/plutonium/ui/page/index.rb:36`, `lib/plutonium/ui/page/show.rb:18`
370
+ - Modify: `lib/plutonium/ui/table/resource.rb:163` and `:189`
371
+ - Modify: `lib/plutonium/ui/grid/card.rb:303`, `lib/plutonium/ui/grid/resource.rb:95`
372
+ - Modify: `lib/plutonium/kanban/column.rb:32-40` (comment only)
373
+ - Test: `test/plutonium/action/hidden_action_test.rb` (new)
374
+
375
+ **Acceptance Criteria:**
376
+ - [ ] `Action::Base#hidden?` exists; `#kanban_drop?` is gone
377
+ - [ ] `hidden` round-trips through `#with` (it is in `to_options`)
378
+ - [ ] All six render-site selectors filter `!a.hidden?`
379
+ - [ ] Both bulk-action selectors filter it — the gap this closes
380
+ - [ ] Kanban's suite passes
381
+
382
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb` → all pass
383
+
384
+ **Steps:**
385
+
386
+ - [ ] **Step 1: Write the failing test**
387
+
388
+ Create `test/plutonium/action/hidden_action_test.rb`:
389
+
390
+ ```ruby
391
+ # frozen_string_literal: true
392
+
393
+ require "test_helper"
394
+
395
+ module Plutonium
396
+ module Action
397
+ class HiddenActionTest < Minitest::Test
398
+ def test_actions_are_visible_by_default
399
+ refute Plutonium::Action::Simple.new(:archive).hidden?
400
+ end
401
+
402
+ def test_hidden_flag_is_readable
403
+ assert Plutonium::Action::Simple.new(:reposition, hidden: true).hidden?
404
+ end
405
+
406
+ # `with` reconstructs an action from to_options — anything missing there
407
+ # is silently dropped on round-trip, which would un-hide the action.
408
+ def test_hidden_survives_a_with_round_trip
409
+ action = Plutonium::Action::Simple.new(:reposition, hidden: true)
410
+ assert action.with(label: "Move").hidden?
411
+ end
412
+
413
+ def test_hidden_can_be_turned_off_via_with
414
+ action = Plutonium::Action::Simple.new(:reposition, hidden: true)
415
+ refute action.with(hidden: false).hidden?
416
+ end
417
+ end
418
+ end
419
+ end
420
+ ```
421
+
422
+ - [ ] **Step 2: Run and watch it fail**
423
+
424
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb`
425
+ Expected: FAIL — `NoMethodError: undefined method 'hidden?'`
426
+
427
+ - [ ] **Step 3: Rename the flag in `Action::Base`**
428
+
429
+ `lib/plutonium/action/base.rb` — line 26:
430
+
431
+ ```ruby
432
+ @hidden = options[:hidden] || false
433
+ ```
434
+
435
+ Line 84, replacing `kanban_drop?` and its comment:
436
+
437
+ ```ruby
438
+ # True when this action must never render. It still has a live route,
439
+ # policy predicate, and (if interactive) form + params machinery — it is
440
+ # simply reachable only by something other than a button: a kanban drop,
441
+ # a drag-reorder, a custom Stimulus controller.
442
+ #
443
+ # Display-only, like #condition_met? — NOT an authorization boundary.
444
+ # Keep authorization in the policy.
445
+ def hidden? = @hidden
446
+ ```
447
+
448
+ Line 145, in `to_options`:
449
+
450
+ ```ruby
451
+ hidden: @hidden,
452
+ ```
453
+
454
+ - [ ] **Step 4: Update the producer**
455
+
456
+ `lib/plutonium/definition/index_views.rb:148` — change `kanban_drop: true` to `hidden: true`, and update the comment on lines 138-139 from "It is flagged `kanban_drop: true`" to "It is flagged `hidden: true`".
457
+
458
+ - [ ] **Step 5: Update the four existing filter sites**
459
+
460
+ Replace `!a.kanban_drop?` with `!a.hidden?` in:
461
+ - `lib/plutonium/ui/page/index.rb:36`
462
+ - `lib/plutonium/ui/page/show.rb:18`
463
+ - `lib/plutonium/ui/table/resource.rb:163`
464
+ - `lib/plutonium/ui/grid/card.rb:303`
465
+
466
+ - [ ] **Step 6: Close the two bulk-action gaps**
467
+
468
+ `lib/plutonium/ui/grid/resource.rb:95`:
469
+
470
+ ```ruby
471
+ .select { |k, a| a.bulk_action? && !a.hidden? }
472
+ ```
473
+
474
+ `lib/plutonium/ui/table/resource.rb:189`:
475
+
476
+ ```ruby
477
+ .select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
478
+ ```
479
+
480
+ - [ ] **Step 7: Downgrade the kanban guard comment**
481
+
482
+ In `lib/plutonium/kanban/column.rb`, the comment ending "...or (b) get auto-classified by Action::Interactive::Factory as a bulk action and leak into the bulk-actions bar (which does not filter kanban_drop actions)" is now stale — the bulk bar does filter. Replace that final clause with:
483
+
484
+ ```ruby
485
+ # by Action::Interactive::Factory as a bulk action. The bulk bars now
486
+ # filter hidden actions, so this is defence in depth rather than the
487
+ # only guard — but a mis-shaped interaction would still fail confusingly
488
+ # at drop time, so reject it here where the error can name the cause.
489
+ ```
490
+
491
+ - [ ] **Step 8: Verify no references remain**
492
+
493
+ Run: `rg -n "kanban_drop\b" lib app test`
494
+ Expected: **no output.** Any hit is a missed site — fix before continuing. (Note `kanban_drop_interaction` / `kanban_drop_immediate` / `kanban_drop_confirm` in `lib/plutonium/ui/kanban/column.rb` are unrelated *data attribute* names, not this flag — the `\b` in the pattern excludes them.)
495
+
496
+ - [ ] **Step 9: Run the tests**
497
+
498
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/hidden_action_test.rb`
499
+ Expected: PASS
500
+
501
+ Run: `bundle exec appraisal rails-8.1 rake test`
502
+ Expected: PASS — the full suite, because this task touched six render paths.
503
+
504
+ - [ ] **Step 10: Commit**
505
+
506
+ ```bash
507
+ git add lib/plutonium/action/base.rb lib/plutonium/definition/index_views.rb \
508
+ lib/plutonium/ui/page/index.rb lib/plutonium/ui/page/show.rb \
509
+ lib/plutonium/ui/table/resource.rb lib/plutonium/ui/grid/card.rb \
510
+ lib/plutonium/ui/grid/resource.rb lib/plutonium/kanban/column.rb \
511
+ test/plutonium/action/hidden_action_test.rb
512
+ git commit -m "feat(actions): generalise kanban_drop into a hidden action flag"
513
+ ```
514
+
515
+ ---
516
+
517
+ ### Task 4: The `position_on` definition DSL
518
+
519
+ **Goal:** `position_on` in a definition declares the resource drag-orderable and expands to sort + default_sort + a hidden reposition action.
520
+
521
+ **Files:**
522
+ - Create: `lib/plutonium/definition/positioning.rb`
523
+ - Modify: `lib/plutonium/definition/base.rb:28-40` (add `include Positioning`)
524
+ - Modify: `lib/plutonium/kanban/dsl.rb:18` (stop seeding a default)
525
+ - Modify: `lib/plutonium/kanban/board.rb:~37` (add `position_config_for`)
526
+ - Modify: `lib/plutonium/resource/controllers/kanban_actions.rb:136,259,581,733,784`
527
+ - Test: `test/plutonium/definition/positioning_test.rb` (new)
528
+
529
+ **Acceptance Criteria:**
530
+ - [ ] A kanban board inherits the definition's `position_on`; its own block overrides
531
+ - [ ] A board on a definition with no `position_on` still gets `Config.default`
532
+ - [ ] Resolution is lazy — declaration order in the class body does not matter
533
+ - [ ] `position_on` / `position_on :attr` / `position_on(:attr) { |move| }` / `position_on false` build the right Config
534
+ - [ ] Mode A raises at class-load time if the model lacks `Plutonium::Positioning`
535
+ - [ ] Mode B does **not** require the concern
536
+ - [ ] Modes A and B register `sort <attr>`, `default_sort <attr>, :asc`, and a hidden `:reposition` action
537
+ - [ ] Mode C registers none of them
538
+ - [ ] `defined_position_config` is readable from an instance
539
+
540
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb` → all pass
541
+
542
+ **Steps:**
543
+
544
+ - [ ] **Step 1: Write the failing tests**
545
+
546
+ Create `test/plutonium/definition/positioning_test.rb`. `Task` in the dummy app is already `positioned_on :position, scope: :status`, so it is the Mode A subject; `Comment` is not positioned, so it is the negative case.
547
+
548
+ ```ruby
549
+ # frozen_string_literal: true
550
+
551
+ require "test_helper"
552
+
553
+ module Plutonium
554
+ module Definition
555
+ class PositioningTest < Minitest::Test
556
+ def build_definition(model, &block)
557
+ Class.new(Plutonium::Resource::Definition) do
558
+ define_singleton_method(:model_class) { model }
559
+ class_eval(&block) if block
560
+ end
561
+ end
562
+
563
+ def test_bare_positioned_defaults_to_the_position_attribute
564
+ definition = build_definition(::Task) { position_on }
565
+ config = definition.new.defined_position_config
566
+
567
+ assert_equal :position, config.attribute
568
+ refute config.disabled?
569
+ end
570
+
571
+ def test_custom_attribute
572
+ definition = build_definition(::Task) { position_on :sort_order }
573
+ assert_equal :sort_order, definition.new.defined_position_config.attribute
574
+ end
575
+
576
+ def test_positioned_sets_the_default_sort_and_registers_the_sort
577
+ definition = build_definition(::Task) { position_on }
578
+
579
+ assert_equal [:position, :asc], definition._default_sort
580
+ assert_includes definition.defined_sorts.keys, :position
581
+ end
582
+
583
+ def test_positioned_registers_a_hidden_reposition_action
584
+ definition = build_definition(::Task) { position_on }
585
+ action = definition.defined_actions[:reposition]
586
+
587
+ refute_nil action
588
+ assert action.hidden?
589
+ end
590
+
591
+ # Mode A delegates to record.reposition!, which only exists on models that
592
+ # include the concern. Failing at class-load time beats a 500 on first drag.
593
+ def test_mode_a_requires_the_model_to_include_the_concern
594
+ error = assert_raises(ArgumentError) do
595
+ build_definition(::Comment) { position_on }
596
+ end
597
+ assert_match(/Plutonium::Positioning/, error.message)
598
+ end
599
+
600
+ # Mode B never calls reposition!, so the concern is irrelevant.
601
+ def test_mode_b_does_not_require_the_concern
602
+ definition = build_definition(::Comment) do
603
+ position_on(:rank) { |move| move.record.insert_at(move.index + 1) }
604
+ end
605
+ assert_equal :rank, definition.new.defined_position_config.attribute
606
+ end
607
+
608
+ def test_mode_c_registers_nothing
609
+ definition = build_definition(::Task) { position_on false }
610
+
611
+ assert definition.new.defined_position_config.disabled?
612
+ assert_nil definition.defined_actions[:reposition]
613
+ refute_equal [:position, :asc], definition._default_sort
614
+ end
615
+
616
+ def test_default_sort_can_be_overridden_after_positioned
617
+ definition = build_definition(::Task) do
618
+ position_on
619
+ default_sort :name, :asc
620
+ end
621
+ assert_equal [:name, :asc], definition._default_sort
622
+ end
623
+ end
624
+ end
625
+ end
626
+ ```
627
+
628
+ - [ ] **Step 2: Run and watch them fail**
629
+
630
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb`
631
+ Expected: FAIL — `NoMethodError: undefined method `position_on``
632
+
633
+ - [ ] **Step 3: Write the concern**
634
+
635
+ Create `lib/plutonium/definition/positioning.rb`:
636
+
637
+ ```ruby
638
+ # frozen_string_literal: true
639
+
640
+ require "plutonium/positioning/config"
641
+
642
+ module Plutonium
643
+ module Definition
644
+ # Declares a resource drag-orderable. The MODEL owns how positions are
645
+ # stored (`positioned_on :position, scope: :project_id`); this only says
646
+ # "this UI can be reordered", and never restates the column or the scope.
647
+ #
648
+ # position_on # Mode A, attribute :position
649
+ # position_on :sort_order # Mode A, custom attribute
650
+ # position_on(:rank) { |move| … } # Mode B, another gem owns the write
651
+ # position_on false # Mode C, ordering off
652
+ #
653
+ # Mirrors kanban's `position_on` (lib/plutonium/kanban/dsl.rb:46) exactly,
654
+ # and builds the same Plutonium::Positioning::Config.
655
+ module Positioning
656
+ extend ActiveSupport::Concern
657
+
658
+ included do
659
+ class_attribute :defined_position_config, instance_writer: false,
660
+ instance_predicate: false, default: nil
661
+
662
+ def self.position_on(attribute = :position, &block)
663
+ config =
664
+ if attribute == false
665
+ Plutonium::Positioning::Config.disabled
666
+ elsif block
667
+ Plutonium::Positioning::Config.with_block(attribute, block)
668
+ else
669
+ validate_model_is_positioned!(attribute)
670
+ Plutonium::Positioning::Config.attribute(attribute)
671
+ end
672
+
673
+ self.defined_position_config = config
674
+ return config if config.disabled?
675
+
676
+ # Registering the sort is load-bearing: dragging is only permitted
677
+ # when the collection is ordered by this attribute, so without a
678
+ # permitted sort there is no way back out of the disabled state.
679
+ sort config.attribute
680
+ default_sort config.attribute, :asc
681
+
682
+ # Hidden: it has a route and a `reposition?` policy predicate, but is
683
+ # reachable only by dragging — never rendered as a button.
684
+ action :reposition, hidden: true
685
+
686
+ config
687
+ end
688
+
689
+ # Mode A calls record.reposition! AND reads `.rebalanced?` off its
690
+ # return, so it needs the real concern — not merely something that
691
+ # responds to reposition!. A model that hand-rolls reposition! (wrapping
692
+ # acts_as_list, say) would otherwise fail at drop time, mid-transaction,
693
+ # with `NoMethodError: undefined method 'rebalanced?' for true`.
694
+ #
695
+ # This check lives HERE rather than in Config#reposition! for two
696
+ # reasons: the house rule against defensive call-site guards, and
697
+ # config.rb's standalone-loadability invariant — naming
698
+ # Plutonium::Positioning::Model there would pull in the concern and
699
+ # break the guard test. The DSL is where the mode is chosen, so it is
700
+ # where the contract belongs.
701
+ def self.validate_model_is_positioned!(attribute)
702
+ return if model_class.include?(Plutonium::Positioning::Model)
703
+
704
+ raise ArgumentError,
705
+ "#{name || "definition"}: `position_on #{attribute.inspect}` requires " \
706
+ "#{model_class} to `include Plutonium::Positioning::Model` and declare " \
707
+ "`positioned_on`. If another gem owns positioning for this model, " \
708
+ "use the block form instead: position_on(#{attribute.inspect}) { |move| … }"
709
+ end
710
+ end
711
+
712
+ def defined_position_config = self.class.defined_position_config
713
+ end
714
+ end
715
+ end
716
+ ```
717
+
718
+ - [ ] **Step 4: Include it in the definition base**
719
+
720
+ In `lib/plutonium/definition/base.rb`, add to the include list (lines 28-40). It must come **after** `Actions` and `Sorting`, because `position_on` calls `action`, `sort`, and `default_sort`:
721
+
722
+ ```ruby
723
+ include Actions
724
+ include Wizards
725
+ include Sorting
726
+ include Positioning
727
+ include Scoping
728
+ ```
729
+
730
+ - [ ] **Step 5: Write the failing board-inheritance test**
731
+
732
+ A board must inherit the definition's `position_on`, with its own block overriding. Append to `test/plutonium/definition/positioning_test.rb`:
733
+
734
+ ```ruby
735
+ def test_board_inherits_the_definitions_position_on
736
+ definition = build_definition(::Task) do
737
+ position_on :sort_order
738
+ kanban { columns :todo, :done }
739
+ end
740
+
741
+ board = definition.defined_kanban_board
742
+ config = board.position_config_for(definition.new)
743
+
744
+ assert_equal :sort_order, config.attribute
745
+ end
746
+
747
+ def test_a_board_block_overrides_the_definition
748
+ definition = build_definition(::Task) do
749
+ position_on :sort_order
750
+ kanban do
751
+ columns :todo, :done
752
+ position_on :board_rank
753
+ end
754
+ end
755
+
756
+ board = definition.defined_kanban_board
757
+ assert_equal :board_rank, board.position_config_for(definition.new).attribute
758
+ end
759
+
760
+ # A board on a definition with no position_on keeps the historic default.
761
+ def test_board_falls_back_to_the_default_when_nothing_is_declared
762
+ definition = build_definition(::Task) { kanban { columns :todo, :done } }
763
+ config = definition.defined_kanban_board.position_config_for(definition.new)
764
+
765
+ assert_equal :position, config.attribute
766
+ refute config.disabled?
767
+ end
768
+ ```
769
+
770
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb -n /board/`
771
+ Expected: FAIL — `NoMethodError: undefined method 'position_config_for'`
772
+
773
+ - [ ] **Step 6: Stop the DSL seeding a default**
774
+
775
+ `lib/plutonium/kanban/dsl.rb:18` currently reads `@position_config = Positioning::Config.default`, which makes "not declared" indistinguishable from "declared as the default" — inheritance is impossible while that holds. Change it to:
776
+
777
+ ```ruby
778
+ # nil means "not declared" so the board can inherit the definition's
779
+ # position_on. Resolution — including the historic Config.default
780
+ # fallback — moves to Board#position_config_for.
781
+ @position_config = nil
782
+ ```
783
+
784
+ - [ ] **Step 7: Resolve lazily on the Board**
785
+
786
+ In `lib/plutonium/kanban/board.rb`, beside `show_in_for` (line 37), which this deliberately mirrors:
787
+
788
+ ```ruby
789
+ # The board's positioning strategy: its own `position_on` if the block
790
+ # declared one, else the definition's, else the historic default.
791
+ #
792
+ # Resolved LAZILY, not at build time: `kanban` eagerly compiles the board
793
+ # at class-load (definition/index_views.rb:121), so a board built before a
794
+ # later `position_on` line would silently miss it — making the declaration
795
+ # order-dependent.
796
+ def position_config_for(definition)
797
+ @position_config || definition.defined_position_config ||
798
+ Plutonium::Positioning::Config.default
799
+ end
800
+ ```
801
+
802
+ Keep `attr_reader :position_config` (line 6) — it is the "did this block declare one" accessor that `position_config_for` reads.
803
+
804
+ - [ ] **Step 8: Update the five kanban call sites**
805
+
806
+ In `lib/plutonium/resource/controllers/kanban_actions.rb`, replace `board.position_config` with `board.position_config_for(current_definition)` at lines 136, 259, 581, 733, and 784.
807
+
808
+ Verify none were missed:
809
+
810
+ Run: `rg -n "position_config\b" lib/plutonium/resource/controllers/kanban_actions.rb`
811
+ Expected: every hit is `position_config_for(current_definition)` — a bare `board.position_config` remaining is a bug.
812
+
813
+ - [ ] **Step 9: Run the tests**
814
+
815
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb`
816
+ Expected: PASS
817
+
818
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/base_test.rb`
819
+ Expected: PASS
820
+
821
+ Run: `bundle exec appraisal rails-8.1 rake test`
822
+ Expected: PASS — the kanban suite is the gate for the Board change.
823
+
824
+ - [ ] **Step 10: Commit**
825
+
826
+ ```bash
827
+ git add lib/plutonium/definition/positioning.rb lib/plutonium/definition/base.rb \
828
+ lib/plutonium/kanban/dsl.rb lib/plutonium/kanban/board.rb \
829
+ lib/plutonium/resource/controllers/kanban_actions.rb \
830
+ test/plutonium/definition/positioning_test.rb
831
+ git commit -m "feat(definition): add the position_on DSL, inherited by kanban boards"
832
+ ```
833
+
834
+ ---
835
+
836
+ ### Task 5: The reposition endpoint
837
+
838
+ **Goal:** `POST <member>/reposition` moves a record between two neighbours, authorized and scoped, responding per the reconciliation rules.
839
+
840
+ **Files:**
841
+ - Create: `lib/plutonium/resource/controllers/position_actions.rb`
842
+ - Modify: `lib/plutonium/routing/mapper_extensions.rb:~155`
843
+ - Modify: `lib/plutonium/resource/policy.rb` (add `reposition?`)
844
+ - Modify: `lib/plutonium/resource/controller.rb` (include the concern)
845
+ - Test: `test/plutonium/resource/controllers/position_actions_test.rb` (new)
846
+
847
+ **Acceptance Criteria:**
848
+ - [ ] `POST <member>/reposition` with `{prev_id, next_id}` repositions the record
849
+ - [ ] `reposition?` defaults to `update?`
850
+ - [ ] Neighbours resolve within `current_authorized_scope` only
851
+ - [ ] Clean Mode A drop → `204`; rebalance or unresolvable neighbour → turbo-stream tbody
852
+ - [ ] Mode B → always turbo-stream
853
+ - [ ] Denial → `403` + tbody + toast; `RecordNotFound` / `RecordInvalid` → `422` + tbody + toast
854
+ - [ ] A resource without `position_on` returns `404`
855
+
856
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb` → all pass
857
+
858
+ **Steps:**
859
+
860
+ - [ ] **Step 1: Add the policy predicate**
861
+
862
+ In `lib/plutonium/resource/policy.rb`, next to `kanban_move?` (line 185):
863
+
864
+ ```ruby
865
+ # Authorizes a drag-reorder. Delegates to update? by default — override to
866
+ # allow reordering without granting full edit-form access.
867
+ #
868
+ # @return [Boolean] Delegates to update?.
869
+ def reposition?
870
+ update?
871
+ end
872
+ ```
873
+
874
+ - [ ] **Step 2: Add the route**
875
+
876
+ In `lib/plutonium/routing/mapper_extensions.rb`, inside `define_member_interactive_actions`, after the kanban lines (155):
877
+
878
+ ```ruby
879
+ post "reposition", action: :reposition, as: :reposition
880
+ ```
881
+
882
+ - [ ] **Step 3: Write the failing tests**
883
+
884
+ Create `test/plutonium/resource/controllers/position_actions_test.rb`. Follow the existing controller-test conventions in `test/plutonium/resource/` — read a neighbouring controller test first and match its setup (portal mounting, sign-in helper, `Task` fixtures).
885
+
886
+ Cover, at minimum:
887
+
888
+ ```ruby
889
+ def test_reposition_moves_the_record_between_its_neighbours
890
+ a, b, c = three_tasks_in_position_order
891
+
892
+ post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
893
+
894
+ assert_response :no_content
895
+ assert_operator a.reload.position, :<, c.reload.position
896
+ assert_operator c.reload.position, :<, b.reload.position
897
+ end
898
+
899
+ def test_clean_drop_returns_204_with_no_body
900
+ a, b, c = three_tasks_in_position_order
901
+ post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
902
+
903
+ assert_response :no_content
904
+ assert_empty response.body
905
+ end
906
+
907
+ def test_a_rebalance_streams_the_tbody
908
+ a, b, c = three_tasks_in_position_order
909
+ a.update_column(:position, 1.0)
910
+ b.update_column(:position, 1.0 + 1e-9)
911
+
912
+ post reposition_task_path(c), params: {prev_id: a.id, next_id: b.id}
913
+
914
+ assert_response :success
915
+ assert_match "turbo-stream", response.body
916
+ end
917
+
918
+ # A neighbour id outside the authorized scope must NOT silently become nil —
919
+ # that would mean "drop at the end", a different and wrong outcome.
920
+ def test_a_neighbour_outside_the_authorized_scope_reconciles_instead_of_appending
921
+ c = task_in_scope
922
+ foreign = task_outside_scope
923
+
924
+ post reposition_task_path(c), params: {prev_id: foreign.id, next_id: nil}
925
+
926
+ assert_response :success
927
+ assert_match "turbo-stream", response.body
928
+ end
929
+
930
+ def test_denied_reposition_returns_403_and_snaps_back
931
+ # policy stubbed so reposition? is false
932
+ assert_response :forbidden
933
+ assert_match "turbo-stream", response.body
934
+ end
935
+
936
+ def test_a_resource_without_positioned_returns_404
937
+ post reposition_comment_path(comment), params: {prev_id: nil, next_id: nil}
938
+ assert_response :not_found
939
+ end
940
+ ```
941
+
942
+ - [ ] **Step 4: Run and watch them fail**
943
+
944
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb`
945
+ Expected: FAIL — no `reposition` action
946
+
947
+ - [ ] **Step 5: Write the concern**
948
+
949
+ Create `lib/plutonium/resource/controllers/position_actions.rb`. The rescue clauses mirror `KanbanActions` (`kanban_actions.rb:331-372`) and each encodes a real lesson — keep the comments.
950
+
951
+ ```ruby
952
+ # frozen_string_literal: true
953
+
954
+ module Plutonium
955
+ module Resource
956
+ module Controllers
957
+ # Drag-reorder endpoint for resources whose definition declares `position_on`.
958
+ #
959
+ # POST <member>/reposition with {prev_id:, next_id:} — the ids of the
960
+ # dropped row's visible neighbours, either nullable for a drop at an end.
961
+ #
962
+ # The client has already moved the row optimistically, so a clean drop
963
+ # answers 204. The tbody is streamed back only when the client's view is
964
+ # or may be stale: a rebalance renumbered the group, a neighbour did not
965
+ # resolve, or the write was a Mode B block (opaque — see spec §3.1).
966
+ module PositionActions
967
+ extend ActiveSupport::Concern
968
+
969
+ def reposition
970
+ config = current_definition.defined_position_config
971
+
972
+ if config.nil? || config.disabled?
973
+ # Not a reorderable resource — a 404, not an authorization failure.
974
+ skip_verify_authorize_current!
975
+ head :not_found
976
+ return
977
+ end
978
+
979
+ record = current_authorized_scope.find(params[:id])
980
+ authorize_current! record, to: :reposition?
981
+
982
+ # Resolve neighbours WITHIN the authorized scope. A nil here would mean
983
+ # "drop at the end", so an id that does not resolve must be treated as
984
+ # drift and reconciled — never silently coerced to nil.
985
+ prev_record, prev_ok = resolve_position_neighbour(params[:prev_id])
986
+ next_record, next_ok = resolve_position_neighbour(params[:next_id])
987
+
988
+ # Bind the return rather than branching on the call directly:
989
+ # `if config.reposition!(...)` reads as a success check, which this is
990
+ # NOT — failure raises. The boolean means "positions other than this
991
+ # record's may have changed, so the client's optimistic view is stale".
992
+ must_reconcile = config.reposition!(
993
+ record:,
994
+ prev_record:,
995
+ next_record:,
996
+ index: params[:to_index].to_i
997
+ )
998
+
999
+ if must_reconcile || !prev_ok || !next_ok
1000
+ render_position_reconciliation
1001
+ else
1002
+ head :no_content
1003
+ end
1004
+ rescue ::ActionPolicy::Unauthorized
1005
+ # NOTE: the leading :: is required — Plutonium::ActionPolicy exists, so a
1006
+ # bare ActionPolicy resolves to that namespace and never matches, letting
1007
+ # the exception reach the global rescue_from, which re-raises for
1008
+ # turbo_stream requests → an HTML error page morphed into the table.
1009
+ #
1010
+ # authorize_count only bumps after a SUCCESSFUL authorize, so a denial
1011
+ # leaves the verifier unsatisfied; we handled authorization by rejecting.
1012
+ skip_verify_authorize_current!
1013
+ render_position_reconciliation(
1014
+ reason: "You are not authorized to reorder this.",
1015
+ status: :forbidden
1016
+ )
1017
+ rescue ActiveRecord::RecordNotFound
1018
+ # The row was destroyed between render and drop. `find` raised before
1019
+ # authorize_current!, so satisfy that verifier explicitly.
1020
+ skip_verify_authorize_current!
1021
+ render_position_reconciliation(reason: "This record no longer exists.")
1022
+ rescue ActiveRecord::RecordInvalid => e
1023
+ reason = e.record.errors.full_messages.to_sentence.presence ||
1024
+ "This record could not be moved."
1025
+ render_position_reconciliation(reason:)
1026
+ end
1027
+
1028
+ private
1029
+
1030
+ # Returns [record, resolved?]. A blank id is a legitimate end-of-list
1031
+ # drop → [nil, true]. An id that does not resolve in the authorized
1032
+ # scope is drift → [nil, false], which forces reconciliation.
1033
+ def resolve_position_neighbour(id)
1034
+ return [nil, true] if id.blank?
1035
+ record = current_authorized_scope.find_by(id: id)
1036
+ [record, !record.nil?]
1037
+ end
1038
+
1039
+ # Re-renders the collection so the client's optimistic DOM is replaced by
1040
+ # the server's truth — the row snaps back on rejection, or settles into
1041
+ # its true place after a rebalance.
1042
+ def render_position_reconciliation(reason: nil, status: :ok)
1043
+ streams = [turbo_stream.update(position_collection_frame_id, render_position_collection_html)]
1044
+
1045
+ if reason
1046
+ streams << turbo_stream.append(
1047
+ "position-flash",
1048
+ partial: "plutonium/toast",
1049
+ locals: {type: :warning, msg: reason}
1050
+ )
1051
+ end
1052
+
1053
+ render turbo_stream: streams, status:
1054
+ end
1055
+ end
1056
+ end
1057
+ end
1058
+ end
1059
+ ```
1060
+
1061
+ **Note for the implementer:** `position_collection_frame_id` and `render_position_collection_html` are the two methods that must bind this concern to the actual table rendering. Locate how `CrudActions#index` builds the collection component (`lib/plutonium/resource/controllers/crud_actions.rb`) and reuse that path — do **not** invent a parallel rendering route. Model them on `KanbanActions#render_kanban_column_html` (`kanban_actions.rb:568`), which resolves the same query pipeline and renders one component to an HTML-safe string.
1062
+
1063
+ - [ ] **Step 6: Include the concern**
1064
+
1065
+ In `lib/plutonium/resource/controller.rb`, include `PositionActions` alongside the other controller concerns. Match the existing include order and style — check where `KanbanActions` is included and put this next to it.
1066
+
1067
+ - [ ] **Step 7: Run the tests**
1068
+
1069
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb`
1070
+ Expected: PASS
1071
+
1072
+ Run: `bundle exec appraisal rails-8.1 rake test`
1073
+ Expected: PASS
1074
+
1075
+ - [ ] **Step 8: Commit**
1076
+
1077
+ ```bash
1078
+ git add lib/plutonium/resource/controllers/position_actions.rb \
1079
+ lib/plutonium/routing/mapper_extensions.rb lib/plutonium/resource/policy.rb \
1080
+ lib/plutonium/resource/controller.rb \
1081
+ test/plutonium/resource/controllers/position_actions_test.rb
1082
+ git commit -m "feat(positioning): add the reposition endpoint"
1083
+ ```
1084
+
1085
+ ---
1086
+
1087
+ ### Task 6: Extract the shared drag core
1088
+
1089
+ **Goal:** One native-DnD implementation, in `src/js/drag/sortable.js`, with kanban's behaviour unchanged.
1090
+
1091
+ **Files:**
1092
+ - Create: `src/js/drag/sortable.js`
1093
+ - Modify: `src/js/controllers/kanban_controller.js:433-523, 700-706`
1094
+
1095
+ **Acceptance Criteria:**
1096
+ - [ ] `sortable.js` exports the drag mechanics with no kanban/board/column knowledge
1097
+ - [ ] `kanban_controller.js` consumes it; its drag behaviour is byte-for-byte equivalent
1098
+ - [ ] Kanban's suite passes unchanged
1099
+ - [ ] `yarn build` succeeds
1100
+
1101
+ **Verify:** `bundle exec appraisal rails-8.1 rake test` and manual kanban drag in the dummy app
1102
+
1103
+ **Steps:**
1104
+
1105
+ - [ ] **Step 1: Create the shared module**
1106
+
1107
+ Create `src/js/drag/sortable.js`. Extract only what is genuinely generic — the parts of `kanban_controller.js` that reference no board concept:
1108
+
1109
+ ```javascript
1110
+ // Shared native HTML5 drag-and-drop mechanics.
1111
+ //
1112
+ // Deliberately knows nothing about kanban columns, tables, WIP limits, or
1113
+ // Turbo. Consumers wire it to their own DOM contract and own the transport.
1114
+ //
1115
+ // Consumers: kanban_controller.js, positioned_controller.js
1116
+
1117
+ // Returns the 0-based insertion index for a drop at `clientY` among `items`,
1118
+ // by finding the first item whose vertical midpoint is below the cursor.
1119
+ // Extracted verbatim from kanban_controller.js#computeDropIndex.
1120
+ export function computeDropIndex(clientY, items) {
1121
+ for (let i = 0; i < items.length; i++) {
1122
+ const rect = items[i].getBoundingClientRect()
1123
+ if (clientY < rect.top + rect.height / 2) return i
1124
+ }
1125
+ return items.length
1126
+ }
1127
+
1128
+ // Horizontal variant, for grid/card layouts that flow in rows.
1129
+ export function computeDropIndexHorizontal(clientX, items) {
1130
+ for (let i = 0; i < items.length; i++) {
1131
+ const rect = items[i].getBoundingClientRect()
1132
+ if (clientX < rect.left + rect.width / 2) return i
1133
+ }
1134
+ return items.length
1135
+ }
1136
+
1137
+ // Applies the drag ghost + dragging class. Deferred by one frame so the
1138
+ // browser captures the ghost image BEFORE the opacity change — otherwise the
1139
+ // dragged element's ghost is rendered already-faded.
1140
+ export function beginDrag(event, element, { draggingClass, payload }) {
1141
+ event.dataTransfer.effectAllowed = "move"
1142
+ event.dataTransfer.setData("text/plain", payload)
1143
+ requestAnimationFrame(() => element.classList.add(draggingClass))
1144
+ }
1145
+
1146
+ export function endDrag(element, { draggingClass }) {
1147
+ element?.classList.remove(draggingClass)
1148
+ }
1149
+ ```
1150
+
1151
+ - [ ] **Step 2: Consume it from kanban**
1152
+
1153
+ In `src/js/controllers/kanban_controller.js`:
1154
+
1155
+ Add at the top, with the other imports:
1156
+
1157
+ ```javascript
1158
+ import { computeDropIndex, beginDrag, endDrag } from "../drag/sortable.js"
1159
+ ```
1160
+
1161
+ Delete the private `#computeDropIndex` method (lines 700-706) and change its call site (line 497) to the import:
1162
+
1163
+ ```javascript
1164
+ const toIndex = computeDropIndex(event.clientY, existingCards)
1165
+ ```
1166
+
1167
+ In `#onDragStart` (lines 433-448), replace the `dataTransfer` + `requestAnimationFrame` block with `beginDrag`, keeping the kanban-specific `#applyDropHints` call:
1168
+
1169
+ ```javascript
1170
+ #onDragStart(event) {
1171
+ const card = event.target.closest("[data-kanban-record-id]")
1172
+ if (!card) return
1173
+
1174
+ this.draggedCard = card
1175
+ beginDrag(event, card, {
1176
+ draggingClass: "pu-kanban-dragging",
1177
+ payload: card.dataset.kanbanRecordId,
1178
+ })
1179
+
1180
+ // Mark columns that would reject a drop from this card's source column.
1181
+ this.#applyDropHints(card.dataset.kanbanColumnKey)
1182
+ }
1183
+ ```
1184
+
1185
+ Use `endDrag` in the existing `#onDragEnd` cleanup, leaving the highlight and hint clearing (both board-specific) in place.
1186
+
1187
+ **Do not move** `#applyDropHints`, `#highlightColumn`, `#clearHighlights`, `#openDropInteraction`, or `#submitMove` — all are board-specific and stay in the kanban controller.
1188
+
1189
+ - [ ] **Step 3: Build and verify**
1190
+
1191
+ Run: `yarn build`
1192
+ Expected: completes with no errors
1193
+
1194
+ Run: `bundle exec appraisal rails-8.1 rake test`
1195
+ Expected: PASS
1196
+
1197
+ - [ ] **Step 4: Manually verify kanban still drags**
1198
+
1199
+ Boot the dummy app and drag a card within a column, across columns, into a WIP-limited column, and into a locked column. All four must behave exactly as before. This is a refactor — any behaviour change is a bug.
1200
+
1201
+ - [ ] **Step 5: Commit**
1202
+
1203
+ ```bash
1204
+ git add src/js/drag/sortable.js src/js/controllers/kanban_controller.js app/assets
1205
+ git commit -m "refactor(js): extract shared drag mechanics from the kanban controller"
1206
+ ```
1207
+
1208
+ ---
1209
+
1210
+ ### Task 7: The `positioned` Stimulus controller and table grip
1211
+
1212
+ **Goal:** A hover grip in each row's first cell drags to reorder, disabled and offering a sort when the table is not in position order.
1213
+
1214
+ **Files:**
1215
+ - Create: `src/js/controllers/positioned_controller.js`
1216
+ - Create: `lib/plutonium/ui/table/components/drag_handle.rb`
1217
+ - Modify: `src/js/controllers/register_controllers.js:39,82`
1218
+ - Modify: `lib/plutonium/ui/table/resource.rb:~152` (wrap the first column's block)
1219
+
1220
+ **Acceptance Criteria:**
1221
+ - [ ] Only the grip carries `draggable="true"` — never the `<tr>`
1222
+ - [ ] Grip is hidden until row hover, and visible on keyboard focus
1223
+ - [ ] Grip is a `<button>`
1224
+ - [ ] When the effective sort is not the position attribute, the grip is disabled and applies the position sort on click
1225
+ - [ ] Dropping posts `{prev_id, next_id, to_index}` and applies the turbo-stream response
1226
+ - [ ] No grip renders when the definition has no `position_on` or is Mode C
1227
+ - [ ] **Nested tables work too**: the reposition URL is the *nested* member route, and neighbours resolve within the parent-scoped `current_authorized_scope`
1228
+
1229
+ **Verify:** `yarn build` succeeds; manual drag in the dummy app on `/tasks`
1230
+
1231
+ **Steps:**
1232
+
1233
+ - [ ] **Step 1: Write the grip component**
1234
+
1235
+ Create `lib/plutonium/ui/table/components/drag_handle.rb`:
1236
+
1237
+ ```ruby
1238
+ # frozen_string_literal: true
1239
+
1240
+ module Plutonium
1241
+ module UI
1242
+ module Table
1243
+ module Components
1244
+ # The drag affordance for a positioned row.
1245
+ #
1246
+ # Lives INSIDE the first cell, not in a column of its own — a permanent
1247
+ # grip column costs horizontal space on every row for a feature used
1248
+ # occasionally.
1249
+ #
1250
+ # Only this element is draggable, never the <tr>. draggable="true"
1251
+ # disables text selection within the element in every major browser, so
1252
+ # a draggable row would silently remove the ability to select and copy a
1253
+ # cell value — and would fight row_click_controller.js, which already
1254
+ # makes the whole row the Show affordance.
1255
+ #
1256
+ # When `sort_url` is present the table is NOT in position order, so the
1257
+ # grip renders disabled and doubles as the control that restores it.
1258
+ class DragHandle < Phlexi::Table::HTML
1259
+ def initialize(record_id:, sort_url: nil)
1260
+ @record_id = record_id
1261
+ @sort_url = sort_url
1262
+ end
1263
+
1264
+ def view_template
1265
+ @sort_url ? render_sort_affordance : render_grip
1266
+ end
1267
+
1268
+ private
1269
+
1270
+ def render_grip
1271
+ # The handle carries NO record id — the row it lives in owns that
1272
+ # (data-positioned-row-id). One source of truth; the controller
1273
+ # always reads the id from the row.
1274
+ button(
1275
+ type: :button,
1276
+ draggable: "true",
1277
+ class: themed(:drag_handle),
1278
+ aria_label: "Drag to reorder. Use arrow keys to move up or down.",
1279
+ data: {
1280
+ positioned_target: "handle",
1281
+ action: "keydown->positioned#onHandleKeydown"
1282
+ }
1283
+ ) { render_grip_icon }
1284
+ end
1285
+
1286
+ # Disabled state: the table is sorted by another column, so the visible
1287
+ # neighbours are not position-adjacent and a drop would be meaningless.
1288
+ # Clicking restores position order rather than explaining where to find
1289
+ # the control.
1290
+ def render_sort_affordance
1291
+ a(
1292
+ href: @sort_url,
1293
+ class: themed(:drag_handle_disabled),
1294
+ title: "Sort by position to reorder",
1295
+ data: {turbo_frame: "_top"}
1296
+ ) { render_grip_icon }
1297
+ end
1298
+
1299
+ def render_grip_icon
1300
+ render Phlex::TablerIcons::GripVertical.new(class: "size-4")
1301
+ end
1302
+ end
1303
+ end
1304
+ end
1305
+ end
1306
+ end
1307
+ ```
1308
+
1309
+ Add `drag_handle` and `drag_handle_disabled` to `lib/plutonium/ui/table/theme.rb`. The grip must be invisible until hover but visible on focus:
1310
+
1311
+ ```ruby
1312
+ drag_handle: "opacity-0 group-hover/row:opacity-100 focus:opacity-100 " \
1313
+ "cursor-grab active:cursor-grabbing text-[var(--pu-text-subtle)] " \
1314
+ "hover:text-[var(--pu-text)] transition-opacity",
1315
+ drag_handle_disabled: "opacity-0 group-hover/row:opacity-40 focus:opacity-100 " \
1316
+ "cursor-pointer text-[var(--pu-text-subtle)] transition-opacity",
1317
+ ```
1318
+
1319
+ This requires `group/row` on the `<tr>` — check `lib/plutonium/ui/table/base.rb` for where row attributes are set and add it there if absent.
1320
+
1321
+ - [ ] **Step 2: Render the grip in the first cell**
1322
+
1323
+ In `lib/plutonium/ui/table/resource.rb`, inside the `@resource_fields.each` loop (around line 152), wrap the first field's `tag_block` so the grip precedes the cell content. Add before the loop:
1324
+
1325
+ ```ruby
1326
+ position_config = resource_definition.defined_position_config
1327
+ positioned = position_config && !position_config.disabled?
1328
+ first_field = @resource_fields.first
1329
+ # nil when the table IS in position order (grip is live); a URL when
1330
+ # it is not (grip is disabled and links back to position order).
1331
+ position_sort_url = if positioned && !ordered_by_position?(position_config)
1332
+ current_query_object.sort_params_for(position_config.attribute)[:url]
1333
+ end
1334
+ ```
1335
+
1336
+ Then, where `tag_block` is assigned, wrap it for the first field only:
1337
+
1338
+ ```ruby
1339
+ if positioned && name == first_field
1340
+ inner_block = tag_block
1341
+ tag_block = ->(wrapped_object, key) {
1342
+ div(class: "flex items-center gap-2") do
1343
+ render DragHandle.new(
1344
+ record_id: wrapped_object.unwrapped.id,
1345
+ sort_url: position_sort_url
1346
+ )
1347
+ render inner_block.call(wrapped_object, key)
1348
+ end
1349
+ }
1350
+ end
1351
+ ```
1352
+
1353
+ Add a private `ordered_by_position?(config)` that compares the query object's active sort field to `config.attribute`. Read `lib/plutonium/resource/query_object.rb` for the exact accessor — do not guess it.
1354
+
1355
+ - [ ] **Step 3: Write the Stimulus controller**
1356
+
1357
+ Create `src/js/controllers/positioned_controller.js`:
1358
+
1359
+ ```javascript
1360
+ import { Controller } from "@hotwired/stimulus"
1361
+ import { computeDropIndex, beginDrag, endDrag } from "../drag/sortable.js"
1362
+
1363
+ // Connects to data-controller="positioned"
1364
+ //
1365
+ // Drag-to-reorder for tables, nested tables, and grids backed by a `position_on`
1366
+ // definition. Only the grip is draggable (see DragHandle for why).
1367
+ //
1368
+ // The client moves the row optimistically. A clean drop answers 204 and nothing
1369
+ // more happens; when the server needs to reconcile it streams the collection
1370
+ // back, which replaces whatever the client did.
1371
+ export default class extends Controller {
1372
+ static values = { url: String }
1373
+ static targets = ["handle", "row"]
1374
+
1375
+ connect() {
1376
+ this.onDragStart = this.#onDragStart.bind(this)
1377
+ this.onDragOver = this.#onDragOver.bind(this)
1378
+ this.onDrop = this.#onDrop.bind(this)
1379
+ this.onDragEnd = this.#onDragEnd.bind(this)
1380
+
1381
+ this.element.addEventListener("dragstart", this.onDragStart)
1382
+ this.element.addEventListener("dragover", this.onDragOver)
1383
+ this.element.addEventListener("drop", this.onDrop)
1384
+ this.element.addEventListener("dragend", this.onDragEnd)
1385
+ }
1386
+
1387
+ disconnect() {
1388
+ this.element.removeEventListener("dragstart", this.onDragStart)
1389
+ this.element.removeEventListener("dragover", this.onDragOver)
1390
+ this.element.removeEventListener("drop", this.onDrop)
1391
+ this.element.removeEventListener("dragend", this.onDragEnd)
1392
+ }
1393
+
1394
+ #onDragStart(event) {
1395
+ const handle = event.target.closest("[data-positioned-target='handle']")
1396
+ if (!handle) return
1397
+
1398
+ this.draggedRow = handle.closest("[data-positioned-target='row']")
1399
+ if (!this.draggedRow) return
1400
+
1401
+ beginDrag(event, this.draggedRow, {
1402
+ draggingClass: "pu-positioned-dragging",
1403
+ payload: this.draggedRow.dataset.positionedRowId,
1404
+ })
1405
+ }
1406
+
1407
+ #onDragOver(event) {
1408
+ if (!this.draggedRow) return
1409
+ event.preventDefault()
1410
+ event.dataTransfer.dropEffect = "move"
1411
+ }
1412
+
1413
+ #onDrop(event) {
1414
+ event.preventDefault()
1415
+ if (!this.draggedRow) return
1416
+
1417
+ const others = this.rowTargets.filter(r => r !== this.draggedRow)
1418
+ const toIndex = computeDropIndex(event.clientY, others)
1419
+
1420
+ // Optimistic move: insert before the row now at toIndex, or append.
1421
+ const anchor = others[toIndex]
1422
+ if (anchor) {
1423
+ anchor.parentNode.insertBefore(this.draggedRow, anchor)
1424
+ } else if (others.length) {
1425
+ const last = others[others.length - 1]
1426
+ last.parentNode.insertBefore(this.draggedRow, last.nextSibling)
1427
+ }
1428
+
1429
+ this.#submit(this.draggedRow, {
1430
+ prevId: others[toIndex - 1]?.dataset.positionedRowId ?? "",
1431
+ nextId: others[toIndex]?.dataset.positionedRowId ?? "",
1432
+ toIndex,
1433
+ })
1434
+ }
1435
+
1436
+ #onDragEnd() {
1437
+ endDrag(this.draggedRow, { draggingClass: "pu-positioned-dragging" })
1438
+ this.draggedRow = null
1439
+ }
1440
+
1441
+ // Keyboard reorder: native HTML5 DnD is mouse-only, so without this the
1442
+ // feature is unusable by keyboard. Moves one slot per keypress, reusing the
1443
+ // same endpoint and neighbour computation as a drag.
1444
+ onHandleKeydown(event) {
1445
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
1446
+ event.preventDefault()
1447
+
1448
+ const row = event.target.closest("[data-positioned-target='row']")
1449
+ if (!row) return
1450
+
1451
+ const rows = this.rowTargets
1452
+ const from = rows.indexOf(row)
1453
+ const to = event.key === "ArrowUp" ? from - 1 : from + 1
1454
+ if (to < 0 || to >= rows.length) return
1455
+
1456
+ const anchor = event.key === "ArrowUp" ? rows[to] : rows[to].nextSibling
1457
+ row.parentNode.insertBefore(row, anchor)
1458
+ event.target.focus()
1459
+
1460
+ const others = this.rowTargets.filter(r => r !== row)
1461
+ this.#submit(row, {
1462
+ prevId: others[to - 1]?.dataset.positionedRowId ?? "",
1463
+ nextId: others[to]?.dataset.positionedRowId ?? "",
1464
+ toIndex: to,
1465
+ })
1466
+ }
1467
+
1468
+ async #submit(row, { prevId, nextId, toIndex }) {
1469
+ const url = this.urlValue.replace("__ID__", row.dataset.positionedRowId)
1470
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
1471
+
1472
+ const response = await fetch(url, {
1473
+ method: "POST",
1474
+ headers: {
1475
+ "Accept": "text/vnd.turbo-stream.html",
1476
+ "Content-Type": "application/x-www-form-urlencoded",
1477
+ "X-CSRF-Token": csrfToken,
1478
+ },
1479
+ body: new URLSearchParams({ prev_id: prevId, next_id: nextId, to_index: toIndex }),
1480
+ credentials: "same-origin",
1481
+ })
1482
+
1483
+ // 204 means the optimistic DOM is already correct — nothing to apply.
1484
+ if (response.status === 204) return
1485
+
1486
+ const html = await response.text()
1487
+ if (html) window.Turbo.renderStreamMessage(html)
1488
+ }
1489
+ }
1490
+ ```
1491
+
1492
+ - [ ] **Step 4: Register the controller**
1493
+
1494
+ In `src/js/controllers/register_controllers.js`, add the import beside the others (near line 39):
1495
+
1496
+ ```javascript
1497
+ import PositionedController from "./positioned_controller.js"
1498
+ ```
1499
+
1500
+ and the registration (near line 82):
1501
+
1502
+ ```javascript
1503
+ application.register("positioned", PositionedController)
1504
+ ```
1505
+
1506
+ - [ ] **Step 5: Wire the controller onto the table**
1507
+
1508
+ The table wrapper needs `data-controller="positioned"` and `data-positioned-url-value` (the member reposition path with `__ID__`); each row needs `data-positioned-target="row"` and `data-positioned-row-id`. Add these in `lib/plutonium/ui/table/resource.rb` where the table wrapper is built — mirror how the kanban board supplies `moveUrlTemplateValue` (`Kanban::Resource#kanban_move_url_template`).
1509
+
1510
+ **The server contract Task 5 actually shipped** — this supersedes the ids sketched earlier in this plan, which were guesses made before the endpoint existed:
1511
+
1512
+ | Thing | Value | Source |
1513
+ |---|---|---|
1514
+ | Turbo-stream target | `#pu-collection-<model_name.plural>` (e.g. `#pu-collection-tasks`) | `Plutonium::UI::Page::Index.collection_dom_id` (`ui/page/index.rb:33`) |
1515
+ | Toast region | `#pu-flash` | `Plutonium::FLASH_REGION` (`lib/plutonium.rb:46`) — lives in the layout, deliberately OUTSIDE the collection wrapper so a stream replacing the collection cannot destroy it |
1516
+
1517
+ The wrapper `div` already exists (`ui/page/index.rb:81`) and wraps the rendered table **or** grid — so Task 8 needs no separate target.
1518
+
1519
+ **The client MUST append the collection's current query string to the POST URL** (i.e. `window.location.search`). This is load-bearing, not cosmetic: the endpoint re-renders through the index's own pipeline via `setup_index_action!`, so search / filters / scope / sort / page all arrive in `params`. Without it, a drop on page 3 of a filtered list reconciles as page 1 of the unfiltered list.
1520
+
1521
+ - [ ] **Step 6: Verify the nested-table URL**
1522
+
1523
+ Nested association tables render through this same `Table::Resource` component, so the grip comes for free — but the reposition URL must be the **nested** member route (`/posts/1/comments/5/reposition`), not the top-level one. If Step 5 builds the URL from `resource_url_for(resource_class)`, that resolves correctly in a nested context already; verify it rather than assume.
1524
+
1525
+ `KanbanActions#render_kanban_column_html` (`kanban_actions.rb:616`) documents why the URL is derived from `resource_url_for` and not `request.path` — the same reasoning applies here, because the reposition POST's own `request.path` is the member route, not the collection path.
1526
+
1527
+ Confirm in the dummy app on a parent's show page that a nested positioned collection drags and persists, and that its POST goes to the nested path.
1528
+
1529
+ - [ ] **Step 7: Build and verify manually**
1530
+
1531
+ Run: `yarn build`
1532
+ Expected: no errors
1533
+
1534
+ Boot the dummy app, visit the Tasks index, and confirm: grip appears on hover; dragging reorders and persists across reload; sorting by another column greys the grip; clicking the greyed grip restores position order; tabbing to a grip and pressing ArrowUp/ArrowDown moves the row.
1535
+
1536
+ - [ ] **Step 8: Commit**
1537
+
1538
+ ```bash
1539
+ git add src/js/controllers/positioned_controller.js src/js/controllers/register_controllers.js \
1540
+ lib/plutonium/ui/table/components/drag_handle.rb lib/plutonium/ui/table/resource.rb \
1541
+ lib/plutonium/ui/table/theme.rb lib/plutonium/ui/table/base.rb app/assets
1542
+ git commit -m "feat(ui): drag-to-reorder for positioned index tables"
1543
+ ```
1544
+
1545
+ ---
1546
+
1547
+ ### Task 8: Grid/card surface
1548
+
1549
+ **Goal:** The same grip and controller on grid/card index views.
1550
+
1551
+ **Files:**
1552
+ - Modify: `lib/plutonium/ui/grid/card.rb`
1553
+ - Modify: `lib/plutonium/ui/grid/resource.rb`
1554
+
1555
+ **Acceptance Criteria:**
1556
+ - [ ] Cards render the grip when the definition is positioned
1557
+ - [ ] Grid uses `computeDropIndexHorizontal` where cards flow in rows
1558
+ - [ ] The grid wrapper carries the same `positioned` controller wiring as the table
1559
+
1560
+ **Verify:** `yarn build`; manual drag on a grid index in the dummy app
1561
+
1562
+ **Steps:**
1563
+
1564
+ - [ ] **Step 1: Render the grip on the card**
1565
+
1566
+ In `lib/plutonium/ui/grid/card.rb`, render `Plutonium::UI::Table::Components::DragHandle` in the card header when `resource_definition.defined_position_config` is present and not disabled. Reuse the component rather than writing a second one — the affordance and its rationale are identical.
1567
+
1568
+ - [ ] **Step 2: Wire the grid wrapper**
1569
+
1570
+ In `lib/plutonium/ui/grid/resource.rb`, add the same `data-controller="positioned"`, `data-positioned-url-value`, and per-card `data-positioned-target="row"` / `data-positioned-row-id` as the table (Task 7 Step 5).
1571
+
1572
+ No separate turbo-stream target is needed: `#pu-collection-<plural>` (`ui/page/index.rb:81`) already wraps the rendered grid as well as the table, and `#pu-flash` is layout-level. The same query-string requirement applies.
1573
+
1574
+ - [ ] **Step 3: Select the horizontal index computation**
1575
+
1576
+ Add a `data-positioned-axis-value` of `"horizontal"` on the grid wrapper, and in `positioned_controller.js` branch on it:
1577
+
1578
+ ```javascript
1579
+ static values = { url: String, axis: { type: String, default: "vertical" } }
1580
+
1581
+ #dropIndex(event, items) {
1582
+ return this.axisValue === "horizontal"
1583
+ ? computeDropIndexHorizontal(event.clientX, items)
1584
+ : computeDropIndex(event.clientY, items)
1585
+ }
1586
+ ```
1587
+
1588
+ Replace both `computeDropIndex(event.clientY, others)` call sites with `this.#dropIndex(event, others)`.
1589
+
1590
+ - [ ] **Step 4: Build and verify**
1591
+
1592
+ Run: `yarn build`
1593
+ Expected: no errors
1594
+
1595
+ Manually drag a card on a grid index and confirm it reorders and persists.
1596
+
1597
+ - [ ] **Step 5: Commit**
1598
+
1599
+ ```bash
1600
+ git add lib/plutonium/ui/grid/card.rb lib/plutonium/ui/grid/resource.rb \
1601
+ src/js/controllers/positioned_controller.js app/assets
1602
+ git commit -m "feat(ui): drag-to-reorder for positioned grid views"
1603
+ ```
1604
+
1605
+ ---
1606
+
1607
+ ### Task 9: System test
1608
+
1609
+ **Goal:** An end-to-end test that a real drag and a real keyboard reorder both persist.
1610
+
1611
+ **Files:**
1612
+ - Create: `test/system/positioned_drag_test.rb`
1613
+ - Modify: `test/dummy/app/definitions/task_definition.rb` (add `position_on`)
1614
+
1615
+ **Acceptance Criteria:**
1616
+ - [ ] A drag on the Tasks index reorders and survives a reload
1617
+ - [ ] ArrowDown on a focused grip reorders and survives a reload
1618
+ - [ ] Sorting by another column disables the grip
1619
+
1620
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/system/positioned_drag_test.rb` → all pass
1621
+
1622
+ **Steps:**
1623
+
1624
+ - [ ] **Step 1: Declare `position_on` on the dummy Task definition**
1625
+
1626
+ `Task` already has `positioned_on :position, scope: :status` (`test/dummy/app/models/task.rb:10`). Add to `test/dummy/app/definitions/task_definition.rb`:
1627
+
1628
+ ```ruby
1629
+ position_on
1630
+ ```
1631
+
1632
+ **Important:** `test/dummy` is git-cleaned by the generator tests. Stage this change before running the suite, or it will be reverted mid-run.
1633
+
1634
+ - [ ] **Step 2: Write the system test**
1635
+
1636
+ Create `test/system/positioned_drag_test.rb`, following the conventions of the existing tests in `test/system/` (read one first for the driver setup and sign-in helper).
1637
+
1638
+ ```ruby
1639
+ # frozen_string_literal: true
1640
+
1641
+ require "application_system_test_case"
1642
+
1643
+ class PositionedDragTest < ApplicationSystemTestCase
1644
+ def test_dragging_a_row_reorders_it_and_persists
1645
+ visit_tasks_index
1646
+
1647
+ first_row_id = page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
1648
+ drag_row(first_row_id, to_index: 2)
1649
+
1650
+ visit current_path
1651
+ refute_equal first_row_id,
1652
+ page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
1653
+ end
1654
+
1655
+ def test_keyboard_reorder_persists
1656
+ visit_tasks_index
1657
+
1658
+ first_row_id = page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
1659
+ find("[data-positioned-row-id='#{first_row_id}'] [data-positioned-target='handle']")
1660
+ .send_keys(:arrow_down)
1661
+
1662
+ visit current_path
1663
+ refute_equal first_row_id,
1664
+ page.all("[data-positioned-target='row']").first["data-positioned-row-id"]
1665
+ end
1666
+
1667
+ def test_sorting_by_another_column_disables_the_grip
1668
+ visit_tasks_index
1669
+ click_on "Name"
1670
+
1671
+ assert_selector "[title='Sort by position to reorder']"
1672
+ assert_no_selector "[data-positioned-target='handle']"
1673
+ end
1674
+ end
1675
+ ```
1676
+
1677
+ - [ ] **Step 3: Run**
1678
+
1679
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/system/positioned_drag_test.rb`
1680
+ Expected: PASS
1681
+
1682
+ - [ ] **Step 4: Run the full suite across Rails versions**
1683
+
1684
+ Run: `bundle exec appraisal rake test`
1685
+ Expected: PASS on rails-7, rails-8.0, rails-8.1
1686
+
1687
+ - [ ] **Step 5: Commit**
1688
+
1689
+ ```bash
1690
+ git add test/system/positioned_drag_test.rb test/dummy/app/definitions/task_definition.rb
1691
+ git commit -m "test(positioning): system tests for drag and keyboard reorder"
1692
+ ```
1693
+
1694
+ ---
1695
+
1696
+ ### Task 10: Documentation and skills
1697
+
1698
+ **Goal:** The feature is discoverable, and the Mode B escape hatch is documented with a worked third-party example.
1699
+
1700
+ **Files:**
1701
+ - Create: `docs/reference/positioning.md`
1702
+ - Modify: `docs/reference/resource/actions.md` (hidden actions)
1703
+ - Modify: `.claude/skills/plutonium-resource/SKILL.md`
1704
+ - Modify: `.claude/skills/plutonium-ui/SKILL.md`
1705
+ - Modify: `docs/.vitepress/config.*` (sidebar entry)
1706
+
1707
+ **Acceptance Criteria:**
1708
+ - [ ] The model/definition split is explained: `positioned_on` vs `position_on`
1709
+ - [ ] All three modes documented, with a worked `acts_as_list` example
1710
+ - [ ] The nested-scope contract from spec §2.1 is stated
1711
+ - [ ] The implicit `default_sort` override is called out prominently
1712
+ - [ ] The deliberate table-vs-kanban affordance difference is explained
1713
+ - [ ] Hidden actions documented, including that they are NOT an authorization boundary
1714
+ - [ ] **The `include Plutonium::Positioning` → `::Model` breaking change is called out** with a one-line upgrade note, and the reason (constant leakage into user models — spec §2.0)
1715
+ - [ ] `yarn docs:build` succeeds with no broken links
1716
+
1717
+ **Verify:** `yarn docs:build` → completes, no broken links
1718
+
1719
+ **Steps:**
1720
+
1721
+ - [ ] **Step 1: Write `docs/reference/positioning.md`**
1722
+
1723
+ Cover, in order: the model layer (`include Plutonium::Positioning::Model`, `positioned_on`, the `t.position` migration helper); the definition layer (`position_on` and its four forms); what `position_on` expands to, with the `default_sort` override called out in a warning callout; when dragging is enabled and how the disabled grip restores position order; the nested-scope contract; and a worked Mode B example:
1724
+
1725
+ ```ruby
1726
+ # Using acts_as_list instead of Plutonium::Positioning
1727
+ class TaskDefinition < Plutonium::Resource::Definition
1728
+ position_on :position do |move|
1729
+ move.record.insert_at(move.index + 1)
1730
+ end
1731
+ end
1732
+ ```
1733
+
1734
+ Note that Mode B always reconciles, and why (`acts_as_list` renumbers the whole group).
1735
+
1736
+ - [ ] **Step 2: Document hidden actions**
1737
+
1738
+ In `docs/reference/resource/actions.md`, add a section on `hidden: true`: what it gives you (route, policy predicate, form/params machinery), what it withholds (every render surface), and the warning that it is a display gate, not an authorization boundary — authorization belongs in the policy.
1739
+
1740
+ - [ ] **Step 3: Update the skills**
1741
+
1742
+ `plutonium-resource`: `position_on` in the definition DSL reference, alongside `sort` and `default_sort`.
1743
+ `plutonium-ui`: the drag affordance, the table-vs-kanban difference and why (text selection + `row_click`).
1744
+
1745
+ Both skills require a gem release to take effect — note that in the commit message, not in the docs.
1746
+
1747
+ - [ ] **Step 4: Add the sidebar entry**
1748
+
1749
+ Add `positioning.md` to the reference section of the VitePress sidebar.
1750
+
1751
+ - [ ] **Step 5: Build the docs**
1752
+
1753
+ Run: `yarn docs:build`
1754
+ Expected: completes, no broken links
1755
+
1756
+ - [ ] **Step 6: Commit**
1757
+
1758
+ ```bash
1759
+ git add docs .claude/skills
1760
+ git commit -m "docs(positioning): document position_on, hidden actions, and the block escape hatch"
1761
+ ```
1762
+
1763
+ ---
1764
+
1765
+ ## Verification Summary
1766
+
1767
+ | Task | Command |
1768
+ |---|---|
1769
+ | 1 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/positioning_test.rb` (unmodified) |
1770
+ | 2 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/positioning_test.rb` |
1771
+ | 3 | `rg -n "kanban_drop\b" lib app test` → no output; then `bundle exec appraisal rails-8.1 rake test` |
1772
+ | 4 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/positioning_test.rb` |
1773
+ | 5 | `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/controllers/position_actions_test.rb` |
1774
+ | 6 | `yarn build` + `bundle exec appraisal rails-8.1 rake test` + manual kanban drag |
1775
+ | 7 | `yarn build` + manual drag on `/tasks` |
1776
+ | 8 | `yarn build` + manual drag on a grid index |
1777
+ | 9 | `bundle exec appraisal rake test` (all Rails versions) |
1778
+ | 10 | `yarn docs:build` |
1779
+
1780
+ ## Known Open Points
1781
+
1782
+ These are places where the plan deliberately says "locate the integration point" rather than inventing an API, because the exact seam was not verified during planning. The implementer must read the named file first:
1783
+
1784
+ 1. **Task 5 Step 5** — `position_collection_frame_id` and `render_position_collection_html` must reuse `CrudActions`' existing collection-rendering path (`lib/plutonium/resource/controllers/crud_actions.rb`), modelled on `KanbanActions#render_kanban_column_html` (`kanban_actions.rb:568`).
1785
+ 2. **Task 7 Step 2** — the active-sort accessor on `lib/plutonium/resource/query_object.rb`, for `ordered_by_position?`.
1786
+ 3. **Task 7 Step 5** — where the table wrapper's attributes are built in `lib/plutonium/ui/table/resource.rb`.
1787
+ 4. **Task 7 Step 1** — whether `<tr>` already carries a `group/row` class in `lib/plutonium/ui/table/base.rb`.