Skip to content
Back to skills

Plutonium Ui

ASecurity

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.

  • 76 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 26, 2026
developmentjavascriptgojavarubyshellbashnodeexpressrailsgit

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 1, 2026

npx -y skills add radioactive-labs/plutonium-core --skill plutonium-ui --agent claude-code

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.

Security grade badge for Plutonium Ui
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/radioactive-labs-plutonium-ui/badge)](https://www.skillsdirectory.com/skills/radioactive-labs-plutonium-ui)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.