sandals 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8706dd760aa94d3533ea6c2fb18bf8d265272a3c49250e1539300e8f2d960740
4
+ data.tar.gz: 87590e396a533c73abc3eb6689c90481f301d84c885e344bb21148845f602baf
5
+ SHA512:
6
+ metadata.gz: b5cf4873ca1ebc024b7f45c744b3d7747ace37a867b6a0c9dfb9e85861398e3bf2ff99d7a431c1e7c9d063f1df1b5a65684918fa581ab2bfcf94915e2201fe81
7
+ data.tar.gz: 1e17469e4cb0a3bdf878ffafc07d45e083e620a8410704c012c2cd524268e2b22d3786a6c7cf0db04e1b28707fea3198b121dbe5e0cc5b6bf7323d6d039a3705
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandals contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,604 @@
1
+ # Sandals
2
+
3
+ Sandals is a small environment for running interactive Ruby applications whose
4
+ structure can be understood by reading their code.
5
+
6
+ The central idea is simple:
7
+
8
+ > Describe a small tool in Ruby, run it immediately, and be able to understand
9
+ > what it displays, what data it keeps, and what happens with each action.
10
+
11
+ Sandals is not trying to be another web framework or to recreate all of Shoes.
12
+ It also does not try to hide a complex application behind a magical DSL. Its
13
+ purpose is to find a clear, concise, and expressive way to write small
14
+ applications that both people and AI can create and understand.
15
+
16
+ ## Installation
17
+
18
+ Sandals requires Ruby 3.1 or later.
19
+
20
+ ```sh
21
+ gem install sandals
22
+ sandals run app.rb
23
+ ```
24
+
25
+ The server listens on `127.0.0.1`. All required assets are included in the gem,
26
+ so a local app does not need an internet connection.
27
+
28
+ ## The experience
29
+
30
+ An application should normally fit in a single file:
31
+
32
+ ```ruby
33
+ require "sandals"
34
+
35
+ Sandals.app title: "Counter" do
36
+ @count = 0
37
+
38
+ view do
39
+ stack do
40
+ title "Counter"
41
+ para "Total: #{@count}"
42
+
43
+ button "Increment" do
44
+ @count += 1
45
+ end
46
+ end
47
+ end
48
+ end
49
+ ```
50
+
51
+ Run it with:
52
+
53
+ ```sh
54
+ sandals run counter.rb
55
+ ```
56
+
57
+ Sandals starts a local Ruby process, opens the application in the browser, and
58
+ handles communication between the interface and its actions. To the person
59
+ creating or using the application, it should feel like running a Ruby program,
60
+ not configuring a web project.
61
+
62
+ ## Layout and content
63
+
64
+ `stack` arranges elements vertically. `flow` arranges them horizontally and
65
+ allows them to wrap onto the next line:
66
+
67
+ ```ruby
68
+ view do
69
+ stack do
70
+ title "Tool"
71
+
72
+ flow do
73
+ button "Cancel"
74
+ button "Continue"
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
80
+ `title` is the main heading, `subtitle` introduces a section, and `para`
81
+ represents regular prose:
82
+
83
+ ```ruby
84
+ stack do
85
+ subtitle "Result"
86
+ para "The pressure is ", strong("high"),
87
+ " and requires ", em("review"), "."
88
+ end
89
+ ```
90
+
91
+ `para` renders as a paragraph and can combine strings with `strong` for
92
+ important content and `em` for emphasis.
93
+
94
+ `image` displays an image from a path or URL. Alternative text is optional, but
95
+ should describe the image whenever it carries meaningful content:
96
+
97
+ ```ruby
98
+ image "red-sandals.jpg", alt: "A pair of red sandals"
99
+ ```
100
+
101
+ `link` creates a regular external link. Navigation is handled entirely by the
102
+ browser and does not run a server-side action:
103
+
104
+ ```ruby
105
+ link "Documentation", to: "https://example.com/docs"
106
+ ```
107
+
108
+ Sandals currently accepts `http`, `https`, and `mailto` destinations. Internal
109
+ routes are reserved until a concrete application needs its own navigation.
110
+
111
+ ## Fields and actions
112
+
113
+ `text_field` displays a labeled text input with an optional initial value. Its
114
+ block receives the new value whenever the field changes:
115
+
116
+ ```ruby
117
+ text_field "Name", value: @name do |value|
118
+ @name = value
119
+ end
120
+ ```
121
+
122
+ Fields accept `error:` to display validation next to the control. Sandals marks
123
+ the field as invalid and associates the message using ARIA attributes:
124
+
125
+ ```ruby
126
+ text_field "Name", value: @name, error: @errors[:name] do |value|
127
+ @name = value
128
+ end
129
+ ```
130
+
131
+ `error` displays a general problem with an operation or form:
132
+
133
+ ```ruby
134
+ error "Review the highlighted fields." unless @errors.empty?
135
+ ```
136
+
137
+ `notice` communicates a successful result or non-urgent information:
138
+
139
+ ```ruby
140
+ notice "Profile saved."
141
+ ```
142
+
143
+ `button` runs its block using the Ruby state already updated by the fields:
144
+
145
+ ```ruby
146
+ button "Greet" do
147
+ @message = "Hello, #{@name}"
148
+ end
149
+ ```
150
+
151
+ While an action is pending, Sandals disables only the button that initiated it
152
+ and marks it with `aria-busy`. This prevents double clicks without interrupting
153
+ other fields.
154
+
155
+ If a field or button block raises an exception, Sandals keeps the last valid
156
+ view and displays the action name, error, file, and line. The next successful
157
+ event clears the message. Ruby state changed before the exception is not rolled
158
+ back automatically.
159
+
160
+ `number_field` works like `text_field`, but its block receives an `Integer`, a
161
+ `Float`, or `nil` when the field is empty:
162
+
163
+ ```ruby
164
+ number_field "Quantity", value: @quantity do |value|
165
+ @quantity = value
166
+ end
167
+ ```
168
+
169
+ `text_area` edits multiline text and passes the new string to its block:
170
+
171
+ ```ruby
172
+ text_area "Notes", value: @notes do |value|
173
+ @notes = value
174
+ end
175
+ ```
176
+
177
+ `checkbox` represents a boolean choice and passes `true` or `false`:
178
+
179
+ ```ruby
180
+ checkbox "I agree", checked: @accepted do |checked|
181
+ @accepted = checked
182
+ end
183
+ ```
184
+
185
+ `select` chooses a string from a collection of options:
186
+
187
+ ```ruby
188
+ select "Color", options: %w[Red Blue], value: @color do |value|
189
+ @color = value
190
+ end
191
+ ```
192
+
193
+ When the Ruby value differs from the visible label, `options` can be a
194
+ `value => label` hash. The block receives the original Ruby value:
195
+
196
+ ```ruby
197
+ select(
198
+ "Country",
199
+ options: { mx: "Mexico", us: "United States" },
200
+ value: @country
201
+ ) do |country|
202
+ @country = country
203
+ end
204
+ ```
205
+
206
+ `radio` provides the same key/value choice as a visible group:
207
+
208
+ ```ruby
209
+ radio(
210
+ "Color",
211
+ options: { red: "Red", blue: "Blue" },
212
+ value: @color
213
+ ) do |color|
214
+ @color = color
215
+ end
216
+ ```
217
+
218
+ ## Binding fields to models
219
+
220
+ A field can bind directly to the getter and setter of a Ruby object:
221
+
222
+ ```ruby
223
+ text_field "Name", model: @profile, bind: :name
224
+ ```
225
+
226
+ Sandals reads `@profile.name`, writes through `@profile.name=`, and, when the
227
+ object responds to `errors`, displays `@profile.errors[:name]`. `bind:` cannot
228
+ be combined with `value:` or an action block.
229
+
230
+ Binding is the recommended convention when state belongs to a model:
231
+
232
+ ```ruby
233
+ text_field "Name", model: @profile, bind: :name
234
+ number_field "Age", model: @profile, bind: :age
235
+ checkbox "Newsletter", model: @profile, bind: :newsletter
236
+ ```
237
+
238
+ Inline blocks remain useful when a change requires a transformation or an
239
+ explicit effect, or when the state is so small that creating a model would not
240
+ improve readability:
241
+
242
+ ```ruby
243
+ select "Currency", options: %w[MXN USD], value: @currency do |currency|
244
+ @currency = currency
245
+ @quote = Quotes.fetch(currency)
246
+ end
247
+ ```
248
+
249
+ The two profile form versions and the margin calculator can be run separately:
250
+
251
+ ```sh
252
+ sandals run app
253
+ sandals run app_with_bindings
254
+ sandals run app_margin_calculator
255
+ ```
256
+
257
+ ## Forms
258
+
259
+ `form` allows an action to run when the user presses Enter through a `submit`.
260
+ It does not collect and resend field values: fields have already synchronized
261
+ their values with Ruby state.
262
+
263
+ ```ruby
264
+ form do
265
+ text_field "New task", model: @draft, bind: :title
266
+
267
+ submit "Add" do
268
+ @list.add(@draft.title)
269
+ end
270
+ end
271
+ ```
272
+
273
+ A `button` remains an independent action. `submit` can only be declared inside
274
+ a `form`.
275
+
276
+ ## Uploaded files
277
+
278
+ `file_field` lets a user select a file without giving the application an
279
+ arbitrary system path:
280
+
281
+ ```ruby
282
+ file_field "CSV file" do |file|
283
+ rows = CSV.parse(file.read, headers: true)
284
+ end
285
+ ```
286
+
287
+ The block receives a `Sandals::UploadedFile`:
288
+
289
+ ```ruby
290
+ file.name
291
+ file.content_type
292
+ file.size
293
+ file.read
294
+ ```
295
+
296
+ The web host receives the file as multipart data with a 10 MB limit. The
297
+ runtime does not save it permanently. Tests can attach a fixture through the
298
+ semantic DSL:
299
+
300
+ ```ruby
301
+ session.attach "CSV file", file: "test/fixtures/people.csv"
302
+ ```
303
+
304
+ The complete application is available in `app_csv_reader.rb`.
305
+
306
+ ## Tables
307
+
308
+ `table` displays collections using explicit headers and rows:
309
+
310
+ ```ruby
311
+ table(
312
+ headers: ["Name", "City"],
313
+ rows: [
314
+ ["Ada", "London"],
315
+ ["Matz", "Tokyo"]
316
+ ]
317
+ )
318
+ ```
319
+
320
+ Cells can use `strong` and `em`, but cannot contain interactive controls. Every
321
+ row must have the same number of cells as the header. `table` does not replace
322
+ a general `each`; it is for genuinely tabular data.
323
+
324
+ ## Semantic tests
325
+
326
+ `Sandals.test` runs the application without a browser and finds controls by
327
+ their visible text rather than internal IDs:
328
+
329
+ ```ruby
330
+ session = Sandals.test(application)
331
+ cost = session.field("Cost")
332
+ price = session.field("Price")
333
+
334
+ cost.fill 60
335
+ price.fill 100
336
+
337
+ assert session.text?("Margin: 40.0%")
338
+
339
+ price.fill 50
340
+
341
+ refute session.text?("Margin:")
342
+ assert_equal "Price must be greater than cost", session.field_error(price)
343
+ ```
344
+
345
+ Other controls are operated using their visible labels and options:
346
+
347
+ ```ruby
348
+ session.check "Newsletter"
349
+ session.uncheck "Newsletter"
350
+ session.select "Colombia", from: "Country"
351
+ session.choose "Phone", from: "Contact"
352
+ session.click "Save"
353
+ ```
354
+
355
+ The same operations are available through a field reference:
356
+
357
+ ```ruby
358
+ session.field("Newsletter").check
359
+ session.field("Country").select("Colombia")
360
+ session.field("Contact").choose("Phone")
361
+ ```
362
+
363
+ General messages can be distinguished from other visible text:
364
+
365
+ ```ruby
366
+ assert session.notice?("Saved")
367
+ assert session.error?("Could not save")
368
+
369
+ session.assert_notice "Saved"
370
+ session.refute_notice "Missing notice"
371
+ session.assert_error "Could not save"
372
+ session.refute_error "Missing error"
373
+ ```
374
+
375
+ References such as `price` are semantic locators. They find the field again in
376
+ the current snapshot after every render instead of retaining stale objects
377
+ from a previous view.
378
+
379
+ ## Sessions
380
+
381
+ Each browser gets an independent application instance. One person's interface
382
+ state is not shared with anyone else:
383
+
384
+ ```ruby
385
+ definition = Sandals.app do
386
+ @name = ""
387
+
388
+ view do
389
+ text_field "Name", value: @name do |value|
390
+ @name = value
391
+ end
392
+ end
393
+ end
394
+
395
+ ana = definition.new_session
396
+ beto = definition.new_session
397
+ ```
398
+
399
+ `ana` and `beto` run the same definition but keep separate variables and
400
+ snapshots. Data that must be shared between people should live in an external
401
+ model or repository, not in application instance variables.
402
+
403
+ ## Persistence with external ORMs
404
+
405
+ Sandals does not include or wrap an ORM. Sequel and Active Record objects can
406
+ use `model:` and `bind:` through the same Ruby interface as any other model.
407
+
408
+ The repository includes two equivalent implementations of a persistent task
409
+ list:
410
+
411
+ - `examples/todos_sequel/app.rb`
412
+ - `examples/todos_active_record/app.rb`
413
+
414
+ Install the development dependencies and run either example:
415
+
416
+ ```sh
417
+ bundle install
418
+ bundle exec bin/sandals run examples/todos_sequel/app.rb
419
+ bundle exec bin/sandals run examples/todos_active_record/app.rb
420
+ ```
421
+
422
+ Each example configures SQLite, creates its table, and performs persistence
423
+ operations explicitly:
424
+
425
+ ```ruby
426
+ @draft.save
427
+ todo.update(completed:)
428
+ todo.destroy
429
+ ```
430
+
431
+ The SQLite file uses the example directory by default. To choose another
432
+ location:
433
+
434
+ ```sh
435
+ SANDALS_TODOS_DATABASE=/path/to/todos.sqlite3 \
436
+ bundle exec bin/sandals run examples/todos_sequel/app.rb
437
+ ```
438
+
439
+ This keeps a clear boundary: Sandals manages sessions and views; the
440
+ application's ORM manages records, validations, and the database.
441
+
442
+ Applications that query shared data can request periodic reevaluation:
443
+
444
+ ```ruby
445
+ Sandals.app do
446
+ refresh every: 5
447
+
448
+ view do
449
+ Todo.order(:id).each do |todo|
450
+ # ...
451
+ end
452
+ end
453
+ end
454
+ ```
455
+
456
+ The interval is expressed in seconds and must be at least one. Sandals
457
+ serializes refreshes with pending events, pauses them while the tab is hidden,
458
+ and refreshes when the tab becomes visible again. This feature is optional and
459
+ does not synchronize temporary state between sessions.
460
+
461
+ ## Development reload
462
+
463
+ `sandals run` watches the application's main file. When the file changes and
464
+ can be loaded successfully, the browser reloads and starts a new session:
465
+
466
+ ```sh
467
+ bundle exec bin/sandals run app.rb
468
+ ```
469
+
470
+ Temporary state and snapshots are reset; SQLite and other external storage
471
+ remain intact.
472
+
473
+ If the new version has a syntax or load error, Sandals keeps the last valid
474
+ definition and displays the error with its location. It continues watching the
475
+ file and recovers automatically when the error is fixed.
476
+
477
+ The current version watches only the file passed to `sandals run`, not files
478
+ loaded indirectly through `require`.
479
+
480
+ ## Mental model
481
+
482
+ ```text
483
+ Ruby state
484
+
485
+ view
486
+
487
+ Interface
488
+
489
+ User action
490
+
491
+ Ruby changes the state
492
+
493
+ view is evaluated again
494
+ ```
495
+
496
+ When the user presses a button, Sandals runs its block and produces the view
497
+ again. The interface is a direct representation of current state, without
498
+ manual DOM manipulation or an additional reactive system.
499
+
500
+ ## Architecture
501
+
502
+ ```text
503
+ app.rb
504
+
505
+ Ruby
506
+
507
+ Sandals runtime
508
+
509
+ Local server
510
+
511
+ HTML in the browser
512
+ ```
513
+
514
+ The browser is the universal renderer. Application logic and state live in
515
+ Ruby.
516
+
517
+ Each evaluation of `view` produces a `Sandals::View` object independent from
518
+ the renderer:
519
+
520
+ ```text
521
+ Application
522
+ ↓ evaluates the block
523
+ Sandals::View
524
+ ↓ contains elements
525
+ HTMLRenderer
526
+
527
+ HTML
528
+ ```
529
+
530
+ The view can be inspected directly:
531
+
532
+ ```ruby
533
+ view = app.render_view
534
+ title = view.children.first
535
+
536
+ title.class
537
+ # Sandals::Title
538
+
539
+ title.content
540
+ # "Hello from Sandals"
541
+ ```
542
+
543
+ This makes it possible to test and explain an application's structure without
544
+ depending on generated HTML. The block still runs in the application context,
545
+ so its Ruby state belongs to the application.
546
+
547
+ In the future, the same application could have different hosts:
548
+
549
+ ```text
550
+ sandals run app.rb → local browser
551
+ sandals desktop app.rb → desktop window
552
+ sandals serve app.rb → accessible server
553
+ ```
554
+
555
+ Only the first mode is part of the current experiment.
556
+
557
+ ## Design goals
558
+
559
+ Sandals favors:
560
+
561
+ - useful applications that are tens or hundreds of lines long;
562
+ - code that reads from top to bottom;
563
+ - state and effects that are easy to locate;
564
+ - behavior close to its trigger;
565
+ - a single Ruby source file as a starting point;
566
+ - fast execution and feedback;
567
+ - code that AI can generate, explain, and repair;
568
+ - tests based on what a person sees and does.
569
+
570
+ After reading an application, a Ruby developer should be able to answer:
571
+
572
+ 1. What state does this application have?
573
+ 2. What does it display?
574
+ 3. What can the user do?
575
+ 4. What does each action change?
576
+ 5. Does it use files, the network, or storage?
577
+
578
+ The current version is not intended to provide:
579
+
580
+ - complete Shoes compatibility;
581
+ - Ruby inside WebAssembly;
582
+ - static deployable applications;
583
+ - packaged desktop applications;
584
+ - authentication;
585
+ - remote storage;
586
+ - integrated AI;
587
+ - a large component or widget catalog;
588
+ - canvas graphics or animation;
589
+ - operation as a public multi-instance server.
590
+
591
+ These capabilities can come later if real applications demonstrate a need for
592
+ them.
593
+
594
+ ## Guiding principle
595
+
596
+ > Complexity should live in the runtime so the application can remain small,
597
+ > but never at the cost of hiding what the application does.
598
+
599
+ Sandals succeeds when running a tool is simple and reading it feels more like
600
+ understanding a small Ruby program than studying a framework.
601
+
602
+ ## License
603
+
604
+ Sandals is available under the MIT License. See [`LICENSE`](LICENSE).
data/bin/sandals ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "sandals/cli"
7
+
8
+ exit Sandals::CLI.start(ARGV)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class ActionError < StandardError
5
+ attr_reader :control, :original_error
6
+
7
+ def initialize(control, original_error)
8
+ @control = control
9
+ @original_error = original_error
10
+ super(original_error.message)
11
+ end
12
+
13
+ def action_name
14
+ return control.content if control.respond_to?(:content)
15
+
16
+ control.label
17
+ end
18
+
19
+ def location
20
+ locations = original_error.backtrace_locations || []
21
+ relevant_location = locations.find do |candidate|
22
+ !candidate.absolute_path.to_s.include?("/lib/sandals/")
23
+ end || locations.first
24
+ return "Location unavailable" unless relevant_location
25
+
26
+ path = relevant_location.absolute_path || relevant_location.path
27
+ path = path.delete_prefix("#{Dir.pwd}/")
28
+ "#{path}:#{relevant_location.lineno}"
29
+ end
30
+ end
31
+
32
+ module Actionable
33
+ private
34
+
35
+ def execute_action(*arguments)
36
+ @action&.call(*arguments)
37
+ rescue StandardError => error
38
+ raise ActionError.new(self, error), cause: error
39
+ end
40
+ end
41
+ end