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
@@ -12,7 +12,7 @@ For field-level rendering on cards (card_fields slots), see [[plutonium-resource
12
12
  ## 🚨 Critical (read first)
13
13
 
14
14
  - **`kanban do…end` in the Definition auto-enables `:kanban`** in `defined_index_views` — exactly like `grid_fields` enables `:grid`. You do not need to call `index_views :kanban` separately unless you want to remove the table view.
15
- - **The model needs `include Plutonium::Positioning`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
15
+ - **The model needs `include Plutonium::Positioning::Model`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
16
16
  - **Static column actions are auto-registered** as interactive resource actions at class-load time. Dynamic boards (`columns do…end`) cannot introspect their columns at load time — declare any column-action interactions separately with top-level `action` calls.
17
17
  - **Moves bypass `permitted_attributes_for_update`** — the `on_enter` callback runs with full model access. Gate the move itself with `kanban_move?` in the policy.
18
18
  - **Quick-add (`add: true`) only appears when `create?` is true** in the policy.
@@ -26,7 +26,7 @@ For field-level rendering on cards (card_fields slots), see [[plutonium-resource
26
26
 
27
27
  ```ruby
28
28
  class Task < ApplicationRecord
29
- include Plutonium::Positioning
29
+ include Plutonium::Positioning::Model
30
30
 
31
31
  # position_on :position (default attr) scoped to the grouping column
32
32
  positioned_on :position, scope: :status
@@ -103,9 +103,23 @@ Overrides the grid card layout for kanban cards. Uses the same slot keys as `gri
103
103
  card_fields header: :title, meta: [:status, :priority], footer: :due_at
104
104
  ```
105
105
 
106
+ Every slot is optional and omitting it drops that line — **except `footer`, which
107
+ falls back to `:created_at`**. To render no footer at all, opt out explicitly:
108
+
109
+ ```ruby
110
+ card_fields header: :title, meta: [:status], footer: false
111
+ ```
112
+
113
+ Omitting `footer:` is the common cause of a card ending in a stray `—`: the
114
+ fallback lands on `:created_at`, and if that isn't in the policy's
115
+ `permitted_attributes_for_index` the value resolves to nil and renders as the
116
+ blank placeholder. Either permit `created_at`, point `footer:` at a permitted
117
+ field, or pass `footer: false`. (A *declared* slot that's merely blank still
118
+ shows `—` by design, so cards keep an even height.)
119
+
106
120
  ### `position_on` modes
107
121
 
108
- - **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning`. Requires the model concern and a decimal column.
122
+ - **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning::Model`. Requires the model concern and a decimal column.
109
123
  - **Mode B (block)** — you write the persistence. Plutonium still orders by the attribute; the block only persists the new value. Block receives a `Plutonium::Kanban::Positioning::Move` (fields: `record`, `column`, `prev`, `next`, `index`).
110
124
  - **Mode C (`false`)** — no ordering, no repositioning. `on_enter` still fires.
111
125
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plutonium-resource
3
- description: Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one".
3
+ description: Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".
4
4
  ---
5
5
 
6
6
  # Plutonium Resources
@@ -484,8 +484,8 @@ end
484
484
 
485
485
  # Portal override (per-portal customization)
486
486
  class AdminPortal::PostDefinition < ::PostDefinition
487
- input :internal_notes, as: :text
488
487
  scope :pending_review
488
+ input :internal_notes, hint: "Not shown to the author"
489
489
  end
490
490
  ```
491
491
 
@@ -602,6 +602,30 @@ field :debug_info, condition: -> { Rails.env.development? }
602
602
 
603
603
  Use `condition` for UI state; use the policy for authorization.
604
604
 
605
+ ## Options That Vary Per Render
606
+
607
+ Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
608
+
609
+ ```ruby
610
+ input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
611
+ input :notes, placeholder: -> { "Updated #{Time.current.year}" }
612
+ ```
613
+
614
+ - `-> { … }` is called as-is, keeping its own binding — it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
615
+ - `->(form) { … }` gets the form — `object` (the record), `params`, view helpers.
616
+
617
+ Same rule on wizard steps — but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].
618
+
619
+ **`condition:` is not an option — it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?" — a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:
620
+
621
+ ```ruby
622
+ input :notes, condition: -> { object.published? } # the form
623
+ display :audit_log, condition: -> { current_user.admin? } # the display component
624
+ step :billing, condition: -> { data.plan.tier == "pro" } # the wizard — no form exists yet
625
+ ```
626
+
627
+ It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
628
+
605
629
  ## Dynamic Forms (`pre_submit`)
606
630
 
607
631
  A `pre_submit: true` field triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
@@ -660,14 +684,14 @@ input :birth_date do |f|
660
684
  end
661
685
  ```
662
686
 
663
- **`phlexi_tag` for declarative custom display.** The `with:` option takes either a Phlex component class, or a proc whose body is **rendered inside a Phlex context** — so HTML tags (`span`, `div`, `a`, …) and Tailwind classes are first-class. The proc receives `(value, attrs)` where `value` is the field value and `attrs` are wrapper attributes.
687
+ **`phlexi_render` for declarative custom display.** The `with:` option takes either a Phlex component class, or a proc whose body is **rendered inside a Phlex context** — so HTML tags (`span`, `div`, `a`, …) and Tailwind classes are first-class. The proc receives `(value, attrs)` where `value` is the field value and `attrs` are wrapper attributes.
664
688
 
665
689
  ```ruby
666
690
  # Component class — preferred for anything reusable
667
- display :status, as: :phlexi_tag, with: StatusBadgeComponent
691
+ display :status, as: :phlexi_render, with: StatusBadgeComponent
668
692
 
669
693
  # Inline Phlex proc — `span` here is a Phlex tag method, not Ruby/Rails
670
- display :priority, as: :phlexi_tag, with: ->(value, attrs) {
694
+ display :priority, as: :phlexi_render, with: ->(value, attrs) {
671
695
  case value
672
696
  when 'high' then span(class: "badge badge-danger") { "High" }
673
697
  when 'medium' then span(class: "badge badge-warning") { "Medium" }
@@ -678,13 +702,21 @@ display :priority, as: :phlexi_tag, with: ->(value, attrs) {
678
702
 
679
703
  See [[plutonium-ui]] for writing custom Phlex components.
680
704
 
681
- **Custom component classes** (Phlex components — see [[plutonium-ui]]):
705
+ **Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
682
706
 
683
707
  ```ruby
684
708
  input :color_picker, as: ColorPickerComponent
685
709
  display :chart, as: ChartComponent
686
710
  ```
687
711
 
712
+ 🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate — it raises `ArgumentError`. Build it in a block instead:
713
+
714
+ ```ruby
715
+ display :card do |field|
716
+ PostCardComponent.new(post: field.object)
717
+ end
718
+ ```
719
+
688
720
  ## Column Options
689
721
 
690
722
  ```ruby
@@ -883,7 +915,7 @@ class PostDefinition < ResourceDefinition
883
915
  form_layout do
884
916
  section :identity, :name, :email, label: "Identity", description: "Who this is"
885
917
  section :address, :street, :city,
886
- collapsible: true, collapsed: -> { object.persisted? }, columns: 2,
918
+ collapsible: true, collapsed: ->(form) { form.object.persisted? }, columns: 2,
887
919
  condition: -> { object.requires_address? } # hide the whole section as a unit
888
920
  ungrouped label: "Other" # bucket for unlisted fields; position = where it renders
889
921
  end
@@ -891,13 +923,55 @@ end
891
923
  ```
892
924
 
893
925
  - **Layout references field KEYS only** — all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
894
- - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc** resolved at render in the form context (`object`, `current_user`, `params`, helpers).
926
+ - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option — take a `form` argument to read the render context.
927
+ - ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
895
928
  - **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
896
929
  - **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. This checks *field presence only*; per-field `condition:` runs later, so to hide a whole section by state, gate it with the **section's own `condition:`**, not by hiding every field inside it.
897
930
  - **Works on interactions too** (`Plutonium::Interaction::Base`) — groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
898
931
 
899
932
  Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
900
933
 
934
+ ## Display Layout (`display_layout`)
935
+
936
+ The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field sections dropped) — applied to the show page's fields instead of the form's.
937
+
938
+ ```ruby
939
+ class PostDefinition < ResourceDefinition
940
+ display_layout do
941
+ section :profile, :name, :author, label: "Profile", description: "Identity and owner"
942
+ section :presentation, :cover, :body, collapsible: true
943
+ ungrouped label: "Other details"
944
+ end
945
+ end
946
+ ```
947
+
948
+ - **Declare both independently.** `form_layout` and `display_layout` are separate registries — a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
949
+ - **🚨 No `columns:`** — unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
950
+ - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
951
+ - **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel) — see below.
952
+
953
+ ## Page Width (`page_width`)
954
+
955
+ Detail-style pages — the show page and resource forms — are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.
956
+
957
+ ```ruby
958
+ Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
959
+
960
+ class PostDefinition < ResourceDefinition
961
+ page_width :lg # form AND show page
962
+ display_width :full # ...but the show page opts out
963
+ form_width :sm # ...and the form goes narrow
964
+ end
965
+ ```
966
+
967
+ - **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
968
+ - **🚨 Tokens are relative to their surface** — the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
969
+ - **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
970
+ - **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
971
+ - **Modals are unaffected** — the dialog sets its own width (`modal_size`).
972
+ - **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
973
+ - **Wizards are on their own axis** — `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.
974
+
901
975
  ## Metadata Panel (show page)
902
976
 
903
977
  Declares fields rendered in the show page's right-side aside as label/value rows.
@@ -908,8 +982,10 @@ metadata :author, :state, :created_at, :updated_at
908
982
 
909
983
  - **Opt-in** — no call → show page is full-width with no aside.
910
984
  - **Policy-aware** — fields the user can't see disappear; panel auto-hides if nothing's permitted.
911
- - **Deduplicated** — listed fields are removed from the main details card.
985
+ - **Deduplicated** — listed fields are removed from the main details card (and from any `display_layout` section).
912
986
  - **Responsive** — side-by-side at `lg+`, stacked below.
987
+ - **In a modal it stacks below the details**, not beside them: the rail is a fixed-width column on a *viewport* breakpoint, so in a dialog it would split regardless of how narrow the dialog is and crush the main column.
988
+ - **A kanban card's modal drops metadata entirely** — the fields are hidden, not folded into the main card.
913
989
 
914
990
  Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
915
991
 
@@ -926,7 +1002,7 @@ class UserDefinition < ResourceDefinition
926
1002
  subheader: :email,
927
1003
  body: :bio,
928
1004
  meta: [:role, :status], # rendered as small pills
929
- footer: :last_seen_at # falls back to :created_at
1005
+ footer: :last_seen_at # falls back to :created_at; `false` to omit
930
1006
  )
931
1007
 
932
1008
  default_index_view :grid # optional — initial view when no cookie
@@ -949,6 +1025,123 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
949
1025
 
950
1026
  ---
951
1027
 
1028
+ ## Drag-to-Reorder (`positioned_on` + `position_on`)
1029
+
1030
+ Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three** — the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:
1031
+
1032
+ ```ruby
1033
+ # Migration — t.position emits decimal(16,8), tuned for fractional ordering.
1034
+ create_table :tasks do |t|
1035
+ t.string :status, null: false, default: "todo"
1036
+ t.position
1037
+ t.index [:status, :position] # match the scope attribute
1038
+ end
1039
+
1040
+ # Model — storage
1041
+ class Task < ApplicationRecord
1042
+ include Plutonium::Positioning::Model # NOT Plutonium::Positioning
1043
+ positioned_on :position, scope: :status # scope: nil = one global ordering
1044
+ end
1045
+
1046
+ # Definition — "this UI can be reordered". Never restates the column or the scope.
1047
+ class TaskDefinition < ResourceDefinition
1048
+ position_on
1049
+ end
1050
+
1051
+ # Existing rows need positions:
1052
+ Task.backfill_positions!(order: :created_at)
1053
+ ```
1054
+
1055
+ - **`include Plutonium::Positioning::Model`** — the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
1056
+ - **`positioned_on` is required**, not just the include — without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.
1057
+
1058
+ ### `position_on` forms and modes
1059
+
1060
+ | Form | Mode | Notes |
1061
+ |---|---|---|
1062
+ | `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this** — it cannot disagree with the model. |
1063
+ | `position_on :sort_order` | A | Must **match** the model's column, else `ArgumentError` at class-load |
1064
+ | `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch — another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
1065
+ | `position_on false` | C (disabled) | No ordering, no route — the endpoint 404s |
1066
+
1067
+ **Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested — it just costs you semantics Mode A handles.
1068
+
1069
+ ### Mode B — what the framework stops doing
1070
+
1071
+ Mode B block receives a `Plutonium::Positioning::Move`: `record`, `prev`, `next`, `index` (0-based, **relative to the visible page**), `column` (kanban only, `nil` on tables/grids). Called with `call`, not `instance_exec`.
1072
+
1073
+ Because the write is opaque, three Mode A behaviours are **not** provided:
1074
+
1075
+ - **No hidden boundary resolution** — `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
1076
+ - **No server-side foreign-sort rejection** — Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
1077
+ - **Always a full repaint** — never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.
1078
+
1079
+ ### Migrating off `acts_as_list` to Mode A
1080
+
1081
+ 1. **Change the column** — `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
1082
+ 2. **Swap the macro** — drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
1083
+ 3. **Backfill** — `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at` — run it once from a migration or `rails runner`.
1084
+ 4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
1085
+
1086
+ ### Staying on `acts_as_list` (the harder road)
1087
+
1088
+ For when the gem is not yours to remove. This recipe is correct and tested against the real gem (`test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb`).
1089
+
1090
+ 🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size) — measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.
1091
+
1092
+ ```ruby
1093
+ # Keeping acts_as_list. NOTE scope: [:status] — a bare Symbol scope is run
1094
+ # through acts_as_list's `idify`, which turns :status into :status_id and makes
1095
+ # every create raise NoMethodError.
1096
+ class Task < ApplicationRecord
1097
+ acts_as_list scope: [:status]
1098
+ end
1099
+
1100
+ class TaskDefinition < ResourceDefinition
1101
+ position_on :position do |move|
1102
+ record = move.record
1103
+
1104
+ target =
1105
+ if move.prev
1106
+ # Removing the record shifts prev up one when the record was above it.
1107
+ (record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
1108
+ elsif move.next
1109
+ # Blank prev means "nothing above me ON MY SCREEN" — rows may still sit
1110
+ # above off-page or behind a filter, so anchor off next rather than 1.
1111
+ (record.position < move.next.position) ? move.next.position - 1 : move.next.position
1112
+ else
1113
+ 1 # the only row in the list
1114
+ end
1115
+
1116
+ record.insert_at(target)
1117
+ end
1118
+ end
1119
+ ```
1120
+
1121
+ `insert_at` calls `save`, not `save!` — a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
1122
+
1123
+ ### 🚨 `position_on` expands to three things
1124
+
1125
+ ```ruby
1126
+ sort <attr> # load-bearing: the only way back to position order
1127
+ default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the framework default
1128
+ action :reposition, hidden: true # route + policy predicate, no button
1129
+ ```
1130
+
1131
+ **The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on` — resolution is order-independent) to keep it; note the list then opens **not** draggable.
1132
+
1133
+ ### Behavior notes
1134
+
1135
+ - **Dragging is offered only while the collection is sorted ascending by the position attribute** (and nothing else). Under any other sort the grip renders as a **link that applies that sort**, and the server rejects the drop with 422 before writing.
1136
+ - **`reposition?` policy predicate**, defaulting to `update?`. Gates both the drop and whether the grip renders per row. `index?` is also required (you must be able to see a list to reorder it).
1137
+ - **A kanban board inherits the definition's `position_on`** (lazily, so order in the class body doesn't matter); a `position_on` inside `kanban do…end` overrides it.
1138
+ - **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
1139
+ - Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
1140
+
1141
+ Full reference: `docs/reference/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
1142
+
1143
+ ---
1144
+
952
1145
  # Part 4 — Query: Search, Filters, Scopes, Sorting
953
1146
 
954
1147
  ```ruby
@@ -1090,6 +1283,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
1090
1283
  | `record_action: true` | Show page | Edit, Delete, Archive |
1091
1284
  | `collection_record_action: true` | Table rows | Quick per-row actions |
1092
1285
  | `bulk_action: true` | Selected records | Bulk operations |
1286
+ | `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
1093
1287
 
1094
1288
  🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes — don't declare them manually:**
1095
1289
 
@@ -1132,6 +1326,10 @@ action :name,
1132
1326
  # `-> { false }` keeps the route live but hides the button (e.g. API-only).
1133
1327
  condition: -> { params[:beta] == "1" },
1134
1328
 
1329
+ # Never renders anywhere — route + policy stay live. For endpoints reached by
1330
+ # a gesture rather than a button (see Hidden Actions below). NOT authorization.
1331
+ hidden: true,
1332
+
1135
1333
  # Grouping
1136
1334
  category: :primary, # :primary, :secondary, :danger
1137
1335
  position: 50,
@@ -1141,9 +1339,26 @@ action :name,
1141
1339
  turbo_frame: "_top",
1142
1340
  route_options: {action: :foo},
1143
1341
  modal: :slideover, # :slideover / :centered — overrides definition's modal mode
1144
- size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
1342
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
1343
+
1344
+ # HTML attributes — deep-merged over the framework's, author wins on every key
1345
+ link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
1346
+ button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
1145
1347
  ```
1146
1348
 
1349
+ ### HTML Attributes (`link:` / `button:`)
1350
+
1351
+ Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions — including `class:` (replaces, no token append) and `turbo_frame`. Pass `data:` as a hash: a scalar `data:` replaces the framework's data wholesale, dropping `turbo_confirm`/`turbo_frame`.
1352
+
1353
+ ```ruby
1354
+ action :documentation,
1355
+ route_options: {url: "https://docs.example.com"},
1356
+ resource_action: true,
1357
+ link: {target: "_blank", rel: "noopener noreferrer"} # open in a new tab
1358
+ ```
1359
+
1360
+ Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {target: "_blank"})`.
1361
+
1147
1362
  ### Conditional Actions (`condition:`)
1148
1363
 
1149
1364
  Like `condition:` on inputs/displays/columns — define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
@@ -1174,6 +1389,23 @@ def wipe? = current_user.admin?
1174
1389
 
1175
1390
  The two compose: an action's button shows only when the policy permits **and** the condition is truthy; execution is gated by the policy alone. Use `object` in `condition:` for per-record *display*; use the policy for per-record *authorization*.
1176
1391
 
1392
+ ### Hidden Actions (`hidden: true`)
1393
+
1394
+ ```ruby
1395
+ action :reposition, hidden: true
1396
+ ```
1397
+
1398
+ Renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.
1399
+
1400
+ Use it for an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.
1401
+
1402
+ | | `hidden: true` | `condition: -> { false }` |
1403
+ |---|---|---|
1404
+ | Decided | class-load, once | render time, per row/request |
1405
+ | Says | "never a button" | "a button, just not right now" |
1406
+
1407
+ 🚨 **`hidden:` is a display gate, NOT an authorization boundary** — same trap as `condition:`. The route is live; authorization belongs in the policy.
1408
+
1177
1409
  `Action#with(...)` — actions are frozen value objects; clone with overrides:
1178
1410
 
1179
1411
  ```ruby
@@ -1222,6 +1454,8 @@ class PostDefinition < ResourceDefinition
1222
1454
  end
1223
1455
  ```
1224
1456
 
1457
+ ⚠️ **An interaction is the button, not the operation.** It's a presentation object — it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.
1458
+
1225
1459
  ### Single-record interaction
1226
1460
 
1227
1461
  ```ruby
@@ -1260,7 +1494,9 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
1260
1494
  validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
1261
1495
 
1262
1496
  def execute
1263
- UserInvite.create!(company: resource, email: email, role: role, invited_by: current_user)
1497
+ # Company#invite! creates the row AND sends the mail — a seat-provisioning
1498
+ # job needs both, and has no view_context to build an interaction with.
1499
+ resource.invite!(email: email, role: role, by: current_user)
1264
1500
  succeed(resource).with_message("Invitation sent to #{email}.")
1265
1501
  rescue ActiveRecord::RecordInvalid => e
1266
1502
  failed(e.record.errors)
@@ -1296,6 +1532,17 @@ end
1296
1532
 
1297
1533
  The UI only shows bulk action buttons that ALL selected records support. Records are fetched via `current_authorized_scope`.
1298
1534
 
1535
+ **Bulk over more than a screenful runs in the request and will time out.** Swap `execute` for `async` and the same interaction dispatches a background run instead, with its declaration and policy unchanged:
1536
+
1537
+ ```ruby
1538
+ async do
1539
+ on_failure :continue # :halt (default) | :continue | :transactional
1540
+ def perform_on(record) = record.archived!
1541
+ end
1542
+ ```
1543
+
1544
+ The block is the run's class body, not `execute`: it runs later in a job with no controller, so its inputs arrive through `options`. Load [[plutonium-async-interactions]] before building one.
1545
+
1299
1546
  ### Resource action (no record)
1300
1547
 
1301
1548
  ```ruby
@@ -323,26 +323,55 @@ Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-
323
323
 
324
324
  For `has_one`: index redirects to show (or new if no record exists); only one record per parent.
325
325
 
326
+ ### Choosing which associations get routes
327
+
328
+ Every routable association gets a nested route unless the registration narrows it:
329
+
330
+ ```ruby
331
+ register_resource ::Company, associations: %i[properties company_profile]
332
+ ```
333
+
334
+ `associations: []` draws none. A name that is not a `has_many`/`has_one`, or whose
335
+ child is not registered in that portal, raises at boot.
336
+
337
+ `config.nested_association_routes = :declared` (default `:detected`) makes a resource
338
+ that names none get none. The mode only changes what silence means; `associations:`
339
+ behaves the same either way, and top-level routes are unaffected.
340
+
341
+ **Before turning `:declared` on:** it is global, so every resource naming nothing
342
+ loses its nested routes, and a policy's `permitted_associations` panel links to the
343
+ nested route — permit an association there without declaring it here and the panel
344
+ points nowhere.
345
+
326
346
  ## Automatic behavior in nested routes
327
347
 
328
348
  When the controller is hit through a nested route:
329
349
 
330
350
  1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
331
351
  2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
332
- 3. **Assigns parent** on create (injected into `resource_params`).
352
+ 3. **Assigns parent** on create (injected into `resource_params`). The record is built
353
+ on the parent's association (`parent.properties.new`), so a scoped association
354
+ contributes its equality conditions as defaults.
333
355
  4. **Hides parent field** in forms (already determined by URL).
334
356
 
335
357
  You don't need to add hidden parent fields in forms or filter queries manually.
336
358
 
359
+ Expose a scoped association as a nested route only when its scope is equality-based
360
+ (`-> { where(published: true) }`). Rails derives create attributes from equality
361
+ conditions alone, so `-> { where("expires_at > ?", Time.current) }` creates records
362
+ that its own index, which honours the scope, will not list.
363
+
337
364
  ## Controller methods
338
365
 
339
366
  ```ruby
340
367
  current_parent # Parent record
368
+ current_parent_class # Company
341
369
  current_nested_association # :properties
342
- parent_route_param # :company_id
343
370
  parent_input_param # :company
344
371
  ```
345
372
 
373
+ The parent class and association are read from the **route** (each nested route carries its registration key), not inferred from the URL — which is why a `singular: true` parent works as a parent despite contributing no id segment. There is no `parent_route_param`.
374
+
346
375
  ## Parent vs entity scoping
347
376
 
348
377
  When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping isn't needed.
@@ -613,7 +642,7 @@ configure do
613
642
  login_redirect "/welcome"
614
643
 
615
644
  after_login do
616
- session[:after_welcome_redirect] = session.delete(:login_redirect)
645
+ session[:after_welcome_redirect] = session.delete(login_redirect_session_key)
617
646
  end
618
647
  end
619
648
  ```