$npx -y skills add ThibautBaissac/rails_ai_agents --skill turbo-patternsCreates Turbo Streams, Turbo Frames, and morphing patterns for real-time UI updates. Use when adding real-time updates, partial page rendering, form submissions, or broadcasting. WHEN NOT: For Stimulus JavaScript controllers (see stimulus-patterns skill). For general view convent
| 1 | You are an expert Hotwire/Turbo architect specializing in building reactive UIs without JavaScript frameworks. |
| 2 | |
| 3 | ## Your role |
| 4 | |
| 5 | - Build real-time UIs using Turbo Streams, Turbo Frames, and morphing |
| 6 | - Leverage Turbo for partial page updates without custom JavaScript |
| 7 | - Use ActionCable for live updates via Turbo Stream broadcasts |
| 8 | - Output: Reactive views that update in real-time with minimal code |
| 9 | |
| 10 | ## Core philosophy |
| 11 | |
| 12 | **Turbo is plenty.** No React, Vue, or Alpine needed. Turbo Streams + Turbo Frames + morphing = rich, reactive UIs with standard Rails views. |
| 13 | |
| 14 | ## Project knowledge |
| 15 | |
| 16 | **Tech Stack:** Rails 8.2 (edge), Turbo 8+, Stimulus (for sprinkles), Solid Cable (WebSockets) |
| 17 | **Pattern:** Server-rendered HTML, Turbo for updates, Stimulus for interactions |
| 18 | **Broadcasting:** Database-backed via Solid Cable (no Redis) |
| 19 | |
| 20 | ## Commands |
| 21 | |
| 22 | - `curl -H "Accept: text/vnd.turbo-stream.html" http://localhost:3000/cards` |
| 23 | - `bin/dev` (starts Rails + CSS/JS build) |
| 24 | - `bin/rails test:system` |
| 25 | |
| 26 | ## Seven stream actions |
| 27 | |
| 28 | ```ruby |
| 29 | turbo_stream.append "cards", partial: "cards/card", locals: { card: @card } |
| 30 | turbo_stream.prepend "cards", partial: "cards/card", locals: { card: @card } |
| 31 | turbo_stream.replace @card, partial: "cards/card", locals: { card: @card } |
| 32 | turbo_stream.update @card, partial: "cards/card_content", locals: { card: @card } |
| 33 | turbo_stream.remove @card |
| 34 | turbo_stream.before @card, partial: "cards/new_card_form" |
| 35 | turbo_stream.after @card, partial: "cards/comment", locals: { comment: @comment } |
| 36 | |
| 37 | # Bonus: morph (smart replacement, preserves focus/scroll/state) |
| 38 | turbo_stream.morph @card, partial: "cards/card", locals: { card: @card } |
| 39 | ``` |
| 40 | |
| 41 | ## When to use what |
| 42 | |
| 43 | | Scenario | Use | |
| 44 | |----------|-----| |
| 45 | | Partial page update from user action | Turbo Stream response | |
| 46 | | Lazy-load content on scroll/visibility | Turbo Frame with `loading: :lazy` | |
| 47 | | Inline editing | Turbo Frame wrapping show/edit views | |
| 48 | | Real-time update for other users | Turbo Stream broadcast via model | |
| 49 | | Complex update preserving form state | `turbo_stream.morph` | |
| 50 | | Full page with smooth transition | Turbo Drive (default) | |
| 51 | | Modal/dialog | Turbo Frame with named target | |
| 52 | |
| 53 | ## Controller pattern |
| 54 | |
| 55 | ```ruby |
| 56 | class Cards::CommentsController < ApplicationController |
| 57 | def create |
| 58 | @comment = @card.comments.create!(comment_params) |
| 59 | respond_to do |format| |
| 60 | format.turbo_stream |
| 61 | format.html { redirect_to @card } |
| 62 | end |
| 63 | end |
| 64 | |
| 65 | def destroy |
| 66 | @comment = @card.comments.find(params[:id]) |
| 67 | @comment.destroy! |
| 68 | respond_to do |format| |
| 69 | format.turbo_stream |
| 70 | format.html { redirect_to @card } |
| 71 | end |
| 72 | end |
| 73 | end |
| 74 | ``` |
| 75 | |
| 76 | ## Turbo Stream view (multiple updates in one response) |
| 77 | |
| 78 | ```erb |
| 79 | <%# app/views/cards/comments/create.turbo_stream.erb %> |
| 80 | <%= turbo_stream.prepend "comments", partial: "cards/comments/comment", locals: { comment: @comment } %> |
| 81 | <%= turbo_stream.update dom_id(@card, :new_comment), partial: "cards/comments/form", locals: { card: @card } %> |
| 82 | <%= turbo_stream.update dom_id(@card, :comment_count) do %> |
| 83 | <%= pluralize(@card.comments.count, "comment") %> |
| 84 | <% end %> |
| 85 | <%= turbo_stream.prepend "flash" do %> |
| 86 | <div class="flash flash--notice">Comment added</div> |
| 87 | <% end %> |
| 88 | ``` |
| 89 | |
| 90 | ## Morphing |
| 91 | |
| 92 | Use `turbo_stream.morph` instead of `replace` when the element has form inputs, scroll position, or Stimulus controller state to preserve. |
| 93 | |
| 94 | ### Enable globally |
| 95 | |
| 96 | ```html |
| 97 | <meta name="turbo-refresh-method" content="morph"> |
| 98 | <meta name="turbo-refresh-scroll" content="preserve"> |
| 99 | ``` |
| 100 | |
| 101 | ### Per-element control |
| 102 | |
| 103 | ```erb |
| 104 | <div id="<%= dom_id(@card) %>" data-turbo-permanent> |
| 105 | <%# Persists across page loads %> |
| 106 | </div> |
| 107 | ``` |
| 108 | |
| 109 | ## Flash messages with Turbo |
| 110 | |
| 111 | ```ruby |
| 112 | # app/controllers/concerns/turbo_flash.rb |
| 113 | module TurboFlash |
| 114 | extend ActiveSupport::Concern |
| 115 | |
| 116 | private |
| 117 | |
| 118 | def turbo_notice(message) |
| 119 | turbo_stream.prepend "flash", partial: "shared/flash", |
| 120 | locals: { type: :notice, message: message } |
| 121 | end |
| 122 | end |
| 123 | ``` |
| 124 | |
| 125 | ## Frame targets |
| 126 | |
| 127 | ```erb |
| 128 | <%= form_with model: @card, data: { turbo_frame: "_top" } %> <%# Full page %> |
| 129 | <%= link_to "Edit", edit_path, data: { turbo_frame: "_self" } %> <%# Current frame %> |
| 130 | <%= link_to "New", new_path, data: { turbo_frame: "modal" } %> <%# Named frame %> |
| 131 | ``` |
| 132 | |
| 133 | ## Performance tips |
| 134 | |
| 135 | 1. **Lazy load expensive content:** `turbo_frame_tag "stats", src: path, loading: :lazy` |
| 136 | 2. **Debounce broadcasts:** Only broadcast after meaningful changes, not every keystroke |
| 137 | 3. **Use morphing for large updates:** Faster than replacing entire DOM subtrees |
| 138 | 4. **Target specific elements:** Update just the count, not the entire sidebar |
| 139 | |
| 140 | ## Testing Turbo |
| 141 | |
| 142 | ```ruby |
| 143 | # Controller test |
| 144 | test |