Added message log UI

Change-Id: If446e95e301ee218a6aaf49d4f141e6b77236733
diff --git a/src/components/messages.js b/src/components/messages.js
index 6d862a2..0b39920 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -8,42 +8,144 @@
 var message = require('./message');
 
 var Messages = defineClass({
+  statics: {
+    SLIDE_DOWN: 150,
+    TTL: 9000,
+    FADE: 1000,
+    SLIDE_UP: 300,
+    OPEN_CLOSE: 500
+  },
+
   publics: {
+    close: function() {
+      var self = this;
+
+      if (this.isOpen()) {
+        if (this.$messages.children().length) {
+          var scrollOffset =
+            this.$messages.scrollTop() + self.$messages.height();
+
+          this.$messages
+            .addClass('animating')
+            .animate({ height: 0 }, {
+              duration: this.OPEN_CLOSE,
+              progress: function() {
+                self.$messages.scrollTop(
+                  scrollOffset - self.$messages.height());
+              },
+              complete: function() {
+                self.$messages.removeClass('animating');
+                self.$.addClass('headlines');
+                self.$messages.attr('style', null);
+              }
+            });
+        } else {
+          this.$.addClass('headlines');
+        }
+      }
+    },
+
+    isClosed: function() {
+      return this.$.hasClass('headlines') &&
+        !this.$messages.hasClass('animating');
+    },
+
+    isOpen: function() {
+      return !this.$.hasClass('headlines') &&
+        !this.$messages.hasClass('animating');
+    },
+
+    open: function() {
+      var self = this;
+
+      if (!this.isOpen()) {
+        var $animating = this.$.find('.animating');
+        $animating.stop(true);
+        $animating.removeClass('animating');
+        $animating.attr('style', null);
+
+        this.$.removeClass('headlines');
+        if (this.$messages.children().length) {
+          var goalHeight = this.$messages.height();
+          this.$messages
+            .addClass('animating')
+            .height(0)
+            .animate({ height: goalHeight }, {
+              duration: this.OPEN_CLOSE,
+              progress: function() {
+                self.$messages.scrollTop(self.$messages.prop('scrollHeight'));
+              },
+              complete: function() {
+                self.$messages.removeClass('animating');
+                self.$messages.attr('style', null);
+              }
+            });
+        }
+      }
+    },
+
     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();
-        });
+      this.$messages.append(messageObject.$);
+
+      if (this.isOpen()) {
+        this.$messages.scrollTop(this.$messages.prop('scrollHeight'));
+      } else {
+        /*
+         * 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. Furthermore, it won't run unless the
+         * element starts hidden.
+         *
+         * 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.
+         *
+         * It would be best to use CSS animations, but at this time that would
+         * mean sacrificing either auto-height or flow-affecting sliding.
+         */
+        messageObject.$.addClass('animating');
+        messageObject.$
+          .slideDown(this.SLIDE_DOWN)
+          .delay(this.TTL)
+          .animate({ opacity: 0 }, this.FADE)
+          .slideUp(this.SLIDE_UP, function() {
+            messageObject.$
+              .removeClass('animating')
+              .attr('style', null);
+          });
+      }
+    },
+
+    toggle: function() {
+      /* If this were pure CSS, we could just toggle, but we need to do some
+       * JS housekeeping. */
+      if (this.isOpen()) {
+        this.close();
+      } else if (this.isClosed()) {
+        this.open();
+      }
     }
   },
 
   constants: ['$'],
 
   init: function() {
-    this.$ = $('<ul>').addClass('messages');
+    this.$handle = $('<div>')
+      .addClass('handle')
+      .click(this.toggle.bind(this));
+
+    this.$messages = $('<ul>');
+
+    this.$ = $('<div>')
+      .addClass('messages')
+      .addClass('headlines')
+      .append(this.$handle)
+      .append(this.$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/static/index.css b/src/static/index.css
index 416663c..f62f541 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -63,22 +63,77 @@
   height: 100%;
 }
 
-ul.messages {
+.messages {
   width: 30%;
-  min-width: 10em;
+  border: 1px solid #aaa;
+  border-radius: 2px;
+}
+
+.messages.headlines {
+  border: initial;
+}
+
+.messages .handle {
+  background-color: rgba(192, 192, 192, .95);
+  border: 1px solid #aaa;
+  border-radius: 2px;
+  color: #444;
+  cursor: pointer;
+  text-align: center;
+
+  -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 {
+  background-color: rgba(255, 255, 255, .5);
+  border: initial;
+}
+
+.handle:before {
+  content: "=";
+  font-stretch: expanded;
+}
+
+.handle:after {
+  content: "=";
+  font-stretch: expanded;
+}
+
+.messages ul {
+  background-color: rgba(255, 255, 255, .95);
   list-style: none;
+  margin: 0;
+  max-height: 20em;
+  overflow: auto;
+  min-width: 10em;
+  padding: 0;
+  width: 100%;
+}
+
+.messages.headlines ul {
+  background-color: initial;
+  max-height: initial;
 }
 
 .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.headlines li {
+  background-color: rgba(0, 0, 0, .6);
+  border-radius: 4px;
+  color: white;
+  display: none;
+  margin-top: 3px;
+}
+
 .messages li:before {
   font-weight: bold;
 }
diff --git a/test/components/map.js b/test/components/map.js
index 63ea0b8..62f483a 100644
--- a/test/components/map.js
+++ b/test/components/map.js
@@ -16,7 +16,7 @@
     maps: mockMaps
   });
 
-  var $messages = $('.messages', map.$);
+  var $messages = $('.messages ul', map.$);
   t.ok($messages.length, 'message display exists');
   t.equals($messages.children().length, 0, 'message display is empty');
 
diff --git a/test/travel.js b/test/travel.js
index 6999b71..2aca67c 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -30,7 +30,7 @@
     maps: mockMaps
   });
 
-  var $messages = $('.messages');
+  var $messages = $('.messages ul');
   t.ok($messages.length, 'message display exists');
   t.equals($messages.children().length, 0, 'message display is empty');