From lisa-rails
Build or Refactor Rails views, partials, and templates into clean, maintainable code. Use when views have inline Ruby logic, deeply nested partials, jQuery or legacy JavaScript, helper methods returning HTML, or when the user asks to modernize, refactor, or clean up Rails views. Applies patterns - Turbo Frames, Turbo Streams, Stimulus controllers, ViewComponent, presenters, strict locals, and proper partial extraction.
npx claudepluginhub codyswanngt/lisa --plugin lisa-railsThis skill uses the workspace's default tool permissions.
Views should contain markup and minimal display logic. If a view has conditionals, calculations, query calls, or complex Ruby blocks, it needs refactoring. The modern Rails 8+ stack uses Hotwire (Turbo + Stimulus) for interactivity, Propshaft for assets, and Importmap for JavaScript — no build step required.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Views should contain markup and minimal display logic. If a view has conditionals, calculations, query calls, or complex Ruby blocks, it needs refactoring. The modern Rails 8+ stack uses Hotwire (Turbo + Stimulus) for interactivity, Propshaft for assets, and Importmap for JavaScript — no build step required.
Read the view and classify each block of code:
| Code type | Extract to | Location |
|---|---|---|
| Reusable UI patterns (buttons, cards, modals, badges) | ViewComponent | app/components/ |
| Display logic (formatting, conditional CSS, label text) | Presenter or ViewComponent | app/presenters/ or app/components/ |
| HTML-returning helper methods | ViewComponent | app/components/ |
Inline <script> tags and jQuery | Stimulus controller | app/javascript/controllers/ |
AJAX calls, remote forms, $.ajax | Turbo Frame or Turbo Stream | ERB template + controller response |
| Partial page updates via JavaScript | Turbo Frame | Wrap in turbo_frame_tag |
| Real-time broadcasts (chat, notifications) | Turbo Stream | Model broadcasts_to or controller stream |
| One-off page sections that are too long | Partial with strict locals | app/views/shared/ or alongside view |
| Complex data assembly for the view | Presenter | app/presenters/ |
| Repeated inline Ruby (loops with logic) | Collection partial or component | Partial or component |
| Instance variables used across partials | Locals / strict locals | Pass explicitly |
Turbo Frames update a specific section of the page without a full reload. No JavaScript needed.
<%# Before — jQuery AJAX %>
<div id="player-stats"></div>
<script>
$.get('/players/<%= @player.id %>/stats', function(data) {
$('#player-stats').html(data);
});
</script>
<%# After — Turbo Frame %>
<%= turbo_frame_tag "player_stats" do %>
<%= render partial: "players/stats", locals: { player: @player } %>
<% end %>
The linked page just needs a matching turbo_frame_tag with the same ID and Turbo handles the rest.
Rails UJS remote: true forms are deprecated. Turbo handles forms natively.
<%# Before — Rails UJS %>
<%= form_with model: @player, remote: true do |f| %>
...
<% end %>
<%# After — Turbo (just remove remote: true, Turbo handles it) %>
<%= form_with model: @player do |f| %>
...
<% end %>
Turbo intercepts all form submissions by default. For partial updates, wrap the form in a turbo_frame_tag. For multi-target updates, respond with a Turbo Stream:
<%# app/views/players/update.turbo_stream.erb %>
<%= turbo_stream.replace "player_header" do %>
<%= render partial: "players/header", locals: { player: @player } %>
<% end %>
<%= turbo_stream.update "flash_messages" do %>
<%= render partial: "shared/flash" %>
<% end %>
Any behavior attached to DOM elements (toggles, dropdowns, form validation, clipboard, modals) should be a Stimulus controller.
<%# Before — inline JS / jQuery %>
<button onclick="document.getElementById('details').classList.toggle('hidden')">
Toggle
</button>
<div id="details" class="hidden">...</div>
<%# After — Stimulus %>
<div data-controller="toggle">
<button data-action="click->toggle#switch">Toggle</button>
<div data-toggle-target="content" class="hidden">...</div>
</div>
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["content"];
switch() {
this.contentTarget.classList.toggle("hidden");
}
}
Conventions:
toggle, clipboard, dropdown, search-formtargets for elements, values for data, classes for CSS class namesFor real-time updates, use Turbo Streams over WebSockets instead of JavaScript polling.
# app/models/score.rb
class Score < ApplicationRecord
broadcasts_to ->(score) { [score.game] }, inserts_by: :prepend
end
<%# Subscribe in the view %>
<%= turbo_stream_from @game %>
<div id="scores">
<%= render @game.scores %>
</div>
New scores automatically appear without any JavaScript.
Use partials for:
Use ViewComponent for:
# app/components/stat_card_component.rb
class StatCardComponent < ViewComponent::Base
def initialize(label:, value:, trend: nil)
@label = label
@value = value
@trend = trend
end
def trend_class
case @trend
when :up then "text-green-600"
when :down then "text-red-600"
else "text-gray-500"
end
end
end
<%# app/components/stat_card_component.html.erb %>
<div class="rounded-lg border p-4">
<dt class="text-sm text-gray-500"><%= @label %></dt>
<dd class="text-2xl font-semibold"><%= @value %></dd>
<% if @trend %>
<span class="<%= trend_class %>"><%= @trend == :up ? "↑" : "↓" %></span>
<% end %>
</div>
<%# Usage %>
<%= render StatCardComponent.new(label: "Batting Avg", value: ".312", trend: :up) %>
Always declare expected locals at the top of partials. This was added in Rails 7.1 and prevents silent nil bugs.
<%# app/views/players/_card.html.erb %>
<%# locals: (player:, show_stats: false) %>
<div class="player-card">
<h3><%= player.name %></h3>
<% if show_stats %>
<%= render partial: "players/stats", locals: { player: player } %>
<% end %>
</div>
Never loop and render partials manually.
<%# Before — slow, verbose %>
<% @players.each do |player| %>
<%= render partial: "players/card", locals: { player: player } %>
<% end %>
<%# After — collection rendering (faster, cleaner) %>
<%= render partial: "players/card", collection: @players, as: :player %>
If partial A renders partial B which renders partial C, it's too deep. Flatten the structure or extract to a ViewComponent that composes its own sub-components.
When a view needs data from multiple sources or complex formatting, use a presenter instead of cramming logic into the template.
# app/presenters/player_profile_presenter.rb
class PlayerProfilePresenter
def initialize(player, current_user)
@player = player
@current_user = current_user
end
def display_name
"#{@player.first_name} #{@player.last_name}"
end
def contract_status_badge
if @player.free_agent?
{ text: "Free Agent", color: "green" }
elsif @player.contract_years_remaining <= 1
{ text: "Expiring", color: "yellow" }
else
{ text: "Under Contract", color: "gray" }
end
end
def can_edit?
@current_user.admin? || @current_user.team == @player.team
end
def formatted_salary
ActiveSupport::NumberHelper.number_to_currency(@player.salary)
end
end
<%# Clean view %>
<h1><%= @presenter.display_name %></h1>
<% badge = @presenter.contract_status_badge %>
<%= render BadgeComponent.new(text: badge[:text], color: badge[:color]) %>
<%= @presenter.formatted_salary %>
<% if @presenter.can_edit? %>
<%= link_to "Edit", edit_player_path(@player) %>
<% end %>
Rails helpers that return HTML are hard to test, hard to read, and hard to compose. Move them to ViewComponents.
# Before — helper returning HTML (bad)
module PlayersHelper
def player_avatar(player, size: :md)
sizes = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }
if player.avatar.attached?
image_tag player.avatar, class: "rounded-full #{sizes[size]}"
else
content_tag :div, player.initials,
class: "rounded-full #{sizes[size]} bg-gray-300 flex items-center justify-center"
end
end
end
# After — ViewComponent (good)
# app/components/avatar_component.rb
class AvatarComponent < ViewComponent::Base
SIZES = { sm: "w-8 h-8", md: "w-12 h-12", lg: "w-16 h-16" }.freeze
def initialize(player:, size: :md)
@player = player
@size = size
end
# ... with its own template and tests
end
Keep helpers for non-HTML formatting utilities only (number formatting, date formatting, text truncation).
remote: true forms, helpers returning HTML, and deeply nested partials.remote: true and AJAX — Turbo handles forms and links natively. Convert to Turbo Frames and Turbo Streams.count or any?. Use the presenter or controller.content_for for complex logic — it creates invisible dependencies between layouts and views.html_safe or raw unless you are certain the content is sanitized.render partial: inside loops — use collection rendering instead.