From rails-cto
How to write clean, well-formatted ERB templates for this Rails 8 project. Use when creating or modifying any ERB view, partial, layout, or component template. Also use when the user mentions "erb", "views", "partials", "templates", "html formatting", "attribute alignment", or asks about view-layer code. Proactively apply these rules whenever touching .html.erb files, even if the user doesn't explicitly ask for formatting help.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rails-cto:rails-cto-erbThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
ERB files are the most-read files in the codebase. Developers scan them constantly to understand layout, wiring, and data flow. Every formatting decision should optimize for scannability — a developer glancing at a file should immediately see the structure, the Stimulus wiring, and the data being rendered.
ERB files are the most-read files in the codebase. Developers scan them constantly to understand layout, wiring, and data flow. Every formatting decision should optimize for scannability — a developer glancing at a file should immediately see the structure, the Stimulus wiring, and the data being rendered.
<%# ... %> to label logical groups.You MUST run these steps every time this skill is invoked. After modifying any .html.erb file, run the Herb linter and formatter on the changed files. Herb catches syntax issues and enforces consistent formatting that manual review can miss.
bundle exec herb --version 2>/dev/null
If the command fails, inform the user:
"This project doesn't have the
herbgem installed. The recommended way to set up Herb (and the rest of the rails-cto toolchain) is to install the companionrails-ctogem:Gemfile:
group :development, :test do gem "rails-cto" endThen run:
bundle install bundle exec rails-cto initThat installs Herb along with the bundled rewriters and rules. You'll also need
@herb-tools/formatterand@herb-tools/linterinpackage.jsondevDependencies."
If Herb is not available, skip steps 2, 3, and 4 below and continue with the rest of the ERB skill. Do not block on Herb installation. But if Herb IS available, you MUST run steps 2, 3, and 4 — do not skip them.
The rails-cto gem ships the attribute alignment rewriter and the no-inline-styles rule. Check that rails-cto init has been run in the project:
ls .herb/rewriters/align-attributes.mjs 2>/dev/null
ls .herb/rules/no-inline-styles.mjs 2>/dev/null
If either file is missing, tell the user to run:
bundle exec rails-cto init
That drops the rewriters and rules into place (skipping any files that already exist). Pass --force to overwrite.
Use git to find only the .erb files that were modified, not the entire codebase:
git diff --name-only --diff-filter=ACMR HEAD | grep '\.erb$'
If there are unstaged changes too:
git diff --name-only --diff-filter=ACMR | grep '\.erb$'
For each changed .erb file, run the linter with auto-fix first, then the formatter:
bundle exec herb --fix path/to/changed_file.html.erb
bundle exec herb format path/to/changed_file.html.erb
For multiple files:
bundle exec herb --fix app/views/posts/index.html.erb app/views/posts/_post.html.erb
bundle exec herb format app/views/posts/index.html.erb app/views/posts/_post.html.erb
Review the output for any issues that couldn't be auto-fixed — these need manual attention. Fix ALL warnings in the file, not just ones you introduced. If Herb reports pre-existing issues unrelated to your changes, fix them anyway. Every file you touch should be left with zero Herb warnings.
When an HTML element has more than one attribute, break each attribute onto its own line. Align attributes with the first attribute after the tag name:
<%# CORRECT — attributes aligned vertically %>
<div class="pane-field-group"
data-controller="posts--tag-sync"
data-posts--tag-sync-url-value="<%= sync_post_taggings_path(post) %>"
data-action="forms--combo-select:change->posts--tag-sync#sync">
<%# WRONG — everything crammed on one line %>
<div class="pane-field-group" data-controller="posts--tag-sync" data-posts--tag-sync-url-value="<%= sync_post_taggings_path(post) %>" data-action="forms--combo-select:change->posts--tag-sync#sync">
A single-attribute element can stay on one line:
<div class="flex-1 px-6 py-6 space-y-5">
For ViewComponent and partial renders with multiple arguments, align parameters with the opening parenthesis:
<%= render(Forms::EditableField.new(model: post,
field_type: :text,
attribute: :title,
url: post_url,
field_arguments: { class: "text-base font-medium" })) %>
For form_with and similar helpers:
<%= form_with(model: post, method: :patch,
class: "space-y-5",
data: { controller: "utils--autosave" }) do |f| %>
<button type="button"
class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs"
data-action="click->combo-suggestions#add"
data-id="<%= item.id %>"
data-name="<%= item.name %>"
title="Add tag">
<%= item.name %>
</button>
<input> and form fields<input type="file"
accept="image/*"
data-avatar-upload-target="input"
data-action="change->avatar-upload#selectFile"
class="hidden">
ERB files should contain zero business logic, calculations, or variable declarations beyond local_assigns.fetch defaults.
Inline variables in ERB are not testable. You cannot unit-test a local variable assigned inside a template. When logic lives in a controller (instance variable), model method, or helper, it can be tested independently with Minitest. This is the primary reason inline variables are banned — if it can't be tested, it shouldn't be in ERB.
if @items.any?)local_assigns.fetchlink_to, image_tag, icon, t())Move these to controllers, concerns, helpers, or model methods — where they can be tested.
Inline variable assignments (NEVER):
Any <% variable = ... %> line in ERB (other than local_assigns.fetch) is wrong. It doesn't matter how simple the assignment is — if it's not a partial local default, it belongs in the controller or a helper.
<%# WRONG — inline variables are not testable %>
<% tags = post.tags.where(account_id: current_account.id).order(:name) %>
<% post_count = current_account.posts.count %>
<% suggested = Tag.where(category: :interest).limit(8) %>
<% display_name = "#{user.first_name} #{user.last_name}".strip %>
<% show_banner = current_account.trial? && current_account.days_remaining < 7 %>
<%# RIGHT — instance variables from the controller, testable in controller tests %>
<%= render partial: "tag", collection: @tags %>
<%= @post_count %>
<%= current_account.display_name %>
<%= render "shared/trial_banner" if @show_trial_banner %>
When you see an inline variable in ERB, move it:
@tags, @post_count)user.display_name) or helper (format_count(total))@show_trial_banner)account.days_remaining_display)Math and calculations:
<%# WRONG — arithmetic in the template, inline styles %>
<span><%= (post.reading_time / 60.0).ceil %> min read</span>
<span><%= ((completed.to_f / total) * 100).round %>% complete</span>
<div style="width: <%= (tag.taggings_count.to_f / max_count * 100).round %>%">
<%# RIGHT — computed in controller or model, Tailwind classes instead of inline styles %>
<span><%= post.reading_time_display %></span>
<span><%= @completion_percentage %>% complete</span>
<div class="<%= tag.weight_class %>">
Database queries:
<%# WRONG — querying in the template %>
<% recent_tags = Tag.where(account_id: current_account.id).order(updated_at: :desc).limit(5) %>
<%# RIGHT — queried in controller, passed to view %>
<% @recent_tags.each do |tag| %>
String manipulation and formatting:
<%# WRONG — formatting logic in the template %>
<span><%= post.url.gsub(/^https?:\/\//, '').truncate(40) %></span>
<%# RIGHT — use a helper or model method %>
<span><%= post.display_url %></span>
The one exception is reading partial locals with defaults at the top of a partial:
<%# This is fine — declaring expected locals with fallback values %>
<% context = local_assigns.fetch(:context, nil) %>
<% active_tab = local_assigns.fetch(:active_tab, :read) %>
<% extra_attrs ||= {} %>
Keep these at the very top of the file, right after the header comment.
Never use style="..." attributes in ERB templates. Use Tailwind CSS utility classes instead. Inline styles bypass the design system, can't be purged, don't support responsive or dark mode variants, and make templates harder to scan.
The no-inline-styles.mjs Herb rule (shipped by the rails-cto gem) flags these automatically during the lint step.
<%# WRONG — inline styles %>
<div style="display: flex; gap: 8px; padding: 16px;">
<div style="width: 50%">
<span style="color: red; font-weight: bold;">Error</span>
<div style="margin-top: 1rem; border-bottom: 1px solid #e5e7eb;">
<%# RIGHT — Tailwind utility classes %>
<div class="flex gap-2 p-4">
<div class="w-1/2">
<span class="text-red-600 font-bold">Error</span>
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
Dynamic widths and computed values — when the width depends on data (e.g., progress bars), use a model method that returns a Tailwind class:
<%# WRONG — inline style for dynamic width %>
<div style="width: <%= @progress %>%">
<%# RIGHT — model returns a Tailwind class like "w-1/4", "w-1/2", "w-3/4", "w-full" %>
<div class="<%= @progress_width_class %>">
If the value truly cannot map to a Tailwind class (e.g., pixel-precise positioning from user data), extract it to a ViewComponent or helper that encapsulates the style — never put it inline in a template.
Every ERB partial follows this structure:
<%# Path comment and description of what this partial does %>
<%# Additional notes about accepted locals if needed %>
<% local_defaults = local_assigns.fetch(:local_defaults, nil) %>
<%# Section Label %>
<div class="...">
<%# Subsection Label %>
<div class="...">
<%= render SomeComponent.new(...) %>
</div>
</div>
local_assigns.fetch calls (if any)For partials that accept locals, document them in the header:
<%# Shared split-pane layout for reading-pane controller.
Locals:
frame_id — turbo frame ID for the reader pane
extra_attrs — optional hash of extra data attributes
Block: rendered as list pane content
%>
<% extra_attrs ||= {} %>
Always 2 spaces. Nested elements increase indentation by one level:
<div class="outer">
<div class="inner">
<span class="text"><%= @value %></span>
</div>
</div>
Indent content inside <% if %> blocks:
<% if @post.feed.present? %>
<div class="pane-field-group">
<label class="pane-field-label">Source</label>
<div class="flex items-center gap-2">
<%= inline_icon(:lucide_rss, "w-4 h-4") %>
<span class="truncate"><%= @post.feed.name %></span>
</div>
</div>
<% end %>
<div class="flex flex-wrap gap-2">
<% @tags.each do |tag| %>
<span class="tag-chip"><%= tag.name %></span>
<% end %>
</div>
Keep classes on the same line as class= unless the line exceeds ~100 characters. When wrapping, keep the continuation on the next line indented under the opening quote:
<%# Single line — fits comfortably %>
<div class="flex items-center gap-2 px-3 py-2 rounded-lg">
<%# Wrapped — long class list %>
<button class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium
bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300
hover:bg-violet-100 dark:hover:bg-violet-900/50 transition-colors cursor-pointer"
data-action="click->combo-suggestions#add">
<% end %> aligns with its opening <% if %>, <% each %>, or <%= form_with %>:
<%= form_with(model: @post) do |f| %>
<%= f.text_field :title %>
<% end %>
Use <%# ... %> for comments. Place them immediately before the element or section they describe:
<%# Title (editable) %>
<div class="pane-field-group">
<%# Collection %>
<%= form_with(model: post) do |f| %>
Use multi-line ERB comments for file headers and documentation:
<%# Shared split-pane layout for reading-pane controller.
Locals:
frame_id — turbo frame ID
extra_attrs — optional hash of extra data attributes
Block: rendered as list pane content
%>
Avoid HTML comments (<!-- -->) for documentation — they leak into the rendered output. Use them only for IE conditionals or legacy compatibility.
Data attributes for Stimulus should follow the same vertical alignment rules. Group them logically: controller first, then values, then targets, then actions:
<div class="pane-field-group"
data-controller="collections--combo-select-create"
data-collections--combo-select-create-create-url-value="<%= collections_path(format: :json) %>"
data-collections--combo-select-create-search-url-value="<%= search_collections_path %>"
data-action="forms--combo-select:change->utils--autosave#save">
Order: class first, then data-controller, then data-*-value, then data-*-target, then data-action, then id/title/other attributes.
<%= turbo_frame_tag frame_id,
data: { reading_pane_target: "readerFrame" } do %>
<% end %>
<%= turbo_stream.replace dom_id(@post) do %>
<%= render partial: "posts/post",
locals: { post: @post } %>
<% end %>
Every UI change must look correct in both light mode and dark mode. When adding or modifying Tailwind classes, always include the dark: variant for colors, backgrounds, borders, and text:
<%# WRONG — only light mode %>
<div class="bg-white text-gray-900 border-gray-200">
<%# RIGHT — both modes %>
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 border-gray-200 dark:border-gray-700">
After making view changes, visually verify the UI in both modes before considering the work done.
Avoid duplicating markup across views. If the same UI pattern appears in more than one place, extract it into a shared partial under app/views/shared/ or a ViewComponent. Before creating a new partial, check if one already exists that does what you need.
<%# WRONG — same card markup copy-pasted in index.html.erb and show.html.erb %>
<%# RIGHT — extract to a shared partial %>
<%= render partial: "posts/post_card", locals: { post: post } %>
| Do | Don't |
|---|---|
Set @suggested_tags in the controller | Query Tag.where(...) in ERB |
Use post.reading_time_display | Write (post.reading_time / 60.0).ceil in ERB |
| Align attributes vertically | Cram 4+ attributes on one line |
Use <%# ... %> for comments | Use <!-- --> for documentation |
| Put local defaults at top of partial | Scatter variable assignments throughout |
Use local_assigns.fetch(:key, default) | Use complex ternaries for defaults |
| Pass data as locals or instance vars | Compute derived values inline |
| Use Tailwind classes for all styling | Use style="..." inline attributes |
npx claudepluginhub mattsears/rails-cto --plugin rails-ctoGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.