playground/client: make results log toggle-able

* Results panel is toggle-able and hidden by default.
* Fix broken rendering in Safari.

Close https://github.com/vanadium/issues/issues/425
Close https://github.com/vanadium/issues/issues/426

Change-Id: Ia0556e73306410e0a5b0c3a2cfb501d6d10a020b
diff --git a/client/browser/api/index.js b/client/browser/api/index.js
index c8f74c6..09daa8a 100644
--- a/client/browser/api/index.js
+++ b/client/browser/api/index.js
@@ -88,7 +88,7 @@
     clone.query = { id: encodeURIComponent(options.uuid) };
   }
 
-  if (api.options.debug) {
+  if (api.options.debug || options.debug) {
     clone.query = clone.query || {};
     clone.query.debug = 1;
   }
@@ -207,7 +207,10 @@
 API.prototype.run = function(data, callback) {
   var api = this;
   var uuid = data.uuid;
-  var uri = api.url({ action: 'run' });
+  var uri = api.url({
+        action: 'run',
+        debug: true
+      });
 
   if (api.isPending(uuid)) {
     var message = format('%s is already running');
diff --git a/client/browser/components/bundle/index.js b/client/browser/components/bundle/index.js
index ac85a98..d31995f 100644
--- a/client/browser/components/bundle/index.js
+++ b/client/browser/components/bundle/index.js
@@ -27,6 +27,7 @@
       stop: stop,
       save: save,
       fileChange: fileChange,
+      showResults: showResults
     }
   });
 
@@ -51,6 +52,7 @@
 
     // If running clear previous logs and open the console.
     if (running) {
+      state.results.open.set(true);
       state.results.logs.set(hg.array([]));
       state.results.follow.set(true);
     }
@@ -59,6 +61,10 @@
   return state;
 }
 
+function showResults(state, data) {
+  state.results.open.set(true);
+}
+
 // When a file's contents change via the editor update the state.
 function fileChange(state, data) {
   var current = state.files.get(data.name);
diff --git a/client/browser/components/bundle/render.js b/client/browser/components/bundle/render.js
index 746d1f8..4e547db 100644
--- a/client/browser/components/bundle/render.js
+++ b/client/browser/components/bundle/render.js
@@ -40,8 +40,18 @@
 
 function tabs(state, channels) {
   var files = toArray(state.files);
+  var children = files.map(tab, state);
 
-  return h('.tabs', files.map(tab, state));
+  // .spacer for flex box pushing a.show-results to the far right
+  children.push(h('.spacer'));
+
+  children.push(h('a.show-results', {
+    title: 'Open the results console.',
+    href: '#',
+    'ev-click': click(channels.showResults)
+  }));
+
+  return h('.tabs', children);
 }
 
 function tab(file, index, array) {
diff --git a/client/browser/components/log/render.js b/client/browser/components/log/render.js
index a064fbf..4dd85d6 100644
--- a/client/browser/components/log/render.js
+++ b/client/browser/components/log/render.js
@@ -8,7 +8,7 @@
 
 // This is expected to be called an iterator fn passed to logs.map(render)
 function render(state, index, logs) {
-  var time = moment(state.timestamp).format('MMM D HH:mm:ss SSS');
+  var time = moment(state.timestamp).format('MMM D HH:mm:ss.SSS');
   var stream = state.stream || 'unknown';
 
   return h('.log', {
diff --git a/client/browser/components/results/index.js b/client/browser/components/results/index.js
index 95c287c..44c29fb 100644
--- a/client/browser/components/results/index.js
+++ b/client/browser/components/results/index.js
@@ -14,7 +14,8 @@
     debug: hg.value(false),
     channels: {
       follow: follow,
-      debug: debug
+      debug: debug,
+      toggle: toggle
     }
   });
 
@@ -40,3 +41,9 @@
     state.debug.set(data.debug);
   }
 }
+
+function toggle(state, data) {
+  var current = state.open();
+
+  state.open.set(!current);
+}
diff --git a/client/browser/components/results/render.js b/client/browser/components/results/render.js
index 330917a..731d93e 100644
--- a/client/browser/components/results/render.js
+++ b/client/browser/components/results/render.js
@@ -8,20 +8,21 @@
 var followHook = require('./follow-hook');
 var log = require('../log');
 var format = require('format');
+var debug = require('debug')('components:results:render');
 
 module.exports = render;
 
 function render(state, channels) {
-  return h('.results', [
+  debug('update %o', state);
+
+  channels = channels || state.channels;
+
+  return h('.results', {
+    className: state.open ? 'opened' : 'closed'
+  },
+  [
     hg.partial(controls, state, channels),
-    h('.console', {
-      className: state.debug ? 'debug' : ''
-    }, [
-      h('.scroller', {
-        'ev-scroll': scroll(channels.follow, { scrolling: true }),
-        'follow-console': followHook(state.follow)
-      }, state.logs.map(log.render))
-    ])
+    hg.partial(terminal, state, channels)
   ]);
 }
 
@@ -30,12 +31,28 @@
   var title = format('Toggle debug console output %s.', onOrOff);
   var text = format(' Debug: %s', onOrOff);
 
-  return h('.controls', [
-    'Results',
-    h('a.debug', {
+  return h('.results-controls', [
+    h('a.toggle-display', {
+      href: '#',
+      title: (state.open ? 'Close' : 'Open') + ' the results console.',
+      'ev-click': click(channels.toggle),
+    }),
+    h('.title', 'Results'),
+    h('a.debug-button', {
       href: '#',
       'ev-click': click(channels.debug, { debug: ! state.debug }),
       title: title
-    }, text)
+    }, text),
+  ]);
+}
+
+function terminal(state, channels) {
+  return h('.results-console', {
+    className: state.debug ? 'debug' : ''
+  }, [
+    h('.scroller', {
+      'ev-scroll': scroll(channels.follow, { scrolling: true }),
+      'follow-console': followHook(state.follow)
+    }, state.logs.map(log.render))
   ]);
 }
diff --git a/client/package.json b/client/package.json
index 9185b9b..fdea47f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -38,9 +38,11 @@
     "minimist": "^1.1.1",
     "myth": "^1.4.0",
     "pgbundle": "0.0.1",
-    "rework-inherit": "^0.2.3",
+    "raf": "^2.0.4",
     "rework": "^1.0.1",
+    "rework-inherit": "^0.2.3",
     "run-browser": "^2.0.2",
+    "synthetic-dom-events": "git://github.com/Raynos/synthetic-dom-events",
     "tape": "^3.5.0"
   },
   "repository": {
diff --git a/client/public/icons/chevron-left.svg b/client/public/icons/chevron-left.svg
new file mode 100644
index 0000000..4048deb
--- /dev/null
+++ b/client/public/icons/chevron-left.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
+    <path d="M30.83 14.83L28 12 16 24l12 12 2.83-2.83L21.66 24z" fill="#ffffff" />
+    <path d="M0 0h48v48H0z" fill="none"/>
+</svg>
diff --git a/client/public/icons/chevron-right.svg b/client/public/icons/chevron-right.svg
new file mode 100644
index 0000000..764bff3
--- /dev/null
+++ b/client/public/icons/chevron-right.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
+    <path d="M20 12l-2.83 2.83L26.34 24l-9.17 9.17L20 36l12-12z" fill="#ffffff"/>
+    <path d="M0 0h48v48H0z" fill="none"/>
+</svg>
diff --git a/client/stylesheets/components/results.css b/client/stylesheets/components/results.css
new file mode 100644
index 0000000..c8312d0
--- /dev/null
+++ b/client/stylesheets/components/results.css
@@ -0,0 +1,127 @@
+.results {
+  display: flex;
+  flex-direction: column;
+  background-color: var(--grey-50);
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 35%;
+  right: 0;
+  z-index: 2000;
+  transition: transform ease-in-out 0.3s;
+}
+
+.results.closed {
+  transform: translateX(100%);
+}
+
+.results.opened {
+  transform: translateX(0);
+}
+
+.results-controls {
+  display: flex;
+  background-color: var(--cyan-600);
+  padding: var(--gutter-half) var(--gutter);
+  padding-left: var(--gutter-half);
+  color: var(--white);
+}
+
+.results-controls a.toggle-display {
+  inherit: .icon-base;
+  background-image: url(/icons/chevron-right.svg);
+}
+
+.results-controls a.toggle-display {
+  inherit: .icon-base;
+  margin-right: var(--gutter-half);
+}
+
+.opened .results-controls a.toggle-display {
+  background-image: url(/icons/chevron-right.svg);
+}
+
+.closed .results-controls a.toggle-display {
+  background-image: url(/icons/chevron-left.svg);
+}
+
+.results-controls .title {
+  inherit: .type-body;
+  margin: 0;
+  color: var(--white);
+  /* Push .debug-button to the end. */
+  flex: 1;
+}
+
+.results-controls .debug-button {
+  color: var(--blue-grey-700);
+}
+
+.results-console {
+  flex: 1;
+  position: relative;
+}
+
+.scroller {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  overflow: auto;
+  padding: var(--gutter);
+}
+
+.log {
+  margin-bottom: var(--gutter);
+}
+
+.log .meta {
+  display: flex;
+}
+
+.log .meta .source {
+  flex: 1;
+  font-weight: bold;
+}
+
+.log .meta .source .stream {
+  font-weight: normal;
+}
+
+.log .message {
+  font-family: "Source Code Pro", monospace;
+  padding-left: var(--gutter-half);
+  overflow: hidden;
+  overflow-y: scroll;
+}
+
+.log .message pre {
+  padding: 0;
+  margin: 0;
+  white-space: pre-wrap;       /* CSS 3 */
+  white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+  white-space: -pre-wrap;      /* Opera 4-6 */
+  white-space: -o-pre-wrap;    /* Opera 7 */
+  word-wrap: break-word;
+}
+
+.log.debug pre {
+  color: #00B9F7;
+}
+
+.log.stdout {
+
+}
+
+.log.stderr pre {
+  color: #F03A76;
+}
+
+.log.debug {
+  display: none;
+}
+
+.results-console.debug .log.debug {
+  display: block;
+}
diff --git a/client/stylesheets/icons.css b/client/stylesheets/icons.css
index 5cc16cd..15ce74f 100644
--- a/client/stylesheets/icons.css
+++ b/client/stylesheets/icons.css
@@ -80,8 +80,8 @@
   content: ' ';
   display: inline-block;
   /* matches typography line-height */
-  width: 28px;
-  height: 28px;
+  width: 24px;
+  height: 24px;
   vertical-align: middle;
   background-repeat: no-repeat;
   background-position: center center;
diff --git a/client/stylesheets/index.css b/client/stylesheets/index.css
index 80bea7d..449c579 100644
--- a/client/stylesheets/index.css
+++ b/client/stylesheets/index.css
@@ -9,11 +9,13 @@
 @import "./components/buttons.css";
 @import "./components/header.css";
 @import "./components/footer.css";
+@import "./components/results.css";
 
 body, .playground {
   display: flex;
   min-height: 100vh;
   flex-direction: column;
+  overflow: hidden;
 }
 
 main {
@@ -25,8 +27,8 @@
   flex: 1;
   display: flex;
   flex-wrap: wrap;
-  align-content: center;
   justify-content: center;
+  position: relative;
 }
 
 .bundle .code,
@@ -45,6 +47,10 @@
   background-color: var(--cyan-700);
 }
 
+.bundle .tabs .spacer {
+  flex: 1;
+}
+
 .bundle .tabs .tab {
   padding: calc(var(--gutter-half) - 2px) var(--gutter);
   border-bottom: 4px solid transparent;
@@ -56,6 +62,14 @@
   color: var(--white);
 }
 
+.bundle .tabs a.show-results {
+  inherit: .icon-base;
+  background-image: url(/icons/chevron-left.svg);
+  display: block;
+  margin: var(--gutter-half);
+  margin-right: var(--gutter);
+}
+
 .editors {
   flex: 1;
   display: flex;
@@ -65,13 +79,17 @@
 .editor {
   flex: 1;
   position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
   width: 100%;
   height: 100%;
-  visibility: hidden;
+  display: none;
 }
 
 .editor.active {
-  visibility: visible;
+  display: block;
 }
 
 .ace_editor {
@@ -79,91 +97,6 @@
   height: 100%;
 }
 
-.results {
-  display: flex;
-  flex-direction: column;
-  background-color: var(--grey-50);
-}
-
-.results .controls {
-  background-color: var(--cyan-600);
-  padding: var(--gutter-half) var(--gutter);
-  color: var(--white);
-}
-
-.results .controls a {
-  color: var(--blue-grey-700);
-}
-
-.results .console {
-  flex: 1;
-  position: relative;
-}
-
-.scroller {
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  left: 0;
-  overflow: auto;
-  padding: var(--gutter);
-}
-
-.log {
-  margin-bottom: var(--gutter);
-}
-
-.log .meta {
-  display: flex;
-}
-
-.log .meta .source {
-  flex: 1;
-  font-weight: bold;
-}
-
-.log .meta .source .stream {
-  font-weight: normal;
-}
-
-.log .message {
-  font-family: "Source Code Pro", monospace;
-  padding-left: var(--gutter-half);
-  overflow: hidden;
-  overflow-y: scroll;
-}
-
-.log .message pre {
-  padding: 0;
-  margin: 0;
-  white-space: pre-wrap;       /* CSS 3 */
-  white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
-  white-space: -pre-wrap;      /* Opera 4-6 */
-  white-space: -o-pre-wrap;    /* Opera 7 */
-  word-wrap: break-word;
-}
-
-.log.debug pre {
-  color: #00B9F7;
-}
-
-.log.stdout {
-
-}
-
-.log.stderr pre {
-  color: #F03A76;
-}
-
-.log.debug {
-  display: none;
-}
-
-.console.debug .log.debug {
-  display: block;
-}
-
 .error {
   margin: var(--gutter) auto;
   padding: var(--gutter);
diff --git a/client/test/test-component-results.js b/client/test/test-component-results.js
new file mode 100644
index 0000000..815cc6f
--- /dev/null
+++ b/client/test/test-component-results.js
@@ -0,0 +1,42 @@
+var test = require('tape');
+var results = require('../browser/components/results');
+var raf = require('raf');
+var event = require('synthetic-dom-events');
+var document = require('global/document');
+var hg = require('mercury');
+
+test('results state', function(t) {
+  var state = results();
+
+  t.equal(state.open(), false);
+  t.deepEqual(state.logs(), []);
+  t.end();
+});
+
+// TODO(jasoncampbell): Refactor all the boilerplate here into some simple test
+// helpers and assertion wrappers.
+test('toggle open', function(t) {
+  var div = document.createElement('div');
+  document.body.appendChild(div);
+
+  var state = results();
+  var remove = hg.app(div, state, results.render);
+  var toggle = document.getElementsByClassName('toggle-display')[0];
+
+  t.equal(state.open(), false);
+
+  toggle.dispatchEvent(event('click'));
+
+  raf(function(){
+    var results = document.getElementsByClassName('results')[0];
+
+    t.equal(state.open(), true);
+    t.ok(results.className.match('opened'), 'should have opened class');
+
+    // NOTE: maybe reset state here?
+    document.body.removeChild(div);
+    remove();
+
+    t.end();
+  });
+});