funicular 0.3.0 → 0.4.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/demo/test_chartjs.html +9 -9
  4. data/demo/test_component.html +8 -8
  5. data/demo/test_error_boundary.html +44 -41
  6. data/demo/test_router.html +48 -48
  7. data/demo/tic-tac-toe.html +25 -25
  8. data/docs/architecture.md +46 -9
  9. data/lib/funicular/ssr/runtime.rb +1 -0
  10. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  11. data/lib/funicular/vendor/mrbc/mrbc.js +613 -486
  12. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  13. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  14. data/lib/funicular/vendor/picoruby/debug/picoruby.js +669 -449
  15. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  16. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  17. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  19. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +921 -629
  20. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  21. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -1
  22. data/lib/funicular/version.rb +1 -1
  23. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  24. data/minitest/dsl_test.rb +237 -0
  25. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  26. data/minitest/form_for_test.rb +2 -2
  27. data/minitest/hydration_test.rb +2 -2
  28. data/minitest/sig_tags_test.rb +30 -0
  29. data/minitest/view_context_test.rb +15 -15
  30. data/mrblib/0_tags.rb +62 -0
  31. data/mrblib/component.rb +113 -23
  32. data/mrblib/error_boundary.rb +25 -19
  33. data/mrblib/form_builder.rb +10 -10
  34. data/mrblib/funicular.rb +1 -1
  35. data/mrblib/styles.rb +102 -12
  36. data/mrblib/view_context.rb +3 -32
  37. data/sig/component.rbs +18 -4
  38. data/sig/error_boundary.rbs +4 -4
  39. data/sig/styles.rbs +18 -5
  40. data/sig/tags.rbs +54 -0
  41. data/sig/view_context.rbs +47 -34
  42. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56720ac2a4a7635871515e66f55bf5ed272bb49cb7cc3a403671494720ef16d7
4
- data.tar.gz: 7bf29c93a9480501c53e9b10cb33d29eb73ec22964300aed1c3e124206a3ab40
3
+ metadata.gz: 5816e8e43d54e6bec4b6bb8a06da6165ab138e549cdbad649d2acba002a5fec4
4
+ data.tar.gz: 51ed5ca984f12669bfd1587bf384ea685c54e2d03dff9552d3cc49485d29081f
5
5
  SHA512:
6
- metadata.gz: 297df4162ffbe5259e2fe5c7738a310714a42ff3e1704386560c65d27e9a6d5aa19f5374e700915b388e37f9879926e2454296e9a39bfee4080d04310e2df295
7
- data.tar.gz: c56295e03efd38afc789b876717f3ce05a0631f02197843362ff627084db12fc3ecdd925b9c8ce437dbc8cff1b6fca9ff9b2b9396a5c22e24fd203fbcf50740d
6
+ metadata.gz: 360c39bdc7375ae5967c37f0b24437fa6d254f8814520ae984cc455195ac957b1279988a430f752e731eb40eab62cbeba178bc1ad08dc8836c7ce64c0d0810d5
7
+ data.tar.gz: 31bb97a1b6fbedbf3d1b1539c3e8ea5a8169c91095733c09d38f465c463a56071ecb766524945e307b66998708e09fff0ce0f6388e009f54b9d9945169922259
data/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - 0.4.0 bareword component DSL: `render` (zero-arity) runs with `self` as
6
+ the component, so HTML tags, `component`, `form_for`, `link_to`,
7
+ `button_to`, `suspense`, `state`, `props`, `styles`, `resources`, and
8
+ `routes` are all called bareword, without the 0.3.0 `h.` receiver.
9
+ - DSL collision detection: tag and helper names are reserved inside
10
+ component classes. Defining one raises `Funicular::DSLCollisionError` at
11
+ class-definition time (`method_added`) or at first mount
12
+ (`validate_dsl_conflicts!`, covering `attr_*` on mruby and included
13
+ modules). `allow_dsl_override :name` opts out per class; the shadowed
14
+ element stays reachable via `tag(:name, ...)`.
15
+ - Bareword style definitions: the class-level `styles do ... end` block
16
+ runs on a `BasicObject` cleanroom builder, so any name (including
17
+ `display`, `hash`, ...) defines a style identically on mruby and CRuby.
18
+ The explicit `styles { |css| css.define(...) }` form remains for
19
+ computed values.
20
+ - Generated style accessors: each declared style name becomes a real
21
+ method on a per-component accessor, e.g. `styles.button(:disabled)`;
22
+ the `styles[:name, variant]` form is kept.
23
+
24
+ ### Breaking Changes
25
+
26
+ - **0.4.0 is a breaking DSL change against 0.3.0. Components written for
27
+ 0.3.0 migrate mechanically: delete the `render(h)` parameter, drop the
28
+ `h.` receivers, convert `css.define :name, "..."` to bareword
29
+ `name "..."`, and `h.styles[:name, variant]` to
30
+ `styles.name(variant)`.**
31
+ - `p` inside a component builds a `<p>` element. Debug with
32
+ `puts x.inspect`; a non-Hash argument to any tag raises `ArgumentError`
33
+ with a hint.
34
+ - A local variable named after a tag shadows the zero-paren call form
35
+ (plain Ruby scoping); write `option()` or rename the local.
36
+ - Tag and helper names (RESERVED_DSL) can no longer be defined as
37
+ component methods without `allow_dsl_override`.
38
+ - Style lookups of unknown names raise (`NoMethodError` for
39
+ `styles.typo`, `ArgumentError` for `styles[:typo]`) instead of
40
+ silently returning an empty class string. Style definition values are
41
+ validated (String / Hash / keyword options; unknown option keys raise).
42
+ - Tag helpers called while the component is not rendering raise
43
+ `Funicular::RenderContextError` instead of being silently dropped.
44
+ - `ErrorBoundary` `fallback:`/`error:` procs keep an explicit view
45
+ context (`->(h, error) { h.div { ... } }`): they are created in the
46
+ parent's scope but run during the boundary's render, so barewords
47
+ cannot work there by design.
48
+ - `Component#render_suspense` no longer takes a view context; suspense
49
+ `fallback:`/`error:`/content procs run bareword in their own component
50
+ (`fallback: -> { div { "Loading" } }`).
51
+
52
+ ### Changed
53
+
54
+ - Requires picoruby-wasm with `JS::Object < BasicObject` (picoruby
55
+ 9e69333f): Kernel names (`hash`, `send`, `open`, ...) no longer shadow
56
+ JS property access, and unknown `?`/`!` methods on JS values raise.
57
+
58
+ ## [0.3.0] - 2026-07-13
59
+
60
+ ### Added
61
+
5
62
  - 0.3.0 rendering architecture: `render(h)` now receives a `ViewContext`
6
63
  facade for elements, components, forms, styles, resources, and routes.
7
64
  - Per-app `Runtime` context for route helpers and renderer/serializer
@@ -118,17 +118,17 @@
118
118
  puts "update_chart: Chart.js update('active') called"
119
119
  end
120
120
 
121
- def render(h)
122
- h.div(id: "dashboard") do |hh|
123
- hh.h2 { "Sales Dashboard" }
124
- hh.div(id: "chart-container") do |hhh|
125
- hhh.canvas(ref: :chart_canvas, id: "chart")
121
+ def render
122
+ div(id: "dashboard") do
123
+ h2 { "Sales Dashboard" }
124
+ div(id: "chart-container") do
125
+ canvas(ref: :chart_canvas, id: "chart")
126
126
  end
127
- hh.div do |hhh|
128
- hhh.button(id: "prev-btn", onclick: :prev_data_set) { "Previous Quarter" }
129
- hhh.button(id: "next-btn", onclick: :next_data_set) { "Next Quarter" }
127
+ div do
128
+ button(id: "prev-btn", onclick: :prev_data_set) { "Previous Quarter" }
129
+ button(id: "next-btn", onclick: :next_data_set) { "Next Quarter" }
130
130
  end
131
- hh.div(id: "info") do
131
+ div(id: "info") do
132
132
  current = state[:data_sets][state[:month_index]]
133
133
  "Current data: #{current[:labels].join(', ')}"
134
134
  end
@@ -48,17 +48,17 @@
48
48
  patch(count: state[:count] - 1)
49
49
  end
50
50
 
51
- def render(h)
52
- h.div(id: "counter-app") do |hh|
53
- hh.h2 { "Counter: #{state[:count]}" }
54
- hh.div(id: "status") do
51
+ def render
52
+ div(id: "counter-app") do
53
+ h2 { "Counter: #{state[:count]}" }
54
+ div(id: "status") do
55
55
  "Mounted: #{state[:mounted]}"
56
56
  end
57
- hh.div do |hhh|
58
- hhh.button(id: "increment-btn", onclick: :increment) { "+1" }
59
- hhh.button(id: "decrement-btn", onclick: :decrement) { "-1" }
57
+ div do
58
+ button(id: "increment-btn", onclick: :increment) { "+1" }
59
+ button(id: "decrement-btn", onclick: :decrement) { "-1" }
60
60
  end
61
- hh.div(ref: :display, id: "display") do
61
+ div(ref: :display, id: "display") do
62
62
  "Current value: #{state[:count]}"
63
63
  end
64
64
  end
@@ -91,7 +91,7 @@
91
91
  <script type="text/ruby">
92
92
  # A component that always throws an error during render
93
93
  class BrokenComponent < Funicular::Component
94
- def render(h)
94
+ def render
95
95
  raise "BrokenComponent intentionally threw this error!"
96
96
  end
97
97
  end
@@ -107,80 +107,83 @@
107
107
  patch(count: state[:count] + 1)
108
108
  end
109
109
 
110
- def render(h)
111
- h.div(class: 'demo-card') do |hh|
112
- hh.h3 { "Working Component" }
113
- hh.p { "Count: #{state[:count]}" }
114
- hh.button(onclick: :increment) { "Increment" }
110
+ def render
111
+ div(class: 'demo-card') do
112
+ h3 { "Working Component" }
113
+ p { "Count: #{state[:count]}" }
114
+ button(onclick: :increment) { "Increment" }
115
115
  end
116
116
  end
117
117
  end
118
118
 
119
119
  # Main demo application
120
120
  class ErrorBoundaryDemo < Funicular::Component
121
- def render(h)
121
+ def render
122
122
  error_handler = ->(error, info) {
123
123
  puts "[Demo] Error caught by ErrorBoundary:"
124
124
  puts " Error: #{error.message}"
125
125
  puts " Component: #{info&.dig(:component_class)}"
126
126
  }
127
127
 
128
- custom_fallback = ->(hh, error) {
129
- hh.div(style: 'background: linear-gradient(135deg, #ff6b6b, #ee5a5a); color: white; padding: 20px; border-radius: 8px;') do |hhh|
130
- hhh.h3(style: 'margin-top: 0;') { "Custom Error Handler" }
131
- hhh.p { "Caught: #{error.message}" }
132
- hhh.p(style: 'font-size: 12px; opacity: 0.8;') { "This is a custom fallback provided via props." }
128
+ # NOTE: an ErrorBoundary fallback runs during the BOUNDARY's render,
129
+ # not this component's, so it cannot use bareword tags. It receives
130
+ # the boundary's view context and builds elements through it.
131
+ custom_fallback = ->(h, error) {
132
+ h.div(style: 'background: linear-gradient(135deg, #ff6b6b, #ee5a5a); color: white; padding: 20px; border-radius: 8px;') do
133
+ h.h3(style: 'margin-top: 0;') { "Custom Error Handler" }
134
+ h.p { "Caught: #{error.message}" }
135
+ h.p(style: 'font-size: 12px; opacity: 0.8;') { "This is a custom fallback provided via props." }
133
136
  end
134
137
  }
135
138
 
136
- h.div do |hh|
137
- hh.h2 { "Error Boundary Demonstrations" }
139
+ div do
140
+ h2 { "Error Boundary Demonstrations" }
138
141
 
139
142
  # Demo 1: Default fallback
140
- hh.div(class: 'demo-card') do |card|
141
- card.h3 { "Demo 1: Default Fallback UI" }
142
- card.p { "ErrorBoundary wraps a component that always throws. Default fallback is shown." }
143
- card.component(Funicular::ErrorBoundary, on_error: error_handler) do |boundary|
144
- boundary.component(BrokenComponent)
143
+ div(class: 'demo-card') do
144
+ h3 { "Demo 1: Default Fallback UI" }
145
+ p { "ErrorBoundary wraps a component that always throws. Default fallback is shown." }
146
+ component(Funicular::ErrorBoundary, on_error: error_handler) do
147
+ component(BrokenComponent)
145
148
  end
146
149
  end
147
150
 
148
151
  # Demo 2: Custom fallback
149
- hh.div(class: 'demo-card') do |card|
150
- card.h3 { "Demo 2: Custom Fallback UI" }
151
- card.p { "ErrorBoundary with a custom fallback prop." }
152
- card.component(Funicular::ErrorBoundary,
152
+ div(class: 'demo-card') do
153
+ h3 { "Demo 2: Custom Fallback UI" }
154
+ p { "ErrorBoundary with a custom fallback prop." }
155
+ component(Funicular::ErrorBoundary,
153
156
  fallback: custom_fallback,
154
157
  on_error: error_handler
155
- ) do |boundary|
156
- boundary.component(BrokenComponent)
158
+ ) do
159
+ component(BrokenComponent)
157
160
  end
158
161
  end
159
162
 
160
163
  # Demo 3: Mixed content - some work, some fail
161
- hh.div(class: 'demo-card') do |card|
162
- card.h3 { "Demo 3: Isolated Error Boundaries" }
163
- card.p { "Each component has its own ErrorBoundary. One failure doesn't affect others." }
164
+ div(class: 'demo-card') do
165
+ h3 { "Demo 3: Isolated Error Boundaries" }
166
+ p { "Each component has its own ErrorBoundary. One failure doesn't affect others." }
164
167
 
165
- card.div(style: 'display: flex; gap: 15px; flex-wrap: wrap;') do |row|
166
- row.div(style: 'flex: 1; min-width: 200px;') do |col|
167
- col.h4 { "Working:" }
168
- col.component(Funicular::ErrorBoundary) do |boundary|
169
- boundary.component(WorkingComponent)
168
+ div(style: 'display: flex; gap: 15px; flex-wrap: wrap;') do
169
+ div(style: 'flex: 1; min-width: 200px;') do
170
+ h4 { "Working:" }
171
+ component(Funicular::ErrorBoundary) do
172
+ component(WorkingComponent)
170
173
  end
171
174
  end
172
175
 
173
- row.div(style: 'flex: 1; min-width: 200px;') do |col|
174
- col.h4 { "Broken:" }
175
- col.component(Funicular::ErrorBoundary) do |boundary|
176
- boundary.component(BrokenComponent)
176
+ div(style: 'flex: 1; min-width: 200px;') do
177
+ h4 { "Broken:" }
178
+ component(Funicular::ErrorBoundary) do
179
+ component(BrokenComponent)
177
180
  end
178
181
  end
179
182
 
180
- row.div(style: 'flex: 1; min-width: 200px;') do |col|
181
- col.h4 { "Also Working:" }
182
- col.component(Funicular::ErrorBoundary) do |boundary|
183
- boundary.component(WorkingComponent)
183
+ div(style: 'flex: 1; min-width: 200px;') do
184
+ h4 { "Also Working:" }
185
+ component(Funicular::ErrorBoundary) do
186
+ component(WorkingComponent)
184
187
  end
185
188
  end
186
189
  end
@@ -39,12 +39,12 @@
39
39
  }
40
40
  end
41
41
 
42
- def render(h)
43
- h.nav do |hh|
44
- hh.a(href: '/login', onclick: go_to('/login')) { 'Login' }
45
- hh.a(href: '/home', onclick: go_to('/home')) { 'Home' }
46
- hh.a(href: '/settings', onclick: go_to('/settings')) { 'Settings' }
47
- hh.a(href: '/users', onclick: go_to('/users')) { 'Users' }
42
+ def render
43
+ nav do
44
+ a(href: '/login', onclick: go_to('/login')) { 'Login' }
45
+ a(href: '/home', onclick: go_to('/home')) { 'Home' }
46
+ a(href: '/settings', onclick: go_to('/settings')) { 'Settings' }
47
+ a(href: '/users', onclick: go_to('/users')) { 'Users' }
48
48
  end
49
49
  end
50
50
  end
@@ -63,12 +63,12 @@
63
63
  Funicular.router.navigate('/home')
64
64
  end
65
65
 
66
- def render(h)
67
- h.div(class: 'login-page') do |hh|
68
- hh.h2(class: 'page-title') { 'Login Page' }
66
+ def render
67
+ div(class: 'login-page') do
68
+ h2(class: 'page-title') { 'Login Page' }
69
69
 
70
- hh.div do |hhh|
71
- hhh.input(
70
+ div do
71
+ input(
72
72
  type: 'text',
73
73
  placeholder: 'Username',
74
74
  value: state[:username],
@@ -76,11 +76,11 @@
76
76
  )
77
77
  end
78
78
 
79
- hh.div do |hhh|
80
- hhh.button(onclick: :on_login_click) { 'Login' }
79
+ div do
80
+ button(onclick: :on_login_click) { 'Login' }
81
81
  end
82
82
 
83
- hh.div(class: 'status') do
83
+ div(class: 'status') do
84
84
  "Username: #{state[:username]}"
85
85
  end
86
86
  end
@@ -102,20 +102,20 @@
102
102
  Funicular.router.navigate('/settings')
103
103
  end
104
104
 
105
- def render(h)
106
- h.div(class: 'home-page') do |hh|
107
- hh.h2(class: 'page-title') { 'Home Page' }
105
+ def render
106
+ div(class: 'home-page') do
107
+ h2(class: 'page-title') { 'Home Page' }
108
108
 
109
- hh.div do
109
+ div do
110
110
  "Counter: #{state[:count]}"
111
111
  end
112
112
 
113
- hh.div do |hhh|
114
- hhh.button(onclick: :increment) { 'Increment' }
115
- hhh.button(onclick: :go_to_settings) { 'Go to Settings' }
113
+ div do
114
+ button(onclick: :increment) { 'Increment' }
115
+ button(onclick: :go_to_settings) { 'Go to Settings' }
116
116
  end
117
117
 
118
- hh.div(class: 'status') do
118
+ div(class: 'status') do
119
119
  "You are on the home page. Click around to test routing!"
120
120
  end
121
121
  end
@@ -143,25 +143,25 @@
143
143
  Funicular.router.navigate('/home')
144
144
  end
145
145
 
146
- def render(h)
147
- h.div(class: 'settings-page') do |hh|
148
- hh.h2(class: 'page-title') { 'Settings Page' }
146
+ def render
147
+ div(class: 'settings-page') do
148
+ h2(class: 'page-title') { 'Settings Page' }
149
149
 
150
- hh.div do |hhh|
151
- hhh.span { "Theme: #{state[:theme]}" }
152
- hhh.button(onclick: :toggle_theme) { 'Toggle Theme' }
150
+ div do
151
+ span { "Theme: #{state[:theme]}" }
152
+ button(onclick: :toggle_theme) { 'Toggle Theme' }
153
153
  end
154
154
 
155
- hh.div do |hhh|
156
- hhh.span { "Notifications: #{state[:notifications] ? 'ON' : 'OFF'}" }
157
- hhh.button(onclick: :toggle_notifications) { 'Toggle Notifications' }
155
+ div do
156
+ span { "Notifications: #{state[:notifications] ? 'ON' : 'OFF'}" }
157
+ button(onclick: :toggle_notifications) { 'Toggle Notifications' }
158
158
  end
159
159
 
160
- hh.div do |hhh|
161
- hhh.button(onclick: :go_to_home) { 'Back to Home' }
160
+ div do
161
+ button(onclick: :go_to_home) { 'Back to Home' }
162
162
  end
163
163
 
164
- hh.div(class: 'status') do
164
+ div(class: 'status') do
165
165
  "Configure your settings here"
166
166
  end
167
167
  end
@@ -185,21 +185,21 @@
185
185
  }
186
186
  end
187
187
 
188
- def render(h)
189
- h.div(class: 'users-page') do |hh|
190
- hh.h2(class: 'page-title') { 'Users List' }
188
+ def render
189
+ div(class: 'users-page') do
190
+ h2(class: 'page-title') { 'Users List' }
191
191
 
192
- hh.ul do |hhh|
192
+ ul do
193
193
  state[:users].each do |user|
194
- hhh.li do |item|
195
- item.a(href: "/users/#{user[:id]}", onclick: go_to_user(user[:id])) do
194
+ li do
195
+ a(href: "/users/#{user[:id]}", onclick: go_to_user(user[:id])) do
196
196
  user[:name]
197
197
  end
198
198
  end
199
199
  end
200
200
  end
201
201
 
202
- hh.div(class: 'status') do
202
+ div(class: 'status') do
203
203
  "Click a user to see dynamic route with :id parameter"
204
204
  end
205
205
  end
@@ -216,19 +216,19 @@
216
216
  Funicular.router.navigate('/users')
217
217
  end
218
218
 
219
- def render(h)
220
- h.div(class: 'user-detail-page') do |hh|
221
- hh.h2(class: 'page-title') { "User Detail" }
219
+ def render
220
+ div(class: 'user-detail-page') do
221
+ h2(class: 'page-title') { "User Detail" }
222
222
 
223
- hh.div do
223
+ div do
224
224
  "Viewing user ID: #{state[:user_id]}"
225
225
  end
226
226
 
227
- hh.div do |hhh|
228
- hhh.button(onclick: :go_back) { 'Back to Users' }
227
+ div do
228
+ button(onclick: :go_back) { 'Back to Users' }
229
229
  end
230
230
 
231
- hh.div(class: 'status') do
231
+ div(class: 'status') do
232
232
  "This page demonstrates dynamic routing with params[:id] = #{state[:user_id]}"
233
233
  end
234
234
  end
@@ -38,8 +38,8 @@
38
38
  on_click.call if on_click
39
39
  end
40
40
 
41
- def render(h)
42
- h.button(class: 'square', onclick: :handle_click) do
41
+ def render
42
+ button(class: 'square', onclick: :handle_click) do
43
43
  props[:value] || ''
44
44
  end
45
45
  end
@@ -55,7 +55,7 @@
55
55
  }
56
56
  end
57
57
 
58
- def render(h)
58
+ def render
59
59
  squares = props[:squares]
60
60
  x_is_next = props[:x_is_next]
61
61
 
@@ -66,22 +66,22 @@
66
66
  "Next player: #{x_is_next ? 'X' : 'O'}"
67
67
  end
68
68
 
69
- h.div do |hh|
70
- hh.div(class: 'status') { status }
71
- hh.div(class: 'board-row') do |hhh|
72
- hhh.component(Square, value: squares[0], on_click: handle_click(0))
73
- hhh.component(Square, value: squares[1], on_click: handle_click(1))
74
- hhh.component(Square, value: squares[2], on_click: handle_click(2))
69
+ div do
70
+ div(class: 'status') { status }
71
+ div(class: 'board-row') do
72
+ component(Square, value: squares[0], on_click: handle_click(0))
73
+ component(Square, value: squares[1], on_click: handle_click(1))
74
+ component(Square, value: squares[2], on_click: handle_click(2))
75
75
  end
76
- hh.div(class: 'board-row') do |hhh|
77
- hhh.component(Square, value: squares[3], on_click: handle_click(3))
78
- hhh.component(Square, value: squares[4], on_click: handle_click(4))
79
- hhh.component(Square, value: squares[5], on_click: handle_click(5))
76
+ div(class: 'board-row') do
77
+ component(Square, value: squares[3], on_click: handle_click(3))
78
+ component(Square, value: squares[4], on_click: handle_click(4))
79
+ component(Square, value: squares[5], on_click: handle_click(5))
80
80
  end
81
- hh.div(class: 'board-row') do |hhh|
82
- hhh.component(Square, value: squares[6], on_click: handle_click(6))
83
- hhh.component(Square, value: squares[7], on_click: handle_click(7))
84
- hhh.component(Square, value: squares[8], on_click: handle_click(8))
81
+ div(class: 'board-row') do
82
+ component(Square, value: squares[6], on_click: handle_click(6))
83
+ component(Square, value: squares[7], on_click: handle_click(7))
84
+ component(Square, value: squares[8], on_click: handle_click(8))
85
85
  end
86
86
  end
87
87
  end
@@ -115,30 +115,30 @@
115
115
  }
116
116
  end
117
117
 
118
- def render(h)
118
+ def render
119
119
  history = state[:history]
120
120
  current_move = state[:current_move]
121
121
  x_is_next = (current_move % 2 == 0)
122
122
  current_squares = history[current_move]
123
123
 
124
- h.div(class: 'game') do |hh|
125
- hh.div(class: 'game-board') do |hhh|
126
- hhh.component(Board,
124
+ div(class: 'game') do
125
+ div(class: 'game-board') do
126
+ component(Board,
127
127
  x_is_next: x_is_next,
128
128
  squares: current_squares,
129
129
  on_play: ->(i) { handle_play(i) }
130
130
  )
131
131
  end
132
- hh.div(class: 'game-info') do |hhh|
133
- hhh.ol do |hhhh|
132
+ div(class: 'game-info') do
133
+ ol do
134
134
  history.each_with_index do |_squares, move|
135
135
  description = if move > 0
136
136
  "Go to move ##{move}"
137
137
  else
138
138
  'Go to game start'
139
139
  end
140
- hhhh.li(key: move) do |item|
141
- item.button(onclick: jump_to(move)) { description }
140
+ li(key: move) do
141
+ button(onclick: jump_to(move)) { description }
142
142
  end
143
143
  end
144
144
  end
data/docs/architecture.md CHANGED
@@ -30,7 +30,8 @@ stay free of browser-only calls on any server code path
30
30
  |---------------------------------------------------------|-------------------------------------------------------------------------------------------------|
31
31
  | `funicular.rb` | Top-level module: `start`, `router`, `server?`, `debug_color` export |
32
32
  | `runtime.rb` | Per-app runtime context propagated through render/SSR/hydration |
33
- | `view_context.rb` | Thin render facade passed as `render(h)`: elements, components, forms, routes, styles, suspense |
33
+ | `0_tags.rb` | Bareword tag DSL mixed into `Component`; reserved-name list and collision errors |
34
+ | `view_context.rb` | Internal element factory shared by the tag DSL, FormBuilder, and framework helpers |
34
35
  | `component.rb` | `Funicular::Component` base: state, props, lifecycle, suspense loading, refs |
35
36
  | `vdom.rb` | Virtual DOM nodes, including component vnodes with ordinary `children` |
36
37
  | `differ.rb` | `Differ.diff(old, new)` -- minimal patch set, key-based list reconciliation |
@@ -41,25 +42,44 @@ stay free of browser-only calls on any server code path
41
42
  | `http.rb` | Low-level fetch wrapper, CSRF, IndexedDB response cache |
42
43
  | `cable.rb` | ActionCable-compatible consumer/subscription client |
43
44
  | `store.rb`, `store_singleton.rb`, `store_collection.rb` | IndexedDB-backed stores, scope API, `subscribes_to`, event dispatch |
44
- | `form_builder.rb` | `h.form_for` field helpers with inline error rendering |
45
+ | `form_builder.rb` | `form_for` field helpers with inline error rendering |
45
46
  | `0_validations.rb`, `1_validators.rb` | ActiveModel-style validators and `errors` |
46
- | `styles.rb` | CSS-in-Ruby `styles { |css| css.define ... }` and `h.styles[...]` access |
47
+ | `styles.rb` | CSS-in-Ruby bareword `styles do ... end` builder and generated `styles.name` accessors |
47
48
  | `error_boundary.rb` | `ErrorBoundary` component |
48
49
  | `file_upload.rb` | File / FormData upload helper |
49
50
  | `debug.rb` | Development-only component/error registry for the DevTools extension |
50
51
  | `environment_inquirer.rb` | Environment detection (`server?`, `development?`) |
51
52
 
52
53
  The render cycle: a state change calls `patch()`, which rebuilds the component's
53
- VDOM by calling `render(h)`, diffs it against the previous VDOM with `Differ`,
54
+ VDOM by calling `render`, diffs it against the previous VDOM with `Differ`,
54
55
  and applies the result with `Patcher`. Event handlers are native DOM listeners,
55
56
  re-bound on each render.
56
57
 
57
- Application code receives all render helpers through `h`. HTML is authored as
58
- `h.div`, custom elements as `h.tag(:custom_element)`, child components as
59
- `h.component`, forms as `h.form_for`, styles as `h.styles[:name]`, resources as
60
- `h.resources[:name]`, and routes as `h.routes.user_path(id)`. Component state is
58
+ Inside `render` (zero-arity as of 0.4.0), `self` is the component, so the
59
+ DSL is bareword: HTML is authored as `div`, custom elements as
60
+ `tag(:custom_element)`, child components as `component`, forms as `form_for`,
61
+ styles as `styles.name(variant)` or `styles[:name]`, resources as
62
+ `resources[:name]`, and routes as `routes.user_path(id)`. Component state is
61
63
  explicitly read with `state[:name]` or `state.fetch(:name)`.
62
64
 
65
+ Tag and helper names (~46 words) are reserved inside component classes:
66
+ defining one raises `DSLCollisionError` at class-definition time
67
+ (`method_added`) or at first mount (`validate_dsl_conflicts!`, which also
68
+ covers `attr_*` on mruby and included modules). `allow_dsl_override :name`
69
+ opts out per class; the shadowed element stays reachable via `tag(:name)`.
70
+ Two caveats are inherent to barewords: `p` builds a `<p>` element (use
71
+ `puts x.inspect` for debugging; non-Hash arguments raise with a hint), and
72
+ a local variable named after a tag shadows the zero-paren call form (write
73
+ `option()` or rename the local). Procs handed to ANOTHER component --
74
+ ErrorBoundary's `fallback:` -- run under that component's cursor and
75
+ therefore receive an explicit view context instead of barewords.
76
+
77
+ Style definitions are bareword too: the class-level `styles do ... end`
78
+ block runs on a BasicObject cleanroom builder, so any name (including
79
+ `display`, `hash`, ...) defines a style identically on mruby and CRuby.
80
+ Computed values need the explicit form `styles { |css| css.define(...) }`.
81
+ Unknown style lookups raise instead of returning an empty class string.
82
+
63
83
  Component children are ordinary VDOM children stored on
64
84
  `VDOM::Component#children`; there is no delayed `children_block` prop. This keeps
65
85
  SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
@@ -96,6 +116,23 @@ SSR, diffing, ErrorBoundary rendering, and hydration on the same data model.
96
116
  Because `copy_wasm` reads sibling directories inside the picoruby repository, it
97
117
  only works from within that checkout -- see Development below.
98
118
 
119
+ ## JavaScript interop contract
120
+
121
+ As of picoruby commit 9e69333f, `JS::Object` inherits `BasicObject` instead of
122
+ `Object`. Consequences for framework code:
123
+
124
+ - Dot access on JS values is reliable for names Kernel used to shadow
125
+ (`hash`, `send`, `open`, `class`, `method`, ...): they now reach the JS side
126
+ via `method_missing`.
127
+ - The Ruby protocol predicates `nil?`, `is_a?`, `kind_of?`, `instance_of?`, and
128
+ `respond_to?` are defined in C on `JS::Object` (a `?` suffix is illegal in a
129
+ JS identifier, so they can never shadow a JS property). `respond_to?` does a
130
+ real method-table lookup only; it does not report JS properties.
131
+ - Any other name ending in `?` or `!` raises `NoMethodError` instead of being
132
+ forwarded to JS, so typos fail loudly rather than silently returning nil.
133
+ - `==`, `to_s`, `inspect`, `[]`, `[]=`, `to_a`, and `typeof` are defined
134
+ directly on `JS::Object` and behave as before.
135
+
99
136
  ## Server-side rendering, briefly
100
137
 
101
138
  For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
@@ -103,7 +140,7 @@ For SSR the `mrblib/` framework is loaded into the Rails process under CRuby.
103
140
  `app/funicular/initializer.rb`, builds a `Runtime` around that router, builds the
104
141
  component's VDOM, and serializes it with `HTMLSerializer`. The state is also
105
142
  embedded as `window.__FUNICULAR_STATE__` so the browser can hydrate the markup
106
- rather than rebuild it. Keep `render(h)` deterministic and free of browser-only
143
+ rather than rebuild it. Keep `render` deterministic and free of browser-only
107
144
  calls so the same code is safe on both sides.
108
145
 
109
146
  ## Development
@@ -21,6 +21,7 @@ module Funicular
21
21
  # few real dependencies exist (vdom before html_serializer; styles and
22
22
  # vdom before component; component before error_boundary).
23
23
  LOAD_ORDER = %w[
24
+ 0_tags
24
25
  environment_inquirer
25
26
  vdom
26
27
  html_serializer
@@ -1 +1 @@
1
- 4.0.1
1
+ 4.0.2