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, and a subclass with no sibling of its
own renders through its parent's. 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.
Your own components
A component you write on the DSL, in an engine or in the
application itself, gets the whole authoring surface: the Style
dictionary, options, parts, attribute merging and
data-slot. Name its view helper with
helper, and it is first-class on the surfaces that
read the registry too. Nothing to register, no manifest to keep
fresh: discovery is by convention, a published Component under
app/ outside the poetry/ namespace. One
helper name per component: two components declaring the same
name, or a name Rails, Poetry, or your own
ApplicationHelper already defines (private methods
included), fail at boot naming the clash, and a clash on a reload
leaves nothing half-defined. A component that declares no helper
is still catalogued, by class: every surface says
render Acme::Pill::Component, and none invents a
poetry_ name for it.
# app/components/billing/invoice_status/component.rb
module Billing
module InvoiceStatus
class Component < Poetry::Core::Component
helper :billing_invoice_status # the view helper's name
AGENT_RULES = ["Pass the invoice's state as tone:; never a free-text label."].freeze
style :tone, default: :neutral, required: true, variants: %i[neutral paid overdue]
def call
content_tag(:span, content, class: css)
end
end
class Style < Poetry::Core::Style
base "cn-badge inline-flex"
variant :tone, neutral: "cn-badge-variant-outline", paid: "cn-badge-variant-success",
overdue: "cn-badge-variant-destructive"
end
end
end
<%= billing_invoice_status(tone: :overdue) { "Overdue" } %>
The helper is defined when the app boots and again on every code reload, so a view can call it before anything else has referenced the class. It takes keywords and a content block, like every Poetry helper. From there the booted surfaces know the component:
-
bin/rails poetry:checklintsbilling_invoice_statusunder its own name, with its own contract: unknown variants, near-miss options, a positional argument, and the stable-identity heuristics. At render, an off-list value raises in development and test and logs in production, the same as for a gem component, so nothing to add inbefore_render. -
/poetry/llms.txtandllms-full.txtlist it under an App components section, agent rules included, so an agent reading the app's catalog sees your vocabulary beside the gem's. -
rails g poetry:skillwrites areferences/app.mdwith the same contract format and adds it to the skill's menu. -
The MCP server runs without booting the app, so
it reads registries from files.
bin/rails poetry:registrywrites the app's components toconfig/component_registry.yml, the same path a gem's registry lives at; commit it and the server describes, checks and composes them with full contracts, and the runtime skill carries the samereferences/app.md. Without the file, the check tool still knows the declared helper names by parsing the component sources; once the file exists it is the truth and the sources are not read. It is a build artifact:poetry:checkwarns andpoetry:verifyfails when it no longer matches the classes, and a file that is not a registry at all (empty, malformed, hand-edited into the wrong shape) is reported asregistry-invalidand ignored by the boot-free surfaces rather than crashing them. An engine's own components are left to the engine's registry, not written into the app's file.
Registry roots are found by convention, not by name: every loaded
engine whose root carries a published registry joins
poetry:check, the census in AGENTS.md, and
the MCP server. An engine built on the DSL commits its own registry
the way poetry-ui does, and its components are served to every
host that bundles it, with nothing to configure.
Controllers of your own that the component wires join the same
surfaces through bin/rails poetry:stimulus:manifest
(the Stimulus guide);
an engine that ships controllers commits its
config/controllers_manifest.json the way poetry-charts
does and every host that bundles it validates them.
Pick a distinctive helper name: a name any view helper already owns
raises at boot, and a generic word would make ordinary method calls
look like component calls to the check. Components copied in with
poetry:add keep their gem namespace and their
poetry_ helpers, and are described by the gem's registry
as before.
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.