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,900 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Plutonium UI — Pages, Forms, Components, Assets
7
+
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
+
10
+ For field-level rendering (`field :foo, as: :markdown`, `display :status do |f| ... end`), see [[plutonium-resource]] › Custom Rendering. For controller render-context hooks (`present_parent?`, `submit_parent?`), see [[plutonium-behavior]].
11
+
12
+ ## 🚨 Critical (read first)
13
+
14
+ - **Override via nested classes in the definition.** `class ShowPage < ShowPage; end`, `class Form < Form; end`. Don't replace the entire view layer.
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.
22
+ - **Configure inputs in the definition; render them with `render_resource_field` in the form.** Don't reimplement field widgets from scratch.
23
+
24
+ ---
25
+
26
+ # Part 1 — Pages
27
+
28
+ Each definition has nested page classes. Override the ones you need to customize:
29
+
30
+ ```ruby
31
+ class PostDefinition < ResourceDefinition
32
+ class IndexPage < IndexPage; end
33
+ class ShowPage < ShowPage; end
34
+ class NewPage < NewPage; end
35
+ class EditPage < EditPage; end
36
+ class InteractiveActionPage < InteractiveActionPage; end
37
+ class Form < Form; end
38
+ class Table < Table; end
39
+ class Display < Display; end
40
+ end
41
+ ```
42
+
43
+ Architecture:
44
+
45
+ ```
46
+ Definition
47
+ ├── IndexPage → renders Table
48
+ ├── ShowPage → renders Display
49
+ ├── NewPage → renders Form
50
+ ├── EditPage → renders Form
51
+ └── InteractiveActionPage → renders Form
52
+ ```
53
+
54
+ ## Page titles, descriptions, breadcrumbs
55
+
56
+ ```ruby
57
+ class PostDefinition < ResourceDefinition
58
+ index_page_title "Blog Posts"
59
+ index_page_description "Manage all published articles"
60
+ show_page_title "Article Details"
61
+ show_page_title -> { "#{current_record!.title} — Details" } # dynamic
62
+
63
+ breadcrumbs true # global default
64
+ index_page_breadcrumbs false # per-page override
65
+ end
66
+ ```
67
+
68
+ ## Page hooks (preferred over `view_template`)
69
+
70
+ Every page inherits these:
71
+
72
+ | Hook | Position |
73
+ |---|---|
74
+ | `render_before_header` / `_after_header` | wraps the entire header section |
75
+ | `render_before_breadcrumbs` / `_after_breadcrumbs` | around the breadcrumb row |
76
+ | `render_before_page_header` / `_after_page_header` | around the title + actions block |
77
+ | `render_before_toolbar` / `_after_toolbar` | around the action toolbar |
78
+ | `render_before_content` / `_after_content` | around main content |
79
+ | `render_before_footer` / `_after_footer` | around footer/pagination |
80
+
81
+ Example:
82
+
83
+ ```ruby
84
+ class ShowPage < ShowPage
85
+ private
86
+
87
+ def page_title
88
+ "#{object.title} — #{object.author.name}"
89
+ end
90
+
91
+ def render_before_content
92
+ div(class: "alert alert-info") do
93
+ plain "This post has #{object.comments.count} comments"
94
+ end
95
+ end
96
+
97
+ def render_after_content
98
+ render RelatedPostsComponent.new(post: object)
99
+ end
100
+
101
+ def render_toolbar
102
+ div(class: "flex gap-2") do
103
+ button(class: "pu-btn pu-btn-md pu-btn-secondary") { "Preview" }
104
+ button(class: "pu-btn pu-btn-md pu-btn-primary") { "Publish" }
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ ## Custom ERB views (full replacement)
111
+
112
+ For total control, drop the page class entirely with an ERB view at the controller path:
113
+
114
+ ```
115
+ app/views/posts/show.html.erb
116
+ packages/admin_portal/app/views/admin_portal/posts/show.html.erb
117
+ ```
118
+
119
+ The default view simply renders the page class:
120
+
121
+ ```erb
122
+ <%= render current_definition.show_page_class.new %>
123
+ ```
124
+
125
+ Mix: keep the default and add chrome around it:
126
+
127
+ ```erb
128
+ <div class="announcement-banner">Special announcement</div>
129
+ <%= render current_definition.show_page_class.new %>
130
+ <div class="related"><%= render partial: "related" %></div>
131
+ ```
132
+
133
+ ## Detecting render context
134
+
135
+ | Helper | True when |
136
+ |---|---|
137
+ | `in_frame?` | Request targets a turbo-frame |
138
+ | `in_modal?` | Request renders inside a modal/slideover |
139
+
140
+ Use to pin action strips, omit nav chrome, or swap layouts.
141
+
142
+ ---
143
+
144
+ # Part 2 — Forms
145
+
146
+ Forms are built on [Phlexi::Form](https://github.com/radioactive-labs/phlexi-form). Hierarchy:
147
+
148
+ ```
149
+ Phlexi::Form::Base
150
+ └── Plutonium::UI::Form::Base
151
+ ├── Plutonium::UI::Form::Resource # CRUD
152
+ │ └── Plutonium::UI::Form::Interaction # action forms
153
+ └── Plutonium::UI::Form::Query # search/filter
154
+ ```
155
+
156
+ ## Override the form
157
+
158
+ ```ruby
159
+ class PostDefinition < ResourceDefinition
160
+ class Form < Form
161
+ def form_template
162
+ render_fields # render every permitted field
163
+ render_actions # submit buttons — REQUIRED
164
+ end
165
+ end
166
+ end
167
+ ```
168
+
169
+ ### Form methods
170
+
171
+ | Method | Purpose |
172
+ |---|---|
173
+ | `form_template` | Main override point |
174
+ | `render_fields` | All permitted fields in default layout |
175
+ | `render_resource_field(name)` | One field, using the definition's `input` config |
176
+ | `render_actions` | Submit + secondary buttons |
177
+ | `fields_wrapper { ... }` | Grid wrapper div (themeable) |
178
+ | `actions_wrapper { ... }` | Button wrapper div (themeable) |
179
+ | `object` / `record` | The form record |
180
+ | `resource_fields` | Array of permitted field names |
181
+ | `resource_definition` | The definition instance |
182
+
183
+ ## Custom layouts
184
+
185
+ ### Sectioned
186
+
187
+ ```ruby
188
+ class Form < Form
189
+ def form_template
190
+ section("Basic") do
191
+ render_resource_field :title
192
+ render_resource_field :slug
193
+ end
194
+
195
+ section("Publishing") do
196
+ render_resource_field :published_at
197
+ render_resource_field :category
198
+ end
199
+
200
+ render_actions
201
+ end
202
+
203
+ private
204
+
205
+ def section(title, &)
206
+ div(class: "mb-8") do
207
+ h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { title }
208
+ fields_wrapper(&)
209
+ end
210
+ end
211
+ end
212
+ ```
213
+
214
+ ### Two-column
215
+
216
+ ```ruby
217
+ def form_template
218
+ div(class: "grid grid-cols-1 lg:grid-cols-3 gap-6") do
219
+ div(class: "lg:col-span-2") do
220
+ fields_wrapper do
221
+ render_resource_field :title
222
+ render_resource_field :content
223
+ end
224
+ end
225
+
226
+ div(class: "space-y-4") do
227
+ Panel do
228
+ h4(class: "font-medium mb-2") { "Settings" }
229
+ render_resource_field :status
230
+ render_resource_field :visibility
231
+ end
232
+ end
233
+ end
234
+ render_actions
235
+ end
236
+ ```
237
+
238
+ ## Field builder (`field(:foo).input_tag`)
239
+
240
+ `render_resource_field` uses the input config from the definition. For ad-hoc rendering, use `field(...)` directly:
241
+
242
+ ```ruby
243
+ render field(:title).wrapped { |f| f.input_tag } # wrapped: label + hint + errors
244
+ render field(:title).input_tag # bare element only
245
+ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
246
+ ```
247
+
248
+ ### Tag methods
249
+
250
+ | Tag | Input |
251
+ |---|---|
252
+ | `input_tag` | text (auto-detected type) |
253
+ | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `tel_tag`, `hidden_tag` | standard HTML inputs |
254
+ | `checkbox_tag`, `select_tag`, `radio_button_tag` | standard |
255
+
256
+ ### Plutonium-enhanced tags
257
+
258
+ | Tag | Component |
259
+ |---|---|
260
+ | `easymde_tag` / `markdown_tag` | EasyMDE markdown editor |
261
+ | `slim_select_tag` | Slim Select |
262
+ | `flatpickr_tag` | Flatpickr date/time picker |
263
+ | `phone_tag` / `int_tel_input_tag` | intl-tel-input phone field |
264
+ | `uppy_tag` / `file_tag` | Uppy file upload |
265
+ | `secure_association_tag` | Association with policy-checked options |
266
+ | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
267
+ | `key_value_store_tag` | Key/value pairs editor |
268
+
269
+ ```ruby
270
+ render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today, enable_time: true) }
271
+ render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes) }
272
+ ```
273
+
274
+ ## Submit buttons
275
+
276
+ Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
277
+
278
+ Control the secondary button via the definition:
279
+
280
+ ```ruby
281
+ class PostDefinition < ResourceDefinition
282
+ submit_and_continue false # nil (default — auto), true (always show), false (always hide)
283
+ end
284
+ ```
285
+
286
+ Singular resources auto-hide it.
287
+
288
+ Custom action strip:
289
+
290
+ ```ruby
291
+ def render_actions
292
+ actions_wrapper do
293
+ a(href: resource_url_for(resource_class), class: "pu-btn pu-btn-md pu-btn-secondary") { "Cancel" }
294
+ button(type: :submit, name: "draft", value: "1", class: "pu-btn pu-btn-md") { "Save Draft" }
295
+ render submit_button
296
+ end
297
+ end
298
+ ```
299
+
300
+ ## Pre-submit, nested inputs, interaction forms
301
+
302
+ These all live in the definition layer:
303
+
304
+ - **Pre-submit / dynamic forms** — see [[plutonium-resource]] › Dynamic Forms.
305
+ - **Nested inputs** (`nested_input :variants`) — see [[plutonium-resource]] › Nested Inputs.
306
+ - **Interaction forms** — interactions define their own `attribute` / `input` and inherit `Plutonium::UI::Form::Interaction`; see [[plutonium-behavior]] › Interactions.
307
+
308
+ ---
309
+
310
+ # Part 3 — Display & Table
311
+
312
+ ## Custom Display
313
+
314
+ ```ruby
315
+ class PostDefinition < ResourceDefinition
316
+ class Display < Display
317
+ def display_template
318
+ div(class: "bg-gradient-to-r from-primary-500 to-secondary-600 p-8 rounded-lg text-white mb-6") do
319
+ h1(class: "text-3xl font-bold") { object.title }
320
+ p(class: "mt-2 opacity-90") { object.excerpt }
321
+ end
322
+
323
+ Block do
324
+ fields_wrapper do
325
+ render_resource_field :author
326
+ render_resource_field :published_at
327
+ end
328
+ end
329
+
330
+ Block do
331
+ div(class: "prose max-w-none") { raw object.content }
332
+ end
333
+
334
+ render_associations if present_associations?
335
+ end
336
+ end
337
+ end
338
+ ```
339
+
340
+ | Method | Purpose |
341
+ |---|---|
342
+ | `render_fields` | All permitted fields |
343
+ | `render_resource_field(name)` | One field |
344
+ | `render_associations` | Association tabs (driven by `permitted_associations` — see [[plutonium-behavior]]) |
345
+ | `object` | The record |
346
+ | `resource_fields`, `resource_associations` | Permitted lists |
347
+
348
+ ## Custom Table
349
+
350
+ ```ruby
351
+ class PostDefinition < ResourceDefinition
352
+ class Table < Table
353
+ def view_template
354
+ render_search_bar
355
+ render_scopes_bar
356
+
357
+ if collection.empty?
358
+ render_empty_card
359
+ else
360
+ # Replace the table with a card grid
361
+ div(class: "grid grid-cols-3 gap-4") do
362
+ collection.each { |post| render PostCardComponent.new(post:) }
363
+ end
364
+ end
365
+
366
+ render_footer
367
+ end
368
+ end
369
+ end
370
+ ```
371
+
372
+ | Method | Purpose |
373
+ |---|---|
374
+ | `render_search_bar`, `render_scopes_bar` | Toolbar pieces |
375
+ | `render_table` | Default table |
376
+ | `render_empty_card` | Empty state |
377
+ | `render_footer` | Pagination |
378
+ | `collection` | Paginated records |
379
+ | `resource_fields` | Column field names |
380
+
381
+ ---
382
+
383
+ # Part 4 — Component Kit & Custom Components
384
+
385
+ ## Built-in shorthand kit
386
+
387
+ Inside any `Plutonium::UI::Component::Base` (or any page/form/display):
388
+
389
+ ```ruby
390
+ PageHeader(title: "Dashboard", description: "...", actions: [...])
391
+ Panel(class: "mt-4") { p { "Content" } }
392
+ Block { TabList(items: tabs) }
393
+ EmptyCard("No items found")
394
+ ActionButton(action, url: "/posts/new")
395
+ DynaFrameHost(src: "/some/path", loading: :lazy)
396
+ DynaFrameContent(content) { |frame| frame.render_content }
397
+ TableSearchBar()
398
+ TableScopesBar()
399
+ TableInfo(pagy)
400
+ TablePagination(pagy)
401
+ Breadcrumbs()
402
+ ```
403
+
404
+ ## Custom Phlex components
405
+
406
+ ```ruby
407
+ class PostCardComponent < Plutonium::UI::Component::Base
408
+ def initialize(post:)
409
+ @post = post
410
+ end
411
+
412
+ def view_template
413
+ div(class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] p-4") do
414
+ h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
415
+ p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
416
+ a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
417
+ end
418
+ end
419
+ end
420
+ ```
421
+
422
+ Use in a definition:
423
+
424
+ ```ruby
425
+ display :card, as: PostCardComponent # custom display component
426
+ input :color, as: ColorPickerComponent # custom input component
427
+
428
+ display :metrics do |field|
429
+ MetricsChartComponent.new(data: field.value)
430
+ end
431
+ ```
432
+
433
+ ## `DynaFrameContent` pattern
434
+
435
+ Enables frame-aware rendering: regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
436
+
437
+ ```ruby
438
+ def view_template(&block)
439
+ DynaFrameContent(page_content(block)) do |frame|
440
+ render_header # skipped for frame requests
441
+ frame.render_content # always rendered
442
+ render_footer # skipped for frame requests
443
+ end
444
+ end
445
+ ```
446
+
447
+ All pages inherit this. Modals and frame navigation work without special handling.
448
+
449
+ ---
450
+
451
+ # Part 5 — Modals, Slideovers, Tabs
452
+
453
+ ## Modal/slideover for `:new` / `:edit`
454
+
455
+ ```ruby
456
+ class PostDefinition < ResourceDefinition
457
+ modal :slideover # default — slide-in panel from the right
458
+ # modal :centered # centered dialog
459
+ # modal false # full standalone page
460
+ end
461
+ ```
462
+
463
+ Custom interactive actions render in their own dialog with their own per-action `modal:` option (`:centered` default, or `:slideover`). See [[plutonium-resource]] › Action Options.
464
+
465
+ ## Tabs on the show page
466
+
467
+ 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.
468
+
469
+ ---
470
+
471
+ # Part 6 — Layout (Chrome) & Eject
472
+
473
+ ## Shell
474
+
475
+ ```ruby
476
+ Plutonium.configure do |config|
477
+ config.shell = :modern # default — topbar + icon rail
478
+ # config.shell = :classic # legacy header + sidebar (only when upgrading)
479
+ end
480
+ ```
481
+
482
+ ## Eject the chrome for per-portal customization
483
+
484
+ ```bash
485
+ rails generate pu:eject:shell --dest=admin_portal
486
+ rails generate pu:eject:layout
487
+ ```
488
+
489
+ These copy `_resource_header.html.erb`, `_resource_sidebar.html.erb`, and `layouts/resource.html.erb` into the portal so you can edit them directly.
490
+
491
+ ## Custom layout class (Phlex)
492
+
493
+ ```ruby
494
+ module AdminPortal
495
+ class ResourceLayout < Plutonium::UI::Layout::ResourceLayout
496
+ private
497
+
498
+ def body_attributes = {class: "antialiased bg-[var(--pu-body)]"}
499
+
500
+ def render_before_main
501
+ super
502
+ render AnnouncementBanner.new if Announcement.active.any?
503
+ end
504
+
505
+ def render_body_scripts
506
+ super
507
+ script(src: "/custom-analytics.js")
508
+ end
509
+ end
510
+ end
511
+ ```
512
+
513
+ | Hook | Position |
514
+ |---|---|
515
+ | `render_before_main` / `_after_main` | around the main content area |
516
+ | `render_before_content` / `_after_content` | inside main, around content |
517
+ | `render_flash` | flash messages |
518
+ | `render_head`, `render_title`, `render_metatags`, `render_assets` | head section |
519
+ | `render_body_scripts` | end-of-body scripts |
520
+ | `render_fonts` | font links |
521
+
522
+ ---
523
+
524
+ # Part 7 — Assets, Tailwind, Stimulus
525
+
526
+ ## Asset configuration
527
+
528
+ ```ruby
529
+ # config/initializers/plutonium.rb
530
+ Plutonium.configure do |config|
531
+ config.load_defaults 1.0
532
+ config.assets.stylesheet = "application"
533
+ config.assets.script = "application"
534
+ config.assets.logo = "my_logo.png"
535
+ config.assets.favicon = "my_favicon.ico"
536
+ end
537
+ ```
538
+
539
+ ## Generator
540
+
541
+ ```bash
542
+ rails generate pu:core:assets
543
+ ```
544
+
545
+ 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.
546
+
547
+ ## Tailwind config (generated)
548
+
549
+ ```javascript
550
+ // tailwind.config.js
551
+ const { execSync } = require('child_process');
552
+ const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
553
+ const plutoniumTailwindConfig = require(`${plutoniumGemPath}/tailwind.options.js`);
554
+
555
+ module.exports = {
556
+ darkMode: plutoniumTailwindConfig.darkMode, // selector
557
+ plugins: [].concat(plutoniumTailwindConfig.plugins),
558
+ theme: plutoniumTailwindConfig.merge(
559
+ plutoniumTailwindConfig.theme,
560
+ { /* your overrides */ },
561
+ ),
562
+ content: [
563
+ `${__dirname}/app/**/*.{erb,haml,html,slim,rb}`,
564
+ `${__dirname}/app/javascript/**/*.js`,
565
+ `${__dirname}/packages/**/app/**/*.{erb,haml,html,slim,rb}`,
566
+ ].concat(plutoniumTailwindConfig.content),
567
+ };
568
+ ```
569
+
570
+ 🚨 Always use `plutoniumTailwindConfig.merge(...)`. A plain spread drops Plutonium's defaults.
571
+
572
+ ## Default color palette
573
+
574
+ | Color | Use |
575
+ |---|---|
576
+ | `primary` | Brand primary (turquoise default) |
577
+ | `secondary` | Brand secondary (navy default) |
578
+ | `success` | Success state (green) |
579
+ | `info` | Informational (blue) |
580
+ | `warning` | Warning (amber) |
581
+ | `danger` | Error (red) |
582
+ | `accent` | Highlight (coral pink) |
583
+
584
+ ```javascript
585
+ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
586
+ extend: {
587
+ colors: {
588
+ primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
589
+ },
590
+ },
591
+ })
592
+ ```
593
+
594
+ ## CSS imports
595
+
596
+ ```css
597
+ /* app/assets/stylesheets/application.tailwind.css */
598
+ @import "gem:plutonium/src/css/plutonium.css";
599
+
600
+ @import "tailwindcss";
601
+ @config '../../../tailwind.config.js';
602
+
603
+ /* your styles */
604
+ ```
605
+
606
+ Plutonium CSS includes core utilities, EasyMDE, Slim Select, intl-tel-input, Flatpickr.
607
+
608
+ ## Stimulus
609
+
610
+ ```javascript
611
+ // app/javascript/controllers/index.js
612
+ import { application } from "./application"
613
+ import { registerControllers } from "@radioactive-labs/plutonium"
614
+
615
+ registerControllers(application)
616
+
617
+ // Your custom controllers...
618
+ import CustomController from "./custom_controller"
619
+ application.register("custom", CustomController)
620
+ ```
621
+
622
+ Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, plus various internal UI controllers.
623
+
624
+ Custom controller — standard Stimulus:
625
+
626
+ ```javascript
627
+ import { Controller } from "@hotwired/stimulus"
628
+ export default class extends Controller {
629
+ connect() { /* ... */ }
630
+ }
631
+ ```
632
+
633
+ ## Typography
634
+
635
+ Default font: Lato. Override:
636
+
637
+ ```ruby
638
+ class MyLayout < Plutonium::UI::Layout::ResourceLayout
639
+ def render_fonts
640
+ link(rel: "preconnect", href: "https://fonts.googleapis.com")
641
+ link(href: "https://fonts.googleapis.com/css2?family=Inter&display=swap", rel: "stylesheet")
642
+ end
643
+ end
644
+ ```
645
+
646
+ ```javascript
647
+ theme: { fontFamily: { body: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'] } }
648
+ ```
649
+
650
+ ## Dark mode
651
+
652
+ `selector` strategy — toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
653
+
654
+ ---
655
+
656
+ # Part 8 — Design Tokens & `.pu-*` Component Classes
657
+
658
+ 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`.
659
+
660
+ ## Key tokens
661
+
662
+ | Token | Purpose |
663
+ |---|---|
664
+ | `--pu-body`, `--pu-surface`, `--pu-surface-alt`, `--pu-surface-raised`, `--pu-surface-overlay` | Backgrounds |
665
+ | `--pu-text`, `--pu-text-muted`, `--pu-text-subtle` | Text colors |
666
+ | `--pu-border`, `--pu-border-muted`, `--pu-border-strong` | Borders |
667
+ | `--pu-input-bg`, `--pu-input-border`, `--pu-input-focus-ring`, `--pu-input-placeholder` | Form inputs |
668
+ | `--pu-card-bg`, `--pu-card-border` | Cards |
669
+ | `--pu-shadow-sm/md/lg` | Shadows |
670
+ | `--pu-radius-sm/md/lg/xl/full` | Border radius |
671
+ | `--pu-space-xs/sm/md/lg/xl` | Spacing |
672
+ | `--pu-transition-fast/normal/slow` | Transitions |
673
+
674
+ 🚨 Tokens are CSS variables — use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
675
+
676
+ ## Customizing tokens
677
+
678
+ ```css
679
+ :root {
680
+ --pu-surface: #fafafa;
681
+ --pu-border: #d1d5db;
682
+ }
683
+
684
+ .dark {
685
+ --pu-surface: #111827;
686
+ --pu-border: #374151;
687
+ }
688
+ ```
689
+
690
+ ## `.pu-*` component classes
691
+
692
+ Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
693
+
694
+ ### Buttons
695
+
696
+ ```
697
+ .pu-btn (base)
698
+ .pu-btn-md / -sm / -xs (size)
699
+ .pu-btn-primary / -secondary / -danger / -success / -warning / -info / -accent
700
+ .pu-btn-ghost / -outline
701
+ .pu-btn-soft-primary / -soft-danger / ...
702
+ ```
703
+
704
+ ```erb
705
+ <%= form.submit "Save", class: "pu-btn pu-btn-md pu-btn-primary" %>
706
+ ```
707
+
708
+ ### Inputs, cards, panels, tables, toolbars, empty states
709
+
710
+ ```
711
+ .pu-input / -invalid / -valid .pu-label / -required .pu-hint / .pu-error .pu-checkbox
712
+ .pu-card / .pu-card-body
713
+ .pu-panel-header / -title / -description
714
+ .pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
715
+ .pu-toolbar / -text / -actions
716
+ .pu-empty-state / -icon / -title / -description
717
+ ```
718
+
719
+ ### Ruby constants
720
+
721
+ ```ruby
722
+ ComponentClasses::Button.classes(variant: :primary, size: :default, soft: false)
723
+ # => "pu-btn pu-btn-md pu-btn-primary"
724
+
725
+ ComponentClasses::Form::INPUT # "pu-input"
726
+ ComponentClasses::Form::LABEL # "pu-label"
727
+ ComponentClasses::Table::WRAPPER # "pu-table-wrapper"
728
+ ComponentClasses::Card::BASE # "pu-card"
729
+ ```
730
+
731
+ ## Migration from hardcoded classes
732
+
733
+ | Old | New |
734
+ |---|---|
735
+ | `text-gray-900 dark:text-white` | `text-[var(--pu-text)]` |
736
+ | `text-gray-500 dark:text-gray-400` | `text-[var(--pu-text-muted)]` |
737
+ | `bg-gray-50 dark:bg-gray-700` | `bg-[var(--pu-surface)]` |
738
+ | `border-gray-300 dark:border-gray-600` | `border-[var(--pu-border)]` |
739
+ | Long input class chain | `pu-input` |
740
+ | Long button class chain | `pu-btn pu-btn-md pu-btn-primary` |
741
+
742
+ ## `tokens` and `classes` helpers
743
+
744
+ For conditional class composition in Phlex components:
745
+
746
+ ```ruby
747
+ class MyComponent < Plutonium::UI::Component::Base
748
+ def initialize(active:) = @active = active
749
+
750
+ def view_template
751
+ div(class: tokens(
752
+ "base-class",
753
+ active?: "bg-primary-500 text-white",
754
+ inactive?: "bg-gray-200 text-gray-700"
755
+ )) { "Content" }
756
+ end
757
+
758
+ private
759
+
760
+ def active? = @active
761
+ def inactive? = !@active
762
+ end
763
+
764
+ # `classes` returns the class as a kwarg-friendly hash
765
+ div(**classes("p-4 rounded", active?: "ring-2"))
766
+ # => <div class="p-4 rounded ring-2">
767
+
768
+ # Then/else branches
769
+ tokens("base", condition?: {then: "if-true", else: "if-false"})
770
+ ```
771
+
772
+ ---
773
+
774
+ # Part 9 — Phlexi Component Themes
775
+
776
+ Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults — never replace wholesale, always `super.merge(...)`.
777
+
778
+ ## Form theme
779
+
780
+ ```ruby
781
+ class PostDefinition < ResourceDefinition
782
+ class Form < Form
783
+ class Theme < Plutonium::UI::Form::Theme
784
+ def self.theme
785
+ super.merge(
786
+ base: "bg-[var(--pu-card-bg)] shadow-md rounded-lg p-6",
787
+ fields_wrapper: "grid grid-cols-2 gap-6",
788
+ actions_wrapper: "flex justify-end mt-6 space-x-2",
789
+ label: "block mb-2 text-base font-bold",
790
+ input: "pu-input",
791
+ error: "pu-error",
792
+ button: "pu-btn pu-btn-md pu-btn-primary"
793
+ )
794
+ end
795
+ end
796
+ end
797
+ end
798
+ ```
799
+
800
+ ### Form theme keys
801
+
802
+ `base`, `fields_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`.
803
+
804
+ ## Display theme
805
+
806
+ ```ruby
807
+ class Display < Display
808
+ class Theme < Plutonium::UI::Display::Theme
809
+ def self.theme
810
+ super.merge(
811
+ fields_wrapper: "grid grid-cols-3 gap-8",
812
+ label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
813
+ string: "text-lg text-[var(--pu-text)]",
814
+ markdown: "prose dark:prose-invert max-w-none"
815
+ )
816
+ end
817
+ end
818
+ end
819
+ ```
820
+
821
+ ### Display theme keys
822
+
823
+ `fields_wrapper`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`.
824
+
825
+ ## Table theme
826
+
827
+ ```ruby
828
+ class Table < Table
829
+ class Theme < Plutonium::UI::Table::Theme
830
+ def self.theme
831
+ super.merge(
832
+ wrapper: "pu-table-wrapper",
833
+ base: "pu-table",
834
+ header: "pu-table-header",
835
+ header_cell: "pu-table-header-cell",
836
+ body_row: "pu-table-body-row",
837
+ body_cell: "pu-table-body-cell"
838
+ )
839
+ end
840
+ end
841
+ end
842
+ ```
843
+
844
+ ### Table theme keys
845
+
846
+ `wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
847
+
848
+ ---
849
+
850
+ ## Available context
851
+
852
+ 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).
853
+
854
+ In Phlex components, Rails helpers are accessed via the `helpers` proxy:
855
+
856
+ ```ruby
857
+ class MyComponent < Plutonium::UI::Component::Base
858
+ def view_template
859
+ helpers.link_to(...)
860
+ helpers.number_to_currency(...)
861
+ end
862
+ end
863
+ ```
864
+
865
+ ---
866
+
867
+ ## Portal-specific overrides
868
+
869
+ Each portal can override page classes independently. The portal definition inherits from the base definition, and its nested classes inherit from the base's nested classes:
870
+
871
+ ```ruby
872
+ class AdminPortal::PostDefinition < ::PostDefinition
873
+ class ShowPage < ShowPage # inherits from ::PostDefinition::ShowPage
874
+ def render_after_content
875
+ super
876
+ render AdminOnlySection.new(post: object)
877
+ end
878
+ end
879
+ end
880
+ ```
881
+
882
+ ---
883
+
884
+ ## Gotchas
885
+
886
+ - **Don't override `view_template` in pages** when a render hook fits — you lose breadcrumbs / header / DynaFrame behavior.
887
+ - **Always register Stimulus controllers.** Without `registerControllers(application)` the entire UI's interactive layer is dead.
888
+ - **Use `plutoniumTailwindConfig.merge`** — plain object merge drops Plutonium's defaults.
889
+ - **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
890
+ - **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
891
+ - **`render_actions` is mandatory in custom `form_template`** — otherwise no submit button.
892
+
893
+ ---
894
+
895
+ ## Related skills
896
+
897
+ - [[plutonium-resource]] — field/input/display config (`as:`, `condition:`, blocks); modal options for actions.
898
+ - [[plutonium-behavior]] — controller presentation hooks (`present_parent?`), available helpers (`resource_record!`, `current_scoped_entity`).
899
+ - [[plutonium-app]] — `pu:eject:layout`, `pu:eject:shell`, portal package overrides.
900
+ - [[plutonium-tenancy]] — `permitted_associations` drives the show-page tablist.