plutonium 0.64.0 → 0.66.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 (344) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -41
  3. data/.claude/skills/plutonium-app/SKILL.md +115 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +236 -145
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +171 -95
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +282 -210
  16. data/README.md +9 -9
  17. data/SECURITY.md +1 -1
  18. data/app/assets/plutonium-charts.js +20604 -0
  19. data/app/assets/plutonium-charts.js.map +7 -0
  20. data/app/assets/plutonium-charts.min.js +41 -0
  21. data/app/assets/plutonium-charts.min.js.map +7 -0
  22. data/app/assets/plutonium.css +1 -1
  23. data/app/assets/plutonium.js +794 -629
  24. data/app/assets/plutonium.js.map +4 -4
  25. data/app/assets/plutonium.min.js +48 -48
  26. data/app/assets/plutonium.min.js.map +4 -4
  27. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  28. data/app/views/plutonium/_resource_header.html.erb +2 -2
  29. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  30. data/app/views/plutonium/_toast.html.erb +2 -2
  31. data/app/views/rodauth/_login_form.html.erb +1 -1
  32. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  33. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  34. data/app/views/rodauth/change_login.html.erb +2 -2
  35. data/app/views/rodauth/create_account.html.erb +4 -4
  36. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  37. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  38. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  39. data/config/brakeman.ignore +4 -4
  40. data/config/initializers/rabl.rb +0 -40
  41. data/config/locales/en/api_client.yml +22 -0
  42. data/config/locales/en/async.yml +34 -0
  43. data/config/locales/en/dashboard.yml +25 -0
  44. data/config/locales/en/invites.yml +37 -0
  45. data/config/locales/en/js.yml +40 -0
  46. data/config/locales/en/kanban.yml +28 -0
  47. data/config/locales/en/plutonium.yml +26 -0
  48. data/config/locales/en/positioning.yml +14 -0
  49. data/config/locales/en/profile.yml +29 -0
  50. data/config/locales/en/resource.yml +60 -0
  51. data/config/locales/en/ui.yml +93 -0
  52. data/config/locales/en/wizard.yml +46 -0
  53. data/docs/.vitepress/config.ts +34 -1
  54. data/docs/.vitepress/sync-skills.mjs +6 -3
  55. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  56. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  57. data/docs/.vitepress/theme/custom.css +34 -377
  58. data/docs/blog/introducing-plutonium-dashboards.md +174 -0
  59. data/docs/blog/introducing-plutonium-i18n.md +90 -0
  60. data/docs/blog/introducing-plutonium.md +4 -5
  61. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  62. data/docs/getting-started/installation.md +5 -5
  63. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  64. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  65. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  66. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  67. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  68. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  69. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  70. data/docs/getting-started/tutorial/index.md +1 -1
  71. data/docs/guides/adding-resources.md +10 -7
  72. data/docs/guides/authentication.md +25 -25
  73. data/docs/guides/authorization.md +24 -24
  74. data/docs/guides/creating-packages.md +17 -17
  75. data/docs/guides/custom-actions.md +32 -32
  76. data/docs/guides/customizing-ui.md +29 -26
  77. data/docs/guides/dashboards.md +300 -0
  78. data/docs/guides/index.md +4 -3
  79. data/docs/guides/kanban.md +56 -56
  80. data/docs/guides/multi-tenancy.md +35 -22
  81. data/docs/guides/nested-resources.md +21 -21
  82. data/docs/guides/performance.md +3 -3
  83. data/docs/guides/search-filtering.md +13 -13
  84. data/docs/guides/testing.md +16 -12
  85. data/docs/guides/theming.md +32 -17
  86. data/docs/guides/troubleshooting.md +2 -2
  87. data/docs/guides/user-invites.md +17 -17
  88. data/docs/guides/user-profile.md +51 -24
  89. data/docs/guides/wizards.md +55 -55
  90. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  91. data/docs/public/images/blog/dashboards-loading.png +0 -0
  92. data/docs/public/images/blog/dashboards-overview.png +0 -0
  93. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  94. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  95. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  96. data/docs/public/images/guides/dashboard-loading.png +0 -0
  97. data/docs/public/images/guides/dashboard-overview.png +0 -0
  98. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  99. data/docs/reference/app/generators.md +24 -24
  100. data/docs/reference/app/index.md +15 -18
  101. data/docs/reference/app/packages.md +8 -8
  102. data/docs/reference/app/portals.md +77 -29
  103. data/docs/reference/auth/accounts.md +15 -15
  104. data/docs/reference/auth/index.md +12 -12
  105. data/docs/reference/auth/profile.md +67 -29
  106. data/docs/reference/behavior/async-interactions.md +24 -24
  107. data/docs/reference/behavior/controllers.md +28 -28
  108. data/docs/reference/behavior/index.md +5 -5
  109. data/docs/reference/behavior/interactions.md +44 -44
  110. data/docs/reference/behavior/policies.md +48 -28
  111. data/docs/reference/configuration.md +88 -31
  112. data/docs/reference/dashboard/dsl.md +123 -0
  113. data/docs/reference/dashboard/index.md +35 -0
  114. data/docs/reference/dashboard/registration.md +76 -0
  115. data/docs/reference/generators/lite.md +7 -7
  116. data/docs/reference/i18n.md +262 -0
  117. data/docs/reference/index.md +11 -2
  118. data/docs/reference/kanban/authorization.md +9 -9
  119. data/docs/reference/kanban/dsl.md +33 -33
  120. data/docs/reference/kanban/index.md +1 -1
  121. data/docs/reference/kanban/positioning.md +18 -16
  122. data/docs/reference/resource/actions.md +51 -51
  123. data/docs/reference/resource/definition.md +166 -88
  124. data/docs/reference/resource/export.md +11 -7
  125. data/docs/reference/resource/index.md +16 -14
  126. data/docs/reference/resource/model.md +24 -24
  127. data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
  128. data/docs/reference/resource/query.md +14 -12
  129. data/docs/reference/tenancy/entity-scoping.md +65 -35
  130. data/docs/reference/tenancy/index.md +11 -11
  131. data/docs/reference/tenancy/invites.md +20 -20
  132. data/docs/reference/tenancy/nested-resources.md +13 -13
  133. data/docs/reference/testing/index.md +116 -22
  134. data/docs/reference/ui/assets.md +65 -25
  135. data/docs/reference/ui/components.md +20 -20
  136. data/docs/reference/ui/displays.md +19 -22
  137. data/docs/reference/ui/forms.md +35 -35
  138. data/docs/reference/ui/index.md +17 -15
  139. data/docs/reference/ui/layouts.md +21 -21
  140. data/docs/reference/ui/pages.md +22 -22
  141. data/docs/reference/ui/tables.md +7 -7
  142. data/docs/reference/wizard/anchoring-resume.md +33 -32
  143. data/docs/reference/wizard/dsl.md +45 -45
  144. data/docs/reference/wizard/index.md +6 -6
  145. data/docs/reference/wizard/one-time.md +18 -18
  146. data/docs/reference/wizard/registration-launch.md +32 -32
  147. data/docs/reference/wizard/storage-config.md +23 -23
  148. data/esbuild.config.js +5 -2
  149. data/gemfiles/rails_7.gemfile.lock +2 -2
  150. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  151. data/lib/active_model/validations/array_validator.rb +1 -1
  152. data/lib/active_model/validations/attached_validator.rb +1 -1
  153. data/lib/active_model/validations/url_validator.rb +2 -2
  154. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  155. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  156. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  157. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  158. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  159. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  160. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  161. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  162. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  163. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  164. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  165. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  166. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  167. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  168. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  169. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  170. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  171. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  172. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  173. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  174. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  175. data/lib/plutonium/action/base.rb +27 -3
  176. data/lib/plutonium/action/interactive.rb +17 -4
  177. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  178. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  179. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  180. data/lib/plutonium/configuration.rb +5 -0
  181. data/lib/plutonium/core/controller.rb +25 -1
  182. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  183. data/lib/plutonium/dashboard/base.rb +114 -0
  184. data/lib/plutonium/dashboard/card.rb +200 -0
  185. data/lib/plutonium/dashboard/controller.rb +101 -0
  186. data/lib/plutonium/dashboard/dsl.rb +104 -0
  187. data/lib/plutonium/dashboard/register.rb +40 -0
  188. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  189. data/lib/plutonium/dashboard.rb +42 -0
  190. data/lib/plutonium/definition/actions.rb +6 -1
  191. data/lib/plutonium/definition/base.rb +41 -3
  192. data/lib/plutonium/definition/index_views.rb +1 -1
  193. data/lib/plutonium/definition/positioning.rb +1 -1
  194. data/lib/plutonium/definition/presentable.rb +2 -2
  195. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  196. data/lib/plutonium/helpers/display_helper.rb +10 -2
  197. data/lib/plutonium/interaction/async/context.rb +6 -8
  198. data/lib/plutonium/interaction/async/executor.rb +6 -7
  199. data/lib/plutonium/interaction/async/run.rb +2 -2
  200. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  201. data/lib/plutonium/interaction/base.rb +2 -0
  202. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  203. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  204. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  205. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  206. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  207. data/lib/plutonium/invites/controller.rb +11 -11
  208. data/lib/plutonium/kanban/column.rb +13 -3
  209. data/lib/plutonium/kanban/dsl.rb +7 -4
  210. data/lib/plutonium/models/has_cents.rb +1 -1
  211. data/lib/plutonium/profile/security_section.rb +10 -18
  212. data/lib/plutonium/query/filter.rb +10 -1
  213. data/lib/plutonium/query/filters/association.rb +27 -2
  214. data/lib/plutonium/query/filters/boolean.rb +4 -4
  215. data/lib/plutonium/query/filters/date.rb +4 -17
  216. data/lib/plutonium/query/filters/date_range.rb +2 -2
  217. data/lib/plutonium/query/filters/select.rb +10 -1
  218. data/lib/plutonium/query/filters/text.rb +9 -12
  219. data/lib/plutonium/railtie.rb +17 -0
  220. data/lib/plutonium/resource/controller.rb +10 -5
  221. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  222. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  223. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  224. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  225. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  226. data/lib/plutonium/resource/query_object.rb +30 -3
  227. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  228. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  229. data/lib/plutonium/routing/resource_registration.rb +5 -0
  230. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  231. data/lib/plutonium/translation.rb +227 -0
  232. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  233. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  234. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  235. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  236. data/lib/plutonium/ui/component/methods.rb +4 -0
  237. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  238. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  239. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  240. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  241. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  242. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  243. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  244. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  245. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  246. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  247. data/lib/plutonium/ui/display/resource.rb +23 -16
  248. data/lib/plutonium/ui/export_button.rb +3 -3
  249. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  250. data/lib/plutonium/ui/form/components/password.rb +2 -2
  251. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  252. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  253. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  254. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  255. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  256. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  257. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  258. data/lib/plutonium/ui/form/query.rb +8 -8
  259. data/lib/plutonium/ui/form/resource.rb +54 -26
  260. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  261. data/lib/plutonium/ui/grid/card.rb +3 -3
  262. data/lib/plutonium/ui/grid/resource.rb +3 -3
  263. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  264. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  265. data/lib/plutonium/ui/kanban/column.rb +6 -6
  266. data/lib/plutonium/ui/layout/base.rb +19 -1
  267. data/lib/plutonium/ui/layout/header.rb +1 -1
  268. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  269. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  270. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  271. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  272. data/lib/plutonium/ui/modal/base.rb +4 -4
  273. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  274. data/lib/plutonium/ui/nav_user.rb +2 -2
  275. data/lib/plutonium/ui/page/base.rb +10 -2
  276. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  277. data/lib/plutonium/ui/page/edit.rb +3 -3
  278. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  279. data/lib/plutonium/ui/page/new.rb +3 -3
  280. data/lib/plutonium/ui/page/show.rb +2 -2
  281. data/lib/plutonium/ui/page/wizard.rb +22 -16
  282. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  283. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  284. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  285. data/lib/plutonium/ui/table/base.rb +2 -2
  286. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  287. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  288. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  289. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  290. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  291. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  292. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  293. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  294. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  295. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  296. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  297. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  298. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  299. data/lib/plutonium/ui/table/resource.rb +53 -17
  300. data/lib/plutonium/ui/wizard/review.rb +7 -7
  301. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  302. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  303. data/lib/plutonium/version.rb +1 -1
  304. data/lib/plutonium/wizard/base.rb +2 -0
  305. data/lib/plutonium/wizard/driving.rb +8 -2
  306. data/lib/plutonium/wizard/dsl.rb +4 -2
  307. data/lib/plutonium/wizard/field_importer.rb +1 -8
  308. data/lib/plutonium/wizard/review_step.rb +8 -1
  309. data/lib/plutonium/wizard/step.rb +14 -3
  310. data/package.json +4 -2
  311. data/plutonium.gemspec +4 -1
  312. data/postcss-gem-import.cjs +34 -0
  313. data/postcss-gem-import.js +3 -28
  314. data/src/css/components.css +101 -10
  315. data/src/css/slim_select.css +0 -38
  316. data/src/css/tokens.css +24 -0
  317. data/src/js/controllers/attachment_input_controller.js +15 -6
  318. data/src/js/controllers/chart_controller.js +162 -0
  319. data/src/js/controllers/clipboard_controller.js +3 -2
  320. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  321. data/src/js/controllers/easymde_controller.js +3 -0
  322. data/src/js/controllers/filter_panel_controller.js +15 -6
  323. data/src/js/controllers/flatpickr_controller.js +8 -0
  324. data/src/js/controllers/frame_refresh_controller.js +59 -0
  325. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  326. data/src/js/controllers/register_controllers.js +4 -0
  327. data/src/js/controllers/slim_select_controller.js +79 -176
  328. data/src/js/core.js +9 -1
  329. data/src/js/i18n.js +69 -0
  330. data/src/js/plutonium-charts.js +15 -0
  331. data/src/js/turbo/turbo_confirm.js +5 -3
  332. metadata +64 -16
  333. data/.yarnrc.yml +0 -8
  334. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  335. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  336. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  337. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  338. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  339. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  340. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  341. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  342. data/public/plutonium-assets/application.js +0 -31419
  343. data/public/plutonium-assets/plutonium.ico +0 -0
  344. data/yarn.lock +0 -6858
@@ -1,6 +1,6 @@
1
1
  # Definition
2
2
 
3
- Definitions configure **how** a resource is rendered and interacted with — which fields appear, how they render, what page chrome looks like. Auto-detection from the model handles the defaults; declare only what you're overriding.
3
+ Definitions configure **how** a resource is rendered and interacted with: which fields appear, how they render, what page chrome looks like. Auto-detection from the model handles the defaults; declare only what you're overriding.
4
4
 
5
5
  For search/filters/scopes/sorting see [Query](./query). For custom actions see [Actions](./actions).
6
6
 
@@ -10,7 +10,7 @@ For search/filters/scopes/sorting see [Query](./query). For custom actions see [
10
10
  - **Use `condition:` for UI state, the policy for authorization.** `condition: -> { object.published? }` is fine. "Only admins see this field" belongs in `permitted_attributes_for_*`.
11
11
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (see [Behavior › Policy](/reference/behavior/policies)).
12
12
  - **`has_cents` fields use the virtual name** (`field :price`), never `:price_cents`.
13
- - **Nested inputs need `accepts_nested_attributes_for` AND `inverse_of:` on the child's `belongs_to`** — without `inverse_of:`, validation fails with "Parent must exist" because the parent isn't saved yet.
13
+ - **Nested inputs need `accepts_nested_attributes_for` AND `inverse_of:` on the child's `belongs_to`**, without `inverse_of:`, validation fails with "Parent must exist" because the parent isn't saved yet.
14
14
 
15
15
  ## File location
16
16
 
@@ -102,7 +102,7 @@ end
102
102
  | Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
103
103
  | Rich text | `:markdown` (EasyMDE editor) |
104
104
  | Numeric | `:number`, `:integer`, `:decimal`, `:range` |
105
- | Boolean | `:toggle` / `:switch` (switch — **default** for boolean columns), `:boolean` (plain checkbox) |
105
+ | Boolean | `:toggle` / `:switch` (switch: **default** for boolean columns), `:boolean` (plain checkbox) |
106
106
  | Date/Time | `:date`, `:time`, `:datetime` |
107
107
  | Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
108
108
  | Files | `:file`, `:uppy`, `:attachment` |
@@ -115,7 +115,7 @@ end
115
115
 
116
116
  #### Auto-inferred display formatting
117
117
 
118
- These render automatically — declare an `as:` only to override or pass options:
118
+ These render automatically; declare an `as:` only to override or pass options:
119
119
 
120
120
  | Column | Renders as | Notes |
121
121
  |---|---|---|
@@ -132,11 +132,11 @@ display :active, as: :boolean, true_label: "Live", false_label: "Off"
132
132
  **Currency symbol.** The `unit:` can be set on the model's `has_cents` declaration
133
133
  (`has_cents :price_cents, unit: "£"`, or `unit: :currency_symbol` to read a method
134
134
  off the record for per-row currencies). That model-level unit is used everywhere the
135
- value renders as currency — the show page, tables, **and grid/kanban cards**. A
135
+ value renders as currency: the show page, tables, **and grid/kanban cards**. A
136
136
  per-display `unit:` overrides it for that one display; `unit: false` explicitly
137
137
  renders no symbol. When neither is set, currency falls back to
138
138
  `Plutonium.configuration.default_currency_unit` (default: the i18n
139
- `number.currency.format.unit` if the locale defines it — `$` in `en` — else no symbol).
139
+ `number.currency.format.unit` if the locale defines it, `$` in `en`, else no symbol).
140
140
 
141
141
  ## Field options
142
142
 
@@ -159,6 +159,8 @@ input :title,
159
159
  wrapper: {class: "col-span-full"}
160
160
  ```
161
161
 
162
+ Label, hint, placeholder and description need no declaration to be translatable: leave them out and Plutonium looks them up under `activerecord.attributes` and `plutonium.fields.<model>.<attr>` respectively. An explicit value can be a translation too, via the class-level `t`. See [Internationalization](../i18n#fields-placeholder-hint-description).
163
+
162
164
  ## Select / choices
163
165
 
164
166
  ### Static
@@ -196,23 +198,30 @@ display :rejection_reason, condition: -> { object.rejected? }
196
198
  field :debug_info, condition: -> { Rails.env.development? }
197
199
  ```
198
200
 
201
+ A surface's own `condition:` wins. Otherwise the form, the show page and the table
202
+ each fall back to the `field` condition, so it runs everywhere. The table has no
203
+ `object`, which means a `field` condition must not reference the record:
204
+ `-> { object.published? }` works on the form and show page and raises on the index.
205
+ Keep a `field` condition to context that exists on every surface (`Rails.env`, a
206
+ feature flag, `current_user`) and put record checks on `input`, `display` or `column`.
207
+
199
208
  ::: warning UI state, not authorization
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).
209
+ `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
210
  :::
202
211
 
203
212
  ## Options that vary per render
204
213
 
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**:
214
+ 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
215
 
207
216
  ```ruby
208
217
  input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
209
218
  input :notes, placeholder: -> { "Updated #{Time.current.year}" }
210
219
  ```
211
220
 
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 }`.
221
+ - **`-> { … }`** 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
222
  - **`->(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
223
 
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).
224
+ 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
225
 
217
226
  ### `condition:` is not an option
218
227
 
@@ -220,10 +229,10 @@ The rule holds on wizard steps too — but there a zero-argument proc closes ove
220
229
 
221
230
  | | asks | so it | receiver |
222
231
  |---|---|---|---|
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 |
232
+ | 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 |
233
+ | **`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
234
 
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.
235
+ 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
236
 
228
237
  ```ruby
229
238
  input :notes, condition: -> { object.published? } # form
@@ -244,7 +253,7 @@ class QuestionDefinition < ResourceDefinition
244
253
  choices: %w[text choice scale],
245
254
  pre_submit: true
246
255
 
247
- # Dependents — no `as:` needed when the model column type matches
256
+ # Dependents: no `as:` needed when the model column type matches
248
257
  input :max_length, condition: -> { object.question_type == "text" }
249
258
  input :choices, condition: -> { object.question_type == "choice" }
250
259
  input :min_value, condition: -> { object.question_type == "scale" }
@@ -293,29 +302,46 @@ input :birth_date do |f|
293
302
  end
294
303
  ```
295
304
 
296
- ### `phlexi_render` for declarative custom display
305
+ ### Inline markup in a display
297
306
 
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)`.
307
+ A `display` block is evaluated in the display component's Phlex context, so HTML tag
308
+ methods (`span`, `div`, `a`) and Tailwind classes are first-class, and `field.object`
309
+ is the record. It renders once, even for a `has_many` field.
299
310
 
300
311
  ```ruby
301
- # Component — preferred for anything reusable
302
- display :status, as: :phlexi_render, with: StatusBadgeComponent
312
+ display :priority do |field|
313
+ variant = {"high" => "danger", "medium" => "warning"}.fetch(field.value.to_s, "info")
314
+ span(class: "pu-badge pu-badge-#{variant}") { field.value.to_s.humanize }
315
+ end
303
316
 
304
- # Inline proc — `span` here is a Phlex tag method, not a Rails helper
305
- display :priority, as: :phlexi_render, with: ->(value, attrs) {
306
- case value
307
- when 'high' then span(class: "badge badge-danger") { "High" }
308
- when 'medium' then span(class: "badge badge-warning") { "Medium" }
309
- else span(class: "badge badge-info") { "Low" }
317
+ # Reaches the record; renders once for a collection
318
+ display :tags do |field|
319
+ div(class: "flex flex-wrap gap-1.5") do
320
+ field.object.tags.each { |tag| span(class: "pu-badge") { tag.name } }
310
321
  end
311
- }
322
+ end
312
323
  ```
313
324
 
325
+ Phlex also renders whatever the block returns, so a block that emits markup must end
326
+ with a tag call (or `nil`). The `div` wrapper above does that. A bare
327
+ `tags.each { span … }` as the last line returns the collection, which renders a
328
+ second time as text.
329
+
330
+ ::: warning `as: :phlexi_render` is deprecated
331
+ `display :x, as: :phlexi_render, with: ...` emits a deprecation warning and will be
332
+ removed. Use the block form above instead. The old `with:` proc did not run in a
333
+ Phlex context (its `self` was the definition, so `span`/`div` in the body raised), it
334
+ received the value already stringified so it could not reach the record, and it fired
335
+ once per element for a `has_many` field. The block form has none of those limits. This
336
+ deprecation applies only to the `as: :phlexi_render` display option; the internal
337
+ `phlexi_render` helper is unaffected.
338
+ :::
339
+
314
340
  See [UI › Components](/reference/ui/components) for writing reusable Phlex components.
315
341
 
316
342
  ### Custom component class
317
343
 
318
- `as:` takes a **field component** — Plutonium constructs it as
344
+ `as:` takes a **field component**: Plutonium constructs it as
319
345
  `YourComponent.new(field, **attributes)`, so it subclasses
320
346
  `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base`
321
347
  (displays) and reads the value off `field`:
@@ -326,7 +352,7 @@ display :chart, as: ChartComponent
326
352
  ```
327
353
 
328
354
  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:
355
+ an `as:` candidate; it would raise `ArgumentError`. Build it in a block instead:
330
356
 
331
357
  ```ruby
332
358
  display :card do |field|
@@ -338,27 +364,65 @@ See [UI › Components › Field components](/reference/ui/components#field-comp
338
364
 
339
365
  ## Column options
340
366
 
367
+ `align:` and `label:` configure the column header. They can be declared on `field`,
368
+ `display` or `column`; the most specific wins.
369
+
341
370
  ```ruby
371
+ field :amount, align: :end # every surface that has a notion of alignment
342
372
  column :title, align: :start # default
343
373
  column :status, align: :center
344
- column :amount, align: :end
374
+ column :amount, label: "Total"
375
+ ```
376
+
377
+ ### What a column inherits from the display
378
+
379
+ With no `column` declared, the table column renders exactly as the display does: the
380
+ display's `as:` and its attributes flow to the cell, so `display :status, as: :badge,
381
+ colors: {…}` colors the badge on the show page and in the table.
382
+
383
+ A `column` that only touches the header (`align:`, `label:`, `condition:`) keeps that
384
+ inheritance. A `column` that says anything about rendering (an `as:`, a component
385
+ attribute, or a block) renders alone: its own `as:` and attributes, nothing from the
386
+ display.
387
+
388
+ A `condition:` on `field` or `column` hides the column. A `condition:` on `display`
389
+ applies to the show page only, because it may reference the record and the table has
390
+ no single one.
391
+
392
+ ```ruby
393
+ display :status, as: :badge, colors: {published: :accent}
394
+ column :status, align: :center # still a badge, still colored
395
+ column :status, as: :badge # a badge, but with default colors
396
+ column :status, formatter: ->(v) { v.upcase } # plain text; the display is out
345
397
  ```
346
398
 
347
399
  ### Value formatting
348
400
 
349
401
  `formatter:` receives just the value. Use a block when you need the full record.
402
+ A column block runs in the table page's Phlex context, the same way a display block
403
+ runs in the show page's: it can emit markup directly, or return a String, a number
404
+ or a component. `self` is the table page, so `current_user`, `helpers` and
405
+ `resource_definition` are available, but methods defined on the definition class are
406
+ not; reach through the record you are passed instead.
350
407
 
351
408
  ```ruby
352
409
  column :description, formatter: ->(value) { value&.truncate(30) }
353
410
  column :price, formatter: ->(value) { "$%.2f" % value if value }
354
- column :status, formatter: ->(value) { value&.humanize&.upcase }
355
411
 
356
- # Block — full record access
412
+ # Block with full record access: emit markup...
413
+ column :status do |record|
414
+ span(class: "pu-badge") { record.status.humanize }
415
+ end
416
+
417
+ # ...or return a String
357
418
  column :full_name do |record|
358
419
  "#{record.first_name} #{record.last_name}"
359
420
  end
360
421
  ```
361
422
 
423
+ As with display blocks, a block that emits markup must end with a tag call (or
424
+ `nil`), because Phlex renders the return value too.
425
+
362
426
  ## Nested inputs
363
427
 
364
428
  Inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
@@ -389,7 +453,7 @@ end
389
453
  |---|---|
390
454
  | `limit` | Max records (auto-detected from model; default 10) |
391
455
  | `allow_destroy` | Show delete checkbox (auto-detected) |
392
- | `update_only` | Hide "Add" button — only edit existing |
456
+ | `update_only` | Hide "Add" button: only edit existing |
393
457
  | `description` | Help text above the section |
394
458
  | `condition` | Proc to show/hide |
395
459
  | `using` | Another Definition class |
@@ -404,13 +468,13 @@ end
404
468
  end
405
469
  ```
406
470
  - **Don't put `*_attributes` hashes in the policy.** Plutonium extracts nested params from the form definition, not the policy. The policy permits just the association name (`:variants`); `nested_input :variants` handles the rest. Adding `{variants_attributes: [...]}` to `permitted_attributes_for_create` renders as a literal text input. See [Behavior › Policy](/reference/behavior/policies).
407
- - **`update_only: true` hides the Add button** — for `has_one` and "settings"-style associations.
408
- - **Custom class names** — use `class_name:` in the model AND `using:` in the definition.
471
+ - **`update_only: true` hides the Add button**: for `has_one` and "settings"-style associations.
472
+ - **Custom class names**: use `class_name:` in the model AND `using:` in the definition.
409
473
 
410
474
  ## Structured inputs
411
475
 
412
476
  Classless inline fieldsets backed by a JSON/jsonb column. No model associations
413
- required — the whole sub-form is serialised into a single column as a hash
477
+ required: the whole sub-form is serialised into a single column as a hash
414
478
  (single form) or an array of hashes (repeater).
415
479
 
416
480
  ![A single structured input (Payload) and a repeater (Rows)](/images/reference/structured-inputs.png)
@@ -449,16 +513,16 @@ end
449
513
  ### Removing rows
450
514
 
451
515
  Each repeater row has a **Remove** button. Removing a row collapses it to a
452
- compact _Removed — Restore_ bar and disables its inputs, so the browser omits
516
+ compact bar (a _Removed_ label and a **Restore** button) and disables its inputs, so the browser omits
453
517
  them from the submission. The server simply rebuilds the JSON column from the
454
- rows it receives — there is no `_destroy` marker. **Restore** brings the row
518
+ rows it receives; there is no `_destroy` marker. **Restore** brings the row
455
519
  back before saving.
456
520
 
457
521
  ![A removed row collapsed to a Restore bar](/images/reference/structured-inputs-removed.png)
458
522
 
459
523
  ### Policy
460
524
 
461
- Permit the column name as a plain symbol — Plutonium handles the nested hash
525
+ Permit the column name as a plain symbol; Plutonium handles the nested hash
462
526
  params automatically:
463
527
 
464
528
  ```ruby
@@ -480,13 +544,13 @@ attribute is declared automatically; `execute` receives the value as a `Hash`
480
544
  The fields are classless render declarations, so there is nothing for Plutonium
481
545
  to attach validations to (unlike [`nested_input`](#nested-inputs), whose nested
482
546
  records run their own model validations). Whatever the form submits is stored
483
- as-is, after blank rows are dropped — **no per-field server-side validation**.
547
+ as-is, after blank rows are dropped: **no per-field server-side validation**.
484
548
  :::
485
549
 
486
550
  Specifically:
487
551
 
488
552
  - **HTML constraints are client-side only.** A field's `required:` and a
489
- select's `choices:` guide the browser but are **not** enforced on the server —
553
+ select's `choices:` guide the browser but are **not** enforced on the server:
490
554
  an API call or a crafted request can submit anything.
491
555
  - **Selects silently drop unknown values.** If a stored value is not among a
492
556
  `as: :select` field's `choices:`, the `<select>` renders **blank**, and saving
@@ -496,7 +560,7 @@ Specifically:
496
560
  `choices:` can drift. Keep `choices:` a stable superset, or use a free-text
497
561
  input, when values can change over time.
498
562
 
499
- To enforce anything, add the validation yourself — it runs server-side:
563
+ To enforce anything, add the validation yourself; it runs server-side:
500
564
 
501
565
  ```ruby
502
566
  # resource: validate the JSON column on the model
@@ -543,7 +607,7 @@ end
543
607
 
544
608
  The block is evaluated once and stored on the class. Re-declaring `form_layout` in a subclass replaces the parent layout as a unit; per-field `input` config inherits normally.
545
609
 
546
- With no `form_layout` declared the form renders unchanged as a single responsive grid — fully backwards-compatible.
610
+ With no `form_layout` declared the form renders unchanged as a single responsive grid, fully backwards-compatible.
547
611
 
548
612
  ### `section(key, *fields, **opts)`
549
613
 
@@ -551,16 +615,16 @@ Groups a set of fields under an optional heading.
551
615
 
552
616
  | Argument | Description |
553
617
  |---|---|
554
- | `key` | Symbol. `:ungrouped` is reserved — use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
618
+ | `key` | Symbol. `:ungrouped` is reserved: use the `ungrouped` macro instead (raises `ArgumentError` otherwise). |
555
619
  | `*fields` | Ordered field keys to place in this section. |
556
620
  | `label:` | Section heading. Defaults to `key.to_s.humanize` (e.g. `:shipping_address` → `"Shipping address"`). |
557
621
  | `description:` | Optional help line rendered below the heading. |
558
622
  | `collapsible:` | Boolean (default `false`). Wraps the section in a native `<details>/<summary>` (no JS). |
559
623
  | `collapsed:` | Boolean (default `false`). Initial collapsed state when `collapsible: true`. |
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.) |
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`). |
624
+ | `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.) |
625
+ | `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`). |
562
626
 
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:
627
+ 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:
564
628
 
565
629
  ```ruby
566
630
  section :advanced, :seo_title, :notes,
@@ -570,29 +634,43 @@ section :advanced, :seo_title, :notes,
570
634
  ```
571
635
 
572
636
  ::: 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`.
637
+ 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
638
 
575
639
  ```ruby
576
640
  - collapsed: -> { object.persisted? }
577
641
  + collapsed: ->(form) { form.object.persisted? }
578
642
  ```
579
643
 
580
- It fails loudly, never silently. `condition:` is unchanged — it is still evaluated against the form and still reads `object` with no argument.
644
+ It fails loudly, never silently. `condition:` is unchanged: it is still evaluated against the form and still reads `object` with no argument.
581
645
  :::
582
646
 
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:
647
+ 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).
648
+
649
+ The same goes for a section whose fields are **all hidden by their own `condition:`** on this render: it disappears with them instead of leaving an empty heading behind. The hidden fields are still recorded on the form, just as a hidden field in a visible section is. So fields that only apply to some records, re-evaluated on `pre_submit`, can be grouped in a section without any extra wiring:
650
+
651
+ ```ruby
652
+ form_layout do
653
+ section :timing, :duration, :starts_on, label: "Timing" # gone when both are hidden
654
+ end
655
+
656
+ input :employment_type, as: :select, pre_submit: true, choices: %w[full_time contract]
657
+ input :duration, condition: -> { object.contract? }
658
+ input :starts_on, condition: -> { object.contract? }
659
+ ```
660
+
661
+ A section's own `condition:` still hides it as a unit, whatever its fields say:
584
662
 
585
663
  ```ruby
586
664
  section :shipping, :address, :city, :postcode,
587
- condition: -> { object.requires_shipping? } # hide the section as a unit
665
+ condition: -> { object.requires_shipping? }
588
666
  ```
589
667
 
590
668
  ### `ungrouped(**opts)`
591
669
 
592
- A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list** — its fields are computed at render time.
670
+ A macro (not a `section` call) that configures the implicit bucket collecting every permitted field not claimed by any `section`. Takes **no field list**; its fields are computed at render time.
593
671
 
594
672
  - Accepts the same options as `section`: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:`, `condition:`.
595
- - **Position** — where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
673
+ - **Position**: where you call `ungrouped` in the block is where leftovers appear. Omit it entirely and leftovers render **last**, after every declared section, with no heading. (Declaring `ungrouped` at the very end is therefore equivalent to omitting it, except that the explicit form lets you add a `label:` and other options.)
596
674
  - Declaring `ungrouped` more than once in a single `form_layout` raises `ArgumentError`.
597
675
 
598
676
  ```ruby
@@ -610,9 +688,9 @@ end
610
688
 
611
689
  ### Layout references keys; config stays on `input`
612
690
 
613
- `form_layout` and `section` carry section-level options only. All per-field rendering config — `as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks — remains on the `input` declaration. Layout never duplicates field config.
691
+ `form_layout` and `section` carry section-level options only. All per-field rendering config (`as:`, the field's own `label:`, `choices:`, per-field `condition:`, `pre_submit:`, blocks) remains on the `input` declaration. Layout never duplicates field config.
614
692
 
615
- This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it — a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
693
+ This includes a field's **column span**. In a section with `columns:`, fields flow into single grid cells by default; a field that declares its own span via `wrapper: {class: "col-span-..."}` keeps it; a field-level span always wins, so you can opt one field back to full width inside a multi-column section:
616
694
 
617
695
  ```ruby
618
696
  input :notes, wrapper: {class: "col-span-full"} # spans the whole row...
@@ -624,7 +702,7 @@ end
624
702
 
625
703
  ```ruby
626
704
  class ArticleDefinition < ResourceDefinition
627
- # per-field config on input — untouched by form_layout
705
+ # per-field config on input: untouched by form_layout
628
706
  input :body, as: :markdown
629
707
  input :published_at, hint: "Leave blank to save as draft"
630
708
  input :visibility, as: :select, choices: %w[public private unlisted]
@@ -638,13 +716,13 @@ end
638
716
 
639
717
  ### Fields not in the permitted set are skipped
640
718
 
641
- A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there — a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting — is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out. And when every field a section lists is dropped this way, the section's chrome is dropped with it (see the zero-fields note above) — so the same layout can serve a richly-permitted `edit` and a minimal `new` without leaving empty headings behind.
719
+ A `section` only renders the fields that are actually in the form's permitted set for the current request. A key it lists that isn't there (a typo, or a field excluded by policy, per-action `permitted_attributes`, entity scoping, or nesting) is **silently dropped**, never an error. This lets a single `form_layout` reference conditionally-permitted fields without crashing the form in the contexts where they're filtered out. And when every field a section lists is dropped this way, the section's chrome is dropped with it (see the zero-fields note above), so the same layout can serve a richly-permitted `edit` and a minimal `new` without leaving empty headings behind.
642
720
 
643
721
  ### On interactions
644
722
 
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.
723
+ `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.
646
724
 
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).
725
+ 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).
648
726
 
649
727
  ```ruby
650
728
  class PublishPostInteraction < Plutonium::Interaction::Base
@@ -664,7 +742,7 @@ end
664
742
 
665
743
  ## Display layout
666
744
 
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.
745
+ 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 and sections whose fields are all condition-hidden dropped entirely, applied to the show page instead of the form.
668
746
 
669
747
  ```ruby
670
748
  class PostDefinition < ResourceDefinition
@@ -678,7 +756,7 @@ class PostDefinition < ResourceDefinition
678
756
  end
679
757
  ```
680
758
 
681
- With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid — fully backwards-compatible.
759
+ With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid, fully backwards-compatible.
682
760
 
683
761
  ### Independent of `form_layout`
684
762
 
@@ -704,9 +782,9 @@ Raising rather than ignoring the option means a `form_layout` block copied acros
704
782
 
705
783
  ### Section options
706
784
 
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.
785
+ `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
786
 
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:
787
+ 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
788
 
711
789
  ```ruby
712
790
  section :audit, :created_at, collapsible: true, collapsed: ->(display) { display.object.active? }
@@ -716,11 +794,11 @@ section :audit, :created_at, collapsible: true, collapsed: ->(display) { display
716
794
 
717
795
  ### Rendering
718
796
 
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).
797
+ 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
798
 
721
799
  ## Page width
722
800
 
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.
801
+ 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
802
 
725
803
  Index and table pages are deliberately **not** affected; a table wants every pixel.
726
804
 
@@ -738,7 +816,7 @@ end
738
816
  `: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
817
 
740
818
  ::: 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":
819
+ 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
820
 
743
821
  | Token | Page width | Centered modal | Slideover |
744
822
  |---|---|---|---|
@@ -768,9 +846,9 @@ All three inherit to subclasses, so a portal-specific definition keeps its paren
768
846
 
769
847
  ### Scope
770
848
 
771
- - **Modals are unaffected** — a dialog sizes itself via `modal_size`.
849
+ - **Modals are unaffected**: a dialog sizes itself via `modal_size`.
772
850
  - **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.
851
+ - **Wizards are configured separately**, via `Plutonium.configuration.wizards.width`. It defaults to `:md` independently of `default_page_width`, so widening resource pages leaves wizard steps where they are. Set both if you want them to match.
774
852
 
775
853
  ## File uploads
776
854
 
@@ -788,9 +866,9 @@ input :documents, as: :uppy,
788
866
 
789
867
  Inside `condition:` procs and block-form `input`/`display`:
790
868
 
791
- - `object` — the record being edited or displayed
869
+ - `object`: the record being edited or displayed
792
870
  - `current_user`
793
- - `current_parent` — parent record for nested resources
871
+ - `current_parent`: parent record for nested resources
794
872
  - `request`, `params`
795
873
  - All view helpers (via the same context as controllers)
796
874
 
@@ -844,23 +922,23 @@ interactive_action_page_breadcrumbs true
844
922
  ```ruby
845
923
  class PostDefinition < ResourceDefinition
846
924
  # "Save and add another" / "Update and continue editing"
847
- # nil (default) — auto: hidden for singular resources, shown for plural
848
- # true — always show
849
- # false — always hide
925
+ # nil (default): auto: hidden for singular resources, shown for plural
926
+ # true: always show
927
+ # false: always hide
850
928
  submit_and_continue false
851
929
 
852
930
  # How :new / :edit and interactive actions render
853
- # :slideover (default) — slide-in panel from the right
854
- # :centered — centered dialog
855
- # false — full standalone pages (no modal)
931
+ # :slideover (default): slide-in panel from the right
932
+ # :centered: centered dialog
933
+ # false: full standalone pages (no modal)
856
934
  # 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,
935
+ # (widths are per-surface: see Page width; a slideover's :md is 480px,
858
936
  # a centered dialog's is 576px, a page's is 896px)
859
937
  modal :centered, size: :lg
860
938
  end
861
939
  ```
862
940
 
863
- `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win — see [Actions](./actions).
941
+ `modal:` is the default for framework `:new`/`:edit` *and* every interactive action on this definition. Per-action `modal:` / `size:` overrides win; see [Actions](./actions).
864
942
 
865
943
  ### `show_in` {#show_in}
866
944
 
@@ -873,8 +951,8 @@ end
873
951
 
874
952
  Controls how the **show page** opens when a record is clicked in the table or grid (and serves as the default for a [kanban board](/reference/kanban/dsl#show_in), which can override it per-board):
875
953
 
876
- - `:page` (default) — full-page navigation to the show route.
877
- - `:modal` — the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`) — show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
954
+ - `:page` (default): full-page navigation to the show route.
955
+ - `:modal`, the show page opens in a **centered** dialog. This is deliberately independent of `modal:`/`modal_mode` above (which styles `:new`/`:edit`), show is always centered, never a slideover. From inside the modal an expand icon opens the full page in a new tab; ⌘/Ctrl-click (or middle-click) on the row/card does the same directly.
878
956
 
879
957
  An unknown mode raises `ArgumentError`.
880
958
 
@@ -894,7 +972,7 @@ Behavior:
894
972
  - **Policy-aware.** Fields intersect with the policy's permitted attributes. The panel auto-hides when nothing is permitted.
895
973
  - **Deduplicated.** Fields listed in `metadata` are removed from the main card so values aren't shown twice.
896
974
  - **Responsive.** Side-by-side at `lg+`, stacked below.
897
- - **Formatting inherits.** Field labels and `as:` declarations propagate — the metadata panel uses the same field-rendering machinery as the main card.
975
+ - **Formatting inherits.** Field labels and `as:` declarations propagate: the metadata panel uses the same field-rendering machinery as the main card.
898
976
 
899
977
  ## Index views (Table & Grid)
900
978
 
@@ -902,7 +980,7 @@ Resources can offer both Table and Grid views. The user switches via the toolbar
902
980
 
903
981
  ```ruby
904
982
  class UserDefinition < ResourceDefinition
905
- # No `index_views :table, :grid` needed — declaring grid_fields auto-enables :grid.
983
+ # No `index_views :table, :grid` needed: declaring grid_fields auto-enables :grid.
906
984
  grid_fields(
907
985
  image: :avatar, # ActiveStorage attachment, Shrine, or URL
908
986
  header: :name, # falls back to to_label
@@ -912,7 +990,7 @@ class UserDefinition < ResourceDefinition
912
990
  footer: :last_seen_at # falls back to :created_at
913
991
  )
914
992
 
915
- default_index_view :grid # optional — initial view when no cookie
993
+ default_index_view :grid # optional: initial view when no cookie
916
994
  grid_layout :media # :compact (default) or :media
917
995
  grid_columns 3 # pin lg+ cols; default is 1/2/3/4 responsive
918
996
  end
@@ -926,13 +1004,13 @@ end
926
1004
  | `grid_layout :compact \| :media` | `:compact` puts image left of content; `:media` stacks image full-width on top. |
927
1005
  | `grid_columns N` | Override responsive column count on `lg+`. Default is 1/2/3/4 at sm/md/lg/xl. |
928
1006
 
929
- Grid slots — `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer` — are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
1007
+ Grid slots, `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`, are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
930
1008
 
931
1009
  Only declare `index_views` explicitly to **disable** one (e.g. `index_views :grid` to drop the table view).
932
1010
 
933
1011
  ## Custom page classes
934
1012
 
935
- Override the rendered page entirely — full control via Phlex:
1013
+ Override the rendered page entirely: full control via Phlex:
936
1014
 
937
1015
  ```ruby
938
1016
  class PostDefinition < ResourceDefinition
@@ -959,8 +1037,8 @@ See [UI › Pages](/reference/ui/pages) and [UI › Forms](/reference/ui/forms)
959
1037
 
960
1038
  ## Related
961
1039
 
962
- - [Query](./query) — search, filters, scopes, sorting
963
- - [Actions](./actions) — custom + bulk actions
964
- - [Behavior › Policy](/reference/behavior/policies) — `permitted_attributes_for_*`, authorization
965
- - [UI › Forms](/reference/ui/forms) — field builder, association inputs, theming
966
- - [UI › Pages](/reference/ui/pages) — custom page classes
1040
+ - [Query](./query): search, filters, scopes, sorting
1041
+ - [Actions](./actions): custom + bulk actions
1042
+ - [Behavior › Policy](/reference/behavior/policies): `permitted_attributes_for_*`, authorization
1043
+ - [UI › Forms](/reference/ui/forms): field builder, association inputs, theming
1044
+ - [UI › Pages](/reference/ui/pages): custom page classes