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,13 +1,13 @@
1
1
  # Testing Reference
2
2
 
3
- `Plutonium::Testing` provides scaffolded integration tests that assert a resource × portal pairing — CRUD, policy matrix, definition smoke tests, model concerns (associated_with, SGID, has_cents), nested-resource scope boundaries, cross-portal access, and interaction outcomes. All optional, all opt-in.
3
+ `Plutonium::Testing` provides scaffolded integration tests that assert a resource × portal pairing: CRUD, policy matrix, definition smoke tests, model concerns (associated_with, SGID, has_cents), nested-resource scope boundaries, cross-portal access, and interaction outcomes. All optional, all opt-in.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **Use the generators.** `pu:test:install` once per app, then `pu:test:scaffold ResourceClass --portals=...` per resource × portal. Hand-written test files drift from conventions.
8
- - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs — it's never autoloaded, never present in production.
8
+ - **Tests are opt-in.** `Plutonium::Testing` is only loaded when `require "plutonium/testing"` runs; it's never autoloaded, never present in production.
9
9
  - **One file per (resource × portal).** Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
10
- - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs — your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
10
+ - **Stub methods are required.** Concerns ship with `NotImplementedError` stubs: your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -88,6 +88,11 @@ class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
88
88
  end
89
89
  ```
90
90
 
91
+ **`valid_update_params` is also the assertion.** After the PATCH, the update test runs `assert_equal value, record.reload.public_send(attr)` for every key, so:
92
+
93
+ - Use values that read back identically. Enums go in as strings (`status: "published"`): the enum reader returns a String, so `:published` fails.
94
+ - Keep association SGIDs out of it. The loop only skips values starting with `gid://`, and `to_sgid.to_s` is a signed token, so the token gets compared to the associated record. Test reassigning an association in its own `test` block that PATCHes the SGID and asserts `record.reload.user == other_user`. `valid_create_params` has no such check, so SGIDs are fine there.
95
+
91
96
  ### `Plutonium::Testing::ResourcePolicy`
92
97
 
93
98
  Asserts the `permit?` matrix across action × role and verifies `relation_scope` returns an `ActiveRecord::Relation`.
@@ -133,18 +138,31 @@ Outcome-assertion helpers for `Plutonium::Resource::Interaction` subclasses.
133
138
 
134
139
  - `assert_interaction_success(klass, **input)` → returns the success outcome
135
140
  - `assert_interaction_failure(klass, **input)` → returns the failure outcome
136
- - `interaction_view_context` (overridable) → defaults to a mock view context
141
+ - `interaction_view_context` (overridable) → the view context both helpers pass in (a mock by default); override it only when the interaction reads from the view context
142
+
143
+ Use the helpers for both outcomes; they build the interaction and call it for you.
137
144
 
138
145
  ```ruby
139
- test "RebuildSearchInteraction succeeds" do
140
- outcome = assert_interaction_success(RebuildSearchInteraction, since: 1.day.ago)
141
- assert_equal 42, outcome.value[:rebuilt_count]
146
+ test "PublishProduct moves a draft to active" do
147
+ product = create_product!(status: :draft)
148
+ assert_interaction_success(Catalog::PublishProduct, resource: product)
149
+ assert product.reload.active?
150
+ end
151
+
152
+ test "PublishProduct fails for a product that isn't a draft" do
153
+ product = create_product!(status: :active)
154
+ assert_interaction_failure(Catalog::PublishProduct, resource: product)
155
+ assert product.reload.active? # state unchanged
142
156
  end
143
157
  ```
144
158
 
159
+ The failure outcome carries no validation errors (they live on the interaction instance), so assert the unchanged state rather than building the interaction by hand to read `errors`.
160
+
161
+ `ResourceInteraction` includes neither the DSL nor `AuthHelpers`. A file scaffolded with only `--concerns=interaction` still has the template's `resource_tests_for` and `login_as(@account)`, which raise `NoMethodError`: delete both (the helpers need no portal and no login).
162
+
145
163
  ### `Plutonium::Testing::ResourceModel`
146
164
 
147
- Tests `associated_with` scope, SGID routing, and `has_cents` accessors — gated by DSL flags.
165
+ Tests `associated_with` scope, SGID routing, and `has_cents` accessors, gated by DSL flags.
148
166
 
149
167
  **Stubs:**
150
168
 
@@ -167,13 +185,77 @@ Asserts CRUD under a parent + scope-boundary tests (sibling tenants invisible).
167
185
 
168
186
  **Stubs:**
169
187
 
170
- - `parent_record!` → current tenant
188
+ - `parent_record!` → current tenant (called several times per test, so return the same record each time, e.g. `@org`)
171
189
  - `other_parent_record!` → sibling tenant
172
190
  - `create_resource!(parent:)` → persisted record under given parent
173
191
 
192
+ The concern builds its URLs as `"#{current_path_prefix}/#{parent.id}/#{collection}"`, so it expects the bare portal mount as the prefix and inserts the parent id itself.
193
+
194
+ #### Entity-scoped portals: CRUD + tenant isolation
195
+
196
+ In a portal that calls `scope_to_entity Model, strategy: :path` (e.g. `/org/:id/...`), "records from another tenant aren't reachable" is the `NestedResource` concern with the entity as the parent. The resolved prefix there is the bare mount (`/org`), and the two concerns need different prefixes and different `create_resource!` signatures:
197
+
198
+ | | `ResourceCrud` | `NestedResource` |
199
+ |---|---|---|
200
+ | URL built | `prefix/collection` | `prefix/parent.id/collection` |
201
+ | Prefix needed | `/org/#{@org.to_param}` (override `current_path_prefix`) | `/org` (the resolved default) |
202
+ | Calls | `create_resource!` | `create_resource!(parent:)` |
203
+
204
+ One class can't satisfy both, so split the scaffolded file into two classes:
205
+
206
+ ```bash
207
+ rails g pu:test:scaffold Catalog::Variant --portals=org --concerns=crud,nested --parent=organization
208
+ ```
209
+
210
+ ```ruby
211
+ class OrgPortal::CatalogVariantTest < ActionDispatch::IntegrationTest
212
+ include IntegrationTestHelper
213
+ include Plutonium::Testing::ResourceCrud
214
+
215
+ resource_tests_for Catalog::Variant, portal: :org
216
+
217
+ setup do
218
+ @org = create_organization!
219
+ @user = create_user!
220
+ create_membership!(organization: @org, user: @user)
221
+ @product = create_product!(user: @user, organization: @org)
222
+ login_as(@user) # :org logs in through /users/login
223
+ end
224
+
225
+ def current_path_prefix = "/org/#{@org.to_param}"
226
+ def create_resource! = create_variant!(product: @product)
227
+ def valid_create_params = {name: "Red", sku: "RED-1", stock_count: 5, product: @product.to_sgid.to_s}
228
+ def valid_update_params = {name: "Red / Large"}
229
+ end
230
+
231
+ class OrgPortal::CatalogVariantNestedTest < ActionDispatch::IntegrationTest
232
+ include IntegrationTestHelper
233
+ include Plutonium::Testing::NestedResource
234
+
235
+ resource_tests_for Catalog::Variant, portal: :org, parent: :organization
236
+
237
+ setup do
238
+ @org = create_organization!
239
+ @other_org = create_organization!
240
+ @user = create_user!
241
+ create_membership!(organization: @org, user: @user)
242
+ login_as(@user)
243
+ end
244
+
245
+ def parent_record! = @org
246
+ def other_parent_record! = @other_org
247
+
248
+ def create_resource!(parent:)
249
+ create_variant!(product: create_product!(organization: parent))
250
+ end
251
+ end
252
+ ```
253
+
254
+ `create_resource!(parent:)` must create under `parent`, not always under `@org`: the isolation test passes `other_parent_record!` and expects a 404 (or redirect) for that record.
255
+
174
256
  ### `Plutonium::Testing::PortalAccess`
175
257
 
176
- Cross-portal access boundaries. Uses its own DSL — NOT `resource_tests_for`.
258
+ Cross-portal access boundaries. Uses its own DSL (NOT `resource_tests_for`).
177
259
 
178
260
  ```ruby
179
261
  class PortalAccessTest < ActionDispatch::IntegrationTest
@@ -210,7 +292,16 @@ Generates one test per (role × portal). Allowed = `200 | 302`; blocked = `302 |
210
292
 
211
293
  ## Auth helpers
212
294
 
213
- `Plutonium::Testing::AuthHelpers` is included transitively by every concern.
295
+ `login_as` and friends come from `Plutonium::Testing::AuthHelpers`, which only some concerns pull in. The default portal comes from the DSL (`resource_tests_for`), which is a separate include:
296
+
297
+ | Concern | `login_as` available | Bare `login_as(account)` works |
298
+ |---|---|---|
299
+ | `ResourceCrud`, `NestedResource` | yes | yes (portal from `resource_tests_for`) |
300
+ | `PortalAccess` | yes | no: pass `portal:` every time |
301
+ | `ResourcePolicy`, `ResourceDefinition`, `ResourceModel` | no | no |
302
+ | `ResourceInteraction` | no | no |
303
+
304
+ A hand-written integration test that only includes the app's own helpers (e.g. an `IntegrationTestHelper`) has no `login_as` at all. Add `include Plutonium::Testing::AuthHelpers` (and `require "plutonium/testing"` if `test_helper.rb` doesn't) and pass `portal:` explicitly. Likewise, delete the scaffold's `login_as(@account)` from a file whose concerns don't provide it.
214
305
 
215
306
  ```ruby
216
307
  login_as(account) # uses portal from the DSL
@@ -255,16 +346,18 @@ rails g pu:test:scaffold Blogging::Post --portals=org --parent=organization --de
255
346
  |---|---|---|
256
347
  | `--portals=admin,org` | required | Emit one file per portal |
257
348
  | `--concerns=...` | `crud,policy,definition` | Concerns to include (`crud`, `policy`, `definition`, `nested`, `model`, `interaction`, `portal_access`) |
258
- | `--parent=organization` | | Wires `NestedResource` parent |
349
+ | `--parent=organization` | | Adds `parent:` to `resource_tests_for` and, only together with `nested` in `--concerns`, the `parent_record!`/`other_parent_record!` stubs |
259
350
  | `--dest=main_app\|<package>` | `main_app` | Output destination |
260
351
 
261
352
  Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
262
353
 
354
+ `--parent` alone does not add the `NestedResource` include; pass `--concerns=crud,nested --parent=organization`. The template is one class with every requested include, a `setup` that calls `login_as(@account)`, and a no-argument `create_resource!`. Treat it as a starting point: drop `login_as`/`resource_tests_for` where the concerns don't provide them (see [Auth helpers](#auth-helpers)), and split `crud` and `nested` into two classes for [entity-scoped portals](#entity-scoped-portals-crud-tenant-isolation).
355
+
263
356
  ## Customization & escape hatches
264
357
 
265
358
  - **Skip individual tests:** `resource_tests_for Klass, portal: :admin, skip: %i[destroy]`
266
359
  - **Restrict action set:** `resource_tests_for Klass, portal: :admin, actions: %i[index show]`
267
- - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix — they coexist.
360
+ - **Custom assertions:** add regular `test "..."` blocks alongside the generated matrix; they coexist.
268
361
  - **Non-Rodauth auth:** override `sign_in_for_tests`. See [AuthHelpers](#auth-helpers).
269
362
  - **Custom path prefix:** `path_prefix: "/v2/admin"` overrides portal resolution.
270
363
 
@@ -273,15 +366,16 @@ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
273
366
  - **Forgotten stubs raise `NotImplementedError`** with the stub name. Look for the missing method in your test class.
274
367
  - **Portal mismatch:** `:admin` portal expects `AdminPortal::Engine` constant. If your portal is named differently, pass `path_prefix:` explicitly.
275
368
  - **Tenant leakage in stubs:** `create_resource!` for an org portal must return a record bound to the test's `@org`. Otherwise scope filtering tests pass for the wrong reason.
276
- - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to — otherwise even allowed roles will see `false`.
277
- - **Nested resources need `parent: :foo`** in the DSL AND a real parent record from `parent_record!`. Without both, path interpolation fails.
278
- - **`PortalAccess` doesn't use `resource_tests_for`** — use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
369
+ - **`policy_record` for tenant-scoped resources** must belong to a tenant the role has access to; otherwise even allowed roles will see `false`.
370
+ - **Nested paths come from `parent_record!.id`**, so it must return a real, persisted tenant the logged-in account belongs to. `parent: :foo` in the DSL documents the relationship; the concern doesn't read it.
371
+ - **Entity-scoped CRUD hitting `/org/<collection>`** (404 or routing error): a `:path` portal resolves to the bare mount, so override `current_path_prefix` to include the tenant. Don't do this in a `NestedResource` class, which adds the id itself.
372
+ - **`PortalAccess` doesn't use `resource_tests_for`**: use `portal_access_for` instead. Mixing them on the same class is undefined behavior.
279
373
 
280
374
  ## Related
281
375
 
282
- - [Behavior › Policy](/reference/behavior/policies) — the policy methods `ResourcePolicy` verifies
283
- - [Behavior › Interaction](/reference/behavior/interactions) — interaction outcomes asserted by `ResourceInteraction`
284
- - [Resource › Definition](/reference/resource/definition) — definition props the smoke test introspects
285
- - [Tenancy](/reference/tenancy/) — parent scoping (`NestedResource`), entity strategies (drive auth/scoping)
286
- - [Auth](/reference/auth/) — Rodauth setup behind the default `sign_in_for_tests`
287
- - [Guides › Testing](/guides/testing) — task-oriented walkthrough
376
+ - [Behavior › Policy](/reference/behavior/policies): the policy methods `ResourcePolicy` verifies
377
+ - [Behavior › Interaction](/reference/behavior/interactions): interaction outcomes asserted by `ResourceInteraction`
378
+ - [Resource › Definition](/reference/resource/definition): definition props the smoke test introspects
379
+ - [Tenancy](/reference/tenancy/): parent scoping (`NestedResource`), entity strategies (drive auth/scoping)
380
+ - [Auth](/reference/auth/): Rodauth setup behind the default `sign_in_for_tests`
381
+ - [Guides › Testing](/guides/testing): task-oriented walkthrough
@@ -4,11 +4,12 @@ TailwindCSS 4 + Stimulus toolchain. CSS design tokens for theming, `.pu-*` compo
4
4
 
5
5
  ## 🚨 Critical
6
6
 
7
- - **Always register Stimulus controllers** — `registerControllers(application)` is required. Without it, Plutonium's controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
8
- - **Use `plutoniumTailwindConfig.merge`** when overriding the theme — plain object spread drops Plutonium's defaults.
9
- - **Tokens are CSS variables**, not Tailwind keys — `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
10
- - **Dark mode uses `selector`** strategy — toggle `dark` on `<html>`. The bundled `color-mode` controller does this.
11
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
7
+ - **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.
8
+ - **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 controllers (color-mode, form, slim-select, flatpickr, easymde, etc.) are dead.
9
+ - **Use `plutoniumTailwindConfig.merge`** when overriding the theme, plain object spread drops Plutonium's defaults.
10
+ - **Tokens are CSS variables**, not Tailwind keys, `bg-[var(--pu-surface)]`, NOT `bg-pu-surface`.
11
+ - **Dark mode uses `selector`** strategy, toggle `dark` on `<html>`. The bundled `color-mode` controller does this.
12
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** Banners, badges, cards and buttons all have a `.pu-*` class that carries its own `.dark` rule. See [Component classes](#component-classes-pu).
12
13
 
13
14
  ## Asset configuration
14
15
 
@@ -30,13 +31,19 @@ end
30
31
  rails generate pu:core:assets
31
32
  ```
32
33
 
33
- This:
34
+ Until this runs, the app serves the gem's prebuilt assets (`config.assets.stylesheet` defaults to `plutonium.css`, `script` to `plutonium.min.js`). Those are compiled from the gem's own sources, so app-side Tailwind classes, a new `primary` palette, token overrides and custom Stimulus controllers have nowhere to go. The generator:
34
35
 
35
- 1. Installs npm packages (`@radioactive-labs/plutonium`, TailwindCSS plugins).
36
- 2. Creates `tailwind.config.js` extending Plutonium's config.
37
- 3. Imports Plutonium CSS into `application.tailwind.css`.
38
- 4. Registers Plutonium's Stimulus controllers.
39
- 5. Updates Plutonium config to point at your asset files.
36
+ 1. Installs `@radioactive-labs/plutonium` (pinned to the gem version), Tailwind 4 and the PostCSS plugins.
37
+ 2. Writes `tailwind.config.js` (through `plutoniumTailwindConfig.merge`) and `postcss.config.js`.
38
+ 3. Prepends `@import "gem:plutonium/src/css/plutonium.css";` to `application.tailwind.css` and adds `@config` after `@import "tailwindcss";`.
39
+ 4. Appends `registerControllers(application)` to `app/javascript/controllers/index.js`.
40
+ 5. Sets `config.assets.stylesheet = "application"` and `config.assets.script = "application"`, and writes the `build` / `build:css` scripts in `package.json`.
41
+
42
+ Step 5 is why `registerControllers` is not optional: the gem's `plutonium.min.js` calls it itself, and your `application.js` replaces that bundle.
43
+
44
+ ### Prerequisites
45
+
46
+ The generator 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. For an app without them, install the bundlers first (`bin/rails javascript:install:esbuild`, `css:install:tailwind`, `stimulus:install` from jsbundling-rails, cssbundling-rails and stimulus-rails), then run 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.
40
47
 
41
48
  ### Package managers
42
49
 
@@ -86,6 +93,8 @@ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
86
93
  })
87
94
  ```
88
95
 
96
+ 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` above plus a rebuild, not a `--pu-*` override.
97
+
89
98
  ### Default color palette
90
99
 
91
100
  | Color | Usage |
@@ -128,15 +137,15 @@ application.register("custom", CustomController)
128
137
 
129
138
  ### Bundled controllers
130
139
 
131
- - `color-mode` — dark/light mode toggle
132
- - `form` — form handling (pre-submit, etc.)
133
- - `nested-resource-form-fields` — nested form management
134
- - `slim-select` — enhanced select boxes
135
- - `flatpickr` — date/time pickers
136
- - `easymde` — markdown editor
140
+ - `color-mode`: dark/light mode toggle
141
+ - `form`: form handling (pre-submit, etc.)
142
+ - `nested-resource-form-fields`: nested form management
143
+ - `slim-select`: enhanced select boxes
144
+ - `flatpickr`: date/time pickers
145
+ - `easymde`: markdown editor
137
146
  - Various internal UI controllers
138
147
 
139
- ### Custom Stimulus controller — standard pattern
148
+ ### Custom Stimulus controller: standard pattern
140
149
 
141
150
  ```javascript
142
151
  // app/javascript/controllers/custom_controller.js
@@ -249,9 +258,13 @@ Plutonium uses a comprehensive CSS custom-property system for consistent, themea
249
258
  ```
250
259
 
251
260
  ::: warning Mirror every `:root` override in `.dark`
252
- Your stylesheet loads after Plutonium's, and `:root` and `.dark` have equal specificity — so a token you override in `:root` beats Plutonium's `.dark` value even when dark mode is active. Any color token you customize in `:root` without re-asserting in `.dark` ships your light value into dark mode, where it's typically unreadable (e.g. a translucent dark `--pu-text-subtle` becomes invisible on a dark surface).
261
+ Your stylesheet loads after Plutonium's, and `:root` and `.dark` have equal specificity, so a token you override in `:root` beats Plutonium's `.dark` value even when dark mode is active. Any color token you customize in `:root` without re-asserting in `.dark` ships your light value into dark mode, where it's typically unreadable (e.g. a translucent dark `--pu-text-subtle` becomes invisible on a dark surface).
262
+
263
+ 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.
253
264
  :::
254
265
 
266
+ 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. Overrides need the app's own stylesheet after the Plutonium import (`pu:core:assets`); never edit the gem's `tokens.css` / `components.css`.
267
+
255
268
  ### Using tokens in templates
256
269
 
257
270
  ```erb
@@ -279,7 +292,11 @@ end
279
292
 
280
293
  ## Component classes (`.pu-*`)
281
294
 
282
- Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs** — they auto-switch with dark mode.
295
+ 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.** In order of preference:
296
+
297
+ 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.
298
+ 2. **A `var(--pu-*)` token** (`text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`) for layout around them. The token switches value under `.dark` and follows any theme override.
299
+ 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 each needs a hand-picked `dark:` twin that won't follow a rebrand.
283
300
 
284
301
  ### Buttons
285
302
 
@@ -318,6 +335,21 @@ Ready-to-use styled components in `src/css/components.css`. **Prefer these over
318
335
 
319
336
  Rendered automatically by the `:badge` display (enums) and `:boolean` display (Yes/No pills). See [Displays](./displays#built-in-display-components).
320
337
 
338
+ ### Alerts (inline banners)
339
+
340
+ ```
341
+ .pu-alert / -success / -warning / -danger / -info
342
+ .pu-alert-message / .pu-alert-close
343
+ ```
344
+
345
+ ```ruby
346
+ div(class: "pu-alert pu-alert-warning", role: "alert") do
347
+ div(class: "pu-alert-message") { t("blog.posts.flagged_comments", count: flagged) }
348
+ end
349
+ ```
350
+
351
+ The same banner the flash messages use (`app/views/plutonium/_flash_alerts.html.erb`), so it already has its dark-mode colors.
352
+
321
353
  ### Cards, panels, tables, toolbars, empty states
322
354
 
323
355
  ```
@@ -408,12 +440,12 @@ end
408
440
  **Theme keys:** `wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
409
441
 
410
442
  ::: warning Always `super.merge(...)`
411
- Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode — `super.merge` keeps them.
443
+ Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode, `super.merge` keeps them.
412
444
  :::
413
445
 
414
446
  ## Gotchas
415
447
 
416
- - **Stimulus controllers register silently fails.** If `registerControllers(application)` isn't called, the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error — just no behavior.
448
+ - **Stimulus controllers register silently fails.** Once `config.assets.script` points at the app's JS, if `registerControllers(application)` isn't called the entire UI's interactive layer is dead (color-mode toggle, slim-select, flatpickr, easymde, pre-submit). No error: just no behavior.
417
449
  - **`plutoniumTailwindConfig.merge` is mandatory.** Plain spread drops defaults silently.
418
450
  - **Tokens are CSS variables, not Tailwind keys.** Use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
419
451
  - **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
@@ -421,6 +453,6 @@ Don't replace the theme wholesale. Plutonium's defaults handle invalid states, f
421
453
 
422
454
  ## Related
423
455
 
424
- - [Forms › Theming](./forms#theming) — Form theme keys + override pattern
425
- - [Components](./components) — `tokens` and `classes` helpers for conditional class composition
426
- - [Layouts](./layouts) — fonts, dark-mode toggle, body attributes
456
+ - [Forms › Theming](./forms#theming): Form theme keys + override pattern
457
+ - [Components](./components): `tokens` and `classes` helpers for conditional class composition
458
+ - [Layouts](./layouts): fonts, dark-mode toggle, body attributes
@@ -22,13 +22,13 @@ TablePagination(pagy)
22
22
  Breadcrumbs()
23
23
  ```
24
24
 
25
- These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc. — they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
25
+ These are shorthand for `render Plutonium::UI::PageHeader.new(...)` etc.; they work because every component class is exposed as a method on `Plutonium::UI::Component::Base`.
26
26
 
27
27
  ## Avatar
28
28
 
29
29
  `Plutonium::UI::Avatar` renders a profile image for a subject. It resolves an optional image source and falls back to a deterministic avatar from the hosted [Navii](https://navii.dev) service, then to a generic user icon when there's nothing to show.
30
30
 
31
- ![Avatar — Navii fallback across sizes, deterministic faces for string subjects, explicit image src, and the icon fallback](/images/components/avatar.png)
31
+ ![Avatar, Navii fallback across sizes, deterministic faces for string subjects, explicit image src, and the icon fallback](/images/components/avatar.png)
32
32
 
33
33
  ```ruby
34
34
  Avatar(user) # Navii fallback seeded from the record
@@ -45,7 +45,7 @@ Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
45
45
  | `src:` | `nil` | The image. A **Symbol** names a method on the subject (`:avatar` → `subject.avatar`); otherwise an ActiveStorage attachment, [active_shrine](https://github.com/radioactive-labs/active_shrine)/Shrine uploader, or URL string. |
46
46
  | `size:` | `:md` | Semantic `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer (px). |
47
47
  | `alt:` | derived | Defaults to the String subject, or the record's display name. |
48
- | `class:` | — | Merged over the default `rounded-full` classes. |
48
+ | `class:` | - | Merged over the default `rounded-full` classes. |
49
49
 
50
50
  ### How the source resolves
51
51
 
@@ -58,7 +58,7 @@ Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
58
58
  When `src` is absent or unattached, a Navii avatar is rendered from the subject; with no subject either, a generic user icon is shown.
59
59
 
60
60
  ::: tip Symbol `src` is a contract
61
- `Avatar(user, src: :avatar)` calls `user.avatar` — the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
61
+ `Avatar(user, src: :avatar)` calls `user.avatar`, the subject **must** respond to it (a `NoMethodError` is raised otherwise). Use a Symbol `src` only with a record subject, not a value that might be a plain string (e.g. a guest `current_user`).
62
62
  :::
63
63
 
64
64
  ### Privacy
@@ -94,7 +94,7 @@ class PostCardComponent < Plutonium::UI::Component::Base
94
94
  span(class: "text-sm text-[var(--pu-text-subtle)]") {
95
95
  @post.published_at&.strftime("%B %d, %Y")
96
96
  }
97
- a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
97
+ a(href: resource_url_for(@post), class: "text-primary-600") { t("blog.posts.card.read_more") }
98
98
  end
99
99
  end
100
100
  end
@@ -104,18 +104,18 @@ end
104
104
  ::: tip Inherit `Plutonium::UI::Component::Base`
105
105
  It gives you:
106
106
  - The component kit (`PageHeader`, `Panel`, `Block`, …)
107
- - Resource helpers (`resource_url_for`, `current_user`, `current_record!`, `current_definition`)
107
+ - Resource helpers (`resource_url_for`, `current_user`, `resource_record!`, `current_definition`)
108
108
  - A `helpers` proxy for Rails helpers (`helpers.link_to`, `helpers.number_to_currency`)
109
109
  - Token / class helpers (`tokens`, `classes`)
110
110
 
111
- A **field** component (one you pass to `as:`) inherits its Phlexi base instead —
111
+ A **field** component (one you pass to `as:`) inherits its Phlexi base instead:
112
112
  `include Plutonium::UI::Component::Behaviour` there to get the same helpers.
113
113
  :::
114
114
 
115
115
  ### Use in a definition
116
116
 
117
117
  A component like `PostCardComponent` above has its own constructor, so it reaches
118
- a field through the **block form** — you build it yourself:
118
+ a field through the **block form**, you build it yourself:
119
119
 
120
120
  ```ruby
121
121
  class PostDefinition < ResourceDefinition
@@ -129,13 +129,13 @@ class PostDefinition < ResourceDefinition
129
129
  end
130
130
  ```
131
131
 
132
- `as: SomeComponent` is the other route, and it expects a **field component** —
132
+ `as: SomeComponent` is the other route, and it expects a **field component**.
133
133
  Plutonium instantiates it with the field builder, not with your keyword
134
134
  arguments. See [field components](#field-components) below.
135
135
 
136
136
  ::: warning `as:` does not take a keyword-argument component
137
137
  `display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
138
- arguments` — the component is constructed as `PostCardComponent.new(field,
138
+ arguments`; the component is constructed as `PostCardComponent.new(field,
139
139
  **attributes)`. Use the block form for those.
140
140
  :::
141
141
 
@@ -143,7 +143,7 @@ arguments` — the component is constructed as `PostCardComponent.new(field,
143
143
 
144
144
  A field component subclasses the Phlexi component base for its surface and reads
145
145
  everything off `field` (`field.value`, `field.object`, `field.dom`, plus
146
- `attributes` — the themed id/name/class Plutonium already computed):
146
+ `attributes`, the themed id/name/class Plutonium already computed):
147
147
 
148
148
  ```ruby
149
149
  # app/components/color_picker_component.rb
@@ -172,7 +172,7 @@ class PostDefinition < ResourceDefinition
172
172
  end
173
173
  ```
174
174
 
175
- An `as:` component works in every surface that renders the field — form, show
175
+ An `as:` component works in every surface that renders the field: form, show
176
176
  page, index column, filter panel and wizard summary alike.
177
177
 
178
178
  ### Use in a page / form / display
@@ -187,7 +187,7 @@ end
187
187
 
188
188
  ## `DynaFrameContent` pattern
189
189
 
190
- Enables frame-aware rendering — regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
190
+ Enables frame-aware rendering, regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
191
191
 
192
192
  ```ruby
193
193
  def view_template(&block)
@@ -205,7 +205,7 @@ All pages inherit this automatically. Modals and frame navigation work without s
205
205
 
206
206
  Rarely. Use it when writing a custom non-resource page that needs the same frame-aware rendering as the built-in pages.
207
207
 
208
- For typical custom pages, just inherit `Plutonium::UI::Page::Base` and override hooks like `render_content` — the DynaFrame wrapping happens in `view_template` automatically.
208
+ For typical custom pages, just inherit `Plutonium::UI::Page::Base` and override hooks like `render_content`, the DynaFrame wrapping happens in `view_template` automatically.
209
209
 
210
210
  ## Conditional class helpers
211
211
 
@@ -257,15 +257,15 @@ class MyComponent < Plutonium::UI::Component::Base
257
257
  end
258
258
  ```
259
259
 
260
- The `helpers` proxy gives you everything `ApplicationController#helpers` exposes — including any custom helpers in `app/helpers/`.
260
+ The `helpers` proxy gives you everything `ApplicationController#helpers` exposes, including any custom helpers in `app/helpers/`.
261
261
 
262
262
  ## Available context
263
263
 
264
- Inside any custom component, the same set of helpers as pages/forms/displays — see [Pages › Available context](./pages#available-context).
264
+ Inside any custom component, the same set of helpers as pages/forms/displays, see [Pages › Available context](./pages#available-context).
265
265
 
266
266
  ## Related
267
267
 
268
- - [Pages](./pages) — `render_*` hooks call your components
269
- - [Forms](./forms) — the built-in input tags and their `as:` aliases
270
- - [Displays](./displays) — using custom display components
271
- - [Assets](./assets) — design tokens (`var(--pu-*)`) and `.pu-*` component classes
268
+ - [Pages](./pages): `render_*` hooks call your components
269
+ - [Forms](./forms): the built-in input tags and their `as:` aliases
270
+ - [Displays](./displays): using custom display components
271
+ - [Assets](./assets): design tokens (`var(--pu-*)`) and `.pu-*` component classes
@@ -14,7 +14,7 @@ class PostDefinition < ResourceDefinition
14
14
  end
15
15
 
16
16
  # `fields_wrapper` is ALREADY a card (it renders a Block internally),
17
- # so do not wrap it in another one — that stacks two cards and doubles
17
+ # so do not wrap it in another one, that stacks two cards and doubles
18
18
  # the border and shadow.
19
19
  fields_wrapper do
20
20
  render_resource_field :author
@@ -38,7 +38,7 @@ end
38
38
  |---|---|
39
39
  | `render_fields` | All permitted fields |
40
40
  | `render_resource_field(name)` | One field |
41
- | `render_associations` | Association tabs (driven by `permitted_associations` — see [Behavior › Policy](/reference/behavior/policies#association-permissions)) |
41
+ | `render_associations` | Association tabs (driven by `permitted_associations`, see [Behavior › Policy](/reference/behavior/policies#association-permissions)) |
42
42
  | `object` | The record |
43
43
  | `resource_fields`, `resource_associations` | Permitted lists |
44
44
 
@@ -48,7 +48,7 @@ For per-field custom rendering, prefer declaring it in the **definition** rather
48
48
 
49
49
  ```ruby
50
50
  class PostDefinition < ResourceDefinition
51
- # Block — returns any Phlex component
51
+ # Block, returns any Phlex component
52
52
  display :status do |field|
53
53
  StatusBadgeComponent.new(value: field.value, class: field.dom.css_class)
54
54
  end
@@ -59,7 +59,7 @@ class PostDefinition < ResourceDefinition
59
59
  span(class: "pu-badge pu-badge-#{variant}") { field.value.to_s.humanize }
60
60
  end
61
61
 
62
- # Field component class — built as ChartComponent.new(field, **attributes),
62
+ # Field component class, built as ChartComponent.new(field, **attributes),
63
63
  # so it subclasses Phlexi::Display::Components::Base and reads `field`.
64
64
  # (A component with its own constructor uses the block form above.)
65
65
  display :chart, as: ChartComponent
@@ -70,14 +70,14 @@ See [Resource › Definition › Custom rendering](/reference/resource/definitio
70
70
 
71
71
  ## Built-in display components
72
72
 
73
- Some types render with richer components automatically — you only declare an `as:` to override or pass options.
73
+ Some types render with richer components automatically; you only declare an `as:` to override or pass options.
74
74
 
75
75
  | `as:` | Renders | Auto-inferred for | Options |
76
76
  |-------|---------|-------------------|---------|
77
77
  | `:boolean` | green "Yes" / neutral "No" pill | `boolean` columns | `true_label:`, `false_label:` |
78
78
  | `:badge` | colored status pill | `enum` columns | `colors:` (per-value override) |
79
79
  | `:currency` | delimited, 2-decimal money | `has_cents` decimal accessors | `unit:`, `options:` |
80
- | `:color` | swatch + value | — | — |
80
+ | `:color` | swatch + value | - | - |
81
81
 
82
82
  ```ruby
83
83
  class OrderDefinition < ResourceDefinition
@@ -90,7 +90,7 @@ end
90
90
 
91
91
  **Badge colors.** Known statuses (`active`, `pending`, `failed`, …) are auto-colored by meaning. Unknown values get a stable decorative color (same value → same color). Override per-value with `colors:`; valid variants: `:neutral`, `:primary`, `:secondary`, `:success`, `:danger`, `:warning`, `:info`, `:accent`.
92
92
 
93
- **Currency.** No symbol is shown unless you pass `unit:` — a literal string (`"£"`) or a Symbol read off the record for per-row currencies. `has_cents` decimal accessors infer `:currency` automatically (still symbol-less until you set `unit:`).
93
+ **Currency.** No symbol is shown unless you pass `unit:`, a literal string (`"£"`) or a Symbol read off the record for per-row currencies. `has_cents` decimal accessors infer `:currency` automatically (still symbol-less until you set `unit:`).
94
94
 
95
95
  ## Theming
96
96
 
@@ -118,20 +118,20 @@ end
118
118
  `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`, plus the shared section-chrome keys (`section_wrapper`, `section_header`, `section_summary`, `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`).
119
119
 
120
120
  ::: warning `fields_wrapper` is the card, `fields_inner` is the grid
121
- `fields_wrapper` is merged into a `Plutonium::UI::Block`, which supplies `pu-card` itself — grid classes put there style the card, not the fields. Override **`fields_inner`** for the unsectioned grid, and **`section_grid`** for the grid inside a [`display_layout`](/reference/resource/definition#display-layout) section.
121
+ `fields_wrapper` is merged into a `Plutonium::UI::Block`, which supplies `pu-card` itself, grid classes put there style the card, not the fields. Override **`fields_inner`** for the unsectioned grid, and **`section_grid`** for the grid inside a [`display_layout`](/reference/resource/definition#display-layout) section.
122
122
  :::
123
123
 
124
124
  Section chrome comes from `Plutonium::UI::Component::Section::DEFAULT_THEME`, merged into **both** `Form::Theme` and `Display::Theme`, so form sections and show-page sections read identically by default while staying independently overridable.
125
125
 
126
- (`boolean` and `badge` apply their pill variant in the component, so their theme value stays empty — restyle the pills via the `.pu-badge*` classes instead.)
126
+ (`boolean` and `badge` apply their pill variant in the component, so their theme value stays empty; restyle the pills via the `.pu-badge*` classes instead.)
127
127
 
128
128
  ## Metadata panel
129
129
 
130
- A right-side aside on the show page. Configured at the definition level, not the Display class — see [Resource › Definition › Metadata panel](/reference/resource/definition#metadata-panel-show-page).
130
+ A right-side aside on the show page. Configured at the definition level, not the Display class, see [Resource › Definition › Metadata panel](/reference/resource/definition#metadata-panel-show-page).
131
131
 
132
132
  ## Related
133
133
 
134
- - [Pages](./pages) — `ShowPage` render hooks (often a lighter alternative to overriding `Display`)
135
- - [Components](./components) — building reusable Phlex display components
136
- - [Resource › Definition](/reference/resource/definition) — field-level display configuration (`as:`, `condition:`, blocks)
137
- - [Behavior › Policy](/reference/behavior/policies) — `permitted_associations` drives the show-page tablist
134
+ - [Pages](./pages): `ShowPage` render hooks (often a lighter alternative to overriding `Display`)
135
+ - [Components](./components): building reusable Phlex display components
136
+ - [Resource › Definition](/reference/resource/definition): field-level display configuration (`as:`, `condition:`, blocks)
137
+ - [Behavior › Policy](/reference/behavior/policies): `permitted_associations` drives the show-page tablist