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.
Installs into .claude/skills of the current project.
Are you the author of Plutonium Ui?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/radioactive-labs-plutonium-ui)
---
name: plutonium-ui
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.
---
# Plutonium UI — Pages, Forms, Components, Assets
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.
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]].
## 🚨 Critical (read first)
- **Override via nested classes in the definition.** `class ShowPage < ShowPage; end`, `class Form < Form; end`. Don't replace the entire view layer.
- **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.
- **All pages inherit `DynaFrameContent`** — turbo-frame requests render only the content. Don't fight it; modals and frame nav "just work".
- **Custom components inherit `Plutonium::UI::Component::Base`** — gives you the component kit (`PageHeader`, `Panel`, `Block`), resource helpers, and the `helpers` proxy for Rails helpers.
- **`render_actions` is mandatory in custom `form_template`** — without it, the form has no submit button.
- **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.
- **Use `plutoniumTailwindConfig.merge`** when extending Tailwind theme — plain object merge drops Plutonium's defaults.
- **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
- **Configure inputs in the definition; render them with `render_resource_field` in the form.** Don't reimplement field widgets from scratch.
---
## 🛑 Before you customize UI: pick the lightest seam (ASK — don't infer)
Plutonium gives you escalating levels of customization. Reach for the **lightest that fits** — jumping to `view_template` or an eject loses breadcrumbs/header/DynaFrame behavior and saddles you with maintaining copied markup forever.
| You want to… | Reach for | **NOT** |
|---|---|---|
| Add a banner / extra section to one page | nested page class + a **render hook** (`render_before_content`, `render_after_content`) | overriding `view_template` |
| Re-arrange the record's fields | a custom `Display` (`display_template`) | a hand-rolled `Form`/`view_template` |
| Group form fields into sections | the `form_layout` DSL in the definition | a `Form` subclass |
| Recolor / rebrand | `plutoniumTailwindConfig.merge(...)` in `tailwind.config.js` | a plain object spread (drops Plutonium's defaults) |
| Replace whole chrome per-portal | `pu:eject:shell` / `pu:eject:layout` — **last resort, you own it after** | ejecting when a hook/class/theme would do |
Then: is the change **global** (base `PostDefinition`) or **per-portal** (`AdminPortal::PostDefinition`)? And does it touch CSS/JS (⇒ the asset toolchain must be set up)? Don't guess field names or the banner copy — read the definition.
## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
You have file access — **inspect**; don't ask the user to describe their app.
| Check | How | Why it matters |
|---|---|---|
| Custom page/Display already exists | Read the definition for nested `ShowPage`/`Display`/`Form` | Re-declaring clobbers an existing override |
| Global vs per-portal | Is it `::PostDefinition` or `AdminPortal::PostDefinition`? | Override the right one |
| Real field names | Read the model/definition | Don't invent fields in `display_template` |
| Asset toolchain wired | `ls tailwind.config.js`; the CSS `@import`; has `pu:core:assets` run? | Brand/CSS edits won't compile otherwise |
| Stimulus registered | grep `app/javascript/controllers/index.js` for `registerControllers` | Else the interactive layer is dead |
| Build watcher | Is `yarn dev` running? (`PLUTONIUM_DEV=1` when working on the gem) | CSS/JS changes need the rebuild |
Inspect with your own tools **before** proposing code.
## 🛠 Use the generator — and prefer hooks over ejecting
| Task | How | Verify first |
|---|---|---|
| Custom Tailwind + Stimulus toolchain | `pu:core:assets` | Not already run |
| Eject chrome (header/sidebar/layout) | `pu:eject:shell` / `pu:eject:layout --dest=portal` | A render hook / nested class / theme genuinely can't do it (last resort) |
---
# Part 1 — Pages
Each definition has nested page classes. Override the ones you need to customize:
```ruby
class PostDefinition < ResourceDefinition
class IndexPage < IndexPage; end
class ShowPage < ShowPage; end
class NewPage < NewPage; end
class EditPage < EditPage; end
class InteractiveActionPage < InteractiveActionPage; end
class Form < Form; end
class Table < Table; end
class Display < Display; end
end
```
Architecture:
```
Definition
├── IndexPage → renders Table
├── ShowPage → renders Display
├── NewPage → renders Form
├── EditPage → renders Form
└── InteractiveActionPage → renders Form
```
## Page titles, descriptions, breadcrumbs
```ruby
class PostDefinition < ResourceDefinition
index_page_title "Blog Posts"
index_page_description "Manage all published articles"
show_page_title "Article Details"
show_page_title -> { "#{current_record!.title} — Details" } # dynamic
breadcrumbs true # global default
index_page_breadcrumbs false # per-page override
end
```
## Page hooks (preferred over `view_template`)
Every page inherits these:
| Hook | Position |
|---|---|
| `render_before_header` / `_after_header` | wraps the entire header section |
| `render_before_breadcrumbs` / `_after_breadcrumbs` | around the breadcrumb row |
| `render_before_page_header` / `_after_page_header` | around the title + actions block |
| `render_before_toolbar` / `_after_toolbar` | around the action toolbar |
| `render_before_content` / `_after_content` | around main content |
| `render_before_footer` / `_after_footer` | around footer/pagination |
Example:
```ruby
class ShowPage < ShowPage
private
def page_title
"#{object.title} — #{object.author.name}"
end
def render_before_content
div(class: "alert alert-info") do
plain "This post has #{object.comments.count} comments"
end
end
def render_after_content
render RelatedPostsComponent.new(post: object)
end
def render_toolbar
div(class: "flex gap-2") do
button(class: "pu-btn pu-btn-md pu-btn-secondary") { "Preview" }
button(class: "pu-btn pu-btn-md pu-btn-primary") { "Publish" }
end
end
end
```
## Custom ERB views (full replacement)
For total control, drop the page class entirely with an ERB view at the controller path:
```
app/views/posts/show.html.erb
packages/admin_portal/app/views/admin_portal/posts/show.html.erb
```
The default view simply renders the page class:
```erb
<%= render current_definition.show_page_class.new %>
```
Mix: keep the default and add chrome around it:
```erb
<div class="announcement-banner">Special announcement</div>
<%= render current_definition.show_page_class.new %>
<div class="related"><%= render partial: "related" %></div>
```
## Detecting render context
| Helper | True when |
|---|---|
| `in_frame?` | Request targets a turbo-frame |
| `in_modal?` | Request renders inside a modal/slideover |
Use to pin action strips, omit nav chrome, or swap layouts.
---
# Part 2 — Forms
Forms are built on [Phlexi::Form](https://github.com/radioactive-labs/phlexi-form). Hierarchy:
```
Phlexi::Form::Base
└── Plutonium::UI::Form::Base
├── Plutonium::UI::Form::Resource # CRUD
│ └── Plutonium::UI::Form::Interaction # action forms
└── Plutonium::UI::Form::Query # search/filter
```
## Override the form
```ruby
class PostDefinition < ResourceDefinition
class Form < Form
def form_template
render_fields # render every permitted field
render_actions # submit buttons — REQUIRED
end
end
end
```
### Form methods
| Method | Purpose |
|---|---|
| `form_template` | Main override point |
| `render_fields` | All permitted fields in default layout |
| `render_resource_field(name)` | One field, using the definition's `input` config |
| `render_actions` | Submit + secondary buttons |
| `fields_wrapper { ... }` | Grid wrapper div (themeable) |
| `actions_wrapper { ... }` | Button wrapper div (themeable) |
| `object` / `record` | The form record |
| `resource_fields` | Array of permitted field names |
| `resource_definition` | The definition instance |
## Custom layouts
### Sectioned — prefer the `form_layout` / `display_layout` DSL
**For grouping fields into sections, don't hand-roll a `Form` or `Display` subclass — declare `form_layout` (forms) or `display_layout` (show page) in the definition.** They handle headings, descriptions, collapsible `<details>`, `condition:`-based visibility, and **auto-drop sections that resolve to zero fields** (so `+ New` doesn't sprout empty headings). `columns:` is form-only — `display_layout` raises on it. See [[plutonium-resource]] › Form Layout / Display Layout.
**Each section renders as its own card** (`Plutonium::UI::Block`), so a sectioned form or show page has **no single outer card** — the form drops its own `pu-card` and the sections supply it. Don't add a card wrapper of your own around them.
```ruby
class PostDefinition < ResourceDefinition
form_layout do
section :basic, :title, :slug, label: "Basic"
section :publishing, :published_at, :category, label: "Publishing", columns: 2
ungrouped label: "Other"
end
end
```
Only drop to a custom `Form#form_template` when you need layout the DSL can't express (arbitrary wrapper markup, interleaved non-field content). The escape hatch:
```ruby
class Form < Form
def form_template
section("Basic") do
render_resource_field :title
render_resource_field :slug
end
section("Publishing") do
render_resource_field :published_at
render_resource_field :category
end
render_actions
end
private
def section(title, &)
div(class: "mb-8") do
h3(class: "text-lg font-semibold mb-4 text-[var(--pu-text)]") { title }
fields_wrapper(&)
end
end
end
```
A hand-rolled `section` like this renders its heading unconditionally — that's exactly the empty-heading problem `form_layout` avoids. If you must hand-roll, guard empty sections yourself.
### Two-column
```ruby
def form_template
div(class: "grid grid-cols-1 lg:grid-cols-3 gap-6") do
div(class: "lg:col-span-2") do
fields_wrapper do
render_resource_field :title
render_resource_field :content
end
end
div(class: "space-y-4") do
Panel do
h4(class: "font-medium mb-2") { "Settings" }
render_resource_field :status
render_resource_field :visibility
end
end
end
render_actions
end
```
## Field builder (`field(:foo).input_tag`)
`render_resource_field` uses the input config from the definition. For ad-hoc rendering, use `field(...)` directly:
```ruby
render field(:title).wrapped { |f| f.input_tag } # wrapped: label + hint + errors
render field(:title).input_tag # bare element only
render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
```
### Tag methods
| Tag | Input |
|---|---|
| `input_tag` | text (auto-detected type) |
| `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `tel_tag`, `hidden_tag` | standard HTML inputs |
| `checkbox_tag`, `select_tag`, `radio_button_tag` | standard |
| `toggle_tag` / `switch_tag` | switch-styled boolean (`as: :toggle` / `:switch`) — default for boolean columns; `as: :boolean` for a plain checkbox |
### Plutonium-enhanced tags
| Tag | Component |
|---|---|
| `easymde_tag` / `markdown_tag` | EasyMDE markdown editor |
| `slim_select_tag` | Slim Select |
| `flatpickr_tag` | Flatpickr date/time picker |
| `phone_tag` / `int_tel_input_tag` | intl-tel-input phone field |
| `uppy_tag` / `file_tag` | Uppy file upload |
| `secure_association_tag` | Association with policy-checked options |
| `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
| `key_value_store_tag` | Key/value pairs editor |
```ruby
render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today, enable_time: true) }
render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes) }
```
### Password & secret fields
`password_tag` masks the stored value — it **never emits the secret into the DOM**. A stored secret renders a sentinel; an untouched submit keeps it, an edit-to-new-value then failed re-render comes back blank + `required` (re-type — secrets are never echoed back), a *cleared* field comes back blank but **not** `required` (the clear may be intentional), a deliberately emptied field clears it (clear-by-blank), a typed value sets it. The sentinel is guarded by the `password-sentinel` Stimulus controller — the first edit (incl. **backspace**) wipes the whole field so a partial edit can't corrupt it.
Auto-detected by name: `password`/`token`/`salt`, `encrypted_*`, `*_password`/`*_digest`/`*_hash`/`*_token`/`*_key`/`*_salt`, or any name containing `secret`. A convenience, **not** a guarantee — odd-named secrets (`recovery_phrase`, `pin`) still leak unless masked explicitly.
```ruby
field :api_token, as: :string # opt OUT — show a readable value (token to copy, checksum)
field :recovery_phrase, as: :password # opt IN — mask a secret the heuristic misses
```
## Submit buttons
Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
Control the secondary button via the definition:
```ruby
class PostDefinition < ResourceDefinition
submit_and_continue false # nil (default — auto), true (always show), false (always hide)
end
```
Singular resources auto-hide it.
Custom action strip:
```ruby
def render_actions
actions_wrapper do
a(href: resource_url_for(resource_class), class: "pu-btn pu-btn-md pu-btn-secondary") { "Cancel" }
button(type: :submit, name: "draft", value: "1", class: "pu-btn pu-btn-md") { "Save Draft" }
render submit_button
end
end
```
## Pre-submit, nested inputs, interaction forms
These all live in the definition layer:
- **Pre-submit / dynamic forms** — see [[plutonium-resource]] › Dynamic Forms.
- **Nested inputs** (`nested_input :variants`) — association-backed inline forms; see [[plutonium-resource]] › Nested Inputs.
- **Structured inputs** (`structured_input :payload`, `structured_input :rows, repeat: 5`) — classless hash / array-of-hashes into a JSON column (resources) or an attribute (interactions); reuses the repeater chrome. See [[plutonium-resource]] › Structured Inputs.
- **Interaction forms** — interactions define their own `attribute` / `input` and inherit `Plutonium::UI::Form::Interaction`; see [[plutonium-behavior]] › Interactions.
---
# Part 3 — Display & Table
## Custom Display
```ruby
class PostDefinition < ResourceDefinition
class Display < Display
def display_template
div(class: "bg-gradient-to-r from-primary-500 to-secondary-600 p-8 rounded-lg text-white mb-6") do
h1(class: "text-3xl font-bold") { object.title }
p(class: "mt-2 opacity-90") { object.excerpt }
end
# `fields_wrapper` is ALREADY a card (it renders a Block internally),
# so do not wrap it in another one — that stacks two cards and doubles
# the border and shadow.
fields_wrapper do
render_resource_field :author
render_resource_field :published_at
end
# `Block` is the card primitive: use it for your own content.
Block do
div(class: "prose max-w-none") { raw object.content }
end
render_associations if present_associations?
end
end
end
```
| Method | Purpose |
|---|---|
| `render_fields` | All permitted fields |
| `render_resource_field(name)` | One field |
| `render_associations` | Association tabs (driven by `permitted_associations` — see [[plutonium-behavior]]) |
| `render_before_fields` / `render_after_fields` | Hooks around the fields — **Details tab only** |
| `object` | The record |
| `resource_fields`, `resource_associations` | Permitted lists |
### Details-tab-only content
To add a banner or extra section that shows on the **Details** tab and not the association tabs, override `render_before_fields` / `render_after_fields` on the **Display** — not the ShowPage. The page-level `render_before_content` / `render_after_content` hooks wrap the whole content block, and the tablist lives inside it, so anything added there shows on every tab.
```ruby
class PostDefinition < ResourceDefinition
class Display < Display
private
def render_before_fields
div(class: "pu-card pu-card-body mb-4") { plain "Only on the Details tab" }
end
end
end
```
Both hooks are no-ops by default. `render_fields` is the Details tab body when the record has associations and the entire display when it doesn't, so the hooks fire in the Details context either way.
## Custom Table
```ruby
class PostDefinition < ResourceDefinition
class Table < Table
def view_template
render_toolbar
render_scopes_pills
if collection.empty?
render_empty_card
else
# Replace the table with a card grid
div(class: "grid grid-cols-3 gap-4") do
collection.each { |post| render PostCardComponent.new(post:) }
end
end
render_footer
end
end
end
```
| Method | Purpose |
|---|---|
| `render_toolbar`, `render_scopes_pills`, `render_filter_pills`, `render_bulk_actions_toolbar` | Toolbar pieces |
| `render_table` | Default table |
| `render_empty_card` | Empty state |
| `render_footer` | Pagination |
| `collection` | Paginated records |
| `resource_fields` | Column field names |
---
## Drag-to-Reorder Affordance (`position_on`)
When a definition declares `position_on` (see [[plutonium-resource]]) the index **table**, the **card grid**, and **nested association tables** render a drag grip. Configuration is entirely in the definition — there is no UI-layer switch.
**What renders where:**
| Surface | Draggable element | Grip placement | Axis |
|---|---|---|---|
| Index / nested table | the **grip only**, never the `<tr>` | inside the first cell's existing left padding (content does not shift) | vertical |
| Card grid | the **grip only** | floated over the card's top-left corner | horizontal, wrap-aware |
| Kanban board | the **whole card** | — | both (cross-column) |
🚨 **Never make a `<tr>` draggable.** Two silent regressions: `draggable="true"` disables text selection inside the element in every major browser (you lose copy-a-cell-value), and it fights `row_click_controller` — a drag that starts and ends in place still fires a click and navigates the user away. Kanban keeps whole-card dragging because neither applies to a kanban card; a **grid** card gets a grip because it *does* carry a row-click show affordance.
**Enabled state.** The grip is live only while the collection is sorted **ascending, by the position attribute, and nothing else**. Otherwise "drop me between these two rows" describes nothing. Under a foreign sort the Stimulus controller isn't attached at all and the grip renders as a **link that applies the position sort** — the disabled state is the way out of the disabled state, which is why `position_on` registers `sort <attr>`. Per record, the grip also requires `reposition?`.
**DOM contract** (relevant if you eject a table/grid or write a custom collection component):
```
wrapper data-controller="positioned"
data-positioned-url-template-value="/things/__ID__/reposition"
data-positioned-axis-value="horizontal" # grid only
row/card data-positioned-row-id="<id>" # single source of truth for the record id
grip data-positioned-grip # a real <button> — tabbable
```
The URL template is built off `current_page_path` (not `request.path`) so a post-rebalance re-render doesn't wire subsequent drops to `/things/5/reposition`. The controller POSTs `{prev_id, next_id, to_index}` plus `window.location.search` — the query string is load-bearing, since the endpoint re-renders through the ordinary index pipeline.
**Accessibility.** Focus the grip and use <kbd>↑</kbd>/<kbd>↓</kbd> — deliberately linear even on a wrapped grid, since one position attribute stores a 1-D order. Focus is restored onto the same record's grip after a stream replaces the collection. ⚠️ Native HTML5 drag does **not** fire on touch devices (inherited from kanban); there is no automatic fallback.
Components: `lib/plutonium/ui/table/components/drag_handle.rb`, `lib/plutonium/ui/component/positionable.rb`, `src/js/controllers/positioned_controller.js`. Reference: `docs/reference/resource/positioning.md`.
---
# Part 4 — Component Kit & Custom Components
## Built-in shorthand kit
Inside any `Plutonium::UI::Component::Base` (or any page/form/display):
```ruby
PageHeader(title: "Dashboard", description: "...", actions: [...])
Panel(class: "mt-4") { p { "Content" } }
Block { TabList(items: tabs) }
Avatar(user) # profile image: src → Navii fallback → icon
EmptyCard("No items found")
ActionButton(action, url: "/posts/new")
DynaFrameHost(src: "/some/path", loading: :lazy)
DynaFrameContent(content) { |frame| frame.render_content }
TableSearchBar()
TableScopesBar()
TableInfo(pagy)
TablePagination(pagy)
Breadcrumbs()
```
## Avatar
`Avatar(subject = nil, src: nil, size: :md, alt: nil, **attrs)` — profile image with a deterministic [Navii](https://navii.dev) fallback. Registered in the kit.
```ruby
Avatar(user) # Navii fallback seeded from the record
Avatar(user, src: :avatar) # user.avatar if present, else Navii fallback
Avatar(user, src: user.avatar) # pass the attachment/uploader/URL directly
Avatar("acme-team") # String subject = deterministic seed
Avatar("https://.../p.png") # URL-shaped subject is shown as the image
Avatar(src: avatar_url) # bare image, no subject/fallback
```
- **subject** (positional): record → PII-free hashed seed + default `alt` (display name); String → seed. A URL-shaped String (`http(s)://…` or `/…`) is routed to `src` (shown as the image), not used as a seed.
- **src**: a Symbol is sent to the subject (`:avatar` → `subject.avatar`, a **contract** — raises if absent); otherwise an ActiveStorage attachment, active_shrine/Shrine uploader, or URL string. ActiveStorage resolves via `helpers.url_for`; everything else via its own `#url`.
- **size**: `:xs 24 / :sm 32 / :md 40 / :lg 48 / :xl 64`, or a raw Integer.
- **Privacy**: the value sent to Navii is **always** a SHA256 hash — no ids, emails, or seed strings leave the app. Deterministic per subject.
- **Resolution order**: resolved `src` → Navii (from subject) → generic user icon.
- **Config**: `config.navii_host_url` (default `https://api.navii.dev`); the component appends `/avatar/:seed`.
🚨 Ejected shells: `Avatar` only shows a Navii avatar when `NavUser` is passed `record:`. The gem's `_resource_header.html.erb` passes `record: (current_user if current_user.respond_to?(:id))`; portals that **ejected** the header before this must re-eject (`rails g pu:eject:shell --dest=<portal>`) or add the `record:` line, otherwise they keep the icon fallback. Pass a record only — a String `current_user` (e.g. a guest) would otherwise be seeded as a literal identity.
## Translating component text
Every `Plutonium::UI::Component::Base` subclass (pages included) has a protected `t(key, **opts)` that reads Rails I18n with a full key; components that subclass Phlexi classes call `Plutonium::Translation.t`. Put new user-facing text in a locale file, never a literal:
```ruby
def view_template
button { t("my_app.cards.expand") }
span { t("my_app.cards.more", count: hidden_count) } # one:/other: in YAML
end
```
Rules: one key per sentence with `%{name}` placeholders (never concatenate fragments around a value), `count:` for plurals, no `.downcase`/`.pluralize` on translated nouns. The gem's own keys live under `plutonium.*` in its `config/locales/en/*.yml` and any can be overridden from the app.
Stimulus controllers bundled with Plutonium read `plutonium.js.*` from a `<meta name="pu-i18n">` JSON blob the layout renders; host JS can call `window.Plutonium.t("plutonium.js.turbo_confirm.confirm")`. Library locales (Slim Select, flatpickr, Uppy, intl-tel-input) pass through `plutonium.js.libraries.*`. See `docs/reference/i18n.md`.
## Custom Phlex components
```ruby
class PostCardComponent < Plutonium::UI::Component::Base
def initialize(post:)
@post = post
end
def view_template
div(class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] p-4") do
h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
a(href: resource_url_for(@post), class: "text-primary-600") { "Read more" }
end
end
end
```
Use in a definition. A component with its **own constructor** (like the one above)
must use the **block form** — you build it:
```ruby
display :card do |field|
PostCardComponent.new(post: field.object)
end
display :metrics do |field|
MetricsChartComponent.new(data: field.value)
end
```
🚨 `as: SomeComponent` takes a **field component**, NOT a keyword-argument
component: Plutonium constructs it as `SomeComponent.new(field, **attributes)`, so
`display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
arguments`. A field component subclasses the Phlexi base for its surface and reads
`field` (`field.value`, `field.object`, `field.dom`, `attributes`):
```ruby
class ColorPickerComponent < Phlexi::Form::Components::Base
include Phlexi::Form::Components::Concerns::HandlesInput # name/id/value plumbing
include Plutonium::UI::Component::Behaviour # optional: kit + resource helpers
def view_template
input(**attributes, type: "color", value: field.value)
end
end
class ChartComponent < Phlexi::Display::Components::Base
def view_template
div(class: "h-40", data: {controller: "chart", chart_series_value: field.value.to_json})
end
end
```
```ruby
input :color, as: ColorPickerComponent # every surface: form + filter panel
display :chart, as: ChartComponent # every surface: show + index column
```
## `DynaFrameContent` pattern
Enables frame-aware rendering: regular requests get the full page (header + content + footer); turbo-frame requests get only the content inside the frame.
```ruby
def view_template(&block)
DynaFrameContent(page_content(block)) do |frame|
render_header # skipped for frame requests
frame.render_content # always rendered
render_footer # skipped for frame requests
end
end
```
All pages inherit this. Modals and frame navigation work without special handling.
---
# Part 5 — Modals, Slideovers, Tabs
## Modal/slideover for `:new` / `:edit` + interactive actions
```ruby
class PostDefinition < ResourceDefinition
modal :slideover # default — slide-in panel from the right
# modal :centered # centered dialog
# modal :centered, size: :lg # centered, wider container
# modal false # full standalone page
end
```
Drives both framework `:new` / `:edit` and every interactive action on the definition. `size:` accepts `:sm`, `:md` (default), `:lg`, `:xl`, `:auto` (hugs content), or `:full`. Per-action `modal:` / `size:` overrides win. See [[plutonium-resource]] › Action Options.
## Tabs on the show page
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.
If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
---
# Part 6 — Layout (Chrome) & Eject
## Shell
```ruby
Plutonium.configure do |config|
config.shell = :modern # default — topbar + icon rail
# config.shell = :plain # topbar, no icon rail (whole app rail-less)
# config.shell = :classic # legacy header + sidebar (only when upgrading)
end
```
`:plain` keeps the Topbar but drops the icon rail. **Shell resolves global → engine → controller**, each overriding the one above (`nil` falls through); read it with `controller.shell`:
```ruby
config.shell = :plain # 1. global default
# 2. per-engine — inside the engine's config.after_initialize (with scope_to_entity)
class CustomerPortal::Engine
config.after_initialize { shell :plain }
end
class DashboardController; shell :modern; end # 3. per-controller (overrides engine/global)
```
`shell` takes a symbol so the class body works too, but the generated engine already has a `config.after_initialize` block (home of `scope_to_entity`) — keep it there for consistency.
Alongside `shell`, the controller-only `rail` DSL flips just the rail (inherited `class_attribute`, so a portal opts in/out once in its concern) — `rail false` / `rail true`; `rail nil` (default) inherits the resolved shell, `rail?` reads the resolved value:
```ruby
module CustomerPortal::Concerns::Controller
extend ActiveSupport::Concern
included { rail false } # entire portal rail-less
end
```
Stable CSS hooks for rail-less overrides: `pu-topbar` (Topbar nav), `pu-sticky-footer` (form footer), and the `html.pu-no-rail` root class. A built-in rule cancels the desktop rail inset on the first two under `html.pu-no-rail`.
## Eject the chrome for per-portal customization
```bash
rails generate pu:eject:shell --dest=admin_portal
rails generate pu:eject:layout
```
These copy `_resource_header.html.erb`, `_resource_sidebar.html.erb`, and `layouts/resource.html.erb` into the portal so you can edit them directly.
## Navigation menu items
The sidebar/icon-rail menu is built with `Phlexi::Menu::Builder` in `_resource_sidebar.html.erb`. Extra options on `item` are spread onto the rendered `<a>`, so an item can opt into `target` / `rel` / `data:` / `aria:`:
```ruby
m.item "Inbox", url: inbox_path, icon: Icon, target: "_blank", rel: "noopener", data: {turbo_frame: "_top"}
```
Applies to both shells (icon-rail leaf, parent flyout trigger, and flyout children; classic sidebar). Framework `class`/`data`/`aria` win on conflict — `class:` merges with the base classes, and on a parent trigger `data:`/`aria:` merge with the flyout wiring so options can't break the toggle. Phlexi's reserved `:active` key is never emitted as an attribute.
## Custom layout class (Phlex)
```ruby
module AdminPortal
class ResourceLayout < Plutonium::UI::Layout::ResourceLayout
private
def body_attributes = {class: "antialiased bg-[var(--pu-body)]"}
def render_before_main
super
render AnnouncementBanner.new if Announcement.active.any?
end
def render_body_scripts
super
script(src: "/custom-analytics.js")
end
end
end
```
| Hook | Position |
|---|---|
| `render_before_main` / `_after_main` | around the main content area |
| `render_before_content` / `_after_content` | inside main, around content |
| `render_flash` | flash messages |
| `render_head`, `render_title`, `render_metatags`, `render_assets` | head section |
| `render_body_scripts` | end-of-body scripts |
| `render_fonts` | font links |
---
# Part 7 — Assets, Tailwind, Stimulus
## Asset configuration
```ruby
# config/initializers/plutonium.rb
Plutonium.configure do |config|
config.load_defaults 1.0
config.assets.stylesheet = "application"
config.assets.script = "application"
config.assets.logo = "my_logo.png"
config.assets.favicon = "my_favicon.ico"
end
```
## Generator
```bash
rails generate pu:core:assets
```
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.
Packages install with the app's package manager, detected from the lockfile (`bun.lock`/`bun.lockb` → bun, `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm, `yarn.lock` → yarn), else the first of bun, yarn, pnpm, npm on PATH. A yarn app stays yarn even with bun installed. Do not run `yarn add` by hand in a bun app: that leaves two lockfiles. Yarn 2+ needs `nodeLinker: node-modules` in `.yarnrc.yml` (the generator writes it); Tailwind's PostCSS plugin does not load under Plug'n'Play.
## Tailwind config (generated)
```javascript
// tailwind.config.js
const { execSync } = require('child_process');
const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
const plutoniumTailwindConfig = require(`${plutoniumGemPath}/tailwind.options.js`);
module.exports = {
darkMode: plutoniumTailwindConfig.darkMode, // selector
plugins: [].concat(plutoniumTailwindConfig.plugins),
theme: plutoniumTailwindConfig.merge(
plutoniumTailwindConfig.theme,
{ /* your overrides */ },
),
content: [
`${__dirname}/app/**/*.{erb,haml,html,slim,rb}`,
`${__dirname}/app/javascript/**/*.js`,
`${__dirname}/packages/**/app/**/*.{erb,haml,html,slim,rb}`,
].concat(plutoniumTailwindConfig.content),
};
```
🚨 Always use `plutoniumTailwindConfig.merge(...)`. A plain spread drops Plutonium's defaults.
## Default color palette
| Color | Use |
|---|---|
| `primary` | Brand primary (turquoise default) |
| `secondary` | Brand secondary (navy default) |
| `success` | Success state (green) |
| `info` | Informational (blue) |
| `warning` | Warning (amber) |
| `danger` | Error (red) |
| `accent` | Highlight (coral pink) |
```javascript
theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
extend: {
colors: {
primary: { 50: '#eff6ff', 500: '#3b82f6', 900: '#1e3a8a' },
},
},
})
```
## CSS imports
```css
/* app/assets/stylesheets/application.tailwind.css */
@import "gem:plutonium/src/css/plutonium.css";
@import "tailwindcss";
@config '../../../tailwind.config.js';
/* your styles */
```
Plutonium CSS includes core utilities, EasyMDE, Slim Select, intl-tel-input, Flatpickr.
## Stimulus
```javascript
// app/javascript/controllers/index.js
import { application } from "./application"
import { registerControllers } from "@radioactive-labs/plutonium"
registerControllers(application)
// Your custom controllers...
import CustomController from "./custom_controller"
application.register("custom", CustomController)
```
Bundled controllers: `color-mode`, `form` (pre-submit), `nested-resource-form-fields`, `slim-select`, `flatpickr`, `easymde`, `kanban`, `positioned` (drag-to-reorder), `row-click`, plus various internal UI controllers.
Custom controller — standard Stimulus:
```javascript
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() { /* ... */ }
}
```
## Typography
Default font: Lato. Override:
```ruby
class MyLayout < Plutonium::UI::Layout::ResourceLayout
def render_fonts
link(rel: "preconnect", href: "https://fonts.googleapis.com")
link(href: "https://fonts.googleapis.com/css2?family=Inter&display=swap", rel: "stylesheet")
end
end
```
```javascript
theme: { fontFamily: { body: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'] } }
```
## Dark mode
`selector` strategy — toggle by adding/removing `dark` on `<html>`. The `color-mode` Stimulus controller handles it; Plutonium ships a switcher.
---
# Part 8 — Design Tokens & `.pu-*` Component Classes
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`.
## Key tokens
| Token | Purpose |
|---|---|
| `--pu-body`, `--pu-surface`, `--pu-surface-alt`, `--pu-surface-raised`, `--pu-surface-overlay` | Backgrounds |
| `--pu-text`, `--pu-text-muted`, `--pu-text-subtle` | Text colors |
| `--pu-border`, `--pu-border-muted`, `--pu-border-strong` | Borders |
| `--pu-input-bg`, `--pu-input-border`, `--pu-input-focus-ring`, `--pu-input-placeholder` | Form inputs |
| `--pu-card-bg`, `--pu-card-border` | Cards |
| `--pu-shadow-sm/md/lg` | Shadows |
| `--pu-radius-sm/md/lg/xl/full` | Border radius |
| `--pu-space-xs/sm/md/lg/xl` | Spacing |
| `--pu-transition-fast/normal/slow` | Transitions |
🚨 Tokens are CSS variables — use `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
## Customizing tokens
```css
:root {
--pu-surface: #fafafa;
--pu-border: #d1d5db;
}
.dark {
--pu-surface: #111827;
--pu-border: #374151;
}
```
🚨 **Mirror every `:root` override in `.dark`.** The app stylesheet loads after Plutonium's and `:root`/`.dark` have equal specificity, so a `:root`-only override beats Plutonium's `.dark` value even in dark mode — your light color ships into dark mode, often unreadably (e.g. translucent navy `--pu-text-subtle` is invisible on a dark surface). Every color token customized in `:root` MUST be re-asserted with a dark value in `.dark`.
## `.pu-*` component classes
Ready-to-use styled components in `src/css/components.css`. **Prefer these over hardcoded `gray-X/dark:gray-Y` pairs.**
### Buttons
```
.pu-btn (base)
.pu-btn-md / -sm / -xs (size)
.pu-btn-primary / -secondary / -danger / -success / -warning / -info / -accent
.pu-btn-ghost / -outline
.pu-btn-soft-primary / -soft-danger / ...
```
```erb
<%= form.submit "Save", class: "pu-btn pu-btn-md pu-btn-primary" %>
```
### Inputs, cards, panels, tables, toolbars, empty states
```
.pu-input / -invalid / -valid .pu-label / -required .pu-hint / .pu-error .pu-checkbox / .pu-toggle
.pu-badge / -neutral / -primary / -secondary / -success / -danger / -warning / -info / -accent
.pu-card / .pu-card-body
.pu-panel-header / -title / -description
.pu-table-wrapper / .pu-table / -header / -header-cell / -body-row / -body-row-selected / -body-cell / .pu-selection-cell
.pu-toolbar / -text / -actions
.pu-empty-state / -icon / -title / -description
```
### Ruby constants
```ruby
ComponentClasses::Button.classes(variant: :primary, size: :default, soft: false)
# => "pu-btn pu-btn-md pu-btn-primary"
ComponentClasses::Form::INPUT # "pu-input"
ComponentClasses::Form::LABEL # "pu-label"
ComponentClasses::Table::WRAPPER # "pu-table-wrapper"
ComponentClasses::Card::BASE # "pu-card"
```
## Migration from hardcoded classes
| Old | New |
|---|---|
| `text-gray-900 dark:text-white` | `text-[var(--pu-text)]` |
| `text-gray-500 dark:text-gray-400` | `text-[var(--pu-text-muted)]` |
| `bg-gray-50 dark:bg-gray-700` | `bg-[var(--pu-surface)]` |
| `border-gray-300 dark:border-gray-600` | `border-[var(--pu-border)]` |
| Long input class chain | `pu-input` |
| Long button class chain | `pu-btn pu-btn-md pu-btn-primary` |
## `tokens` and `classes` helpers
For conditional class composition in Phlex components:
```ruby
class MyComponent < Plutonium::UI::Component::Base
def initialize(active:) = @active = active
def view_template
div(class: tokens(
"base-class",
active?: "bg-primary-500 text-white",
inactive?: "bg-gray-200 text-gray-700"
)) { "Content" }
end
private
def active? = @active
def inactive? = !@active
end
# `classes` returns the class as a kwarg-friendly hash
div(**classes("p-4 rounded", active?: "ring-2"))
# => <div class="p-4 rounded ring-2">
# Then/else branches
tokens("base", condition?: {then: "if-true", else: "if-false"})
```
---
# Part 9 — Phlexi Component Themes
Themes are Ruby classes nested under a Form/Display/Table override. They merge into Plutonium's defaults — never replace wholesale, always `super.merge(...)`.
## Form theme
```ruby
class PostDefinition < ResourceDefinition
class Form < Form
class Theme < Plutonium::UI::Form::Theme
def self.theme
super.merge(
base: "bg-[var(--pu-card-bg)] shadow-md rounded-lg p-6",
fields_wrapper: "grid grid-cols-2 gap-6",
actions_wrapper: "flex justify-end mt-6 space-x-2",
label: "block mb-2 text-base font-bold",
input: "pu-input",
error: "pu-error",
button: "pu-btn pu-btn-md pu-btn-primary"
)
end
end
end
end
```
### Form theme keys
`base`, `sectioned_base`, `fields_wrapper`, `sections_wrapper`, `actions_wrapper`, `wrapper`, `inner_wrapper`, `label`, `invalid_label`, `valid_label`, `neutral_label`, `input`, `invalid_input`, `valid_input`, `neutral_input`, `hint`, `error`, `button`, `checkbox`, `select`, plus the shared section keys below.
`sectioned_base` replaces `base` when the definition declares a `form_layout` — the sections are cards, so the form itself stops being one.
⚠️ **Width is NOT a theme key.** It's configuration (`page_width` / `form_width` on the definition, `Plutonium.configuration.default_page_width` globally) and is appended by `Form::Resource`/`Page::Show`, so overriding `base` or `fields_wrapper` restyles a surface without silently pinning its width. See [[plutonium-resource]] › Page Width.
## Display theme
```ruby
class Display < Display
class Theme < Plutonium::UI::Display::Theme
def self.theme
super.merge(
fields_inner: "pu-card-body grid grid-cols-3 gap-8", # the GRID
label: "text-sm font-bold text-[var(--pu-text-muted)] mb-1",
string: "text-lg text-[var(--pu-text)]",
markdown: "prose dark:prose-invert max-w-none"
)
end
end
end
```
### Display theme keys
`fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`.
⚠️ **`fields_wrapper` is the CARD, `fields_inner` is the grid.** `fields_wrapper` is merged into a `Plutonium::UI::Block` (which supplies `pu-card` itself), so putting grid classes there styles the card, not the fields. Override `fields_inner` to change the unsectioned grid, and `section_grid` to change the grid inside a `display_layout` section.
### Section theme keys (forms *and* displays)
Section chrome is shared: `Plutonium::UI::Component::Section::DEFAULT_THEME` is merged into **both** `Form::Theme` and `Display::Theme`, so the two read identically by default while staying independently overridable.
`section_wrapper` (merged into the section's Block — Block already supplies `pu-card`), `section_header`, `section_summary` (the collapsible header row), `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`, plus `sections_wrapper` (the container that stacks sections).
## Table theme
```ruby
class Table < Table
class Theme < Plutonium::UI::Table::Theme
def self.theme
super.merge(
wrapper: "pu-table-wrapper",
base: "pu-table",
header: "pu-table-header",
header_cell: "pu-table-header-cell",
body_row: "pu-table-body-row",
body_cell: "pu-table-body-cell"
)
end
end
end
```
### Table theme keys
`wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
---
## Available context
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).
In Phlex components, Rails helpers are accessed via the `helpers` proxy:
```ruby
class MyComponent < Plutonium::UI::Component::Base
def view_template
helpers.link_to(...)
helpers.number_to_currency(...)
end
end
```
---
## Portal-specific overrides
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:
```ruby
class AdminPortal::PostDefinition < ::PostDefinition
class ShowPage < ShowPage # inherits from ::PostDefinition::ShowPage
def render_after_content
super
render AdminOnlySection.new(post: object)
end
end
end
```
---
## Gotchas
- **Don't override `view_template` in pages** when a render hook fits — you lose breadcrumbs / header / DynaFrame behavior.
- **Always register Stimulus controllers.** Without `registerControllers(application)` the entire UI's interactive layer is dead.
- **Use `plutoniumTailwindConfig.merge`** — plain object merge drops Plutonium's defaults.
- **Dark mode is `selector`, not `class`.** Toggle via `document.documentElement.classList.toggle('dark')`.
- **Tokens are CSS variables, not Tailwind keys** — `bg-[var(--pu-surface)]`, not `bg-pu-surface`.
- **`render_actions` is mandatory in custom `form_template`** — otherwise no submit button.
- **Dropdowns (`resource-drop-down`) teleport their menu to `<body>` while open.** popper's `fixed` strategy alone is still clipped by a transformed + `overflow:hidden` ancestor (e.g. grid cards, app shells), so the controller reparents the open menu to `<body>` and restores it on close. Don't rely on the menu being a DOM child of its trigger while open.
- **`DisplaysValue` components stringify the value and loop per item.** `render_value` receives `normalize_value(value)`, which is `value.to_s`, and for a `field.multiple?` (has_many) field it is called once per element (`field.value.each`). So a custom component that inherits `DisplaysValue` only ever sees the stringified value, per item, never the record. When you need `f.object` or whole-collection rendering, use the block-form display (`display :x do |f| … end`), which is `instance_exec`ed in Phlex once and can emit markup directly.
- **Blocks run in a Phlex context on every surface.** A `display`, `input`, or `column` block emits `span`/`div` directly, or returns a String or a component. All three are `instance_exec`ed by the resource page rendering them (`self` is the page, not the definition): `display`/`input` blocks receive the field `f`, a `column` block receives the record. Phlex renders the return value too, so a block that emits markup must end with a tag call or `nil`. Most columns need no block at all, because `display :x, as: …` already flows to the table column.
- **A component reads its attributes from the surface declaration, not from `field`.** Options on `field` go to the Phlexi field builder, which consumes the field-level keys (`:label`, `:description` for displays, `:hint` for forms, `:placeholder`) and ignores the rest. Component options (`colors:`, `unit:`, …) belong on `input`/`display`/`column`. The four field-level help keys are stripped on every surface (the union), so a form key on a display or vice versa is dropped rather than leaked. Any *other* unknown key on a surface whose component does not consume it still renders as a raw HTML attribute, so keep each option on a surface whose component uses it. The table inherits the display's `as:` and attributes unless a `column` declares its own rendering (an `as:`, a component attribute, or a block); a `column` that only sets the header keys (`align:`, `label:`, `condition:`) keeps the display's rendering. `align:` and `label:` may also be declared on `field`.
- **`as: :phlexi_render, with:` is deprecated** (see [[plutonium-resource]] › Custom Rendering). Use a block-form display. This covers only the display option; the internal `phlexi_render` helper (`Component::Behaviour`) stays.
---
## Related skills
- [[plutonium-resource]] — field/input/display config (`as:`, `condition:`, blocks); modal options for actions.
- [[plutonium-behavior]] — controller presentation hooks (`present_parent?`), available helpers (`resource_record!`, `current_scoped_entity`).
- [[plutonium-app]] — `pu:eject:layout`, `pu:eject:shell`, portal package overrides.
- [[plutonium-tenancy]] — `permitted_associations` drives the show-page tablist.