todosapp: start of changes to support sharing/sync

- redo css (i.e. make it sane & make it easier to extend UI)
- simplify and improve JS state management (prepare for concurrent
  updates from sync)
- various other things

Also includes some tangentially related fixes and tweaks:
- add keys where needed to avoid react warnings
- use less for css
- use autoprefixer and cssnano for vendor prefixes and css compression
- switch to ctrl-L to open/close in-dom console log
- show loading message even while dispatcher is being initialized

Change-Id: I531649b521d41ef83f2a6d4903879e3daad9f531
diff --git a/Makefile b/Makefile
index ea49bb4..5cf4f9f 100644
--- a/Makefile
+++ b/Makefile
@@ -60,6 +60,9 @@
 # https://github.com/substack/node-browserify/issues/1063
 	touch node_modules
 
+public/bundle.min.css: $(shell find stylesheets) node_modules
+	lessc -sm=on stylesheets/index.less | postcss -u autoprefixer -u cssnano > $@
+
 public/bundle.min.js: browser/index.js $(shell find browser) node_modules
 ifdef DEBUG
 	$(call BROWSERIFY,$<,$@)
@@ -68,7 +71,7 @@
 endif
 
 .PHONY: build
-build: bin node_modules public/bundle.min.js
+build: bin node_modules public/bundle.min.css public/bundle.min.js
 
 .PHONY: serve
 serve: build
@@ -76,7 +79,7 @@
 
 .PHONY: clean
 clean:
-	rm -rf bin node_modules public/bundle.min.js
+	rm -rf bin node_modules public/bundle.*
 
 .PHONY: lint
 lint:
diff --git a/README.md b/README.md
index 00169cd..9b9bf64 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@
 By default, the web app will use an in-memory (in-browser-tab) local storage
 engine, and will not talk to Syncbase at all. To configure the app to talk to
 Syncbase, add `d=syncbase` to the url query params, or simply click the storage
-engine indicator in the upper right corner to toggle it.
+engine indicator in the top right corner to toggle it.
 
 When using Syncbase, by default the app attempts to contact the Syncbase service
 using the Vanadium object name `/localhost:8200`. To specify a different name,
@@ -132,28 +132,19 @@
 To run a simple benchmark (100 puts, followed by a scan of those rows), specify
 query param `bm=1`.
 
-### Open questions
+### Benchmark performance
 
-- Why can test browser talk to normal syncbase and not to test syncbase? This is
-  the opposite of what I'd expect given the blessings.
-  - Glob from test browser to test syncbase (service.listApps) fails with "does
-    not have Resolve access".
-  - RPCs from test browser to normal syncbase should fail with "untrusted root",
-    but instead they succeed.
+All numbers assume dev console is closed, and assume non-test setup as described
+above.
 
-- Why do test and normal browsers have different performance talking to the
-  same (normal) syncbase?
-  - Test browser: 100 puts takes 2s, scan takes 3.5s.
-  - Normal browser: 100 puts takes 5s, scan takes 9s.
+Currently, parallel 100 puts takes 4s, and scanning 100 rows takes 0.6s.
 
-  With dev console closed, scan takes roughly 0.6s on both (see below), but 100
-  puts still takes 2s in test browser vs. 4s in normal browser.
+For the puts, avoiding unnecessarily cautious Signature RPC and avoiding 2x
+blowup from unnecessary Resolve calls will help. Parallelism doesn't help as
+much as one would hope, need to understand why.
 
-- Why is JS scan so slow? Note, latency appears to be proportional to data size,
-  with some small fixed overhead. Also note, vrpc scan takes less than 0.3s.
-
-  ANSWER: Turns out if the dev console is closed, scan is much faster (0.6s).
-  Issue filed: https://github.com/vanadium/issues/issues/610
+For the scan, 100ms comes from JS encode/decode, and probably much of the rest
+from WSPR. Needs further digging.
 
 [syncbase]: https://docs.google.com/document/d/12wS_IEPf8HTE7598fcmlN-Y692OWMSneoe2tvyBEpi0/edit#
 [crx]: https://v.io/tools/vanadium-chrome-extension.html
diff --git a/browser/benchmark.js b/browser/benchmark.js
index 9e264a0..d6ba9a7 100644
--- a/browser/benchmark.js
+++ b/browser/benchmark.js
@@ -8,21 +8,21 @@
 
 var util = require('./util');
 
-exports.logLatency = logLatency;
+exports.logFn = logFn;
 exports.runBenchmark = runBenchmark;
 
 var LOG_EVERYTHING = false;
 
 function logStart(name) {
-  util.log(name + ' start');
+  console.log(name + ' start');
   return Date.now();
 }
 
 function logStop(name, start) {
-  util.log(name + ' took ' + (Date.now() - start) + 'ms');
+  console.log(name + ' took ' + (Date.now() - start) + 'ms');
 }
 
-function logLatency(name, cb) {
+function logFn(name, cb) {
   var start = logStart(name);
   return function() {
     logStop(name, start);
@@ -31,24 +31,17 @@
 }
 
 // Does n parallel puts with a common prefix, then returns the prefix.
-// TODO(sadovsky): According to Shyam, since these puts are being done in
-// parallel, it may be the case that each one is setting up its own VC (doing
-// auth handshake, fetching discharge, etc.), rather than all puts sharing a
-// single VC. Ali or Suharsh should know the state of VC sharing. Possible
-// workaround would be to do something that forces VC creation before starting
-// the parallel puts. (OTOH, we always run service.listApps before the puts,
-// which should create the VC.)
 function doPuts(ctx, tb, n, cb) {
-  cb = logLatency('doPuts', cb);
+  cb = logFn('doPuts', cb);
   var prefix = util.timestamp() + '.';
   async.times(100, function(n, cb) {
     // TODO(sadovsky): Remove this once we loosen Syncbase's naming rules.
     prefix = prefix.replace(/:/g, '.');
     var key = prefix + n;
     var value = '';
-    if (LOG_EVERYTHING) util.log('put: ' + key);
+    if (LOG_EVERYTHING) console.log('put: ' + key);
     tb.put(ctx, key, value, function(err) {
-      if (LOG_EVERYTHING) util.log('put done: ' + key);
+      if (LOG_EVERYTHING) console.log('put done: ' + key);
       cb(err);
     });
   }, function(err) {
@@ -58,16 +51,16 @@
 
 // Scans (and logs) all records with the given prefix.
 function doScan(ctx, tb, prefix, cb) {
-  cb = logLatency('doScan(' + prefix + ')', cb);
+  cb = logFn('doScan(' + prefix + ')', cb);
   var bytes = 0, streamErr = null;
   tb.scan(ctx, nosql.rowrange.prefix(prefix), function(err) {
     err = err || streamErr;
     if (err) return cb(err);
-    util.log('scanned ' + bytes + ' bytes');
+    console.log('scanned ' + bytes + ' bytes');
     cb();
   }).on('data', function(row) {
     bytes += row.key.length + row.value.length;
-    if (LOG_EVERYTHING) util.log('scan: ' + JSON.stringify(row));
+    if (LOG_EVERYTHING) console.log('scan: ' + JSON.stringify(row));
   }).on('error', function(err) {
     streamErr = streamErr || err.error;
   });
@@ -75,7 +68,7 @@
 
 // Assumes table 'tb' exists.
 function runBenchmark(ctx, db, cb) {
-  cb = logLatency('runBenchmark', cb);
+  cb = logFn('runBenchmark', cb);
   var tb = db.table('tb');
   doPuts(ctx, tb, 100, function(err, prefix) {
     if (err) return cb(err);
diff --git a/browser/defaults.js b/browser/defaults.js
index bae85e3..d41e637 100644
--- a/browser/defaults.js
+++ b/browser/defaults.js
@@ -2,12 +2,13 @@
 
 var async = require('async');
 var syncbase = require('syncbase');
+var vanadium = require('vanadium');
+var verror = vanadium.verror;
 
 var bm = require('./benchmark');
 var CollectionDispatcher = require('./collection_dispatcher');
 var MemCollection = require('./mem_collection');
 var SyncbaseDispatcher = require('./syncbase_dispatcher');
-var util = require('./util');
 
 // Copied from meteor/todos/server/bootstrap.js.
 var data = [
@@ -48,11 +49,12 @@
 ];
 
 function initData(disp, cb) {
+  cb = bm.logFn('initData', cb);
   var timestamp = Date.now();
   async.each(data, function(list, cb) {
     disp.addList({name: list.name}, function(err, listId) {
       if (err) return cb(err);
-      async.eachSeries(list.contents, function(info, cb) {
+      async.each(list.contents, function(info, cb) {
         timestamp += 1;  // ensure unique timestamp
         disp.addTodo(listId, {
           text: info[0],
@@ -62,7 +64,13 @@
         }, cb);
       }, cb);
     });
-  }, cb);
+  }, function(err) {
+    // NOTE(sadovsky): Based on console logs, it looks like browser async.each
+    // doesn't use process.nextTick for its final callback!
+    process.nextTick(function() {
+      return cb(err);
+    });
+  });
 }
 
 // Returns a new Vanadium context object with a timeout.
@@ -70,45 +78,37 @@
   return ctx.withTimeout(timeout || 5000);
 }
 
-function appExists(ctx, service, name, cb) {
-  service.listApps(ctx, function(err, names) {
-    if (err) return cb(err);
-    return cb(null, names.indexOf(name) >= 0);
-  });
-}
-
 exports.initSyncbaseDispatcher = function(rt, name, benchmark, cb) {
-  cb = bm.logLatency('initSyncbaseDispatcher', cb);
-  var service = syncbase.newService(name);
-  // TODO(sadovsky): Instead of appExists, simply check for ErrExist in the
-  // app.create response.
+  cb = bm.logFn('initSyncbaseDispatcher', cb);
   var ctx = rt.getContext();
-  appExists(wt(ctx), service, 'todos', function(err, exists) {
-    if (err) return cb(err);
-    var app = service.app('todos'), db = app.noSqlDatabase('db');
-    var disp = new SyncbaseDispatcher(rt, db);
-    if (exists) {
-      if (benchmark) {
-        return bm.runBenchmark(ctx, db, cb);
+  var service = syncbase.newService(name);
+  var app = service.app('todos'), db = app.noSqlDatabase('db');
+  var disp = new SyncbaseDispatcher(rt, db);
+  // TODO(sadovsky): Check that the VC (and discharge, etc.) for this RPC is
+  // reused for all subsequent RPCs.
+  app.create(wt(ctx), {}, function(err) {
+    if (err) {
+      if (err instanceof verror.ExistError) {
+        if (benchmark) {
+          return bm.runBenchmark(ctx, db, cb);
+        }
+        console.log('app exists; assuming database has been initialized');
+        return cb(null, disp);
       }
-      util.log('app exists; assuming everything has been initialized');
-      return cb(null, disp);
+      return cb(err);
     }
-    util.log('app does not exist; initializing everything');
-    app.create(wt(ctx), {}, function(err) {
+    console.log('app did not exist; initializing database');
+    db.create(wt(ctx), {}, function(err) {
       if (err) return cb(err);
-      db.create(wt(ctx), {}, function(err) {
+      db.createTable(wt(ctx), 'tb', {}, function(err) {
         if (err) return cb(err);
-        db.createTable(wt(ctx), 'tb', {}, function(err) {
+        if (benchmark) {
+          return bm.runBenchmark(ctx, db, cb);
+        }
+        console.log('hierarchy created; writing rows');
+        initData(disp, function(err) {
           if (err) return cb(err);
-          if (benchmark) {
-            return bm.runBenchmark(ctx, db, cb);
-          }
-          util.log('hierarchy created; writing rows');
-          initData(disp, function(err) {
-            if (err) return cb(err);
-            cb(null, disp);
-          });
+          cb(null, disp);
         });
       });
     });
@@ -116,7 +116,7 @@
 };
 
 exports.initCollectionDispatcher = function(cb) {
-  cb = bm.logLatency('initCollectionDispatcher', cb);
+  cb = bm.logFn('initCollectionDispatcher', cb);
   var lists = new MemCollection('lists'), todos = new MemCollection('todos');
   var disp = new CollectionDispatcher(lists, todos);
   initData(disp, function(err) {
diff --git a/browser/index.js b/browser/index.js
index 00e3eff..6b4df96 100644
--- a/browser/index.js
+++ b/browser/index.js
@@ -1,11 +1,12 @@
-// TODO(sadovsky): Maybe update to the new Meteor Todos UI.
 // https://github.com/meteor/simple-todos
 
 'use strict';
 
 /* jshint newcap: false */
+/* global Mousetrap */
 
 var _ = require('lodash');
+var async = require('async');
 var page = require('page');
 var React = require('react');
 var url = require('url');
@@ -25,13 +26,44 @@
 ////////////////////////////////////////
 // Global state
 
-var disp;  // type Dispatcher
+// Dispatcher, initialized by initDispatcher.
+var disp;
+
+// Used for query params.
+var u = url.parse(window.location.href, true);
 
 ////////////////////////////////////////
 // Helpers
 
 function noop() {}
 
+function initDispatcher(dispType, syncbaseName, benchmark, cb) {
+  var clientCb = cb;
+  cb = function(err, resDisp) {
+    if (err) return clientCb(err);
+    disp = resDisp;
+    clientCb();
+  };
+  if (dispType === 'collection') {
+    console.assert(!benchmark);
+    defaults.initCollectionDispatcher(cb);
+  } else if (dispType === 'syncbase') {
+    var vanadiumConfig = {
+      logLevel: vanadium.vlog.levels.INFO,
+      namespaceRoots: u.query.mounttable ? [u.query.mounttable] : undefined,
+      proxy: u.query.proxy
+    };
+    vanadium.init(vanadiumConfig, function(err, rt) {
+      if (err) return cb(err);
+      defaults.initSyncbaseDispatcher(rt, syncbaseName, benchmark, cb);
+    });
+  } else {
+    process.nextTick(function() {
+      cb(new Error('unknown dispType: ' + dispType));
+    });
+  }
+}
+
 function activateInput(input) {
   input.focus();
   input.select();
@@ -40,27 +72,27 @@
 function okCancelEvents(cbs) {
   var ok = cbs.ok || noop;
   var cancel = cbs.cancel || noop;
-  function done(ev) {
-    var value = ev.target.value;
+  function done(e) {
+    var value = e.target.value;
     if (value) {
-      ok(value, ev);
+      ok(value, e);
     } else {
-      cancel(ev);
+      cancel(e);
     }
   }
   return {
-    onKeyDown: function(ev) {
-      if (ev.which === 27) {  // esc
-        cancel(ev);
+    onKeyDown: function(e) {
+      if (e.which === 27) {  // esc
+        cancel(e);
       }
     },
-    onKeyUp: function(ev) {
-      if (ev.which === 13) {  // enter
-        done(ev);
+    onKeyUp: function(e) {
+      if (e.which === 13) {  // enter
+        done(e);
       }
     },
-    onBlur: function(ev) {
-      done(ev);
+    onBlur: function(e) {
+      done(e);
     }
   };
 }
@@ -68,8 +100,8 @@
 ////////////////////////////////////////
 // Components
 
-var TagFilter = React.createFactory(React.createClass({
-  displayName: 'TagFilter',
+var TagsPane = React.createFactory(React.createClass({
+  displayName: 'TagsPane',
   render: function() {
     var that = this;
     var tagFilter = this.props.tagFilter;
@@ -94,22 +126,23 @@
       count: totalCount,
       selected: tagFilter === null
     });
-    return h('div#tag-filter.tag-list', [
-      h('div.label', 'Show:')
+    return h('div#tags-pane', [
+      h('div.label', {key: 'label'}, 'Show:')
     ].concat(_.map(tagInfos, function(tagInfo) {
-      var count = h('span.count', '(' + tagInfo.count + ')');
+      var count = h('span.count', {key: 'count'}, '(' + tagInfo.count + ')');
       return h('div.tag' + (tagInfo.selected ? '.selected' : ''), {
+        key: 'tag:' + (tagInfo.tag || ''),
         onMouseDown: function() {
           var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
           that.props.setTagFilter(newTagFilter);
         }
-      }, [tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count]);
+      }, [(tagInfo.tag || 'All items'), count]);
     })));
   }
 }));
 
-var Tags = React.createFactory(React.createClass({
-  displayName: 'Tags',
+var TodoTags = React.createFactory(React.createClass({
+  displayName: 'TodoTags',
   getInitialState: function() {
     return {
       addingTag: false
@@ -124,25 +157,20 @@
     var that = this;
     var children = [];
     _.each(this.props.tags, function(tag) {
-      // Note, we must specify the "key" prop so that React doesn't reuse the
-      // opacity=0 node after a tag is removed.
-      children.push(h('div.tag.removable_tag', {key: tag}, [
-        h('div.name', tag),
+      children.push(h('div.tag.removable', {key: tag}, [
+        h('div.name', {key: 'name'}, tag),
         h('div.remove', {
-          onClick: function(ev) {
-            ev.target.parentNode.style.opacity = 0;
-            // Wait for CSS animation to finish.
-            window.setTimeout(function() {
-              // TODO(sadovsky): If no other todos have the removed tag, maybe
-              // set tagFilter to null.
-              disp.removeTag(that.props.todoId, tag);
-            }, 200);
+          key: 'remove',
+          onClick: function(e) {
+            disp.removeTag(that.props.todoId, tag);
           }
         })
       ]));
     });
     if (this.state.addingTag) {
-      children.push(h('div.tag.edittag', h('input#edittag-input', _.assign({
+      children.push(h('div.tag.edittag', {
+        key: 'edittag'
+      }, h('input#edittag-input', _.assign({
         type: 'text',
         defaultValue: ''
       }, okCancelEvents({
@@ -156,6 +184,7 @@
       })))));
     } else {
       children.push(h('div.tag.addtag', {
+        key: 'addtag',
         onClick: function() {
           that.setState({addingTag: true});
         }
@@ -178,10 +207,12 @@
     }
   },
   render: function() {
-    var that = this;
-    var todo = this.props.todo, children = [];
-    if (this.state.editingText) {
-      children.push(h('div.edit', h('input#todo-input', _.assign({
+    var that = this, todo = this.props.todo, et = this.state.editingText;
+    var hDescription;
+    if (et) {
+      hDescription = h('div.description', {
+        key: 'description'
+      }, h('input#todo-input', _.assign({
         type: 'text',
         defaultValue: todo.text
       }, okCancelEvents({
@@ -192,67 +223,71 @@
         cancel: function() {
           that.setState({editingText: false});
         }
-      })))));
+      }))));
     } else {
-      children.push(h('div.destroy', {
+      hDescription = h('div.description', {
+        key: 'description',
+        onDoubleClick: function() {
+          that.setState({editingText: true});
+        }
+      }, todo.text);
+    }
+    var opts = (et ? '.edit' : '') + (todo.done ? '.done' : '');
+    return h('li.todo-row' + opts, [
+      h('div.destroy', {
+        key: 'destroy',
         onClick: function() {
           disp.removeTodo(todo._id);
         }
-      }));
-      children.push(h('div.display', [
-        h('input.check', {
-          type: 'checkbox',
-          checked: todo.done,
-          onClick: function() {
-            disp.markTodoDone(todo._id, !todo.done);
-          }
-        }),
-        h('div.todo-text', {
-          onDoubleClick: function() {
-            that.setState({editingText: true});
-          }
-        }, todo.text)
-      ]));
-    }
-    children.push(Tags({todoId: todo._id, tags: todo.tags}));
-    return h('li.todo' + (todo.done ? '.done' : ''), children);
+      }),
+      h('input.checkbox', {
+        key: 'checkbox',
+        type: 'checkbox',
+        checked: todo.done,
+        onChange: function() {
+          disp.markTodoDone(todo._id, !todo.done);
+        }
+      }),
+      hDescription,
+      TodoTags({key: 'tags', todoId: todo._id, tags: todo.tags})
+    ]);
   }
 }));
 
-var Todos = React.createFactory(React.createClass({
-  displayName: 'Todos',
+var TodosPane = React.createFactory(React.createClass({
+  displayName: 'TodosPane',
   render: function() {
     var that = this;
     if (!this.props.listId) {
       return null;
     }
     var children = [];
-    if (this.props.todos === null) {
-      children.push('Loading...');
+    if (!this.props.todos) {
+      children.push(h('div.loading', {key: 'loading'}, 'Loading...'));
     } else {
       var tagFilter = this.props.tagFilter, items = [];
       _.each(this.props.todos, function(todo) {
         if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
-          items.push(Todo({todo: todo}));
+          items.push(Todo({key: todo._id, todo: todo}));
         }
       });
-      children.push(h('div#new-todo-box', h('input#new-todo', _.assign({
+      children.push(h('div#new-todo', {key: 'new-todo'}, h('input', _.assign({
         type: 'text',
         placeholder: 'New item'
       }, okCancelEvents({
-        ok: function(value, ev) {
+        ok: function(value, e) {
           disp.addTodo(that.props.listId, {
             text: value,
             tags: tagFilter ? [tagFilter] : [],
             done: false,
             timestamp: Date.now()
           });
-          ev.target.value = '';
+          e.target.value = '';
         }
       })))));
-      children.push(h('ul#item-list', items));
+      children.push(h('ul#todo-list', {key: 'todo-list'}, items));
     }
-    return h('div#items-view', children);
+    return h('div#todos-pane', children);
   }
 }));
 
@@ -269,11 +304,13 @@
     }
   },
   render: function() {
-    var that = this;
-    var list = this.props.list, child;
+    var that = this, list = this.props.list;
+    var children = [];
     // http://facebook.github.io/react/docs/forms.html#controlled-components
     if (this.state.editingName) {
-      child = h('div.edit', h('input#list-name-input', _.assign({
+      children.push(h('div.edit', {
+        key: 'edit'
+      }, h('input#list-name-input', _.assign({
         type: 'text',
         defaultValue: list.name
       }, okCancelEvents({
@@ -284,14 +321,23 @@
         cancel: function() {
           that.setState({editingName: false});
         }
-      }))));
+      })))));
     } else {
-      child = h('div.display', h('a.list-name' + (list.name ? '' : '.empty'), {
-        href: '/lists/' + list._id,
-        onClick: function(ev) {
-          ev.preventDefault();
+      children.push(h('div.status', {
+        key: 'status',
+        onClick: function(e) {
+          e.preventDefault();
+          that.props.showStatusDialog();
         }
-      }, list.name));
+      }, h('div.circle')));
+      children.push(h('div.display', {
+        key: 'display'
+      }, h('a.list-name' + (list.name ? '' : '.empty'), {
+        href: '/lists/' + list._id,
+        onClick: function(e) {
+          e.preventDefault();
+        }
+      }, list.name)));
     }
     return h('div.list' + (list.selected ? '.selected' : ''), {
       onMouseDown: function() {
@@ -300,48 +346,98 @@
       onDoubleClick: function() {
         that.setState({editingName: true});
       }
-    }, child);
+    }, children);
   }
 }));
 
-var Lists = React.createFactory(React.createClass({
-  displayName: 'Lists',
+var StatusPane = React.createFactory(React.createClass({
+  displayName: 'StatusPane',
+  componentDidMount: function() {
+    var that = this;
+    Mousetrap.bind('esc', function() {
+      that.props.close();
+    });
+  },
+  componentWillUnmount: function() {
+    Mousetrap.unbind('esc');
+  },
   render: function() {
     var that = this;
-    var children = [h('h3', 'Todo Lists')];
-    if (this.props.lists === null) {
-      children.push(h('div#lists', 'Loading...'));
+    return h('div#status-pane', {
+      onClick: function(e) {
+        if (e.target === e.currentTarget) {
+          that.props.close();
+        }
+      }
+    }, h('div.status-dialog', [
+      // TODO(sadovsky): Add stuff here.
+      h('h4', {key: 'title'}, 'Share with others'),
+      // FIXME: Finish implementing this.
+      h('div.close', {
+        key: 'close',
+        onClick: function() {
+          that.props.close();
+        }
+      })
+    ]));
+  }
+}));
+
+var ListsPane = React.createFactory(React.createClass({
+  displayName: 'ListsPane',
+  getInitialState: function() {
+    return {
+      statusDialog: null  // null or list id
+    };
+  },
+  render: function() {
+    var that = this;
+    var children = [h('div.lists-title', {key: 'title'}, 'Todo Lists')];
+    if (!this.props.lists) {
+      children.push(h('div.loading', {key: 'loading'}, 'Loading...'));
     } else {
       var lists = [];
       _.each(this.props.lists, function(list) {
         list.selected = that.props.listId === list._id;
         lists.push(List({
+          key: list._id,
           list: list,
-          setListId: that.props.setListId
+          setListId: that.props.setListId,
+          showStatusDialog: function() {
+            that.setState({statusDialog: list._id});
+          }
         }));
       });
-      children.push(h('div#lists', lists));
-      children.push(h('div#createList', h('input#new-list', _.assign({
+      children.push(h('div', {key: 'lists'}, lists));
+      children.push(h('div.new-list', {key: 'new-list'}, h('input', _.assign({
         type: 'text',
         placeholder: 'New list'
       }, okCancelEvents({
-        ok: function(value, ev) {
+        ok: function(value, e) {
           disp.addList({name: value}, function(err, listId) {
             if (err) throw err;
             that.props.setListId(listId);
           });
-          ev.target.value = '';
+          e.target.value = '';
         }
       })))));
+      if (this.state.statusDialog) {
+        children.push(StatusPane({
+          key: 'status',
+          close: function() {
+            that.setState({statusDialog: null});
+          }
+        }));
+      }
     }
-    return h('div', children);
+    return h('div#lists-pane', children);
   }
 }));
 
 var DispType = React.createFactory(React.createClass({
   render: function() {
     var that = this;
-    return h('div.disp-type.' + this.props.dispType, {
+    return h('div#disp-type.' + this.props.dispType, {
       onClick: function() {
         that.props.toggleDispType();
       }
@@ -353,8 +449,11 @@
   displayName: 'Page',
   getInitialState: function() {
     return {
-      lists: null,  // all lists
-      todos: null,  // all todos for current listId
+      dispInitialized: false,
+      lists: {seq: 0, items: null},  // all lists
+      todos: {},  // map of listId to {seq, items}
+      // FIXME: Populate and use this.
+      syncgroups: {},  // map of listId to sgInfo
       listId: this.props.initialListId,  // current list
       tagFilter: null  // current tag
     };
@@ -382,58 +481,120 @@
     // Note, this doesn't trigger a re-render; it's purely visual.
     window.history.replaceState({}, '', pathname + window.location.search);
   },
-  componentDidMount: function() {
-    var that = this;
-
-    // TODO(sadovsky): Only read (and only update) what's needed based on what
-    // changed.
-    disp.on('change', function() {
-      var listId = that.state.listId;
-      that.getLists_(function(err, lists) {
-        if (err) throw err;
-        that.getTodos_(listId, function(err, todos) {
-          if (err) throw err;
-          // TODO(sadovsky): Maybe don't call setState if a newer change has
-          // been observed.
-          var nextState = {lists: lists};
-          if (that.state.listId === listId) {
-            nextState.todos = todos;
-          }
-          that.setState(nextState);
-        });
-      });
-    });
-
-    that.getLists_(function(err, lists) {
+  componentWillMount: function() {
+    var that = this, props = this.props;
+    var dt = props.dispType, sn = props.syncbaseName, bm = props.benchmark;
+    initDispatcher(dt, sn, bm, function(err) {
       if (err) throw err;
-      var listId = that.state.listId;
-      if ((!listId || !_.includes(_.pluck(lists, '_id'), listId)) &&
-          lists.length > 0) {
+      that.setState({dispInitialized: true});
+    });
+  },
+  componentDidMount: function() {
+    console.assert(!this.state.dispInitialized);
+  },
+  componentDidUpdate: function(prevProps, prevState) {
+    var that = this;
+    this.updateURL();
+
+    // Only run the code below when disp has just been initialized.
+    if (prevState.dispInitialized || !this.state.dispInitialized) {
+      return;
+    }
+
+    // Returns the list id for the list that should be displayed.
+    function getListId() {
+      var listId = that.state.listId, lists = that.state.lists.items;
+      // If listId refers to an unknown list, set it to null.
+      if (!_.includes(_.pluck(lists, '_id'), listId)) {
+        listId = null;
+      }
+      // If listId is not set, set it to the id of the first list.
+      if (!listId && lists.length > 0) {
         listId = lists[0]._id;
       }
+      return listId;
+    }
+
+    // Updates lists. Calls cb once the setState call has completed.
+    // TODO(sadovsky): If possible, simplify how we deal with concurrent state
+    // updates, here and elsewhere. The current approach is fairly subtle and
+    // error-prone. Our goal is simple: never show stale data, even in the
+    // presence of sync.
+    function updateLists(cb) {
+      var listsSeq = that.state.lists.seq + 1;
+      that.getLists_(function(err, lists) {
+        if (err) return cb(err);
+        // Use setState(cb) form to ensure atomicity.
+        // References: https://goo.gl/CZ82Vp and https://goo.gl/vVCp8B
+        that.setState(function(state) {
+          if (listsSeq <= state.lists.seq) {
+            return {};
+          }
+          return {lists: {seq: listsSeq, items: lists}};
+        }, cb);
+      });
+    }
+
+    // Updates todos for the specified list. Calls cb once the setState call
+    // has completed.
+    function updateTodos(listId, cb) {
+      var stateTodos = that.state.todos[listId];
+      var todosSeq = (stateTodos ? stateTodos.seq : 0) + 1;
       that.getTodos_(listId, function(err, todos) {
+        if (err) return cb(err);
+        // Use setState(cb) form to ensure atomicity.
+        // https://goo.gl/CZ82Vp
+        that.setState(function(state) {
+          var stateTodos = state.todos[listId];
+          if (stateTodos && todosSeq <= stateTodos.seq) {
+            return {};
+          }
+          state.todos[listId] = {seq: todosSeq, items: todos};
+          return {todos: state.todos};
+        }, cb);
+      });
+    }
+
+    // TODO(sadovsky): Only read (and only redraw) what's needed based on what
+    // changed.
+    disp.on('change', function() {
+      updateLists(function(err) {
         if (err) throw err;
-        that.setState({
-          lists: lists,
-          todos: todos,
-          listId: listId
+        var listId = getListId();
+        updateTodos(listId, function(err) {
+          if (err) throw err;
         });
       });
     });
-  },
-  componentWillUpdate: function(nextProps, nextState) {
-    if (false) {
-      util.log(this.props, nextProps);
-      util.log(this.state, nextState);
-    }
-  },
-  componentDidUpdate: function() {
-    this.updateURL();
+
+    // Load initial lists and todos. Note that changes can come in concurrently
+    // via sync.
+    updateLists(function(err) {
+      if (err) throw err;
+      // Set initial listId if needed.
+      var listId = getListId();
+      if (listId !== that.state.listId) {
+        that.setState({listId: listId});
+      }
+      // Get todos for all lists.
+      var listIds = _.pluck(that.state.lists.items, '_id');
+      async.each(listIds, updateTodos, function(err) {
+        if (err) throw err;
+      });
+    });
   },
   render: function() {
+    if (this.props.benchmark) {
+      return null;
+    }
+
     var that = this;
-    return h('div', [
+    var listId = this.state.listId;
+    // If currTodos is {}, todos.items will be undefined, as desired.
+    var currTodos = this.state.todos[listId] || {};
+    return h('div#page-pane', [
       DispType({
+        key: 'DispType',
         dispType: this.props.dispType,
         toggleDispType: function() {
           var newDispType = DISP_TYPE_SYNCBASE;
@@ -443,44 +604,35 @@
           window.location.href = '/?d=' + newDispType + '&n=' + SYNCBASE_NAME;
         }
       }),
-      h('div#top-tag-filter', TagFilter({
-        todos: this.state.todos,
-        tagFilter: this.state.tagFilter,
-        setTagFilter: function(tagFilter) {
-          that.setState({tagFilter: tagFilter});
-        }
-      })),
-      h('div#main-pane', Todos({
-        todos: this.state.todos,
-        listId: this.state.listId,
-        tagFilter: this.state.tagFilter
-      })),
-      h('div#side-pane', Lists({
-        lists: this.state.lists,
-        listId: this.state.listId,
+      ListsPane({
+        key: 'ListsPane',
+        lists: this.state.lists.items,
+        listId: listId,
         setListId: function(listId) {
           if (listId !== that.state.listId) {
             that.setState({
-              todos: null,
               listId: listId,
               tagFilter: null
-            }, function() {
-              // Run getTodos_ in the setState callback to ensure that it will
-              // execute after the 'change' event handler executes when a list
-              // is created locally.
-              // TODO(sadovsky): Maybe hold all todos (for all lists) in memory
-              // so that we don't show a brief "loading" message on every list
-              // change.
-              that.getTodos_(listId, function(err, todos) {
-                if (err) throw err;
-                if (listId === that.state.listId) {
-                  that.setState({todos: todos});
-                }
-              });
             });
           }
         }
-      }))
+      }),
+      h('div#tags-and-todos-pane', {key: 'tags-and-todos-pane'}, [
+        TagsPane({
+          key: 'TagsPane',
+          todos: currTodos.items,
+          tagFilter: this.state.tagFilter,
+          setTagFilter: function(tagFilter) {
+            that.setState({tagFilter: tagFilter});
+          }
+        }),
+        TodosPane({
+          key: 'TodosPane',
+          todos: currTodos.items,
+          listId: listId,
+          tagFilter: this.state.tagFilter
+        })
+      ])
     ]);
   }
 }));
@@ -488,63 +640,36 @@
 ////////////////////////////////////////
 // Initialization
 
+// TODO(sadovsky): Override other console methods and add window.onerror
+// handler.
 var logEl = document.querySelector('#log');
-util.addLogger(function() {
+var consoleLog = console.log.bind(console);
+console.log = function() {
+  var args = [util.timestamp()].concat(Array.prototype.slice.call(arguments));
+  consoleLog.apply(null, args);
   var msgEl = document.createElement('div');
   msgEl.className = 'msg';
-  msgEl.innerText = Array.prototype.slice.call(arguments).join(' ');
+  msgEl.innerText = args.join(' ');
   logEl.appendChild(msgEl);
+  logEl.scrollTop = logEl.scrollHeight;  // scroll to bottom
+};
+
+Mousetrap.bind(['ctrl+l', 'meta+l'], function() {
+  logEl.classList.toggle('visible');
 });
-util.log('starting app');
-
-var u = url.parse(window.location.href, true);
-
-var rc;  // React component
-function render(props) {
-  console.assert(!rc);
-  rc = React.render(Page(props), document.querySelector('#page'));
-}
-
-function initDispatcher(dispType, syncbaseName, benchmark, cb) {
-  if (dispType === 'collection') {
-    console.assert(!benchmark);
-    defaults.initCollectionDispatcher(cb);
-  } else if (dispType === 'syncbase') {
-    var vanadiumConfig = {
-      logLevel: vanadium.vlog.levels.INFO,
-      namespaceRoots: u.query.mounttable ? [u.query.mounttable] : undefined,
-      proxy: u.query.proxy
-    };
-    vanadium.init(vanadiumConfig, function(err, rt) {
-      if (err) return cb(err);
-      defaults.initSyncbaseDispatcher(rt, syncbaseName, benchmark, cb);
-    });
-  } else {
-    process.nextTick(function() {
-      cb(new Error('unknown dispType: ' + dispType));
-    });
-  }
-}
 
 // Note, ctx here is a Page.js context, not a Vanadium context.
 function main(ctx) {
-  console.assert(!rc);
   var dispType = u.query.d || 'collection';
   var syncbaseName = u.query.n || SYNCBASE_NAME;
   var benchmark = Boolean(u.query.bm);
   var props = {
     initialListId: ctx.params.listId,
     dispType: dispType,
-    syncbaseName: syncbaseName
+    syncbaseName: syncbaseName,
+    benchmark: benchmark
   };
-  initDispatcher(dispType, syncbaseName, benchmark, function(err, resDisp) {
-    if (err) throw err;
-    if (benchmark) return;
-    disp = resDisp;
-    // TODO(sadovsky): initDispatcher with DISP_TYPE_SYNCBASE is slow. We should
-    // show a "loading" message in the UI.
-    render(props);
-  });
+  React.render(Page(props), document.querySelector('#page'));
 }
 
 page('/', main);
diff --git a/browser/mem_collection.js b/browser/mem_collection.js
index 2156dc4..3b2dc16 100644
--- a/browser/mem_collection.js
+++ b/browser/mem_collection.js
@@ -27,7 +27,7 @@
     return that.matches_(v, q);
   });
   if (opts.sort) {
-    // TODO(sadovsky): Eliminate simplifying assumptions.
+    // Note, we make various simplifying assumptions.
     var keys = _.keys(opts.sort);
     console.assert(keys.length === 1);
     var key = keys[0];
@@ -72,7 +72,7 @@
     return that.matches_(v, q);
   });
 
-  // TODO(sadovsky): Eliminate simplifying assumptions.
+  // Note, we make various simplifying assumptions.
   var keys = _.keys(opts);
   console.assert(keys.length === 1);
   var key = keys[0];
diff --git a/browser/syncbase_dispatcher.js b/browser/syncbase_dispatcher.js
index 1c64ccc..e840b44 100644
--- a/browser/syncbase_dispatcher.js
+++ b/browser/syncbase_dispatcher.js
@@ -13,10 +13,10 @@
 // enables us to use simple last-one-wins conflict resolution for all records
 // stored in Syncbase.
 //
-// TODO(sadovsky): Unfortunately, orphaning degrades performance, because scan
-// RPCs (e.g. scan to get all lists) read (and discard) orphaned records. If we
-// switch from scans to queries, performance should improve since all row
-// filtering will happen on the server side.
+// TODO(sadovsky): Orphaning degrades performance, because scan responses (e.g.
+// scan to get all lists) include orphaned records. If we switch from scans to
+// queries, performance should improve since all row filtering will happen
+// server side.
 
 'use strict';
 
@@ -31,7 +31,6 @@
 
 var bm = require('./benchmark');
 var Dispatcher = require('./dispatcher');
-var util = require('./util');
 
 inherits(SyncbaseDispatcher, Dispatcher);
 module.exports = SyncbaseDispatcher;
@@ -115,7 +114,7 @@
     // Drop ctx and cb, convert to JSON, drop square brackets.
     var cb = args[args.length - 1];
     var argsStr = JSON.stringify(args.slice(1, -1)).slice(1, -1);
-    args[args.length - 1] = bm.logLatency(name + '(' + argsStr + ')', cb);
+    args[args.length - 1] = bm.logFn(name + '(' + argsStr + ')', cb);
     return fn.apply(this, args);
   };
 }
@@ -249,7 +248,7 @@
 };
 
 SyncbaseDispatcher.prototype.logTraceRecords = function() {
-  util.log(vtrace.formatTraces(this.getTraceRecords()));
+  console.log(vtrace.formatTraces(this.getTraceRecords()));
 };
 
 // TODO(sadovsky): Watch for changes on Syncbase itself so that we can detect
diff --git a/browser/util.js b/browser/util.js
index 999ca99..0584383 100644
--- a/browser/util.js
+++ b/browser/util.js
@@ -23,20 +23,7 @@
 };
 
 // Returns a string timestamp, useful for logging.
-var timestamp = exports.timestamp = function(t) {
+exports.timestamp = function(t) {
   t = t || Date.now();
   return moment(t).format('HH:mm:ss.SSS');
 };
-
-var LOGGERS = [console.log.bind(console)];
-
-exports.addLogger = function(logger) {
-  LOGGERS.push(logger);
-};
-
-exports.log = function() {
-  var args = [timestamp()].concat(Array.prototype.slice.call(arguments));
-  _.forEach(LOGGERS, function(logger) {
-    logger.apply(null, args);
-  });
-};
diff --git a/package.json b/package.json
index 139877b..026b9ef 100644
--- a/package.json
+++ b/package.json
@@ -24,10 +24,14 @@
     "react": "^0.13.3"
   },
   "devDependencies": {
+    "autoprefixer": "^5.2.0",
     "browserify": "^10.2.6",
     "browserify-shim": "^3.8.9",
+    "cssnano": "^2.1.0",
     "exorcist": "~0.4.0",
     "jshint": "^2.8.0",
+    "less": "^2.5.1",
+    "postcss-cli": "^1.4.0",
     "uglifyify": "~3.0.1"
   }
 }
diff --git a/public/extras.css b/public/extras.css
deleted file mode 100644
index 2edb974..0000000
--- a/public/extras.css
+++ /dev/null
@@ -1,54 +0,0 @@
-*,
-:before,
-:after {
-  box-sizing: border-box;
-}
-
-.disp-type {
-  position: fixed;
-  top: 0;
-  right: 0;
-  padding: 4px 8px;
-  cursor: pointer;
-  color: white;
-  font-weight: bold;
-  z-index: 1;
-}
-
-/* https://www.google.com/design/spec/style/color.html */
-.disp-type.collection {
-  background-color: #388e3c;
-}
-.disp-type.syncbase {
-  background-color: #673ab7;
-}
-
-#log {
-  position: fixed;
-  right: 0;
-  bottom: 0;
-  width: 20px;
-  height: 20px;
-  background-color: #e91e63;
-  font: 400 14px/1.4 monospace;
-  overflow-x: hidden;
-  overflow-y: scroll;
-  white-space: pre-wrap;
-  word-wrap: break-word;
-}
-
-#log .msg {
-  display: none;
-}
-
-#log:hover {
-  width: 100%;
-  height: 300px;
-  padding: 16px;
-  background-color: #fff;
-  border-top: 1px solid #000;
-}
-
-#log:hover .msg {
-  display: block;
-}
diff --git a/public/index.css b/public/index.css
deleted file mode 100644
index 7ada0b4..0000000
--- a/public/index.css
+++ /dev/null
@@ -1,266 +0,0 @@
-/* Copy of the original Meteor Todos app CSS file (only slightly modified). */
-
-* {
-  padding: 0;
-  margin: 0;
-}
-
-ul {
-  list-style: none;
-}
-
-html, body {
-  height: 100%;
-}
-
-body {
-  font-size: 16px;
-  line-height: 1.5;
-  background: #eeeeee;
-  color: #333333;
-}
-
-body, input {
-  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-}
-
-input {
-  font-size: 100%;
-}
-
-a,
-a:visited,
-a:active {
-  color: #258;
-}
-
-h3 {
-  font-weight: bold;
-  text-decoration: underline;
-  font-size: 120%;
-  padding: 8px 6px;
-  text-align: center;
-}
-
-#top-tag-filter, #main-pane, #side-pane, #bottom-pane {
-  position: absolute;
-  left: 0;
-  right: 0;
-  top: 0;
-  bottom: 0;
-  overflow: hidden;
-}
-
-#top-tag-filter {
-  left: 200px;
-  height: 44px;
-  bottom: auto;
-  background: #ddd;
-  border-bottom: 1px solid #999;
-}
-
-#help {
-  padding: 8px;
-}
-
-#main-pane {
-  top: 45px;
-  bottom: 0;
-  left: 220px;
-  overflow: auto;
-}
-
-#side-pane {
-  width: 200px;
-  right: auto;
-  overflow: auto;
-  background: #eee;
-  border-right: 1px solid #999;
-  background: #ddd;
-}
-
-.tag {
-  cursor: pointer;
-  float: left;
-  margin: 5px;
-  padding: 2px 7px;
-  font-size: 80%;
-  font-weight: bold;
-  background: #999;
-  color: #fff;
-  border-radius: 4px;
-  -webkit-border-radius: 4px;
-  -moz-border-radius: 4px;
-  -o-border-radius: 4px;
-
-  opacity: 1;
-  transition: opacity 0.3s linear;
-  -moz-transition: opacity 0.3s linear;
-  -webkit-transition: opacity 0.3s linear;
-  -o-transition: opacity 0.3s linear;
-
-  position: relative;
-}
-
-#tag-filter .label {
-  float: left;
-  margin-top: 9px;
-  margin-left: 12px;
-  margin-right: 8px;
-}
-
-#tag-filter .tag {
-  margin-top: 10px;
-  border: 1px solid #777;
-}
-
-#tag-filter .selected {
-  background: #69d;
-}
-
-#tag-filter .count {
-  font-weight: normal;
-  padding-left: 2px;
-}
-
-#lists .list {
-  padding: 3px 6px;
-}
-
-#lists .selected {
-  padding: 2px 6px;
-  background: #9be;
-  font-weight: bold;
-}
-
-#lists .list-name {
-  cursor: pointer;
-  color: black;
-  text-decoration: none;
-}
-
-#createList {
-  padding: 3px 6px;
-  margin-top: 5px;
-}
-
-#createList input {
-  width: 180px;
-}
-
-#new-todo-box {
-  margin-top: 10px;
-  margin-bottom: 10px;
-  margin-left: 60px;
-  margin-right: 20px;
-  font-size: 160%;
-  position: relative;
-  height: 40px;
-}
-
-#new-todo {
-  position: absolute;
-  width: 100%;
-}
-
-#items-view {
-  margin-top: 5px;
-  margin-left: 5px;
-}
-
-#item-list .todo {
-  display: block;
-  height: 50px;
-  position: relative;
-  overflow: hidden;
-  border-top: 1px solid #ccc;
-}
-
-#item-list .todo .destroy {
-  cursor: pointer;
-  position: absolute;
-  left: 5px;
-  top: 15px;
-  height: 20px;
-  width: 20px;
-}
-
-#item-list .todo .display, #item-list .todo .edit {
-  margin-left: 30px;
-  height: 100%;
-  width: auto;
-  float: left;
-  padding-top: 18px;
-  line-height: 1;
-}
-
-#todo-input {
-  width: 300px;
-  position: relative;
-  top: -3px;
-}
-
-#item-list .done .todo-text {
-  text-decoration: line-through;
-  color: #999;
-}
-
-#item-list .todo:hover .destroy {
-  background: url("/public/destroy.png") no-repeat 0 0;
-}
-
-#item-list .todo .destroy:hover {
-  background-position: 0 -20px;
-}
-
-#item-list .todo .item-tags {
-  overflow: auto;
-  float: right;
-  margin-right: 8px;
-}
-
-#item-list .todo .item-tags .tag {
-  margin-top: 15px;
-}
-
-#item-list .todo .item-tags .removable_tag {
-  padding-right: 22px;
-}
-
-#item-list .todo .item-tags .tag .remove {
-  position: absolute;
-  top: 0;
-  right: 4px;
-  bottom: 0;
-  width: 16px;
-  background: url("/public/close_16.png") no-repeat 0 center;
-}
-
-#item-list .todo .item-tags .tag .remove:hover {
-  background-position: -16px center;
-}
-
-#item-list .todo .item-tags div.addtag {
-  background: none;
-  color: #333;
-  border: 1px dashed #999;
-}
-
-#item-list .todo .check {
-  float: left;
-  width: 25px;
-}
-
-#item-list .todo .todo-text {
-  float: left;
-  margin-left: 10px;
-  font-size: 100%;
-}
-
-#item-list .todo .edit input {
-  margin-left: 35px;
-}
-
-#edittag-input {
-  width: 80px;
-}
diff --git a/public/index.html b/public/index.html
index 9cf329c..b76dc6d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,8 +3,7 @@
   <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no">
-    <link rel="stylesheet" href="/public/index.css">
-    <link rel="stylesheet" href="/public/extras.css">
+    <link rel="stylesheet" href="/public/bundle.min.css">
     <link rel="icon" href="about:blank">
     <title>Todos</title>
   </head>
@@ -14,6 +13,7 @@
     <script src="/third_party/async.min.js"></script>
     <script src="/third_party/lodash.min.js"></script>
     <script src="/third_party/moment.min.js"></script>
+    <script src="/third_party/mousetrap.min.js"></script>
     <!--<script src="public/third_party/react.min.js"></script>-->
     <script src="/third_party/react-with-addons.js"></script>
     <script src="/public/bundle.min.js"></script>
diff --git a/stylesheets/constants.less b/stylesheets/constants.less
new file mode 100644
index 0000000..8e996ff
--- /dev/null
+++ b/stylesheets/constants.less
@@ -0,0 +1,3 @@
+/* https://www.google.com/design/spec/style/color.html */
+@color-green-700: #388e3c;
+@color-red-700: #d32f2f;
diff --git a/stylesheets/index.less b/stylesheets/index.less
new file mode 100644
index 0000000..b2b6897
--- /dev/null
+++ b/stylesheets/index.less
@@ -0,0 +1,384 @@
+/* Adapted from the original Meteor Todos app CSS file. */
+
+@import "constants";
+
+*,
+:before,
+:after {
+  box-sizing: border-box;
+}
+
+html {
+  font: 400 16px/1.5 sans-serif;
+}
+
+html, body {
+  height: 100%;
+}
+
+body {
+  font-size: 16px;
+  line-height: 1.5;
+  background-color: #eee;
+  color: #333;
+  margin: 0;
+}
+
+h1, h2, h3, h4, h5, h6, p {
+  margin: 0;
+
+  &:not(:last-child) {
+    margin-bottom: 1em;
+  }
+}
+
+h1, h2, h3, h4, h5, h6 {
+  &:not(:first-child) {
+    margin-top: 2em;
+  }
+}
+
+p {
+  &:not(:first-child) {
+    margin-top: 1em;
+  }
+}
+
+input {
+  font: inherit;
+
+  &:focus {
+    outline: none;
+  }
+}
+
+/* Pane arrangement ***********************************************************/
+
+#page-pane {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  overflow: hidden;
+}
+
+#page-pane, #tags-and-todos-pane {
+  display: flex;
+}
+
+#tags-and-todos-pane {
+  flex-direction: column;
+}
+
+#tags-and-todos-pane, #todos-pane {
+  flex: 1;
+}
+
+#lists-pane {
+  flex: 0 0 240px;
+}
+
+#tags-pane {
+  flex: 0 0 48px;
+}
+
+#todos-pane, #lists-pane, #tags-pane {
+  overflow: auto;
+}
+
+/* Generic classes ************************************************************/
+
+.hcenter {
+  display: flex;
+  justify-content: center;
+}
+
+.vcenter {
+  display: flex;
+  align-items: center;
+}
+
+.loading {
+  padding: 4px 8px;
+}
+
+.tag {
+  display: inline-block;
+  margin-left: 8px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  background-color: #999;
+  color: #fff;
+  font-size: 13px;
+  font-weight: bold;
+  cursor: pointer;
+}
+
+/* Lists pane *****************************************************************/
+
+#lists-pane {
+  border-right: 1px solid #999;
+  background-color: #ddd;
+
+  .lists-title {
+    font-weight: bold;
+    text-decoration: underline;
+    font-size: 20px;
+    margin: 8px 0;
+    text-align: center;
+  }
+
+  .list, .new-list {
+    .vcenter;
+    padding: 0 12px;
+    width: 100%;
+    height: 40px;
+  }
+
+  .list{
+    &.selected {
+      background-color: #9be;
+      font-weight: bold;
+    }
+
+    .status {
+      position: relative;
+      display: inline-block;
+      margin-left: -8px;
+      padding: 8px;
+      cursor: pointer;
+
+      .circle {
+	      width: 8px;
+	      height: 8px;
+        border-radius: 50%;
+        background-color: #888;
+      }
+    }
+
+    .list-name {
+      cursor: pointer;
+      color: #000;
+      text-decoration: none;
+    }
+  }
+
+  .new-list input {
+    margin-top: 8px;
+    width: 100%;
+  }
+}
+
+/* Status pane (currently, within #lists-pane) ********************************/
+
+#status-pane {
+  .hcenter;
+  .vcenter;
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  overflow: hidden;
+  background-color: rgba(255, 255, 255, 0.85);
+  z-index: 10;  /* above everything */
+
+  .status-dialog {
+    position: relative;
+    width: 600px;
+    padding: 32px;
+    background-color: #fff;
+    border: 1px solid rgba(0, 0, 0, 0.3);
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+
+    .title {
+      font-size: 18px;
+      margin-bottom: 1em;
+    }
+
+    .close {
+      position: absolute;
+      top: 0;
+      right: 0;
+      margin: 6px;
+      background: url("/public/destroy.png") no-repeat 0 0;
+      cursor: pointer;
+      height: 20px;
+      width: 20px;
+
+      &:hover {
+        background-position: 0 -20px;
+      }
+    }
+  }
+}
+
+/* Tags pane ******************************************************************/
+
+#tags-pane {
+  .vcenter;
+  border-bottom: 1px solid #999;
+  background-color: #ddd;
+
+  .label {
+    display: inline-block;
+  }
+
+  .label {
+    margin-left: 16px;
+    margin-right: 8px;
+  }
+
+  .tag {
+    border: 1px solid #666;
+
+    &.selected {
+      background: #69d;
+    }
+  }
+
+  .count {
+    font-weight: normal;
+
+    &:before {
+      content: " ";
+    }
+  }
+}
+
+/* Todos pane *****************************************************************/
+
+#todos-pane {
+  overflow-y: scroll;
+
+  #new-todo {
+    margin: 16px 32px;
+    font-size: 20px;
+
+    input {
+      width: 100%;
+    }
+  }
+
+  #todo-list {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+  }
+
+  #todo-list .todo-row {
+    .vcenter;
+    display: flex;
+    height: 48px;
+    margin: 0 16px;
+    border-top: 1px solid #ccc;
+    overflow: hidden;
+
+    &.edit {
+      .destroy, .checkbox {
+        visibility: hidden;
+      }
+    }
+
+    .destroy {
+      cursor: pointer;
+      height: 20px;
+      width: 20px;
+      margin: 6px;
+    }
+
+    &:hover .destroy {
+      background: url("/public/destroy.png") no-repeat 0 0;
+
+      &:hover {
+        background-position: 0 -20px;
+      }
+    }
+
+    .description {
+      flex: 1;
+      margin-left: 16px;
+    }
+
+    &.done .description {
+      text-decoration: line-through;
+      color: #999;
+    }
+
+    .item-tags {
+      overflow: auto;
+
+      #edittag-input {
+        width: 80px;
+      }
+
+      .tag.removable {
+        position: relative;
+        padding-right: 24px;
+      }
+
+      .remove {
+        position: absolute;
+        top: 0;
+        right: 4px;
+        bottom: 0;
+        width: 16px;
+        background: url("/public/close_16.png") no-repeat 0 center;
+
+        &:hover {
+          background-position: -16px center;
+        }
+      }
+
+      .addtag {
+        margin-right: 8px;
+        border: 1px dashed #999;
+        background-color: transparent;
+        color: #333;
+      }
+    }
+  }
+}
+
+/* Other **********************************************************************/
+
+#log {
+  position: fixed;
+  bottom: 0;
+  width: 100%;
+  height: 300px;
+  z-index: 2;  /* above .disp-type */
+  padding: 16px;
+  border-top: 1px solid #000;
+  overflow-y: scroll;
+  background-color: #fff;
+  color: #000;
+  font: 400 14px/1.4 monospace;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  transform: translate3d(0, 100%, 0);
+  transition: transform 0.2s ease-out;
+
+  &.visible {
+    transform: translate3d(0, 0, 0);
+  }
+}
+
+#disp-type {
+  position: fixed;
+  top: 0;
+  right: 0;
+  padding: 0 16px;
+  color: #fff;
+  cursor: pointer;
+  z-index: 1;
+
+  &.collection {
+    background-color: @color-red-700;
+  }
+
+  &.syncbase {
+    background-color: @color-green-700;
+  }
+}
diff --git a/third_party/mousetrap.min.js b/third_party/mousetrap.min.js
new file mode 100644
index 0000000..291aff8
--- /dev/null
+++ b/third_party/mousetrap.min.js
@@ -0,0 +1,11 @@
+/* mousetrap v1.5.3 craig.is/killing/mice */
+(function(C,r,g){function t(a,b,h){a.addEventListener?a.addEventListener(b,h,!1):a.attachEvent("on"+b,h)}function x(a){if("keypress"==a.type){var b=String.fromCharCode(a.which);a.shiftKey||(b=b.toLowerCase());return b}return l[a.which]?l[a.which]:p[a.which]?p[a.which]:String.fromCharCode(a.which).toLowerCase()}function D(a){var b=[];a.shiftKey&&b.push("shift");a.altKey&&b.push("alt");a.ctrlKey&&b.push("ctrl");a.metaKey&&b.push("meta");return b}function u(a){return"shift"==a||"ctrl"==a||"alt"==a||
+"meta"==a}function y(a,b){var h,c,e,g=[];h=a;"+"===h?h=["+"]:(h=h.replace(/\+{2}/g,"+plus"),h=h.split("+"));for(e=0;e<h.length;++e)c=h[e],z[c]&&(c=z[c]),b&&"keypress"!=b&&A[c]&&(c=A[c],g.push("shift")),u(c)&&g.push(c);h=c;e=b;if(!e){if(!k){k={};for(var m in l)95<m&&112>m||l.hasOwnProperty(m)&&(k[l[m]]=m)}e=k[h]?"keydown":"keypress"}"keypress"==e&&g.length&&(e="keydown");return{key:c,modifiers:g,action:e}}function B(a,b){return null===a||a===r?!1:a===b?!0:B(a.parentNode,b)}function c(a){function b(a){a=
+a||{};var b=!1,n;for(n in q)a[n]?b=!0:q[n]=0;b||(v=!1)}function h(a,b,n,f,c,h){var g,e,l=[],m=n.type;if(!d._callbacks[a])return[];"keyup"==m&&u(a)&&(b=[a]);for(g=0;g<d._callbacks[a].length;++g)if(e=d._callbacks[a][g],(f||!e.seq||q[e.seq]==e.level)&&m==e.action){var k;(k="keypress"==m&&!n.metaKey&&!n.ctrlKey)||(k=e.modifiers,k=b.sort().join(",")===k.sort().join(","));k&&(k=f&&e.seq==f&&e.level==h,(!f&&e.combo==c||k)&&d._callbacks[a].splice(g,1),l.push(e))}return l}function g(a,b,n,f){d.stopCallback(b,
+b.target||b.srcElement,n,f)||!1!==a(b,n)||(b.preventDefault?b.preventDefault():b.returnValue=!1,b.stopPropagation?b.stopPropagation():b.cancelBubble=!0)}function e(a){"number"!==typeof a.which&&(a.which=a.keyCode);var b=x(a);b&&("keyup"==a.type&&w===b?w=!1:d.handleKey(b,D(a),a))}function l(a,c,n,f){function e(c){return function(){v=c;++q[a];clearTimeout(k);k=setTimeout(b,1E3)}}function h(c){g(n,c,a);"keyup"!==f&&(w=x(c));setTimeout(b,10)}for(var d=q[a]=0;d<c.length;++d){var p=d+1===c.length?h:e(f||
+y(c[d+1]).action);m(c[d],p,f,a,d)}}function m(a,b,c,f,e){d._directMap[a+":"+c]=b;a=a.replace(/\s+/g," ");var g=a.split(" ");1<g.length?l(a,g,b,c):(c=y(a,c),d._callbacks[c.key]=d._callbacks[c.key]||[],h(c.key,c.modifiers,{type:c.action},f,a,e),d._callbacks[c.key][f?"unshift":"push"]({callback:b,modifiers:c.modifiers,action:c.action,seq:f,level:e,combo:a}))}var d=this;a=a||r;if(!(d instanceof c))return new c(a);d.target=a;d._callbacks={};d._directMap={};var q={},k,w=!1,p=!1,v=!1;d._handleKey=function(a,
+c,e){var f=h(a,c,e),d;c={};var k=0,l=!1;for(d=0;d<f.length;++d)f[d].seq&&(k=Math.max(k,f[d].level));for(d=0;d<f.length;++d)f[d].seq?f[d].level==k&&(l=!0,c[f[d].seq]=1,g(f[d].callback,e,f[d].combo,f[d].seq)):l||g(f[d].callback,e,f[d].combo);f="keypress"==e.type&&p;e.type!=v||u(a)||f||b(c);p=l&&"keydown"==e.type};d._bindMultiple=function(a,b,c){for(var d=0;d<a.length;++d)m(a[d],b,c)};t(a,"keypress",e);t(a,"keydown",e);t(a,"keyup",e)}var l={8:"backspace",9:"tab",13:"enter",16:"shift",17:"ctrl",18:"alt",
+20:"capslock",27:"esc",32:"space",33:"pageup",34:"pagedown",35:"end",36:"home",37:"left",38:"up",39:"right",40:"down",45:"ins",46:"del",91:"meta",93:"meta",224:"meta"},p={106:"*",107:"+",109:"-",110:".",111:"/",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},A={"~":"`","!":"1","@":"2","#":"3",$:"4","%":"5","^":"6","&":"7","*":"8","(":"9",")":"0",_:"-","+":"=",":":";",'"':"'","<":",",">":".","?":"/","|":"\\"},z={option:"alt",command:"meta","return":"enter",
+escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},k;for(g=1;20>g;++g)l[111+g]="f"+g;for(g=0;9>=g;++g)l[g+96]=g;c.prototype.bind=function(a,b,c){a=a instanceof Array?a:[a];this._bindMultiple.call(this,a,b,c);return this};c.prototype.unbind=function(a,b){return this.bind.call(this,a,function(){},b)};c.prototype.trigger=function(a,b){if(this._directMap[a+":"+b])this._directMap[a+":"+b]({},a);return this};c.prototype.reset=function(){this._callbacks={};this._directMap=
+{};return this};c.prototype.stopCallback=function(a,b){return-1<(" "+b.className+" ").indexOf(" mousetrap ")||B(b,this.target)?!1:"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable};c.prototype.handleKey=function(){return this._handleKey.apply(this,arguments)};c.init=function(){var a=c(r),b;for(b in a)"_"!==b.charAt(0)&&(c[b]=function(b){return function(){return a[b].apply(a,arguments)}}(b))};c.init();C.Mousetrap=c;"undefined"!==typeof module&&module.exports&&(module.exports=
+c);"function"===typeof define&&define.amd&&define(function(){return c})})(window,document);
diff --git a/tools/get_third_party_scripts.sh b/tools/get_third_party_scripts.sh
index 2c7350e..ac21c5d 100755
--- a/tools/get_third_party_scripts.sh
+++ b/tools/get_third_party_scripts.sh
@@ -14,5 +14,6 @@
 get async.min.js https://raw.githubusercontent.com/caolan/async/master/dist/async.min.js
 get lodash.min.js https://raw.githubusercontent.com/lodash/lodash/3.10.0/lodash.min.js
 get moment.min.js https://raw.githubusercontent.com/moment/moment/2.10.3/min/moment.min.js
+get mousetrap.min.js https://raw.githubusercontent.com/ccampbell/mousetrap/1.5.3/mousetrap.min.js
 get react.min.js https://fb.me/react-0.13.3.min.js
 get react-with-addons.js https://fb.me/react-with-addons-0.13.3.js