Skip to content

Commit d45a44f

Browse files
cirdesclaude
andcommitted
docs(skills): add ruby-ui-stimulus skill for Stimulus/Hotwire conventions
Codify how RubyUI wires Stimulus controllers to Phlex components as an agent skill loaded on demand, adapted from The Hotwire Club skills (MIT): - .claude/skills/ruby-ui-stimulus/SKILL.md — core workflow + guardrails - references/phlex-stimulus-conventions.md — naming, default_attrs wiring, outlets, manifest registration, dependencies.yml - references/stimulus-fundamentals.md — controller-quality patterns - NOTICE.md — MIT attribution to The Hotwire Club Add a pointer in gem/AGENTS.md (JavaScript section) so the convention is discoverable from the always-loaded doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c7fcc90 commit d45a44f

5 files changed

Lines changed: 401 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Attribution
2+
3+
The controller-quality patterns and guardrails in this skill (`SKILL.md` and
4+
`references/stimulus-fundamentals.md`) are adapted from **The Hotwire Club
5+
skills** — specifically the `hwc-stimulus-fundamentals` skill — available at
6+
https://github.com/TheHotwireClub/hotwire_club-skills and used under the MIT
7+
License.
8+
9+
The ruby_ui-specific conventions in `references/phlex-stimulus-conventions.md`
10+
(Phlex `default_attrs` wiring, the `ruby-ui--` naming scheme, manifest
11+
registration, `dependencies.yml`) are original to this repository.
12+
13+
## Original license (The Hotwire Club skills)
14+
15+
```
16+
MIT License
17+
18+
Copyright (c) 2026 The Hotwire Club
19+
20+
Permission is hereby granted, free of charge, to any person obtaining a copy
21+
of this software and associated documentation files (the "Software"), to deal
22+
in the Software without restriction, including without limitation the rights
23+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24+
copies of the Software, and to permit persons to whom the Software is
25+
furnished to do so, subject to the following conditions:
26+
27+
The above copyright notice and this permission notice shall be included in all
28+
copies or substantial portions of the Software.
29+
30+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36+
SOFTWARE.
37+
```
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
name: ruby-ui-stimulus
3+
description: >-
4+
Use when writing, reviewing, or debugging a Stimulus controller for a ruby_ui
5+
Phlex component — controller lifecycle, state via values, DOM via targets,
6+
inter-controller communication via outlets, and the ruby_ui-specific
7+
data-attribute / naming / registration conventions. Covers how a Phlex
8+
component wires Stimulus through `data:` in `default_attrs`. Adapted from
9+
The Hotwire Club skills (MIT); see NOTICE.md.
10+
---
11+
12+
# RubyUI Stimulus conventions
13+
14+
RubyUI adds interactivity with Stimulus controllers colocated with each Phlex
15+
component: `gem/lib/ruby_ui/<component>/<component>_controller.js`. The component
16+
class wires the controller through the `data:` hash returned by `default_attrs`.
17+
There is **no custom helper/DSL** — everything is plain Phlex attributes plus
18+
Stimulus conventions.
19+
20+
This skill covers **client-side interactivity via Stimulus**, which is the norm
21+
for RubyUI components. Turbo (Frames/Streams) is out of scope here — it is only
22+
used server-driven, and today the sole case is `DataTable`.
23+
24+
## Core workflow
25+
26+
Building or changing a Stimulus-backed component:
27+
28+
1. **Write the controller** at `gem/lib/ruby_ui/<component>/<component>_controller.js`,
29+
extending `Controller` from `@hotwired/stimulus`. Keep a clean lifecycle
30+
(`connect`/`disconnect`) — see the guardrails below.
31+
2. **Model the contract with statics, not ad-hoc DOM reads:**
32+
- `static values` for reactive state (`openValue`, `optionsValue`, …).
33+
- `static targets` for the DOM nodes the controller touches.
34+
- `static outlets` for talking to sibling/child controllers.
35+
Prefer these over reading `this.element.dataset` or querying the DOM by hand.
36+
3. **Wire from the Phlex component** in `default_attrs`: set
37+
`data: { controller: "ruby-ui--<component>", … }`, plus targets, actions, and
38+
values using the underscore keys Phlex converts to `data-*` attributes. Exact
39+
naming table and worked examples: `references/phlex-stimulus-conventions.md`.
40+
4. **Register & declare deps:**
41+
- Importmap apps eager-load `controllers/` — no manifest edit needed.
42+
- esbuild/webpack apps regenerate the manifest with
43+
`rake stimulus:manifest:update` (`docs/app/javascript/controllers/index.js`).
44+
- New JS packages go in `gem/package.json` **and** per-component in
45+
`gem/lib/generators/ruby_ui/dependencies.yml`.
46+
5. **Update docs & tests** in the same PR: `<component>_docs.rb` and
47+
`test/ruby_ui/<component>_test.rb` (see `gem/AGENTS.md`).
48+
49+
## Guardrails
50+
51+
- **Symmetric setup/teardown.** Every listener, timer, `MutationObserver`, or
52+
Floating UI `autoUpdate` added in `connect()` must be removed/cleaned in
53+
`disconnect()`. `popover_controller.js` is the canonical example
54+
(`addEventListeners`/`removeEventListeners` + `this.cleanup()`).
55+
- **Idempotent `connect()`.** Controllers reconnect on Turbo navigation and DOM
56+
changes; connecting twice must not double-bind or leak.
57+
- **Declarative over imperative.** Reach for `static values` + action parameters
58+
before parsing `dataset` or hand-wiring `addEventListener`. Use `<name>Changed`
59+
value callbacks to react to state instead of scattering conditionals.
60+
- **Feature-detect** browser APIs (Clipboard, Web Share, View Transitions, …)
61+
before exposing UI that depends on them.
62+
- **No fixed timeouts as a proxy for completion.** Drive UI off real signals
63+
(value-changed callbacks, native/Turbo events), not a guessed `setTimeout`.
64+
- **Always namespace `ruby-ui--`.** Controller identifiers, target/value/outlet
65+
keys all carry the `ruby-ui--<component>` prefix. Never register a bare
66+
identifier — it would collide with a consumer app's own controllers.
67+
- **Don't invent a Ruby DSL** for data attributes. Follow the existing
68+
`data:` / `default_attrs` pattern documented in the references.
69+
70+
## Load references selectively
71+
72+
- `references/phlex-stimulus-conventions.md` — the ruby_ui-specific mechanics:
73+
naming table (folder → identifier → HTML attribute), how `default_attrs`
74+
wires `data:` (both nested-hash and flattened-key styles), outlets, manifest
75+
registration, and `dependencies.yml`. Read this to wire the Phlex side.
76+
- `references/stimulus-fundamentals.md` — controller-quality patterns (lifecycle,
77+
values, targets, outlets, action parameters, guardrail rationale) adapted to
78+
Phlex/ruby_ui. Read this to write a well-behaved controller.
79+
80+
## Attribution
81+
82+
Controller-quality patterns and guardrails are adapted from
83+
[The Hotwire Club skills](https://github.com/TheHotwireClub/hotwire_club-skills)
84+
(`hwc-stimulus-fundamentals`), used under the MIT License. Full notice in
85+
`NOTICE.md`.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Phlex ↔ Stimulus wiring conventions (ruby_ui)
2+
3+
How RubyUI components connect their Phlex markup to Stimulus controllers. All
4+
examples are real code from `gem/lib/ruby_ui/`.
5+
6+
## Naming table
7+
8+
A component lives in a snake_case folder; its Stimulus identifier is the folder
9+
path with `/``--` (so the `ruby_ui/` namespace becomes the `ruby-ui--`
10+
prefix). Phlex converts each `_` in an attribute key to `-`, so `__` becomes
11+
`--` in the emitted HTML.
12+
13+
| Thing | Folder / Ruby | Stimulus identifier | HTML attribute emitted |
14+
| --- | --- | --- | --- |
15+
| Controller | `popover/``popover_controller.js` | `ruby-ui--popover` | `data-controller="ruby-ui--popover"` |
16+
| Target `trigger` | `ruby_ui__popover_target: "trigger"` || `data-ruby-ui--popover-target="trigger"` |
17+
| Value `open` | `ruby_ui__popover_open_value: "false"` | `openValue` | `data-ruby-ui--popover-open-value="false"` |
18+
| Action | `action: "click->ruby-ui--popover#toggle"` || `data-action="click->ruby-ui--popover#toggle"` |
19+
| Outlet | `ruby_ui__select_ruby_ui__select_item_outlet: ".item"` || `data-ruby-ui--select-ruby-ui--select-item-outlet=".item"` |
20+
21+
The controller file itself is anonymous (`export default class extends
22+
Controller`); the identifier comes from **where it is registered**, not from the
23+
file. See "Registration" below.
24+
25+
## Two ways to write `data:` in `default_attrs`
26+
27+
Both are valid Phlex and both appear in the codebase. Pick one per component and
28+
be consistent.
29+
30+
**Nested hash** (most common — `popover.rb`, `clipboard.rb`, `select.rb`):
31+
32+
```ruby
33+
# gem/lib/ruby_ui/clipboard/clipboard.rb
34+
def default_attrs
35+
{
36+
data: {
37+
controller: "ruby-ui--clipboard",
38+
action: "click@window->ruby-ui--clipboard#onClickOutside",
39+
ruby_ui__clipboard_success_value: @success,
40+
ruby_ui__clipboard_error_value: @error,
41+
ruby_ui__clipboard_options_value: @options.to_json
42+
}
43+
}
44+
end
45+
```
46+
47+
**Flattened keys** (`dialog_content.rb`) — the whole `data-...` name is one key:
48+
49+
```ruby
50+
# gem/lib/ruby_ui/dialog/dialog_content.rb
51+
def default_attrs
52+
{
53+
data_ruby_ui__dialog_target: "dialog",
54+
data_action: "click->ruby-ui--dialog#backdropClick",
55+
class: [...]
56+
}
57+
end
58+
```
59+
60+
`default_attrs` is merged with the caller's attributes by `Base#initialize`
61+
(`gem/lib/ruby_ui/base.rb`) via Phlex `mix`, with Tailwind classes deduped by
62+
`TailwindMerge`. So callers can add/override `data-*` and classes without
63+
clobbering the component's wiring.
64+
65+
## Values
66+
67+
Pass values as strings/JSON from Ruby; declare their type in the controller:
68+
69+
```ruby
70+
# popover.rb
71+
ruby_ui__popover_options_value: @options.to_json # Object value → JSON string
72+
ruby_ui__popover_trigger_value: @options[:trigger] || "hover"
73+
```
74+
75+
```js
76+
// popover_controller.js
77+
static values = {
78+
open: { type: Boolean, default: false },
79+
options: { type: Object, default: {} }, // parsed from the JSON string
80+
trigger: { type: String, default: "hover" },
81+
};
82+
```
83+
84+
React to changes with the auto-called `<name>Changed(newVal, oldVal)` callback
85+
rather than polling.
86+
87+
## Targets
88+
89+
Child/sub-components mark themselves as targets of the parent controller:
90+
91+
```ruby
92+
# select_item.rb — an item is a target of the ruby-ui--select controller
93+
data: {
94+
controller: "ruby-ui--select-item",
95+
action: "click->ruby-ui--select#selectItem keydown.enter->ruby-ui--select#selectItem ...",
96+
ruby_ui__select_target: "item"
97+
}
98+
```
99+
100+
Multiple actions go in a single space-separated string, as above.
101+
102+
## Actions on non-element scopes
103+
104+
Use `@window` / `@document` scope for outside-click and global handlers:
105+
106+
```ruby
107+
# clipboard.rb
108+
action: "click@window->ruby-ui--clipboard#onClickOutside"
109+
# select.rb
110+
action: "click@window->ruby-ui--select#clickOutside"
111+
```
112+
113+
A trigger sub-component can dispatch to an ancestor controller without declaring
114+
its own `controller:` — it relies on the parent controller being on an ancestor
115+
element:
116+
117+
```ruby
118+
# dialog_trigger.rb
119+
data: { action: "click->ruby-ui--dialog#open" }
120+
```
121+
122+
## Outlets (parent ↔ child controllers)
123+
124+
The parent declares the outlet selector; the child is a separate controller the
125+
parent can call into:
126+
127+
```ruby
128+
# select.rb — parent declares the outlet to select-item
129+
data: {
130+
controller: "ruby-ui--select",
131+
ruby_ui__select_ruby_ui__select_item_outlet: ".item"
132+
}
133+
```
134+
135+
Outlet attribute shape: `data-<controller>-<outlet-identifier>-outlet`, hence the
136+
doubled `ruby_ui__…_ruby_ui__…_outlet` key in Ruby.
137+
138+
## Registration
139+
140+
- **Importmap** apps (Rails default): controllers are eager-loaded from
141+
`controllers/` — nothing to register manually.
142+
- **esbuild/webpack** apps: the manifest `docs/app/javascript/controllers/index.js`
143+
is auto-generated; regenerate with `rake stimulus:manifest:update`. Each entry is:
144+
145+
```js
146+
import RubyUi__PopoverController from "./ruby_ui/popover_controller"
147+
application.register("ruby-ui--popover", RubyUi__PopoverController)
148+
```
149+
150+
Note the file path segment `ruby_ui/` + `popover_controller` maps to identifier
151+
`ruby-ui--popover`.
152+
153+
## Dependencies
154+
155+
Declare any JS package the controller imports (e.g. `@floating-ui/dom`,
156+
`fuse.js`, `embla-carousel`) in two places:
157+
158+
- `gem/package.json` — for the gem's own dev/test.
159+
- `gem/lib/generators/ruby_ui/dependencies.yml` — per-component map so the
160+
generator installs it into consumer apps (also lists required gems and other
161+
components a component depends on).

0 commit comments

Comments
 (0)