funicular 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
@@ -0,0 +1,427 @@
1
+ # Routing and Navigation
2
+
3
+ This guide covers Funicular's built-in client-side routing and the Rails-style `link_to` helper for navigation.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Router Setup](#router-setup)
8
+ - [Defining Routes](#defining-routes)
9
+ - [URL Parameters](#url-parameters)
10
+ - [Named Routes and URL Helpers](#named-routes-and-url-helpers)
11
+ - [Programmatic Navigation](#programmatic-navigation)
12
+ - [link_to Helper](#link_to-helper)
13
+ - [Route Constraints](#route-constraints)
14
+ - [Best Practices](#best-practices)
15
+
16
+ ## Router Setup
17
+
18
+ Initialize the router when starting your application:
19
+
20
+ ```ruby
21
+ Funicular.start(container: 'app') do |router|
22
+ router.get('/login', to: LoginComponent, as: 'login')
23
+ router.get('/dashboard', to: DashboardComponent, as: 'dashboard')
24
+ router.get('/settings', to: SettingsComponent, as: 'settings')
25
+
26
+ router.set_default('/login') # Redirect / to /login
27
+ end
28
+ ```
29
+
30
+ The router:
31
+ - Uses the History API for SPA navigation
32
+ - Listens to browser back/forward buttons
33
+ - Automatically mounts/unmounts components on route changes
34
+
35
+ ## Defining Routes
36
+
37
+ ### Basic Routes
38
+
39
+ ```ruby
40
+ router.get('/about', to: AboutComponent, as: 'about')
41
+ router.get('/contact', to: ContactComponent, as: 'contact')
42
+ ```
43
+
44
+ ### HTTP Method Routes
45
+
46
+ While Funicular is client-side, you can define method-specific routes for semantic clarity:
47
+
48
+ ```ruby
49
+ router.get('/posts', to: PostListComponent, as: 'posts')
50
+ router.post('/posts', to: CreatePostComponent, as: 'create_post')
51
+ router.delete('/posts/:id', to: DeletePostComponent, as: 'delete_post')
52
+ ```
53
+
54
+ **Note**: These routes are for client-side organization. For server communication, use the `link_to` helper with `method:` option (see below).
55
+
56
+ ## URL Parameters
57
+
58
+ Define dynamic routes with `:param` syntax:
59
+
60
+ ```ruby
61
+ router.get('/users/:id', to: UserProfileComponent, as: 'user')
62
+ router.get('/posts/:post_id/comments/:comment_id', to: CommentDetailComponent, as: 'comment')
63
+ ```
64
+
65
+ Access parameters in your component via `props`:
66
+
67
+ ```ruby
68
+ class UserProfileComponent < Funicular::Component
69
+ def component_mounted
70
+ user_id = props[:id] # From /users/:id
71
+ User.find(user_id) do |user|
72
+ patch(user: user)
73
+ end
74
+ end
75
+
76
+ def render
77
+ div do
78
+ h1 { "User Profile: #{props[:id]}" }
79
+ if state.user
80
+ p { state.user.name }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ ```
86
+
87
+ ## Route Constraints
88
+
89
+ You can add regular expression constraints to URL parameters. When a constraint is specified, the route only matches if the parameter value matches the pattern. This is useful for distinguishing routes that share the same structure but accept different parameter formats.
90
+
91
+ ```ruby
92
+ Funicular.start(container: 'app') do |router|
93
+ # Only matches when :id is numeric
94
+ router.get('/users/:id', to: UserProfileComponent, as: 'user', constraints: { id: /\d+/ })
95
+
96
+ # Only matches when :channel_id is numeric
97
+ router.get('/chat/:channel_id', to: ChatComponent, as: 'chat_channel', constraints: { channel_id: /\d+/ })
98
+
99
+ # Multiple parameters can each have constraints
100
+ router.get('/posts/:year/:month', to: ArchiveComponent, as: 'archive', constraints: { year: /\d{4}/, month: /\d{1,2}/ })
101
+
102
+ # No constraint - matches any string
103
+ router.get('/pages/:slug', to: PageComponent, as: 'page')
104
+ end
105
+ ```
106
+
107
+ The `constraints` option takes a Hash mapping parameter names (as Symbols) to Regexp objects. If a segment does not match its constraint, the router skips that route and continues to the next candidate.
108
+
109
+ **Note**: Constraints use `Regexp#match?`, which is backed by JavaScript's `RegExp` engine in PicoRuby.wasm.
110
+
111
+ ## Named Routes and URL Helpers
112
+
113
+ The `as:` parameter generates URL helper methods:
114
+
115
+ ```ruby
116
+ router.get('/users/:id', to: UserProfileComponent, as: 'user')
117
+ router.get('/posts/:id/edit', to: EditPostComponent, as: 'edit_post')
118
+ router.get('/settings', to: SettingsComponent, as: 'settings')
119
+ ```
120
+
121
+ Use helpers via `RouteHelpers` module:
122
+
123
+ ```ruby
124
+ include Funicular::RouteHelpers
125
+
126
+ user_path(123) # => "/users/123"
127
+ edit_post_path(456) # => "/posts/456/edit"
128
+ settings_path # => "/settings"
129
+ ```
130
+
131
+ ### With Model Objects
132
+
133
+ Helpers work with objects that respond to `#id`:
134
+
135
+ ```ruby
136
+ user = User.new(id: 123, name: "Alice")
137
+ user_path(user) # => "/users/123"
138
+
139
+ post = Post.new(id: 456, title: "Hello")
140
+ edit_post_path(post) # => "/posts/456/edit"
141
+ ```
142
+
143
+ ### In Components
144
+
145
+ ```ruby
146
+ class UserCard < Funicular::Component
147
+ include Funicular::RouteHelpers
148
+
149
+ def render
150
+ div do
151
+ h3 { props[:user].name }
152
+ link_to user_path(props[:user]), navigate: true do
153
+ span { "View Profile" }
154
+ end
155
+ end
156
+ end
157
+ end
158
+ ```
159
+
160
+ ## Programmatic Navigation
161
+
162
+ Navigate imperatively using the router instance:
163
+
164
+ ```ruby
165
+ class LoginComponent < Funicular::Component
166
+ def handle_login
167
+ User.authenticate(state.credentials) do |user, error|
168
+ if user
169
+ # Navigate to dashboard on success
170
+ Funicular.router.navigate('/dashboard')
171
+ else
172
+ patch(error: error)
173
+ end
174
+ end
175
+ end
176
+
177
+ def render
178
+ button(onclick: -> { handle_login }) { "Login" }
179
+ end
180
+ end
181
+ ```
182
+
183
+ ### Getting Current Path
184
+
185
+ ```ruby
186
+ Funicular.router.current_path # => "/users/123"
187
+ ```
188
+
189
+ ## link_to Helper
190
+
191
+ The `link_to` helper provides Rails-style navigation and actions. It intelligently chooses between navigation (`<a>` tags) and actions (`<div>` tags) based on context.
192
+
193
+ ### Navigation Links
194
+
195
+ Use `navigate: true` for client-side page transitions:
196
+
197
+ ```ruby
198
+ class Navigation < Funicular::Component
199
+ include Funicular::RouteHelpers
200
+
201
+ def render
202
+ nav do
203
+ link_to dashboard_path, navigate: true, class: "nav-link" do
204
+ span { "Dashboard" }
205
+ end
206
+
207
+ link_to settings_path, navigate: true, class: "nav-link" do
208
+ span { "Settings" }
209
+ end
210
+ end
211
+ end
212
+ end
213
+ ```
214
+
215
+ **Behavior**:
216
+ - Generates `<a href="/path">` tag
217
+ - Normal click: SPA navigation via History API (no page reload)
218
+ - Right-click → "Open in New Tab": Opens URL in new tab
219
+ - Browser features work: link preview, copy link, etc.
220
+
221
+ ### Action Links
222
+
223
+ For server actions (create, update, delete), omit `navigate:`:
224
+
225
+ ```ruby
226
+ class MessageItem < Funicular::Component
227
+ include Funicular::RouteHelpers
228
+
229
+ def render
230
+ div do
231
+ p { props[:message].content }
232
+
233
+ # DELETE request to server
234
+ link_to message_path(props[:message]), method: :delete, class: "delete-btn" do
235
+ span { "Delete" }
236
+ end
237
+ end
238
+ end
239
+ end
240
+ ```
241
+
242
+ **Behavior**:
243
+ - Generates `<div>` tag (not `<a>`)
244
+ - Sends HTTP request via Fetch API
245
+ - Supports all HTTP methods: `:get`, `:post`, `:put`, `:patch`, `:delete`
246
+
247
+ ### Why Different Elements?
248
+
249
+ | Type | Element | Use Case | Browser Features |
250
+ |------|---------|----------|------------------|
251
+ | **Navigation** | `<a href>` | Page transitions | ✅ Right-click menu, new tab |
252
+ | **Action** | `<div>` | Server operations | ❌ Not a link (semantically correct) |
253
+
254
+ ### Custom Response Handling
255
+
256
+ Override `handle_link_response` to customize action response handling:
257
+
258
+ ```ruby
259
+ class ChatComponent < Funicular::Component
260
+ def handle_link_response(response, path, method)
261
+ if response.error?
262
+ puts "Action failed: #{response.error_message}"
263
+ patch(error: response.error_message)
264
+ else
265
+ puts "Action succeeded!"
266
+ # Update state based on response
267
+ if method == :delete
268
+ # Remove deleted item from state
269
+ patch(messages: state.messages.reject { |m| m.id == response.data[:id] })
270
+ end
271
+ end
272
+ end
273
+
274
+ def render
275
+ state.messages.map do |message|
276
+ link_to message_path(message), method: :delete, class: "text-red-600" do
277
+ span { "Delete" }
278
+ end
279
+ end
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### Styling Links
285
+
286
+ ```ruby
287
+ class Navigation < Funicular::Component
288
+ include Funicular::RouteHelpers
289
+
290
+ styles do
291
+ nav_link base: "px-4 py-2 text-gray-700 hover:bg-gray-100 rounded transition-colors",
292
+ active: "bg-blue-100 text-blue-700 font-semibold"
293
+ end
294
+
295
+ def render
296
+ nav(class: "flex gap-2") do
297
+ link_to dashboard_path,
298
+ navigate: true,
299
+ class: s.nav_link(is_current_path?(dashboard_path)) do
300
+ span { "Dashboard" }
301
+ end
302
+
303
+ link_to settings_path,
304
+ navigate: true,
305
+ class: s.nav_link(is_current_path?(settings_path)) do
306
+ span { "Settings" }
307
+ end
308
+ end
309
+ end
310
+
311
+ def is_current_path?(path)
312
+ Funicular.router.current_path == path
313
+ end
314
+ end
315
+ ```
316
+
317
+ ## Best Practices
318
+
319
+ ### 1. Use Named Routes
320
+
321
+ ```ruby
322
+ # ✅ Good: Named routes
323
+ router.get('/users/:id', to: UserProfileComponent, as: 'user')
324
+ link_to user_path(user), navigate: true
325
+
326
+ # ❌ Avoid: Hard-coded paths
327
+ link_to "/users/#{user.id}", navigate: true
328
+ ```
329
+
330
+ ### 2. Choose Correct Element Type
331
+
332
+ ```ruby
333
+ # ✅ Navigation: use <a> tag
334
+ link_to settings_path, navigate: true do
335
+ span { "Settings" }
336
+ end
337
+
338
+ # ✅ Server action: use <div> tag
339
+ link_to post_path(post), method: :delete do
340
+ span { "Delete Post" }
341
+ end
342
+
343
+ # ❌ Avoid: Using navigate: true for server actions
344
+ link_to post_path(post), method: :delete, navigate: true # Wrong!
345
+ ```
346
+
347
+ ### 3. Include RouteHelpers
348
+
349
+ ```ruby
350
+ # ✅ Include in components that use routing
351
+ class MyComponent < Funicular::Component
352
+ include Funicular::RouteHelpers
353
+
354
+ def render
355
+ link_to user_path(user), navigate: true
356
+ end
357
+ end
358
+ ```
359
+
360
+ ### 4. Handle Loading States
361
+
362
+ ```ruby
363
+ def handle_navigation
364
+ patch(is_loading: true)
365
+
366
+ # Load data, then navigate
367
+ User.find(id) do |user|
368
+ patch(is_loading: false, user: user)
369
+ Funicular.router.navigate(user_path(user))
370
+ end
371
+ end
372
+ ```
373
+
374
+ ### 5. Validate Before Navigation
375
+
376
+ ```ruby
377
+ def handle_next_step
378
+ if state.form_valid?
379
+ Funicular.router.navigate('/step-2')
380
+ else
381
+ patch(errors: validate_form)
382
+ end
383
+ end
384
+ ```
385
+
386
+ ## Advanced: Route Guards
387
+
388
+ Implement route guards by checking conditions in `component_mounted`:
389
+
390
+ ```ruby
391
+ class DashboardComponent < Funicular::Component
392
+ def component_mounted
393
+ # Redirect to login if not authenticated
394
+ unless Session.authenticated?
395
+ Funicular.router.navigate('/login')
396
+ return
397
+ end
398
+
399
+ # Load dashboard data
400
+ load_suspense_data
401
+ end
402
+ end
403
+ ```
404
+
405
+ ## Integration with Rails Routes
406
+
407
+ If using Funicular with Rails, you can mirror Rails routes on the client:
408
+
409
+ ```ruby
410
+ # Rails routes.rb
411
+ Rails.application.routes.draw do
412
+ get '/dashboard', to: 'pages#dashboard'
413
+ resources :users
414
+ resources :posts do
415
+ resources :comments
416
+ end
417
+ end
418
+
419
+ # Funicular router
420
+ Funicular.start(container: 'app') do |router|
421
+ router.get('/dashboard', to: DashboardComponent, as: 'dashboard')
422
+ router.get('/users/:id', to: UserProfileComponent, as: 'user')
423
+ router.get('/posts/:post_id/comments/:comment_id', to: CommentDetailComponent, as: 'comment')
424
+ end
425
+ ```
426
+
427
+ This allows you to use the same path helpers on both client and server.
data/docs/styling.md ADDED
@@ -0,0 +1,285 @@
1
+ # CSS-in-Ruby with Styles DSL
2
+
3
+ Funicular provides a powerful CSS-in-Ruby DSL that keeps your styles organized and scoped within each component.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Basic Usage](#basic-usage)
8
+ - [Conditional Styles](#conditional-styles)
9
+ - [Variant Styles](#variant-styles)
10
+ - [Combining Styles](#combining-styles)
11
+ - [Best Practices](#best-practices)
12
+
13
+ ## Basic Usage
14
+
15
+ Define styles using the `styles` block at the top of your component:
16
+
17
+ ```ruby
18
+ class LoginComponent < Funicular::Component
19
+ styles do
20
+ container "min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600"
21
+ card "bg-white p-8 rounded-lg shadow-2xl w-96"
22
+ title "text-3xl font-bold text-center mb-8 text-gray-800"
23
+ input "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
24
+ end
25
+
26
+ def render
27
+ div(class: s.container) do
28
+ div(class: s.card) do
29
+ h1(class: s.title) { "Welcome" }
30
+ input(class: s.input, type: "text", placeholder: "Username")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ ```
36
+
37
+ Access styles via the `s` helper in your render method.
38
+
39
+ ## Conditional Styles
40
+
41
+ For styles that toggle based on state (like active/inactive), use the `active:` key:
42
+
43
+ ```ruby
44
+ class ChannelList < Funicular::Component
45
+ styles do
46
+ channel_item base: "p-4 hover:bg-gray-700 cursor-pointer transition-colors",
47
+ active: "bg-gray-700 border-l-4 border-blue-500"
48
+ end
49
+
50
+ def render
51
+ state.channels.map do |channel|
52
+ is_active = state.current_channel_id == channel.id
53
+ div(class: s.channel_item(is_active)) do
54
+ span { channel.name }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ When `is_active` is `true`, the result is `"p-4 hover:bg-gray-700 cursor-pointer transition-colors bg-gray-700 border-l-4 border-blue-500"`.
62
+ When `false`, only the base classes are applied.
63
+
64
+ ## Variant Styles
65
+
66
+ For styles with multiple states (e.g., primary/danger buttons), use `variants:`:
67
+
68
+ ```ruby
69
+ class ButtonComponent < Funicular::Component
70
+ styles do
71
+ button base: "px-4 py-2 rounded font-semibold transition-colors",
72
+ variants: {
73
+ primary: "bg-blue-600 text-white hover:bg-blue-700",
74
+ danger: "bg-red-600 text-white hover:bg-red-700",
75
+ secondary: "bg-gray-600 text-white hover:bg-gray-700",
76
+ ghost: "bg-transparent border border-gray-300 hover:bg-gray-100"
77
+ }
78
+ end
79
+
80
+ def render
81
+ div do
82
+ button(class: s.button(:primary)) { "Submit" }
83
+ button(class: s.button(:danger)) { "Delete" }
84
+ button(class: s.button(:secondary)) { "Cancel" }
85
+ button(class: s.button(:ghost)) { "Learn More" }
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ You can also combine variants with props:
92
+
93
+ ```ruby
94
+ class ActionButton < Funicular::Component
95
+ styles do
96
+ button base: "px-4 py-2 rounded font-semibold",
97
+ variants: {
98
+ primary: "bg-blue-600 text-white hover:bg-blue-700",
99
+ danger: "bg-red-600 text-white hover:bg-red-700"
100
+ }
101
+ end
102
+
103
+ def render
104
+ button(class: s.button(props[:variant] || :primary)) do
105
+ props[:label]
106
+ end
107
+ end
108
+ end
109
+
110
+ # Usage
111
+ component(ActionButton, variant: :danger, label: "Delete")
112
+ ```
113
+
114
+ ## Combining Styles
115
+
116
+ Chain multiple styles together using the `|` operator:
117
+
118
+ ```ruby
119
+ class Sidebar < Funicular::Component
120
+ styles do
121
+ flex "flex"
122
+ items_center "items-center"
123
+ gap_2 "gap-2"
124
+ sidebar "w-64 bg-gray-800 text-white p-4"
125
+ channel_item base: "p-2 rounded hover:bg-gray-700 cursor-pointer",
126
+ active: "bg-gray-700"
127
+ end
128
+
129
+ def render
130
+ div(class: s.sidebar) do
131
+ # Combine utility classes
132
+ div(class: s.flex | s.items_center | s.gap_2) do
133
+ span { "Channel List" }
134
+ end
135
+
136
+ # Mix DSL styles with custom classes
137
+ div(class: s.channel_item(is_active) | "mb-2" | "custom-shadow") do
138
+ span { "General" }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ ```
144
+
145
+ The `|` operator:
146
+ - Automatically filters out `nil` values
147
+ - Perfect for conditional styling
148
+ - Allows mixing DSL styles with raw strings
149
+
150
+ ### Conditional Combination
151
+
152
+ ```ruby
153
+ def render
154
+ div(class: s.card | (state.highlighted ? "ring-2 ring-blue-500" : nil)) do
155
+ # Card content
156
+ end
157
+ end
158
+ ```
159
+
160
+ ## Best Practices
161
+
162
+ ### 1. Keep Styles Scoped
163
+
164
+ Define styles within components to avoid global namespace pollution:
165
+
166
+ ```ruby
167
+ # ✅ Good: Scoped to component
168
+ class UserCard < Funicular::Component
169
+ styles do
170
+ card "bg-white rounded shadow p-4"
171
+ end
172
+ end
173
+
174
+ # ❌ Avoid: Global CSS classes scattered in render
175
+ class UserCard < Funicular::Component
176
+ def render
177
+ div(class: "bg-white rounded shadow p-4") # Less maintainable
178
+ end
179
+ end
180
+ ```
181
+
182
+ ### 2. Use Semantic Names
183
+
184
+ ```ruby
185
+ # ✅ Good: Semantic style names
186
+ styles do
187
+ primary_button "bg-blue-600 text-white px-4 py-2 rounded"
188
+ danger_button "bg-red-600 text-white px-4 py-2 rounded"
189
+ end
190
+
191
+ # ❌ Avoid: Generic or unclear names
192
+ styles do
193
+ btn1 "bg-blue-600 text-white px-4 py-2 rounded"
194
+ btn2 "bg-red-600 text-white px-4 py-2 rounded"
195
+ end
196
+ ```
197
+
198
+ ### 3. Group Related Styles
199
+
200
+ ```ruby
201
+ styles do
202
+ # Layout
203
+ container "max-w-7xl mx-auto px-4"
204
+ sidebar "w-64 bg-gray-800"
205
+ main_content "flex-1 p-6"
206
+
207
+ # Typography
208
+ heading "text-2xl font-bold text-gray-900"
209
+ subheading "text-lg font-semibold text-gray-700"
210
+ body_text "text-base text-gray-600"
211
+
212
+ # Buttons
213
+ button base: "px-4 py-2 rounded",
214
+ variants: {
215
+ primary: "bg-blue-600 text-white",
216
+ secondary: "bg-gray-600 text-white"
217
+ }
218
+ end
219
+ ```
220
+
221
+ ### 4. Extract Common Styles
222
+
223
+ For styles used across multiple components, consider creating a shared module:
224
+
225
+ ```ruby
226
+ module ButtonStyles
227
+ def self.included(base)
228
+ base.class_eval do
229
+ styles do
230
+ button base: "px-4 py-2 rounded font-semibold transition-colors",
231
+ variants: {
232
+ primary: "bg-blue-600 text-white hover:bg-blue-700",
233
+ danger: "bg-red-600 text-white hover:bg-red-700"
234
+ }
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ class MyComponent < Funicular::Component
241
+ include ButtonStyles
242
+
243
+ def render
244
+ button(class: s.button(:primary)) { "Click Me" }
245
+ end
246
+ end
247
+ ```
248
+
249
+ ### 5. Use Tailwind (or Similar) for Utility Classes
250
+
251
+ Funicular's Styles DSL works great with Tailwind CSS utility classes:
252
+
253
+ ```ruby
254
+ styles do
255
+ card "bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 hover:shadow-xl transition-shadow"
256
+ input "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
257
+ end
258
+ ```
259
+
260
+ ### 6. Prefer Composition Over Long Class Strings
261
+
262
+ ```ruby
263
+ # ✅ Good: Composed styles
264
+ styles do
265
+ base_input "w-full px-3 py-2 border rounded-md"
266
+ focused_input "outline-none ring-2 ring-blue-500"
267
+ end
268
+
269
+ def render
270
+ input(class: s.base_input | s.focused_input)
271
+ end
272
+
273
+ # ❌ Avoid: One massive style
274
+ styles do
275
+ input "w-full px-3 py-2 border rounded-md outline-none ring-2 ring-blue-500"
276
+ end
277
+ ```
278
+
279
+ ## Key Benefits
280
+
281
+ - **Scoped Styles**: Styles are defined within components, avoiding global namespace pollution
282
+ - **Readability**: `s.button(:primary)` is more semantic than raw Tailwind classes scattered throughout render methods
283
+ - **Maintainability**: Change styles in one place rather than hunting through render methods
284
+ - **Type Safety**: The DSL ensures style names are consistent throughout your component
285
+ - **Flexibility**: Mix DSL styles with raw strings when needed using the `|` operator