Merge "veyron2/security: Return details on why blessings fail validation. MultiPart: 6/7 https://github.com/veyron/release-issues/issues/942"
diff --git a/client/README.md b/client/README.md
index 8f45136..3b3c328 100644
--- a/client/README.md
+++ b/client/README.md
@@ -9,7 +9,7 @@
 * _package.json_ - Used by `npm install` to grab playground dependencies.
 * `public` - Deployed client website, served by `npm start`.
 * `src/javascript` - Scripts implementing the playground client.
-* `src/static` - HTML and other static resources for a simple embedded client instance.
+* `src/static` - HTML and other static resources for a simple page with a client instance.
 * `src/stylesheets` - CSS for playground editor and output.
 * _test.sh_ - Script testing correctness of default playground examples.
 
diff --git a/client/package.json b/client/package.json
index ac52f91..c1193b7 100644
--- a/client/package.json
+++ b/client/package.json
@@ -13,6 +13,7 @@
     "minifyify": "^6.1.0",
     "moment": "^2.9.0",
     "pgbundle": "0.0.1",
+    "spin.js": "^2.0.2",
     "superagent": "^0.21.0"
   },
   "homepage": "https://vanadium.googlesource.com/release.projects.playground/+/master/client",
diff --git a/client/src/javascript/embedded.js b/client/src/javascript/embedded.js
deleted file mode 100644
index ce0bdb7..0000000
--- a/client/src/javascript/embedded.js
+++ /dev/null
@@ -1,331 +0,0 @@
-module.exports = EmbeddedPlayground;
-
-var _ = require('lodash');
-var http = require('http');
-var moment = require('moment');
-var path = require('path');
-var url = require('url');
-
-var m = require('mercury');
-var h = m.h;
-
-var Editor = require('./editor');
-
-// Shows each file in a tab.
-// * el: The DOM element to mount on.
-// * id: Identifier for this playground instance, used in debug messages.
-// * files: List of {name, body}, as written by bundler.
-function EmbeddedPlayground(el, id, files) {
-  this.id_ = id;
-  this.files_ = _.map(files, function(file) {
-    return _.assign({}, file, {
-      basename: path.basename(file.name),
-      type: path.extname(file.name).substr(1)
-    });
-  });
-  this.editors_ = _.map(this.files_, function(file) {
-    return new Editor(file.type, file.body);
-  });
-  // scrollState_ changes should not trigger render_, thus are not monitored
-  // by mercury.
-  this.scrollState_ = {bottom: true};
-  var state = m.state({
-    activeTab: m.value(0),
-    nextRunId: m.value(0),
-    running: m.value(false),
-    hasRun: m.value(false),
-    consoleEvents: m.value([]),
-    channels: {
-      run: this.run.bind(this),
-      reset: this.reset.bind(this),
-      switchTab: this.switchTab.bind(this)
-    }
-  });
-  m.app(el, state, this.render_.bind(this));
-  // Enable ev-scroll listening.
-  m.Delegator().listenTo('scroll');
-}
-
-EmbeddedPlayground.prototype.renderTopBar_ = function(state) {
-  var tabs = _.map(this.files_, function(file, i) {
-    var selector = 'div.tab';
-    if (i === state.activeTab) {
-      selector += '.active';
-    }
-    return h(selector, {
-      'ev-click': m.sendClick(state.channels.switchTab, {index: i})
-    }, file.basename);
-  });
-
-  var runBtn = h('button.btn', {
-    'ev-click': m.sendClick(state.channels.run)
-  }, 'Run');
-  var resetBtn = h('button.btn', {
-    'ev-click': m.sendClick(state.channels.reset)
-  }, 'Reset');
-
-  return h('div.top-bar', [h('div', tabs), h('div.btns', [runBtn, resetBtn])]);
-};
-
-EmbeddedPlayground.prototype.renderEditors_ = function(state) {
-  var editors = _.map(this.editors_, function(editor, i) {
-    var properties = {};
-    if (i !== state.activeTab) {
-      // Use "visibility: hidden" rather than "display: none" because the latter
-      // causes the editor to initialize lazily and thus flicker when it's first
-      // opened.
-      properties['style'] = {visibility: 'hidden'};
-    }
-    return h('div.editor', properties, editor);
-  });
-
-  return h('div.editors', editors);
-};
-
-EmbeddedPlayground.prototype.renderConsoleEvent_ = function(event) {
-  var children = [];
-  if (event.Timestamp) {
-    // Convert UTC to local time.
-    var t = moment(event.Timestamp / 1e6);
-    children.push(h('span.timestamp', t.format('H:mm:ss.SSS') + ' '));
-  }
-  if (event.File) {
-    children.push(h('span.filename', path.basename(event.File) + ': '));
-  }
-  // A single trailing newline is always ignored.
-  // Ignoring the last character, check if there are any newlines in message.
-  if (event.Message.slice(0, -1).indexOf('\n') !== -1) {
-    // Multiline messages are marked with U+23CE and started in a new line.
-    children.push('\u23ce'/* U+23CE RETURN SYMBOL */, h('br'));
-  }
-  children.push(h('span.message.' + (event.Stream || 'unknown'),
-                  event.Message));
-  return h('div', children);
-};
-
-// ScrollHandle provides a hook to keep the console scrolled to the bottom
-// unless the user has scrolled up, and the update method to detect the
-// user scrolling up.
-function ScrollHandle(scrollState) {
-  this.scrollState_ = scrollState;
-}
-
-ScrollHandle.prototype.hook = function(elem, propname) {
-  var scrollState = this.scrollState_;
-  process.nextTick(function() {
-    if (scrollState.bottom) {
-      elem.scrollTop = elem.scrollHeight - elem.clientHeight;
-    }
-  });
-};
-
-ScrollHandle.prototype.update = function(ev) {
-  var elem = ev.target;
-  this.scrollState_.bottom =
-      elem.scrollTop === elem.scrollHeight - elem.clientHeight;
-};
-
-EmbeddedPlayground.prototype.renderConsole_ = function(state) {
-  if (state.hasRun) {
-    var scrollHandle = new ScrollHandle(this.scrollState_);
-    return h('div.console.open', {
-      'ev-scroll': scrollHandle.update.bind(scrollHandle),
-      'scrollhook': scrollHandle
-    }, [
-      h('div.text', _.map(state.consoleEvents, this.renderConsoleEvent_))
-    ]);
-  }
-  return h('div.console');
-};
-
-EmbeddedPlayground.prototype.render_ = function(state) {
-  return h('div.pg', [
-    this.renderTopBar_(state),
-    this.renderEditors_(state),
-    this.renderConsole_(state)
-  ]);
-};
-
-EmbeddedPlayground.prototype.switchTab = function(state, data) {
-  state.activeTab.set(data.index);
-};
-
-// Sends the files to the backend, then injects the response in the console.
-EmbeddedPlayground.prototype.run = function(state) {
-  if (state.running()) {
-    console.log('Already running', this.id_);
-    return;
-  }
-  var runId = state.nextRunId();
-
-  // TODO(sadovsky): Visually disable the "Run" button or change it to a "Stop"
-  // button.
-  state.running.set(true);
-  state.hasRun.set(true);
-  state.consoleEvents.set([{Message: 'Running...'}]);
-  this.scrollState_.bottom = true;
-
-  var myUrl = url.parse(window.location.href, true);
-  var pgaddr = myUrl.query.pgaddr;
-  if (pgaddr) {
-    console.log('Using pgaddr', pgaddr);
-  } else {
-    pgaddr = 'https://staging.v.io/playground';
-  }
-  var compileUrl = pgaddr + '/compile';
-  if (myUrl.query.debug === '1') {
-    compileUrl += '?debug=1';
-  }
-
-  var editors = this.editors_;
-  var reqData = {
-    files: _.map(this.files_, function(file, i) {
-      var editor = editors[i];
-      return {
-        Name: file.name,
-        Body: editor.getText()
-      };
-    }),
-    Identities: []
-  };
-
-  // TODO(sadovsky): To deal with cached responses, shift timestamps (based on
-  // current time) and introduce a fake delay. Also, switch to streaming
-  // messages, for usability.
-  var that = this;
-
-  // If the user stops the current run or resets the playground, functions
-  // wrapped with ifRunActive become no-ops.
-  var ifRunActive = function(cb) {
-    return function() {
-      if (runId === state.nextRunId()) {
-        cb.apply(this, arguments);
-      }
-    };
-  };
-
-  var appendToConsole = function(events) {
-    state.consoleEvents.set(state.consoleEvents().concat(events));
-  };
-  var makeEvent = function(stream, message) {
-    return {Stream: stream, Message: message};
-  };
-
-  var urlp = url.parse(compileUrl);
-
-  var options = {
-    method: 'POST',
-    protocol: urlp.protocol,
-    hostname: urlp.hostname,
-    port: urlp.port || (urlp.protocol === 'https:' ? '443' : '80'),
-    path: urlp.path,
-    // TODO(ivanpi): Change once deployed.
-    withCredentials: false,
-    headers: {
-      'accept': 'application/json',
-      'content-type': 'application/json'
-    }
-  };
-
-  var req = http.request(options);
-
-  var watchdog = null;
-  // The heartbeat function clears the existing timeout (if any) and, if the run
-  // is still active, starts a new timeout.
-  var heartbeat = function() {
-    if (watchdog !== null) {
-      clearTimeout(watchdog);
-    }
-    watchdog = null;
-    ifRunActive(function() {
-      // TODO(ivanpi): Reduce timeout duration when server heartbeat is added.
-      watchdog = setTimeout(function() {
-        process.nextTick(ifRunActive(function() {
-          req.destroy();
-          appendToConsole(makeEvent('syserr', 'Server response timed out.'));
-        }));
-      }, 10500);
-    })();
-  };
-
-  var endRunIfActive = ifRunActive(function() {
-    that.endRun_(state);
-    // Cleanup watchdog timer.
-    heartbeat();
-  });
-
-  // error and close callbacks call endRunIfActive in the next tick to ensure
-  // that if both events are triggered, both are executed before the run is
-  // ended by either.
-  req.on('error', ifRunActive(function(err) {
-    console.error('Connection error: ' + err.message + '\n' + err.stack);
-    appendToConsole(makeEvent('syserr', 'Error connecting to server.'));
-    process.nextTick(endRunIfActive);
-  }));
-
-  // Holds partial prefix of next response line.
-  var partialLine = '';
-
-  req.on('response', ifRunActive(function(res) {
-    heartbeat();
-    if (res.statusCode !== 0 && res.statusCode !== 200) {
-      appendToConsole(makeEvent('syserr', 'HTTP status ' + res.statusCode));
-    }
-    res.on('data', ifRunActive(function(chunk) {
-      heartbeat();
-      // Each complete line is one JSON Event.
-      var eventsJson = (partialLine + chunk).split('\n');
-      partialLine = eventsJson.pop();
-      var events = [];
-      _.forEach(eventsJson, function(line) {
-        // Ignore empty lines.
-        line = line.trim();
-        if (line) {
-          var ev;
-          try {
-            ev = JSON.parse(line);
-          } catch (err) {
-            console.error('Error parsing line: ' + line + '\n' + err.message);
-            events.push(makeEvent('syserr', 'Error parsing server response.'));
-            endRunIfActive();
-            return false;
-          }
-          events.push(ev);
-        }
-      });
-      appendToConsole(events);
-    }));
-  }));
-
-  req.on('close', ifRunActive(function() {
-    // Sanity check: partialLine should be empty when connection is closed.
-    partialLine = partialLine.trim();
-    if (partialLine) {
-      console.error('Connection closed without newline after: ' + partialLine);
-      appendToConsole(makeEvent('syserr', 'Error parsing server response.'));
-    }
-    process.nextTick(endRunIfActive);
-  }));
-
-  req.write(JSON.stringify(reqData));
-  req.end();
-
-  // Start watchdog.
-  heartbeat();
-};
-
-// Clears the console and resets all editors to their original contents.
-EmbeddedPlayground.prototype.reset = function(state) {
-  state.consoleEvents.set([]);
-  this.scrollState_.bottom = true;
-  _.forEach(this.editors_, function(editor) {
-    editor.reset();
-  });
-  this.endRun_(state);
-  state.hasRun.set(false);
-};
-
-EmbeddedPlayground.prototype.endRun_ = function(state) {
-  state.nextRunId.set(state.nextRunId() + 1);
-  state.running.set(false);
-};
diff --git a/client/src/javascript/index.js b/client/src/javascript/index.js
index 8e65c0f..7fb0f38 100644
--- a/client/src/javascript/index.js
+++ b/client/src/javascript/index.js
@@ -1,8 +1,8 @@
 var _ = require('lodash');
 var path = require('path');
-var request = require('superagent');
+var superagent = require('superagent');
 
-var EmbeddedPlayground = require('./embedded');
+var Playground = require('./playground');
 
 _.forEach(document.querySelectorAll('.playground'), function(el) {
   var srcdir = el.getAttribute('data-srcdir');
@@ -14,14 +14,14 @@
         '<br>Bundle not found: <strong>' + srcdir + '</strong></p></div>';
       return;
     }
-    new EmbeddedPlayground(el, srcdir, bundle.files);  // jshint ignore:line
+    new Playground(el, srcdir, bundle);  // jshint ignore:line
   });
 });
 
 function fetchBundle(loc, cb) {
   var basePath = '/bundles';
   console.log('Fetching bundle', loc);
-  request
+  superagent
     .get(path.join(basePath, loc, 'bundle.json'))
     .accept('json')
     .end(function(err, res) {
diff --git a/client/src/javascript/playground.js b/client/src/javascript/playground.js
new file mode 100644
index 0000000..9e2095c
--- /dev/null
+++ b/client/src/javascript/playground.js
@@ -0,0 +1,602 @@
+module.exports = Playground;
+
+var _ = require('lodash');
+var http = require('http');
+var moment = require('moment');
+var path = require('path');
+var superagent = require('superagent');
+var url = require('url');
+
+var m = require('mercury');
+var h = m.h;
+
+var Editor = require('./widgets/editor');
+var Spinner = require('./widgets/spinner');
+
+// Timeout for save and load requests, in milliseconds.
+var storageRequestTimeout = 1000;
+
+// Shows each file in a tab.
+// * el: The DOM element to mount on.
+// * name: Name of this playground instance, used in debug messages.
+// * bundle: The default bundle formatted as {files:[{name, body}]}, as written
+//           by pgbundle, to use if id is unspecified or if load fails.
+function Playground(el, name, bundle) {
+  this.name_ = name;
+  this.defaultBundle_ = bundle;
+  this.files_ = [];
+  this.editors_ = [];
+  this.editorSpinner_ = new Spinner();
+  // scrollState_ changes should not trigger render_, thus are not monitored
+  // by mercury.
+  this.scrollState_ = {bottom: true};
+  // Mercury framework state. Changes to state trigger virtual DOM render.
+  var state = m.state({
+    notification: m.value({}),
+    // False on page load (no bundle), empty if default bundle is loaded.
+    bundleId: m.value(false),
+    // Incrementing counter on each bundle reload to force render.
+    bundleVersion: m.value(0),
+    activeTab: m.value(0),
+    idToLoad: m.value(''),
+    // Save or load request in progress.
+    requestActive: m.value(false),
+    nextRunId: m.value(0),
+    running: m.value(false),
+    hasRun: m.value(false),
+    consoleEvents: m.value([]),
+    // Mercury framework channels. References to event handlers callable from
+    // rendering code.
+    channels: {
+      switchTab: this.switchTab.bind(this),
+      setIdToLoad: this.setIdToLoad.bind(this),
+      // For testing. Load in production can be done purely using links.
+      // Reload is not necessary before adding history.
+      load: this.load.bind(this),
+      save: this.save.bind(this),
+      run: this.run.bind(this),
+      stop: this.stop.bind(this),
+      reset: this.reset.bind(this)
+    }
+  });
+  m.app(el, state, this.render_.bind(this));
+  // When page is first loaded, load bundle in url.
+  this.loadUrl_(state, window.location.href);
+  // When user goes forward/back, load bundle in url.
+  var that = this;
+  window.addEventListener('popstate', function(ev) {
+    console.log('window.onpopstate', ev);
+    that.loadUrl_(state, window.location.href);
+  });
+  // Enable ev-scroll listening.
+  m.Delegator().listenTo('scroll');
+}
+
+// Attempts to load the bundle with id specified in the url, or the default
+// bundle if id is not specified.
+Playground.prototype.loadUrl_ = function(state, pgurl) {
+  this.setNotification_(state);
+  this.url_ = url.parse(pgurl, true);
+  // Deleted to make url.format() use this.url_.query.
+  delete this.url_.search;
+  var bId = this.url_.query.id || '';
+  // Filled into idToLoad to allow retrying using Load button.
+  state.idToLoad.set(bId);
+  if (bId) {
+    console.log('Loading bundle', bId, 'from URL');
+    process.nextTick(this.load.bind(this, state, {id: bId}));
+  } else {
+    console.log('Loading default bundle');
+    process.nextTick(
+      this.setBundle_.bind(this, state, this.defaultBundle_, ''));
+  }
+};
+
+// Builds bundle object from editor contents.
+Playground.prototype.getBundle_ = function() {
+  var editors = this.editors_;
+  return {
+    files: _.map(this.files_, function(file, i) {
+      var editor = editors[i];
+      return {
+        name: file.name,
+        body: editor.getText()
+      };
+    })
+  };
+};
+
+// Loads bundle object into editor contents, updates url.
+Playground.prototype.setBundle_ = function(state, bundle, id) {
+  this.files_ = _.map(bundle.files, function(file) {
+    return _.assign({}, file, {
+      basename: path.basename(file.name),
+      type: path.extname(file.name).substr(1)
+    });
+  });
+  this.editors_ = _.map(this.files_, function(file) {
+    return new Editor(file.type, file.body);
+  });
+  state.activeTab.set(0);
+  this.resetState_(state);
+  state.bundleId.set(id);
+  // Increment counter in state to force render.
+  state.bundleVersion.set((state.bundleVersion() + 1) & 0x7fffffff);
+  this.setUrlForId_(id);
+};
+
+// Updates url with new id if different from the current one.
+Playground.prototype.setUrlForId_ = function(id) {
+  if (!id && !this.url_.query.id || id === this.url_.query.id) {
+    return;
+  }
+  if (!id) {
+    delete this.url_.query.id;
+  } else {
+    this.url_.query.id = id;
+  }
+  window.history.pushState(null, '', url.format(this.url_));
+};
+
+// Determines base url for backend calls.
+Playground.prototype.getBackendUrl_ = function() {
+  var pgaddr = this.url_.query.pgaddr;
+  if (pgaddr) {
+    console.log('Using pgaddr', pgaddr);
+  } else {
+    pgaddr = 'https://staging.v.io/playground';
+  }
+  return pgaddr;
+};
+
+// Shows notification, green if success is set, red otherwise.
+// Call with undefined/blank msg to clear notification.
+Playground.prototype.setNotification_ = function(state, msg, success) {
+  if (!msg) {
+    state.notification.set({});
+  } else {
+    state.notification.set({message: msg, ok: success});
+    // TODO(ivanpi): Expire message.
+  }
+};
+
+// Renders button with provided label and target.
+// If disable is true or a request is active, the button is disabled.
+Playground.prototype.button_ = function(state, label, target, disable) {
+  if (disable || state.requestActive) {
+    return h('button.btn', {
+      disabled: true
+    }, label);
+  } else {
+    return h('button.btn', {
+      'ev-click': target
+    }, label);
+  }
+};
+
+Playground.prototype.renderLoadBar_ = function(state) {
+  var idToLoad = h('input.bundleid', {
+    type: 'text',
+    name: 'idToLoad',
+    size: 64,
+    maxLength: 64,
+    value: state.idToLoad,
+    'ev-input': m.sendChange(state.channels.setIdToLoad)
+  });
+  var loadBtn = this.button_(state, 'Load',
+    m.sendClick(state.channels.load, {id: state.idToLoad}),
+    state.running);
+
+  return h('div.widget-bar', [
+    h('span', [idToLoad]),
+    h('span.btns', [loadBtn])
+  ]);
+};
+
+Playground.prototype.renderResetBar_ = function(state) {
+  var idShow = h('span.bundleid',
+    state.bundleId || (state.bundleId === '' ? '<default>' : '<none>'));
+  var link = h('a', {
+    href: window.location.href
+  }, 'link');
+  var notif = h('span.notif.' + (state.notification.ok ? 'success' : 'error'),
+    state.notification.message || '');
+
+  var resetBtn = this.button_(state, 'Reset',
+    m.sendClick(state.channels.reset),
+    state.bundleId === false);
+  var reloadBtn = this.button_(state, 'Reload',
+    m.sendClick(state.channels.load, {id: state.bundleId}),
+    state.running || !state.bundleId);
+
+  return h('div.widget-bar', [
+    h('span', [idShow, ' ', link, ' ', notif]),
+    h('span.btns', [resetBtn, reloadBtn])
+  ]);
+};
+
+Playground.prototype.renderTabBar_ = function(state) {
+  var tabs = _.map(this.files_, function(file, i) {
+    var selector = 'div.tab';
+    if (i === state.activeTab) {
+      selector += '.active';
+    }
+    return h(selector, {
+      'ev-click': m.sendClick(state.channels.switchTab, {index: i})
+    }, file.basename);
+  });
+
+  var runStopBtn = state.running ?
+    this.button_(state, 'Stop', m.sendClick(state.channels.stop)) :
+    this.button_(state, 'Run', m.sendClick(state.channels.run),
+      state.bundleId === false);
+  var saveBtn = this.button_(state, 'Save',
+    m.sendClick(state.channels.save),
+    state.running || (state.bundleId === false));
+
+  return h('div.widget-bar', [
+    h('span', tabs),
+    h('span.btns', [runStopBtn, saveBtn])
+  ]);
+};
+
+Playground.prototype.renderEditors_ = function(state) {
+  var editors = _.map(this.editors_, function(editor, i) {
+    var properties = {};
+    if (i !== state.activeTab) {
+      // Use "visibility: hidden" rather than "display: none" because the latter
+      // causes the editor to initialize lazily and thus flicker when it's first
+      // opened.
+      properties['style'] = {visibility: 'hidden'};
+    }
+    return h('div.editor', properties, editor);
+  });
+
+  if (state.requestActive) {
+    editors.push(this.editorSpinner_);
+  }
+
+  return h('div.editors', editors);
+};
+
+Playground.prototype.renderConsoleEvent_ = function(event) {
+  var children = [];
+  if (event.Timestamp) {
+    // Convert UTC to local time.
+    var t = moment(event.Timestamp / 1e6);
+    children.push(h('span.timestamp', t.format('H:mm:ss.SSS') + ' '));
+  }
+  if (event.File) {
+    children.push(h('span.filename', path.basename(event.File) + ': '));
+  }
+  // A single trailing newline is always ignored.
+  // Ignoring the last character, check if there are any newlines in message.
+  if (event.Message.slice(0, -1).indexOf('\n') !== -1) {
+    // Multiline messages are marked with U+23CE and started in a new line.
+    children.push('\u23ce'/* U+23CE RETURN SYMBOL */, h('br'));
+  }
+  children.push(h('span.message.' + (event.Stream || 'unknown'),
+                  event.Message));
+  return h('div', children);
+};
+
+// ScrollHandle provides a hook to keep the console scrolled to the bottom
+// unless the user has scrolled up, and the update method to detect the
+// user scrolling up.
+function ScrollHandle(scrollState) {
+  this.scrollState_ = scrollState;
+}
+
+ScrollHandle.prototype.hook = function(elem, propname) {
+  var scrollState = this.scrollState_;
+  process.nextTick(function() {
+    if (scrollState.bottom) {
+      elem.scrollTop = elem.scrollHeight - elem.clientHeight;
+    }
+  });
+};
+
+ScrollHandle.prototype.update = function(ev) {
+  var elem = ev.currentTarget;
+  this.scrollState_.bottom =
+      elem.scrollTop === elem.scrollHeight - elem.clientHeight;
+};
+
+Playground.prototype.renderConsole_ = function(state) {
+  if (state.hasRun) {
+    var scrollHandle = new ScrollHandle(this.scrollState_);
+    return h('div.console.open', {
+      'ev-scroll': scrollHandle.update.bind(scrollHandle),
+      'scrollhook': scrollHandle
+    }, [
+      h('div.text', _.map(state.consoleEvents, this.renderConsoleEvent_))
+    ]);
+  }
+  return h('div.console');
+};
+
+Playground.prototype.render_ = function(state) {
+  return h('div.pg', [
+    this.renderLoadBar_(state),
+    this.renderResetBar_(state),
+    this.renderTabBar_(state),
+    this.renderEditors_(state),
+    this.renderConsole_(state)
+  ]);
+};
+
+// Switches active tab to data.index.
+Playground.prototype.switchTab = function(state, data) {
+  this.setNotification_(state);
+  state.activeTab.set(data.index);
+};
+
+// Reads the idToLoad text box into state.
+Playground.prototype.setIdToLoad = function(state, formdata) {
+  this.setNotification_(state);
+  state.idToLoad.set(formdata.idToLoad);
+};
+
+Playground.prototype.showMessage_ = function(state, prefix, msg, ok) {
+  var fullMsg = prefix + ': ' + msg;
+  if (ok) {
+    console.log(fullMsg);
+  } else {
+    console.error(fullMsg);
+  }
+  this.setNotification_(state, fullMsg, ok);
+};
+
+// Returns callback to be used for save and load requests. Callback loads the
+// bundle returned from the server and updates bundleId and url.
+Playground.prototype.saveLoadCallback_ = function(state, operation) {
+  var log = this.showMessage_.bind(this, state, operation);
+  var that = this;
+  return function(rerr, res) {
+    state.requestActive.set(false);
+    var processResponse = function() {
+      if (rerr) {
+        if (rerr.timeout) {
+          log('request timed out');
+        } else {
+          log('error connecting to server: ' + rerr);
+        }
+        return;
+      }
+      if (res.body && res.body.Error) {
+        log('error ' + res.status + ': ' + res.body.Error);
+        // TODO(ivanpi): Special handling of 404? Retry on other errors?
+        return;
+      }
+      if (res.error) {
+        log('error ' + res.status + ': unknown');
+        return;
+      }
+      if (!res.body.Link || !res.body.Data) {
+        log('invalid response format');
+        return;
+      }
+      var bundle;
+      try {
+        bundle = JSON.parse(res.body.Data);
+      } catch (jerr) {
+        log('error parsing Data: ' + res.body.Data + '\n' + jerr.message);
+        return;
+      }
+      // Opens bundle in editors, updates bundleId and url.
+      that.setBundle_(state, bundle, res.body.Link);
+      log('success', true);
+      return true;
+    };
+    if (processResponse()) {
+      state.idToLoad.set('');
+    } else {
+      // Load/save failed.
+      if (state.bundleId() === false) {
+        // If no bundle was loaded, load default.
+        that.setBundle_(state, that.defaultBundle_, '');
+      } else {
+        // Otherwise, reset url to previously loaded bundle.
+        that.setUrlForId_(state.bundleId());
+      }
+    }
+  };
+};
+
+// Loads bundle for data.id.
+Playground.prototype.load = function(state, data) {
+  this.setNotification_(state);
+  if (!data.id) {
+    this.showMessage_(state, 'load', 'cannot load blank id');
+    return;
+  }
+  superagent
+    .get(this.getBackendUrl_() + '/load?id=' + encodeURIComponent(data.id))
+    .accept('json')
+    .timeout(storageRequestTimeout)
+    .end(this.saveLoadCallback_(state, 'load ' + data.id));
+  state.requestActive.set(true);
+};
+
+// Saves bundle and updates bundleId with the received id.
+Playground.prototype.save = function(state) {
+  this.setNotification_(state);
+  superagent
+    .post(this.getBackendUrl_() + '/save')
+    .type('json')
+    .accept('json')
+    .timeout(storageRequestTimeout)
+    .send(this.getBundle_())
+    .end(this.saveLoadCallback_(state, 'save'));
+  state.requestActive.set(true);
+};
+
+// Sends the files to the compile backend, streaming the response into the
+// console.
+Playground.prototype.run = function(state) {
+  if (state.running()) {
+    console.log('Already running', this.name_);
+    return;
+  }
+  var runId = state.nextRunId();
+
+  this.setNotification_(state);
+  state.running.set(true);
+  state.hasRun.set(true);
+  state.consoleEvents.set([{Message: 'Running...'}]);
+  this.scrollState_.bottom = true;
+
+  var compileUrl = this.getBackendUrl_() + '/compile';
+  if (this.url_.query.debug === '1') {
+    compileUrl += '?debug=1';
+  }
+
+  var reqData = this.getBundle_();
+
+  // TODO(sadovsky): To deal with cached responses, shift timestamps (based on
+  // current time) and introduce a fake delay. Also, switch to streaming
+  // messages, for usability.
+  var that = this;
+
+  // If the user stops the current run or resets the playground, functions
+  // wrapped with ifRunActive become no-ops.
+  var ifRunActive = function(cb) {
+    return function() {
+      if (runId === state.nextRunId()) {
+        cb.apply(this, arguments);
+      }
+    };
+  };
+
+  var appendToConsole = function(events) {
+    state.consoleEvents.set(state.consoleEvents().concat(events));
+  };
+  var makeEvent = function(stream, message) {
+    return {Stream: stream, Message: message};
+  };
+
+  var urlp = url.parse(compileUrl);
+
+  var options = {
+    method: 'POST',
+    protocol: urlp.protocol,
+    hostname: urlp.hostname,
+    port: urlp.port || (urlp.protocol === 'https:' ? '443' : '80'),
+    path: urlp.path,
+    // TODO(ivanpi): Change once deployed.
+    withCredentials: false,
+    headers: {
+      'accept': 'application/json',
+      'content-type': 'application/json'
+    }
+  };
+
+  var req = http.request(options);
+
+  var watchdog = null;
+  // The heartbeat function clears the existing timeout (if any) and, if the run
+  // is still active, starts a new timeout.
+  var heartbeat = function() {
+    if (watchdog !== null) {
+      clearTimeout(watchdog);
+    }
+    watchdog = null;
+    ifRunActive(function() {
+      // TODO(ivanpi): Reduce timeout duration when server heartbeat is added.
+      watchdog = setTimeout(function() {
+        process.nextTick(ifRunActive(function() {
+          req.destroy();
+          appendToConsole(makeEvent('syserr', 'Server response timed out.'));
+        }));
+      }, 10500);
+    })();
+  };
+
+  var endRunIfActive = ifRunActive(function() {
+    that.stop(state);
+    // Cleanup watchdog timer.
+    heartbeat();
+  });
+
+  // error and close callbacks call endRunIfActive in the next tick to ensure
+  // that if both events are triggered, both are executed before the run is
+  // ended by either.
+  req.on('error', ifRunActive(function(err) {
+    console.error('Connection error: ' + err.message + '\n' + err.stack);
+    appendToConsole(makeEvent('syserr', 'Error connecting to server.'));
+    process.nextTick(endRunIfActive);
+  }));
+
+  // Holds partial prefix of next response line.
+  var partialLine = '';
+
+  req.on('response', ifRunActive(function(res) {
+    heartbeat();
+    if (res.statusCode !== 0 && res.statusCode !== 200) {
+      appendToConsole(makeEvent('syserr', 'HTTP status ' + res.statusCode));
+    }
+    res.on('data', ifRunActive(function(chunk) {
+      heartbeat();
+      // Each complete line is one JSON Event.
+      var eventsJson = (partialLine + chunk).split('\n');
+      partialLine = eventsJson.pop();
+      var events = [];
+      _.forEach(eventsJson, function(line) {
+        // Ignore empty lines.
+        line = line.trim();
+        if (line) {
+          var ev;
+          try {
+            ev = JSON.parse(line);
+          } catch (err) {
+            console.error('Error parsing line: ' + line + '\n' + err.message);
+            events.push(makeEvent('syserr', 'Error parsing server response.'));
+            endRunIfActive();
+            return false;
+          }
+          events.push(ev);
+        }
+      });
+      appendToConsole(events);
+    }));
+  }));
+
+  req.on('close', ifRunActive(function() {
+    // Sanity check: partialLine should be empty when connection is closed.
+    partialLine = partialLine.trim();
+    if (partialLine) {
+      console.error('Connection closed without newline after: ' + partialLine);
+      appendToConsole(makeEvent('syserr', 'Error parsing server response.'));
+    }
+    process.nextTick(endRunIfActive);
+  }));
+
+  req.write(JSON.stringify(reqData));
+  req.end();
+
+  // Start watchdog.
+  heartbeat();
+};
+
+// Clears the console and resets all editors to their original contents.
+Playground.prototype.reset = function(state) {
+  this.resetState_(state);
+  _.forEach(this.editors_, function(editor) {
+    editor.reset();
+  });
+  this.setUrlForId_(state.bundleId());
+};
+
+Playground.prototype.resetState_ = function(state) {
+  state.consoleEvents.set([]);
+  this.scrollState_.bottom = true;
+  this.stop(state);
+  state.hasRun.set(false);
+};
+
+// Stops bundle execution.
+Playground.prototype.stop = function(state) {
+  this.setNotification_(state);
+  state.nextRunId.set((state.nextRunId() + 1) & 0x7fffffff);
+  state.running.set(false);
+};
diff --git a/client/src/javascript/editor.js b/client/src/javascript/widgets/editor.js
similarity index 78%
rename from client/src/javascript/editor.js
rename to client/src/javascript/widgets/editor.js
index e2a3158..58cd90b 100644
--- a/client/src/javascript/editor.js
+++ b/client/src/javascript/widgets/editor.js
@@ -15,9 +15,14 @@
 function Editor(type, text) {
   this.type_ = type;
   this.text_ = text;
+  // Every explicitly created (not copied) editor has a unique nonce.
+  this.nonce_ = Editor.nonceSeed_;
+  Editor.nonceSeed_ = (Editor.nonceSeed_ + 1) & 0x7fffffff;
   this.aceEditor_ = null;
 }
 
+Editor.nonceSeed_ = 0;
+
 // This tells Mercury to treat Editor as a widget and not try to render its
 // internals.
 Editor.prototype.type = 'Widget';
@@ -29,8 +34,15 @@
   return el;
 };
 
-Editor.prototype.update = function() {
+Editor.prototype.update = function(prev, el) {
   console.log('EditorWidget.update');
+  // If update is called with the currently mounted Editor instance or its
+  // copy (detected by nonce), remount would reset any edits. Remount should
+  // only happen if a new editor instance (with a new nonce) is explicitly
+  // created.
+  if (this.nonce_ !== prev.nonce_) {
+    this.mount(el, this.type_, this.text_);
+  }
 };
 
 Editor.prototype.getText = function() {
diff --git a/client/src/javascript/widgets/spinner.js b/client/src/javascript/widgets/spinner.js
new file mode 100644
index 0000000..8e0bfd6
--- /dev/null
+++ b/client/src/javascript/widgets/spinner.js
@@ -0,0 +1,28 @@
+module.exports = Spinner;
+
+var spinjs = require('spin.js');
+
+// Mercury widget for displaying a spinner over an element.
+function Spinner() {
+  this.spinner_ = new spinjs({
+    className: 'spinner-internal',
+    'z-index': 2000000000,
+    color: '#ffffff'
+  });
+}
+
+// This tells Mercury to treat Spinner as a widget and not try to render its
+// internals.
+Spinner.prototype.type = 'Widget';
+
+Spinner.prototype.init = function() {
+  console.log('SpinnerWidget.init');
+  var el = document.createElement('div');
+  el.setAttribute('class', 'spinner-overlay');
+  this.spinner_.spin(el);
+  return el;
+};
+
+Spinner.prototype.update = function(prev, el) {
+  console.log('SpinnerWidget.update');
+};
diff --git a/client/src/static/go/index.html b/client/src/static/go/index.html
new file mode 100644
index 0000000..c529f7c
--- /dev/null
+++ b/client/src/static/go/index.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Playground</title>
+    <link rel="stylesheet" href="/stylesheets/playground.css">
+    <script src="/playground.js" async></script>
+  </head>
+  <body>
+    <main>
+      <h1>Hello, go playground!</h1>
+      <div class="lang-go playground" data-srcdir="/fortune/ex0_go"></div>
+    </main>
+  </body>
+</html>
diff --git a/client/src/static/index.html b/client/src/static/index.html
index 3128005..8b9752e 100644
--- a/client/src/static/index.html
+++ b/client/src/static/index.html
@@ -2,15 +2,14 @@
 <html>
   <head>
     <title>Playground</title>
-    <link rel="stylesheet" href="/stylesheets/playground.css">
-    <script src="/playground.js" async></script>
   </head>
-  <body class="default-layout">
+  <body>
     <main>
-      <h1>Hello, go playground!</h1>
-      <div class="lang-go playground" data-srcdir="/fortune/ex0_go"></div>
-      <h1>Hello, js playground!</h1>
-      <div class="lang-js playground" data-srcdir="/fortune/ex0_js"></div>
+      <h1>Hello, playground!</h1>
+      <ul>
+        <li><a href="./go">Go</a></li>
+        <li><a href="./js">Javascript</a></li>
+      </ul>
     </main>
   </body>
 </html>
diff --git a/client/src/static/js/index.html b/client/src/static/js/index.html
new file mode 100644
index 0000000..2592f82
--- /dev/null
+++ b/client/src/static/js/index.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Playground</title>
+    <link rel="stylesheet" href="/stylesheets/playground.css">
+    <script src="/playground.js" async></script>
+  </head>
+  <body>
+    <main>
+      <h1>Hello, js playground!</h1>
+      <div class="lang-js playground" data-srcdir="/fortune/ex0_js"></div>
+    </main>
+  </body>
+</html>
diff --git a/client/src/stylesheets/playground.css b/client/src/stylesheets/playground.css
index d8ccac0..db70cac 100644
--- a/client/src/stylesheets/playground.css
+++ b/client/src/stylesheets/playground.css
@@ -1,11 +1,13 @@
-/* CSS rules for elements inside EmbeddedPlayground instances. */
+/* CSS rules for elements inside Playground instances. */
 
 .pg {
   width: 100%;
 }
 
-.pg .top-bar {
+.pg .widget-bar {
   position: relative;
+  margin-top: 2px;
+  overflow: auto;
 }
 
 .pg .btn,
@@ -28,9 +30,7 @@
 }
 
 .pg .btns {
-  position: absolute;
-  top: 0;
-  right: 0;
+  float: right;
 }
 
 .pg .btn {
@@ -38,6 +38,7 @@
   color: #fff;
   border: 0;
   margin-left: 2px;
+  min-width: 70px;
 }
 
 /* TODO(sadovsky): Add .no-touch once we integrate Modernizr. */
@@ -51,10 +52,13 @@
 }
 
 .pg .btn:active {
-  -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
   box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
 }
 
+.pg .btn:disabled {
+  background-color: #bbb;
+}
+
 /* TODO(sadovsky): Make this reactive. */
 .pg .editors {
   position: relative;
@@ -127,6 +131,35 @@
   overflow: auto;
 }
 
+.pg .bundleid {
+  color: #0033ff;
+  width: 64ch; /* input size doesn't work properly in Chrome */
+  font-family: monospace;
+  font-size: 12px;
+  user-select: all;
+}
+
+.pg .spinner-overlay {
+  position: absolute;
+  height: 100%;
+  width: 100%;
+  z-index: 1999999999; /* one less than spinner-internal */
+  background-color: #000;
+  opacity: 0.5;
+}
+
+.pg .notif {
+  font-size: 14px;
+}
+
+.pg .notif.success {
+  color: #33cc33;
+}
+
+.pg .notif.error {
+  color: #d01716;
+}
+
 .clearfix {
   display: table;
   clear: both;
diff --git a/client/test.sh b/client/test.sh
index 9a0c9ea..3423138 100755
--- a/client/test.sh
+++ b/client/test.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 
-# Tests that embedded playground examples execute successfully.
+# Tests that default playground examples execute successfully.
 # If any new examples are added, they should be appended to $EXAMPLES below.
 
 # To debug playground compile errors you can build examples locally, e.g.: