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
@@ -11,7 +11,8 @@ Custom buttons that go beyond standard CRUD — publish, archive, import, send i
11
11
  - **For interactive actions, visibility is inferred from the interaction's attributes.** Don't declare `record_action: true` / `bulk_action: true` etc. by hand unless you're opting OUT.
12
12
  - **Bulk action authorization is per-record.** If any selected record fails the policy check, the entire request is rejected.
13
13
  - **Always pass `as:`** on custom routes — without it, `resource_url_for` can't generate URLs (critical for nested resources).
14
- - **Prefer interactive actions over hand-written controller routes.** Anything with business logic belongs in an interaction.
14
+ - **Prefer interactive actions over hand-written controller routes.** Anything a user triggers from a page belongs behind an interaction.
15
+ - **An interaction is the button, not the operation.** Logic may start in `execute`; once a job or an API also needs it, it moves to the model — see [Behavior › Interactions](/reference/behavior/interactions#what-an-interaction-is-for).
15
16
 
16
17
  ## Action visibility flags
17
18
 
@@ -21,6 +22,7 @@ Custom buttons that go beyond standard CRUD — publish, archive, import, send i
21
22
  | `record_action: true` | Show page — for actions on a single record (Edit, Archive, Delete) |
22
23
  | `collection_record_action: true` | Per-row in the index table — for quick actions (Edit, Show) |
23
24
  | `bulk_action: true` | Bulk-actions toolbar (shown when records are selected) |
25
+ | `hidden: true` | **Nowhere.** Suppresses all four surfaces at once, while keeping the route and policy live — see [Hidden actions](#hidden-actions) |
24
26
 
25
27
  ### Inferred visibility (interactive actions)
26
28
 
@@ -64,6 +66,9 @@ action :name,
64
66
  # Conditional visibility — display-only proc, NOT authorization (see below)
65
67
  condition: -> { params[:beta] == "1" },
66
68
 
69
+ # Never render, anywhere — route + policy stay live (see below)
70
+ hidden: true,
71
+
67
72
  # Grouping
68
73
  category: :primary, # :primary, :secondary, :danger
69
74
  position: 50, # display order (lower = first)
@@ -74,9 +79,34 @@ action :name,
74
79
  return_to: "/custom/path",
75
80
  route_options: {action: :foo},
76
81
  modal: :slideover, # :slideover / :centered — overrides the definition's modal mode
77
- size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides the definition's modal size
82
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides the definition's modal size
83
+
84
+ # HTML attributes (see below)
85
+ link: {target: "_blank", rel: "noopener"}, # merged onto the action's <a> renderings
86
+ button: {data: {analytics: "archive"}} # merged onto the button_to <form> (non-GET)
78
87
  ```
79
88
 
89
+ ### HTML attributes — `link:` / `button:`
90
+
91
+ Two per-element attribute bags, deep-merged over the framework's own attributes at render time — **the author wins on every key**, recursively through nested `data`:
92
+
93
+ - **`link:`** applies to every `<a>` rendered for the action: the toolbar link (GET), dropdown items (**any** HTTP method — dropdown items are always anchors, submitting via `data-turbo-method`), bulk-action links, kanban column action links, and the grid/kanban card's hidden show link (for `:show`).
94
+ - **`button:`** applies to the `button_to` **`<form>`** element of the non-GET toolbar rendering (the form wrapper, not the inner `<button>`).
95
+
96
+ ```ruby
97
+ action :documentation,
98
+ route_options: {url: "https://docs.example.com"},
99
+ resource_action: true,
100
+ link: {target: "_blank", rel: "noopener noreferrer", data: {analytics: "docs"}}
101
+ ```
102
+
103
+ Because the author wins, you can override anything — `turbo_frame`, `class`, `data-*` — at your own risk. Two things to know:
104
+
105
+ - `class:` **replaces** the framework's classes (no token append) — a bare `link: {class: "mt-2"}` removes the button styling entirely.
106
+ - Pass `data:` as a **hash**. The merge only recurses when both sides are hashes, so a scalar `data:` replaces the framework's data wholesale (dropping `turbo_confirm`/`turbo_frame`).
107
+
108
+ Both bags round-trip through [`with(...)`](#deriving-variants-action-with), so `defined_actions[:edit].with(link: {target: "_blank"})` works in `customize_actions`.
109
+
80
110
  ### Deriving variants — `Action#with(...)`
81
111
 
82
112
  Action records are frozen value objects. Inside `customize_actions`, derive a copy with overrides:
@@ -87,7 +117,7 @@ def customize_actions
87
117
  end
88
118
  ```
89
119
 
90
- ## Conditional visibility — `condition:`
120
+ ## Conditional visibility — `condition:` {#conditional-visibility}
91
121
 
92
122
  Like the `condition:` proc on [inputs/displays/columns](/reference/resource/definition), an action can be **defined but only rendered when a runtime proc is truthy**. It's purely a toggle on whether the **button is shown** — the action (and its route) stays fully live either way.
93
123
 
@@ -139,6 +169,48 @@ end
139
169
  `condition: -> { object.draft? }` is fine for **showing/hiding** a per-record button. But if the rule is about **who may run it** ("only while draft *and* nobody else has it locked"), put it in the policy — `def publish? = record.draft?` is also evaluated per record (per row), and unlike `condition:` it actually gates execution.
140
170
  :::
141
171
 
172
+ ## Hidden actions — `hidden: true` {#hidden-actions}
173
+
174
+ An action declared `hidden: true` renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of its visibility flags, the policy, or `condition:`. Everything else about it stays live:
175
+
176
+ - the **route** is mounted;
177
+ - the **policy predicate** (`def name?`) is defined and enforced;
178
+ - for `interaction:`-based actions, the **form and permitted-params machinery** work exactly as they do for a visible action.
179
+
180
+ ```ruby
181
+ action :reposition, hidden: true
182
+ ```
183
+
184
+ The use case is an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller, a client-side widget you wrote yourself. The framework uses it for exactly that: [`position_on`](/reference/positioning) expands to `action :reposition, hidden: true`, and the kanban board's drop endpoint is declared the same way.
185
+
186
+ ### `hidden:` vs `condition: -> { false }`
187
+
188
+ Both suppress the button, so pick by intent:
189
+
190
+ | | `hidden: true` | `condition: -> { false }` |
191
+ |---|---|---|
192
+ | Decided | at **class-load**, once | at **render time**, per row/request |
193
+ | Costs | nothing — the surfaces filter it out before any policy or condition runs | a proc evaluation per rendering |
194
+ | Says | "this is never a button" | "this is a button, just not right now" |
195
+
196
+ Use `hidden:` for an action that is *structurally* not a button. Use `condition:` when visibility genuinely depends on the record, the user, or the request.
197
+
198
+ ::: danger `hidden:` is a display gate, NOT an authorization boundary
199
+ This is the same trap as [`condition:`](#conditional-visibility), and it bears repeating because "hidden" reads more absolute than it is. A hidden action has a **live route**: anyone who can construct the URL can call it.
200
+
201
+ ```ruby
202
+ # 🚫 WRONG — hiding the button does not stop the request.
203
+ action :purge_all, interaction: PurgeInteraction, hidden: true
204
+
205
+ # ✅ RIGHT — authorization belongs in the policy.
206
+ class WidgetPolicy < ResourcePolicy
207
+ def purge_all? = current_user.admin?
208
+ end
209
+ ```
210
+
211
+ **Rule of thumb, unchanged:** "who may run this" → **policy**. "does a button belong here" → `hidden:` / `condition:`.
212
+ :::
213
+
142
214
  ## Simple actions (navigation)
143
215
 
144
216
  Link to an existing route. The target route MUST exist.
@@ -176,6 +248,8 @@ For anything with business logic, use an **interactive action** instead.
176
248
 
177
249
  Run an [Interaction](/reference/behavior/interactions) — automatically renders a form if the interaction declares attributes beyond `:resource`/`:resources`, otherwise executes immediately with a confirmation.
178
250
 
251
+ The interactions below call named model methods (`archive!`, `invite!`) rather than doing the work inline. That's not mandatory for a one-off — the trigger to extract is the second caller, since an interaction can only be built with a `view_context`. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
252
+
179
253
  ```ruby
180
254
  class PostDefinition < Plutonium::Resource::Definition
181
255
  action :publish, interaction: PublishInteraction
@@ -233,7 +307,7 @@ class InviteUserInteraction < Plutonium::Resource::Interaction
233
307
  validates :role, presence: true
234
308
 
235
309
  def execute
236
- UserInvite.create!(company: resource, email: email, role: role)
310
+ resource.invite!(email: email, role: role, by: current_user)
237
311
  succeed(resource).with_message("Invitation sent to #{email}.")
238
312
  rescue ActiveRecord::RecordInvalid => e
239
313
  failed(e.record.errors)
@@ -299,6 +373,25 @@ end
299
373
  action :import, interaction: ImportInteraction
300
374
  ```
301
375
 
376
+ ## Running the work in the background
377
+
378
+ An interactive action executes inside the request. When that is too slow — a bulk action over thousands of records, or a single call to something slow — replace `execute` with `async` and the interaction dispatches a persisted, resumable run instead:
379
+
380
+ ```ruby
381
+ class BulkArchiveInteraction < ResourceInteraction
382
+ attribute :resources
383
+
384
+ async do
385
+ on_failure :continue
386
+ def perform_on(record) = record.archive!
387
+ end
388
+ end
389
+ ```
390
+
391
+ Nothing else about the action changes: same `action` declaration, same policy method, same form. The user is returned where they were, and that index shows a banner linking to the run's progress page.
392
+
393
+ See [Async Interactions](/reference/behavior/async-interactions) for failure policies, file attributes, and what happens when a run crashes mid-batch.
394
+
302
395
  ## Immediate vs form
303
396
 
304
397
  | Interaction shape | Behavior |
@@ -42,8 +42,8 @@ end
42
42
 
43
43
  # packages/admin_portal/app/definitions/admin_portal/post_definition.rb (per-portal)
44
44
  class AdminPortal::PostDefinition < ::PostDefinition
45
- input :internal_notes, as: :text # admins see this; customers don't
46
45
  scope :pending_review
46
+ input :internal_notes, hint: "Not shown to the author"
47
47
  end
48
48
  ```
49
49
 
@@ -200,6 +200,39 @@ field :debug_info, condition: -> { Rails.env.development? }
200
200
  `condition:` is for UI logic ("show this when published"). For "who can see this", use the policy's `permitted_attributes_for_*` — see [Behavior › Policy](/reference/behavior/policies).
201
201
  :::
202
202
 
203
+ ## Options that vary per render
204
+
205
+ Any option may be a **proc**, resolved on every render rather than frozen when the class loads. This holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input` and nested inputs. Arity says **whether you want the form**:
206
+
207
+ ```ruby
208
+ input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
209
+ input :notes, placeholder: -> { "Updated #{Time.current.year}" }
210
+ ```
211
+
212
+ - **`-> { … }`** is called as-is, keeping whatever it closed over — it means what it reads like where you wrote it. Nothing rebinds `self`. That is what lets an option declared inside an interaction's `customize_inputs` reach the interaction, private helpers included: `choices: -> { reviewer_choices }`.
213
+ - **`->(form) { … }`** is handed the form, so `object` (the record being edited), `params` and view helpers are reachable. Use it whenever the value depends on what is being rendered.
214
+
215
+ The rule holds on wizard steps too — but there a zero-argument proc closes over an internal field recorder, so options must take the form and read the run off it: `->(form) { form.wizard.anchor.tiers }`. See [Wizard DSL › Runtime input options](/reference/wizard/dsl#runtime-input-options).
216
+
217
+ ### `condition:` is not an option
218
+
219
+ `condition:` follows a different rule, and it is worth knowing why rather than memorising it as an exception. The two are different kinds of thing:
220
+
221
+ | | asks | so it | receiver |
222
+ |---|---|---|---|
223
+ | an **option** (`choices:`, `label:`, `collapsed:`, …) | "what value should this have?" | may or may not care about the render — so it means what it reads like where you wrote it, and takes `form` when it does care | its own closure, or the form |
224
+ | **`condition:`** | "should this render *here, now*?" | is a question about the render context by definition — there is no useful reading of it that ignores that context | always the thing doing the rendering |
225
+
226
+ So `condition:` always runs **against** its context and reads it with no argument — and "its context" is whatever is rendering: the form for a field, section or nested input; the component for a `column` or `display`; the **wizard** for a step's `condition:` (evaluated in the runner to decide which steps exist, before any form is built); a condition context for an action or scope.
227
+
228
+ ```ruby
229
+ input :notes, condition: -> { object.published? } # form
230
+ display :audit_log, condition: -> { current_user.admin? } # display component
231
+ step :billing, condition: -> { data.plan.tier == "pro" } # wizard, no form exists yet
232
+ ```
233
+
234
+ That is why it cannot take a `form` argument the way an option does: in several of those places there is no form.
235
+
203
236
  ## Dynamic forms (`pre_submit`)
204
237
 
205
238
  A field with `pre_submit: true` triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
@@ -260,16 +293,16 @@ input :birth_date do |f|
260
293
  end
261
294
  ```
262
295
 
263
- ### `phlexi_tag` for declarative custom display
296
+ ### `phlexi_render` for declarative custom display
264
297
 
265
- `with:` takes either a Phlex component class OR a proc whose body is **rendered inside a Phlex context** — HTML tag methods (`span`, `div`, `a`) and Tailwind classes are first-class. The proc receives `(value, attrs)`.
298
+ `as: :phlexi_render` (or its shorthand `as: :phlexi`). `with:` takes either a Phlex component class OR a proc whose body is **rendered inside a Phlex context** — HTML tag methods (`span`, `div`, `a`) and Tailwind classes are first-class. The proc receives `(value, attrs)`.
266
299
 
267
300
  ```ruby
268
301
  # Component — preferred for anything reusable
269
- display :status, as: :phlexi_tag, with: StatusBadgeComponent
302
+ display :status, as: :phlexi_render, with: StatusBadgeComponent
270
303
 
271
304
  # Inline proc — `span` here is a Phlex tag method, not a Rails helper
272
- display :priority, as: :phlexi_tag, with: ->(value, attrs) {
305
+ display :priority, as: :phlexi_render, with: ->(value, attrs) {
273
306
  case value
274
307
  when 'high' then span(class: "badge badge-danger") { "High" }
275
308
  when 'medium' then span(class: "badge badge-warning") { "Medium" }
@@ -282,11 +315,27 @@ See [UI › Components](/reference/ui/components) for writing reusable Phlex com
282
315
 
283
316
  ### Custom component class
284
317
 
318
+ `as:` takes a **field component** — Plutonium constructs it as
319
+ `YourComponent.new(field, **attributes)`, so it subclasses
320
+ `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base`
321
+ (displays) and reads the value off `field`:
322
+
285
323
  ```ruby
286
324
  input :color_picker, as: ColorPickerComponent
287
325
  display :chart, as: ChartComponent
288
326
  ```
289
327
 
328
+ A component with its own constructor (e.g. `PostCardComponent.new(post:)`) is not
329
+ an `as:` candidate — it would raise `ArgumentError`. Build it in a block instead:
330
+
331
+ ```ruby
332
+ display :card do |field|
333
+ PostCardComponent.new(post: field.object)
334
+ end
335
+ ```
336
+
337
+ See [UI › Components › Field components](/reference/ui/components#field-components).
338
+
290
339
  ## Column options
291
340
 
292
341
  ```ruby
@@ -511,15 +560,26 @@ Groups a set of fields under an optional heading.
511
560
  | `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer — any other value raises. (Literal only — not dynamic.) |
512
561
  | `condition:` | Lambda evaluated in the form instance context — same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
513
562
 
514
- Every option except `columns:` may be either a literal **or a proc** resolved at render time in the same form instance context as `condition:` (so `object`, `current_user`, `params`, helpers are all available). This makes the layout record-aware — e.g. collapse a section by default only for existing records:
563
+ Every option except `columns:` may be either a literal **or a proc** resolved at render time, following the same arity rule as every other option ([Options that vary per render](#options-that-vary-per-render)): take a `form` argument to read the render context. This makes the layout record-aware — e.g. collapse a section by default only for existing records:
515
564
 
516
565
  ```ruby
517
566
  section :advanced, :seo_title, :notes,
518
567
  collapsible: true,
519
- collapsed: -> { object.persisted? }, # open for new, collapsed for edits
520
- label: -> { object.new_record? ? "Set up" : "Advanced" }
568
+ collapsed: ->(form) { form.object.persisted? }, # open for new, collapsed for edits
569
+ label: ->(form) { form.object.new_record? ? "Set up" : "Advanced" }
521
570
  ```
522
571
 
572
+ ::: warning Breaking change in 0.63
573
+ Section options previously took a **zero-argument** proc evaluated against the form (`collapsed: -> { object.persisted? }`). They now follow the same rule as every other option, where a zero-argument proc keeps its own binding — and a `form_layout` block is evaluated against the layout builder, so `object` there is a `NameError`.
574
+
575
+ ```ruby
576
+ - collapsed: -> { object.persisted? }
577
+ + collapsed: ->(form) { form.object.persisted? }
578
+ ```
579
+
580
+ It fails loudly, never silently. `condition:` is unchanged — it is still evaluated against the form and still reads `object` with no argument.
581
+ :::
582
+
523
583
  A section that resolves to **zero fields** — every declared field filtered out by the permitted set, or no field assigned — renders nothing at all (no heading, no grid). This keeps forms clean when fewer attributes are permitted than declared (notably `+ New`, where the create policy often permits a subset). The check is purely "are there fields to render"; it does **not** evaluate per-field `condition:` procs (those run later, at field render). So if you want a whole section to appear only under some state, gate it with the section's own `condition:` rather than relying on every field inside it being hidden:
524
584
 
525
585
  ```ruby
@@ -584,7 +644,7 @@ A `section` only renders the fields that are actually in the form's permitted se
584
644
 
585
645
  `form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically — no extra wiring needed.
586
646
 
587
- Dynamic options and `condition:` work here too, with one difference: in an interaction form `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: -> { object.resource.archived? }`.
647
+ Dynamic options and `condition:` work here too, with one difference: on an interaction form the form's `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: ->(form) { form.object.resource.archived? }`, and `condition: -> { object.resource.archived? }` (which is form-evaluated, so it needs no argument).
588
648
 
589
649
  ```ruby
590
650
  class PublishPostInteraction < Plutonium::Interaction::Base
@@ -602,6 +662,116 @@ class PublishPostInteraction < Plutonium::Interaction::Base
602
662
  end
603
663
  ```
604
664
 
665
+ ## Display layout
666
+
667
+ The show page's counterpart to [`form_layout`](#form-layout). Same DSL and the same resolution rules — first-section-wins ownership, unlisted permitted fields collected into `ungrouped`, absent fields skipped, zero-field sections dropped entirely — applied to the show page instead of the form.
668
+
669
+ ```ruby
670
+ class PostDefinition < ResourceDefinition
671
+ display_layout do
672
+ section :profile, :name, :author, label: "Profile", description: "Identity and owner"
673
+ section :presentation, :cover, :body,
674
+ collapsible: true,
675
+ condition: -> { object.published? }
676
+ ungrouped label: "Other details"
677
+ end
678
+ end
679
+ ```
680
+
681
+ With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid — fully backwards-compatible.
682
+
683
+ ### Independent of `form_layout`
684
+
685
+ The two are separate registries. A resource may declare either, both, or neither, and grouping its form one way has no effect on its show page. Both inherit to subclasses and are replaced as a unit when re-declared.
686
+
687
+ ### No `columns:`
688
+
689
+ Unlike `form_layout`, `display_layout` **raises** on `columns:`:
690
+
691
+ ```ruby
692
+ display_layout do
693
+ section :a, :x, columns: 2 # ArgumentError
694
+ end
695
+ ```
696
+
697
+ Every display section renders into the same responsive grid. Field width is a per-field concern, set the same way inside a section as outside one:
698
+
699
+ ```ruby
700
+ display :body, wrapper: {class: "col-span-2"}
701
+ ```
702
+
703
+ Raising rather than ignoring the option means a `form_layout` block copied across fails immediately, instead of silently having no effect.
704
+
705
+ ### Section options
706
+
707
+ `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as [`section(key, *fields, **opts)`](#section-key-fields-opts) minus `columns:`. A collapsible display section behaves exactly as a form one does, `collapsed:` included.
708
+
709
+ Every option except `condition:` may be a proc, resolved at render under the same arity rule the form uses — a zero-arity proc keeps its own binding, a one-arity proc is handed the display:
710
+
711
+ ```ruby
712
+ section :audit, :created_at, collapsible: true, collapsed: ->(display) { display.object.active? }
713
+ ```
714
+
715
+ `condition:` is evaluated separately and against the display, where `object` is the record.
716
+
717
+ ### Rendering
718
+
719
+ Each section renders as its own card, stacked by a `sections_wrapper` container — so a sectioned show page has **no single outer card**. Fields declared via [`metadata`](#metadata-panel-show-page) are excluded from the sections and render in the metadata panel instead. Section chrome is themeable; see [UI › Displays › Theming](/reference/ui/displays#theming).
720
+
721
+ ## Page width
722
+
723
+ Detail-style pages — the show page and resource forms — are constrained to a readable column by default. Inputs and values stretch to their container, so at full content width they become ~1200px-wide text boxes: past a comfortable measure, and a long eye-travel between a label and the value beside it.
724
+
725
+ Index and table pages are deliberately **not** affected; a table wants every pixel.
726
+
727
+ ```ruby
728
+ # config/initializers/plutonium.rb
729
+ Plutonium.configure { |config| config.default_page_width = :md }
730
+
731
+ class PostDefinition < ResourceDefinition
732
+ page_width :lg # this resource's form AND show page
733
+ end
734
+ ```
735
+
736
+ ### Sizes
737
+
738
+ `:sm` `:md` `:lg` `:xl` `:full`. `:full` opts out of any constraint. An unknown value raises `ArgumentError` at declaration rather than silently rendering at some other width.
739
+
740
+ ::: warning Size tokens are relative to their surface
741
+ These are the same token *names* [modal sizes](#modals) use, but **not the same widths**. Each surface has its own scale, because the surfaces aren't comparable — a "small page" is reasonably larger than a "small dialog":
742
+
743
+ | Token | Page width | Centered modal | Slideover |
744
+ |---|---|---|---|
745
+ | `:sm` | 672px | 448px | 400px |
746
+ | `:md` | 896px | 576px | 480px |
747
+ | `:lg` | 1152px | 672px | 640px |
748
+ | `:xl` | 1280px | 896px | 800px |
749
+ | `:full` | unconstrained | 95vw | 95vw |
750
+
751
+ Modals additionally support `:auto` (hug the content); a page has nothing to hug, so it does not.
752
+ :::
753
+
754
+ ### Per-surface overrides
755
+
756
+ `form_width` and `display_width` override `page_width` for one surface only:
757
+
758
+ ```ruby
759
+ class PostDefinition < ResourceDefinition
760
+ page_width :lg
761
+ display_width :full # the show page runs full width; the form stays :lg
762
+ end
763
+ ```
764
+
765
+ Resolution, most specific first: the surface-specific setting → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is a real choice and is honoured, not treated as "unset".
766
+
767
+ All three inherit to subclasses, so a portal-specific definition keeps its parent's width unless it says otherwise.
768
+
769
+ ### Scope
770
+
771
+ - **Modals are unaffected** — a dialog sizes itself via `modal_size`.
772
+ - **Interactions** (`Plutonium::Interaction::Base`) support the same settings, for interactive actions rendered as standalone pages.
773
+ - **Wizards are configured separately**, via `Plutonium.configuration.wizards.width`. It defaults to `default_page_width`, so a wizard step carrying a `form_layout` renders those sections at the same width the identical sections get on a resource form.
774
+
605
775
  ## File uploads
606
776
 
607
777
  ```ruby
@@ -684,6 +854,8 @@ class PostDefinition < ResourceDefinition
684
854
  # :centered — centered dialog
685
855
  # false — full standalone pages (no modal)
686
856
  # size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
857
+ # (widths are per-surface — see Page width; a slideover's :md is 480px,
858
+ # a centered dialog's is 576px, a page's is 896px)
687
859
  modal :centered, size: :lg
688
860
  end
689
861
  ```
@@ -161,7 +161,7 @@ configure do
161
161
  login_redirect "/welcome"
162
162
 
163
163
  after_login do
164
- session[:after_welcome_redirect] = session.delete(:login_redirect)
164
+ session[:after_welcome_redirect] = session.delete(login_redirect_session_key)
165
165
  end
166
166
  end
167
167
  ```
@@ -45,6 +45,40 @@ For `has_one`:
45
45
  - Only one record can exist per parent.
46
46
  - Forms don't show the parent field (determined by URL).
47
47
 
48
+ ## Declaring which associations get routes
49
+
50
+ By default every `has_many` and `has_one` whose child is a registered resource gets
51
+ a nested route. Name the ones you want and the rest are not drawn:
52
+
53
+ ```ruby
54
+ register_resource ::Company, associations: %i[properties company_profile]
55
+ ```
56
+
57
+ `associations: []` draws none. Naming an association that is not a `has_many` or
58
+ `has_one`, or whose child is not registered in that portal, fails the boot rather
59
+ than quietly drawing one route fewer.
60
+
61
+ To make declaring them the rule rather than the exception, flip the default so that
62
+ a resource naming none gets none:
63
+
64
+ ```ruby
65
+ # config/initializers/plutonium.rb
66
+ Plutonium.configure do |config|
67
+ config.nested_association_routes = :declared # default: :detected
68
+ end
69
+ ```
70
+
71
+ The mode only decides what silence means. `associations:` works the same either way,
72
+ and top-level routes are untouched by both.
73
+
74
+ Two things to know before turning it on:
75
+
76
+ - It applies to every portal at once, and every resource that names nothing loses its
77
+ nested routes. On an existing app, expect to add `associations:` in several places.
78
+ - A policy's `permitted_associations` renders a panel on the show page that links to
79
+ the nested route. An association permitted there but omitted here leaves that panel
80
+ pointing at a route that does not exist. The two lists have to agree.
81
+
48
82
  ## Automatic behavior on nested routes
49
83
 
50
84
  When the controller is hit via a nested route, Plutonium automatically:
@@ -53,7 +87,8 @@ When the controller is hit via a nested route, Plutonium automatically:
53
87
  2. **Scopes queries** via the parent association:
54
88
  - `has_many` → `parent.send(parent_association)` (e.g. `company.properties`)
55
89
  - `has_one` → `relation.where(foreign_key => parent.id)` with limit
56
- 3. **Assigns the parent** on create (injected into `resource_params`).
90
+ 3. **Assigns the parent** on create (injected into `resource_params`), building the
91
+ record on the parent's association so a scoped association supplies its defaults.
57
92
  4. **Hides the parent field** in forms and displays (already determined by URL).
58
93
 
59
94
  You don't add hidden parent fields or filter queries manually.
@@ -62,11 +97,13 @@ You don't add hidden parent fields or filter queries manually.
62
97
 
63
98
  ```ruby
64
99
  current_parent # parent record (e.g. Company instance)
100
+ current_parent_class # parent class (e.g. Company)
65
101
  current_nested_association # association name (e.g. :properties)
66
- parent_route_param # URL param (e.g. :company_id)
67
102
  parent_input_param # form param / association name (e.g. :company)
68
103
  ```
69
104
 
105
+ Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all — it contributes no id parameter, so there is nothing in the path to infer from.
106
+
70
107
  ## Parent vs entity scoping
71
108
 
72
109
  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 is redundant.
@@ -163,6 +200,27 @@ resource_params
163
200
 
164
201
  No hidden parent fields needed in forms.
165
202
 
203
+ ### Scoped associations
204
+
205
+ The record is built on the parent's association (`company.properties.new`), so an
206
+ association that carries a scope contributes its equality conditions as defaults:
207
+
208
+ ```ruby
209
+ class Company < ResourceRecord
210
+ has_many :published_properties, -> { where(published: true) },
211
+ class_name: "Property"
212
+ end
213
+ ```
214
+
215
+ Creating through `/companies/123/nested_published_properties` sets `published: true`.
216
+ It has to: the index honours the same scope, so a record created without it is
217
+ filtered out of the list it was created from.
218
+
219
+ Only equality conditions become attributes. A scope like
220
+ `-> { where("expires_at > ?", Time.current) }` cannot supply one, so an association
221
+ scoped that way still creates records its own index will not list. Prefer an
222
+ equality scope for any association you expose as a nested route.
223
+
166
224
  ## Presentation hooks
167
225
 
168
226
  Control whether the parent field appears in views/forms:
@@ -240,6 +240,10 @@ Plutonium uses a comprehensive CSS custom-property system for consistent, themea
240
240
  }
241
241
  ```
242
242
 
243
+ ::: warning Mirror every `:root` override in `.dark`
244
+ Your stylesheet loads after Plutonium's, and `:root` and `.dark` have equal specificity — so a token you override in `:root` beats Plutonium's `.dark` value even when dark mode is active. Any color token you customize in `:root` without re-asserting in `.dark` ships your light value into dark mode, where it's typically unreadable (e.g. a translucent dark `--pu-text-subtle` becomes invisible on a dark surface).
245
+ :::
246
+
243
247
  ### Using tokens in templates
244
248
 
245
249
  ```erb
@@ -101,20 +101,27 @@ class PostCardComponent < Plutonium::UI::Component::Base
101
101
  end
102
102
  ```
103
103
 
104
- ::: tip Always inherit `Plutonium::UI::Component::Base`
104
+ ::: tip Inherit `Plutonium::UI::Component::Base`
105
105
  It gives you:
106
106
  - The component kit (`PageHeader`, `Panel`, `Block`, …)
107
107
  - Resource helpers (`resource_url_for`, `current_user`, `current_record!`, `current_definition`)
108
108
  - A `helpers` proxy for Rails helpers (`helpers.link_to`, `helpers.number_to_currency`)
109
109
  - Token / class helpers (`tokens`, `classes`)
110
+
111
+ A **field** component (one you pass to `as:`) inherits its Phlexi base instead —
112
+ `include Plutonium::UI::Component::Behaviour` there to get the same helpers.
110
113
  :::
111
114
 
112
115
  ### Use in a definition
113
116
 
117
+ A component like `PostCardComponent` above has its own constructor, so it reaches
118
+ a field through the **block form** — you build it yourself:
119
+
114
120
  ```ruby
115
121
  class PostDefinition < ResourceDefinition
116
- display :card, as: PostCardComponent # custom display component
117
- input :color, as: ColorPickerComponent # custom input component
122
+ display :card do |field|
123
+ PostCardComponent.new(post: field.object)
124
+ end
118
125
 
119
126
  display :metrics do |field|
120
127
  MetricsChartComponent.new(data: field.value)
@@ -122,6 +129,52 @@ class PostDefinition < ResourceDefinition
122
129
  end
123
130
  ```
124
131
 
132
+ `as: SomeComponent` is the other route, and it expects a **field component** —
133
+ Plutonium instantiates it with the field builder, not with your keyword
134
+ arguments. See [field components](#field-components) below.
135
+
136
+ ::: warning `as:` does not take a keyword-argument component
137
+ `display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
138
+ arguments` — the component is constructed as `PostCardComponent.new(field,
139
+ **attributes)`. Use the block form for those.
140
+ :::
141
+
142
+ ### Field components
143
+
144
+ A field component subclasses the Phlexi component base for its surface and reads
145
+ everything off `field` (`field.value`, `field.object`, `field.dom`, plus
146
+ `attributes` — the themed id/name/class Plutonium already computed):
147
+
148
+ ```ruby
149
+ # app/components/color_picker_component.rb
150
+ class ColorPickerComponent < Phlexi::Form::Components::Base
151
+ include Phlexi::Form::Components::Concerns::HandlesInput # name/id/value plumbing
152
+
153
+ def view_template
154
+ input(**attributes, type: "color", value: field.value)
155
+ end
156
+ end
157
+
158
+ # app/components/chart_component.rb
159
+ class ChartComponent < Phlexi::Display::Components::Base
160
+ include Plutonium::UI::Component::Behaviour # optional: kit + resource helpers
161
+
162
+ def view_template
163
+ div(class: "h-40", data: {controller: "chart", chart_series_value: field.value.to_json})
164
+ end
165
+ end
166
+ ```
167
+
168
+ ```ruby
169
+ class PostDefinition < ResourceDefinition
170
+ input :color, as: ColorPickerComponent # custom input component
171
+ display :chart, as: ChartComponent # custom display component
172
+ end
173
+ ```
174
+
175
+ An `as:` component works in every surface that renders the field — form, show
176
+ page, index column, filter panel and wizard summary alike.
177
+
125
178
  ### Use in a page / form / display
126
179
 
127
180
  ```ruby
@@ -213,6 +266,6 @@ Inside any custom component, the same set of helpers as pages/forms/displays —
213
266
  ## Related
214
267
 
215
268
  - [Pages](./pages) — `render_*` hooks call your components
216
- - [Forms](./forms) — using custom input components via `as: MyComponent`
269
+ - [Forms](./forms) — the built-in input tags and their `as:` aliases
217
270
  - [Displays](./displays) — using custom display components
218
271
  - [Assets](./assets) — design tokens (`var(--pu-*)`) and `.pu-*` component classes