plutonium 0.62.2 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,568 @@
1
+ # Positioning & Drag-to-Reorder
2
+
3
+ Manual ordering for a resource: a decimal `position` column on the model, a `position_on` line in the definition, and Plutonium renders a drag grip on the index **table** and the **card grid** — and on any **nested association table** of the same resource.
4
+
5
+ Ordering is **fractional**. A drop writes one decimal (the midpoint between its two neighbours), so the common case updates exactly one row. No `UPDATE … SET position = position + 1` sweep across the table.
6
+
7
+ The same machinery drives the [kanban board](/reference/kanban/), which is why a board and a table share one vocabulary: see [Two verbs, one feature](#two-verbs-one-feature).
8
+
9
+ ## 🚨 Critical
10
+
11
+ - **The concern is `Plutonium::Positioning::Model`, not `Plutonium::Positioning`.** This changed — see [the upgrade note](#upgrading-from-include-plutonium-positioning) before you touch anything.
12
+ - **`position_on` silently sets `default_sort`** when your definition hasn't declared one. See [the warning](#what-position-on-expands-to).
13
+ - **The model owns storage; the definition owns the UI.** `positioned_on` (model) says *how positions are stored*. `position_on` (definition) says *this list is orderable*. Never restate the column or the scope in the definition.
14
+ - **Dragging is offered only while the collection is sorted ascending by the position attribute.** Under any other sort the grip renders as a link back to that sort, and the server rejects the drop outright.
15
+ - **`reposition?` on the policy gates the drop.** It defaults to `update?`. Override it to let someone reorder without granting full edit access — or to forbid reordering while still allowing edits.
16
+ - **Prefer [Mode A](#mode-a-delegate) — the framework-owned write.** A block ([Mode B](#mode-b)) is a supported escape hatch for models already ordered by a positioning gem, but it hands you semantics Mode A handles for you. Already on `acts_as_list`? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
17
+
18
+ ## Two verbs, one feature {#two-verbs-one-feature}
19
+
20
+ There are exactly two, and the split is deliberate:
21
+
22
+ | Verb | Lives on | Answers |
23
+ |---|---|---|
24
+ | `positioned_on :column, scope: :attr` | the **model** | *How are positions stored?* Which column, and what groups rows into independent orderings. |
25
+ | `position_on` | the **definition** (and inside `kanban do…end`) | *Is this list orderable, and who writes the new position?* |
26
+
27
+ The definition-layer verb is `position_on` on both a definition and a kanban board — the **same** verb, not a third one. A board with no `position_on` of its own inherits the definition's. So the framework's whole positioning vocabulary is two words, and you learn the board by learning the table.
28
+
29
+ ```ruby
30
+ class Task < ApplicationRecord
31
+ include Plutonium::Positioning::Model
32
+ positioned_on :position, scope: :status # ← storage
33
+ end
34
+
35
+ class TaskDefinition < Plutonium::Resource::Definition
36
+ position_on # ← "this UI can be reordered"
37
+ end
38
+ ```
39
+
40
+ That second line names neither the column nor the scope. It reads them off the model, and **raises at class-load** if they disagree (see [Mode A](#mode-a-delegate)).
41
+
42
+ ---
43
+
44
+ ## Quick start
45
+
46
+ **1. Migration** — use the `t.position` helper. It emits a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so the scale can't be too small to rebalance cleanly. It works in `create_table` and `change_table` alike:
47
+
48
+ ```ruby
49
+ create_table :tasks do |t|
50
+ t.string :status, null: false, default: "todo"
51
+ t.position # decimal :position, precision: 16, scale: 8
52
+ t.timestamps
53
+
54
+ t.index [:status, :position] # match your scope attribute
55
+ end
56
+ ```
57
+
58
+ ```ruby
59
+ t.position :sort_order # custom column name
60
+ t.position index: true # also add a single-column index
61
+ t.position scale: 10 # override precision/scale
62
+ ```
63
+
64
+ **2. Model** — include the concern and declare the column:
65
+
66
+ ```ruby
67
+ class Task < ApplicationRecord
68
+ include Plutonium::Resource::Record
69
+ include Plutonium::Positioning::Model
70
+
71
+ positioned_on :position, scope: :status
72
+ end
73
+ ```
74
+
75
+ **3. Definition** — one line:
76
+
77
+ ```ruby
78
+ class TaskDefinition < Plutonium::Resource::Definition
79
+ position_on
80
+ end
81
+ ```
82
+
83
+ **4. Existing rows** need positions. `backfill_positions!` numbers every row per scope group as `1.0, 2.0, 3.0, …`:
84
+
85
+ ```ruby
86
+ Task.backfill_positions!(order: :created_at)
87
+ ```
88
+
89
+ That's it. The index table and the card grid now render a drag grip, `POST /tasks/:id/reposition` is live, and `TaskPolicy#reposition?` (inherited, `= update?`) gates it.
90
+
91
+ ---
92
+
93
+ ## The model layer — `Plutonium::Positioning::Model`
94
+
95
+ ### Upgrading from `include Plutonium::Positioning` {#upgrading-from-include-plutonium-positioning}
96
+
97
+ ::: danger Breaking change — the concern moved down a level
98
+ `Plutonium::Positioning` is now a **pure namespace**. The ActiveRecord concern is `Plutonium::Positioning::Model`.
99
+
100
+ ```ruby
101
+ # Before
102
+ include Plutonium::Positioning
103
+
104
+ # After
105
+ include Plutonium::Positioning::Model
106
+ ```
107
+
108
+ `positioned_on`, `reposition!` and `backfill_positions!` are unchanged — only the `include` line moves.
109
+
110
+ **Why it had to change.** Every constant nested inside an included module joins the including class's constant lookup. While the concern *was* `Plutonium::Positioning`, a bare `Config` written anywhere inside a positioned model resolved to `Plutonium::Positioning::Config` instead of the application's own `::Config` — silently, with no error, in a class the app author never suspected. Splitting the namespace from the mixin stops the leak: `Model` nests nothing.
111
+ :::
112
+
113
+ ### `positioned_on(column = :position, scope: nil)`
114
+
115
+ | Argument | Description |
116
+ |---|---|
117
+ | `column` | The `decimal` column that stores positions. Default `:position`. |
118
+ | `scope:` | Group positions by this attribute. Rows with different scope values are ordered **independently**. `nil` = one global ordering across the whole table. |
119
+
120
+ After the call the model gains:
121
+
122
+ - a `before_create` callback assigning the next position **in its scope group** (appends to the end);
123
+ - `reposition!(prev_record:, next_record:)`;
124
+ - `backfill_positions!(order: :created_at)` on the class.
125
+
126
+ ::: warning Including the concern is not enough
127
+ `include Plutonium::Positioning::Model` without a `positioned_on` call installs no `before_create` hook — every row is created with a `NULL` position and the list orders arbitrarily. `position_on` in the definition raises at class-load rather than let that ship.
128
+ :::
129
+
130
+ ### `reposition!(prev_record:, next_record:)`
131
+
132
+ Moves the record so it sits between the two neighbours **within its scope group**. Pass `nil` for an end.
133
+
134
+ ```ruby
135
+ task.reposition!(prev_record: a, next_record: b) # midpoint
136
+ task.reposition!(prev_record: nil, next_record: first) # prepend
137
+ task.reposition!(prev_record: last, next_record: nil) # append
138
+ ```
139
+
140
+ It returns a `Plutonium::Positioning::Result`, whose **`rebalanced?`** tells the caller whether rows *other than this one* moved. That is the signal the drop endpoint uses to decide between "204, nothing to repaint" and "here is the whole collection back".
141
+
142
+ The arithmetic, the `EPSILON = 1e-6` rebalance threshold, and the pure `Plutonium::Positioning.position_between` / `.gap_exhausted?` helpers are documented in full under [Kanban › Positioning](/reference/kanban/positioning) — the model layer is shared, so there is one description of it and both surfaces point at it.
143
+
144
+ ---
145
+
146
+ ## The definition layer — `position_on`
147
+
148
+ ### Four forms
149
+
150
+ ```ruby
151
+ position_on # Mode A — follow the model's column ← use this
152
+ position_on :sort_order # Mode A — must MATCH the model's column
153
+ position_on(:rank) { |move| … } # Mode B — another gem owns the write (escape hatch)
154
+ position_on false # Mode C — ordering off
155
+ ```
156
+
157
+ [Mode A](#mode-a-delegate) is the one to reach for: the framework owns the write, and the bare form cannot disagree with the model. [Mode B](#mode-b) exists for models already ordered by a positioning gem — it is supported and tested, but it hands you semantics Mode A handles, so prefer [migrating off the gem](#migrating-off-a-positioning-gem) where you can.
158
+
159
+ ### What `position_on` expands to {#what-position-on-expands-to}
160
+
161
+ Every form except `false` also registers three things:
162
+
163
+ ```ruby
164
+ sort :position # so the column is sortable at all
165
+ default_sort :position, :asc # ⚠ see below
166
+ action :reposition, hidden: true # route + policy predicate, no button
167
+ ```
168
+
169
+ The `sort` registration is load-bearing rather than a convenience: dragging is only permitted while the list is in ascending position order, so without a permitted sort there would be **no way back out** of the disabled state.
170
+
171
+ ::: warning `position_on` claims `default_sort`
172
+ If your definition has not declared a `default_sort`, `position_on` sets it to `<attribute>, :asc` — replacing the framework default of `id, :desc`. A resource that used to list newest-first will list in position order after you add this line.
173
+
174
+ That is almost always what you want (a hand-ordered list that ignores its own order is useless), but it is a change you did not write. To keep a different default, just declare one — **in either order**, above or below `position_on`:
175
+
176
+ ```ruby
177
+ class TaskDefinition < Plutonium::Resource::Definition
178
+ default_sort :created_at, :desc
179
+ position_on # registers `sort :position`, leaves default_sort alone
180
+ end
181
+ ```
182
+
183
+ `position_on` only claims `default_sort` while nobody has declared one, so an explicit declaration always wins regardless of where it sits in the class body. Note the consequence: with a foreign default sort, the list opens **not draggable** — the grip renders as a link that applies the position sort.
184
+
185
+ "Explicit" is by declaration, not by value: `default_sort :id, :desc` wins too, even though it names the same field and direction as the framework default. Writing it means you chose it.
186
+
187
+ **Inherited declarations count.** A base definition is the usual way an app applies one house ordering to every resource, and `position_on` respects it:
188
+
189
+ ```ruby
190
+ class ResourceDefinition < Plutonium::Resource::Definition
191
+ default_sort :created_at, :desc # house style, every resource
192
+ end
193
+
194
+ class TaskDefinition < ResourceDefinition
195
+ position_on # inherits :created_at — NOT draggable on open
196
+ end
197
+ ```
198
+
199
+ Every positioned resource under that base therefore opens in the disabled state until the user clicks the grip. If you want position order to win for a particular resource, declare it there:
200
+
201
+ ```ruby
202
+ class TaskDefinition < ResourceDefinition
203
+ position_on
204
+ default_sort :position, :asc
205
+ end
206
+ ```
207
+ :::
208
+
209
+ ### Mode A — delegate (the default) {#mode-a-delegate}
210
+
211
+ The framework owns the write. On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`.
212
+
213
+ ```ruby
214
+ position_on # follows the model's positioning_column
215
+ position_on :position # explicit, and must agree with the model
216
+ ```
217
+
218
+ Mode A validates the model **at class-load**, with errors that name the fix:
219
+
220
+ | Situation | Result |
221
+ |---|---|
222
+ | Model does not `include Plutonium::Positioning::Model` | `ArgumentError` pointing at the concern, and at Mode B as the escape hatch |
223
+ | Model includes it but never calls `positioned_on` | `ArgumentError` — no `before_create`, so every row would sort arbitrarily |
224
+ | `position_on :rank` while the model says `positioned_on :position` | `ArgumentError` — the list would be *ordered* by one column while `reposition!` *wrote* another, so dragging would appear to do nothing |
225
+
226
+ That last one is why the bare form is the recommended one: it cannot disagree with the model.
227
+
228
+ ### Mode B — bring your own positioning gem {#mode-b}
229
+
230
+ ::: tip Reach for Mode A first
231
+ Mode A is one word in the definition. The *correct* `acts_as_list` block further down this page is fifteen lines of rank arithmetic, and writing it means already knowing three things neither library's README tells you:
232
+
233
+ - **`move.index` is page-relative**, while a positioning gem's `insert_at` addresses the whole group;
234
+ - **removing a record shifts its neighbours' ranks by one**, in a direction that depends on whether the record started above or below them;
235
+ - **a blank `move.prev` means "nothing above me *on screen*"**, not "top of the list".
236
+
237
+ These docs got two of those three wrong for a while — in the very section written to explain the first. That is the honest case for preferring Mode A. Not that Mode B is broken: it is supported, it is [tested against the real gem](#why-move-index-cannot-be-the-anchor), and the recipe below is correct. But its correctness lives in arithmetic you own and have to keep owning, and Mode A's does not.
238
+
239
+ Choosing today? Choose Mode A. Already on a positioning gem? [Migrating](#migrating-off-a-positioning-gem) is a column change, two lines on the model, and a backfill.
240
+ :::
241
+
242
+ Give `position_on` a block and Plutonium stops writing positions. It still **orders** the collection by the attribute you name, still renders the grip, still routes and authorizes the drop — but the block persists the new value.
243
+
244
+ The block receives a single `Plutonium::Positioning::Move`:
245
+
246
+ | Field | Meaning |
247
+ |---|---|
248
+ | `move.record` | the dropped record |
249
+ | `move.prev` | the record immediately **before** the slot **on the client's page**, or `nil` |
250
+ | `move.next` | the record immediately **after** the slot **on the client's page**, or `nil` |
251
+ | `move.index` | 0-based insertion index among the other rows **on that page** — see [why it cannot be your anchor](#why-move-index-cannot-be-the-anchor) |
252
+ | `move.column` | the destination kanban column key — `nil` on tables and grids, which have no columns |
253
+
254
+ It is called with `call`, not `instance_exec` — `self` inside the block is wherever you wrote it.
255
+
256
+ #### What the framework stops doing {#mode-b-handover}
257
+
258
+ A block is an opaque write. Plutonium cannot know what it touched, or against what notion of "neighbour" it decided, so three things Mode A does are simply not done for you:
259
+
260
+ | | Mode A | Mode B |
261
+ |---|---|---|
262
+ | **Hidden boundary neighbours** | resolved server-side before the write, so a drop at the edge of a page anchors to the real row the client couldn't see | not resolved — `resolve_position_boundaries` returns early unless the config delegates. The block gets the client's viewport verbatim, `nil` and all |
263
+ | **Drop under a foreign sort** | rejected `422` before any write | not checked server-side. Only the client-side gate applies; the block owns its own notion of neighbours |
264
+ | **Response** | `204` when nothing else moved | always `200` + a turbo-stream of the collection |
265
+
266
+ That last row is not a missing optimisation. Gems in this space routinely renumber the entire group on every move — `acts_as_list` does exactly that — so the client's optimistic DOM is stale by definition. A repaint per drop is the only way the two are guaranteed to agree.
267
+
268
+ The first two rows are the ones to weigh before choosing Mode B: they are the semantics you are taking on, and the worked example below is what taking them on looks like.
269
+
270
+ #### Migrating off a positioning gem {#migrating-off-a-positioning-gem}
271
+
272
+ If nothing external depends on the gem's contiguous integer ranks, moving to Mode A is a column change, two lines on the model, and a backfill.
273
+
274
+ **1. Change the column.** `acts_as_list` stores contiguous integers; Plutonium stores fractional decimals, and `t.position` emits `decimal(16, 8)` for exactly that reason — a whole-number column would round every midpoint straight back onto a neighbour. `t.position` *adds* a column, so an existing one wants `change_column`:
275
+
276
+ ```ruby
277
+ class ChangeTaskPositionToDecimal < ActiveRecord::Migration[8.0]
278
+ def change
279
+ change_column :tasks, :position, :decimal, precision: 16, scale: 8
280
+ end
281
+ end
282
+ ```
283
+
284
+ **2. Swap the macro on the model.** Note that `scope:` takes a bare Symbol here — the [Array-form trap](#worked-example-acts-as-list) goes away with the gem:
285
+
286
+ ```ruby
287
+ class Task < ApplicationRecord
288
+ include Plutonium::Resource::Record
289
+
290
+ acts_as_list scope: [:status] # [!code --]
291
+ include Plutonium::Positioning::Model # [!code ++]
292
+ positioned_on :position, scope: :status # [!code ++]
293
+ end
294
+ ```
295
+
296
+ **3. Number the existing rows.** `backfill_positions!(order:)` walks each scope group and writes `1.0, 2.0, 3.0, …` in `order` order. Pass `order: :position` to keep the ordering the gem already produced:
297
+
298
+ ```ruby
299
+ Task.backfill_positions!(order: :position)
300
+ ```
301
+
302
+ Then drop the block from the definition — a bare `position_on` is the whole of Mode A.
303
+
304
+ ::: warning `backfill_positions!` is a one-shot
305
+ It loads the table, groups it in Ruby, and writes every row with `update_column` — no callbacks, no validations, no `updated_at`. That is what you want for a backfill and not what you want in a request. Run it from a migration or a `rails runner`, once.
306
+ :::
307
+
308
+ #### Worked example: staying on `acts_as_list` {#worked-example-acts-as-list}
309
+
310
+ For when the gem is not yours to remove — another codepath calls `move_higher`, a report reads the integer ranks, or the migration simply isn't due yet. This block is correct and stays correct; it is just longer than the one word above.
311
+
312
+ ::: danger Anchor off the neighbours, never off `move.index`
313
+ `insert_at(move.index + 1)` is the obvious block to write and it is **wrong on any list that paginates or filters** — which, since Plutonium paginates every index at 20 rows by default, means wrong on any list with 21 rows in it. The numbers are in [Why `move.index` cannot be the anchor](#why-move-index-cannot-be-the-anchor). Use the block below.
314
+ :::
315
+
316
+ ```ruby
317
+ class Task < ApplicationRecord
318
+ include Plutonium::Resource::Record
319
+
320
+ # NOTE the Array. `scope: :status` does NOT work: acts_as_list runs a bare
321
+ # Symbol scope through its `idify` helper, which appends `_id` to anything
322
+ # that is neither an association nor already `*_id`. `scope: :status` becomes
323
+ # `scope: :status_id` and every create dies with
324
+ # `NoMethodError: undefined method 'status_id'`. The Array form is literal.
325
+ acts_as_list scope: [:status] # integer :position, 1-based, contiguous
326
+ end
327
+
328
+ class TaskDefinition < Plutonium::Resource::Definition
329
+ # No Plutonium::Positioning::Model, no positioned_on — acts_as_list owns
330
+ # both the column and the write. Plutonium only orders, routes and authorizes.
331
+ position_on :position do |move|
332
+ record = move.record
333
+
334
+ target =
335
+ if move.prev
336
+ # Land immediately after prev. When the record currently sits ABOVE
337
+ # prev, removing it shifts prev up one — so prev's own rank is already
338
+ # the slot the record should occupy.
339
+ (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
340
+ elsif move.next
341
+ # Nothing visible above, but rows may still sit above off-page or behind
342
+ # a filter. Land immediately before next, mirrored: when the record
343
+ # currently sits above next, removing it shifts next up one.
344
+ (record.position < move.next.position) ? move.next.position - 1 : move.next.position
345
+ else
346
+ 1 # the only row in the list
347
+ end
348
+
349
+ record.insert_at(target)
350
+ end
351
+ end
352
+ ```
353
+
354
+ `insert_at(n)` in `acts_as_list` means "end up at rank `n` after the move", and every rank in the expression above is a **real** rank read off a neighbour record — never a viewport offset. `default_sort :position, :asc` is registered for you, which is exactly the order `acts_as_list` maintains.
355
+
356
+ Both the `elsif move.next` branch and the `else 1` are load-bearing. A blank `prev` means "nothing above me **on my screen**"; falling straight through to rank 1 sends the row to the top of the whole list, past every row the page or the filter hid.
357
+
358
+ ::: tip `insert_at` swallows a failed save
359
+ `insert_at` calls `save`, not `save!`, so a record that fails validation mid-move silently no-ops — the drop answers `200` and the streamed collection shows the row back where it started. Use `insert_at!` if you would rather the endpoint surface the errors as a `422` with a toast — the validation-failure row of [the response table](#the-endpoint).
360
+ :::
361
+
362
+ #### Why `move.index` cannot be the anchor {#why-move-index-cannot-be-the-anchor}
363
+
364
+ Plutonium hands a Mode B block the client's neighbours **verbatim** — it does not look up the rows pagination or a filter hid, the way Mode A does. So `move.index` is a claim about the **viewport**, while `insert_at` addresses the **whole scope group**. The two only agree on an unfiltered page 1.
365
+
366
+ These are measured, not reasoned — `test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb` drives each one through `POST <member>/reposition` against the real gem:
367
+
368
+ | List | Gesture | `insert_at(move.index + 1)` | Anchored off neighbours |
369
+ |---|---|---|---|
370
+ | 25 rows, page 2 | drag rank 25 between ranks 21 and 22 (`to_index: 1`) | `insert_at(2)` → the row lands at **rank 2**, 20 slots away on page 1 | rank **22**, where it was dropped |
371
+ | 25 rows, page 2 | drag rank 25 to the top of page 2 (`prev_id` blank, `to_index: 0`) | `insert_at(1)` → the row lands at **rank 1**, the head of the whole list | rank **21**, below the last row of page 1 |
372
+ | 10 rows, filter shows ranks 1 and 10 | drag rank 1 below the filtered row at rank 10 (`to_index: 1`) | `insert_at(2)` → the row moves **one slot**, staying at the top | rank **10**, below the hidden rows |
373
+ | 10 rows, filter shows ranks 5 and 10 | drag rank 10 above the filtered row at rank 5 (`prev_id` blank, `to_index: 0`) | `insert_at(1)` → **rank 1**, above four rows the filter hid | rank **5**, immediately above the row it was dropped on |
374
+
375
+ Mode A has none of this to think about: it resolves hidden boundary neighbours server-side before the write (see [Nested resources & scope groups](#nested-resources-scope-groups)). Mode B is a real escape hatch and the block above is a correct one — the price of the hatch is simply that the semantics are yours. If nothing outside the gem depends on those integer ranks, [the migration](#migrating-off-a-positioning-gem) hands them back.
376
+
377
+ ### Mode C — disabled
378
+
379
+ ```ruby
380
+ position_on false
381
+ ```
382
+
383
+ No ordering is applied (the relation passes through unchanged), no `sort`/`default_sort` is registered, no `reposition` action is created, and the endpoint answers **404**. Useful to switch a resource's ordering off in one portal while another keeps it — see [portal overrides](/reference/resource/definition).
384
+
385
+ ### Kanban boards inherit the definition's `position_on`
386
+
387
+ A board resolves its positioning strategy as: **its own `position_on`, else the definition's, else the historic default** (`:position`, Mode A).
388
+
389
+ ```ruby
390
+ class TaskDefinition < Plutonium::Resource::Definition
391
+ position_on :sort_order # table, grid AND board all order by :sort_order
392
+
393
+ kanban do
394
+ column :todo
395
+ column :done
396
+ end
397
+ end
398
+ ```
399
+
400
+ ```ruby
401
+ class TaskDefinition < Plutonium::Resource::Definition
402
+ position_on :sort_order # table and grid
403
+
404
+ kanban do
405
+ position_on :board_rank # …but the board overrides it
406
+ end
407
+ end
408
+ ```
409
+
410
+ Resolution is **lazy**, so declaration order in the class body does not matter — a `kanban do…end` written above `position_on` still picks it up.
411
+
412
+ ---
413
+
414
+ ## When dragging is offered
415
+
416
+ A drop says "put me between these two rows". That only describes a position when the **visual order is the stored order**. Under a title sort the neighbours say nothing; under a *descending* position sort they say the opposite of what the write would assume.
417
+
418
+ So the grip is live only when the collection is sorted **ascending, by the position attribute, and by nothing else** — whether that comes from the default sort or from the user clicking the column header. Both halves of the feature enforce the same rule from the same predicate:
419
+
420
+ - **Client** — the Stimulus controller is not even attached under a foreign sort. There is nothing to drag against.
421
+ - **Server** — a Mode A drop arriving under a foreign sort is rejected with `422` **before any write**, and the collection is streamed back with a toast. (Mode B has no server-side sort check: the block owns its own notion of neighbours, so only the client-side gate applies.)
422
+
423
+ ### The disabled grip is the way out of the disabled state
424
+
425
+ Under a foreign sort the grip does not disappear — it renders as a **link that applies the ascending position sort**. Hiding it would leave the user with no hint that the list is reorderable at all, and no way to make it so. This is precisely why `position_on` registers `sort <attribute>`.
426
+
427
+ Per record, the grip is also gated on `reposition?`: a row this viewer may not reorder renders exactly as it did before, with no grip at all. Offering an affordance that can only ever answer `403` is worse than offering none.
428
+
429
+ ---
430
+
431
+ ## The affordance: table, grid, and board
432
+
433
+ | Surface | What you drag | Axis |
434
+ |---|---|---|
435
+ | Index table | the **grip** in the row's first cell | vertical |
436
+ | Card grid | the **grip** in the card's top-left gutter | horizontal, wrap-aware |
437
+ | Nested association table | the **grip**, same as the index table | vertical |
438
+ | Kanban board | the **whole card** | both (cross-column) |
439
+
440
+ On a table the grip sits *inside* the first cell, pulled left into padding the cell already had, so the cell's content does not shift by a pixel whether the grip is there or not. It appears on hover, and on keyboard focus.
441
+
442
+ ::: details Why only the grip is draggable on a table — but the whole card is on a board
443
+ Two concrete costs of making a `<tr>` draggable, both silent regressions on an ordinary data table:
444
+
445
+ 1. **`draggable="true"` disables text selection inside the element** in every major browser. On a data table that quietly removes the ability to select and copy a cell value.
446
+ 2. **`row_click_controller` makes the whole row the Show affordance.** A draggable row fights it: a drag that starts and ends in place still fires a click, and the user is navigated away instead of left where they were.
447
+
448
+ Neither applies to a kanban card — it has no cell text to select and no row-click behaviour — so the board keeps whole-card dragging, which is the better gesture where you can afford it. The inconsistency is deliberate.
449
+
450
+ A **grid** card gets a grip rather than whole-card dragging, because unlike a kanban card it *does* carry a row-click show affordance: reason 2 applies to it exactly as it does to a table row.
451
+ :::
452
+
453
+ ---
454
+
455
+ ## The endpoint — `POST <member>/reposition` {#the-endpoint}
456
+
457
+ Mounted on every resource (like the kanban move routes). Resources that declare no `position_on` answer `404`.
458
+
459
+ ```
460
+ POST /tasks/42/reposition?<the collection's own query string>
461
+ prev_id=41&next_id=43&to_index=2
462
+ ```
463
+
464
+ The trailing query string is load-bearing, not decoration: it is the index's own query — search, filters, scope, sort, page, view — and it is what lets the endpoint re-render **exactly the page the user is looking at** through the ordinary index pipeline. Page 3 of a filtered list comes back as page 3 of that filtered list.
465
+
466
+ The client moves the row **optimistically**, so the response is deliberately quiet in the common case:
467
+
468
+ | Outcome | Response |
469
+ |---|---|
470
+ | Clean Mode A drop, both neighbours resolved | `204 No Content` — nothing repaints |
471
+ | `reposition!` had to rebalance the group | `200` + turbo-stream of the collection |
472
+ | A neighbour did not resolve, or belongs to another positioning group | `200` + stream |
473
+ | Mode B (opaque block write) | `200` + stream |
474
+ | `reposition?` denied | `403` + stream + toast (the row snaps back) |
475
+ | `index?` denied | `403`, **no body** — you may not see the list, so the refusal must not carry it to you |
476
+ | Mode A drop under a foreign sort | `422` + stream + toast, **no write** |
477
+ | Validation failure, or the record was destroyed meanwhile | `422` + stream + toast |
478
+ | No `position_on`, Mode C, or the kanban view is selected | `404` |
479
+
480
+ ### Authorization
481
+
482
+ ```ruby
483
+ class TaskPolicy < ResourcePolicy
484
+ # Defaults to update?. Override to decouple the two.
485
+ def reposition? = record.status != "archived"
486
+ end
487
+ ```
488
+
489
+ Two checks run, in this order:
490
+
491
+ 1. **`index?` on the resource class.** You must be able to *see* a list to reorder it — without this, a policy with `index? == false` but `update? == true` would be handed the whole listing by the reconciliation render.
492
+ 2. **`reposition?` on the record.**
493
+
494
+ The same `reposition?` decides whether the grip renders at all, per row.
495
+
496
+ ---
497
+
498
+ ## Nested resources & scope groups {#nested-resources-scope-groups}
499
+
500
+ `scope:` on `positioned_on` is the **model author's** decision, and Plutonium never second-guesses it from the definition. The two combinations behave differently, and it is worth knowing which you have.
501
+
502
+ **Scoped to the parent** (the usual case for a nested table):
503
+
504
+ ```ruby
505
+ class Catalog::Variant < ApplicationRecord
506
+ include Plutonium::Positioning::Model
507
+ positioned_on :position, scope: :product_id
508
+ end
509
+ ```
510
+
511
+ Each product's variants are numbered independently. A rebalance touches one product's rows. This is what you want.
512
+
513
+ **Positioned globally, rendered nested:**
514
+
515
+ ```ruby
516
+ positioned_on :position # scope: nil — one ordering across the whole table
517
+ ```
518
+
519
+ Reordering within one parent still works correctly. Positions interleave across parents (product A holds `1.0, 3.0, 5.0` while product B holds `2.0, 4.0`), but because the list is ordered *by position*, the relative order inside A is exactly what the user sees and drags. And when a drop lands at the top or bottom of the visible page, the server looks up the real boundary neighbour in the model's group rather than taking `nil` at face value — which is what stops a bottom-of-page drop writing a position that duplicates a row the client could not see.
520
+
521
+ The one thing that leaks is a **rebalance**: gap exhaustion renumbers the whole *model-level* group, which with `scope: nil` is every row in the table, including other parents'. Positions stay in the same relative order, so nothing visibly moves — but far more rows are written than the user's gesture suggests. If a resource is normally viewed per-parent, scope it per-parent.
522
+
523
+ ::: tip A neighbour from another group is drift, not an anchor
524
+ When the model *is* scoped, the endpoint rejects a neighbour id that resolves to a row in a **different** scope group and reconciles instead. This is not exotic: a `scope: :status` resource lists several groups in one table, with independent and freely interleaved numberings, so anchoring off a neighbour from another group would fling the record to an arbitrary point in its own.
525
+ :::
526
+
527
+ ---
528
+
529
+ ## Hidden actions
530
+
531
+ `position_on` registers `action :reposition, hidden: true`. The flag is general — see [Actions › Hidden actions](/reference/resource/actions#hidden-actions).
532
+
533
+ A hidden action has a live route, a policy predicate, and (if interactive) the full form/params machinery. It simply renders in **no** toolbar, row dropdown, card, or bulk bar. It is how the framework exposes an endpoint reachable by a gesture rather than a button.
534
+
535
+ ::: danger `hidden: true` is a display gate, NOT an authorization boundary
536
+ The route is live. Anyone who can construct the URL can `POST` to it. Authorization lives in the policy — `def reposition?` — and runs whether or not anything rendered.
537
+ :::
538
+
539
+ ---
540
+
541
+ ## Accessibility & known limitations
542
+
543
+ **Keyboard reorder works.** The grip is a real `<button>`, so it is tabbable and carries a screen-reader label. With it focused:
544
+
545
+ | Key | Effect |
546
+ |---|---|
547
+ | <kbd>↑</kbd> | move the row/card one slot earlier |
548
+ | <kbd>↓</kbd> | move it one slot later |
549
+
550
+ Focus travels with the row — including across a rebalance, where the whole collection is replaced and focus is restored onto the same record's new grip.
551
+
552
+ Arrow navigation is deliberately **linear on a grid too**: <kbd>↑</kbd> means the previous *card* in reading order, not the card one line above. A single position attribute stores a one-dimensional order, and two-dimensional navigation could not express the in-between slots at all.
553
+
554
+ ::: warning Drag does not work on touch devices
555
+ The drag gesture uses **native HTML5 drag-and-drop**, which browsers do not fire from touch input. This limitation is inherited from the kanban board and applies identically here. Touch users cannot drag to reorder.
556
+
557
+ There is no automatic fallback. If touch reordering matters for your resource, expose an explicit ordering path — a "move up"/"move down" pair of [record actions](/reference/resource/actions), or a numeric position field on the edit form.
558
+ :::
559
+
560
+ ---
561
+
562
+ ## Related
563
+
564
+ - [Kanban › Positioning](/reference/kanban/positioning) — the shared model API in full: arithmetic, `EPSILON`, rebalancing, `backfill_positions!`, the pure helpers
565
+ - [Kanban › DSL](/reference/kanban/dsl) — `position_on` inside `kanban do…end`
566
+ - [Actions](/reference/resource/actions) — `hidden:`, `condition:`, and the policy rule
567
+ - [Query](/reference/resource/query) — `sort`, `default_sort`
568
+ - [Nested resources](/reference/tenancy/nested-resources) — nested association tables