plutonium 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) 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 +144 -133
  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 +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -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
 
@@ -308,7 +311,7 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
308
311
  | `input_tag` | text (auto-detected type) |
309
312
  | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `tel_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
 
@@ -330,13 +333,13 @@ render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg
330
333
 
331
334
  ### Password & secret fields
332
335
 
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.
336
+ `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
337
 
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.
338
+ 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.
336
339
 
337
340
  ```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
341
+ field :api_token, as: :string # opt OUT: show a readable value (token to copy, checksum)
342
+ field :recovery_phrase, as: :password # opt IN: mask a secret the heuristic misses
340
343
  ```
341
344
 
342
345
  ## Submit buttons
@@ -347,7 +350,7 @@ Control the secondary button via the definition:
347
350
 
348
351
  ```ruby
349
352
  class PostDefinition < ResourceDefinition
350
- submit_and_continue false # nil (default — auto), true (always show), false (always hide)
353
+ submit_and_continue false # nil (default, auto), true (always show), false (always hide)
351
354
  end
352
355
  ```
353
356
 
@@ -369,14 +372,14 @@ end
369
372
 
370
373
  These all live in the definition layer:
371
374
 
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.
375
+ - **Pre-submit / dynamic forms**: see [[plutonium-resource]] › Dynamic Forms.
376
+ - **Nested inputs** (`nested_input :variants`): association-backed inline forms; see [[plutonium-resource]] › Nested Inputs.
377
+ - **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.
378
+ - **Interaction forms**: interactions define their own `attribute` / `input` and inherit `Plutonium::UI::Form::Interaction`; see [[plutonium-behavior]] › Interactions.
376
379
 
377
380
  ---
378
381
 
379
- # Part 3 — Display & Table
382
+ # Part 3: Display & Table
380
383
 
381
384
  ## Custom Display
382
385
 
@@ -390,7 +393,7 @@ class PostDefinition < ResourceDefinition
390
393
  end
391
394
 
392
395
  # `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
396
+ # so do not wrap it in another one: that stacks two cards and doubles
394
397
  # the border and shadow.
395
398
  fields_wrapper do
396
399
  render_resource_field :author
@@ -412,14 +415,14 @@ end
412
415
  |---|---|
413
416
  | `render_fields` | All permitted fields |
414
417
  | `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** |
418
+ | `render_associations` | Association tabs (driven by `permitted_associations`, see [[plutonium-behavior]]) |
419
+ | `render_before_fields` / `render_after_fields` | Hooks around the fields, **Details tab only** |
417
420
  | `object` | The record |
418
421
  | `resource_fields`, `resource_associations` | Permitted lists |
419
422
 
420
423
  ### Details-tab-only content
421
424
 
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.
425
+ 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
426
 
424
427
  ```ruby
425
428
  class PostDefinition < ResourceDefinition
@@ -427,13 +430,28 @@ class PostDefinition < ResourceDefinition
427
430
  private
428
431
 
429
432
  def render_before_fields
430
- div(class: "pu-card pu-card-body mb-4") { plain "Only on the Details tab" }
433
+ flagged = object.comments.where(flagged: true).count
434
+ return if flagged.zero?
435
+
436
+ div(class: "pu-alert pu-alert-warning", role: "alert") do
437
+ div(class: "pu-alert-message") { t("blog.posts.flagged_comments", count: flagged) }
438
+ end
431
439
  end
432
440
  end
433
441
  end
434
442
  ```
435
443
 
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.
444
+ ```yaml
445
+ # config/locales/en.yml
446
+ en:
447
+ blog:
448
+ posts:
449
+ flagged_comments:
450
+ one: "1 comment is waiting for moderation."
451
+ other: "%{count} comments are waiting for moderation."
452
+ ```
453
+
454
+ `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
455
 
438
456
  ## Custom Table
439
457
 
@@ -472,7 +490,7 @@ end
472
490
 
473
491
  ## Drag-to-Reorder Affordance (`position_on`)
474
492
 
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.
493
+ 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
494
 
477
495
  **What renders where:**
478
496
 
@@ -480,11 +498,11 @@ When a definition declares `position_on` (see [[plutonium-resource]]) the index
480
498
  |---|---|---|---|
481
499
  | Index / nested table | the **grip only**, never the `<tr>` | inside the first cell's existing left padding (content does not shift) | vertical |
482
500
  | 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) |
501
+ | Kanban board | the **whole card** | - | both (cross-column) |
484
502
 
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.
503
+ 🚨 **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
504
 
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?`.
505
+ **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
506
 
489
507
  **DOM contract** (relevant if you eject a table/grid or write a custom collection component):
490
508
 
@@ -493,18 +511,18 @@ wrapper data-controller="positioned"
493
511
  data-positioned-url-template-value="/things/__ID__/reposition"
494
512
  data-positioned-axis-value="horizontal" # grid only
495
513
  row/card data-positioned-row-id="<id>" # single source of truth for the record id
496
- grip data-positioned-grip # a real <button> — tabbable
514
+ grip data-positioned-grip # a real <button>, tabbable
497
515
  ```
498
516
 
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.
517
+ 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
518
 
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.
519
+ **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
520
 
503
521
  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
522
 
505
523
  ---
506
524
 
507
- # Part 4 — Component Kit & Custom Components
525
+ # Part 4: Component Kit & Custom Components
508
526
 
509
527
  ## Built-in shorthand kit
510
528
 
@@ -528,7 +546,7 @@ Breadcrumbs()
528
546
 
529
547
  ## Avatar
530
548
 
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.
549
+ `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
550
 
533
551
  ```ruby
534
552
  Avatar(user) # Navii fallback seeded from the record
@@ -540,17 +558,24 @@ Avatar(src: avatar_url) # bare image, no subject/fallback
540
558
  ```
541
559
 
542
560
  - **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`.
561
+ - **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
562
  - **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.
563
+ - **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
564
  - **Resolution order**: resolved `src` → Navii (from subject) → generic user icon.
547
565
  - **Config**: `config.navii_host_url` (default `https://api.navii.dev`); the component appends `/avatar/:seed`.
548
566
 
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.
567
+ 🚨 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
568
 
551
- ## Translating component text
569
+ ## Translating text
552
570
 
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:
571
+ Put new user-facing text in a locale file, never a literal. Which `t` you get depends on where the code runs:
572
+
573
+ | Where | Call | Notes |
574
+ |---|---|---|
575
+ | 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 |
576
+ | `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` |
577
+ | 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 |
578
+ | A bare Phlexi field component without `Behaviour` | `Plutonium::Translation.t("full.key")` | |
554
579
 
555
580
  ```ruby
556
581
  def view_template
@@ -559,6 +584,8 @@ def view_template
559
584
  end
560
585
  ```
561
586
 
587
+ Never call `I18n.t` directly in a definition class body: it runs once at load time, in whatever locale is active then.
588
+
562
589
  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
590
 
564
591
  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 +602,14 @@ class PostCardComponent < Plutonium::UI::Component::Base
575
602
  div(class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] p-4") do
576
603
  h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
577
604
  p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
578
- a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
605
+ a(href: resource_url_for(@post), class: "text-primary-600") { t("blog.posts.card.read_more") }
579
606
  end
580
607
  end
581
608
  end
582
609
  ```
583
610
 
584
611
  Use in a definition. A component with its **own constructor** (like the one above)
585
- must use the **block form** — you build it:
612
+ must use the **block form**; you build it:
586
613
 
587
614
  ```ruby
588
615
  display :card do |field|
@@ -640,13 +667,13 @@ All pages inherit this. Modals and frame navigation work without special handlin
640
667
 
641
668
  ---
642
669
 
643
- # Part 5 — Modals, Slideovers, Tabs
670
+ # Part 5: Modals, Slideovers, Tabs
644
671
 
645
672
  ## Modal/slideover for `:new` / `:edit` + interactive actions
646
673
 
647
674
  ```ruby
648
675
  class PostDefinition < ResourceDefinition
649
- modal :slideover # default — slide-in panel from the right
676
+ modal :slideover # default, slide-in panel from the right
650
677
  # modal :centered # centered dialog
651
678
  # modal :centered, size: :lg # centered, wider container
652
679
  # modal false # full standalone page
@@ -657,19 +684,19 @@ Drives both framework `:new` / `:edit` and every interactive action on the defin
657
684
 
658
685
  ## Tabs on the show page
659
686
 
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.
687
+ 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
688
 
662
689
  If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
663
690
 
664
691
  ---
665
692
 
666
- # Part 6 — Layout (Chrome) & Eject
693
+ # Part 6: Layout (Chrome) & Eject
667
694
 
668
695
  ## Shell
669
696
 
670
697
  ```ruby
671
698
  Plutonium.configure do |config|
672
- config.shell = :modern # default — topbar + icon rail
699
+ config.shell = :modern # default, topbar + icon rail
673
700
  # config.shell = :plain # topbar, no icon rail (whole app rail-less)
674
701
  # config.shell = :classic # legacy header + sidebar (only when upgrading)
675
702
  end
@@ -679,16 +706,16 @@ end
679
706
 
680
707
  ```ruby
681
708
  config.shell = :plain # 1. global default
682
- # 2. per-engine — inside the engine's config.after_initialize (with scope_to_entity)
709
+ # 2. per-engine: inside the engine's config.after_initialize (with scope_to_entity)
683
710
  class CustomerPortal::Engine
684
711
  config.after_initialize { shell :plain }
685
712
  end
686
713
  class DashboardController; shell :modern; end # 3. per-controller (overrides engine/global)
687
714
  ```
688
715
 
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.
716
+ `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
717
 
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:
718
+ 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
719
 
693
720
  ```ruby
694
721
  module CustomerPortal::Concerns::Controller
@@ -716,7 +743,7 @@ The sidebar/icon-rail menu is built with `Phlexi::Menu::Builder` in `_resource_s
716
743
  m.item "Inbox", url: inbox_path, icon: Icon, target: "_blank", rel: "noopener", data: {turbo_frame: "_top"}
717
744
  ```
718
745
 
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.
746
+ 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
747
 
721
748
  ## Custom layout class (Phlex)
722
749
 
@@ -751,7 +778,7 @@ end
751
778
 
752
779
  ---
753
780
 
754
- # Part 7 — Assets, Tailwind, Stimulus
781
+ # Part 7: Assets, Tailwind, Stimulus
755
782
 
756
783
  ## Asset configuration
757
784
 
@@ -772,7 +799,17 @@ end
772
799
  rails generate pu:core:assets
773
800
  ```
774
801
 
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.
802
+ 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:
803
+
804
+ - installs `@radioactive-labs/plutonium` (pinned to the gem version), Tailwind 4 and the PostCSS plugins
805
+ - writes `tailwind.config.js` (through `plutoniumTailwindConfig.merge`) and `postcss.config.js`
806
+ - prepends `@import "gem:plutonium/src/css/plutonium.css";` to `application.tailwind.css` and adds `@config` after `@import "tailwindcss";`
807
+ - appends `registerControllers(application)` to `app/javascript/controllers/index.js`
808
+ - sets `config.assets.stylesheet = "application"` and `config.assets.script = "application"`, and writes the `build` / `build:css` scripts in `package.json`
809
+
810
+ 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.
811
+
812
+ **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
813
 
777
814
  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
815
 
@@ -803,6 +840,8 @@ module.exports = {
803
840
 
804
841
  ## Default color palette
805
842
 
843
+ 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.
844
+
806
845
  | Color | Use |
807
846
  |---|---|
808
847
  | `primary` | Brand primary (turquoise default) |
@@ -853,7 +892,7 @@ application.register("custom", CustomController)
853
892
 
854
893
  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
894
 
856
- Custom controller — standard Stimulus:
895
+ Custom controller, standard Stimulus:
857
896
 
858
897
  ```javascript
859
898
  import { Controller } from "@hotwired/stimulus"
@@ -881,11 +920,11 @@ theme: { fontFamily: { body: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-seri
881
920
 
882
921
  ## Dark mode
883
922
 
884
- `selector` strategy — toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
923
+ `selector` strategy: toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
885
924
 
886
925
  ---
887
926
 
888
- # Part 8 — Design Tokens & `.pu-*` Component Classes
927
+ # Part 8: Design Tokens & `.pu-*` Component Classes
889
928
 
890
929
  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
930
 
@@ -898,12 +937,15 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
898
937
  | `--pu-border`, `--pu-border-muted`, `--pu-border-strong` | Borders |
899
938
  | `--pu-input-bg`, `--pu-input-border`, `--pu-input-focus-ring`, `--pu-input-placeholder` | Form inputs |
900
939
  | `--pu-card-bg`, `--pu-card-border` | Cards |
940
+ | `--pu-table-header-bg`, `--pu-table-header-text`, `--pu-table-row-bg`, `--pu-table-row-hover`, `--pu-table-row-selected`, `--pu-table-border` | Tables |
941
+ | `--pu-text-danger` | Error text |
942
+ | `--pu-chart-1` … `--pu-chart-8` | Dashboard chart series |
901
943
  | `--pu-shadow-sm/md/lg` | Shadows |
902
944
  | `--pu-radius-sm/md/lg/xl/full` | Border radius |
903
945
  | `--pu-space-xs/sm/md/lg/xl` | Spacing |
904
946
  | `--pu-transition-fast/normal/slow` | Transitions |
905
947
 
906
- 🚨 Tokens are CSS variables — use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
948
+ 🚨 Tokens are CSS variables: use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
907
949
 
908
950
  ## Customizing tokens
909
951
 
@@ -919,11 +961,23 @@ Plutonium uses CSS custom properties for surfaces, text, borders, forms, cards,
919
961
  }
920
962
  ```
921
963
 
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`.
964
+ 🚨 **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.
965
+
966
+ 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.
967
+
968
+ Overrides need the app's own stylesheet, after the Plutonium import (`pu:core:assets`). Never edit the gem's `tokens.css` / `components.css`.
923
969
 
924
970
  ## `.pu-*` component classes
925
971
 
926
- Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
972
+ 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.**
973
+
974
+ Why, in order of preference:
975
+
976
+ 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.
977
+ 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.
978
+ 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.
979
+
980
+ 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
981
 
928
982
  ### Buttons
929
983
 
@@ -944,6 +998,7 @@ Ready-to-use styled components in `src/css/components.css`. **Prefer these over
944
998
  ```
945
999
  .pu-input / -invalid / -valid .pu-label / -required .pu-hint / .pu-error .pu-checkbox / .pu-toggle
946
1000
  .pu-badge / -neutral / -primary / -secondary / -success / -danger / -warning / -info / -accent
1001
+ .pu-alert / -success / -warning / -danger / -info .pu-alert-message / .pu-alert-close
947
1002
  .pu-card / .pu-card-body
948
1003
  .pu-panel-header / -title / -description
949
1004
  .pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
@@ -1006,9 +1061,9 @@ tokens("base", condition?: {then: "if-true", else: "if-false"})
1006
1061
 
1007
1062
  ---
1008
1063
 
1009
- # Part 9 — Phlexi Component Themes
1064
+ # Part 9: Phlexi Component Themes
1010
1065
 
1011
- Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults — never replace wholesale, always `super.merge(...)`.
1066
+ Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults, never replace wholesale: always `super.merge(...)`.
1012
1067
 
1013
1068
  ## Form theme
1014
1069
 
@@ -1036,7 +1091,7 @@ end
1036
1091
 
1037
1092
  `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
1093
 
1039
- `sectioned_base` replaces `base` when the definition declares a `form_layout` — the sections are cards, so the form itself stops being one.
1094
+ `sectioned_base` replaces `base` when the definition declares a `form_layout`, because the sections are cards, so the form itself stops being one.
1040
1095
 
1041
1096
  ⚠️ **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
1097
 
@@ -1067,7 +1122,7 @@ end
1067
1122
 
1068
1123
  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
1124
 
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).
1125
+ `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
1126
 
1072
1127
  ## Table theme
1073
1128
 
@@ -1096,7 +1151,7 @@ end
1096
1151
 
1097
1152
  ## Available context
1098
1153
 
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).
1154
+ 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
1155
 
1101
1156
  In Phlex components, Rails helpers are accessed via the `helpers` proxy:
1102
1157
 
@@ -1130,12 +1185,12 @@ end
1130
1185
 
1131
1186
  ## Gotchas
1132
1187
 
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.
1188
+ - **Don't override `view_template` in pages** when a render hook fits; you lose breadcrumbs / header / DynaFrame behavior.
1189
+ - **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.
1190
+ - **Use `plutoniumTailwindConfig.merge`**: plain object merge drops Plutonium's defaults.
1136
1191
  - **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.
1192
+ - **Tokens are CSS variables, not Tailwind keys**: `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
1193
+ - **`render_actions` is mandatory in custom `form_template`**: otherwise no submit button.
1139
1194
  - **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
1195
  - **`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
1196
  - **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 +1201,7 @@ end
1146
1201
 
1147
1202
  ## Related skills
1148
1203
 
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.
1204
+ - [[plutonium-resource]]: field/input/display config (`as:`, `condition:`, blocks); modal options for actions.
1205
+ - [[plutonium-behavior]]: controller presentation hooks (`present_parent?`), available helpers (`resource_record!`, `current_scoped_entity`).
1206
+ - [[plutonium-app]]: `pu:eject:layout`, `pu:eject:shell`, portal package overrides.
1207
+ - [[plutonium-tenancy]]: `permitted_associations` drives the show-page tablist.