Sandals
Sandals is a small environment for running interactive Ruby applications whose structure can be understood by reading their code.
The central idea is simple:
Describe a small tool in Ruby, run it immediately, and be able to understand what it displays, what data it keeps, and what happens with each action.
Sandals is not trying to be another web framework or to recreate all of Shoes. It also does not try to hide a complex application behind a magical DSL. Its purpose is to find a clear, concise, and expressive way to write small applications that both people and AI can create and understand.
Installation
Sandals requires Ruby 3.1 or later.
gem install sandals
sandals new hello
sandals run hello.rb
The server listens on 127.0.0.1. All required assets are included in the gem,
so a local app does not need an internet connection.
Quickstart
Create your first application:
sandals new hello
This creates two files:
hello.rb
hello_test.rb
hello.rb is a small application with a text field and a greeting. Run it:
sandals run hello.rb
Keep Sandals running and open hello.rb in your editor. Change:
title "Hello"
to:
title "Hello from Ruby"
Save the file. The browser reloads automatically and shows the change.
If the file contains a syntax or load error, Sandals keeps the last valid version running and reports the error with its location. Fix the file and save it again; the application recovers automatically.
The generated test interacts with the application through visible labels and text. Run it with:
ruby hello_test.rb
The test fills in "Your name" and verifies that the greeting appears. It uses
Minitest, which is installed with Sandals.
Startup errors
Sandals reports startup failures with the error type, application file, and line number instead of printing an internal framework backtrace:
sandals: SyntaxError at app.rb:12
app.rb:12: syntax error, unexpected end-of-input
Missing dependencies are reported in the same way:
sandals: LoadError at app.rb:1
cannot load such file -- sqlite3
Add the missing dependency to the app and try again.
Sandals also evaluates the initial view before opening the server, so an exception during the first render points to the relevant line in the application.
The experience
An application should normally fit in a single file:
require "sandals"
Sandals.app title: "Counter" do
@count = 0
view do
stack do
title "Counter"
para "Total: #{@count}"
"Increment" do
@count += 1
end
end
end
end
Run it with:
sandals run counter.rb
Sandals starts a local Ruby process, opens the application in the browser, and handles communication between the interface and its actions. To the person creating or using the application, it should feel like running a Ruby program, not configuring a web project.
Layout and content
stack arranges elements vertically. flow arranges them horizontally and
allows them to wrap onto the next line:
view do
stack do
title "Tool"
flow do
"Cancel"
"Continue"
end
end
end
title is the main heading, subtitle introduces a section, and para
represents regular prose:
stack do
subtitle "Result"
para "The pressure is ", strong("high"),
" and requires ", em("review"), "."
end
para renders as a paragraph and can combine strings with strong for
important content and em for emphasis.
Styling
Sandals has a neutral default appearance, but an application can establish its
own personality in Ruby. Use style outside the view to configure defaults:
Sandals.app title: "Notes" do
style :app, background: "#fffaf3", color: "#292524"
style :stack, gap: 18
style :title, color: "#9a3412", size: 42
style :button,
background: "#ea580c",
color: "white",
border: "1px solid #c2410c",
radius: 10
view do
stack padding: 24 do
title "Notes"
para "A small app with its own visual character."
"Delete", background: "#b91c1c"
end
end
end
An option on an individual element overrides the application default. The resolution order is:
Sandals defaults → application defaults → element options
The first styling API supports app, stack, flow, title, subtitle,
para, and button. Available properties are background, color, border,
radius, size, weight, width, padding, margin, gap, and align.
Numeric dimensions use pixels, while strings can include their own CSS unit:
style :title, size: "2.5rem"
stack width: "100%", gap: 16 do
# ...
end
Unknown elements and properties raise an error instead of silently producing
invalid styles. form remains semantic: use a surrounding stack or flow
to control its composition.
image displays an image from a path or URL. Alternative text is optional, but
should describe the image whenever it carries meaningful content:
image "red-sandals.jpg", alt: "A pair of red sandals"
link creates a regular external link. Navigation is handled entirely by the
browser and does not run a server-side action:
link "Documentation", to: "https://example.com/docs"
Sandals currently accepts http, https, and mailto destinations. Internal
routes are reserved until a concrete application needs its own navigation.
Fields and actions
text_field displays a labeled text input with an optional initial value. Its
block receives the new value whenever the field changes:
text_field "Name", value: @name do |value|
@name = value
end
Fields accept error: to display validation next to the control. Sandals marks
the field as invalid and associates the message using ARIA attributes:
text_field "Name", value: @name, error: @errors[:name] do |value|
@name = value
end
error displays a general problem with an operation or form:
error "Review the highlighted fields." unless @errors.empty?
notice communicates a successful result or non-urgent information:
notice "Profile saved."
button runs its block using the Ruby state already updated by the fields:
"Greet" do
@message = "Hello, #{@name}"
end
While an action is pending, Sandals disables only the button that initiated it
and marks it with aria-busy. This prevents double clicks without interrupting
other fields.
If a field or button block raises an exception, Sandals keeps the last valid view and displays the action name, error, file, and line. The next successful event clears the message. Ruby state changed before the exception is not rolled back automatically.
number_field works like text_field, but its block receives an Integer, a
Float, or nil when the field is empty:
number_field "Quantity", value: @quantity do |value|
@quantity = value
end
text_area edits multiline text and passes the new string to its block:
text_area "Notes", value: @notes do |value|
@notes = value
end
checkbox represents a boolean choice and passes true or false:
checkbox "I agree", checked: @accepted do |checked|
@accepted = checked
end
select chooses a string from a collection of options:
select "Color", options: %w[Red Blue], value: @color do |value|
@color = value
end
When the Ruby value differs from the visible label, options can be a
value => label hash. The block receives the original Ruby value:
select(
"Country",
options: { mx: "Mexico", us: "United States" },
value: @country
) do |country|
@country = country
end
radio provides the same key/value choice as a visible group:
radio(
"Color",
options: { red: "Red", blue: "Blue" },
value: @color
) do |color|
@color = color
end
Binding fields to models
A field can bind directly to the getter and setter of a Ruby object:
text_field "Name", model: @profile, bind: :name
Sandals reads @profile.name, writes through @profile.name=, and, when the
object responds to errors, displays @profile.errors[:name]. bind: cannot
be combined with value: or an action block.
Binding is the recommended convention when state belongs to a model:
text_field "Name", model: @profile, bind: :name
number_field "Age", model: @profile, bind: :age
checkbox "Newsletter", model: @profile, bind: :newsletter
Inline blocks remain useful when a change requires a transformation or an explicit effect, or when the state is so small that creating a model would not improve readability:
select "Currency", options: %w[MXN USD], value: @currency do |currency|
@currency = currency
@quote = Quotes.fetch(currency)
end
The two profile form versions and the margin calculator can be run separately:
sandals run app
sandals run app_with_bindings
sandals run app_margin_calculator
Forms
form allows an action to run when the user presses Enter through a submit.
It does not collect and resend field values: fields have already synchronized
their values with Ruby state.
form do
text_field "New task", model: @draft, bind: :title
submit "Add" do
@list.add(@draft.title)
end
end
A button remains an independent action. submit can only be declared inside
a form.
Uploaded files
file_field lets a user select a file without giving the application an
arbitrary system path:
file_field "CSV file" do |file|
rows = CSV.parse(file.read, headers: true)
end
The block receives a Sandals::UploadedFile:
file.name
file.content_type
file.size
file.read
The web host receives the file as multipart data with a 10 MB limit. The runtime does not save it permanently. Tests can attach a fixture through the semantic DSL:
session.attach "CSV file", file: "test/fixtures/people.csv"
The complete application is available in app_csv_reader.rb.
Tables
table displays collections using explicit headers and rows:
table(
headers: ["Name", "City"],
rows: [
["Ada", "London"],
["Matz", "Tokyo"]
]
)
Cells can use strong and em, but cannot contain interactive controls. Every
row must have the same number of cells as the header. table does not replace
a general each; it is for genuinely tabular data.
Semantic tests
Sandals.test runs the application without a browser and finds controls by
their visible text rather than internal IDs:
session = Sandals.test(application)
cost = session.field("Cost")
price = session.field("Price")
cost.fill 60
price.fill 100
assert session.text?("Margin: 40.0%")
price.fill 50
refute session.text?("Margin:")
assert_equal "Price must be greater than cost", session.field_error(price)
Other controls are operated using their visible labels and options:
session.check "Newsletter"
session.uncheck "Newsletter"
session.select "Colombia", from: "Country"
session.choose "Phone", from: "Contact"
session.click "Save"
The same operations are available through a field reference:
session.field("Newsletter").check
session.field("Country").select("Colombia")
session.field("Contact").choose("Phone")
General messages can be distinguished from other visible text:
assert session.notice?("Saved")
assert session.error?("Could not save")
session.assert_notice "Saved"
session.refute_notice "Missing notice"
session.assert_error "Could not save"
session.refute_error "Missing error"
References such as price are semantic locators. They find the field again in
the current snapshot after every render instead of retaining stale objects
from a previous view.
Sessions
Each browser gets an independent application instance. One person's interface state is not shared with anyone else:
definition = Sandals.app do
@name = ""
view do
text_field "Name", value: @name do |value|
@name = value
end
end
end
ana = definition.new_session
beto = definition.new_session
ana and beto run the same definition but keep separate variables and
snapshots. Data that must be shared between people should live in an external
model or repository, not in application instance variables.
Persistence with external ORMs
Sandals does not include or wrap an ORM. Sequel and Active Record objects can
use model: and bind: through the same Ruby interface as any other model.
The repository includes two equivalent implementations of a persistent task list:
examples/todos_sequel/app.rbexamples/todos_active_record/app.rb
Install the development dependencies and run either example:
bundle install
bundle exec bin/sandals run examples/todos_sequel/app.rb
bundle exec bin/sandals run examples/todos_active_record/app.rb
Each example configures SQLite, creates its table, and performs persistence operations explicitly:
@draft.save
todo.update(completed:)
todo.destroy
The SQLite file uses the example directory by default. To choose another location:
SANDALS_TODOS_DATABASE=/path/to/todos.sqlite3 \
bundle exec bin/sandals run examples/todos_sequel/app.rb
This keeps a clear boundary: Sandals manages sessions and views; the application's ORM manages records, validations, and the database.
Applications that query shared data can request periodic reevaluation:
Sandals.app do
refresh every: 5
view do
Todo.order(:id).each do |todo|
# ...
end
end
end
The interval is expressed in seconds and must be at least one. Sandals serializes refreshes with pending events, pauses them while the tab is hidden, and refreshes when the tab becomes visible again. This feature is optional and does not synchronize temporary state between sessions.
Development reload
sandals run watches the application's main file. When the file changes and
can be loaded successfully, the browser reloads and starts a new session:
bundle exec bin/sandals run app.rb
Temporary state and snapshots are reset; SQLite and other external storage remain intact.
If the new version has a syntax or load error, Sandals keeps the last valid definition and displays the error with its location. It continues watching the file and recovers automatically when the error is fixed.
The current version watches only the file passed to sandals run, not files
loaded indirectly through require.
Connection recovery
If the local Sandals process stops while the application is open, the browser shows a persistent “Connection lost” message and releases any pending buttons. It retries the connection once per second while the tab is visible.
When Sandals is available again, the browser reloads the application automatically. Events entered while disconnected are discarded because the restarted process creates a new Ruby session.
Mental model
Ruby state
↓
view
↓
Interface
↓
User action
↓
Ruby changes the state
↓
view is evaluated again
When the user presses a button, Sandals runs its block and produces the view again. The interface is a direct representation of current state, without manual DOM manipulation or an additional reactive system.
Architecture
app.rb
↓
Ruby
↓
Sandals runtime
↓
Local server
↓
HTML in the browser
The browser is the universal renderer. Application logic and state live in Ruby.
Each evaluation of view produces a Sandals::View object independent from
the renderer:
Application
↓ evaluates the block
Sandals::View
↓ contains elements
HTMLRenderer
↓
HTML
The view can be inspected directly:
view = app.render_view
title = view.children.first
title.class
# Sandals::Title
title.content
# "Hello from Sandals"
This makes it possible to test and explain an application's structure without depending on generated HTML. The block still runs in the application context, so its Ruby state belongs to the application.
In the future, the same application could have different hosts:
sandals run app.rb → local browser
sandals desktop app.rb → desktop window
sandals serve app.rb → accessible server
Only the first mode is part of the current experiment.
Design goals
Sandals favors:
- useful applications that are tens or hundreds of lines long;
- code that reads from top to bottom;
- state and effects that are easy to locate;
- behavior close to its trigger;
- a single Ruby source file as a starting point;
- fast execution and feedback;
- code that AI can generate, explain, and repair;
- tests based on what a person sees and does.
After reading an application, a Ruby developer should be able to answer:
- What state does this application have?
- What does it display?
- What can the user do?
- What does each action change?
- Does it use files, the network, or storage?
The current version is not intended to provide:
- complete Shoes compatibility;
- Ruby inside WebAssembly;
- static deployable applications;
- packaged desktop applications;
- authentication;
- remote storage;
- integrated AI;
- a large component or widget catalog;
- canvas graphics or animation;
- operation as a public multi-instance server.
These capabilities can come later if real applications demonstrate a need for them.
Guiding principle
Complexity should live in the runtime so the application can remain small, but never at the cost of hiding what the application does.
Sandals succeeds when running a tool is simple and reading it feels more like understanding a small Ruby program than studying a framework.
License
Sandals is available under the MIT License. See LICENSE.