sandals 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79d2b3d1395db396acd1aad4055141b75c955ca0ff0462e5fcf700db819af32e
4
- data.tar.gz: 1cc17abdffbbc21540ada84f192cf9b1139c63c9422ef8152c2767ad6091473b
3
+ metadata.gz: 98c7f9a7ec9e5802a3ba038d5dfa807c3c93169ba1a3722d20c59efdf807a8cd
4
+ data.tar.gz: 644f49cd97d3002639c261034af166c485bd90e8947def34a2bcb219568f7f0a
5
5
  SHA512:
6
- metadata.gz: 6002d0c758974e5e797e153ebd50c0422b2a706b8329d6831cd44c7a13daabee0c9f9bd55aa49b3389e7798c00195ad92156f8f205bc74afb1fcab7395c0df90
7
- data.tar.gz: 075fb63042fd98661f107e249ff3af9c3c82bf203d44f7a58c937a8bdf7ac71fc235d4ae22246972b26ecb8cde00a246f9968bb343a9ffb9306a4f509e52d32f
6
+ metadata.gz: 2298067696337102444a3c32fc6414a1d498ab1fae70967c1ba40bc45fc83e55c76cfe5010fbad45139a663514b97ea1d4e645c1a5a487aff46f11fdfc35f93e
7
+ data.tar.gz: 86458ee36df1a77367e726a309f584565e90932c745e9c4594f9d61fbdb11685b8b440455bb575b4020e08e0091c1976f6752182d13be58040bcf48c9d0bc3c1
data/README.md CHANGED
@@ -163,6 +163,55 @@ end
163
163
  `para` renders as a paragraph and can combine strings with `strong` for
164
164
  important content and `em` for emphasis.
165
165
 
166
+ ## Styling
167
+
168
+ Sandals has a neutral default appearance, but an application can establish its
169
+ own personality in Ruby. Use `style` outside the view to configure defaults:
170
+
171
+ ```ruby
172
+ Sandals.app title: "Notes" do
173
+ style :app, background: "#fffaf3", color: "#292524"
174
+ style :stack, gap: 18
175
+ style :title, color: "#9a3412", size: 42
176
+ style :button,
177
+ background: "#ea580c",
178
+ color: "white",
179
+ border: "1px solid #c2410c",
180
+ radius: 10
181
+
182
+ view do
183
+ stack padding: 24 do
184
+ title "Notes"
185
+ para "A small app with its own visual character."
186
+ button "Delete", background: "#b91c1c"
187
+ end
188
+ end
189
+ end
190
+ ```
191
+
192
+ An option on an individual element overrides the application default. The
193
+ resolution order is:
194
+
195
+ ```text
196
+ Sandals defaults → application defaults → element options
197
+ ```
198
+
199
+ The first styling API supports `app`, `stack`, `flow`, `title`, `subtitle`,
200
+ `para`, and `button`. Available properties are `background`, `color`, `border`,
201
+ `radius`, `size`, `weight`, `width`, `padding`, `margin`, `gap`, and `align`.
202
+ Numeric dimensions use pixels, while strings can include their own CSS unit:
203
+
204
+ ```ruby
205
+ style :title, size: "2.5rem"
206
+ stack width: "100%", gap: 16 do
207
+ # ...
208
+ end
209
+ ```
210
+
211
+ Unknown elements and properties raise an error instead of silently producing
212
+ invalid styles. `form` remains semantic: use a surrounding `stack` or `flow`
213
+ to control its composition.
214
+
166
215
  `image` displays an image from a path or URL. Alternative text is optional, but
167
216
  should describe the image whenever it carries meaningful content:
168
217
 
@@ -549,6 +598,16 @@ file and recovers automatically when the error is fixed.
549
598
  The current version watches only the file passed to `sandals run`, not files
550
599
  loaded indirectly through `require`.
551
600
 
601
+ ## Connection recovery
602
+
603
+ If the local Sandals process stops while the application is open, the browser
604
+ shows a persistent “Connection lost” message and releases any pending buttons.
605
+ It retries the connection once per second while the tab is visible.
606
+
607
+ When Sandals is available again, the browser reloads the application
608
+ automatically. Events entered while disconnected are discarded because the
609
+ restarted process creates a new Ruby session.
610
+
552
611
  ## Mental model
553
612
 
554
613
  ```text
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Sandals
4
4
  class Application
5
- attr_reader :refresh_interval
5
+ attr_reader :refresh_interval, :styles
6
6
 
7
7
  def initialize(title:, &definition)
8
8
  @title = title
9
9
  @definition = definition
10
10
  @refresh_interval = nil
11
+ @styles = {}
11
12
  @view_definition = nil
12
13
  @snapshots = SnapshotHistory.new
13
14
  instance_eval(&definition)
@@ -32,18 +33,24 @@ module Sandals
32
33
  @refresh_interval = interval
33
34
  end
34
35
 
36
+ def style(element, **properties)
37
+ element = Style.validate_element!(element)
38
+ normalized = Style.normalize(element, properties)
39
+ @styles[element] = (@styles.fetch(element, {})).merge(normalized).freeze
40
+ end
41
+
35
42
  def select(label, **options, &action)
36
43
  @current_view.select(label, **options, &action)
37
44
  end
38
45
 
39
- def title(*arguments)
40
- return @title if arguments.empty?
46
+ def title(*arguments, **options)
47
+ return @title if arguments.empty? && options.empty?
41
48
 
42
- @current_view.title(*arguments)
49
+ @current_view.title(*arguments, **options)
43
50
  end
44
51
 
45
52
  def render_view
46
- @current_view = View.new
53
+ @current_view = View.new(styles:)
47
54
  instance_eval(&view)
48
55
  @current_view
49
56
  ensure
@@ -10,57 +10,112 @@ module Sandals
10
10
  DEFAULT_STYLES = <<~CSS
11
11
  :root {
12
12
  color-scheme: light;
13
+ --color-canvas: #f7f6f2;
14
+ --color-surface: #ffffff;
15
+ --color-surface-subtle: #efede7;
16
+ --color-text: #242320;
17
+ --color-muted: #6b6963;
18
+ --color-border: #d8d5cc;
19
+ --color-border-strong: #aaa69b;
20
+ --color-accent: #6558d3;
21
+ --color-accent-soft: #eeecff;
22
+ --color-danger: #a9362a;
23
+ --color-danger-soft: #fff0ed;
24
+ --color-success: #187451;
25
+ --color-success-soft: #eaf8f1;
26
+ --radius-control: 0.5rem;
27
+ --radius-panel: 0.75rem;
28
+ --shadow-focus: 0 0 0 3px rgb(101 88 211 / 20%);
29
+ --space-1: 0.375rem;
30
+ --space-2: 0.75rem;
31
+ --space-3: 1rem;
32
+ --space-4: 1.5rem;
33
+ --space-5: 2rem;
34
+ --space-6: 3rem;
13
35
  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
14
- background: #f7f7f5;
15
- color: #20201e;
36
+ background: var(--color-canvas);
37
+ color: var(--color-text);
38
+ }
39
+
40
+ *,
41
+ *::before,
42
+ *::after {
43
+ box-sizing: border-box;
16
44
  }
17
45
 
18
46
  body {
19
47
  margin: 0;
48
+ min-width: 0;
49
+ background: var(--color-canvas);
50
+ font-size: 1rem;
51
+ line-height: 1.5;
20
52
  }
21
53
 
22
54
  main {
23
- box-sizing: border-box;
24
55
  width: min(100%, 48rem);
25
56
  margin: 0 auto;
26
- padding: 3rem 1.5rem;
57
+ padding: clamp(var(--space-4), 6vw, var(--space-6));
27
58
  }
28
59
 
29
60
  h1 {
30
61
  margin: 0;
31
- font-size: clamp(2rem, 7vw, 4rem);
32
- letter-spacing: -0.04em;
62
+ max-width: 18ch;
63
+ font-size: clamp(2.25rem, 7vw, 3.75rem);
64
+ line-height: 0.98;
65
+ letter-spacing: -0.045em;
66
+ text-wrap: balance;
67
+ overflow-wrap: anywhere;
68
+ }
69
+
70
+ h2 {
71
+ margin: 0;
72
+ font-size: clamp(1.35rem, 4vw, 1.75rem);
73
+ line-height: 1.15;
74
+ letter-spacing: -0.02em;
75
+ text-wrap: balance;
33
76
  }
34
77
 
35
78
  p {
36
79
  margin: 0;
37
- line-height: 1.6;
80
+ max-width: 68ch;
81
+ line-height: 1.65;
82
+ overflow-wrap: anywhere;
83
+ }
84
+
85
+ .error {
86
+ padding: var(--space-2) var(--space-3);
87
+ border: 1px solid #efb2aa;
88
+ border-radius: var(--radius-control);
89
+ background: var(--color-danger-soft);
90
+ color: var(--color-danger);
38
91
  }
39
92
 
40
- .error,
41
93
  .field-error {
42
- color: #b42318;
94
+ color: var(--color-danger);
43
95
  }
44
96
 
45
97
  .notice {
46
- color: #067647;
98
+ padding: var(--space-2) var(--space-3);
99
+ border: 1px solid #a9dec6;
100
+ border-radius: var(--radius-control);
101
+ background: var(--color-success-soft);
102
+ color: var(--color-success);
47
103
  }
48
104
 
49
105
  .runtime-errors {
50
- box-sizing: border-box;
51
106
  width: min(100%, 48rem);
52
107
  margin: 0 auto;
53
- padding: 1rem 1.5rem 0;
108
+ padding: var(--space-3) var(--space-4) 0;
54
109
  }
55
110
 
56
111
  .action-error {
57
112
  display: grid;
58
- gap: 0.375rem;
59
- padding: 1rem;
60
- border: 1px solid #fda29b;
61
- border-radius: 0.375rem;
62
- background: #fef3f2;
63
- color: #b42318;
113
+ gap: var(--space-1);
114
+ padding: var(--space-3);
115
+ border: 1px solid #efb2aa;
116
+ border-radius: var(--radius-control);
117
+ background: var(--color-danger-soft);
118
+ color: var(--color-danger);
64
119
  }
65
120
 
66
121
  .action-error code {
@@ -69,17 +124,34 @@ module Sandals
69
124
 
70
125
  .field-error {
71
126
  font-size: 0.875rem;
127
+ font-weight: 450;
72
128
  }
73
129
 
74
130
  img {
75
131
  display: block;
76
132
  max-width: 100%;
77
133
  height: auto;
134
+ border-radius: var(--radius-panel);
78
135
  }
79
136
 
80
137
  a {
81
- color: #175cd3;
138
+ color: var(--color-accent);
139
+ font-weight: 550;
82
140
  text-underline-offset: 0.15em;
141
+ text-decoration-thickness: 0.08em;
142
+ }
143
+
144
+ a:hover {
145
+ text-decoration-thickness: 0.14em;
146
+ }
147
+
148
+ a:focus-visible,
149
+ button:focus-visible,
150
+ input:focus-visible,
151
+ textarea:focus-visible,
152
+ select:focus-visible {
153
+ outline: 2px solid var(--color-accent);
154
+ outline-offset: 2px;
83
155
  }
84
156
 
85
157
  .text-field,
@@ -88,7 +160,9 @@ module Sandals
88
160
  .file-field,
89
161
  .select {
90
162
  display: grid;
91
- gap: 0.375rem;
163
+ min-width: 0;
164
+ gap: var(--space-1);
165
+ font-weight: 600;
92
166
  }
93
167
 
94
168
  .text-field input,
@@ -96,20 +170,40 @@ module Sandals
96
170
  .text-area textarea,
97
171
  .file-field input,
98
172
  .select select {
99
- box-sizing: border-box;
100
173
  width: 100%;
101
- padding: 0.625rem 0.75rem;
102
- border: 1px solid #c8c8c3;
103
- border-radius: 0.375rem;
104
- background: white;
174
+ min-height: 2.75rem;
175
+ padding: 0.625rem var(--space-2);
176
+ border: 1px solid var(--color-border-strong);
177
+ border-radius: var(--radius-control);
178
+ background: var(--color-surface);
105
179
  color: inherit;
106
180
  font: inherit;
181
+ font-weight: 400;
182
+ transition: border-color 120ms ease, box-shadow 120ms ease;
183
+ }
184
+
185
+ .text-field input:hover,
186
+ .number-field input:hover,
187
+ .text-area textarea:hover,
188
+ .file-field input:hover,
189
+ .select select:hover {
190
+ border-color: var(--color-text);
191
+ }
192
+
193
+ .text-field input:focus,
194
+ .number-field input:focus,
195
+ .text-area textarea:focus,
196
+ .file-field input:focus,
197
+ .select select:focus {
198
+ border-color: var(--color-accent);
199
+ box-shadow: var(--shadow-focus);
107
200
  }
108
201
 
109
202
  input[aria-invalid="true"],
110
203
  textarea[aria-invalid="true"],
111
204
  select[aria-invalid="true"] {
112
- border-color: #b42318;
205
+ border-color: var(--color-danger);
206
+ background: var(--color-danger-soft);
113
207
  }
114
208
 
115
209
  .text-area textarea {
@@ -120,88 +214,139 @@ module Sandals
120
214
  .checkbox {
121
215
  display: flex;
122
216
  align-items: center;
123
- gap: 0.5rem;
217
+ min-width: 0;
218
+ gap: var(--space-2);
219
+ font-weight: 550;
124
220
  }
125
221
 
126
222
  .checkbox input {
127
- width: 1rem;
128
- height: 1rem;
223
+ width: 1.125rem;
224
+ height: 1.125rem;
129
225
  margin: 0;
226
+ accent-color: var(--color-accent);
130
227
  }
131
228
 
132
229
  .radio {
133
230
  display: grid;
134
- gap: 0.5rem;
231
+ min-width: 0;
232
+ gap: var(--space-2);
135
233
  margin: 0;
136
234
  padding: 0;
137
235
  border: 0;
138
236
  }
139
237
 
140
238
  .radio legend {
141
- margin-bottom: 0.375rem;
239
+ margin-bottom: var(--space-1);
240
+ font-weight: 600;
142
241
  }
143
242
 
144
243
  .radio label {
145
244
  display: flex;
146
245
  align-items: center;
147
- gap: 0.5rem;
246
+ gap: var(--space-2);
148
247
  }
149
248
 
150
249
  .radio input {
151
250
  margin: 0;
251
+ accent-color: var(--color-accent);
252
+ }
253
+
254
+ .table-scroll {
255
+ max-width: 100%;
256
+ overflow-x: auto;
257
+ border: 1px solid var(--color-border);
258
+ border-radius: var(--radius-panel);
259
+ background: var(--color-surface);
260
+ overscroll-behavior-inline: contain;
152
261
  }
153
262
 
154
263
  table {
155
264
  width: 100%;
265
+ min-width: 36rem;
156
266
  border-collapse: collapse;
157
- background: white;
267
+ background: var(--color-surface);
158
268
  }
159
269
 
160
270
  th,
161
271
  td {
162
- padding: 0.625rem 0.75rem;
163
- border: 1px solid #deded9;
272
+ padding: var(--space-2) var(--space-3);
273
+ border-bottom: 1px solid var(--color-border);
164
274
  text-align: left;
165
275
  vertical-align: top;
276
+ overflow-wrap: anywhere;
166
277
  }
167
278
 
168
279
  th {
169
- background: #f0f0ed;
280
+ background: var(--color-surface-subtle);
170
281
  font-weight: 600;
171
282
  }
172
283
 
284
+ tbody tr:last-child td {
285
+ border-bottom: 0;
286
+ }
287
+
173
288
  button {
174
- padding: 0.625rem 1rem;
175
- border: 0;
176
- border-radius: 0.375rem;
177
- background: #20201e;
289
+ min-height: 2.75rem;
290
+ padding: 0.625rem var(--space-3);
291
+ border: 1px solid var(--color-text);
292
+ border-radius: var(--radius-control);
293
+ background: var(--color-text);
178
294
  color: white;
179
295
  font: inherit;
296
+ font-weight: 650;
180
297
  cursor: pointer;
298
+ transition: transform 100ms ease, opacity 100ms ease, background 100ms ease;
299
+ }
300
+
301
+ button:hover:not(:disabled) {
302
+ background: #3a3935;
303
+ }
304
+
305
+ button:active:not(:disabled) {
306
+ transform: translateY(1px);
181
307
  }
182
308
 
183
309
  button[aria-busy="true"] {
184
310
  cursor: wait;
185
- opacity: 0.7;
311
+ opacity: 0.65;
186
312
  }
187
313
 
188
314
  .stack {
189
315
  display: flex;
190
316
  flex-direction: column;
191
- gap: 1rem;
317
+ min-width: 0;
318
+ gap: var(--space-3);
319
+ }
320
+
321
+ .stack > * {
322
+ min-width: 0;
323
+ }
324
+
325
+ .stack > h2 {
326
+ margin-top: var(--space-4);
192
327
  }
193
328
 
194
329
  .flow {
195
330
  display: flex;
196
331
  flex-flow: row wrap;
197
332
  align-items: center;
198
- gap: 1rem;
333
+ min-width: 0;
334
+ gap: var(--space-2);
199
335
  }
200
336
 
201
337
  .form {
202
- display: flex;
203
- flex-direction: column;
204
- gap: 1rem;
338
+ display: contents;
339
+ }
340
+
341
+ @media (max-width: 32rem) {
342
+ main {
343
+ padding-inline: var(--space-3);
344
+ }
345
+
346
+ .runtime-errors {
347
+ padding-inline: var(--space-3);
348
+ }
349
+
205
350
  }
206
351
  CSS
207
352
 
@@ -211,7 +356,8 @@ module Sandals
211
356
  render_children(view),
212
357
  revision:,
213
358
  refresh_interval: application.refresh_interval,
214
- development_reload:
359
+ development_reload:,
360
+ app_styles: application.styles.fetch(:app, {})
215
361
  )
216
362
  end
217
363
 
@@ -247,11 +393,11 @@ module Sandals
247
393
  when String
248
394
  escape(element)
249
395
  when Title
250
- "<h1>#{escape(element.content)}</h1>"
396
+ "<h1#{style_attribute(:title, element)}>#{escape(element.content)}</h1>"
251
397
  when Subtitle
252
- "<h2>#{escape(element.content)}</h2>"
398
+ "<h2#{style_attribute(:subtitle, element)}>#{escape(element.content)}</h2>"
253
399
  when Para
254
- "<p>#{element.parts.map { |part| render_element(part) }.join}</p>"
400
+ "<p#{style_attribute(:para, element)}>#{element.parts.map { |part| render_element(part) }.join}</p>"
255
401
  when Error
256
402
  %(<p class="error" role="alert">#{escape(element.content)}</p>)
257
403
  when Notice
@@ -288,7 +434,7 @@ module Sandals
288
434
  when Submit
289
435
  %(<button type="submit" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
290
436
  when Button
291
- %(<button type="button" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
437
+ %(<button type="button" data-sandals-target="#{escape(element.id)}"#{style_attribute(:button, element)}>#{escape(element.content)}</button>)
292
438
  when Strong
293
439
  "<strong>#{escape(element.content)}</strong>"
294
440
  when Emphasis
@@ -306,7 +452,7 @@ module Sandals
306
452
 
307
453
  def container(class_name, element)
308
454
  content = element.children.map { |child| render_element(child) }.join
309
- %(<div class="#{class_name}">#{content}</div>)
455
+ %(<div class="#{class_name}"#{style_attribute(class_name.to_sym, element)}>#{content}</div>)
310
456
  end
311
457
 
312
458
  def form(element)
@@ -322,7 +468,7 @@ module Sandals
322
468
  cells = row.map { |cell| "<td>#{render_element(cell)}</td>" }.join
323
469
  "<tr>#{cells}</tr>"
324
470
  end.join
325
- "<table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table>"
471
+ %(<div class="table-scroll"><table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table></div>)
326
472
  end
327
473
 
328
474
  def validation_attributes(element)
@@ -342,7 +488,8 @@ module Sandals
342
488
  content,
343
489
  revision:,
344
490
  refresh_interval:,
345
- development_reload:
491
+ development_reload:,
492
+ app_styles:
346
493
  )
347
494
  refresh_attribute =
348
495
  if refresh_interval
@@ -363,14 +510,22 @@ module Sandals
363
510
  <script src="#{TURBO_URL}"></script>
364
511
  <script>#{client_script}</script>
365
512
  </head>
366
- <body>
513
+ <body#{style_attribute(:app, app_styles.slice(:background, :color))}>
514
+ <div id="sandals-connection-status" class="runtime-errors" aria-live="assertive"></div>
367
515
  <div id="sandals-errors" class="runtime-errors" aria-live="assertive"></div>
368
- <main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}>#{content}</main>
516
+ <main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}#{style_attribute(:app, app_styles)}>#{content}</main>
369
517
  </body>
370
518
  </html>
371
519
  HTML
372
520
  end
373
521
 
522
+ def style_attribute(element, styled)
523
+ properties = styled.respond_to?(:styles) ? styled.styles : styled
524
+ return "" if properties.empty?
525
+
526
+ %( style="#{escape(Style.inline(element, properties))}")
527
+ end
528
+
374
529
  def client_script
375
530
  <<~JAVASCRIPT
376
531
  document.addEventListener("DOMContentLoaded", () => {
@@ -386,6 +541,8 @@ module Sandals
386
541
  let codeVersion
387
542
  let checkingCode = false
388
543
  let refreshTimer
544
+ let reconnectTimer
545
+ let disconnected = false
389
546
  const pending = []
390
547
  const pendingButtons = new Map()
391
548
 
@@ -412,6 +569,65 @@ module Sandals
412
569
  }
413
570
  }
414
571
 
572
+ function releaseAllPendingButtons() {
573
+ for (const button of pendingButtons.values()) {
574
+ button.disabled = false
575
+ button.removeAttribute("aria-busy")
576
+ delete button.dataset.sandalsPending
577
+ }
578
+ pendingButtons.clear()
579
+ }
580
+
581
+ function showConnectionLost() {
582
+ const region = document.querySelector("#sandals-connection-status")
583
+ const section = document.createElement("section")
584
+ const title = document.createElement("strong")
585
+ const message = document.createElement("p")
586
+
587
+ section.className = "action-error"
588
+ section.setAttribute("role", "alert")
589
+ title.textContent = "Connection lost"
590
+ message.textContent = "Waiting for Sandals to reconnect…"
591
+ section.append(title, message)
592
+ region.replaceChildren(section)
593
+ }
594
+
595
+ function connectionLost() {
596
+ if (disconnected) return
597
+
598
+ disconnected = true
599
+ pending.length = 0
600
+ clearTimeout(refreshTimer)
601
+ releaseAllPendingButtons()
602
+ showConnectionLost()
603
+ scheduleReconnect()
604
+ }
605
+
606
+ function handleRequestError(error) {
607
+ if (error instanceof TypeError) {
608
+ connectionLost()
609
+ } else {
610
+ console.error(error)
611
+ }
612
+ }
613
+
614
+ function scheduleReconnect() {
615
+ clearTimeout(reconnectTimer)
616
+ reconnectTimer = setTimeout(checkConnection, 1000)
617
+ }
618
+
619
+ async function checkConnection() {
620
+ if (!disconnected) return
621
+ if (document.hidden) return
622
+
623
+ try {
624
+ await fetch("/", { cache: "no-store" })
625
+ window.location.reload()
626
+ } catch (error) {
627
+ scheduleReconnect()
628
+ }
629
+ }
630
+
415
631
  function showCodeError(error) {
416
632
  const region = document.querySelector("#sandals-errors")
417
633
  const section = document.createElement("section")
@@ -429,7 +645,7 @@ module Sandals
429
645
  }
430
646
 
431
647
  async function checkForCodeChanges() {
432
- if (!developmentReload || document.hidden || checkingCode) return
648
+ if (!developmentReload || document.hidden || checkingCode || disconnected) return
433
649
 
434
650
  checkingCode = true
435
651
  try {
@@ -447,12 +663,16 @@ module Sandals
447
663
  }
448
664
 
449
665
  if (result.error) showCodeError(result.error)
666
+ } catch (error) {
667
+ handleRequestError(error)
450
668
  } finally {
451
669
  checkingCode = false
452
670
  }
453
671
  }
454
672
 
455
673
  function enqueue(message) {
674
+ if (disconnected) return
675
+
456
676
  scheduleRefresh()
457
677
  const last = pending.at(-1)
458
678
 
@@ -472,14 +692,14 @@ module Sandals
472
692
 
473
693
  function scheduleRefresh() {
474
694
  clearTimeout(refreshTimer)
475
- if (!refreshInterval || document.hidden) return
695
+ if (!refreshInterval || document.hidden || disconnected) return
476
696
 
477
697
  refreshTimer = setTimeout(refreshView, refreshInterval * 1000)
478
698
  }
479
699
 
480
700
  async function refreshView() {
481
701
  clearTimeout(refreshTimer)
482
- if (!refreshInterval || document.hidden) return
702
+ if (!refreshInterval || document.hidden || disconnected) return
483
703
  if (active || pending.length > 0) {
484
704
  scheduleRefresh()
485
705
  return
@@ -513,6 +733,8 @@ module Sandals
513
733
  } else if (!response.ok) {
514
734
  throw new Error(body)
515
735
  }
736
+ } catch (error) {
737
+ handleRequestError(error)
516
738
  } finally {
517
739
  active = false
518
740
  drain()
@@ -521,7 +743,7 @@ module Sandals
521
743
  }
522
744
 
523
745
  async function drain() {
524
- if (active || pending.length === 0) return
746
+ if (active || disconnected || pending.length === 0) return
525
747
 
526
748
  active = true
527
749
  const batchRevision = pending[0].revision
@@ -578,6 +800,8 @@ module Sandals
578
800
  } else if (!response.ok) {
579
801
  throw new Error(body)
580
802
  }
803
+ } catch (error) {
804
+ handleRequestError(error)
581
805
  } finally {
582
806
  releasePendingButtons(events)
583
807
  active = false
@@ -616,6 +840,7 @@ module Sandals
616
840
  const button = event.target.closest("button[data-sandals-target]")
617
841
  if (!button) return
618
842
  if (button.type === "submit") return
843
+ if (disconnected) return
619
844
  if (button.disabled || button.dataset.sandalsPending) return
620
845
 
621
846
  markButtonPending(button)
@@ -652,6 +877,7 @@ module Sandals
652
877
  event.submitter ||
653
878
  form.querySelector('button[type="submit"][data-sandals-target]')
654
879
  if (!button) return
880
+ if (disconnected) return
655
881
  if (button.disabled || button.dataset.sandalsPending) return
656
882
 
657
883
  markButtonPending(button)
@@ -664,6 +890,10 @@ module Sandals
664
890
  document.addEventListener("visibilitychange", () => {
665
891
  clearTimeout(refreshTimer)
666
892
  if (!document.hidden) {
893
+ if (disconnected) {
894
+ checkConnection()
895
+ return
896
+ }
667
897
  refreshView()
668
898
  checkForCodeChanges()
669
899
  }
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class Style
5
+ ELEMENTS = %i[app stack flow title subtitle para button].freeze
6
+ PROPERTIES = %i[
7
+ background color border radius size weight width padding margin gap align
8
+ ].freeze
9
+ DIMENSIONS = %i[radius size width padding margin gap].freeze
10
+
11
+ CSS_PROPERTIES = {
12
+ background: "background",
13
+ color: "color",
14
+ border: "border",
15
+ radius: "border-radius",
16
+ size: "font-size",
17
+ weight: "font-weight",
18
+ width: "width",
19
+ padding: "padding",
20
+ margin: "margin",
21
+ gap: "gap"
22
+ }.freeze
23
+
24
+ def self.validate_element!(element)
25
+ name = element.to_sym
26
+ return name if ELEMENTS.include?(name)
27
+
28
+ raise ArgumentError,
29
+ "unknown style element: #{element.inspect}; expected one of #{ELEMENTS.join(", ")}"
30
+ end
31
+
32
+ def self.normalize(element, properties)
33
+ element = validate_element!(element)
34
+ properties.to_h do |name, value|
35
+ name = name.to_sym
36
+ unless PROPERTIES.include?(name)
37
+ raise ArgumentError, "unknown style property for #{element}: #{name}"
38
+ end
39
+
40
+ [name, normalize_value(name, value)]
41
+ end.freeze
42
+ end
43
+
44
+ def self.inline(element, properties)
45
+ properties.map do |name, value|
46
+ property = name == :align ? alignment_property(element) : CSS_PROPERTIES.fetch(name)
47
+ "#{property}: #{value}"
48
+ end.join("; ")
49
+ end
50
+
51
+ def self.normalize_value(name, value)
52
+ if value.is_a?(Numeric)
53
+ return value.to_s unless DIMENSIONS.include?(name)
54
+
55
+ return value.zero? ? "0" : "#{value}px"
56
+ end
57
+
58
+ value = value.to_s
59
+ if value.empty? || value.match?(/[;{}<>]/)
60
+ raise ArgumentError, "invalid value for style property #{name}: #{value.inspect}"
61
+ end
62
+
63
+ value
64
+ end
65
+ private_class_method :normalize_value
66
+
67
+ def self.alignment_property(element)
68
+ %i[stack flow].include?(element) ? "align-items" : "text-align"
69
+ end
70
+ private_class_method :alignment_property
71
+ end
72
+
73
+ module Styled
74
+ attr_reader :styles
75
+
76
+ private
77
+
78
+ def initialize_styles(styles)
79
+ @styles = styles.freeze
80
+ end
81
+ end
82
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sandals
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/sandals/view.rb CHANGED
@@ -8,22 +8,23 @@ module Sandals
8
8
 
9
9
  attr_reader :children
10
10
 
11
- def initialize
11
+ def initialize(styles: {})
12
12
  @children = []
13
13
  @containers = [self]
14
14
  @next_control_id = 0
15
+ @styles = styles
15
16
  end
16
17
 
17
- def title(content)
18
- add Title.new(content)
18
+ def title(content, **styles)
19
+ add Title.new(content, styles: styles_for(:title, styles))
19
20
  end
20
21
 
21
- def subtitle(content)
22
- add Subtitle.new(content)
22
+ def subtitle(content, **styles)
23
+ add Subtitle.new(content, styles: styles_for(:subtitle, styles))
23
24
  end
24
25
 
25
- def para(*parts)
26
- add Para.new(parts)
26
+ def para(*parts, **styles)
27
+ add Para.new(parts, styles: styles_for(:para, styles))
27
28
  end
28
29
 
29
30
  def error(content)
@@ -136,8 +137,13 @@ module Sandals
136
137
  add FileField.new(control_id("file-field"), label, error:, action:)
137
138
  end
138
139
 
139
- def button(content, &action)
140
- add Button.new(control_id("button"), content, action:)
140
+ def button(content, **styles, &action)
141
+ add Button.new(
142
+ control_id("button"),
143
+ content,
144
+ action:,
145
+ styles: styles_for(:button, styles)
146
+ )
141
147
  end
142
148
 
143
149
  def submit(content, &action)
@@ -156,12 +162,12 @@ module Sandals
156
162
  Emphasis.new(content)
157
163
  end
158
164
 
159
- def stack(&block)
160
- container Stack.new, &block
165
+ def stack(**styles, &block)
166
+ container Stack.new(styles: styles_for(:stack, styles)), &block
161
167
  end
162
168
 
163
- def flow(&block)
164
- container Flow.new, &block
169
+ def flow(**styles, &block)
170
+ container Flow.new(styles: styles_for(:flow, styles)), &block
165
171
  end
166
172
 
167
173
  def form(&block)
@@ -199,13 +205,20 @@ module Sandals
199
205
  model:, bind:, value:, error:, action:, default:
200
206
  ).deconstruct
201
207
  end
208
+
209
+ def styles_for(element, local_styles)
210
+ @styles.fetch(element, {}).merge(Style.normalize(element, local_styles))
211
+ end
202
212
  end
203
213
 
204
214
  class Container
215
+ include Styled
216
+
205
217
  attr_reader :children
206
218
 
207
- def initialize
219
+ def initialize(styles: {})
208
220
  @children = []
221
+ initialize_styles(styles)
209
222
  end
210
223
  end
211
224
 
@@ -214,26 +227,35 @@ module Sandals
214
227
  class Form < Container; end
215
228
 
216
229
  class Title
230
+ include Styled
231
+
217
232
  attr_reader :content
218
233
 
219
- def initialize(content)
234
+ def initialize(content, styles: {})
220
235
  @content = content.to_s
236
+ initialize_styles(styles)
221
237
  end
222
238
  end
223
239
 
224
240
  class Subtitle
241
+ include Styled
242
+
225
243
  attr_reader :content
226
244
 
227
- def initialize(content)
245
+ def initialize(content, styles: {})
228
246
  @content = content.to_s
247
+ initialize_styles(styles)
229
248
  end
230
249
  end
231
250
 
232
251
  class Para
252
+ include Styled
253
+
233
254
  attr_reader :parts
234
255
 
235
- def initialize(parts)
256
+ def initialize(parts, styles: {})
236
257
  @parts = parts
258
+ initialize_styles(styles)
237
259
  end
238
260
  end
239
261
 
@@ -475,13 +497,15 @@ module Sandals
475
497
 
476
498
  class Button
477
499
  include Actionable
500
+ include Styled
478
501
 
479
502
  attr_reader :id, :content
480
503
 
481
- def initialize(id, content, action:)
504
+ def initialize(id, content, action:, styles: {})
482
505
  @id = id
483
506
  @content = content.to_s
484
507
  @action = action
508
+ initialize_styles(styles)
485
509
  end
486
510
 
487
511
  def activate
data/lib/sandals.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "sandals/error_details"
5
5
  require_relative "sandals/binding_resolver"
6
6
  require_relative "sandals/action_error"
7
7
  require_relative "sandals/uploaded_file"
8
+ require_relative "sandals/style"
8
9
  require_relative "sandals/view"
9
10
  require_relative "sandals/html_renderer"
10
11
  require_relative "sandals/snapshot_history"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sandals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benito Serna
@@ -61,10 +61,12 @@ files:
61
61
  - lib/sandals/html_renderer.rb
62
62
  - lib/sandals/server.rb
63
63
  - lib/sandals/snapshot_history.rb
64
+ - lib/sandals/style.rb
64
65
  - lib/sandals/test_session.rb
65
66
  - lib/sandals/uploaded_file.rb
66
67
  - lib/sandals/version.rb
67
68
  - lib/sandals/view.rb
69
+ homepage: https://rubygems.org/gems/sandals
68
70
  licenses:
69
71
  - MIT
70
72
  metadata: