From 8d5c1bd7cc65f49cc24f2c59c40a25c4f97298ee Mon Sep 17 00:00:00 2001
From: Noah Gibbs <the.codefolio.guy@gmail.com>
Date: Wed, 25 Oct 2023 11:54:45 +0100
Subject: [PATCH] Update Scarpe-Wasm with the last month of Scarpe-Webview and
 Lacci changes.

Co-authored-by: gintama91 <pavannambi0408@gmail.com>
---
 README.md                                  |   2 +-
 lib/scarpe/wasm.rb                         |  20 +-
 lib/scarpe/wasm/alert.rb                   |  66 ----
 lib/scarpe/wasm/app.rb                     |  40 +--
 lib/scarpe/wasm/arc.rb                     |  56 ----
 lib/scarpe/wasm/art_drawables.rb           |  33 ++
 lib/scarpe/wasm/background.rb              |  27 --
 lib/scarpe/wasm/border.rb                  |  24 --
 lib/scarpe/wasm/button.rb                  |  41 +--
 lib/scarpe/wasm/check.rb                   |   8 +-
 lib/scarpe/wasm/control_interface.rb       |  38 ++-
 lib/scarpe/wasm/control_interface_test.rb  | 234 --------------
 lib/scarpe/wasm/dimensions.rb              |  22 --
 lib/scarpe/wasm/document_root.rb           |  72 ++++-
 lib/scarpe/wasm/{widget.rb => drawable.rb} |  86 ++---
 lib/scarpe/wasm/edit_box.rb                |  21 +-
 lib/scarpe/wasm/edit_line.rb               |  22 +-
 lib/scarpe/wasm/errors.rb                  |  85 +++++
 lib/scarpe/wasm/flow.rb                    |  20 +-
 lib/scarpe/wasm/font.rb                    |  36 ---
 lib/scarpe/wasm/html.rb                    | 108 -------
 lib/scarpe/wasm/image.rb                   |  36 +--
 lib/scarpe/wasm/line.rb                    |  35 --
 lib/scarpe/wasm/link.rb                    |  19 +-
 lib/scarpe/wasm/list_box.rb                |  10 +-
 lib/scarpe/wasm/para.rb                    |  34 +-
 lib/scarpe/wasm/radio.rb                   |  20 +-
 lib/scarpe/wasm/shape.rb                   |  11 +-
 lib/scarpe/wasm/slot.rb                    |  33 +-
 lib/scarpe/wasm/spacing.rb                 |  41 ---
 lib/scarpe/wasm/span.rb                    |  30 +-
 lib/scarpe/wasm/stack.rb                   |  20 +-
 lib/scarpe/wasm/star.rb                    |  63 ----
 lib/scarpe/wasm/subscription_item.rb       |  43 ++-
 lib/scarpe/wasm/text_drawable.rb           |  28 ++
 lib/scarpe/wasm/text_widget.rb             |  30 --
 lib/scarpe/wasm/video.rb                   |  29 +-
 lib/scarpe/wasm/wasm_calls.rb              |   6 +-
 lib/scarpe/wasm/wasm_local_display.rb      |  80 +++--
 lib/scarpe/wasm/web_wrangler.rb            | 357 ++-------------------
 lib/scarpe/wasm_local.rb                   |  25 +-
 test/test_helper.rb                        |   3 +-
 42 files changed, 516 insertions(+), 1498 deletions(-)
 delete mode 100644 lib/scarpe/wasm/alert.rb
 delete mode 100644 lib/scarpe/wasm/arc.rb
 create mode 100644 lib/scarpe/wasm/art_drawables.rb
 delete mode 100644 lib/scarpe/wasm/background.rb
 delete mode 100644 lib/scarpe/wasm/border.rb
 delete mode 100644 lib/scarpe/wasm/control_interface_test.rb
 delete mode 100644 lib/scarpe/wasm/dimensions.rb
 rename lib/scarpe/wasm/{widget.rb => drawable.rb} (68%)
 create mode 100644 lib/scarpe/wasm/errors.rb
 delete mode 100644 lib/scarpe/wasm/font.rb
 delete mode 100644 lib/scarpe/wasm/html.rb
 delete mode 100644 lib/scarpe/wasm/line.rb
 delete mode 100644 lib/scarpe/wasm/spacing.rb
 delete mode 100644 lib/scarpe/wasm/star.rb
 create mode 100644 lib/scarpe/wasm/text_drawable.rb
 delete mode 100644 lib/scarpe/wasm/text_widget.rb

diff --git a/README.md b/README.md
index a00e449..3cd5f1b 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ Install the gem and add to the application's Gemfile by executing:
 
     $ bundle add scarpe-wasm
 
-If bundler is not being used to manage dependencies, install the gem by executing:
+If bundler is not being used to manage dependencies (NOT RECOMMENDED), install the gem by executing:
 
     $ gem install scarpe-wasm
 
diff --git a/lib/scarpe/wasm.rb b/lib/scarpe/wasm.rb
index dd925cf..cf7a0aa 100644
--- a/lib/scarpe/wasm.rb
+++ b/lib/scarpe/wasm.rb
@@ -1,9 +1,9 @@
 # frozen_string_literal: true
 
-# Scarpe WASM Display Service
+# Scarpe Wasm Display Service
 
 # This file should be required on the Wasm side, not the Ruby side.
-# So it's used to link to JS, and to instantiate widgets, but not
+# So it's used to link to JS, and to instantiate drawables, but not
 # for e.g. packaging.
 
 require_relative "wasm/version"
@@ -12,20 +12,12 @@
 require_relative "wasm/web_wrangler"
 require_relative "wasm/control_interface"
 
-require_relative "wasm/widget"
+require_relative "wasm/drawable"
 require_relative "wasm/wasm_local_display"
 
-require_relative "wasm/dimensions"
-require_relative "wasm/html"
-
-require_relative "wasm/spacing"
-require_relative "wasm/star"
 require_relative "wasm/radio"
-require_relative "wasm/background"
-require_relative "wasm/border"
 
-require_relative "wasm/arc"
-require_relative "wasm/font"
+require_relative "wasm/art_drawables"
 
 require_relative "wasm/app"
 require_relative "wasm/para"
@@ -39,12 +31,10 @@
 require_relative "wasm/edit_box"
 require_relative "wasm/edit_line"
 require_relative "wasm/list_box"
-require_relative "wasm/alert"
 require_relative "wasm/span"
 require_relative "wasm/shape"
 
-require_relative "wasm/text_widget"
+require_relative "wasm/text_drawable"
 require_relative "wasm/link"
-require_relative "wasm/line"
 require_relative "wasm/video"
 require_relative "wasm/check"
diff --git a/lib/scarpe/wasm/alert.rb b/lib/scarpe/wasm/alert.rb
deleted file mode 100644
index bbfd3df..0000000
--- a/lib/scarpe/wasm/alert.rb
+++ /dev/null
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class WASMAlert < WASMWidget
-    def initialize(properties)
-      super
-
-      bind("click") do
-        send_self_event(event_name: "click")
-      end
-    end
-
-    def element
-      onclick = handler_js_code("click")
-
-      HTML.render do |h|
-        h.div(id: html_id, style: overlay_style) do
-          h.div(style: modal_style) do
-            h.div(style: text_style) { @text }
-            h.button(style: button_style, onclick: onclick) { "OK" }
-          end
-        end
-      end
-    end
-
-    protected
-
-    # If the whole widget is hidden, the parent style adds display:none
-    def overlay_style
-      {
-        position: "fixed",
-        top: "0",
-        left: "0",
-        width: "100%",
-        height: "100%",
-        overflow: "auto",
-        "z-index": "1",
-        background: "rgba(0,0,0,0.4)",
-        display: "flex",
-        "align-items": "center",
-        "justify-content": "center",
-      }.merge(style)
-    end
-
-    def modal_style
-      {
-        "min-width": "200px",
-        "min-height": "50px",
-        padding: "10px",
-        display: "flex",
-        background: "#fefefe",
-        "flex-direction": "column",
-        "justify-content": "space-between",
-        "border-radius": "9px",
-      }
-    end
-
-    def text_style
-      {}
-    end
-
-    def button_style
-      {}
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/app.rb b/lib/scarpe/wasm/app.rb
index 6fba27b..1e7426e 100644
--- a/lib/scarpe/wasm/app.rb
+++ b/lib/scarpe/wasm/app.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
-class Scarpe
-  # Scarpe::WASMApp must only be used from the main thread, due to GTK+ limitations.
-  class WASMApp < WASMWidget
+module Scarpe::Wasm
+  # App must only be used from the main thread, due to GTK+ limitations.
+  class App < Drawable
     attr_reader :control_interface
 
     attr_writer :shoes_linkable_id
@@ -10,22 +10,10 @@ class WASMApp < WASMWidget
     def initialize(properties)
       super
 
-      # It's possible to provide a Ruby script by setting
-      # SCARPE_TEST_CONTROL to its file path. This can
-      # allow pre-setting test options or otherwise
-      # performing additional actions not written into
-      # the Shoes app itself.
-      #
-      # The control interface is what lets these files see
-      # events, specify overrides and so on.
       @control_interface = ControlInterface.new
-      if ENV["SCARPE_TEST_CONTROL"]
-        require "scarpe/components/unit_test_helpers"
-        @control_interface.instance_eval File.read(ENV["SCARPE_TEST_CONTROL"])
-      end
 
       # TODO: rename @view
-      @view = Scarpe::WebWrangler.new title: @title,
+      @view = Scarpe::Wasm::WebWrangler.new title: @title,
         width: @width,
         height: @height,
         resizable: @resizable
@@ -62,12 +50,10 @@ def init
 
     def run
       @control_interface.dispatch_event(:init)
-
       send_shoes_event("return", event_name: "custom_event_loop")
 
-      # This takes control of the main thread and never returns. And it *must* be run from
-      # the main thread. And it stops any Ruby background threads.
-      # That's totally cool and normal, right?
+      @view.empty_page = empty_page_element
+
       @view.run
     end
 
@@ -82,24 +68,28 @@ def destroy
       end
     end
 
-    # All JS callbacks to Scarpe widgets are dispatched
+    # All JS callbacks to Scarpe drawables are dispatched
     # via this handler
     def handle_callback(name, *args)
-      @callbacks[name].call(*args)
+      if @callbacks.key?(name)
+        @callbacks[name].call(*args)
+      else
+        raise Scarpe::UnknownEventTypeError, "No such Wasm callback: #{name.inspect}!"
+      end
     end
 
     # Bind a Scarpe callback name; see handle_callback above.
-    # See Scarpe::Widget for how the naming is set up
+    # See {Drawable} for how the naming is set up
     def bind(name, &block)
       @callbacks[name] = block
     end
 
-    # Request a full redraw if WASM is running. Otherwise
+    # Request a full redraw if Wasm is running. Otherwise
     # this is a no-op.
     #
     # @return [void]
     def request_redraw!
-      wrangler = WASMDisplayService.instance.wrangler
+      wrangler = DisplayService.instance.wrangler
       if wrangler.is_running
         wrangler.replace(@document_root.to_html)
       end
diff --git a/lib/scarpe/wasm/arc.rb b/lib/scarpe/wasm/arc.rb
deleted file mode 100644
index 5a3a5f1..0000000
--- a/lib/scarpe/wasm/arc.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class WASMArc < Scarpe::WASMWidget
-    def initialize(properties)
-      super(properties)
-    end
-
-    def element(&block)
-      HTML.render do |h|
-        h.div(id: html_id, style: style) do
-          h.svg(width: @width, height: @height) do
-            h.path(d: arc_path)
-          end
-          block.call(h) if block_given?
-        end
-      end
-    end
-
-    protected
-
-    def style
-      super.merge({
-        left: "#{@left}px",
-        top: "#{@top}px",
-        width: "#{@width}px",
-        height: "#{@height}px",
-      })
-    end
-
-    private
-
-    def arc_path
-      center_x = @width / 2
-      center_y = @height / 2
-      radius_x = @width / 2
-      radius_y = @height / 2
-      start_angle_degrees = radians_to_degrees(@angle1) % 360
-      end_angle_degrees = radians_to_degrees(@angle2) % 360
-      large_arc_flag = (end_angle_degrees - start_angle_degrees) % 360 > 180 ? 1 : 0
-
-      "M#{center_x} #{center_y} L#{@width} #{center_y} " \
-        "A#{radius_x} #{radius_y} 0 #{large_arc_flag} 0 " \
-        "#{center_x + radius_x * Math.cos(degrees_to_radians(end_angle_degrees))} " \
-        "#{center_y + radius_y * Math.sin(degrees_to_radians(end_angle_degrees))} Z"
-    end
-
-    def degrees_to_radians(degrees)
-      degrees * Math::PI / 180
-    end
-
-    def radians_to_degrees(radians)
-      radians * (180.0 / Math::PI)
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/art_drawables.rb b/lib/scarpe/wasm/art_drawables.rb
new file mode 100644
index 0000000..38ffa77
--- /dev/null
+++ b/lib/scarpe/wasm/art_drawables.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Scarpe::Wasm
+  class Arc < Drawable
+    def element(&block)
+      render("arc")
+    end
+  end
+
+  class Arrow < Drawable
+    def element(&block)
+      render("arrow")
+    end
+  end
+
+  class Line < Drawable
+    def element(&block)
+      render("line")
+    end
+  end
+
+  class Rect < Drawable
+    def element(&block)
+      render("rect")
+    end
+  end
+
+  class Star < Drawable
+    def element(&block)
+      render("star", &block)
+    end
+  end
+end
diff --git a/lib/scarpe/wasm/background.rb b/lib/scarpe/wasm/background.rb
deleted file mode 100644
index c1770f7..0000000
--- a/lib/scarpe/wasm/background.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-require "scarpe/components/base64"
-
-class Scarpe
-  module WASMBackground
-    include Components::Base64
-
-    def style
-      styles = (super if defined?(super)) || {}
-      return styles unless @background_color
-
-      color = case @background_color
-      when Array
-        "rgba(#{@background_color.join(", ")})"
-      when Range
-        "linear-gradient(45deg, #{@background_color.first}, #{@background_color.last})"
-      when ->(value) { File.exist?(value) }
-        "url(data:image/png;base64,#{encode_file_to_base64(@background_color)})"
-      else
-        @background_color
-      end
-
-      styles.merge(background: color)
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/border.rb b/lib/scarpe/wasm/border.rb
deleted file mode 100644
index 54e8a2d..0000000
--- a/lib/scarpe/wasm/border.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  module WASMBorder
-    def style
-      styles = (super if defined?(super)) || {}
-      return styles unless @border_color
-
-      border_color = case @border_color
-      when Range
-        { "border-image": "linear-gradient(45deg, #{@border_color.first}, #{@border_color.last})" }
-      when Array
-        { "border-color": "rgba(#{@border_color.join(", ")})" }
-      else
-        { "border-color": @border_color }
-      end
-      styles.merge(
-        "border-style": "solid",
-        "border-width": "#{@options[:strokewidth] || 1}px",
-        "border-radius": "#{@options[:curve] || 0}px",
-      ).merge(border_color)
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/button.rb b/lib/scarpe/wasm/button.rb
index e921b31..04b8b32 100644
--- a/lib/scarpe/wasm/button.rb
+++ b/lib/scarpe/wasm/button.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMButton < WASMWidget
+module Scarpe::Wasm
+  class Button < Drawable
     def initialize(properties)
       super
 
@@ -10,43 +10,14 @@ def initialize(properties)
         # This will be sent to the bind_self_event in Button
         send_self_event(event_name: "click")
       end
-    end
 
-    def element
-      HTML.render do |h|
-        h.button(id: html_id, onclick: handler_js_code("click"), style: style) do
-          @text
-        end
+      bind("hover") do
+        send_self_event(event_name: "hover")
       end
     end
 
-    protected
-
-    def style
-      styles = super
-
-      styles[:"background-color"] = @color
-      styles[:"padding-top"] = @padding_top
-      styles[:"padding-bottom"] = @padding_bottom
-      styles[:color] = @text_color
-      styles[:width] = Dimensions.length(@width) if @width
-      styles[:height] = Dimensions.length(@height) if @height
-      styles[:"font-size"] = @font_size
-
-      styles[:top] = Dimensions.length(@top) if @top
-      styles[:left] = Dimensions.length(@left) if @left
-      styles[:position] = "absolute" if @top || @left
-      styles[:"font-size"] = Dimensions.length(font_size) if @size
-      styles[:"font-family"] = @font if @font
-      styles[:color] = rgb_to_hex(@stroke) if @stroke
-
-      styles
-    end
-
-    def font_size
-      font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size
-
-      Dimensions.length(font_size)
+    def element
+      render("button")
     end
   end
 end
diff --git a/lib/scarpe/wasm/check.rb b/lib/scarpe/wasm/check.rb
index 8e0a633..be97d8a 100644
--- a/lib/scarpe/wasm/check.rb
+++ b/lib/scarpe/wasm/check.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMCheck < Scarpe::WASMWidget
+module Scarpe::Wasm
+  class Check < Drawable
     attr_reader :text
 
     def initialize(properties)
@@ -21,9 +21,7 @@ def properties_changed(changes)
     end
 
     def element
-      HTML.render do |h|
-        h.input(type: :checkbox, id: html_id, onclick: handler_js_code("click"), value: "hmm #{text}", checked: @checked, style:)
-      end
+      render("check")
     end
   end
 end
diff --git a/lib/scarpe/wasm/control_interface.rb b/lib/scarpe/wasm/control_interface.rb
index bcb812f..ce88668 100644
--- a/lib/scarpe/wasm/control_interface.rb
+++ b/lib/scarpe/wasm/control_interface.rb
@@ -10,19 +10,22 @@
 # And if you depend on this from the framework, I'll add a check-mode that
 # never dispatches any events to any handlers. Do NOT test me on this.
 
-class Scarpe
+module Scarpe::Wasm
   class ControlInterface
     include Shoes::Log
 
     SUBSCRIBE_EVENTS = [:init, :shutdown, :next_redraw, :every_redraw, :next_heartbeat, :every_heartbeat]
     DISPATCH_EVENTS = [:init, :shutdown, :redraw, :heartbeat]
+    INVALID_SYSTEM_COMPONENTS_MESSAGE = "Must pass non-nil app and wrangler to ControlInterface#set_system_components!"
+    CONTROL_INTERFACE_INIT_MESSAGE = "ControlInterface code needs to be wrapped in handlers like on_event(:init) " +
+      "to make sure they have access to app, doc_root, wrangler, etc!"
 
     attr_writer :doc_root
     attr_reader :do_shutdown
 
     # The control interface needs to see major system components to hook into their events
     def initialize
-      log_init("WV::ControlInterface")
+      log_init("Wasm::ControlInterface")
 
       @do_shutdown = false
       @event_handlers = {}
@@ -35,11 +38,15 @@ def inspect
 
     # This should get called once, from Shoes::App
     def set_system_components(app:, doc_root:, wrangler:)
-      unless app && wrangler
-        @log.error("False app passed to set_system_components!") unless app
-        @log.error("False wrangler passed to set_system_components!") unless wrangler
-        raise "Must pass non-nil app and wrangler to ControlInterface#set_system_components!"
+      unless app
+        @log.error("False app passed to set_system_components!")
+        raise Scarpe::MissingAppError, INVALID_SYSTEM_COMPONENTS_MESSAGE
       end
+      unless wrangler
+        @log.error("False wrangler passed to set_system_components!")
+        raise Scarpe::MissingWranglerError, INVALID_SYSTEM_COMPONENTS_MESSAGE
+      end
+
       @app = app
       @doc_root = doc_root # May be nil at this point
       @wrangler = wrangler
@@ -50,28 +57,19 @@ def set_system_components(app:, doc_root:, wrangler:)
     end
 
     def app
-      unless @app
-        raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " +
-          "to make sure they have access to app, doc_root, wrangler, etc!"
-      end
+      raise Scarpe::MissingAppError, CONTROL_INTERFACE_INIT_MESSAGE unless @app
 
       @app
     end
 
     def doc_root
-      unless @doc_root
-        raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " +
-          "to make sure they have access to app, doc_root, wrangler, etc!"
-      end
+      raise Scarpe::MissingDocRootError, CONTROL_INTERFACE_INIT_MESSAGE unless @doc_root
 
       @doc_root
     end
 
     def wrangler
-      unless @wrangler
-        raise "ControlInterface code needs to be wrapped in handlers like on_event(:init) " +
-          "to make sure they have access to app, doc_root, wrangler, etc!"
-      end
+      raise Scarpe::MissingWranglerError, CONTROL_INTERFACE_INIT_MESSAGE unless @wrangler
 
       @wrangler
     end
@@ -82,7 +80,7 @@ def wrangler
     # On recognised events, this sets a handler for that event
     def on_event(event, &block)
       unless SUBSCRIBE_EVENTS.include?(event)
-        raise "Illegal subscribe to event #{event.inspect}! Valid values are: #{SUBSCRIBE_EVENTS.inspect}"
+        raise Scarpe::IllegalSubscribeEventError, "Illegal subscribe to event #{event.inspect}! Valid values are: #{SUBSCRIBE_EVENTS.inspect}"
       end
 
       @unsub_id ||= 0
@@ -97,7 +95,7 @@ def dispatch_event(event, *args, **keywords)
       @log.debug("CTL event #{event.inspect} #{args.inspect} #{keywords.inspect}")
 
       unless DISPATCH_EVENTS.include?(event)
-        raise "Illegal dispatch of event #{event.inspect}! Valid values are: #{DISPATCH_EVENTS.inspect}"
+        raise Scarpe::IllegalDispatchEventError, "Illegal dispatch of event #{event.inspect}! Valid values are: #{DISPATCH_EVENTS.inspect}"
       end
 
       if @do_shutdown
diff --git a/lib/scarpe/wasm/control_interface_test.rb b/lib/scarpe/wasm/control_interface_test.rb
deleted file mode 100644
index 13914d5..0000000
--- a/lib/scarpe/wasm/control_interface_test.rb
+++ /dev/null
@@ -1,234 +0,0 @@
-# frozen_string_literal: true
-
-# The ControlInterface doesn't, by default, include much of a test framework.
-# But writing a test framework in heredocs in test_helper.rb seems bad.
-# So we write a test framework here, but don't include it by default.
-# A running shoes app won't normally include it, but unit tests will.
-
-require "json"
-
-class Scarpe
-  DEFAULT_ASSERTION_TIMEOUT = 1.0
-
-  class ControlInterface
-    include Scarpe::Test::Helpers
-
-    def timed_out?
-      @did_time_out
-    end
-
-    def die_after(time)
-      t_start = Time.now
-      @die_after = [t_start, time]
-
-      wrangler.periodic_code("scarpeTestTimeout") do |*_args|
-        t_delta = (Time.now - t_start).to_f
-        if t_delta > time
-          @did_time_out = true
-          @log.warn("die_after - timed out after #{t_delta.inspect} (threshold: #{time.inspect})")
-          return_results(false, "Timed out!")
-          app.destroy
-        end
-      end
-    end
-
-    # This is returned alongside the actual results automatically
-    def test_metadata
-      data = {}
-      if @die_after
-        t_delta = (Time.now - @die_after[0]).to_f
-        data["die_after"] = {
-          t_start: @die_after[0].to_s,
-          threshold: @die_after[1],
-          passed: t_delta,
-        }
-      end
-      data
-    end
-
-    # Need to be able to query widgets in test code
-
-    def all_wv_widgets
-      known = [doc_root]
-      to_check = [doc_root]
-
-      until to_check.empty?
-        next_layer = to_check.flat_map(&:children)
-        known += next_layer
-        to_check = next_layer
-      end
-
-      # I don't *think* we'll ever have widget trees that merge back together, but just in case we'll de-dup
-      known.uniq
-    end
-
-    # Shoes doesn't name widgets. We aren't guaranteed that the Shoes widgets are even in the same
-    # process, since we have the Relay display service for Webview. So mostly we can look by
-    # display service class.
-    def find_wv_widgets(*specifiers)
-      widgets = all_wv_widgets
-
-      specifiers.each do |spec|
-        if spec.is_a?(Class)
-          widgets.select! { |w| spec === w }
-        else
-          raise "I don't know how to search for widgets by #{spec.inspect}!"
-        end
-      end
-
-      widgets
-    end
-
-    # We want an assertions library, but one that runs inside the spawned
-    # Webview sub-process.
-
-    # Note that we do *not* extract this assertions library to use elsewhere
-    # because it's very focused on evented assertions that start and stop
-    # over a period of time. Instantaneous procedural asserts don't want to
-    # use this API.
-
-    def return_when_assertions_done
-      assertions_may_exist
-
-      wrangler.periodic_code("scarpeReturnWhenAssertionsDone") do |*_args|
-        if @assertions_pending.empty?
-          success = @assertions_failed.empty?
-          return_results success, "Assertions #{success ? "succeeded" : "failed"}", assertion_data_as_a_struct
-          app.destroy
-        end
-      end
-    end
-
-    def assertions_may_exist
-      @assertions_pending ||= {}
-      @assertions_failed ||= {}
-      @assertions_passed ||= 0
-      @assertion_counter ||= 0
-    end
-
-    def start_assertion(code)
-      assertions_may_exist
-
-      this_assertion = @assertion_counter
-      @assertion_counter += 1
-
-      @assertions_pending[this_assertion] = {
-        id: this_assertion,
-        code: code,
-      }
-
-      this_assertion
-    end
-
-    def pass_assertion(id)
-      @assertions_pending.delete(id)
-      @assertions_passed += 1
-    end
-
-    def fail_assertion(id, fail_message)
-      item = @assertions_pending.delete(id)
-      item[:fail_message] = fail_message
-      @assertions_failed[id] = item
-    end
-
-    def assertions_pending?
-      !@assertions_pending.empty?
-    end
-
-    def assertion_data_as_a_struct
-      {
-        still_pending: @assertions_pending.size,
-        succeeded: @assertions_passed,
-        failed: @assertions_failed.size,
-        failures: @assertions_failed.values.map { |item| [item[:code], item[:failure_reason]] },
-      }
-    end
-
-    # Create a promise to do a JS assertion, normally after other ops have finished.
-    def assert_js(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT)
-      id = start_assertion(js_code)
-
-      # this isn't a TestPromise, so it doesn't have the additional DSL entries
-      promise = wrangler.eval_js_async(js_code, wait_for: wait_for, timeout: timeout)
-      promise.on_rejected do
-        fail_assertion(id, "JS Eval failed: #{promise.reason.inspect}")
-      end
-      promise.on_fulfilled do
-        ret_val = promise.returned_value
-        if ret_val
-          pass_assertion(id)
-        else
-          fail_assertion(id, "Expected true JS value: #{ret_val.inspect}")
-        end
-      end
-
-      # So we wrap it in a no-op TestPromise, to get the DSL entries.
-      TestPromise.new(iface: self, wait_for: [promise]).to_execute {}
-    end
-
-    def assert(value, msg = nil)
-      id = start_assertion("#{caller[0]}: #{msg || "Value should be true!"}")
-
-      if value
-        pass_assertion(id)
-      else
-        fail_assertion(id, "Expected true Ruby value: #{value.inspect}")
-      end
-    end
-
-    def assert_equal(val1, val2, msg = nil)
-      assert val1 == val2, (msg || "Expected #{val2.inspect} to equal #{val1.inspect}!")
-    end
-
-    # How do we signal an error?
-    def with_js_value(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block)
-      raise "Must give a block to with_js_value!" unless block
-
-      js_promise = wrangler.eval_js_async(js_code, wait_for: wait_for, timeout: timeout)
-      ruby_promise = TestPromise.new(iface: self, wait_for: [js_promise])
-      ruby_promise.to_execute(&block)
-      ruby_promise
-    end
-
-    def with_js_dom_html(wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block)
-      with_js_value("document.getElementById('wrapper-wvroot').innerHTML", wait_for: wait_for, timeout: timeout, &block)
-    end
-
-    def fully_updated(wait_for: [])
-      wrangler.promise_dom_fully_updated
-    end
-  end
-
-  # A Promise but with helper functions
-  class TestPromise < Promise
-    def initialize(iface:, state: nil, wait_for: [], &scheduler)
-      @iface = iface
-      super(state: state, parents: wait_for, &scheduler)
-    end
-
-    def inspect
-      "<#TestPromise::#{object_id} state=#{state.inspect} parents=#{parents.inspect} value=#{returned_value.inspect} reason=#{reason.inspect}>"
-    end
-
-    # This method expects to wait for the parent TestPromise and then run a block of Ruby that returns
-    # another promise. This is useful for wrapping Promises like those from replace() that don't have
-    # the test DSL built in. The block will execute when this outer promise is scheduled -- so we don't do
-    # a replace() too early, for instance. And then the outer promise will fulfill when the inner one does.
-    def then_ruby_promise(wait_for: [], &block)
-      ruby_wrapper_promise = TestPromise.new iface: @iface, wait_for: ([self] + wait_for)
-
-      ruby_wrapper_promise.on_scheduled do
-        inner_ruby_promise = block.call
-        inner_ruby_promise.on_fulfilled { ruby_wrapper_promise.fulfilled!(inner_ruby_promise.returned_value) }
-      end
-    end
-
-    def then_with_js_value(js_code, wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block)
-      @iface.with_js_value(js_code, wait_for: (wait_for + [self]), timeout:, &block)
-    end
-
-    def then_with_js_dom_html(wait_for: [], timeout: DEFAULT_ASSERTION_TIMEOUT, &block)
-      @iface.with_js_dom_html(wait_for: (wait_for + [self]), timeout:, &block)
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/dimensions.rb b/lib/scarpe/wasm/dimensions.rb
deleted file mode 100644
index 1869a13..0000000
--- a/lib/scarpe/wasm/dimensions.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class Dimensions
-    class << self
-      def length(value)
-        case value
-        when Integer
-          if value < 0
-            "calc(100% - #{value.abs}px)"
-          else
-            "#{value}px"
-          end
-        when Float
-          "#{value * 100}%"
-        else
-          value
-        end
-      end
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/document_root.rb b/lib/scarpe/wasm/document_root.rb
index 13c0aad..b04fc2b 100644
--- a/lib/scarpe/wasm/document_root.rb
+++ b/lib/scarpe/wasm/document_root.rb
@@ -1,8 +1,72 @@
 # frozen_string_literal: true
 
-class Scarpe
-  # A WASMDocumentRoot is a WASMFlow, with all the same properties
-  # and basic behavior.
-  class WASMDocumentRoot < Scarpe::WASMFlow
+module Scarpe::Wasm
+  # A DocumentRoot is a {Scarpe::WASM::Flow}, with all the same properties
+  # and basic behavior. It also reserves space for Builtins like fonts, alerts,
+  # etc. which don't have individual {Shoes::Drawable} objects.
+  class DocumentRoot < Flow
+    def initialize(properties)
+      super
+
+      @fonts = []
+      @alerts = []
+
+      bind_shoes_event(event_name: "builtin") do |cmd_name, args|
+        case cmd_name
+        when "font"
+          @fonts << args[0]
+          needs_update!
+        when "alert"
+          bind_ok_event
+          @alerts << args[0]
+          needs_update!
+        else
+          raise Scarpe::UnknownBuiltinCommandError, "Unexpected builtin command: #{cmd_name.inspect}!"
+        end
+      end
+    end
+
+    def element(&block)
+      contents = block ? block.call : ""
+      contents += builtin_contents
+      super { contents }
+    end
+
+    private
+
+    # This needs to occur after initialize() because the app isn't yet allocated initially,
+    # so we can't call bind() for app events yet.
+    def bind_ok_event
+      return if @ok_event_setup_done
+      @ok_event_setup_done = true
+
+      # Done with the alert(s), delete them
+      bind("OK") do
+        @alerts = []
+        needs_update!
+      end
+    end
+
+    def builtin_contents
+      font_contents = @fonts.map do |font|
+        HTML.render do |h|
+          h.link(href: font, rel: "stylesheet")
+          h.style do
+            font_name = File.basename(font, ".*")
+            <<~CSS
+              @font-face {
+                font-family: #{font_name};
+                src: url("data:font/truetype;base64,#{encode_file_to_base64(font)}") format('truetype');
+              }
+            CSS
+          end
+        end
+      end.join
+      alert_contents = @alerts.map do |alert_text|
+        render("alert", { "text" => alert_text, "event_name" => "OK" })
+      end.join
+
+      font_contents + alert_contents
+    end
   end
 end
diff --git a/lib/scarpe/wasm/widget.rb b/lib/scarpe/wasm/drawable.rb
similarity index 68%
rename from lib/scarpe/wasm/widget.rb
rename to lib/scarpe/wasm/drawable.rb
index 9d01e95..bb29605 100644
--- a/lib/scarpe/wasm/widget.rb
+++ b/lib/scarpe/wasm/drawable.rb
@@ -1,66 +1,70 @@
 # frozen_string_literal: true
 
-class Scarpe
-  # The WASMWidget parent class helps connect a WASM widget with
-  # its Shoes equivalent, render itself to the WASM DOM, handle
-  # Javascript events and generally keep things working in WASM.
-  class WASMWidget < Shoes::Linkable
+module Scarpe::Wasm
+  # The Drawable parent class helps connect a Wasm drawable with
+  # its Shoes equivalent, render itself to the Wasm DOM, handle
+  # Javascript events and generally keep things working in Wasm.
+  class Drawable < Shoes::Linkable
     include Shoes::Log
 
     class << self
-      # Return the corresponding WASM class for a particular Shoes class name
+      # Return the corresponding Wasm class for a particular Shoes class name
       def display_class_for(scarpe_class_name)
         scarpe_class = Shoes.const_get(scarpe_class_name)
         unless scarpe_class.ancestors.include?(Shoes::Linkable)
-          raise "Scarpe WASM can only get display classes for Shoes " +
-            "linkable widgets, not #{scarpe_class_name.inspect}!"
+          raise Scarpe::InvalidClassError, "Scarpe Wasm can only get display classes for Shoes " +
+            "linkable drawables, not #{scarpe_class_name.inspect}!"
         end
 
-        klass = Scarpe.const_get("WASM" + scarpe_class_name.split("::")[-1])
+        klass = Scarpe::Wasm.const_get(scarpe_class_name.split("::")[-1])
         if klass.nil?
-          raise "Couldn't find corresponding Scarpe WASM class for #{scarpe_class_name.inspect}!"
+          raise Scarpe::MissingClassError, "Couldn't find corresponding Scarpe Wasm class for #{scarpe_class_name.inspect}!"
         end
 
         klass
       end
     end
 
-    # The Shoes ID corresponding to the Shoes widget for this WASM widget
+    # The Shoes ID corresponding to the Shoes drawable for this Wasm drawable
     attr_reader :shoes_linkable_id
 
-    # The WASMWidget parent of this widget
+    # The Drawable parent of this drawable
     attr_reader :parent
 
-    # An array of WASMWidget children (possibly empty) of this widget
+    # An array of Drawable children (possibly empty) of this drawable
     attr_reader :children
 
-    # Set instance variables for the display properties of this widget. Bind Shoes
-    # events for changes of parent widget and changes of property values.
+    # Set instance variables for the Shoes styles of this drawable. Bind Shoes
+    # events for changes of parent drawable and changes of property values.
     def initialize(properties)
-      log_init("WASM::Widget")
+      log_init("Wasm::Drawable")
+
+      @shoes_style_names = properties.keys.map(&:to_s) - ["shoes_linkable_id"]
 
       # Call method, which looks up the parent
       @shoes_linkable_id = properties["shoes_linkable_id"] || properties[:shoes_linkable_id]
       unless @shoes_linkable_id
-        raise "Could not find property shoes_linkable_id in #{properties.inspect}!"
+        raise Scarpe::MissingAttributeError, "Could not find property shoes_linkable_id in #{properties.inspect}!"
       end
 
-      # Set the display properties
+      # Set the Shoes styles as instance variables
       properties.each do |k, v|
         next if k == "shoes_linkable_id"
 
         instance_variable_set("@" + k.to_s, v)
       end
 
-      # The parent field is *almost* simple enough that a typed display property would handle it.
+      # Must call this before bind
+      super(linkable_id: @shoes_linkable_id)
+
       bind_shoes_event(event_name: "parent", target: shoes_linkable_id) do |new_parent_id|
-        display_parent = WASMDisplayService.instance.query_display_widget_for(new_parent_id)
+        display_parent = DisplayService.instance.query_display_drawable_for(new_parent_id)
         if @parent != display_parent
           set_parent(display_parent)
         end
       end
 
-      # When Shoes widgets change properties, we get a change notification here
+      # When Shoes drawables change properties, we get a change notification here
       bind_shoes_event(event_name: "prop_change", target: shoes_linkable_id) do |prop_changes|
         prop_changes.each do |k, v|
           instance_variable_set("@" + k, v)
@@ -71,19 +75,25 @@ def initialize(properties)
       bind_shoes_event(event_name: "destroy", target: shoes_linkable_id) do
         destroy_self
       end
+    end
 
-      super(linkable_id: @shoes_linkable_id)
+    def shoes_styles
+      p = {}
+      @shoes_style_names.each do |prop_name|
+        p[prop_name] = instance_variable_get("@#{prop_name}")
+      end
+      p
     end
 
     # Properties_changed will be called automatically when properties change.
-    # The widget should delete any changes from the Hash that it knows how
+    # The drawable should delete any changes from the Hash that it knows how
     # to incrementally handle, and pass the rest to super. If any changes
     # go entirely un-handled, a full redraw will be scheduled.
     # This exists to be overridden by children watching for changes.
     #
     # @param changes [Hash] a Hash of new values for properties that have changed
     def properties_changed(changes)
-      # If a widget does something really nonstandard with its html_id or element, it will
+      # If a drawable does something really nonstandard with its html_id or element, it will
       # need to override to prevent this from happening. That's easy enough, though.
       if changes.key?("hidden")
         hidden = changes.delete("hidden")
@@ -99,7 +109,7 @@ def properties_changed(changes)
       needs_update! unless changes.empty?
     end
 
-    # Give this widget a new parent, including managing the appropriate child lists for parent widgets.
+    # Give this drawable a new parent, including managing the appropriate child lists for parent drawables.
     def set_parent(new_parent)
       @parent&.remove_child(self)
       new_parent&.add_child(self)
@@ -108,7 +118,7 @@ def set_parent(new_parent)
 
     # A shorter inspect text for prettier irb output
     def inspect
-      "#<#{self.class}:#{self.object_id} @shoes_linkable_id=#{@shoes_linkable_id} @parent=#{@parent.inspect} @children=#{@children.inspect}>"
+      "#<#{self.class}:#{self.object_id} @shoes_linkable_id=#{@shoes_linkable_id} @children=#{@children.inspect}>"
     end
 
     protected
@@ -169,8 +179,8 @@ def style
 
     public
 
-    # This gets a mini-WASM for just this element and its children, if any.
-    # It is normally called by the widget itself to do its DOM management.
+    # This gets an updater for just this element and its children, if any.
+    # It is normally called by the drawable itself to do its DOM management.
     #
     # @return [Scarpe::WebWrangler::ElementWrangler] a DOM object manager
     def html_element
@@ -192,7 +202,7 @@ def html_id
     end
 
     # to_html is intended to get the HTML DOM rendering of this object and its children.
-    # Calling it should be side-effect-free and NOT update the WASM.
+    # Calling it should be side-effect-free and NOT update the DOM.
     #
     # @return [String] the rendered HTML
     def to_html
@@ -207,24 +217,26 @@ def to_html
 
     # This binds a Scarpe JS callback, handled via a single dispatch point in the app
     #
-    # @param event [String] the Scarpe widget event name
+    # @param event [String] the Scarpe drawable event name
     # @yield the block to call when the event occurs
     def bind(event, &block)
-      raise("Widget has no linkable_id! #{inspect}") unless linkable_id
+      raise(Scarpe::MissingAttributeError, "Drawable has no linkable_id! #{inspect}") unless linkable_id
 
-      WASMDisplayService.instance.app.bind("#{linkable_id}-#{event}", &block)
+      DisplayService.instance.app.bind("#{linkable_id}-#{event}", &block)
     end
 
-    # Removes the element from both the Ruby Widget tree and the HTML DOM.
+    # Removes the element from both the Ruby Drawable tree and the HTML DOM.
+    # Unsubscribe from all Shoes events.
     # Return a promise for when that HTML change will be visible.
     #
     # @return [Scarpe::Promise] a promise that is fulfilled when the HTML change is complete
     def destroy_self
       @parent&.remove_child(self)
+      unsub_all_shoes_events
       html_element.remove
     end
 
-    # Request a full redraw of all widgets.
+    # Request a full redraw of all drawables.
     #
     # It's really hard to do dirty-tracking here because the redraws are fully asynchronous.
     # And so we can't easily cancel one "in flight," and we can't easily pick up the latest
@@ -232,16 +244,16 @@ def destroy_self
     #
     # @return [void]
     def needs_update!
-      WASMDisplayService.instance.app.request_redraw!
+      DisplayService.instance.app.request_redraw!
     end
 
-    # Generate JS code to trigger a specific event name on this widget with the supplies arguments.
+    # Generate JS code to trigger a specific event name on this drawable with the supplies arguments.
     #
     # @param handler_function_name [String] the event name - @see #bind
     # @param args [Array] additional arguments that will be passed to the event in the generated JS
     # @return [String] the generated JS code
     def handler_js_code(handler_function_name, *args)
-      raise("Widget has no linkable_id! #{inspect}") unless linkable_id
+      raise(Scarpe::MissingAttributeError, "Drawable has no linkable_id! #{inspect}") unless linkable_id
 
       js_args = ["'#{linkable_id}-#{handler_function_name}'", *args].join(", ")
       "scarpeHandler(#{js_args})"
diff --git a/lib/scarpe/wasm/edit_box.rb b/lib/scarpe/wasm/edit_box.rb
index 5f30ef4..7e6cf49 100644
--- a/lib/scarpe/wasm/edit_box.rb
+++ b/lib/scarpe/wasm/edit_box.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMEditBox < Scarpe::WASMWidget
+module Scarpe::Wasm
+  class EditBox < Drawable
     attr_reader :text, :height, :width
 
     def initialize(properties)
       super
 
-      # The JS handler sends a "change" event, which we forward to the Shoes widget tree
+      # The JS handler sends a "change" event, which we forward to the Shoes drawable tree
       bind("change") do |new_text|
         send_self_event(new_text, event_name: "change")
       end
@@ -23,20 +23,7 @@ def properties_changed(changes)
     end
 
     def element
-      oninput = handler_js_code("change", "this.value")
-
-      HTML.render do |h|
-        h.textarea(id: html_id, oninput: oninput, style: style) { text }
-      end
-    end
-
-    protected
-
-    def style
-      super.merge({
-        height: Dimensions.length(height),
-        width: Dimensions.length(width),
-      }.compact)
+      render("edit_box")
     end
   end
 end
diff --git a/lib/scarpe/wasm/edit_line.rb b/lib/scarpe/wasm/edit_line.rb
index f326a80..f5c46a3 100644
--- a/lib/scarpe/wasm/edit_line.rb
+++ b/lib/scarpe/wasm/edit_line.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMEditLine < WASMWidget
+module Scarpe::Wasm
+  class EditLine < Drawable
     attr_reader :text, :width
 
     def initialize(properties)
       super
 
-      # The JS handler sends a "change" event, which we forward to the Shoes widget tree
+      # The JS handler sends a "change" event, which we forward to the Shoes drawable tree
       bind("change") do |new_text|
         send_self_event(new_text, event_name: "change")
       end
@@ -23,21 +23,7 @@ def properties_changed(changes)
     end
 
     def element
-      oninput = handler_js_code("change", "this.value")
-
-      HTML.render do |h|
-        h.input(id: html_id, oninput: oninput, value: @text, style: style)
-      end
-    end
-
-    protected
-
-    def style
-      styles = super
-
-      styles[:width] = Dimensions.length(@width) if @width
-
-      styles
+      render("edit_line")
     end
   end
 end
diff --git a/lib/scarpe/wasm/errors.rb b/lib/scarpe/wasm/errors.rb
new file mode 100644
index 0000000..bc4397a
--- /dev/null
+++ b/lib/scarpe/wasm/errors.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+module Scarpe
+  class UnknownShoesEventAPIError < Scarpe::Error; end
+
+  class UnknownShapeCommandError < Scarpe::Error; end
+
+  class UnknownBuiltinCommandError < Scarpe::Error; end
+
+  class UnknownEventTypeError < Scarpe::Error; end
+
+  class UnexpectedFiberTransferError < Scarpe::Error; end
+
+  class MultipleDrawablesFoundError < Scarpe::Error; end
+
+  class NoDrawablesFoundError < Scarpe::Error; end
+
+  class InvalidPromiseError < Scarpe::Error; end
+
+  class MissingAppError < Scarpe::Error; end
+
+  class MissingDocRootError < Scarpe::Error; end
+
+  class MissingWranglerError < Scarpe::Error; end
+
+  class IllegalSubscribeEventError < Scarpe::Error; end
+
+  class IllegalDispatchEventError < Scarpe::Error; end
+
+  class MissingBlockError < Scarpe::Error; end
+
+  class DuplicateCallbackError < Scarpe::Error; end
+
+  class JSBindingError < Scarpe::Error; end
+
+  class JSInitError < Scarpe::Error; end
+
+  class PeriodicHandlerSetupError < Scarpe::Error; end
+
+  class WebWranglerNotRunningError < Scarpe::Error; end
+
+  class NonexistentEvalResultError < Scarpe::Error; end
+
+  class JSRedrawError < Scarpe::Error; end
+
+  class SingletonError < Scarpe::Error; end
+
+  class ConnectionError < Scarpe::Error; end
+
+  class DatagramSendError < Scarpe::Error; end
+
+  class InvalidOperationError < Scarpe::Error; end
+
+  class MissingAttributeError < Scarpe::Error; end
+
+  # This error indicates a problem when running ConfirmedEval
+  class JSEvalError < Scarpe::Error
+    def initialize(data)
+      @data = data
+      super(data[:msg] || (self.class.name + "!"))
+    end
+  end
+
+  # An error running the supplied JS code string in confirmed_eval
+  class JSRuntimeError < JSEvalError; end
+
+  # The code timed out for some reason
+  class JSTimeoutError < JSEvalError; end
+
+  # We got weird or nonsensical results that seem like an error on WebWrangler's part
+  class JSInternalError < JSEvalError; end
+
+  # An error occurred which would normally be handled by shutting down the app
+  class AppShutdownError < Scarpe::Error; end
+
+  class InvalidClassError < Scarpe::Error; end
+
+  class MissingClassError < Scarpe::Error; end
+
+  class MultipleShoesSpecRunsError < Scarpe::Error; end
+
+  class EmptyPageNotSetError < Scarpe::Error; end
+
+  class BadDisplayClassType < Scarpe::Error; end
+end
diff --git a/lib/scarpe/wasm/flow.rb b/lib/scarpe/wasm/flow.rb
index 57bc4d9..03c52de 100644
--- a/lib/scarpe/wasm/flow.rb
+++ b/lib/scarpe/wasm/flow.rb
@@ -1,22 +1,6 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMFlow < Scarpe::WASMSlot
-    def initialize(properties)
-      super
-    end
-
-    protected
-
-    def style
-      {
-        display: "flex",
-        "flex-direction": "row",
-        "flex-wrap": "wrap",
-        "align-content": "flex-start",
-        "justify-content": "flex-start",
-        "align-items": "flex-start",
-      }.merge(super)
-    end
+module Scarpe::Wasm
+  class Flow < Slot
   end
 end
diff --git a/lib/scarpe/wasm/font.rb b/lib/scarpe/wasm/font.rb
deleted file mode 100644
index 34d99c8..0000000
--- a/lib/scarpe/wasm/font.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-require "scarpe/components/base64"
-
-class Scarpe
-  class WASMFont < WASMWidget
-    include Components::Base64
-    attr_accessor :font
-
-    def initialize(properties)
-      @font = properties[:font]
-      super
-    end
-
-    def font_name
-      File.basename(@font, ".*")
-    end
-
-    def element
-      HTML.render do |h|
-        h.link(href: @font, rel: "stylesheet")
-        h.style do
-          <<~CSS
-            @font-face {
-              font-family: #{font_name};
-              src: url("data:font/truetype;base64,#{encode_file_to_base64(@font)}") format('truetype');
-            }
-            * {
-              font-family: #{font_name};
-            }
-          CSS
-        end
-      end
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/html.rb b/lib/scarpe/wasm/html.rb
deleted file mode 100644
index 7ea4131..0000000
--- a/lib/scarpe/wasm/html.rb
+++ /dev/null
@@ -1,108 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class HTML
-    CONTENT_TAGS = [:div, :p, :button, :ul, :li, :textarea, :a, :video, :strong, :style, :em, :code, :u, :line, :span, :svg].freeze
-    VOID_TAGS = [:input, :img, :polygon, :source, :link, :path].freeze
-
-    TAGS = (CONTENT_TAGS + VOID_TAGS).freeze
-
-    class << self
-      def render(&block)
-        new(&block).value
-      end
-    end
-
-    def initialize(&block)
-      @buffer = ""
-      block.call(self)
-    end
-
-    def value
-      @buffer
-    end
-
-    def respond_to_missing?(name, include_all = false)
-      TAGS.include?(name) || super(name, include_all)
-    end
-
-    def p(*args, &block)
-      method_missing(:p, *args, &block)
-    end
-
-    def option(**attrs, &block)
-      tag(:option, **attrs, &block)
-    end
-
-    def tag(name, **attrs, &block)
-      if VOID_TAGS.include?(name)
-        raise ArgumentError, "void tag #{name} cannot have content" if block_given?
-
-        @buffer += "<#{name}#{render_attributes(attrs)} />"
-      else
-        @buffer += "<#{name}#{render_attributes(attrs)}>"
-
-        if block_given?
-          result = block.call(self)
-        else
-          result = attrs[:content]
-          @buffer += result if result.is_a?(String)
-        end
-        @buffer += result if result.is_a?(String)
-
-        @buffer += "</#{name}>"
-      end
-
-      nil
-    end
-
-    def select(**attrs, &block)
-      tag(:select, **attrs, &block)
-    end
-
-    def method_missing(name, *args, &block)
-      raise NoMethodError, "no method #{name} for #{self.class.name}" unless TAGS.include?(name)
-
-      if VOID_TAGS.include?(name)
-        raise ArgumentError, "void tag #{name} cannot have content" if block_given?
-
-        @buffer += "<#{name}#{render_attributes(*args)} />"
-      else
-        @buffer += "<#{name}#{render_attributes(*args)}>"
-
-        if block_given?
-          result = block.call(self)
-        else
-          result = args.first
-          @buffer += result if result.is_a?(String)
-        end
-        @buffer += result if result.is_a?(String)
-
-        @buffer += "</#{name}>"
-      end
-
-      nil
-    end
-
-    private
-
-    def render_attributes(attributes = {})
-      return "" if attributes.empty?
-
-      attributes[:style] = render_style(attributes[:style]) if attributes[:style]
-      attributes.compact!
-
-      return if attributes.empty?
-
-      result = attributes.map { |k, v| "#{k}=\"#{v}\"" }.join(" ")
-      " #{result}"
-    end
-
-    def render_style(style)
-      return style unless style.is_a?(Hash)
-      return if style.empty?
-
-      style.map { |k, v| "#{k}:#{v}" }.join(";")
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/image.rb b/lib/scarpe/wasm/image.rb
index dbfa1d5..99234fd 100644
--- a/lib/scarpe/wasm/image.rb
+++ b/lib/scarpe/wasm/image.rb
@@ -2,40 +2,20 @@
 
 require "scarpe/components/base64"
 
-class Scarpe
-  class WASMImage < WASMWidget
-    include Components::Base64
+module Scarpe::Wasm
+  class Image < Drawable
+    include Scarpe::Components::Base64
+
     def initialize(properties)
       super
 
-      @url = valid_url?(@url) ? @url : "data:image/png;base64,#{encode_file_to_base64(@url)}"
-    end
-
-    def element
-      if @click
-        HTML.render do |h|
-          h.a(id: html_id, href: @click) { h.img(id: html_id, src: @url, style:) }
-        end
-      else
-        HTML.render do |h|
-          h.img(id: html_id, src: @url, style:)
-        end
+      unless valid_url?(@url)
+        @url = "data:image/png;base64,#{encode_file_to_base64(@url)}"
       end
     end
 
-    protected
-
-    def style
-      styles = super
-
-      styles[:width] = Dimensions.length(@width) if @width
-      styles[:height] = Dimensions.length(@height) if @height
-
-      styles[:top] = Dimensions.length(@top) if @top
-      styles[:left] = Dimensions.length(@left) if @left
-      styles[:position] = "absolute" if @top || @left
-
-      styles
+    def element
+      render("image")
     end
   end
 end
diff --git a/lib/scarpe/wasm/line.rb b/lib/scarpe/wasm/line.rb
deleted file mode 100644
index 7967908..0000000
--- a/lib/scarpe/wasm/line.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class WASMLine < Scarpe::WASMWidget
-    def initialize(properties)
-      super(properties)
-    end
-
-    def element
-      HTML.render do |h|
-        h.div(id: html_id, style: style) do
-          h.svg(width: @x2, height: @y2) do
-            h.line(x1: @left, y1: @top, x2: @x2, y2: @y2, style: line_style)
-          end
-        end
-      end
-    end
-
-    protected
-
-    def style
-      super.merge({
-        left: "#{@left}px",
-        top: "#{@top}px",
-      })
-    end
-
-    def line_style
-      {
-        stroke: @draw_context["stroke"],
-        "stroke-width": "4",
-      }
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/link.rb b/lib/scarpe/wasm/link.rb
index 7e9cd00..435421d 100644
--- a/lib/scarpe/wasm/link.rb
+++ b/lib/scarpe/wasm/link.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMLink < WASMWidget
+module Scarpe::Wasm
+  class Link < Drawable
     def initialize(properties)
       super
 
@@ -11,20 +11,7 @@ def initialize(properties)
     end
 
     def element
-      HTML.render do |h|
-        h.a(**attributes) do
-          @text
-        end
-      end
-    end
-
-    def attributes
-      {
-        id: html_id,
-        href: @click,
-        onclick: (handler_js_code("click") if @has_block),
-        style: style,
-      }.compact
+      render "link"
     end
   end
 end
diff --git a/lib/scarpe/wasm/list_box.rb b/lib/scarpe/wasm/list_box.rb
index 8a1ee68..89495d5 100644
--- a/lib/scarpe/wasm/list_box.rb
+++ b/lib/scarpe/wasm/list_box.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMListBox < Scarpe::WASMWidget
-    attr_reader :selected_item, :items, :height, :width
+module Scarpe::Wasm
+  class ListBox < Drawable
+    attr_reader :selected_item, :items, :height, :width, :choose
 
     def initialize(properties)
-      super(properties)
+      super
 
-      # The JS handler sends a "change" event, which we forward to the Shoes widget tree
+      # The JS handler sends a "change" event, which we forward to the Shoes drawable tree
       bind("change") do |new_item|
         send_self_event(new_item, event_name: "change")
       end
diff --git a/lib/scarpe/wasm/para.rb b/lib/scarpe/wasm/para.rb
index d4550ab..3f40d03 100644
--- a/lib/scarpe/wasm/para.rb
+++ b/lib/scarpe/wasm/para.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMPara < WASMWidget
+module Scarpe::Wasm
+  class Para < Drawable
     SIZES = {
       inscription: 10,
       ins: 10,
@@ -14,10 +14,6 @@ class WASMPara < WASMWidget
     }.freeze
     private_constant :SIZES
 
-    def initialize(properties)
-      super
-    end
-
     def properties_changed(changes)
       items = changes.delete("text_items")
       if items
@@ -40,15 +36,13 @@ def items_to_display_children(items)
         if item.is_a?(String)
           item
         else
-          WASMDisplayService.instance.query_display_widget_for(item)
+          DisplayService.instance.query_display_drawable_for(item)
         end
       end
     end
 
     def element(&block)
-      HTML.render do |h|
-        h.p(**options, &block)
-      end
+      render("para", &block)
     end
 
     def to_html
@@ -57,22 +51,6 @@ def to_html
       element { child_markup }
     end
 
-    protected
-
-    def style
-      super.merge({
-        color: rgb_to_hex(@stroke),
-        "font-size": font_size,
-        "font-family": @font,
-      }.compact)
-    end
-
-    def font_size
-      font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size
-
-      Dimensions.length(font_size)
-    end
-
     private
 
     def child_markup
@@ -84,9 +62,5 @@ def child_markup
         end
       end.join
     end
-
-    def options
-      @html_attributes.merge(id: html_id, style: style)
-    end
   end
 end
diff --git a/lib/scarpe/wasm/radio.rb b/lib/scarpe/wasm/radio.rb
index eba4826..ccd4317 100644
--- a/lib/scarpe/wasm/radio.rb
+++ b/lib/scarpe/wasm/radio.rb
@@ -1,14 +1,14 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMRadio < Scarpe::WASMWidget
+module Scarpe::Wasm
+  class Radio < Drawable
     attr_reader :text
 
     def initialize(properties)
       super
 
       bind("click") do
-        send_self_event(event_name: "click", target: shoes_linkable_id)
+        send_self_event(event_name: "click")
       end
     end
 
@@ -20,15 +20,13 @@ def properties_changed(changes)
     end
 
     def element
-      HTML.render do |h|
-        h.input(type: :radio, id: html_id, onclick: handler_js_code("click"), name: group_name, value: "hmm #{text}", checked: @checked, style: style)
-      end
-    end
+      props = shoes_styles
 
-    private
-
-    def group_name
-      @group || @parent
+      # If a group isn't set, default to the linkable ID of the parent slot
+      unless @group
+        props["group"] = @parent ? @parent.shoes_linkable_id : "no_group"
+      end
+      render("radio", props)
     end
   end
 end
diff --git a/lib/scarpe/wasm/shape.rb b/lib/scarpe/wasm/shape.rb
index ee76e96..4ab48ea 100644
--- a/lib/scarpe/wasm/shape.rb
+++ b/lib/scarpe/wasm/shape.rb
@@ -1,12 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  # Should inherit from Slot?
-  class WASMShape < Scarpe::WASMWidget
-    def initialize(properties)
-      super(properties)
-    end
-
+module Scarpe::Wasm
+  class Shape < Drawable
     def to_html
       @children ||= []
       child_markup = @children.map(&:to_html).join
@@ -51,7 +46,7 @@ def path_from_shape_commands
           x, y = *args
           current_path += "L #{x} #{y} "
         else
-          raise "Unknown shape command! #{cmd.inspect}"
+          raise Scarpe::UnknownShapeCommandError, "Unknown shape command! #{cmd.inspect}"
         end
       end
 
diff --git a/lib/scarpe/wasm/slot.rb b/lib/scarpe/wasm/slot.rb
index cf57aed..1e00472 100644
--- a/lib/scarpe/wasm/slot.rb
+++ b/lib/scarpe/wasm/slot.rb
@@ -1,11 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMSlot < Scarpe::WASMWidget
-    include Scarpe::WASMBackground
-    include Scarpe::WASMBorder
-    include Scarpe::WASMSpacing
-
+module Scarpe::Wasm
+  class Slot < Drawable
     def initialize(properties)
       @event_callbacks = {}
 
@@ -13,16 +9,16 @@ def initialize(properties)
     end
 
     def element(&block)
-      HTML.render do |h|
-        h.div(attributes.merge(id: html_id, style: style), &block)
-      end
+      props = shoes_styles.merge("html_attributes" => html_attributes)
+      render_name = self.class.name.split("::")[-1].downcase # usually "stack" or "flow" or "documentroot"
+      render(render_name, props, &block)
     end
 
     def set_event_callback(obj, event_name, js_code)
       event_name = event_name.to_s
       @event_callbacks[event_name] ||= {}
       if @event_callbacks[event_name][obj]
-        raise "Can't have two callbacks on the same event, from the same object, on the same parent!"
+        raise Scarpe::DuplicateCallbackError, "Can't have two callbacks on the same event, from the same object, on the same parent!"
       end
 
       @event_callbacks[event_name][obj] = js_code
@@ -54,7 +50,8 @@ def update_dom_event(event_name)
       html_element.set_attribute(event_name, @event_callbacks[event_name].values.join(";"))
     end
 
-    def attributes
+    # These get added for event handlers and passed to Calzini
+    def html_attributes
       attr = {}
 
       @event_callbacks.each do |event_name, handlers|
@@ -63,19 +60,5 @@ def attributes
 
       attr
     end
-
-    def style
-      styles = super
-
-      styles[:"margin-top"] = @margin_top if @margin_top
-      styles[:"margin-bottom"] = @margin_bottom if @margin_bottom
-      styles[:"margin-left"] = @margin_left if @margin_left
-      styles[:"margin-right"] = @margin_right if @margin_right
-
-      styles[:width] = Dimensions.length(@width) if @width
-      styles[:height] = Dimensions.length(@height) if @height
-
-      styles
-    end
   end
 end
diff --git a/lib/scarpe/wasm/spacing.rb b/lib/scarpe/wasm/spacing.rb
deleted file mode 100644
index 4e36aba..0000000
--- a/lib/scarpe/wasm/spacing.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  module WASMSpacing
-    SPACING_DIRECTIONS = [:left, :right, :top, :bottom]
-
-    def style
-      styles = defined?(super) ? super : {}
-
-      extract_spacing_styles_for(:margin, styles, @margin)
-      extract_spacing_styles_for(:padding, styles, @padding)
-
-      styles
-    end
-
-    def extract_spacing_styles_for(attribute, styles, values)
-      values ||= spacing_values_from_options(attribute)
-
-      case values
-      when Hash
-        values.each do |direction, value|
-          styles["#{attribute}-#{direction}"] = Dimensions.length(value)
-        end
-      when Array
-        SPACING_DIRECTIONS.zip(values).to_h.compact.each do |direction, value|
-          styles["#{attribute}-#{direction}"] = Dimensions.length(value)
-        end
-      else
-        styles[attribute] = Dimensions.length(values)
-      end
-
-      styles.compact!
-    end
-
-    def spacing_values_from_options(attribute)
-      SPACING_DIRECTIONS.map do |direction|
-        @options.delete("#{attribute}_#{direction}".to_sym)
-      end
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/span.rb b/lib/scarpe/wasm/span.rb
index 4ab42f6..14c0f75 100644
--- a/lib/scarpe/wasm/span.rb
+++ b/lib/scarpe/wasm/span.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMSpan < Scarpe::WASMWidget
+module Scarpe::Wasm
+  class Span < Drawable
     SIZES = {
       inscription: 10,
       ins: 10,
@@ -34,35 +34,11 @@ def properties_changed(changes)
     end
 
     def element(&block)
-      HTML.render do |h|
-        h.span(**options, &block)
-      end
+      render("span", &block)
     end
 
     def to_html
       element { @text }
     end
-
-    protected
-
-    def style
-      {
-        color: @stroke,
-        "font-size": font_size,
-        "font-family": @font,
-      }.compact
-    end
-
-    private
-
-    def options
-      @html_attributes.merge(id: html_id, style: style)
-    end
-
-    def font_size
-      font_size = @size.is_a?(Symbol) ? SIZES[@size] : @size
-
-      Dimensions.length(font_size)
-    end
   end
 end
diff --git a/lib/scarpe/wasm/stack.rb b/lib/scarpe/wasm/stack.rb
index 1b9f067..0e0d4ab 100644
--- a/lib/scarpe/wasm/stack.rb
+++ b/lib/scarpe/wasm/stack.rb
@@ -1,22 +1,6 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMStack < Scarpe::WASMSlot
-    def get_style
-      style
-    end
-
-    protected
-
-    def style
-      {
-        display: "flex",
-        "flex-direction": "column",
-        "align-content": "flex-start",
-        "justify-content": "flex-start",
-        "align-items": "flex-start",
-        overflow: @scroll ? "auto" : nil,
-      }.compact.merge(super)
-    end
+module Scarpe::Wasm
+  class Stack < Slot
   end
 end
diff --git a/lib/scarpe/wasm/star.rb b/lib/scarpe/wasm/star.rb
deleted file mode 100644
index 564656f..0000000
--- a/lib/scarpe/wasm/star.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class WASMStar < Scarpe::WASMWidget
-    def initialize(properties)
-      super(properties)
-    end
-
-    def element(&block)
-      fill = @draw_context["fill"]
-      stroke = @draw_context["stroke"]
-      fill = "black" if fill == ""
-      stroke = "black" if stroke == ""
-      HTML.render do |h|
-        h.div(id: html_id, style: style) do
-          h.svg(width: @outer, height: @outer, style: "fill:#{fill};") do
-            h.polygon(points: star_points, style: "stroke:#{stroke};stroke-width:2")
-          end
-          block.call(h) if block_given?
-        end
-      end
-    end
-
-    protected
-
-    def style
-      super.merge({
-        width: Dimensions.length(@width),
-        height: Dimensions.length(@height),
-      })
-    end
-
-    private
-
-    def star_points
-      get_star_points.join(",")
-    end
-
-    def get_star_points
-      angle = 2 * Math::PI / @points
-      coordinates = []
-
-      @points.times do |i|
-        outer_angle = i * angle
-        inner_angle = outer_angle + angle / 2
-
-        coordinates.concat(get_coordinates(outer_angle, inner_angle))
-      end
-
-      coordinates
-    end
-
-    def get_coordinates(outer_angle, inner_angle)
-      outer_x = @outer / 2 + Math.cos(outer_angle) * @outer / 2
-      outer_y = @outer / 2 + Math.sin(outer_angle) * @outer / 2
-
-      inner_x = @outer / 2 + Math.cos(inner_angle) * @inner / 2
-      inner_y = @outer / 2 + Math.sin(inner_angle) * @inner / 2
-
-      [outer_x, outer_y, inner_x, inner_y]
-    end
-  end
-end
diff --git a/lib/scarpe/wasm/subscription_item.rb b/lib/scarpe/wasm/subscription_item.rb
index e9c8f59..c59f2e0 100644
--- a/lib/scarpe/wasm/subscription_item.rb
+++ b/lib/scarpe/wasm/subscription_item.rb
@@ -1,12 +1,39 @@
 # frozen_string_literal: true
 
-class Scarpe::WASMSubscriptionItem < Scarpe::WASMWidget
+class Scarpe::Wasm::SubscriptionItem < Scarpe::Wasm::Drawable
+
   def initialize(properties)
     super
 
     bind(@shoes_api_name) do |*args|
       send_self_event(*args, event_name: @shoes_api_name)
     end
+
+    @wrangler = Scarpe::Wasm::DisplayService.instance.wrangler
+
+    case @shoes_api_name
+    when "animate"
+      frame_rate = (@args[0] || 10)
+      @counter = 0
+      @wrangler.periodic_code("animate_#{@shoes_linkable_id}", 1.0 / frame_rate) do
+        @counter += 1
+        send_self_event(@counter, event_name: @shoes_api_name)
+      end
+    when "every"
+      delay = @args[0]
+      @counter = 0
+      @wrangler.periodic_code("every_#{@shoes_linkable_id}", delay) do
+        @counter += 1
+        send_self_event(@counter, event_name: @shoes_api_name)
+      end
+    when "timer"
+      # JS setTimeout?
+      raise "Implement me!"
+    when "motion", "hover", "leave", "click", "release", "keypress"
+      # Wait for set_parent
+    else
+      raise Scarpe::UnknownShoesEventAPIError, "Unknown Shoes event API: #{@shoes_api_name}!"
+    end
   end
 
   def element
@@ -21,7 +48,7 @@ def set_parent(new_parent)
     case @shoes_api_name
     when "motion"
       # TODO: what do we do for whole-screen mousemove outside the window?
-      # Those should be set on body, which right now doesn't have a widget.
+      # Those should be set on body, which right now doesn't have a drawable.
       # TODO: figure out how to handle alt and meta keys - does Shoes3 recognise those?
       new_parent.set_event_callback(
         self,
@@ -36,15 +63,23 @@ def set_parent(new_parent)
       )
     when "hover"
       new_parent.set_event_callback(self, "onmouseenter", handler_js_code(@shoes_api_name))
+    when "leave"
+      new_parent.set_event_callback(self, "onmouseleave", handler_js_code(@shoes_api_name))
     when "click"
       new_parent.set_event_callback(self, "onclick", handler_js_code(@shoes_api_name, "arguments[0].button", "arguments[0].x", "arguments[0].y"))
+    when "release"
+      new_parent.set_event_callback(self, "onmouseup", handler_js_code(@shoes_api_name, "arguments[0].button", "arguments[0].x", "arguments[0].y"))
+    when "keypress"
+      raise "Implement me!"
+    when "animate", "every", "timer"
+      # These were handled in initialize(), ignore them here
     else
-      raise "Unknown Shoes event API: #{@shoes_api_name}!"
+      raise Scarpe::UnknownShoesEventAPIError, "Unknown Shoes event API: #{@shoes_api_name}!"
     end
   end
 
   def destroy_self
-    @parent.remove_event_callbacks(self)
+    @parent&.remove_event_callbacks(self)
     super
   end
 end
diff --git a/lib/scarpe/wasm/text_drawable.rb b/lib/scarpe/wasm/text_drawable.rb
new file mode 100644
index 0000000..9712c4a
--- /dev/null
+++ b/lib/scarpe/wasm/text_drawable.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Scarpe::Wasm
+  class TextDrawable < Drawable
+  end
+
+  class << self
+    def default_wasm_text_drawable_with(element)
+      wasm_class_name = element.capitalize
+      wasm_drawable_class = Class.new(Scarpe::Wasm::TextDrawable) do
+        def initialize(properties)
+          class_name = self.class.name.split("::")[-1]
+          @html_tag = class_name.delete_prefix("Wasm").downcase
+          super
+        end
+
+        def element
+          render(@html_tag) { @content.to_s }
+        end
+      end
+      Scarpe::Wasm.const_set wasm_class_name, wasm_drawable_class
+    end
+  end
+end
+
+Scarpe::Wasm.default_wasm_text_drawable_with(:code)
+Scarpe::Wasm.default_wasm_text_drawable_with(:em)
+Scarpe::Wasm.default_wasm_text_drawable_with(:strong)
diff --git a/lib/scarpe/wasm/text_widget.rb b/lib/scarpe/wasm/text_widget.rb
deleted file mode 100644
index 2754aed..0000000
--- a/lib/scarpe/wasm/text_widget.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-class Scarpe
-  class WASMTextWidget < Scarpe::WASMWidget
-  end
-
-  class << self
-    def default_wasm_text_widget_with(element)
-      wasm_class_name = "WASM#{element.capitalize}"
-      wasm_widget_class = Class.new(Scarpe::WASMTextWidget) do
-        def initialize(properties)
-          class_name = self.class.name.split("::")[-1]
-          @html_tag = class_name.delete_prefix("WASM").downcase
-          super
-        end
-
-        def element
-          HTML.render do |h|
-            h.send(@html_tag) { @content.to_s }
-          end
-        end
-      end
-      Scarpe.const_set wasm_class_name, wasm_widget_class
-    end
-  end
-end
-
-Scarpe.default_wasm_text_widget_with(:code)
-Scarpe.default_wasm_text_widget_with(:em)
-Scarpe.default_wasm_text_widget_with(:strong)
diff --git a/lib/scarpe/wasm/video.rb b/lib/scarpe/wasm/video.rb
index 0d0fec8..f3924d2 100644
--- a/lib/scarpe/wasm/video.rb
+++ b/lib/scarpe/wasm/video.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
-class Scarpe
-  class WASMVideo < Scarpe::WASMWidget
+module Scarpe::Wasm
+  class Video < Drawable
     SUPPORTED_FORMATS = {
       "video/mp4" => [".mp4"],
       "video/webp" => [".webp"],
@@ -9,26 +9,25 @@ class WASMVideo < Scarpe::WASMWidget
       "video/x-matroska" => [".mkv"],
       # Add more formats and their associated file extensions if needed
     }.freeze
+    FORMAT_FOR_EXT = {}
+    SUPPORTED_FORMATS.each do |format, extensions|
+      extensions.each do |ext|
+        if FORMAT_FOR_EXT.key?(ext)
+          raise "Internal error! Must have a specific format for each extension!"
+        end
+        FORMAT_FOR_EXT[ext] = format
+      end
+    end
+    FORMAT_FOR_EXT.freeze
 
     def initialize(properties)
       @url = properties[:url]
       super
+      @format = FORMAT_FOR_EXT[File.extname(@url)]
     end
 
     def element
-      HTML.render do |h|
-        h.video(id: html_id, style: style, controls: true) do
-          supported_formats.each do |format|
-            h.source(src: @url, type: format)
-          end
-        end
-      end
-    end
-
-    private
-
-    def supported_formats
-      SUPPORTED_FORMATS.select { |_format, extensions| extensions.include?(File.extname(@url)) }.keys
+      render "video", shoes_styles.merge("format" => @format)
     end
   end
 end
diff --git a/lib/scarpe/wasm/wasm_calls.rb b/lib/scarpe/wasm/wasm_calls.rb
index cd1296d..b386ece 100644
--- a/lib/scarpe/wasm/wasm_calls.rb
+++ b/lib/scarpe/wasm/wasm_calls.rb
@@ -2,12 +2,12 @@
 
 require "js"
 
-class Scarpe
-  class WASMInterops
+module Scarpe
+  class WasmInterops
     include Shoes::Log
 
     def initialize
-      log_init("WASM")
+      log_init("Wasm")
     end
 
     def js_integer?(num)
diff --git a/lib/scarpe/wasm/wasm_local_display.rb b/lib/scarpe/wasm/wasm_local_display.rb
index 721775c..6c357d0 100644
--- a/lib/scarpe/wasm/wasm_local_display.rb
+++ b/lib/scarpe/wasm/wasm_local_display.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
-class Scarpe
-  # This is the simplest type of WASM DisplayService. It creates WASM widgets
-  # corresponding to Shoes widgets, manages the WASM and its DOM tree, and
+module Scarpe::Wasm
+  # This is the simplest type of WASM DisplayService. It creates WASM drawables
+  # corresponding to Shoes drawables, manages the WASM and its DOM tree, and
   # generally keeps the Shoes/WASM connection working.
   #
   # This is an in-process WASM-based display service, with all the limitations that
@@ -10,77 +10,91 @@ class Scarpe
   # process, too many or too large evals can crash the process, etc.
   # Frequently it's better to use a RelayDisplayService to a second
   # process containing one of these.
-  class WASMDisplayService < Shoes::DisplayService
+  class DisplayService < Shoes::DisplayService
     include Shoes::Log
 
     class << self
       attr_accessor :instance
     end
 
-    # The ControlInterface is used to handle internal events in WASM Scarpe
+    # The ControlInterface is used to handle internal events in Wasm Scarpe
     attr_reader :control_interface
 
-    # The DocumentRoot is the top widget of the WASM-side widget tree
+    # The DocumentRoot is the top drawable of the Wasm-side drawable tree
     attr_reader :doc_root
 
-    # app is the Scarpe::WASMApp
+    # app is the Scarpe::Wasm::App
     attr_reader :app
 
     # wrangler is the Scarpe::WebWrangler
     attr_reader :wrangler
 
-    # This is called before any of the various WASMWidgets are created, to be
+    # This is called before any of the various Drawables are created, to be
     # able to create them and look them up.
     def initialize
-      if WASMDisplayService.instance
+      if DisplayService.instance
         raise "ERROR! This is meant to be a singleton!"
       end
 
-      WASMDisplayService.instance = self
+      DisplayService.instance = self
 
       super()
-      log_init("WASM::WASMDisplayService")
+      log_init("Wasm::DisplayService")
 
-      @display_widget_for = {}
+      @display_drawable_for = {}
     end
 
-    # Create a WASM display widget for a specific Shoes widget, and pair it with
-    # the linkable ID for this Shoes widget.
+    # Create a Wasm display drawable for a specific Shoes drawable, and pair it with
+    # the linkable ID for this Shoes drawable.
     #
-    # @param widget_class_name [String] The class name of the Shoes widget, e.g. Shoes::Button
-    # @param widget_id [String] the linkable ID for widget events
-    # @param properties [Hash] a JSON-serialisable Hash with the widget's display properties
-    # @return [WASMWidget] the newly-created WASM widget
-    def create_display_widget_for(widget_class_name, widget_id, properties)
-      if widget_class_name == "App"
+    # @param drawable_class_name [String] The class name of the Shoes drawable, e.g. Shoes::Button
+    # @param drawable_id [String] the linkable ID for drawable events
+    # @param properties [Hash] a JSON-serialisable Hash with the drawable's display properties
+    # @param is_widget [Boolean] whether the class is a user-defined Shoes::Widget subclass
+    # @return [Wasm::Drawable] the newly-created Wasm drawable
+    def create_display_drawable_for(drawable_class_name, drawable_id, properties, is_widget:)
+      existing = query_display_drawable_for(drawable_id, nil_ok: true)
+      if existing
+        @log.warn("There is already a display drawable for #{drawable_id.inspect}! Returning #{existing.class.name}.")
+        return existing
+      end
+
+      if drawable_class_name == "App"
         unless @doc_root
-          raise "WASMDocumentRoot is supposed to be created before WASMApp!"
+          raise Scarpe::MissingDocRootError, "DocumentRoot is supposed to be created before App!"
         end
 
-        display_app = Scarpe::WASMApp.new(properties)
+        display_app = Scarpe::Wasm::App.new(properties)
         display_app.document_root = @doc_root
         @control_interface = display_app.control_interface
         @control_interface.doc_root = @doc_root
         @app = @control_interface.app
         @wrangler = @control_interface.wrangler
 
-        set_widget_pairing(widget_id, display_app)
+        set_drawable_pairing(drawable_id, display_app)
 
         return display_app
       end
 
-      # Create a corresponding display widget
-      display_class = Scarpe::WASMWidget.display_class_for(widget_class_name)
-      display_widget = display_class.new(properties)
-      set_widget_pairing(widget_id, display_widget)
+      # Create a corresponding display drawable
+      if is_widget
+        display_class = Scarpe::Wasm::Flow
+      else
+        display_class = Scarpe::Wasm::Drawable.display_class_for(drawable_class_name)
+        unless display_class < Scarpe::Wasm::Drawable
+          raise Scarpe::BadDisplayClassType, "Wrong display class type #{display_class.inspect} for class name #{drawable_class_name.inspect}!"
+        end
+      end
+      display_drawable = display_class.new(properties)
+      set_drawable_pairing(drawable_id, display_drawable)
 
-      if widget_class_name == "DocumentRoot"
-        # WASMDocumentRoot is created before WASMApp. Mostly doc_root is just like any other widget,
-        # but we'll want a reference to it when we create WASMApp.
-        @doc_root = display_widget
+      if drawable_class_name == "DocumentRoot"
+        # DocumentRoot is created before App. Mostly doc_root is just like any other drawable,
+        # but we'll want a reference to it when we create App.
+        @doc_root = display_drawable
       end
 
-      display_widget
+      display_drawable
     end
 
     # Destroy the display service and the app. Quit the process (eventually.)
@@ -88,7 +102,7 @@ def create_display_widget_for(widget_class_name, widget_id, properties)
     # @return [void]
     def destroy
       @app.destroy
-      WASMDisplayService.instance = nil
+      DisplayService.instance = nil
     end
   end
 end
diff --git a/lib/scarpe/wasm/web_wrangler.rb b/lib/scarpe/wasm/web_wrangler.rb
index 13b1035..971a5ab 100644
--- a/lib/scarpe/wasm/web_wrangler.rb
+++ b/lib/scarpe/wasm/web_wrangler.rb
@@ -7,7 +7,7 @@
 # After creation, it starts in setup mode, and you can
 # use setup-mode callbacks.
 
-class Scarpe
+module Scarpe::Wasm
   class WebWrangler
     include Shoes::Log
 
@@ -16,41 +16,18 @@ class WebWrangler
     attr_reader :heartbeat # This is the heartbeat duration in seconds, usually fractional
     attr_reader :control_interface
 
-    # This error indicates a problem when running ConfirmedEval
-    class JSEvalError < Scarpe::Error
-      def initialize(data)
-        @data = data
-        super(data[:msg] || (self.class.name + "!"))
-      end
-    end
-
-    # We got an error running the supplied JS code string in confirmed_eval
-    class JSRuntimeError < JSEvalError
-    end
-
-    # The code timed out for some reason
-    class JSTimeoutError < JSEvalError
-    end
-
-    # We got weird or nonsensical results that seem like an error on WebWrangler's part
-    class InternalError < JSEvalError
-    end
-
     # This is the JS function name for eval results
     EVAL_RESULT = "scarpeAsyncEvalResult"
 
     # Allow a half-second for wasm to finish our JS eval before we decide it's not going to
     EVAL_DEFAULT_TIMEOUT = 0.5
 
-    def initialize(title:, width:, height:, resizable: false, debug: false, heartbeat: 0.1)
-      log_init("WASM::WebWrangler")
+    def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1)
+      log_init("Wasm::WebWrangler")
 
       @log.debug("Creating WebWrangler...")
 
-      # For now, always allow inspect element
-      @wasm = Scarpe::WASMInterops.new
-      #@wasm = Scarpe::LoggedWrapper.new(@wasm, "wasmAPI") if debug
-      @init_refs = {} # Inits don't go away so keep a reference to them to prevent GC
+      @wasm = Scarpe::WasmInterops.new
 
       @title = title
       @width = width
@@ -67,11 +44,7 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea
       @pending_evals = {}
       @eval_counter = 0
 
-      @dom_wrangler = DOMWrangler.new(self)
-
-      # bind("puts") do |*args|
-      #   puts(*args)
-      # end
+      @dom_wrangler = Scarpe::WebWrangler::DOMWrangler.new(self)
 
       @wasm.bind(EVAL_RESULT) do |*results|
         receive_eval_result(*results)
@@ -80,9 +53,6 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea
       # Ruby receives scarpeHeartbeat messages via the window library's main loop.
       # So this is a way for Ruby to be notified periodically, in time with that loop.
       @wasm.bind("scarpeHeartbeat") do
-        #  return unless @wasm # I think GTK+ may continue to deliver events after shutdown
-
-        # periodic_js_callback
         @heartbeat_handlers.each(&:call)
         @control_interface.dispatch_event(:heartbeat)
       end
@@ -92,7 +62,7 @@ def initialize(title:, width:, height:, resizable: false, debug: false, heartbea
 
     # Shorter name for better stack trace messages
     def inspect
-      "Scarpe::WebWrangler:#{object_id}"
+      "Scarpe::WebWasm:#{object_id}"
     end
 
     attr_writer :control_interface
@@ -100,17 +70,16 @@ def inspect
     ### Setup-mode Callbacks
 
     def bind(name, &block)
-      raise "App is running, javascript binding no longer works because it uses wasm init!" if @is_running
+      raise Scarpe::JSBindingError, "App is running, javascript binding no longer works because it uses wasm init!" if @is_running
 
       @wasm.bind(name, &block)
     end
 
     def init_code(name, &block)
-      raise "App is running, javascript init no longer works!" if @is_running
+      raise Scarpe::JSInitError, "App is running, javascript init no longer works!" if @is_running
 
       # Save a reference to the init string so that it goesn't get GC'd
       code_str = "#{name}();"
-      @init_refs[name] = code_str
 
       bind(name, &block)
       @wasm.init(code_str)
@@ -128,12 +97,11 @@ def periodic_code(name, interval = heartbeat, &block)
           # new window. But will there ever be a new page/window? Can we just
           # use eval instead of init to set up a periodic handler and call it
           # good?
-          raise "App is running, can't set up new periodic handlers with init!"
+          raise Scarpe::PeriodicHandlerSetupError, "App is running, can't set up new periodic handlers with init!"
         end
 
         js_interval = (interval.to_f * 1_000.0).to_i
         code_str = "setInterval(#{name}, #{js_interval});"
-        @init_refs[name] = code_str
 
         bind(name, &block)
         @wasm.init(code_str)
@@ -151,9 +119,7 @@ def periodic_code(name, interval = heartbeat, &block)
     # This method does *not* return a promise, and there is no way to track
     # its progress or its success or failure.
     def js_eventually(code)
-      raise "WebWrangler isn't running, eval doesn't work!" unless @is_running
-
-      # @log.warn "Deprecated: please do NOT use js_eventually, it's basically never what you want!" unless ENV["CI"]
+      raise Scarpe::WebWranglerNotRunningError, "WebWrangler isn't running, eval doesn't work!" unless @is_running
 
       @wasm.eval(code)
     end
@@ -172,52 +138,7 @@ def js_eventually(code)
     # in a JS function.
     EVAL_OPTS = [:timeout, :wait_for]
     def eval_js_async(code, opts = {})
-      # bad_opts = opts.keys - EVAL_OPTS
-      # raise("Bad options given to eval_with_handler! #{bad_opts.inspect}") unless bad_opts.empty?
-
-      # unless @is_running
-      #   raise "WebWrangler isn't running, so evaluating JS won't work!"
-      # end
-
-      # this_eval_serial = @eval_counter
-      # @eval_counter += 1
-
-      # @pending_evals[this_eval_serial] = {
-      #   id: this_eval_serial,
-      #   code: code,
-      #   start_time: Time.now,
-      #   timeout_if_not_scheduled: Time.now + EVAL_DEFAULT_TIMEOUT,
-      # }
-
-      # # We'll need this inside the promise-scheduling block
-      # pending_evals = @pending_evals
-      # timeout = opts[:timeout] || EVAL_DEFAULT_TIMEOUT
-
-      # promise = Scarpe::Promise.new(parents: (opts[:wait_for] || [])) do
-      #   # Are we mid-shutdown?
-      #   if @wasm
-      #     wrapped_code = WebWrangler.js_wrapped_code(code, this_eval_serial)
-
-      #     # We've been scheduled!
-      #     t_now = Time.now
-      #     # Hard to be sure wasm keeps a proper reference to this, so we will
-      #     pending_evals[this_eval_serial][:wrapped_code] = wrapped_code
-
-      #     pending_evals[this_eval_serial][:scheduled_time] = t_now
-      #     pending_evals[this_eval_serial].delete(:timeout_if_not_scheduled)
-
-      #     pending_evals[this_eval_serial][:timeout_if_not_finished] = t_now + timeout
-      #     @wasm.eval(wrapped_code)
-      #     @log.debug("Scheduled JS: (#{this_eval_serial})\n#{wrapped_code}")
-      #   else
-      #     # We're mid-shutdown. No more scheduling things.
-      #   end
-      # end
-
-      # @pending_evals[this_eval_serial][:promise] = promise
-      # @pending_evals[this_eval_serial][:promise].await
-      # promise
-      js_eventually(code)
+      @wasm.eval(code)
     end
 
     def self.js_wrapped_code(code, eval_id)
@@ -236,83 +157,28 @@ def self.js_wrapped_code(code, eval_id)
 
     private
 
-    # def periodic_js_callback
-    #   time_out_eval_results
-    # end
-
     def receive_eval_result(r_type, id, val)
       entry = @pending_evals.delete(id)
       unless entry
-        raise "Received an eval result for a nonexistent ID #{id.inspect}!"
+        raise Scarpe::NonexistentEvalResultError, "Received an eval result for a nonexistent ID #{id.inspect}!"
       end
 
       @log.debug("Got JS value: #{r_type} / #{id} / #{val.inspect}")
-
-      # promise = entry[:promise]
-
-      # case r_type
-      # when "success"
-      #   promise.fulfilled!(val)
-      # when "error"
-      #   promise.rejected! JSRuntimeError.new(
-      #     msg: "JS runtime error: #{val.inspect}!",
-      #     code: entry[:code],
-      #     ret_value: val,
-      #   )
-      # else
-      #   promise.rejected! InternalError.new(
-      #     msg: "JS eval internal error! r_type: #{r_type.inspect}",
-      #     code: entry[:code],
-      #     ret_value: val,
-      #   )
-      # end
     end
 
-    # TODO: would be good to keep 'tombstone' results for awhile after timeout, maybe up to around a minute,
-    # so we can detect if we're timing things out and then having them return successfully after a delay.
-    # Then we could adjust the timeouts. We could also check if later serial numbers have returned, and time
-    # out earlier serial numbers... *if* we're sure wasm will always execute JS evals in order.
-    # This all adds complexity, though. For now, do timeouts on a simple max duration.
-    # def time_out_eval_results
-    #   t_now = Time.now
-    #   timed_out_from_scheduling = @pending_evals.keys.select do |id|
-    #     t = @pending_evals[id][:timeout_if_not_scheduled]
-    #     t && t_now >= t
-    #   end
-    #   timed_out_from_finish = @pending_evals.keys.select do |id|
-    #     t = @pending_evals[id][:timeout_if_not_finished]
-    #     t && t_now >= t
-    #   end
-    #   timed_out_from_scheduling.each do |id|
-    #     @log.debug("JS timed out because it was never scheduled: (#{id}) #{@pending_evals[id][:code].inspect}")
-    #   end
-    #   timed_out_from_finish.each do |id|
-    #     @log.debug("JS timed out because it never finished: (#{id}) #{@pending_evals[id][:code].inspect}")
-    #   end
-
-    #   # A plus *should* be fine since nothing should ever be on both lists. But let's be safe.
-    #   timed_out_ids = timed_out_from_scheduling | timed_out_from_finish
-
-    #   timed_out_ids.each do |id|
-    #     @log.error "Timing out JS eval! #{@pending_evals[id][:code]}"
-    #     entry = @pending_evals.delete(id)
-    #     err = JSTimeoutError.new(msg: "JS timeout error!", code: entry[:code], ret_value: nil)
-    #     entry[:promise].rejected!(err)
-    #   end
-    # end
-
     public
 
+    attr_writer :empty_page
+
     # After setup, we call run to go to "running" mode.
     # No more setup callbacks, only running callbacks.
 
     def run
       @log.debug("Run...")
 
-      # From wasm:
       # 0 - Width and height are default size
-      # 1 - Width and height are minimum bonds
-      # 2 - Width and height are maximum bonds
+      # 1 - Width and height are minimum bounds
+      # 2 - Width and height are maximum bounds
       # 3 - Window size can not be changed by a user
       hint = @resizable ? 0 : 3
 
@@ -320,8 +186,6 @@ def run
       @wasm.set_size(@width, @height, hint)
       @wasm.navigate("data:text/html, #{empty}")
 
-      # monkey_patch_console(@wasm)
-
       @is_running = true
       @wasm.run
     end
@@ -339,49 +203,8 @@ def destroy
 
     private
 
-    # TODO: can this be an init()?
-    def monkey_patch_console(window)
-      # this forwards all console.log/info/error/warn calls also
-      # to the terminal that is running the scarpe app
-      # window.eval("
-      #   function patchConsole(fn) {
-      #     const original = console[fn];
-      #     console[fn] = function(...args) {
-      #       original(...args);
-      #       puts(...args);
-      #     }
-      #   };
-      #   patchConsole('log');
-      #   patchConsole('info');
-      #   patchConsole('error');
-      #   patchConsole('warn');
-      # ")
-    end
-
     def empty
-      html = <<~HTML
-        <html>
-          <head id='head-wvroot'>
-            <style id='style-wvroot'>
-              /** Style resets **/
-              body {
-                font-family: arial, Helvetica, sans-serif;
-                margin: 0;
-                height: 100%;
-                overflow: hidden;
-              }
-              p {
-                margin: 0;
-              }
-            </style>
-          </head>
-          <body id='body-wvroot'>
-            <div id='wrapper-wvroot'></div>
-          </body>
-        </html>
-      HTML
-
-      CGI.escape(html)
+      Scarpe::Components::Calzini.empty_page_element
     end
 
     public
@@ -402,12 +225,6 @@ def dom_change(js)
       @dom_wrangler.request_change(js)
     end
 
-    # Return whether the DOM is, right this moment, confirmed to be fully
-    # up to date or not.
-    # def dom_fully_updated?
-    #   @dom_wrangler.fully_updated?
-    # end
-
     # Return a promise that will be fulfilled when all current DOM changes
     # have committed (but not necessarily any future DOM changes.)
     def dom_redraw
@@ -439,26 +256,19 @@ def on_every_redraw(&block)
 # changes waiting to catch the next bus. But we don't want more than one in flight,
 # since it seems like having too many pending RPC requests can crash wasm. So:
 # one redraw scheduled and one redraw promise waiting around, at maximum.
-class Scarpe
+module Scarpe
   class WebWrangler
     class DOMWrangler
       include Shoes::Log
 
       attr_reader :waiting_changes
 
-      # attr_reader :pending_redraw_promise
-      # attr_reader :waiting_redraw_promise
-
       def initialize(web_wrangler, debug: false)
-        log_init("WASM::WebWrangler::DOMWrangler")
+        log_init("Wasm::WebWrangler::DOMWrangler")
 
         @wrangler = web_wrangler
 
         @waiting_changes = []
-        # @pending_redraw_promise = nil
-        # @waiting_redraw_promise = nil
-
-        # @fully_up_to_date_promise = nil
 
         # Initially we're waiting for a full replacement to happen.
         # It's possible to request updates/changes before we have
@@ -467,19 +277,6 @@ def initialize(web_wrangler, debug: false)
         @first_draw_requested = false
 
         @redraw_handlers = []
-
-        # The "fully up to date" logic is complicated and not
-        # as well tested as I'd like. This makes it far less
-        # likely that the event simply won't fire.
-        # With more comprehensive testing, this should be
-        # removable.
-        # web_wrangler.periodic_code("scarpeDOMWranglerHeartbeat") do
-        #   if @fully_up_to_date_promise && fully_updated?
-        #     @log.info("Fulfilling up-to-date promise on heartbeat")
-        #     @fully_up_to_date_promise.fulfilled!
-        #     @fully_up_to_date_promise = nil
-        #   end
-        # end
       end
 
       def request_change(js_code)
@@ -510,124 +307,13 @@ def on_every_redraw(&block)
         @redraw_handlers << block
       end
 
-      # What are the states of redraw?
-      # "empty" - no waiting promise, no pending-redraw promise, no pending changes
-      # "pending only" - no waiting promise, but we have a pending redraw with some changes; it hasn't committed yet
-      # "pending and waiting" - we have a waiting promise for our unscheduled changes; we can add more unscheduled
-      #     changes since we haven't scheduled them yet.
-      #
-      # This is often called after adding a new waiting change or replacing them, so the state may have just changed.
-      # It can also be called when no changes have been made and no updates need to happen.
       def redraw
-        # if fully_updated?
-        #   # No changes to make, nothing in-process or waiting, so just return a pre-fulfilled promise
-        #   @log.debug("Requesting redraw but there are no pending changes or promises, return pre-fulfilled")
-        #   return Promise.fulfilled
-        # end
-
-        # Already have a redraw requested *and* one on deck? Then all current changes will have committed
-        # when we (eventually) fulfill the waiting_redraw_promise.
-        # if @waiting_redraw_promise
-        #   @log.debug("Promising eventual redraw of #{@waiting_changes.size} waiting unscheduled changes.")
-        #   return @waiting_redraw_promise
-        # end
-
-        # if @waiting_changes.empty?
-        #   # There's no waiting_redraw_promise. There are no waiting changes. But we're not fully updated.
-        #   # So there must be a redraw in flight, and we don't need to schedule a new waiting_redraw_promise.
-        #   @log.debug("Returning in-flight redraw promise")
-        #   return @pending_redraw_promise
-        # end
-
-        # @log.debug("Requesting redraw with #{@waiting_changes.size} waiting changes - need to schedule something!")
-
-        # We have at least one waiting change, possibly newly-added. We have no waiting_redraw_promise.
-        # Do we already have a redraw in-flight?
-        # if @pending_redraw_promise
-        #   # Yes we do. Schedule a new waiting promise. When it turns into the pending_redraw_promise it will
-        #   # grab all waiting changes. In the mean time, it sits here and waits.
-        #   #
-        #   # We *could* do a fancy promise thing and have it update @waiting_changes for itself, etc, when it
-        #   # schedules itself. But we should always be calling promise_redraw or having a redraw fulfilled (see below)
-        #   # when these things change. I'd rather keep the logic in this method. It's easier to reason through
-        #   # all the cases.
-        #   @waiting_redraw_promise = Promise.new
-
-        #   @log.debug("Creating a new waiting promise since a pending promise is already in place")
-        #   return @waiting_redraw_promise
-        # end
-
-        # We have no redraw in-flight and no pre-existing waiting line. The new change(s) are presumably right
-        # after things were fully up-to-date. We can schedule them for immediate redraw.
-
         @log.debug("Requesting redraw with #{@waiting_changes.size} waiting changes - scheduling a new redraw for them!")
-        # promise = schedule_waiting_changes # This clears the waiting changes
         schedule_waiting_changes
-        # @pending_redraw_promise = promise
 
         @redraw_handlers.each(&:call)
-        # @pending_redraw_promise = nil
-
-        # if @waiting_redraw_promise
-        #   # While this redraw was in flight, more waiting changes got added and we made a promise
-        #   # about when they'd complete. Now they get scheduled, and we'll fulfill the waiting
-        #   # promise when that redraw finishes. Clear the old waiting promise. We'll add a new one
-        #   # when/if more changes are scheduled during this redraw.
-        #   old_waiting_promise = @waiting_redraw_promise
-        #   @waiting_redraw_promise = nil
-
-        #   @log.debug "Fulfilled redraw with #{@waiting_changes.size} waiting changes - scheduling a new redraw for them!"
-
-        #   new_promise = promise_redraw
-        #   new_promise.on_fulfilled { old_waiting_promise.fulfilled! }
-        # else
-        # The in-flight redraw completed, and there's still no waiting promise. Good! That means
-        # we should be fully up-to-date.
-        #     @log.debug "Fulfilled redraw with no waiting changes - marking us as up to date!"
-        #     if @waiting_changes.empty?
-        #       # We're fully up to date! Fulfill the promise. Now we don't need it again until somebody asks
-        #       # us for another.
-        #       if @fully_up_to_date_promise
-        #         @fully_up_to_date_promise.fulfilled!
-        #         @fully_up_to_date_promise = nil
-        #       end
-        #     else
-        #       @log.error "WHOAH, WHAT? My logic must be wrong, because there's " +
-        #         "no waiting promise, but waiting changes!"
-        #     end
-        #   end
-
-        #   @log.debug("Redraw is now fully up-to-date") if fully_updated?
-        # end.on_rejected do
-        #   @log.error "Could not complete JS redraw! #{promise.reason.full_message}"
-        #   @log.debug("REDRAW FULLY UP TO DATE BUT JS FAILED") if fully_updated?
-
-        #   raise "JS Redraw failed! Bailing!"
-
-        #   # Later we should figure out how to handle this. Clear the promises and queues and request another redraw?
-        # end
       end
 
-      # def fully_updated?
-      #   @pending_redraw_promise.nil? && @waiting_redraw_promise.nil? && @waiting_changes.empty?
-      # end
-
-      # Return a promise which will be fulfilled when the DOM is fully up-to-date
-      # def promise_fully_updated
-      #   if fully_updated?
-      #     # No changes to make, nothing in-process or waiting, so just return a pre-fulfilled promise
-      #     return Promise.fulfilled
-      #   end
-
-      #   # Do we already have a promise for this? Return it. Everybody can share one.
-      #   if @fully_up_to_date_promise
-      #     return @fully_up_to_date_promise
-      #   end
-
-      #   # We're not fully updated, so we need a promise. Create it, return it.
-      #   @fully_up_to_date_promise = Promise.new
-      # end
-
       private
 
       # Put together the waiting changes into a new in-flight redraw request.
@@ -645,13 +331,13 @@ def schedule_waiting_changes
 
 # For now we don't need one of these to add DOM elements, just to manipulate them
 # after initial render.
-class Scarpe
+module Scarpe
   class WebWrangler
     class ElementWrangler
       attr_reader :html_id
 
       def initialize(html_id)
-        @webwrangler = WASMDisplayService.instance.wrangler
+        @webwrangler = Scarpe::Wasm::DisplayService.instance.wrangler
         @html_id = html_id
       end
 
@@ -697,7 +383,6 @@ def toggle_input_button(mark)
         checked_value = mark ? "true" : "false"
         @webwrangler.dom_change("document.getElementById('#{html_id}').checked = #{checked_value};")
       end
-
     end
   end
 end
diff --git a/lib/scarpe/wasm_local.rb b/lib/scarpe/wasm_local.rb
index 5c0c558..40e1450 100644
--- a/lib/scarpe/wasm_local.rb
+++ b/lib/scarpe/wasm_local.rb
@@ -6,12 +6,35 @@
 require "shoes"
 require "lacci/scarpe_core"
 
+require "scarpe/components/string_helpers"
+
 # For Wasm, use simple no-dependency printing logger
 require "scarpe/components/print_logger"
 Shoes::Log.instance = Scarpe::Components::PrintLogImpl.new
 Shoes::Log.configure_logger(Shoes::Log::DEFAULT_LOG_CONFIG)
 
+require "scarpe/components/segmented_file_loader"
+loader = Scarpe::Components::SegmentedFileLoader.new
+Shoes.add_file_loader loader
+
+# TODO: Shoes::Spec
+#if ENV["SHOES_SPEC_TEST"]
+#  require_relative "shoes_spec"
+#  Shoes::Spec.instance = Scarpe::Test
+#end
+
+require "scarpe/components/html"
+module Scarpe::Wasm
+  HTML = Scarpe::Components::HTML
+
+  class Drawable < Shoes::Linkable
+    require "scarpe/components/calzini"
+    # This is where we would make the HTML renderer modular by choosing another
+    include Scarpe::Components::Calzini
+  end
+end
+
 require_relative "wasm"
 require_relative "wasm/wasm_local_display"
 
-Shoes::DisplayService.set_display_service_class(Scarpe::WASMDisplayService)
+Shoes::DisplayService.set_display_service_class(Scarpe::Wasm::DisplayService)
diff --git a/test/test_helper.rb b/test/test_helper.rb
index acf6b2e..1c21335 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -52,13 +52,14 @@ def setup
   def build_test_wasm_package
     return if TEST_DATA[:wasm_built]
 
-    Dir.chdir TEST_CACHE_DIR
+    Dir.chdir TEST_CACHE_DIR # This needs to be true during the test, but is there a better place for it?
     FileUtils.touch "src/APP_NAME.rb" # Use this to have a boilerplate name to search/replace
 
     # Need to use the TEST_CACHE_DIR Bundler env, *not* the one for the test harness.
     Bundler.with_unbundled_env do
       system("bundle exec wasify src/APP_NAME.rb") || raise("Couldn't wasify-build!")
     end
+    raise "Wasify didn't create packed_ruby.wasm!" unless File.exist?("packed_ruby.wasm")
 
     TEST_DATA[:wasm_built] = true
   end