plutonium 0.63.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 (280) 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/.standard.yml +1 -1
  8. data/CHANGELOG.md +65 -0
  9. data/CLAUDE.md +282 -210
  10. data/SECURITY.md +1 -1
  11. data/app/assets/plutonium-charts.js +20604 -0
  12. data/app/assets/plutonium-charts.js.map +7 -0
  13. data/app/assets/plutonium-charts.min.js +41 -0
  14. data/app/assets/plutonium-charts.min.js.map +7 -0
  15. data/app/assets/plutonium.css +1 -1
  16. data/app/assets/plutonium.js +794 -629
  17. data/app/assets/plutonium.js.map +4 -4
  18. data/app/assets/plutonium.min.js +48 -48
  19. data/app/assets/plutonium.min.js.map +4 -4
  20. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  21. data/app/views/plutonium/_resource_header.html.erb +2 -2
  22. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  23. data/app/views/plutonium/_toast.html.erb +2 -2
  24. data/app/views/rodauth/_login_form.html.erb +1 -1
  25. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  26. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  27. data/app/views/rodauth/change_login.html.erb +2 -2
  28. data/app/views/rodauth/create_account.html.erb +4 -4
  29. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  30. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  31. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  32. data/config/brakeman.ignore +4 -4
  33. data/config/initializers/rabl.rb +0 -40
  34. data/config/locales/en/api_client.yml +22 -0
  35. data/config/locales/en/async.yml +34 -0
  36. data/config/locales/en/dashboard.yml +25 -0
  37. data/config/locales/en/invites.yml +37 -0
  38. data/config/locales/en/js.yml +40 -0
  39. data/config/locales/en/kanban.yml +28 -0
  40. data/config/locales/en/plutonium.yml +26 -0
  41. data/config/locales/en/positioning.yml +14 -0
  42. data/config/locales/en/profile.yml +29 -0
  43. data/config/locales/en/resource.yml +60 -0
  44. data/config/locales/en/ui.yml +93 -0
  45. data/config/locales/en/wizard.yml +46 -0
  46. data/docs/.vitepress/config.ts +34 -1
  47. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  48. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  49. data/docs/.vitepress/theme/custom.css +34 -377
  50. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  51. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  52. data/docs/blog/introducing-plutonium.md +4 -5
  53. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  54. data/docs/guides/dashboards.md +300 -0
  55. data/docs/guides/index.md +1 -0
  56. data/docs/guides/kanban.md +2 -2
  57. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  58. data/docs/public/images/blog/dashboards-loading.png +0 -0
  59. data/docs/public/images/blog/dashboards-overview.png +0 -0
  60. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  61. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  62. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  63. data/docs/public/images/guides/dashboard-loading.png +0 -0
  64. data/docs/public/images/guides/dashboard-overview.png +0 -0
  65. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  66. data/docs/reference/app/generators.md +2 -2
  67. data/docs/reference/app/portals.md +2 -0
  68. data/docs/reference/configuration.md +84 -27
  69. data/docs/reference/dashboard/dsl.md +123 -0
  70. data/docs/reference/dashboard/index.md +35 -0
  71. data/docs/reference/dashboard/registration.md +76 -0
  72. data/docs/reference/i18n.md +239 -0
  73. data/docs/reference/index.md +10 -1
  74. data/docs/reference/kanban/dsl.md +1 -1
  75. data/docs/reference/kanban/positioning.md +2 -2
  76. data/docs/reference/resource/actions.md +1 -1
  77. data/docs/reference/resource/definition.md +96 -18
  78. data/docs/reference/resource/export.md +5 -1
  79. data/docs/reference/resource/index.md +2 -0
  80. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  81. data/docs/reference/resource/query.md +2 -0
  82. data/docs/reference/ui/assets.md +8 -0
  83. data/docs/reference/ui/displays.md +5 -8
  84. data/docs/reference/ui/index.md +1 -1
  85. data/docs/reference/wizard/dsl.md +2 -2
  86. data/esbuild.config.js +5 -2
  87. data/gemfiles/rails_7.gemfile.lock +3 -3
  88. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  89. data/gemfiles/rails_8.1.gemfile.lock +2 -2
  90. data/lib/active_model/validations/array_validator.rb +1 -1
  91. data/lib/active_model/validations/attached_validator.rb +1 -1
  92. data/lib/active_model/validations/url_validator.rb +2 -2
  93. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  94. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  95. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  96. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  97. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  98. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  99. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  100. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  101. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  102. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  103. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  104. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  105. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  106. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  107. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  109. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  110. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  111. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  112. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  113. data/lib/plutonium/action/base.rb +27 -3
  114. data/lib/plutonium/action/interactive.rb +17 -4
  115. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  116. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  117. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  118. data/lib/plutonium/configuration.rb +5 -0
  119. data/lib/plutonium/core/controller.rb +25 -1
  120. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  121. data/lib/plutonium/dashboard/base.rb +114 -0
  122. data/lib/plutonium/dashboard/card.rb +200 -0
  123. data/lib/plutonium/dashboard/controller.rb +101 -0
  124. data/lib/plutonium/dashboard/dsl.rb +104 -0
  125. data/lib/plutonium/dashboard/register.rb +40 -0
  126. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  127. data/lib/plutonium/dashboard.rb +42 -0
  128. data/lib/plutonium/definition/actions.rb +6 -1
  129. data/lib/plutonium/definition/base.rb +41 -3
  130. data/lib/plutonium/definition/index_views.rb +1 -1
  131. data/lib/plutonium/definition/positioning.rb +1 -1
  132. data/lib/plutonium/definition/presentable.rb +2 -2
  133. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  134. data/lib/plutonium/helpers/display_helper.rb +10 -2
  135. data/lib/plutonium/interaction/async/context.rb +6 -8
  136. data/lib/plutonium/interaction/async/executor.rb +6 -7
  137. data/lib/plutonium/interaction/async/run.rb +2 -2
  138. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  139. data/lib/plutonium/interaction/base.rb +2 -0
  140. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  141. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  142. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  143. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  144. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  145. data/lib/plutonium/invites/controller.rb +11 -11
  146. data/lib/plutonium/kanban/column.rb +13 -3
  147. data/lib/plutonium/kanban/dsl.rb +7 -4
  148. data/lib/plutonium/models/has_cents.rb +1 -1
  149. data/lib/plutonium/profile/security_section.rb +10 -18
  150. data/lib/plutonium/query/filter.rb +10 -1
  151. data/lib/plutonium/query/filters/association.rb +27 -2
  152. data/lib/plutonium/query/filters/boolean.rb +4 -4
  153. data/lib/plutonium/query/filters/date.rb +4 -17
  154. data/lib/plutonium/query/filters/date_range.rb +2 -2
  155. data/lib/plutonium/query/filters/select.rb +10 -1
  156. data/lib/plutonium/query/filters/text.rb +9 -12
  157. data/lib/plutonium/railtie.rb +17 -0
  158. data/lib/plutonium/resource/controller.rb +10 -5
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  160. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  161. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  162. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  163. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  164. data/lib/plutonium/resource/query_object.rb +30 -3
  165. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  166. data/lib/plutonium/routing/resource_registration.rb +5 -0
  167. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  168. data/lib/plutonium/translation.rb +227 -0
  169. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  170. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  171. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  172. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  173. data/lib/plutonium/ui/component/methods.rb +4 -0
  174. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  175. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  176. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  177. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  178. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  179. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  180. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  181. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  182. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  183. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  184. data/lib/plutonium/ui/display/resource.rb +23 -16
  185. data/lib/plutonium/ui/export_button.rb +3 -3
  186. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  187. data/lib/plutonium/ui/form/components/password.rb +2 -2
  188. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  189. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  190. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  192. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  194. data/lib/plutonium/ui/form/query.rb +8 -8
  195. data/lib/plutonium/ui/form/resource.rb +54 -26
  196. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  197. data/lib/plutonium/ui/grid/card.rb +3 -3
  198. data/lib/plutonium/ui/grid/resource.rb +3 -3
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  201. data/lib/plutonium/ui/kanban/column.rb +6 -6
  202. data/lib/plutonium/ui/layout/base.rb +19 -1
  203. data/lib/plutonium/ui/layout/header.rb +1 -1
  204. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  205. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  206. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  207. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  208. data/lib/plutonium/ui/modal/base.rb +4 -4
  209. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  210. data/lib/plutonium/ui/nav_user.rb +2 -2
  211. data/lib/plutonium/ui/page/base.rb +10 -2
  212. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  213. data/lib/plutonium/ui/page/edit.rb +3 -3
  214. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  215. data/lib/plutonium/ui/page/new.rb +3 -3
  216. data/lib/plutonium/ui/page/show.rb +2 -2
  217. data/lib/plutonium/ui/page/wizard.rb +22 -16
  218. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  219. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  220. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  221. data/lib/plutonium/ui/table/base.rb +2 -2
  222. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  223. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  224. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  225. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  226. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  227. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  228. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  229. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  230. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  231. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  232. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  233. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  234. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  235. data/lib/plutonium/ui/table/resource.rb +53 -17
  236. data/lib/plutonium/ui/wizard/review.rb +7 -7
  237. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  238. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  239. data/lib/plutonium/version.rb +1 -1
  240. data/lib/plutonium/wizard/base.rb +2 -0
  241. data/lib/plutonium/wizard/driving.rb +8 -2
  242. data/lib/plutonium/wizard/dsl.rb +4 -2
  243. data/lib/plutonium/wizard/field_importer.rb +1 -8
  244. data/lib/plutonium/wizard/review_step.rb +8 -1
  245. data/lib/plutonium/wizard/step.rb +14 -3
  246. data/package.json +4 -2
  247. data/plutonium.gemspec +15 -30
  248. data/postcss-gem-import.cjs +34 -0
  249. data/postcss-gem-import.js +3 -28
  250. data/src/css/components.css +91 -0
  251. data/src/css/slim_select.css +0 -38
  252. data/src/css/tokens.css +24 -0
  253. data/src/js/controllers/attachment_input_controller.js +15 -6
  254. data/src/js/controllers/chart_controller.js +162 -0
  255. data/src/js/controllers/clipboard_controller.js +3 -2
  256. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  257. data/src/js/controllers/easymde_controller.js +3 -0
  258. data/src/js/controllers/filter_panel_controller.js +15 -6
  259. data/src/js/controllers/flatpickr_controller.js +8 -0
  260. data/src/js/controllers/frame_refresh_controller.js +59 -0
  261. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  262. data/src/js/controllers/register_controllers.js +4 -0
  263. data/src/js/controllers/slim_select_controller.js +79 -176
  264. data/src/js/core.js +9 -1
  265. data/src/js/i18n.js +69 -0
  266. data/src/js/plutonium-charts.js +15 -0
  267. data/src/js/turbo/turbo_confirm.js +5 -3
  268. metadata +71 -31
  269. data/.yarnrc.yml +0 -8
  270. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  271. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  272. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  273. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  274. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  275. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  276. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  277. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  278. data/public/plutonium-assets/application.js +0 -31419
  279. data/public/plutonium-assets/plutonium.ico +0 -0
  280. data/yarn.lock +0 -6858
@@ -344,7 +344,7 @@ rails g pu:core:install
344
344
 
345
345
  ### `pu:core:assets`
346
346
 
347
- Set up the custom Tailwind + Stimulus toolchain. Installs npm packages, creates `tailwind.config.js`, imports Plutonium CSS, registers Stimulus controllers.
347
+ Set up the custom Tailwind + Stimulus toolchain. Installs npm packages with the app's package manager (bun, yarn 1, yarn 2+, npm or pnpm, detected from the lockfile, else from what is on PATH), creates `tailwind.config.js`, imports Plutonium CSS, registers Stimulus controllers.
348
348
 
349
349
  ```bash
350
350
  rails g pu:core:assets
@@ -354,7 +354,7 @@ See [UI › Assets](/reference/ui/assets) for what gets configured.
354
354
 
355
355
  ### `pu:core:update`
356
356
 
357
- Update the plutonium gem and npm package together.
357
+ Update the plutonium gem and npm package together, using the app's package manager.
358
358
 
359
359
  ```bash
360
360
  rails g pu:core:update
@@ -327,6 +327,8 @@ For the full multi-tenancy story, see [Tenancy › Entity scoping](/reference/te
327
327
 
328
328
  ## Dashboard / non-resource pages
329
329
 
330
+ The generated `dashboard#index` is a plain page listing the registered resources. To replace it with a [dashboard](/guides/dashboards) of metric and chart cards, run `rails g pu:dashboard Home --dest=<portal> --at=/`, which swaps the `root to:` line for a `register_dashboard ... at: "/"` registration.
331
+
330
332
  ```ruby
331
333
  # config/routes.rb
332
334
  AdminPortal::Engine.routes.draw do
@@ -1,24 +1,27 @@
1
1
  # Configuration
2
2
 
3
- Plutonium is configured through `Plutonium.configure` in an initializer. A generated app has this at `config/initializers/plutonium.rb`:
3
+ Plutonium is configured through `Plutonium.configure` in an initializer. `pu:core:install` writes `config/initializers/plutonium.rb`:
4
4
 
5
5
  ```ruby
6
- # config/initializers/plutonium.rb
6
+ # Configure plutonium
7
+
7
8
  Plutonium.configure do |config|
8
9
  config.load_defaults 1.0
9
10
 
10
- # config.shell = :modern
11
- # config.navii_host_url = "https://api.navii.dev"
12
- # config.auto_eager_load_collections = true
13
-
14
- config.assets.logo = "plutonium.png"
15
- config.assets.favicon = "plutonium.ico"
16
- config.assets.stylesheet = "plutonium.css"
17
- config.assets.script = "plutonium.min.js"
11
+ # Shell variant: :modern (icon rail), :plain (no rail), or :classic (legacy).
12
+ config.shell = :modern
13
+ # Configure plutonium above.
18
14
  end
19
15
  ```
20
16
 
21
- Access the live config anywhere via `Plutonium.configuration`.
17
+ Everything else is opt-in. Read the live config anywhere via `Plutonium.configuration`, and query development mode with `Plutonium.configuration.development?`.
18
+
19
+ Other generators edit this file in place. `pu:core:assets` rewrites two lines to point at your own bundles:
20
+
21
+ ```ruby
22
+ config.assets.stylesheet = "application"
23
+ config.assets.script = "application"
24
+ ```
22
25
 
23
26
  ## Versioned defaults
24
27
 
@@ -26,41 +29,95 @@ Access the live config anywhere via `Plutonium.configuration`.
26
29
  config.load_defaults 1.0
27
30
  ```
28
31
 
29
- Loads the baseline defaults for a given framework version. Call this first; later versions layer their changes on top. Read the resolved version with `config.defaults_version`.
32
+ Applies the baseline defaults for a framework version, and every earlier version in order. Call it first, before any option you set yourself, or the defaults overwrite you. `1.0` is currently the only version. Read back what resolved with `config.defaults_version`, which is `nil` until you call this.
33
+
34
+ Passing a version older than the earliest available raises rather than silently applying nothing.
30
35
 
31
- ## Options
36
+ ## Core
32
37
 
33
38
  | Option | Default | Description |
34
39
  |--------|---------|-------------|
35
40
  | `load_defaults(version)` | — | Apply versioned framework defaults. Call first. |
36
- | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. You rarely set this in an app — see [Development mode](#development-mode). |
37
- | `cache_discovery` | `true` outside `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
38
- | `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
41
+ | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. Apps rarely set this, see [Development mode](#development-mode). |
42
+ | `cache_discovery` | `true` outside the `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
43
+ | `enable_hotreload` | `true` in the `development` env | Hot-reload Plutonium components on change. |
44
+
45
+ ## Appearance
46
+
47
+ | Option | Default | Description |
48
+ |--------|---------|-------------|
39
49
  | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail), `:plain` (topbar, no icon rail), or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
50
+ | `default_page_width` | `:md` | Width of detail-style pages: the show page and resource forms. One of `:sm` `:md` `:lg` `:xl` `:full` (`:full` opts out of any constraint). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`, see [Definition › Page width](./resource/definition#page-width). |
40
51
  | `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
41
- | `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
42
- | `default_page_width` | `:md` | Width of detail-style pages — the show page and resource forms. One of `:sm :md :lg :xl :full` (`:full` = unconstrained). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`; see [Definition › Page width](./resource/definition#page-width). |
43
- | `wizards.width` | `:md` | Default width of wizard step pages. **Independent of `default_page_width`** — a wizard is a self-contained flow, so widening resource pages leaves wizards alone. Override per wizard with `width`. Same size tokens. |
44
- | `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is registered. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. See [Nested resources › Declaring which associations get routes](./tenancy/nested-resources#declaring-which-associations-get-routes). |
45
52
  | `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
46
53
  | `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
47
- | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
48
- | `assets.script` | `"plutonium.min.js"` | JavaScript entry. |
54
+ | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. `pu:core:assets` sets this to `"application"`. |
55
+ | `assets.script` | `"plutonium.min.js"` | JavaScript entry. `pu:core:assets` sets this to `"application"`. |
56
+
57
+ ## Rendering and routing
58
+
59
+ | Option | Default | Description |
60
+ |--------|---------|-------------|
61
+ | `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. The field set comes from the policy, so it is known before the collection loads. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
62
+ | `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is a registered resource. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. Any other value raises `ArgumentError` rather than drawing the wrong route table for a typo. See [Nested resources](./tenancy/nested-resources#declaring-which-associations-get-routes). |
63
+ | `default_currency_unit` | `nil` | Symbol used when rendering a currency value with no unit set on `has_cents` or the display. `nil` falls back to the i18n `number.currency.format.unit` when the locale defines one, otherwise no symbol. Set a literal like `"£"`, or `false` (or `""`) for no symbol application-wide. See [`has_cents`](./resource/model#has-cents) and [Currency fields](./ui/forms#currency-fields). |
64
+ | `default_phone_country` | `nil` | Default country (ISO2, e.g. `"gh"`) for `as: :phone` inputs that set no `initial_country:`. `nil` leaves it to intl-tel-input, with no country preselected. Stored verbatim; `config.normalized_default_phone_country` returns it downcased, so `"GH"` and `"gh"` are interchangeable. See [Phone fields](./ui/forms#phone-fields). |
65
+
66
+ ## Attachments
67
+
68
+ `attachment_backend` picks which library stages a file that travels as a plain string: `:active_storage` or `:shrine`. It is the shared default, and each subsystem layers its own override on top, so setting it once covers both and setting one of theirs narrows it to that subsystem.
69
+
70
+ | Option | Default | Description |
71
+ |--------|---------|-------------|
72
+ | `attachment_backend` | `nil` | Shared default for staged attachments. `nil` auto-detects. |
73
+ | `wizards.attachment_backend` | `nil` | Overrides the above for wizard attachment fields. `nil` falls through. |
74
+ | `async_interactions.attachment_backend` | `nil` | Overrides the above for run dispatch. `nil` falls through. |
75
+
76
+ Resolution runs first match wins:
77
+
78
+ 1. The field's own `backend:` option.
79
+ 2. The subsystem setting, `wizards.attachment_backend` or `async_interactions.attachment_backend`.
80
+ 3. `config.attachment_backend`.
81
+ 4. Auto-detection: `:shrine` if `ActiveShrine` is loaded, else `:active_storage`.
82
+
83
+ Only plain uploads are affected. A direct-upload field already arrives as a token and ignores all of this.
84
+
85
+ ## Wizards
86
+
87
+ `enabled` gates the subsystem and its migrations. Full detail in [Wizards › Storage & config](./wizard/storage-config).
88
+
89
+ | Option | Default | Description |
90
+ |--------|---------|-------------|
91
+ | `wizards.enabled` | `false` | Enable wizards and their migrations. See [Enabling the subsystem](./wizard/storage-config#enabling-the-subsystem). |
92
+ | `wizards.width` | `:md` | Width of wizard step pages. **Independent of `default_page_width`**: a wizard is a self-contained flow, so widening resource pages leaves wizards where they are. Same size tokens. Override per wizard with `width`. |
93
+ | `wizards.cleanup_after` | `14.days` | How long completed and abandoned sessions are kept before `SweepJob` removes them. See [Cleanup & the SweepJob](./wizard/storage-config#cleanup-the-sweepjob). |
94
+ | `wizards.database` | `:primary` | Which database the wizard tables live in. |
95
+ | `wizards.encrypt_data` | `false` | Encrypt every wizard's staged `data` at rest. Off by default because it needs ActiveRecord encryption keys configured. A wizard can still opt in with `encrypt_data` or out with `encrypt_data false` regardless. See [Encryption](./wizard/storage-config#encryption). |
96
+ | `wizards.attachment_backend` | `nil` | See [Attachments](#attachments). |
97
+
98
+ ## Async interactions
99
+
100
+ `enabled` gates the runs subsystem and its migrations. Full detail in [Async interactions](./behavior/async-interactions).
101
+
102
+ | Option | Default | Description |
103
+ |--------|---------|-------------|
104
+ | `async_interactions.enabled` | `false` | Enable persisted runs and their migrations. See [Enabling](./behavior/async-interactions#enabling). |
105
+ | `async_interactions.queue` | `:default` | ActiveJob queue for run jobs. |
106
+ | `async_interactions.stall_after` | `1.hour` | How long a run may sit with no progress write before `ReapJob` treats it as stalled. See [Stalled runs and ReapJob](./behavior/async-interactions#stalled-runs-and-reapjob). |
107
+ | `async_interactions.attachment_backend` | `nil` | See [Attachments](#attachments). |
49
108
 
50
109
  ## Development mode
51
110
 
52
- `config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not set in the initializer. It’s primarily for working **on the Plutonium gem** (uses local `src/` assets, enables hot reloading, and shows more detailed errors). Applications generally leave it unset.
111
+ `config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not by the initializer. It is for working **on the Plutonium gem**: it uses local `src/` assets, enables hot reloading, and shows more detailed errors. Applications leave it unset.
53
112
 
54
113
  ```bash
55
114
  export PLUTONIUM_DEV=1
56
115
  ```
57
116
 
58
- ## Assets
59
-
60
- Asset entries live under `config.assets` and point the framework at your compiled stylesheet/script and brand imagery. The `pu:core:assets` generator wires these up. See [Assets](./ui/assets) for the full asset/Tailwind/Stimulus setup.
61
-
62
117
  ## Related
63
118
 
64
119
  - [Assets](./ui/assets) — stylesheet, script, Tailwind, and design tokens
65
120
  - [Layouts](./ui/layouts) — the `shell` option and ejecting chrome
66
121
  - [Components › Avatar](./ui/components#avatar) — `navii_host_url`
122
+ - [Wizards › Storage & config](./wizard/storage-config) — the `wizards.*` settings in context
123
+ - [Async interactions](./behavior/async-interactions) — the `async_interactions.*` settings in context
@@ -0,0 +1,123 @@
1
+ # Dashboard DSL Reference
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 subclass of `Plutonium::Dashboard::Base`. Everything is declared at class level; an instance is built per request with the view context and is the receiver of every card block.
8
+
9
+ ## Presentation
10
+
11
+ ```ruby
12
+ presents label: "Sales", description: "Orders and revenue", icon: Phlex::TablerIcons::ChartBar
13
+ ```
14
+
15
+ | Key | Default |
16
+ |---|---|
17
+ | `label` | `plutonium.dashboards.<key>.label`, then the class name without `Dashboard` |
18
+ | `description` | `plutonium.dashboards.<key>.description`, then nothing |
19
+ | `icon` | `Phlex::TablerIcons::LayoutDashboard` (used in the sidebar) |
20
+
21
+ `<key>` is `Class.i18n_key`: the class name underscored without the suffix (`AdminPortal::SalesDashboard` → `admin_portal/sales`). `label` and `description` accept the class-level lazy `t("...")`.
22
+
23
+ ## Board-level options
24
+
25
+ ### refresh(seconds)
26
+
27
+ Default reload interval for every lazy card. `nil` (the default) disables it.
28
+
29
+ ### width(token)
30
+
31
+ Page width, one of `:sm`, `:md`, `:lg`, `:xl`, `:full`. Default `:full`.
32
+
33
+ ## Cards
34
+
35
+ ```ruby
36
+ metric(key, **options) { ... }
37
+ chart(key, **options) { ... }
38
+ card(key, **options) { ... }
39
+ ```
40
+
41
+ Keys are unique per dashboard; a duplicate raises at class load. Cards render in declaration order.
42
+
43
+ ### Common options
44
+
45
+ | Option | Type | Default | Meaning |
46
+ |---|---|---|---|
47
+ | `label:` | String, lazy `t` | convention, then key titleized | Title |
48
+ | `description:` | String, lazy `t` | convention | Caption |
49
+ | `icon:` | Phlex icon class | none | Shown beside the title |
50
+ | `span:` | `1`..`12`, `:full` | metric `3`, chart `6`, card `6` | Columns of the 12-column grid. `:full` is `12`. On tablets (2 columns) a span of `6` or more takes the row; phones are one column |
51
+ | `lazy:` | Boolean | `true` | `true`: own lazy turbo frame, block runs in a separate request. `false`: inline, block runs in the page request; no frame, never refreshes. See the [guide](/guides/dashboards#lazy-and-inline-cards) |
52
+ | `refresh:` | Integer seconds, or `false` | dashboard `refresh` | Reload interval; requires `lazy: true`. `false` opts the card out of the dashboard's `refresh` |
53
+ | `condition:` | Proc, Symbol | none | Hides the card and 404s its endpoint when false |
54
+ | `href:` | String, Proc | none | Links the title |
55
+
56
+ Procs (`condition:`, `href:`, the block) run on the dashboard instance with `instance_exec`; a one-argument proc receives the instance instead.
57
+
58
+ ### Locale conventions
59
+
60
+ ```
61
+ plutonium.dashboards.<key>.cards.<card>.label
62
+ plutonium.dashboards.<key>.cards.<card>.description
63
+ plutonium.portals.<portal>.dashboards.<key>...
64
+ ```
65
+
66
+ ### metric
67
+
68
+ The block returns a value or a hash.
69
+
70
+ | Hash key | Meaning |
71
+ |---|---|
72
+ | `value` | The number or string. `nil` renders `plutonium.dashboard.metric.empty` |
73
+ | `previous` | Computes `change` as a percentage from this value; a zero previous yields no percentage |
74
+ | `change` | Numeric percentage points (`2.5` → `+2.5%`) or a string shown as-is |
75
+ | `trend` | `:up`, `:down`, `:flat`; inferred from the sign of `change` |
76
+ | `change_label` | Caption after the change |
77
+
78
+ | Option | Values | Meaning |
79
+ |---|---|---|
80
+ | `format:` | `:number` (default), `:currency`, `:percentage`, `:human`, Proc | How `value` is rendered. A proc receives the value and runs on the dashboard |
81
+ | `precision:` | Integer | Decimal places; defaults per format (2 for currency, 1 for percentage, 3 significant for human) |
82
+ | `unit:` | String | Currency symbol for `:currency` |
83
+ | `prefix:` / `suffix:` | String | Wrapped around the formatted value |
84
+ | `positive:` | `:up` (default), `:down` | Which direction is good; drives the change colour |
85
+ | `change_label:` | String | Same as the hash key |
86
+
87
+ ### chart
88
+
89
+ The block returns Chartkick data: `{label => value}`, `[[label, value], ...]` or `[{name:, data:}, ...]`. `Date` and `Time` keys serialise as ISO strings and draw a time axis.
90
+
91
+ | Option | Values | Meaning |
92
+ |---|---|---|
93
+ | `type:` | `:line` (default), `:area`, `:column`, `:bar`, `:pie`, `:donut`, `:scatter` | Chartkick chart class |
94
+ | `height:` | CSS length | Drawing area height, default `"240px"` |
95
+ | anything else | | Passed to Chartkick unchanged: `colors`, `stacked`, `min`, `max`, `prefix`, `suffix`, `thousands`, `decimal`, `xtitle`, `ytitle`, `legend`, `curve`, `points`, `discrete`, `download`, `library`, ... |
96
+
97
+ The `chart` Stimulus controller merges these over Plutonium's defaults: series colours from `--pu-chart-1` to `--pu-chart-8`, axis text from `--pu-text-muted`, grid lines from `--pu-border`, and the empty-data message from `plutonium.js.charts.empty`. A colour-mode switch redraws every chart.
98
+
99
+ ### card
100
+
101
+ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dashboard::Custom`, a Phlex component: it emits markup directly, and any method the component does not define is forwarded to the dashboard instance.
102
+
103
+ ## Instance API
104
+
105
+ | Method | Meaning |
106
+ |---|---|
107
+ | `authorize?` | Override to gate the page and every card. Default `true` |
108
+ | `visible_cards` | Cards whose `condition:` passes |
109
+ | `visible_card!(key)` | The card, or `Plutonium::Dashboard::UnknownCardError` (404) |
110
+ | `refresh_for(card)` | The card's interval, else the dashboard's; `nil` for a card declared `refresh: false` |
111
+ | `view_context` / `helpers` | The Rails view context |
112
+ | `current_user`, `current_scoped_entity`, `scoped_to_entity?`, `params`, `request`, `controller`, `current_engine`, `resource_url_for`, `authorized_resource_scope`, `allowed_to?`, `policy_for`, `registered_resources`, `root_path` | Delegated to the view context |
113
+
114
+ ## Class API
115
+
116
+ | Method | Meaning |
117
+ |---|---|
118
+ | `cards` | All declared cards, in order |
119
+ | `find_card(key)` / `find_card!(key)` | Lookup by key |
120
+ | `label`, `description`, `icon` | Resolved presentation |
121
+ | `i18n_key`, `route_name` | `admin_portal/sales`, `sales` |
122
+
123
+ Subclasses inherit the parent's cards and options by copy, so a portal-specific subclass can add cards without touching the parent.
@@ -0,0 +1,35 @@
1
+ # Dashboard Reference
2
+
3
+ ::: warning Experimental
4
+ Dashboards are experimental: the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ Reference documentation for Plutonium dashboards: pages of metric, chart and free-form cards, each loaded in its own lazy turbo frame.
8
+
9
+ ## In this section
10
+
11
+ | Page | What it covers |
12
+ |------|---------------|
13
+ | [DSL](/reference/dashboard/dsl) | `Plutonium::Dashboard::Base`, `presents`, `refresh`, `width`, and the `metric` / `chart` / `card` macros with every option |
14
+ | [Registration](/reference/dashboard/registration) | `register_dashboard`, the routes it draws, the synthesized controller, the sidebar, and the `pu:dashboard` generator |
15
+
16
+ ## Quick start
17
+
18
+ ```ruby
19
+ # packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb
20
+ module AdminPortal
21
+ class SalesDashboard < Plutonium::Dashboard::Base
22
+ presents label: "Sales", icon: Phlex::TablerIcons::ChartBar
23
+
24
+ metric(:orders) { {value: Order.this_month.count, previous: Order.last_month.count} }
25
+ chart(:revenue, type: :area, span: 8) { Order.group_by_day(:created_at).sum(:total) }
26
+ end
27
+ end
28
+
29
+ # packages/admin_portal/config/routes.rb
30
+ AdminPortal::Engine.routes.draw do
31
+ register_dashboard AdminPortal::SalesDashboard, at: "sales"
32
+ end
33
+ ```
34
+
35
+ See the [Dashboards guide](/guides/dashboards) for a full walkthrough.
@@ -0,0 +1,76 @@
1
+ # Dashboard Registration
2
+
3
+ ::: warning Experimental
4
+ Dashboards are experimental: the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ ## register_dashboard
8
+
9
+ ```ruby
10
+ AdminPortal::Engine.routes.draw do
11
+ register_dashboard AdminPortal::HomeDashboard, at: "/" # the portal root
12
+ register_dashboard AdminPortal::SalesDashboard, at: "sales" # /admin/dashboards/sales
13
+ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
14
+ end
15
+ ```
16
+
17
+ | Argument | Meaning |
18
+ |---|---|
19
+ | `at:` | Path under the portal's `dashboards/` segment. `"/"` or `""` mounts at the engine root |
20
+ | `prefix:` | Leading path segment, `"dashboards"` by default. `nil` draws the mount at the bare `at:` path (`/admin/sales`); a string swaps the segment (`prefix: "reports"` → `/admin/reports/sales`). Does not affect helper names |
21
+ | `as:` | Route helper prefix. Defaults to `at:` with slashes replaced, or the class slug for a root mount |
22
+
23
+ ### Routes drawn
24
+
25
+ ```
26
+ GET /dashboards/sales → DashboardsController#show sales_dashboard_path
27
+ GET /dashboards/sales/cards/:card → DashboardsController#card sales_dashboard_card_path
28
+ ```
29
+
30
+ Every path is drawn under `dashboards/`, so a dashboard never shadows a `register_resource` route of the same name (`at: "sales"` next to a `Sale` resource). The route helpers carry no prefix.
31
+
32
+ With `prefix: nil` nothing reserves the path, so keep `at:` clear of your resource routes yourself.
33
+
34
+ A root mount draws `root` for the page and `/dashboards/<as>/cards/:card` for the cards (`/<as>/cards/:card` with `prefix: nil`). Replace the portal's generated `root to: "dashboard#index"` line with the registration; two root routes clash. The generated `DashboardController` and its view are then unrouted and can be deleted. The [guide](/guides/dashboards#replacing-the-portal-s-default-page) walks through it.
35
+
36
+ On a `:path` entity-scoped portal the routes sit inside the scope segment and the helpers are prefixed (`organization_scoped_sales_dashboard_path`). URLs built by the framework thread the current tenant through; when building your own use `dashboard_path_for(SalesDashboard)`, available in controllers, views and components.
37
+
38
+ ### The controller
39
+
40
+ `register_dashboard` synthesizes `<Portal>::DashboardsController < <Portal>::PlutoniumController` including `Plutonium::Dashboard::Controller` and the portal's `Concerns::Controller`, so it carries the portal's authentication, entity scoping and layout. On the main app it synthesizes a bare `::DashboardsController < ApplicationController`.
41
+
42
+ Define the class yourself to take over; the synthesized one is only created when the constant is missing.
43
+
44
+ ```ruby
45
+ module AdminPortal
46
+ class DashboardsController < PlutoniumController
47
+ include Plutonium::Dashboard::Controller
48
+
49
+ before_action { set_page_title("Reports") }
50
+ end
51
+ end
52
+ ```
53
+
54
+ The concern provides `show` and `card`, `current_dashboard`, `current_dashboard_class`, `authorize_dashboard!` and `dashboard_card_path(card)`.
55
+
56
+ ### The card endpoint
57
+
58
+ `GET <mount>/cards/:card` runs `authorize?`, looks the key up among the dashboard's cards, checks its `condition:`, and renders the card inside `<turbo-frame id="pu-dashboard-card-<key>">`. Unknown or hidden cards respond 404 (`Plutonium::Dashboard::UnknownCardError`); a denied `authorize?` responds 403. A request without a `Turbo-Frame` header renders the card inside the full layout.
59
+
60
+ ## Sidebar
61
+
62
+ `registered_dashboards` (a controller helper, also available in components) lists the dashboards mounted on the current engine, and `dashboard_path_for(klass)` builds each page path. The gem's `_resource_sidebar.html.erb` groups them under a Dashboards item (`plutonium.resource.nav.dashboards`) after the Home link (`plutonium.resource.nav.home`), skipping the one mounted at the root, which the Home link already opens.
63
+
64
+ ## Generator
65
+
66
+ ```bash
67
+ rails g pu:dashboard Sales --dest=admin_portal # /admin/dashboards/sales
68
+ rails g pu:dashboard Home --dest=admin_portal --at=/ # portal root
69
+ rails g pu:dashboard Reporting --dest=main_app --at=reports
70
+ ```
71
+
72
+ Writes `app/dashboards/<portal>/<name>_dashboard.rb` in the package (or `app/dashboards/` for `main_app`) and adds the `register_dashboard` line to the routes. Idempotent.
73
+
74
+ ## Assets
75
+
76
+ Chart cards load `plutonium-charts.js` (Chart.js and Chartkick) on demand; the file ships with the gem beside `plutonium.js` and is precompiled with it. `Plutonium.configuration.assets.charts_script` names the asset (`"plutonium-charts.min.js"` by default). When `PLUTONIUM_DEV` is set the file comes from the `src/build` manifest like the main bundle.
@@ -0,0 +1,239 @@
1
+ # Internationalization
2
+
3
+ Every string Plutonium renders comes from a locale file, and every label it derives from a key (an action, a scope, a filter, a kanban column, a wizard step, a field's placeholder) can be supplied by one. Nothing in a definition has to change for an app to translate its UI or reword the defaults.
4
+
5
+ ## Where the strings live
6
+
7
+ Plutonium ships its own text under `config/locales/en/*.yml` in the gem, all under the `plutonium` namespace. Rails loads it at the lowest precedence:
8
+
9
+ 1. The host application's `config/locales` wins over everything.
10
+ 2. Package and portal engines' `config/locales`, which Rails loads automatically.
11
+ 3. The gem's defaults.
12
+
13
+ So a portal can reword a Plutonium string for itself, and the app can override the portal.
14
+
15
+ To change a fixed string, copy its key into your own locale file. The gem's locale files are the catalogue:
16
+
17
+ ```yaml
18
+ # config/locales/en.yml
19
+ en:
20
+ plutonium:
21
+ boolean:
22
+ "true": "On"
23
+ "false": "Off"
24
+ ```
25
+
26
+ Pagination is the one surface with its own dictionary. The info sentence, the per-page sentence and the nav aria labels come from [Pagy's](https://ddnexus.github.io/pagy/resources/i18n) locale files, which cover about thirty-five languages. Override a Pagy key by adding a file to `Pagy::I18n.pathnames`, not through Rails I18n.
27
+
28
+ ## Switching locale
29
+
30
+ Plutonium reads `I18n.locale` on every render and never sets it. Set the locale the way any Rails app does, typically an `around_action` in the portal's controller concern:
31
+
32
+ ```ruby
33
+ module AdminPortal
34
+ module Concerns
35
+ module Controller
36
+ extend ActiveSupport::Concern
37
+
38
+ included do
39
+ around_action :switch_locale
40
+ end
41
+
42
+ def switch_locale(&)
43
+ I18n.with_locale(current_user&.locale || I18n.default_locale, &)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ ```
49
+
50
+ The Pagy locale is synced to `I18n.locale` on each pagination render, so the same switch covers pagination.
51
+
52
+ ## Models and attributes
53
+
54
+ Model and attribute names follow the Rails convention and already drive every label, column header, page title and flash:
55
+
56
+ ```yaml
57
+ en:
58
+ activerecord:
59
+ models:
60
+ blogging/post:
61
+ one: "Post"
62
+ other: "Posts"
63
+ attributes:
64
+ blogging/post:
65
+ title: "Title"
66
+ published_at: "Published"
67
+ ```
68
+
69
+ The model key is `model_name.i18n_key`: `blogging/post` for `Blogging::Post`. When a model defines `one` and `other`, Plutonium uses them for the plural forms in page titles, empty states and pagination. Without them it falls back to English inflection.
70
+
71
+ ## Fields: placeholder, hint, description
72
+
73
+ A definition never has to declare help text to make it translatable. When an input or display leaves a slot blank, Plutonium looks it up by convention:
74
+
75
+ ```yaml
76
+ en:
77
+ plutonium:
78
+ fields:
79
+ blogging/post:
80
+ title:
81
+ placeholder: "A short, descriptive title"
82
+ hint: "Shown in search results"
83
+ body:
84
+ description: "Rendered as Markdown"
85
+ ```
86
+
87
+ `placeholder` and `hint` apply to forms. `description` applies to displays. The lookup walks the model's ancestors like `human_attribute_name` does, so a key on `blogging/post` also serves an STI subclass.
88
+
89
+ For each slot the resolution order is:
90
+
91
+ 1. An explicit option on `input` or `display`, then on `field`.
92
+ 2. `plutonium.portals.<portal>.fields.<model>.<attr>.<slot>` when rendering inside a portal.
93
+ 3. `plutonium.fields.<model>.<attr>.<slot>`.
94
+ 4. `helpers.placeholder.<model>.<attr>`, the Rails `form_with` convention, for placeholders only.
95
+ 5. Nothing.
96
+
97
+ An explicit option can still be a translation. Use the definition's class-level `t`, which returns a lazy value resolved on every render in the request's locale:
98
+
99
+ ```ruby
100
+ class PostDefinition < Plutonium::Resource::Definition
101
+ input :email, placeholder: t("forms.shared.email_placeholder")
102
+ input :quantity, hint: t("forms.stock.hint", count: 3)
103
+ end
104
+ ```
105
+
106
+ `t` is sugar over the proc form, which also works and has always been locale-safe:
107
+
108
+ ```ruby
109
+ input :email, placeholder: -> { I18n.t("forms.shared.email_placeholder") }
110
+ ```
111
+
112
+ Never call `I18n.t` directly in a class body. It runs once, at load time, in whatever locale happened to be active. Literal strings remain allowed and are used as they are.
113
+
114
+ The page-title setters take the same values. Pass a literal to fix it, or the lazy `t` to translate it per request:
115
+
116
+ ```ruby
117
+ index_page_title t("blog.index.title")
118
+ index_page_description t("blog.index.description")
119
+ ```
120
+
121
+ When a definition sets no title, the page falls back to the resource's translated model name.
122
+
123
+ ## Derived labels
124
+
125
+ Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
126
+
127
+ ```yaml
128
+ en:
129
+ plutonium:
130
+ actions:
131
+ blogging/post:
132
+ publish: "Publish now"
133
+ scopes:
134
+ blogging/post:
135
+ drafts: "Drafts"
136
+ filters:
137
+ blogging/post:
138
+ author: "Written by"
139
+ kanban_columns:
140
+ blogging/post:
141
+ in_review: "In review"
142
+ wizard_steps:
143
+ onboarding_wizard:
144
+ billing: "Billing details"
145
+ ```
146
+
147
+ The model segment is the resource's `model_name.i18n_key`. For wizard steps it is the wizard's `model_name.i18n_key`.
148
+
149
+ Enum values shown as badges, and the value pills of a select filter, resolve the way Rails resolves enum attribute values, with a Plutonium key as an alternative:
150
+
151
+ ```yaml
152
+ en:
153
+ activerecord:
154
+ attributes:
155
+ blogging/post:
156
+ status/draft: "Draft"
157
+ status/published: "Live"
158
+ plutonium:
159
+ values:
160
+ blogging/post:
161
+ status:
162
+ archived: "Archived"
163
+ ```
164
+
165
+ ## Portals
166
+
167
+ Every convention key has a portal-scoped variant under `plutonium.portals.<portal>`, where `<portal>` is the package namespace (`admin_portal`). It wins over the global key while rendering inside that portal, and the main app has no portal segment.
168
+
169
+ Portal packages are Rails engines, so their `config/locales` directory loads automatically. The portal generator scaffolds `packages/<portal>/config/locales/en.yml` with the prefix in place:
170
+
171
+ ```yaml
172
+ # packages/admin_portal/config/locales/en.yml
173
+ en:
174
+ plutonium:
175
+ portals:
176
+ admin_portal:
177
+ fields:
178
+ blogging/post:
179
+ title:
180
+ placeholder: "Internal working title"
181
+ actions:
182
+ blogging/post:
183
+ publish: "Publish to site"
184
+ ```
185
+
186
+ Feature packages get the same file for their models' names, attributes and field text. Keys without the portal prefix apply everywhere, and load order settles conflicts: the app's files beat a package's, and a package's beat the gem's.
187
+
188
+ Locale keys vary by portal, never by tenant. Tenant-specific wording is a custom I18n backend concern and is out of scope.
189
+
190
+ ## JavaScript
191
+
192
+ The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page, in the current locale:
193
+
194
+ ```html
195
+ <meta name="pu-i18n" content="{&quot;clipboard&quot;:{&quot;copied&quot;:&quot;Copied!&quot;},...}">
196
+ ```
197
+
198
+ The blob is the `plutonium.js` subtree of the locale merged over the default locale, so overriding a key in YAML changes the bundled JavaScript's text without rebuilding it, and a partially translated locale falls back per key. Host code can read the same blob:
199
+
200
+ ```js
201
+ window.Plutonium.t("plutonium.js.turbo_confirm.confirm")
202
+ window.Plutonium.t("plutonium.js.attachment_input.delete_all", { count: 3 })
203
+ ```
204
+
205
+ Third-party widgets keep their own locale mechanisms. The blob carries optional pass-through settings under `plutonium.js.libraries`:
206
+
207
+ | Key | Passed to |
208
+ |---|---|
209
+ | `slim_select` | Slim Select `settings` (`placeholderText`, `searchText`, `searchPlaceholder`, `searchingText`) |
210
+ | `flatpickr.locale` | flatpickr's `locale` option. Loading the matching `l10n` bundle is the host's job. |
211
+ | `uppy.strings` | Uppy `locale.strings` |
212
+ | `intl_tel_input` | intl-tel-input `i18n` |
213
+
214
+ ## Auth
215
+
216
+ Rodauth's own text (labels, buttons, flashes, email subjects) is translated by the [rodauth-i18n](https://github.com/janko/rodauth-i18n) gem, which Plutonium's Rodauth views pick up unchanged because they render through Rodauth's configuration methods. The mailer templates the Rodauth generator copies into your app are plain ERB and are yours to translate.
217
+
218
+ ## Testing
219
+
220
+ Turn on missing-translation errors in the test environment so a rendered component with a missing key fails instead of printing its key:
221
+
222
+ ```ruby
223
+ # config/environments/test.rb
224
+ config.i18n.raise_on_missing_translations = true
225
+ ```
226
+
227
+ Convention lookups are exempt. They probe with `default: nil` and render nothing when a key is absent, which is the intended behaviour, so they never raise.
228
+
229
+ ## Adding a language
230
+
231
+ For a second locale, provide:
232
+
233
+ 1. A [rails-i18n](https://github.com/svenfuchs/rails-i18n) locale for Rails' own strings, dates and numbers.
234
+ 2. A Pagy dictionary for pagination, which Pagy ships for most languages.
235
+ 3. `rodauth-i18n` if you use Rodauth.
236
+ 4. A `plutonium` namespace translated from the gem's `config/locales/en/*.yml`.
237
+ 5. Your models, attributes and field text under the keys above.
238
+
239
+ Plutonium itself ships only `en`.