refactor(playground/client): break up playground.js into several modules.

Separate concerns by breaking up client/playground.js into several modules and
and mercury components.

* Split out router logic into a generic and isolated module.
* Use hashbang urls for routing
* Add special view helper for generating anchors to trigger route changes.
* The entry point (browser/index.js) sets up state and router->state glue.
* browser/api.js holds all logic dealing with remote service interaction.
* Add a single place for URL processing/templating in the api module.
* bundles are lazily updated and rendered immediately when available.
* Refactor streaming response handling from run requests.
* Add new repo and bug links to package.json
* Scroll handle split up into individual components.
* UI: dropped the reset button for now

Open/pending issues have been dropped into veyron/release-issues#1890
Related veyron/release-issues#802
Close veyron/release-issues#1558

Change-Id: I55cd0a38c01d57132cca951cffa151bc90ea97cb
diff --git a/client/.gitignore b/client/.gitignore
index 35cb0b7..1df0517 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -2,6 +2,7 @@
 bundles/*/*/pkg
 public/bundles
 bundle.*
+!browser/components/bundle.js
 bundle_*.json
 node_modules
 npm-debug.log
diff --git a/client/.jshintrc b/client/.jshintrc
index 8837eae..e6bb35f 100644
--- a/client/.jshintrc
+++ b/client/.jshintrc
@@ -19,10 +19,6 @@
   "undef": true,
   "unused": "vars",
 
-  "browser": true,
   "devel": true,
-  "node": true,
-
-  "globals": {
-  }
+  "node": true
 }
diff --git a/client/Makefile b/client/Makefile
index 5b65b96..d446ba2 100644
--- a/client/Makefile
+++ b/client/Makefile
@@ -77,10 +77,22 @@
 	@$(RM) -rf public/bundles
 	@$(RM) -rf $(shell find bundles -name "bundle*.json")
 
+.PHONY: dependency-check
+dependency-check: package.json node_modules
+	dependency-check $<
+
 .PHONY: lint
-lint:
-	@jshint .
+lint: dependency-check
+	jshint .
 
 .PHONY: test
 test:
 	v23 run ./test.sh
+
+.PHONY: test-client
+test-client: node_modules
+	tape test/index.js
+
+.PHONY: test-browser
+test-browser: node_modules
+	run-browser test/index.js --phantom
diff --git a/client/browser/api/index.js b/client/browser/api/index.js
new file mode 100644
index 0000000..899d0fe
--- /dev/null
+++ b/client/browser/api/index.js
@@ -0,0 +1,250 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('api');
+var request = require('superagent');
+var hyperquest = require('hyperquest');
+var parallel = require('run-parallel');
+var format = require('format');
+var normalize = require('./normalize');
+var url = require('url');
+var extend = require('xtend');
+var prr = require('prr');
+var config = require('../config');
+var JSONStream = require('./json-stream');
+var split = require('split');
+var defaults = {
+  // Timeout for HTTP requests, 5 secs in milliseconds.
+  timeout: 5 * 60 * 1000,
+  // Temporarily default to the staging load balancer until bundle lists are
+  // available from the API.
+  url: 'http://104.197.24.229:8181/',
+  debug: false
+};
+
+var options = {};
+
+// If options have been defined by a developer via the console use them to
+// override the defaults.
+if (config('api-url')) {
+  options.url = config('api-url');
+}
+
+if (config('api-debug')) {
+  options.debug = config('api-debug');
+}
+
+// Create a singleton instance of the API object which can be shared across
+// modules and components.
+module.exports = API(options);
+
+function API(options) {
+  if (!(this instanceof API)) {
+    return new API(options);
+  }
+
+  options = options || {};
+
+  var api = this;
+
+  api.options = extend(defaults, options);
+
+  debug('initializing with options: %o', api.options);
+
+  prr(api, '_url', url.parse(api.options.url));
+  prr(api, '_pending', []);
+}
+
+API.prototype.url = function(options) {
+  options = options || {};
+
+  // By default return a formatted url string.
+  options = extend({ format: true }, options);
+
+  var api = this;
+  var clone = extend(api._url);
+
+  // Append trailing slash if it's missing
+  if (! clone.pathname.match(/\/$/)) {
+    clone.pathname = clone.pathname + '/'
+  }
+
+  // NOTE: paying attention to options.action is temporary until the API gets
+  // cleaned up and becomes RESTful making the url templating simpler.
+  switch (options.action) {
+    case 'create':
+      clone.pathname = url.resolve(clone.pathname, 'save');
+      break;
+    case 'read':
+      clone.pathname = url.resolve(clone.pathname, 'load');
+      break;
+    case 'run':
+      clone.pathname = url.resolve(clone.pathname, 'compile');
+      break;
+  }
+
+  if (options.uuid) {
+    clone.query = { id: encodeURIComponent(options.uuid) };
+  }
+
+  if (api.options.debug) {
+    clone.query = clone.query || {};
+    clone.query.debug = 1;
+  }
+
+  if (options.format) {
+    return url.format(clone);
+  } else {
+    return clone;
+  }
+};
+
+// Temporary way to list bundles until there is an API endpoint to hit.
+API.prototype.bundles = function(callback) {
+  var api = this;
+  // TODO(jasoncampbell): remove this list once a list API endpoint is
+  // available.
+  var ids = [
+    '_39bce22c2acd70cd235ad2764738f3ffcaec7a5fea67ec4c14a674b991a373c',
+    '_8e15dfe13cf1288ace5f58b0c35a77015be2b23b61ca673a497c7d50afedf3b',
+    '_de64000da6c89b19f566135e7459557fdd3be1f6f4b6e74f76c60f2693fceb1',
+    '_e9a1eb094d30d3d7602f561610f155e29e3d0c9d875116657a65415937e1137'
+  ];
+
+  var workers = ids.map(createWorker);
+
+  // Request all ids in parallel.
+  parallel(workers, callback);
+
+  function createWorker(id) {
+    return worker;
+
+    function worker(cb) {
+      api.get(id, cb);
+    }
+  }
+};
+
+API.prototype.get = function(uuid, callback) {
+  var api = this;
+  var uri = api.url({ uuid: uuid, action: 'read' });
+
+  request
+  .get(uri)
+  .accept('json')
+  .timeout(api.options.timeout)
+  .end(onget);
+
+  function onget(err, res, body) {
+    if (err) {
+      return callback(err);
+    }
+
+    if (! res.ok) {
+      var message = format('GET %s - %s NOT OK', uri, res.statusCode);
+      err = new Error(message);
+      return callback(err);
+    }
+
+    var data = normalize(res.body);
+
+    callback(null, data);
+  }
+};
+
+API.prototype.create = function(data, callback) {
+  var api = this;
+  var uri = api.url({ action: 'create' });
+
+  request
+  .post(uri)
+  .type('json')
+  .accept('json')
+  .timeout(api.options.timeout)
+  .send(data)
+  .end(oncreate);
+
+  function oncreate(err, res) {
+    if (err) {
+      return callback(err);
+    }
+
+    if (! res.ok) {
+      var message = format('POST %s - %s NOT OK', uri, res.status);
+      err = new Error(message);
+      return callback(err);
+    }
+
+    var data = normalize(res.body);
+
+    callback(null, data);
+  }
+};
+
+API.prototype.isPending = function(uuid) {
+  return this._pending.indexOf(uuid) >= 0;
+};
+
+API.prototype.pending = function(uuid) {
+  return this._pending.push(uuid);
+};
+
+API.prototype.done = function(uuid) {
+  var api = this;
+  var start = api._pending.indexOf(uuid);
+  var deleteCount = 1;
+
+  return api._pending.splice(start, deleteCount);
+};
+
+// Initializes an http request and returns a stream.
+//
+// TODO(jasoncampbell): Drop the callback API and return the stream
+// immediately.
+// TODO(jasoncampbell): stop pending xhr
+// SEE: https://github.com/veyron/release-issues/issues/1890
+API.prototype.run = function(data, callback) {
+  var api = this;
+  var uuid = data.uuid;
+  var uri = api.url({ action: 'run' });
+
+  if (api.isPending(uuid)) {
+    var message = format('%s is already running');
+    var err = new Error(message);
+    return callback(err);
+  }
+
+  api.pending(uuid);
+
+  var options = {
+    withCredentials: false,
+    headers: {
+      'accept': 'application/json',
+      'content-type': 'application/json'
+    }
+  };
+
+  // TODO(jasoncampbell): Consolidate http libraries.
+  // TODO(jasoncampbell): Verify XHR timeout logic and handle appropriately.
+  var req = hyperquest.post(uri, options);
+
+  req.once('error', callback);
+
+  var stream = JSONStream(); // jshint ignore:line
+
+  stream.on('end', function() {
+    api.done(uuid);
+  });
+
+  req
+  .pipe(split())
+  .pipe(stream);
+
+  callback(null, stream);
+
+  var string = JSON.stringify(data);
+
+  req.write(string);
+  req.end();
+};
diff --git a/client/browser/api/json-stream.js b/client/browser/api/json-stream.js
new file mode 100644
index 0000000..6436c67
--- /dev/null
+++ b/client/browser/api/json-stream.js
@@ -0,0 +1,28 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var through2 = require('through2');
+
+module.exports = create;
+
+function create() {
+  return through2.obj(write);
+}
+
+function write(buffer, enc, callback) {
+  if (buffer.length === 0) {
+    return callback();
+  }
+
+  var json;
+  var err;
+
+  try {
+    json = JSON.parse(buffer);
+  } catch (err) {
+    err.data = buffer;
+  }
+
+  callback(err, json);
+}
diff --git a/client/browser/api/normalize.js b/client/browser/api/normalize.js
new file mode 100644
index 0000000..b2998f2
--- /dev/null
+++ b/client/browser/api/normalize.js
@@ -0,0 +1,16 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = normalize;
+
+// TODO: Update once the API returns the correct data structures.
+// map old data format to a new one and return a bundle state object.
+function normalize(old) {
+  var data = JSON.parse(old.Data);
+
+  return {
+    uuid: old.Link,
+    files: data.files
+  };
+}
diff --git a/client/browser/components/bundle/index.js b/client/browser/components/bundle/index.js
new file mode 100644
index 0000000..b49ba5b
--- /dev/null
+++ b/client/browser/components/bundle/index.js
@@ -0,0 +1,136 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundle:state');
+var hg = require('mercury');
+var log = require('../log');
+var api = require('../../api');
+var resultsConsole = require('../results-console');
+var router = require('../../router');
+var toArray = require('../../util').toArray;
+
+module.exports = bundle;
+module.exports.render = require('./render');
+
+function bundle(json) {
+  var state = hg.state({
+    uuid: hg.value(json.uuid),
+    files: hg.varhash({}),
+    tab: hg.value(json.files[0].name),
+    running: hg.value(false),
+    reset: hg.value(false),
+    resultsConsole: resultsConsole(),
+    channels: {
+      tab: tab,
+      run: run,
+      stop: stop,
+      save: save,
+      fileChange: fileChange,
+    }
+  });
+
+  // Loop thorugh the files array in the `json` object and update the
+  // `state.files` varhash.
+  var length = json.files.length;
+
+  for (var i = 0; i < length; i++) {
+    var file = json.files[i];
+    // NOTE: hg.varhash has a bug where certain keys such as "delete" can not
+    // be used ( https://github.com/nrw/observ-varhash/issues/2 ), once we
+    // start allowing user's to create thier own files we will need to
+    // accomodate the possibility of running into naming collisions for
+    // "name", "get", "put", "delete" by possibily normalizing the keys with a
+    // prefix.
+    state.files.put(file.name, file);
+  }
+
+  // When the console is running update the resultsConsole state.
+  state.running(function update(running) {
+    debug('run changed: %s', running);
+
+    // If running clear previous logs and open the console.
+    if (running) {
+      state.resultsConsole.logs.set(hg.array([]));
+      state.resultsConsole.open.set(true);
+      state.resultsConsole.follow.set(true);
+    }
+  });
+
+  return state;
+}
+
+// When a file's contents change via the editor update the state.
+function fileChange(state, data) {
+  var current = state.files.get(data.name);
+
+  if (current.body !== data.body) {
+    state.files.put(data.name, data);
+  }
+}
+
+// Change the current tab.
+function tab(state, data) {
+  state.tab.set(data.tab);
+}
+
+// "stop" the code run. This will hide the console...
+function stop(state, data) {
+  state.running.set(false);
+  // TODO(jasoncampbell): stop pending xhr.
+}
+
+// Doesn't "save" in a normal sense as the request generates a new resource.
+// This is more like a "Fork" but currently the only thing we have that might
+// resemble saving.
+function save(state) {
+  var data = {
+    files: toArray(state.files())
+  };
+
+  api.create(data, function(err, data) {
+    if (err) {
+      // TODO(jasoncampbell): handle error appropriately.
+      //
+      // SEE: https://github.com/veyron/release-issues/issues/1890
+      throw err;
+    }
+
+    // Since a new id is generated and the id is in the url the router needs
+    // to be updated.
+    //
+    // TODO(jasoncampbell): put the the value of the new bundle here, maybe don't trigger a
+    // reload.
+    router.href.set(data.uuid);
+  });
+}
+
+// Run the code remotely.
+function run(state) {
+  debug('running');
+  state.running.set(true);
+
+  var data = {
+    uuid: state.uuid(),
+    files: toArray(state.files())
+  };
+
+  api.run(data, function onrun(err, stream) {
+    if (err) {
+      // TODO(jasoncampbell): handle error appropriately.
+      throw err;
+    }
+
+    stream.on('error', function onerror(err) {
+      throw err;
+    });
+
+    stream.on('data', function ondata(data) {
+      state.resultsConsole.logs.push(log(data));
+    });
+
+    stream.on('end', function() {
+      state.running.set(false);
+    });
+  });
+}
diff --git a/client/browser/components/bundle/render.js b/client/browser/components/bundle/render.js
new file mode 100644
index 0000000..1bb65dd
--- /dev/null
+++ b/client/browser/components/bundle/render.js
@@ -0,0 +1,102 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundle:render');
+var anchor = require('../../router/anchor');
+var h = require('mercury').h;
+var hg = require('mercury');
+var results = require('../results-console');
+var toArray = require('../../util').toArray;
+
+module.exports = render;
+
+function render(state) {
+  debug('update %o', state);
+
+  if (! state) {
+    return h('.bundle', [
+      h('p', 'Loading...')
+    ]);
+  }
+
+  return h('.bundle', [
+    h('.pg', [
+      hg.partial(anchorbar, state, state.channels),
+      hg.partial(tabs, state, state.channels),
+      hg.partial(controls, state, state.channels),
+      hg.partial(editors, state, state.channels),
+      hg.partial(results.render,
+        state.resultsConsole,
+        state.resultsConsole.channels)
+    ])
+  ]);
+}
+
+var options = { preventDefault: true };
+
+function anchorbar(state, channels) {
+  return h('.widget-bar', [
+    h('p', [
+      h('strong', 'anchor:'),
+      anchor({ href: '/' + state.uuid }, state.uuid)
+    ])
+  ]);
+}
+
+function tabs(state, channels) {
+  var files = toArray(state.files);
+
+  return h('span.tabs', files.map(tab, state));
+}
+
+function tab(file, index, array) {
+  var state = this;
+  var channels = state.channels;
+
+  return h('span.tab', {
+    className: state.tab === file.name ? 'active' : '',
+    'ev-click': hg.sendClick(channels.tab, { tab: file.name }, options)
+  }, file.name);
+}
+
+function controls(state, channels) {
+  return h('span.btns', [
+    hg.partial(runButton, state, channels),
+    h('button.btn', {
+      'ev-click': hg.sendClick(channels.save)
+    }, 'Save')
+  ]);
+}
+
+function runButton(state, channels) {
+  var text = 'Run Code';
+  var sink = channels.run;
+
+  if (state.running) {
+    text = 'Stop';
+    sink = channels.stop;
+  }
+
+  return h('button.btn', {
+    'ev-click': hg.sendClick(sink)
+  }, text);
+}
+
+// TODO(jasoncampbell): It makes sense to break the editor into it's own
+// component as we will be adding features...
+var aceWidget = require('../../widgets/ace-widget');
+var aceChange = require('../../event-handlers/ace-change');
+
+function editors(state, channels) {
+  var files = toArray(state.files);
+
+  return h('.editors', files.map(function(file) {
+    return h('.editor', {
+      className: (state.tab === file.name ? 'active' : ''),
+      'ev-ace-change': aceChange(state.channels.fileChange),
+    }, [
+      aceWidget(file)
+    ]);
+  }));
+}
diff --git a/client/browser/components/bundles/index.js b/client/browser/components/bundles/index.js
new file mode 100644
index 0000000..041674f
--- /dev/null
+++ b/client/browser/components/bundles/index.js
@@ -0,0 +1,12 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+
+module.exports = bundles;
+module.exports.render = require('./render');
+
+function bundles() {
+  return hg.varhash({});
+}
diff --git a/client/browser/components/bundles/render.js b/client/browser/components/bundles/render.js
new file mode 100644
index 0000000..3349a00
--- /dev/null
+++ b/client/browser/components/bundles/render.js
@@ -0,0 +1,28 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:bundles');
+var h = require('mercury').h;
+var anchor = require('../../router/anchor');
+var toArray = require('../../util').toArray;
+
+module.exports = render;
+
+function render(state) {
+  debug('update %o', state);
+
+  var bundles = toArray(state);
+
+  if (bundles.length === 0) {
+    return h('p', 'Loading...');
+  } else {
+    return h('ul.bundles', bundles.map(li));
+  }
+}
+
+function li(bundle) {
+  return h('li', [
+    anchor({ href: '/' + bundle.uuid }, bundle.uuid)
+  ]);
+}
diff --git a/client/browser/components/header.js b/client/browser/components/header.js
new file mode 100644
index 0000000..2727cd6
--- /dev/null
+++ b/client/browser/components/header.js
@@ -0,0 +1,15 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var h = require('mercury').h;
+
+module.exports = {
+  render: render
+};
+
+function render(state, chanels) {
+  return h('header', [
+    h('h1', state.title)
+  ]);
+}
diff --git a/client/browser/components/log/index.js b/client/browser/components/log/index.js
new file mode 100644
index 0000000..f6c73e1
--- /dev/null
+++ b/client/browser/components/log/index.js
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var normalize = require('./normalize');
+
+module.exports = log;
+module.exports.render = require('./render');
+
+function log(data) {
+  data = normalize(data);
+
+  var state = hg.struct({
+    message: data.message,
+    file: data.file,
+    stream: data.stream,
+    timestamp: data.timestamp
+  });
+
+  return state;
+}
diff --git a/client/browser/components/log/normalize.js b/client/browser/components/log/normalize.js
new file mode 100644
index 0000000..11ccd37
--- /dev/null
+++ b/client/browser/components/log/normalize.js
@@ -0,0 +1,20 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = normalize;
+
+// Takes data from API messages and converts them to appropriate objects
+// (lowecase, correct values, etc.).
+function normalize(data) {
+  // convert `data.Timestamp` nanosecond value to a float in milliseconds.
+  var oneMillion = 1e6;
+  var timestamp = data.Timestamp / oneMillion;
+
+  return {
+    message: data.Message,
+    file: data.File,
+    stream: data.Stream,
+    timestamp: timestamp
+  };
+}
diff --git a/client/browser/components/log/render.js b/client/browser/components/log/render.js
new file mode 100644
index 0000000..ac0f253
--- /dev/null
+++ b/client/browser/components/log/render.js
@@ -0,0 +1,34 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var h = require('mercury').h;
+var moment = require('moment');
+
+module.exports = render;
+
+function render(state) {
+  var date = moment(state.timestamp).format('H:mm:ss.SSS');
+
+  var children = [
+    h('span.timestamp', date + ' '),
+    h('span.filename', state.file ? state.file + ': ' : '')
+  ];
+
+  // TODO(jasoncampbell): render in a pre tag instead
+
+  // A single trailing newline is always ignored.
+  // Ignoring the last character, check if there are any newlines in message.
+  if (state.message.slice(0, -1).indexOf('\n') !== -1) {
+    children.push('\u23ce'); // U+23CE RETURN SYMBOL
+    children.push('br');
+  }
+
+  var message = h('span.message', {
+    className: state.stream || 'unknown'
+  }, state.message);
+
+  children.push(message);
+
+  return h('.log', children);
+}
diff --git a/client/browser/components/results-console/follow-hook.js b/client/browser/components/results-console/follow-hook.js
new file mode 100644
index 0000000..2041535
--- /dev/null
+++ b/client/browser/components/results-console/follow-hook.js
@@ -0,0 +1,34 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = FollowConsoleHook;
+
+// # FollowConsoleHook(state.follow)
+//
+// Used to hook into the vdom cycle so that DOM attributes can be accessed
+// and manipulated causing the DOM node to scroll to the bottom.
+function FollowConsoleHook(shouldFollow){
+  if (!(this instanceof FollowConsoleHook)) {
+    return new FollowConsoleHook(shouldFollow);
+  }
+
+  this.track = shouldFollow;
+}
+
+FollowConsoleHook.prototype.hook = function(node, property) {
+  if (! this.track) {
+    return;
+  }
+
+  // Do not mutate the DOM node until it has been inserted.
+  process.nextTick(update);
+
+  function update() {
+    var visibleHeight = node.clientHeight;
+    var overflow = node.scrollHeight - visibleHeight;
+
+    // Scroll the overflowing content into view.
+    node.scrollTop = overflow;
+  }
+};
diff --git a/client/browser/components/results-console/index.js b/client/browser/components/results-console/index.js
new file mode 100644
index 0000000..6368399
--- /dev/null
+++ b/client/browser/components/results-console/index.js
@@ -0,0 +1,36 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var debug = require('debug')('components:results-console:state');
+
+module.exports = resultsConsole;
+module.exports.render = require('./render');
+
+function resultsConsole() {
+  debug('create');
+
+  var state = hg.state({
+    logs: hg.array([]),
+    open: hg.value(false),
+    follow: hg.value(true),
+    channels: {
+      follow: follow
+    }
+  });
+
+  return state;
+}
+
+function follow(state, data) {
+  var following = state.follow();
+
+  if (data.scrolling && following) {
+    state.follow.set(false);
+  }
+
+  if (data.scrolledToBottom) {
+    state.follow.set(true);
+  }
+}
diff --git a/client/browser/components/results-console/render.js b/client/browser/components/results-console/render.js
new file mode 100644
index 0000000..964ddfb
--- /dev/null
+++ b/client/browser/components/results-console/render.js
@@ -0,0 +1,23 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('components:results-console:render');
+var h = require('mercury').h;
+var scroll = require('../../event-handlers/scroll');
+var followHook = require('./follow-hook');
+var log = require('../log');
+
+module.exports = render;
+
+function render(state, channels) {
+  debug('update console %o', state);
+
+  return h('.console', {
+    className: state.open ? 'open' : 'closed',
+    'ev-scroll': scroll(channels.follow, { scrolling: true }),
+    'follow-console': followHook(state.follow)
+  }, [
+    h('.text', state.logs.map(log.render))
+  ]);
+}
diff --git a/client/browser/components/toast.js b/client/browser/components/toast.js
new file mode 100644
index 0000000..cdd2b4c
--- /dev/null
+++ b/client/browser/components/toast.js
@@ -0,0 +1,32 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+
+module.exports = toast;
+module.exports.render = render;
+
+function toast(options) {
+  options = options || {};
+
+  var state = hg.state({
+    message: hg.value(options.message || ''),
+    active: hg.value(options.message ? true : false),
+  });
+
+  return state;
+}
+
+function render(state) {
+  var attributes = {
+    className: state.active ? 'active' : ''
+  };
+
+  var children = [
+    h('p', state.message)
+  ];
+
+  return h('.toast', attributes, children);
+}
diff --git a/client/browser/config.js b/client/browser/config.js
new file mode 100644
index 0000000..7405b36
--- /dev/null
+++ b/client/browser/config.js
@@ -0,0 +1,47 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var window = require('global/window');
+
+module.exports = window.config = config;
+
+var store = window.localStorage || new MemStorage();
+
+// # config(key, value)
+//
+// Quick method to set configuration values via the developer console. For
+// instance to point at a different API url:
+//
+//     config('api-url', 'http://120.0.0.1:9999')
+//
+function config(key, value) {
+  if (typeof value === 'undefined') {
+    return get(key);
+  } else {
+    return set(key, value);
+  }
+}
+
+function get(key) {
+  return store.getItem(key);
+}
+
+function set(key, value) {
+  store.setItem(key, value);
+}
+
+// Stubbed out localStorage API for running tests.
+function MemStorage() {
+  this.store = {};
+}
+
+MemStorage.prototype.getItem = function(key) {
+  if (this.store.hasOwnProperty(key)) {
+    return this.store[key];
+  }
+};
+
+MemStorage.prototype.setItem = function(key, value) {
+  this.store[key] = value;
+};
diff --git a/client/browser/event-handlers/ace-change.js b/client/browser/event-handlers/ace-change.js
new file mode 100644
index 0000000..dd0d9f0
--- /dev/null
+++ b/client/browser/event-handlers/ace-change.js
@@ -0,0 +1,27 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var extend = require('xtend');
+
+// Tell the singleton Delegator to listen to the custom 'ace-change' event.
+hg.Delegator().listenTo('ace-change');
+
+module.exports = hg.BaseEvent(change);
+
+// # change(channel, data)
+//
+// Custom change event for the Ace editor which broadcasts updated content of
+// an editor on change.
+function change(event, broadcast) {
+  // The ProxyEvent object in dom-delegator doesn't set event.detail for
+  // custom events until my PR is merged and a new version is attached to
+  // mercury.
+  //
+  // TODO(jasoncampbell): Send a path upstream
+  var detail = event.detail || event._rawEvent.detail;
+  var data = extend(this.data, detail);
+
+  broadcast(data);
+}
diff --git a/client/browser/event-handlers/scroll.js b/client/browser/event-handlers/scroll.js
new file mode 100644
index 0000000..f92d514
--- /dev/null
+++ b/client/browser/event-handlers/scroll.js
@@ -0,0 +1,33 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var extend = require('xtend');
+
+hg.Delegator().listenTo('scroll');
+
+module.exports = hg.BaseEvent(scroll);
+
+// # scroll(channel, data)
+//
+// dom-delegator event for scrolling. Currently broadcasts:
+//
+//     { scrolledToBottom: true || false }
+//
+// Example:
+//
+//     h('.scroller', { 'ev-scroll': scroll(channel) })
+//
+function scroll(event, broadcast) {
+  var element = event.target;
+  var overflowTop = element.scrollTop;
+  var visibleHeight = element.clientHeight;
+  var scrolledToBottom = overflowTop + visibleHeight >= element.scrollHeight;
+
+  var data = extend(this.data, {
+    scrolledToBottom: scrolledToBottom
+  });
+
+  broadcast(data);
+}
diff --git a/client/browser/index.js b/client/browser/index.js
index 2c0bda1..e89d822 100644
--- a/client/browser/index.js
+++ b/client/browser/index.js
@@ -2,39 +2,88 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var _ = require('lodash');
-var path = require('path');
-var superagent = require('superagent');
+var debug = require('debug')('app');
+var domready = require('domready');
+var window = require('global/window');
+var document = require('global/document');
+var hg = require('mercury');
+var render = require('./render');
+var router = require('./router');
+var bundle = require('./components/bundle');
+var bundles = require('./components/bundles');
+var toast = require('./components/toast');
+var api = require('./api');
 
-var Playground = require('./playground');
+// Make the debug module accessible via the console. This allows the module's
+// output to be toggled by opening the developer console and typing:
+//
+//    debug.enable('*')
+//
+// It is also possible to only enable specific, namespaced statements. For
+// example to only see comments from components.
+//
+//    debug.enable('component:*')
+//
+// SEE: https://www.npmjs.com/package/debug
+window.debug = require('debug');
 
-_.forEach(document.querySelectorAll('.playground'), function(el) {
-  var src = el.getAttribute('data-src');
-  console.log('Creating playground', src);
+domready(function domisready() {
+  debug('DOM is ready, initializing mercury app.');
 
-  fetchBundle(src, function(err, bundle) {
-    if (err) {
-      el.innerHTML = '<div class="error"><p>Playground error.' +
-        '<br>Bundle not found: <strong>' + src + '</strong></p></div>';
-      return;
-    }
-    new Playground(el, src, bundle);  // jshint ignore:line
+  var state = hg.state({
+    bundles: bundles(),
+    uuid: hg.value(''),
+    toast: toast(),
+    title: hg.value('Vanadium Playground')
   });
-});
 
-function fetchBundle(loc, cb) {
-  var basePath = '/bundles';
-  console.log('Fetching bundle', loc);
-  superagent
-    .get(path.join(basePath, loc))
-    .accept('json')
-    .end(function(err, res) {
+  router({
+    '/#!/': index,
+    '/#!/:uuid': show
+  }).on('notfound', notfound);
+
+  hg.app(document.body, state, render);
+
+  // Route: "/#!/" - The homepage, show list of available bundles.
+  function index(params, route) {
+    // Unset state.uuid from previous route.
+    state.uuid.set('');
+
+    api.bundles(function(err, list) {
       if (err) {
-        return cb(err);
+        return console.error('TODO: API list error', err);
       }
-      if (res.error) {
-        return cb(res.error);
+
+      var length = list.length;
+
+      for (var i = 0; i < length; i++) {
+        var data = list[i];
+        state.bundles.put(data.uuid, bundle(data));
       }
-      cb(null, res.body);
     });
-}
+  }
+
+  // Route: "/#!/:uuid" - Show a single bundle
+  function show(params) {
+    state.uuid.set(params.uuid);
+
+    // TODO(jasoncampbell): If there is not an entry for `params.uuid` show a
+    // spinner/loader.
+    //
+    // SEE: https://github.com/veyron/release-issues/issues/1890
+
+    api.get(params.uuid, function(err, data) {
+      if (err) {
+        return console.error('TODO: API GET error', err);
+      }
+
+      // Set and or update bundle
+      state.bundles.put(data.uuid, bundle(data));
+    });
+  }
+
+  // SEE: https://github.com/veyron/release-issues/issues/1890
+  function notfound(href) {
+    console.error('TODO: not found error - %s', href);
+  }
+});
diff --git a/client/browser/playground.js b/client/browser/playground.js
deleted file mode 100644
index d8f509e..0000000
--- a/client/browser/playground.js
+++ /dev/null
@@ -1,614 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-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) {
-    if (window.location.hostname === 'localhost') {
-      console.log('Using pgaddr', pgaddr);
-      return pgaddr;
-    } else {
-      console.error('pgaddr disabled on non-localhost clients');
-      pgaddr = '';
-    }
-  }
-  var origin = window.location.protocol + '//' + window.location.host;
-  var defaddr = origin + '/api';
-  console.log('Using default backend', defaddr);
-  return defaddr;
-};
-
-// 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;
-  this.enableScrollUpdates_ = false;
-}
-
-ScrollHandle.prototype.hook = function(elem, propname) {
-  var that = this;
-  process.nextTick(function() {
-    if (that.scrollState_.bottom) {
-      elem.scrollTop = elem.scrollHeight - elem.clientHeight;
-    }
-    that.enableScrollUpdates_ = true;
-  });
-};
-
-ScrollHandle.prototype.update = function(ev) {
-  var el = ev.currentTarget;
-  if (this.enableScrollUpdates_) {
-    // scrollHeight and clientHeight are rounded to an integer, so we need to
-    // compare fuzzily.
-    this.scrollState_.bottom =
-        Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) <= 2.01;
-  }
-};
-
-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 that = this;
-  return function(rerr, res) {
-    state.requestActive.set(false);
-    var processResponse = function() {
-      if (rerr) {
-        if (rerr.timeout) {
-          return 'request timed out';
-        }
-        return 'error connecting to server: ' + rerr;
-      }
-      if (res.body && res.body.Error) {
-        // TODO(ivanpi): Special handling of 404? Retry on other errors?
-        return 'error ' + res.status + ': ' + res.body.Error;
-      }
-      if (res.error) {
-        return 'error ' + res.status + ': unknown';
-      }
-      if (!res.body.Link || !res.body.Data) {
-        return 'invalid response format';
-      }
-      var bundle;
-      try {
-        bundle = JSON.parse(res.body.Data);
-      } catch (jerr) {
-        return 'error parsing Data: ' + res.body.Data + '\n' + jerr.message;
-      }
-      // Opens bundle in editors, updates bundleId and url.
-      that.setBundle_(state, bundle, res.body.Link);
-      return null;
-    };
-    var errm = processResponse();
-    if (!errm) {
-      state.idToLoad.set('');
-      that.showMessage_(state, operation, 'success', true);
-    } 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());
-      }
-      that.showMessage_(state, operation, errm);
-    }
-  };
-};
-
-// 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/browser/render.js b/client/browser/render.js
new file mode 100644
index 0000000..580af26
--- /dev/null
+++ b/client/browser/render.js
@@ -0,0 +1,52 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+var debug = require('debug')('render');
+var header = require('./components/header');
+var bundles = require('./components/bundles');
+var bundle = require('./components/bundle');
+
+module.exports = render;
+
+// # render(state)
+//
+// High-level render function for entire playground app state.
+function render(state) {
+  return h('.playground', [
+    hg.partial(header.render, state, state.channels),
+    hg.partial(main, state)
+  ]);
+}
+
+function main(state, anchor) {
+  debug('update %o', state);
+
+  // Possible scenarios to be considered:
+  //
+  // * loading/initial state
+  // * a list of bundles
+  // * a single bundle
+  // * an error
+  //
+  // Currently there are only two screens to show:
+  //
+  // 1. A list of bundles
+  // 2. A single bundle
+  var partial;
+
+  // If there is a uuid show a single bundle
+  if (state.uuid) {
+    partial = hg.partial(bundle.render,
+      state.bundles[state.uuid],
+      state.channels
+    );
+  } else {
+    // By default show a list of bundles
+    partial = hg.partial(bundles.render, state.bundles);
+  }
+
+  return h('main', [ partial ]);
+}
diff --git a/client/browser/router/anchor.js b/client/browser/router/anchor.js
new file mode 100644
index 0000000..df2170c
--- /dev/null
+++ b/client/browser/router/anchor.js
@@ -0,0 +1,47 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var hg = require('mercury');
+var h = require('mercury').h;
+var options = { preventDefault: true };
+var href = require('./index').href;
+var hashbang = require('./hashbang');
+
+// Create a handle for dom-delegation. This step treats the generated `handle`
+// the same as a mercury channel.
+var handle = hg.Delegator.allocateHandle(click);
+
+module.exports = anchor;
+
+// # anchor(attributes, text)
+//
+// Helper for creating virtual-dom anchors that trigger route changes. All
+// event/DOM delegation and router coupling are handled in this function
+// so that any anchor tags can be simply created with:
+//
+//     h('p', [
+//       anchor({ href: '/some-url' }, 'Click me!');
+//     ]);
+//
+// Clicking the generated link will fire the callbacks in the router and have
+// the application state update appropriately.
+function anchor(attributes, text) {
+  // Ensure that the href has the hashbang boilerplate, this makes ctrl+click
+  // open the right url for loading the app in the correct state.
+  attributes.href = hashbang(attributes.href);
+
+  attributes['ev-click'] = hg.sendClick(handle, {
+    href: attributes.href
+  }, options);
+
+  return h('a', attributes, text);
+}
+
+// # click(data)
+//
+// Used as a mercury channel to update the current route using the exported
+// `router.href` observable.
+function click(data) {
+  href.set(data.href);
+}
diff --git a/client/browser/router/hashbang.js b/client/browser/router/hashbang.js
new file mode 100644
index 0000000..0206c5f
--- /dev/null
+++ b/client/browser/router/hashbang.js
@@ -0,0 +1,30 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+module.exports = hashbang;
+
+// # hashbang(href)
+//
+// Convert an href to a hashbang url.
+//
+//     var href = hashbang('/foo')
+//     //=> href === '/#!/foo'
+//
+// Returns a "/#!" prefixed href.
+function hashbang(string) {
+  string = string || '';
+
+  // trim leading slash
+  var href = string.replace(/^\//, '');
+
+  if (! href.match(/^\#\!/)) {
+    // add the hashbang
+    href = '#!/' + href;
+  }
+
+  // add leading slash
+  href = '/' + href;
+
+  return href;
+}
diff --git a/client/browser/router/index.js b/client/browser/router/index.js
new file mode 100644
index 0000000..d39e80c
--- /dev/null
+++ b/client/browser/router/index.js
@@ -0,0 +1,119 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('router');
+var EE = require('events').EventEmitter;
+var inherits = require('util').inherits;
+var spa = require('single-page');
+var routes = require('routes');
+var hg = require('mercury');
+var hashbang = require('./hashbang');
+var window = require('global/window');
+
+// The block of code here creates an observable for the current href
+//
+// NOTE: The check for `window.location` allows tests to run in node. Since
+// this code is executing when `require(...)` is called on this module it will
+// be executed at startup, the check prevents errors with accessing undefined
+// properties on `window.location`.
+var pathname = window.location ? window.location.pathname : '';
+var current = hashbang(pathname);
+var atom = hg.value(current);
+
+module.exports = Router;
+module.exports.href = atom;
+
+// The single-page module does not natively support hashbang urls (only
+// window.location.pathname) so there are some work arounds to make sure
+// everything works as expected:
+//
+// * `window.addEventListener('hashchange', router);` - For back button support.
+// * String routes are prefixed with "/#!"
+// * The single-page callback, `router.update(href)` takes special care to
+// consider the hashbang prefix.
+
+// The routes module provides a simple way to define patterns that match to
+// functions.
+//
+// SEE: https://www.npmjs.com/package/routes
+function Router(hash) {
+  if (!(this instanceof Router)) {
+    return new Router(hash);
+  }
+
+  var router = this;
+
+  router.routes = routes();
+
+  for (var key in hash) { // jshint ignore: line
+    router.routes.addRoute(key, hash[key]);
+  }
+
+  router.sp = spa(router.update.bind(this));
+
+  // SEE: Router.prototype.handleEvent
+  window.addEventListener('hashchange', router);
+
+  atom(function onhref(href) {
+    debug('atom update: %s', href);
+    href = hashbang(href);
+    router.sp.show(href);
+  });
+
+  EE.call(router);
+}
+
+inherits(Router, EE);
+
+// # router.update(href)
+//
+// Fired anytime the href is updated (via single-page). This method handles
+// href normalizing and route matching based on the hash of functions passed
+// in on initialization.
+Router.prototype.update = function(href) {
+  debug('dom href update: %s', href);
+
+  var router = this;
+  var hash = window.location.hash;
+
+  // force initial hashbang in case href is missing it.
+  if (href === '/' && ! href.match(hash)) {
+    debug('hash mismatch: %s', hash);
+    href += hash;
+  } else if (href === '/') {
+    // At this point it's possible that the `href` is missing the /#!/ and the
+    // original `window.location.hash` is empty. In this case update the
+    // `href` so that it will match '/#!/' and use `router.sp.push` to update
+    // the url without triggering any callbacks.
+    href = hashbang(href);
+    router.sp.push(href);
+  }
+
+  var route = router.routes.match(href);
+
+  if (route) {
+    route.fn.call(router, route.params, route);
+  } else {
+    router.emit('notfound', href);
+  }
+};
+
+// # router.handleEvent(event)
+//
+// Implments the `EventListener` API so that the "hashchange" event can be
+// listened to in order to enable the back button with hashbang urls.
+//
+// SEE: https://mdn.io/EventListener
+// SEE: https://mdn.io/WindowEventHandlers/onhashchange
+Router.prototype.handleEvent = function(event) {
+  if (event.type !== 'hashchange') {
+    return;
+  }
+
+  var router = this;
+  var hash = String(window.location.hash);
+  var current = hashbang(hash);
+
+  router.update(current);
+};
diff --git a/client/browser/util.js b/client/browser/util.js
new file mode 100644
index 0000000..ad7e7e1
--- /dev/null
+++ b/client/browser/util.js
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+
+module.exports = {
+  toArray: toArray
+};
+
+// # toArray(obejct)
+//
+// Convert an object to an array.
+function toArray(object) {
+  var keys = Object.keys(object);
+  var array = keys.map(toItem);
+
+  return array;
+
+  function toItem(key, index) {
+    return object[key];
+  }
+}
diff --git a/client/browser/widgets/ace-widget.js b/client/browser/widgets/ace-widget.js
new file mode 100644
index 0000000..9559fcd
--- /dev/null
+++ b/client/browser/widgets/ace-widget.js
@@ -0,0 +1,111 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var debug = require('debug')('widgets:editor');
+var path = require('path');
+var prr = require('prr');
+var window = require('global/window');
+var document = require('global/document');
+
+module.exports = AceWidget;
+
+// NOTE(sadovsky): We considered both Ace and CodeMirror, but CodeMirror has
+// weird issues, e.g. its injected editor divs can obscure other DOM elements.
+
+// Mercury widget that wraps a code editor around a file object.
+function AceWidget(file) {
+  if (!(this instanceof AceWidget)) {
+    return new AceWidget(file);
+  }
+
+  var editor = this;
+
+  // This tells Mercury to treat AceWidget as a mercury/virtual-dom widget not
+  // to render it's internals.
+  //
+  // There will be trouble if the editor.type is ever changed so it is
+  // protected here using prr.
+  prr(editor, 'type', 'Widget');
+
+  editor.filename = file.name;
+  editor.extname = path.extname(file.name).replace('.', '');
+  editor.text = file.body;
+
+  debug('initialization %s - %s', editor.filename, editor.visible);
+}
+
+// The first time an instance of widget is seen it will have widget.init()
+// called by mercury. It is expected to return a dom element.
+AceWidget.prototype.init = function() {
+  var editor = this;
+
+  debug('init() - %s', editor.filename);
+
+  // Ace does all kinds of weird stuff with the global objects (window,
+  // document) so moving the module's initialization here makes it possible to
+  // run the tests headlessly in node without throwing errors.
+  var ace = require('brace');
+  require('brace/mode/javascript');
+  require('brace/mode/golang');
+  require('brace/theme/monokai');
+
+  var element = document.createElement('div');
+
+  editor.ace = ace.edit(element);
+  editor.ace.setTheme('ace/theme/monokai');
+  editor.ace.on('change', function(data) {
+    var event = new window.CustomEvent('ace-change', {
+      detail: {
+        name: editor.filename,
+        body: editor.ace.getValue()
+      }
+    });
+
+    // Events are processed synchronously and there is no guarantee that the
+    // element or it's parent will be in the DOM at the time this event
+    // fires. The `process.nextTick` here ensures that the custom event above
+    // is not emitted until after the virtual-dom create, update, insert cycle
+    // has finished and the custom event above has a chance to bubble.
+    process.nextTick(function dispatch(){
+      element.dispatchEvent(event);
+    });
+  });
+
+
+  var session = editor.ace.getSession();
+
+  switch (editor.extname) {
+    case 'go':
+      session.setMode('ace/mode/golang');
+      break;
+    case 'vdl':
+      session.setMode('ace/mode/golang');
+      break;
+    case 'json':
+      session.setMode('ace/mode/javascript');
+      break;
+    case 'js':
+      session.setMode('ace/mode/javascript');
+      break;
+    default:
+      throw new Error('Language type not supported: ' + editor.extname);
+  }
+
+  // Tell Ace about the file contents.
+  session.setValue(editor.text);
+
+  // Disable syntax checking. The UI is annoying and only works for JS.
+  session.setOption('useWorker', false);
+
+  // Ensure that update is called on the first vdom cycle.
+  editor.update(null, element);
+
+  return element;
+};
+
+AceWidget.prototype.update = function(prev, element) {
+  var current = this;
+
+  current.ace = current.ace || prev.ace;
+};
diff --git a/client/browser/widgets/editor.js b/client/browser/widgets/editor.js
deleted file mode 100644
index 58ea6a4..0000000
--- a/client/browser/widgets/editor.js
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-module.exports = Editor;
-
-// NOTE(sadovsky): We considered both Ace and CodeMirror, but CodeMirror appears
-// to be incompatible with Bootstrap and has other weird issues, e.g. its
-// injected editor divs can obscure other DOM elements.
-
-var ace = require('brace');
-require('brace/mode/javascript');
-require('brace/mode/golang');
-require('brace/theme/monokai');
-
-// Mercury widget that wraps a code editor.
-// * type: Type of code. Currently either 'js' or 'go'.
-// * text: Initial code.
-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';
-
-Editor.prototype.init = function() {
-  console.log('EditorWidget.init');
-  var el = document.createElement('div');
-  this.mount(el, this.type_, this.text_);
-  return el;
-};
-
-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() {
-  return this.aceEditor_.getValue();
-};
-
-Editor.prototype.reset = function() {
-  // The '-1' argument puts the cursor at the document start.
-  this.aceEditor_.setValue(this.text_, -1);
-};
-
-// Creates a new Ace editor instance and mounts it on a DOM node.
-// * el: The DOM node to mount on.
-// * type: Type of code. Currently either 'js' or 'go'.
-// * text: Initial code.
-Editor.prototype.mount = function(el, type, text) {
-  var editor = this.aceEditor_ = ace.edit(el);
-  editor.setTheme('ace/theme/monokai');
-
-  var session = editor.getSession();
-  switch (type) {
-  case 'go':
-    session.setMode('ace/mode/golang');
-    break;
-  case 'vdl':
-    session.setMode('ace/mode/golang');
-    break;
-  case 'json':
-    session.setMode('ace/mode/javascript');
-    break;
-  case 'js':
-    session.setMode('ace/mode/javascript');
-    break;
-  default:
-    throw new Error('Language type not supported: ' + type);
-  }
-
-  session.setValue(text);
-
-  // Disable syntax checking. The UI is annoying and only works for JS anyways.
-  session.setOption('useWorker', false);
-};
diff --git a/client/browser/widgets/spinner.js b/client/browser/widgets/spinner.js
deleted file mode 100644
index 6aa03fa..0000000
--- a/client/browser/widgets/spinner.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-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/package.json b/client/package.json
index 362cf7f..1188b23 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,32 +1,46 @@
 {
+  "private": true,
   "name": "vanadium-pg-client",
   "description": "Vanadium playground web client",
   "version": "0.0.0",
   "bugs": {
     "url": "https://github.com/veyron/release-issues/issues"
   },
+  "main": "browser/index.js",
   "dependencies": {
     "brace": "^0.4.0",
     "browserify": "^8.1.1",
-    "lodash": "^3.0.0",
-    "mercury": "^12.0.0",
-    "minifyify": "^6.1.0",
-    "moment": "^2.9.0",
+    "debug": "^2.1.3",
+    "domready": "^1.0.7",
+    "format": "^0.2.1",
+    "global": "^4.3.0",
+    "hyperquest": "^1.0.1",
+    "mercury": "^14.0.0",
+    "moment": "^2.10.2",
     "pgbundle": "0.0.1",
-    "spin.js": "^2.0.2",
-    "superagent": "^0.21.0"
+    "prr": "^1.0.1",
+    "routes": "^2.0.0",
+    "run-parallel": "^1.1.0",
+    "single-page": "^1.0.0",
+    "split": "^0.3.3",
+    "superagent": "^1.1.0",
+    "through2": "^0.6.3",
+    "xtend": "^4.0.0"
   },
-  "homepage": "https://vanadium.googlesource.com/release.projects.playground/+/master/client",
-  "repository": {
-    "type": "git",
-    "url": "https://vanadium.googlesource.com/release.projects.playground"
-  },
+  "homepage": "https://github.com/vanadium/playground",
   "scripts": {
     "start": "make start",
     "test": "make test"
   },
   "devDependencies": {
     "http-server": "^0.7.4",
-    "jshint": "^2.6.0"
-  }
+    "jshint": "^2.6.0",
+    "run-browser": "^2.0.2",
+    "tape": "^3.5.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git@github.com:vanadium/playground.git"
+  },
+  "license": "BSD"
 }
diff --git a/client/public/index.html b/client/public/index.html
index ac3bad8..8fb013a 100644
--- a/client/public/index.html
+++ b/client/public/index.html
@@ -1,20 +1,10 @@
 <!DOCTYPE html>
 <html>
-  <head>
-    <title>Playground</title>
-  </head>
-  <body>
-    <main>
-      <h1>Hello, playground!</h1>
-      <ul>
-        <li><a href="./go">Go</a></li>
-        <li><a href="./js">Javascript</a></li>
-      </ul>
-      <h3>More challenge?</h3>
-      <ul>
-        <li><a href="./go_js">Go client, Javascript server</a></li>
-        <li><a href="./js_go">Javascript client, Go server</a></li>
-      </ul>
-    </main>
-  </body>
+<head>
+  <title>Playground</title>
+  <link rel="stylesheet" href="/bundle.css">
+  <script src="/bundle.js" async></script>
+</head>
+<body>
+</body>
 </html>
diff --git a/client/stylesheets/index.css b/client/stylesheets/index.css
index d1d628d..55b47ce 100644
--- a/client/stylesheets/index.css
+++ b/client/stylesheets/index.css
@@ -74,6 +74,11 @@
   position: absolute;  /* stack editors one on top of the other */
   width: 100%;
   height: 100%;
+  visibility: hidden;
+}
+
+.pg .editor.active {
+  visibility: visible;
 }
 
 .pg .ace_editor {
@@ -88,6 +93,7 @@
   width: 100%;
   height: 4px;  /* matches .editors border-top; expands on run */
   transition: height 0.2s;
+  overflow: hidden;
 }
 
 .pg .console .text {
diff --git a/client/test/index.js b/client/test/index.js
new file mode 100644
index 0000000..6cf839a
--- /dev/null
+++ b/client/test/index.js
@@ -0,0 +1,7 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+require('./test-api-url');
+require('./test-config');
+require('./test-component-log');
diff --git a/client/test/test-api-url.js b/client/test/test-api-url.js
new file mode 100644
index 0000000..a8fc916
--- /dev/null
+++ b/client/test/test-api-url.js
@@ -0,0 +1,89 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var API = require('../browser/api').constructor;
+var format = require('format');
+
+test('var api = API({ url: "https://host.tld/" })', function(t) {
+  var options = { url: 'https://random-host.tld/' };
+  var api = API(options);
+
+  t.test('api.url()', function(t) {
+    t.equal(api.url(), options.url);
+    t.end();
+  });
+
+  t.test('api.url({ uuid: <uuid>, action: "read" })', function(t) {
+    var options = {
+      uuid: '0E88DE8C-88E3-43CC-BD80-87207CC124FA',
+      action: 'read'
+    };
+    var expected = format('%sload?id=%s', api.url(), options.uuid);
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+
+  t.test('api.url({ action: "create" })', function(t) {
+    var options = {
+      action: 'create'
+    };
+    var expected = format('%ssave', api.url());
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+
+  t.test('api.url({ action: "run" })', function(t) {
+    var options = {
+      action: 'run'
+    };
+    var expected = format('%scompile', api.url());
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+});
+
+test('var api = API({ url: "https://host.tld/api" })', function(t) {
+  var options = { url: 'https://random-host.tld/api' };
+  var api = API(options);
+
+  t.test('api.url()', function(t) {
+    t.equal(api.url(), options.url + '/');
+    t.end();
+  });
+
+  t.test('api.url({ uuid: <uuid>, action: "read" })', function(t) {
+    var options = {
+      uuid: '0E88DE8C-88E3-43CC-BD80-87207CC124FA',
+      action: 'read'
+    };
+    var expected = format('%sload?id=%s', api.url(), options.uuid);
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+
+  t.test('api.url({ action: "create" })', function(t) {
+    var options = {
+      action: 'create'
+    };
+    var expected = format('%ssave', api.url());
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+
+  t.test('api.url({ action: "run" })', function(t) {
+    var options = {
+      action: 'run'
+    };
+    var expected = format('%scompile', api.url());
+
+    t.equal(api.url(options), expected);
+    t.end();
+  });
+});
diff --git a/client/test/test-component-log.js b/client/test/test-component-log.js
new file mode 100644
index 0000000..3efadbb
--- /dev/null
+++ b/client/test/test-component-log.js
@@ -0,0 +1,25 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var log = require('../browser/components/log');
+
+test('log(data)', function(t) {
+  var data = {
+    File: '',
+    Message: 'Response finished↵',
+    Stream: 'debug',
+    Timestamp: 1428710996621822700
+  };
+  var state = log(data);
+  var value = state();
+
+  t.deepEqual(value, {
+    file: '',
+    message: data.Message,
+    stream: data.Stream,
+    timestamp: data.Timestamp / 1e6
+  });
+  t.end();
+});
diff --git a/client/test/test-config.js b/client/test/test-config.js
new file mode 100644
index 0000000..8493e9f
--- /dev/null
+++ b/client/test/test-config.js
@@ -0,0 +1,16 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var test = require('tape');
+var config = require('../browser/config');
+
+test('config(key, value)', function(t) {
+  var url = 'https://playground.staging.v.io/api';
+
+  config('api-url', url);
+  var value = config('api-url');
+
+  t.equal(value, url);
+  t.end();
+});