funicular 0.3.0 → 0.5.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +486 -1
  3. data/demo/local_notes.html +207 -0
  4. data/demo/test_chartjs.html +9 -9
  5. data/demo/test_component.html +8 -8
  6. data/demo/test_error_boundary.html +44 -41
  7. data/demo/test_router.html +48 -48
  8. data/demo/tic-tac-toe.html +25 -25
  9. data/docs/architecture.md +227 -12
  10. data/docs/local_database.md +1035 -0
  11. data/lib/funicular/assets/funicular.rb +14 -0
  12. data/lib/funicular/configuration.rb +65 -0
  13. data/lib/funicular/epoch_header.rb +69 -0
  14. data/lib/funicular/epoch_stamping.rb +66 -0
  15. data/lib/funicular/helpers/picoruby_helper.rb +96 -1
  16. data/lib/funicular/railtie.rb +30 -0
  17. data/lib/funicular/schema.rb +45 -12
  18. data/lib/funicular/session_epoch.rb +110 -0
  19. data/lib/funicular/ssr/runtime.rb +58 -12
  20. data/lib/funicular/ssr.rb +25 -0
  21. data/lib/funicular/testing/node_runner.mjs +19 -0
  22. data/lib/funicular/testing.rb +47 -0
  23. data/lib/funicular/vendor/mrbc/VERSION +1 -1
  24. data/lib/funicular/vendor/mrbc/mrbc.js +655 -574
  25. data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
  26. data/lib/funicular/vendor/picoruby/VERSION +1 -1
  27. data/lib/funicular/vendor/picoruby/debug/picoruby.js +800 -530
  28. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  29. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -2
  30. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  31. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
  32. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -6909
  33. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  34. data/lib/funicular/version.rb +1 -1
  35. data/lib/funicular.rb +1 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +37 -38
  37. data/lib/tasks/funicular.rake +10 -2
  38. data/minitest/callback_error_visibility_test.rb +48 -0
  39. data/minitest/configuration_test.rb +78 -0
  40. data/minitest/dsl_test.rb +264 -0
  41. data/minitest/epoch_header_test.rb +149 -0
  42. data/minitest/epoch_stamping_test.rb +225 -0
  43. data/minitest/fixtures/funicular_app/components/greeting_component.rb +5 -5
  44. data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
  45. data/minitest/form_for_test.rb +2 -2
  46. data/minitest/hydration_test.rb +2 -2
  47. data/minitest/navigation_guard_test.rb +65 -0
  48. data/minitest/picoruby_helper_test.rb +236 -0
  49. data/minitest/schema_test.rb +47 -0
  50. data/minitest/session_epoch_test.rb +122 -0
  51. data/minitest/sig_tags_test.rb +30 -0
  52. data/minitest/ssr_database_test.rb +78 -0
  53. data/minitest/ssr_reload_test.rb +106 -0
  54. data/minitest/ssr_test.rb +41 -0
  55. data/minitest/testing_ensure_compiled_test.rb +52 -0
  56. data/minitest/validations_test.rb +35 -5
  57. data/minitest/view_context_test.rb +15 -15
  58. data/mrbgem.rake +2 -0
  59. data/mrblib/0_tags.rb +62 -0
  60. data/mrblib/cable.rb +1 -1
  61. data/mrblib/component.rb +226 -24
  62. data/mrblib/db.rb +3116 -0
  63. data/mrblib/error_boundary.rb +25 -19
  64. data/mrblib/file_upload.rb +17 -7
  65. data/mrblib/form_builder.rb +10 -10
  66. data/mrblib/funicular.rb +136 -17
  67. data/mrblib/http.rb +84 -107
  68. data/mrblib/model.rb +1178 -23
  69. data/mrblib/relation.rb +342 -0
  70. data/mrblib/router.rb +45 -4
  71. data/mrblib/styles.rb +122 -12
  72. data/mrblib/view_context.rb +3 -32
  73. data/sig/component.rbs +25 -4
  74. data/sig/db.rbs +328 -0
  75. data/sig/error_boundary.rbs +4 -4
  76. data/sig/funicular.rbs +5 -0
  77. data/sig/http.rbs +8 -21
  78. data/sig/model.rbs +101 -7
  79. data/sig/relation.rbs +44 -0
  80. data/sig/router.rbs +1 -0
  81. data/sig/styles.rbs +19 -5
  82. data/sig/tags.rbs +54 -0
  83. data/sig/view_context.rbs +47 -34
  84. metadata +23 -2
  85. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
@@ -0,0 +1,207 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Local Notes - Funicular</title>
6
+ </head>
7
+ <body>
8
+ <div><a href="../index.html">Back</a></div>
9
+ <h1>Local Notes (Funicular local database)</h1>
10
+ <!-- A page declaring storage :local models must choose its
11
+ namespace explicitly (docs decision 12) or the boot fails
12
+ loud. A Rails app gets these attributes from
13
+ picoruby_include_tag; this static page carries them by hand. -->
14
+ <div id="app"
15
+ data-funicular-local-database="true"
16
+ data-funicular-application-id="local_notes_demo"
17
+ data-funicular-anonymous-only="true"></div>
18
+
19
+ <div class="explain">
20
+ <p>A server-less demo of the local database layer:
21
+ <code>storage :local</code>, <code>.local</code> queries,
22
+ <code>watch</code>, snapshot persistence, the single-writer
23
+ election, and <code>Funicular::DB.wipe</code>.</p>
24
+ <ul>
25
+ <li><b>Add some notes, then reload the page.</b> The writer tab
26
+ snapshots the database into IndexedDB about half a second
27
+ after the last write, and the next visit restores it.</li>
28
+ <li><b>Open this page in a second tab.</b> Only one tab per
29
+ browser profile wins the writer election; the other becomes a
30
+ persistent reader whose writes raise. Close the writer tab
31
+ and reload the reader to take over.</li>
32
+ <li><b>Press "Wipe everything".</b> Both databases of this
33
+ namespace are dropped and rebuilt empty, the stored snapshots
34
+ are deleted, and the watcher re-renders onto empty tables.</li>
35
+ <li>The list re-renders through
36
+ <code>watch(:notes) { Note.local.order(created_at: :desc) }</code>
37
+ after every change event; no manual state bookkeeping.</li>
38
+ </ul>
39
+ </div>
40
+
41
+ <script type="text/ruby">
42
+ class Note < Funicular::Model
43
+ table_name "local_notes"
44
+ storage :local do
45
+ migrate 1 do |t|
46
+ t.string :title
47
+ t.timestamps
48
+ end
49
+ end
50
+ end
51
+
52
+ class NotesApp < Funicular::Component
53
+ READER_MESSAGE =
54
+ "This tab is a persistent reader: another tab holds the " \
55
+ "writer lock. Close the writer tab and reload to write here."
56
+
57
+ def initialize_state
58
+ # start boots the database before anything mounts, so the
59
+ # durability verdict is already final here.
60
+ {
61
+ notes: [],
62
+ message: "",
63
+ durability: Funicular::DB.durability,
64
+ }
65
+ end
66
+
67
+ def component_mounted
68
+ watch(:notes) { Note.local.order(created_at: :desc) }
69
+ end
70
+
71
+ def handle_add(event)
72
+ event.preventDefault
73
+ field = JS.document.getElementById("note-title")
74
+ title = field[:value].to_s
75
+ return if title.empty?
76
+ begin
77
+ Note.create(title: title)
78
+ field[:value] = ""
79
+ patch(message: "")
80
+ rescue Funicular::DB::ReadOnlyTabError
81
+ patch(message: READER_MESSAGE)
82
+ end
83
+ end
84
+
85
+ def handle_delete(note)
86
+ -> {
87
+ begin
88
+ note.destroy
89
+ patch(message: "")
90
+ rescue Funicular::DB::ReadOnlyTabError
91
+ patch(message: READER_MESSAGE)
92
+ end
93
+ }
94
+ end
95
+
96
+ def handle_wipe(event)
97
+ event.preventDefault
98
+ begin
99
+ Funicular::DB.wipe
100
+ patch(message: "Wiped: tables rebuilt empty, snapshots deleted.")
101
+ rescue Funicular::DB::ReadOnlyTabError
102
+ patch(message: READER_MESSAGE)
103
+ end
104
+ end
105
+
106
+ def render
107
+ notes = state[:notes]
108
+ div(class: "notes-app") do
109
+ p(class: "status") do
110
+ "durability: #{state[:durability]} | notes: #{notes.size}"
111
+ end
112
+ div(class: "composer") do
113
+ # A placeholder disappears as soon as text is typed, so it
114
+ # cannot be the field's accessible name.
115
+ label(for: "note-title") { "Note title" }
116
+ input(type: "text", id: "note-title",
117
+ placeholder: "What to remember?")
118
+ button(onclick: :handle_add) { "Add" }
119
+ button(onclick: :handle_wipe, class: "danger") do
120
+ "Wipe everything"
121
+ end
122
+ end
123
+ p(class: "message") { state[:message] }
124
+ ul(class: "notes") do
125
+ notes.each do |note|
126
+ li(key: note.id) do
127
+ span(class: "title") { note.title }
128
+ span(class: "stamp") { note.created_at.to_s }
129
+ button(onclick: handle_delete(note), class: "delete") do
130
+ "delete"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ # No load_schemas round: a local-only app boots right inside
140
+ # start's gate. The namespace comes from the hand-written
141
+ # data-funicular-* attributes on the #app container above: pages
142
+ # with storage :local models must declare anonymous_only (or a
143
+ # user key) explicitly -- a silent shared default would merge
144
+ # every user's data, so the boot fails loud without it.
145
+ Funicular.start(NotesApp, container: 'app')
146
+ </script>
147
+ <script src="../init.iife.js"></script>
148
+ </body>
149
+ <style>
150
+ .notes-app {
151
+ max-width: 560px;
152
+ }
153
+
154
+ .status {
155
+ color: #555;
156
+ font-family: monospace;
157
+ }
158
+
159
+ .composer input {
160
+ margin-right: 6px;
161
+ padding: 4px 6px;
162
+ width: 260px;
163
+ }
164
+
165
+ .composer button {
166
+ margin-right: 6px;
167
+ }
168
+
169
+ .composer .danger {
170
+ color: #a00;
171
+ }
172
+
173
+ .message {
174
+ color: #a00;
175
+ min-height: 1.2em;
176
+ }
177
+
178
+ ul.notes {
179
+ list-style: none;
180
+ padding: 0;
181
+ }
182
+
183
+ ul.notes li {
184
+ border-bottom: 1px solid #ddd;
185
+ display: flex;
186
+ gap: 10px;
187
+ padding: 6px 2px;
188
+ }
189
+
190
+ ul.notes .title {
191
+ flex: 1;
192
+ }
193
+
194
+ ul.notes .stamp {
195
+ color: #888;
196
+ font-size: 0.85em;
197
+ }
198
+
199
+ .explain {
200
+ border-top: 1px solid #ccc;
201
+ color: #333;
202
+ margin-top: 24px;
203
+ max-width: 640px;
204
+ padding-top: 12px;
205
+ }
206
+ </style>
207
+ </html>
@@ -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