Initial content commit of travel sample app, with maps.

Change-Id: I057b2b2514cb63bbd965ee72d96e0c3a698693d4
diff --git a/.gitignore b/.gitignore
index b7e2fde..21261d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
-/.v23
\ No newline at end of file
+/.v23
+/ifc
+/node_modules
+/server-root
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..411db13
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2015 The Vanadium Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ca463ca
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,58 @@
+PATH := node_modules/.bin:$(PATH)
+PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin
+
+.DEFAULT_GOAL := all
+
+port ?= 1058
+
+js_files := $(shell find src -name "*.js")
+server_static := $(patsubst src/static/%,server-root/%,$(wildcard src/static/*))
+tests := $(patsubst %.js,%,$(shell find test -name "*.js"))
+
+out_dirs := ifc server-root node_modules
+
+.DELETE_ON_ERROR:
+
+.PHONY: all
+all: static js
+	@true
+
+.PHONY: static
+static: $(server_static)
+
+.PHONY: js
+js: server-root/bundle.js
+
+ifc: src/ifc/*
+	@VDLPATH=src vdl generate -lang=javascript -js-out-dir=. ifc
+
+node_modules: package.json
+	@npm prune
+	@npm install
+	@npm install $(V23_ROOT)/release/javascript/core/ #TODO: remove
+	@touch node_modules # if npm does nothing, we don't want to keep trying
+
+server-root:
+	@mkdir server-root
+
+server-root/bundle.js: ifc node_modules $(js_files) | server-root
+	browserify --debug src/index.js 1> $@
+
+$(server_static): server-root/%: src/static/% | server-root
+	@cp $< $@
+	@echo "Copying static file $<"
+
+.PHONY: test
+test: $(tests)
+
+.PHONY: $(tests)
+$(tests): test/%: test/%.js test/* mocks/* ifc node_modules $(js_files)
+	@tape $<
+
+.PHONY: start
+start: all
+	@static server-root -p $(port)
+
+.PHONY: clean
+clean:
+	rm -rf $(out_dirs)
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
new file mode 100644
index 0000000..2a25bc8
--- /dev/null
+++ b/mocks/google-maps.js
@@ -0,0 +1,38 @@
+var $ = require('../src/util/jquery')
+var defineClass = require('../src/util/define-class')
+
+var ControlPosition = {
+  TOP_LEFT: 'tl',
+  TOP_CENTER: 'tc'
+};
+
+var ControlPanel = defineClass({
+  init: function(parent) {
+    this.$ = $('<div>');
+    this.$.appendTo(parent);
+  },
+  
+  publics: {
+    push: function(child) {
+      this.$.append(child);
+    }
+  }
+});
+
+module.exports = {
+  Map: function(canvas) {
+    this.controls = {};
+    this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
+    this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
+  },
+  LatLng: function(){},
+  ControlPosition: ControlPosition,
+  
+  places: {
+    SearchBox: function(){}
+  },
+  
+  event: {
+    addListener: function(){}
+  }
+};
\ No newline at end of file
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
new file mode 100644
index 0000000..b69b6ce
--- /dev/null
+++ b/mocks/vanadium-wrapper.js
@@ -0,0 +1,7 @@
+var $ = require('../src/util/jquery');
+
+module.exports = {
+  init: function(){
+    return $.Deferred().promise();
+  }
+};
\ No newline at end of file
diff --git a/mocks/vanadium.js b/mocks/vanadium.js
new file mode 100644
index 0000000..815df95
--- /dev/null
+++ b/mocks/vanadium.js
@@ -0,0 +1,47 @@
+var defineClass = require('../src/util/define-class');
+
+var MockRuntime = defineClass({
+  publics: {
+    on: function(event, handler) {
+      if (event == 'crash')
+        this.crash.add(handler);
+    },
+    fireCrash: function(err) {
+      this.crash(err);
+    }
+  },
+  
+  events: {
+    crash: 'private'
+  }
+});
+
+var MockVanadium = defineClass({
+  init: function(t) {
+    this.t = t;
+  },
+  
+  publics: {
+    init: function(config, callback) {
+      this.t.ok(config, 'has config');
+      this.callback = callback;
+    },
+    
+    finishInit: function(err, runtime) {
+      this.callback(err, runtime);
+    }
+  },
+  
+  statics: {
+    vlog: {
+      levels: {
+        INFO: 'info'
+      }
+    }
+  }
+});
+
+module.exports = {
+  MockRuntime: MockRuntime,
+  MockVanadium: MockVanadium
+};
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7ed8b4c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+  "name": "travel",
+  "version": "0.1.0",
+  "decription": "Distributed travel planner Vanadium sample application.",
+  "scripts": {
+    "test": "make test"
+  },
+  "devDependencies": {
+    "browserify": "^10.2.4",
+    "jsdom": "^3.1.2",
+    "node-static": "^0.7.6",
+    "tape": "^4.0.0"
+  },
+  "dependencies": {
+    "global": "^4.3.0",
+    "jquery": "^2.1.4",
+    "uuid": "^2.0.1"
+  }
+}
diff --git a/src/components/maps.js b/src/components/maps.js
new file mode 100644
index 0000000..3e52c98
--- /dev/null
+++ b/src/components/maps.js
@@ -0,0 +1,80 @@
+var global = require('global');
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var strings = require('../strings')();
+var Messages = require('./messages');
+
+var Widget = defineClass({
+  publics: {
+    clearMarkers: function() {
+      var markers = this.markers;
+      this.markers = [];
+      $.each(markers, function(i, marker) {
+        marker.setMap(null);
+      });
+    },
+    
+    message: function(message) {
+      this.messages.push(message);
+    }
+  },
+  
+  constants: ['$'],
+  
+  // https://developers.google.com/maps/documentation/javascript/tutorial
+  init: function(maps) {
+    maps = maps || global.google.maps;
+    var widget = this;
+    
+    this.$ = $('<div>').addClass('map-canvas');
+    
+    this.markers = [];
+    this.messages = new Messages();
+    
+    var config = {
+      zoom: 11,
+      center: new maps.LatLng(37.4184, -122.0880) //Googleplex
+    };
+    
+    var map = new maps.Map(this.$[0], config);
+  
+    // https://developers.google.com/maps/documentation/javascript/examples/map-geolocation
+    if (global.navigator && global.navigator.geolocation) {
+      global.navigator.geolocation.getCurrentPosition(function(position) {
+        map.setCenter(new maps.LatLng(position.coords.latitude,
+          position.coords.longitude));
+      });
+    }
+  
+    var controls = map.controls;
+    
+    var $searchBox = $('<input>')
+      .attr('type', 'text')
+      .attr('placeholder', strings['Search']);
+    var txtSearchBox = $searchBox[0];
+    controls[maps.ControlPosition.TOP_LEFT].push(txtSearchBox);
+    
+    controls[maps.ControlPosition.TOP_CENTER].push(this.messages.$[0]);
+    
+    var searchBox = new maps.places.SearchBox(txtSearchBox);
+    
+    maps.event.addListener(map, 'bounds_changed', function() {
+      searchBox.setBounds(map.getBounds());
+    });
+    
+    maps.event.addListener(searchBox, 'places_changed', function() {
+      var places = searchBox.getPlaces();
+      if (places.length == 1) {
+        var place = places[0];
+        widget.markers.push(new maps.Marker({
+          map: map,
+          title: place.name,
+          position: place.geometry.location
+        }));
+      }
+    });
+  }
+});
+
+module.exports = Widget;
diff --git a/src/components/message.js b/src/components/message.js
new file mode 100644
index 0000000..a96e7cd
--- /dev/null
+++ b/src/components/message.js
@@ -0,0 +1,55 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var INFO = 'INFO';
+var ERROR = 'ERROR';
+
+module.exports = {
+  INFO: INFO,
+  ERROR: ERROR,
+  
+  info: function(text) {
+    return {
+      type: INFO,
+      text: text
+    };
+  },
+  
+  error: function(text) {
+    return {
+      type: ERROR,
+      text: text
+    };
+  },
+  
+  Message: defineClass({
+    publics: {
+      setType: function(type) {
+        if (type == INFO) {
+          this.$.attr('class', 'info');
+        } else if (type == ERROR) {
+          this.$.attr('class', 'error');
+        } else {
+          throw 'Invalid message type ' + type;
+        }
+      },
+      
+      setText: function(text) {
+        this.$.text(text);
+      },
+      
+      set: function(message) {
+        this.setType(message.type);
+        this.setText(message.text);
+      }
+    },
+    
+    constants: ['$'],
+    
+    init: function(initial) {
+      this.$ = $('<li>');
+      if (initial)
+        this.set(initial);
+    }
+  })
+};
diff --git a/src/components/messages.js b/src/components/messages.js
new file mode 100644
index 0000000..55eef83
--- /dev/null
+++ b/src/components/messages.js
@@ -0,0 +1,45 @@
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var message = require('./message');
+
+var Messages = defineClass({
+  publics: {
+    push: function(messageData) {
+      var messageObject = new message.Message(messageData);
+      /*
+       * Implementation notes: slideDown won't work properly (won't be able to
+       * calculate goal height) unless the element is in the DOM tree prior
+       * to the call, so we hide first, attach, and then animate. slideDown
+       * implicitly shows the element.
+       *
+       * Similarly, we use animate rather than fadeIn because fadeIn implicitly
+       * hides the element upon completion, resulting in an abrupt void in the
+       * element flow. Instead, we want to keep the element taking up space
+       * while invisible until we've collapsed the height via slideUp.
+       */
+      messageObject.$.hide();
+      this.$.append(messageObject.$);
+      messageObject.$
+        .slideDown(Messages.slideDown)
+        .delay(Messages.ttl)
+        .animate({ opacity: 0 }, Messages.fade)
+        .slideUp(Messages.slideUp, function() {
+          messageObject.$.remove();
+        });
+    }
+  },
+  
+  constants: ['$'],
+  
+  init: function() {
+    this.$ = $('<ul>').addClass('messages');
+  }
+});
+
+Messages.slideDown = 150;
+Messages.ttl = 9000;
+Messages.fade = 1000;
+Messages.slideUp = 300;
+
+module.exports = Messages;
\ No newline at end of file
diff --git a/src/debug.js b/src/debug.js
new file mode 100644
index 0000000..97c15f5
--- /dev/null
+++ b/src/debug.js
@@ -0,0 +1,8 @@
+var global = require('global');
+
+/**
+ * Global variable exports for console debug.
+ */
+module.exports = function(app) {
+  global.travel = app;
+};
\ No newline at end of file
diff --git a/src/identity.js b/src/identity.js
new file mode 100644
index 0000000..762fdb4
--- /dev/null
+++ b/src/identity.js
@@ -0,0 +1,29 @@
+'use strict';
+
+var uuid = require('uuid');
+
+module.exports = Identity;
+
+function Identity(accountName) {
+  this.username = extractUsername(accountName);
+  this.deviceType = 'desktop';
+  this.deviceId = uuid.v4();
+  
+  this.deviceName = this.deviceType + '_' + this.deviceId;
+  this.entityName = this.username + '/' + this.deviceName;
+};
+
+function autoUsername() {
+  return uuid.v4();
+}
+
+function extractUsername(accountName) {
+  if (!accountName || accountName === 'unknown')
+    return autoUsername();
+  
+  var parts = accountName.split('/');
+  if (parts[0] !== 'dev.v.io' || parts[1] !== 'u')
+    return accountName;
+  
+  return parts[2];
+}
diff --git a/src/ifc/ops.vdl b/src/ifc/ops.vdl
new file mode 100644
index 0000000..9a646ab
--- /dev/null
+++ b/src/ifc/ops.vdl
@@ -0,0 +1,50 @@
+package ifc
+
+const (
+  Read = "R"
+  Write = "W"
+  Collaborate = "C"
+)
+
+// Stub multicast RPCs to mock SyncBase storage.
+// TODO: allow multiple trips (e.g. multiple planned trips).
+type TravelSync interface {
+  // Gets the current trip.
+  Get() (Trip | error) { Read }
+
+  // Pushes a trip plan to the server instance, optionally with a notification
+  // message (ex. "X has accepted Y's destination proposal.").
+  // To simplify the API, this is the sole API through which the trip plan may
+  // actually be altered.
+  UpdatePlan(plan TripPlan, message string) error { Write }
+
+  // Pushes the current trip status to the server instance, leaving the trip
+  // plan unchanged.
+  UpdateStatus(status TravellerStatus) error { Write }
+
+  // Posts a suggestion to add a destination to the trip plan.
+  SuggestDestinationAddition(
+    destination Destination, at TripStatus, message string) (
+    SuggestionId | error) { Collaborate }
+
+  // Posts a suggestion to add a waypoint to the trip plan.
+  SuggestWaypointAddition(
+    waypoint Waypoint, at TripStatus, message string) (
+    SuggestionId | error) { Collaborate }
+
+  // Posts a suggestion to remove a waypoint or destination from the trip plan.
+  SuggestRemoval(at TripStatus, message string) (
+    SuggestionId | error) { Collaborate }
+
+  // Comments on an existing suggestion.
+  Comment(suggestion SuggestionId, message string) error { Collaborate }
+
+  // Deletes an existing suggestion.
+  DeleteSuggestion(
+    suggestion SuggestionId, message string) error { Collaborate }
+}
+
+type Travel interface {
+  TravelSync
+  // TODO: casting if warranted
+}
diff --git a/src/ifc/types.vdl b/src/ifc/types.vdl
new file mode 100644
index 0000000..22329b5
--- /dev/null
+++ b/src/ifc/types.vdl
@@ -0,0 +1,45 @@
+package ifc
+
+type Location complex64
+type Waypoint Location
+type LegId int16
+type WaypointId int16
+type SuggestionId int16
+
+type Destination struct {
+  // TODO; may or may not have an embedded Waypoint
+}
+
+/*
+ * A leg of travel. Each leg has one destination of significance but may be
+ * routed through any number of waypoints.
+ */
+type Leg struct {
+  Waypoints []Waypoint
+  // Logically, the last waypoint. However, it contains metadata that other
+  // waypoints do not (see Destination struct).
+  Destination Destination
+  // TODO: timeline data
+}
+
+type TripPlan []Leg
+
+type TripStatus struct {
+  CurrentLeg LegId
+  // ID of the next waypoint on the current leg. The leg destination is
+  // considered the last waypoint.
+  NextWaypoint WaypointId
+}
+
+/*
+ * Status actually varies by agent/user.
+ */
+type TravellerStatus struct {
+  TripStatus TripStatus
+  Location Location
+}
+
+type Trip struct {
+  Plan TripPlan
+  Status map[string]TravellerStatus
+}
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..e304925
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,8 @@
+var $ = require('./util/jquery');
+var Travel = require('./travel');
+var debug = require('./debug');
+
+//http://api.jquery.com/ready/
+$(function() {
+  debug(new Travel());
+});
\ No newline at end of file
diff --git a/src/static/index.css b/src/static/index.css
new file mode 100644
index 0000000..358500e
--- /dev/null
+++ b/src/static/index.css
@@ -0,0 +1,39 @@
+body {
+  margin: 0;
+  font-family: Arial, sans-serif;
+}
+
+.map-canvas {
+  width: 100%;
+  height: 100%;
+}
+
+ul.messages {
+  width: 30%;
+  min-width: 10em;
+  list-style: none;
+}
+
+.messages li {
+  color: #FFF;
+  background-color: rgba(0, 0, 0, .6);
+  font-size: 10pt;
+  padding: 3px 3px 3px 1em;
+  border-radius: 4px;
+  margin-bottom: 3px;
+  text-indent: -.5em;
+}
+
+.messages li:before {
+  font-weight: bold;
+}
+
+.messages li.info:before {
+  content: "i ";
+  color: #77F;
+}
+
+.messages li.error:before {
+  content: "x ";
+  color: red;
+}
diff --git a/src/static/index.html b/src/static/index.html
new file mode 100644
index 0000000..2f50623
--- /dev/null
+++ b/src/static/index.html
@@ -0,0 +1,13 @@
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>Google Travel</title>
+    <link rel="stylesheet" type="text/css" href="index.css">
+    <script type="text/javascript"
+      src="https://maps.googleapis.com/maps/api/js?key=AIzaSyAQCvKWEWcSuQE2DSjVbvMKETSgF6S9i1k&signed_in=true&libraries=places">
+    </script>
+  </head>
+  <body>
+    <script type="text/javascript" src="/bundle.js"></script>
+  </body>
+</html>
diff --git a/src/strings.js b/src/strings.js
new file mode 100644
index 0000000..76bb6bb
--- /dev/null
+++ b/src/strings.js
@@ -0,0 +1,5 @@
+module.exports = function() {
+  return {
+    'Search': 'Search'
+  };
+};
\ No newline at end of file
diff --git a/src/travel.js b/src/travel.js
new file mode 100644
index 0000000..7f4f8b9
--- /dev/null
+++ b/src/travel.js
@@ -0,0 +1,52 @@
+var $ = require('./util/jquery');
+
+var message = require('./components/message');
+var vanadiumWrapperDefault = require('./vanadium-wrapper');
+
+var defineClass = require('./util/define-class');
+
+var Maps = require('./components/maps');
+var TravelSync = require('./travelsync');
+var Identity = require('./identity');
+
+var strings = require('./strings')(/* TODO: locale */);
+
+var Travel = defineClass({
+  publics: {
+    error: function (err) {
+      this.maps.message(message.error(err.toString()));
+    },
+    
+    info: function (info) {
+      this.maps.message(message.info(info));
+    }
+  },
+  
+  init: function (opts) {
+    opts = opts || {};
+    var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
+    var travel = this;
+    
+    this.sync = new TravelSync();
+    
+    var reportError = $.proxy(this, 'error')
+    
+    vanadiumWrapper.init(opts.vanadium).then(
+      function(wrapper) {
+        var identity = new Identity(wrapper.getAccountName());
+        identity.mountName = makeMountName(identity);
+        travel.sync.start(identity.mountName, wrapper).fail(reportError);
+      }, reportError);
+    
+    this.maps = new Maps(opts.maps);    
+    var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
+    $domRoot.append(travel.maps.$);
+  }
+});
+
+function makeMountName(id) {
+  // TODO: first-class app-wide rather than siloed by account
+  return 'users/' + id.username + '/travel/' + id.deviceName;
+}
+
+module.exports = Travel;
diff --git a/src/travelsync.js b/src/travelsync.js
new file mode 100644
index 0000000..293c755
--- /dev/null
+++ b/src/travelsync.js
@@ -0,0 +1,44 @@
+var defineClass = require('./util/define-class');
+
+var vdlTravel = require('../ifc');
+
+var TravelSync = defineClass({
+  events: ['onMessage', 'onPlanUpdate', 'onStatusUpdate'],
+  init: function() {
+    this.tripPlan = [];
+    this.tripStatus = {};
+    
+    // TODO: sync initial state
+    this.server = new vdlTravel.TravelSync();
+
+    var travelSync = this;
+    this.server.get = function(ctx, serverCall) {
+      return {
+        Plan: travelSync.tripPlan,
+        Status: travelSync.tripStatus
+      };
+    };
+
+    this.server.updatePlan = function(ctx, serverCall, plan, message) {
+      travelSync.tripPlan = plan;
+      travelSync.onPlanUpdate(plan);
+      travelSync.onMessage(message);
+    };
+
+    this.server.updateStatus = function(ctx, serverCall, status) {
+      travelSync.tripStatus = status;
+      travelSync.onStatusUpdate(status);
+    };
+  },
+  publics: {
+    start: function(mountName, v) {
+      return v.server(mountName, this.server);
+    },
+    pushTrip: function() {
+    },
+    pushStatus: function() {
+    }
+  }
+});
+
+module.exports = TravelSync;
diff --git a/src/util/define-class.js b/src/util/define-class.js
new file mode 100644
index 0000000..2a48279
--- /dev/null
+++ b/src/util/define-class.js
@@ -0,0 +1,115 @@
+var $ = require('./jquery');
+
+/**
+ * <p>Plays a similar role to other npm private encapsulation facilities, but
+ * exposes private members on `this` via per-instance bindings. A class
+ * definition can contain the following members:
+ * <ul>
+ *  <li><code>init</code>: constructor/initializer function for an instance. It
+ *    will be called when the class is instantiated via <code>new</code>. Fields
+ *    can be initialized in this function. Private functions and events can also
+ *    be defined within this function.
+ *  <li><code>privates</code>: map of private functions or private static
+ *    constants, with access to other members via <code>this</code>. These
+ *    members are not publicly visible. This is equivalent to associating these
+ *    members explicitly within <code>init</code>.
+ *  <li><code>publics</code>: map of public functions, with access to other
+ *    members via <code>this</code>. These members are publicly visible.
+ *  <li><code>constants</code>: list of names of instance constants initialized
+ *    in <code>init</code> to be exposed.
+ *  <li><code>statics</code>: map of public static constants, accessible from
+ *    the private context, the public context, and on the constructor function.
+ *  <li><code>events</code>: list of event names, some of which can actually be
+ *    a singleton map with the event name and a string of flags, or a map of
+ *    event names to flags. Flags are those to
+ *    <a href="https://api.jquery.com/jQuery.Callbacks/">jQuery Callbacks</a>,
+ *    plus the "private" flag, which hides the event from the public interface
+ *    entirely, and the "public" flag, which exposes the event trigger to the
+ *    public interface.
+ * </ul>
+ *
+ * <p>Care should be taken not to be tempted to declare instance constants
+ * within <code>private</code>, as any instantiations done on the initial
+ * values is done at class definition time rather than class instantiation
+ * time. (As such, using that mechanism to declare private static constants does
+ * work.)
+ */
+module.exports = function defineClass(def) {
+  var constructor = function() {
+    var pthis = $.extend({}, def.privates, def.publics, def.statics);
+    var ifc = this;
+    
+    if (def.events) {
+      if ($.isArray(def.events)) {
+        $.each(def.events, function(i, event) {
+          if ($.type(event) === 'string') {
+            defineEvent(pthis, ifc, event);
+          } else {
+            defineEventsFromObject(pthis, ifc, event);
+          }
+        });
+      } else {
+        defineEventsFromObject(pthis, ifc, def.events);
+      }
+    }
+    
+    if (def.statics)
+      $.extend(ifc, def.statics);
+    
+    if (def.init)
+      def.init.apply(pthis, arguments);
+    
+    if (def.publics)
+      polyProxy(ifc, pthis, def.publics);
+    
+    if (def.constants) {
+      $.each(def.constants, function(i, constant) {
+        ifc[constant] = pthis[constant];
+      });
+    }
+  };
+  
+  if (def.statics)
+    $.extend(constructor, def.statics);
+  
+  return constructor;
+};
+
+function polyProxy(proxy, context, members) {
+  $.each(members, function(name, member) {
+    proxy[name] = $.proxy(member, context);
+  });
+  return proxy;
+}
+
+function filterProxy(proxy, context, nameFilter) {
+  $.each(context, function(name, member) {
+    if (nameFilter(name))
+      proxy[name] = $.proxy(member, context);
+  });
+  return proxy;
+}
+
+function defineEvent(pthis, ifc, name, flags) {
+  var dispatcher = $.Callbacks(flags);
+  //Use polyProxy on function that fires to add the callable syntactic sugar
+  var callableDispatcher = pthis[name] =
+    polyProxy($.proxy(dispatcher, 'fire'), dispatcher, dispatcher);
+    
+  if (flags && flags.indexOf('private') > -1)
+    return;
+  
+  if (flags && flags.indexOf('public') > -1) {
+    ifc[name] = callableDispatcher;
+  } else {
+    ifc[name] = filterProxy({}, dispatcher, function(name) {
+      return name != 'fire' && name != 'fireWith';
+    });
+  }
+}
+
+function defineEventsFromObject(pthis, ifc, events) {
+  $.each(events, function(event, flags) {
+    defineEvent(pthis, ifc, event, flags);
+  });
+}
diff --git a/src/util/jquery.js b/src/util/jquery.js
new file mode 100644
index 0000000..18118cd
--- /dev/null
+++ b/src/util/jquery.js
@@ -0,0 +1,10 @@
+var jq = require('jquery');
+var window = require('global/window');
+
+if (window.document) {
+  module.exports = jq;
+} else {
+  var jsdom = require('jsdom').jsdom;
+  window = jsdom().parentWindow;
+  module.exports = jq(window);
+}
\ No newline at end of file
diff --git a/src/vanadium-wrapper.js b/src/vanadium-wrapper.js
new file mode 100644
index 0000000..2774a7b
--- /dev/null
+++ b/src/vanadium-wrapper.js
@@ -0,0 +1,80 @@
+var $ = require('./util/jquery');
+
+var vanadiumDefault = require('vanadium');
+var defineClass = require('./util/define-class');
+
+var VanadiumWrapper = defineClass({
+  init: function(runtime) {
+    this.runtime = runtime;
+    runtime.on('crash', this.crash);
+  },
+  
+  publics: {
+    getAccountName: function() {
+      return this.runtime.accountName;
+    },
+    
+    /**
+     * @param endpoint Vanadium name
+     * @returns a promise resolving to a client or rejecting with an error.
+     */
+    client: function(endpoint) {
+      var client = this.runtime.newClient();
+      var async = $.Deferred();
+      client.bindTo(this.runtime.getContext(), endpoint, function(err, client) {
+        if (err)
+          async.reject(err);
+        else
+          async.resolve(client);
+      });
+      
+      return async.promise();
+    },
+    
+    /**
+     * @param endpoint Vanadium name
+     * @param server object implementing server APIs
+     * @returns a promise resolving to void or rejecting with an error.
+     */
+    server: function(endpoint, server, callback) {
+      var async = $.Deferred();
+      this.runtime.newServer().serve(endpoint, server, function(err) {
+        if (err)
+          async.reject(err);
+        else
+          async.resolve();
+      });
+      return async.promise();
+    }
+  },
+  
+  events: {
+    crash: 'memory'
+  }
+});
+
+module.exports = {
+  /**
+   * @param vanadium optional vanadium override
+   * @returns a promise resolving to a VanadiumWrapper or rejecting with an error.
+   */
+  init: function(vanadium) {
+    vanadium = vanadium || vanadiumDefault;
+
+    var config = {
+      logLevel: vanadium.vlog.levels.INFO,
+      appName: 'Google Travel'
+    };
+    
+    var async = $.Deferred();
+    
+    vanadium.init(config, function(err, runtime) {
+      if (err)
+        async.reject(err);
+      else
+        async.resolve(new VanadiumWrapper(runtime));
+    });
+    
+    return async.promise();
+  }
+};
diff --git a/test/components/maps.js b/test/components/maps.js
new file mode 100644
index 0000000..e578bf0
--- /dev/null
+++ b/test/components/maps.js
@@ -0,0 +1,28 @@
+var test = require('tape');
+
+var $ = require('../../src/util/jquery');
+var defineClass = require('../../src/util/define-class');
+
+var Maps = require('../../src/components/maps');
+var message = require ('../../src/components/message');
+
+var mockMaps = require('../../mocks/google-maps');
+
+test('message display', function(t) {
+  var maps = new Maps(mockMaps);
+  
+  var $messages = $('.messages', maps.$);
+  t.ok($messages.length, 'message display exists');
+  t.equals($messages.children().length, 0, 'message display is empty');
+  
+  maps.message(message.info('Test message.'));
+  
+  var $messageItem = $messages.children();
+  t.equals($messageItem.length, 1, 'message display shows 1 message');
+  t.equals($messageItem.text(), 'Test message.',
+    'message displays message text');
+    
+  t.end();
+});
+
+module.exports = mockMaps;
\ No newline at end of file
diff --git a/test/components/message.js b/test/components/message.js
new file mode 100644
index 0000000..3105f1b
--- /dev/null
+++ b/test/components/message.js
@@ -0,0 +1,26 @@
+var test = require('tape');
+var $ = require('../../src/util/jquery');
+
+var message = require('../../src/components/message');
+
+test('init', function(t) {
+  t.ok(new message.Message(), 'default instantiation');
+  t.end();
+});
+
+test('dom', function(t) {
+  var msg = new message.Message(message.info('Hello, world!'));
+  t.equal(msg.$.length, 1, 'unique element');
+  t.equal(msg.$[0].tagName, 'LI', 'tag name');
+  t.assert(msg.$.hasClass('info'), 'class info');
+  t.equal(msg.$.text(), 'Hello, world!', 'text');
+  
+  msg.setType(message.ERROR);
+  t.notOk(msg.$.hasClass('info'), 'class not info');
+  t.assert(msg.$.hasClass('error'), 'class error');
+  
+  msg.setText('hi');
+  t.equal(msg.$.text(), 'hi', 'text update');
+  
+  t.end();
+});
\ No newline at end of file
diff --git a/test/identity.js b/test/identity.js
new file mode 100644
index 0000000..adb82de
--- /dev/null
+++ b/test/identity.js
@@ -0,0 +1,53 @@
+'use strict';
+
+var test = require('tape');
+
+var Identity = require('../src/identity');
+
+function verifyAutoAccountName(t, n) {
+  t.assert(n.length > 1, 'auto-generated username is nontrivial');
+}
+
+test('auto-generated username from unknown', function(t) {
+  var a = new Identity('unknown').username,
+      b = new Identity('unknown').username;
+  verifyAutoAccountName(t, a);
+  verifyAutoAccountName(t, b);
+  t.notEqual(b, a, 'auto-generated username is unique');
+  t.end();
+});
+
+function testAutoExtract(t, r) {
+  var n = new Identity(r).username;
+  verifyAutoAccountName(t, n);
+  t.not(n, r);
+  t.end();
+}
+
+test('extract username from undefined', function(t) {
+  testAutoExtract(t);
+});
+
+test('extract username from null', function(t) {
+  testAutoExtract(t, null);
+});
+
+test('extract username from "false"', function(t) {
+  t.equals(new Identity('false').username, 'false',
+    '"false" string literal should pass as a username');
+  t.end();
+});
+
+var testAccountName = 'dev.v.io/u/joeuser@google.com/chrome';
+
+test('init', function(t) {
+  var i = new Identity(testAccountName);
+  t.equals(i.username, 'joeuser@google.com',
+    'should extract a username from a dev.v.io account name');
+  var expectedPrefix = 'joeuser@google.com/desktop_';
+  t.assert(i.entityName.slice(0, expectedPrefix.length) == expectedPrefix,
+    'entityName starts with expected prefix');
+  t.assert(i.entityName.length > expectedPrefix.length,
+    'entityName is longer than expected prefix');
+  t.end();
+});
\ No newline at end of file
diff --git a/test/travel.js b/test/travel.js
new file mode 100644
index 0000000..88eae30
--- /dev/null
+++ b/test/travel.js
@@ -0,0 +1,57 @@
+var test = require('tape');
+
+var $ = require('../src/util/jquery');
+var Travel = require('../src/travel');
+
+var mockMaps = require('../mocks/google-maps');
+var mockVanadiumWrapper = require('../mocks/vanadium-wrapper');
+
+function cleanDom() {
+  $('body').empty();
+}
+
+test('init', function(t) {
+  new Travel({
+    maps: mockMaps
+  });
+  t.end();
+  cleanDom();
+});
+
+test('message display', function(t) {
+  var travel = new Travel({
+    vanadiumWrapper: mockVanadiumWrapper,
+    maps: mockMaps
+  });
+  
+  var $messages = $('.messages');
+  t.ok($messages.length, 'message display exists');
+  t.equals($messages.children().length, 0, 'message display is empty');
+  
+  travel.info('Test message.');
+  
+  var $messageItem = $messages.children();
+  t.equals($messageItem.length, 1, 'message display shows 1 message');
+  t.equals($messageItem.text(), 'Test message.',
+    'message displays message text');
+    
+  t.end();
+  cleanDom();
+});
+
+test('domRoot', function(t) {
+  var $root = $('<div>');
+  var root = $root[0];
+  $('body').append($root);
+  
+  new Travel({
+    maps: mockMaps,
+    vanadiumWrapper: mockVanadiumWrapper,
+    domRoot: root
+  });
+  
+  t.ok($root.children().length, 'app parented to given root');
+  
+  t.end();
+  cleanDom();
+});
\ No newline at end of file
diff --git a/test/travelsync.js b/test/travelsync.js
new file mode 100644
index 0000000..dd0575a
--- /dev/null
+++ b/test/travelsync.js
@@ -0,0 +1,8 @@
+var test = require('tape');
+
+var TravelSync = require('../src/travelsync');
+
+test('init', function(t) {
+  t.ok(new TravelSync(), 'initializes');
+  t.end();
+});
\ No newline at end of file
diff --git a/test/util/define-class.js b/test/util/define-class.js
new file mode 100644
index 0000000..743d84c
--- /dev/null
+++ b/test/util/define-class.js
@@ -0,0 +1,150 @@
+var test = require('tape');
+
+var $ = require('../../src/util/jquery');
+var defineClass = require('../../src/util/define-class');
+
+test('trivial', function(t) {
+  var TestClass = defineClass({});
+  t.ok(new TestClass(), 'class instantiates');
+  t.end();
+});
+
+test('defineClass', function(t) {
+  var TestClass = defineClass({
+    init: function(value) {
+      this.value = value;
+      this.greeting = 'Hello';
+    },
+    privates: {
+      getMessage: function() {
+        return this.greeting + ', ' + this.value + '!';
+      }
+    },
+    publics: {
+      toString: function() {
+        this.stringQueried(this.value);
+        this.stringQueriedOnce(this.value);
+        return this.getMessage();
+      }
+    },
+    constants: ['greeting'],
+    events: ['stringQueried', {stringQueriedOnce: 'once'}]
+  });
+  
+  var testInstance = new TestClass('world');
+  
+  t.ok(testInstance, 'instance instantiated');
+  
+  var queried = 0, queriedOnce = 0;
+  testInstance.stringQueried.add(function(value) {
+    t.equal(value, 'world', 'event argument');
+    queried++;
+  });
+  testInstance.stringQueriedOnce.add(function(value) {
+    t.equal(value, 'world', 'event argument');
+    queriedOnce++;
+  });
+  
+  t.notOk(testInstance.stringQueried.fired(), 'event not fired');
+  
+  t.equal(testInstance.greeting, 'Hello', 'public constant accessible');
+  t.equal(testInstance.toString(), 'Hello, world!', 'public member accessible');
+  
+  t.assert(testInstance.stringQueried.fired(), 'event fired');
+  
+  t.equal(queried, 1, 'event fired');
+  t.equal(queriedOnce, 1, 'once event fired');
+  
+  testInstance.toString();
+  t.equal(queried, 2, 'event fired again');
+  t.equal(queriedOnce, 1, 'once event not fired again');
+  
+  t.notOk(testInstance.getMessage, 'private member not accessible');
+  t.notOk(testInstance.value, 'instance field not accessible');
+  t.notOk(testInstance.stringQueried.fire, 'event fire not accessible');
+  t.notOk(testInstance.stringQueried.fireWith, 'event fireWith not accessible');
+  
+  t.end();
+});
+
+test('events object', function(t) {
+  var TestClass = defineClass({
+    init: function() {
+      this.privateFires = this.publicFires = 0;
+      
+      var self = this;
+      this.privateEvent.add(function() {
+        self.privateFires++;
+      });
+      this.publicEvent.add(function() {
+        self.publicFires++;
+      });
+    },
+    
+    publics: {
+      getPrivateFires: function() {
+        return this.privateFires;
+      },
+      
+      getPublicFires: function() {
+        return this.publicFires;
+      },
+      
+      trigger: function() {
+        this.triggerOnce.fire();
+        this.privateEvent();
+        this.publicEvent();
+      }
+    },
+    events: {
+      triggerOnce: 'once',
+      privateEvent: 'private',
+      publicEvent: 'public'
+    }
+  });
+  
+  var testInstance = new TestClass();
+  var count = 0;
+  testInstance.triggerOnce.add(function() {
+    count++;
+  });
+  
+  testInstance.trigger();
+  t.equal(count, 1, 'event fired');
+  testInstance.trigger();
+  t.equal(count, 1, 'event not fired again');
+  
+  t.notOk(testInstance.privateEvent, 'private event not accessible');
+  t.equal(testInstance.getPrivateFires(), 2, 'private event fired twice');
+  t.equal(testInstance.getPublicFires(), 2, 'public event fired twice');
+  
+  t.notOk($.isFunction(testInstance.triggerOnce), 'normal event not callable');
+  t.ok($.isFunction(testInstance.publicEvent), 'public event callable');
+  testInstance.publicEvent();
+  t.equal(testInstance.getPublicFires(), 3, 'public event fired thrice');
+  
+  t.end();
+});
+
+test('statics', function(t) {
+  var TestClass = defineClass({
+    publics: {
+      getValue: function() {
+        return this.CONSTANT;
+      }
+    },
+    
+    statics: {
+      CONSTANT: 42
+    }
+  });
+  
+  t.equal(TestClass.CONSTANT, 42, 'public static access');
+  
+  var testInstance = new TestClass();
+  
+  t.equal(testInstance.CONSTANT, 42, 'public access');
+  t.equal(testInstance.getValue(), 42, 'private access');
+  
+  t.end();
+});
diff --git a/test/util/jquery.js b/test/util/jquery.js
new file mode 100644
index 0000000..bffb00b
--- /dev/null
+++ b/test/util/jquery.js
@@ -0,0 +1,8 @@
+var test = require('tape');
+
+var jquery = require('../../src/util/jquery');
+
+test('load on server', function(t) {
+  t.ok(jquery.each, 'jquery has an each function');
+  t.end();
+});
diff --git a/test/vanadium-wrapper.js b/test/vanadium-wrapper.js
new file mode 100644
index 0000000..43d1427
--- /dev/null
+++ b/test/vanadium-wrapper.js
@@ -0,0 +1,60 @@
+var test = require('tape');
+
+var $ = require('../src/util/jquery');
+var defineClass = require('../src/util/define-class');
+
+var vanadiumWrapper = require('../src/vanadium-wrapper');
+
+var vanadiumMocks = require('../mocks/vanadium');
+var MockVanadium = vanadiumMocks.MockVanadium;
+var MockRuntime = vanadiumMocks.MockRuntime;
+
+function setUpCrashTest(t) {
+  var mockVanadium = new MockVanadium(t);
+  var mockRuntime = new MockRuntime();
+  
+  var context = {
+    bindCrashHandler: function(err) {
+      var self = this;
+      self.vanadiumWrapper.crash.add(function(err) {
+        self.crashErr = err;
+      });
+    },
+    crash: function(err) {
+      mockRuntime.fireCrash(err);
+    }
+  };
+  
+  vanadiumWrapper.init(mockVanadium).then(
+    function(v) {
+      context.vanadiumWrapper = v;
+    },
+    function(err) {
+      t.fail('init error');
+    });
+  
+  mockVanadium.finishInit(null, mockRuntime);
+  
+  return context;
+}
+
+test('crashBefore', function(t) {
+  var crashTest = setUpCrashTest(t);
+  
+  crashTest.crash('I lost the game.');
+  crashTest.bindCrashHandler();
+  t.equal(crashTest.crashErr, 'I lost the game.');
+  
+  t.end();
+});
+
+test('crashAfter', function(t) {
+  var crashTest = setUpCrashTest(t);
+  crashTest.bindCrashHandler();
+  t.notOk(crashTest.crashErr, 'no crash yet');
+  
+  crashTest.crash('I lost the game.');
+  t.equal(crashTest.crashErr, 'I lost the game.');
+  
+  t.end();
+});
\ No newline at end of file