Core
poetry-core is the engine gem: the component DSL, design tokens, and Stimulus primitives every Poetry gem is built on. The same public surface is yours to build your own component library, styled with Tailwind utilities or your own CSS.
What it is
poetry-core is a Rails engine that carries the framework layer and none of the catalog. The components you actually render live in poetry-ui; this gem is what makes them uniform: one base class, one styling contract, one wiring contract, one machine-readable index. At boot the engine wires component autoload paths, previews, importmap pins, asset paths, and the view helpers into the host.
| Layer | What ships |
|---|---|
| Component DSL | Poetry::Core::Component with the style / option / slot / part declarations and use_stimulus wiring |
| Design tokens | the semantic token set as CSS custom properties, generated from one DTCG source and contrast-gated (see theming) |
| Primitives | 53 Stimulus controllers: overlay, focus, presence, positioning, and the per-component behavior machines |
| Contract machinery | the generated component registry, the controllers manifest, the part contract, and stable DOM ids |
| The check | the static ERB linter behind bin/rails poetry:check and the MCP check tool |
| Agent surfaces | the registry, check, and llms/skill text that poetry-agent projects - the MCP server binary and the WebMCP runtime live in poetry-agent |
Install
Most apps get poetry-core as a dependency of poetry-ui; the installation guide covers that path. Depend on it directly when you are building components of your own:
gem "poetry-core"
Build your own component library
Everything poetry-ui's 88 components are made of is public API.
Subclass Poetry::Core::Component and your components
get the same declared surface: typed attributes, a style axis
vocabulary, documented slots, a part contract, and a place in the
machine-readable registry.
The component class
A component declares its whole public surface at the class level.
style defines a visual axis with a closed variant
vocabulary; option defines a typed non-visual
attribute. Both take static or proc defaults (a proc can reference
other attributes and stays live until explicitly set),
required: true, and a doc: string that
travels into the registry and the generated API docs.
# app/components/my_kit/pill/component.rb
module MyKit
module Pill
class Component < Poetry::Core::Component
style :variant, default: :neutral, required: true,
variants: %i[neutral success danger],
doc: "The visual intent axis."
style :interactive, variants: :boolean, default: false,
doc: "Hover and focus affordances for clickable pills."
option :count, :integer,
doc: "Optional trailing count, rendered after the text."
renders_one :icon, doc: "Optional leading visual."
part "pill", "The root span - the component's styling surface"
requires_content "the pill's visible text"
def before_render
ensure_content!
end
def call
tag.span(**root_attributes.to_attributes) do
safe_join([icon, content, count_tag].compact)
end
end
private
def root_attributes
html_attributes.merge_if_not_set(component_data_attributes)
end
def count_tag
tag.span(count, class: css(:count)) if count
end
end
end
end
The pieces at work:
-
Slots are declared with
renders_one/renders_many;doc:gives each one a reference sentence that projects into the registry, andrenders:passes a slot's lambda as a keyword so the doc reads first. -
partdeclares the component's rendered anatomy: thedata-slotparts its DOM exposes, with the state attributes and CSS custom properties each part carries. The declaration is verified against rendered DOM, so the published contract cannot drift from the markup. -
requires_contentfeeds two layers from one declaration:ensure_content!raises at render, and the registry marks the component so the check flags the omission statically. -
html_attributesmerges everything the caller passed (class:,data:,aria:) over your defaults, with class conflicts resolved by the configured merger. Attributes not on the declared surface flow here automatically. -
Declarations double as ActiveModel validations: variant
vocabularies become inclusion validations and
required:becomes presence, so an instance answersvalid?and the same facts drive static checking. -
Inner classes of a family call
internal_component!to keep the full machinery without becoming published registry entries.
The style dictionary
Styling lives in a sidecar Style class, found by
naming convention (Pill::Component resolves
Pill::Style). It is a dictionary from your declared
style values to CSS classes: base for the root,
element for named inner elements, variant
for each axis, and compound for classes that apply
only when several axes match at once. Defaults deliberately never
live here; declaring them in the dictionary raises, because the
component's style :attr, default: is the single
source of truth.
# app/components/my_kit/pill/style.rb
module MyKit
module Pill
class Style < Poetry::Core::Style
base "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
element :icon, "size-3 shrink-0"
element :count, "tabular-nums opacity-70"
variant :variant, neutral: "bg-muted text-muted-foreground",
success: "bg-primary/10 text-primary",
danger: "bg-destructive/10 text-destructive"
variant :interactive, { true => "cursor-pointer hover:opacity-80", false => "" }
compound({ variant: :danger, interactive: true }, "hover:bg-destructive/20")
end
end
end
<%= render MyKit::Pill::Component.new(variant: :success, count: 3, class: "ml-2") do %>
Deployed
<% end %>
Behavior from the primitives
The 53 shipped Stimulus controllers are a behavior kit your
components wire declaratively. use_stimulus is an
element-major declaration: for each named element, which
controllers attach, which values they read, and which actions and
targets they carry. Symbols name Poetry controllers and are
validated against the controllers manifest when the class loads,
so a typo raises at boot rather than at first render; pass a
String to wire a controller of your own, which Poetry does not
validate.
use_stimulus do
on :root do
controller :hover_card do
register
value :open
value :open_delay, unless: -> { open_delay.nil? }
end
controller :popper do
register
value :side
end
end
on :trigger do
controller :hover_card do
action :pointer_enter, on: :pointerenter
end
controller :popper do
target :anchor
end
end
end
At render time, stimulus_attributes_for(:trigger)
returns the element's merged attribute hash for your template, and
the declaration projects into the registry so agents and docs see
the wiring without reading your templates.
The registry
Every declaration above is introspectable, and the registry is the
committed YAML index built from that introspection: styles with
their variants, options with their types, slots, parts, wiring,
and each dictionary's classes. Poetry's own gems commit theirs at
config/component_registry.yml and verify it in CI so
it can never drift from source. Generate one for your library the
same way:
# A rake task in your library's Rakefile
task :registry do
Poetry::Core::Registry.new(source_root: gem_root).generate!
end
One contract feeds every surface: the docs tables, the agent surface, and the check all read the registry rather than your source, which is what keeps them honest.
Two styling paths: Tailwind or BEM
The style dictionary is written once; how its resolution reaches
the page is governed by css_mode. The default,
:tailwind, resolves style values through the
dictionary to utility classes and merges them with whatever the
caller passed, caller classes winning conflicts. For a host that
brings its own CSS, :bem skips the utilities entirely
and emits a stable class vocabulary instead:
# config/initializers/poetry.rb
Poetry::Core::Config.current.css_mode = :bem
Poetry::Core::Config.current.classname_merger = Poetry::Core::CSS::BemMerger.new
pill = MyKit::Pill::Component.new(variant: :danger, interactive: true)
pill.css # => "my-kit-pill my-kit-pill--interactive my-kit-pill--variant-danger"
pill.css(:icon) # => "my-kit-pill__icon"
The scheme is derived, never hand-named: the block class comes
from the component path, each style value becomes a
block--attribute-value modifier, booleans become
presence modifiers, and named elements become
block__element. The merger pairs with the mode:
BemMerger dedupes and joins caller classes instead
of applying Tailwind conflict semantics to a stylesheet that is
not Tailwind. The mode is global through
Poetry::Core::Config or per call
(css(css_mode: :bem)); there is deliberately no mode
that emits both.
You style those classes against a generated reference stylesheet.
Poetry::Core::CSS::BemReference renders every class a
dictionary can emit as a documented selector skeleton, each rule
carrying a comment naming the utilities the Tailwind path would
have resolved, so the reference doubles as the class contract's
documentation:
/* poetry BEM reference for `.my-kit-pill` - capsule 1a2b3c...
Generated from the Style dictionary; consumers on css_mode = :bem
style these selectors with their own CSS. */
.my-kit-pill { /* tailwind-equivalent: inline-flex items-center gap-1 rounded-full ... */ }
.my-kit-pill__icon { /* tailwind-equivalent: size-3 shrink-0 */ }
.my-kit-pill--variant-danger { /* tailwind-equivalent: bg-destructive/10 text-destructive */ }
.my-kit-pill--interactive { /* tailwind-equivalent: cursor-pointer hover:opacity-80 ... */ }
The header embeds the dictionary's capsule digest as a leak-guard:
CSS written against an older dictionary is detectable by digest
mismatch instead of silently drifting on upgrade. Choose
:tailwind when your app runs Tailwind and wants the
token layer; choose :bem when your design system owns
the CSS and needs only a stable, framework-agnostic class
contract, with no Tailwind build in the app at all.
Design tokens
The design system's colors, radius, and modes live in one DTCG
token file inside the gem; everything else is generated from it.
The generated stylesheet defines each semantic role as a CSS
custom property in both light and dark, and a companion file maps
the roles into Tailwind so utilities like bg-primary
resolve through the tokens:
:root {
--background: oklch(1 0 0);
--primary: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
/* every role redefined for dark */
}
A contrast gate holds every semantic text pair to WCAG AA at minimum in both modes, and pairs locked at AAA may never regress. Because components only ever reference roles, a theme is just a different assignment of the same custom properties, which is how the nine shipped themes work and why the check flags raw palette classes in app markup.
The check
Poetry::Core::Check is the static linter for the ERB
you (or your agent) write against any registry built by this gem.
It parses templates without rendering and validates every Poetry
surface: unknown components, options, and variants with
did-you-mean suggestions, Stimulus wiring against the controllers
manifest, icon names against the active set, and the three style
exits that bypass the tokens: raw and arbitrary color classes,
inline style colors, and the ! important modifier, in
class="" attributes and class: keywords
alike, and the fake button: a div or span
carrying onclick or role="button" where a
real button belongs.
bin/rails poetry:check # sweep app views + app Ruby
POETRY_CHECK_JSON=1 bin/rails poetry:check # JSON for editors / CI
The same linter answers the MCP server's check tool,
so an agent gets a verdict in milliseconds without booting the
app. The testing guide places the
check in the wider test ladder, and the
API reference documents the
framework surface this page introduces.
Box and Wrapper
The catalog lives in poetry-ui, but the engine ships two pieces of
its own: Box, the one component in core's registry,
and Wrapper, a rendering utility. Both live at this
layer because they are about the machinery itself rather than any
design system.
Box, the polymorphic element
Box renders one element of any tag with the full poetry attribute
machinery attached - the class merger, Stimulus data:
merging, and data-slot self-identification that a raw
content_tag skips. It exists for the bare styled
element a real component would be too much for: a spacer, a
semantic wrapper, a grid cell. html_tag: (default
div) is validated as a tag name, and void elements
(br, hr, img, ...)
self-close - passing one a content block raises rather than
silently dropping the content.
<%= render Poetry::Core::Box::Component.new(html_tag: "section", class: "grid gap-4") do %>
Cards, text, other components...
<% end %>
<%= render Poetry::Core::Box::Component.new(html_tag: "hr", class: "my-6") %>
Wrapper, conditional outer HTML
Wrapper renders custom HTML around a child component only when the
child's own render? returns true, so a conditional
shell needs no conditionals in your template. The block receives
the wrapper and must call wrapper.component exactly
once, where the child belongs: a block that skips it raises (the
wrap would silently drop the child), and a second call raises too.
Every Poetry::Core::Component answers
#wrapped, the shorthand for
Wrapper::Component.new(self).
<% pill = MyKit::Pill::Component.new(variant: :success).with_content("Deployed") %>
<%= render pill.wrapped do |wrapper| %>
<div class="mt-2 flex items-center gap-2">
<%= wrapper.component %>
</div>
<% end %>
One caveat travels with the conditional: the child's
render? is consulted before the child gains a view
context, so a render? that calls view helpers works
standalone but not under the wrapper. Both classes are documented
in full in the API reference.