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,13 +1,13 @@
1
1
  # Testing Reference
2
2
 
3
- `Plutonium::Testing` provides scaffolded integration tests that assert a resource × portal pairing — CRUD, policy matrix, definition smoke tests, model concerns (associated_with, SGID, has_cents), nested-resource scope boundaries, cross-portal access, and interaction outcomes. All optional, all opt-in.
3
+ `Plutonium::Testing` provides scaffolded integration tests that assert a resource × portal pairing: CRUD, policy matrix, definition smoke tests, model concerns (associated_with, SGID, has_cents), nested-resource scope boundaries, cross-portal access, and interaction outcomes. All optional, all opt-in.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **Use the generators.** `pu:test:install` once per app, then `pu:test:scaffold ResourceClass --portals=...` per resource × portal. Hand-written test files drift from conventions.
8
- - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs — it's never autoloaded, never present in production.
8
+ - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs; it's never autoloaded, never present in production.
9
9
  - **One file per (resource × portal).** Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
10
- - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs — your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
10
+ - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs: your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -88,6 +88,11 @@ class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
88
88
  end
89
89
  ```
90
90
 
91
+ **`valid_update_params` is also the assertion.** After the PATCH, the update test runs `assert_equal value, record.reload.public_send(attr)` for every key, so:
92
+
93
+ - Use values that read back identically. Enums go in as strings (`status: "published"`): the enum reader returns a String, so `:published` fails.
94
+ - Keep association SGIDs out of it. The loop only skips values starting with `gid://`, and `to_sgid.to_s` is a signed token, so the token gets compared to the associated record. Test reassigning an association in its own `test` block that PATCHes the SGID and asserts `record.reload.user == other_user`. `valid_create_params` has no such check, so SGIDs are fine there.
95
+
91
96
  ### `Plutonium::Testing::ResourcePolicy`
92
97
 
93
98
  Asserts the `permit?` matrix across action × role and verifies `relation_scope` returns an `ActiveRecord::Relation`.
@@ -133,18 +138,31 @@ Outcome-assertion helpers for `Plutonium::Resource::Interaction` subclasses.
133
138
 
134
139
  - `assert_interaction_success(klass, **input)` → returns the success outcome
135
140
  - `assert_interaction_failure(klass, **input)` → returns the failure outcome
136
- - `interaction_view_context` (overridable) → defaults to a mock view context
141
+ - `interaction_view_context` (overridable) → the view context both helpers pass in (a mock by default); override it only when the interaction reads from the view context
142
+
143
+ Use the helpers for both outcomes; they build the interaction and call it for you.
137
144
 
138
145
  ```ruby
139
- test "RebuildSearchInteraction succeeds" do
140
- outcome = assert_interaction_success(RebuildSearchInteraction, since: 1.day.ago)
141
- assert_equal 42, outcome.value[:rebuilt_count]
146
+ test "PublishProduct moves a draft to active" do
147
+ product = create_product!(status: :draft)
148
+ assert_interaction_success(Catalog::PublishProduct, resource: product)
149
+ assert product.reload.active?
150
+ end
151
+
152
+ test "PublishProduct fails for a product that isn't a draft" do
153
+ product = create_product!(status: :active)
154
+ assert_interaction_failure(Catalog::PublishProduct, resource: product)
155
+ assert product.reload.active? # state unchanged
142
156
  end
143
157
  ```
144
158
 
159
+ The failure outcome carries no validation errors (they live on the interaction instance), so assert the unchanged state rather than building the interaction by hand to read `errors`.
160
+
161
+ `ResourceInteraction` includes neither the DSL nor `AuthHelpers`. A file scaffolded with only `--concerns=interaction` still has the template's `resource_tests_for` and `login_as(@account)`, which raise `NoMethodError`: delete both (the helpers need no portal and no login).
162
+
145
163
  ### `Plutonium::Testing::ResourceModel`
146
164
 
147
- Tests `associated_with` scope, SGID routing, and `has_cents` accessors — gated by DSL flags.
165
+ Tests `associated_with` scope, SGID routing, and `has_cents` accessors, gated by DSL flags.
148
166
 
149
167
  **Stubs:**
150
168
 
@@ -167,13 +185,77 @@ Asserts CRUD under a parent + scope-boundary tests (sibling tenants invisible).
167
185
 
168
186
  **Stubs:**
169
187
 
170
- - `parent_record!` → current tenant
188
+ - `parent_record!` → current tenant (called several times per test, so return the same record each time, e.g. `@org`)
171
189
  - `other_parent_record!` → sibling tenant
172
190
  - `create_resource!(parent:)` → persisted record under given parent
173
191
 
192
+ The concern builds its URLs as `"#{current_path_prefix}/#{parent.id}/#{collection}"`, so it expects the bare portal mount as the prefix and inserts the parent id itself.
193
+
194
+ #### Entity-scoped portals: CRUD + tenant isolation
195
+
196
+ In a portal that calls `scope_to_entity Model, strategy: :path` (e.g. `/org/:id/...`), "records from another tenant aren't reachable" is the `NestedResource` concern with the entity as the parent. The resolved prefix there is the bare mount (`/org`), and the two concerns need different prefixes and different `create_resource!` signatures:
197
+
198
+ | | `ResourceCrud` | `NestedResource` |
199
+ |---|---|---|
200
+ | URL built | `prefix/collection` | `prefix/parent.id/collection` |
201
+ | Prefix needed | `/org/#{@org.to_param}` (override `current_path_prefix`) | `/org` (the resolved default) |
202
+ | Calls | `create_resource!` | `create_resource!(parent:)` |
203
+
204
+ One class can't satisfy both, so split the scaffolded file into two classes:
205
+
206
+ ```bash
207
+ rails g pu:test:scaffold Catalog::Variant --portals=org --concerns=crud,nested --parent=organization
208
+ ```
209
+
210
+ ```ruby
211
+ class OrgPortal::CatalogVariantTest < ActionDispatch::IntegrationTest
212
+ include IntegrationTestHelper
213
+ include Plutonium::Testing::ResourceCrud
214
+
215
+ resource_tests_for Catalog::Variant, portal: :org
216
+
217
+ setup do
218
+ @org = create_organization!
219
+ @user = create_user!
220
+ create_membership!(organization: @org, user: @user)
221
+ @product = create_product!(user: @user, organization: @org)
222
+ login_as(@user) # :org logs in through /users/login
223
+ end
224
+
225
+ def current_path_prefix = "/org/#{@org.to_param}"
226
+ def create_resource! = create_variant!(product: @product)
227
+ def valid_create_params = {name: "Red", sku: "RED-1", stock_count: 5, product: @product.to_sgid.to_s}
228
+ def valid_update_params = {name: "Red / Large"}
229
+ end
230
+
231
+ class OrgPortal::CatalogVariantNestedTest < ActionDispatch::IntegrationTest
232
+ include IntegrationTestHelper
233
+ include Plutonium::Testing::NestedResource
234
+
235
+ resource_tests_for Catalog::Variant, portal: :org, parent: :organization
236
+
237
+ setup do
238
+ @org = create_organization!
239
+ @other_org = create_organization!
240
+ @user = create_user!
241
+ create_membership!(organization: @org, user: @user)
242
+ login_as(@user)
243
+ end
244
+
245
+ def parent_record! = @org
246
+ def other_parent_record! = @other_org
247
+
248
+ def create_resource!(parent:)
249
+ create_variant!(product: create_product!(organization: parent))
250
+ end
251
+ end
252
+ ```
253
+
254
+ `create_resource!(parent:)` must create under `parent`, not always under `@org`: the isolation test passes `other_parent_record!` and expects a 404 (or redirect) for that record.
255
+
174
256
  ### `Plutonium::Testing::PortalAccess`
175
257
 
176
- Cross-portal access boundaries. Uses its own DSL — NOT `resource_tests_for`.
258
+ Cross-portal access boundaries. Uses its own DSL (NOT `resource_tests_for`).
177
259
 
178
260
  ```ruby
179
261
  class PortalAccessTest < ActionDispatch::IntegrationTest
@@ -210,7 +292,16 @@ Generates one test per (role × portal). Allowed = `200 | 302`; blocked = `302 |
210
292
 
211
293
  ## Auth helpers
212
294
 
213
- `Plutonium::Testing::AuthHelpers` is included transitively by every concern.
295
+ `login_as` and friends come from `Plutonium::Testing::AuthHelpers`, which only some concerns pull in. The default portal comes from the DSL (`resource_tests_for`), which is a separate include:
296
+
297
+ | Concern | `login_as` available | Bare `login_as(account)` works |
298
+ |---|---|---|
299
+ | `ResourceCrud`, `NestedResource` | yes | yes (portal from `resource_tests_for`) |
300
+ | `PortalAccess` | yes | no: pass `portal:` every time |
301
+ | `ResourcePolicy`, `ResourceDefinition`, `ResourceModel` | no | no |
302
+ | `ResourceInteraction` | no | no |
303
+
304
+ A hand-written integration test that only includes the app's own helpers (e.g. an `IntegrationTestHelper`) has no `login_as` at all. Add `include Plutonium::Testing::AuthHelpers` (and `require "plutonium/testing"` if `test_helper.rb` doesn't) and pass `portal:` explicitly. Likewise, delete the scaffold's `login_as(@account)` from a file whose concerns don't provide it.
214
305
 
215
306
  ```ruby
216
307
  login_as(account) # uses portal from the DSL
@@ -255,16 +346,18 @@ rails g pu:test:scaffold Blogging::Post --portals=org --parent=organization --de
255
346
  |---|---|---|
256
347
  | `--portals=admin,org` | required | Emit one file per portal |
257
348
  | `--concerns=...` | `crud,policy,definition` | Concerns to include (`crud`, `policy`, `definition`, `nested`, `model`, `interaction`, `portal_access`) |
258
- | `--parent=organization` | | Wires `NestedResource` parent |
349
+ | `--parent=organization` | | Adds `parent:` to `resource_tests_for` and, only together with `nested` in `--concerns`, the `parent_record!`/`other_parent_record!` stubs |
259
350
  | `--dest=main_app\|<package>` | `main_app` | Output destination |
260
351
 
261
352
  Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
262
353
 
354
+ `--parent` alone does not add the `NestedResource` include; pass `--concerns=crud,nested --parent=organization`. The template is one class with every requested include, a `setup` that calls `login_as(@account)`, and a no-argument `create_resource!`. Treat it as a starting point: drop `login_as`/`resource_tests_for` where the concerns don't provide them (see [Auth helpers](#auth-helpers)), and split `crud` and `nested` into two classes for [entity-scoped portals](#entity-scoped-portals-crud-tenant-isolation).
355
+
263
356
  ## Customization & escape hatches
264
357
 
265
358
  - **Skip individual tests:** `resource_tests_for Klass, portal: :admin, skip: %i[destroy]`
266
359
  - **Restrict action set:** `resource_tests_for Klass, portal: :admin, actions: %i[index show]`
267
- - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix — they coexist.
360
+ - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix; they coexist.
268
361
  - **Non-Rodauth auth:** override `sign_in_for_tests`. See [AuthHelpers](#auth-helpers).
269
362
  - **Custom path prefix:** `path_prefix: "/v2/admin"` overrides portal resolution.
270
363
 
@@ -273,15 +366,16 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
273
366
  - **Forgotten stubs raise `NotImplementedError`** with the stub name. Look for the missing method in your test class.
274
367
  - **Portal mismatch:** `:admin` portal expects `AdminPortal::Engine` constant. If your portal is named differently, pass `path_prefix:` explicitly.
275
368
  - **Tenant leakage in stubs:** `create_resource!` for an org portal must return a record bound to the test's `@org`. Otherwise scope filtering tests pass for the wrong reason.
276
- - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to — otherwise even allowed roles will see `false`.
277
- - **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
278
- - **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
369
+ - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to; otherwise even allowed roles will see `false`.
370
+ - **Nested paths come from `parent_record!.id`**, so it must return a real, persisted tenant the logged-in account belongs to. `parent: :foo` in the DSL documents the relationship; the concern doesn't read it.
371
+ - **Entity-scoped CRUD hitting `/org/<collection>`** (404 or routing error): a `:path` portal resolves to the bare mount, so override `current_path_prefix` to include the tenant. Don't do this in a `NestedResource` class, which adds the id itself.
372
+ - **`PortalAccess` doesn't use `resource_tests_for`**: use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
279
373
 
280
374
  ## Related
281
375
 
282
- - [Behavior › Policy](/reference/behavior/policies) — the policy methods `ResourcePolicy` verifies
283
- - [Behavior › Interaction](/reference/behavior/interactions) — interaction outcomes asserted by `ResourceInteraction`
284
- - [Resource › Definition](/reference/resource/definition) — definition props the smoke test introspects
285
- - [Tenancy](/reference/tenancy/) — parent scoping (`NestedResource`), entity strategies (drive auth/scoping)
286
- - [Auth](/reference/auth/) — Rodauth setup behind the default `sign_in_for_tests`
287
- - [Guides › Testing](/guides/testing) — task-oriented walkthrough
376
+ - [Behavior › Policy](/reference/behavior/policies): the policy methods `ResourcePolicy` verifies
377
+ - [Behavior › Interaction](/reference/behavior/interactions): interaction outcomes asserted by `ResourceInteraction`
378
+ - [Resource › Definition](/reference/resource/definition): definition props the smoke test introspects
379
+ - [Tenancy](/reference/tenancy/): parent scoping (`NestedResource`), entity strategies (drive auth/scoping)
380
+ - [Auth](/reference/auth/): Rodauth setup behind the default `sign_in_for_tests`
381
+ - [Guides › Testing](/guides/testing): task-oriented walkthrough
@@ -4,11 +4,12 @@ TailwindCSS 4 + Stimulus toolchain. CSS design tokens for theming, `.pu-*` compo
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Always register Stimulus controllers** — `registerControllers(application)` is required. Without it, Plutonium's controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
8
- - **Use `plutoniumTailwindConfig.merge`** when overriding the theme — plain object spread drops Plutonium's defaults.
9
- - **Tokens are CSS variables**, not Tailwind keys — `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
10
- - **Dark mode uses `selector`** strategy — toggle `dark` on `<html>`. The bundled `color-mode` controller does this.
11
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
7
+ - **Custom CSS, brand colors, or your own Stimulus controllers need `pu:core:assets` first.** Out of the box the app serves the gem's prebuilt `plutonium.css` / `plutonium.min.js`; the generator switches it to your own bundles. Don't hand-write the Tailwind/PostCSS pipeline.
8
+ - **Once the app owns its JS bundle, `registerControllers(application)`** must be in `app/javascript/controllers/index.js` (`pu:core:assets` adds it). Your bundle replaces the gem's, so without it Plutonium's controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
9
+ - **Use `plutoniumTailwindConfig.merge`** when overriding the theme, plain object spread drops Plutonium's defaults.
10
+ - **Tokens are CSS variables**, not Tailwind keys, `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
11
+ - **Dark mode uses `selector`** strategy, toggle `dark` on `<html>`. The bundled `color-mode` controller does this.
12
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule. See [Component classes](#component-classes-pu).
12
13
 
13
14
  ## Asset configuration
14
15
 
@@ -30,13 +31,27 @@ end
30
31
  rails generate pu:core:assets
31
32
  ```
32
33
 
33
- This:
34
+ Until this runs, the app serves the gem's prebuilt assets (`config.assets.stylesheet` defaults to `plutonium.css`, `script` to `plutonium.min.js`). Those are compiled from the gem's own sources, so app-side Tailwind classes, a new `primary` palette, token overrides and custom Stimulus controllers have nowhere to go. The generator:
34
35
 
35
- 1. Installs npm packages (`@radioactive-labs/plutonium`, TailwindCSS plugins).
36
- 2. Creates `tailwind.config.js` extending Plutonium's config.
37
- 3. Imports Plutonium CSS into `application.tailwind.css`.
38
- 4. Registers Plutonium's Stimulus controllers.
39
- 5. Updates Plutonium config to point at your asset files.
36
+ 1. Installs `@radioactive-labs/plutonium` (pinned to the gem version), Tailwind 4 and the PostCSS plugins.
37
+ 2. Writes `tailwind.config.js` (through `plutoniumTailwindConfig.merge`) and `postcss.config.js`.
38
+ 3. Prepends `@import "gem:plutonium/src/css/plutonium.css";` to `application.tailwind.css` and adds `@config` after `@import "tailwindcss";`.
39
+ 4. Appends `registerControllers(application)` to `app/javascript/controllers/index.js`.
40
+ 5. Sets `config.assets.stylesheet = "application"` and `config.assets.script = "application"`, and writes the `build` / `build:css` scripts in `package.json`.
41
+
42
+ Step 5 is why `registerControllers` is not optional: the gem's `plutonium.min.js` calls it itself, and your `application.js` replaces that bundle.
43
+
44
+ ### Prerequisites
45
+
46
+ The generator aborts unless `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` exist, i.e. an app created with `-j esbuild -c tailwind` plus Stimulus. For an app without them, install the bundlers first (`bin/rails javascript:install:esbuild`, `css:install:tailwind`, `stimulus:install` from jsbundling-rails, cssbundling-rails and stimulus-rails), then run the generator. Don't hand-write `tailwind.config.js` / `postcss.config.js` instead: the generated ones resolve the gem path (`bundle show plutonium`) and load its `postcss-gem-import.cjs` so the `gem:` import works.
47
+
48
+ ### Package managers
49
+
50
+ The generator installs with the package manager `rails javascript:build` will use. The lockfile decides: `bun.lock` or `bun.lockb` means bun, `pnpm-lock.yaml` pnpm, `package-lock.json` npm, `yarn.lock` yarn. With no lockfile, `bun.config.js` means bun, and otherwise the first of bun, yarn, pnpm or npm found on PATH wins (yarn if none is). That is why `rails new -j esbuild` on a machine with bun installed produces a bun app. When the app's jsbundling-rails provides `Jsbundling::PackageManager` (its `main` branch, not yet in a release as of 1.3.1), the generator defers to it instead; that detector ignores `yarn.lock` and picks by PATH. Every package goes through one `add` with versions inline (`tailwindcss@latest`, `@radioactive-labs/plutonium@^<gem version>`), which yarn 1, yarn 2+, bun, npm and pnpm all understand.
51
+
52
+ Yarn 2+ apps get `nodeLinker: node-modules` written to `.yarnrc.yml` if no linker is set. Tailwind's PostCSS plugin does not load under Plug'n'Play.
53
+
54
+ `pu:core:update` and `pu:docker:install` use the same detection, so the Dockerfile installs bun, yarn 1, or yarn 2+ via corepack to match the app.
40
55
 
41
56
  ## Tailwind config
42
57
 
@@ -78,6 +93,8 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
78
93
  })
79
94
  ```
80
95
 
96
+ These are Tailwind palette colors, compiled into the CSS at build time (`.pu-btn-primary` is `@apply bg-primary-600 ...`; `--pu-input-focus-ring` is `theme(colors.primary.500)`). Recoloring `primary` therefore means the `merge` above plus a rebuild, not a `--pu-*` override.
97
+
81
98
  ### Default color palette
82
99
 
83
100
  | Color | Usage |
@@ -120,15 +137,15 @@ application.register("custom", CustomController)
120
137
 
121
138
  ### Bundled controllers
122
139
 
123
- - `color-mode` — dark/light mode toggle
124
- - `form` — form handling (pre-submit, etc.)
125
- - `nested-resource-form-fields` — nested form management
126
- - `slim-select` — enhanced select boxes
127
- - `flatpickr` — date/time pickers
128
- - `easymde` — markdown editor
140
+ - `color-mode`: dark/light mode toggle
141
+ - `form`: form handling (pre-submit, etc.)
142
+ - `nested-resource-form-fields`: nested form management
143
+ - `slim-select`: enhanced select boxes
144
+ - `flatpickr`: date/time pickers
145
+ - `easymde`: markdown editor
129
146
  - Various internal UI controllers
130
147
 
131
- ### Custom Stimulus controller — standard pattern
148
+ ### Custom Stimulus controller: standard pattern
132
149
 
133
150
  ```javascript
134
151
  // app/javascript/controllers/custom_controller.js
@@ -241,9 +258,13 @@ Plutonium uses a comprehensive CSS custom-property system for consistent, themea
241
258
  ```
242
259
 
243
260
  ::: 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).
261
+ 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).
262
+
263
+ That includes the shadows: `src/css/tokens.css` redefines `--pu-shadow-sm/md/lg` (and every surface, text, border, table, input, card and chart token) under `.dark`, so a tinted light shadow left out of your `.dark` block replaces the dark one.
245
264
  :::
246
265
 
266
+ Put dark values in a `.dark { ... }` block, not `@media (prefers-color-scheme: dark)`. Dark mode is the `dark` class on `<html>` (set by the `color-mode` controller), so a media query ignores the user's toggle. Overrides need the app's own stylesheet after the Plutonium import (`pu:core:assets`); never edit the gem's `tokens.css` / `components.css`.
267
+
247
268
  ### Using tokens in templates
248
269
 
249
270
  ```erb
@@ -271,7 +292,11 @@ end
271
292
 
272
293
  ## Component classes (`.pu-*`)
273
294
 
274
- Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs** — they auto-switch with dark mode.
295
+ Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` (or `warning-50 dark:warning-950`) pairs.** In order of preference:
296
+
297
+ 1. **A `.pu-*` class** (`pu-alert-warning`, `pu-badge-warning`, `pu-card`, `pu-btn-soft-danger`). Each ships with its own `.dark` rule and is always in the CSS, because `components.css` is part of `plutonium.css` whether the app uses the prebuilt file or imports it.
298
+ 2. **A `var(--pu-*)` token** (`text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`) for layout around them. The token switches value under `.dark` and follows any theme override.
299
+ 3. **Raw palette utilities** only for what neither covers. On the prebuilt `plutonium.css` they exist only if the gem's own sources happen to use them (its Tailwind `content` scans the gem, not your app); with your own build they compile, but each needs a hand-picked `dark:` twin that won't follow a rebrand.
275
300
 
276
301
  ### Buttons
277
302
 
@@ -310,6 +335,21 @@ Ready-to-use styled components in `src/css/components.css`. **Prefer these over
310
335
 
311
336
  Rendered automatically by the `:badge` display (enums) and `:boolean` display (Yes/No pills). See [Displays](./displays#built-in-display-components).
312
337
 
338
+ ### Alerts (inline banners)
339
+
340
+ ```
341
+ .pu-alert / -success / -warning / -danger / -info
342
+ .pu-alert-message / .pu-alert-close
343
+ ```
344
+
345
+ ```ruby
346
+ div(class: "pu-alert pu-alert-warning", role: "alert") do
347
+ div(class: "pu-alert-message") { t("blog.posts.flagged_comments", count: flagged) }
348
+ end
349
+ ```
350
+
351
+ The same banner the flash messages use (`app/views/plutonium/_flash_alerts.html.erb`), so it already has its dark-mode colors.
352
+
313
353
  ### Cards, panels, tables, toolbars, empty states
314
354
 
315
355
  ```
@@ -400,12 +440,12 @@ end
400
440
  **Theme keys:** `wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
401
441
 
402
442
  ::: warning Always `super.merge(...)`
403
- Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode — `super.merge` keeps them.
443
+ Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode, `super.merge` keeps them.
404
444
  :::
405
445
 
406
446
  ## Gotchas
407
447
 
408
- - **Stimulus controllers register silently fails.** If `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error — just no behavior.
448
+ - **Stimulus controllers register silently fails.** Once `config.assets.script` points at the app's JS, if `registerControllers(application)` isn't called the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior.
409
449
  - **`plutoniumTailwindConfig.merge` is mandatory.** Plain spread drops defaults silently.
410
450
  - **Tokens are CSS variables, not Tailwind keys.** Use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
411
451
  - **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
@@ -413,6 +453,6 @@ Don't replace the theme wholesale. Plutonium's defaults handle invalid states, f
413
453
 
414
454
  ## Related
415
455
 
416
- - [Forms › Theming](./forms#theming) — Form theme keys + override pattern
417
- - [Components](./components) — `tokens` and `classes` helpers for conditional class composition
418
- - [Layouts](./layouts) — fonts, dark-mode toggle, body attributes
456
+ - [Forms › Theming](./forms#theming): Form theme keys + override pattern
457
+ - [Components](./components): `tokens` and `classes` helpers for conditional class composition
458
+ - [Layouts](./layouts): fonts, dark-mode toggle, body attributes
@@ -22,13 +22,13 @@ TablePagination(pagy)
22
22
  Breadcrumbs()
23
23
  ```
24
24
 
25
- These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc. — they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
25
+ These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc.; they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
26
26
 
27
27
  ## Avatar
28
28
 
29
29
  `Plutonium::UI::Avatar` renders a profile image for a subject. It resolves an optional image source and falls back to a deterministic avatar from the hosted [Navii](https://navii.dev) service, then to a generic user icon when there's nothing to show.
30
30
 
31
- ![Avatar — Navii fallback across sizes, deterministic faces for string subjects, explicit image src, and the icon fallback](/images/components/avatar.png)
31
+ ![Avatar, Navii fallback across sizes, deterministic faces for string subjects, explicit image src, and the icon fallback](/images/components/avatar.png)
32
32
 
33
33
  ```ruby
34
34
  Avatar(user) # Navii fallback seeded from the record
@@ -45,7 +45,7 @@ Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
45
45
  | `src:` | `nil` | The image. A **Symbol** names a method on the subject (`:avatar` → `subject.avatar`); otherwise an ActiveStorage attachment, [active_shrine](https://github.com/radioactive-labs/active_shrine)/Shrine uploader, or URL string. |
46
46
  | `size:` | `:md` | Semantic `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer (px). |
47
47
  | `alt:` | derived | Defaults to the String subject, or the record's display name. |
48
- | `class:` | — | Merged over the default `rounded-full` classes. |
48
+ | `class:` | - | Merged over the default `rounded-full` classes. |
49
49
 
50
50
  ### How the source resolves
51
51
 
@@ -58,7 +58,7 @@ Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
58
58
  When `src` is absent or unattached, a Navii avatar is rendered from the subject; with no subject either, a generic user icon is shown.
59
59
 
60
60
  ::: tip Symbol `src` is a contract
61
- `Avatar(user, src: :avatar)` calls `user.avatar` — the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
61
+ `Avatar(user, src: :avatar)` calls `user.avatar`, the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
62
62
  :::
63
63
 
64
64
  ### Privacy
@@ -94,7 +94,7 @@ class PostCardComponent < Plutonium::UI::Component::Base
94
94
  span(class: "text-sm text-[var(--pu-text-subtle)]") {
95
95
  @post.published_at&.strftime("%B %d, %Y")
96
96
  }
97
- a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
97
+ a(href: resource_url_for(@post), class: "text-primary-600") { t("blog.posts.card.read_more") }
98
98
  end
99
99
  end
100
100
  end
@@ -104,18 +104,18 @@ end
104
104
  ::: tip Inherit `Plutonium::UI::Component::Base`
105
105
  It gives you:
106
106
  - The component kit (`PageHeader`, `Panel`, `Block`, …)
107
- - Resource helpers (`resource_url_for`, `current_user`, `current_record!`, `current_definition`)
107
+ - Resource helpers (`resource_url_for`, `current_user`, `resource_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
110
 
111
- A **field** component (one you pass to `as:`) inherits its Phlexi base instead —
111
+ A **field** component (one you pass to `as:`) inherits its Phlexi base instead:
112
112
  `include Plutonium::UI::Component::Behaviour` there to get the same helpers.
113
113
  :::
114
114
 
115
115
  ### Use in a definition
116
116
 
117
117
  A component like `PostCardComponent` above has its own constructor, so it reaches
118
- a field through the **block form** — you build it yourself:
118
+ a field through the **block form**, you build it yourself:
119
119
 
120
120
  ```ruby
121
121
  class PostDefinition < ResourceDefinition
@@ -129,13 +129,13 @@ class PostDefinition < ResourceDefinition
129
129
  end
130
130
  ```
131
131
 
132
- `as: SomeComponent` is the other route, and it expects a **field component** —
132
+ `as: SomeComponent` is the other route, and it expects a **field component**.
133
133
  Plutonium instantiates it with the field builder, not with your keyword
134
134
  arguments. See [field components](#field-components) below.
135
135
 
136
136
  ::: warning `as:` does not take a keyword-argument component
137
137
  `display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
138
- arguments` — the component is constructed as `PostCardComponent.new(field,
138
+ arguments`; the component is constructed as `PostCardComponent.new(field,
139
139
  **attributes)`. Use the block form for those.
140
140
  :::
141
141
 
@@ -143,7 +143,7 @@ arguments` — the component is constructed as `PostCardComponent.new(field,
143
143
 
144
144
  A field component subclasses the Phlexi component base for its surface and reads
145
145
  everything off `field` (`field.value`, `field.object`, `field.dom`, plus
146
- `attributes` — the themed id/name/class Plutonium already computed):
146
+ `attributes`, the themed id/name/class Plutonium already computed):
147
147
 
148
148
  ```ruby
149
149
  # app/components/color_picker_component.rb
@@ -172,7 +172,7 @@ class PostDefinition < ResourceDefinition
172
172
  end
173
173
  ```
174
174
 
175
- An `as:` component works in every surface that renders the field — form, show
175
+ An `as:` component works in every surface that renders the field: form, show
176
176
  page, index column, filter panel and wizard summary alike.
177
177
 
178
178
  ### Use in a page / form / display
@@ -187,7 +187,7 @@ end
187
187
 
188
188
  ## `DynaFrameContent` pattern
189
189
 
190
- Enables frame-aware rendering — regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
190
+ Enables frame-aware rendering, regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
191
191
 
192
192
  ```ruby
193
193
  def view_template(&block)
@@ -205,7 +205,7 @@ All pages inherit this automatically. Modals and frame navigation work without s
205
205
 
206
206
  Rarely. Use it when writing a custom non-resource page that needs the same frame-aware rendering as the built-in pages.
207
207
 
208
- For typical custom pages, just inherit `Plutonium::UI::Page::Base` and override hooks like `render_content` — the DynaFrame wrapping happens in `view_template` automatically.
208
+ For typical custom pages, just inherit `Plutonium::UI::Page::Base` and override hooks like `render_content`, the DynaFrame wrapping happens in `view_template` automatically.
209
209
 
210
210
  ## Conditional class helpers
211
211
 
@@ -257,15 +257,15 @@ class MyComponent < Plutonium::UI::Component::Base
257
257
  end
258
258
  ```
259
259
 
260
- The `helpers` proxy gives you everything `ApplicationController#helpers` exposes — including any custom helpers in `app/helpers/`.
260
+ The `helpers` proxy gives you everything `ApplicationController#helpers` exposes, including any custom helpers in `app/helpers/`.
261
261
 
262
262
  ## Available context
263
263
 
264
- Inside any custom component, the same set of helpers as pages/forms/displays — see [Pages › Available context](./pages#available-context).
264
+ Inside any custom component, the same set of helpers as pages/forms/displays, see [Pages › Available context](./pages#available-context).
265
265
 
266
266
  ## Related
267
267
 
268
- - [Pages](./pages) — `render_*` hooks call your components
269
- - [Forms](./forms) — the built-in input tags and their `as:` aliases
270
- - [Displays](./displays) — using custom display components
271
- - [Assets](./assets) — design tokens (`var(--pu-*)`) and `.pu-*` component classes
268
+ - [Pages](./pages): `render_*` hooks call your components
269
+ - [Forms](./forms): the built-in input tags and their `as:` aliases
270
+ - [Displays](./displays): using custom display components
271
+ - [Assets](./assets): design tokens (`var(--pu-*)`) and `.pu-*` component classes
@@ -14,7 +14,7 @@ class PostDefinition < ResourceDefinition
14
14
  end
15
15
 
16
16
  # `fields_wrapper` is ALREADY a card (it renders a Block internally),
17
- # so do not wrap it in another one — that stacks two cards and doubles
17
+ # so do not wrap it in another one, that stacks two cards and doubles
18
18
  # the border and shadow.
19
19
  fields_wrapper do
20
20
  render_resource_field :author
@@ -38,7 +38,7 @@ end
38
38
  |---|---|
39
39
  | `render_fields` | All permitted fields |
40
40
  | `render_resource_field(name)` | One field |
41
- | `render_associations` | Association tabs (driven by `permitted_associations` — see [Behavior › Policy](/reference/behavior/policies#association-permissions)) |
41
+ | `render_associations` | Association tabs (driven by `permitted_associations`, see [Behavior › Policy](/reference/behavior/policies#association-permissions)) |
42
42
  | `object` | The record |
43
43
  | `resource_fields`, `resource_associations` | Permitted lists |
44
44
 
@@ -48,21 +48,18 @@ For per-field custom rendering, prefer declaring it in the **definition** rather
48
48
 
49
49
  ```ruby
50
50
  class PostDefinition < ResourceDefinition
51
- # Block — returns any Phlex component
51
+ # Block, returns any Phlex component
52
52
  display :status do |field|
53
53
  StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
54
54
  end
55
55
 
56
- # phlexi_render — proc whose body is rendered inside a Phlex context
57
- display :priority, as: :phlexi_render, with: ->(value, attrs) {
58
- case value
59
- when 'high' then span(class: "badge badge-danger") { "High" }
60
- when 'medium' then span(class: "badge badge-warning") { "Medium" }
61
- else span(class: "badge badge-info") { "Low" }
62
- end
63
- }
56
+ # Block that emits markup directly (evaluated in the display's Phlex context)
57
+ display :priority do |field|
58
+ variant = {"high" => "danger", "medium" => "warning"}.fetch(field.value.to_s, "info")
59
+ span(class: "pu-badge pu-badge-#{variant}") { field.value.to_s.humanize }
60
+ end
64
61
 
65
- # Field component class — built as ChartComponent.new(field, **attributes),
62
+ # Field component class, built as ChartComponent.new(field, **attributes),
66
63
  # so it subclasses Phlexi::Display::Components::Base and reads `field`.
67
64
  # (A component with its own constructor uses the block form above.)
68
65
  display :chart, as: ChartComponent
@@ -73,14 +70,14 @@ See [Resource › Definition › Custom rendering](/reference/resource/definitio
73
70
 
74
71
  ## Built-in display components
75
72
 
76
- Some types render with richer components automatically — you only declare an `as:` to override or pass options.
73
+ Some types render with richer components automatically; you only declare an `as:` to override or pass options.
77
74
 
78
75
  | `as:` | Renders | Auto-inferred for | Options |
79
76
  |-------|---------|-------------------|---------|
80
77
  | `:boolean` | green "Yes" / neutral "No" pill | `boolean` columns | `true_label:`, `false_label:` |
81
78
  | `:badge` | colored status pill | `enum` columns | `colors:` (per-value override) |
82
79
  | `:currency` | delimited, 2-decimal money | `has_cents` decimal accessors | `unit:`, `options:` |
83
- | `:color` | swatch + value | — | — |
80
+ | `:color` | swatch + value | - | - |
84
81
 
85
82
  ```ruby
86
83
  class OrderDefinition < ResourceDefinition
@@ -93,7 +90,7 @@ end
93
90
 
94
91
  **Badge colors.** Known statuses (`active`, `pending`, `failed`, …) are auto-colored by meaning. Unknown values get a stable decorative color (same value → same color). Override per-value with `colors:`; valid variants: `:neutral`, `:primary`, `:secondary`, `:success`, `:danger`, `:warning`, `:info`, `:accent`.
95
92
 
96
- **Currency.** No symbol is shown unless you pass `unit:` — a literal string (`"£"`) or a Symbol read off the record for per-row currencies. `has_cents` decimal accessors infer `:currency` automatically (still symbol-less until you set `unit:`).
93
+ **Currency.** No symbol is shown unless you pass `unit:`, a literal string (`"£"`) or a Symbol read off the record for per-row currencies. `has_cents` decimal accessors infer `:currency` automatically (still symbol-less until you set `unit:`).
97
94
 
98
95
  ## Theming
99
96
 
@@ -121,20 +118,20 @@ end
121
118
  `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`, plus the shared section-chrome keys (`section_wrapper`, `section_header`, `section_summary`, `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`).
122
119
 
123
120
  ::: warning `fields_wrapper` is the card, `fields_inner` is the grid
124
- `fields_wrapper` is merged into a `Plutonium::UI::Block`, which supplies `pu-card` itself — grid classes put there style the card, not the fields. Override **`fields_inner`** for the unsectioned grid, and **`section_grid`** for the grid inside a [`display_layout`](/reference/resource/definition#display-layout) section.
121
+ `fields_wrapper` is merged into a `Plutonium::UI::Block`, which supplies `pu-card` itself, grid classes put there style the card, not the fields. Override **`fields_inner`** for the unsectioned grid, and **`section_grid`** for the grid inside a [`display_layout`](/reference/resource/definition#display-layout) section.
125
122
  :::
126
123
 
127
124
  Section chrome comes from `Plutonium::UI::Component::Section::DEFAULT_THEME`, merged into **both** `Form::Theme` and `Display::Theme`, so form sections and show-page sections read identically by default while staying independently overridable.
128
125
 
129
- (`boolean` and `badge` apply their pill variant in the component, so their theme value stays empty — restyle the pills via the `.pu-badge*` classes instead.)
126
+ (`boolean` and `badge` apply their pill variant in the component, so their theme value stays empty; restyle the pills via the `.pu-badge*` classes instead.)
130
127
 
131
128
  ## Metadata panel
132
129
 
133
- A right-side aside on the show page. Configured at the definition level, not the Display class — see [Resource › Definition › Metadata panel](/reference/resource/definition#metadata-panel-show-page).
130
+ A right-side aside on the show page. Configured at the definition level, not the Display class, see [Resource › Definition › Metadata panel](/reference/resource/definition#metadata-panel-show-page).
134
131
 
135
132
  ## Related
136
133
 
137
- - [Pages](./pages) — `ShowPage` render hooks (often a lighter alternative to overriding `Display`)
138
- - [Components](./components) — building reusable Phlex display components
139
- - [Resource › Definition](/reference/resource/definition) — field-level display configuration (`as:`, `condition:`, blocks)
140
- - [Behavior › Policy](/reference/behavior/policies) — `permitted_associations` drives the show-page tablist
134
+ - [Pages](./pages): `ShowPage` render hooks (often a lighter alternative to overriding `Display`)
135
+ - [Components](./components): building reusable Phlex display components
136
+ - [Resource › Definition](/reference/resource/definition): field-level display configuration (`as:`, `condition:`, blocks)
137
+ - [Behavior › Policy](/reference/behavior/policies): `permitted_associations` drives the show-page tablist