plutonium 0.65.0 → 0.67.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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +158 -141
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +161 -98
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +43 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +56 -3
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +32 -32
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/config/brakeman.ignore +1 -1
  23. data/config/locales/en/js.yml +4 -0
  24. data/config/locales/en/ui.yml +16 -0
  25. data/docs/.vitepress/sync-skills.mjs +6 -3
  26. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  27. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  28. data/docs/getting-started/installation.md +5 -5
  29. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  30. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  31. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  32. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  33. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  34. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  36. data/docs/getting-started/tutorial/index.md +1 -1
  37. data/docs/guides/adding-resources.md +10 -7
  38. data/docs/guides/authentication.md +25 -25
  39. data/docs/guides/authorization.md +24 -24
  40. data/docs/guides/creating-packages.md +17 -17
  41. data/docs/guides/custom-actions.md +32 -32
  42. data/docs/guides/customizing-ui.md +29 -26
  43. data/docs/guides/dashboards.md +1 -1
  44. data/docs/guides/index.md +3 -3
  45. data/docs/guides/kanban.md +55 -55
  46. data/docs/guides/multi-tenancy.md +35 -22
  47. data/docs/guides/nested-resources.md +21 -21
  48. data/docs/guides/performance.md +3 -3
  49. data/docs/guides/search-filtering.md +13 -13
  50. data/docs/guides/testing.md +16 -12
  51. data/docs/guides/theming.md +32 -17
  52. data/docs/guides/troubleshooting.md +2 -2
  53. data/docs/guides/user-invites.md +17 -17
  54. data/docs/guides/user-profile.md +51 -24
  55. data/docs/guides/wizards.md +55 -55
  56. data/docs/reference/app/generators.md +22 -22
  57. data/docs/reference/app/index.md +15 -18
  58. data/docs/reference/app/packages.md +8 -8
  59. data/docs/reference/app/portals.md +75 -29
  60. data/docs/reference/auth/accounts.md +15 -15
  61. data/docs/reference/auth/index.md +12 -12
  62. data/docs/reference/auth/profile.md +67 -29
  63. data/docs/reference/behavior/async-interactions.md +24 -24
  64. data/docs/reference/behavior/controllers.md +28 -28
  65. data/docs/reference/behavior/index.md +5 -5
  66. data/docs/reference/behavior/interactions.md +44 -44
  67. data/docs/reference/behavior/policies.md +48 -28
  68. data/docs/reference/configuration.md +6 -6
  69. data/docs/reference/dashboard/dsl.md +2 -2
  70. data/docs/reference/dashboard/index.md +1 -1
  71. data/docs/reference/generators/lite.md +7 -7
  72. data/docs/reference/i18n.md +23 -0
  73. data/docs/reference/index.md +1 -1
  74. data/docs/reference/kanban/authorization.md +9 -9
  75. data/docs/reference/kanban/dsl.md +32 -32
  76. data/docs/reference/kanban/index.md +1 -1
  77. data/docs/reference/kanban/positioning.md +17 -15
  78. data/docs/reference/resource/actions.md +51 -51
  79. data/docs/reference/resource/definition.md +85 -80
  80. data/docs/reference/resource/export.md +6 -6
  81. data/docs/reference/resource/index.md +16 -16
  82. data/docs/reference/resource/model.md +24 -24
  83. data/docs/reference/resource/positioning.md +78 -76
  84. data/docs/reference/resource/query.md +24 -14
  85. data/docs/reference/tenancy/entity-scoping.md +65 -35
  86. data/docs/reference/tenancy/index.md +11 -11
  87. data/docs/reference/tenancy/invites.md +20 -20
  88. data/docs/reference/tenancy/nested-resources.md +13 -13
  89. data/docs/reference/testing/index.md +116 -22
  90. data/docs/reference/ui/assets.md +57 -25
  91. data/docs/reference/ui/components.md +20 -20
  92. data/docs/reference/ui/displays.md +32 -15
  93. data/docs/reference/ui/forms.md +89 -39
  94. data/docs/reference/ui/index.md +17 -15
  95. data/docs/reference/ui/layouts.md +21 -21
  96. data/docs/reference/ui/pages.md +22 -22
  97. data/docs/reference/ui/tables.md +7 -7
  98. data/docs/reference/wizard/anchoring-resume.md +33 -32
  99. data/docs/reference/wizard/dsl.md +44 -44
  100. data/docs/reference/wizard/index.md +6 -6
  101. data/docs/reference/wizard/one-time.md +18 -18
  102. data/docs/reference/wizard/registration-launch.md +32 -32
  103. data/docs/reference/wizard/storage-config.md +23 -23
  104. data/gemfiles/rails_7.gemfile +1 -0
  105. data/gemfiles/rails_7.gemfile.lock +13 -3
  106. data/gemfiles/rails_8.0.gemfile +1 -0
  107. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  108. data/gemfiles/rails_8.1.gemfile +1 -0
  109. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  110. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  111. data/lib/plutonium/auth/rodauth.rb +7 -0
  112. data/lib/plutonium/core/controller.rb +0 -2
  113. data/lib/plutonium/helpers/content_helper.rb +0 -3
  114. data/lib/plutonium/query/filter.rb +2 -1
  115. data/lib/plutonium/query/filters/tags.rb +36 -0
  116. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  117. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  118. data/lib/plutonium/ui/display/base.rb +50 -2
  119. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  120. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  121. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  122. data/lib/plutonium/ui/display/components/code.rb +23 -0
  123. data/lib/plutonium/ui/display/components/color.rb +14 -6
  124. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  125. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  126. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  127. data/lib/plutonium/ui/display/components/list.rb +33 -0
  128. data/lib/plutonium/ui/display/components/number.rb +32 -0
  129. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  130. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  131. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  132. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  133. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  134. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  135. data/lib/plutonium/ui/display/theme.rb +24 -2
  136. data/lib/plutonium/ui/form/base.rb +30 -1
  137. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  138. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  139. data/lib/plutonium/ui/form/components/list.rb +46 -0
  140. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  141. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  142. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  143. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  144. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  145. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  146. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  147. data/lib/plutonium/ui/form/theme.rb +20 -4
  148. data/lib/plutonium/ui/grid/card.rb +13 -1
  149. data/lib/plutonium/ui/layout/base.rb +10 -1
  150. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  151. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  152. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  153. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  154. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  155. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  156. data/lib/plutonium/version.rb +1 -1
  157. data/package.json +1 -1
  158. data/src/css/components.css +53 -10
  159. data/src/css/flatpickr.css +91 -0
  160. data/src/js/controllers/easymde_controller.js +1 -0
  161. data/src/js/controllers/register_controllers.js +2 -0
  162. data/src/js/controllers/slim_select_controller.js +32 -3
  163. data/src/js/controllers/timeago_controller.js +40 -0
  164. metadata +25 -2
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: plutonium-ui
3
- description: Use BEFORE building or customizing any Plutonium UI — page classes, forms, displays, tables, custom Phlex components, layouts, Stimulus controllers, Tailwind config, design tokens, themes, or component classes. Covers the full view + asset toolchain.
3
+ description: 'Use BEFORE building or customizing any Plutonium UI: page classes, forms, displays, tables, custom Phlex components, layouts, Stimulus controllers, Tailwind config, design tokens, themes, or component classes. Covers the full view + asset toolchain.'
4
4
  ---
5
5
 
6
- # Plutonium UI — Pages, Forms, Components, Assets
6
+ # Plutonium UI: Pages, Forms, Components, Assets
7
7
 
8
8
  Plutonium uses Phlex for all view components and TailwindCSS 4 + Stimulus for the frontend. This skill covers everything from overriding a single page to writing custom Phlex components, configuring Tailwind, and theming via design tokens.
9
9
 
@@ -13,19 +13,21 @@ For field-level rendering (`field :foo, as: :markdown`, `display :status do |f|
13
13
 
14
14
  - **Override via nested classes in the definition.** `class ShowPage < ShowPage; end`, `class Form < Form; end`. Don't replace the entire view layer.
15
15
  - **Use render hooks, not `view_template`.** `render_before_content`, `render_after_content`, `render_before_toolbar`, etc. exist so you don't reimplement the whole page.
16
- - **All pages inherit `DynaFrameContent`** — turbo-frame requests render only the content. Don't fight it; modals and frame nav "just work".
17
- - **Custom components inherit `Plutonium::UI::Component::Base`** — gives you the component kit (`PageHeader`, `Panel`, `Block`), resource helpers, and the `helpers` proxy for Rails helpers.
18
- - **`render_actions` is mandatory in custom `form_template`** — without it, the form has no submit button.
19
- - **Always `registerControllers(application)`** in `app/javascript/controllers/index.js`. Without it, Plutonium's Stimulus controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
20
- - **Use `plutoniumTailwindConfig.merge`** when extending Tailwind theme — plain object merge drops Plutonium's defaults.
21
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
16
+ - **All pages inherit `DynaFrameContent`**: turbo-frame requests render only the content. Don't fight it; modals and frame nav "just work".
17
+ - **Custom components inherit `Plutonium::UI::Component::Base`**: it gives you the component kit (`PageHeader`, `Panel`, `Block`), resource helpers, and the `helpers` proxy for Rails helpers.
18
+ - **`render_actions` is mandatory in custom `form_template`**: without it, the form has no submit button.
19
+ - **Custom CSS, brand colors, or your own Stimulus controllers need `pu:core:assets` first.** Out of the box the app serves the gem's prebuilt `plutonium.css` / `plutonium.min.js`; the generator switches it to your own bundles. Don't hand-write the Tailwind/PostCSS pipeline.
20
+ - **Once the app owns its JS bundle, `registerControllers(application)`** must be in `app/javascript/controllers/index.js` (`pu:core:assets` adds it). Your bundle replaces the gem's, so without it Plutonium's Stimulus controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
21
+ - **Use `plutoniumTailwindConfig.merge`** when extending Tailwind theme; plain object merge drops Plutonium's defaults.
22
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs never.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule. A hand-written `bg-warning-50 dark:bg-warning-950/30` pair duplicates that, drifts from the theme, and may not even exist in the prebuilt CSS (see Part 8).
23
+ - **User-facing copy goes through `t(...)` with a locale key**, in components, pages, displays and definitions alike. See Part 4 › Translating text.
22
24
  - **Configure inputs in the definition; render them with `render_resource_field` in the form.** Don't reimplement field widgets from scratch.
23
25
 
24
26
  ---
25
27
 
26
- ## 🛑 Before you customize UI: pick the lightest seam (ASK — don't infer)
28
+ ## 🛑 Before you customize UI: pick the lightest seam (ASK: don't infer)
27
29
 
28
- Plutonium gives you escalating levels of customization. Reach for the **lightest that fits** — jumping to `view_template` or an eject loses breadcrumbs/header/DynaFrame behavior and saddles you with maintaining copied markup forever.
30
+ Plutonium gives you escalating levels of customization. Reach for the **lightest that fits**: jumping to `view_template` or an eject loses breadcrumbs/header/DynaFrame behavior and saddles you with maintaining copied markup forever.
29
31
 
30
32
  | You want to… | Reach for | **NOT** |
31
33
  |---|---|---|
@@ -33,35 +35,35 @@ Plutonium gives you escalating levels of customization. Reach for the **lightest
33
35
  | Re-arrange the record's fields | a custom `Display` (`display_template`) | a hand-rolled `Form`/`view_template` |
34
36
  | Group form fields into sections | the `form_layout` DSL in the definition | a `Form` subclass |
35
37
  | Recolor / rebrand | `plutoniumTailwindConfig.merge(...)` in `tailwind.config.js` | a plain object spread (drops Plutonium's defaults) |
36
- | Replace whole chrome per-portal | `pu:eject:shell` / `pu:eject:layout` — **last resort, you own it after** | ejecting when a hook/class/theme would do |
38
+ | Replace whole chrome per-portal | `pu:eject:shell` / `pu:eject:layout`, **last resort; you own it after** | ejecting when a hook/class/theme would do |
37
39
 
38
- Then: is the change **global** (base `PostDefinition`) or **per-portal** (`AdminPortal::PostDefinition`)? And does it touch CSS/JS (⇒ the asset toolchain must be set up)? Don't guess field names or the banner copy — read the definition.
40
+ Then: is the change **global** (base `PostDefinition`) or **per-portal** (`AdminPortal::PostDefinition`)? And does it touch CSS/JS (⇒ the asset toolchain must be set up)? Don't guess field names or the banner copy; read the definition.
39
41
 
40
- ## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
42
+ ## ✅ Before you edit: verify the ground truth (CHECK: read it, don't ask for it)
41
43
 
42
- You have file access — **inspect**; don't ask the user to describe their app.
44
+ You have file access, **inspect**; don't ask the user to describe their app.
43
45
 
44
46
  | Check | How | Why it matters |
45
47
  |---|---|---|
46
48
  | Custom page/Display already exists | Read the definition for nested `ShowPage`/`Display`/`Form` | Re-declaring clobbers an existing override |
47
49
  | Global vs per-portal | Is it `::PostDefinition` or `AdminPortal::PostDefinition`? | Override the right one |
48
50
  | Real field names | Read the model/definition | Don't invent fields in `display_template` |
49
- | Asset toolchain wired | `ls tailwind.config.js`; the CSS `@import`; has `pu:core:assets` run? | Brand/CSS edits won't compile otherwise |
50
- | Stimulus registered | grep `app/javascript/controllers/index.js` for `registerControllers` | Else the interactive layer is dead |
51
+ | Asset toolchain wired | `ls tailwind.config.js`; `config.assets.stylesheet` in the Plutonium initializer; has `pu:core:assets` run? | Still on the gem's prebuilt `plutonium.css` ⇒ brand/CSS edits won't compile |
52
+ | Stimulus registered | grep `app/javascript/controllers/index.js` for `registerControllers` | Once the app serves its own JS bundle, the interactive layer is dead without it |
51
53
  | Build watcher | Is `yarn dev` running? (`PLUTONIUM_DEV=1` when working on the gem) | CSS/JS changes need the rebuild |
52
54
 
53
55
  Inspect with your own tools **before** proposing code.
54
56
 
55
- ## 🛠 Use the generator — and prefer hooks over ejecting
57
+ ## 🛠 Use the generator, and prefer hooks over ejecting
56
58
 
57
59
  | Task | How | Verify first |
58
60
  |---|---|---|
59
- | Custom Tailwind + Stimulus toolchain | `pu:core:assets` | Not already run |
61
+ | Custom Tailwind + Stimulus toolchain | `pu:core:assets` (see Part 7 for its prerequisites) | Not already run |
60
62
  | Eject chrome (header/sidebar/layout) | `pu:eject:shell` / `pu:eject:layout --dest=portal` | A render hook / nested class / theme genuinely can't do it (last resort) |
61
63
 
62
64
  ---
63
65
 
64
- # Part 1 — Pages
66
+ # Part 1: Pages
65
67
 
66
68
  Each definition has nested page classes. Override the ones you need to customize:
67
69
 
@@ -93,16 +95,17 @@ Definition
93
95
 
94
96
  ```ruby
95
97
  class PostDefinition < ResourceDefinition
96
- index_page_title "Blog Posts"
97
- index_page_description "Manage all published articles"
98
- show_page_title "Article Details"
99
- show_page_title -> { "#{current_record!.title} — Details" } # dynamic
98
+ index_page_title t("blog.posts.index.title") # lazy: resolved per request, in its locale
99
+ index_page_description t("blog.posts.index.description")
100
+ show_page_title "Article Details" # a literal also works (fixed, untranslated)
100
101
 
101
102
  breadcrumbs true # global default
102
103
  index_page_breadcrumbs false # per-page override
103
104
  end
104
105
  ```
105
106
 
107
+ The class-level `t` in a definition returns a lazy proc; calling `I18n.t` directly in the class body would resolve once, at load time. A title that depends on the record belongs in a `page_title` override on the nested page class (see the hooks example below), not in a lambda here: the setter's proc is called with no record context.
108
+
106
109
  ## Page hooks (preferred over `view_template`)
107
110
 
108
111
  Every page inherits these:
@@ -123,12 +126,12 @@ class ShowPage < ShowPage
123
126
  private
124
127
 
125
128
  def page_title
126
- "#{object.title} — #{object.author.name}"
129
+ "#{object.title} - #{object.author.name}"
127
130
  end
128
131
 
129
132
  def render_before_content
130
- div(class: "alert alert-info") do
131
- plain "This post has #{object.comments.count} comments"
133
+ div(class: "pu-alert pu-alert-info", role: "status") do
134
+ div(class: "pu-alert-message") { t("blog.posts.show.comment_count", count: object.comments.count) }
132
135
  end
133
136
  end
134
137
 
@@ -179,7 +182,7 @@ Use to pin action strips, omit nav chrome, or swap layouts.
179
182
 
180
183
  ---
181
184
 
182
- # Part 2 — Forms
185
+ # Part 2: Forms
183
186
 
184
187
  Forms are built on [Phlexi::Form](https://github.com/radioactive-labs/phlexi-form). Hierarchy:
185
188
 
@@ -198,7 +201,7 @@ class PostDefinition < ResourceDefinition
198
201
  class Form < Form
199
202
  def form_template
200
203
  render_fields # render every permitted field
201
- render_actions # submit buttons — REQUIRED
204
+ render_actions # submit buttons, REQUIRED
202
205
  end
203
206
  end
204
207
  end
@@ -220,11 +223,11 @@ end
220
223
 
221
224
  ## Custom layouts
222
225
 
223
- ### Sectioned — prefer the `form_layout` / `display_layout` DSL
226
+ ### Sectioned: prefer the `form_layout` / `display_layout` DSL
224
227
 
225
- **For grouping fields into sections, don't hand-roll a `Form` or `Display` subclass — declare `form_layout` (forms) or `display_layout` (show page) in the definition.** They handle headings, descriptions, collapsible `<details>`, `condition:`-based visibility, and **auto-drop sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). `columns:` is form-only — `display_layout` raises on it. See [[plutonium-resource]] › Form Layout / Display Layout.
228
+ **For grouping fields into sections, don't hand-roll a `Form` or `Display` subclass; declare `form_layout` (forms) or `display_layout` (show page) in the definition.** They handle headings, descriptions, collapsible `<details>`, `condition:`-based visibility, and **auto-drop sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). `columns:` is form-only, `display_layout` raises on it. See [[plutonium-resource]] › Form Layout / Display Layout.
226
229
 
227
- **Each section renders as its own card** (`Plutonium::UI::Block`), so a sectioned form or show page has **no single outer card** — the form drops its own `pu-card` and the sections supply it. Don't add a card wrapper of your own around them.
230
+ **Each section renders as its own card** (`Plutonium::UI::Block`), so a sectioned form or show page has **no single outer card**: the form drops its own `pu-card` and the sections supply it. Don't add a card wrapper of your own around them.
228
231
 
229
232
  ```ruby
230
233
  class PostDefinition < ResourceDefinition
@@ -265,7 +268,7 @@ class Form < Form
265
268
  end
266
269
  ```
267
270
 
268
- A hand-rolled `section` like this renders its heading unconditionally — that's exactly the empty-heading problem `form_layout` avoids. If you must hand-roll, guard empty sections yourself.
271
+ A hand-rolled `section` like this renders its heading unconditionally, which is exactly the empty-heading problem `form_layout` avoids. If you must hand-roll, guard empty sections yourself.
269
272
 
270
273
  ### Two-column
271
274
 
@@ -306,9 +309,9 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
306
309
  | Tag | Input |
307
310
  |---|---|
308
311
  | `input_tag` | text (auto-detected type) |
309
- | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `tel_tag`, `hidden_tag` | standard HTML inputs |
312
+ | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag` / `secret_tag`, `url_tag`, `hidden_tag` | standard HTML inputs |
310
313
  | `checkbox_tag`, `select_tag`, `radio_button_tag` | standard |
311
- | `toggle_tag` / `switch_tag` | switch-styled boolean (`as: :toggle` / `:switch`) — default for boolean columns; `as: :boolean` for a plain checkbox |
314
+ | `toggle_tag` / `switch_tag` | switch-styled boolean (`as: :toggle` / `:switch`), default for boolean columns; `as: :boolean` for a plain checkbox |
312
315
 
313
316
  ### Plutonium-enhanced tags
314
317
 
@@ -322,6 +325,13 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
322
325
  | `secure_association_tag` | Association with policy-checked options |
323
326
  | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
324
327
  | `key_value_store_tag` | Key/value pairs editor |
328
+ | `currency_tag` | Money input (number field + unit prefix) |
329
+ | `rich_text_tag` | Action Text editor (Trix, or Lexxy once the app installs it); default for `has_rich_text` |
330
+ | `list_tag` | Chip input for array values (`suggestions:`, `limit:`, `addable: false`) |
331
+ | `tags_tag` | Chip input for a no_fly_list `has_tags` context; default for those contexts |
332
+ | `rating_tag` | Star picker (`max:`, default 5); a Clear option unless required |
333
+ | `binary_tag` | Plain file input whose bytes go into a `binary` column |
334
+ | `json_tag` | JSON editor; default for `json`/`jsonb` columns |
325
335
 
326
336
  ```ruby
327
337
  render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today, enable_time: true) }
@@ -330,13 +340,14 @@ render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg
330
340
 
331
341
  ### Password & secret fields
332
342
 
333
- `password_tag` masks the stored value — it **never emits the secret into the DOM**. A stored secret renders a sentinel; an untouched submit keeps it, an edit-to-new-value then failed re-render comes back blank + `required` (re-type — secrets are never echoed back), a *cleared* field comes back blank but **not** `required` (the clear may be intentional), a deliberately emptied field clears it (clear-by-blank), a typed value sets it. The sentinel is guarded by the `password-sentinel` Stimulus controller — the first edit (incl. **backspace**) wipes the whole field so a partial edit can't corrupt it.
343
+ `password_tag` masks the stored value and **never emits the secret into the DOM**. A stored secret renders a sentinel; an untouched submit keeps it, an edit-to-new-value then failed re-render comes back blank + `required` (re-type; secrets are never echoed back), a *cleared* field comes back blank but **not** `required` (the clear may be intentional), a deliberately emptied field clears it (clear-by-blank), a typed value sets it. The sentinel is guarded by the `password-sentinel` Stimulus controller: the first edit (incl. **backspace**) wipes the whole field so a partial edit can't corrupt it.
334
344
 
335
- Auto-detected by name: `password`/`token`/`salt`, `encrypted_*`, `*_password`/`*_digest`/`*_hash`/`*_token`/`*_key`/`*_salt`, or any name containing `secret`. A convenience, **not** a guarantee — odd-named secrets (`recovery_phrase`, `pin`) still leak unless masked explicitly.
345
+ Auto-detected by name: `password`/`token`/`salt`, `encrypted_*`, `*_password`/`*_digest`/`*_hash`/`*_token`/`*_key`/`*_salt`, or any name containing `secret`. The same names render `••••••••` on the show page, table, wizard summary and grid/kanban cards; `field`/`display` `as:` overrides it there too. A convenience, **not** a guarantee: odd-named secrets (`recovery_phrase`, `pin`) still leak unless masked explicitly.
336
346
 
337
347
  ```ruby
338
- field :api_token, as: :string # opt OUT — show a readable value (token to copy, checksum)
339
- field :recovery_phrase, as: :password # opt IN — mask a secret the heuristic misses
348
+ field :api_token, as: :string # opt OUT: show a readable value (token to copy, checksum)
349
+ field :recovery_phrase, as: :password # opt IN: mask a secret the heuristic misses
350
+ field :pin, as: :secret # :secret is an alias of :password
340
351
  ```
341
352
 
342
353
  ## Submit buttons
@@ -347,7 +358,7 @@ Control the secondary button via the definition:
347
358
 
348
359
  ```ruby
349
360
  class PostDefinition < ResourceDefinition
350
- submit_and_continue false # nil (default — auto), true (always show), false (always hide)
361
+ submit_and_continue false # nil (default, auto), true (always show), false (always hide)
351
362
  end
352
363
  ```
353
364
 
@@ -369,14 +380,14 @@ end
369
380
 
370
381
  These all live in the definition layer:
371
382
 
372
- - **Pre-submit / dynamic forms** — see [[plutonium-resource]] › Dynamic Forms.
373
- - **Nested inputs** (`nested_input :variants`) — association-backed inline forms; see [[plutonium-resource]] › Nested Inputs.
374
- - **Structured inputs** (`structured_input :payload`, `structured_input :rows, repeat: 5`) — classless hash / array-of-hashes into a JSON column (resources) or an attribute (interactions); reuses the repeater chrome. See [[plutonium-resource]] › Structured Inputs.
375
- - **Interaction forms** — interactions define their own `attribute` / `input` and inherit `Plutonium::UI::Form::Interaction`; see [[plutonium-behavior]] › Interactions.
383
+ - **Pre-submit / dynamic forms**: see [[plutonium-resource]] › Dynamic Forms.
384
+ - **Nested inputs** (`nested_input :variants`): association-backed inline forms; see [[plutonium-resource]] › Nested Inputs.
385
+ - **Structured inputs** (`structured_input :payload`, `structured_input :rows, repeat: 5`): classless hash / array-of-hashes into a JSON column (resources) or an attribute (interactions); reuses the repeater chrome. See [[plutonium-resource]] › Structured Inputs.
386
+ - **Interaction forms**: interactions define their own `attribute` / `input` and inherit `Plutonium::UI::Form::Interaction`; see [[plutonium-behavior]] › Interactions.
376
387
 
377
388
  ---
378
389
 
379
- # Part 3 — Display & Table
390
+ # Part 3: Display & Table
380
391
 
381
392
  ## Custom Display
382
393
 
@@ -390,7 +401,7 @@ class PostDefinition < ResourceDefinition
390
401
  end
391
402
 
392
403
  # `fields_wrapper` is ALREADY a card (it renders a Block internally),
393
- # so do not wrap it in another one — that stacks two cards and doubles
404
+ # so do not wrap it in another one: that stacks two cards and doubles
394
405
  # the border and shadow.
395
406
  fields_wrapper do
396
407
  render_resource_field :author
@@ -412,14 +423,14 @@ end
412
423
  |---|---|
413
424
  | `render_fields` | All permitted fields |
414
425
  | `render_resource_field(name)` | One field |
415
- | `render_associations` | Association tabs (driven by `permitted_associations` — see [[plutonium-behavior]]) |
416
- | `render_before_fields` / `render_after_fields` | Hooks around the fields — **Details tab only** |
426
+ | `render_associations` | Association tabs (driven by `permitted_associations`, see [[plutonium-behavior]]) |
427
+ | `render_before_fields` / `render_after_fields` | Hooks around the fields, **Details tab only** |
417
428
  | `object` | The record |
418
429
  | `resource_fields`, `resource_associations` | Permitted lists |
419
430
 
420
431
  ### Details-tab-only content
421
432
 
422
- To add a banner or extra section that shows on the **Details** tab and not the association tabs, override `render_before_fields` / `render_after_fields` on the **Display** — not the ShowPage. The page-level `render_before_content` / `render_after_content` hooks wrap the whole content block, and the tablist lives inside it, so anything added there shows on every tab.
433
+ To add a banner or extra section that shows on the **Details** tab and not the association tabs, override `render_before_fields` / `render_after_fields` on the **Display**, not the ShowPage. The page-level `render_before_content` / `render_after_content` hooks wrap the whole content block, and the tablist lives inside it, so anything added there shows on every tab.
423
434
 
424
435
  ```ruby
425
436
  class PostDefinition < ResourceDefinition
@@ -427,13 +438,28 @@ class PostDefinition < ResourceDefinition
427
438
  private
428
439
 
429
440
  def render_before_fields
430
- div(class: "pu-card pu-card-body mb-4") { plain "Only on the Details tab" }
441
+ flagged = object.comments.where(flagged: true).count
442
+ return if flagged.zero?
443
+
444
+ div(class: "pu-alert pu-alert-warning", role: "alert") do
445
+ div(class: "pu-alert-message") { t("blog.posts.flagged_comments", count: flagged) }
446
+ end
431
447
  end
432
448
  end
433
449
  end
434
450
  ```
435
451
 
436
- Both hooks are no-ops by default. `render_fields` is the Details tab body when the record has associations and the entire display when it doesn't, so the hooks fire in the Details context either way.
452
+ ```yaml
453
+ # config/locales/en.yml
454
+ en:
455
+ blog:
456
+ posts:
457
+ flagged_comments:
458
+ one: "1 comment is waiting for moderation."
459
+ other: "%{count} comments are waiting for moderation."
460
+ ```
461
+
462
+ `pu-alert pu-alert-<success|warning|danger|info>` is the same inline banner the flash messages use (`app/views/plutonium/_flash_alerts.html.erb`), so it already has its dark-mode colors. Both hooks are no-ops by default. `render_fields` is the Details tab body when the record has associations and the entire display when it doesn't, so the hooks fire in the Details context either way.
437
463
 
438
464
  ## Custom Table
439
465
 
@@ -472,7 +498,7 @@ end
472
498
 
473
499
  ## Drag-to-Reorder Affordance (`position_on`)
474
500
 
475
- When a definition declares `position_on` (see [[plutonium-resource]]) the index **table**, the **card grid**, and **nested association tables** render a drag grip. Configuration is entirely in the definition — there is no UI-layer switch.
501
+ When a definition declares `position_on` (see [[plutonium-resource]]) the index **table**, the **card grid**, and **nested association tables** render a drag grip. Configuration is entirely in the definition; there is no UI-layer switch.
476
502
 
477
503
  **What renders where:**
478
504
 
@@ -480,11 +506,11 @@ When a definition declares `position_on` (see [[plutonium-resource]]) the index
480
506
  |---|---|---|---|
481
507
  | Index / nested table | the **grip only**, never the `<tr>` | inside the first cell's existing left padding (content does not shift) | vertical |
482
508
  | Card grid | the **grip only** | floated over the card's top-left corner | horizontal, wrap-aware |
483
- | Kanban board | the **whole card** | — | both (cross-column) |
509
+ | Kanban board | the **whole card** | - | both (cross-column) |
484
510
 
485
- 🚨 **Never make a `<tr>` draggable.** Two silent regressions: `draggable="true"` disables text selection inside the element in every major browser (you lose copy-a-cell-value), and it fights `row_click_controller` — a drag that starts and ends in place still fires a click and navigates the user away. Kanban keeps whole-card dragging because neither applies to a kanban card; a **grid** card gets a grip because it *does* carry a row-click show affordance.
511
+ 🚨 **Never make a `<tr>` draggable.** Two silent regressions: `draggable="true"` disables text selection inside the element in every major browser (you lose copy-a-cell-value), and it fights `row_click_controller`: a drag that starts and ends in place still fires a click and navigates the user away. Kanban keeps whole-card dragging because neither applies to a kanban card; a **grid** card gets a grip because it *does* carry a row-click show affordance.
486
512
 
487
- **Enabled state.** The grip is live only while the collection is sorted **ascending, by the position attribute, and nothing else**. Otherwise "drop me between these two rows" describes nothing. Under a foreign sort the Stimulus controller isn't attached at all and the grip renders as a **link that applies the position sort** — the disabled state is the way out of the disabled state, which is why `position_on` registers `sort <attr>`. Per record, the grip also requires `reposition?`.
513
+ **Enabled state.** The grip is live only while the collection is sorted **ascending, by the position attribute, and nothing else**. Otherwise "drop me between these two rows" describes nothing. Under a foreign sort the Stimulus controller isn't attached at all and the grip renders as a **link that applies the position sort**: the disabled state is the way out of the disabled state, which is why `position_on` registers `sort <attr>`. Per record, the grip also requires `reposition?`.
488
514
 
489
515
  **DOM contract** (relevant if you eject a table/grid or write a custom collection component):
490
516
 
@@ -493,18 +519,18 @@ wrapper data-controller="positioned"
493
519
  data-positioned-url-template-value="/things/__ID__/reposition"
494
520
  data-positioned-axis-value="horizontal" # grid only
495
521
  row/card data-positioned-row-id="<id>" # single source of truth for the record id
496
- grip data-positioned-grip # a real <button> — tabbable
522
+ grip data-positioned-grip # a real <button>, tabbable
497
523
  ```
498
524
 
499
- The URL template is built off `current_page_path` (not `request.path`) so a post-rebalance re-render doesn't wire subsequent drops to `/things/5/reposition`. The controller POSTs `{prev_id, next_id, to_index}` plus `window.location.search` — the query string is load-bearing, since the endpoint re-renders through the ordinary index pipeline.
525
+ The URL template is built off `current_page_path` (not `request.path`) so a post-rebalance re-render doesn't wire subsequent drops to `/things/5/reposition`. The controller POSTs `{prev_id, next_id, to_index}` plus `window.location.search`; the query string is load-bearing, since the endpoint re-renders through the ordinary index pipeline.
500
526
 
501
- **Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
527
+ **Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> (deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order). Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
502
528
 
503
529
  Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/resource/positioning.md`.
504
530
 
505
531
  ---
506
532
 
507
- # Part 4 — Component Kit & Custom Components
533
+ # Part 4: Component Kit & Custom Components
508
534
 
509
535
  ## Built-in shorthand kit
510
536
 
@@ -528,7 +554,7 @@ Breadcrumbs()
528
554
 
529
555
  ## Avatar
530
556
 
531
- `Avatar(subject = nil, src: nil, size: :md, alt: nil, **attrs)` — profile image with a deterministic [Navii](https://navii.dev) fallback. Registered in the kit.
557
+ `Avatar(subject = nil, src: nil, size: :md, alt: nil, **attrs)`: profile image with a deterministic [Navii](https://navii.dev) fallback. Registered in the kit.
532
558
 
533
559
  ```ruby
534
560
  Avatar(user) # Navii fallback seeded from the record
@@ -540,17 +566,24 @@ Avatar(src: avatar_url) # bare image, no subject/fallback
540
566
  ```
541
567
 
542
568
  - **subject** (positional): record → PII-free hashed seed + default `alt` (display name); String → seed. A URL-shaped String (`http(s)://…` or `/…`) is routed to `src` (shown as the image), not used as a seed.
543
- - **src**: a Symbol is sent to the subject (`:avatar` → `subject.avatar`, a **contract** — raises if absent); otherwise an ActiveStorage attachment, active_shrine/Shrine uploader, or URL string. ActiveStorage resolves via `helpers.url_for`; everything else via its own `#url`.
569
+ - **src**: a Symbol is sent to the subject (`:avatar` → `subject.avatar`, a **contract**, raises if absent); otherwise an ActiveStorage attachment, active_shrine/Shrine uploader, or URL string. ActiveStorage resolves via `helpers.url_for`; everything else via its own `#url`.
544
570
  - **size**: `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer.
545
- - **Privacy**: the value sent to Navii is **always** a SHA256 hash — no ids, emails, or seed strings leave the app. Deterministic per subject.
571
+ - **Privacy**: the value sent to Navii is **always** a SHA256 hash, so no ids, emails, or seed strings leave the app. Deterministic per subject.
546
572
  - **Resolution order**: resolved `src` → Navii (from subject) → generic user icon.
547
573
  - **Config**: `config.navii_host_url` (default `https://api.navii.dev`); the component appends `/avatar/:seed`.
548
574
 
549
- 🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only — a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
575
+ 🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only: a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
550
576
 
551
- ## Translating component text
577
+ ## Translating text
552
578
 
553
- Every `Plutonium::UI::Component::Base` subclass (pages included) has a protected `t(key, **opts)` that reads Rails I18n with a full key; components that subclass Phlexi classes call `Plutonium::Translation.t`. Put new user-facing text in a locale file, never a literal:
579
+ Put new user-facing text in a locale file, never a literal. Which `t` you get depends on where the code runs:
580
+
581
+ | Where | Call | Notes |
582
+ |---|---|---|
583
+ | Components, pages, and nested `Form` / `Display` / `Table` classes (render hooks included) | `t("full.key", **opts)` | Protected instance method from `Plutonium::UI::Component::Behaviour`; full keys only |
584
+ | `display` / `input` / `column` blocks in a definition | `t("full.key")` | The block is `instance_exec`ed by the page, so it is the page's `t` |
585
+ | Definition class body (`label:`, `hint:`, `placeholder:`, page titles) | `t("full.key")` | Class-level lazy `t` (`Plutonium::Translation::Lazy`), resolved per render in the request's locale |
586
+ | A bare Phlexi field component without `Behaviour` | `Plutonium::Translation.t("full.key")` | |
554
587
 
555
588
  ```ruby
556
589
  def view_template
@@ -559,6 +592,8 @@ def view_template
559
592
  end
560
593
  ```
561
594
 
595
+ Never call `I18n.t` directly in a definition class body: it runs once at load time, in whatever locale is active then.
596
+
562
597
  Rules: one key per sentence with `%{name}` placeholders (never concatenate fragments around a value), `count:` for plurals, no `.downcase`/`.pluralize` on translated nouns. The gem's own keys live under `plutonium.*` in its `config/locales/en/*.yml` and any can be overridden from the app.
563
598
 
564
599
  Stimulus controllers bundled with Plutonium read `plutonium.js.*` from a `<meta name="pu-i18n">` JSON blob the layout renders; host JS can call `window.Plutonium.t("plutonium.js.turbo_confirm.confirm")`. Library locales (Slim Select, flatpickr, Uppy, intl-tel-input) pass through `plutonium.js.libraries.*`. See `docs/reference/i18n.md`.
@@ -575,14 +610,14 @@ class PostCardComponent < Plutonium::UI::Component::Base
575
610
  div(class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] p-4") do
576
611
  h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
577
612
  p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
578
- a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
613
+ a(href: resource_url_for(@post), class: "text-primary-600") { t("blog.posts.card.read_more") }
579
614
  end
580
615
  end
581
616
  end
582
617
  ```
583
618
 
584
619
  Use in a definition. A component with its **own constructor** (like the one above)
585
- must use the **block form** — you build it:
620
+ must use the **block form**; you build it:
586
621
 
587
622
  ```ruby
588
623
  display :card do |field|
@@ -640,13 +675,13 @@ All pages inherit this. Modals and frame navigation work without special handlin
640
675
 
641
676
  ---
642
677
 
643
- # Part 5 — Modals, Slideovers, Tabs
678
+ # Part 5: Modals, Slideovers, Tabs
644
679
 
645
680
  ## Modal/slideover for `:new` / `:edit` + interactive actions
646
681
 
647
682
  ```ruby
648
683
  class PostDefinition < ResourceDefinition
649
- modal :slideover # default — slide-in panel from the right
684
+ modal :slideover # default, slide-in panel from the right
650
685
  # modal :centered # centered dialog
651
686
  # modal :centered, size: :lg # centered, wider container
652
687
  # modal false # full standalone page
@@ -657,19 +692,19 @@ Drives both framework `:new` / `:edit` and every interactive action on the defin
657
692
 
658
693
  ## Tabs on the show page
659
694
 
660
- Show pages with `permitted_associations` (see [[plutonium-behavior]]) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
695
+ Show pages with `permitted_associations` (see [[plutonium-behavior]]) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports; they don't wrap.
661
696
 
662
697
  If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
663
698
 
664
699
  ---
665
700
 
666
- # Part 6 — Layout (Chrome) & Eject
701
+ # Part 6: Layout (Chrome) & Eject
667
702
 
668
703
  ## Shell
669
704
 
670
705
  ```ruby
671
706
  Plutonium.configure do |config|
672
- config.shell = :modern # default — topbar + icon rail
707
+ config.shell = :modern # default, topbar + icon rail
673
708
  # config.shell = :plain # topbar, no icon rail (whole app rail-less)
674
709
  # config.shell = :classic # legacy header + sidebar (only when upgrading)
675
710
  end
@@ -679,16 +714,16 @@ end
679
714
 
680
715
  ```ruby
681
716
  config.shell = :plain # 1. global default
682
- # 2. per-engine — inside the engine's config.after_initialize (with scope_to_entity)
717
+ # 2. per-engine: inside the engine's config.after_initialize (with scope_to_entity)
683
718
  class CustomerPortal::Engine
684
719
  config.after_initialize { shell :plain }
685
720
  end
686
721
  class DashboardController; shell :modern; end # 3. per-controller (overrides engine/global)
687
722
  ```
688
723
 
689
- `shell` takes a symbol so the class body works too, but the generated engine already has a `config.after_initialize` block (home of `scope_to_entity`) — keep it there for consistency.
724
+ `shell` takes a symbol so the class body works too, but the generated engine already has a `config.after_initialize` block (home of `scope_to_entity`); keep it there for consistency.
690
725
 
691
- Alongside `shell`, the controller-only `rail` DSL flips just the rail (inherited `class_attribute`, so a portal opts in/out once in its concern) — `rail false` / `rail true`; `rail nil` (default) inherits the resolved shell, `rail?` reads the resolved value:
726
+ Alongside `shell`, the controller-only `rail` DSL flips just the rail (inherited `class_attribute`, so a portal opts in/out once in its concern), `rail false` / `rail true`; `rail nil` (default) inherits the resolved shell, `rail?` reads the resolved value:
692
727
 
693
728
  ```ruby
694
729
  module CustomerPortal::Concerns::Controller
@@ -716,7 +751,7 @@ The sidebar/icon-rail menu is built with `Phlexi::Menu::Builder` in `_resource_s
716
751
  m.item "Inbox", url: inbox_path, icon: Icon, target: "_blank", rel: "noopener", data: {turbo_frame: "_top"}
717
752
  ```
718
753
 
719
- Applies to both shells (icon-rail leaf, parent flyout trigger, and flyout children; classic sidebar). Framework `class`/`data`/`aria` win on conflict — `class:` merges with the base classes, and on a parent trigger `data:`/`aria:` merge with the flyout wiring so options can't break the toggle. Phlexi's reserved `:active` key is never emitted as an attribute.
754
+ Applies to both shells (icon-rail leaf, parent flyout trigger, and flyout children; classic sidebar). Framework `class`/`data`/`aria` win on conflict; `class:` merges with the base classes, and on a parent trigger `data:`/`aria:` merge with the flyout wiring so options can't break the toggle. Phlexi's reserved `:active` key is never emitted as an attribute.
720
755
 
721
756
  ## Custom layout class (Phlex)
722
757
 
@@ -751,7 +786,7 @@ end
751
786
 
752
787
  ---
753
788
 
754
- # Part 7 — Assets, Tailwind, Stimulus
789
+ # Part 7: Assets, Tailwind, Stimulus
755
790
 
756
791
  ## Asset configuration
757
792
 
@@ -772,7 +807,17 @@ end
772
807
  rails generate pu:core:assets
773
808
  ```
774
809
 
775
- This installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers, and points the Plutonium config at your asset files.
810
+ Until this runs, the app serves the gem's prebuilt assets (`config.assets.stylesheet` defaults to `plutonium.css`, `script` to `plutonium.min.js`, see `lib/plutonium/configuration.rb`). Those are compiled from the gem's own sources, so app-side Tailwind classes, a new `primary` palette, CSS token overrides and custom Stimulus controllers have nowhere to go. The generator:
811
+
812
+ - installs `@radioactive-labs/plutonium` (pinned to the gem version), Tailwind 4 and the PostCSS plugins
813
+ - writes `tailwind.config.js` (through `plutoniumTailwindConfig.merge`) and `postcss.config.js`
814
+ - prepends `@import "gem:plutonium/src/css/plutonium.css";` to `application.tailwind.css` and adds `@config` after `@import "tailwindcss";`
815
+ - appends `registerControllers(application)` to `app/javascript/controllers/index.js`
816
+ - sets `config.assets.stylesheet = "application"` and `config.assets.script = "application"`, and writes the `build` / `build:css` scripts in `package.json`
817
+
818
+ That last step is why `registerControllers` is not optional: the gem's `plutonium.min.js` calls it itself, and your `application.js` replaces that bundle.
819
+
820
+ **Prerequisites.** It aborts unless `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` exist, i.e. an app created with `-j esbuild -c tailwind` plus Stimulus. An app without them gets the bundlers installed first (`bin/rails javascript:install:esbuild`, `css:install:tailwind`, `stimulus:install` from jsbundling-rails / cssbundling-rails / stimulus-rails), then the generator. Don't hand-write `tailwind.config.js` / `postcss.config.js` instead: the generated ones resolve the gem path (`bundle show plutonium`) and load its `postcss-gem-import.cjs` so the `gem:` import works.
776
821
 
777
822
  Packages install with the app's package manager, detected from the lockfile (`bun.lock`/`bun.lockb` → bun, `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm, `yarn.lock` → yarn), else the first of bun, yarn, pnpm, npm on PATH. A yarn app stays yarn even with bun installed. Do not run `yarn add` by hand in a bun app: that leaves two lockfiles. Yarn 2+ needs `nodeLinker: node-modules` in `.yarnrc.yml` (the generator writes it); Tailwind's PostCSS plugin does not load under Plug'n'Play.
778
823
 
@@ -803,6 +848,8 @@ module.exports = {
803
848
 
804
849
  ## Default color palette
805
850
 
851
+ These are Tailwind palette colors, compiled into the CSS at build time (`.pu-btn-primary` is `@apply bg-primary-600 ...`; `--pu-input-focus-ring` is `theme(colors.primary.500)`). Recoloring `primary` therefore means the `merge` below plus a rebuild, not a `--pu-*` override.
852
+
806
853
  | Color | Use |
807
854
  |---|---|
808
855
  | `primary` | Brand primary (turquoise default) |
@@ -853,7 +900,7 @@ application.register("custom", CustomController)
853
900
 
854
901
  Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, `kanban`, `positioned` (drag-to-reorder), `row-click`, plus various internal UI controllers.
855
902
 
856
- Custom controller — standard Stimulus:
903
+ Custom controller, standard Stimulus:
857
904
 
858
905
  ```javascript
859
906
  import { Controller } from "@hotwired/stimulus"
@@ -881,11 +928,11 @@ theme: { fontFamily: { body: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-seri
881
928
 
882
929
  ## Dark mode
883
930
 
884
- `selector` strategy — toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
931
+ `selector` strategy: toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
885
932
 
886
933
  ---
887
934
 
888
- # Part 8 — Design Tokens & `.pu-*` Component Classes
935
+ # Part 8: Design Tokens & `.pu-*` Component Classes
889
936
 
890
937
  Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards, shadows, radii, spacing, and transitions. Tokens auto-switch with dark mode. Source: `src/css/tokens.css`.
891
938
 
@@ -898,12 +945,15 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
898
945
  | `--pu-border`, `--pu-border-muted`, `--pu-border-strong` | Borders |
899
946
  | `--pu-input-bg`, `--pu-input-border`, `--pu-input-focus-ring`, `--pu-input-placeholder` | Form inputs |
900
947
  | `--pu-card-bg`, `--pu-card-border` | Cards |
948
+ | `--pu-table-header-bg`, `--pu-table-header-text`, `--pu-table-row-bg`, `--pu-table-row-hover`, `--pu-table-row-selected`, `--pu-table-border` | Tables |
949
+ | `--pu-text-danger` | Error text |
950
+ | `--pu-chart-1` … `--pu-chart-8` | Dashboard chart series |
901
951
  | `--pu-shadow-sm/md/lg` | Shadows |
902
952
  | `--pu-radius-sm/md/lg/xl/full` | Border radius |
903
953
  | `--pu-space-xs/sm/md/lg/xl` | Spacing |
904
954
  | `--pu-transition-fast/normal/slow` | Transitions |
905
955
 
906
- 🚨 Tokens are CSS variables — use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
956
+ 🚨 Tokens are CSS variables: use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
907
957
 
908
958
  ## Customizing tokens
909
959
 
@@ -919,11 +969,23 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
919
969
  }
920
970
  ```
921
971
 
922
- 🚨 **Mirror every `:root` override in `.dark`.** The app stylesheet loads after Plutonium's and `:root`/`.dark` have equal specificity, so a `:root`-only override beats Plutonium's `.dark` value even in dark mode — your light color ships into dark mode, often unreadably (e.g. translucent navy `--pu-text-subtle` is invisible on a dark surface). Every color token customized in `:root` MUST be re-asserted with a dark value in `.dark`.
972
+ 🚨 **Mirror every `:root` override in `.dark`.** The app stylesheet loads after Plutonium's and `:root`/`.dark` have equal specificity, so a `:root`-only override beats Plutonium's `.dark` value even in dark mode, so your light color ships into dark mode, often unreadably (e.g. translucent navy `--pu-text-subtle` is invisible on a dark surface). Every color token customized in `:root` MUST be re-asserted with a dark value in `.dark`. That includes the shadows: `src/css/tokens.css` redefines `--pu-shadow-sm/md/lg` (and every surface, text, border, table, input, card and chart token) under `.dark`, so a tinted light shadow left out of your `.dark` block replaces the dark one.
973
+
974
+ Put dark values in a `.dark { ... }` block, not `@media (prefers-color-scheme: dark)`. Dark mode is the `dark` class on `<html>` (set by the `color-mode` controller), so a media query ignores the user's toggle.
975
+
976
+ Overrides need the app's own stylesheet, after the Plutonium import (`pu:core:assets`). Never edit the gem's `tokens.css` / `components.css`.
923
977
 
924
978
  ## `.pu-*` component classes
925
979
 
926
- Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
980
+ Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` (or `warning-50 dark:warning-950`) pairs.**
981
+
982
+ Why, in order of preference:
983
+
984
+ 1. **A `.pu-*` class** (`pu-alert-warning`, `pu-badge-warning`, `pu-card`, `pu-btn-soft-danger`). Each ships with its own `.dark` rule and is always in the CSS, because `components.css` is part of `plutonium.css` whether the app uses the prebuilt file or imports it.
985
+ 2. **A `var(--pu-*)` token** (`text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`) for layout around them. The token switches value under `.dark`, so one class covers both modes and follows any theme override.
986
+ 3. **Raw palette utilities** only for what neither covers. On the prebuilt `plutonium.css` they exist only if the gem's own sources happen to use them (its Tailwind `content` scans the gem, not your app); with your own build they compile, but every one needs a hand-picked `dark:` twin that won't follow a rebrand.
987
+
988
+ For a status banner use `pu-alert pu-alert-<variant>` with a `pu-alert-message` child; for an inline status chip, `pu-badge pu-badge-<variant>`.
927
989
 
928
990
  ### Buttons
929
991
 
@@ -944,6 +1006,7 @@ Ready-to-use styled components in `src/css/components.css`. **Prefer these over
944
1006
  ```
945
1007
  .pu-input / -invalid / -valid .pu-label / -required .pu-hint / .pu-error .pu-checkbox / .pu-toggle
946
1008
  .pu-badge / -neutral / -primary / -secondary / -success / -danger / -warning / -info / -accent
1009
+ .pu-alert / -success / -warning / -danger / -info .pu-alert-message / .pu-alert-close
947
1010
  .pu-card / .pu-card-body
948
1011
  .pu-panel-header / -title / -description
949
1012
  .pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
@@ -1006,9 +1069,9 @@ tokens("base", condition?: {then: "if-true", else: "if-false"})
1006
1069
 
1007
1070
  ---
1008
1071
 
1009
- # Part 9 — Phlexi Component Themes
1072
+ # Part 9: Phlexi Component Themes
1010
1073
 
1011
- Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults — never replace wholesale, always `super.merge(...)`.
1074
+ Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults, never replace wholesale: always `super.merge(...)`.
1012
1075
 
1013
1076
  ## Form theme
1014
1077
 
@@ -1036,7 +1099,7 @@ end
1036
1099
 
1037
1100
  `base`, `sectioned_base`, `fields_wrapper`, `sections_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select`, plus the shared section keys below.
1038
1101
 
1039
- `sectioned_base` replaces `base` when the definition declares a `form_layout` — the sections are cards, so the form itself stops being one.
1102
+ `sectioned_base` replaces `base` when the definition declares a `form_layout`, because the sections are cards, so the form itself stops being one.
1040
1103
 
1041
1104
  ⚠️ **Width is NOT a theme key.** It's configuration (`page_width` / `form_width` on the definition, `Plutonium.configuration.default_page_width` globally) and is appended by `Form::Resource`/`Page::Show`, so overriding `base` or `fields_wrapper` restyles a surface without silently pinning its width. See [[plutonium-resource]] › Page Width.
1042
1105
 
@@ -1059,7 +1122,7 @@ end
1059
1122
 
1060
1123
  ### Display theme keys
1061
1124
 
1062
- `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
1125
+ `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `rich_text`, `json`, `key_value`, `list` / `list_item`, `boolean`, `badge`, `currency`, `color`, `binary`, `relative_time`, `code`, `duration`, `file_size`, `progress`, `rating`. Table cells use `Plutonium::UI::Table::DisplayTheme`, which has its own (more compact) entry for each.
1063
1126
 
1064
1127
  ⚠️ **`fields_wrapper` is the CARD, `fields_inner` is the grid.** `fields_wrapper` is merged into a `Plutonium::UI::Block` (which supplies `pu-card` itself), so putting grid classes there styles the card, not the fields. Override `fields_inner` to change the unsectioned grid, and `section_grid` to change the grid inside a `display_layout` section.
1065
1128
 
@@ -1067,7 +1130,7 @@ end
1067
1130
 
1068
1131
  Section chrome is shared: `Plutonium::UI::Component::Section::DEFAULT_THEME` is merged into **both** `Form::Theme` and `Display::Theme`, so the two read identically by default while staying independently overridable.
1069
1132
 
1070
- `section_wrapper` (merged into the section's Block — Block already supplies `pu-card`), `section_header`, `section_summary` (the collapsible header row), `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`, plus `sections_wrapper` (the container that stacks sections).
1133
+ `section_wrapper` (merged into the section's Block, Block already supplies `pu-card`), `section_header`, `section_summary` (the collapsible header row), `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`, plus `sections_wrapper` (the container that stacks sections).
1071
1134
 
1072
1135
  ## Table theme
1073
1136
 
@@ -1096,7 +1159,7 @@ end
1096
1159
 
1097
1160
  ## Available context
1098
1161
 
1099
- Inside any page / form / display / Phlex component, the same set of helpers is available — model accessors, definition/policy methods, URL helpers, `current_user`. For the full list, see [[plutonium-behavior]] › Key methods (controllers expose the same surface; pages inherit it).
1162
+ Inside any page / form / display / Phlex component, the same set of helpers is available: model accessors, definition/policy methods, URL helpers, `current_user`. For the full list, see [[plutonium-behavior]] › Key methods (controllers expose the same surface; pages inherit it).
1100
1163
 
1101
1164
  In Phlex components, Rails helpers are accessed via the `helpers` proxy:
1102
1165
 
@@ -1130,12 +1193,12 @@ end
1130
1193
 
1131
1194
  ## Gotchas
1132
1195
 
1133
- - **Don't override `view_template` in pages** when a render hook fits — you lose breadcrumbs / header / DynaFrame behavior.
1134
- - **Always register Stimulus controllers.** Without `registerControllers(application)` the entire UI's interactive layer is dead.
1135
- - **Use `plutoniumTailwindConfig.merge`** — plain object merge drops Plutonium's defaults.
1196
+ - **Don't override `view_template` in pages** when a render hook fits; you lose breadcrumbs / header / DynaFrame behavior.
1197
+ - **Always register Stimulus controllers in your own bundle.** Once `config.assets.script` points at the app's JS, without `registerControllers(application)` the entire UI's interactive layer is dead.
1198
+ - **Use `plutoniumTailwindConfig.merge`**: plain object merge drops Plutonium's defaults.
1136
1199
  - **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
1137
- - **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
1138
- - **`render_actions` is mandatory in custom `form_template`** — otherwise no submit button.
1200
+ - **Tokens are CSS variables, not Tailwind keys**: `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
1201
+ - **`render_actions` is mandatory in custom `form_template`**: otherwise no submit button.
1139
1202
  - **Dropdowns (`resource-drop-down`) teleport their menu to `<body>` while open.** popper's `fixed` strategy alone is still clipped by a transformed + `overflow:hidden` ancestor (e.g. grid cards, app shells), so the controller reparents the open menu to `<body>` and restores it on close. Don't rely on the menu being a DOM child of its trigger while open.
1140
1203
  - **`DisplaysValue` components stringify the value and loop per item.** `render_value` receives `normalize_value(value)`, which is `value.to_s`, and for a `field.multiple?` (has_many) field it is called once per element (`field.value.each`). So a custom component that inherits `DisplaysValue` only ever sees the stringified value, per item, never the record. When you need `f.object` or whole-collection rendering, use the block-form display (`display :x do |f| … end`), which is `instance_exec`ed in Phlex once and can emit markup directly.
1141
1204
  - **Blocks run in a Phlex context on every surface.** A `display`, `input`, or `column` block emits `span`/`div` directly, or returns a String or a component. All three are `instance_exec`ed by the resource page rendering them (`self` is the page, not the definition): `display`/`input` blocks receive the field `f`, a `column` block receives the record. Phlex renders the return value too, so a block that emits markup must end with a tag call or `nil`. Most columns need no block at all, because `display :x, as: …` already flows to the table column.
@@ -1146,7 +1209,7 @@ end
1146
1209
 
1147
1210
  ## Related skills
1148
1211
 
1149
- - [[plutonium-resource]] — field/input/display config (`as:`, `condition:`, blocks); modal options for actions.
1150
- - [[plutonium-behavior]] — controller presentation hooks (`present_parent?`), available helpers (`resource_record!`, `current_scoped_entity`).
1151
- - [[plutonium-app]] — `pu:eject:layout`, `pu:eject:shell`, portal package overrides.
1152
- - [[plutonium-tenancy]] — `permitted_associations` drives the show-page tablist.
1212
+ - [[plutonium-resource]]: field/input/display config (`as:`, `condition:`, blocks); modal options for actions.
1213
+ - [[plutonium-behavior]]: controller presentation hooks (`present_parent?`), available helpers (`resource_record!`, `current_scoped_entity`).
1214
+ - [[plutonium-app]]: `pu:eject:layout`, `pu:eject:shell`, portal package overrides.
1215
+ - [[plutonium-tenancy]]: `permitted_associations` drives the show-page tablist.