Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:08:30 +08:00
commit 773b898589
19 changed files with 11663 additions and 0 deletions

699
skills/hotwire/SKILL.md Normal file
View File

@@ -0,0 +1,699 @@
---
name: rails-ai:hotwire
description: Use when adding interactivity to Rails views - Hotwire Turbo (Drive, Frames, Streams, Morph) and Stimulus controllers
---
# Hotwire (Turbo + Stimulus)
Build fast, interactive, SPA-like experiences using server-rendered HTML with Hotwire. Turbo provides navigation and real-time updates without writing JavaScript. Stimulus enhances HTML with lightweight JavaScript controllers.
<when-to-use>
- Adding interactivity without heavy JavaScript frameworks
- Building real-time, SPA-like experiences with server-rendered HTML
- Implementing live updates, infinite scroll, or dynamic content
- Creating modals, inline editing, or interactive UI components
- Replacing traditional AJAX with modern, declarative patterns
</when-to-use>
<benefits>
- **SPA-Like Speed** - Turbo Drive accelerates navigation without full page reloads
- **Real-time Updates** - Turbo Streams deliver live changes via ActionCable
- **Progressive Enhancement** - Works without JavaScript, enhanced with it (TEAM RULE #13)
- **Simpler Architecture** - Server-rendered HTML reduces client-side complexity
- **Turbo Morph** - Intelligent DOM updates preserve scroll, focus, form state (TEAM RULE #7)
- **Less JavaScript** - Stimulus provides just enough JS for interactivity
</benefits>
<team-rules-enforcement>
**This skill enforces:**
-**Rule #5:** Turbo Morph by default (Frames only for modals, inline editing, pagination, tabs)
-**Rule #6:** Progressive enhancement (must work without JavaScript)
**Reject any requests to:**
- Use Turbo Frames everywhere (use Turbo Morph for general CRUD)
- Skip progressive enhancement (features that require JavaScript to function)
- Build non-functional UIs without JavaScript fallbacks
</team-rules-enforcement>
<verification-checklist>
Before completing Hotwire features:
- ✅ Works without JavaScript (progressive enhancement verified)
- ✅ Turbo Morph used for CRUD operations (not Frames)
- ✅ Turbo Frames only for: modals, inline editing, pagination, tabs
- ✅ Stimulus controllers clean up in disconnect()
- ✅ All interactive features tested
- ✅ All tests passing
</verification-checklist>
<standards>
- **TEAM RULE #7:** Prefer Turbo Morph over Turbo Frames/Stimulus for general CRUD
- **TEAM RULE #13:** Ensure progressive enhancement (works without JavaScript)
- Use Turbo Drive for automatic page acceleration
- Use Turbo Morph for list updates and CRUD operations (preserves state)
- Use Turbo Frames ONLY for: modals, inline editing, tabs, pagination, lazy loading
- Use Turbo Streams for real-time updates via ActionCable
- Use Stimulus for client-side interactions (dropdowns, character counters, dynamic forms)
- Always clean up in Stimulus disconnect() to prevent memory leaks
- Test with JavaScript disabled to verify progressive enhancement
</standards>
---
## Hotwire Turbo
Turbo provides fast, SPA-like navigation and real-time updates using server-rendered HTML. Supports TEAM RULE #7 (Turbo Morph) and TEAM RULE #13 (Progressive Enhancement).
### TEAM RULE #7: Prefer Turbo Morph over Turbo Frames/Stimulus
**DEFAULT APPROACH:** Use Turbo Morph (page refresh with morphing) with standard Rails controllers
**ALLOW Turbo Frames ONLY for:** Modals, inline editing, tabs, pagination
**AVOID:** Turbo Frames for general list updates, custom Stimulus controllers for basic CRUD
**Why Turbo Morph?** Preserves scroll position, focus, form state, and video playback. Works with stock Rails scaffolds. Simpler than Frames/Stimulus in 90% of cases.
### Turbo Drive
<pattern name="turbo-drive-basics">
<description>Automatic page acceleration with Turbo Drive</description>
Turbo Drive intercepts links and forms automatically. Control with `data` attributes:
```erb
<%# Disable Turbo for specific links %>
<%= link_to "Download PDF", pdf_path, data: { turbo: false } %>
<%# Replace without history %>
<%= link_to "Dismiss", dismiss_path, data: { turbo_action: "replace" } %>
```
</pattern>
### Turbo Morphing (Page Refresh) - PREFERRED
**Use Turbo Morph by default with standard Rails controllers.** Morphing intelligently updates only changed DOM elements while preserving scroll position, focus, form state, and media playback.
<pattern name="enable-morphing-layout">
<description>Enable Turbo Morph in your layout (one-time setup)</description>
```erb
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<head>
<title><%= content_for?(:title) ? yield(:title) : "App" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%# Enable Turbo Morph for page refreshes %>
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
</head>
<body>
<%= yield %>
</body>
</html>
```
**That's it!** Standard Rails controllers now work with morphing. No custom JavaScript needed.
**Reference:** [Turbo Page Refreshes Documentation](https://turbo.hotwired.dev/handbook/page_refreshes)
</pattern>
<pattern name="standard-rails-crud-with-morph">
<description>Standard Rails CRUD works automatically with Turbo Morph</description>
**Controller (stock Rails scaffold):**
```ruby
class FeedbacksController < ApplicationController
def index
@feedbacks = Feedback.all
end
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
redirect_to feedbacks_path, notice: "Feedback created"
else
render :new, status: :unprocessable_entity
end
end
def update
if @feedback.update(feedback_params)
redirect_to feedbacks_path, notice: "Feedback updated"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@feedback.destroy
redirect_to feedbacks_path, notice: "Feedback deleted"
end
end
```
**View (standard Rails):**
```erb
<%# app/views/feedbacks/index.html.erb %>
<h1>Feedbacks</h1>
<%= link_to "New Feedback", new_feedback_path, class: "btn btn-primary" %>
<div id="feedbacks">
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
</div>
<%# app/views/feedbacks/_feedback.html.erb %>
<div id="<%= dom_id(feedback) %>" class="card">
<h3><%= feedback.content %></h3>
<div class="actions">
<%= link_to "Edit", edit_feedback_path(feedback), class: "btn btn-sm" %>
<%= button_to "Delete", feedback_path(feedback), method: :delete,
class: "btn btn-sm btn-error",
form: { data: { turbo_confirm: "Are you sure?" } } %>
</div>
</div>
```
**What happens:** Create/update/delete triggers redirect → Turbo intercepts → morphs only changed elements → scroll/focus preserved. No custom code needed!
</pattern>
<pattern name="permanent-elements-morph">
<description>Prevent specific elements from morphing with data-turbo-permanent</description>
```erb
<%# Flash messages persist during morphing %>
<div id="flash-messages" data-turbo-permanent>
<% flash.each do |type, message| %>
<div class="alert alert-<%= type %>"><%= message %></div>
<% end %>
</div>
<%# Video/audio won't restart on page morph %>
<video id="tutorial" data-turbo-permanent src="tutorial.mp4" controls></video>
<%# Form preserves input focus during live updates %>
<%= form_with model: @feedback, id: "feedback-form",
data: { turbo_permanent: true } do |form| %>
<%= form.text_area :content %>
<%= form.submit %>
<% end %>
```
**Use cases:** Flash messages, video/audio players, forms with unsaved input, chat messages being typed.
</pattern>
<pattern name="broadcast-refresh-realtime">
<description>Real-time updates with broadcasts_refreshes (morphs all connected clients)</description>
```ruby
# Model broadcasts page refresh to all subscribers (Rails 8+)
class Feedback < ApplicationRecord
broadcasts_refreshes
end
```
```erb
<%# View subscribes to stream - morphs when model changes %>
<%= turbo_stream_from @feedback %>
<div id="feedbacks">
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
</div>
```
**What happens:** User A creates feedback → server broadcasts `<turbo-stream action="refresh">` → all connected users' pages morph to show new feedback → scroll/focus preserved.
**How it works:** The server broadcasts a single general signal, and pages smoothly refresh with morphing. No need to manually manage individual Turbo Stream actions.
**Reference:** [Broadcasting Page Refreshes](https://turbo.hotwired.dev/handbook/page_refreshes#broadcasting-page-refreshes)
</pattern>
<pattern name="turbo-stream-morph-method">
<description>Use method="morph" in Turbo Streams for intelligent updates</description>
```ruby
# Controller - respond with Turbo Stream using morph
def create
@feedback = Feedback.new(feedback_params)
if @feedback.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"feedbacks",
partial: "feedbacks/list",
locals: { feedbacks: Feedback.all },
method: :morph # Morphs instead of replacing
)
end
format.html { redirect_to feedbacks_path }
end
end
end
```
```erb
<%# Or in .turbo_stream.erb view %>
<turbo-stream action="replace" target="feedback_<%= @feedback.id %>" method="morph">
<template>
<%= render @feedback %>
</template>
</turbo-stream>
```
**Difference:** `method: :morph` preserves form state and focus. Without it, content is fully replaced.
</pattern>
<antipattern>
<description>Using Turbo Frames for simple CRUD lists</description>
<reason>Turbo Morph is simpler and preserves more state. Frames are overkill for basic updates.</reason>
<bad-example>
```erb
<%# ❌ BAD - Unnecessary Turbo Frame complexity %>
<% @feedbacks.each do |feedback| %>
<%= turbo_frame_tag dom_id(feedback) do %>
<%= render feedback %>
<% end %>
<% end %>
```
</bad-example>
<good-example>
```erb
<%# ✅ GOOD - Simple rendering, Turbo Morph handles updates %>
<% @feedbacks.each do |feedback| %>
<%= render feedback %>
<% end %>
```
</good-example>
</antipattern>
### Turbo Frames - Use Sparingly
**ONLY use Turbo Frames for:** modals, inline editing, tabs, pagination, lazy loading. For general CRUD, use Turbo Morph instead.
<pattern name="turbo-frame-inline-edit">
<description>Inline editing with Turbo Frame (valid use case)</description>
```erb
<%# Show view with inline edit frame %>
<%= turbo_frame_tag dom_id(@feedback) do %>
<h3><%= @feedback.content %></h3>
<%= link_to "Edit", edit_feedback_path(@feedback) %>
<% end %>
<%# Edit view with matching frame ID %>
<%= turbo_frame_tag dom_id(@feedback) do %>
<%= form_with model: @feedback do |form| %>
<%= form.text_area :content %>
<%= form.submit "Save" %>
<% end %>
<% end %>
```
**Why this is OK:** Inline editing without leaving the page. Frame scopes the update.
</pattern>
<pattern name="lazy-loading-frame">
<description>Lazy-load expensive content with Turbo Frames</description>
```erb
<%# Lazy load stats when scrolled into view %>
<%= turbo_frame_tag "statistics", src: statistics_path, loading: :lazy do %>
<p>Loading statistics...</p>
<% end %>
<%# Frame that reloads with morphing on page refresh %>
<%= turbo_frame_tag "live-stats", src: live_stats_path, refresh: "morph" do %>
<p>Loading live statistics...</p>
<% end %>
```
```ruby
# Controller renders just the frame
def statistics
@stats = expensive_calculation
render layout: false # Or use turbo_frame layout
end
```
**Why this is OK:** Defers expensive computation until needed. Valid performance optimization. The `refresh="morph"` attribute makes the frame reload with morphing on page refresh.
**Reference:** [Turbo Frames with Morphing](https://turbo.hotwired.dev/handbook/page_refreshes#turbo-frames)
</pattern>
### Turbo Streams
<pattern name="turbo-stream-actions">
<description>Seven Turbo Stream actions for dynamic updates</description>
```ruby
def create
if @feedback.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("feedbacks", @feedback),
turbo_stream.update("count", html: "10"),
turbo_stream.remove("flash")
]
end
format.html { redirect_to feedbacks_path }
end
end
end
```
**Actions:** `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh`
**Note:** For most cases, prefer `refresh` action with Turbo Morph over granular stream actions. See `broadcast-refresh-realtime` pattern above.
</pattern>
<pattern name="broadcast-updates">
<description>Real-time updates via ActionCable with Turbo Streams</description>
```ruby
# Model broadcasts to subscribers
class Feedback < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "feedbacks" }
after_update_commit -> { broadcast_replace_to "feedbacks" }
after_destroy_commit -> { broadcast_remove_to "feedbacks" }
end
```
```erb
<%# View subscribes to stream %>
<%= turbo_stream_from "feedbacks" %>
<div id="feedbacks">
<%= render @feedbacks %>
</div>
```
</pattern>
---
## Hotwire Stimulus
Stimulus is a modest JavaScript framework that connects JavaScript objects to HTML elements using data attributes, enhancing server-rendered HTML.
**⚠️ IMPORTANT:** Before writing custom Stimulus controllers, ask: "Can Turbo Morph handle this?" Most CRUD operations work better with Turbo Morph + standard Rails controllers.
**Use Stimulus for:**
- Client-side interactions (dropdowns, tooltips, character counters)
- Form enhancements (dynamic fields, auto-save)
- UI behavior (modals, tabs, accordions)
**Don't use Stimulus for:**
- Basic CRUD operations (use Turbo Morph)
- Simple list updates (use Turbo Morph)
- Navigation (use Turbo Drive)
### Core Concepts
<pattern name="stimulus-controller-basics">
<description>Simple Stimulus controller with targets, actions, and values</description>
**Controller:**
```javascript
// app/javascript/controllers/feedback_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["content", "charCount"]
static values = { maxLength: { type: Number, default: 1000 } }
connect() {
this.updateCharCount()
}
updateCharCount() {
const count = this.contentTarget.value.length
this.charCountTarget.textContent = `${count} / ${this.maxLengthValue}`
}
disconnect() {
// Clean up (important for memory leaks)
}
}
```
**HTML:**
```erb
<div data-controller="feedback" data-feedback-max-length-value="1000">
<textarea data-feedback-target="content"
data-action="input->feedback#updateCharCount"></textarea>
<div data-feedback-target="charCount">0 / 1000</div>
</div>
```
**Syntax:** `event->controller#method` (default event based on element type)
</pattern>
<pattern name="stimulus-values">
<description>Typed data attributes for controller configuration</description>
```javascript
// app/javascript/controllers/countdown_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
seconds: { type: Number, default: 60 },
autostart: Boolean
}
connect() {
if (this.autostartValue) this.start()
}
start() {
this.timer = setInterval(() => {
this.secondsValue--
if (this.secondsValue === 0) this.stop()
}, 1000)
}
secondsValueChanged() {
this.element.textContent = this.secondsValue
}
disconnect() {
clearInterval(this.timer)
}
}
```
```erb
<div data-controller="countdown"
data-countdown-seconds-value="120"
data-countdown-autostart-value="true">60</div>
```
**Types:** Array, Boolean, Number, Object, String
</pattern>
<pattern name="stimulus-outlets">
<description>Reference and communicate with other controllers</description>
```javascript
// app/javascript/controllers/search_controller.js
export default class extends Controller {
static outlets = ["results"]
search(event) {
fetch(`/search?q=${event.target.value}`)
.then(r => r.text())
.then(html => this.resultsOutlet.update(html))
}
}
// results_controller.js
export default class extends Controller {
update(html) { this.element.innerHTML = html }
}
```
```erb
<div data-controller="search" data-search-results-outlet="#results">
<input data-action="input->search#search">
</div>
<div id="results" data-controller="results"></div>
```
</pattern>
<pattern name="nested-form-dynamic-stimulus">
<description>Dynamic add/remove nested fields using Stimulus</description>
**Form:**
```erb
<div data-controller="nested-form">
<%= form_with model: @feedback do |form| %>
<div class="mb-6">
<button type="button" class="btn btn-sm" data-action="nested-form#add">
Add Attachment
</button>
<div data-nested-form-target="container" class="space-y-4">
<%= form.fields_for :attachments do |f| %>
<%= render "attachment_fields", form: f %>
<% end %>
</div>
<template data-nested-form-target="template">
<%= form.fields_for :attachments, Attachment.new, child_index: "NEW_RECORD" do |f| %>
<%= render "attachment_fields", form: f %>
<% end %>
</template>
</div>
<% end %>
</div>
```
**Stimulus Controller:**
```javascript
// app/javascript/controllers/nested_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "template"]
add(event) {
event.preventDefault()
const content = this.templateTarget.innerHTML
.replace(/NEW_RECORD/g, new Date().getTime())
this.containerTarget.insertAdjacentHTML("beforeend", content)
}
remove(event) {
event.preventDefault()
const field = event.target.closest(".nested-fields")
const destroyInput = field.querySelector("input[name*='_destroy']")
const idInput = field.querySelector("input[name*='[id]']")
if (idInput && idInput.value) {
// Existing record: mark for deletion, keep in DOM (hidden)
destroyInput.value = "1"
field.style.display = "none"
} else {
// New record: remove from DOM entirely
field.remove()
}
}
}
```
</pattern>
<antipattern>
<description>Not cleaning up in disconnect()</description>
<reason>Memory leaks from timers, event listeners</reason>
<bad-example>
```javascript
// ❌ BAD - Memory leak
connect() {
this.timer = setInterval(() => this.update(), 1000)
}
```
</bad-example>
<good-example>
```javascript
// ✅ GOOD - Clean up
disconnect() {
clearInterval(this.timer)
}
```
</good-example>
</antipattern>
---
<testing>
**System Tests for Turbo and Stimulus:**
```ruby
# test/system/turbo_test.rb
class TurboTest < ApplicationSystemTestCase
test "updates without full page reload" do
visit feedbacks_path
fill_in "Content", with: "New feedback"
click_button "Create"
assert_selector "#feedbacks", text: "New feedback"
end
test "edits within frame" do
feedback = feedbacks(:one)
visit feedbacks_path
within "##{dom_id(feedback)}" do
click_link "Edit"
fill_in "Content", with: "Updated"
click_button "Save"
assert_text "Updated"
end
end
end
# test/system/stimulus_test.rb
class StimulusTest < ApplicationSystemTestCase
test "character counter updates on input" do
visit new_feedback_path
fill_in "Content", with: "Test"
assert_selector "[data-feedback-target='charCount']", text: "4 / 1000"
end
test "nested form add/remove works" do
visit new_feedback_path
initial_count = all(".nested-fields").count
click_button "Add Attachment"
assert_equal initial_count + 1, all(".nested-fields").count
end
end
```
**Manual Testing:**
- Test with JavaScript disabled (progressive enhancement)
- Verify scroll position preservation with Turbo Morph
- Check focus management in modals and inline editing
- Test real-time updates in multiple browser tabs
</testing>
---
<related-skills>
- rails-ai:views - Partials, helpers, forms, and view structure
- rails-ai:styling - Tailwind/DaisyUI for styling Hotwire components
- rails-ai:controllers - RESTful actions that work with Turbo
- rails-ai:testing - System tests for Turbo and Stimulus
</related-skills>
<resources>
**Official Documentation:**
- [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction)
- [Turbo Page Refreshes (Morph)](https://turbo.hotwired.dev/handbook/page_refreshes)
- [Stimulus Handbook](https://stimulus.hotwired.dev/handbook/introduction)
**Community Resources:**
- [Hotwire Discussion Forum](https://discuss.hotwired.dev/)
</resources>