Each message now has a copy link button.
Click one. Nothing happens. There's no server round trip that makes sense here: the text is already on the page, and the clipboard belongs to the browser. This is the kind of thing Turbo leaves alone on purpose.
Look at the HTML, though. The button already says what it wants: data-action="clipboard#copy". It's waiting for someone to answer.
Nine lines answer it.
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "source" ]
copy() {
const text = this.sourceTarget.textContent
navigator.clipboard.writeText(text)
}
}
Load Stimulus and click again. In Rails, stimulus-rails registers every file in app/javascript/controllers, so the identifier clipboard finds clipboard_controller.js by name. No querySelector, no event listener written by hand.
Controller, target, action.
Hover each attribute to light up the element it marks in the page.
Read the HTML alone and you can tell what it does. That's the design goal. Most frameworks build HTML from state held in JavaScript; Stimulus attaches behaviour to HTML the server already wrote.
Post a message. Then copy its link.
The new message arrived as a stream, with a data-controller on it. Watch its pencil note in the margin change: the controller connected the moment the element landed. Its copy button works with no setup.
Stimulus watches the whole document with a MutationObserver. Any element that appears, from a Drive visit, a frame, a stream, or your own code, gets its controllers connected. Any element that leaves gets them disconnected. This is why you never write turbo:load handlers again: there's nothing to re-initialise.
That's the whole framework. The rest is detail.
Three details worth a side path each: