plutonium 0.64.0 → 0.65.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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +4 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-dashboard/SKILL.md +161 -0
  5. data/.claude/skills/plutonium-resource/SKILL.md +94 -14
  6. data/.claude/skills/plutonium-ui/SKILL.md +22 -1
  7. data/CHANGELOG.md +59 -0
  8. data/CLAUDE.md +282 -210
  9. data/SECURITY.md +1 -1
  10. data/app/assets/plutonium-charts.js +20604 -0
  11. data/app/assets/plutonium-charts.js.map +7 -0
  12. data/app/assets/plutonium-charts.min.js +41 -0
  13. data/app/assets/plutonium-charts.min.js.map +7 -0
  14. data/app/assets/plutonium.css +1 -1
  15. data/app/assets/plutonium.js +794 -629
  16. data/app/assets/plutonium.js.map +4 -4
  17. data/app/assets/plutonium.min.js +48 -48
  18. data/app/assets/plutonium.min.js.map +4 -4
  19. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  20. data/app/views/plutonium/_resource_header.html.erb +2 -2
  21. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  22. data/app/views/plutonium/_toast.html.erb +2 -2
  23. data/app/views/rodauth/_login_form.html.erb +1 -1
  24. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  25. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  26. data/app/views/rodauth/change_login.html.erb +2 -2
  27. data/app/views/rodauth/create_account.html.erb +4 -4
  28. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  29. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  30. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  31. data/config/brakeman.ignore +4 -4
  32. data/config/initializers/rabl.rb +0 -40
  33. data/config/locales/en/api_client.yml +22 -0
  34. data/config/locales/en/async.yml +34 -0
  35. data/config/locales/en/dashboard.yml +25 -0
  36. data/config/locales/en/invites.yml +37 -0
  37. data/config/locales/en/js.yml +40 -0
  38. data/config/locales/en/kanban.yml +28 -0
  39. data/config/locales/en/plutonium.yml +26 -0
  40. data/config/locales/en/positioning.yml +14 -0
  41. data/config/locales/en/profile.yml +29 -0
  42. data/config/locales/en/resource.yml +60 -0
  43. data/config/locales/en/ui.yml +93 -0
  44. data/config/locales/en/wizard.yml +46 -0
  45. data/docs/.vitepress/config.ts +34 -1
  46. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  47. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  48. data/docs/.vitepress/theme/custom.css +34 -377
  49. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  50. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  51. data/docs/blog/introducing-plutonium.md +4 -5
  52. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  53. data/docs/guides/dashboards.md +300 -0
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +2 -2
  56. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  57. data/docs/public/images/blog/dashboards-loading.png +0 -0
  58. data/docs/public/images/blog/dashboards-overview.png +0 -0
  59. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  60. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  61. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  62. data/docs/public/images/guides/dashboard-loading.png +0 -0
  63. data/docs/public/images/guides/dashboard-overview.png +0 -0
  64. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  65. data/docs/reference/app/generators.md +2 -2
  66. data/docs/reference/app/portals.md +2 -0
  67. data/docs/reference/configuration.md +84 -27
  68. data/docs/reference/dashboard/dsl.md +123 -0
  69. data/docs/reference/dashboard/index.md +35 -0
  70. data/docs/reference/dashboard/registration.md +76 -0
  71. data/docs/reference/i18n.md +239 -0
  72. data/docs/reference/index.md +10 -1
  73. data/docs/reference/kanban/dsl.md +1 -1
  74. data/docs/reference/kanban/positioning.md +2 -2
  75. data/docs/reference/resource/actions.md +1 -1
  76. data/docs/reference/resource/definition.md +96 -18
  77. data/docs/reference/resource/export.md +5 -1
  78. data/docs/reference/resource/index.md +2 -0
  79. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  80. data/docs/reference/resource/query.md +2 -0
  81. data/docs/reference/ui/assets.md +8 -0
  82. data/docs/reference/ui/displays.md +5 -8
  83. data/docs/reference/ui/index.md +1 -1
  84. data/docs/reference/wizard/dsl.md +2 -2
  85. data/esbuild.config.js +5 -2
  86. data/gemfiles/rails_7.gemfile.lock +2 -2
  87. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  88. data/lib/active_model/validations/array_validator.rb +1 -1
  89. data/lib/active_model/validations/attached_validator.rb +1 -1
  90. data/lib/active_model/validations/url_validator.rb +2 -2
  91. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  92. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  93. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  94. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  95. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  96. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  97. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  98. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  99. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  100. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  101. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  102. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  103. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  104. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  105. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  106. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  107. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  109. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  110. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  111. data/lib/plutonium/action/base.rb +27 -3
  112. data/lib/plutonium/action/interactive.rb +17 -4
  113. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  114. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  115. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  116. data/lib/plutonium/configuration.rb +5 -0
  117. data/lib/plutonium/core/controller.rb +25 -1
  118. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  119. data/lib/plutonium/dashboard/base.rb +114 -0
  120. data/lib/plutonium/dashboard/card.rb +200 -0
  121. data/lib/plutonium/dashboard/controller.rb +101 -0
  122. data/lib/plutonium/dashboard/dsl.rb +104 -0
  123. data/lib/plutonium/dashboard/register.rb +40 -0
  124. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  125. data/lib/plutonium/dashboard.rb +42 -0
  126. data/lib/plutonium/definition/actions.rb +6 -1
  127. data/lib/plutonium/definition/base.rb +41 -3
  128. data/lib/plutonium/definition/index_views.rb +1 -1
  129. data/lib/plutonium/definition/positioning.rb +1 -1
  130. data/lib/plutonium/definition/presentable.rb +2 -2
  131. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  132. data/lib/plutonium/helpers/display_helper.rb +10 -2
  133. data/lib/plutonium/interaction/async/context.rb +6 -8
  134. data/lib/plutonium/interaction/async/executor.rb +6 -7
  135. data/lib/plutonium/interaction/async/run.rb +2 -2
  136. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  137. data/lib/plutonium/interaction/base.rb +2 -0
  138. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  139. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  140. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  141. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  142. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  143. data/lib/plutonium/invites/controller.rb +11 -11
  144. data/lib/plutonium/kanban/column.rb +13 -3
  145. data/lib/plutonium/kanban/dsl.rb +7 -4
  146. data/lib/plutonium/models/has_cents.rb +1 -1
  147. data/lib/plutonium/profile/security_section.rb +10 -18
  148. data/lib/plutonium/query/filter.rb +10 -1
  149. data/lib/plutonium/query/filters/association.rb +27 -2
  150. data/lib/plutonium/query/filters/boolean.rb +4 -4
  151. data/lib/plutonium/query/filters/date.rb +4 -17
  152. data/lib/plutonium/query/filters/date_range.rb +2 -2
  153. data/lib/plutonium/query/filters/select.rb +10 -1
  154. data/lib/plutonium/query/filters/text.rb +9 -12
  155. data/lib/plutonium/railtie.rb +17 -0
  156. data/lib/plutonium/resource/controller.rb +10 -5
  157. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  158. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  159. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  160. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  161. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  162. data/lib/plutonium/resource/query_object.rb +30 -3
  163. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  164. data/lib/plutonium/routing/resource_registration.rb +5 -0
  165. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  166. data/lib/plutonium/translation.rb +227 -0
  167. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  168. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  169. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  170. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  171. data/lib/plutonium/ui/component/methods.rb +4 -0
  172. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  173. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  174. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  175. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  176. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  177. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  178. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  179. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  180. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  181. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  182. data/lib/plutonium/ui/display/resource.rb +23 -16
  183. data/lib/plutonium/ui/export_button.rb +3 -3
  184. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  185. data/lib/plutonium/ui/form/components/password.rb +2 -2
  186. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  187. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  188. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  189. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  190. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  191. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  192. data/lib/plutonium/ui/form/query.rb +8 -8
  193. data/lib/plutonium/ui/form/resource.rb +54 -26
  194. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  195. data/lib/plutonium/ui/grid/card.rb +3 -3
  196. data/lib/plutonium/ui/grid/resource.rb +3 -3
  197. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  198. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  199. data/lib/plutonium/ui/kanban/column.rb +6 -6
  200. data/lib/plutonium/ui/layout/base.rb +19 -1
  201. data/lib/plutonium/ui/layout/header.rb +1 -1
  202. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  203. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  204. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  205. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  206. data/lib/plutonium/ui/modal/base.rb +4 -4
  207. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  208. data/lib/plutonium/ui/nav_user.rb +2 -2
  209. data/lib/plutonium/ui/page/base.rb +10 -2
  210. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  211. data/lib/plutonium/ui/page/edit.rb +3 -3
  212. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  213. data/lib/plutonium/ui/page/new.rb +3 -3
  214. data/lib/plutonium/ui/page/show.rb +2 -2
  215. data/lib/plutonium/ui/page/wizard.rb +22 -16
  216. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  217. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  218. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  219. data/lib/plutonium/ui/table/base.rb +2 -2
  220. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  221. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  222. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  223. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  224. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  225. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  226. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  227. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  228. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  229. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  230. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  231. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  232. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  233. data/lib/plutonium/ui/table/resource.rb +53 -17
  234. data/lib/plutonium/ui/wizard/review.rb +7 -7
  235. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  236. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  237. data/lib/plutonium/version.rb +1 -1
  238. data/lib/plutonium/wizard/base.rb +2 -0
  239. data/lib/plutonium/wizard/driving.rb +8 -2
  240. data/lib/plutonium/wizard/dsl.rb +4 -2
  241. data/lib/plutonium/wizard/field_importer.rb +1 -8
  242. data/lib/plutonium/wizard/review_step.rb +8 -1
  243. data/lib/plutonium/wizard/step.rb +14 -3
  244. data/package.json +4 -2
  245. data/plutonium.gemspec +4 -1
  246. data/postcss-gem-import.cjs +34 -0
  247. data/postcss-gem-import.js +3 -28
  248. data/src/css/components.css +91 -0
  249. data/src/css/slim_select.css +0 -38
  250. data/src/css/tokens.css +24 -0
  251. data/src/js/controllers/attachment_input_controller.js +15 -6
  252. data/src/js/controllers/chart_controller.js +162 -0
  253. data/src/js/controllers/clipboard_controller.js +3 -2
  254. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  255. data/src/js/controllers/easymde_controller.js +3 -0
  256. data/src/js/controllers/filter_panel_controller.js +15 -6
  257. data/src/js/controllers/flatpickr_controller.js +8 -0
  258. data/src/js/controllers/frame_refresh_controller.js +59 -0
  259. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  260. data/src/js/controllers/register_controllers.js +4 -0
  261. data/src/js/controllers/slim_select_controller.js +79 -176
  262. data/src/js/core.js +9 -1
  263. data/src/js/i18n.js +69 -0
  264. data/src/js/plutonium-charts.js +15 -0
  265. data/src/js/turbo/turbo_confirm.js +5 -3
  266. metadata +64 -16
  267. data/.yarnrc.yml +0 -8
  268. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  269. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  270. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  271. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  272. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  273. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  274. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  275. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  276. data/public/plutonium-assets/application.js +0 -31419
  277. data/public/plutonium-assets/plutonium.ico +0 -0
  278. data/yarn.lock +0 -6858
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6abf981b5e375a2c576de8bfe5fe6448b115753356b6c91f1c3b0ae946e66935
4
- data.tar.gz: b519f55b96e20f592baab81b34a633de562dcd8060dbe2bf65cbff7342ae76dd
3
+ metadata.gz: '008433e7fc21f308028d5d047ddfa2c01c26efe13258f8af39ca99b7b72f0e0d'
4
+ data.tar.gz: d3c4ecbec9fd85ee133c4e0bd4b28fcf4fcc66ee632e0b5ea84d277fa00316a1
5
5
  SHA512:
6
- metadata.gz: cbf194184d5d16439031b3802f5fc7b05b7f22b24a2075cac2d03a5e6869160f5332ea6cc4d83d3a580169db7405266435e5f9b7f4f1b42befd334a93a470f53
7
- data.tar.gz: ec740ece535648156d103872cb0362347b6b2f2823d425aa0b94134f336a32355c807f767d05a673f173670fb1efd572c42240542b48b44f0ab5dc8b23ed63cc
6
+ metadata.gz: cc40c769d7dc3b30e2f5a2cd88aeb07d41c52bb37f1e28fb6946c88705d1dfb04adf19c19cdbc2889d4356f722a4d5705b4f0b3e1142ba3b58dbe9c9cbb8f30e
7
+ data.tar.gz: 0a772b07eee9784de0012559814134515a3fb228f9b9bf51a75b94f4161a5b05d913434a24cbb7991fdc0722f415a69b87f725990603ed7557af5b1afdd0205b
@@ -53,7 +53,7 @@ Not "defaults someone picked for you" — **computed from existing declarations*
53
53
  ### 4. Climb the escape-hatch ladder only as far as the problem requires
54
54
 
55
55
  1. **Change an option** — `input :content, as: :markdown`
56
- 2. **Render inline** — `display :priority, as: :phlexi_render, with: ->(value, attrs) do … end`
56
+ 2. **Render inline** — `display :priority do |f| … end` (a block, `instance_exec`ed in Phlex, emits markup directly and gives you `f.object`)
57
57
  3. **Write a component** — a *field* component (subclasses the Phlexi base) plugs into `as:`; anything with its own constructor goes through a block (`display :card do |field| … end`)
58
58
  4. **Implement a hook** — controller hooks instead of reopening `create`/`update`; page `render_before_*` / `render_after_*` instead of `view_template`
59
59
  5. **Replace the page** — `view_template` on the nested class, or an ERB view at the controller path (ERB wins when both exist)
@@ -85,6 +85,7 @@ This is the global "look before you leap"; each targeted skill carries its own A
85
85
  | **[[plutonium-async-interactions]]** | Async interactions — `async`, the Run STI model, failure policies (`halt`/`continue`/`transactional`), authorization re-derivation at perform time, registering AsyncRun as a resource (progress page + running banner), scheduling `ReapJob` |
86
86
  | **[[plutonium-ui]]** | Page classes, forms, displays, tables, custom Phlex components, layouts, modals & tabs, Tailwind config, Stimulus, design tokens, `.pu-*` classes, Phlexi themes |
87
87
  | **[[plutonium-kanban]]** | `kanban do…end` DSL in a Definition — columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy, quick-add, static vs dynamic boards |
88
+ | **[[plutonium-dashboard]]** | Dashboards — `Plutonium::Dashboard::Base`, `metric` / `chart` / `card`, `register_dashboard`, lazy turbo-frame cards, `refresh`, `condition:`, `authorize?`, `pu:dashboard` |
88
89
  | **[[plutonium-auth]]** | Rodauth install, account types (basic / admin / SaaS), profile resource, security section |
89
90
  | **[[plutonium-tenancy]]** | Entity scoping (`associated_with`, `default_relation_scope`, three model shapes), nested resources, invites |
90
91
  | **[[plutonium-testing]]** | `pu:test:install`, `pu:test:scaffold`, `ResourceCrud`/`ResourcePolicy`/`ResourceDefinition`/`ResourceModel`/`NestedResource`/`PortalAccess`/`ResourceInteraction`, `AuthHelpers` |
@@ -122,6 +123,7 @@ Add when relevant:
122
123
  | Configure parent/child nested routes, custom parent resolution | **[[plutonium-tenancy]]** |
123
124
  | Set up user invitations or entity membership | **[[plutonium-tenancy]]** |
124
125
  | Build or customize a kanban board view — `kanban do…end`, columns, `card_fields`, `position_on`, `realtime`, column actions, `kanban_move?` policy | **[[plutonium-kanban]]** |
126
+ | Build a dashboard, KPI overview or chart page — `pu:dashboard`, `metric` / `chart` / `card`, `register_dashboard`, refresh, per-card conditions | **[[plutonium-dashboard]]** |
125
127
  | Build a custom page (override `ShowPage`/`IndexPage`/`NewPage`/`EditPage`), custom form, custom display, custom table, custom Phlex component | **[[plutonium-ui]]** |
126
128
  | Configure Tailwind, register Stimulus controllers, edit design tokens, theme forms/displays/tables, write a custom layout | **[[plutonium-ui]]** |
127
129
  | Install Rodauth, set up accounts, configure login flow, add the profile resource | **[[plutonium-auth]]** |
@@ -169,6 +171,7 @@ Every Plutonium generator is discoverable via `rails g pu:<tab>`. Always pass `-
169
171
  | `pu:eject:shell` | Eject topbar/sidebar partials | `plutonium-ui` |
170
172
  | `pu:test:install` | Install `Plutonium::Testing` scaffolding | `plutonium-testing` |
171
173
  | `pu:test:scaffold NAME --portals=...` | Scaffold integration tests | `plutonium-testing` |
174
+ | `pu:dashboard NAME --dest=PORTAL [--at=/]` | Dashboard class + `register_dashboard` route | `plutonium-dashboard` |
172
175
  | `pu:skills:sync` | Sync Plutonium Claude skills into the project | (this skill) |
173
176
 
174
177
  ## Unattended execution
@@ -263,6 +263,16 @@ end
263
263
 
264
264
  This is loaded from `config/application.rb`. Migrations from all packages are picked up by `rails db:migrate` automatically.
265
265
 
266
+ ## Locale files
267
+
268
+ Packages and portals are Rails engines, so `packages/<name>/config/locales/*.yml` loads automatically, ahead of the app's own `config/locales` (app wins, then packages, then the gem's defaults). Both generators scaffold `config/locales/en.yml`.
269
+
270
+ - Feature package: model/attribute names (`activerecord.models`, `activerecord.attributes`) and field text (`plutonium.fields.<model>.<attr>.{placeholder,hint,description}`).
271
+ - Portal: portal-specific wording under `plutonium.portals.<portal_namespace>.…` (same tree as the global keys: `fields`, `actions`, `scopes`, `filters`, `kanban_columns`, `values`). Wins over the global key only inside that portal.
272
+ - Any of Plutonium's own strings (`plutonium.*` in the gem's `config/locales/en/*.yml`) can be overridden from either place. Pagination text comes from Pagy's dictionaries instead.
273
+
274
+ Switch locale with a normal Rails `around_action` in the portal's controller concern; Plutonium reads `I18n.locale` and never sets it. See `docs/reference/i18n.md`.
275
+
266
276
  ## When to use which
267
277
 
268
278
  **Feature packages** — domain logic that:
@@ -475,6 +485,10 @@ Use the `--singular` flag on `pu:res:conn`:
475
485
  rails g pu:res:conn Profile --dest=customer_portal --singular
476
486
  ```
477
487
 
488
+ ## Dashboards
489
+
490
+ `register_dashboard SomeDashboard, at: "overview"` mounts a `Plutonium::Dashboard::Base` subclass (metric / chart / free-form cards, each in its own lazy turbo frame) on the portal at `/dashboards/overview`; `at: "/"` (generator: `--at=/`) makes it the portal root in place of `root to: "dashboard#index"`; delete the generated `DashboardController` and view afterwards. Generate one with `rails g pu:dashboard Overview --dest=<portal>`. See [[plutonium-dashboard]].
491
+
478
492
  ## Custom member / collection routes
479
493
 
480
494
  ```ruby
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: plutonium-dashboard
3
+ description: Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app — Plutonium::Dashboard::Base, the metric / chart / card DSL, register_dashboard, lazy turbo-frame cards, refresh, conditions, authorize?, and the pu:dashboard generator. The single source for "how do I add a dashboard with metric cards and charts".
4
+ ---
5
+
6
+ # Plutonium Dashboards
7
+
8
+ A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line. Every card loads in its own lazy turbo frame, so the page paints at once and each card's queries run in a separate request. Charts render with Chart.js through Chartkick, styled by Plutonium's design tokens.
9
+
10
+ For resources and their definitions see [[plutonium-resource]]; for portals and routes see [[plutonium-app]]; for custom Phlex markup inside a card see [[plutonium-ui]].
11
+
12
+ ## 🚨 Critical (read first)
13
+
14
+ - **Experimental.** The DSL and behavior may change in a future release, the same status as [[plutonium-wizard]], [[plutonium-kanban]] and [[plutonium-async-interactions]]. Fine to build on; expect to follow the changelog.
15
+ - **Generate, don't hand-write:** `rails g pu:dashboard Sales --dest=admin_portal` writes the class AND the `register_dashboard` line.
16
+ - **Replacing the portal's default page:** `rails g pu:dashboard Home --dest=admin_portal --at=/` mounts it at the portal root and replaces the generated `root to: "dashboard#index"` line (two roots clash). It does NOT delete the generated `DashboardController` + `dashboard/index.html.erb`; delete them, nothing routes there any more. The sidebar Home link then opens the dashboard, and it is left out of the Dashboards group.
17
+ - **Dashboards live in `app/dashboards/`** (`packages/<portal>/app/dashboards/<portal>/` in a package). Class name ends in `Dashboard`; the label drops the suffix.
18
+ - **Card blocks run on the dashboard instance**, not the controller: use `current_user`, `current_scoped_entity`, `authorized_resource_scope(Model)`, `resource_url_for`, `helpers`, and the dashboard's own private methods. Scope queries with `authorized_resource_scope`, never `Model.all`, on a multi-tenant portal.
19
+ - **Root-qualify packaged models inside a portal dashboard.** Within `module AdminPortal`, `Blogging::Post` resolves to the portal's own `AdminPortal::Blogging` controller namespace and raises `NameError`; write `::Blogging::Post`.
20
+ - **`metric` and `chart` blocks return data; `card` blocks render Phlex markup.** A `card` block is `instance_exec`ed in a Phlex component: `div`, `ul`, `render` work, and unknown methods forward to the dashboard.
21
+ - **`condition:` gates the card endpoint too** (404 when false), unlike a field `condition:`. Page-level access is `authorize?` on the dashboard (403).
22
+ - **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`.
23
+ - **Chart options other than `type:` / `height:` pass straight to Chartkick.** Unknown metric options raise.
24
+ - **Ejected sidebars don't list dashboards automatically.** Portals generated before this feature have their own `_resource_sidebar.html.erb`; add the `registered_dashboards` block (below), which groups them under a "Dashboards" parent after the Home link (`plutonium.resource.nav.home`).
25
+
26
+ ## Minimal dashboard
27
+
28
+ ```ruby
29
+ # packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb
30
+ module AdminPortal
31
+ class SalesDashboard < Plutonium::Dashboard::Base
32
+ presents label: "Sales", description: "Orders and revenue", icon: Phlex::TablerIcons::ChartBar
33
+ refresh 60 # seconds, every lazy card (default off)
34
+
35
+ metric(:orders, icon: Phlex::TablerIcons::ShoppingCart, href: -> { resource_url_for(Order, parent: nil) }) do
36
+ {value: orders.where(created_at: 30.days.ago..).count,
37
+ previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
38
+ change_label: "vs. previous 30 days"}
39
+ end
40
+ metric(:revenue, format: :currency) { orders.sum(:total) }
41
+ metric(:refund_rate, format: :percentage, precision: 1, positive: :down) { {value: 2.1, previous: 1.8} }
42
+
43
+ chart(:revenue_by_day, type: :area, span: 8) { orders.group_by_day(:created_at, last: 30).sum(:total) }
44
+ chart(:by_channel, type: :donut, span: 4) { orders.group(:channel).count }
45
+
46
+ card(:latest, span: :full) do
47
+ ul { latest_orders.each { |o| li { o.number } } }
48
+ end
49
+
50
+ def authorize? = current_user.admin? # optional; default true
51
+
52
+ private
53
+
54
+ def orders = authorized_resource_scope(Order)
55
+ def latest_orders = orders.order(created_at: :desc).limit(5)
56
+ end
57
+ end
58
+
59
+ # packages/admin_portal/config/routes.rb
60
+ AdminPortal::Engine.routes.draw do
61
+ register_dashboard AdminPortal::SalesDashboard, at: "sales" # GET /admin/dashboards/sales, /admin/dashboards/sales/cards/:card
62
+ end
63
+ ```
64
+
65
+ ## Card options (all kinds)
66
+
67
+ | Option | Meaning |
68
+ |---|---|
69
+ | `label:` / `description:` | Text; default from `plutonium.dashboards.<key>.cards.<card>.label`, then the key titleized |
70
+ | `icon:` | `Phlex::TablerIcons::*` class |
71
+ | `span:` | `1`..`12` of a 12-column grid, or `:full` (= 12). Default: metric `3`, chart `6`, card `6`. There is no `columns` macro. Tablet: span >= 6 takes the row, else one of two columns |
72
+ | `lazy:` | `true` (own turbo frame, default) / `false` (inline) |
73
+ | `refresh:` | seconds, overrides the dashboard's; `false` opts this card out of the dashboard's `refresh` |
74
+ | `condition:` | proc (on the instance) or symbol (dashboard method); false hides + 404s |
75
+ | `href:` | path or proc; links the title |
76
+
77
+ ## metric
78
+
79
+ Return a value, or a hash: `value` (required), `previous` (computes % change), `change` (numeric percentage points or a verbatim string), `trend` (`:up`/`:down`/`:flat`, inferred), `change_label`.
80
+
81
+ Options: `format:` (`:number` default, `:currency`, `:percentage`, `:human`, or `->(v) { ... }`), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` default, `:down` when falling is good), `change_label:`.
82
+
83
+ ## chart
84
+
85
+ Return Chartkick data: `{label => value}`, `[[label, value], ...]`, or `[{name:, data:}, ...]`. Date/Time keys draw a time axis. `groupdate` (`group_by_day` etc.) is the natural companion; it is not a dependency.
86
+
87
+ `type:` `:line` (default), `:area`, `:column`, `:bar`, `:pie`, `:donut`, `:scatter`. `height:` default `"240px"`. Everything else (`colors:`, `stacked:`, `min:`, `max:`, `suffix:`, `xtitle:`, `legend:`, `library:` ...) goes to Chartkick. Colours default to `--pu-chart-1..8`; charts redraw on colour-mode change.
88
+
89
+ ## card
90
+
91
+ ```ruby
92
+ card(:onboarding, span: 4) do
93
+ Plutonium::Wizard.in_progress_for(view_context).each do |entry|
94
+ a(href: entry.resume_url, class: "block py-1") { entry.label }
95
+ end
96
+ end
97
+ ```
98
+
99
+ ## Registration
100
+
101
+ ```ruby
102
+ register_dashboard HomeDashboard, at: "/" # root: replaces `root to: "dashboard#index"`
103
+ register_dashboard SalesDashboard, at: "sales" # /dashboards/sales, sales_dashboard_path
104
+ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
105
+ ```
106
+
107
+ - Synthesizes `<Portal>::DashboardsController < <Portal>::PlutoniumController` (auth, tenancy, layout inherited). Define that class yourself, including `Plutonium::Dashboard::Controller`, to customize.
108
+ - Entity-scoped portals: URLs carry the scope segment; use `dashboard_path_for(Klass)` rather than a hand-built helper.
109
+ - Every non-root mount is drawn under `dashboards/` (`at: "sales"` → `/admin/dashboards/sales`), so it cannot collide with a resource route. Helper names carry no prefix. `prefix: nil` mounts at the bare path (`/admin/sales`, keeping it clear of resource routes is then on you); `prefix: "reports"` swaps the segment.
110
+ - Lazy vs inline: see the table below.
111
+ - Errors in a card: raised in development/test. In production the card is replaced by a notice, and the error is logged and reported via `Rails.error` (`source: "plutonium.dashboard"`, `context: {dashboard:, card:}`).
112
+
113
+ ## Lazy vs inline (`lazy: false`)
114
+
115
+ | | `lazy: true` (default) | `lazy: false` (inline) |
116
+ |---|---|---|
117
+ | Where the block runs | In its own request to `<mount>/cards/<key>`, when the frame scrolls into view | In the page request, before the page is sent |
118
+ | What the page response holds | A `<turbo-frame loading="lazy">` with a skeleton | The finished card; no frame, no skeleton, no second request |
119
+ | Cost | One extra request per card; the page itself never waits | The page waits for the block, so every inline card adds its query time to the page |
120
+ | `refresh` | Reloads on the card's or the dashboard's interval | Never refreshes. A number raises `ArgumentError`; the dashboard's `refresh` skips it |
121
+ | `condition:` false | Left out of the page, block never runs, endpoint is 404 | The same |
122
+ | Raises in production | Notice in place of the body, logged and reported | The same; the rest of the page still renders |
123
+ | Raises in development and test | That card's frame request fails; the page and the other cards are fine | The whole page raises, because the card is part of the page |
124
+
125
+ Default to lazy. Use `lazy: false` only for a cheap value (cached number, indexed count) near the top of the page; one slow inline card delays the whole page.
126
+
127
+ ## Sidebar (ejected partial)
128
+
129
+ ```erb
130
+ dashboards = registered_dashboards.reject { |dashboard| dashboard_path_for(dashboard) == root_path }
131
+ if dashboards.any?
132
+ m.item t("plutonium.resource.nav.dashboards"), icon: Phlex::TablerIcons::LayoutDashboard do |n|
133
+ dashboards.each do |dashboard|
134
+ n.item dashboard.label, url: dashboard_path_for(dashboard), icon: dashboard.icon
135
+ end
136
+ end
137
+ end
138
+ ```
139
+
140
+ ## Translations
141
+
142
+ ```yaml
143
+ en:
144
+ plutonium:
145
+ dashboards:
146
+ admin_portal/sales: # Class.i18n_key
147
+ label: "Sales"
148
+ cards:
149
+ orders: { label: "Orders", description: "Last 30 days" }
150
+ ```
151
+
152
+ Portal override: `plutonium.portals.<portal>.dashboards...`.
153
+
154
+ ## Testing
155
+
156
+ Integration-test the page (`get "/admin/dashboards/sales"`, expect one `<turbo-frame ... loading="lazy">` per card) and each card endpoint with the frame header: `get "/admin/dashboards/sales/cards/orders", headers: {"Turbo-Frame" => "pu-dashboard-card-orders"}`. Hidden cards are `:not_found`; a denied `authorize?` is `:forbidden`.
157
+
158
+ ## Full docs
159
+
160
+ - Guide: `/guides/dashboards`
161
+ - Reference: `/reference/dashboard/dsl`, `/reference/dashboard/registration`
@@ -495,7 +495,7 @@ end
495
495
  |--------|-----------|----------|
496
496
  | `field` | Forms + Show + Table | Universal type override |
497
497
  | `input` | Forms only | Form-specific options |
498
- | `display` | Show page only | Display-specific options |
498
+ | `display` | Show page, and the table unless a `column` renders | Display-specific options |
499
499
  | `column` | Table only | Table-specific options |
500
500
 
501
501
  ```ruby
@@ -507,6 +507,17 @@ class PostDefinition < ResourceDefinition
507
507
  end
508
508
  ```
509
509
 
510
+ ⚠️ **A component option (`colors:`, `unit:`, `true_label:`, `formatter:`, …) must be declared on the surface that renders it (`input`, `display`, or `column`), not on `field`.** A component reads its attributes from the surface declaration. Options on `field` go to the Phlexi field builder instead, which consumes the field-level keys (`:label`, `:description`/`:hint`, `:placeholder`) and ignores the rest. So `field :status, as: :badge, colors: {...}` renders a badge everywhere but silently ignores `colors:` and falls back to auto-coloring. `field` is still the right place for the shared `as:` type and for label/description/placeholder.
511
+
512
+ Where to put a component option:
513
+
514
+ - **`display` covers show AND index.** With no `column` declared, the table column inherits the display's `as:` and attributes, so `display :status, as: :badge, colors: {...}` alone colors the badge on both the show page and the table.
515
+ - **A `column` that renders, renders alone.** A `column` with an `as:`, a component attribute, or a block replaces the display for the table: its own `as:` and attributes only, even when the type matches. A `column` that only sets `align:`, `label:` or `condition:` keeps the display's rendering, so `column :status, align: :center` under `display :status, as: :badge, colors: {...}` is still a colored badge.
516
+ - **`align:` and `label:` can live on `field`.** They configure the column header, not the cell, so `field :amount, align: :end` works and never leaks into a component.
517
+ - Use `input` for the form.
518
+
519
+ The field-level help keys (`:label`, `:description`, `:hint`, `:placeholder`) are always stripped before a component sees them, so they never leak as HTML attributes wherever you put them. Other unknown keys are not stripped: a stray option on a surface whose component does not consume it still renders as a raw HTML attribute.
520
+
510
521
  ## Separation of Concerns
511
522
 
512
523
  | Layer | Purpose | Example |
@@ -592,6 +603,13 @@ input :team_members do |f|
592
603
  end
593
604
  ```
594
605
 
606
+ ⚠️ **Don't bind a form field to a bare association name for a multi-select** (say `input :tags` / permit `:tags`). This is by design: param extraction excludes association writers (`submitted_resource_params` keeps a key only when `reflect_on_association(k).nil?`) because `tags=` expects model instances, not param strings, so a bare `tags` param is dropped rather than mis-assigned. Bind to a non-association writer instead:
607
+
608
+ - `input :tag_ids` uses Rails' collection-ids setter. Permit `:tag_ids`; values are plain ids. Simplest for a scoped checkbox list.
609
+ - `input :tags, as: :secure_association` is the native picker. It sets input_param `tag_sgids`, scopes via `choices:` (or the target policy), and validates submitted SGIDs by decoded model-id.
610
+
611
+ ⚠️ Do **not** hand-roll `collection_checkboxes_tag(choices: [[label, record.to_sgid.to_s]])`. SGIDs are non-deterministic (`to_sgid` embeds a timestamp), the component re-derives `choices` at submit and intersects the posted value against the freshly-minted SGIDs, so it never matches and the value is dropped. Use stable ids in custom checkboxes, or `secure_association` (which validates by model-id).
612
+
595
613
  ## Conditional Rendering
596
614
 
597
615
  ```ruby
@@ -602,6 +620,8 @@ field :debug_info, condition: -> { Rails.env.development? }
602
620
 
603
621
  Use `condition` for UI state; use the policy for authorization.
604
622
 
623
+ A surface's own `condition:` wins; otherwise the surface falls back to the `field` condition. So a `field` condition runs on the form, the show page AND the table. **The table has no `object`**, so a `field` condition must not reference the record: `-> { object.published? }` works on the form and show page and raises on the index. Keep a `field` condition to context that exists everywhere (`Rails.env`, a feature flag, `current_user`) and put record checks on `input`/`display`/`column`.
624
+
605
625
  ## Options That Vary Per Render
606
626
 
607
627
  Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
@@ -626,6 +646,38 @@ step :billing, condition: -> { data.plan.tier == "pro" } # the wizard —
626
646
 
627
647
  It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
628
648
 
649
+ ## Internationalization (labels, placeholders, hints)
650
+
651
+ **Never hard-code display text a locale file can supply.** Labels come from `activerecord.attributes.<model>.<attr>` already. Placeholders, hints and descriptions are looked up by convention when the definition leaves them blank, so the translatable default is to declare nothing:
652
+
653
+ ```yaml
654
+ en:
655
+ plutonium:
656
+ fields:
657
+ blogging/post:
658
+ title: { placeholder: "A short, descriptive title", hint: "Shown in search" }
659
+ body: { description: "Rendered as Markdown" }
660
+ actions: { blogging/post: { publish: "Publish now" } }
661
+ scopes: { blogging/post: { drafts: "Drafts" } }
662
+ filters: { blogging/post: { author: "Written by" } }
663
+ kanban_columns: { blogging/post: { in_review: "In review" } }
664
+ wizard_steps: { onboarding_wizard: { billing: "Billing details" } }
665
+ values: { blogging/post: { status: { archived: "Archived" } } } # or activerecord.attributes.<model>.status/archived
666
+ ```
667
+
668
+ Resolution for every slot: explicit option → `plutonium.portals.<portal>.…` → `plutonium.…` → (placeholder only) `helpers.placeholder.<model>.<attr>` → nothing. For actions, an interaction's explicit `presents label:` counts as declared (beats the convention); its class-name default does not. The model segment is `model_name.i18n_key` (`blogging/post`), walked up STI ancestors.
669
+
670
+ When an explicit value must be translated, use the definition's class-level `t`, which is lazy (resolved per render, in the request locale):
671
+
672
+ ```ruby
673
+ input :email, placeholder: t("forms.shared.email_placeholder")
674
+ action :publish, label: t("plutonium.actions.blogging/post.publish")
675
+ ```
676
+
677
+ 🚨 Never `I18n.t(...)` in a class body: it freezes the string in whatever locale was active at load. Use `t(...)` or a `-> { I18n.t(...) }` proc.
678
+
679
+ Full reference: `docs/reference/i18n.md`.
680
+
629
681
  ## Dynamic Forms (`pre_submit`)
630
682
 
631
683
  A `pre_submit: true` field triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
@@ -684,22 +736,30 @@ input :birth_date do |f|
684
736
  end
685
737
  ```
686
738
 
687
- **`phlexi_render` for declarative custom display.** The `with:` option takes either a Phlex component class, or a proc whose body is **rendered inside a Phlex context** — so HTML tags (`span`, `div`, `a`, …) and Tailwind classes are first-class. The proc receives `(value, attrs)` where `value` is the field value and `attrs` are wrapper attributes.
739
+ **Custom display: use the block form.** `display :x do |f| … end` is `instance_exec`ed in the display component's Phlex context (`render instance_exec(f, &block)`), so you emit markup directly, it runs **once** (no per-item loop), and `f.object` is the real record:
688
740
 
689
741
  ```ruby
690
- # Component class — preferred for anything reusable
691
- display :status, as: :phlexi_render, with: StatusBadgeComponent
742
+ # Inline markup. span/div are Phlex tag methods here.
743
+ display :priority do |f|
744
+ variant = {"high" => "danger", "medium" => "warning"}.fetch(f.value.to_s, "info")
745
+ span(class: "pu-badge pu-badge-#{variant}") { f.value.to_s.humanize }
746
+ end
692
747
 
693
- # Inline Phlex proc — `span` here is a Phlex tag method, not Ruby/Rails
694
- display :priority, as: :phlexi_render, with: ->(value, attrs) {
695
- case value
696
- when 'high' then span(class: "badge badge-danger") { "High" }
697
- when 'medium' then span(class: "badge badge-warning") { "Medium" }
698
- else span(class: "badge badge-info") { "Low" }
748
+ # Reaches the record, renders once even for a has_many
749
+ display :tags do |f|
750
+ div(class: "flex flex-wrap gap-1.5") do
751
+ f.object.tags.each { |t| span(class: "pu-badge") { t.name } }
699
752
  end
700
- }
753
+ end
754
+
755
+ # Reusable markup: hand the block a component
756
+ display :card do |f|
757
+ PostCardComponent.new(post: f.object)
758
+ end
701
759
  ```
702
760
 
761
+ > ⚠️ **`as: :phlexi_render, with:` is deprecated. Use the block form above.** It emits a deprecation warning and will be removed. It was strictly worse than the block form: the `with:` proc is `.call`ed with `self` set to the definition (so `span`/`div` in its body raise, and it must instead *return* a component or proc), its `value` arrives stringified (`DisplaysValue#normalize_value` runs `to_s`, so it never reaches the record), and for a `has_many`/`multiple?` field it fires once per element. The deprecation covers only the `as: :phlexi_render` display option. The internal `phlexi_render` helper is unaffected.
762
+
703
763
  See [[plutonium-ui]] for writing custom Phlex components.
704
764
 
705
765
  **Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
@@ -732,6 +792,26 @@ column :full_name do |record|
732
792
  end
733
793
  ```
734
794
 
795
+ **A `column` block runs in a Phlex context** (like a `display` block), so it can emit `span`/`div` directly, or return a String or a component. The block receives the record.
796
+
797
+ ```ruby
798
+ column :status do |record| # emit markup directly
799
+ span(class: "pu-badge") { record.status.humanize }
800
+ end
801
+
802
+ column :full_name do |record| # or return a String, rendered as text
803
+ "#{record.first_name} #{record.last_name}"
804
+ end
805
+
806
+ column :card do |record| # or return a component
807
+ StatusBadgeComponent.new(value: record.status)
808
+ end
809
+
810
+ column :status, as: :badge, colors: {...} # or just declare the type, no block
811
+ ```
812
+
813
+ Inside the block `self` is the table page (the same as a `display` block, where `self` is the show page): `current_user`, `helpers` and `resource_definition` work, methods defined on the definition class do not, so reach through the record you are passed. A block that emits markup must end with a tag call (or `nil`), because Phlex renders the return value too; a number or other scalar return renders as text. Most of the time you need no column block at all, because `display :x, as: …` already flows to the table column (type and attributes). Declare it once on `display` and reach for `column` only to override per-table.
814
+
735
815
  ## Nested Inputs
736
816
 
737
817
  Inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
@@ -926,14 +1006,14 @@ end
926
1006
  - **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option — take a `form` argument to read the render context.
927
1007
  - ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
928
1008
  - **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
929
- - **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. This checks *field presence only*; per-field `condition:` runs later, so to hide a whole section by state, gate it with the **section's own `condition:`**, not by hiding every field inside it.
1009
+ - **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. A section whose fields are **all** hidden by their own `condition:` on this render drops too (the hidden fields are still recorded on the form). A section's own `condition:` hides it as a unit regardless of its fields.
930
1010
  - **Works on interactions too** (`Plutonium::Interaction::Base`) — groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
931
1011
 
932
1012
  Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
933
1013
 
934
1014
  ## Display Layout (`display_layout`)
935
1015
 
936
- The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field sections dropped) — applied to the show page's fields instead of the form's.
1016
+ The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field and all-condition-hidden sections dropped) — applied to the show page's fields instead of the form's.
937
1017
 
938
1018
  ```ruby
939
1019
  class PostDefinition < ResourceDefinition
@@ -1138,7 +1218,7 @@ action :reposition, hidden: true # route + policy predicate, no button
1138
1218
  - **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
1139
1219
  - Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
1140
1220
 
1141
- Full reference: `docs/reference/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
1221
+ Full reference: `docs/reference/resource/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
1142
1222
 
1143
1223
  ---
1144
1224
 
@@ -500,7 +500,7 @@ The URL template is built off `current_page_path` (not `request.path`) so a post
500
500
 
501
501
  **Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
502
502
 
503
- Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/positioning.md`.
503
+ Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/resource/positioning.md`.
504
504
 
505
505
  ---
506
506
 
@@ -548,6 +548,21 @@ Avatar(src: avatar_url) # bare image, no subject/fallback
548
548
 
549
549
  🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only — a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
550
550
 
551
+ ## Translating component text
552
+
553
+ Every `Plutonium::UI::Component::Base` subclass (pages included) has a protected `t(key, **opts)` that reads Rails I18n with a full key; components that subclass Phlexi classes call `Plutonium::Translation.t`. Put new user-facing text in a locale file, never a literal:
554
+
555
+ ```ruby
556
+ def view_template
557
+ button { t("my_app.cards.expand") }
558
+ span { t("my_app.cards.more", count: hidden_count) } # one:/other: in YAML
559
+ end
560
+ ```
561
+
562
+ Rules: one key per sentence with `%{name}` placeholders (never concatenate fragments around a value), `count:` for plurals, no `.downcase`/`.pluralize` on translated nouns. The gem's own keys live under `plutonium.*` in its `config/locales/en/*.yml` and any can be overridden from the app.
563
+
564
+ Stimulus controllers bundled with Plutonium read `plutonium.js.*` from a `<meta name="pu-i18n">` JSON blob the layout renders; host JS can call `window.Plutonium.t("plutonium.js.turbo_confirm.confirm")`. Library locales (Slim Select, flatpickr, Uppy, intl-tel-input) pass through `plutonium.js.libraries.*`. See `docs/reference/i18n.md`.
565
+
551
566
  ## Custom Phlex components
552
567
 
553
568
  ```ruby
@@ -759,6 +774,8 @@ rails generate pu:core:assets
759
774
 
760
775
  This installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers, and points the Plutonium config at your asset files.
761
776
 
777
+ Packages install with the app's package manager, detected from the lockfile (`bun.lock`/`bun.lockb` → bun, `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm, `yarn.lock` → yarn), else the first of bun, yarn, pnpm, npm on PATH. A yarn app stays yarn even with bun installed. Do not run `yarn add` by hand in a bun app: that leaves two lockfiles. Yarn 2+ needs `nodeLinker: node-modules` in `.yarnrc.yml` (the generator writes it); Tailwind's PostCSS plugin does not load under Plug'n'Play.
778
+
762
779
  ## Tailwind config (generated)
763
780
 
764
781
  ```javascript
@@ -1120,6 +1137,10 @@ end
1120
1137
  - **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
1121
1138
  - **`render_actions` is mandatory in custom `form_template`** — otherwise no submit button.
1122
1139
  - **Dropdowns (`resource-drop-down`) teleport their menu to `<body>` while open.** popper's `fixed` strategy alone is still clipped by a transformed + `overflow:hidden` ancestor (e.g. grid cards, app shells), so the controller reparents the open menu to `<body>` and restores it on close. Don't rely on the menu being a DOM child of its trigger while open.
1140
+ - **`DisplaysValue` components stringify the value and loop per item.** `render_value` receives `normalize_value(value)`, which is `value.to_s`, and for a `field.multiple?` (has_many) field it is called once per element (`field.value.each`). So a custom component that inherits `DisplaysValue` only ever sees the stringified value, per item, never the record. When you need `f.object` or whole-collection rendering, use the block-form display (`display :x do |f| … end`), which is `instance_exec`ed in Phlex once and can emit markup directly.
1141
+ - **Blocks run in a Phlex context on every surface.** A `display`, `input`, or `column` block emits `span`/`div` directly, or returns a String or a component. All three are `instance_exec`ed by the resource page rendering them (`self` is the page, not the definition): `display`/`input` blocks receive the field `f`, a `column` block receives the record. Phlex renders the return value too, so a block that emits markup must end with a tag call or `nil`. Most columns need no block at all, because `display :x, as: …` already flows to the table column.
1142
+ - **A component reads its attributes from the surface declaration, not from `field`.** Options on `field` go to the Phlexi field builder, which consumes the field-level keys (`:label`, `:description` for displays, `:hint` for forms, `:placeholder`) and ignores the rest. Component options (`colors:`, `unit:`, …) belong on `input`/`display`/`column`. The four field-level help keys are stripped on every surface (the union), so a form key on a display or vice versa is dropped rather than leaked. Any *other* unknown key on a surface whose component does not consume it still renders as a raw HTML attribute, so keep each option on a surface whose component uses it. The table inherits the display's `as:` and attributes unless a `column` declares its own rendering (an `as:`, a component attribute, or a block); a `column` that only sets the header keys (`align:`, `label:`, `condition:`) keeps the display's rendering. `align:` and `label:` may also be declared on `field`.
1143
+ - **`as: :phlexi_render, with:` is deprecated** (see [[plutonium-resource]] › Custom Rendering). Use a block-form display. This covers only the display option; the internal `phlexi_render` helper (`Component::Behaviour`) stays.
1123
1144
 
1124
1145
  ---
1125
1146
 
data/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.65.0] - 2026-10-02
6
+
7
+ ### Bug Fixes
8
+
9
+ - Stop the nav overflowing, and let crushed tables scroll ([#111](https://github.com/radioactive-labs/plutonium-core/issues/111))
10
+ - Cover auto-detected attachments in attachment_input_keys ([#114](https://github.com/radioactive-labs/plutonium-core/issues/114))
11
+ - Keep the current view when clearing filters from the slideover ([#115](https://github.com/radioactive-labs/plutonium-core/issues/115))
12
+ - Inherit width in subclasses and wrap long stepper labels ([#116](https://github.com/radioactive-labs/plutonium-core/issues/116))
13
+ - Apply association filter's scope: option to dropdown and typeahead ([#151](https://github.com/radioactive-labs/plutonium-core/issues/151))
14
+ - Guard #draw against nil engine for unconventional engine names ([#138](https://github.com/radioactive-labs/plutonium-core/issues/138))
15
+ - Raise ArgumentError correctly in authorized_resource_scope ([#140](https://github.com/radioactive-labs/plutonium-core/issues/140))
16
+ - Rescue FloatDomainError in has_cents setters ([#147](https://github.com/radioactive-labs/plutonium-core/issues/147))
17
+ - Honour field condition: on forms by reading before stripping ([#149](https://github.com/radioactive-labs/plutonium-core/issues/149))
18
+ - Preserve sort and scope when applying filters ([#145](https://github.com/radioactive-labs/plutonium-core/issues/145))
19
+ - Neutralize full-width formula characters in CSV export ([#144](https://github.com/radioactive-labs/plutonium-core/issues/144))
20
+ - Strip leading dot from extension fallback in attachment thumbnails ([#143](https://github.com/radioactive-labs/plutonium-core/issues/143))
21
+ - Reject SGIDs whose model class has been removed ([#142](https://github.com/radioactive-labs/plutonium-core/issues/142))
22
+ - Require Stimulus entrypoint alongside Tailwind ([#141](https://github.com/radioactive-labs/plutonium-core/issues/141))
23
+ - Raise ::ActionPolicy::Unauthorized on denied wizard entry ([#148](https://github.com/radioactive-labs/plutonium-core/issues/148))
24
+ - Raise NotImplementedError instead of returning non-existent EntityMembership ([#150](https://github.com/radioactive-labs/plutonium-core/issues/150))
25
+ - Apply association filter's scope: option to filter pill labels ([#155](https://github.com/radioactive-labs/plutonium-core/issues/155))
26
+ - Prevent duplicate memberships via locked state re-check ([#139](https://github.com/radioactive-labs/plutonium-core/issues/139))
27
+ - Keep .env.test.local git-ignored in dotenv generator ([#146](https://github.com/radioactive-labs/plutonium-core/issues/146))
28
+ - JSON-encode Array/Hash direct-upload values for Stimulus ([#121](https://github.com/radioactive-labs/plutonium-core/issues/121))
29
+ - Float slim-select dropdown above modal instead of clipping it ([#157](https://github.com/radioactive-labs/plutonium-core/issues/157))
30
+ - Validate async file inputs by declaration, not value shape ([#169](https://github.com/radioactive-labs/plutonium-core/issues/169))
31
+ - Use sqlserver JDBC subadapter for SQL Server on JRuby ([#168](https://github.com/radioactive-labs/plutonium-core/issues/168))
32
+ - Install assets with the app's package manager, not yarn 1 ([#170](https://github.com/radioactive-labs/plutonium-core/issues/170))
33
+ - Npm install in docker-compose, document jsbundling detection accurately ([#172](https://github.com/radioactive-labs/plutonium-core/issues/172))
34
+ - Drop sections whose fields are all condition-hidden, and leave hidden inputs out of wizard review ([#173](https://github.com/radioactive-labs/plutonium-core/issues/173))
35
+
36
+ ### Documentation
37
+
38
+ - Publish the introduction post ([#108](https://github.com/radioactive-labs/plutonium-core/issues/108))
39
+ - Per-post og cards, mobile search fix, complete configuration reference ([#109](https://github.com/radioactive-labs/plutonium-core/issues/109))
40
+ - Add the four unreachable pages to the landing rail ([#110](https://github.com/radioactive-labs/plutonium-core/issues/110))
41
+ - Note that width inherits to subclasses ([#120](https://github.com/radioactive-labs/plutonium-core/issues/120))
42
+ - Note reserved filter names in query reference ([#156](https://github.com/radioactive-labs/plutonium-core/issues/156))
43
+
44
+ ### Features
45
+
46
+ - Reject filters named after reserved query controls ([#153](https://github.com/radioactive-labs/plutonium-core/issues/153))
47
+ - [**breaking**] Render column blocks in a Phlex context and layer header-only columns on the display ([#161](https://github.com/radioactive-labs/plutonium-core/issues/161))
48
+ - Make every Plutonium string and derived label translatable ([#162](https://github.com/radioactive-labs/plutonium-core/issues/162))
49
+ - Declarative dashboards with metric/chart/card DSL and pu:dashboard generator ([#163](https://github.com/radioactive-labs/plutonium-core/issues/163))
50
+
51
+ ### Miscellaneous Tasks
52
+
53
+ - Remove dead rabl config and template-handler block ([#117](https://github.com/radioactive-labs/plutonium-core/issues/117))
54
+ - Remove dead commented-out actions ([#118](https://github.com/radioactive-labs/plutonium-core/issues/118))
55
+ - Remove dead PlutoniumGenerators::Installer module and Config concern ([#119](https://github.com/radioactive-labs/plutonium-core/issues/119))
56
+ - Remove commented-out create_presenter/create_query_object scaffold methods and orphaned templates ([#165](https://github.com/radioactive-labs/plutonium-core/issues/165))
57
+ - Remove unused Serializer concern and commented-out appname helpers ([#164](https://github.com/radioactive-labs/plutonium-core/issues/164))
58
+ - Remove dead pu:field:input generator ([#160](https://github.com/radioactive-labs/plutonium-core/issues/160))
59
+ - Remove dead turbo-rails monkey patches initializer ([#159](https://github.com/radioactive-labs/plutonium-core/issues/159))
60
+ - Remove dead FieldImporter::Spec#validate method ([#158](https://github.com/radioactive-labs/plutonium-core/issues/158))
61
+ - Remove unused invoke_options helper ([#166](https://github.com/radioactive-labs/plutonium-core/issues/166))
62
+ - Update claude.md
63
+
5
64
  ## [0.64.0] - 2026-08-22
6
65
 
7
66
  ### Miscellaneous Tasks