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
@@ -0,0 +1,175 @@
1
+ ---
2
+ title: "Introducing Plutonium dashboards: metric cards and charts for your Rails admin"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-10-01
5
+ description: Plutonium now ships dashboards. Declare metric, chart and free-form cards in one Ruby class, mount it with one routes line, and every card loads in its own turbo frame inside your portal's auth and tenancy.
6
+ author: Stefan Froelich
7
+ tags: [announcement, dashboards, charts]
8
+ draft: true
9
+ ---
10
+
11
+ # Introducing Plutonium dashboards: metric cards and charts for your Rails admin
12
+
13
+ <BlogMeta />
14
+
15
+ Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. The next release ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
16
+
17
+ ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/blog/dashboards-overview.png)
18
+
19
+ ## What's in the box
20
+
21
+ - A class-level DSL with three kinds of card: `metric` for headline numbers, `chart` for charts, `card` for anything else.
22
+ - `register_dashboard`, which draws the routes and a controller that inherits your portal's authentication, tenant scoping and layout.
23
+ - Lazy loading: every card is fetched in its own turbo frame, behind a skeleton.
24
+ - A 12-column grid with a default width per kind of card, plus per-card refresh intervals, conditions, links and icons.
25
+ - A `pu:dashboard` generator, and a Dashboards group in the portal sidebar.
26
+
27
+ ## Generate one
28
+
29
+ ```bash
30
+ rails g pu:dashboard Sales --dest=admin_portal
31
+ ```
32
+
33
+ This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds the registration to the portal's routes:
34
+
35
+ ```ruby
36
+ AdminPortal::Engine.routes.draw do
37
+ register_dashboard AdminPortal::SalesDashboard, at: "sales"
38
+ end
39
+ ```
40
+
41
+ The page is served at `/admin/dashboards/sales`. Pass `--at=/` instead and the dashboard becomes the portal's root page, replacing the generated `root to: "dashboard#index"` (the old `DashboardController` and its view can then go).
42
+
43
+ ## Declare the cards
44
+
45
+ ```ruby
46
+ module AdminPortal
47
+ class SalesDashboard < Plutonium::Dashboard::Base
48
+ presents label: "Sales", icon: Phlex::TablerIcons::ChartBar
49
+
50
+ refresh 60
51
+
52
+ metric(:orders, icon: Phlex::TablerIcons::ShoppingCart, href: -> { resource_url_for(Order, parent: nil) }) do
53
+ {value: orders.where(created_at: 30.days.ago..).count,
54
+ previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
55
+ change_label: "vs. previous 30 days"}
56
+ end
57
+
58
+ metric(:revenue, format: :currency) { orders.sum(:total) }
59
+
60
+ metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
61
+ {value: 2.1, previous: 1.8}
62
+ end
63
+
64
+ chart(:orders_per_day, type: :area, span: 8) do
65
+ orders.pluck(:created_at).map(&:to_date).tally.sort.to_h
66
+ end
67
+
68
+ chart(:by_status, type: :donut, span: 4) { orders.group(:status).count }
69
+
70
+ card(:latest, span: :full) do
71
+ ul { latest_orders.each { |order| li { order.number } } }
72
+ end
73
+
74
+ private
75
+
76
+ def orders = authorized_resource_scope(Order)
77
+
78
+ def latest_orders = orders.order(created_at: :desc).limit(5)
79
+ end
80
+ end
81
+ ```
82
+
83
+ **Metrics.** The block returns a number, or a hash. Give it a `previous` value and the card computes the percentage change and shows it with a trend arrow. `format:` is `:number`, `:currency`, `:percentage`, `:human` (1.23 Million) or a proc. `positive: :down` says a falling number is the good direction, so the refund rate above, up from 1.8 to 2.1, is styled as bad news.
84
+
85
+ **Charts.** The block returns [Chartkick data](https://chartkick.com/#data): a hash, an array of pairs, or an array of named series. `type:` is `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`, and every other option passes straight through to Chartkick.
86
+
87
+ **Custom cards.** The block renders Phlex markup inside the card, for a recent-activity list, a call to action, or whatever the first two do not cover.
88
+
89
+ **Layout.** The grid is 12 columns wide, so halves, thirds and quarters can share a dashboard. `span:` says how many a card takes. Left out, a metric takes 3 (four to a row) and a chart or custom card takes 6. Above, the area chart takes two thirds of its row and the donut the remaining third. Tablets drop to two columns and phones to one.
90
+
91
+ Blocks run on the dashboard instance, so private methods are how cards share a query. `authorized_resource_scope(Order)` is the same policy scope your resource pages use: the dashboard counts what this user may see, in this tenant, without restating either rule.
92
+
93
+ ## Every card loads on its own
94
+
95
+ The page response contains no query results. Each card renders as a lazy turbo frame holding a skeleton:
96
+
97
+ ```html
98
+ <turbo-frame id="pu-dashboard-card-orders" src="/admin/dashboards/sales/cards/orders" loading="lazy">
99
+ ```
100
+
101
+ The browser fetches each frame as it scrolls into view, and the card's block runs in that request. The page paints at once, the cheap cards fill in first, and one expensive aggregate delays itself and nothing else.
102
+
103
+ ![The same dashboard before its frames have loaded: every card is a grey skeleton except the Conversion metric, which is already showing 12.5%](/images/blog/dashboards-loading.png)
104
+
105
+ The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload.
106
+
107
+ `refresh 60` on the dashboard reloads every lazy card once a minute. `refresh: 10` on a card overrides it, and `refresh: false` keeps an expensive card out of it. Reloads pause while the tab is hidden and catch up when it becomes visible again, so a dashboard left open overnight is not running your aggregates all night.
108
+
109
+ ## Who sees what
110
+
111
+ A portal dashboard sits behind the portal's login like every other page. To restrict it further, override `authorize?`; a false answer is a 403 from the page and from every card URL.
112
+
113
+ ```ruby
114
+ def authorize? = current_user.admin?
115
+ ```
116
+
117
+ `condition:` hides a single card. Because a card is an endpoint, the condition guards the endpoint too: a hidden card responds 404, so it cannot be fetched by guessing its key.
118
+
119
+ ```ruby
120
+ metric(:margin, format: :percentage, condition: -> { current_user.admin? }) { margin }
121
+ ```
122
+
123
+ On an entity-scoped portal the page and card URLs carry the tenant segment (`/org/1/dashboards/team`) and `current_scoped_entity` is available in every block:
124
+
125
+ ```ruby
126
+ class TeamDashboard < Plutonium::Dashboard::Base
127
+ metric(:members) { current_scoped_entity.memberships.count }
128
+ end
129
+ ```
130
+
131
+ ## When a card breaks
132
+
133
+ In development and test a card that raises, raises, and you fix the query. In production the card shows a short notice in place of its body and the rest of the dashboard is untouched.
134
+
135
+ ![A dashboard where the Revenue card shows a red "This card could not be loaded." notice while the metrics and the signups chart around it render normally](/images/blog/dashboards-card-error.png)
136
+
137
+ The failure is written to the Rails log with the dashboard class, the card key and the top of the backtrace, and it is reported through `Rails.error`:
138
+
139
+ ```ruby
140
+ Rails.error.report(
141
+ error,
142
+ handled: true,
143
+ source: "plutonium.dashboard",
144
+ context: {dashboard: "AdminPortal::SalesDashboard", card: "revenue"}
145
+ )
146
+ ```
147
+
148
+ Both happen because `Rails.error.report` notifies subscribers and writes nothing to the log. An app with no error tracker still gets a log line; an app with Sentry, Honeybadger or AppSignal subscribed gets a handled error it can group by card and alert on.
149
+
150
+ ## Charts without the bundle tax
151
+
152
+ Charts render with [Chart.js](https://www.chartjs.org/) through Chartkick, which is a lot of JavaScript for an app where most pages are tables and forms. It ships as a separate bundle. A chart card carries the bundle's URL in a data attribute and the `chart` Stimulus controller injects the script the first time a chart connects, so pages without a chart never download it.
153
+
154
+ Series colours come from the `--pu-chart-1` to `--pu-chart-8` design tokens and are re-read when the colour mode flips. Charts follow your theme in light and dark mode with no per-chart configuration.
155
+
156
+ ## In the sidebar
157
+
158
+ Registered dashboards are grouped under a Dashboards item in the portal sidebar, with a link per dashboard. A dashboard mounted at the root is what the Home link opens, so it stays out of the group.
159
+
160
+ ![The portal's icon rail with the Dashboards item open, listing Overview and Content](/images/blog/dashboards-sidebar.png)
161
+
162
+ If your portal has an ejected `_resource_sidebar.html.erb` from an earlier release, the [guide](/guides/dashboards#sidebar) has the block to add.
163
+
164
+ ## A note on URLs
165
+
166
+ Every dashboard is drawn under a `dashboards/` segment. A portal's URL space already belongs to `register_resource`, and a dashboard at a bare `/admin/sales` is one `Sale` model away from a route collision that Rails resolves silently, by order. If you want the bare path anyway, `prefix: nil` turns the segment off for that mount.
167
+
168
+ ## Experimental, for now
169
+
170
+ Dashboards ship marked experimental, like kanban, wizards and async interactions: fine to build on, but the DSL may still move before 1.0. If the API fights you, tell me. That feedback still changes things.
171
+
172
+ ## Where to go next
173
+
174
+ - [Dashboards guide](/guides/dashboards): the worked example, every card option, layout and translations.
175
+ - [Dashboard reference](/reference/dashboard/): the DSL, `register_dashboard` and the routes it draws, and the generator.
@@ -0,0 +1,91 @@
1
+ ---
2
+ title: "Introducing Plutonium i18n: every string translatable, the Rails way"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-09-17
5
+ description: The text a framework renders has always been the framework's, not yours. Plutonium now routes every string it draws through a locale file, so translating the UI is a matter of YAML and nothing else.
6
+ author: Stefan Froelich
7
+ tags: [announcement, i18n, rails]
8
+ draft: true
9
+ ---
10
+
11
+ # Introducing Plutonium i18n: every string translatable, the Rails way
12
+
13
+ <BlogMeta />
14
+
15
+ You have always been able to translate your own app. Rails has shipped `I18n.t`, per-model attribute names and localized dates since long before you needed them. What you could not translate was the framework on top. The button that says "Create", the "Search..." in the filter box, the flash that a record was saved, the empty-state line, the sentence under the paginator: an admin framework ships those in English, baked into views you adopted the framework precisely so you would never open. Translating them meant forking them.
16
+
17
+ Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition, a policy or a controller changes.
18
+
19
+ ## The strings come from YAML
20
+
21
+ Plutonium's own text lives under `config/locales/en/*.yml`, in a `plutonium` namespace, loaded at the lowest precedence. Rails' normal load order does the rest: your app's `config/locales` beats a portal's, a portal's beats the gem's. To reword a fixed string, or translate it, copy its key into your own file:
22
+
23
+ ```yaml
24
+ # config/locales/en.yml
25
+ en:
26
+ plutonium:
27
+ boolean:
28
+ "true": "On"
29
+ "false": "Off"
30
+ ```
31
+
32
+ That one move covers renaming "On" to "Active" in English and translating it to another language. There is no second mechanism to learn for the second case.
33
+
34
+ ## The labels you never wrote translate too
35
+
36
+ The strings above are the easy half. The interesting half is the text a definition never spells out. An action, a scope, a filter, a kanban column, a field's placeholder: each one defaults to a humanized version of its key, and each has a convention key that a locale file can supply.
37
+
38
+ ```yaml
39
+ en:
40
+ plutonium:
41
+ actions:
42
+ blogging/post:
43
+ publish: "Publish now"
44
+ scopes:
45
+ blogging/post:
46
+ drafts: "Drafts"
47
+ fields:
48
+ blogging/post:
49
+ title:
50
+ placeholder: "A short, descriptive title"
51
+ hint: "Shown in search results"
52
+ ```
53
+
54
+ A field you never annotated is already translatable. You did not declare a placeholder to get one, and you do not declare a key to translate it: Plutonium looks it up by the model's `i18n_key`, the same way `human_attribute_name` walks ancestors, so a key on `blogging/post` also serves its STI subclasses. Model and attribute names flow from the standard Rails `activerecord` keys, which is why column headers, page titles and flashes come along the moment you provide them. Nothing is declared twice, and the exception, an explicit label you do want to spell out, gets a named place: the definition's own `t`, resolved per request in the right locale.
55
+
56
+ ## Switching locale is a Rails around_action
57
+
58
+ Plutonium reads `I18n.locale` on every render and never sets it, so you switch the way you would in any Rails app. The dummy app's admin portal wires an `EN | ES` toggle into the top bar and remembers the choice per session:
59
+
60
+ ```ruby
61
+ included do
62
+ around_action :switch_locale
63
+ end
64
+
65
+ def switch_locale(&)
66
+ session[:locale] = params[:locale] if params[:locale].present?
67
+ locale = session[:locale].to_s.to_sym
68
+ locale = I18n.default_locale unless I18n.available_locales.include?(locale)
69
+ I18n.with_locale(locale, &)
70
+ end
71
+ ```
72
+
73
+ Pick ES and the portal answers in Spanish: the heading, the search box, the filters and scopes, the column headers, the row and page actions, the status badges, the pager and the CRUD flashes. The heading translates because the definition sets it with the same lazy `t`, `index_page_title t("blog.index.title")`, that inputs and labels use. Two things stay English by design: the record data, because it is yours and not the framework's, and any label a definition hardcodes as a plain string, like the `column :user, label: "Author"` you can see in the shot sitting next to the translated `Autor`.
74
+
75
+ ![The dummy admin portal rendered in Spanish, with the EN | ES switch in the top bar](/images/blog/i18n-es-admin.png)
76
+
77
+ ## Even the parts that aren't Rails views
78
+
79
+ Two surfaces never touch Rails' server-side `t`, and both come along.
80
+
81
+ The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page in the current locale. Override a key in YAML and the bundled JavaScript's text changes with it, no rebuild. And pagination, which has its own dictionary, is translated by [Pagy](https://ddnexus.github.io/pagy/resources/i18n) in about thirty-five languages out of the box. Plutonium syncs Pagy's locale to `I18n.locale` on every render, so the same `around_action` that switched the chrome switched the pager, in the screenshot above, for free.
82
+
83
+ ## Ship a half-translated locale
84
+
85
+ You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the test environment turns on `raise_on_missing_translations`, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
86
+
87
+ ## Adding a language
88
+
89
+ A full second locale is the usual Rails checklist: a [rails-i18n](https://github.com/svenfuchs/rails-i18n) locale for dates and numbers, a Pagy dictionary, `rodauth-i18n` if you use Rodauth, the `plutonium` namespace translated from the gem's English files, and your own models and attributes. Plutonium itself ships only English, and hands you the door to the rest.
90
+
91
+ The [internationalization reference](/reference/i18n) has the full key catalogue and the exact resolution order for every slot.
@@ -1,11 +1,10 @@
1
1
  ---
2
2
  title: "Introducing Plutonium: Rails conventions, past CRUD"
3
3
  titleTemplate: "Plutonium Blog"
4
- date: 2026-08-24
4
+ date: 2026-08-22
5
5
  description: Rails made a bargain. Follow the conventions and the framework carries you. Plutonium makes the same bargain about the layer Rails deliberately left alone.
6
6
  author: Stefan Froelich
7
7
  tags: [announcement, rails, architecture]
8
- draft: true
9
8
  ---
10
9
 
11
10
  # Introducing Plutonium: Rails conventions, past CRUD
@@ -146,11 +145,11 @@ input :content, as: :markdown
146
145
  display :status, as: :badge, colors: {published: :accent, draft: :neutral}
147
146
  ```
148
147
 
149
- **Render it inline.** A display takes a block, or a proc that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
148
+ **Render it inline.** A display takes a block that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
150
149
 
151
150
  ```ruby
152
- display :priority, as: :phlexi_render, with: ->(value, attrs) do
153
- span(class: "pu-badge") { value.humanize }
151
+ display :priority do |field|
152
+ span(class: "pu-badge") { field.value.to_s.humanize }
154
153
  end
155
154
  ```
156
155
 
@@ -119,7 +119,7 @@ See [Wizards](/guides/wizards) for the full DSL, including per-step writes, resu
119
119
 
120
120
  ## The thing underneath the board
121
121
 
122
- Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
122
+ Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/resource/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
123
123
 
124
124
  A board and a sortable table are the same feature wearing different clothes, which is why they share a vocabulary. Positioning itself is stable, not experimental.
125
125
 
@@ -0,0 +1,300 @@
1
+ # Dashboards
2
+
3
+ ::: warning Experimental
4
+ Dashboards are experimental: the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ A dashboard is a page of cards: headline numbers, charts and free-form panels, declared in one Ruby class and mounted with a single routes line. Every card loads in its own lazy turbo frame, so the page paints immediately and each card's queries run in a separate request as it scrolls into view.
8
+
9
+ ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/guides/dashboard-overview.png)
10
+
11
+ ## What you get
12
+
13
+ - A `Plutonium::Dashboard::Base` subclass with a class-level DSL: `metric`, `chart` and `card`.
14
+ - `register_dashboard` in a portal's routes draws the page, the per-card endpoint and a synthesized controller that inherits the portal's auth, tenant scoping and layout.
15
+ - Metric cards format numbers (delimited, currency, percentage, human) and show a change indicator against a previous value.
16
+ - Chart cards render with [Chart.js](https://www.chartjs.org/) through [Chartkick](https://chartkick.com/), using Plutonium's design tokens in light and dark mode. The chart bundle loads on demand, so pages without a chart never download it.
17
+ - Cards sit on a 12-column grid with a sensible default width per kind, and can refresh themselves on an interval, link somewhere, and hide behind a condition.
18
+ - Registered dashboards appear in the portal sidebar.
19
+
20
+ ## Worked example
21
+
22
+ ### 1. Generate the dashboard
23
+
24
+ ```bash
25
+ rails g pu:dashboard Sales --dest=admin_portal
26
+ ```
27
+
28
+ This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds `register_dashboard AdminPortal::SalesDashboard, at: "sales"` to the portal's routes. Pass `--at=/` to make the dashboard the portal's root page instead; see [Replacing the portal's default page](#replacing-the-portal-s-default-page).
29
+
30
+ ### 2. Declare the cards
31
+
32
+ ```ruby
33
+ module AdminPortal
34
+ class SalesDashboard < Plutonium::Dashboard::Base
35
+ presents label: "Sales", description: "Orders and revenue at a glance",
36
+ icon: Phlex::TablerIcons::ChartBar
37
+
38
+ refresh 60
39
+
40
+ metric(:orders, icon: Phlex::TablerIcons::ShoppingCart,
41
+ href: -> { resource_url_for(Order, parent: nil) }) do
42
+ {value: orders.where(created_at: 30.days.ago..).count,
43
+ previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
44
+ change_label: "vs. previous 30 days"}
45
+ end
46
+
47
+ metric(:revenue, format: :currency) { orders.sum(:total) }
48
+
49
+ metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
50
+ {value: refund_rate, previous: previous_refund_rate}
51
+ end
52
+
53
+ metric(:customers, format: :human) { Customer.count }
54
+
55
+ chart(:revenue_by_day, type: :area, span: 8) do
56
+ orders.group_by_day(:created_at, last: 30).sum(:total)
57
+ end
58
+
59
+ chart(:by_channel, type: :donut, span: 4) do
60
+ orders.group(:channel).count
61
+ end
62
+
63
+ card(:latest, span: :full) do
64
+ ul(class: "divide-y divide-[var(--pu-border-muted)]") do
65
+ latest_orders.each do |order|
66
+ li(class: "py-2 flex justify-between") do
67
+ span { order.number }
68
+ span(class: "text-[var(--pu-text-muted)]") { helpers.number_to_currency(order.total) }
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def orders = authorized_resource_scope(Order)
77
+ def latest_orders = orders.order(created_at: :desc).limit(5)
78
+ def refund_rate = 100.0 * orders.refunded.count / [orders.count, 1].max
79
+ def previous_refund_rate = 1.8
80
+ end
81
+ end
82
+ ```
83
+
84
+ `group_by_day` comes from the [groupdate](https://github.com/ankane/groupdate) gem, which pairs well with chart cards but is not required.
85
+
86
+ ### 3. Visit it
87
+
88
+ ![The dashboard before its frames have loaded: every card is a skeleton except the inline Conversion metric](/images/guides/dashboard-loading.png)
89
+
90
+ The page is at `/admin/dashboards/sales`. Each card is a `<turbo-frame src="/admin/dashboards/sales/cards/<key>" loading="lazy">` holding a skeleton until its response lands. Every mount sits under `dashboards/`, so `at: "sales"` never collides with a `Sale` resource registered on the same portal. Pass `prefix: nil` to mount at the bare path (`/admin/sales`), or a string to use another segment (`prefix: "reports"`).
91
+
92
+ ## Replacing the portal's default page
93
+
94
+ Every generated portal opens on `root to: "dashboard#index"`, a `DashboardController` and a view that lists the registered resources. To make a dashboard the portal's root page instead, mount it at `/`:
95
+
96
+ ```bash
97
+ rails g pu:dashboard Home --dest=admin_portal --at=/
98
+ ```
99
+
100
+ The generator writes `AdminPortal::HomeDashboard` and replaces the `root to:` line with `register_dashboard AdminPortal::HomeDashboard, at: "/"`. The page is served at the portal root (`/admin`), its cards at `/admin/dashboards/home/cards/<key>`, and the sidebar's Home link keeps pointing at it. A root-mounted dashboard is the Home link, so it is left out of the Dashboards group.
101
+
102
+ The generator does not delete the old `DashboardController` or its `dashboard/index.html.erb`; nothing routes to them any more, so remove them yourself:
103
+
104
+ ```bash
105
+ rm packages/admin_portal/app/controllers/admin_portal/dashboard_controller.rb
106
+ rm packages/admin_portal/app/views/admin_portal/dashboard/index.html.erb
107
+ ```
108
+
109
+ The same registration by hand, for an existing portal:
110
+
111
+ ```ruby
112
+ AdminPortal::Engine.routes.draw do
113
+ register_dashboard AdminPortal::HomeDashboard, at: "/" # in place of root to: "dashboard#index"
114
+ # ...
115
+ end
116
+ ```
117
+
118
+ Only one root per portal: keep either the `root to:` line or the `at: "/"` registration, never both.
119
+
120
+ ## Cards
121
+
122
+ All three kinds share the same options:
123
+
124
+ | Option | Meaning |
125
+ |---|---|
126
+ | `label:` | Card title. Defaults to a locale convention, then the key titleized. |
127
+ | `description:` | Caption under the title. |
128
+ | `icon:` | A `Phlex::TablerIcons::*` class shown beside the title. |
129
+ | `span:` | Columns of the 12-column grid to span: `1` to `12`, or `:full`. Defaults to `3` for a metric and `6` for a chart or a custom card. See [Layout](#layout). |
130
+ | `lazy:` | `true` (default) loads the card in its own turbo frame; `false` renders it inline with the page. See [Lazy and inline cards](#lazy-and-inline-cards). |
131
+ | `refresh:` | Seconds between automatic reloads of the card's frame. Needs `lazy: true`. `false` opts the card out of the dashboard's `refresh`. |
132
+ | `condition:` | A proc or a symbol naming a dashboard method. When false the card is left out of the page and its endpoint responds 404. |
133
+ | `href:` | A path, or a proc returning one, that links the title. |
134
+
135
+ Blocks run on the dashboard instance, which exposes `current_user`, `current_scoped_entity`, `params`, `authorized_resource_scope`, `resource_url_for`, `allowed_to?`, `helpers` (the view context) and the dashboard's own private methods. A one-argument block receives the dashboard instead.
136
+
137
+ ### Metric
138
+
139
+ The block returns a value, or a hash with a change:
140
+
141
+ ```ruby
142
+ metric(:signups) { User.count } # just the number
143
+ metric(:mrr, format: :currency) { {value: 12_400, previous: 11_000} } # +12.7%
144
+ metric(:latency, suffix: " ms") { {value: 182, change: "-14 ms", trend: :down} }
145
+ metric(:churn, format: :percentage, positive: :down) { {value: 1.2, previous: 1.0} }
146
+ ```
147
+
148
+ | Key | Meaning |
149
+ |---|---|
150
+ | `value` | The number (or string) to show. `nil` renders an em dash. |
151
+ | `previous` | Computes the percentage change from this value. |
152
+ | `change` | A number in percentage points (`2.5` renders `+2.5%`) or a string shown verbatim. |
153
+ | `trend` | `:up`, `:down` or `:flat`. Inferred from the sign of `change` when omitted. |
154
+ | `change_label` | Caption after the change, such as "vs. last month". Also a card option. |
155
+
156
+ Metric options: `format:` (`:number`, `:currency`, `:percentage`, `:human`, or a proc receiving the value), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` by default; `:down` when a falling number is good) and `change_label:`.
157
+
158
+ ### Chart
159
+
160
+ The block returns [Chartkick data](https://chartkick.com/#data): a `{label => value}` hash, an array of pairs, or an array of `{name:, data:}` series. Date and time keys become a time axis.
161
+
162
+ ```ruby
163
+ chart(:signups, type: :line) { User.group_by_day(:created_at, last: 30).count }
164
+ chart(:plans, type: :pie) { Subscription.group(:plan).count }
165
+ chart(:traffic, type: :column, stacked: true) do
166
+ [{name: "Web", data: web_by_week}, {name: "Mobile", data: mobile_by_week}]
167
+ end
168
+ ```
169
+
170
+ `type:` is one of `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`; `height:` sets the drawing area (default `"240px"`). Every other option (`colors:`, `stacked:`, `min:`, `max:`, `prefix:`, `suffix:`, `xtitle:`, `ytitle:`, `legend:`, `curve:`, `points:`, `library:`, ...) is passed straight through to Chartkick. Series colours default to the `--pu-chart-1` to `--pu-chart-8` design tokens.
171
+
172
+ ### Custom
173
+
174
+ The block renders Phlex markup inside the card body. It is evaluated in the card component, so `div`, `ul`, `render` and the rest are available, and any method it calls that the component lacks is forwarded to the dashboard.
175
+
176
+ ```ruby
177
+ card(:onboarding, description: "Where new tenants are") do
178
+ Plutonium::Wizard.in_progress_for(view_context).each do |entry|
179
+ a(href: entry.resume_url, class: "block py-1") { entry.label }
180
+ end
181
+ end
182
+ ```
183
+
184
+ ## Layout
185
+
186
+ The grid is 12 columns wide on large screens, so halves, thirds and quarters all divide evenly and can share a dashboard. `span:` says how many of the 12 a card takes. Left out, a metric takes `3` (four to a row) and a chart or a custom card takes `6` (two to a row).
187
+
188
+ ```ruby
189
+ metric(:orders) { orders.count } # 3 of 12: four to a row
190
+ metric(:revenue, span: 6, format: :currency) { revenue } # a headline number, half the row
191
+ chart(:revenue_by_day, type: :area, span: 8) { by_day } # two thirds
192
+ chart(:by_channel, type: :donut, span: 4) { by_channel } # the remaining third
193
+ card(:latest, span: :full) { render_latest } # the whole row (same as 12)
194
+ ```
195
+
196
+ Cards fill rows in declaration order and wrap when the next card does not fit. Tablets get two columns: a card with a span of `6` or more takes the full row, anything narrower takes one column. Phones get a single column.
197
+
198
+ `width` sets the page width with the same tokens as resource pages (`:sm` to `:full`) and defaults to `:full`.
199
+
200
+ ## Lazy and inline cards
201
+
202
+ Every card is lazy unless you say otherwise. `lazy: false` makes it inline: rendered with the page instead of fetched after it.
203
+
204
+ | | `lazy: true` (default) | `lazy: false` (inline) |
205
+ |---|---|---|
206
+ | 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 |
207
+ | What the page response holds | A `<turbo-frame loading="lazy">` with a skeleton | The finished card; no frame, no skeleton, no second request |
208
+ | 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 |
209
+ | `refresh` | Reloads on the card's or the dashboard's interval | Never refreshes. A number raises `ArgumentError`; the dashboard's `refresh` skips it |
210
+ | `condition:` false | Left out of the page, block never runs, endpoint is 404 | The same |
211
+ | Raises in production | Notice in place of the body, logged and reported | The same; the rest of the page still renders |
212
+ | 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 |
213
+
214
+ Make a card inline when its value is cheap (a cached number, an indexed count) and sits at the top of the page, where a skeleton flash and an extra request cost more than the query. Leave everything else lazy: one slow inline card delays the whole page, which is the problem lazy cards exist to solve.
215
+
216
+ ## Refreshing
217
+
218
+ `refresh 60` on the dashboard reloads every lazy card once a minute; `refresh: 10` on a card overrides it, and `refresh: false` keeps a card out of it (an expensive chart that does not need to be live). Reloads pause while the tab is hidden and catch up when it becomes visible. A chart re-renders in place when its data changes.
219
+
220
+ ## Authorization
221
+
222
+ A portal dashboard sits behind the portal's authentication like every other page. To restrict it further, override `authorize?`; a false answer is a 403 on the page and on every card endpoint:
223
+
224
+ ```ruby
225
+ def authorize? = current_user.admin?
226
+ ```
227
+
228
+ Use `condition:` to hide an individual card. The condition gates the card's endpoint as well, so a hidden card cannot be fetched by URL. An inline card behaves the same way.
229
+
230
+ ## Errors
231
+
232
+ When `config.consider_all_requests_local` is off (production), a card whose block raises renders a short notice in its place, and the rest of the dashboard is unaffected. The failure is written to the Rails log and reported through `Rails.error` with `source: "plutonium.dashboard"` and a `context` naming the dashboard class and the card key, so an error tracker subscribed to `Rails.error` (Sentry, Honeybadger, AppSignal) receives it. In development and test the error is raised so it is visible.
233
+
234
+ ![A dashboard where the Revenue card shows a "This card could not be loaded." notice while the cards around it render normally](/images/guides/dashboard-card-error.png)
235
+
236
+ An inline card renders under the same guard, so in production it shows the same notice. In development and test its error is the page's error: the whole dashboard raises, where a lazy card fails only its own frame. [Lazy and inline cards](#lazy-and-inline-cards) has the full comparison.
237
+
238
+ ## Multi-tenancy
239
+
240
+ On an entity-scoped portal the page and card URLs carry the tenant segment, and `current_scoped_entity` is available in every block:
241
+
242
+ ```ruby
243
+ class TeamDashboard < Plutonium::Dashboard::Base
244
+ metric(:members) { current_scoped_entity.memberships.count }
245
+ end
246
+ ```
247
+
248
+ ## Sidebar
249
+
250
+ Dashboards registered with `register_dashboard` are grouped in the portal sidebar under a **Dashboards** item, after the Home link, with one child link per dashboard. A dashboard mounted at the root is what the Home link opens, so it is left out of the group; with nothing else registered the group is not rendered. ![The icon rail with the Dashboards item open, listing the Overview and Content dashboards](/images/guides/dashboard-sidebar.png)
251
+
252
+ Portals generated before this feature carry an ejected `_resource_sidebar.html.erb`; add the block from the gem's partial to list dashboards there:
253
+
254
+ ```erb
255
+ dashboards = registered_dashboards.reject { |dashboard| dashboard_path_for(dashboard) == root_path }
256
+ if dashboards.any?
257
+ m.item t("plutonium.resource.nav.dashboards"), icon: Phlex::TablerIcons::LayoutDashboard do |n|
258
+ dashboards.each do |dashboard|
259
+ n.item dashboard.label, url: dashboard_path_for(dashboard), icon: dashboard.icon
260
+ end
261
+ end
262
+ end
263
+ ```
264
+
265
+ The labels are the `plutonium.resource.nav.home` and `plutonium.resource.nav.dashboards` locale keys.
266
+
267
+ ## Translations
268
+
269
+ Titles resolve by convention, layered per portal like every other derived label:
270
+
271
+ ```yaml
272
+ en:
273
+ plutonium:
274
+ dashboards:
275
+ admin_portal/sales:
276
+ label: "Ventes"
277
+ description: "Commandes et chiffre d'affaires"
278
+ cards:
279
+ orders:
280
+ label: "Commandes"
281
+ ```
282
+
283
+ The dashboard key is the class name underscored without the `Dashboard` suffix. See the [i18n reference](/reference/i18n).
284
+
285
+ ## Customizing the page
286
+
287
+ The page is `Plutonium::UI::Page::Dashboard`, a `Page::Base` with the usual `render_before_*` and `render_after_*` hooks. To take it over, define `<Portal>::DashboardsController` yourself, include `Plutonium::Dashboard::Controller`, and override `show`:
288
+
289
+ ```ruby
290
+ module AdminPortal
291
+ class DashboardsController < PlutoniumController
292
+ include Plutonium::Dashboard::Controller
293
+
294
+ def show
295
+ authorize_dashboard!
296
+ render CustomDashboardPage.new(dashboard: current_dashboard)
297
+ end
298
+ end
299
+ end
300
+ ```
data/docs/guides/index.md CHANGED
@@ -27,6 +27,7 @@ aside: false
27
27
  { name: 'Search & filtering', link: '/plutonium-core/guides/search-filtering' },
28
28
  { name: 'Wizards', desc: 'Multi-step flows — onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
29
29
  { name: 'Kanban boards', desc: 'Drag-and-drop board view — columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
30
+ { name: 'Dashboards', desc: 'Metric, chart and free-form cards, each loaded in its own lazy turbo frame.', link: '/plutonium-core/guides/dashboards' },
30
31
  ]},
31
32
  { group: 'Customization', items: [
32
33
  { name: 'Customizing the UI', desc: 'A map of the override surface — pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
@@ -396,7 +396,7 @@ By default Plutonium uses decimal fractional positioning: cards always slot exac
396
396
 
397
397
  ### Position modes
398
398
 
399
- `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
399
+ `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/resource/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
400
400
 
401
401
  ```ruby
402
402
  kanban do
@@ -424,7 +424,7 @@ kanban do
424
424
  end
425
425
  ```
426
426
 
427
- See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/positioning) for the table/grid side of the same feature.
427
+ See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/resource/positioning) for the table/grid side of the same feature.
428
428
 
429
429
  ---
430
430