ariadna 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/ariadna.gemspec +0 -1
  3. data/data/agents/ariadna-codebase-mapper.md +34 -722
  4. data/data/agents/ariadna-debugger.md +44 -1139
  5. data/data/agents/ariadna-executor.md +75 -396
  6. data/data/agents/ariadna-planner.md +78 -1215
  7. data/data/agents/ariadna-roadmapper.md +55 -582
  8. data/data/agents/ariadna-verifier.md +60 -702
  9. data/data/ariadna/templates/config.json +8 -33
  10. data/data/ariadna/workflows/debug.md +28 -0
  11. data/data/ariadna/workflows/execute-phase.md +31 -513
  12. data/data/ariadna/workflows/map-codebase.md +20 -319
  13. data/data/ariadna/workflows/new-milestone.md +20 -365
  14. data/data/ariadna/workflows/new-project.md +19 -880
  15. data/data/ariadna/workflows/plan-phase.md +24 -443
  16. data/data/ariadna/workflows/progress.md +20 -376
  17. data/data/ariadna/workflows/quick.md +19 -221
  18. data/data/ariadna/workflows/roadmap-ops.md +28 -0
  19. data/data/ariadna/workflows/verify-work.md +23 -560
  20. data/data/commands/ariadna/add-phase.md +11 -22
  21. data/data/commands/ariadna/debug.md +11 -143
  22. data/data/commands/ariadna/execute-phase.md +12 -30
  23. data/data/commands/ariadna/insert-phase.md +7 -14
  24. data/data/commands/ariadna/map-codebase.md +16 -49
  25. data/data/commands/ariadna/new-milestone.md +12 -25
  26. data/data/commands/ariadna/new-project.md +22 -26
  27. data/data/commands/ariadna/plan-phase.md +13 -22
  28. data/data/commands/ariadna/progress.md +16 -6
  29. data/data/commands/ariadna/quick.md +9 -11
  30. data/data/commands/ariadna/remove-phase.md +9 -12
  31. data/data/commands/ariadna/verify-work.md +14 -19
  32. data/data/skills/rails-backend/API.md +138 -0
  33. data/data/skills/rails-backend/CONTROLLERS.md +154 -0
  34. data/data/skills/rails-backend/JOBS.md +132 -0
  35. data/data/skills/rails-backend/MODELS.md +213 -0
  36. data/data/skills/rails-backend/SKILL.md +169 -0
  37. data/data/skills/rails-frontend/ASSETS.md +154 -0
  38. data/data/skills/rails-frontend/COMPONENTS.md +253 -0
  39. data/data/skills/rails-frontend/SKILL.md +187 -0
  40. data/data/skills/rails-frontend/VIEWS.md +168 -0
  41. data/data/skills/rails-performance/PROFILING.md +106 -0
  42. data/data/skills/rails-performance/SKILL.md +217 -0
  43. data/data/skills/rails-security/AUDIT.md +118 -0
  44. data/data/skills/rails-security/SKILL.md +422 -0
  45. data/data/skills/rails-testing/FIXTURES.md +78 -0
  46. data/data/skills/rails-testing/SKILL.md +160 -0
  47. data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
  48. data/lib/ariadna/installer.rb +11 -15
  49. data/lib/ariadna/tools/cli.rb +0 -12
  50. data/lib/ariadna/tools/config_manager.rb +10 -72
  51. data/lib/ariadna/tools/frontmatter.rb +23 -1
  52. data/lib/ariadna/tools/init.rb +201 -401
  53. data/lib/ariadna/tools/model_profiles.rb +6 -14
  54. data/lib/ariadna/tools/phase_manager.rb +1 -10
  55. data/lib/ariadna/tools/state_manager.rb +170 -451
  56. data/lib/ariadna/tools/template_filler.rb +4 -12
  57. data/lib/ariadna/tools/verification.rb +21 -399
  58. data/lib/ariadna/uninstaller.rb +9 -0
  59. data/lib/ariadna/version.rb +1 -1
  60. data/lib/ariadna.rb +1 -0
  61. metadata +20 -91
  62. data/data/agents/ariadna-backend-executor.md +0 -261
  63. data/data/agents/ariadna-frontend-executor.md +0 -259
  64. data/data/agents/ariadna-integration-checker.md +0 -418
  65. data/data/agents/ariadna-phase-researcher.md +0 -469
  66. data/data/agents/ariadna-plan-checker.md +0 -622
  67. data/data/agents/ariadna-project-researcher.md +0 -618
  68. data/data/agents/ariadna-research-synthesizer.md +0 -236
  69. data/data/agents/ariadna-test-executor.md +0 -266
  70. data/data/ariadna/references/checkpoints.md +0 -772
  71. data/data/ariadna/references/continuation-format.md +0 -249
  72. data/data/ariadna/references/decimal-phase-calculation.md +0 -65
  73. data/data/ariadna/references/git-integration.md +0 -248
  74. data/data/ariadna/references/git-planning-commit.md +0 -38
  75. data/data/ariadna/references/model-profile-resolution.md +0 -32
  76. data/data/ariadna/references/model-profiles.md +0 -73
  77. data/data/ariadna/references/phase-argument-parsing.md +0 -61
  78. data/data/ariadna/references/planning-config.md +0 -194
  79. data/data/ariadna/references/questioning.md +0 -153
  80. data/data/ariadna/references/rails-conventions.md +0 -416
  81. data/data/ariadna/references/tdd.md +0 -267
  82. data/data/ariadna/references/ui-brand.md +0 -160
  83. data/data/ariadna/references/verification-patterns.md +0 -853
  84. data/data/ariadna/templates/codebase/architecture.md +0 -481
  85. data/data/ariadna/templates/codebase/concerns.md +0 -380
  86. data/data/ariadna/templates/codebase/conventions.md +0 -434
  87. data/data/ariadna/templates/codebase/integrations.md +0 -328
  88. data/data/ariadna/templates/codebase/stack.md +0 -189
  89. data/data/ariadna/templates/codebase/structure.md +0 -418
  90. data/data/ariadna/templates/codebase/testing.md +0 -606
  91. data/data/ariadna/templates/context.md +0 -283
  92. data/data/ariadna/templates/continue-here.md +0 -78
  93. data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
  94. data/data/ariadna/templates/phase-prompt.md +0 -609
  95. data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
  96. data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
  97. data/data/ariadna/templates/research-project/FEATURES.md +0 -168
  98. data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
  99. data/data/ariadna/templates/research-project/STACK.md +0 -251
  100. data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
  101. data/data/ariadna/templates/state.md +0 -176
  102. data/data/ariadna/templates/summary-complex.md +0 -59
  103. data/data/ariadna/templates/summary-minimal.md +0 -41
  104. data/data/ariadna/templates/summary-standard.md +0 -48
  105. data/data/ariadna/templates/user-setup.md +0 -310
  106. data/data/ariadna/workflows/add-phase.md +0 -111
  107. data/data/ariadna/workflows/add-todo.md +0 -157
  108. data/data/ariadna/workflows/audit-milestone.md +0 -241
  109. data/data/ariadna/workflows/check-todos.md +0 -176
  110. data/data/ariadna/workflows/complete-milestone.md +0 -644
  111. data/data/ariadna/workflows/diagnose-issues.md +0 -219
  112. data/data/ariadna/workflows/discovery-phase.md +0 -289
  113. data/data/ariadna/workflows/discuss-phase.md +0 -408
  114. data/data/ariadna/workflows/execute-plan.md +0 -448
  115. data/data/ariadna/workflows/help.md +0 -470
  116. data/data/ariadna/workflows/insert-phase.md +0 -129
  117. data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
  118. data/data/ariadna/workflows/pause-work.md +0 -122
  119. data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
  120. data/data/ariadna/workflows/remove-phase.md +0 -154
  121. data/data/ariadna/workflows/research-phase.md +0 -74
  122. data/data/ariadna/workflows/resume-project.md +0 -306
  123. data/data/ariadna/workflows/set-profile.md +0 -80
  124. data/data/ariadna/workflows/settings.md +0 -145
  125. data/data/ariadna/workflows/transition.md +0 -493
  126. data/data/ariadna/workflows/update.md +0 -212
  127. data/data/ariadna/workflows/verify-phase.md +0 -226
  128. data/data/commands/ariadna/add-todo.md +0 -42
  129. data/data/commands/ariadna/audit-milestone.md +0 -42
  130. data/data/commands/ariadna/check-todos.md +0 -41
  131. data/data/commands/ariadna/complete-milestone.md +0 -136
  132. data/data/commands/ariadna/discuss-phase.md +0 -86
  133. data/data/commands/ariadna/help.md +0 -22
  134. data/data/commands/ariadna/list-phase-assumptions.md +0 -50
  135. data/data/commands/ariadna/pause-work.md +0 -35
  136. data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
  137. data/data/commands/ariadna/reapply-patches.md +0 -110
  138. data/data/commands/ariadna/research-phase.md +0 -187
  139. data/data/commands/ariadna/resume-work.md +0 -40
  140. data/data/commands/ariadna/set-profile.md +0 -34
  141. data/data/commands/ariadna/settings.md +0 -36
  142. data/data/commands/ariadna/update.md +0 -37
  143. data/data/guides/backend.md +0 -3069
  144. data/data/guides/frontend.md +0 -1479
  145. data/data/guides/performance.md +0 -1193
  146. data/data/guides/security.md +0 -1522
  147. data/data/guides/style-guide.md +0 -1091
  148. data/data/guides/testing.md +0 -504
  149. data/data/templates.md +0 -94
@@ -1,1479 +0,0 @@
1
- # Frontend Patterns
2
-
3
- **View Layer Conventions for Rails Applications**
4
-
5
- This guide covers the frontend and view layer patterns used in Rails applications: the Presenter Pattern, Turbo (Drive, Frames, Streams), Stimulus controllers, and view template conventions.
6
-
7
- **Related guides:**
8
- - [Backend Patterns](backend.md) — Architecture, models, controllers, jobs, style guide
9
- - [Testing Patterns](testing.md) — Testing philosophy, model/controller/job test patterns
10
- - [Security Guide](security.md) — Agent-oriented security checklist for code review
11
- - See `data/guides/style-guide.md` for CSS architecture and design tokens
12
-
13
- ## Table of Contents
14
-
15
- - [1. Presenter Pattern](#1-presenter-pattern)
16
- - [1.1 Philosophy](#11-philosophy)
17
- - [1.2 When to Create a Presenter](#12-when-to-create-a-presenter)
18
- - [1.3 Anatomy of a Presenter](#13-anatomy-of-a-presenter)
19
- - [1.4 Generating HTML in Presenters](#14-generating-html-in-presenters)
20
- - [1.5 Nested Presenters](#15-nested-presenters)
21
- - [1.6 Instantiation Patterns](#16-instantiation-patterns)
22
- - [1.7 View Usage](#17-view-usage)
23
- - [1.8 Testing Presenters](#18-testing-presenters)
24
- - [1.9 Real Examples](#19-real-examples)
25
- - [2. Turbo Streams & Turbo Frames](#2-turbo-streams--turbo-frames)
26
- - [2.1 Turbo Drive Essentials](#21-turbo-drive-essentials)
27
- - [2.2 Turbo Frames](#22-turbo-frames)
28
- - [2.3 Turbo Streams](#23-turbo-streams)
29
- - [2.4 Optimistic UI](#24-optimistic-ui)
30
- - [2.5 HTTP Response Conventions](#25-http-response-conventions)
31
- - [2.6 View Transitions](#26-view-transitions)
32
- - [3. Stimulus Controllers](#3-stimulus-controllers)
33
- - [3.1 Controller Architecture](#31-controller-architecture)
34
- - [3.2 Lifecycle](#32-lifecycle)
35
- - [3.3 Values (Reactive State)](#33-values-reactive-state)
36
- - [3.4 Targets (DOM References)](#34-targets-dom-references)
37
- - [3.5 Outlets (Controller-to-Controller Communication)](#35-outlets-controller-to-controller-communication)
38
- - [3.6 Actions & Parameters](#36-actions--parameters)
39
- - [3.7 Common Patterns](#37-common-patterns)
40
- - [4. View Templates & Partials](#4-view-templates--partials)
41
- - [4.1 ERB Conventions](#41-erb-conventions)
42
- - [4.2 Partial Extraction Rules](#42-partial-extraction-rules)
43
- - [4.3 Turbo Frame Wrapping in Views](#43-turbo-frame-wrapping-in-views)
44
- - [4.4 Template-Based DOM Patterns](#44-template-based-dom-patterns)
45
- - [4.5 Cache-Safe Views](#45-cache-safe-views)
46
-
47
- ---
48
-
49
- # 1. Presenter Pattern
50
-
51
- It uses presenter classes to encapsulate complex view logic. Unlike typical Rails apps, presenters don't live in an `app/presenters/` directory—they live in `app/models/` organized by domain, aligning with "vanilla Rails" philosophy.
52
-
53
- ## 1.1 Philosophy
54
-
55
- Presenters are plain Ruby classes that:
56
- - Package data and display logic for views
57
- - Transform domain objects into view-ready formats
58
- - Encapsulate conditional display logic
59
- - Provide cache keys for fragment caching
60
-
61
- **Why models layer?** Presenters are domain objects that know about business rules. They fit naturally alongside concerns like `User::Filtering` and `Event::Description`. No separate presenter infrastructure is needed.
62
-
63
- ## 1.2 When to Create a Presenter
64
-
65
- **Create a presenter when:**
66
- - A view needs complex conditional logic (3+ conditions)
67
- - Multiple related values need to be computed together
68
- - You need to transform data into HTML or formatted text
69
- - The same display logic is needed in multiple views
70
- - You want to cache complex view fragments
71
-
72
- **Don't create a presenter when:**
73
- - Simple delegation would suffice (use helpers)
74
- - You only need one or two computed values
75
- - The logic is purely formatting (use view helpers)
76
-
77
- ## 1.3 Anatomy of a Presenter
78
-
79
- **File**: `app/models/user/filtering.rb`
80
-
81
- ```ruby
82
- class User::Filtering
83
- attr_reader :user, :filter, :expanded
84
-
85
- delegate :as_params, :single_board, to: :filter
86
- delegate :only_closed?, to: :filter
87
-
88
- def initialize(user, filter, expanded: false)
89
- @user, @filter, @expanded = user, filter, expanded
90
- end
91
-
92
- # Memoized collections (lazy-loaded)
93
- def boards
94
- @boards ||= user.boards.ordered_by_recently_accessed
95
- end
96
-
97
- def tags
98
- @tags ||= account.tags.all.alphabetically
99
- end
100
-
101
- def users
102
- @users ||= account.users.active.alphabetically
103
- end
104
-
105
- # Boolean methods for conditional display
106
- def expanded?
107
- @expanded
108
- end
109
-
110
- def any?
111
- filter.used?(ignore_boards: true)
112
- end
113
-
114
- def show_tags?
115
- return unless Tag.any?
116
- filter.tags.any?
117
- end
118
-
119
- def show_assignees?
120
- filter.assignees.any?
121
- end
122
-
123
- # Cache key for fragment caching
124
- def cache_key
125
- ActiveSupport::Cache.expand_cache_key(
126
- [ user, filter, expanded?, boards, tags, users, filters ],
127
- "user-filtering"
128
- )
129
- end
130
-
131
- private
132
- def account
133
- user.account
134
- end
135
- end
136
- ```
137
-
138
- **Pattern breakdown:**
139
-
140
- 1. **Plain Ruby class** - No framework, no gem, no inheritance
141
- 2. **Explicit dependencies** - All inputs via constructor
142
- 3. **Memoization** - `@var ||=` for lazy-loaded collections
143
- 4. **Boolean methods** - `show_tags?`, `expanded?` for conditional display
144
- 5. **Cache key** - Composite key for fragment caching
145
- 6. **Private helpers** - Keep the interface clean
146
-
147
- ## 1.4 Generating HTML in Presenters
148
-
149
- When presenters need to generate HTML, include ActionView helpers:
150
-
151
- **File**: `app/models/event/description.rb`
152
-
153
- ```ruby
154
- class Event::Description
155
- include ActionView::Helpers::TagHelper # ← For tag.span, etc.
156
- include ERB::Util # ← For h() escaping
157
-
158
- attr_reader :event, :user
159
-
160
- def initialize(event, user)
161
- @event = event
162
- @user = user
163
- end
164
-
165
- def to_html
166
- to_sentence(creator_tag, card_title_tag).html_safe
167
- end
168
-
169
- def to_plain_text
170
- to_sentence(creator_name, quoted(card.title))
171
- end
172
-
173
- private
174
- def creator_tag
175
- tag.span data: { creator_id: event.creator.id } do
176
- tag.span("You", data: { only_visible_to_you: true }) +
177
- tag.span(event.creator.name, data: { only_visible_to_others: true })
178
- end
179
- end
180
-
181
- def card_title_tag
182
- tag.span card.title, class: "txt-underline"
183
- end
184
-
185
- # ... action-specific sentence methods ...
186
- end
187
- ```
188
-
189
- **Key patterns:**
190
- - `to_html` / `to_plain_text` for multiple output formats
191
- - Include only the helpers you need
192
- - Use `h()` for escaping user content
193
- - Keep HTML generation in private methods
194
-
195
- ## 1.5 Nested Presenters
196
-
197
- Presenters can create other presenters for sub-components:
198
-
199
- **File**: `app/models/user/day_timeline.rb`
200
-
201
- ```ruby
202
- class User::DayTimeline
203
- def added_column
204
- @added_column ||= build_column(:added, "Added", 1,
205
- events.where(action: %w[card_published card_reopened]))
206
- end
207
-
208
- def updated_column
209
- @updated_column ||= build_column(:updated, "Updated", 2,
210
- events.where.not(action: %w[card_published card_closed card_reopened]))
211
- end
212
-
213
- def closed_column
214
- @closed_column ||= build_column(:closed, "Done", 3,
215
- events.where(action: "card_closed"))
216
- end
217
-
218
- private
219
- def build_column(id, base_title, index, events)
220
- Column.new(self, id, base_title, index, events) # ← Nested presenter
221
- end
222
- end
223
- ```
224
-
225
- **File**: `app/models/user/day_timeline/column.rb`
226
-
227
- ```ruby
228
- class User::DayTimeline::Column
229
- include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper
230
-
231
- def title
232
- date_tag = local_datetime_tag(day_timeline.day, style: :agoorweekday)
233
- parts = [ base_title, date_tag ]
234
- parts << tag.span("(#{full_events_count})", class: "font-weight-normal") if full_events_count > 0
235
- safe_join(parts, " ")
236
- end
237
-
238
- def events_by_hour
239
- limited_events.group_by { it.created_at.hour }
240
- end
241
-
242
- def has_more_events?
243
- limited_events.count < full_events_count
244
- end
245
- end
246
- ```
247
-
248
- ## 1.6 Instantiation Patterns
249
-
250
- ### Pattern 1: Controller Concerns
251
-
252
- For presenters used across multiple controllers, create a concern:
253
-
254
- **File**: `app/controllers/concerns/filter_scoped.rb`
255
-
256
- ```ruby
257
- module FilterScoped
258
- extend ActiveSupport::Concern
259
-
260
- included do
261
- before_action :set_filter
262
- before_action :set_user_filtering
263
- end
264
-
265
- private
266
- def set_filter
267
- if params[:filter_id].present?
268
- @filter = Current.user.filters.find(params[:filter_id])
269
- else
270
- @filter = Current.user.filters.from_params filter_params
271
- end
272
- end
273
-
274
- def set_user_filtering
275
- @user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)
276
- end
277
- end
278
- ```
279
-
280
- **Usage in controllers:**
281
-
282
- ```ruby
283
- class CardsController < ApplicationController
284
- include FilterScoped # ← Sets @user_filtering automatically
285
-
286
- def index
287
- # @user_filtering is available
288
- end
289
- end
290
- ```
291
-
292
- ### Pattern 2: Factory Methods on Models
293
-
294
- For presenters tied to a specific model, add a factory method:
295
-
296
- **File**: `app/models/event.rb`
297
-
298
- ```ruby
299
- class Event < ApplicationRecord
300
- def description_for(user)
301
- Event::Description.new(self, user)
302
- end
303
- end
304
- ```
305
-
306
- **Usage in views:**
307
-
308
- ```erb
309
- <%= event.description_for(Current.user).to_html %>
310
- ```
311
-
312
- This keeps the API discoverable and maintains the object-oriented style.
313
-
314
- ## 1.7 View Usage
315
-
316
- **With controller-instantiated presenter:**
317
-
318
- ```erb
319
- <%# @user_filtering set by FilterScoped concern %>
320
-
321
- <% if @user_filtering.show_tags? %>
322
- <div class="filter-tags">
323
- <% @user_filtering.tags.each do |tag| %>
324
- <%= render "tag", tag: tag %>
325
- <% end %>
326
- </div>
327
- <% end %>
328
-
329
- <% if @user_filtering.show_assignees? %>
330
- <!-- assignees UI -->
331
- <% end %>
332
- ```
333
-
334
- **With factory method:**
335
-
336
- ```erb
337
- <% @events.each do |event| %>
338
- <div class="event-description">
339
- <%= event.description_for(Current.user).to_html %>
340
- </div>
341
- <% end %>
342
- ```
343
-
344
- **With fragment caching:**
345
-
346
- ```erb
347
- <% cache @user_filtering.cache_key do %>
348
- <!-- expensive view rendering -->
349
- <% end %>
350
- ```
351
-
352
- ## 1.8 Testing Presenters
353
-
354
- Test presenters like any Ruby class:
355
-
356
- ```ruby
357
- require "test_helper"
358
-
359
- class User::FilteringTest < ActiveSupport::TestCase
360
- setup do
361
- Current.session = sessions(:david)
362
- @user = users(:david)
363
- @filter = Filter.new
364
- end
365
-
366
- test "boards returns user's boards ordered by access" do
367
- filtering = User::Filtering.new(@user, @filter)
368
-
369
- assert_equal @user.boards.ordered_by_recently_accessed, filtering.boards
370
- end
371
-
372
- test "show_tags? returns false when no tags selected" do
373
- filtering = User::Filtering.new(@user, @filter)
374
-
375
- assert_not filtering.show_tags?
376
- end
377
-
378
- test "show_tags? returns true when tags present" do
379
- @filter.tags = [ tags(:bug) ]
380
- filtering = User::Filtering.new(@user, @filter)
381
-
382
- assert filtering.show_tags?
383
- end
384
-
385
- test "cache_key changes when filter changes" do
386
- filtering1 = User::Filtering.new(@user, @filter)
387
- key1 = filtering1.cache_key
388
-
389
- @filter.tags = [ tags(:bug) ]
390
- filtering2 = User::Filtering.new(@user, @filter)
391
- key2 = filtering2.cache_key
392
-
393
- assert_not_equal key1, key2
394
- end
395
- end
396
- ```
397
-
398
- For presenters that generate HTML:
399
-
400
- ```ruby
401
- class Event::DescriptionTest < ActiveSupport::TestCase
402
- test "to_html includes creator name" do
403
- event = events(:card_closed)
404
- description = Event::Description.new(event, users(:david))
405
-
406
- assert_includes description.to_html, event.creator.name
407
- end
408
-
409
- test "to_plain_text is safe for notifications" do
410
- event = events(:card_closed)
411
- description = Event::Description.new(event, users(:david))
412
-
413
- # No HTML tags in plain text
414
- assert_no_match /<[^>]+>/, description.to_plain_text
415
- end
416
- end
417
- ```
418
-
419
- ## 1.9 Real Examples
420
-
421
- | Presenter | File | Purpose |
422
- |-----------|------|---------|
423
- | `User::Filtering` | `app/models/user/filtering.rb` | Filter UI state and collections |
424
- | `Event::Description` | `app/models/event/description.rb` | Event → human-readable text |
425
- | `User::DayTimeline` | `app/models/user/day_timeline.rb` | Timeline organization |
426
- | `User::DayTimeline::Column` | `app/models/user/day_timeline/column.rb` | Timeline column with HTML generation |
427
-
428
- ### Summary
429
-
430
- Presenter pattern:
431
- - **Plain Ruby classes** in `app/models/` (no special directory)
432
- - **Domain-organized** (`User::Filtering`, not `FilteringPresenter`)
433
- - **Include ActionView helpers** when generating HTML
434
- - **Factory methods** on models for discoverable APIs
435
- - **Controller concerns** for cross-controller instantiation
436
- - **Memoization** for lazy-loaded collections
437
- - **Boolean methods** for conditional display
438
- - **Cache keys** for fragment caching
439
-
440
- ---
441
-
442
- # 2. Turbo Streams & Turbo Frames
443
-
444
- ## 2.1 Turbo Drive Essentials
445
-
446
- **Turbo Drive** intercepts link clicks and form submissions, replacing full page loads with fetch requests and DOM swaps. These lifecycle events are the integration points.
447
-
448
- **`turbo:submit-start`** / **`turbo:submit-end`** — Form activity indicators:
449
-
450
- ```javascript
451
- document.addEventListener("turbo:submit-start", (event) => {
452
- const btn = event.target.querySelector("[type=submit]")
453
- btn.disabled = true
454
- btn.textContent = "Saving..."
455
- })
456
- ```
457
-
458
- **`turbo:before-render`** — Intercept rendering. **Pausable** via `preventDefault()` + `detail.resume()`. Always guard against `data-turbo-preview` to skip animations on cached snapshots:
459
-
460
- ```javascript
461
- document.addEventListener("turbo:before-render", (event) => {
462
- if (document.documentElement.hasAttribute("data-turbo-preview")) return
463
- event.preventDefault()
464
- document.documentElement.classList.add("page-leaving")
465
- document.documentElement.addEventListener("animationend", () => event.detail.resume(), { once: true })
466
- })
467
- ```
468
-
469
- **`turbo:before-cache`** — Clean transient UI before Turbo snapshots the page. Reset widgets, close dropdowns, clear flash messages:
470
-
471
- ```javascript
472
- document.addEventListener("turbo:before-cache", () => {
473
- document.querySelectorAll("[data-dropdown-open]").forEach((el) => el.removeAttribute("data-dropdown-open"))
474
- document.querySelectorAll(".flash-message").forEach((el) => el.remove())
475
- })
476
- ```
477
-
478
- **`turbo:load`** — Page fully loaded and rendered. Equivalent to `DOMContentLoaded` for Turbo navigations.
479
-
480
- **Progress bar** — Reuse Turbo's built-in bar; style with CSS:
481
-
482
- ```css
483
- .turbo-progress-bar { height: 3px; background-color: var(--color-accent); }
484
- ```
485
-
486
- ## 2.2 Turbo Frames
487
-
488
- A **Turbo Frame** (`<turbo-frame>`) scopes navigation to a region of the page. Only the matching frame swaps on navigation.
489
-
490
- **Wrapping conventions** — Scope frame boundaries to the **smallest rerenderable unit**. Use `dom_id` for IDs:
491
-
492
- ```erb
493
- <turbo-frame id="<%= dom_id(card) %>">
494
- <%= render "cards/card", card: card %>
495
- </turbo-frame>
496
- ```
497
-
498
- **Lazy loading** — Set `loading="lazy"` with `src` to defer until the frame enters the viewport:
499
-
500
- ```erb
501
- <turbo-frame id="activity_feed" src="<%= activity_feed_path %>" loading="lazy">
502
- <p class="loading-placeholder">Loading activity...</p>
503
- </turbo-frame>
504
- ```
505
-
506
- **Tabbed navigation** — Drive a content frame from nav links with `data-turbo-frame` and `data-turbo-action="advance"` for history:
507
-
508
- ```erb
509
- <a href="<%= project_tab_path(@project, tab) %>"
510
- data-turbo-frame="tab_content"
511
- data-turbo-action="advance">
512
- <%= tab.titlecase %>
513
- </a>
514
-
515
- <turbo-frame id="tab_content"><%= yield %></turbo-frame>
516
- ```
517
-
518
- Update active state on **`turbo:frame-load`** (NOT `turbo:click` — click fires before the response):
519
-
520
- ```javascript
521
- document.addEventListener("turbo:frame-load", (event) => {
522
- if (event.target.id !== "tab_content") return
523
- document.querySelectorAll("[data-turbo-frame='tab_content']").forEach((link) => {
524
- link.classList.toggle("active", link.href === event.target.src)
525
- })
526
- })
527
- ```
528
-
529
- **Pagination with history** — Add `data-turbo-action="advance"` to pagination links so page numbers push to browser history.
530
-
531
- **Forms in frames** — HTTP status determines behavior:
532
- - **422** — Turbo swaps the response into the frame (validation errors rendered in place)
533
- - **303** — Turbo follows the redirect
534
-
535
- ```ruby
536
- def create
537
- if @card.save
538
- redirect_to @card, status: :see_other
539
- else
540
- render :new, status: :unprocessable_entity
541
- end
542
- end
543
- ```
544
-
545
- **External form controls** — Use the HTML `form` attribute on inputs outside the `<form>` tag:
546
-
547
- ```erb
548
- <form id="search_form" action="<%= search_path %>">
549
- <input type="text" name="query">
550
- </form>
551
- <select name="category" form="search_form">...</select>
552
- ```
553
-
554
- **Loading states** — Style with the `[busy]` attribute Turbo adds automatically:
555
-
556
- ```css
557
- turbo-frame[busy] { opacity: 0.5; pointer-events: none; }
558
- ```
559
-
560
- ## 2.3 Turbo Streams
561
-
562
- **Turbo Streams** deliver targeted DOM updates via `<turbo-stream>` elements.
563
-
564
- **Default actions** — Prefer the 8 built-in actions before writing custom ones: `append`, `prepend`, `replace`, `update`, `remove`, `before`, `after`, `refresh` (Turbo 8 morph).
565
-
566
- **Custom stream actions** — Register on `StreamActions`. Inside the function, **`this`** is the `<turbo-stream>` element:
567
-
568
- ```javascript
569
- import { StreamActions } from "@hotwired/turbo"
570
-
571
- StreamActions.flash = function () {
572
- const flash = document.createElement("div")
573
- flash.className = `flash flash--${this.getAttribute("type") || "notice"}`
574
- flash.textContent = this.getAttribute("message")
575
- document.getElementById("flash_container").appendChild(flash)
576
- }
577
- ```
578
-
579
- **Inline stream tags** — A `<turbo-stream>` appended to the DOM **executes immediately and self-removes**. Store in a `<template>` to prevent premature execution; clone + modify + append:
580
-
581
- ```html
582
- <template id="optimistic_card_template">
583
- <turbo-stream action="append" target="cards">
584
- <template>
585
- <div id="card_PLACEHOLDER" class="card card--optimistic">
586
- <span data-title></span>
587
- </div>
588
- </template>
589
- </turbo-stream>
590
- </template>
591
- ```
592
-
593
- ```javascript
594
- function appendOptimisticCard(id, title) {
595
- const stream = document.getElementById("optimistic_card_template").content.cloneNode(true)
596
- stream.querySelector("[id^='card_']").id = `card_${id}`
597
- stream.querySelector("[data-title]").textContent = title
598
- document.body.appendChild(stream)
599
- }
600
- ```
601
-
602
- **Broadcast patterns** — Use `after_create_commit` callbacks with broadcast helpers:
603
-
604
- ```ruby
605
- class Card < ApplicationRecord
606
- after_create_commit -> { broadcast_append_to board, target: "cards" }
607
- after_update_commit -> { broadcast_replace_to board }
608
- after_destroy_commit -> { broadcast_remove_to board }
609
- end
610
- ```
611
-
612
- **Turbo 8 morphing** — `turbo_stream.refresh` triggers a full-page morph reconciling the DOM with the server. Use after optimistic UI:
613
-
614
- ```ruby
615
- format.turbo_stream { render turbo_stream: turbo_stream.refresh }
616
- ```
617
-
618
- ## 2.4 Optimistic UI
619
-
620
- Render the expected outcome on the client before server confirmation, then reconcile.
621
-
622
- **Pattern:** Store markup in a `<template>` containing `<turbo-stream>`. On `turbo:submit-start`, clone and append. Server responds with `turbo_stream.refresh` to correct discrepancies.
623
-
624
- ```javascript
625
- document.addEventListener("turbo:submit-start", (event) => {
626
- if (event.target.id !== "new_card_form") return
627
- const title = event.target.querySelector("[name='card[title]']").value
628
- appendOptimisticCard(generateULID(), title)
629
- event.target.reset()
630
- })
631
- ```
632
-
633
- **Client-side ULID generation** for optimistic record IDs (time-ordered, collision-resistant):
634
-
635
- ```javascript
636
- function generateULID() {
637
- const time = Date.now().toString(36).padStart(10, "0")
638
- const rand = Array.from(crypto.getRandomValues(new Uint8Array(10)))
639
- .map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 16)
640
- return (time + rand).toUpperCase()
641
- }
642
- ```
643
-
644
- **Reconciliation** — Turbo 8 diffs server-rendered DOM against client state. Matching optimistic elements are preserved; mismatches are corrected.
645
-
646
- ## 2.5 HTTP Response Conventions
647
-
648
- - **422 Unprocessable Entity** — Validation failures. Turbo re-renders the form frame with errors.
649
- - **303 See Other** — Successful submissions. Turbo follows redirect with GET.
650
- - **Never return 200** for form submissions expecting a redirect.
651
- - **Always use 303** (not 301/302) — guarantees GET follow-up, prevents resubmission.
652
-
653
- ```ruby
654
- def update
655
- if @card.update(card_params)
656
- redirect_to @card, status: :see_other
657
- else
658
- render :edit, status: :unprocessable_entity
659
- end
660
- end
661
- ```
662
-
663
- ## 2.6 View Transitions
664
-
665
- Use the **View Transitions API** with Turbo for animated page transitions. Enable globally:
666
-
667
- ```erb
668
- <meta name="view-transition" content="same-origin">
669
- ```
670
-
671
- ```css
672
- ::view-transition-old(root) { animation: fade-out 150ms ease-in; }
673
- ::view-transition-new(root) { animation: fade-in 150ms ease-out; }
674
- ```
675
-
676
- **Direction-aware transitions** — Capture direction in `turbo:click`, apply in `turbo:before-render`, clean up in `turbo:load`:
677
-
678
- ```javascript
679
- let direction = "forward"
680
-
681
- document.addEventListener("turbo:click", (event) => {
682
- direction = event.target.closest("[data-direction]")?.dataset.direction || "forward"
683
- })
684
-
685
- document.addEventListener("turbo:before-render", (event) => {
686
- if (document.documentElement.hasAttribute("data-turbo-preview")) return
687
- document.documentElement.dataset.transitionDirection = direction
688
- })
689
-
690
- document.addEventListener("turbo:load", () => {
691
- delete document.documentElement.dataset.transitionDirection
692
- })
693
- ```
694
-
695
- ```css
696
- [data-transition-direction="forward"]::view-transition-old(root) { animation: slide-out-left 200ms ease-in; }
697
- [data-transition-direction="forward"]::view-transition-new(root) { animation: slide-in-right 200ms ease-out; }
698
- [data-transition-direction="backward"]::view-transition-old(root) { animation: slide-out-right 200ms ease-in; }
699
- [data-transition-direction="backward"]::view-transition-new(root) { animation: slide-in-left 200ms ease-out; }
700
- ```
701
-
702
- ### Summary
703
-
704
- - **Turbo Drive events** — `turbo:before-cache` for cleanup, `turbo:before-render` for animations (pausable), guard against `data-turbo-preview`
705
- - **Frame boundaries** match the smallest rerenderable unit; use `dom_id` for IDs
706
- - **Lazy frames** defer with `loading="lazy"` + `src`; post-load setup via `turbo:frame-load`
707
- - **Tabs and pagination** use `data-turbo-frame` + `data-turbo-action="advance"` for history
708
- - **HTTP status codes** — 422 for errors, 303 for redirects, never 200 for redirect-expecting forms
709
- - **Prefer built-in stream actions** before custom; register custom actions on `StreamActions`
710
- - **Inline streams** execute on DOM insertion and self-remove; store in `<template>` to control timing
711
- - **Optimistic UI** clones stream templates on `turbo:submit-start`, uses ULIDs, reconciles via morph
712
- - **Broadcasts** use `after_create_commit` + `broadcast_append_to` / `broadcast_replace_to`
713
- - **View Transitions** integrate through `turbo:before-render` with direction-aware CSS
714
-
715
- ---
716
-
717
- # 3. Stimulus Controllers
718
-
719
- ## 3.1 Controller Architecture
720
-
721
- **File naming** follows the Stimulus convention: `app/javascript/controllers/{name}_controller.js`. Multi-word names use kebab-case in filenames and camelCase in class names:
722
-
723
- | File | Class | Identifier |
724
- |------|-------|------------|
725
- | `upload_preview_controller.js` | `UploadPreviewController` | `upload-preview` |
726
- | `broadcast_channel_controller.js` | `BroadcastChannelController` | `broadcast-channel` |
727
- | `media_player_controller.js` | `MediaPlayerController` | `media-player` |
728
-
729
- **Registration** is automatic via `esbuild` or `importmap` conventions. Controllers placed in `app/javascript/controllers/` are auto-discovered and registered. No manual `application.register()` calls needed.
730
-
731
- **Contract-first declaration** means every controller declares its full interface at the top, before any methods. This makes controllers self-documenting and lets agents understand the API without reading implementation:
732
-
733
- ```javascript
734
- import { Controller } from "@hotwired/stimulus"
735
-
736
- export default class extends Controller {
737
- static values = { url: String, refreshInterval: Number, active: Boolean }
738
- static targets = ["output", "spinner", "emptyState"]
739
- static outlets = ["filter", "notification"]
740
- static classes = ["loading", "hidden"]
741
-
742
- // Lifecycle methods
743
- connect() { }
744
- disconnect() { }
745
-
746
- // Action methods
747
- refresh() { }
748
- toggle() { }
749
- }
750
- ```
751
-
752
- **Single-purpose controllers.** Each controller owns one behavior. A `clipboard_controller.js` copies text. A `toggle_controller.js` shows/hides elements. Compose multiple controllers on the same element rather than building one large controller:
753
-
754
- ```html
755
- <div data-controller="clipboard toggle tooltip"
756
- data-clipboard-text-value="https://example.com/share/abc123"
757
- data-toggle-class="hidden">
758
- <button data-action="clipboard#copy toggle#toggle">Copy Link</button>
759
- <span data-toggle-target="content" class="hidden">Copied!</span>
760
- </div>
761
- ```
762
-
763
- ## 3.2 Lifecycle
764
-
765
- **Symmetric setup and teardown.** Every resource acquired in `connect()` must be released in `disconnect()`. Turbo navigations and morphs trigger these repeatedly, so leaks accumulate fast:
766
-
767
- ```javascript
768
- import { Controller } from "@hotwired/stimulus"
769
-
770
- export default class extends Controller {
771
- static values = { channel: String }
772
-
773
- connect() {
774
- this.broadcast = new BroadcastChannel(this.channelValue)
775
- this.broadcast.onmessage = this.handleMessage.bind(this)
776
-
777
- this.resizeObserver = new ResizeObserver(this.handleResize.bind(this))
778
- this.resizeObserver.observe(this.element)
779
-
780
- this.refreshTimer = setInterval(() => this.refresh(), 30000)
781
- }
782
-
783
- disconnect() {
784
- this.broadcast.close()
785
-
786
- this.resizeObserver.disconnect()
787
-
788
- clearInterval(this.refreshTimer)
789
- }
790
- }
791
- ```
792
-
793
- **Common teardown checklist:**
794
-
795
- | Resource | Setup | Teardown |
796
- |----------|-------|----------|
797
- | BroadcastChannel | `new BroadcastChannel()` | `.close()` |
798
- | Blob URL | `URL.createObjectURL()` | `URL.revokeObjectURL()` |
799
- | Third-party player | `WaveSurfer.create()` | `.destroy()` |
800
- | Timer | `setInterval()` / `setTimeout()` | `clearInterval()` / `clearTimeout()` |
801
- | Observer | `.observe()` | `.disconnect()` |
802
- | EventListener (window/document) | `addEventListener()` | `removeEventListener()` |
803
-
804
- **Guard `valueChanged` callbacks.** Value callbacks can fire before `connect()` completes, which means targets or instance properties may not exist yet. Always guard:
805
-
806
- ```javascript
807
- export default class extends Controller {
808
- static values = { url: String }
809
- static targets = ["frame"]
810
-
811
- urlValueChanged(url) {
812
- // Guard — target may not be connected yet
813
- this.frameTarget?.src = url
814
- }
815
- }
816
- ```
817
-
818
- Alternatively, use an early return:
819
-
820
- ```javascript
821
- urlValueChanged(url) {
822
- if (!this.hasFrameTarget) return
823
- this.frameTarget.src = url
824
- }
825
- ```
826
-
827
- ## 3.3 Values (Reactive State)
828
-
829
- **Declaration** uses `static values` with type constructors. Stimulus handles serialization, type coercion, and default values:
830
-
831
- ```javascript
832
- export default class extends Controller {
833
- static values = {
834
- url: String, // default: ""
835
- count: Number, // default: 0
836
- active: Boolean, // default: false
837
- filters: Object, // default: {}
838
- items: Array, // default: []
839
- }
840
- }
841
- ```
842
-
843
- ```html
844
- <div data-controller="dashboard"
845
- data-dashboard-url-value="/api/stats"
846
- data-dashboard-count-value="42"
847
- data-dashboard-active-value="true"
848
- data-dashboard-filters-value='{"status":"open"}'>
849
- </div>
850
- ```
851
-
852
- **React with `{name}ValueChanged` callbacks.** These fire whenever the value changes, including the initial set from HTML attributes:
853
-
854
- ```javascript
855
- export default class extends Controller {
856
- static values = { page: Number }
857
- static targets = ["list", "counter"]
858
-
859
- pageValueChanged(current, previous) {
860
- if (previous !== undefined) {
861
- this.fetchPage(current)
862
- }
863
- this.counterTarget.textContent = `Page ${current}`
864
- }
865
-
866
- next() {
867
- this.pageValue++ // Triggers pageValueChanged automatically
868
- }
869
- }
870
- ```
871
-
872
- **Bridge third-party libraries through value callbacks.** The value becomes the single source of truth. The callback translates it into library-specific API calls:
873
-
874
- ```javascript
875
- import { Controller } from "@hotwired/stimulus"
876
- import Chart from "chart.js/auto"
877
-
878
- export default class extends Controller {
879
- static values = { type: String, data: Object, options: Object }
880
- static targets = ["canvas"]
881
-
882
- connect() {
883
- this.chart = new Chart(this.canvasTarget, {
884
- type: this.typeValue,
885
- data: this.dataValue,
886
- options: this.optionsValue
887
- })
888
- }
889
-
890
- dataValueChanged(data) {
891
- if (!this.chart) return
892
- this.chart.data = data
893
- this.chart.update()
894
- }
895
-
896
- optionsValueChanged(options) {
897
- if (!this.chart) return
898
- this.chart.options = options
899
- this.chart.update()
900
- }
901
-
902
- disconnect() {
903
- this.chart.destroy()
904
- }
905
- }
906
- ```
907
-
908
- **Values as the single source of truth.** Never duplicate state in DOM attributes, instance variables, or dataset entries alongside values. If a controller needs state, declare a value. Read state from `this.{name}Value`, mutate via `this.{name}Value = x`, and react in the callback. This keeps the data flow unidirectional and predictable.
909
-
910
- ## 3.4 Targets (DOM References)
911
-
912
- **Declaration** registers named references to child elements:
913
-
914
- ```javascript
915
- export default class extends Controller {
916
- static targets = ["input", "output", "submitButton"]
917
- }
918
- ```
919
-
920
- ```html
921
- <form data-controller="search">
922
- <input data-search-target="input" type="text">
923
- <div data-search-target="output"></div>
924
- <button data-search-target="submitButton">Search</button>
925
- </form>
926
- ```
927
-
928
- Access via `this.inputTarget` (first match), `this.inputTargets` (all matches), and `this.hasInputTarget` (existence check).
929
-
930
- **Target callbacks** fire when the DOM changes. These are essential for Turbo Stream integration — when elements are appended or removed, the controller reacts automatically:
931
-
932
- ```javascript
933
- export default class extends Controller {
934
- static targets = ["item", "counter", "emptyState"]
935
-
936
- itemTargetConnected(element) {
937
- this.updateCount()
938
- element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 })
939
- }
940
-
941
- itemTargetDisconnected(element) {
942
- this.updateCount()
943
- }
944
-
945
- updateCount() {
946
- this.counterTarget.textContent = this.itemTargets.length
947
- this.emptyStateTarget.hidden = this.itemTargets.length > 0
948
- }
949
- }
950
- ```
951
-
952
- **Keep target callbacks idempotent.** Turbo morphs and reconnections can trigger `TargetConnected` multiple times for the same element. Avoid additive side effects:
953
-
954
- ```javascript
955
- // Bad — adds duplicate listeners on reconnect
956
- itemTargetConnected(element) {
957
- element.addEventListener("click", this.handleClick)
958
- }
959
-
960
- // Good — safe across repeated connect/disconnect cycles
961
- itemTargetConnected(element) {
962
- element.handleClick ||= this.handleClick.bind(this)
963
- element.removeEventListener("click", element.handleClick)
964
- element.addEventListener("click", element.handleClick)
965
- }
966
- ```
967
-
968
- **Derive computed state from targets** rather than tracking counts or flags in separate values:
969
-
970
- ```javascript
971
- get isEmpty() {
972
- return this.itemTargets.length === 0
973
- }
974
-
975
- get selectedItems() {
976
- return this.itemTargets.filter(el => el.dataset.selected === "true")
977
- }
978
-
979
- get selectedCount() {
980
- return this.selectedItems.length
981
- }
982
- ```
983
-
984
- ## 3.5 Outlets (Controller-to-Controller Communication)
985
-
986
- **Outlets** let one controller access another controller's instance directly. Declare with `static outlets`:
987
-
988
- ```javascript
989
- // dashboard_controller.js
990
- export default class extends Controller {
991
- static outlets = ["chart", "filter"]
992
-
993
- apply() {
994
- const filters = this.filterOutlet.currentFilters
995
- this.chartOutlets.forEach(chart => chart.reload(filters))
996
- }
997
- }
998
- ```
999
-
1000
- ```html
1001
- <div data-controller="dashboard"
1002
- data-dashboard-chart-outlet=".chart-widget"
1003
- data-dashboard-filter-outlet="#main-filter">
1004
-
1005
- <div id="main-filter" data-controller="filter">...</div>
1006
- <div class="chart-widget" data-controller="chart">...</div>
1007
- <div class="chart-widget" data-controller="chart">...</div>
1008
- </div>
1009
- ```
1010
-
1011
- **Access patterns:**
1012
-
1013
- | Accessor | Returns | Throws if missing |
1014
- |----------|---------|-------------------|
1015
- | `this.chartOutlet` | First matching controller | Yes |
1016
- | `this.chartOutlets` | Array of all matching controllers | No (empty array) |
1017
- | `this.hasChartOutlet` | Boolean | No |
1018
-
1019
- **Outlet callbacks** notify when outlets connect or disconnect:
1020
-
1021
- ```javascript
1022
- export default class extends Controller {
1023
- static outlets = ["player"]
1024
-
1025
- playerOutletConnected(controller, element) {
1026
- controller.mute() // Direct method call on connected controller
1027
- }
1028
-
1029
- playerOutletDisconnected(controller, element) {
1030
- // Cleanup references
1031
- }
1032
- }
1033
- ```
1034
-
1035
- **Prefer outlets** over `this.application.getControllerForElementAndIdentifier()` or custom events for direct controller-to-controller communication. Outlets are declarative, observable, and automatically managed by Stimulus.
1036
-
1037
- ## 3.6 Actions & Parameters
1038
-
1039
- **Action parameters** pass data from HTML to action methods without manual `dataset` parsing. Declare parameters with `data-{controller}-{param}-param`:
1040
-
1041
- ```html
1042
- <div data-controller="cart">
1043
- <button data-action="cart#add"
1044
- data-cart-id-param="42"
1045
- data-cart-name-param="Widget"
1046
- data-cart-price-param="19.99">
1047
- Add to Cart
1048
- </button>
1049
- </div>
1050
- ```
1051
-
1052
- ```javascript
1053
- export default class extends Controller {
1054
- add({ params: { id, name, price } }) {
1055
- // id = 42 (Number), name = "Widget" (String), price = 19.99 (Number)
1056
- // Types are automatically inferred from the value
1057
- this.addItem(id, name, price)
1058
- }
1059
- }
1060
- ```
1061
-
1062
- **Keyboard filters** let you bind actions to specific key combinations:
1063
-
1064
- ```html
1065
- <div data-controller="editor">
1066
- <textarea data-action="keydown.ctrl+s->editor#save
1067
- keydown.meta+s->editor#save
1068
- keydown.esc->editor#cancel
1069
- keydown.ctrl+enter->editor#submit">
1070
- </textarea>
1071
- </div>
1072
- ```
1073
-
1074
- **Supported keyboard filters:**
1075
-
1076
- | Category | Filters |
1077
- |----------|---------|
1078
- | Modifiers | `ctrl`, `alt`, `shift`, `meta` |
1079
- | Navigation | `enter`, `tab`, `esc`, `space`, `up`, `down`, `left`, `right` |
1080
- | Letters | `a` through `z` |
1081
- | Numbers | `0` through `9` |
1082
- | Combinations | `ctrl+s`, `shift+enter`, `meta+k`, `ctrl+shift+p` |
1083
-
1084
- **Non-focusable elements** need `tabindex="0"` to receive keyboard events:
1085
-
1086
- ```html
1087
- <div data-controller="shortcuts"
1088
- data-action="keydown.ctrl+z->shortcuts#undo"
1089
- tabindex="0">
1090
- <!-- Content that needs keyboard shortcuts -->
1091
- </div>
1092
- ```
1093
-
1094
- ## 3.7 Common Patterns
1095
-
1096
- ### Image Upload Preview
1097
-
1098
- Use `URL.createObjectURL()` for instant client-side previews. Always revoke the URL after the image loads to free memory:
1099
-
1100
- ```javascript
1101
- export default class extends Controller {
1102
- static targets = ["input", "preview"]
1103
-
1104
- preview() {
1105
- const file = this.inputTarget.files[0]
1106
- if (!file) return
1107
-
1108
- const url = URL.createObjectURL(file)
1109
- this.previewTarget.src = url
1110
- this.previewTarget.onload = () => URL.revokeObjectURL(url)
1111
- this.previewTarget.hidden = false
1112
- }
1113
- }
1114
- ```
1115
-
1116
- ```html
1117
- <div data-controller="upload-preview">
1118
- <input type="file" accept="image/*"
1119
- data-upload-preview-target="input"
1120
- data-action="change->upload-preview#preview">
1121
- <img data-upload-preview-target="preview" hidden>
1122
- </div>
1123
- ```
1124
-
1125
- ### Inter-Tab Communication
1126
-
1127
- **BroadcastChannel** API enables communication across browser tabs. Create in `connect()`, close in `disconnect()`, and scope channels by purpose:
1128
-
1129
- ```javascript
1130
- export default class extends Controller {
1131
- static values = { channel: { type: String, default: "notifications" } }
1132
-
1133
- connect() {
1134
- this.channel = new BroadcastChannel(this.channelValue)
1135
- this.channel.onmessage = this.handleMessage.bind(this)
1136
- }
1137
-
1138
- handleMessage({ data }) {
1139
- if (data.type === "logout") {
1140
- window.location.href = "/session/new"
1141
- }
1142
- }
1143
-
1144
- broadcast(type, payload = {}) {
1145
- this.channel.postMessage({ type, ...payload })
1146
- }
1147
-
1148
- disconnect() {
1149
- this.channel.close()
1150
- }
1151
- }
1152
- ```
1153
-
1154
- ### Intersection Observer
1155
-
1156
- Use `stimulus-use` `useIntersection` for viewport-based behavior like lazy loading or picture-in-picture triggers:
1157
-
1158
- ```javascript
1159
- import { Controller } from "@hotwired/stimulus"
1160
- import { useIntersection } from "stimulus-use"
1161
-
1162
- export default class extends Controller {
1163
- static values = { loaded: Boolean }
1164
-
1165
- connect() {
1166
- useIntersection(this, { threshold: 0.25 })
1167
- }
1168
-
1169
- appear() {
1170
- if (this.loadedValue) return
1171
- this.loadedValue = true
1172
- this.element.src = this.element.dataset.lazySrc
1173
- }
1174
- }
1175
- ```
1176
-
1177
- ### MutationObserver
1178
-
1179
- Watch DOM attribute changes for reactivity. Useful for observing Turbo Frame `busy` attribute changes:
1180
-
1181
- ```javascript
1182
- export default class extends Controller {
1183
- static targets = ["frame", "spinner"]
1184
-
1185
- connect() {
1186
- this.observer = new MutationObserver(this.handleMutation.bind(this))
1187
- this.observer.observe(this.frameTarget, { attributes: true, attributeFilter: ["busy"] })
1188
- }
1189
-
1190
- handleMutation(mutations) {
1191
- const isBusy = this.frameTarget.hasAttribute("busy")
1192
- this.spinnerTarget.hidden = !isBusy
1193
- }
1194
-
1195
- disconnect() {
1196
- this.observer.disconnect()
1197
- }
1198
- }
1199
- ```
1200
-
1201
- ### Feature Detection
1202
-
1203
- Always check browser API availability before exposing functionality. Hide or disable UI that depends on unsupported APIs:
1204
-
1205
- ```javascript
1206
- export default class extends Controller {
1207
- static targets = ["pipButton", "shareButton"]
1208
- static classes = ["unsupported"]
1209
-
1210
- connect() {
1211
- if (!document.pictureInPictureEnabled) {
1212
- this.pipButtonTarget.hidden = true
1213
- }
1214
-
1215
- if (!navigator.share) {
1216
- this.shareButtonTarget.hidden = true
1217
- }
1218
-
1219
- if (!("mediaSession" in navigator)) {
1220
- this.element.classList.add(this.unsupportedClass)
1221
- }
1222
- }
1223
- }
1224
- ```
1225
-
1226
- ## Summary
1227
-
1228
- Stimulus controller conventions:
1229
-
1230
- - **Contract-first** — declare `static values`, `targets`, `outlets`, `classes` before any methods
1231
- - **Single-purpose** — one behavior per controller, compose via multiple `data-controller` bindings
1232
- - **Symmetric lifecycle** — every `connect()` setup has a matching `disconnect()` teardown
1233
- - **Guard value callbacks** — use optional chaining or `hasTarget` checks since callbacks fire before `connect()`
1234
- - **Values as single source of truth** — bridge third-party libraries through `{name}ValueChanged` callbacks
1235
- - **Target callbacks for Turbo** — use `TargetConnected` / `TargetDisconnected` to react to DOM changes from Turbo Streams
1236
- - **Outlets over events** — prefer declared outlets for direct controller communication
1237
- - **Action parameters over dataset** — use `data-{controller}-{param}-param` to pass typed data to actions
1238
- - **Feature detection** — check API availability before exposing UI that depends on browser capabilities
1239
- - **Idempotent callbacks** — target and outlet callbacks must be safe across repeated connect/disconnect cycles
1240
-
1241
- ---
1242
-
1243
- # 4. View Templates & Partials
1244
-
1245
- ## 4.1 ERB Conventions
1246
-
1247
- **Templates are rendering surfaces, not logic containers.** Keep them thin by delegating decisions to presenters or model methods. A template should read like a layout blueprint: structure and data slots, nothing more.
1248
-
1249
- **Rules:**
1250
- - No conditionals deeper than one level in a template
1251
- - No query calls (`where`, `find`, `count`) in ERB — use presenters
1252
- - Use `content_for` to inject section-specific content into layouts
1253
- - Prefer `tag.div` helpers inside presenters over inline ERB for complex HTML
1254
-
1255
- ### `content_for` for Section-Specific Content
1256
-
1257
- Use `content_for` to push page-specific content into layout slots:
1258
-
1259
- ```erb
1260
- <%# app/views/messages/show.html.erb %>
1261
- <% content_for :title, @message.subject %>
1262
- <% content_for :head do %>
1263
- <%= javascript_include_tag "trix" %>
1264
- <% end %>
1265
-
1266
- <div class="message">
1267
- <%= render partial: "message", locals: { message: @message } %>
1268
- </div>
1269
- ```
1270
-
1271
- ```erb
1272
- <%# app/views/layouts/application.html.erb %>
1273
- <head>
1274
- <title><%= content_for(:title) || "App" %></title>
1275
- <%= yield :head %>
1276
- </head>
1277
- ```
1278
-
1279
- ### Delegating Logic to Presenters
1280
-
1281
- When a template starts accumulating conditionals, extract them:
1282
-
1283
- ```erb
1284
- <%# Bad — logic in template %>
1285
- <% if user.avatar.attached? && user.avatar.variable? %>
1286
- <%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %>
1287
- <% else %>
1288
- <%= image_tag "default_avatar.png" %>
1289
- <% end %>
1290
-
1291
- <%# Good — delegate to presenter %>
1292
- <%= presenter.avatar_tag %>
1293
- ```
1294
-
1295
- ## 4.2 Partial Extraction Rules
1296
-
1297
- **Extract a partial when:** the same markup appears in 2+ templates, or you identify a clear UI component boundary (card, form group, nav item). A partial is the smallest reusable rendering unit.
1298
-
1299
- ### Naming and Organization
1300
-
1301
- - Name with a leading underscore: `_card.html.erb`
1302
- - Place cross-controller partials in `app/views/shared/`: `shared/_flash.html.erb`
1303
- - Name partials after the UI concept, not the model: `_card.html.erb`, not `_message_display.html.erb`
1304
-
1305
- ### Pass Data Explicitly
1306
-
1307
- **Never rely on instance variables inside partials.** Always pass data via `locals:`:
1308
-
1309
- ```erb
1310
- <%# Good — explicit locals %>
1311
- <%= render partial: "messages/card", locals: { message: message, show_actions: true } %>
1312
-
1313
- <%# Also good — short form for collections %>
1314
- <%= render partial: "messages/message", collection: @messages, as: :message %>
1315
-
1316
- <%# Bad — instance variable dependency %>
1317
- <%= render partial: "messages/card" %>
1318
- <%# partial internally references @message — fragile and implicit %>
1319
- ```
1320
-
1321
- ### Cross-Controller Partials
1322
-
1323
- For UI components shared across controllers, use the `shared/` directory:
1324
-
1325
- ```erb
1326
- <%# From any controller %>
1327
- <%= render partial: "shared/empty_state", locals: { message: "No results found", icon: "search" } %>
1328
- <%= render partial: "shared/pagination", locals: { pagy: @pagy } %>
1329
- ```
1330
-
1331
- ## 4.3 Turbo Frame Wrapping in Views
1332
-
1333
- **Wrap the rerenderable unit, not the entire page.** A Turbo Frame defines the boundary of what gets swapped on navigation or form submission. Frame IDs must match between the source page and the server response.
1334
-
1335
- ### Frame ID Conventions
1336
-
1337
- Use `dom_id` for consistent, collision-free frame and target IDs:
1338
-
1339
- ```erb
1340
- <%# app/views/messages/show.html.erb %>
1341
- <%= turbo_frame_tag dom_id(message) do %>
1342
- <h2><%= message.subject %></h2>
1343
- <p><%= message.body %></p>
1344
- <%= link_to "Edit", edit_message_path(message) %>
1345
- <% end %>
1346
-
1347
- <%# app/views/messages/edit.html.erb %>
1348
- <%= turbo_frame_tag dom_id(message) do %>
1349
- <%= render "form", message: message %>
1350
- <% end %>
1351
- ```
1352
-
1353
- The frame IDs match (`message_123`), so clicking "Edit" swaps only the frame content.
1354
-
1355
- ### Lazy-Loaded Frames
1356
-
1357
- Use the `src` attribute to load frame content on demand:
1358
-
1359
- ```erb
1360
- <%= turbo_frame_tag "comments", src: message_comments_path(message), loading: :lazy do %>
1361
- <p>Loading comments...</p>
1362
- <% end %>
1363
- ```
1364
-
1365
- The frame renders placeholder content immediately, then fetches and replaces it when the frame enters the viewport.
1366
-
1367
- ## 4.4 Template-Based DOM Patterns
1368
-
1369
- **Store Turbo Stream markup in `<template>` elements** to prevent premature execution by the browser. This is essential for optimistic UI patterns where you prepare stream actions in the DOM and dispatch them from Stimulus controllers.
1370
-
1371
- ### Clone-and-Append Pattern
1372
-
1373
- ```html
1374
- <%# Embed a hidden template in the page %>
1375
- <template data-optimistic-stream>
1376
- <turbo-stream action="append" target="messages">
1377
- <template>
1378
- <div class="message message--pending" id="pending_message">
1379
- <p data-placeholder>Sending...</p>
1380
- </div>
1381
- </template>
1382
- </turbo-stream>
1383
- </template>
1384
- ```
1385
-
1386
- ```javascript
1387
- // app/javascript/controllers/optimistic_controller.js
1388
- import { Controller } from "@hotwired/stimulus"
1389
-
1390
- export default class extends Controller {
1391
- static targets = ["template"]
1392
-
1393
- submit() {
1394
- const template = this.templateTarget.content.cloneNode(true)
1395
- const id = `pending_${Date.now()}`
1396
- template.querySelector(".message").id = id
1397
- document.body.append(template)
1398
- }
1399
- }
1400
- ```
1401
-
1402
- The `<template>` element prevents the `<turbo-stream>` from executing on page load. Cloning and appending triggers the stream action on demand.
1403
-
1404
- ## 4.5 Cache-Safe Views
1405
-
1406
- **Turbo caches pages before navigating away.** If transient UI states (open dropdowns, flash messages, active modals) are cached, they reappear as stale artifacts on restoration visits. Clean them up before the cache snapshot.
1407
-
1408
- ### Cleaning Transient UI
1409
-
1410
- ```javascript
1411
- // app/javascript/controllers/cache_cleanup_controller.js
1412
- import { Controller } from "@hotwired/stimulus"
1413
-
1414
- export default class extends Controller {
1415
- connect() {
1416
- document.addEventListener("turbo:before-cache", this.cleanup)
1417
- }
1418
-
1419
- disconnect() {
1420
- document.removeEventListener("turbo:before-cache", this.cleanup)
1421
- }
1422
-
1423
- cleanup = () => {
1424
- // Close dropdowns
1425
- this.element.querySelectorAll("[data-expanded]").forEach(el => {
1426
- el.removeAttribute("data-expanded")
1427
- })
1428
- // Clear flash messages
1429
- this.element.querySelectorAll(".flash").forEach(el => el.remove())
1430
- // Reset form states
1431
- this.element.querySelectorAll("form").forEach(form => form.reset())
1432
- }
1433
- }
1434
- ```
1435
-
1436
- ### Guard Against Preview Rendering
1437
-
1438
- When Turbo restores a cached page, it adds `data-turbo-preview` to the `<html>` element. Use this to guard rendering that depends on fresh data:
1439
-
1440
- ```erb
1441
- <% unless request.headers["Purpose"] == "preview" %>
1442
- <div class="live-metrics" data-controller="polling">
1443
- <%= render partial: "dashboard/metrics", locals: { stats: @stats } %>
1444
- </div>
1445
- <% end %>
1446
- ```
1447
-
1448
- ### Fragment Caching with Presenter Keys
1449
-
1450
- Use presenter cache keys to invalidate fragments when underlying data changes:
1451
-
1452
- ```erb
1453
- <% cache presenter.cache_key do %>
1454
- <div class="filtering-panel">
1455
- <%= render partial: "filters/tags", locals: { tags: presenter.tags } %>
1456
- <%= render partial: "filters/users", locals: { users: presenter.users } %>
1457
- </div>
1458
- <% end %>
1459
- ```
1460
-
1461
- ### Summary
1462
-
1463
- View templates and partials:
1464
- - **Keep templates thin** — delegate conditionals and queries to presenters
1465
- - **Use `content_for`** to inject page-specific content into layouts
1466
- - **Extract partials** when markup repeats or a clear component boundary exists
1467
- - **Pass data via `locals:`** — never rely on instance variables in partials
1468
- - **Use `dom_id`** for Turbo Frame IDs to ensure consistency between page and response
1469
- - **Lazy-load frames** with `src` for deferred content
1470
- - **Store Turbo Streams in `<template>` elements** to prevent premature execution
1471
- - **Clean transient UI** in `turbo:before-cache` to avoid stale cached states
1472
- - **Guard preview rendering** with `data-turbo-preview` checks
1473
- - **Use presenter cache keys** for fragment caching invalidation
1474
-
1475
- ---
1476
-
1477
- **Document Version**: 2.0
1478
- **Last Updated**: 2026-02-17
1479
- **Maintainer**: Development Team