plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,287 @@
1
+ # Testing Reference
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.
4
+
5
+ ## 🚨 Critical
6
+
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.
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.
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ # Once per app
16
+ rails g pu:test:install
17
+
18
+ # Per resource × portal pairing
19
+ rails g pu:test:scaffold Blogging::Post --portals=admin,org
20
+
21
+ # Run
22
+ bin/rails test
23
+ ```
24
+
25
+ `pu:test:install` adds `require "plutonium/testing"` to `test/test_helper.rb` and creates `test/support/plutonium_testing.rb` (a stub for non-Rodauth auth overrides).
26
+
27
+ ## The DSL
28
+
29
+ Every concern uses the same class-level DSL:
30
+
31
+ ```ruby
32
+ resource_tests_for ResourceClass,
33
+ portal: :admin, # required
34
+ path_prefix: "/admin", # optional override
35
+ parent: :organization, # for nested resources
36
+ actions: %i[index show new create edit update destroy],
37
+ skip: %i[destroy],
38
+ associated_with: :organization, # ResourceModel only
39
+ sgid_routing: true, # ResourceModel only
40
+ has_cents: %i[price] # ResourceModel only
41
+ ```
42
+
43
+ The **portal symbol** drives:
44
+
45
+ | Derived | `:admin` example | `:org` example |
46
+ |---|---|---|
47
+ | `path_prefix` | `/admin` | `/org` |
48
+ | Default sign-in helper | admin Rodauth | user Rodauth |
49
+ | Allowed action set | from definition | from definition |
50
+
51
+ `path_prefix` is auto-resolved from the mounted portal engine. For mounts inside `constraints` (typical Plutonium setup), the resolver walks the route tree and finds the engine.
52
+
53
+ ## Concerns
54
+
55
+ Each concern is `include`d separately. Pick the ones you need.
56
+
57
+ ### `Plutonium::Testing::ResourceCrud`
58
+
59
+ Generates index / show / new / create / edit / update / destroy integration tests against the portal-mounted resource.
60
+
61
+ **Stubs:**
62
+
63
+ - `create_resource!` → persisted record
64
+ - `valid_create_params` → Hash for POST
65
+ - `valid_update_params` → Hash for PATCH
66
+
67
+ ```ruby
68
+ class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
69
+ include IntegrationTestHelper
70
+ include Plutonium::Testing::ResourceCrud
71
+
72
+ resource_tests_for Blogging::Post, portal: :admin
73
+
74
+ setup do
75
+ @admin = create_admin!
76
+ @user = create_user!
77
+ @org = create_organization!
78
+ login_as(@admin)
79
+ end
80
+
81
+ def create_resource! = create_post!(user: @user, organization: @org)
82
+
83
+ def valid_create_params
84
+ {title: "x", body: "y", status: :draft, user: @user.to_sgid.to_s, organization: @org.to_sgid.to_s}
85
+ end
86
+
87
+ def valid_update_params = {title: "Updated"}
88
+ end
89
+ ```
90
+
91
+ ### `Plutonium::Testing::ResourcePolicy`
92
+
93
+ Asserts the `permit?` matrix across action × role and verifies `relation_scope` returns an `ActiveRecord::Relation`.
94
+
95
+ **Stubs:**
96
+
97
+ - `policy_roles` → `{role_sym => -> { account }}`
98
+ - `policy_record` → persisted record under test
99
+ - `policy_matrix` → `{action_sym => [allowed_role_syms]}`
100
+ - `policy_context` (optional) → extra kwargs (defaults to `{entity_scope: nil}`)
101
+
102
+ ```ruby
103
+ def policy_roles
104
+ {admin: -> { @admin }, member: -> { @user }}
105
+ end
106
+
107
+ def policy_record
108
+ create_post!(user: @user, organization: @org)
109
+ end
110
+
111
+ def policy_matrix
112
+ {
113
+ index: %i[admin member],
114
+ show: %i[admin member],
115
+ create: %i[admin],
116
+ update: %i[admin],
117
+ destroy: %i[admin]
118
+ }
119
+ end
120
+ ```
121
+
122
+ ### `Plutonium::Testing::ResourceDefinition`
123
+
124
+ Smoke-tests the resource definition: the class is constantize-able, every defineable prop dictionary (fields/inputs/displays/columns/scopes/filters/sorts/actions) is queryable, and declared fields exist on the model.
125
+
126
+ **No stubs required** for the happy path.
127
+
128
+ ### `Plutonium::Testing::ResourceInteraction`
129
+
130
+ Outcome-assertion helpers for `Plutonium::Resource::Interaction` subclasses.
131
+
132
+ **Helpers:**
133
+
134
+ - `assert_interaction_success(klass, **input)` → returns the success outcome
135
+ - `assert_interaction_failure(klass, **input)` → returns the failure outcome
136
+ - `interaction_view_context` (overridable) → defaults to a mock view context
137
+
138
+ ```ruby
139
+ test "RebuildSearchInteraction succeeds" do
140
+ outcome = assert_interaction_success(RebuildSearchInteraction, since: 1.day.ago)
141
+ assert_equal 42, outcome.value[:rebuilt_count]
142
+ end
143
+ ```
144
+
145
+ ### `Plutonium::Testing::ResourceModel`
146
+
147
+ Tests `associated_with` scope, SGID routing, and `has_cents` accessors — gated by DSL flags.
148
+
149
+ **Stubs:**
150
+
151
+ - `model_test_record` → persisted record
152
+
153
+ ```ruby
154
+ resource_tests_for Catalog::Product, portal: :admin,
155
+ associated_with: :organization,
156
+ sgid_routing: true,
157
+ has_cents: %i[price]
158
+
159
+ def model_test_record = create_product!(user: @user, organization: @org)
160
+ ```
161
+
162
+ Only the flagged features generate tests.
163
+
164
+ ### `Plutonium::Testing::NestedResource`
165
+
166
+ Asserts CRUD under a parent + scope-boundary tests (sibling tenants invisible).
167
+
168
+ **Stubs:**
169
+
170
+ - `parent_record!` → current tenant
171
+ - `other_parent_record!` → sibling tenant
172
+ - `create_resource!(parent:)` → persisted record under given parent
173
+
174
+ ### `Plutonium::Testing::PortalAccess`
175
+
176
+ Cross-portal access boundaries. Uses its own DSL — NOT `resource_tests_for`.
177
+
178
+ ```ruby
179
+ class PortalAccessTest < ActionDispatch::IntegrationTest
180
+ include IntegrationTestHelper
181
+ include Plutonium::Testing::PortalAccess
182
+
183
+ portal_access_for portals: %i[admin org],
184
+ matrix: {admin: %i[admin], member: %i[org]}
185
+
186
+ setup do
187
+ @admin = create_admin!
188
+ @user = create_user!
189
+ @org = create_organization!
190
+ create_membership!(organization: @org, user: @user)
191
+ end
192
+
193
+ def login_as_role(role)
194
+ case role
195
+ when :admin then login_as(@admin, portal: :admin)
196
+ when :member then login_as(@user, portal: :user)
197
+ end
198
+ end
199
+
200
+ def portal_root_path(portal)
201
+ case portal
202
+ when :admin then "/admin"
203
+ when :org then "/org/#{@org.id}"
204
+ end
205
+ end
206
+ end
207
+ ```
208
+
209
+ Generates one test per (role × portal). Allowed = `200 | 302`; blocked = `302 | 401 | 403 | 404`.
210
+
211
+ ## Auth helpers
212
+
213
+ `Plutonium::Testing::AuthHelpers` is included transitively by every concern.
214
+
215
+ ```ruby
216
+ login_as(account) # uses portal from the DSL
217
+ login_as(account, portal: :admin) # explicit override
218
+ sign_out # uses portal from the DSL
219
+ sign_out(portal: :admin)
220
+ current_account # uses portal from the DSL
221
+ current_account(portal: :admin)
222
+ with_portal(:org) { ... } # scoped portal switch
223
+ ```
224
+
225
+ ### Override hook for non-Rodauth apps
226
+
227
+ Define `sign_in_for_tests(account, portal:)` in your test class (or in `test/support/plutonium_testing.rb` for project-wide use). `AuthHelpers` will defer to it.
228
+
229
+ ```ruby
230
+ def sign_in_for_tests(account, portal:)
231
+ # your custom auth flow here
232
+ end
233
+ ```
234
+
235
+ ## Generators
236
+
237
+ ### `pu:test:install`
238
+
239
+ ```bash
240
+ rails g pu:test:install
241
+ ```
242
+
243
+ - Adds `require "plutonium/testing"` to `test/test_helper.rb` (idempotent)
244
+ - Creates `test/support/plutonium_testing.rb` with override stub
245
+
246
+ ### `pu:test:scaffold`
247
+
248
+ ```bash
249
+ rails g pu:test:scaffold Blogging::Post --portals=admin,org
250
+ rails g pu:test:scaffold Blogging::Post --portals=admin --concerns=crud,policy,definition
251
+ rails g pu:test:scaffold Blogging::Post --portals=org --parent=organization --dest=blogging
252
+ ```
253
+
254
+ | Flag | Default | Purpose |
255
+ |---|---|---|
256
+ | `--portals=admin,org` | required | Emit one file per portal |
257
+ | `--concerns=...` | `crud,policy,definition` | Concerns to include (`crud`, `policy`, `definition`, `nested`, `model`, `interaction`, `portal_access`) |
258
+ | `--parent=organization` | | Wires `NestedResource` parent |
259
+ | `--dest=main_app\|<package>` | `main_app` | Output destination |
260
+
261
+ Output path: `test/integration/<portal>_portal/<resource_underscored>_test.rb`.
262
+
263
+ ## Customization & escape hatches
264
+
265
+ - **Skip individual tests:** `resource_tests_for Klass, portal: :admin, skip: %i[destroy]`
266
+ - **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.
268
+ - **Non-Rodauth auth:** override `sign_in_for_tests`. See [AuthHelpers](#auth-helpers).
269
+ - **Custom path prefix:** `path_prefix: "/v2/admin"` overrides portal resolution.
270
+
271
+ ## Common pitfalls
272
+
273
+ - **Forgotten stubs raise `NotImplementedError`** with the stub name. Look for the missing method in your test class.
274
+ - **Portal mismatch:** `:admin` portal expects `AdminPortal::Engine` constant. If your portal is named differently, pass `path_prefix:` explicitly.
275
+ - **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.
279
+
280
+ ## Related
281
+
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
@@ -0,0 +1,400 @@
1
+ # Assets
2
+
3
+ TailwindCSS 4 + Stimulus toolchain. CSS design tokens for theming, `.pu-*` component classes for consistent styling, and a Phlexi theme system for component-level overrides.
4
+
5
+ ## 🚨 Critical
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.
12
+
13
+ ## Asset configuration
14
+
15
+ ```ruby
16
+ # config/initializers/plutonium.rb
17
+ Plutonium.configure do |config|
18
+ config.load_defaults 1.0
19
+
20
+ config.assets.stylesheet = "application" # your CSS file
21
+ config.assets.script = "application" # your JS file
22
+ config.assets.logo = "my_logo.png"
23
+ config.assets.favicon = "my_favicon.ico"
24
+ end
25
+ ```
26
+
27
+ ## Generator
28
+
29
+ ```bash
30
+ rails generate pu:core:assets
31
+ ```
32
+
33
+ This:
34
+
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.
40
+
41
+ ## Tailwind config
42
+
43
+ Generated `tailwind.config.js`:
44
+
45
+ ```javascript
46
+ const { execSync } = require('child_process');
47
+ const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
48
+ const plutoniumTailwindConfig = require(`${plutoniumGemPath}/tailwind.options.js`);
49
+
50
+ module.exports = {
51
+ darkMode: plutoniumTailwindConfig.darkMode, // 'selector'
52
+ plugins: [].concat(plutoniumTailwindConfig.plugins),
53
+ theme: plutoniumTailwindConfig.merge(
54
+ plutoniumTailwindConfig.theme,
55
+ { /* your overrides */ },
56
+ ),
57
+ content: [
58
+ `${__dirname}/app/**/*.{erb,haml,html,slim,rb}`,
59
+ `${__dirname}/app/javascript/**/*.js`,
60
+ `${__dirname}/packages/**/app/**/*.{erb,haml,html,slim,rb}`,
61
+ ].concat(plutoniumTailwindConfig.content),
62
+ };
63
+ ```
64
+
65
+ ::: danger Use `plutoniumTailwindConfig.merge`
66
+ A plain spread (`...plutoniumTailwindConfig.theme`) drops the merge logic and you lose Plutonium's defaults. Always use `merge(...)`.
67
+ :::
68
+
69
+ ### Customizing colors
70
+
71
+ ```javascript
72
+ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
73
+ extend: {
74
+ colors: {
75
+ primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
76
+ },
77
+ },
78
+ })
79
+ ```
80
+
81
+ ### Default color palette
82
+
83
+ | Color | Usage |
84
+ |---|---|
85
+ | `primary` | Brand primary (turquoise default) |
86
+ | `secondary` | Brand secondary (navy default) |
87
+ | `success` | Success states (green) |
88
+ | `info` | Informational (blue) |
89
+ | `warning` | Warning (amber) |
90
+ | `danger` | Error (red) |
91
+ | `accent` | Highlight (coral pink) |
92
+
93
+ ## CSS imports
94
+
95
+ ```css
96
+ /* app/assets/stylesheets/application.tailwind.css */
97
+ @import "gem:plutonium/src/css/plutonium.css";
98
+
99
+ @import "tailwindcss";
100
+ @config '../../../tailwind.config.js';
101
+
102
+ /* your styles */
103
+ ```
104
+
105
+ Plutonium CSS includes core utility classes, EasyMDE (markdown editor), Slim Select, intl-tel-input, Flatpickr (date picker).
106
+
107
+ ## Stimulus
108
+
109
+ ```javascript
110
+ // app/javascript/controllers/index.js
111
+ import { application } from "./application"
112
+ import { registerControllers } from "@radioactive-labs/plutonium"
113
+
114
+ registerControllers(application)
115
+
116
+ // Your custom controllers...
117
+ import CustomController from "./custom_controller"
118
+ application.register("custom", CustomController)
119
+ ```
120
+
121
+ ### Bundled controllers
122
+
123
+ - `color-mode` — dark/light mode toggle
124
+ - `form` — form handling (pre-submit, etc.)
125
+ - `nested-resource-form-fields` — nested form management
126
+ - `slim-select` — enhanced select boxes
127
+ - `flatpickr` — date/time pickers
128
+ - `easymde` — markdown editor
129
+ - Various internal UI controllers
130
+
131
+ ### Custom Stimulus controller — standard pattern
132
+
133
+ ```javascript
134
+ // app/javascript/controllers/custom_controller.js
135
+ import { Controller } from "@hotwired/stimulus"
136
+
137
+ export default class extends Controller {
138
+ connect() {
139
+ console.log("Custom controller connected")
140
+ }
141
+ }
142
+ ```
143
+
144
+ ```javascript
145
+ // Register
146
+ application.register("custom", CustomController)
147
+ ```
148
+
149
+ ## Design tokens
150
+
151
+ Plutonium uses a comprehensive CSS custom-property system for consistent, themeable UI components. Tokens auto-switch with dark mode. Source: `src/css/tokens.css`.
152
+
153
+ ### Surface & backgrounds
154
+
155
+ ```css
156
+ /* Light */
157
+ --pu-body: #f8fafc;
158
+ --pu-surface: #ffffff;
159
+ --pu-surface-alt: #f1f5f9;
160
+ --pu-surface-raised: #ffffff;
161
+ --pu-surface-overlay: rgba(255, 255, 255, 0.95);
162
+
163
+ /* Dark (.dark class) */
164
+ --pu-body: #0f172a;
165
+ --pu-surface: #1e293b;
166
+ --pu-surface-alt: #0f172a;
167
+ --pu-surface-raised: #334155;
168
+ --pu-surface-overlay: rgba(30, 41, 59, 0.95);
169
+ ```
170
+
171
+ ### Text
172
+
173
+ ```css
174
+ /* Light */
175
+ --pu-text: #0f172a;
176
+ --pu-text-muted: #64748b;
177
+ --pu-text-subtle: #94a3b8;
178
+
179
+ /* Dark */
180
+ --pu-text: #f8fafc;
181
+ --pu-text-muted: #94a3b8;
182
+ --pu-text-subtle: #64748b;
183
+ ```
184
+
185
+ ### Borders, forms, cards
186
+
187
+ ```css
188
+ --pu-border: #e2e8f0;
189
+ --pu-border-muted: #f1f5f9;
190
+ --pu-border-strong: #cbd5e1;
191
+
192
+ --pu-input-bg: #ffffff;
193
+ --pu-input-border: #e2e8f0;
194
+ --pu-input-focus-ring: theme(colors.primary.500);
195
+ --pu-input-placeholder: #94a3b8;
196
+
197
+ --pu-card-bg: #ffffff;
198
+ --pu-card-border: #e2e8f0;
199
+ ```
200
+
201
+ ### Shadows, radii, spacing, transitions
202
+
203
+ ```css
204
+ --pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03), 0 1px 3px 0 rgb(0 0 0 / 0.05);
205
+ --pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.04), 0 4px 6px -1px rgb(0 0 0 / 0.06);
206
+ --pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.03), 0 10px 15px -3px rgb(0 0 0 / 0.08);
207
+
208
+ --pu-radius-sm: 0.375rem;
209
+ --pu-radius-md: 0.5rem;
210
+ --pu-radius-lg: 0.75rem;
211
+ --pu-radius-xl: 1rem;
212
+ --pu-radius-full: 9999px;
213
+
214
+ --pu-space-xs: 0.25rem;
215
+ --pu-space-sm: 0.5rem;
216
+ --pu-space-md: 1rem;
217
+ --pu-space-lg: 1.5rem;
218
+ --pu-space-xl: 2rem;
219
+
220
+ --pu-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
221
+ --pu-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
222
+ --pu-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
223
+ ```
224
+
225
+ ### Customizing tokens
226
+
227
+ ```css
228
+ /* app/assets/stylesheets/application.tailwind.css */
229
+ @import "gem:plutonium/src/css/plutonium.css";
230
+ @import "tailwindcss";
231
+
232
+ :root {
233
+ --pu-surface: #fafafa;
234
+ --pu-border: #d1d5db;
235
+ }
236
+
237
+ .dark {
238
+ --pu-surface: #111827;
239
+ --pu-border: #374151;
240
+ }
241
+ ```
242
+
243
+ ### Using tokens in templates
244
+
245
+ ```erb
246
+ <h1 class="text-[var(--pu-text)]">Title</h1>
247
+ <p class="text-[var(--pu-text-muted)]">Description</p>
248
+
249
+ <div class="bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
250
+ Content
251
+ </div>
252
+ ```
253
+
254
+ ```ruby
255
+ class MyComponent < Plutonium::UI::Component::Base
256
+ def view_template
257
+ div(
258
+ class: "bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]",
259
+ style: "box-shadow: var(--pu-shadow-md)"
260
+ ) do
261
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Title" }
262
+ p(class: "text-[var(--pu-text-muted)]") { "Description" }
263
+ end
264
+ end
265
+ end
266
+ ```
267
+
268
+ ## Component classes (`.pu-*`)
269
+
270
+ 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.
271
+
272
+ ### Buttons
273
+
274
+ ```
275
+ .pu-btn (base)
276
+ .pu-btn-md / -sm / -xs (size)
277
+ .pu-btn-primary / -secondary / -danger / -success / -warning / -info / -accent
278
+ .pu-btn-ghost / -outline
279
+ .pu-btn-soft-primary / -soft-danger / ...
280
+ ```
281
+
282
+ ```erb
283
+ <%= form.submit "Save", class: "pu-btn pu-btn-md pu-btn-primary" %>
284
+ ```
285
+
286
+ ### Inputs, labels, hints, errors
287
+
288
+ ```
289
+ .pu-input / -invalid / -valid
290
+ .pu-label / -required
291
+ .pu-hint / .pu-error
292
+ .pu-checkbox
293
+ ```
294
+
295
+ ### Cards, panels, tables, toolbars, empty states
296
+
297
+ ```
298
+ .pu-card / .pu-card-body
299
+ .pu-panel-header / -title / -description
300
+ .pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
301
+ .pu-toolbar / -text / -actions
302
+ .pu-empty-state / -icon / -title / -description
303
+ ```
304
+
305
+ ### Ruby constants
306
+
307
+ `Plutonium::UI::ComponentClasses` (in `lib/plutonium/ui/component_classes.rb`):
308
+
309
+ ```ruby
310
+ ComponentClasses::Button.classes(variant: :primary, size: :default, soft: false)
311
+ # => "pu-btn pu-btn-md pu-btn-primary"
312
+
313
+ ComponentClasses::Form::INPUT # "pu-input"
314
+ ComponentClasses::Form::LABEL # "pu-label"
315
+ ComponentClasses::Table::WRAPPER # "pu-table-wrapper"
316
+ ComponentClasses::Card::BASE # "pu-card"
317
+ ```
318
+
319
+ ## Migration from hardcoded classes
320
+
321
+ | Old | New |
322
+ |---|---|
323
+ | `text-gray-900 dark:text-white` | `text-[var(--pu-text)]` |
324
+ | `text-gray-500 dark:text-gray-400` | `text-[var(--pu-text-muted)]` |
325
+ | `bg-gray-50 dark:bg-gray-700` | `bg-[var(--pu-surface)]` |
326
+ | `border-gray-300 dark:border-gray-600` | `border-[var(--pu-border)]` |
327
+ | Long input class chain | `pu-input` |
328
+ | `block mb-2 text-sm font-semibold ...` | `pu-label` |
329
+ | `text-red-600 dark:text-red-400` | `pu-error` |
330
+ | Long button class chain | `pu-btn pu-btn-md pu-btn-primary` |
331
+
332
+ ## Phlexi component themes
333
+
334
+ Plutonium components use a Phlexi-based theme system for customizing Form, Display, and Table components. Each has a theme class with named style tokens.
335
+
336
+ ### Form theme
337
+
338
+ See [Forms › Theming](./forms#theming) for the full Form theme surface.
339
+
340
+ ### Display theme
341
+
342
+ ```ruby
343
+ class PostDefinition < ResourceDefinition
344
+ class Display < Display
345
+ class Theme < Plutonium::UI::Display::Theme
346
+ def self.theme
347
+ super.merge(
348
+ fields_wrapper: "grid grid-cols-3 gap-8",
349
+ label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
350
+ string: "text-lg text-[var(--pu-text)]",
351
+ markdown: "prose dark:prose-invert max-w-none"
352
+ )
353
+ end
354
+ end
355
+ end
356
+ end
357
+ ```
358
+
359
+ **Theme keys:** `fields_wrapper`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`.
360
+
361
+ ### Table theme
362
+
363
+ ```ruby
364
+ class PostDefinition < ResourceDefinition
365
+ class Table < Table
366
+ class Theme < Plutonium::UI::Table::Theme
367
+ def self.theme
368
+ super.merge(
369
+ wrapper: "pu-table-wrapper",
370
+ base: "pu-table",
371
+ header: "pu-table-header",
372
+ header_cell: "pu-table-header-cell",
373
+ body_row: "pu-table-body-row",
374
+ body_cell: "pu-table-body-cell"
375
+ )
376
+ end
377
+ end
378
+ end
379
+ end
380
+ ```
381
+
382
+ **Theme keys:** `wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
383
+
384
+ ::: warning Always `super.merge(...)`
385
+ Don't replace the theme wholesale. Plutonium's defaults handle invalid states, focus rings, and dark mode — `super.merge` keeps them.
386
+ :::
387
+
388
+ ## Gotchas
389
+
390
+ - **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.
391
+ - **`plutoniumTailwindConfig.merge` is mandatory.** Plain spread drops defaults silently.
392
+ - **Tokens are CSS variables, not Tailwind keys.** Use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
393
+ - **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
394
+ - **`.pu-*` classes auto-switch with dark mode.** Hardcoded `gray-X/dark:gray-Y` pairs don't get auto-updated when tokens change.
395
+
396
+ ## Related
397
+
398
+ - [Forms › Theming](./forms#theming) — Form theme keys + override pattern
399
+ - [Components](./components) — `tokens` and `classes` helpers for conditional class composition
400
+ - [Layouts](./layouts) — fonts, dark-mode toggle, body attributes