#715: [Makefile] Wipe node_modules if not properly installed

diff --git a/.gitattributes b/.gitattributes
index 8e06004..66fbd75 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,4 +2,4 @@
 * text=lf
 
 # don't mess with fonts
-src/font/* text=binary
+src/fonts/* text=binary
diff --git a/Makefile b/Makefile
index fc87a4c..906bfa0 100644
--- a/Makefile
+++ b/Makefile
@@ -62,8 +62,8 @@
 CSS_MAIN = $(CSS_DIR)/main.less
 CSS_SOURCES = $(shell find $(CSS_DIR) -name '*.less')
 
-FONT_SOURCE = $(SRC_DIR)/font
-FONT_TARGET = $(BUILD_DIR)/font
+FONT_SOURCE = $(SRC_DIR)/fonts
+FONT_TARGET = $(BUILD_DIR)/fonts
 
 UNIT_TESTS = ./test/unit/*.test.js
 
diff --git a/circle.yml b/circle.yml
index f93227b..7fd97e5 100644
--- a/circle.yml
+++ b/circle.yml
@@ -17,8 +17,8 @@
 #       + https://circleci.com/docs/configuration/
 #   - our `circle.yml` first installs and runs a tunnel to Sauce Labs
 #   - and runs `make server`
-#   - then it calls out to Sauce Labs' REST API to open a browser that reaches
-#     back through the tunnel to access the unit test page on the local server
+#   - then it calls out to Sauce Labs' REST API to open browsers that reach
+#     back through the tunnel to access test pages on the local server
 #       + > Sauce Connect allows you to run a test server within the CircleCI
 #         > build container and expose it it (using a URL like `localhost:8080`)
 #         > to Sauce Labs’ browsers.
@@ -35,167 +35,214 @@
   cache_directories:
     - ~/sauce-connect
   pre:
-    - "test $SAUCE_USERNAME && test $SAUCE_ACCESS_KEY
-       # Sauce Labs credentials required. Sign up here: https://saucelabs.com/opensauce/"
     - ? |-
-        {
-          mkdir -p ~/sauce-connect
-          cd ~/sauce-connect
-          if [ -x sc-*-linux/bin/sc ]; then
-            echo Using cached sc-*-linux/bin/sc
-          else
-            time wget https://saucelabs.com/downloads/sc-latest-linux.tar.gz
-            time tar -xzf sc-latest-linux.tar.gz
-          fi
-          # Sauce Connect randomly fails so try twice
-          time sc-*-linux/bin/sc --user $SAUCE_USERNAME --api-key $SAUCE_ACCESS_KEY --readyfile ~/sauce_is_ready \
-            || time sc-*-linux/bin/sc --user $SAUCE_USERNAME --api-key $SAUCE_ACCESS_KEY --readyfile ~/sauce_is_ready \
-            || echo ERROR > ~/sauce_is_ready
-        } >$CIRCLE_ARTIFACTS/sauce-connect.log 2>&1
+        # SauceConnect: download if not cached, and launch with retry
+        test $SAUCE_USERNAME && test $SAUCE_ACCESS_KEY || {
+          echo 'Sauce Labs credentials required. Sign up here: https://saucelabs.com/opensauce/'
+          exit 1
+        }
+
+        mkdir -p ~/sauce-connect
+        cd ~/sauce-connect
+
+        if [ -x sc-*-linux/bin/sc ]; then
+          echo Using cached sc-*-linux/bin/sc
+        else
+          time wget https://saucelabs.com/downloads/sc-latest-linux.tar.gz
+          time tar -xzf sc-latest-linux.tar.gz
+        fi
+
+        time sc-*-linux/bin/sc --user $SAUCE_USERNAME --api-key $SAUCE_ACCESS_KEY \
+          --readyfile ~/sauce_is_ready
+        test -e ~/sauce_was_ready && exit
+
+        echo 'Sauce Connect failed, try redownloading (https://git.io/vSxsJ)'
+        rm -rf *
+        time wget https://saucelabs.com/downloads/sc-latest-linux.tar.gz
+        time tar -xzf sc-latest-linux.tar.gz
+
+        time sc-*-linux/bin/sc --user $SAUCE_USERNAME --api-key $SAUCE_ACCESS_KEY \
+          --readyfile ~/sauce_is_ready
+        test -e ~/sauce_was_ready && exit
+
+        echo 'ERROR: Exited twice without creating readyfile' \
+          | tee /dev/stderr > ~/sauce_is_ready
+        exit 1
       :
         background: true
 
 test:
-  override:
-    # Sauce can connect to Safari on ports 3000, 4000, 7000, and 8000. Edge needs port 7000 or 8000.
+  pre:
+    - |-
+      # Generate link to Many-Worlds build and add to GitHub Commit Status
+      curl -i -X POST https://api.github.com/repos/mathquill/mathquill/statuses/$CIRCLE_SHA1 \
+           -u MathQuillBot:$GITHUB_STATUS_API_KEY \
+           -d '{
+                 "context": "ci/many-worlds",
+                 "state": "success",
+                 "description": "Try the tests on the Many-Worlds build of this commit:",
+                 "target_url": "http://many-worlds.glitch.me/mathquill/mathquill/commit/'$CIRCLE_SHA1'/test/"
+               }'
+
+    # Safari on Sauce can only connect to port 3000, 4000, 7000, or 8000. Edge needs port 7000 or 8000.
     # https://david263a.wordpress.com/2015/04/18/fixing-safari-cant-connect-to-localhost-issue-when-using-sauce-labs-connect-tunnel/
     # https://support.saucelabs.com/customer/portal/questions/14368823-requests-to-localhost-on-microsoft-edge-are-failing-over-sauce-connect
-    - PORT=8000 make server >$CIRCLE_ARTIFACTS/make_server.log 2>&1:
+    - PORT=8000 make server:
         background: true
 
-    # CircleCI expects test results to be reported in an JUnit/xUnit-style XML
-    # file:
-    #   https://circleci.com/docs/test-metadata/
-    # Our unit tests are in a browser, so they can't write to a file, and Sauce
-    # apparently truncates custom data in their test result reports, so instead
-    # we POST to this trivial Node server on localhost:9000 that writes the
-    # body of any POST request to $CIRCLE_TEST_REPORTS/mocha/xunit.xml
-    - mkdir -p $CIRCLE_TEST_REPORTS/mocha
+    # Wait for tunnel to be ready (`make server` is much faster, no need to wait for it)
+    - while [ ! -e ~/sauce_is_ready ]; do sleep 1; done; touch ~/sauce_was_ready; test -z "$(<~/sauce_is_ready)"
+
+  override:
     - ? |-
-        node << 'EOF' >$CIRCLE_TEST_REPORTS/mocha/xunit.xml \
-                      2>$CIRCLE_ARTIFACTS/mocha-test-report.log
-          require('http').createServer(function(req, res) {
-            res.setHeader('Access-Control-Allow-Origin', '*');
+        # Screenshots: capture in the background while running unit tests
+        mkdir -p $CIRCLE_TEST_REPORTS/mocha
+
+        # CircleCI expects test results to be reported in an JUnit/xUnit-style XML file:
+        #   https://circleci.com/docs/test-metadata/#a-namemochajsamocha-for-nodejs
+        # Our unit tests are in a browser, so they can't write to a file, and Sauce
+        # apparently truncates custom data in their test result reports, so instead we
+        # POST to this trivial Node server on localhost:9000 that writes the body of
+        # any POST request to $CIRCLE_TEST_REPORTS/junit/test-results.xml
+        node -e '
+          require("http").createServer(function(req, res) {
+            res.setHeader("Access-Control-Allow-Origin", "*");
             req.pipe(process.stdout);
-            req.on('end', res.end.bind(res));
+            req.on("end", res.end.bind(res));
           })
           .listen(9000);
-          console.error('listening on http://0.0.0.0:9000/');
-        EOF
-      :
-        background: true
+          console.error("listening on http://0.0.0.0:9000/");
+        ' 2>&1 >$CIRCLE_TEST_REPORTS/junit/test-results.xml | {
+          # ^ note: `2>&1` must precede `>$CIRCLE_TEST_REPORTS/...` because
+          # shell redirect is like assignment; if it came after, then both
+          # stdout and stderr would be written to `xunit.xml` and nothing
+          # would be  piped into here
 
-    # Wait for tunnel to be ready (`make server` and the trivial Node server
-    # are much faster, no need to wait for them)
-    - while [ ! -e ~/sauce_is_ready ]; do sleep 1; done; test "$(<~/sauce_is_ready)" != ERROR
+          head -1 # wait for "listening on ..." to be logged
 
-    # Start taking screenshots in the background while the unit tests are running
-    - ? |-
-        {
+          # https://circleci.com/docs/environment-variables/
+          build_name="CircleCI build #$CIRCLE_BUILD_NUM"
+          if [ $CIRCLE_PR_NUMBER ]; then
+            build_name="$build_name: PR #$CIRCLE_PR_NUMBER"
+            [ "$CIRCLE_BRANCH" ] && build_name="$build_name ($CIRCLE_BRANCH)"
+          else
+            build_name="$build_name: $CIRCLE_BRANCH"
+          fi
+          build_name="$build_name @ ${CIRCLE_SHA1:0:7}"
+          export MQ_CI_BUILD_NAME="$build_name"
+
           time { test -d node_modules/wd || npm install wd; }
           time node script/screenshots.js http://localhost:8000/test/visual.html \
-            && touch ~/screenshots_are_ready || echo ERROR > ~/screenshots_are_ready:
-        } >$CIRCLE_ARTIFACTS/screenshots.log 2>&1
+            && touch ~/screenshots_are_ready || echo EXIT STATUS $? | tee /dev/stderr > ~/screenshots_are_ready:
+        }
       :
         background: true
 
-    # Run in-browser unit tests, based on:
-    #   https://wiki.saucelabs.com/display/DOCS/JavaScript+Unit+Testing+Methods
-    # "build" and "tag" parameters from:
-    #   https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-TestAnnotation
     - |-
-      curl -i https://saucelabs.com/rest/v1/$SAUCE_USERNAME/js-tests \
-           -X POST \
+      # Unit tests in the browser
+
+      echo '1. Launch tests'
+      echo
+
+      # https://circleci.com/docs/environment-variables/
+      build_name="CircleCI build #$CIRCLE_BUILD_NUM"
+      if [ $CIRCLE_PR_NUMBER ]; then
+        build_name="$build_name: PR #$CIRCLE_PR_NUMBER"
+        [ "$CIRCLE_BRANCH" ] && build_name="$build_name ($CIRCLE_BRANCH)"
+      else
+        build_name="$build_name: $CIRCLE_BRANCH"
+      fi
+      build_name="$build_name @ ${CIRCLE_SHA1:0:7}"
+
+      # "build" and "customData" parameters from:
+      #   https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options#TestConfigurationOptions-TestAnnotation
+      set -o pipefail
+      curl -i -X POST https://saucelabs.com/rest/v1/$SAUCE_USERNAME/js-tests \
            -u $SAUCE_USERNAME:$SAUCE_ACCESS_KEY \
            -H 'Content-Type: application/json' \
            -d '{
-                 "build": "'$(git rev-parse HEAD)'",
-                 "tags": [
-                   "after-v'$(node -p 'require("./package.json").version')'",
-                   "circle-ci"
-                 ],
+                 "name": "Unit tests, Mocha",
+                 "build": "'"$build_name"'",
+                 "customData": {"build_url": "'"$CIRCLE_BUILD_URL"'"},
                  "framework": "mocha",
                  "url": "http://localhost:8000/test/unit.html?post_xunit_to=http://localhost:9000",
                  "platforms": [["", "Chrome", ""]]
-      }' | tee js-tests
+               }' \
+      | tee /dev/stderr | tail -1 > js-tests.json
 
-    # Wait for tests to finish:
-    #
-    #   > Make the request multiple times as the tests run until the response
-    #   > contains `completed: true` to the get the final results.
-    #
-    #   https://wiki.saucelabs.com/display/DOCS/JavaScript+Unit+Testing+Methods
-    - |-
+      echo '2. Wait for tests to finish:'
+      echo
+      #   > Make the request multiple times as the tests run until the response
+      #   > contains `completed: true` to the get the final results.
+      # https://wiki.saucelabs.com/display/DOCS/JavaScript+Unit+Testing+Methods
       while true  # Bash has no do...while >:(
       do
         sleep 5
-        curl -i https://saucelabs.com/rest/v1/$SAUCE_USERNAME/js-tests/status \
-             -X POST \
+        curl -i -X POST https://saucelabs.com/rest/v1/$SAUCE_USERNAME/js-tests/status \
              -u $SAUCE_USERNAME:$SAUCE_ACCESS_KEY \
              -H 'Content-Type: application/json' \
-             -d "$(tail -1 js-tests)" \
-        | tee status
-        tail -1 status > status.json
+             -d @js-tests.json \
+        | tee /dev/stderr | tail -1 > status.json
+
         # deliberately do `... != false` rather than `... == true`
         # because unexpected values should break rather than infinite loop
-        [ "$(node -p 'require("./status.json").completed')" != false ] && break
+        [ "$(jq .completed <status.json)" != false ] && break
       done
 
-    # Wait for screenshots to be ready
-    - while [ ! -e ~/screenshots_are_ready ]; do sleep 1; done; test "$(<~/screenshots_are_ready)" != ERROR:
-        timeout: 300
+      echo '3. Exit with non-zero status code if any unit tests failed'
+      exit "$(jq '.["js tests"][0].result.failures' <status.json)"
 
-    # Stitch together images
-    # TODO: Split this into multiple yaml lines. I (Michael)
-    #       niavely tried to split this into mutiple yaml lines
-    #       but was unsucessful in doing do.
     - |-
-      img_dir=$CIRCLE_ARTIFACTS/imgs/
-      for x in $(ls $img_dir)
-      do
-        convert $img_dir/$x/*.png -append $img_dir/$x.png
+      # Stitch together screenshots and diff against master
+
+      echo '0. Wait for screenshots to be ready'
+      while [ ! -e ~/screenshots_are_ready ]; do sleep 1; done
+      test -z "$(<~/screenshots_are_ready)" || exit 1
+
+      echo '1. Stitch together pieces'
+      for img in $(ls $CIRCLE_ARTIFACTS/imgs/pieces/); do
+        convert $(ls -1 $CIRCLE_ARTIFACTS/imgs/pieces/$img/*.png | sort -n) -append $CIRCLE_ARTIFACTS/imgs/$img.png
       done
 
-      # Remove all directories in $CIRCLE_ARTIFACTS/img
-      # Currently the pieces aren't kept around. If it's
-      # desirable to keep them around, we should use
-      #    cp -r $dir $CIRCLE_ARTIFACTS/img_pieces
-      # The reason the image pieces aren't currently kept
-      # around is that it was leading to a problem. Specifically,
-      # when we get the previous images, we niavely grab any *.png,
-      # including the pieces images. This compounded so that each
-      # iteration of a test run would have all of the images from
-      # the previous test run plus whichever new images were generated.
-      rm -R -- $img_dir/*/
+      echo '2. Download the latest screenshots from master'
+      echo
 
-      # Install utility we need
-      npm install -g json
+      artifacts_json="$(curl https://circleci.com/api/v1/project/mathquill/mathquill/latest/artifacts?branch=master)"
+      echo
+      echo '/latest/artifacts?branch=master:'
+      echo
+      echo "$artifacts_json"
+      echo
 
-      # Download the latest mathquill artifacts.
-      curl $(curl https://circleci.com/api/v1/project/mathquill/mathquill/latest/artifacts \
-              | json -a url pretty_path -d '\n\t' \
-              | grep '\.png$' \
-              | grep -v 'PREV' \
-              | sed "s:\$CIRCLE_ARTIFACTS/imgs/:-o $img_dir/PREV_:")
+      mkdir $CIRCLE_ARTIFACTS/imgs/baseline/
+      baseline_imgs="$(echo "$artifacts_json" \
+                    | jq -r '.[] | .url + " -o " + .pretty_path' \
+                    | grep '\.png$' \
+                    | grep -v '_DIFF\.png$' \
+                    | grep -vF '/pieces/' \
+                    | grep -vF '/baseline/' \
+                    | sed "s:\$CIRCLE_ARTIFACTS/imgs/:$CIRCLE_ARTIFACTS/imgs/baseline/:")"
+      echo 'Baseline image URLs and files:'
+      echo
+      echo "$baseline_imgs"
+      echo
 
-      # Generate image diffs.
-      cd $img_dir
-      for file in $(ls PINNED*); do
-        prev=PREV_$file
-        metric_diff=$(compare -metric AE -compose src $prev $file raw_diff.png)
-        composite -alpha on raw_diff.png $prev DIFF_$file
+      test -z "$baseline_imgs" && { echo 'No baseline images to download'; exit; }
+      curl $baseline_imgs
+      echo
+
+      echo '3.  Generate image diffs'
+      echo
+      cd $CIRCLE_ARTIFACTS/imgs/
+      for file in $(ls *.png); do
+        # if evergreen browser, browser version of previous screenshot may not match,
+        # so replace previous browser version with glob
+        baseline="$(echo baseline/$(echo $file | sed 's/[^_]*_(evergreen)/*/; s/OS_X_.*/OS_X_*.png/' | tee /dev/stderr) | tee /dev/stderr)"
+        echo "Number of different pixels from baseline in $file:"
+        compare -metric AE $baseline $file ${file/%.png/_DIFF.png}
+        echo
       done
+      true  # ignore errors like "image widths or heights differ"
 
-      for file in $(ls EVERGREEN*); do
-        prev=$(ls PREV_$file*)
-        metric_diff=$(compare -metric AE -compose src $prev $file raw_diff.png)
-        composite -alpha on raw_diff.png $prev DIFF_$file
-      done
-
-      rm raw_diff.png
-
-    # finally, complain to Circle CI if there were nonzero test failures
-    - |-
-      [ "$(node -p 'require("./status.json")["js tests"][0].result.failures')" == 0 ]
   post:
     - killall --wait sc; true  # wait for Sauce Connect to close the tunnel; ignore errors since it's just cleanup
diff --git a/docs/Api_Methods.md b/docs/Api_Methods.md
index b7db00c..a776774 100644
--- a/docs/Api_Methods.md
+++ b/docs/Api_Methods.md
@@ -190,7 +190,7 @@
 
 Move the cursor to the left/right end of the editable field, respectively. These are shorthand for [`.moveToDirEnd(L/R)`](#movetodirenddirection), respectively.
 
-## .movetoDirEnd(direction)
+## .moveToDirEnd(direction)
 
 Moves the cursor to the end of the mathfield in the direction specified. The direction can be one of `MQ.L` or `MQ.R`. These are constants, where `MQ.L === -MQ.R` and vice versa. This function may be easier to use than [moveToLeftEnd or moveToRightEnd](#movetoleftend-movetorightend) if used in the [`moveOutOf` handler](Config.md#outof-handlers).
 
diff --git a/docs/Contributing.md b/docs/Contributing.md
index bcbd3ae..1b8aaa8 100644
--- a/docs/Contributing.md
+++ b/docs/Contributing.md
@@ -8,7 +8,7 @@
 
 `make basic` builds a stripped-down version of MathQuill for basic mathematics, without advanced LaTeX commands. This version doesn't allow typed LaTeX backslash commands with `\` or text blocks with `$`, and also won't render any LaTeX commands that can't by typed without `\`. This version of MathQuill's JS is only somewhat smaller, but the font is like 100x smaller.
 
-To run this smaller version, serve and load `mathquill-basic.{js,min.js,css}` and `font/Symbola-basic.{eot,ttf}` instead.
+To run this smaller version, serve and load `mathquill-basic.{js,min.js,css}` and `fonts/Symbola-basic.{eot,ttf}` instead.
 
 # Testing
 
diff --git a/package.json b/package.json
index 5bf3263..9cb6c4b 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
   },
   "files": [
     "build/mathquill.{css,js,min.js}",
-    "build/font/Symbola.*",
+    "build/fonts/Symbola.*",
     "quickstart.html"
   ],
   "devDependencies": {
diff --git a/script/screenshots.js b/script/screenshots.js
index a28f035..63d9374 100644
--- a/script/screenshots.js
+++ b/script/screenshots.js
@@ -14,186 +14,193 @@
 
 var wd = require('wd');
 var fs = require('fs');
-var username = process.env['SAUCE_USERNAME'];
-var accessKey = process.env['SAUCE_ACCESS_KEY'];
-var baseDir = process.env['CIRCLE_ARTIFACTS'] || '/tmp';
 var url = process.argv[2];
-var allImgsDir = baseDir+'/imgs';
-fs.mkdirSync(allImgsDir);
+var username = process.env.SAUCE_USERNAME;
+var accessKey = process.env.SAUCE_ACCESS_KEY;
+var build_name = process.env.MQ_CI_BUILD_NAME;
+var baseDir = process.env.CIRCLE_ARTIFACTS;
+if (!baseDir) {
+  console.error('No $CIRCLE_ARTIFACTS found, for testing do something like `CIRCLE_ARTIFACTS=/tmp script/screenshots.js`');
+  process.exit(1);
+}
+fs.mkdirSync(baseDir+'/imgs');
+fs.mkdirSync(baseDir+'/imgs/pieces');
+fs.mkdirSync(baseDir+'/browser_logs');
 
-var browserVersions = [
+var browsers = [
   {
-    'version': {
-      // Expecting IE 8
-      'browserName': 'Internet Explorer',
-      'platform': 'Windows XP'
+    config: {
+      browserName: 'Internet Explorer',
+      platform: 'Windows XP'
     },
-    'pinned': 'PINNED'
+    pinned: true // assume pinned to IE 8
   },
   {
-    'version': {
-      // Expecting IE 11
-      'browserName': 'Internet Explorer',
-      'platform': 'Windows 7'
+    config: {
+      browserName: 'Internet Explorer',
+      platform: 'Windows 7'
     },
-    'pinned': 'PINNED'
+    pinned: true // assume pinned to IE 11
   },
   {
-    'version': {
-      'browserName': 'MicrosoftEdge',
-      'platform': 'Windows 10'
-    },
-    'pinned': 'EVERGREEN'
+    config: {
+      browserName: 'MicrosoftEdge',
+      platform: 'Windows 10'
+    }
   },
   {
-    'version': {
-      'browserName': 'Firefox',
-      'platform': 'OS X 10.11'
-    },
-    'pinned': 'EVERGREEN'
+    config: {
+      browserName: 'Firefox',
+      platform: 'OS X 10.11'
+    }
   },
   {
-    'version': {
-      'browserName': 'Safari',
-      'platform': 'OS X 10.11'
-    },
-    'pinned': 'EVERGREEN'
+    config: {
+      browserName: 'Safari',
+      platform: 'OS X 10.11'
+    }
   },
   {
-    'version': {
-      'browserName': 'Chrome',
-      'platform': 'OS X 10.11'
-    },
-    'pinned': 'EVERGREEN'
+    config: {
+      browserName: 'Chrome',
+      platform: 'OS X 10.11'
+    }
   },
   {
-    'version': {
-      'browserName': 'Firefox',
-      'platform': 'Linux'
-    },
-    'pinned': 'EVERGREEN'
-  },
+    config: {
+      browserName: 'Firefox',
+      platform: 'Linux'
+    }
+  }
 ];
 
 
-browserVersions.forEach(function(obj) {
-  var cfg = obj.version;
-  var browserDriver = wd.remote('ondemand.saucelabs.com', 80, username, accessKey);
-  // The following is in the style of
-  // https://github.com/admc/wd/blob/62f2b0060d36a402de5634477b26a5ed4c051967/examples/async/chrome.js#L25-L40
-  browserDriver.init(cfg, function(err, _, capabilities) {
-    if (err) console.log(err);
-    console.log(cfg.browserName,cfg.platform,'init')
+browsers.forEach(function(browser) {
+  browser.config.build = build_name;
+  browser.config.name = 'Visual tests, ' + browser.config.browserName + ' on ' + browser.config.platform;
+  browser.config.customData = {build_url: process.env.CIRCLE_BUILD_URL};
+  var browserDriver = wd.promiseChainRemote('ondemand.saucelabs.com', 80, username, accessKey);
+  return browserDriver.init(browser.config)
+  .then(function(args) {
+    var cfg = browser.config, capabilities = args[1];
+    var version = capabilities.version || capabilities.browserVersion;
+    var sessionName = [cfg.browserName, version, cfg.platform].join(' ');
+    if (capabilities.platformVersion) sessionName += ' ' + capabilities.platformVersion;
+    console.log(sessionName, 'init', args);
 
-    var browser = cfg.browserName.replace(/\s/g, '_');
-    var platform = cfg.platform.replace(/\s/g, '_');
-    var piecesDir = allImgsDir+'/'+obj.pinned+'_'+platform+'_'+browser;
-    fs.mkdirSync(piecesDir);
+    var evergreen = browser.pinned ? '' : '_(evergreen)';
+    var fileName = [cfg.browserName, version + evergreen, cfg.platform].join('_');
+    if (capabilities.platformVersion) fileName += ' ' + capabilities.platformVersion;
+    fileName = fileName.replace(/ /g, '_');
 
-    browserDriver.get(url, function(err) {
-      if (err) console.log(err);
-      console.log(cfg.browserName,cfg.platform,'get')
-      browserDriver.safeExecute('document.documentElement.scrollHeight', function(err,scrollHeight) {
-        if (err) console.log(err);
-        console.log(cfg.browserName,cfg.platform,'get scrollHeight')
-        browserDriver.safeExecute('document.documentElement.clientHeight', function(err,viewportHeight) {
-          if (err) console.log(err);
-          console.log(cfg.browserName,cfg.platform,'get clientHeight')
+    return browserDriver.get(url)
+    .then(willLog(sessionName, 'get'))
+    .safeExecute('document.body.focus()') // blur anything that's auto-focused
+    .then(willLog(sessionName, 'document.body.focus()'))
+    .safeExecute('document.documentElement.style.overflow = "hidden"') // hide scrollbars
+    .then(willLog(sessionName, 'hide scrollbars'))
+    .then(function() {
+      // Microsoft Edge starts out with illegally big window: https://git.io/vD63O
+      if (cfg.browserName === 'MicrosoftEdge') {
+        return browserDriver.getWindowSize()
+        .then(function(size) {
+          return browserDriver.setWindowSize(size.width, size.height)
+        })
+        .then(willLog(sessionName, 'reset window size (Edge-only workaround)'))
+      }
+    })
+    .then(function() {
+      return [browserDriver.safeExecute('document.documentElement.scrollHeight'),
+              browserDriver.safeExecute('document.documentElement.clientHeight')];
+    })
+    .spread(function(scrollHeight, viewportHeight) {
+      console.log(sessionName, 'get scrollHeight, clientHeight', scrollHeight, viewportHeight);
 
-          // Firefox and Internet Explorer will take a screenshot of the entire webpage,
-          if (cfg.browserName != 'Safari' && cfg.browserName != 'Chrome' && cfg.browserName != 'MicrosoftEdge') {
-            // saves file in the file `piecesDir/browser_version_platform/*.png`
-            var filename = piecesDir+'/'+browser+'_'+platform+'.png';
-            browserDriver.saveScreenshot(filename, function(err) {
-              if (err) console.log(err);
-              console.log(cfg.browserName,cfg.platform,'saveScreenshot');
+      // the easy case: Firefox and IE return a screenshot of the entire webpage
+      if (cfg.browserName === 'Firefox' || cfg.browserName === 'Internet Explorer') {
+        return browserDriver.saveScreenshot(baseDir + '/imgs/' + fileName + '.png')
+        .then(willLog(sessionName, 'saveScreenshot'))
+      // the hard case: for Chrome, Safari, and Edge, scroll through the page and
+      // take screenshots of each piece; circle.yml will stitch them together
+      } else {
+        var piecesDir = baseDir + '/imgs/pieces/' + fileName + '/';
+        fs.mkdirSync(piecesDir);
 
-              browserDriver.log('browser', function(err,logs) {
-                if (err) console.log(err);
-                console.log(cfg.browserName,cfg.platform,'log');
+        var scrollTop = 0;
+        var index = 1;
 
-                var logfile = baseDir+'/'+browser+'_'+platform+'.log'
-                logs = logs || [];
-                fs.writeFile(logfile,logs.join('\n'), function(err) {
-                  if (err) console.log(err);
+        return (function loop() {
+          return browserDriver.safeEval('window.scrollTo(0,'+scrollTop+');')
+          .then(willLog(sessionName, 'scrollTo()'))
+          .saveScreenshot(piecesDir + index + '.png')
+          .then(function() {
+            console.log(sessionName, 'saveScreenshot');
 
-                  browserDriver.quit();
-                });
-              });
+            scrollTop += viewportHeight;
+            index += 1;
 
-            });
-          } else {
-            var scrollTop = 0;
-
-            // loop generates the images. Firefox and Internet Explorer will take
-            // a screenshot of the entire webpage, but Opera, Safari, and Chrome
-            // do not. For those browsers we scroll through the page and take
-            // incremental screenshots.
-            (function loop() {
-              var index = (scrollTop/viewportHeight) + 1;
-
-              // Use `window.scrollTo` because thats what jQuery does
-              // https://github.com/jquery/jquery/blob/1.12.3/src/offset.js#L186
-              // `window.scrollTo` was used instead of jQuery because jQuery was
+            // if the viewport hasn't passed the bottom edge of the page yet,
+            // scroll down and take another screenshot
+            if (scrollTop + viewportHeight <= scrollHeight) {
+              // Use `window.scrollTo` because thats what jQuery does:
+              //   https://github.com/jquery/jquery/blob/1.12.3/src/offset.js#L186
+              // Use `window.scrollTo` instead of jQuery because jQuery was
               // causing a stackoverflow in Safari.
-              browserDriver.safeEval('window.scrollTo(0,'+scrollTop+');', function(err) {
-                if (err) console.log(JSON.stringify(err));
-                console.log(cfg.browserName,cfg.platform,'safeEval 1');
+              return loop();
+            } else { // we are past the bottom edge of the page, reduce window size to
+              // fit only the part of the page that hasn't been screenshotted.
 
-                // saves file in the file `piecesDir/browser_version_platform/#.png`
-                var filename = piecesDir+'/'+index+'.png';
-                browserDriver.saveScreenshot(filename, function(err) {
-                  if (err) console.log(err);
-                  console.log(cfg.browserName,cfg.platform,'saveScreenshot');
+              // If there is no remaining part of the page, we're done, short-circuit
+              if (scrollTop === scrollHeight) return browserDriver;
 
-                  scrollTop += viewportHeight;
-                  if (scrollTop + viewportHeight > scrollHeight) {
-                    browserDriver.getWindowSize(function(err,size) {
-                      if (err) console.log(err);
-                      console.log(cfg.browserName,cfg.platform,'getWindowSize');
-                      // account for the viewport offset
-                      var extra = size.height - viewportHeight;
-                      browserDriver.setWindowSize(size.width, (scrollHeight-scrollTop)+extra, function(err) {
-                        if (err) console.log(err);
-                        console.log(cfg.browserName,cfg.platform,'setWindowSize');
-
-                        browserDriver.safeEval('window.scrollTo(0,'+scrollHeight+');', function(err) {
-                          if (err) console.log(JSON.stringify(err));
-                          console.log(cfg.browserName,cfg.platform,'safeEval 2');
-
-                          index++;
-                          var filename = piecesDir+'/'+index+'.png';
-                          browserDriver.saveScreenshot(filename, function(err) {
-                            if (err) console.log(err);
-                            console.log(cfg.browserName,cfg.platform,'saveScreenshot Final');
-
-                            browserDriver.log('browser', function(err,logs) {
-                              if (err) console.log(err);
-                              console.log(cfg.browserName,cfg.platform,'log');
-
-                              var logfile = baseDir+'/'+browser+'_'+platform+'.log'
-                              logs = logs || [];
-                              fs.writeFile(logfile,logs.join('\n'), function(err) {
-                                if (err) console.log(err);
-                                console.log(cfg.browserName,cfg.platform,'writeFile');
-
-                                browserDriver.quit();
-                              });
-                            });
-                          });
-                        });
-
-                      });
-                    });
-                  } else {
-                    loop();
-                  }
-                });
+              return browserDriver.getWindowSize()
+              .then(function(windowSize) {
+                console.log(sessionName, 'getWindowSize');
+                // window size is a little bigger than the viewport because of address
+                // bar and scrollbars and stuff
+                var windowPadding = windowSize.height - viewportHeight;
+                var newWindowHeight = scrollHeight - scrollTop + windowPadding;
+                return browserDriver.setWindowSize(windowSize.width, newWindowHeight)
+                .then(willLog(sessionName, 'setWindowSize'))
+                .safeEval('window.scrollTo(0,'+scrollHeight+');')
+                .then(willLog(sessionName, 'scrollTo() Final'))
+                .saveScreenshot(piecesDir + index + '.png')
+                .then(willLog(sessionName, 'saveScreenshot Final'));
               });
-            })();
-          }
-        });
+            }
+          });
+        }());
+      }
+    })
+    .then(function() {
+      return browserDriver.log('browser')
+      .then(function(logs) {
+        var logfile = baseDir + '/browser_logs/' + sessionName.replace(/ /g, '_') + '.log';
+        return new Promise(function(resolve, reject) {
+          fs.writeFile(logfile, JSON.stringify(logs, null, 2), function(err) {
+            err ? reject(err) : resolve();
+          });
+        })
+        .then(willLog(sessionName, 'writeFile'));
+      }, function(err) {
+        // the Edge, IE, and Firefox-on-macOS drivers don't support logs, but the others do
+        console.log(sessionName, 'Error fetching logs:', JSON.stringify(err, null, 2));
       });
     });
-  });
+  })
+  .sauceJobStatus(true)
+  .fail(function(err) {
+    console.log('ERROR:', browser.config.browserName, browser.config.platform);
+    console.log(JSON.stringify(err, null, 2));
+    return browserDriver.sauceJobStatus(false);
+  })
+  .quit();
+
+  function willLog() {
+    var msg = [].join.call(arguments, ' ');
+    return function(value) {
+      console.log(msg);
+      return value;
+    };
+  }
 });
diff --git a/src/commands/math/LatexCommandInput.js b/src/commands/math/LatexCommandInput.js
index fb4e18e..19dfc82 100644
--- a/src/commands/math/LatexCommandInput.js
+++ b/src/commands/math/LatexCommandInput.js
@@ -33,7 +33,7 @@
       if (ch.match(/[a-z]/i)) VanillaSymbol(ch).createLeftOf(cursor);
       else {
         this.parent.renderCommand(cursor);
-        if (ch !== '\\' || !this.isEmpty()) this.parent.parent.write(cursor, ch);
+        if (ch !== '\\' || !this.isEmpty()) cursor.parent.write(cursor, ch);
       }
     };
     this.ends[L].keystroke = function(key, e, ctrlr) {
diff --git a/src/commands/math/advancedSymbols.js b/src/commands/math/advancedSymbols.js
index 3ce1318..28a685a 100644
--- a/src/commands/math/advancedSymbols.js
+++ b/src/commands/math/advancedSymbols.js
@@ -304,9 +304,9 @@
       bind(VanillaSymbol, '\\nexists ', '&#8708;');
 
 LatexCmds.and = LatexCmds.land = LatexCmds.wedge =
-  bind(VanillaSymbol,'\\wedge ','&and;');
+  bind(BinaryOperator,'\\wedge ','&and;');
 
-LatexCmds.or = LatexCmds.lor = LatexCmds.vee = bind(VanillaSymbol,'\\vee ','&or;');
+LatexCmds.or = LatexCmds.lor = LatexCmds.vee = bind(BinaryOperator,'\\vee ','&or;');
 
 LatexCmds.o = LatexCmds.O =
 LatexCmds.empty = LatexCmds.emptyset =
diff --git a/src/commands/math/commands.js b/src/commands/math/commands.js
index f60f054..68838bb 100644
--- a/src/commands/math/commands.js
+++ b/src/commands/math/commands.js
@@ -81,6 +81,17 @@
 LatexCmds.overline = LatexCmds.bar = bind(Style, '\\overline', 'span', 'class="mq-non-leaf mq-overline"');
 LatexCmds.overrightarrow = bind(Style, '\\overrightarrow', 'span', 'class="mq-non-leaf mq-overarrow mq-arrow-right"');
 LatexCmds.overleftarrow = bind(Style, '\\overleftarrow', 'span', 'class="mq-non-leaf mq-overarrow mq-arrow-left"');
+LatexCmds.overleftrightarrow = bind(Style, '\\overleftrightarrow', 'span', 'class="mq-non-leaf mq-overarrow mq-arrow-both"');
+LatexCmds.overarc = bind(Style, '\\overarc', 'span', 'class="mq-non-leaf mq-overarc"');
+LatexCmds.dot = P(MathCommand, function(_, super_) {
+    _.init = function() {
+        super_.init.call(this, '\\dot', '<span class="mq-non-leaf"><span class="mq-dot-recurring-inner">'
+            + '<span class="mq-dot-recurring">&#x2d9;</span>'
+            + '<span class="mq-empty-box">&0</span>'
+            + '</span></span>'
+        );
+    };
+});
 
 // `\textcolor{color}{math}` will apply a color to the given math content, where
 // `color` is any valid CSS Color Value (see [SitePoint docs][] (recommended),
@@ -319,6 +330,15 @@
     this.sup.downOutOf = insLeftOfMeUnlessAtEnd;
     super_.finalizeTree.call(this);
   };
+  _.reflow = function() {
+     var $block = this.jQ;//mq-supsub
+
+     var h = $block.prev().innerHeight() ;
+     h *= 0.6 ;
+
+     $block.css( 'vertical-align',  h + 'px' ) ;
+
+  } ;
 });
 
 var SummationNotation = P(MathCommand, function(_, super_) {
diff --git a/src/commands/text.js b/src/commands/text.js
index 59614ee..5af2946 100644
--- a/src/commands/text.js
+++ b/src/commands/text.js
@@ -65,7 +65,7 @@
   _.latex = function() {
     var contents = this.textContents();
     if (contents.length === 0) return '';
-    return '\\text{' + contents + '}';
+    return '\\text{' + contents.replace(/\\/g, '\\backslash ').replace(/[{}]/g, '\\$&') + '}';
   };
   _.html = function() {
     return (
diff --git a/src/css/font.less b/src/css/font.less
index af1e7f6..6c7b1dc 100644
--- a/src/css/font.less
+++ b/src/css/font.less
@@ -9,24 +9,24 @@
 
 @basic:;
 .font-srcs() when not (@basic) {
-  src: url(font/Symbola.eot);
+  src: url(fonts/Symbola.eot);
   src:
     local("Symbola Regular"),
     local("Symbola"),
-    url(font/Symbola.woff2) format("woff2"),
-    url(font/Symbola.woff) format("woff"),
-    url(font/Symbola.ttf) format("truetype"),
-    url(font/Symbola.svg#Symbola) format("svg")
+    url(fonts/Symbola.woff2) format("woff2"),
+    url(fonts/Symbola.woff) format("woff"),
+    url(fonts/Symbola.ttf) format("truetype"),
+    url(fonts/Symbola.svg#Symbola) format("svg")
   ;
 }
 .font-srcs() when (@basic) {
-  src: url(font/Symbola-basic.eot);
+  src: url(fonts/Symbola-basic.eot);
   src:
     local("Symbola Regular"),
     local("Symbola"),
-    url(font/Symbola-basic.woff2) format("woff2"),
-    url(font/Symbola-basic.woff) format("woff"),
-    url(font/Symbola-basic.ttf) format("truetype")
+    url(fonts/Symbola-basic.woff2) format("woff2"),
+    url(fonts/Symbola-basic.woff) format("woff"),
+    url(fonts/Symbola-basic.ttf) format("truetype")
   ;
 }
 
diff --git a/src/css/math.less b/src/css/math.less
index 389058c..dfa53e2 100644
--- a/src/css/math.less
+++ b/src/css/math.less
@@ -50,7 +50,9 @@
 
   .mq-text-mode {
     display: inline-block;
+    white-space: pre;  
   }
+
   .mq-text-mode.mq-hasCursor {
     box-shadow: inset darkgray 0 .1em .2em;
     padding: 0 .1em;
@@ -259,7 +261,7 @@
     display: inline-block;
   }
 
-  .mq-numerator, .mq-denominator {
+  .mq-numerator, .mq-denominator, .mq-dot-recurring {
     display: block;
   }
 
@@ -274,6 +276,11 @@
     padding: 0.1em;
   }
 
+  .mq-dot-recurring {
+    text-align: center;
+    height: 0.3em;
+  }
+
   ////
   // \sqrt
   // square roots
@@ -343,10 +350,24 @@
     font-family: @symbola;
   }
 
+  .mq-overarc {
+    border-top: 1px solid black;
+    -webkit-border-top-right-radius: 50% .3em;
+    -moz-border-radius-topright: 50% .3em;
+    border-top-right-radius: 50% .3em;
+    -webkit-border-top-left-radius: 50% .3em;
+    -moz-border-radius-topleft: 50% .3em;
+    border-top-left-radius: 50% .3em;
+    margin-top: 1px;
+    padding-top: 0.15em;
+  }
+
   .mq-overarrow {
+    min-width: .5em;
     border-top: 1px solid black;
     margin-top: 1px;
     padding-top: 0.2em;
+    text-align: center;
 
     &:before {
       display: block;
@@ -365,5 +386,34 @@
       filter: FlipH;
       -ms-filter: "FlipH";
     }
+    &.mq-arrow-both {
+      vertical-align: text-bottom;
+
+      &.mq-empty {
+        min-height: 1.23em;
+
+        &:after {
+          top: -0.34em;
+        }
+      }
+      &:before{
+        -moz-transform: scaleX(-1);
+        -o-transform: scaleX(-1);
+        -webkit-transform: scaleX(-1);
+        transform: scaleX(-1);
+        filter: FlipH;
+        -ms-filter: "FlipH";
+      }
+      &:after {
+        display: block;
+        position: relative;
+        top: -2.3em;
+        font-size: 0.5em;
+        line-height: 0em;
+        content: '\27A4';
+        visibility: visible; //must override .mq-editable-field.mq-empty:after
+        text-align: right;
+      }
+    }
   }
 }
diff --git a/src/font/Symbola-basic.eot b/src/fonts/Symbola-basic.eot
similarity index 100%
rename from src/font/Symbola-basic.eot
rename to src/fonts/Symbola-basic.eot
Binary files differ
diff --git a/src/font/Symbola-basic.ttf b/src/fonts/Symbola-basic.ttf
similarity index 100%
rename from src/font/Symbola-basic.ttf
rename to src/fonts/Symbola-basic.ttf
Binary files differ
diff --git a/src/font/Symbola-basic.woff b/src/fonts/Symbola-basic.woff
similarity index 100%
rename from src/font/Symbola-basic.woff
rename to src/fonts/Symbola-basic.woff
Binary files differ
diff --git a/src/font/Symbola-basic.woff2 b/src/fonts/Symbola-basic.woff2
similarity index 100%
rename from src/font/Symbola-basic.woff2
rename to src/fonts/Symbola-basic.woff2
Binary files differ
diff --git a/src/font/Symbola.eot b/src/fonts/Symbola.eot
similarity index 100%
rename from src/font/Symbola.eot
rename to src/fonts/Symbola.eot
Binary files differ
diff --git a/src/font/Symbola.svg b/src/fonts/Symbola.svg
similarity index 100%
rename from src/font/Symbola.svg
rename to src/fonts/Symbola.svg
diff --git a/src/font/Symbola.ttf b/src/fonts/Symbola.ttf
similarity index 100%
rename from src/font/Symbola.ttf
rename to src/fonts/Symbola.ttf
Binary files differ
diff --git a/src/font/Symbola.woff b/src/fonts/Symbola.woff
similarity index 100%
rename from src/font/Symbola.woff
rename to src/fonts/Symbola.woff
Binary files differ
diff --git a/src/font/Symbola.woff2 b/src/fonts/Symbola.woff2
similarity index 100%
rename from src/font/Symbola.woff2
rename to src/fonts/Symbola.woff2
Binary files differ
diff --git a/src/publicapi.js b/src/publicapi.js
index 84b13db..63cd46f 100644
--- a/src/publicapi.js
+++ b/src/publicapi.js
@@ -160,6 +160,15 @@
       if (this.__controller.blurred) this.__controller.cursor.hide().parent.blur();
       return this;
     };
+    _.empty = function() {
+      var root = this.__controller.root, cursor = this.__controller.cursor;
+      root.eachChild('postOrder', 'dispose');
+      root.ends[L] = root.ends[R] = 0;
+      root.jQ.empty();
+      delete cursor.selection;
+      cursor.insAtRightEnd(root);
+      return this;
+    };
     _.cmd = function(cmd) {
       var ctrlr = this.__controller.notify(), cursor = ctrlr.cursor;
       if (/^\\[a-z]+$/i.test(cmd)) {
diff --git a/src/services/keystroke.js b/src/services/keystroke.js
index fc3fc8e..a53cb5a 100644
--- a/src/services/keystroke.js
+++ b/src/services/keystroke.js
@@ -241,11 +241,15 @@
   _.ctrlDeleteDir = function(dir) {
     prayDirection(dir);
     var cursor = this.cursor;
-    if (!cursor[L] || cursor.selection) return this.deleteDir();
+    if (!cursor[dir] || cursor.selection) return this.deleteDir(dir);
 
     this.notify('edit');
-    Fragment(cursor.parent.ends[L], cursor[L]).remove();
-    cursor.insAtDirEnd(L, cursor.parent);
+    if (dir === L) {
+      Fragment(cursor.parent.ends[L], cursor[L]).remove();
+    } else {
+      Fragment(cursor[R], cursor.parent.ends[R]).remove();
+    };
+    cursor.insAtDirEnd(dir, cursor.parent);
 
     if (cursor[L].siblingDeleted) cursor[L].siblingDeleted(cursor.options, R);
     if (cursor[R].siblingDeleted) cursor[R].siblingDeleted(cursor.options, L);
diff --git a/src/services/textarea.js b/src/services/textarea.js
index eaed10c..8782e26 100644
--- a/src/services/textarea.js
+++ b/src/services/textarea.js
@@ -48,7 +48,8 @@
     var ctrlr = this, root = ctrlr.root, cursor = ctrlr.cursor,
       textarea = ctrlr.textarea, textareaSpan = ctrlr.textareaSpan;
 
-    this.container.prepend('<span class="mq-selectable">$'+ctrlr.exportLatex()+'$</span>');
+    this.container.prepend(jQuery('<span class="mq-selectable">')
+      .text('$'+ctrlr.exportLatex()+'$'));
     ctrlr.blurred = true;
     textarea.bind('cut paste', false)
     .bind('copy', function() { ctrlr.setTextareaSelection(); })
diff --git a/test/unit/latex.test.js b/test/unit/latex.test.js
index 7baa156..e6e4096 100644
--- a/test/unit/latex.test.js
+++ b/test/unit/latex.test.js
@@ -329,4 +329,18 @@
     testCantParse('langlerfish/ranglerfish (checking for confusion with langle/rangle)',
 		    '\\left\\langlerfish 123\\right\\ranglerfish)');
   });
+
+  suite('selectable span', function() {
+    setup(function() {
+      MQ.StaticMath($('<span>2&lt;x</span>').appendTo('#mock')[0]);
+    });
+
+    function selectableContent() {
+      return document.querySelector('#mock .mq-selectable').textContent;
+    }
+
+    test('escapes < in textContent', function () {
+      assert.equal(selectableContent(), '$2<x$');
+    });
+  });
 });
diff --git a/test/unit/publicapi.test.js b/test/unit/publicapi.test.js
index 4395cc8..f980855 100644
--- a/test/unit/publicapi.test.js
+++ b/test/unit/publicapi.test.js
@@ -108,7 +108,7 @@
       mq.latex('x+y');
       assert.equal(mq.html(), '<var>x</var><span class="mq-binary-operator">+</span><var>y</var>');
     });
-    
+
     test('.text() with incomplete commands', function() {
       assert.equal(mq.text(), '');
       mq.typedText('\\');
@@ -157,6 +157,12 @@
       assert.equal(mq.__controller.cursor[L].ctrlSeq, '0');
       assert.equal(mq.__controller.cursor[R], 0);
     });
+
+    test('.empty()', function() {
+      mq.latex('xyz');
+      mq.empty();
+      assert.equal(mq.latex(), '');
+    });
   });
 
   test('edit handler interface versioning', function() {
@@ -515,7 +521,7 @@
       });
       test('backslashes', function() {
         assertPaste('something \\pi something \\asdf',
-                    '\\text{something \\pi something \\asdf}');
+                    '\\text{something \\backslash pi something \\backslash asdf}');
       });
       // TODO: braces (currently broken)
       test('actual math LaTeX wrapped in dollar signs', function() {
diff --git a/test/unit/typing.test.js b/test/unit/typing.test.js
index c2f8462..c77fc90 100644
--- a/test/unit/typing.test.js
+++ b/test/unit/typing.test.js
@@ -57,6 +57,11 @@
       mq.typedText('$');
       assertLatex('\\$');
     });
+
+    test('\\text followed by command', function() {
+      mq.typedText('\\text{');
+      assertLatex('\\text{\\{}');
+    });
   });
 
   suite('auto-expanding parens', function() {
@@ -472,6 +477,26 @@
         assertLatex('\\left(1+2\\right)+3+4+5');
       });
 
+      test('typing Ctrl-Backspace deletes everything to the left of the cursor', function () {
+        mq.typedText('12345');
+        assertLatex('12345');
+        mq.keystroke('Left Left');
+        mq.keystroke('Ctrl-Backspace');
+        assertLatex('45');
+        mq.keystroke('Ctrl-Backspace');
+        assertLatex('45');
+      });
+
+      test('typing Ctrl-Del deletes everything to the right of the cursor', function () {
+        mq.typedText('12345');
+        assertLatex('12345');
+        mq.keystroke('Left Left');
+        mq.keystroke('Ctrl-Del');
+        assertLatex('123');
+        mq.keystroke('Ctrl-Del');
+        assertLatex('123');
+      });
+
       suite('pipes', function() {
         test('typing then backspacing a pipe in the middle of 1+2+3+4', function() {
           mq.typedText('1+2+3+4');
diff --git a/test/visual.html b/test/visual.html
index a2207d1..3d091f5 100644
--- a/test/visual.html
+++ b/test/visual.html
@@ -212,6 +212,11 @@
 <tr><td><span class="mathquill-static-math">\vec x + \tilde x + \vec A + \tilde A + \vec{abcd} + \tilde{abcd}</span><td><span>\vec x + \tilde x + \vec A + \tilde A + \vec{abcd} + \tilde{abcd}</span>^M

 <tr><td><span class="mathquill-static-math">\int _{\phi =0}^{2\pi }\int _{\theta =0}^{\pi }\int _{r=0}^{\infty }f(r,\theta ,\phi )r^2\sin \theta drd\theta d\phi </span><td><span>\int _{\phi =0}^{2\pi }\int _{\theta =0}^{\pi }\int _{r=0}^{\infty }f(r,\theta ,\phi )r^2\sin \theta drd\theta d\phi </span>

 <tr><td><span class="mathquill-static-math">\int_0^{\frac{\frac{1}{2}}{3}} \int_0^{\frac{1}{\frac{2}{3}}} \int_0^{\frac{1}{\frac{2}{\frac{3}{\frac{4}{5}}}}}</span><td><span>\int_0^{\frac{\frac{1}{2}}{3}} \int_0^{\frac{1}{\frac{2}{3}}} \int_0^{\frac{1}{\frac{2}{\frac{3}{\frac{4}{5}}}}}</span>

+<tr><td><span class="mathquill-static-math">\overline{abc}</span><td><span>\overline{abc}</span>

+<tr><td><span class="mathquill-static-math">\overleftarrow{abc}</span><td><span>\overleftarrow{abc}</span>

+<tr><td><span class="mathquill-static-math">\overrightarrow{abc}</span><td><span>\overrightarrow{abc}</span>

+<tr><td><span class="mathquill-static-math">\overleftrightarrow{abc}</span><td><span>\overleftrightarrow{abc}</span>

+<tr><td><span class="mathquill-static-math">\overarc{abc}</span><td><span>\overarc{abc}</span>

 <tr><td colspan=2><span id="sixes"></span>

 <script>

 $(function() {

@@ -282,6 +287,12 @@
 

 <p>Should wrap anything you type in '&lt;&gt;': <span id="wrap-typing">1+2+3</span></p>

 

+<h3>Text mode</h3>

+

+<p>Spaces at the beginning and end of text mode blocks should be visible: <span class="mathquill-static-math">1\text{ And }2</span></p>

+

+<p>Mutiple consecutive spaces in the middle of a text mode block should not collapse into one space: <span class="mathquill-static-math">\text{three   spaces}</span></p>

+

 </div>

 <script type="text/javascript">

 window.onerror = function(err) {