agentleFS
mattsearsmattsears/rails-cto/skills/rails-cto-erb/SKILL.md

rails-cto-erb

Skill15 starsChanged 7 months agoMITView on GitHub

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.

The file

Show its rules and text

We split this skill into the rules it gives an agent

22 rules. About 0 are common, 13 are distinctive, and 9 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈13

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Indent consistently at 2 spaces: No tabs, no 4-space blocks.

    line 20
  2. Very specific:

    Comment sections, not lines: Use <%# ... %> to label logical groups.

    line 21
  3. Very specific:

    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 …

    line 27
  4. Distinctive:

    If the command fails, inform the user:

    line 35
  5. Distinctive:

    If Herb is not available, skip steps 2, 3, and 4 below and continue with the rest of the ERB skill.

    line 54
  6. Distinctive:

    If either file is missing, tell the user to run:

    line 65
  7. Very specific:

    Use git to find only the .erb files that were modified, not the entire codebase:

    line 75
  8. Distinctive:

    If there are unstaged changes too:

    line 81
  9. Distinctive:

    When an HTML element has more than one attribute, break each attribute onto its own line.

    line 109
  10. Distinctive:

    When you see an inline variable in ERB, move it:

    line 214
  11. Distinctive:

    Keep these at the very top of the file, right after the header comment.

    line 265
  12. Very specific:

    Never use style="..." attributes in ERB templates.

    line 271
  13. Distinctive:

    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 …

    line 299
  14. Distinctive:

    Every ERB partial follows this structure:

    line 305
  15. Distinctive:

    Always 2 spaces. Nested elements increase indentation by one level:

    line 345
  16. Very specific:

    Keep classes on the same line as class= unless the line exceeds ~100 characters.

    line 383
  17. Very specific:

    Use <%# ... %> for comments.

    line 410
  18. Distinctive:

    Use multi-line ERB comments for file headers and documentation:

    line 420
  19. Very specific:

    Avoid HTML comments (<!-- -->) for documentation — they leak into the rendered output.

    line 431
  20. Very specific:

    Every UI change must look correct in both light mode and dark mode.

    line 470
  21. Distinctive:

    After making view changes, visually verify the UI in both modes before considering the work done.

    line 480
  22. Very specific:

    Avoid duplicating markup across views.

    line 484

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

ERB Templates in This Project

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.

The Golden Rules

  1. No logic in templates. ERB files render — they do not compute, query, or decide.
  2. Align attributes vertically. Multi-attribute elements get one attribute per line.
  3. Indent consistently at 2 spaces. No tabs, no 4-space blocks.
  4. Comment sections, not lines. Use <%# ... %> to label logical groups.

Pre-flight: Lint and Format ERB Files (MANDATORY)

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.

1. Check if Herb is available

bundle exec herb --version 2>/dev/null

If the command fails, inform the user:

"This project doesn't have the herb gem installed. The recommended way to set up Herb (and the rest of the rails-cto toolchain) is to install the companion rails-cto gem:

Gemfile:

group :development, :test do
  gem "rails-cto"
end

Then run:

bundle install
bundle exec rails-cto init

That installs Herb along with the bundled rewriters and rules. You'll also need @herb-tools/formatter and @herb-tools/linter in package.json devDependencies."

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.

2. Verify the bundled rewriters and rules are installed

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.

3. Identify changed ERB files

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$'

4. Run Herb on changed files

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.


Attribute Alignment

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">

Render calls

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| %>

Buttons and interactive elements

<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">

No Logic in ERB

ERB files should contain zero business logic, calculations, or variable declarations beyond local_assigns.fetch defaults.

Why: Testability

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.

What belongs in ERB

  • Rendering components and partials
  • Iterating over collections passed from the controller
  • Simple conditionals that control visibility (if @items.any?)
  • Reading locals via local_assigns.fetch
  • Output helpers (link_to, image_tag, icon, t())

What does NOT belong in ERB

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:

  • Data/queries → controller sets an instance variable (@tags, @post_count)
  • Formatting/display → model method (user.display_name) or helper (format_count(total))
  • Conditional flags → controller sets a boolean instance variable (@show_trial_banner)
  • Computed values → model method or concern (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>

Acceptable local defaults

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.


No Inline Styles

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.


File Structure

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>
  1. Header comment — file path and/or description of purpose
  2. Local defaults — local_assigns.fetch calls (if any)
  3. Markup — organized into labeled sections

Documenting partials

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 ||= {} %>

HTML Formatting

Indentation

Always 2 spaces. Nested elements increase indentation by one level:

<div class="outer">
  <div class="inner">
    <span class="text"><%= @value %></span>
  </div>
</div>

Conditionals

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 %>

Iterators

<div class="flex flex-wrap gap-2">
  <% @tags.each do |tag| %>
    <span class="tag-chip"><%= tag.name %></span>
  <% end %>
</div>

Long Tailwind class strings

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">

Closing tags

<% end %> aligns with its opening <% if %>, <% each %>, or <%= form_with %>:

<%= form_with(model: @post) do |f| %>
  <%= f.text_field :title %>
<% end %>

ERB Comments

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.


Stimulus Wiring in ERB

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 Frames and Streams

<%= 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 %>

Light Mode and Dark Mode

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.

Shared Partials

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 } %>

Quick Reference: Do vs Don't

DoDon't
Set @suggested_tags in the controllerQuery Tag.where(...) in ERB
Use post.reading_time_displayWrite (post.reading_time / 60.0).ceil in ERB
Align attributes verticallyCram 4+ attributes on one line
Use <%# ... %> for commentsUse <!-- --> for documentation
Put local defaults at top of partialScatter variable assignments throughout
Use local_assigns.fetch(:key, default)Use complex ternaries for defaults
Pass data as locals or instance varsCompute derived values inline
Use Tailwind classes for all stylingUse style="..." inline attributes

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.