Skip to content

Layouts ​

The overall page chrome, topbar, sidebar, footer, body wrapping. Plutonium ships three shells; you can eject the templates or write a custom ResourceLayout for total control.

Shell ​

ruby
Plutonium.configure do |config|
  config.shell = :modern     # default, topbar + icon rail
  # config.shell = :plain    # topbar, no icon rail (rail-less app)
  # config.shell = :classic  # legacy header + sidebar (only when upgrading)
end

:classic is only for upgrade paths

If you're starting fresh, use :modern. :classic exists so apps upgrading from pre-:modern versions can preserve their chrome while migrating.

Shell variants & the icon rail ​

The shell variant selects the page chrome:

  • :modern (default), Topbar plus the desktop icon rail.
  • :plain: Topbar but no icon rail. The Topbar is kept; only the rail is removed, so the surface is rail-less.
  • :classic: legacy Header/Sidebar (upgrade paths only).

Resolving the shell (global → engine → controller) ​

The shell resolves across three layers, each overriding the one above it:

ruby
# 1. Global default (config/initializers/plutonium.rb)
Plutonium.configure { |config| config.shell = :modern }

# 2. Per-engine: set it on a portal engine (lib/engine.rb)
module CustomerPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    config.after_initialize do
      shell :plain   # this whole portal is rail-less
    end
  end
end

# 3. Per-controller: overrides the engine/global default for one controller (and subclasses)
class DashboardController < ResourceController
  shell :modern   # opt this controller back into the rail
end

shell takes a plain symbol, so it's safe in the class body too, but the generated engine already has a config.after_initialize block (where scope_to_entity lives), so keeping it there is the consistent home.

An unset engine/controller value (nil) falls through to the next layer. Read the resolved value with the shell helper (controller.shell).

rail: a controller-level rail toggle ​

Alongside shell, any resource controller exposes a class-level rail DSL that flips just the icon rail without changing the shell. It's a class_attribute, so it's inherited; a portal opts its entire surface in or out by calling rail false (or rail true) once in its controller concern:

ruby
module CustomerPortal
  module Concerns
    module Controller
      extend ActiveSupport::Concern
      included { rail false }  # entire portal rail-less
    end
  end
end

rail nil (the default) inherits the resolved shell, the rail shows when the resolved shell == :modern. Read the resolved value with the rail? predicate.

Stable CSS hooks ​

Rail-less rendering exposes a few stable hooks for custom overrides:

  • pu-topbar: class on the Topbar nav.
  • pu-sticky-footer: class on the form sticky-footer div.
  • html.pu-no-rail: root class present whenever the current page is rail-less.

A built-in rule cancels the desktop rail inset on .pu-topbar and .pu-sticky-footer under html.pu-no-rail; target these hooks to layer your own CSS.

Eject the chrome for per-portal customization ​

bash
rails generate pu:eject:shell --dest=admin_portal
rails generate pu:eject:layout

pu:eject:shell copies _resource_header.html.erb and _resource_sidebar.html.erb into the portal's app/views/plutonium/. The eject is independent of shell; you can run it on either.

pu:eject:layout copies layouts/resource.html.erb for layout-level edits.

The sidebar/icon-rail navigation is built with Phlexi::Menu::Builder in the ejected _resource_sidebar.html.erb. Each item takes a label, plus url:, icon:, and optional leading_badge: / trailing_badge::

erb
<%= render Plutonium::UI::Layout::IconRail.new(
      menu: Phlexi::Menu::Builder.new do |m|
        m.item "Dashboard", url: root_path, icon: Phlex::TablerIcons::Home

        m.item "Resources", icon: Phlex::TablerIcons::GridDots do |n|
          registered_resources.each do |resource|
            n.item resource_label(resource), url: resource_url_for(resource, parent: nil)
          end
        end
      end
    ) %>

footer_menu: takes a second Phlexi::Menu::Builder whose items render between the scrolling nav and the pin button, so they stay in view however long the nav gets. Items render the same way as the main menu's:

erb
<%= render Plutonium::UI::Layout::IconRail.new(
      menu: main_menu,
      footer_menu: Phlexi::Menu::Builder.new do |m|
        m.item "Settings", url: settings_path, icon: Phlex::TablerIcons::Settings
      end
    ) %>

Any extra options you pass to item are spread straight onto the rendered <a>, so a menu entry can opt into target, rel, data-*, aria-*, etc. Useful for items that open in their own tab or drive a Stimulus/Turbo behavior:

ruby
m.item "Inbox",
  url: inbox_path,
  icon: Phlex::TablerIcons::Mail,
  target: "_blank",
  rel: "noopener",
  data: {turbo_frame: "_top"}

This works across both shells, the :modern icon-rail (leaf items, parent flyout triggers, and flyout children) and the :classic sidebar. Framework attributes always win on conflict: a custom class: is merged with the component's base classes, and on a parent trigger your data: / aria: merge with the flyout's own wiring (so you can't accidentally break the toggle). The :active key is reserved by Phlexi for custom active-state logic and is never emitted as an attribute.

Custom layout class ​

For full Phlex-level control over the layout:

ruby
module AdminPortal
  class ResourceLayout < Plutonium::UI::Layout::ResourceLayout
    private

    def body_attributes
      {class: "antialiased bg-[var(--pu-body)]"}
    end

    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

Layout hooks ​

HookPosition
render_before_main / _after_mainaround the main content area
render_before_content / _after_contentinside main, around content
render_flashflash messages
render_head, render_title, render_metatags, render_assetshead section
render_body_scriptsend-of-body scripts
render_fontsfont links

Typography ​

Plutonium uses Lato by default. Override via render_fonts:

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

Then configure Tailwind to match:

javascript
// tailwind.config.js
theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
  fontFamily: {
    body: ['Inter', 'sans-serif'],
    sans: ['Inter', 'sans-serif']
  }
})

See Assets › Tailwind config for the full merge story.

Dark mode ​

selector strategy, toggle by adding/removing dark on <html>. The bundled color-mode Stimulus controller handles toggling; Plutonium ships a switcher.

javascript
// Manual toggle if needed
document.documentElement.classList.toggle('dark')
  • Assets: Tailwind config, design tokens, .pu-* classes
  • Components: custom components used in layout hooks (AnnouncementBanner, etc.)
  • Pages: page-level hooks (a lighter alternative for per-page chrome)

Released under the MIT License.