Travel app UX updates

Rebranded classic routing UI to "Timeline" and put it in a collapsible left
  panel.
Introducing minimal UI for streamlined common-case destination addition and
  editing.
Implementing auto-remove of unused tail destinations.

Change-Id: I65b0d58d0edf1da535a83c27757d678c44d4442c
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 45e07ac..3ef6412 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -6,9 +6,10 @@
 var defineClass = require('../src/util/define-class');
 
 var ControlPosition = {
+  LEFT_CENTER: 'lc',
   LEFT_TOP: 'lt',
-  TOP_LEFT: 'tl',
-  TOP_CENTER: 'tc'
+  TOP_CENTER: 'tc',
+  TOP_LEFT: 'tl'
 };
 
 var ControlPanel = defineClass({
@@ -68,10 +69,11 @@
   },
 
   init: function(canvas) {
+    var self = this;
     this.controls = {};
-    this.controls[ControlPosition.LEFT_TOP] = new ControlPanel(canvas);
-    this.controls[ControlPosition.TOP_CENTER] = new ControlPanel(canvas);
-    this.controls[ControlPosition.TOP_LEFT] = new ControlPanel(canvas);
+    $.each(ControlPosition, function() {
+      self.controls[this] = new ControlPanel(canvas);
+    });
 
     this.infoWindows = [];
   }
diff --git a/package.json b/package.json
index 9a0ee71..5045df4 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,10 @@
     "tape": "^4.0.0"
   },
   "dependencies": {
+    "es6-promisify": "^2.0.0",
     "global": "^4.3.0",
     "jquery": "^2.1.4",
-    "es6-promisify": "^2.0.0",
+    "raf": "^3.1.0",
     "uuid": "^2.0.1"
   }
 }
diff --git a/src/components/add-button.js b/src/components/add-button.js
new file mode 100644
index 0000000..9f17a14
--- /dev/null
+++ b/src/components/add-button.js
@@ -0,0 +1,42 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var AddButton = defineClass({
+  publics: {
+    disable: function() {
+      this.$.addClass('disabled');
+    },
+
+    enable: function() {
+      this.$.removeClass('disabled');
+    },
+
+    isEnabled: function() {
+      return !this.$.hasClass('disabled');
+    }
+  },
+
+  constants: [ '$' ],
+  events: [ 'onClick' ],
+
+  init: function(maps) {
+    var self = this;
+
+    this.$ = $('<div>')
+      .addClass('add-bn')
+      .click(function() {
+        if (self.isEnabled()) {
+          self.onClick();
+        }
+      })
+      .append($('<div>')
+        .addClass('vertical-middle')
+        .text('+'));
+  }
+});
+
+module.exports = AddButton;
\ No newline at end of file
diff --git a/src/components/destination-control.js b/src/components/destination-control.js
deleted file mode 100644
index 797143a..0000000
--- a/src/components/destination-control.js
+++ /dev/null
@@ -1,184 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var $ = require('../util/jquery');
-var defineClass = require('../util/define-class');
-
-var strings = require('../strings').currentLocale;
-
-var DestinationControl = defineClass({
-  publics: {
-    focus: function() {
-      this.$.find('input:visible').focus();
-    },
-
-    hasFocus: function() {
-      return this.$.find(':focus').length > 0;
-    },
-
-    setSearchBounds: function(bounds) {
-      this.searchBox.setBounds(bounds);
-    },
-
-    selectControl: function() {
-      if (this.destination) {
-        this.destination.select();
-      }
-    },
-
-    deselectControl: function() {
-      if (this.destination) {
-        this.destination.deselect();
-      }
-    },
-
-    bindDestination: function(destination) {
-      if (this.destination) {
-        this.destination.onPlaceChange.remove(this.handlePlaceChange);
-        this.destination.onSelect.remove(this.handleSelect);
-        this.destination.onDeselect.remove(this.handleDeselect);
-        this.destination.onOrdinalChange.remove(this.updateOrdinal);
-      }
-
-      this.destination = destination;
-
-      if (destination) {
-        destination.onPlaceChange.add(this.handlePlaceChange);
-        destination.onSelect.add(this.handleSelect);
-        destination.onDeselect.add(this.handleDeselect);
-        destination.onOrdinalChange.add(this.updateOrdinal);
-      }
-
-      this.updateOrdinal();
-      this.handlePlaceChange(destination && destination.getPlace());
-      if (destination && destination.isSelected()) {
-        this.handleSelect();
-      } else {
-        this.handleDeselect();
-      }
-    }
-  },
-
-  privates: {
-    handlePlaceChange: function(place) {
-      this.setAutocomplete(!place);
-
-      var newValue;
-      if (place) {
-        newValue = place.getSingleLine();
-      } else if (!this.hasFocus()) {
-        newValue = '';
-      }
-      if (newValue !== undefined) {
-        this.$searchBox.prop('value', newValue);
-      }
-    },
-
-    updateOrdinal: function() {
-      var placeholder;
-      var destination = this.destination;
-      if (destination) {
-        if (!destination.hasPrevious()) {
-          placeholder = strings['Origin'];
-        } else if (destination.getIndex() === 1 && !destination.hasNext()) {
-          placeholder = strings['Destination'];
-        } else {
-          placeholder = strings.destination(destination.getIndex());
-        }
-      }
-
-      this.$searchBox.attr('placeholder', placeholder);
-    },
-
-    handleSelect: function() {
-      this.$.addClass('selected');
-    },
-
-    handleDeselect: function() {
-      this.$.removeClass('selected');
-    },
-
-    /**
-     * This is a bit of a hack; Maps API does not include functionality to
-     * disable autocomplete.
-     */
-    setAutocomplete: function(autocomplete) {
-      /* True boolean comparison. We could coerce the input to boolean, but
-       * this is less impactful. */
-      /* jshint eqeqeq: false */
-      if (this.autocomplete != autocomplete) {
-      /* jshint eqeqeq: true */
-        this.autocomplete = autocomplete;
-
-        var oldBox = this.$searchBox[autocomplete? 1 : 0];
-        var newBox = this.$searchBox[autocomplete? 0 : 1];
-
-        newBox.value = oldBox.value;
-        var active = global.document &&
-          global.document.activeElement === oldBox;
-        if (newBox.setSelectionRange) {
-          //non-universal browser support
-          newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
-        }
-
-        if (autocomplete) {
-          this.$.addClass('autocomplete');
-        } else {
-          this.$.removeClass('autocomplete');
-        }
-
-        if (active) {
-          $(newBox).focus();
-        }
-      }
-    }
-  },
-
-  events: [
-    /**
-     * @param event jQuery Event object for text box focus event.
-     */
-    'onFocus',
-    /**
-     * @param places (array of places)
-     */
-    'onSearch'
-  ],
-
-  constants: ['$'],
-
-  init: function(maps) {
-    var self = this;
-
-    var $searchBox = $.merge($('<input>'), $('<input>'))
-      .attr('type', 'text')
-      //to make dummy box consistent with search
-      .attr('autocomplete', 'off');
-    this.$searchBox = $searchBox;
-
-    $searchBox[0].className = 'autocomplete';
-
-    $searchBox.focus(this.onFocus);
-    $searchBox.on('input', function() {
-      if (self.destination) {
-        self.destination.setPlace(null);
-      }
-    });
-
-    this.$ = $('<div>')
-      .addClass('destination')
-      .addClass('autocomplete')
-      .append($searchBox);
-
-    this.searchBox = new maps.places.SearchBox($searchBox[0]);
-
-    this.autocomplete = true;
-
-    maps.event.addListener(this.searchBox, 'places_changed', function() {
-      self.onSearch(self.searchBox.getPlaces());
-    });
-  }
-});
-
-module.exports = DestinationControl;
\ No newline at end of file
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index bba109c..9f4319f 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -72,6 +72,10 @@
       }
     },
 
+    getClient: function() {
+      return this.topClient().client;
+    },
+
     setColor: function(color) {
       this.topClient().color = color;
       this.updateIcon();
@@ -89,6 +93,16 @@
       this.updateIcon();
     },
 
+    restrictListenerToClient: function(callback, client) {
+      var self = this;
+      client = client || this.getClient();
+      return function() {
+        if (self.getClient() === client) {
+          return callback.apply(this, arguments);
+        }
+      };
+    },
+
     setIcon: function(icon) {
       var client = this.topClient();
       client.icon = icon;
@@ -155,6 +169,11 @@
       clickable: false
     });
 
+    /* Override onClick.add to keep a record of which listeners are bound to
+     * which clients to remove listeners on client removal. This does not
+     * however implicitly restrict such listeners; that must be left to the
+     * caller so as to allow the caller to later pre-emptively remove the
+     * listener if desired. */
     defineClass.decorate(this.onClick, 'add', function(listener, global) {
       if (!global) {
         /* Per jQuery, listener can also be an array; however, there seems to
@@ -169,7 +188,7 @@
           listeners.push(listener);
         }
       }
-
+    }, function() {
       self.refreshClickability();
     });
 
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
new file mode 100644
index 0000000..7323794
--- /dev/null
+++ b/src/components/destination-search.js
@@ -0,0 +1,173 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var DestinationSearch = defineClass({
+  publics: {
+    clear: function() {
+      this.setPlace(null);
+      this.$searchBox.prop('value', '');
+    },
+
+    enable: function() {
+      this.$searchBox.removeAttr('disabled');
+    },
+
+    disable: function() {
+      this.$searchBox.attr('disabled', 'disabled');
+    },
+
+    focus: function() {
+      this.$.find('input:visible').focus();
+    },
+
+    hasFocus: function() {
+      return this.$.find(':focus').length > 0;
+    },
+
+    setSearchBounds: function(bounds) {
+      this.searchBox.setBounds(bounds);
+    },
+
+    select: function() {
+      this.$.addClass('selected');
+    },
+
+    deselect: function() {
+      if (this.isSelected()) {
+        this.$.removeClass('selected');
+        this.onDeselect();
+      }
+    },
+
+    isSelected: function() {
+      return this.$.hasClass('selected');
+    },
+
+    getPlace: function() {
+      return this.place;
+    },
+
+    setPlace: function(place) {
+      var prev = this.place;
+      if (prev !== place) {
+        this.place = place;
+        this.setAutocomplete(!place);
+
+        var newValue;
+        if (place) {
+          newValue = place.getSingleLine();
+        } else if (!this.hasFocus()) {
+          newValue = '';
+        }
+        if (newValue !== undefined) {
+          this.$searchBox.prop('value', newValue);
+        }
+
+        this.onPlaceChange(place, prev);
+      }
+    },
+
+    setPlaceholder: function(placeholder) {
+      this.$searchBox.attr('placeholder', placeholder);
+    }
+  },
+
+  privates: {
+    /**
+     * This is a bit of a hack; Maps API does not include functionality to
+     * disable autocomplete.
+     */
+    setAutocomplete: function(autocomplete) {
+      /* True boolean comparison. We could coerce the input to boolean, but
+       * this is less impactful. */
+      /* jshint eqeqeq: false */
+      if (this.autocomplete != autocomplete) {
+      /* jshint eqeqeq: true */
+        this.autocomplete = autocomplete;
+
+        var oldBox = this.$searchBox[autocomplete? 1 : 0];
+        var newBox = this.$searchBox[autocomplete? 0 : 1];
+
+        newBox.value = oldBox.value;
+        var active = global.document &&
+          global.document.activeElement === oldBox;
+
+        /* Restrict selection restoration to active elements because
+         * setSelectionRange apparently takes keyboard focus away from the
+         * currently focused element without actually setting it to anything,
+         * and trying to restore focus afterwards doesn't work. */
+        if (active && newBox.setSelectionRange) {
+          //non-universal browser support
+          newBox.setSelectionRange(oldBox.selectionStart, oldBox.selectionEnd);
+        }
+
+        if (autocomplete) {
+          this.$.addClass('autocomplete');
+        } else {
+          this.$.removeClass('autocomplete');
+        }
+
+        if (active) {
+          $(newBox).focus();
+        }
+      }
+    }
+  },
+
+  events: [
+    /**
+     * @param event jQuery Event object for text box focus event.
+     */
+    'onFocus',
+
+    /**
+     * @param place
+     * @param previous
+     */
+    'onPlaceChange',
+
+    /**
+     * @param places (array of places)
+     */
+    'onSearch',
+
+    'onDeselect'
+  ],
+
+  constants: ['$'],
+
+  init: function(maps) {
+    var self = this;
+
+    var $searchBox = $.merge($('<input>'), $('<input>'))
+      .attr('type', 'text')
+      //to make dummy box consistent with search
+      .attr('autocomplete', 'off');
+    this.$searchBox = $searchBox;
+
+    $searchBox[0].className = 'autocomplete';
+
+    $searchBox.focus(this.onFocus);
+    $searchBox.on('input', function() {
+      self.setPlace(null);
+    });
+
+    this.$ = $('<div>')
+      .addClass('destination autocomplete')
+      .append($searchBox);
+
+    this.searchBox = new maps.places.SearchBox($searchBox[0]);
+
+    this.autocomplete = true;
+
+    maps.event.addListener(this.searchBox, 'places_changed', function() {
+      self.onSearch(self.searchBox.getPlaces());
+    });
+  }
+});
+
+module.exports = DestinationSearch;
\ No newline at end of file
diff --git a/src/components/destinations.js b/src/components/destinations.js
deleted file mode 100644
index a1ca01d..0000000
--- a/src/components/destinations.js
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2015 The Vanadium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-var $ = require('../util/jquery');
-var defineClass = require('../util/define-class');
-
-var DestinationControl = require('./destination-control');
-
-var Destinations = defineClass({
-  publics: {
-    append: function() {
-      var controls = this.controls;
-
-      var destinationControl = new DestinationControl(this.maps);
-      this.$destContainer.append(destinationControl.$);
-      controls.push(destinationControl);
-
-      return destinationControl;
-    }
-  },
-
-  constants: [ '$' ],
-  events: [ 'onAddClick' ],
-
-  init: function(maps) {
-    var self = this;
-
-    this.maps = maps;
-    this.$ = $('<form>').addClass('destinations');
-    this.$destContainer = $('<div>');
-    this.$.append(this.$destContainer);
-
-    $('<div>')
-      .addClass('add-bn')
-      .text('+')
-      .click(function() {
-        self.onAddClick();
-      })
-      .appendTo(this.$);
-
-    this.controls = [];
-  }
-});
-
-module.exports = Destinations;
\ No newline at end of file
diff --git a/src/components/map.js b/src/components/map.js
index f28f0e6..24ab5db 100644
--- a/src/components/map.js
+++ b/src/components/map.js
@@ -5,12 +5,12 @@
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var Destination = require('../destination');
+var Destinations = require('../destinations');
 var Place = require('../place');
 var DestinationInfo = require('./destination-info');
 var DestinationMarker = require('./destination-marker');
 
-var strings = require('../strings').currentLocale;
+var describeDestination = require('../describe-destination');
 
 //named destination marker clients
 var SEARCH_CLIENT = 'search';
@@ -28,16 +28,10 @@
       });
     },
 
-    addDestination: function() {
+    addDestination: function(index) {
       var self = this;
 
-      var destination = new Destination();
-      if (!this.origin) {
-        this.finalDestination = this.origin = destination;
-      } else {
-        this.finalDestination.bindNext(destination);
-        this.finalDestination = destination;
-      }
+      var destination = this.destinations.add(index);
 
       destination.onPlaceChange.add(function(place) {
         self.handleDestinationPlaceChange(destination, place);
@@ -52,6 +46,19 @@
       return destination;
     },
 
+    getDestination: function(index) {
+      return this.destinations.get(index);
+    },
+
+    removeDestination: function(index) {
+      //TODO(rosswang): clear any rendered legs
+      return this.destinations.remove(index);
+    },
+
+    getSelectedDestination: function() {
+      return this.selectedDestination;
+    },
+
     clearSearchMarkers: function() {
       $.each(this.searchMarkers, function() {
         this.removeClient(SEARCH_CLIENT);
@@ -73,21 +80,19 @@
 
     fitAll: function() {
       var geoms = [];
-      var dest = this.origin;
 
       function addToGeoms() {
         geoms.push({ location: this });
       }
 
-      while (dest) {
-        if (dest.hasPlace()) {
-          if (dest.leg && dest.leg.sync) {
-            $.each(dest.leg.sync.routes[0]['overview_path'], addToGeoms);
+      this.destinations.each(function() {
+        if (this.hasPlace()) {
+          if (this.leg && this.leg.sync) {
+            $.each(this.leg.sync.routes[0]['overview_path'], addToGeoms);
           }
-          geoms.push(dest.getPlace().getGeometry());
+          geoms.push(this.getPlace().getGeometry());
         }
-        dest = dest.getNext();
-      }
+      });
 
       this.ensureGeomsVisible(geoms);
     },
@@ -96,6 +101,28 @@
       this.ensureGeomsVisible([place.getGeometry()]);
     },
 
+    invalidateSize: function() {
+      this.maps.event.trigger(this.map, 'resize');
+    },
+
+    /**
+     * @return whether or not location selection is now enabled. Location
+     *  selection can only be enabled when a destination slot has been selected.
+     */
+    enableLocationSelection: function() {
+      if (this.selectedDestination) {
+        this.map.setOptions({ draggableCursor: 'auto' });
+        this.locationSelectionEnabled = true;
+        return true;
+      }
+      return false;
+    },
+
+    disableLocationSelection: function() {
+      this.map.setOptions({ draggableCursor: null });
+      this.locationSelectionEnabled = false;
+    },
+
     showSearchResults: function(results) {
       var self = this;
 
@@ -106,17 +133,14 @@
         return result.geometry;
       }));
 
-      if (results.length === 1) {
-        var place = new Place(results[0]);
+      var dest = this.selectedDestination;
+      if (results.length === 1 && dest) {
         /* It would be nice if we could distinguish between an autocomplete
          * click and a normal search so that we don't overwrite the search box
          * text for the autocomplete click.*/
-        var dest = this.selectedDestination;
-        if (dest) {
-          dest.setPlace(place);
-          self.createDestinationMarker(dest);
-        }
-      } else if (results.length > 1) {
+        dest.setPlace(new Place(results[0]));
+        self.createDestinationMarker(dest);
+      } else if (results.length > 0) {
         $.each(results, function(i, result) {
           var place = new Place(result);
 
@@ -124,13 +148,13 @@
             DestinationMarker.color.RED);
           self.searchMarkers.push(marker);
 
-          marker.onClick.add(function() {
+          marker.onClick.add(marker.restrictListenerToClient(function() {
             var dest = self.selectedDestination;
             if (dest) {
               dest.setPlace(place);
               self.associateDestinationMarker(dest, marker);
             }
-          });
+          }));
         });
       }
     }
@@ -181,20 +205,7 @@
       destination.onDeselect.add(handleSelection);
 
       function handleOrdinalChange() {
-        var destLabel;
-        if (!destination.hasPrevious()) {
-          destLabel = strings['Origin'];
-          marker.setIcon(DestinationMarker.icon.ORIGIN);
-        } else if (!destination.hasNext()) {
-          destLabel = strings[destination.getIndex() === 1?
-            'Destination' : 'Final destination'];
-          marker.setIcon(DestinationMarker.icon.DESTINATION);
-        } else {
-          destLabel = strings.destination(destination.getIndex());
-          marker.setLabel(destination.getIndex());
-        }
-
-        marker.setDestinationLabel(destLabel);
+        describeDestination.decorateMarker(marker, destination);
       }
 
       destination.onOrdinalChange.add(handleOrdinalChange);
@@ -242,7 +253,6 @@
       destination.onPlaceChange.remove(
         this.handleSelectedDestinationPlaceChange);
       this.disableLocationSelection();
-      this.clearSearchMarkers();
     },
 
     handleDestinationSelect: function(destination) {
@@ -330,10 +340,11 @@
 
           self.geocoder.geocode({ location: latLng },
             function(results, status) {
+              var origin = self.destinations.get(0);
               if (status === maps.GeocoderStatus.OK &&
-                  self.origin && !self.origin.hasPlace()) {
-                self.origin.setPlace(new Place(results[0]));
-                self.createDestinationMarker(self.origin);
+                  origin && !origin.hasPlace()) {
+                origin.setPlace(new Place(results[0]));
+                self.createDestinationMarker(origin);
               }
             });
           });
@@ -377,29 +388,28 @@
       }
     },
 
-    enableLocationSelection: function() {
-      this.map.setOptions({ draggableCursor: 'auto' });
-      this.locationSelectionEnabled = true;
-    },
-
-    disableLocationSelection: function() {
-      this.map.setOptions({ draggableCursor: null });
-      this.locationSelectionEnabled = false;
-    },
-
     selectLocation: function(latLng) {
       var self = this;
       var maps = this.maps;
 
       var dest = this.selectedDestination;
-      if (dest && this.locationSelectionEnabled) {
-        self.geocoder.geocode({ location: latLng },
-          function(results, status) {
-            if (status === maps.GeocoderStatus.OK) {
-              dest.setPlace(new Place(results[0]));
-              self.createDestinationMarker(dest);
-            }
-          });
+      if (dest) {
+        if (this.locationSelectionEnabled) {
+          self.geocoder.geocode({ location: latLng },
+            function(results, status) {
+              if (status === maps.GeocoderStatus.OK) {
+                dest.setPlace(new Place(results[0]));
+                self.createDestinationMarker(dest);
+
+                /* If we've just picked a location like this, we probably don't
+                 * care about search results anymore. */
+                self.clearSearchMarkers();
+              }
+            });
+        } else {
+          dest.deselect();
+          this.closeActiveInfoWindow();
+        }
       }
     }
   },
@@ -407,14 +417,15 @@
   constants: ['$', 'maps'],
   events: {
     /**
+     * @param bounds
+     */
+    onBoundsChange: '',
+
+    /**
      * @param error A union with one of the following keys:
      *  directionsStatus
      */
-    onError: 'memory',
-    /**
-     * @param bounds
-     */
-    onBoundsChange: ''
+    onError: 'memory'
   },
 
   // https://developers.google.com/maps/documentation/javascript/tutorial
@@ -427,6 +438,7 @@
     this.navigator = opts.navigator || global.navigator;
     this.geocoder = new maps.Geocoder();
     this.directionsService = new maps.DirectionsService();
+    this.destinations = new Destinations();
 
     this.$ = $('<div>').addClass('map-canvas');
 
diff --git a/src/components/message.js b/src/components/message.js
index 1c72faf..a2e5062 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -17,10 +17,18 @@
       };
     },
 
-    error: function(text) {
+    error: function(err) {
+      if (typeof err !== 'string') {
+        console.error(err);
+      }
+
+      while (err.message) {
+        err = err.message; //ExtensionCrashError.message.message = ...
+      }
+
       return {
         type: Message.ERROR,
-        text: text
+        text: err.msg || err.toString()
       };
     }
   },
diff --git a/src/components/messages.js b/src/components/messages.js
index 2d8555c..27d49a5 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -148,16 +148,14 @@
 
   init: function() {
     this.$handle = $('<div>')
-      .addClass('handle')
+      .addClass('handle no-select')
       .click(this.toggle);
 
     this.$messages = $('<ul>');
 
     this.$ = $('<div>')
-      .addClass('messages')
-      .addClass('headlines')
-      .append(this.$handle)
-      .append(this.$messages);
+      .addClass('messages headlines')
+      .append(this.$handle, this.$messages);
   }
 });
 
diff --git a/src/components/timeline.js b/src/components/timeline.js
new file mode 100644
index 0000000..24df0ab
--- /dev/null
+++ b/src/components/timeline.js
@@ -0,0 +1,71 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('../util/jquery');
+var defineClass = require('../util/define-class');
+
+var AddButton = require('./add-button');
+var DestinationSearch = require('./destination-search');
+
+var Timeline = defineClass({
+  publics: {
+    disableAdd: function() {
+      this.addButton.disable();
+    },
+
+    enableAdd: function() {
+      this.addButton.enable();
+    },
+
+    append: function() {
+      var controls = this.controls;
+
+      var destinationSearch = new DestinationSearch(this.maps);
+      this.$destContainer.append(destinationSearch.$);
+      controls.push(destinationSearch);
+
+      return destinationSearch;
+    },
+
+    get: function(i) {
+      if (i === undefined) {
+        return this.controls.slice(0);
+      } else if (i >= 0) {
+        return this.controls[i];
+      } else if (i < 0) {
+        return this.controls[this.controls.length + i];
+      }
+    },
+
+    remove: function(i) {
+      if (i >= 0) {
+        this.controls.splice(i, 1)[0].$.remove();
+      } else if (i < 0) {
+        this.controls.splice(this.controls.length + i, 1)[0].$.remove();
+      }
+    }
+  },
+
+  constants: [ '$' ],
+  events: [ 'onAddClick' ],
+
+  init: function(maps) {
+    this.maps = maps;
+
+    this.addButton = new AddButton();
+    this.addButton.onClick.add(this.onAddClick);
+
+    this.$ = $('<form>')
+      .addClass('timeline no-select')
+      .append(this.$destContainer = $('<div>'), //for easier appending
+              this.addButton.$,
+              //get the scroll region to include the add button
+              $('<div>')
+                .addClass('clear-float'));
+
+    this.controls = [];
+  }
+});
+
+module.exports = Timeline;
\ No newline at end of file
diff --git a/src/debug.js b/src/debug.js
index 1d04f70..12da771 100644
--- a/src/debug.js
+++ b/src/debug.js
@@ -2,9 +2,12 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var $ = require('./util/jquery');
+
 /**
  * Global variable exports for console debug.
  */
 module.exports = function(app) {
   global.travel = app;
+  global.$ = $;
 };
\ No newline at end of file
diff --git a/src/describe-destination.js b/src/describe-destination.js
new file mode 100644
index 0000000..1d5310f
--- /dev/null
+++ b/src/describe-destination.js
@@ -0,0 +1,68 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var strings = require('./strings').currentLocale;
+var DestinationMarker = require('./components/destination-marker');
+
+var ORIGIN = 0;
+var INTERMEDIATE = 1;
+var TERMINAL = 2;
+var TERMINAL_DIRECT = 3;
+
+function classify(destination) {
+  if (!destination.hasPrevious()) {
+    return ORIGIN;
+  } else if (!destination.hasNext()) {
+    return destination.getIndex() === 1? TERMINAL_DIRECT : TERMINAL;
+  } else {
+    return INTERMEDIATE;
+  }
+}
+
+function description(destination) {
+  switch (classify(destination)) {
+    case ORIGIN:
+      return strings['Origin'];
+    case INTERMEDIATE:
+      return strings.destination(destination.getIndex());
+    case TERMINAL:
+      return strings['Final destination'];
+    case TERMINAL_DIRECT:
+      return strings['Destination'];
+  }
+}
+
+function descriptionOpenEnded(destination) {
+  switch (classify(destination)) {
+    case ORIGIN:
+      return strings['Origin'];
+    case TERMINAL:
+    case INTERMEDIATE:
+      return strings.destination(destination.getIndex());
+    case TERMINAL_DIRECT:
+      return strings['Destination'];
+  }
+}
+
+function decorateMarker(marker, destination) {
+  switch (classify(destination)) {
+    case ORIGIN:
+      marker.setIcon(DestinationMarker.icon.ORIGIN);
+      break;
+    case INTERMEDIATE:
+      marker.setLabel(destination.getIndex());
+      break;
+    case TERMINAL:
+    case TERMINAL_DIRECT:
+      marker.setIcon(DestinationMarker.icon.DESTINATION);
+      break;
+  }
+  marker.setDestinationLabel(description(destination));
+}
+
+module.exports = {
+  description: description,
+  descriptionOpenEnded: descriptionOpenEnded,
+  decorateMarker: decorateMarker
+};
diff --git a/src/destination.js b/src/destination.js
index aa98a21..25c609c 100644
--- a/src/destination.js
+++ b/src/destination.js
@@ -45,78 +45,37 @@
     },
 
     hasNext: function() {
-      return !!this.next;
+      return this.index < this.list.count() - 1;
     },
 
     getNext: function() {
-      return this.next;
-    },
-
-    bindNext: function(next) {
-      var oldNext = this.next;
-      if (oldNext !== next) {
-        if (oldNext) {
-          oldNext.bindPrev(null);
-        }
-
-        this.next = next;
-
-        if (next) {
-          next.bindPrevious(this.ifc);
-        }
-
-        if (!(oldNext && next)) {
-          this.onOrdinalChange(); //changed to or from last
-        }
-      }
+      return this.list.get(this.index + 1);
     },
 
     hasPrevious: function() {
-      return !!this.prev;
+      return this.index > 0;
     },
 
     getPrevious: function() {
-      return this.prev;
-    },
-
-    bindPrevious: function(prev) {
-      if (this.prev !== prev) {
-        if (this.prev) {
-          this.prev.onOrdinalChange.remove(this.updateIndex);
-          this.prev.bindNext(null);
-        }
-
-        this.prev = prev;
-
-        if (prev) {
-          prev.bindNext(this.ifc);
-          prev.onOrdinalChange.add(this.updateIndex);
-        }
-
-        this.updateIndex();
-      }
+      return this.hasPrevious()? this.list.get(this.index - 1) : null;
     }
   },
 
   privates: {
-    updateIndex: function() {
-      var oldIndex = this.index;
-      if (this.prev) {
-        this.index = this.prev.getIndex() + 1;
-      } else {
-        this.index = 0;
-      }
-      if (oldIndex !== this.index) {
-        this.onOrdinalChange();
-      }
+    setIndex: function(index) {
+      this.index = index;
     }
   },
 
   events: [
     /**
      * Fired when properties related to the ordering of this destination with
-     * respect to other destinations have changed. Such properties include
+     * respect to other timeline have changed. Such properties include
      * whether this destination is or last and its index number.
+     *
+     * @param index the new index, which may not have changed. If the index has
+     *  not changed, then this event is in response to the destination changing
+     *  to or from last.
      */
     'onOrdinalChange',
     /**
@@ -129,9 +88,13 @@
     'onDeselect'
   ],
 
-  init: function() {
+  init: function(list, index, callbacks) {
+    this.list = list;
     this.selected = false;
-    this.index = 0;
+    this.index = index;
+
+    callbacks.ordinalChange = this.onOrdinalChange;
+    this.onOrdinalChange.add(this.setIndex);
   }
 });
 
diff --git a/src/destinations.js b/src/destinations.js
new file mode 100644
index 0000000..2679a4e
--- /dev/null
+++ b/src/destinations.js
@@ -0,0 +1,112 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var $ = require('./util/jquery');
+var defineClass = require('./util/define-class');
+var Destination = require('./destination');
+
+var Destinations = defineClass({
+  publics: {
+    add: function(index) {
+      index = index || this.destinations.length;
+
+      var isLast = index === this.destinations.length;
+
+      var callbacks = {};
+      var destination = new Destination(this.ifc, index, callbacks);
+
+      this.destinations.splice(index, 0, {
+        callbacks: callbacks,
+        destination: destination
+      });
+
+      this.onAdd(destination);
+
+      if (isLast && index > 0) {
+        //old last is no longer last
+        this.destinations[index - 1].callbacks.ordinalChange(index - 1);
+      }
+      for (var i = index + 1; i < this.destinations.length; i++) {
+        this.destinations[i].callbacks.ordinalChange(i);
+      }
+
+      return destination;
+    },
+
+    get: function(index) {
+      if (index === undefined) {
+        return this.destinations.map(function(record) {
+          return record.destination;
+        });
+      }
+
+      var record;
+      if (index >= 0) {
+        record = this.destinations[index];
+      } else if (index < 0) {
+        record = this.destinations[this.destinations.length + index];
+      }
+
+      return record && record.destination;
+    },
+
+    count: function() {
+      return this.destinations.length;
+    },
+
+    remove: function(i) {
+      if (typeof i !== 'number') {
+        return;
+      }
+
+      if (i < 0) {
+        i += this.destinations.length;
+      }
+
+      var removed = this.destinations.splice(i, 1)[0];
+      if (removed) {
+        this.onRemove(removed);
+
+        if (i === this.destinations.length && i > 0) {
+          //new last
+          this.destinations[i - 1].callbacks.ordinalChange(i - 1);
+        }
+        for (var j = i; j < this.destinations.length; j++) {
+          this.destinations[j].callbacks.ordinalChange(j);
+        }
+
+        return removed.destination;
+      }
+    },
+
+    /**
+     * Behaves like jQuery each.
+     */
+    each: function(callback) {
+      $.each(this.destinations, function(i, elem) {
+        callback.call(this.destination, i, elem.destination);
+      });
+    }
+  },
+
+  events: [
+    /**
+     * @param destination. The index on the destination is reflective of its
+     *  insertion index.
+     */
+    'onAdd',
+
+    /**
+     * @param destination. The index on the destination is reflective of its
+     *  index prior to removal.
+     */
+    'onRemove'
+  ],
+
+  init: function() {
+    this.destinations = [];
+  }
+});
+
+module.exports = Destinations;
\ No newline at end of file
diff --git a/src/static/index.css b/src/static/index.css
index f7dad01..5b69c32 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -7,28 +7,47 @@
   font-family: Arial, sans-serif;
 }
 
-.destinations {
-  margin: 2em 3em;
-  width: 30em;
+.add-bn {
+  border-radius: 24px;
+  box-shadow: 0 0 4px rgba(0,0,0,.14), 0 4px 8px rgba(0,0,0,.28);
+  float: right;
+  width: 48px;
+  height: 48px;
+  cursor: pointer;
+  color: white;
+  background-color: #db4437;
+  text-align: center;
+  margin-top: 12px;
+  font-size: 27px;
+  font-weight: 100;
+  transform-origin: 100% 0;
+  transition: box-shadow .15s, transform .2s .1s;
 }
 
-.add-bn {
-  border-radius: 16px;
-  border: 1px solid #aaa;
-  width: 32px;
-  height: 32px;
-  cursor: pointer;
-  color: #aaa;
-  background-color: white;
-  text-align: center;
-  margin-top: 4px;
-  font-size: 27px;
-  font-weight: lighter;
+.collapsed .add-bn {
+  transform: scaleX(0);
+  transition: transform .2s .2s;
+}
+
+.add-bn:hover {
+  box-shadow: 2px 4px 4px rgba(0,0,0,.14), 2px 8px 8px rgba(0,0,0,.28);
+  transition: box-shadow .15s;
+}
+
+.add-bn.disabled {
+  transform: scaleY(0);
+  transition: transform .2s .1s;
+  /* Delay is just to style consistently with .add-bn transition so
+   * we don't have to create a dummy parent just for the different transition.*/
+}
+
+.clear-float {
+  clear: both;
 }
 
 .destination {
-  width: 100%;
   position: relative;
+  width: 100%;
 }
 
 .destination input {
@@ -58,8 +77,12 @@
   font-size: 14px;
 }
 
+.selected {
+  box-shadow: 0 0 8px #05f;
+  z-index: 1;
+}
+
 .map-canvas {
-  width: 100%;
   height: 100%;
 }
 
@@ -81,13 +104,6 @@
   cursor: pointer;
   text-align: center;
   transition: background-color .2s, border-bottom .2s;
-
-  -webkit-touch-callout: none;
-  -webkit-user-select: none;
-  -khtml-user-select: none;
-  -moz-user-select: none;
-  -ms-user-select: none;
-  user-select: none;
 }
 
 .messages.headlines .handle {
@@ -153,7 +169,94 @@
   color: red;
 }
 
-.selected {
-  box-shadow: 0 0 8px #05f;
-  z-index: 1;
+.mini-search {
+  overflow: hidden;
+  vertical-align: middle;
+  transform-origin: 0 0;
+  transition: transform .5s;
+}
+
+.mini-search.collapsed {
+  transform: translateX(-200%);
+  /* keep this in sync with js search disable delay */
+  transition: transform .5s;
+}
+
+.mini-search > * {
+  display: inline-block;
+  margin-left: 1em;
+}
+
+.mini-search .add-bn {
+  float: none;
+  margin-bottom: 12px;
+  transform-origin: 0 0;
+}
+
+.mini-search.collapsed .add-bn {
+  transform: initial;
+}
+
+.mini-search .destination {
+  width: 32em;
+}
+
+.no-select {
+  -webkit-touch-callout: none;
+  -webkit-user-select: none;
+  -khtml-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.timeline {
+  padding: 1em;
+  margin: 0;
+}
+
+.timeline-container {
+  box-shadow: inset -7px 0 7px -7px gray;
+  float: left;
+  height: 100%;
+  overflow-y: auto;
+  width: 25em;
+  transition: width .5s;
+}
+
+.timeline-container.collapsed {
+  overflow: hidden;
+  width: 0;
+  transition: width .5s;
+}
+
+.toggle-timeline {
+  background-color: #f0f0f0;
+  border-radius: 4px 4px 0 0;
+  box-shadow: 0 0 4px gray;
+  cursor: pointer;
+  letter-spacing: .5px;
+  padding: 4px 1em;
+  transform: rotate(90deg) translateY(-50%);
+  transform-origin: 0 50%;
+}
+
+.toggle-timeline:after {
+  content: '▼';
+  font-size: 6pt;
+  padding-left: 1em;
+}
+
+.toggle-timeline.collapsed {
+  background-color: white;
+}
+
+.toggle-timeline.collapsed:after {
+  content: '▲';
+}
+
+.vertical-middle {
+  position: relative;
+  top: 50%;
+  transform: translateY(-50%);
 }
diff --git a/src/strings.js b/src/strings.js
index 0f03161..bdd3a06 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -4,6 +4,10 @@
 
 function getStrings(locale) {
   return {
+    'Add destination': 'Add destination',
+    change: function(object) {
+      return 'Change ' + object.toLowerCase();
+    },
     'Connected to all services.': 'Connected to all services.',
     'Connecting...': 'Connecting...',
     'Destination': 'Destination',
@@ -23,6 +27,8 @@
       return label + ': ' + details;
     },
     'Origin': 'Origin',
+    'Search': 'Search',
+    'Timeline': 'Timeline',
     'Travel Planner': 'Travel Planner',
     'Unknown error': 'Unknown error'
   };
diff --git a/src/travel.js b/src/travel.js
index 1e0a4e0..953f7f3 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -3,19 +3,57 @@
 // license that can be found in the LICENSE file.
 
 var $ = require('./util/jquery');
-
-var Destinations = require('./components/destinations');
-var Messages = require('./components/messages');
-var Message = require('./components/message');
-var vanadiumWrapperDefault = require('./vanadium-wrapper');
-
+var raf = require('raf');
 var defineClass = require('./util/define-class');
 
-var Map = require('./components/map');
-var TravelSync = require('./travelsync');
+var AddButton = require('./components/add-button');
+var DestinationSearch = require('./components/destination-search');
 var Identity = require('./identity');
+var Map = require('./components/map');
+var Messages = require('./components/messages');
+var Message = require('./components/message');
+var Timeline = require('./components/timeline');
+var TravelSync = require('./travelsync');
+
+var vanadiumWrapperDefault = require('./vanadium-wrapper');
 
 var strings = require('./strings').currentLocale;
+var describeDestination = require('./describe-destination');
+
+function bindControlToDestination(control, destination) {
+  function updateOrdinal() {
+    handleDestinationOrdinalUpdate(control, destination);
+  }
+
+  if (destination) {
+    destination.onPlaceChange.add(control.setPlace);
+    destination.onSelect.add(control.select);
+    destination.onDeselect.add(control.deselect);
+    destination.onOrdinalChange.add(updateOrdinal);
+    control.setPlace(destination.getPlace());
+    /* Since these controls are 1:1 with destinations, we don't want to stay in
+     * a state where the control has invalid text but the destination is still
+     * valid; that would be confusing to the user (e.g. abandoned query string
+     * "restaurants" for destination 4 Privet Drive.) */
+    control.onPlaceChange.add(destination.setPlace);
+  }
+
+  updateOrdinal();
+
+  if (destination && destination.isSelected()) {
+    control.select();
+  } else {
+    control.deselect();
+  }
+
+  return destination? function unbind() {
+    destination.onPlaceChange.remove(control.setPlace);
+    destination.onSelect.remove(control.select);
+    destination.onDeselect.remove(control.deselect);
+    destination.onOrdinalChange.remove(updateOrdinal);
+    control.onPlaceChange.remove(destination.setPlace);
+  } : $.noop;
+}
 
 function buildStatusErrorStringMap(statusClass, stringGroup) {
   var dict = {};
@@ -25,14 +63,19 @@
   return dict;
 }
 
+function handleDestinationOrdinalUpdate(control, destination) {
+  control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
+}
+
 var Travel = defineClass({
   publics: {
     addDestination: function() {
       var map = this.map;
 
       var destination = map.addDestination();
-      var control = this.destinations.append();
-      control.bindDestination(destination);
+      var control = this.timeline.append();
+
+      bindControlToDestination(control, destination);
 
       control.setSearchBounds(map.getBounds());
       map.onBoundsChange.add(control.setSearchBounds);
@@ -45,21 +88,32 @@
       });
 
       control.onSearch.add(function(results) {
-        map.showSearchResults(results);
-
         /* There seems to be a bug where if you click a search suggestion (for
          * a query, not a resolved location) in autocomplete, the input box
          * under it gets clicked and focused... I haven't been able to figure
          * out why. */
-         control.focus();
+        control.focus();
+
+        map.showSearchResults(results);
       });
 
-      return control;
+      this.timeline.disableAdd();
+      var oldLast = this.timeline.get(-2);
+      if (oldLast) {
+        this.unbindLastDestinationSearchEvents(oldLast);
+      }
+      this.bindLastDestinationSearchEvents(control);
+
+      this.bindMiniFeedback(destination);
+
+      return {
+        destination: destination,
+        control: control
+      };
     },
 
     error: function (err) {
-      this.messages.push(Message.error(
-        err.message || err.msg || err.toString()));
+      this.messages.push(Message.error(err));
     },
 
     info: function (info, promise) {
@@ -69,6 +123,144 @@
     }
   },
 
+  privates: {
+    /**
+     * Handles destination addition via the mini-UI.
+     */
+    addDestinationMini: function() {
+      this.miniDestinationSearch.clear();
+      this.map.closeActiveInfoWindow();
+
+      var destination = this.addDestination().destination;
+      destination.select();
+      this.miniDestinationSearch.focus();
+      this.miniDestinationSearch.setPlaceholder(strings['Add destination']);
+    },
+
+    bindMiniFeedback: function(destination) {
+      var mf = this.miniFeedback;
+
+      destination.onSelect.add(mf.handleSelect);
+      destination.onDeselect.add(mf.handleDeselect);
+    },
+
+    initMiniFeedback: function() {
+      var self = this;
+
+      //context: destination
+      function handlePlaceChange(place) {
+        self.miniDestinationSearch.setPlace(place);
+        self.miniDestinationSearch.setPlaceholder(
+          strings.change(describeDestination.description(this)));
+      }
+
+      //context: destination.
+      function handleSelect() {
+        handlePlaceChange.call(this, this.getPlace());
+        this.onPlaceChange.add(handlePlaceChange);
+      }
+
+      function handleDeselect() {
+        this.onPlaceChange.remove(handlePlaceChange);
+        if (self.miniDestinationSearch.getPlace()) {
+          self.miniDestinationSearch.clear();
+        }
+        self.miniDestinationSearch.setPlaceholder(strings['Search']);
+      }
+
+      this.miniFeedback = {
+        handleSelect: handleSelect,
+        handleDeselect: handleDeselect,
+        handlePlaceChange: handlePlaceChange
+      };
+    },
+
+    showTimeline: function() {
+      if (this.$timelineContainer.hasClass('collapsed')) {
+        this.$toggleTimeline.removeClass('collapsed');
+        this.$timelineContainer.removeClass('collapsed');
+        this.$minPanel.addClass('collapsed');
+        //disable the control, but wait until offscreen to avoid distraction
+        this.$minPanel.one('transitionend', this.miniDestinationSearch.disable);
+        this.watchMapResizes();
+      }
+    },
+
+    collapseTimeline: function() {
+      if (!this.$timelineContainer.hasClass('collapsed')) {
+        this.$toggleTimeline.addClass('collapsed');
+        this.$timelineContainer.addClass('collapsed');
+        this.$minPanel.removeClass('collapsed');
+        this.miniDestinationSearch.enable();
+        if (!this.miniDestinationSearch.getPlace()) {
+          this.miniDestinationSearch.focus();
+        }
+        this.watchMapResizes();
+      }
+    },
+
+    bindLastDestinationSearchEvents: function(control) {
+      control.onPlaceChange.add(this.handleLastPlaceChange);
+      control.onDeselect.add(this.handleLastPlaceDeselected);
+    },
+
+    unbindLastDestinationSearchEvents: function(control) {
+      control.onPlaceChange.remove(this.handleLastPlaceChange);
+      control.onDeselect.remove(this.handleLastPlaceDeselected);
+    },
+
+    handleLastPlaceChange: function(place) {
+      if (place) {
+        this.timeline.enableAdd();
+      } else {
+        this.timeline.disableAdd();
+      }
+    },
+
+    handleLastPlaceDeselected: function() {
+      var self = this;
+      /* Wait until next frame to allow selection/focus to update; we don't want
+       * to remove a box that has just received focus. */
+      raf(function() {
+        var lastControl = self.timeline.get(-1);
+        var oldLast = lastControl;
+
+        while (!lastControl.getPlace() && !lastControl.isSelected() &&
+            self.timeline.get().length > 1) {
+          self.timeline.remove(-1);
+          self.map.removeDestination(-1);
+          lastControl = self.timeline.get(-1);
+        }
+
+        if (oldLast !== lastControl) {
+          self.bindLastDestinationSearchEvents(lastControl);
+          self.handleLastPlaceChange(lastControl.getPlace());
+        }
+      });
+    },
+
+    /**
+     * The map widget isn't very sensitive to size updates, so we need to
+     * continuously invalidate during animations.
+     */
+    watchMapResizes: function() {
+      var newWidth = this.map.$.width();
+      if (newWidth !== this.mapWidth) {
+        this.widthStable = 0;
+
+        this.mapWidth = newWidth;
+        this.map.invalidateSize();
+        raf(this.watchMapResizes);
+
+      } else if (this.widthStable < 5) {
+        raf(this.watchMapResizes);
+        this.widthStable++;
+      } else {
+        this.mapWidth = null;
+      }
+    }
+  },
+
   init: function (opts) {
     var self = this;
 
@@ -79,19 +271,12 @@
     var maps = map.maps;
 
     var messages = this.messages = new Messages();
-    var destinations = this.destinations = new Destinations(maps);
+    var timeline = this.timeline = new Timeline(maps);
 
     var sync = this.sync = new TravelSync();
 
     var error = this.error;
 
-    map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
-    map.addControls(maps.ControlPosition.LEFT_TOP, destinations.$);
-
-    destinations.onAddClick.add(function() {
-      self.addDestination().focus();
-    });
-
     this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
       .then(function(wrapper) {
         wrapper.onCrash.add(error);
@@ -101,9 +286,6 @@
         return sync.start(identity.mountName, wrapper);
       }).then(function() {
         return strings['Connected to all services.'];
-      }, function(err) {
-        console.error(err);
-        throw err;
       }));
 
     var directionsServiceStatusStrings = buildStatusErrorStringMap(
@@ -116,11 +298,69 @@
       error(message);
     });
 
+    timeline.onAddClick.add(function() {
+      self.addDestination().control.focus();
+    });
+
+    var miniAddButton = this.miniAddButton = new AddButton();
+    var miniDestinationSearch = this.miniDestinationSearch =
+      new DestinationSearch(maps);
+
+    miniAddButton.onClick.add(this.addDestinationMini);
+
+    miniDestinationSearch.setPlaceholder(strings['Search']);
+    miniDestinationSearch.setSearchBounds(map.getBounds());
+    map.onBoundsChange.add(miniDestinationSearch.setSearchBounds);
+
+    miniDestinationSearch.onSearch.add(function(results) {
+      if (results.length > 0) {
+        /* If we've searched for a location via the minibox, any subsequent
+         * map click is probably intended to deselect the destination rather
+         * than pick by clicking. This differs from the timeline behavior since
+         * when we invalidate a timeline location, we delete the destination
+         * place and so must pick a new one. */
+        map.disableLocationSelection();
+      }
+      map.showSearchResults(results);
+    });
+
+    miniDestinationSearch.onPlaceChange.add(function(place) {
+      if (!place) {
+        self.map.enableLocationSelection();
+      }
+    });
+
+    var $miniPanel = this.$minPanel = $('<div>')
+      .addClass('mini-search')
+      .append(miniAddButton.$,
+              miniDestinationSearch.$);
+
+    /* This container lets us collapse the destination panel even though it has
+     * padding, without resorting to transform: scaleX which would
+     * unnecessarily distort the text (which is an effect that is nice for the
+     * add button, so that gets it explicitly). */
+    var $timelineContainer = this.$timelineContainer = $('<div>')
+      .addClass('timeline-container collapsed')
+      .append(timeline.$);
+
+    var $toggleTimeline = this.$toggleTimeline = $('<div>')
+      .addClass('toggle-timeline no-select collapsed')
+      .text(strings['Timeline'])
+      .mouseenter(this.showTimeline)
+      .click(this.collapseTimeline);
+
+    map.addControls(maps.ControlPosition.TOP_CENTER, messages.$);
+    map.addControls(maps.ControlPosition.LEFT_TOP, $miniPanel);
+    map.addControls(maps.ControlPosition.LEFT_CENTER, $toggleTimeline);
+
     var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
-    $domRoot.append(map.$);
+
+    $domRoot.append($timelineContainer, map.$);
+
+    this.initMiniFeedback();
 
     this.addDestination();
-    this.addDestination();
+    miniDestinationSearch.focus();
   }
 });
 
diff --git a/src/util/define-class.js b/src/util/define-class.js
index ba2074b..eede6b6 100644
--- a/src/util/define-class.js
+++ b/src/util/define-class.js
@@ -114,14 +114,20 @@
 };
 
 /**
- * Decorates a member function with a like-signatured function to be called
- * prior to the main invocation.
+ * Decorates a member function with like-signatured functions to be called
+ * before and/or after the main invocation.
  */
-defineClass.decorate = function(context, name, before)  {
+defineClass.decorate = function(context, name, before, after)  {
   var proto = context[name];
   context[name] = function() {
-    before.apply(context, arguments);
-    return proto.apply(context, arguments);
+    if (before) {
+      before.apply(context, arguments);
+    }
+    var ret = proto.apply(context, arguments);
+    if (after) {
+      after.apply(context, arguments);
+    }
+    return ret;
   };
 };
 
diff --git a/src/util/jquery.js b/src/util/jquery.js
index 049a576..4f35251 100644
--- a/src/util/jquery.js
+++ b/src/util/jquery.js
@@ -5,10 +5,13 @@
 var jq = require('jquery');
 var window = require('global/window');
 
+var $;
 if (window.document) {
-  module.exports = jq;
+  $ = jq;
 } else {
   var jsdom = require('jsdom').jsdom;
   window = jsdom().parentWindow;
-  module.exports = jq(window);
-}
\ No newline at end of file
+  $ = jq(window);
+}
+
+module.exports = $;
\ No newline at end of file