Rails Engines
A reusable engine built on Poetry ships its views as Ruby and ERB and ships no CSS at all. The host application owns the theme, so every engine in the bundle renders in the host's design - and retheming the host restyles the whole application, engines included, in one command.
Why this works
Three properties of the architecture carry the whole story; none of them need engine-specific configuration:
-
Helpers are global. poetry-ui registers its
helpers with Action View itself, so
poetry_cardand friends compose inside an isolated engine's views exactly as they do in the host's. The same goes for behavior: the host's one-time Stimulus registration covers components rendered from any engine. -
Components carry names, the theme carries design.
Every component emits stable
cn-*classes plus structural utilities; its visual treatment lives in the host's one theme fragment, loaded inlayer(base). Markup rendered from an engine and markup rendered from the app resolve against the same rules. -
The safelist ships every component class. The
host's
poetry:installwrites a safelist of every dictionary and template class, so the host's Tailwind build emits Poetry's own classes without ever scanning gem paths. An engine's Poetry markup needs no build integration to render styled.
The engine recipe
Depend on poetry-ui, render inside the host's chrome, and compose views from the helpers. That is the whole recipe:
# billing.gemspec
spec.add_dependency "poetry-ui"
# app/controllers/billing/application_controller.rb
module Billing
class ApplicationController < ActionController::Base
# In an isolated engine the engine's own layout is namespaced
# (layouts/billing/application), so the bare name resolves to the
# HOST's layouts/application - the file that links the host's
# compiled CSS and theme.
layout "application"
end
end
<%# app/views/billing/invoices/index.html.erb - ships zero CSS %>
<div class="mx-auto flex max-w-4xl flex-col gap-6 p-8">
<div class="grid gap-4 md:grid-cols-3">
<%= poetry_stat(label: "Outstanding", delta: "+2", trend: :up) { "$3,640.00" } %>
<%= poetry_stat(label: "Collected", delta: "+12%", trend: :up) { "$3,965.00" } %>
<%= poetry_stat(label: "Overdue", delta: "1", trend: :down) { "$460.00" } %>
</div>
<%= poetry_alert(variant: :destructive) do |alert| %>
<% alert.with_title { "One invoice is overdue" } %>
INV-2043 passed its due date 6 days ago.
<% end %>
</div>
Ship no stylesheet, no tokens, and no theme from the engine. The host's install decides all of it, which is precisely what keeps the engine reusable across differently themed applications.
Page-level utilities: the one wiring step
The host's Tailwind build scans the host's own files, not gem paths.
Poetry's component classes are covered by the safelist, but the
engine's page-level layout classes - the
md:grid-cols-3 on a stat row - are not, and get purged
from the host build unless the host happens to use them too. The
fix is tailwindcss-rails' engines convention. The engine ships one
file pointing the scanner at its own views:
/* app/assets/tailwind/billing/engine.css (path: tailwind/<engine_name>/) */
@source "../../../views";
And the host opts in once:
$ bin/rails tailwindcss:engines
# writes app/assets/builds/tailwind/billing.css; then opt in:
/* app/assets/tailwind/application.css */
@import "../builds/tailwind/billing";
Document both lines in the engine's install instructions. An engine whose views stick to component helpers and safelisted layout classes can skip this entirely, but shipping the file costs nothing and removes the failure mode.
Retheming the whole application
Because every engine reads the host's theme, a retheme is two commands in the host - no engine changes, no engine releases:
$ bin/rails g poetry:install --theme lyra
$ bin/rails tailwindcss:build
Every Poetry surface follows: the host's screens and every mounted
engine's screens, radii, palettes, and density together. The same
applies to brand themes imported from a
DESIGN.md via
poetry:design:import - study a brand once, and the
application and all of its engines adopt it in one pass.
Customizing a component from an engine
A component binds to its style dictionary by a class-level
convention: Alert::Component resolves the sibling
Alert::Style. There is no per-render style-class
parameter - the render-time surface is each helper's options plus
class:, whose classes win conflicts. To give a
component your own dictionary, subclass the pair:
# The component and its sidecar Style travel as a pair - the subclass
# inherits the full dictionary and can extend or override axes.
module Billing
module StatusAlert
class Component < Poetry::Ui::Alert::Component
style :variant, default: :default, required: true,
variants: %i[default destructive info]
end
class Style < Poetry::Ui::Alert::Style
variant :variant, { info: "cn-alert-variant-info" }
end
end
end
render Billing::StatusAlert::Component.new(variant: :info) { "..." }
The subclass dictionary starts as a copy of its parent's, so
existing variants keep working while new axes and values layer on.
For restyling without Ruby, the
override ladder in the
Theming guide applies unchanged inside engines: template utilities,
token imports, and declared cn-* overrides.
Keep one theme contract
The temptation for an engine author is to ship default styles "just in case". Resist it. The value of Poetry-built engines compounds only while every engine speaks the host's theme: three engines that each carry their own styling are three design systems in one application, and the one-command retheme story dies with them. If an engine must carry a visual identity, ship it as a Poetry theme fragment the host can choose to install - never as engine-loaded CSS.