ariadna 1.2.0 → 1.2.2

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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/data/agents/ariadna-backend-executor.md +9 -6
  3. data/data/agents/ariadna-codebase-mapper.md +5 -5
  4. data/data/agents/ariadna-debugger.md +13 -13
  5. data/data/agents/ariadna-executor.md +9 -6
  6. data/data/agents/ariadna-frontend-executor.md +9 -6
  7. data/data/agents/ariadna-integration-checker.md +1 -1
  8. data/data/agents/ariadna-phase-researcher.md +1 -1
  9. data/data/agents/ariadna-planner.md +17 -17
  10. data/data/agents/ariadna-project-researcher.md +10 -10
  11. data/data/agents/ariadna-research-synthesizer.md +11 -11
  12. data/data/agents/ariadna-roadmapper.md +8 -8
  13. data/data/agents/ariadna-test-executor.md +9 -6
  14. data/data/agents/ariadna-verifier.md +4 -4
  15. data/data/ariadna/references/decimal-phase-calculation.md +2 -2
  16. data/data/ariadna/references/git-integration.md +4 -4
  17. data/data/ariadna/references/git-planning-commit.md +7 -7
  18. data/data/ariadna/references/model-profile-resolution.md +1 -1
  19. data/data/ariadna/references/model-profiles.md +2 -2
  20. data/data/ariadna/references/planning-config.md +13 -13
  21. data/data/ariadna/references/rails-conventions.md +9 -2
  22. data/data/ariadna/references/tdd.md +2 -2
  23. data/data/ariadna/templates/DEBUG.md +2 -2
  24. data/data/ariadna/templates/UAT.md +3 -3
  25. data/data/ariadna/templates/codebase/architecture.md +1 -1
  26. data/data/ariadna/templates/codebase/concerns.md +1 -1
  27. data/data/ariadna/templates/codebase/conventions.md +1 -1
  28. data/data/ariadna/templates/codebase/integrations.md +1 -1
  29. data/data/ariadna/templates/codebase/stack.md +1 -1
  30. data/data/ariadna/templates/codebase/structure.md +1 -1
  31. data/data/ariadna/templates/codebase/testing.md +1 -1
  32. data/data/ariadna/templates/context.md +2 -2
  33. data/data/ariadna/templates/continue-here.md +1 -1
  34. data/data/ariadna/templates/debug-subagent-prompt.md +2 -2
  35. data/data/ariadna/templates/discovery.md +2 -2
  36. data/data/ariadna/templates/milestone-archive.md +4 -4
  37. data/data/ariadna/templates/milestone.md +1 -1
  38. data/data/ariadna/templates/phase-prompt.md +19 -19
  39. data/data/ariadna/templates/planner-subagent-prompt.md +9 -9
  40. data/data/ariadna/templates/project.md +2 -2
  41. data/data/ariadna/templates/requirements.md +1 -1
  42. data/data/ariadna/templates/research-project/ARCHITECTURE.md +1 -1
  43. data/data/ariadna/templates/research-project/FEATURES.md +1 -1
  44. data/data/ariadna/templates/research-project/PITFALLS.md +1 -1
  45. data/data/ariadna/templates/research-project/STACK.md +1 -1
  46. data/data/ariadna/templates/research-project/SUMMARY.md +1 -1
  47. data/data/ariadna/templates/research.md +2 -2
  48. data/data/ariadna/templates/roadmap.md +1 -1
  49. data/data/ariadna/templates/state.md +4 -4
  50. data/data/ariadna/templates/summary.md +12 -1
  51. data/data/ariadna/templates/user-setup.md +2 -2
  52. data/data/ariadna/templates/verification-report.md +1 -1
  53. data/data/ariadna/workflows/add-phase.md +5 -5
  54. data/data/ariadna/workflows/add-todo.md +7 -7
  55. data/data/ariadna/workflows/audit-milestone.md +7 -7
  56. data/data/ariadna/workflows/check-todos.md +4 -4
  57. data/data/ariadna/workflows/complete-milestone.md +18 -18
  58. data/data/ariadna/workflows/diagnose-issues.md +4 -4
  59. data/data/ariadna/workflows/discovery-phase.md +4 -4
  60. data/data/ariadna/workflows/discuss-phase.md +2 -2
  61. data/data/ariadna/workflows/execute-phase.md +97 -9
  62. data/data/ariadna/workflows/execute-plan.md +30 -19
  63. data/data/ariadna/workflows/help.md +18 -18
  64. data/data/ariadna/workflows/insert-phase.md +6 -6
  65. data/data/ariadna/workflows/list-phase-assumptions.md +1 -1
  66. data/data/ariadna/workflows/map-codebase.md +22 -22
  67. data/data/ariadna/workflows/new-milestone.md +16 -16
  68. data/data/ariadna/workflows/new-project.md +39 -39
  69. data/data/ariadna/workflows/pause-work.md +4 -4
  70. data/data/ariadna/workflows/plan-milestone-gaps.md +4 -4
  71. data/data/ariadna/workflows/plan-phase.md +43 -19
  72. data/data/ariadna/workflows/progress.md +6 -6
  73. data/data/ariadna/workflows/quick.md +6 -6
  74. data/data/ariadna/workflows/remove-phase.md +3 -3
  75. data/data/ariadna/workflows/research-phase.md +4 -4
  76. data/data/ariadna/workflows/resume-project.md +9 -9
  77. data/data/ariadna/workflows/set-profile.md +2 -2
  78. data/data/ariadna/workflows/settings.md +4 -4
  79. data/data/ariadna/workflows/transition.md +11 -11
  80. data/data/ariadna/workflows/verify-phase.md +2 -2
  81. data/data/ariadna/workflows/verify-work.md +8 -8
  82. data/data/commands/ariadna/add-phase.md +2 -2
  83. data/data/commands/ariadna/add-todo.md +1 -1
  84. data/data/commands/ariadna/audit-milestone.md +6 -6
  85. data/data/commands/ariadna/check-todos.md +2 -2
  86. data/data/commands/ariadna/complete-milestone.md +11 -11
  87. data/data/commands/ariadna/debug.md +3 -3
  88. data/data/commands/ariadna/discuss-phase.md +2 -2
  89. data/data/commands/ariadna/execute-phase.md +6 -5
  90. data/data/commands/ariadna/insert-phase.md +2 -2
  91. data/data/commands/ariadna/list-phase-assumptions.md +2 -2
  92. data/data/commands/ariadna/map-codebase.md +7 -7
  93. data/data/commands/ariadna/new-milestone.md +10 -10
  94. data/data/commands/ariadna/new-project.md +6 -6
  95. data/data/commands/ariadna/pause-work.md +1 -1
  96. data/data/commands/ariadna/plan-milestone-gaps.md +5 -5
  97. data/data/commands/ariadna/quick.md +2 -2
  98. data/data/commands/ariadna/remove-phase.md +2 -2
  99. data/data/commands/ariadna/research-phase.md +6 -6
  100. data/data/commands/ariadna/verify-work.md +2 -2
  101. data/data/guides/frontend.md +1044 -9
  102. data/data/statusline/ariadna-statusline.sh +47 -0
  103. data/data/templates.md +1 -1
  104. data/exe/ariadna +2 -1
  105. data/lib/ariadna/installer.rb +30 -1
  106. data/lib/ariadna/tools/config_manager.rb +12 -6
  107. data/lib/ariadna/tools/git_integration.rb +2 -2
  108. data/lib/ariadna/tools/init.rb +66 -61
  109. data/lib/ariadna/tools/phase_manager.rb +31 -13
  110. data/lib/ariadna/tools/roadmap_analyzer.rb +5 -5
  111. data/lib/ariadna/tools/state_manager.rb +14 -14
  112. data/lib/ariadna/tools/template_filler.rb +5 -5
  113. data/lib/ariadna/tools/utilities.rb +4 -4
  114. data/lib/ariadna/tools/verification.rb +4 -4
  115. data/lib/ariadna/uninstaller.rb +17 -0
  116. data/lib/ariadna/version.rb +1 -1
  117. metadata +2 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  **View Layer Conventions for Rails Applications**
4
4
 
5
- This guide covers the frontend and view layer patterns used in Rails applications. Currently focused on the Presenter Pattern, with future sections planned for Turbo, Stimulus, and view templates.
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
6
 
7
7
  **Related guides:**
8
8
  - [Backend Patterns](backend.md) — Architecture, models, controllers, jobs, style guide
@@ -22,9 +22,27 @@ This guide covers the frontend and view layer patterns used in Rails application
22
22
  - [1.7 View Usage](#17-view-usage)
23
23
  - [1.8 Testing Presenters](#18-testing-presenters)
24
24
  - [1.9 Real Examples](#19-real-examples)
25
- - [2. Turbo Streams & Turbo Frames](#2-turbo-streams--turbo-frames) *(planned)*
26
- - [3. Stimulus Controllers](#3-stimulus-controllers) *(planned)*
27
- - [4. View Templates & Partials](#4-view-templates--partials) *(planned)*
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)
28
46
 
29
47
  ---
30
48
 
@@ -423,22 +441,1039 @@ Presenter pattern:
423
441
 
424
442
  # 2. Turbo Streams & Turbo Frames
425
443
 
426
- *Section planned. Will cover Turbo Stream actions, Turbo Frame conventions, broadcast patterns, and real-time update strategies.*
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
427
714
 
428
715
  ---
429
716
 
430
717
  # 3. Stimulus Controllers
431
718
 
432
- *Section planned. Will cover Stimulus controller conventions, naming patterns, data attribute usage, and common controller patterns.*
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
433
1240
 
434
1241
  ---
435
1242
 
436
1243
  # 4. View Templates & Partials
437
1244
 
438
- *Section planned. Will cover ERB template conventions, partial extraction rules, layout patterns, and component-like partial usage.*
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
439
1474
 
440
1475
  ---
441
1476
 
442
- **Document Version**: 1.0
443
- **Last Updated**: 2026-02-15
1477
+ **Document Version**: 2.0
1478
+ **Last Updated**: 2026-02-17
444
1479
  **Maintainer**: Development Team