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}"

      button "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
      button "Cancel"
      button "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."
      button "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:

button "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.rb
  • examples/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:

  1. What state does this application have?
  2. What does it display?
  3. What can the user do?
  4. What does each action change?
  5. 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.