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.
- checksums.yaml +4 -4
- data/ariadna.gemspec +0 -1
- data/data/agents/ariadna-codebase-mapper.md +34 -722
- data/data/agents/ariadna-debugger.md +44 -1139
- data/data/agents/ariadna-executor.md +75 -396
- data/data/agents/ariadna-planner.md +78 -1215
- data/data/agents/ariadna-roadmapper.md +55 -582
- data/data/agents/ariadna-verifier.md +60 -702
- data/data/ariadna/templates/config.json +8 -33
- data/data/ariadna/workflows/debug.md +28 -0
- data/data/ariadna/workflows/execute-phase.md +31 -513
- data/data/ariadna/workflows/map-codebase.md +20 -319
- data/data/ariadna/workflows/new-milestone.md +20 -365
- data/data/ariadna/workflows/new-project.md +19 -880
- data/data/ariadna/workflows/plan-phase.md +24 -443
- data/data/ariadna/workflows/progress.md +20 -376
- data/data/ariadna/workflows/quick.md +19 -221
- data/data/ariadna/workflows/roadmap-ops.md +28 -0
- data/data/ariadna/workflows/verify-work.md +23 -560
- data/data/commands/ariadna/add-phase.md +11 -22
- data/data/commands/ariadna/debug.md +11 -143
- data/data/commands/ariadna/execute-phase.md +12 -30
- data/data/commands/ariadna/insert-phase.md +7 -14
- data/data/commands/ariadna/map-codebase.md +16 -49
- data/data/commands/ariadna/new-milestone.md +12 -25
- data/data/commands/ariadna/new-project.md +22 -26
- data/data/commands/ariadna/plan-phase.md +13 -22
- data/data/commands/ariadna/progress.md +16 -6
- data/data/commands/ariadna/quick.md +9 -11
- data/data/commands/ariadna/remove-phase.md +9 -12
- data/data/commands/ariadna/verify-work.md +14 -19
- data/data/skills/rails-backend/API.md +138 -0
- data/data/skills/rails-backend/CONTROLLERS.md +154 -0
- data/data/skills/rails-backend/JOBS.md +132 -0
- data/data/skills/rails-backend/MODELS.md +213 -0
- data/data/skills/rails-backend/SKILL.md +169 -0
- data/data/skills/rails-frontend/ASSETS.md +154 -0
- data/data/skills/rails-frontend/COMPONENTS.md +253 -0
- data/data/skills/rails-frontend/SKILL.md +187 -0
- data/data/skills/rails-frontend/VIEWS.md +168 -0
- data/data/skills/rails-performance/PROFILING.md +106 -0
- data/data/skills/rails-performance/SKILL.md +217 -0
- data/data/skills/rails-security/AUDIT.md +118 -0
- data/data/skills/rails-security/SKILL.md +422 -0
- data/data/skills/rails-testing/FIXTURES.md +78 -0
- data/data/skills/rails-testing/SKILL.md +160 -0
- data/data/skills/rails-testing/SYSTEM-TESTS.md +73 -0
- data/lib/ariadna/installer.rb +11 -15
- data/lib/ariadna/tools/cli.rb +0 -12
- data/lib/ariadna/tools/config_manager.rb +10 -72
- data/lib/ariadna/tools/frontmatter.rb +23 -1
- data/lib/ariadna/tools/init.rb +201 -401
- data/lib/ariadna/tools/model_profiles.rb +6 -14
- data/lib/ariadna/tools/phase_manager.rb +1 -10
- data/lib/ariadna/tools/state_manager.rb +170 -451
- data/lib/ariadna/tools/template_filler.rb +4 -12
- data/lib/ariadna/tools/verification.rb +21 -399
- data/lib/ariadna/uninstaller.rb +9 -0
- data/lib/ariadna/version.rb +1 -1
- data/lib/ariadna.rb +1 -0
- metadata +20 -91
- data/data/agents/ariadna-backend-executor.md +0 -261
- data/data/agents/ariadna-frontend-executor.md +0 -259
- data/data/agents/ariadna-integration-checker.md +0 -418
- data/data/agents/ariadna-phase-researcher.md +0 -469
- data/data/agents/ariadna-plan-checker.md +0 -622
- data/data/agents/ariadna-project-researcher.md +0 -618
- data/data/agents/ariadna-research-synthesizer.md +0 -236
- data/data/agents/ariadna-test-executor.md +0 -266
- data/data/ariadna/references/checkpoints.md +0 -772
- data/data/ariadna/references/continuation-format.md +0 -249
- data/data/ariadna/references/decimal-phase-calculation.md +0 -65
- data/data/ariadna/references/git-integration.md +0 -248
- data/data/ariadna/references/git-planning-commit.md +0 -38
- data/data/ariadna/references/model-profile-resolution.md +0 -32
- data/data/ariadna/references/model-profiles.md +0 -73
- data/data/ariadna/references/phase-argument-parsing.md +0 -61
- data/data/ariadna/references/planning-config.md +0 -194
- data/data/ariadna/references/questioning.md +0 -153
- data/data/ariadna/references/rails-conventions.md +0 -416
- data/data/ariadna/references/tdd.md +0 -267
- data/data/ariadna/references/ui-brand.md +0 -160
- data/data/ariadna/references/verification-patterns.md +0 -853
- data/data/ariadna/templates/codebase/architecture.md +0 -481
- data/data/ariadna/templates/codebase/concerns.md +0 -380
- data/data/ariadna/templates/codebase/conventions.md +0 -434
- data/data/ariadna/templates/codebase/integrations.md +0 -328
- data/data/ariadna/templates/codebase/stack.md +0 -189
- data/data/ariadna/templates/codebase/structure.md +0 -418
- data/data/ariadna/templates/codebase/testing.md +0 -606
- data/data/ariadna/templates/context.md +0 -283
- data/data/ariadna/templates/continue-here.md +0 -78
- data/data/ariadna/templates/debug-subagent-prompt.md +0 -91
- data/data/ariadna/templates/phase-prompt.md +0 -609
- data/data/ariadna/templates/planner-subagent-prompt.md +0 -117
- data/data/ariadna/templates/research-project/ARCHITECTURE.md +0 -439
- data/data/ariadna/templates/research-project/FEATURES.md +0 -168
- data/data/ariadna/templates/research-project/PITFALLS.md +0 -406
- data/data/ariadna/templates/research-project/STACK.md +0 -251
- data/data/ariadna/templates/research-project/SUMMARY.md +0 -247
- data/data/ariadna/templates/state.md +0 -176
- data/data/ariadna/templates/summary-complex.md +0 -59
- data/data/ariadna/templates/summary-minimal.md +0 -41
- data/data/ariadna/templates/summary-standard.md +0 -48
- data/data/ariadna/templates/user-setup.md +0 -310
- data/data/ariadna/workflows/add-phase.md +0 -111
- data/data/ariadna/workflows/add-todo.md +0 -157
- data/data/ariadna/workflows/audit-milestone.md +0 -241
- data/data/ariadna/workflows/check-todos.md +0 -176
- data/data/ariadna/workflows/complete-milestone.md +0 -644
- data/data/ariadna/workflows/diagnose-issues.md +0 -219
- data/data/ariadna/workflows/discovery-phase.md +0 -289
- data/data/ariadna/workflows/discuss-phase.md +0 -408
- data/data/ariadna/workflows/execute-plan.md +0 -448
- data/data/ariadna/workflows/help.md +0 -470
- data/data/ariadna/workflows/insert-phase.md +0 -129
- data/data/ariadna/workflows/list-phase-assumptions.md +0 -178
- data/data/ariadna/workflows/pause-work.md +0 -122
- data/data/ariadna/workflows/plan-milestone-gaps.md +0 -256
- data/data/ariadna/workflows/remove-phase.md +0 -154
- data/data/ariadna/workflows/research-phase.md +0 -74
- data/data/ariadna/workflows/resume-project.md +0 -306
- data/data/ariadna/workflows/set-profile.md +0 -80
- data/data/ariadna/workflows/settings.md +0 -145
- data/data/ariadna/workflows/transition.md +0 -493
- data/data/ariadna/workflows/update.md +0 -212
- data/data/ariadna/workflows/verify-phase.md +0 -226
- data/data/commands/ariadna/add-todo.md +0 -42
- data/data/commands/ariadna/audit-milestone.md +0 -42
- data/data/commands/ariadna/check-todos.md +0 -41
- data/data/commands/ariadna/complete-milestone.md +0 -136
- data/data/commands/ariadna/discuss-phase.md +0 -86
- data/data/commands/ariadna/help.md +0 -22
- data/data/commands/ariadna/list-phase-assumptions.md +0 -50
- data/data/commands/ariadna/pause-work.md +0 -35
- data/data/commands/ariadna/plan-milestone-gaps.md +0 -40
- data/data/commands/ariadna/reapply-patches.md +0 -110
- data/data/commands/ariadna/research-phase.md +0 -187
- data/data/commands/ariadna/resume-work.md +0 -40
- data/data/commands/ariadna/set-profile.md +0 -34
- data/data/commands/ariadna/settings.md +0 -36
- data/data/commands/ariadna/update.md +0 -37
- data/data/guides/backend.md +0 -3069
- data/data/guides/frontend.md +0 -1479
- data/data/guides/performance.md +0 -1193
- data/data/guides/security.md +0 -1522
- data/data/guides/style-guide.md +0 -1091
- data/data/guides/testing.md +0 -504
- data/data/templates.md +0 -94
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rails-backend
|
|
3
|
+
description: Ruby on Rails backend conventions — models, controllers, jobs, API design. Use when implementing Rails backend code, creating models, writing controllers, or designing APIs.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rails Backend Skill
|
|
7
|
+
|
|
8
|
+
Opinionated conventions for Rails backend code. Architecture is built on a single principle: **place the domain model at the center.** Controllers, jobs, and the console are all boundaries that orchestrate domain logic — they contain no business logic themselves.
|
|
9
|
+
|
|
10
|
+
**Sub-files:**
|
|
11
|
+
- [MODELS.md](MODELS.md) — Concern architecture, associations, scoping, callbacks
|
|
12
|
+
- [CONTROLLERS.md](CONTROLLERS.md) — REST conventions, strong params, concerns
|
|
13
|
+
- [JOBS.md](JOBS.md) — _now/_later pattern, multi-tenancy context
|
|
14
|
+
- [API.md](API.md) — JSON responses, serialization, versioning
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Core Philosophy
|
|
19
|
+
|
|
20
|
+
### Domain Model at the Center
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
Controller ──┐
|
|
24
|
+
▼
|
|
25
|
+
Console ──► Domain Model ◄── Job
|
|
26
|
+
▲
|
|
27
|
+
Script ─────┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`card.close` works identically whether called from a controller, job, console, or test. The domain model is the single source of truth for business behavior.
|
|
31
|
+
|
|
32
|
+
### No New Architectural Artifacts
|
|
33
|
+
|
|
34
|
+
No service objects, form objects, interactors, or command pattern libraries. Building blocks:
|
|
35
|
+
|
|
36
|
+
- **Models** — domain entities and operations (ActiveRecord and plain Ruby)
|
|
37
|
+
- **Concerns** — organize model behavior into cohesive modules
|
|
38
|
+
- **Controllers** — HTTP boundary only
|
|
39
|
+
- **Jobs** — async boundary; delegate to model methods
|
|
40
|
+
- **Views** — render domain state (templates, not view components)
|
|
41
|
+
|
|
42
|
+
When something doesn't fit in an entity, create a plain Ruby object with a semantic name — not a new architectural pattern. A `Signup` class, not a `SignupService`. A `Notifier`, not a `NotificationInteractor`.
|
|
43
|
+
|
|
44
|
+
### Preference for Rails Defaults
|
|
45
|
+
|
|
46
|
+
Minitest over RSpec · view templates over view components · ActiveRecord callbacks over observer patterns · `Current` over dependency injection · concerns over decorator libraries.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Domain Model Overview
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Account (tenant/organization)
|
|
54
|
+
└── Users (members with roles)
|
|
55
|
+
└── Boards (project spaces)
|
|
56
|
+
└── Columns (workflow stages)
|
|
57
|
+
└── Cards (tasks/issues)
|
|
58
|
+
└── Comments, Assignments, Tags
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Multi-Tenancy
|
|
64
|
+
|
|
65
|
+
URL path-based tenancy — account ID extracted from path by middleware, sets `Current.account`.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
69
|
+
attribute :session, :user, :identity, :account
|
|
70
|
+
|
|
71
|
+
def session=(value)
|
|
72
|
+
super(value)
|
|
73
|
+
self.identity = session.identity if value.present?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def identity=(identity)
|
|
77
|
+
super(identity)
|
|
78
|
+
self.user = identity.users.find_by(account: account) if identity.present?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Setting `Current.session` cascades: resolves `identity`, then resolves `user` for the current account.
|
|
84
|
+
|
|
85
|
+
- Always use `Current.user` instead of passing `@user` as a parameter
|
|
86
|
+
- Always use `Current.account` for tenant scoping
|
|
87
|
+
- In tests: `Current.session = sessions(:david)` sets up the full chain
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## No Service Objects
|
|
92
|
+
|
|
93
|
+
Controllers already fulfill the application service role from DDD — they sit at the boundary and orchestrate domain entities. Adding service objects creates a redundant layer.
|
|
94
|
+
|
|
95
|
+
**The real danger: anemic domain models.** When business logic lives in service objects instead of domain entities, models become empty data holders and logic scatters across a flat list of service classes with no object-oriented structure.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Bad: service object drains logic from the model
|
|
99
|
+
class CloseCardService
|
|
100
|
+
def call = @card.update!(status: "closed") && EventService.new(@card).call
|
|
101
|
+
|
|
102
|
+
# Good: logic belongs in the domain entity
|
|
103
|
+
module Card::Closeable
|
|
104
|
+
def close(user: Current.user)
|
|
105
|
+
transaction { create_closure!(user:); track_event :closed, creator: user }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Decision tree: where does logic belong?**
|
|
111
|
+
|
|
112
|
+
- Acts on one entity's state → put in model/concern
|
|
113
|
+
- Coordinates 2-3 entities, triggered by HTTP → controller handles it
|
|
114
|
+
- Coordinates 2-3 entities, domain concept → plain Ruby object (`Signup`, not `SignupService`)
|
|
115
|
+
- Runs async → job delegates to model method
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Code Style Conventions
|
|
120
|
+
|
|
121
|
+
### Conditional Returns
|
|
122
|
+
|
|
123
|
+
Prefer expanded conditionals over guard clauses in the middle of methods. Guard clauses are acceptable at the very start when the method body is non-trivial.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
# Avoid mid-method returns
|
|
127
|
+
def process
|
|
128
|
+
return [] unless ids
|
|
129
|
+
find(ids)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Prefer
|
|
133
|
+
def process
|
|
134
|
+
if ids then find(ids) else [] end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Method Ordering
|
|
139
|
+
|
|
140
|
+
1. Class methods (top)
|
|
141
|
+
2. Public methods (`initialize` first)
|
|
142
|
+
3. Private methods — indented under `private`, no blank line after `private`
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
private
|
|
146
|
+
def method_one; end
|
|
147
|
+
def method_two; end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Private methods are ordered by invocation — top-to-bottom following execution flow.
|
|
151
|
+
|
|
152
|
+
### Bang Methods
|
|
153
|
+
|
|
154
|
+
Only use `!` for methods that have a non-bang counterpart (`save`/`save!`). Do not use `!` to flag destructive actions with no non-bang version.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Common Gotchas
|
|
159
|
+
|
|
160
|
+
1. **Business logic in controllers** — move it to models
|
|
161
|
+
2. **Custom route actions** — use `resource :closure` not `post :close`
|
|
162
|
+
3. **Logic in jobs** — jobs are thin wrappers; logic lives in model methods
|
|
163
|
+
4. **Association declaration order** — declare `belongs_to :board` before `belongs_to :account, default: -> { board.account }`
|
|
164
|
+
5. **Finding cards by `:id`** — cards use `:number`: `accessible_cards.find_by!(number: params[:card_id])`
|
|
165
|
+
6. **Multi-step operations without transactions** — always wrap in `transaction do`
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
See [MODELS.md](MODELS.md) for ActiveRecord patterns, [CONTROLLERS.md](CONTROLLERS.md) for HTTP conventions, [JOBS.md](JOBS.md) for background job patterns, and [API.md](API.md) for API design.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Assets — CSS, JS Bundling, Design Tokens
|
|
2
|
+
|
|
3
|
+
## CSS Architecture
|
|
4
|
+
|
|
5
|
+
**Pure custom CSS — no Tailwind, Bootstrap, or utility frameworks.** Built on modern CSS: custom properties, OKLCH colors, CSS layers, and logical properties.
|
|
6
|
+
|
|
7
|
+
### Layer Organization
|
|
8
|
+
|
|
9
|
+
```css
|
|
10
|
+
@layer reset; /* Browser normalization */
|
|
11
|
+
@layer base; /* Base element styles */
|
|
12
|
+
@layer components; /* Component-specific styles */
|
|
13
|
+
@layer modules; /* Feature modules */
|
|
14
|
+
@layer utilities; /* Utility classes */
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### File Organization
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
app/assets/stylesheets/
|
|
21
|
+
├── _global.css # Root variables and @layer definitions
|
|
22
|
+
├── base.css # Base element styling
|
|
23
|
+
├── utilities.css # Utility classes
|
|
24
|
+
├── buttons.css # Button components
|
|
25
|
+
├── inputs.css # Form inputs
|
|
26
|
+
├── cards.css # Card components
|
|
27
|
+
├── layout.css # Main layout
|
|
28
|
+
└── [feature].css # Feature-specific styles
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Design Tokens
|
|
34
|
+
|
|
35
|
+
### Colors — OKLCH
|
|
36
|
+
|
|
37
|
+
Use OKLCH for perceptual uniformity across light/dark modes. Always use semantic variables:
|
|
38
|
+
|
|
39
|
+
| Variable | Purpose |
|
|
40
|
+
|----------|---------|
|
|
41
|
+
| `--color-ink` | Primary text |
|
|
42
|
+
| `--color-ink-light` | Secondary text |
|
|
43
|
+
| `--color-canvas` | Background |
|
|
44
|
+
| `--color-link` | Interactive elements (blue) |
|
|
45
|
+
| `--color-positive` | Success (green) |
|
|
46
|
+
| `--color-negative` | Error (red) |
|
|
47
|
+
|
|
48
|
+
Dark mode via attribute and media query:
|
|
49
|
+
|
|
50
|
+
```css
|
|
51
|
+
html[data-theme="dark"] { ... }
|
|
52
|
+
@media (prefers-color-scheme: dark) { html:not([data-theme]) { ... } }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Spacing — Logical Properties
|
|
56
|
+
|
|
57
|
+
Use `block` (vertical) and `inline` (horizontal) instead of top/bottom/left/right for RTL support:
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
--inline-space: 1ch;
|
|
61
|
+
--block-space: 1rem;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Key utilities: `.pad`, `.pad-block`, `.pad-inline`, `.margin`, `.gap`, `.gap-half`
|
|
65
|
+
|
|
66
|
+
### Typography
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
--font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
70
|
+
--font-mono: ui-monospace, monospace;
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Scale: `--text-xx-small` (0.55rem) through `--text-xx-large` (2.5rem). Always use `rem`, never `px` for font sizes.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Icons
|
|
78
|
+
|
|
79
|
+
CSS mask-based icon system — icons inherit `currentColor`, easy to style contextually:
|
|
80
|
+
|
|
81
|
+
```erb
|
|
82
|
+
<%= icon_tag "check" %>
|
|
83
|
+
<%= icon_tag "pencil", class: "txt-subtle" %>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Generates: `<span class="icon icon--check" aria-hidden="true"></span>`
|
|
87
|
+
|
|
88
|
+
Icon-only buttons — use `aria-label` or `.for-screen-reader` for accessibility:
|
|
89
|
+
|
|
90
|
+
```erb
|
|
91
|
+
<%= button_to path, class: "btn", aria: { label: "Edit" } do %>
|
|
92
|
+
<%= icon_tag "pencil" %>
|
|
93
|
+
<% end %>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
New SVG requirements: `viewBox="0 0 24 24"`, no fixed `width`/`height`, `fill="currentColor"`, monochrome for mask-image compatibility.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Component Patterns
|
|
101
|
+
|
|
102
|
+
### Buttons
|
|
103
|
+
|
|
104
|
+
```css
|
|
105
|
+
.btn /* Base — rounded pill */
|
|
106
|
+
.btn--positive /* Green */
|
|
107
|
+
.btn--negative /* Red/destructive */
|
|
108
|
+
.btn--link /* Text-only */
|
|
109
|
+
.btn--circle /* Circular icon button */
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Inputs
|
|
113
|
+
|
|
114
|
+
All inputs use `font-size: max(16px, 1em)` to prevent iOS auto-zoom.
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<%= form.text_field :name, class: "input" %>
|
|
118
|
+
<%= form.select :status, options, {}, class: "input input--select" %>
|
|
119
|
+
<%= form.text_area :body, class: "input input--textarea", rows: 1 %>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Auto-resizing textarea (modern browsers): `field-sizing: content`
|
|
123
|
+
|
|
124
|
+
### Focus & Motion
|
|
125
|
+
|
|
126
|
+
```css
|
|
127
|
+
--focus-ring: 2px solid var(--color-link); /* Applied via :focus-visible */
|
|
128
|
+
|
|
129
|
+
@media (prefers-reduced-motion: reduce) {
|
|
130
|
+
animation-duration: 0.01ms !important;
|
|
131
|
+
transition-duration: 0.01ms !important;
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Always respect `prefers-reduced-motion`. Always apply focus states on interactive elements.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## JS Bundling
|
|
140
|
+
|
|
141
|
+
Controllers are auto-discovered from `app/javascript/controllers/`. No manual registration.
|
|
142
|
+
|
|
143
|
+
Importmap (default) or esbuild depending on project setup. Third-party libraries bridged through Stimulus value callbacks — keep the controller as the integration boundary, not inline `<script>` tags.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Rules
|
|
148
|
+
|
|
149
|
+
- Use semantic color variables, never hard-coded values
|
|
150
|
+
- Use logical properties (`block-start` not `top`, `inline-size` not `width`)
|
|
151
|
+
- Use CSS custom properties for component variants
|
|
152
|
+
- Use `clamp()` for fluid responsive values
|
|
153
|
+
- Test in both light and dark modes
|
|
154
|
+
- Never create z-index values without adding to the defined stack
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Components — Stimulus Controllers & Presenter Pattern
|
|
2
|
+
|
|
3
|
+
## Stimulus Controller Architecture
|
|
4
|
+
|
|
5
|
+
**File naming:** `app/javascript/controllers/{name}_controller.js`
|
|
6
|
+
|
|
7
|
+
| File | Class | Identifier |
|
|
8
|
+
|------|-------|------------|
|
|
9
|
+
| `upload_preview_controller.js` | `UploadPreviewController` | `upload-preview` |
|
|
10
|
+
| `broadcast_channel_controller.js` | `BroadcastChannelController` | `broadcast-channel` |
|
|
11
|
+
|
|
12
|
+
Controllers in `app/javascript/controllers/` are auto-discovered. No manual `application.register()` needed.
|
|
13
|
+
|
|
14
|
+
### Contract-First Declaration
|
|
15
|
+
|
|
16
|
+
Declare the full interface at the top before any methods:
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import { Controller } from "@hotwired/stimulus"
|
|
20
|
+
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static values = { url: String, refreshInterval: Number, active: Boolean }
|
|
23
|
+
static targets = ["output", "spinner", "emptyState"]
|
|
24
|
+
static outlets = ["filter", "notification"]
|
|
25
|
+
static classes = ["loading", "hidden"]
|
|
26
|
+
|
|
27
|
+
connect() { }
|
|
28
|
+
disconnect() { }
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Single-purpose controllers.** Compose multiple controllers rather than one large one:
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<div data-controller="clipboard toggle tooltip"
|
|
36
|
+
data-clipboard-text-value="https://example.com/share/abc123">
|
|
37
|
+
<button data-action="clipboard#copy toggle#toggle">Copy Link</button>
|
|
38
|
+
</div>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Lifecycle
|
|
44
|
+
|
|
45
|
+
**Every `connect()` resource must be released in `disconnect()`.** Turbo navigations and morphs trigger these repeatedly:
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
connect() {
|
|
49
|
+
this.broadcast = new BroadcastChannel(this.channelValue)
|
|
50
|
+
this.broadcast.onmessage = this.handleMessage.bind(this)
|
|
51
|
+
this.refreshTimer = setInterval(() => this.refresh(), 30000)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.broadcast.close()
|
|
56
|
+
clearInterval(this.refreshTimer)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Resource | Setup | Teardown |
|
|
61
|
+
|----------|-------|----------|
|
|
62
|
+
| BroadcastChannel | `new BroadcastChannel()` | `.close()` |
|
|
63
|
+
| Blob URL | `URL.createObjectURL()` | `URL.revokeObjectURL()` |
|
|
64
|
+
| Timer | `setInterval()` / `setTimeout()` | `clearInterval()` / `clearTimeout()` |
|
|
65
|
+
| Observer | `.observe()` | `.disconnect()` |
|
|
66
|
+
| EventListener (window/doc) | `addEventListener()` | `removeEventListener()` |
|
|
67
|
+
|
|
68
|
+
**Guard `valueChanged` callbacks** — they fire before `connect()` completes; targets may not exist yet:
|
|
69
|
+
|
|
70
|
+
```javascript
|
|
71
|
+
urlValueChanged(url) {
|
|
72
|
+
if (!this.hasFrameTarget) return
|
|
73
|
+
this.frameTarget.src = url
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Values (Reactive State)
|
|
80
|
+
|
|
81
|
+
Values are the single source of truth. Never duplicate state in dataset entries or instance variables.
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
static values = {
|
|
85
|
+
url: String, // default: ""
|
|
86
|
+
count: Number, // default: 0
|
|
87
|
+
active: Boolean, // default: false
|
|
88
|
+
filters: Object, // default: {}
|
|
89
|
+
items: Array, // default: []
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
React with `{name}ValueChanged`. The `previous` argument is `undefined` on initial load:
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
pageValueChanged(current, previous) {
|
|
97
|
+
if (previous !== undefined) this.fetchPage(current)
|
|
98
|
+
this.counterTarget.textContent = `Page ${current}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
next() { this.pageValue++ } // Triggers pageValueChanged automatically
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Bridge third-party libraries through value callbacks — the value is the source of truth, the callback translates to the library API:
|
|
105
|
+
|
|
106
|
+
```javascript
|
|
107
|
+
dataValueChanged(data) {
|
|
108
|
+
if (!this.chart) return
|
|
109
|
+
this.chart.data = data
|
|
110
|
+
this.chart.update()
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Targets
|
|
117
|
+
|
|
118
|
+
Target callbacks fire when the DOM changes — essential for Turbo Stream integration:
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
static targets = ["item", "counter", "emptyState"]
|
|
122
|
+
|
|
123
|
+
itemTargetConnected(element) {
|
|
124
|
+
this.updateCount()
|
|
125
|
+
element.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
itemTargetDisconnected() { this.updateCount() }
|
|
129
|
+
|
|
130
|
+
updateCount() {
|
|
131
|
+
this.counterTarget.textContent = this.itemTargets.length
|
|
132
|
+
this.emptyStateTarget.hidden = this.itemTargets.length > 0
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Keep target callbacks idempotent** — morphs can trigger `TargetConnected` multiple times:
|
|
137
|
+
|
|
138
|
+
```javascript
|
|
139
|
+
// Bad — adds duplicate listeners on reconnect
|
|
140
|
+
itemTargetConnected(element) {
|
|
141
|
+
element.addEventListener("click", this.handleClick)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Good
|
|
145
|
+
itemTargetConnected(element) {
|
|
146
|
+
element.handleClick ||= this.handleClick.bind(this)
|
|
147
|
+
element.removeEventListener("click", element.handleClick)
|
|
148
|
+
element.addEventListener("click", element.handleClick)
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Derive computed state from targets rather than tracking separate values:
|
|
153
|
+
|
|
154
|
+
```javascript
|
|
155
|
+
get isEmpty() { return this.itemTargets.length === 0 }
|
|
156
|
+
get selectedItems() { return this.itemTargets.filter(el => el.dataset.selected === "true") }
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Outlets (Controller-to-Controller)
|
|
162
|
+
|
|
163
|
+
Prefer outlets over custom events or `getControllerForElementAndIdentifier` for direct communication:
|
|
164
|
+
|
|
165
|
+
```javascript
|
|
166
|
+
// dashboard_controller.js
|
|
167
|
+
static outlets = ["chart", "filter"]
|
|
168
|
+
|
|
169
|
+
apply() {
|
|
170
|
+
const filters = this.filterOutlet.currentFilters
|
|
171
|
+
this.chartOutlets.forEach(chart => chart.reload(filters))
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<div data-controller="dashboard"
|
|
177
|
+
data-dashboard-chart-outlet=".chart-widget"
|
|
178
|
+
data-dashboard-filter-outlet="#main-filter">
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Action Parameters
|
|
184
|
+
|
|
185
|
+
Pass typed data from HTML without manual `dataset` parsing:
|
|
186
|
+
|
|
187
|
+
```html
|
|
188
|
+
<button data-action="cart#add"
|
|
189
|
+
data-cart-id-param="42"
|
|
190
|
+
data-cart-name-param="Widget"
|
|
191
|
+
data-cart-price-param="19.99">
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
add({ params: { id, name, price } }) {
|
|
196
|
+
this.addItem(id, name, price) // id = 42 (Number), name = "Widget" (String)
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Keyboard filters:
|
|
201
|
+
|
|
202
|
+
```html
|
|
203
|
+
<textarea data-action="keydown.ctrl+s->editor#save
|
|
204
|
+
keydown.meta+s->editor#save
|
|
205
|
+
keydown.esc->editor#cancel">
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Presenter Pattern
|
|
211
|
+
|
|
212
|
+
Plain Ruby classes in `app/models/` — no special directory, no inheritance:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
class Event::Description
|
|
216
|
+
include ActionView::Helpers::TagHelper
|
|
217
|
+
include ERB::Util
|
|
218
|
+
|
|
219
|
+
def initialize(event, user) = @event, @user = event, user
|
|
220
|
+
|
|
221
|
+
def to_html = to_sentence(creator_tag, card_title_tag).html_safe
|
|
222
|
+
def to_plain_text = to_sentence(creator_name, quoted(card.title))
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
def creator_tag = tag.span(event.creator.name, class: "creator")
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Factory method on the model keeps the API discoverable:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
# app/models/event.rb
|
|
233
|
+
def description_for(user) = Event::Description.new(self, user)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```erb
|
|
237
|
+
<%= event.description_for(Current.user).to_html %>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Controller concern for cross-controller instantiation:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
module FilterScoped
|
|
244
|
+
extend ActiveSupport::Concern
|
|
245
|
+
included do
|
|
246
|
+
before_action :set_user_filtering
|
|
247
|
+
end
|
|
248
|
+
private
|
|
249
|
+
def set_user_filtering
|
|
250
|
+
@user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
```
|