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
@@ -112,39 +112,39 @@ class Blogging::PostDefinition < Blogging::ResourceDefinition
112
112
  index_page_title "Blog Posts"
113
113
  index_page_description "Manage your blog content"
114
114
 
115
- show_page_title { |record| record.title }
116
115
  show_page_description "View post details"
117
116
  end
118
117
  ```
119
118
 
119
+ These setters take a literal, or the definition's lazy `t("some.key")` to translate per request. They have no record context, so a title built from the record goes in a `page_title` override on the page class (below).
120
+
120
121
  The default "Posts" heading becomes your branded title and description:
121
122
 
122
123
  ![Customized index page title](/images/tutorial/08-customized-index.png)
123
124
 
124
- For more advanced customization, you can create custom page classes that inherit from Plutonium's page components:
125
+ For more advanced customization, override the page class nested in the definition:
125
126
 
126
127
  ```ruby
127
- # packages/admin_portal/app/views/admin_portal/blogging/posts/index_page.rb
128
- class AdminPortal::Blogging::Posts::IndexPage < Blogging::PostDefinition::IndexPage
129
- private
130
-
131
- def page_title
132
- "Blog Posts"
133
- end
128
+ class Blogging::PostDefinition < Blogging::ResourceDefinition
129
+ class ShowPage < ShowPage
130
+ private
134
131
 
135
- def page_description
136
- "Manage your blog content"
137
- end
132
+ def page_title
133
+ object.title
134
+ end
138
135
 
139
- # Add content after the page header
140
- def render_after_page_header
141
- div(class: "mb-4 p-4 bg-blue-50 rounded") do
142
- p { "Custom content here" }
136
+ # Add content after the page header
137
+ def render_after_page_header
138
+ div(class: "pu-alert pu-alert-info", role: "status") do
139
+ div(class: "pu-alert-message") { t("blogging.posts.show.comment_count", count: object.comments.count) }
140
+ end
143
141
  end
144
142
  end
145
143
  end
146
144
  ```
147
145
 
146
+ `pu-alert pu-alert-<success|warning|danger|info>` is the banner the flash messages use, with dark-mode colors built in. The text comes from a locale key (see [i18n](/reference/i18n#your-own-components-and-pages)).
147
+
148
148
  ## Custom Form Layout
149
149
 
150
150
  Control form layout using wrapper options in definitions:
@@ -171,20 +171,35 @@ end
171
171
 
172
172
  ## Theming with TailwindCSS
173
173
 
174
- Plutonium uses TailwindCSS 4. Customize the theme:
174
+ Plutonium uses TailwindCSS 4. Out of the box the app serves the gem's prebuilt CSS, so first switch it to your own build:
175
175
 
176
- ```css
177
- /* app/assets/stylesheets/application.css */
178
- @import "tailwindcss";
179
- @import "gem:plutonium/src/css/plutonium.css";
176
+ ```bash
177
+ rails generate pu:core:assets
178
+ ```
179
+
180
+ Brand colors are Tailwind palette colors compiled into the CSS, so change them in the generated `tailwind.config.js` through `plutoniumTailwindConfig.merge`, then rebuild:
181
+
182
+ ```javascript
183
+ // tailwind.config.js
184
+ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
185
+ extend: {
186
+ colors: {
187
+ primary: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }, // Indigo
188
+ },
189
+ },
190
+ }),
191
+ ```
180
192
 
181
- @theme {
182
- --color-primary-500: #6366f1; /* Indigo */
183
- --color-primary-600: #4f46e5;
184
- --color-primary-700: #4338ca;
193
+ Surfaces, borders, radii and shadows are `--pu-*` tokens. Override them after the Plutonium import in `app/assets/stylesheets/application.tailwind.css`, and repeat every one you change in a `.dark` block so dark mode gets its own value:
194
+
195
+ ```css
196
+ :root {
197
+ --pu-radius-md: 0.5rem;
198
+ --pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
199
+ }
185
200
 
186
- --radius-md: 0.5rem;
187
- --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
201
+ .dark {
202
+ --pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
188
203
  }
189
204
  ```
190
205
 
@@ -201,9 +216,9 @@ class StatusBadge < Plutonium::UI::Component::Base
201
216
 
202
217
  def view_template
203
218
  if @published
204
- span(class: "px-2 py-1 text-xs bg-green-100 text-green-800 rounded") { "Published" }
219
+ span(class: "pu-badge pu-badge-success") { t("blogging.posts.status.published") }
205
220
  else
206
- span(class: "px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded") { "Draft" }
221
+ span(class: "pu-badge pu-badge-warning") { t("blogging.posts.status.draft") }
207
222
  end
208
223
  end
209
224
  end
@@ -224,7 +239,7 @@ class CustomLayout < Plutonium::UI::Layout::ResourceLayout
224
239
 
225
240
  # Customize body classes
226
241
  def body_attributes
227
- {class: "antialiased min-h-screen bg-white dark:bg-gray-900"}
242
+ {class: "antialiased pu-min-h-viewport bg-[var(--pu-surface)] text-[var(--pu-text)]"}
228
243
  end
229
244
 
230
245
  # Add content before the main section
@@ -60,7 +60,7 @@ Customize forms, tables, and views to match your requirements.
60
60
  If you get stuck:
61
61
 
62
62
  - Check the [Guides](/guides/) for task-oriented walkthroughs.
63
- - Browse the [Reference](/reference/) for full API surface — [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
63
+ - Browse the [Reference](/reference/) for full API surface: [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
64
64
  - Visit [GitHub Issues](https://github.com/radioactive-labs/plutonium-core/issues).
65
65
 
66
66
  [Begin Chapter 1: Project Setup →](./01-setup)
@@ -40,6 +40,8 @@ rails g pu:res:conn Post --dest=admin_portal
40
40
 
41
41
  This creates the portal-specific controller, policy, and definition, plus registers the resource in the portal's routes. Until you do this, the resource has no URL.
42
42
 
43
+ Run it after the migration. When there is no base policy to inherit from, `pu:res:conn` seeds the policy's attribute lists from the table's columns; on an unmigrated table it logs an error and writes empty lists.
44
+
43
45
  For singular resources (`/profile`, `/settings`), add `--singular`:
44
46
 
45
47
  ```bash
@@ -48,7 +50,7 @@ rails g pu:res:conn Profile --dest=customer_portal --singular
48
50
 
49
51
  ### 5. Trim the generated policy
50
52
 
51
- The generator is liberal — it seeds `permitted_attributes_for_*` from your model columns. Open `packages/admin_portal/app/policies/admin_portal/post_policy.rb` and:
53
+ The generator is liberal: it seeds `permitted_attributes_for_*` from your model columns. Open `packages/admin_portal/app/policies/admin_portal/post_policy.rb` and:
52
54
 
53
55
  - Drop `_id` fields when the form should use the association name (e.g. `:user`, not `:user_id`).
54
56
  - Replace `:price_cents` with `:price` if the model uses `has_cents`.
@@ -83,7 +85,7 @@ You should see:
83
85
 
84
86
  Two paths:
85
87
 
86
- **Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it — appears in all CRUD pages.
88
+ **Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it and shows it in all CRUD pages.
87
89
 
88
90
  **Field with custom rendering.** Add the column, then declare it in the definition:
89
91
 
@@ -117,6 +119,7 @@ rails g pu:res:conn Post --dest=admin_portal
117
119
 
118
120
  ```bash
119
121
  rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
122
+ rails db:prepare
120
123
  rails g pu:res:conn Blogging::Post --dest=admin_portal
121
124
  ```
122
125
 
@@ -132,8 +135,8 @@ The `blogging/post` syntax expands to `Blogging::Post`.
132
135
 
133
136
  ## Related
134
137
 
135
- - [Reference › App › Generators](/reference/app/generators) — full generator catalog
136
- - [Reference › Resource](/reference/resource/) — model + definition + query + actions
137
- - [Reference › App › Portals](/reference/app/portals) — `pu:res:conn` details
138
- - [Creating packages](./creating-packages) — resources in feature packages
139
- - [Nested resources](./nested-resources) — parent/child relationships
138
+ - [Reference › App › Generators](/reference/app/generators): full generator catalog
139
+ - [Reference › Resource](/reference/resource/): model + definition + query + actions
140
+ - [Reference › App › Portals](/reference/app/portals): `pu:res:conn` details
141
+ - [Creating packages](./creating-packages): resources in feature packages
142
+ - [Nested resources](./nested-resources): parent/child relationships
@@ -6,7 +6,7 @@ Add Rodauth-based authentication to your Plutonium app.
6
6
 
7
7
  Authenticated users can sign up, log in, change passwords, and reset forgotten passwords. Pages in protected portals are gated.
8
8
 
9
- ## Quick path — basic user auth
9
+ ## Quick path: basic user auth
10
10
 
11
11
  ```bash
12
12
  # 1. Install Rodauth
@@ -22,7 +22,7 @@ rails db:prepare
22
22
  # (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)
23
23
  ```
24
24
 
25
- If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint — open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
25
+ If you generated the portal with `--auth=user`, the engine is already mounted with the `Rodauth::Rails.authenticate(:user)` constraint: open `packages/admin_portal/config/routes.rb` to see it. The wiring looks like:
26
26
 
27
27
  ```ruby
28
28
  # packages/admin_portal/config/routes.rb (generated)
@@ -64,13 +64,13 @@ EMAIL=admin@example.com rails rodauth:admin
64
64
 
65
65
  The task creates the account and triggers a verification email; the admin sets their own password through that flow. No password is passed on the command line.
66
66
 
67
- ### Multi-tenant SaaS — user + entity + membership in one shot
67
+ ### Multi-tenant SaaS: user + entity + membership in one shot
68
68
 
69
69
  ```bash
70
70
  rails generate pu:saas:setup --user Customer --entity Organization
71
71
  ```
72
72
 
73
- ⚠️ This is a **meta-generator** — it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
73
+ ⚠️ This is a **meta-generator**: it also runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install`. Don't re-run those manually. See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup).
74
74
 
75
75
  ### API-only (JWT)
76
76
 
@@ -103,7 +103,7 @@ module CustomerPortal::Concerns::Controller
103
103
  end
104
104
  ```
105
105
 
106
- Multiple account types — different portals use different Rodauth instances:
106
+ Multiple account types (different portals use different Rodauth instances):
107
107
 
108
108
  ```ruby
109
109
  # Admin portal
@@ -117,40 +117,40 @@ See [Reference › App › Portals](/reference/app/portals#controller-concern-au
117
117
 
118
118
  ## Multiple portals in one browser
119
119
 
120
- A person can hold a session in several portals at once — signed into the admin portal and the customer portal in the same browser, with neither evicting the other. Two settings make that work, and the generators emit both:
120
+ A person can hold a session in several portals at once (signed into the admin portal and the customer portal in the same browser, with neither evicting the other). Two settings make that work, and the generators emit both:
121
121
 
122
122
  ```ruby
123
- # app/rodauth/rodauth_plugin.rb — the shared base
123
+ # app/rodauth/rodauth_plugin.rb: the shared base
124
124
  configure do
125
125
  enable :session_isolation
126
126
  end
127
127
  ```
128
128
 
129
129
  ```ruby
130
- # app/rodauth/admin_rodauth_plugin.rb — once per account type
130
+ # app/rodauth/admin_rodauth_plugin.rb: once per account type
131
131
  configure do
132
132
  session_key_prefix "admin_"
133
133
  remember_cookie_key "_admin_remember"
134
134
  end
135
135
  ```
136
136
 
137
- Both are required. `session_key_prefix` namespaces *every* session key a configuration touches — the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
137
+ Both are required. `session_key_prefix` namespaces *every* session key a configuration touches, the account id plus `authenticated_by`, `login_redirect`, `two_factor_auth_setup` and the rest. `session_isolation` uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
138
138
 
139
139
  ::: danger Do not also set `session_key`
140
- An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686` — only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request* — `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
140
+ An explicit `session_key` is **not** prefixed (`convert_session_key`, `rodauth/features/base.rb:686`, only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no `authenticated_by` makes Rodauth raise on *every request*, `logged_in_via_remember_key?` calls `nil.include?` (`remember.rb:175`).
141
141
 
142
142
  A config without a prefix at all is also not isolated: its keys are Rodauth's unprefixed defaults, indistinguishable from any other unprefixed config's, so `session_isolation` carries nothing for it.
143
143
  :::
144
144
 
145
145
  ::: details Why this is needed
146
- Rodauth resets the session on every login — including the `remember` feature's `load_memory` autologin — to defend against session fixation, and rodauth-rails implements that reset as a full `reset_session`.
146
+ Rodauth resets the session on every login, including the `remember` feature's `load_memory` autologin, to defend against session fixation, and rodauth-rails implements that reset as a full `reset_session`.
147
147
 
148
148
  Without `session_isolation`, signing into one portal wipes every other portal's session. And because `RodauthApp#route` calls `load_memory` for *every* configuration on *every* request, the evicted configuration immediately autologins from its remember cookie and evicts the new one right back. The last `load_memory` call in the route block wins permanently, so the other portal can never hold a session at all.
149
149
 
150
150
  `session_isolation` carries only the *other configurations'* session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
151
151
  :::
152
152
 
153
- Reading a raw Rodauth session key? Go through its accessor, never the literal — with a prefix set, the literal is the wrong key:
153
+ Reading a raw Rodauth session key? Go through its accessor, never the literal: with a prefix set, the literal is the wrong key:
154
154
 
155
155
  ```ruby
156
156
  after_login do
@@ -159,9 +159,9 @@ after_login do
159
159
  end
160
160
  ```
161
161
 
162
- **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin — and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
162
+ **Upgrading an app generated before this existed:** add `enable :session_isolation` once in the base plugin and `session_key_prefix` in each account plugin, and **delete the existing `session_key "_x_session"` line** while you're there, for the reason above.
163
163
 
164
- Every key name changes together, so old session cookies stop matching and are ignored — the safe outcome. Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
164
+ Every key name changes together, so old session cookies stop matching and are ignored (the safe outcome). Keeping the old `session_key` to "preserve" logins is what produces the half-migrated session that crashes.
165
165
 
166
166
  In practice only *unremembered* sessions drop: `remember_cookie_key` is a cookie name and isn't touched by the prefix, so anyone holding a valid `_x_remember` cookie is restored by `load_memory` on their next request, which writes the new prefixed keys. Expect logouts for users who never ticked "Remember Me", not a full sign-out.
167
167
 
@@ -180,9 +180,9 @@ after_login { remember_login }
180
180
  ```
181
181
 
182
182
  ::: warning Compare the value, don't just check presence
183
- `param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page — which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
183
+ `param_or_nil(remember_param)` on its own is a truthiness check, and `remember_param` is shared with Rodauth's `/remember` settings page, which submits `forget` and `disable` as well as `remember`. A bare presence check means `remember=disable` would remember you. Compare against `remember_remember_param_value`, as above.
184
184
 
185
- Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false) — but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
185
+ Plutonium's login form also passes `include_hidden: false` to `check_box`, so an unticked box sends nothing at all rather than Rails' default `"0"`. With the value comparison that's belt-and-braces rather than load-bearing (`"0" == "remember"` is already false), but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
186
186
  :::
187
187
 
188
188
  Logged-in users can change the setting later on Rodauth's `/remember` page (Remember / Forget / Disable).
@@ -254,15 +254,15 @@ user
254
254
 
255
255
  ## Common issues
256
256
 
257
- - **"You need to set up Rodauth"** — run `pu:rodauth:install` first.
258
- - **Portal redirects to login even though you're authenticated** — the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
259
- - **Email confirmation never arrives in development** — Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
260
- - **Signing into one portal signs you out of another** — or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
257
+ - **"You need to set up Rodauth"**: run `pu:rodauth:install` first.
258
+ - **Portal redirects to login even though you're authenticated**: the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
259
+ - **Email confirmation never arrives in development**: Plutonium sets ActionMailer to `:test` by default. Check `tmp/letter_opener/` or your mail interceptor. In production, configure SMTP (see above).
260
+ - **Signing into one portal signs you out of another**: or one portal can never stay signed in at all. Missing `enable :session_isolation` and/or `session_key_prefix`; see [Multiple portals in one browser](#multiple-portals-in-one-browser).
261
261
 
262
262
  ## Related
263
263
 
264
- - [Reference › Auth](/reference/auth/) — full auth surface
265
- - [Authorization](./authorization) — controlling who can do what AFTER login
266
- - [Multi-tenancy](./multi-tenancy) — entity scoping for SaaS apps
267
- - [User invites](./user-invites) — invitation-based onboarding
268
- - [User profile](./user-profile) — account-settings page
264
+ - [Reference › Auth](/reference/auth/): full auth surface
265
+ - [Authorization](./authorization): controlling who can do what AFTER login
266
+ - [Multi-tenancy](./multi-tenancy): entity scoping for SaaS apps
267
+ - [User invites](./user-invites): invitation-based onboarding
268
+ - [User profile](./user-profile): account-settings page
@@ -10,9 +10,9 @@ For each resource, decide who can create / read / update / destroy / run custom
10
10
 
11
11
  Every policy controls three things:
12
12
 
13
- 1. **Action permissions** — `create?`, `read?`, `update?`, `destroy?`, plus your custom action methods.
14
- 2. **Attribute permissions** — `permitted_attributes_for_create`, `_for_read`, etc.
15
- 3. **Collection scope** — `relation_scope` (which records show up in lists).
13
+ 1. **Action permissions**: `create?`, `read?`, `update?`, `destroy?`, plus your custom action methods.
14
+ 2. **Attribute permissions**: `permitted_attributes_for_create`, `_for_read`, etc.
15
+ 3. **Collection scope**: `relation_scope` (which records show up in lists).
16
16
 
17
17
  ## 🚨 Critical
18
18
 
@@ -39,7 +39,7 @@ class PostPolicy < ResourcePolicy
39
39
  end
40
40
  ```
41
41
 
42
- These default to `false` — without an explicit override, nobody can create or read records.
42
+ These default to `false`, without an explicit override, nobody can create or read records.
43
43
 
44
44
  ### 3. Override derived methods only when rules differ
45
45
 
@@ -55,7 +55,7 @@ def destroy?
55
55
  end
56
56
  ```
57
57
 
58
- 🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class — there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe — they are always evaluated against an instance.
58
+ 🚨 **`record` is the resource CLASS on collection routes.** `read?` backs both `show?` (called with the record) and `index?` (called with the class: there is no single record to pass). The same goes for `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates. So `def read? = record.published?` raises `NoMethodError` the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in `relation_scope` (step 6 below), and gate individual records in `show?` (which always receives the record). Record-action methods like `publish?` are safe; they are always evaluated against an instance.
59
59
 
60
60
  ### 4. Declare attribute permissions
61
61
 
@@ -69,8 +69,8 @@ def permitted_attributes_for_read
69
69
  end
70
70
  ```
71
71
 
72
- ::: warning Index has no `record`
73
- `permitted_attributes_for_index` runs at collection level — `record` is `nil`. If you write a `record`-dependent `_for_read`, you MUST also declare an explicit `_for_index`. See [Reference › Behavior › Policies › Index has no record](/reference/behavior/policies#index-has-no-record).
72
+ ::: warning Index has no `record` instance
73
+ `permitted_attributes_for_index` runs at collection level, where `record` is the resource class. `_for_index` falls back to `_for_read` (and `_for_export` to `_for_index`), so if you write a `record`-dependent `_for_read`, you MUST also declare an explicit `_for_index` that never touches `record`. See [Reference › Behavior › Policies › Index has no record](/reference/behavior/policies#index-has-no-record).
74
74
  :::
75
75
 
76
76
  ### 5. Custom action methods
@@ -87,7 +87,7 @@ end
87
87
 
88
88
  The method name matches the action name plus `?`. Undefined methods return `false`.
89
89
 
90
- ### 6. Optionally filter the collection — `relation_scope`
90
+ ### 6. Optionally filter the collection: `relation_scope`
91
91
 
92
92
  ```ruby
93
93
  relation_scope do |relation|
@@ -95,7 +95,7 @@ relation_scope do |relation|
95
95
  end
96
96
  ```
97
97
 
98
- 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
98
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise `verify_default_relation_scope_applied!` raises at runtime. Calling it explicitly here is safest. `super` works only when the parent policy also calls it.
99
99
 
100
100
  ## Common patterns
101
101
 
@@ -143,7 +143,7 @@ def update?
143
143
  end
144
144
  ```
145
145
 
146
- ## Bulk action authorization — per record
146
+ ## Bulk action authorization: per record
147
147
 
148
148
  ```ruby
149
149
  def bulk_archive?
@@ -154,7 +154,7 @@ end
154
154
  - **Backend:** if any selected record fails, the entire request is rejected.
155
155
  - **UI:** only actions ALL selected records support are shown (intersection).
156
156
 
157
- Records come from `current_authorized_scope` — users can only select records they can access.
157
+ Records come from `current_authorized_scope`; users can only select records they can access.
158
158
 
159
159
  ## Portal-specific policies
160
160
 
@@ -163,7 +163,7 @@ class PostPolicy < ResourcePolicy
163
163
  def create? = user.present?
164
164
  end
165
165
 
166
- # Admin — more permissive
166
+ # Admin: more permissive
167
167
  class AdminPortal::PostPolicy < ::PostPolicy
168
168
  include AdminPortal::ResourcePolicy
169
169
 
@@ -171,7 +171,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
171
171
  def permitted_attributes_for_create = %i[title content featured internal_notes]
172
172
  end
173
173
 
174
- # Public — read-only
174
+ # Public: read-only
175
175
  class PublicPortal::PostPolicy < ::PostPolicy
176
176
  include PublicPortal::ResourcePolicy
177
177
  def create? = false
@@ -186,7 +186,7 @@ def permitted_associations
186
186
  end
187
187
  ```
188
188
 
189
- Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource. See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
189
+ Drives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource in every portal that uses the policy; a portal that doesn't register the child raises `ArgumentError ... is not a registered resource` on its show page. To add a tab in one portal only, put it in a portal-specific policy (`rails g pu:res:conn <Resource> --dest=<portal> --policy`). See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
190
190
 
191
191
  ::: warning Not for nested forms
192
192
  `permitted_associations` is for show-page navigation tabs, NOT nested forms. Nested forms come from `nested_input :variants` in the definition. See [Reference › Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
@@ -194,7 +194,7 @@ Drives the show-page tablist. Each named association must exist on the model AND
194
194
 
195
195
  ## Multi-tenant scoping
196
196
 
197
- When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org — no work in the policy. To add filters on top:
197
+ When the portal sets `scope_to_entity Organization`, the inherited `relation_scope` automatically filters everything to the current org, no work in the policy. To add filters on top:
198
198
 
199
199
  ```ruby
200
200
  relation_scope do |relation|
@@ -243,15 +243,15 @@ end
243
243
 
244
244
  ## Common issues
245
245
 
246
- - **Undefined custom action policy method** — the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
247
- - **`record.X` crashes during index** — `record` is `nil` on index. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
248
- - **`verify_default_relation_scope_applied!` raises** — your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
249
- - **`super` in `relation_scope`** — works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere — not that you wrote it in this class.
246
+ - **Undefined custom action policy method**: the button silently disappears (undefined returns `false`). Add `def my_action?` to the policy.
247
+ - **`record.X` crashes during index**: `record` is the resource class on index, not an instance. Add an explicit `permitted_attributes_for_index` that doesn't depend on `record`.
248
+ - **`verify_default_relation_scope_applied!` raises**: your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
249
+ - **`super` in `relation_scope`**; works when you're extending a parent policy that itself calls `default_relation_scope`. If you're not sure (or you're inheriting from `Plutonium::Resource::Policy` directly), call `default_relation_scope(relation)` explicitly. The runtime check verifies `default_relation_scope` was hit somewhere: not that you wrote it in this class.
250
250
 
251
251
  ## Related
252
252
 
253
- - [Reference › Behavior › Policies](/reference/behavior/policies) — full policy surface
254
- - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `default_relation_scope`, multi-tenant patterns
255
- - [Authentication](./authentication) — who's the user in the first place
256
- - [Multi-tenancy](./multi-tenancy) — entity scoping setup
257
- - [Custom actions](./custom-actions) — defining the actions that need policy methods
253
+ - [Reference › Behavior › Policies](/reference/behavior/policies): full policy surface
254
+ - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): `default_relation_scope`, multi-tenant patterns
255
+ - [Authentication](./authentication): who's the user in the first place
256
+ - [Multi-tenancy](./multi-tenancy): entity scoping setup
257
+ - [Custom actions](./custom-actions): defining the actions that need policy methods
@@ -13,7 +13,7 @@ Domain code (models, policies, definitions, interactions) lives in **feature pac
13
13
  | **Feature** | Business logic | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
14
14
  | **Portal** | Web interface | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |
15
15
 
16
- 🚨 Don't mix the two. Feature packages own the **domain code** — models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface** — controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
16
+ 🚨 Don't mix the two. Feature packages own the **domain code**: models, interactions, policies/definitions for resources owned by that feature. Portal packages own the **web surface**: controllers, routes, auth, and portal-specific policy/definition *overrides* for resources they expose.
17
17
 
18
18
  ## Feature package
19
19
 
@@ -59,12 +59,12 @@ rails g pu:pkg:portal admin --auth=user
59
59
 
60
60
  Options:
61
61
 
62
- - `--auth=NAME` — Rodauth account to authenticate with.
63
- - `--public` — public access, no auth.
64
- - `--byo` — bring your own auth.
65
- - `--scope=CLASS` — entity class for multi-tenancy.
62
+ - `--auth=NAME`: Rodauth account to authenticate with.
63
+ - `--public`: public access, no auth.
64
+ - `--byo`: bring your own auth.
65
+ - `--scope=CLASS`: entity class for multi-tenancy.
66
66
 
67
- The generator mounts the engine for you — at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
67
+ The generator mounts the engine for you: at `/admin` in this case, wrapped in `constraints Rodauth::Rails.authenticate(:user)` because you passed `--auth=user`. Open `packages/admin_portal/config/routes.rb` to see the generated mount.
68
68
 
69
69
  ### 2. Connect resources
70
70
 
@@ -87,7 +87,7 @@ Every file under `app/<kind>/blogging/` resolves to `Blogging::*`:
87
87
  - `app/models/blogging/post.rb` → `Blogging::Post`
88
88
  - `app/policies/blogging/post_policy.rb` → `Blogging::PostPolicy`
89
89
 
90
- Each feature package gets base classes — `Blogging::ApplicationRecord`, `Blogging::ResourceRecord`, `Blogging::ResourcePolicy`, `Blogging::ResourceDefinition`, `Blogging::ResourceInteraction` — that inherit from the main app's.
90
+ Each feature package gets base classes, `Blogging::ApplicationRecord`, `Blogging::ResourceRecord`, `Blogging::ResourcePolicy`, `Blogging::ResourceDefinition`, `Blogging::ResourceInteraction`, that inherit from the main app's.
91
91
 
92
92
  ## Cross-package references
93
93
 
@@ -129,7 +129,7 @@ packages/
129
129
  └── customer_portal/ # Portal: customer dashboard
130
130
  ```
131
131
 
132
- The portals expose the features. A single feature can be exposed by multiple portals — usually with different policies and definitions per portal.
132
+ The portals expose the features. A single feature can be exposed by multiple portals, usually with different policies and definitions per portal.
133
133
 
134
134
  ## Package loading
135
135
 
@@ -147,13 +147,13 @@ Loaded from `config/application.rb`. Migrations from all packages are picked up
147
147
  ## Per-portal overrides
148
148
 
149
149
  ```ruby
150
- # Definition — how fields render per portal
150
+ # Definition: how fields render per portal
151
151
  class AdminPortal::PostDefinition < ::PostDefinition
152
152
  scope :pending_review
153
153
  input :internal_notes, hint: "Not shown to the author"
154
154
  end
155
155
 
156
- # Policy — which fields exist, and who may act
156
+ # Policy: which fields exist, and who may act
157
157
  # `internal_notes` appears for admins because THIS permits it,
158
158
  # not because the definition above mentions it.
159
159
  class AdminPortal::PostPolicy < ::PostPolicy
@@ -166,13 +166,13 @@ end
166
166
 
167
167
  ## Common issues
168
168
 
169
- - **Class not loading** — namespace must match the directory: `app/models/blogging/post.rb` MUST be `Blogging::Post`.
170
- - **Migration not running** — package migrations are auto-included. If they aren't running, check `config/packages.rb` is loaded from `application.rb`.
171
- - **Cross-package association fails** — use `blogging/post:belongs_to` in `pu:res:scaffold`, OR manually set `class_name: "Blogging::Post"` on the `belongs_to`.
169
+ - **Class not loading**: namespace must match the directory: `app/models/blogging/post.rb` MUST be `Blogging::Post`.
170
+ - **Migration not running**: package migrations are auto-included. If they aren't running, check `config/packages.rb` is loaded from `application.rb`.
171
+ - **Cross-package association fails**: use `blogging/post:belongs_to` in `pu:res:scaffold`, OR manually set `class_name: "Blogging::Post"` on the `belongs_to`.
172
172
 
173
173
  ## Related
174
174
 
175
- - [Reference › App › Packages](/reference/app/packages) — full package surface
176
- - [Reference › App › Portals](/reference/app/portals) — portal-specific configuration
177
- - [Adding resources](./adding-resources) — `pu:res:scaffold` and `pu:res:conn`
178
- - [Authentication](./authentication) — portal auth setup
175
+ - [Reference › App › Packages](/reference/app/packages): full package surface
176
+ - [Reference › App › Portals](/reference/app/portals): portal-specific configuration
177
+ - [Adding resources](./adding-resources): `pu:res:scaffold` and `pu:res:conn`
178
+ - [Authentication](./authentication): portal auth setup