Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static/js/framework/clientmon.js b/static/js/framework/clientmon.js
new file mode 100644
index 0000000..aa6dc0a
--- /dev/null
+++ b/static/js/framework/clientmon.js
@@ -0,0 +1,51 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+(function(window) {
+  'use strict';
+
+  // This code sets up a reporting mechanism for uncaught javascript errors
+  // to the server. It reports at most every THRESHOLD_MS milliseconds and
+  // each report contains error signatures with counts.
+
+  let errBuff = {};
+  let THRESHOLD_MS = 2000;
+
+  function throttle(fn) {
+    let last, timer;
+    return function() {
+      let now = Date.now();
+      if (last && now < last + THRESHOLD_MS) {
+        clearTimeout(timer);
+        timer = setTimeout(function() {
+          last = now;
+          fn.apply();
+        }, THRESHOLD_MS + last - now);
+      } else {
+        last = now;
+        fn.apply();
+      }
+    };
+  }
+  let flushErrs = throttle(function() {
+    let data = {errors: JSON.stringify(errBuff)};
+    CS_doPost('/_/clientmon.do', null, data);
+    errBuff = {};
+  });
+
+  window.addEventListener('error', function(evt) {
+    let signature = evt.message;
+    if (evt.error instanceof Error) {
+      signature += '\n' + evt.error.stack;
+    }
+    if (!errBuff[signature]) {
+      errBuff[signature] = 0;
+    }
+    errBuff[signature] += 1;
+    flushErrs();
+  });
+})(window);
diff --git a/static/js/framework/env.js b/static/js/framework/env.js
new file mode 100644
index 0000000..baf19cb
--- /dev/null
+++ b/static/js/framework/env.js
@@ -0,0 +1,73 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Defines the type of the CS_env Javascript object
+ * provided by the Codesite server.
+ *
+ * This is marked as an externs file so that any variable defined with a
+ * CS.env type will not have its properties renamed.
+ * @externs
+ */
+
+/** Codesite namespace object. */
+var CS = {};
+
+/**
+ * Javascript object holding basic information about the current page.
+ * This is defined as an interface so that we can use CS.env as a Closure
+ * type name, but it will never be implemented; rather, it will be
+ * made available on every page as the global object CS_env (see
+ * codesite/templates/demetrius/header.ezt).
+ *
+ * The type of the CS_env global object will actually be one of
+ * CS.env, CS.project_env, etc. depending on the page
+ * rendered by the server.
+ *
+ * @interface
+ */
+CS.env = function() {};
+
+/**
+ * Like relativeBaseUrl, but a full URL preceded by http://code.google.com
+ * @type {string}
+ */
+CS.env.prototype.absoluteBaseUrl;
+
+/**
+ * Path to versioned static assets (mostly js and css).
+ * @type {string}
+ */
+CS.env.prototype.appVersion;
+
+/**
+ * Request token for the logged-in user, or null for the anonymous user.
+ * @type {?string}
+ */
+CS.env.prototype.token;
+
+/**
+ * Email address of the logged-in user, or null for anon.
+ * @type {?string}
+ */
+CS.env.prototype.loggedInUserEmail;
+
+/**
+ * Url to the logged-in user's profile, or null for anon.
+ * @type {?string}
+ */
+CS.env.prototype.profileUrl;
+
+/**
+ * CS.env specialization for browsing project pages.
+ * @interface
+ * @extends {CS.env}
+ */
+CS.project_env = function() {};
+
+/** @type {string} */
+CS.project_env.prototype.projectName;
diff --git a/static/js/framework/externs.js b/static/js/framework/externs.js
new file mode 100644
index 0000000..a0375a1
--- /dev/null
+++ b/static/js/framework/externs.js
@@ -0,0 +1,25 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/** @type {CS.env} */
+var CS_env;
+
+// Exported functions must be mentioned in this externs file so that JSCompiler
+// will allow exporting functions by writing '_hideID = CS_hideID'.
+var _hideID;
+var _showID;
+var _hideEl;
+var _showEl;
+var _showInstead;
+var _toggleHidden;
+var _toggleCollapse;
+var _CS_dismissCue;
+var _CS_updateProjects;
+var _CP_checkProjectName;
+var _TKR_toggleStar;
+var _TKR_toggleStarLocal;
+var _TKR_syncStarIcons;
diff --git a/static/js/framework/framework-ajax.js b/static/js/framework/framework-ajax.js
new file mode 100644
index 0000000..038c4c3
--- /dev/null
+++ b/static/js/framework/framework-ajax.js
@@ -0,0 +1,153 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+
+/**
+ * @fileoverview AJAX-related helper functions.
+ */
+
+
+var DEBOUNCE_THRESH_MS = 2000;
+
+
+/**
+ * Simple debouncer to handle text input.  Don't try to hit the server
+ * until the user has stopped typing for a few seconds.  E.g.,
+ * var debouncedKeyHandler = debounce(keyHandler);
+ * el.addEventListener('keyup', debouncedKeyHandler);
+ */
+function debounce(func, opt_threshold_ms) {
+  let timeout;
+  return function() {
+    let context = this, args = arguments;
+    let later = function() {
+      timeout = null;
+      func.apply(context, args);
+    };
+    clearTimeout(timeout);
+    timeout = setTimeout(later, opt_threshold_ms || DEBOUNCE_THRESH_MS);
+  };
+}
+
+
+/**
+ * Builds a POST string from a parameter dictionary.
+ * @param {Array|Object} args: parameters to encode. Either an object
+ *   mapping names to values or an Array of doubles containing [key, value].
+ * @return {string} encoded POST data.
+ */
+function CS_postData(args) {
+  let params = [];
+
+  if (args instanceof Array) {
+    for (var key in args) {
+      let inputValue = args[key];
+      let name = inputValue[0];
+      let value = inputValue[1];
+      if (value !== undefined) {
+        params.push(name + '=' + encodeURIComponent(String(value)));
+      }
+    }
+  } else {
+    for (var key in args) {
+      params.push(key + '=' + encodeURIComponent(String(args[key])));
+    }
+  }
+
+  params.push('token=' + encodeURIComponent(window.prpcClient.token));
+
+  return params.join('&');
+}
+
+/**
+ * Helper for an extremely common kind of XHR: a POST with an XHRF token
+ * where we silently ignore server or connectivity errors.  If the token
+ * has expired, get a new one and retry the original request with the new
+ * token.
+ * @param {string} url request destination.
+ * @param {function(event)} callback function to be called
+ *   upon successful completion of the request.
+ * @param {Object} args parameters to encode as POST data.
+ */
+function CS_doPost(url, callback, args) {
+  window.prpcClient.ensureTokenIsValid().then(() => {
+    let xh = XH_XmlHttpCreate();
+    XH_XmlHttpPOST(xh, url, CS_postData(args), callback);
+  });
+}
+
+
+/**
+ * Helper function to strip leading junk characters from a JSON response
+ * and then parse it into a JS constant.
+ *
+ * The reason that "}])'\n" is prepended to the response text is that
+ * it makes it impossible for a hacker to hit one of our JSON servlets
+ * via a <script src="..."> tag and do anything with the result.  Even
+ * though a JSON response is just a constant, it could be passed into
+ * hacker code by tricks such as overriding the array constructor.
+ */
+function CS_parseJSON(xhr) {
+  return JSON.parse(xhr.responseText.substr(5));
+}
+
+
+/**
+ * Promise-based version of CS_parseJSON using the fetch API.
+ *
+ * Sends a GET request to a JSON endpoint then strips the XSSI prefix off
+ * of the response before resolving the promise.
+ *
+ * Args:
+ *   url (string): The URL to fetch.
+ * Returns:
+ *   A promise, resolved when the request returns. Also be sure to call
+ *   .catch() on the promise (or wrap in a try/catch if using async/await)
+ *   if you don't want errors to halt script execution.
+ */
+function CS_fetch(url) {
+  return fetch(url, {credentials: 'same-origin'})
+    .then((res) => res.text())
+    .then((rawResponse) => JSON.parse(rawResponse.substr(5)));
+}
+
+
+/**
+ * After we refresh the form token, we need to actually submit the form.
+ * formToSubmit keeps track of which form the user was trying to submit.
+ */
+var formToSubmit = null;
+
+/**
+ * If the form token that was generated when the page was served has
+ * now expired, then request a refreshed token from the server, and
+ * don't submit the form until after it arrives.
+ */
+function refreshTokens(event, formToken, formTokenPath, tokenExpiresSec) {
+  if (!window.prpcClient.constructor.isTokenExpired(tokenExpiresSec)) {
+    return;
+  }
+
+  formToSubmit = event.target;
+  event.preventDefault();
+  const message = {
+    token: formToken,
+    tokenPath: formTokenPath,
+  };
+  const refreshTokenPromise = window.prpcClient.call(
+    'monorail.Sitewide', 'RefreshToken', message);
+
+  refreshTokenPromise.then((freshToken) => {
+    let tokenFields = document.querySelectorAll('input[name=token]');
+    for (let i = 0; i < tokenFields.length; ++i) {
+      tokenFields[i].value = freshToken.token;
+    }
+    if (formToSubmit) {
+      formToSubmit.submit();
+    }
+  });
+}
diff --git a/static/js/framework/framework-ajax_test.js b/static/js/framework/framework-ajax_test.js
new file mode 100644
index 0000000..c5218d3
--- /dev/null
+++ b/static/js/framework/framework-ajax_test.js
@@ -0,0 +1,37 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Tests for framework-ajax.js.
+ */
+
+var CS_env;
+
+function setUp() {
+  CS_env = {'token': 'd34db33f'};
+}
+
+function testPostData() {
+  assertEquals(
+    'token=d34db33f',
+    CS_postData({}));
+  assertEquals(
+    'token=d34db33f',
+    CS_postData({}, true));
+  assertEquals(
+    '',
+    CS_postData({}, false));
+  assertEquals(
+    'a=5&b=foo&token=d34db33f',
+    CS_postData({a: 5, b: 'foo'}));
+
+  let unescaped = {};
+  unescaped['f oo?'] = 'b&ar';
+  assertEquals(
+    'f%20oo%3F=b%26ar',
+    CS_postData(unescaped, false));
+}
diff --git a/static/js/framework/framework-cues.js b/static/js/framework/framework-cues.js
new file mode 100644
index 0000000..2c620a1
--- /dev/null
+++ b/static/js/framework/framework-cues.js
@@ -0,0 +1,38 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Simple functions for dismissible on-page help ("cues").
+ */
+
+/**
+ * Dimisses the cue.  This both updates the DOM and hits the server to
+ * record the fact that the user has dismissed it, so that it won't
+ * be shown again.
+ *
+ * If no security token is present, only the DOM is updated and
+ * nothing is recorded on the server.
+ *
+ * @param {string} cueId The identifier of the cue to hide.
+ * @return {boolean} false to cancel any event.
+ */
+function CS_dismissCue(cueId) {
+  let cueElements = document.querySelectorAll('.cue');
+  for (let i = 0; i < cueElements.length; ++i) {
+    cueElements[i].style.display = 'none';
+  }
+
+  if (CS_env.token) {
+    window.prpcClient.call(
+      'monorail.Users', 'SetUserPrefs',
+      {prefs: [{name: cueId, value: 'true'}]});
+  }
+  return false;
+}
+
+// Exports
+_CS_dismissCue = CS_dismissCue;
diff --git a/static/js/framework/framework-display.js b/static/js/framework/framework-display.js
new file mode 100644
index 0000000..9213e82
--- /dev/null
+++ b/static/js/framework/framework-display.js
@@ -0,0 +1,191 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by the Project Hosting to control the display of
+ * elements on the page, rollovers, and popup menus.
+ *
+ * Most of these functions are extracted from dit-display.js
+ */
+
+
+/**
+ * Hide the HTML element with the given ID.
+ * @param {string} id The HTML element ID.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_hideID(id) {
+  $(id).style.display = 'none';
+  return false;
+}
+
+
+/**
+ * Show the HTML element with the given ID.
+ * @param {string} id The HTML element ID.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_showID(id) {
+  $(id).style.display = '';
+  return false;
+}
+
+
+/**
+ * Hide the given HTML element.
+ * @param {Element} el The HTML element.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_hideEl(el) {
+  el.style.display = 'none';
+  return false;
+}
+
+
+/**
+ * Show the given HTML element.
+ * @param {Element} el The HTML element.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_showEl(el) {
+  el.style.display = '';
+  return false;
+}
+
+
+/**
+ * Show one element instead of another.  That is to say, show a new element and
+ * hide an old one.  Usually the element is the element that the user clicked
+ * on with the intention of "expanding it" to access the new element.
+ * @param {string} newID The ID of the HTML element to show.
+ * @param {Element} oldEl The HTML element to hide.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_showInstead(newID, oldEl) {
+  $(newID).style.display = '';
+  oldEl.style.display = 'none';
+  return false;
+}
+
+/**
+ * Toggle the open/closed state of a section of the page.  As a result, CSS
+ * rules will make certain elements displayed and other elements hidden.  The
+ * section is some HTML element that encloses the element that the user clicked
+ * on.
+ * @param {Element} el The element that the user clicked on.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_toggleHidden(el) {
+  while (el) {
+    if (el.classList.contains('closed')) {
+      el.classList.remove('closed');
+      el.classList.add('opened');
+      return false;
+    }
+    if (el.classList.contains('opened')) {
+      el.classList.remove('opened');
+      el.classList.add('closed');
+      return false;
+    }
+    el = el.parentNode;
+  }
+}
+
+
+/**
+ * Toggle the expand/collapse state of a section of the page.  As a result, CSS
+ * rules will make certain elements displayed and other elements hidden.  The
+ * section is some HTML element that encloses the element that the user clicked
+ * on.
+ * TODO(jrobbins): eliminate redundancy with function above.
+ * @param {Element} el The element that the user clicked on.
+ * @return {boolean} Always returns false to cancel the browser event
+ *     if used as an event handler.
+ */
+function CS_toggleCollapse(el) {
+  while (el) {
+    if (el.classList.contains('collapse')) {
+      el.classList.remove('collapse');
+      el.classList.add('expand');
+      return false;
+    }
+    if (el.classList.contains('expand')) {
+      el.classList.remove('expand');
+      el.classList.add('collapse');
+      return false;
+    }
+    el = el.parentNode;
+  }
+}
+
+
+/**
+ * Register a function for mouse clicks on the results table.  We
+ * listen on the table to avoid adding 1000 individual listeners on
+ * the cells.  This is needed because some browsers (now including
+ * Chrome) do not generate click events for mouse buttons other than
+ * the primary mouse button.  Chrome and Firefox generate auxclick
+ * events, but Edge does not.
+ */
+
+function CS_addClickListener(tableEl, handler) {
+  function maybeClick(event) {
+    const target = getTargetFromEvent(event);
+
+    const inLink = target.tagName == 'A' || target.parentNode.tagName == 'A';
+
+    if (inLink && !target.classList.contains('computehref')) {
+      // The <a> elements already have the correct hrefs.
+      return;
+    }
+    if (event.button == 2) {
+      // User is trying to open a context menu, not trying to navigate.
+      return;
+    }
+
+    let td = target;
+    while (td && td.tagName != 'TD' && td.tagName != 'TH') {
+      td = td.parentNode;
+    }
+    if (td.classList.contains('rowwidgets')) {
+      // User clicked on a checkbox.
+      return;
+    }
+    // User clicked on an issue ID link or text or cell.
+    event.preventDefault();
+    handler(event);
+  }
+  tableEl.addEventListener('click', maybeClick);
+  tableEl.addEventListener('auxclick', maybeClick);
+}
+
+function getTargetFromEvent(event) {
+  let target = event.target || event.srcElement;
+  if (target.shadowRoot) {
+  // Find the element within the shadowDOM.
+    const path = event.path || event.composedPath();
+    target = path[0];
+  }
+  return target;
+}
+
+
+// Exports
+_hideID = CS_hideID;
+_showID = CS_showID;
+_hideEl = CS_hideEl;
+_showEl = CS_showEl;
+_showInstead = CS_showInstead;
+_toggleHidden = CS_toggleHidden;
+_toggleCollapse = CS_toggleCollapse;
+_addClickListener = CS_addClickListener;
diff --git a/static/js/framework/framework-menu.js b/static/js/framework/framework-menu.js
new file mode 100644
index 0000000..35bbebc
--- /dev/null
+++ b/static/js/framework/framework-menu.js
@@ -0,0 +1,566 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview This file represents a standalone, reusable drop down menu
+ * widget that can be attached to any element on a given page. It supports
+ * multiple instances of the widget on a page. It has no dependencies. Usage
+ * is as simple as creating a new Menu object and supplying it with a target
+ * element.
+ */
+
+/**
+ * The entry point and constructor for the Menu object. Creating
+ * a valid instance of this object will insert a drop down menu
+ * near the element supplied as the target, attach all the necessary
+ * events and insert the necessary elements on the page.
+ *
+ * @param {Element} target the target element on the page to which
+ *     the drop down menu will be placed near.
+ * @param {Function=} opt_onShow function to execute every time the
+ *     menu is made visible, most likely through a click on the target.
+ * @constructor
+ */
+var Menu = function(target, opt_onShow) {
+  this.iid = Menu.instance.length;
+  Menu.instance[this.iid] = this;
+  this.target = target;
+  this.onShow = opt_onShow || null;
+
+  // An optional trigger element on the page that can be used to trigger
+  // the drop-down. Currently hard-coded to be the same as the target element.
+  this.trigger = target;
+  this.items = [];
+  this.onOpenEvents = [];
+  this.menu = this.createElement('div', 'menuDiv instance' + this.iid);
+  this.targetId = this.target.getAttribute('id');
+  let menuId = (this.targetId != null) ?
+    'menuDiv-' + this.targetId : 'menuDiv-instance' + this.iid;
+  this.menu.setAttribute('id', menuId);
+  this.menu.role = 'listbox';
+  this.hide();
+  this.addCategory('default');
+  this.addEvent(this.trigger, 'click', this.toggle.bind(this));
+  this.addEvent(window, 'resize', this.adjustSizeAndLocation.bind(this));
+
+  // Hide the menu if a user clicks outside the menu widget
+  this.addEvent(document, 'click', this.hide.bind(this));
+  this.addEvent(this.menu, 'click', this.stopPropagation());
+  this.addEvent(this.trigger, 'click', this.stopPropagation());
+};
+
+// A reference to the element or node that the drop down
+// will appear next to
+Menu.prototype.target = null;
+
+// Element ID of the target. ID will be assigned to the newly created
+// menu div based on the target ID. A default ID will be
+// assigned If there is no ID on the target.
+Menu.prototype.targetId = null;
+
+/**
+ * A reference to the element or node that will trigger
+ * the drop down to appear. If not specified, this value
+ * will be the same as <Menu Instance>.target
+ * @type {Element}
+ */
+Menu.prototype.trigger = null;
+
+// A reference to the event type that will "open" the
+// menu div. By default this is the (on)click method.
+Menu.prototype.triggerType = null;
+
+// A reference to the element that will appear when the
+// trigger is clicked.
+Menu.prototype.menu = null;
+
+/**
+ * Function to execute every time the menu is made shown.
+ * @type {Function}
+ */
+Menu.prototype.onShow = null;
+
+// A list of category divs. By default these categories
+// are set to display none until at least one element
+// is placed within them.
+Menu.prototype.categories = null;
+
+// An id used to track timed intervals
+Menu.prototype.thread = -1;
+
+// The static instance id (iid) denoting which menu in the
+// list of Menu.instance items is this instantiated object.
+Menu.prototype.iid = -1;
+
+// A counter to indicate the number of items added with
+// addItem(). After 5 items, a height is set on the menu
+// and a scroll bar will appear.
+Menu.prototype.items = null;
+
+// A flag to detect whether or not a scroll bar has been added
+Menu.prototype.scrolls = false;
+
+// onOpen event handlers; each function in this list will
+// be executed and passed the executing instance as a
+// parameter before the menu is to be displayed.
+Menu.prototype.onOpenEvents = null;
+
+/**
+ * An extended short-cut for document.createElement(); this
+ * method allows the creation of an element, the assignment
+ * of one or more class names and the ability to set the
+ * content of the created element all with one function call.
+ * @param {string} element name of the element to create. Examples would
+ *     be 'div' or 'a'.
+ * @param {string} opt_className an optional string to assign to the
+ *     newly created element's className property.
+ * @param {string|Element} opt_content either a snippet of HTML or a HTML
+ *     element that is to be appended to the newly created element.
+ * @return {Element} a reference to the newly created element.
+ */
+Menu.prototype.createElement = function(element, opt_className, opt_content) {
+  let div = document.createElement(element);
+  div.className = opt_className;
+  if (opt_content) {
+    this.append(opt_content, div);
+  }
+  return div;
+};
+
+/**
+ * Uses a fairly browser agnostic approach to applying a callback to
+ * an element on the page.
+ *
+ * @param {Element|EventTarget} element a reference to an element on the page to
+ *     which to attach and event.
+ * @param {string} eventType a browser compatible event type as a string
+ *     without the sometimes assumed on- prefix. Examples: 'click',
+ *     'mousedown', 'mouseover', etc...
+ * @param {Function} callback a function reference to invoke when the
+ *     the event occurs.
+ */
+Menu.prototype.addEvent = function(element, eventType, callback) {
+  if (element.addEventListener) {
+    element.addEventListener(eventType, callback, false);
+  } else {
+    try {
+      element.attachEvent('on' + eventType, callback);
+    } catch (e) {
+      element['on' + eventType] = callback;
+    }
+  }
+};
+
+/**
+ * Similar to addEvent, this provides a specialied handler for onOpen
+ * events that apply to this instance of the Menu class. The supplied
+ * callbacks are appended to an internal array and called in order
+ * every time the menu is opened. The array can be accessed via
+ * menuInstance.onOpenEvents.
+ */
+Menu.prototype.addOnOpen = function(eventCallback) {
+  let eventIndex = this.onOpenEvents.length;
+  this.onOpenEvents.push(eventCallback);
+  return eventIndex;
+};
+
+/**
+ * This method will create a div with the classes .menuCategory and the
+ * name of the category as supplied in the first parameter. It then, if
+ * a title is supplied, creates a title div and appends it as well. The
+ * optional title is styled with the .categoryTitle and category name
+ * class.
+ *
+ * Categories are stored within the menu object instance for programmatic
+ * manipulation in the array, menuInstance.categories. Note also that this
+ * array is doubly linked insofar as that the category div can be accessed
+ * via it's index in the array as well as by instance.categories[category]
+ * where category is the string name supplied when creating the category.
+ *
+ * @param {string} category the string name used to create the category;
+ *     used as both a class name and a key into the internal array. It
+ *     must be a valid JavaScript variable name.
+ * @param {string|Element} opt_title this optional field is used to visibly
+ *     denote the category title. It can be either HTML or an element.
+ * @return {Element} the newly created div.
+ */
+Menu.prototype.addCategory = function(category, opt_title) {
+  this.categories = this.categories || [];
+  let categoryDiv = this.createElement('div', 'menuCategory ' + category);
+  categoryDiv._categoryName = category;
+  if (opt_title) {
+    let categoryTitle = this.createElement('b', 'categoryTitle ' +
+          category, opt_title);
+    categoryTitle.style.display = 'block';
+    this.append(categoryTitle);
+    categoryDiv._categoryTitle = categoryTitle;
+  }
+  this.append(categoryDiv);
+  this.categories[this.categories.length] = this.categories[category] =
+      categoryDiv;
+
+  return categoryDiv;
+};
+
+/**
+ * This method removes the contents of a given category but does not
+ * remove the category itself.
+ */
+Menu.prototype.emptyCategory = function(category) {
+  if (!this.categories[category]) {
+    return;
+  }
+  let div = this.categories[category];
+  for (let i = div.childNodes.length - 1; i >= 0; i--) {
+    div.removeChild(div.childNodes[i]);
+  }
+};
+
+/**
+ * This function is the most drastic of the cleansing functions; it removes
+ * all categories and all menu items and all HTML snippets that have been
+ * added to this instance of the Menu class.
+ */
+Menu.prototype.clear = function() {
+  for (var i = 0; i < this.categories.length; i++) {
+    // Prevent memory leaks
+    this.categories[this.categories[i]._categoryName] = null;
+  }
+  this.items.splice(0, this.items.length);
+  this.categories.splice(0, this.categories.length);
+  this.categories = [];
+  this.items = [];
+  for (var i = this.menu.childNodes.length - 1; i >= 0; i--) {
+    this.menu.removeChild(this.menu.childNodes[i]);
+  }
+};
+
+/**
+ * Passed an instance of a menu item, it will be removed from the menu
+ * object, including any residual array links and possible memory leaks.
+ * @param {Element} item a reference to the menu item to remove.
+ * @return {Element} returns the item removed.
+ */
+Menu.prototype.removeItem = function(item) {
+  let result = null;
+  for (let i = 0; i < this.items.length; i++) {
+    if (this.items[i] == item) {
+      result = this.items[i];
+      this.items.splice(i, 1);
+    }
+    // Renumber
+    this.items[i].item._index = i;
+  }
+  return result;
+};
+
+/**
+ * Removes a category from the menu element and all of its children thus
+ * allowing the Element to be collected by the browsers VM.
+ * @param {string} category the name of the category to retrieve and remove.
+ */
+Menu.prototype.removeCategory = function(category) {
+  let div = this.categories[category];
+  if (!div || !div.parentNode) {
+    return;
+  }
+  if (div._categoryTitle) {
+    div._categoryTitle.parentNode.removeChild(div._categoryTitle);
+  }
+  div.parentNode.removeChild(div);
+  for (var i = 0; i < this.categories.length; i++) {
+    if (this.categories[i] === div) {
+      this.categories[this.categories[i]._categoryName] = null;
+      this.categories.splice(i, 1);
+      return;
+    }
+  }
+  for (var i = 0; i < div.childNodes.length; i++) {
+    if (div.childNodes[i]._index) {
+      this.items.splice(div.childNodes[i]._index, 1);
+    } else {
+      this.removeItem(div.childNodes[i]);
+    }
+  }
+};
+
+/**
+ * This heart of the menu population scheme, the addItem function creates
+ * a combination of elements that visually form up a menu item. If no
+ * category is supplied, the default category is used. The menu item is
+ * an <a> tag with the class .menuItem. The menu item is directly styled
+ * as a block element. Other than that, all styling should be done via a
+ * external CSS definition.
+ *
+ * @param {string|Element} html_or_element a string of HTML text or a
+ *     HTML element denoting the contents of the menu item.
+ * @param {string} opt_href the href of the menu item link. This is
+ *     the most direct way of defining the menu items function.
+ *     [Default: '#'].
+ * @param {string} opt_category the category string name of the category
+ *     to append the menu item to. If the category doesn't exist, one will
+ *     be created. [Default: 'default'].
+ * @param {string} opt_title used when creating a new category and is
+ *     otherwise ignored completely. It is also ignored when supplied if
+ *     the named category already exists.
+ * @return {Element} returns the element that was created.
+ */
+Menu.prototype.addItem = function(html_or_element, opt_href, opt_category,
+  opt_title) {
+  let category = opt_category ? (this.categories[opt_category] ||
+                                 this.addCategory(opt_category, opt_title)) :
+    this.categories['default'];
+  let menuHref = (opt_href == undefined ? '#' : opt_href);
+  let menuItem = undefined;
+  if (menuHref) {
+    menuItem = this.createElement('a', 'menuItem', html_or_element);
+  } else {
+    menuItem = this.createElement('span', 'menuText', html_or_element);
+  }
+  let itemText = typeof html_or_element == 'string' ? html_or_element :
+    html_or_element.textContent || 'ERROR';
+
+  menuItem.style.display = 'block';
+  if (menuHref) {
+    menuItem.setAttribute('href', menuHref);
+  }
+  menuItem._index = this.items.length;
+  menuItem.role = 'option';
+  this.append(menuItem, category);
+  this.items[this.items.length] = {item: menuItem, text: itemText};
+
+  return menuItem;
+};
+
+/**
+ * Adds a visual HTML separator to the menu, optionally creating a
+ * category as per addItem(). See above.
+ * @param {string} opt_category the category string name of the category
+ *     to append the menu item to. If the category doesn't exist, one will
+ *     be created. [Default: 'default'].
+ * @param {string} opt_title used when creating a new category and is
+ *     otherwise ignored completely. It is also ignored when supplied if
+ *     the named category already exists.
+ */
+Menu.prototype.addSeparator = function(opt_category, opt_title) {
+  let category = opt_category ? (this.categories[opt_category] ||
+                                 this.addCategory(opt_category, opt_title)) :
+    this.categories['default'];
+  let hr = this.createElement('hr', 'menuSeparator');
+  this.append(hr, category);
+};
+
+/**
+ * This method performs all the dirty work of positioning the menu. It is
+ * responsible for dynamic sizing, insertion and deletion of scroll bars
+ * and calculation of offscreen width considerations.
+ */
+Menu.prototype.adjustSizeAndLocation = function() {
+  let style = this.menu.style;
+  style.position = 'absolute';
+
+  let firstCategory = null;
+  for (let i = 0; i < this.categories.length; i++) {
+    this.categories[i].className = this.categories[i].className.
+      replace(/ first/, '');
+    if (this.categories[i].childNodes.length == 0) {
+      this.categories[i].style.display = 'none';
+    } else {
+      this.categories[i].style.display = '';
+      if (!firstCategory) {
+        firstCategory = this.categories[i];
+        firstCategory.className += ' first';
+      }
+    }
+  }
+
+  let alreadyVisible = style.display != 'none' &&
+      style.visibility != 'hidden';
+  let docElemWidth = document.documentElement.clientWidth;
+  let docElemHeight = document.documentElement.clientHeight;
+  let pageSize = {
+    w: (window.innerWidth || docElemWidth && docElemWidth > 0 ?
+      docElemWidth : document.body.clientWidth) || 1,
+    h: (window.innerHeight || docElemHeight && docElemHeight > 0 ?
+      docElemHeight : document.body.clientHeight) || 1,
+  };
+  let targetPos = this.find(this.target);
+  let targetSize = {w: this.target.offsetWidth,
+    h: this.target.offsetHeight};
+  let menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
+
+  if (!alreadyVisible) {
+    let oldVisibility = style.visibility;
+    let oldDisplay = style.display;
+    style.visibility = 'hidden';
+    style.display = '';
+    style.height = '';
+    style.width = '';
+    menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
+    style.display = oldDisplay;
+    style.visibility = oldVisibility;
+  }
+
+  let addScroll = (this.menu.offsetHeight / pageSize.h) > 0.8;
+  if (addScroll) {
+    menuSize.h = parseInt((pageSize.h * 0.8), 10);
+    style.height = menuSize.h + 'px';
+    style.overflowX = 'hidden';
+    style.overflowY = 'auto';
+  } else {
+    style.height = style.overflowY = style.overflowX = '';
+  }
+
+  style.top = (targetPos.y + targetSize.h) + 'px';
+  style.left = targetPos.x + 'px';
+
+  if (menuSize.w < 175) {
+    style.width = '175px';
+  }
+
+  if (addScroll) {
+    style.width = parseInt(style.width, 10) + 13 + 'px';
+  }
+
+  if ((targetPos.x + menuSize.w) > pageSize.w) {
+    style.left = targetPos.x - (menuSize.w - targetSize.w) + 'px';
+  }
+};
+
+
+/**
+ * This function is used heavily, internally. It appends text
+ * or the supplied element via appendChild(). If
+ * the opt_target variable is present, the supplied element will be
+ * the container rather than the menu div for this instance.
+ *
+ * @param {string|Element} text_or_element the html or element to insert
+ *     into opt_target.
+ * @param {Element} opt_target the target element it should be appended to.
+ *
+ */
+Menu.prototype.append = function(text_or_element, opt_target) {
+  let element = opt_target || this.menu;
+  if (typeof opt_target == 'string' && this.categories[opt_target]) {
+    element = this.categories[opt_target];
+  }
+  if (typeof text_or_element == 'string') {
+    element.textContent += text_or_element;
+  } else {
+    element.appendChild(text_or_element);
+  }
+};
+
+/**
+ * Displays the menu (such as upon mouseover).
+ */
+Menu.prototype.over = function() {
+  if (this.menu.style.display != 'none') {
+    this.show();
+  }
+  if (this.thread != -1) {
+    clearTimeout(this.thread);
+    this.thread = -1;
+  }
+};
+
+/**
+ * Hides the menu (such as upon mouseout).
+ */
+Menu.prototype.out = function() {
+  if (this.thread != -1) {
+    clearTimeout(this.thread);
+    this.thread = -1;
+  }
+  this.thread = setTimeout(this.hide.bind(this), 400);
+};
+
+/**
+ * Stops event propagation.
+ */
+Menu.prototype.stopPropagation = function() {
+  return (function(e) {
+    if (!e) {
+      e = window.event;
+    }
+    e.cancelBubble = true;
+    if (e.stopPropagation) {
+      e.stopPropagation();
+    }
+  });
+};
+
+/**
+ * Toggles the menu between hide/show.
+ */
+Menu.prototype.toggle = function(event) {
+  event.preventDefault();
+  if (this.menu.style.display == 'none') {
+    this.show();
+  } else {
+    this.hide();
+  }
+};
+
+/**
+ * Makes the menu visible, then calls the user-supplied onShow callback.
+ */
+Menu.prototype.show = function() {
+  if (this.menu.style.display != '') {
+    for (var i = 0; i < this.onOpenEvents.length; i++) {
+      this.onOpenEvents[i].call(null, this);
+    }
+
+    // Invisibly show it first
+    this.menu.style.visibility = 'hidden';
+    this.menu.style.display = '';
+    this.adjustSizeAndLocation();
+    if (this.trigger.nodeName && this.trigger.nodeName == 'A') {
+      this.trigger.blur();
+    }
+    this.menu.style.visibility = 'visible';
+
+    // Hide other menus
+    for (var i = 0; i < Menu.instance.length; i++) {
+      let menuInstance = Menu.instance[i];
+      if (menuInstance != this) {
+        menuInstance.hide();
+      }
+    }
+
+    if (this.onShow) {
+      this.onShow();
+    }
+  }
+};
+
+/**
+ * Makes the menu invisible.
+ */
+Menu.prototype.hide = function() {
+  this.menu.style.display = 'none';
+};
+
+Menu.prototype.find = function(element) {
+  let curleft = 0, curtop = 0;
+  if (element.offsetParent) {
+    do {
+      curleft += element.offsetLeft;
+      curtop += element.offsetTop;
+    }
+    while ((element = element.offsetParent) && (element.style &&
+          element.style.position != 'relative' &&
+          element.style.position != 'absolute'));
+  }
+  return {x: curleft, y: curtop};
+};
+
+/**
+ * A static array of object instances for global reference.
+ * @type {Array.<Menu>}
+ */
+Menu.instance = [];
diff --git a/static/js/framework/framework-myhotlists.js b/static/js/framework/framework-myhotlists.js
new file mode 100644
index 0000000..6459090
--- /dev/null
+++ b/static/js/framework/framework-myhotlists.js
@@ -0,0 +1,109 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview This file initializes the "My Hotlists" drop down menu in the
+ *     user bar. It utilizes the menu widget defined in framework-menu.js.
+ */
+
+/** @type {Menu} */
+var myhotlists;
+
+(function() {
+  var target = document.getElementById('hotlists-dropdown');
+
+  if (!target) {
+    return;
+  }
+
+  myhotlists = new Menu(target, function() {});
+
+  myhotlists.addEvent(window, 'load', CS_updateHotlists);
+  myhotlists.addOnOpen(CS_updateHotlists);
+  myhotlists.addEvent(window, 'load', function() {
+    document.body.appendChild(myhotlists.menu);
+  });
+})();
+
+
+/**
+ * Grabs the list of logged in user's hotlists to populate the "My Hotlists"
+ * drop down menu.
+ */
+async function CS_updateHotlists() {
+  if (!myhotlists) return;
+
+  if (!window.CS_env.loggedInUserEmail) {
+    myhotlists.clear();
+    myhotlists.addItem('sign in to see your hotlists',
+                       window.CS_env.login_url,
+                       'controls');
+    return;
+  }
+
+  const ownedHotlistsMessage = {
+    user: {
+      display_name: window.CS_env.loggedInUserEmail,
+    }};
+
+  const responses = await Promise.all([
+    window.prpcClient.call(
+      'monorail.Features', 'ListHotlistsByUser', ownedHotlistsMessage),
+    window.prpcClient.call(
+      'monorail.Features', 'ListStarredHotlists', {}),
+    window.prpcClient.call(
+      'monorail.Features', 'ListRecentlyVisitedHotlists', {}),
+  ]);
+  const ownedHotlists = responses[0];
+  const starredHotlists = responses[1];
+  const visitedHotlists = responses[2];
+
+  myhotlists.clear();
+
+  const sortByName = (hotlist1, hotlist2) => {
+    hotlist1.name.localeCompare(hotlist2.name);
+  };
+
+  if (ownedHotlists.hotlists) {
+    ownedHotlists.hotlists.sort(sortByName);
+    ownedHotlists.hotlists.forEach(hotlist => {
+      const name = hotlist.name;
+      const userId = hotlist.ownerRef.userId;
+      const url = `/u/${userId}/hotlists/${name}`;
+      myhotlists.addItem(name, url, 'hotlists', 'Hotlists');
+    });
+  }
+
+  if (starredHotlists.hotlists) {
+    myhotlists.addSeparator();
+    starredHotlists.hotlists.sort(sortByName);
+    starredHotlists.hotlists.forEach(hotlist => {
+      const name = hotlist.name;
+      const userId = hotlist.ownerRef.userId;
+      const url = `/u/${userId}/hotlists/${name}`;
+      myhotlists.addItem(name, url, 'starred_hotlists', 'Starred Hotlists');
+    });
+  }
+
+  if (visitedHotlists.hotlists) {
+    myhotlists.addSeparator();
+    visitedHotlists.hotlists.sort(sortByName);
+    visitedHotlists.hotlists.forEach(hotlist => {
+      const name = hotlist.name;
+      const userId = hotlist.ownerRef.userId;
+      const url = `/u/${userId}/hotlists/${name}`;
+      myhotlists.addItem(
+          name, url, 'visited_hotlists', 'Recently Visited Hotlists');
+    });
+  }
+
+  myhotlists.addSeparator();
+  myhotlists.addItem(
+      'All hotlists', `/u/${window.CS_env.loggedInUserEmail}/hotlists`,
+      'controls');
+  myhotlists.addItem('Create hotlist', '/hosting/createHotlist', 'controls');
+}
diff --git a/static/js/framework/framework-stars.js b/static/js/framework/framework-stars.js
new file mode 100644
index 0000000..946264e
--- /dev/null
+++ b/static/js/framework/framework-stars.js
@@ -0,0 +1,114 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that support setting and showing
+ * stars throughout Monorail.
+ */
+
+
+/**
+ * The character to display when the user has starred an issue.
+ */
+var TKR_STAR_ON = '\u2605';
+
+
+/**
+ * The character to display when the user has not starred an issue.
+ */
+var TKR_STAR_OFF = '\u2606';
+
+
+/**
+ * Function to toggle the star on an issue.  Does both an update of the
+ * DOM and hit the server to record the star.
+ *
+ * @param {Element} el The star <a> element.
+ * @param {String} projectName name of the project to be starred, or name of
+ *                 the project containing the issue to be starred.
+ * @param {Integer} localId number of the issue to be starred.
+ * @param {String} projectName number of the user to be starred.
+ */
+function TKR_toggleStar(el, projectName, localId, userId, hotlistId) {
+  const starred = (el.textContent.trim() == TKR_STAR_OFF);
+  TKR_toggleStarLocal(el);
+
+  const starRequestMessage = {starred: Boolean(starred)};
+  if (userId) {
+    starRequestMessage.user_ref = {user_id: userId};
+    window.prpcClient.call('monorail.Users', 'StarUser', starRequestMessage);
+  } else if (projectName && localId) {
+    starRequestMessage.issue_ref = {
+      project_name: projectName,
+      local_id: localId,
+    };
+    window.prpcClient.call('monorail.Issues', 'StarIssue', starRequestMessage);
+  } else if (projectName) {
+    starRequestMessage.project_name = projectName;
+    window.prpcClient.call(
+      'monorail.Projects', 'StarProject', starRequestMessage);
+  } else if (hotlistId) {
+    starRequestMessage.hotlist_ref = {hotlist_id: hotlistId};
+    window.prpcClient.call(
+      'monorail.Features', 'StarHotlist', starRequestMessage);
+  }
+}
+
+
+/**
+ * Just update the display state of a star, without contacting the server.
+ * Optionally update the value of a form element as well. Useful for when
+ * a user is entering a new issue and wants to set its initial starred state.
+ * @param {Element} el Star <img> element.
+ * @param {string} opt_formElementId HTML ID of the hidden form element for
+ *      stars.
+ */
+function TKR_toggleStarLocal(el, opt_formElementId) {
+  let starred = (el.textContent.trim() == TKR_STAR_OFF) ? 1 : 0;
+
+  el.textContent = starred ? TKR_STAR_ON : TKR_STAR_OFF;
+  el.style.color = starred ? 'cornflowerblue' : 'grey';
+  el.title = starred ? 'You have starred this item' : 'Click to star this item';
+
+  if (opt_formElementId) {
+    $(opt_formElementId).value = '' + starred; // convert to string
+  }
+}
+
+
+/**
+ * When we show two star icons on the same details page, keep them
+ * in sync with each other. And, update a message about starring
+ * that is displayed near the issue update form.
+ * @param {Element} clickedStar The star that the user clicked on.
+ * @param {string} otherStarId ID of the other star icon.
+ */
+function TKR_syncStarIcons(clickedStar, otherStarId) {
+  let otherStar = document.getElementById(otherStarId);
+  if (!otherStar) {
+    return;
+  }
+  TKR_toggleStarLocal(otherStar);
+
+  let vote_feedback = document.getElementById('vote_feedback');
+  if (!vote_feedback) {
+    return;
+  }
+
+  if (clickedStar.textContent == TKR_STAR_OFF) {
+    vote_feedback.textContent =
+        'Vote for this issue and get email change notifications.';
+  } else {
+    vote_feedback.textContent = 'Your vote has been recorded.';
+  }
+}
+
+
+// Exports
+_TKR_toggleStar = TKR_toggleStar;
+_TKR_toggleStarLocal = TKR_toggleStarLocal;
+_TKR_syncStarIcons = TKR_syncStarIcons;
diff --git a/static/js/framework/project-name-check.js b/static/js/framework/project-name-check.js
new file mode 100644
index 0000000..65c2bdf
--- /dev/null
+++ b/static/js/framework/project-name-check.js
@@ -0,0 +1,30 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview Functions that support project name checks when
+ * creating a new project.
+ */
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName The proposed project name.
+ */
+async function checkProjectName(projectName) {
+  const message = {
+    project_name: projectName
+  };
+  const response = await window.prpcClient.call(
+      'monorail.Projects', 'CheckProjectName', message);
+  if (response.error) {
+    $('projectnamefeedback').textContent = response.error;
+    $('submit_btn').disabled = 'disabled';
+  }
+}
+
+// Make this function globally available
+_CP_checkProjectName = checkProjectName;
diff --git a/static/js/graveyard/common.js b/static/js/graveyard/common.js
new file mode 100644
index 0000000..621a626
--- /dev/null
+++ b/static/js/graveyard/common.js
@@ -0,0 +1,709 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// ------------------------------------------------------------------------
+// This file contains common utilities and basic javascript infrastructure.
+//
+// Notes:
+// * Press 'D' to toggle debug mode.
+//
+// Functions:
+//
+// - Assertions
+// DEPRECATED: Use assert.js
+// AssertTrue(): assert an expression. Throws an exception if false.
+// Fail(): Throws an exception. (Mark block of code that should be unreachable)
+// AssertEquals(): assert that two values are equal.
+// AssertType(): assert that a value has a particular type
+//
+// - Cookies
+// SetCookie(): Sets a cookie.
+// ExpireCookie(): Expires a cookie.
+// GetCookie(): Gets a cookie value.
+//
+// - Dynamic HTML/DOM utilities
+// MaybeGetElement(): get an element by its id
+// GetElement(): get an element by its id
+// GetParentNode(): Get the parent of an element
+// GetAttribute(): Get attribute value of a DOM node
+// GetInnerHTML(): get the inner HTML of a node
+// SetCssStyle(): Sets a CSS property of a node.
+// GetStyleProperty(): Get CSS property from a style attribute string
+// GetCellIndex(): Get the index of a table cell in a table row
+// ShowElement(): Show/hide element by setting the "display" css property.
+// ShowBlockElement(): Show/hide block element
+// SetButtonText(): Set the text of a button element.
+// AppendNewElement(): Create and append a html element to a parent node.
+// CreateDIV(): Create a DIV element and append to the document.
+// HasClass(): check if element has a given class
+// AddClass(): add a class to an element
+// RemoveClass(): remove a class from an element
+//
+// - Window/Screen utiltiies
+// GetPageOffsetLeft(): get the X page offset of an element
+// GetPageOffsetTop(): get the Y page offset of an element
+// GetPageOffset(): get the X and Y page offsets of an element
+// GetPageOffsetRight() : get X page offset of the right side of an element
+// GetPageOffsetRight() : get Y page offset of the bottom of an element
+// GetScrollTop(): get the vertical scrolling pos of a window.
+// GetScrollLeft(): get the horizontal scrolling pos of a window
+// IsScrollAtEnd():  check if window scrollbar has reached its maximum offset
+// ScrollTo(): scroll window to a position
+// ScrollIntoView(): scroll window so that an element is in view.
+// GetWindowWidth(): get width of a window.
+// GetWindowHeight(): get height of a window
+// GetAvailScreenWidth(): get available screen width
+// GetAvailScreenHeight(): get available screen height
+// GetNiceWindowHeight(): get a nice height for a new browser window.
+// Open{External/Internal}Window(): open a separate window
+// CloseWindow(): close a window
+//
+// - DOM walking utilities
+// AnnotateTerms(): find terms in a node and decorate them with some tag
+// AnnotateText(): find terms in a text node and decorate them with some tag
+//
+// - String utilties
+// HtmlEscape(): html escapes a string
+// HtmlUnescape(): remove html-escaping.
+// QuoteEscape(): escape " quotes.
+// CollapseWhitespace(): collapse multiple whitespace into one whitespace.
+// Trim(): trim whitespace on ends of string
+// IsEmpty(): check if CollapseWhiteSpace(String) == ""
+// IsLetterOrDigit(): check if a character is a letter or a digit
+// ConvertEOLToLF(): normalize the new-lines of a string.
+// HtmlEscapeInsertWbrs(): HtmlEscapes and inserts <wbr>s (word break tags)
+//   after every n non-space chars and/or after or before certain special chars
+//
+// - TextArea utilities
+// GetCursorPos(): finds the cursor position of a textfield
+// SetCursorPos(): sets the cursor position in a textfield
+//
+// - Array utilities
+// FindInArray(): do a linear search to find an element value.
+// DeleteArrayElement(): return a new array with a specific value removed.
+// CloneObject(): clone an object, copying its values recursively.
+// CloneEvent(): clone an event; cannot use CloneObject because it
+//               suffers from infinite recursion
+//
+// - Formatting utilities
+// PrintArray(): used to print/generate HTML by combining static text
+// and dynamic strings.
+// ImageHtml(): create html for an img tag
+// FormatJSLink(): formats a link that invokes js code when clicked.
+// MakeId3(): formats an id that has two id numbers, eg, foo_3_7
+//
+// - Timeouts
+// SafeTimeout(): sets a timeout with protection against ugly JS-errors
+// CancelTimeout(): cancels a timeout with a given ID
+// CancelAllTimeouts(): cancels all timeouts on a given window
+//
+// - Miscellaneous
+// IsDefined(): returns true if argument is not undefined
+// ------------------------------------------------------------------------
+
+// browser detection
+function BR_AgentContains_(str) {
+  if (str in BR_AgentContains_cache_) {
+    return BR_AgentContains_cache_[str];
+  }
+
+  return BR_AgentContains_cache_[str] =
+    (navigator.userAgent.toLowerCase().indexOf(str) != -1);
+}
+// We cache the results of the indexOf operation. This gets us a 10x benefit in
+// Gecko, 8x in Safari and 4x in MSIE for all of the browser checks
+var BR_AgentContains_cache_ = {};
+
+function BR_IsIE() {
+  return (BR_AgentContains_('msie') || BR_AgentContains_('trident')) &&
+         !window.opera;
+}
+
+function BR_IsKonqueror() {
+  return BR_AgentContains_('konqueror');
+}
+
+function BR_IsSafari() {
+  return BR_AgentContains_('safari') || BR_IsKonqueror();
+}
+
+function BR_IsNav() {
+  return !BR_IsIE() &&
+         !BR_IsSafari() &&
+         BR_AgentContains_('mozilla');
+}
+
+var BACKSPACE_KEYNAME = 'Backspace';
+var COMMA_KEYNAME = ',';
+var DELETE_KEYNAME = 'Delete';
+var UP_KEYNAME = 'ArrowUp';
+var DOWN_KEYNAME = 'ArrowDown';
+var LEFT_KEYNAME = 'ArrowLeft';
+var RIGHT_KEYNAME = 'ArrowRight';
+var ENTER_KEYNAME = 'Enter';
+var ESC_KEYNAME = 'Escape';
+var SPACE_KEYNAME = ' ';
+var TAB_KEYNAME = 'Tab';
+var SHIFT_KEYNAME = 'Shift';
+var PAGE_DOWN_KEYNAME = 'PageDown';
+var PAGE_UP_KEYNAME = 'PageUp';
+
+var MAX_EMAIL_ADDRESS_LENGTH = 320; // 64 + '@' + 255
+var MAX_SIGNATURE_LENGTH = 1000; // 1000 chars of maximum signature
+
+// ------------------------------------------------------------------------
+// Assertions
+// DEPRECATED: Use assert.js
+// ------------------------------------------------------------------------
+/**
+ * DEPRECATED: Use assert.js
+ */
+function raise(msg) {
+  if (typeof Error != 'undefined') {
+    throw new Error(msg || 'Assertion Failed');
+  } else {
+    throw (msg);
+  }
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Fail() is useful for marking logic paths that should
+ * not be reached. For example, if you have a class that uses
+ * ints for enums:
+ *
+ * MyClass.ENUM_FOO = 1;
+ * MyClass.ENUM_BAR = 2;
+ * MyClass.ENUM_BAZ = 3;
+ *
+ * And a switch statement elsewhere in your code that
+ * has cases for each of these enums, then you can
+ * "protect" your code as follows:
+ *
+ * switch(type) {
+ *   case MyClass.ENUM_FOO: doFooThing(); break;
+ *   case MyClass.ENUM_BAR: doBarThing(); break;
+ *   case MyClass.ENUM_BAZ: doBazThing(); break;
+ *   default:
+ *     Fail("No enum in MyClass with value: " + type);
+ * }
+ *
+ * This way, if someone introduces a new value for this enum
+ * without noticing this switch statement, then the code will
+ * fail if the logic allows it to reach the switch with the
+ * new value, alerting the developer that they should add a
+ * case to the switch to handle the new value they have introduced.
+ *
+ * @param {string} opt_msg to display for failure
+ *                 DEFAULT: "Assertion failed"
+ */
+function Fail(opt_msg) {
+  opt_msg = opt_msg || 'Assertion failed';
+  if (IsDefined(DumpError)) DumpError(opt_msg + '\n');
+  raise(opt_msg);
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Asserts that an expression is true (non-zero and non-null).
+ *
+ * Note that it is critical not to pass logic
+ * with side-effects as the expression for AssertTrue
+ * because if the assertions are removed by the
+ * JSCompiler, then the expression will be removed
+ * as well, in which case the side-effects will
+ * be lost. So instead of this:
+ *
+ *  AssertTrue( criticalComputation() );
+ *
+ * Do this:
+ *
+ *  var result = criticalComputation();
+ *  AssertTrue(result);
+ *
+ * @param expression to evaluate
+ * @param {string} opt_msg to display if the assertion fails
+ *
+ */
+function AssertTrue(expression, opt_msg) {
+  if (!expression) {
+    opt_msg = opt_msg || 'Assertion failed';
+    Fail(opt_msg);
+  }
+}
+
+/**
+ * DEPRECATED: Use assert.js
+ *
+ * Asserts that a value is of the provided type.
+ *
+ *   AssertType(6, Number);
+ *   AssertType("ijk", String);
+ *   AssertType([], Array);
+ *   AssertType({}, Object);
+ *   AssertType(ICAL_Date.now(), ICAL_Date);
+ *
+ * @param value
+ * @param type A constructor function
+ * @param {string} opt_msg to display if the assertion fails
+ */
+function AssertType(value, type, opt_msg) {
+  // for backwards compatability only
+  if (typeof value == type) return;
+
+  if (value || value == '') {
+    try {
+      if (type == AssertTypeMap[typeof value] || value instanceof type) return;
+    } catch (e) {/* failure, type was an illegal argument to instanceof */}
+  }
+  let makeMsg = opt_msg === undefined;
+  if (makeMsg) {
+    if (typeof type == 'function') {
+      let match = type.toString().match(/^\s*function\s+([^\s\{]+)/);
+      if (match) type = match[1];
+    }
+    opt_msg = 'AssertType failed: <' + value + '> not typeof '+ type;
+  }
+  Fail(opt_msg);
+}
+
+var AssertTypeMap = {
+  'string': String,
+  'number': Number,
+  'boolean': Boolean,
+};
+
+var EXPIRED_COOKIE_VALUE = 'EXPIRED';
+
+
+// ------------------------------------------------------------------------
+// Window/screen utilities
+// TODO: these should be renamed (e.g. GetWindowWidth to GetWindowInnerWidth
+// and moved to geom.js)
+// ------------------------------------------------------------------------
+// Get page offset of an element
+function GetPageOffsetLeft(el) {
+  let x = el.offsetLeft;
+  if (el.offsetParent != null) {
+    x += GetPageOffsetLeft(el.offsetParent);
+  }
+  return x;
+}
+
+// Get page offset of an element
+function GetPageOffsetTop(el) {
+  let y = el.offsetTop;
+  if (el.offsetParent != null) {
+    y += GetPageOffsetTop(el.offsetParent);
+  }
+  return y;
+}
+
+// Get page offset of an element
+function GetPageOffset(el) {
+  let x = el.offsetLeft;
+  let y = el.offsetTop;
+  if (el.offsetParent != null) {
+    let pos = GetPageOffset(el.offsetParent);
+    x += pos.x;
+    y += pos.y;
+  }
+  return {x: x, y: y};
+}
+
+// Get the y position scroll offset.
+function GetScrollTop(win) {
+  return GetWindowPropertyByBrowser_(win, getScrollTopGetters_);
+}
+
+var getScrollTopGetters_ = {
+  ieQuirks_: function(win) {
+    return win.document.body.scrollTop;
+  },
+  ieStandards_: function(win) {
+    return win.document.documentElement.scrollTop;
+  },
+  dom_: function(win) {
+    return win.pageYOffset;
+  },
+};
+
+// Get the x position scroll offset.
+function GetScrollLeft(win) {
+  return GetWindowPropertyByBrowser_(win, getScrollLeftGetters_);
+}
+
+var getScrollLeftGetters_ = {
+  ieQuirks_: function(win) {
+    return win.document.body.scrollLeft;
+  },
+  ieStandards_: function(win) {
+    return win.document.documentElement.scrollLeft;
+  },
+  dom_: function(win) {
+    return win.pageXOffset;
+  },
+};
+
+// Scroll so that as far as possible the entire element is in view.
+var ALIGN_BOTTOM = 'b';
+var ALIGN_MIDDLE = 'm';
+var ALIGN_TOP = 't';
+
+var getWindowWidthGetters_ = {
+  ieQuirks_: function(win) {
+    return win.document.body.clientWidth;
+  },
+  ieStandards_: function(win) {
+    return win.document.documentElement.clientWidth;
+  },
+  dom_: function(win) {
+    return win.innerWidth;
+  },
+};
+
+function GetWindowHeight(win) {
+  return GetWindowPropertyByBrowser_(win, getWindowHeightGetters_);
+}
+
+var getWindowHeightGetters_ = {
+  ieQuirks_: function(win) {
+    return win.document.body.clientHeight;
+  },
+  ieStandards_: function(win) {
+    return win.document.documentElement.clientHeight;
+  },
+  dom_: function(win) {
+    return win.innerHeight;
+  },
+};
+
+/**
+ * Allows the easy use of different getters for IE quirks mode, IE standards
+ * mode and fully DOM-compliant browers.
+ *
+ * @param win window to get the property for
+ * @param getters object with various getters. Invoked with the passed window.
+ * There are three properties:
+ * - ieStandards_: IE 6.0 standards mode
+ * - ieQuirks_: IE 6.0 quirks mode and IE 5.5 and older
+ * - dom_: Mozilla, Safari and other fully DOM compliant browsers
+ *
+ * @private
+ */
+function GetWindowPropertyByBrowser_(win, getters) {
+  try {
+    if (BR_IsSafari()) {
+      return getters.dom_(win);
+    } else if (!window.opera &&
+               'compatMode' in win.document &&
+               win.document.compatMode == 'CSS1Compat') {
+      return getters.ieStandards_(win);
+    } else if (BR_IsIE()) {
+      return getters.ieQuirks_(win);
+    }
+  } catch (e) {
+    // Ignore for now and fall back to DOM method
+  }
+
+  return getters.dom_(win);
+}
+
+function GetAvailScreenWidth(win) {
+  return win.screen.availWidth;
+}
+
+// Used for horizontally centering a new window of the given width in the
+// available screen. Set the new window's distance from the left of the screen
+// equal to this function's return value.
+// Params: width: the width of the new window
+// Returns: the distance from the left edge of the screen for the new window to
+//   be horizontally centered
+function GetCenteringLeft(win, width) {
+  return (win.screen.availWidth - width) >> 1;
+}
+
+// Used for vertically centering a new window of the given height in the
+// available screen. Set the new window's distance from the top of the screen
+// equal to this function's return value.
+// Params: height: the height of the new window
+// Returns: the distance from the top edge of the screen for the new window to
+//   be vertically aligned.
+function GetCenteringTop(win, height) {
+  return (win.screen.availHeight - height) >> 1;
+}
+
+/**
+ * Opens a child popup window that has no browser toolbar/decorations.
+ * (Copied from caribou's common.js library with small modifications.)
+ *
+ * @param url the URL for the new window (Note: this will be unique-ified)
+ * @param opt_name the name of the new window
+ * @param opt_width the width of the new window
+ * @param opt_height the height of the new window
+ * @param opt_center if true, the new window is centered in the available screen
+ * @param opt_hide_scrollbars if true, the window hides the scrollbars
+ * @param opt_noresize if true, makes window unresizable
+ * @param opt_blocked_msg message warning that the popup has been blocked
+ * @return {Window} a reference to the new child window
+ */
+function Popup(url, opt_name, opt_width, opt_height, opt_center,
+  opt_hide_scrollbars, opt_noresize, opt_blocked_msg) {
+  if (!opt_height) {
+    opt_height = Math.floor(GetWindowHeight(window.top) * 0.8);
+  }
+  if (!opt_width) {
+    opt_width = Math.min(GetAvailScreenWidth(window), opt_height);
+  }
+
+  let features = 'resizable=' + (opt_noresize ? 'no' : 'yes') + ',' +
+                 'scrollbars=' + (opt_hide_scrollbars ? 'no' : 'yes') + ',' +
+                 'width=' + opt_width + ',height=' + opt_height;
+  if (opt_center) {
+    features += ',left=' + GetCenteringLeft(window, opt_width) + ',' +
+                'top=' + GetCenteringTop(window, opt_height);
+  }
+  return OpenWindow(window, url, opt_name, features, opt_blocked_msg);
+}
+
+/**
+ * Opens a new window. Returns the new window handle. Tries to open the new
+ * window using top.open() first. If that doesn't work, then tries win.open().
+ * If that still doesn't work, prints an alert.
+ * (Copied from caribou's common.js library with small modifications.)
+ *
+ * @param win the parent window from which to open the new child window
+ * @param url the URL for the new window (Note: this will be unique-ified)
+ * @param opt_name the name of the new window
+ * @param opt_features the properties of the new window
+ * @param opt_blocked_msg message warning that the popup has been blocked
+ * @return {Window} a reference to the new child window
+ */
+function OpenWindow(win, url, opt_name, opt_features, opt_blocked_msg) {
+  let newwin = OpenWindowHelper(top, url, opt_name, opt_features);
+  if (!newwin || newwin.closed || !newwin.focus) {
+    newwin = OpenWindowHelper(win, url, opt_name, opt_features);
+  }
+  if (!newwin || newwin.closed || !newwin.focus) {
+    if (opt_blocked_msg) alert(opt_blocked_msg);
+  } else {
+    // Make sure that the window has the focus
+    newwin.focus();
+  }
+  return newwin;
+}
+
+/*
+ * Helper for OpenWindow().
+ * (Copied from caribou's common.js library with small modifications.)
+ */
+function OpenWindowHelper(win, url, name, features) {
+  let newwin;
+  if (features) {
+    newwin = win.open(url, name, features);
+  } else if (name) {
+    newwin = win.open(url, name);
+  } else {
+    newwin = win.open(url);
+  }
+  return newwin;
+}
+
+// ------------------------------------------------------------------------
+// String utilities
+// ------------------------------------------------------------------------
+// Do html escaping
+var amp_re_ = /&/g;
+var lt_re_ = /</g;
+var gt_re_ = />/g;
+
+// converts multiple ws chars to a single space, and strips
+// leading and trailing ws
+var spc_re_ = /\s+/g;
+var beg_spc_re_ = /^ /;
+var end_spc_re_ = / $/;
+
+var newline_re_ = /\r?\n/g;
+var spctab_re_ = /[ \t]+/g;
+var nbsp_re_ = /\xa0/g;
+
+// URL-decodes the string. We need to specially handle '+'s because
+// the javascript library doesn't properly convert them to spaces
+var plus_re_ = /\+/g;
+
+// Converts any instances of "\r" or "\r\n" style EOLs into "\n" (Line Feed),
+// and also trim the extra newlines and whitespaces at the end.
+var eol_re_ = /\r\n?/g;
+var trailingspc_re_ = /[\n\t ]+$/;
+
+// Converts a string to its canonicalized label form.
+var illegal_chars_re_ = /[ \/(){}&|\\\"\000]/g;
+
+// ------------------------------------------------------------------------
+// TextArea utilities
+// ------------------------------------------------------------------------
+
+// Gets the cursor pos in a text area. Returns -1 if the cursor pos cannot
+// be determined or if the cursor out of the textfield.
+function GetCursorPos(win, textfield) {
+  try {
+    if (IsDefined(textfield.selectionEnd)) {
+      // Mozilla directly supports this
+      return textfield.selectionEnd;
+    } else if (win.document.selection && win.document.selection.createRange) {
+      // IE doesn't export an accessor for the endpoints of a selection.
+      // Instead, it uses the TextRange object, which has an extremely obtuse
+      // API. Here's what seems to work:
+
+      // (1) Obtain a textfield from the current selection (cursor)
+      let tr = win.document.selection.createRange();
+
+      // Check if the current selection is in the textfield
+      if (tr.parentElement() != textfield) {
+        return -1;
+      }
+
+      // (2) Make a text range encompassing the textfield
+      let tr2 = tr.duplicate();
+      tr2.moveToElementText(textfield);
+
+      // (3) Move the end of the copy to the beginning of the selection
+      tr2.setEndPoint('EndToStart', tr);
+
+      // (4) The span of the textrange copy is equivalent to the cursor pos
+      let cursor = tr2.text.length;
+
+      // Finally, perform a sanity check to make sure the cursor is in the
+      // textfield. IE sometimes screws this up when the window is activated
+      if (cursor > textfield.value.length) {
+        return -1;
+      }
+      return cursor;
+    } else {
+      Debug('Unable to get cursor position for: ' + navigator.userAgent);
+
+      // Just return the size of the textfield
+      // TODO: Investigate how to get cursor pos in Safari!
+      return textfield.value.length;
+    }
+  } catch (e) {
+    DumpException(e, 'Cannot get cursor pos');
+  }
+
+  return -1;
+}
+
+function SetCursorPos(win, textfield, pos) {
+  if (IsDefined(textfield.selectionEnd) &&
+      IsDefined(textfield.selectionStart)) {
+    // Mozilla directly supports this
+    textfield.selectionStart = pos;
+    textfield.selectionEnd = pos;
+  } else if (win.document.selection && textfield.createTextRange) {
+    // IE has textranges. A textfield's textrange encompasses the
+    // entire textfield's text by default
+    let sel = textfield.createTextRange();
+
+    sel.collapse(true);
+    sel.move('character', pos);
+    sel.select();
+  }
+}
+
+// ------------------------------------------------------------------------
+// Array utilities
+// ------------------------------------------------------------------------
+// Find an item in an array, returns the key, or -1 if not found
+function FindInArray(array, x) {
+  for (let i = 0; i < array.length; i++) {
+    if (array[i] == x) {
+      return i;
+    }
+  }
+  return -1;
+}
+
+// Delete an element from an array
+function DeleteArrayElement(array, x) {
+  let i = 0;
+  while (i < array.length && array[i] != x) {
+    i++;
+  }
+  array.splice(i, 1);
+}
+
+// Clean up email address:
+// - remove extra spaces
+// - Surround name with quotes if it contains special characters
+// to check if we need " quotes
+// Note: do not use /g in the regular expression, otherwise the
+// regular expression cannot be reusable.
+var specialchars_re_ = /[()<>@,;:\\\".\[\]]/;
+
+// ------------------------------------------------------------------------
+// Timeouts
+//
+// It is easy to forget to put a try/catch block around a timeout function,
+// and the result is an ugly user visible javascript error.
+// Also, it would be nice if a timeout associated with a window is
+// automatically cancelled when the user navigates away from that window.
+//
+// When storing timeouts in a window, we can't let that variable be renamed
+// since the window could be top.js, and renaming such a property could
+// clash with any of the variables/functions defined in top.js.
+// ------------------------------------------------------------------------
+/**
+ * Sets a timeout safely.
+ * @param win the window object. If null is passed in, then a timeout if set
+ *   on the js frame. If the window is closed, or freed, the timeout is
+ *   automaticaaly cancelled
+ * @param fn the callback function: fn(win) will be called.
+ * @param ms number of ms the callback should be called later
+ */
+function SafeTimeout(win, fn, ms) {
+  if (!win) win = window;
+  if (!win._tm) {
+    win._tm = [];
+  }
+  let timeoutfn = SafeTimeoutFunction_(win, fn);
+  let id = win.setTimeout(timeoutfn, ms);
+
+  // Save the id so that it can be removed from the _tm array
+  timeoutfn.id = id;
+
+  // Safe the timeout in the _tm array
+  win._tm[id] = 1;
+
+  return id;
+}
+
+/** Creates a callback function for a timeout*/
+function SafeTimeoutFunction_(win, fn) {
+  var timeoutfn = function() {
+    try {
+      fn(win);
+
+      let t = win._tm;
+      if (t) {
+        delete t[timeoutfn.id];
+      }
+    } catch (e) {
+      DumpException(e);
+    }
+  };
+  return timeoutfn;
+}
+
+// ------------------------------------------------------------------------
+// Misc
+// ------------------------------------------------------------------------
+// Check if a value is defined
+function IsDefined(value) {
+  return (typeof value) != 'undefined';
+}
diff --git a/static/js/graveyard/geom.js b/static/js/graveyard/geom.js
new file mode 100644
index 0000000..3eaffb7
--- /dev/null
+++ b/static/js/graveyard/geom.js
@@ -0,0 +1,94 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// functions for dealing with layout and geometry of page elements.
+// Requires shapes.js
+
+/** returns the bounding box of the given DOM node in document space.
+  *
+  * @param {Element?} obj a DOM node.
+  * @return {Rect?}
+  */
+function nodeBounds(obj) {
+  if (!obj) return null;
+
+  function fixRectForScrolling(r) {
+    // Need to take into account scrolling offset of ancestors (IE already does
+    // this)
+    for (let o = obj.offsetParent;
+      o && o.offsetParent;
+      o = o.offsetParent) {
+      if (o.scrollLeft) {
+        r.x -= o.scrollLeft;
+      }
+      if (o.scrollTop) {
+        r.y -= o.scrollTop;
+      }
+    }
+  }
+
+  let refWindow;
+  if (obj.ownerDocument && obj.ownerDocument.parentWindow) {
+    refWindow = obj.ownerDocument.parentWindow;
+  } else if (obj.ownerDocument && obj.ownerDocument.defaultView) {
+    refWindow = obj.ownerDocument.defaultView;
+  } else {
+    refWindow = window;
+  }
+
+  // IE, Mozilla 3+
+  if (obj.getBoundingClientRect) {
+    let rect = obj.getBoundingClientRect();
+
+    return new Rect(rect.left + GetScrollLeft(refWindow),
+      rect.top + GetScrollTop(refWindow),
+      rect.right - rect.left,
+      rect.bottom - rect.top,
+      refWindow);
+  }
+
+  // Mozilla < 3
+  if (obj.ownerDocument && obj.ownerDocument.getBoxObjectFor) {
+    let box = obj.ownerDocument.getBoxObjectFor(obj);
+    var r = new Rect(box.x, box.y, box.width, box.height, refWindow);
+    fixRectForScrolling(r);
+    return r;
+  }
+
+  // Fallback to recursively computing this
+  let left = 0;
+  let top = 0;
+  for (let o = obj; o.offsetParent; o = o.offsetParent) {
+    left += o.offsetLeft;
+    top += o.offsetTop;
+  }
+
+  var r = new Rect(left, top, obj.offsetWidth, obj.offsetHeight, refWindow);
+  fixRectForScrolling(r);
+  return r;
+}
+
+function GetMousePosition(e) {
+  // copied from http://www.quirksmode.org/js/events_compinfo.html
+  let posx = 0;
+  let posy = 0;
+  if (e.pageX || e.pageY) {
+    posx = e.pageX;
+    posy = e.pageY;
+  } else if (e.clientX || e.clientY) {
+    let obj = (e.target ? e.target : e.srcElement);
+    let refWindow;
+    if (obj.ownerDocument && obj.ownerDocument.parentWindow) {
+      refWindow = obj.ownerDocument.parentWindow;
+    } else {
+      refWindow = window;
+    }
+    posx = e.clientX + GetScrollLeft(refWindow);
+    posy = e.clientY + GetScrollTop(refWindow);
+  }
+  return new Point(posx, posy, window);
+}
diff --git a/static/js/graveyard/listen.js b/static/js/graveyard/listen.js
new file mode 100644
index 0000000..953d674
--- /dev/null
+++ b/static/js/graveyard/listen.js
@@ -0,0 +1,145 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+var listen;
+var unlisten;
+var unlistenByKey;
+
+(function() {
+  let listeners = {};
+  let nextId = 0;
+
+  function getHashCode_(obj) {
+    if (obj.listen_hc_ == null) {
+      obj.listen_hc_ = ++nextId;
+    }
+    return obj.listen_hc_;
+  }
+
+  /**
+   * Takes a node, event, listener, and capture flag to create a key
+   * to identify the tuple in the listeners hash.
+   *
+   * @param {Element} node The node to listen to events on.
+   * @param {string} event The name of the event without the "on" prefix.
+   * @param {Function} listener A function to call when the event occurs.
+   * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+   *                                 whether the listener is fired during the
+   *                                 capture or bubble phase of the event.
+   * @return {string} key to identify this tuple in the listeners hash.
+   */
+  function createKey_(node, event, listener, opt_useCapture) {
+    let nodeHc = getHashCode_(node);
+    let listenerHc = getHashCode_(listener);
+    opt_useCapture = !!opt_useCapture;
+    let key = nodeHc + '_' + event + '_' + listenerHc + '_' + opt_useCapture;
+    return key;
+  }
+
+  /**
+   * Adds an event listener to a DOM node for a specific event.
+   *
+   * Listen() and unlisten() use an indirect lookup of listener functions
+   * to avoid circular references between DOM (in IE) or XPCOM (in Mozilla)
+   * objects which leak memory. This makes it easier to write OO
+   * Javascript/DOM code.
+   *
+   * Examples:
+   * listen(myButton, 'click', myHandler, true);
+   * listen(myButton, 'click', this.myHandler.bind(this), true);
+   *
+   * @param {Element} node The node to listen to events on.
+   * @param {string} event The name of the event without the "on" prefix.
+   * @param {Function} listener A function to call when the event occurs.
+   * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+   *                                 whether the listener is fired during the
+   *                                 capture or bubble phase of the event.
+   * @return {string} a unique key to indentify this listener.
+   */
+  listen = function(node, event, listener, opt_useCapture) {
+    let key = createKey_(node, event, listener, opt_useCapture);
+
+    // addEventListener does not allow multiple listeners
+    if (key in listeners) {
+      return key;
+    }
+
+    let proxy = handleEvent.bind(null, key);
+    listeners[key] = {
+      listener: listener,
+      proxy: proxy,
+      event: event,
+      node: node,
+      useCapture: opt_useCapture,
+    };
+
+    if (node.addEventListener) {
+      node.addEventListener(event, proxy, opt_useCapture);
+    } else if (node.attachEvent) {
+      node.attachEvent('on' + event, proxy);
+    } else {
+      throw new Error('Node {' + node + '} does not support event listeners.');
+    }
+
+    return key;
+  };
+
+  /**
+   * Removes an event listener which was added with listen().
+   *
+   * @param {Element} node The node to stop listening to events on.
+   * @param {string} event The name of the event without the "on" prefix.
+   * @param {Function} listener The listener function to remove.
+   * @param {boolean} opt_useCapture In DOM-compliant browsers, this determines
+   *                                 whether the listener is fired during the
+   *                                 capture or bubble phase of the event.
+   * @return {boolean} indicating whether the listener was there to remove.
+   */
+  unlisten = function(node, event, listener, opt_useCapture) {
+    let key = createKey_(node, event, listener, opt_useCapture);
+
+    return unlistenByKey(key);
+  };
+
+  /**
+   * Variant of {@link unlisten} that takes a key that was returned by
+   * {@link listen} and removes that listener.
+   *
+   * @param {string} key Key of event to be unlistened.
+   * @return {boolean} indicating whether it was there to be removed.
+   */
+  unlistenByKey = function(key) {
+    if (!(key in listeners)) {
+      return false;
+    }
+    let listener = listeners[key];
+    let proxy = listener.proxy;
+    let event = listener.event;
+    let node = listener.node;
+    let useCapture = listener.useCapture;
+
+    if (node.removeEventListener) {
+      node.removeEventListener(event, proxy, useCapture);
+    } else if (node.detachEvent) {
+      node.detachEvent('on' + event, proxy);
+    }
+
+    delete listeners[key];
+    return true;
+  };
+
+  /**
+   * The function which is actually called when the DOM event occurs. This
+   * function is a proxy for the real listener the user specified.
+   */
+  function handleEvent(key) {
+    // pass all arguments which were sent to this function except listenerID
+    // on to the actual listener.
+    let args = Array.prototype.splice.call(arguments, 1, arguments.length);
+    return listeners[key].listener.apply(null, args);
+  }
+})();
diff --git a/static/js/graveyard/popup_controller.js b/static/js/graveyard/popup_controller.js
new file mode 100644
index 0000000..41c2956
--- /dev/null
+++ b/static/js/graveyard/popup_controller.js
@@ -0,0 +1,145 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * It is common to make a DIV temporarily visible to simulate
+ * a popup window. Often, this is done by adding an onClick
+ * handler to the element that can be clicked on to show the
+ * popup.
+ *
+ * Unfortunately, closing the popup is not as simple.
+ * The popup creator often wants to let the user close
+ * the popup by clicking elsewhere on the window; however,
+ * the popup only receives mouse events that occur
+ * on the popup itself. Thus, popups need a mechanism
+ * that notifies them that the user has clicked elsewhere
+ * to try to get rid of them.
+ *
+ * PopupController is such a mechanism --
+ * it monitors all mousedown events that
+ * occur in the window so that it can notify registered
+ * popups of the mousedown, and the popups can choose
+ * to deactivate themselves.
+ *
+ * For an object to qualify as a popup, it must have a
+ * function called "deactivate" that takes a mousedown event
+ * and returns a boolean indicating that it has deactivated
+ * itself as a result of that event.
+ *
+ * EXAMPLE:
+ *
+ * // popup that attaches itself to the supplied div
+ * function MyPopup(div) {
+ *   this._div = div;
+ *   this._isVisible = false;
+ *   this._innerHTML = ...
+ * }
+ *
+ * MyPopup.prototype.show = function() {
+ *   this._div.display = '';
+ *   this._isVisible = true;
+ *   PC_addPopup(this);
+ * }
+ *
+ * MyPopup.prototype.hide = function() {
+ *   this._div.display = 'none';
+ *   this._isVisible = false;
+ * }
+ *
+ * MyPopup.prototype.deactivate = function(e) {
+ *   if (this._isVisible) {
+ *     var p = GetMousePosition(e);
+ *     if (nodeBounds(this._div).contains(p)) {
+ *       return false; // use clicked on popup, remain visible
+ *     } else {
+ *       this.hide();
+ *       return true; // clicked outside popup, make invisible
+ *     }
+ *   } else {
+ *     return true; // already deactivated, not visible
+ *   }
+ * }
+ *
+ * DEPENDENCIES (from this directory):
+ *   bind.js
+ *   listen.js
+ *   common.js
+ *   shapes.js
+ *   geom.js
+ *
+ * USAGE:
+ *  _PC_Install() must be called after the body is loaded
+ */
+
+/**
+ * PopupController constructor.
+ * @constructor
+ */
+function PopupController() {
+  this.activePopups_ = [];
+}
+
+/**
+ * @param {Document} opt_doc document to add PopupController to
+ *                   DEFAULT: "document" variable that is currently in scope
+ * @return {boolean} indicating if PopupController installed for the document;
+ *                   returns false if document already had PopupController
+ */
+function _PC_Install(opt_doc) {
+  if (gPopupControllerInstalled) return false;
+  gPopupControllerInstalled = true;
+  let doc = (opt_doc) ? opt_doc : document;
+
+  // insert _notifyPopups in BODY's onmousedown chain
+  listen(doc.body, 'mousedown', PC_notifyPopups);
+  return true;
+}
+
+/**
+ * Notifies each popup of a mousedown event, giving
+ * each popup the chance to deactivate itself.
+ *
+ * @throws Error if a popup does not have a deactivate function
+ *
+ * @private
+ */
+function PC_notifyPopups(e) {
+  if (gPopupController.activePopups_.length == 0) return false;
+  e = e || window.event;
+  for (let i = gPopupController.activePopups_.length - 1; i >= 0; --i) {
+    let popup = gPopupController.activePopups_[i];
+    PC_assertIsPopup(popup);
+    if (popup.deactivate(e)) {
+      gPopupController.activePopups_.splice(i, 1);
+    }
+  }
+  return true;
+}
+
+/**
+ * Adds the popup to the list of popups to be
+ * notified of a mousedown event.
+ *
+ * @return boolean indicating if added popup; false if already contained
+ * @throws Error if popup does not have a deactivate function
+ */
+function PC_addPopup(popup) {
+  PC_assertIsPopup(popup);
+  for (let i = 0; i < gPopupController.activePopups_.length; ++i) {
+    if (popup === gPopupController.activePopups_[i]) return false;
+  }
+  gPopupController.activePopups_.push(popup);
+  return true;
+}
+
+/** asserts that popup has a deactivate function */
+function PC_assertIsPopup(popup) {
+  AssertType(popup.deactivate, Function, 'popup missing deactivate function');
+}
+
+var gPopupController = new PopupController();
+var gPopupControllerInstalled = false;
diff --git a/static/js/graveyard/shapes.js b/static/js/graveyard/shapes.js
new file mode 100644
index 0000000..27cd7f1
--- /dev/null
+++ b/static/js/graveyard/shapes.js
@@ -0,0 +1,126 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+// shape related classes
+
+/** a point in 2 cartesian dimensions.
+  * @constructor
+  * @param x x-coord.
+  * @param y y-coord.
+  * @param opt_coordinateFrame a key that can be passed to a translation function to
+  *   convert from one coordinate frame to another.
+  *   Coordinate frames might correspond to things like windows, iframes, or
+  *   any element with a position style attribute.
+  */
+function Point(x, y, opt_coordinateFrame) {
+  /** a numeric x coordinate. */
+  this.x = x;
+  /** a numeric y coordinate. */
+  this.y = y;
+  /** a key that can be passed to a translation function to
+    * convert from one coordinate frame to another.
+    * Coordinate frames might correspond to things like windows, iframes, or
+    * any element with a position style attribute.
+    */
+  this.coordinateFrame = opt_coordinateFrame || null;
+}
+Point.prototype.toString = function() {
+  return '[P ' + this.x + ',' + this.y + ']';
+};
+Point.prototype.clone = function() {
+  return new Point(this.x, this.y, this.coordinateFrame);
+};
+
+/** a distance between two points in 2-space in cartesian form.
+  * A delta doesn't have a coordinate frame associated since all the coordinate
+  * frames used in the HTML dom are convertible without rotation/scaling.
+  * If a delta is not being used in pixel-space then it may be annotated with
+  * a coordinate frame, and the undefined coordinate frame can be assumed
+  * to represent pixel space.
+  * @constructor
+  * @param dx distance along x axis
+  * @param dy distance along y axis
+  */
+function Delta(dx, dy) {
+  /** a numeric distance along the x dimension. */
+  this.dx = dx;
+  /** a numeric distance along the y dimension. */
+  this.dy = dy;
+}
+Delta.prototype.toString = function() {
+  return '[D ' + this.dx + ',' + this.dy + ']';
+};
+
+/** a rectangle or bounding region.
+  * @constructor
+  * @param x x-coord of the left edge.
+  * @param y y-coord of the top edge.
+  * @param w width.
+  * @param h height.
+  * @param opt_coordinateFrame a key that can be passed to a translation function to
+  *   convert from one coordinate frame to another.
+  *   Coordinate frames might correspond to things like windows, iframes, or
+  *   any element with a position style attribute.
+  */
+function Rect(x, y, w, h, opt_coordinateFrame) {
+  /** the numeric x coordinate of the left edge. */
+  this.x = x;
+  /** the numeric y coordinate of the top edge. */
+  this.y = y;
+  /** the numeric distance between the right edge and the left. */
+  this.w = w;
+  /** the numeric distance between the top edge and the bottom. */
+  this.h = h;
+  /** a key that can be passed to a translation function to
+    * convert from one coordinate frame to another.
+    * Coordinate frames might correspond to things like windows, iframes, or
+    * any element with a position style attribute.
+    */
+  this.coordinateFrame = opt_coordinateFrame || null;
+}
+
+/**
+ * Determines whether the Rectangle contains the Point.
+ * The Point is considered "contained" if it lies
+ * on the boundary of, or in the interior of, the Rectangle.
+ *
+ * @param {Point} p
+ * @return boolean indicating if this Rect contains p
+ */
+Rect.prototype.contains = function(p) {
+  return this.x <= p.x && p.x < (this.x + this.w) &&
+             this.y <= p.y && p.y < (this.y + this.h);
+};
+
+/**
+ * Determines whether the given rectangle intersects this rectangle.
+ *
+ * @param {Rect} r
+ * @return boolean indicating if this the two rectangles intersect
+ */
+Rect.prototype.intersects = function(r) {
+  let p = function(x, y) {
+    return new Point(x, y, null);
+  };
+
+  return this.contains(p(r.x, r.y)) ||
+         this.contains(p(r.x + r.w, r.y)) ||
+         this.contains(p(r.x + r.w, r.y + r.h)) ||
+         this.contains(p(r.x, r.y + r.h)) ||
+         r.contains(p(this.x, this.y)) ||
+         r.contains(p(this.x + this.w, this.y)) ||
+         r.contains(p(this.x + this.w, this.y + this.h)) ||
+         r.contains(p(this.x, this.y + this.h));
+};
+
+Rect.prototype.toString = function() {
+  return '[R ' + this.w + 'x' + this.h + '+' + this.x + '+' + this.y + ']';
+};
+
+Rect.prototype.clone = function() {
+  return new Rect(this.x, this.y, this.w, this.h, this.coordinateFrame);
+};
diff --git a/static/js/graveyard/xmlhttp.js b/static/js/graveyard/xmlhttp.js
new file mode 100644
index 0000000..eaf1f36
--- /dev/null
+++ b/static/js/graveyard/xmlhttp.js
@@ -0,0 +1,141 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * @fileoverview A bunch of XML HTTP recipes used to do RPC from JavaScript
+ */
+
+
+/**
+ * The active x identifier used for ie.
+ * @type String
+ * @private
+ */
+var XH_ieProgId_;
+
+
+// Domain for XMLHttpRequest readyState
+var XML_READY_STATE_UNINITIALIZED = 0;
+var XML_READY_STATE_LOADING = 1;
+var XML_READY_STATE_LOADED = 2;
+var XML_READY_STATE_INTERACTIVE = 3;
+var XML_READY_STATE_COMPLETED = 4;
+
+
+/**
+ * Initialize the private state used by other functions.
+ * @private
+ */
+function XH_XmlHttpInit_() {
+  // The following blog post describes what PROG IDs to use to create the
+  // XMLHTTP object in Internet Explorer:
+  // http://blogs.msdn.com/xmlteam/archive/2006/10/23/using-the-right-version-of-msxml-in-internet-explorer.aspx
+  // However we do not (yet) fully trust that this will be OK for old versions
+  // of IE on Win9x so we therefore keep the last 2.
+  // Versions 4 and 5 have been removed because 3.0 is the preferred "fallback"
+  // per the article above.
+  // - Version 5 was built for Office applications and is not recommended for
+  //   web applications.
+  // - Version 4 has been superseded by 6 and is only intended for legacy apps.
+  // - Version 3 has a wide install base and is serviced regularly with the OS.
+
+  /**
+   * Candidate Active X types.
+   * @type Array.<String>
+   * @private
+   */
+  let XH_ACTIVE_X_IDENTS = ['MSXML2.XMLHTTP.6.0', 'MSXML2.XMLHTTP.3.0',
+    'MSXML2.XMLHTTP', 'Microsoft.XMLHTTP'];
+
+  if (typeof XMLHttpRequest == 'undefined' &&
+      typeof ActiveXObject != 'undefined') {
+    for (let i = 0; i < XH_ACTIVE_X_IDENTS.length; i++) {
+      let candidate = XH_ACTIVE_X_IDENTS[i];
+
+      try {
+        new ActiveXObject(candidate);
+        XH_ieProgId_ = candidate;
+        break;
+      } catch (e) {
+        // do nothing; try next choice
+      }
+    }
+
+    // couldn't find any matches
+    if (!XH_ieProgId_) {
+      throw Error('Could not create ActiveXObject. ActiveX might be disabled,' +
+                  ' or MSXML might not be installed.');
+    }
+  }
+}
+
+
+XH_XmlHttpInit_();
+
+
+/**
+ * Create and return an xml http request object that can be passed to
+ * {@link #XH_XmlHttpGET} or {@link #XH_XmlHttpPOST}.
+ */
+function XH_XmlHttpCreate() {
+  if (XH_ieProgId_) {
+    return new ActiveXObject(XH_ieProgId_);
+  } else {
+    return new XMLHttpRequest();
+  }
+}
+
+
+/**
+ * Send a get request.
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string} url the service to contact
+ * @param {Function} handler function called when the response is received.
+ */
+function XH_XmlHttpGET(xmlHttp, url, handler) {
+  xmlHttp.open('GET', url, true);
+  xmlHttp.onreadystatechange = handler;
+  XH_XmlHttpSend(xmlHttp, null);
+}
+
+/**
+ * Send a post request.
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string} url the service to contact
+ * @param {string} data the request content.
+ * @param {Function} handler function called when the response is received.
+ */
+function XH_XmlHttpPOST(xmlHttp, url, data, handler) {
+  xmlHttp.open('POST', url, true);
+  xmlHttp.onreadystatechange = handler;
+  xmlHttp.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+  XH_XmlHttpSend(xmlHttp, data);
+}
+
+/**
+ * Calls 'send' on the XMLHttpRequest object and calls a function called 'log'
+ * if any error occured.
+ *
+ * @deprecated This dependes on a function called 'log'. You are better off
+ * handling your errors on application level.
+ *
+ * @param {XMLHttpRequest} xmlHttp as from {@link XH_XmlHttpCreate}.
+ * @param {string|null} data the request content.
+ */
+function XH_XmlHttpSend(xmlHttp, data) {
+  try {
+    xmlHttp.send(data);
+  } catch (e) {
+    // You may want to log/debug this error one that you should be aware of is
+    // e.number == -2146697208, which occurs when the 'Languages...' setting in
+    // IE is empty.
+    // This is not entirely true. The same error code is used when the user is
+    // off line.
+    console.log('XMLHttpSend failed ' + e.toString() + '<br>' + e.stack);
+    throw e;
+  }
+}
diff --git a/static/js/hotlists/edit-hotlist.js b/static/js/hotlists/edit-hotlist.js
new file mode 100644
index 0000000..6e837a1
--- /dev/null
+++ b/static/js/hotlists/edit-hotlist.js
@@ -0,0 +1,35 @@
+/**
+ * Sets up the transfer ownership dialog box.
+ * @param {Long} hotlist_id id of the current hotlist
+*/
+function initializeDialogBox(hotlist_id) {
+  let transferContainer = $('transfer-ownership-container');
+  $('transfer-ownership').addEventListener('click', function() {
+    transferContainer.style.display = 'block';
+  });
+
+  let cancelButton = document.getElementById('cancel');
+
+  cancelButton.addEventListener('click', function() {
+    transferContainer.style.display = 'none';
+  });
+
+  $('hotlist_star').addEventListener('click', function() {
+    _TKR_toggleStar($('hotlist_star'), null, null, null, hotlist_id);
+  });
+}
+
+function initializeDialogBoxRemoveSelf() {
+  /* Initialise the dialog box for removing self from the hotlist. */
+
+  let removeSelfContainer = $('remove-self-container');
+  $('remove-self').addEventListener('click', function() {
+    removeSelfContainer.style.display = 'block';
+  });
+
+  let cancelButtonRS = document.getElementById('cancel-remove-self');
+
+  cancelButtonRS.addEventListener('click', function() {
+    removeSelfContainer.style.display = 'none';
+  });
+}
diff --git a/static/js/prettify.js b/static/js/prettify.js
new file mode 100644
index 0000000..7b99049
--- /dev/null
+++ b/static/js/prettify.js
@@ -0,0 +1,30 @@
+!function(){var q=null;window.PR_SHOULD_USE_CONTINUATION=!0;
+(function(){function S(a){function d(e){var b=e.charCodeAt(0);if(b!==92)return b;var a=e.charAt(1);return(b=r[a])?b:"0"<=a&&a<="7"?parseInt(e.substring(1),8):a==="u"||a==="x"?parseInt(e.substring(2),16):e.charCodeAt(1)}function g(e){if(e<32)return(e<16?"\\x0":"\\x")+e.toString(16);e=String.fromCharCode(e);return e==="\\"||e==="-"||e==="]"||e==="^"?"\\"+e:e}function b(e){var b=e.substring(1,e.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),e=[],a=
+b[0]==="^",c=["["];a&&c.push("^");for(var a=a?1:0,f=b.length;a<f;++a){var h=b[a];if(/\\[bdsw]/i.test(h))c.push(h);else{var h=d(h),l;a+2<f&&"-"===b[a+1]?(l=d(b[a+2]),a+=2):l=h;e.push([h,l]);l<65||h>122||(l<65||h>90||e.push([Math.max(65,h)|32,Math.min(l,90)|32]),l<97||h>122||e.push([Math.max(97,h)&-33,Math.min(l,122)&-33]))}}e.sort(function(e,a){return e[0]-a[0]||a[1]-e[1]});b=[];f=[];for(a=0;a<e.length;++a)h=e[a],h[0]<=f[1]+1?f[1]=Math.max(f[1],h[1]):b.push(f=h);for(a=0;a<b.length;++a)h=b[a],c.push(g(h[0])),
+h[1]>h[0]&&(h[1]+1>h[0]&&c.push("-"),c.push(g(h[1])));c.push("]");return c.join("")}function s(e){for(var a=e.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),c=a.length,d=[],f=0,h=0;f<c;++f){var l=a[f];l==="("?++h:"\\"===l.charAt(0)&&(l=+l.substring(1))&&(l<=h?d[l]=-1:a[f]=g(l))}for(f=1;f<d.length;++f)-1===d[f]&&(d[f]=++x);for(h=f=0;f<c;++f)l=a[f],l==="("?(++h,d[h]||(a[f]="(?:")):"\\"===l.charAt(0)&&(l=+l.substring(1))&&l<=h&&
+(a[f]="\\"+d[l]);for(f=0;f<c;++f)"^"===a[f]&&"^"!==a[f+1]&&(a[f]="");if(e.ignoreCase&&m)for(f=0;f<c;++f)l=a[f],e=l.charAt(0),l.length>=2&&e==="["?a[f]=b(l):e!=="\\"&&(a[f]=l.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return a.join("")}for(var x=0,m=!1,j=!1,k=0,c=a.length;k<c;++k){var i=a[k];if(i.ignoreCase)j=!0;else if(/[a-z]/i.test(i.source.replace(/\\u[\da-f]{4}|\\x[\da-f]{2}|\\[^UXux]/gi,""))){m=!0;j=!1;break}}for(var r={b:8,t:9,n:10,v:11,
+f:12,r:13},n=[],k=0,c=a.length;k<c;++k){i=a[k];if(i.global||i.multiline)throw Error(""+i);n.push("(?:"+s(i)+")")}return RegExp(n.join("|"),j?"gi":"g")}function T(a,d){function g(a){var c=a.nodeType;if(c==1){if(!b.test(a.className)){for(c=a.firstChild;c;c=c.nextSibling)g(c);c=a.nodeName.toLowerCase();if("br"===c||"li"===c)s[j]="\n",m[j<<1]=x++,m[j++<<1|1]=a}}else if(c==3||c==4)c=a.nodeValue,c.length&&(c=d?c.replace(/\r\n?/g,"\n"):c.replace(/[\t\n\r ]+/g," "),s[j]=c,m[j<<1]=x,x+=c.length,m[j++<<1|1]=
+a)}var b=/(?:^|\s)nocode(?:\s|$)/,s=[],x=0,m=[],j=0;g(a);return{a:s.join("").replace(/\n$/,""),d:m}}function H(a,d,g,b){d&&(a={a:d,e:a},g(a),b.push.apply(b,a.g))}function U(a){for(var d=void 0,g=a.firstChild;g;g=g.nextSibling)var b=g.nodeType,d=b===1?d?a:g:b===3?V.test(g.nodeValue)?a:d:d;return d===a?void 0:d}function C(a,d){function g(a){for(var j=a.e,k=[j,"pln"],c=0,i=a.a.match(s)||[],r={},n=0,e=i.length;n<e;++n){var z=i[n],w=r[z],t=void 0,f;if(typeof w==="string")f=!1;else{var h=b[z.charAt(0)];
+if(h)t=z.match(h[1]),w=h[0];else{for(f=0;f<x;++f)if(h=d[f],t=z.match(h[1])){w=h[0];break}t||(w="pln")}if((f=w.length>=5&&"lang-"===w.substring(0,5))&&!(t&&typeof t[1]==="string"))f=!1,w="src";f||(r[z]=w)}h=c;c+=z.length;if(f){f=t[1];var l=z.indexOf(f),B=l+f.length;t[2]&&(B=z.length-t[2].length,l=B-f.length);w=w.substring(5);H(j+h,z.substring(0,l),g,k);H(j+h+l,f,I(w,f),k);H(j+h+B,z.substring(B),g,k)}else k.push(j+h,w)}a.g=k}var b={},s;(function(){for(var g=a.concat(d),j=[],k={},c=0,i=g.length;c<i;++c){var r=
+g[c],n=r[3];if(n)for(var e=n.length;--e>=0;)b[n.charAt(e)]=r;r=r[1];n=""+r;k.hasOwnProperty(n)||(j.push(r),k[n]=q)}j.push(/[\S\s]/);s=S(j)})();var x=d.length;return g}function v(a){var d=[],g=[];a.tripleQuotedStrings?d.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?d.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/,
+q,"'\"`"]):d.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&g.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var b=a.hashComments;b&&(a.cStyleComments?(b>1?d.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):d.push(["com",/^#(?:(?:define|e(?:l|nd)if|else|error|ifn?def|include|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),g.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h(?:h|pp|\+\+)?|[a-z]\w*)>/,q])):d.push(["com",
+/^#[^\n\r]*/,q,"#"]));a.cStyleComments&&(g.push(["com",/^\/\/[^\n\r]*/,q]),g.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));if(b=a.regexLiterals){var s=(b=b>1?"":"\n\r")?".":"[\\S\\s]";g.push(["lang-regex",RegExp("^(?:^^\\.?|[+-]|[!=]=?=?|\\#|%=?|&&?=?|\\(|\\*=?|[+\\-]=|->|\\/=?|::?|<<?=?|>>?>?=?|,|;|\\?|@|\\[|~|{|\\^\\^?=?|\\|\\|?=?|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*("+("/(?=[^/*"+b+"])(?:[^/\\x5B\\x5C"+b+"]|\\x5C"+s+"|\\x5B(?:[^\\x5C\\x5D"+b+"]|\\x5C"+
+s+")*(?:\\x5D|$))+/")+")")])}(b=a.types)&&g.push(["typ",b]);b=(""+a.keywords).replace(/^ | $/g,"");b.length&&g.push(["kwd",RegExp("^(?:"+b.replace(/[\s,]+/g,"|")+")\\b"),q]);d.push(["pln",/^\s+/,q," \r\n\t\u00a0"]);b="^.[^\\s\\w.$@'\"`/\\\\]*";a.regexLiterals&&(b+="(?!s*/)");g.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,
+q],["pun",RegExp(b),q]);return C(d,g)}function J(a,d,g){function b(a){var c=a.nodeType;if(c==1&&!x.test(a.className))if("br"===a.nodeName)s(a),a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)b(a);else if((c==3||c==4)&&g){var d=a.nodeValue,i=d.match(m);if(i)c=d.substring(0,i.index),a.nodeValue=c,(d=d.substring(i.index+i[0].length))&&a.parentNode.insertBefore(j.createTextNode(d),a.nextSibling),s(a),c||a.parentNode.removeChild(a)}}function s(a){function b(a,c){var d=
+c?a.cloneNode(!1):a,e=a.parentNode;if(e){var e=b(e,1),g=a.nextSibling;e.appendChild(d);for(var i=g;i;i=g)g=i.nextSibling,e.appendChild(i)}return d}for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),d;(d=a.parentNode)&&d.nodeType===1;)a=d;c.push(a)}for(var x=/(?:^|\s)nocode(?:\s|$)/,m=/\r\n?|\n/,j=a.ownerDocument,k=j.createElement("li");a.firstChild;)k.appendChild(a.firstChild);for(var c=[k],i=0;i<c.length;++i)b(c[i]);d===(d|0)&&c[0].setAttribute("value",d);var r=j.createElement("ol");
+r.className="linenums";for(var d=Math.max(0,d-1|0)||0,i=0,n=c.length;i<n;++i)k=c[i],k.className="L"+(i+d)%10,k.firstChild||k.appendChild(j.createTextNode("\u00a0")),r.appendChild(k);a.appendChild(r)}function p(a,d){for(var g=d.length;--g>=0;){var b=d[g];F.hasOwnProperty(b)?D.console&&console.warn("cannot override language handler %s",b):F[b]=a}}function I(a,d){if(!a||!F.hasOwnProperty(a))a=/^\s*</.test(d)?"default-markup":"default-code";return F[a]}function K(a){var d=a.h;try{var g=T(a.c,a.i),b=g.a;
+a.a=b;a.d=g.d;a.e=0;I(d,b)(a);var s=/\bMSIE\s(\d+)/.exec(navigator.userAgent),s=s&&+s[1]<=8,d=/\n/g,x=a.a,m=x.length,g=0,j=a.d,k=j.length,b=0,c=a.g,i=c.length,r=0;c[i]=m;var n,e;for(e=n=0;e<i;)c[e]!==c[e+2]?(c[n++]=c[e++],c[n++]=c[e++]):e+=2;i=n;for(e=n=0;e<i;){for(var p=c[e],w=c[e+1],t=e+2;t+2<=i&&c[t+1]===w;)t+=2;c[n++]=p;c[n++]=w;e=t}c.length=n;var f=a.c,h;if(f)h=f.style.display,f.style.display="none";try{for(;b<k;){var l=j[b+2]||m,B=c[r+2]||m,t=Math.min(l,B),A=j[b+1],G;if(A.nodeType!==1&&(G=x.substring(g,
+t))){s&&(G=G.replace(d,"\r"));A.nodeValue=G;var L=A.ownerDocument,o=L.createElement("span");o.className=c[r+1];var v=A.parentNode;v.replaceChild(o,A);o.appendChild(A);g<l&&(j[b+1]=A=L.createTextNode(x.substring(t,l)),v.insertBefore(A,o.nextSibling))}g=t;g>=l&&(b+=2);g>=B&&(r+=2)}}finally{if(f)f.style.display=h}}catch(u){D.console&&console.log(u&&u.stack||u)}}var D=window,y=["break,continue,do,else,for,if,return,while"],E=[[y,"auto,case,char,const,default,double,enum,extern,float,goto,inline,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],
+"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],M=[E,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,delegate,dynamic_cast,explicit,export,friend,generic,late_check,mutable,namespace,nullptr,property,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],N=[E,"abstract,assert,boolean,byte,extends,final,finally,implements,import,instanceof,interface,null,native,package,strictfp,super,synchronized,throws,transient"],
+O=[N,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,internal,into,is,let,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var,virtual,where"],E=[E,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],P=[y,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],
+Q=[y,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],W=[y,"as,assert,const,copy,drop,enum,extern,fail,false,fn,impl,let,log,loop,match,mod,move,mut,priv,pub,pure,ref,self,static,struct,true,trait,type,unsafe,use"],y=[y,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],R=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)\b/,
+V=/\S/,X=v({keywords:[M,O,E,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",P,Q,y],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),F={};p(X,["default-code"]);p(C([],[["pln",/^[^<?]+/],["dec",/^<!\w[^>]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",
+/^<xmp\b[^>]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);p(C([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],
+["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css",/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);p(C([],[["atv",/^[\S\s]+/]]),["uq.val"]);p(v({keywords:M,hashComments:!0,cStyleComments:!0,types:R}),["c","cc","cpp","cxx","cyc","m"]);p(v({keywords:"null,true,false"}),["json"]);p(v({keywords:O,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:R}),
+["cs"]);p(v({keywords:N,cStyleComments:!0}),["java"]);p(v({keywords:y,hashComments:!0,multiLineStrings:!0}),["bash","bsh","csh","sh"]);p(v({keywords:P,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),["cv","py","python"]);p(v({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:2}),["perl","pl","pm"]);p(v({keywords:Q,
+hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb","ruby"]);p(v({keywords:E,cStyleComments:!0,regexLiterals:!0}),["javascript","js"]);p(v({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,throw,true,try,unless,until,when,while,yes",hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);p(v({keywords:W,cStyleComments:!0,multilineStrings:!0}),["rc","rs","rust"]);
+p(C([],[["str",/^[\S\s]+/]]),["regex"]);var Y=D.PR={createSimpleLexer:C,registerLangHandler:p,sourceDecorator:v,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ",prettyPrintOne:D.prettyPrintOne=function(a,d,g){var b=document.createElement("div");b.innerHTML="<pre>"+a+"</pre>";b=b.firstChild;g&&J(b,g,!0);K({h:d,j:g,c:b,i:1});
+return b.innerHTML},prettyPrint:D.prettyPrint=function(a,d){function g(){for(var b=D.PR_SHOULD_USE_CONTINUATION?c.now()+250:Infinity;i<p.length&&c.now()<b;i++){for(var d=p[i],j=h,k=d;k=k.previousSibling;){var m=k.nodeType,o=(m===7||m===8)&&k.nodeValue;if(o?!/^\??prettify\b/.test(o):m!==3||/\S/.test(k.nodeValue))break;if(o){j={};o.replace(/\b(\w+)=([\w%+\-.:]+)/g,function(a,b,c){j[b]=c});break}}k=d.className;if((j!==h||e.test(k))&&!v.test(k)){m=!1;for(o=d.parentNode;o;o=o.parentNode)if(f.test(o.tagName)&&
+o.className&&e.test(o.className)){m=!0;break}if(!m){d.className+=" prettyprinted";m=j.lang;if(!m){var m=k.match(n),y;if(!m&&(y=U(d))&&t.test(y.tagName))m=y.className.match(n);m&&(m=m[1])}if(w.test(d.tagName))o=1;else var o=d.currentStyle,u=s.defaultView,o=(o=o?o.whiteSpace:u&&u.getComputedStyle?u.getComputedStyle(d,q).getPropertyValue("white-space"):0)&&"pre"===o.substring(0,3);u=j.linenums;if(!(u=u==="true"||+u))u=(u=k.match(/\blinenums\b(?::(\d+))?/))?u[1]&&u[1].length?+u[1]:!0:!1;u&&J(d,u,o);r=
+{h:m,c:d,j:u,i:o};K(r)}}}i<p.length?setTimeout(g,250):"function"===typeof a&&a()}for(var b=d||document.body,s=b.ownerDocument||document,b=[b.getElementsByTagName("pre"),b.getElementsByTagName("code"),b.getElementsByTagName("xmp")],p=[],m=0;m<b.length;++m)for(var j=0,k=b[m].length;j<k;++j)p.push(b[m][j]);var b=q,c=Date;c.now||(c={now:function(){return+new Date}});var i=0,r,n=/\blang(?:uage)?-([\w.]+)(?!\S)/,e=/\bprettyprint\b/,v=/\bprettyprinted\b/,w=/pre|xmp/i,t=/^code$/i,f=/^(?:pre|code|xmp)$/i,
+h={};g()}};typeof define==="function"&&define.amd&&define("google-code-prettify",[],function(){return Y})})();}()
diff --git a/static/js/sitewide/linked-accounts.js b/static/js/sitewide/linked-accounts.js
new file mode 100644
index 0000000..e7fa7e1
--- /dev/null
+++ b/static/js/sitewide/linked-accounts.js
@@ -0,0 +1,80 @@
+/* Copyright 2019 The Chromium Authors. All rights reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+const parentSelect = document.getElementById('parent_to_invite');
+const createButton = document.getElementById('create_linked_account_invite');
+const acceptButtons = document.querySelectorAll('.incoming_invite');
+const unlinkButtons = document.querySelectorAll('.unlink_account');
+
+function CreateLinkedAccountInvite(ev) {
+  const email = parentSelect.value;
+  const message = {
+    email: email,
+  };
+  const inviteCall = window.prpcClient.call(
+    'monorail.Users', 'InviteLinkedParent', message);
+  inviteCall.then((resp) => {
+    location.reload();
+  }).catch((reason) => {
+    console.error('Inviting failed: ' + reason);
+  });
+}
+
+function AcceptIncomingInvite(ev) {
+  const email = ev.target.attributes['data-email'].value;
+  const message = {
+    email: email,
+  };
+  const acceptCall = window.prpcClient.call(
+    'monorail.Users', 'AcceptLinkedChild', message);
+  acceptCall.then((resp) => {
+    location.reload();
+  }).catch((reason) => {
+    console.error('Accepting failed: ' + reason);
+  });
+}
+
+
+function UnlinkAccounts(ev) {
+  const parent = ev.target.dataset.parent;
+  const child = ev.target.dataset.child;
+  const message = {
+    parent: {display_name: parent},
+    child: {display_name: child},
+  };
+  const unlinkCall = window.prpcClient.call(
+    'monorail.Users', 'UnlinkAccounts', message);
+  unlinkCall.then((resp) => {
+    location.reload();
+  }).catch((reason) => {
+    console.error('Unlinking failed: ' + reason);
+  });
+}
+
+
+if (parentSelect) {
+  parentSelect.onchange = function(e) {
+    const email = parentSelect.value;
+    createButton.disabled = email ? '' : 'disabled';
+  };
+}
+
+if (createButton) {
+  createButton.onclick = CreateLinkedAccountInvite;
+}
+
+if (acceptButtons) {
+  for (const acceptButton of acceptButtons) {
+    acceptButton.onclick = AcceptIncomingInvite;
+  }
+}
+
+if (unlinkButtons) {
+  for (const unlinkButton of unlinkButtons) {
+    unlinkButton.onclick = UnlinkAccounts;
+  }
+}
diff --git a/static/js/tracker/ac.js b/static/js/tracker/ac.js
new file mode 100644
index 0000000..4c0bf2b
--- /dev/null
+++ b/static/js/tracker/ac.js
@@ -0,0 +1,1010 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * An autocomplete library for javascript.
+ * Public API
+ * - _ac_install() install global handlers required for everything else to
+ *   function.
+ * - _ac_register(SC) register a store constructor (see below)
+ * - _ac_isCompleting() true iff focus is in an auto complete box and the user
+ *   has triggered completion with a keystroke, and completion has not been
+ *   cancelled (programatically or otherwise).
+ * - _ac_isCompleteListShowing() true if _as_isCompleting and the complete list
+ *   is visible to the user.
+ * - _ac_cancel() if completing, stop it, otherwise a no-op.
+ *
+ *
+ * A quick example
+ *     // an auto complete store
+ *     var myFavoritestAutoCompleteStore = new _AC_SimpleStore(
+ *       ['some', 'strings', 'to', 'complete']);
+ *
+ *     // a store constructor
+ *     _ac_register(function (inputNode, keyEvent) {
+ *         if (inputNode.id == 'my-auto-completing-check-box') {
+ *           return myFavoritestAutoCompleteStore;
+ *         }
+ *         return null;
+ *       });
+ *
+ *     <html>
+ *       <head>
+ *         <script type=text/javascript src=ac.js></script>
+ *       </head>
+ *       <body onload=_ac_install()>
+ *         <!-- the constructor above looks at the id.  It could as easily
+ *            - look at the class, name, or value.
+ *            - The autocomplete=off stops browser autocomplete from
+ *            - interfering with our autocomplete
+ *           -->
+ *         <input type=text id="my-auto-completing-check-box"
+ *          autocomplete=off>
+ *       </body>
+ *     </html>
+ *
+ *
+ * Concepts
+ * - Store Constructor function
+ *   A store constructor is a policy function with the signature
+ *     _AC_Store myStoreConstructor(
+ *       HtmlInputElement|HtmlTextAreaElement inputNode, Event keyEvent)
+ *   When a key event is received on a text input or text area, the autocomplete
+ *   library will try each of the store constructors in turn until it finds one
+ *   that returns an AC_Store which will be used for auto-completion of that
+ *   text box until focus is lost.
+ *
+ * - interface _AC_Store
+ *   An autocomplete store encapsulates all operations that affect how a
+ *   particular text node is autocompleted.  It has the following operations:
+ *   - String completable(String inputValue, int caret)
+ *     This method returns null if not completable or the section of inputValue
+ *     that is subject to completion.  If autocomplete works on items in a
+ *     comma separated list, then the input value "foo, ba" might yield "ba"
+ *     as the completable chunk since it is separated from its predecessor by
+ *     a comma.
+ *     caret is the position of the text cursor (caret) in the text input.
+ *   - _AC_Completion[] completions(String completable,
+ *                                  _AC_Completion[] toFilter)
+ *     This method returns null if there are no completions.  If toFilter is
+ *     not null or undefined, then this method may assume that toFilter was
+ *     returned as a set of completions that contain completable.
+ *   - String substitute(String inputValue, int caret,
+ *                       String completable, _AC_Completion completion)
+ *     returns the inputValue with the given completion substituted for the
+ *     given completable.  caret has the same meaning as in the
+ *     completable operation.
+ *   - String oncomplete(boolean completed, String key,
+ *                       HTMLElement element, String text)
+ *     This method is called when the user hits a completion key. The default
+ *     value is to do nothing, but you can override it if you want. Note that
+ *     key will be null if the user clicked on it to select
+ *   - Boolean autoselectFirstRow()
+ *     This method returns True by default, but subclasses can override it
+ *     to make autocomplete fields that require the user to press the down
+ *     arrow or do a mouseover once before any completion option is considered
+ *     to be selected.
+ *
+ * - class _AC_SimpleStore
+ *   An implementation of _AC_Store that completes a set of strings given at
+ *   construct time in a text field with a comma separated value.
+ *
+ * - struct _AC_Completion
+ *   a struct with two fields
+ *   - String value : the plain text completion value
+ *   - String html : the value, as html, with the completable in bold.
+ *
+ * Key Handling
+ * Several keys affect completion in an autocompleted input.
+ * ESC - the escape key cancels autocompleting.  The autocompletion will have
+ *   no effect on the focused textbox until it loses focus, regains it, and
+ *   a key is pressed.
+ * ENTER - completes using the currently selected completion, or if there is
+ *   only one, uses that completion.
+ * UP ARROW - selects the completion above the current selection.
+ * DOWN ARROW - selects the completion below the current selection.
+ *
+ *
+ * CSS styles
+ * The following CSS selector rules can be used to change the completion list
+ * look:
+ * #ac-list               style of the auto-complete list
+ * #ac-list .selected     style of the selected item
+ * #ac-list b             style of the matching text in a candidate completion
+ *
+ * Dependencies
+ * The library depends on the following libraries:
+ * javascript:base for definition of key constants and SetCursorPos
+ * javascript:shapes for nodeBounds()
+ */
+
+/**
+ * install global handlers required for the rest of the module to function.
+ */
+function _ac_install() {
+  ac_addHandler_(document.body, 'onkeydown', ac_keyevent_);
+  ac_addHandler_(document.body, 'onkeypress', ac_keyevent_);
+}
+
+/**
+ * register a store constructor
+ * @param storeConstructor a function like
+ *   _AC_Store myStoreConstructor(HtmlInputElement|HtmlTextArea, Event)
+ */
+function _ac_register(storeConstructor) {
+  // check that not already registered
+  for (let i = ac_storeConstructors.length; --i >= 0;) {
+    if (ac_storeConstructors[i] === storeConstructor) {
+      return;
+    }
+  }
+  ac_storeConstructors.push(storeConstructor);
+}
+
+/**
+ * may be attached as an onfocus handler to a text input to popup autocomplete
+ * immediately on the box gaining focus.
+ */
+function _ac_onfocus(event) {
+  ac_keyevent_(event);
+}
+
+/**
+ * true iff the autocomplete widget is currently active.
+ */
+function _ac_isCompleting() {
+  return !!ac_store && !ac_suppressCompletions;
+}
+
+/**
+ * true iff the completion list is displayed.
+ */
+function _ac_isCompleteListShowing() {
+  return !!ac_store && !ac_suppressCompletions && ac_completions &&
+    ac_completions.length;
+}
+
+/**
+ * cancel any autocomplete in progress.
+ */
+function _ac_cancel() {
+  ac_suppressCompletions = true;
+  ac_updateCompletionList(false);
+}
+
+/** add a handler without whacking any existing handler. @private */
+function ac_addHandler_(node, handlerName, handler) {
+  const oldHandler = node[handlerName];
+  if (!oldHandler) {
+    node[handlerName] = handler;
+  } else {
+    node[handlerName] = ac_fnchain_(node[handlerName], handler);
+  }
+  return oldHandler;
+}
+
+/** cancel the event. @private */
+function ac_cancelEvent_(event) {
+  if ('stopPropagation' in event) {
+    event.stopPropagation();
+  } else {
+    event.cancelBubble = true;
+  }
+
+  // This is handled in IE by returning false from the handler
+  if ('preventDefault' in event) {
+    event.preventDefault();
+  }
+}
+
+/** Call two functions, a and b, and return false if either one returns
+    false.  This is used as a primitive way to attach multiple event
+    handlers to an element without using addEventListener().   This
+    library predates the availablity of addEventListener().
+    @private
+*/
+function ac_fnchain_(a, b) {
+  return function() {
+    const ar = a.apply(this, arguments);
+    const br = b.apply(this, arguments);
+
+    // NOTE 1: (undefined && false) -> undefined
+    // NOTE 2: returning FALSE from a onkeypressed cancels it,
+    //         returning UNDEFINED does not.
+    // As such, we specifically look for falses here
+    if (ar === false || br === false) {
+      return false;
+    } else {
+      return true;
+    }
+  };
+}
+
+/** key press handler.  @private */
+function ac_keyevent_(event) {
+  event = event || window.event;
+
+  const source = getTargetFromEvent(event);
+  const isInput = 'INPUT' == source.tagName &&
+    source.type.match(/^text|email$/i);
+  const isTextarea = 'TEXTAREA' == source.tagName;
+  if (!isInput && !isTextarea) return true;
+
+  const key = event.key;
+  const isDown = event.type == 'keydown';
+  const isShiftKey = event.shiftKey;
+  let storeFound = true;
+
+  if ((source !== ac_focusedInput) || (ac_store === null)) {
+    ac_focusedInput = source;
+    storeFound = false;
+    if (ENTER_KEYNAME !== key && ESC_KEYNAME !== key) {
+      for (let i = 0; i < ac_storeConstructors.length; ++i) {
+        const store = (ac_storeConstructors[i])(source, event);
+        if (store) {
+          ac_store = store;
+          ac_store.setAvoid(event);
+          ac_oldBlurHandler = ac_addHandler_(
+              ac_focusedInput, 'onblur', _ac_ob);
+          storeFound = true;
+          break;
+        }
+      }
+
+      // There exists an odd condition where an edit box with autocomplete
+      // attached can be removed from the DOM without blur being called
+      // In which case we are left with a store around that will try to
+      // autocomplete the next edit box to receive focus. We need to clean
+      // this up
+
+      // If we can't find a store, force a blur
+      if (!storeFound) {
+        _ac_ob(null);
+      }
+    }
+    // ac-table rows need to be removed when switching to another input.
+    ac_updateCompletionList(false);
+  }
+  // If the user typed Esc when the auto-complete menu was not shown,
+  // then blur the input text field so that the user can use keyboard
+  // shortcuts.
+  const acList = document.getElementById('ac-list');
+  if (ESC_KEYNAME == key &&
+      (!acList || acList.style.display == 'none')) {
+    ac_focusedInput.blur();
+  }
+
+  if (!storeFound) return true;
+
+  const isCompletion = ac_store.isCompletionKey(key, isDown, isShiftKey);
+  const hasResults = ac_completions && (ac_completions.length > 0);
+  let cancelEvent = false;
+
+  if (isCompletion && hasResults) {
+    // Cancel any enter keystrokes if something is selected so that the
+    // browser doesn't go submitting the form.
+    cancelEvent = (!ac_suppressCompletions && !!ac_completions &&
+                      (ac_selected != -1));
+    window.setTimeout(function() {
+      if (ac_store) {
+        ac_handleKey_(key, isDown, isShiftKey);
+      }
+    }, 0);
+  } else if (!isCompletion) {
+    // Don't want to also blur the field. Up and down move the cursor (in
+    // Firefox) to the start/end of the field. We also don't want that while
+    // the list is showing.
+    cancelEvent = (key == ESC_KEYNAME ||
+                  key == DOWN_KEYNAME ||
+                  key == UP_KEYNAME);
+
+    window.setTimeout(function() {
+      if (ac_store) {
+        ac_handleKey_(key, isDown, isShiftKey);
+      }
+    }, 0);
+  } else { // implicit if (isCompletion && !hasResults)
+    if (ac_store.oncomplete) {
+      ac_store.oncomplete(false, key, ac_focusedInput, undefined);
+    }
+  }
+
+  if (cancelEvent) {
+    ac_cancelEvent_(event);
+  }
+
+  return !cancelEvent;
+}
+
+/** Autocomplete onblur handler. */
+function _ac_ob(event) {
+  if (ac_focusedInput) {
+    ac_focusedInput.onblur = ac_oldBlurHandler;
+  }
+  ac_store = null;
+  ac_focusedInput = null;
+  ac_everTyped = false;
+  ac_oldBlurHandler = null;
+  ac_suppressCompletions = false;
+  ac_updateCompletionList(false);
+}
+
+/** @constructor */
+function _AC_Store() {
+}
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.completable = function(inputValue, caret) {
+  console.log('UNIMPLEMENTED completable');
+};
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.completions = function(prefix, tofilter) {
+  console.log('UNIMPLEMENTED completions');
+};
+/** returns the chunk of the input to treat as completable. */
+_AC_Store.prototype.oncomplete = function(completed, key, element, text) {
+  // Call the onkeyup handler so that choosing an autocomplete option has
+  // the same side-effect as typing.  E.g., exposing the next row of input
+  // fields.
+  element.dispatchEvent(new Event('keyup'));
+  _ac_ob();
+};
+/** substitutes a completion for a completable in a text input's value. */
+_AC_Store.prototype.substitute =
+  function(inputValue, caret, completable, completion) {
+    console.log('UNIMPLEMENTED substitute');
+  };
+/** true iff hitting a comma key should complete. */
+_AC_Store.prototype.commaCompletes = true;
+/**
+ * true iff the given keystroke should cause a completion (and be consumed in
+ * the process.
+ */
+_AC_Store.prototype.isCompletionKey = function(key, isDown, isShiftKey) {
+  if (!isDown && (ENTER_KEYNAME === key ||
+                  (COMMA_KEYNAME == key && this.commaCompletes))) {
+    return true;
+  }
+  if (TAB_KEYNAME === key && !isShiftKey) {
+    // IE doesn't fire an event for tab on click in a text field, and firefox
+    // requires that the onkeypress event for tab be consumed or it navigates
+    // to next field.
+    return false;
+    // JER: return isDown == BR_IsIE();
+  }
+  return false;
+};
+
+_AC_Store.prototype.setAvoid = function(event) {
+  if (event && event.avoidValues) {
+    ac_avoidValues = event.avoidValues;
+  } else {
+    ac_avoidValues = this.computeAvoid();
+  }
+  ac_avoidValues = ac_avoidValues.map((val) => val.toLowerCase());
+};
+
+/* Subclasses may implement this to compute values to avoid
+   offering in the current input field, i.e., because those
+   values are already used. */
+_AC_Store.prototype.computeAvoid = function() {
+  return [];
+};
+
+
+function _AC_AddItemToFirstCharMap(firstCharMap, ch, s) {
+  let l = firstCharMap[ch];
+  if (!l) {
+    l = firstCharMap[ch] = [];
+  } else if (l[l.length - 1].value == s) {
+    return;
+  }
+  l.push(new _AC_Completion(s, null, ''));
+}
+
+/**
+ * an _AC_Store implementation suitable for completing lists of email
+ * addresses.
+ * @constructor
+ */
+function _AC_SimpleStore(strings, opt_docStrings) {
+  this.firstCharMap_ = {};
+
+  for (let i = 0; i < strings.length; ++i) {
+    let s = strings[i];
+    if (!s) {
+      continue;
+    }
+    if (opt_docStrings && opt_docStrings[s]) {
+      s = s + ' ' + opt_docStrings[s];
+    }
+
+    const parts = s.split(/\W+/);
+    for (let j = 0; j < parts.length; ++j) {
+      if (parts[j]) {
+        _AC_AddItemToFirstCharMap(
+            this.firstCharMap_, parts[j].charAt(0).toLowerCase(), strings[i]);
+      }
+    }
+  }
+
+  // The maximimum number of results that we are willing to show
+  this.countThreshold = 2500;
+  this.docstrings = opt_docStrings || {};
+}
+_AC_SimpleStore.prototype = new _AC_Store();
+_AC_SimpleStore.prototype.constructor = _AC_SimpleStore;
+
+_AC_SimpleStore.prototype.completable =
+  function(inputValue, caret) {
+  // complete after the last comma not inside ""s
+    let start = 0;
+    let state = 0;
+    for (let i = 0; i < caret; ++i) {
+      const ch = inputValue.charAt(i);
+      switch (state) {
+        case 0:
+          if ('"' == ch) {
+            state = 1;
+          } else if (',' == ch || ' ' == ch) {
+            start = i + 1;
+          }
+          break;
+        case 1:
+          if ('"' == ch) {
+            state = 0;
+          }
+          break;
+      }
+    }
+    while (start < caret &&
+         ' \t\r\n'.indexOf(inputValue.charAt(start)) >= 0) {
+      ++start;
+    }
+    return inputValue.substring(start, caret);
+  };
+
+
+/** Simple function to create a <span> with matching text in bold.
+ */
+function _AC_CreateSpanWithMatchHighlighted(match) {
+  const span = document.createElement('span');
+  span.appendChild(document.createTextNode(match[1] || ''));
+  const bold = document.createElement('b');
+  span.appendChild(bold);
+  bold.appendChild(document.createTextNode(match[2]));
+  span.appendChild(document.createTextNode(match[3] || ''));
+  return span;
+};
+
+
+/**
+ * Get all completions matching the given prefix.
+ * @param {string} prefix The prefix of the text to autocomplete on.
+ * @param {List.<string>?} toFilter Optional list to filter on. Otherwise will
+ *     use this.firstCharMap_ using the prefix's first character.
+ * @return {List.<_AC_Completion>} The computed list of completions.
+ */
+_AC_SimpleStore.prototype.completions = function(prefix) {
+  if (!prefix) {
+    return [];
+  }
+  toFilter = this.firstCharMap_[prefix.charAt(0).toLowerCase()];
+
+  // Since we use prefix to build a regular expression, we need to escape RE
+  // characters. We match '-', '{', '$' and others in the prefix and convert
+  // them into "\-", "\{", "\$".
+  const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
+  const modifiedPrefix = prefix.replace(regexForRegexCharacters, '\\$1');
+
+  // Match the modifiedPrefix anywhere as long as it is either at the very
+  // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
+  // such as "Ga" -> "The-Great-Gatsby".
+  const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
+  const pattern = new RegExp(patternRegex, 'i' /* ignore case */);
+
+  // We keep separate lists of possible completions that were generated
+  // by matching a value or generated by matching a docstring.  We return
+  // a concatenated list so that value matches all come before docstring
+  // matches.
+  const completions = [];
+  const docCompletions = [];
+
+  if (toFilter) {
+    const toFilterLength = toFilter.length;
+    for (let i = 0; i < toFilterLength; ++i) {
+      const docStr = this.docstrings[toFilter[i].value];
+      let compSpan = null;
+      let docSpan = null;
+      const matches = toFilter[i].value.match(pattern);
+      const docMatches = docStr && docStr.match(pattern);
+      if (matches) {
+        compSpan = _AC_CreateSpanWithMatchHighlighted(matches);
+        if (docStr) docSpan = document.createTextNode(docStr);
+      } else if (docMatches) {
+        compSpan = document.createTextNode(toFilter[i].value);
+        docSpan = _AC_CreateSpanWithMatchHighlighted(docMatches);
+      }
+
+      if (compSpan) {
+        const newCompletion = new _AC_Completion(
+            toFilter[i].value, compSpan, docSpan);
+
+        if (matches) {
+          completions.push(newCompletion);
+        } else {
+          docCompletions.push(newCompletion);
+        }
+        if (completions.length + docCompletions.length > this.countThreshold) {
+          break;
+        }
+      }
+    }
+  }
+
+  return completions.concat(docCompletions);
+};
+
+// Normally, when the user types a few characters, we aggressively
+// select the first possible completion (if any).  When the user
+// hits ENTER, that first completion is substituted.  When that
+// behavior is not desired, override this to return false.
+_AC_SimpleStore.prototype.autoselectFirstRow = function() {
+  return true;
+};
+
+// Comparison function for _AC_Completion
+function _AC_CompareACCompletion(a, b) {
+  // convert it to lower case and remove all leading junk
+  const aval = a.value.toLowerCase().replace(/^\W*/, '');
+  const bval = b.value.toLowerCase().replace(/^\W*/, '');
+
+  if (a.value === b.value) {
+    return 0;
+  } else if (aval < bval) {
+    return -1;
+  } else {
+    return 1;
+  }
+}
+
+_AC_SimpleStore.prototype.substitute =
+function(inputValue, caret, completable, completion) {
+  return inputValue.substring(0, caret - completable.length) +
+    completion.value + ', ' + inputValue.substring(caret);
+};
+
+/**
+ * a possible completion.
+ * @constructor
+ */
+function _AC_Completion(value, compSpan, docSpan) {
+  /** plain text. */
+  this.value = value;
+  if (typeof compSpan == 'string') compSpan = document.createTextNode(compSpan);
+  this.compSpan = compSpan;
+  if (typeof docSpan == 'string') docSpan = document.createTextNode(docSpan);
+  this.docSpan = docSpan;
+}
+_AC_Completion.prototype.toString = function() {
+  return '(AC_Completion: ' + this.value + ')';
+};
+
+/** registered store constructors.  @private */
+var ac_storeConstructors = [];
+/**
+ * the focused text input or textarea whether store is null or not.
+ * A text input may have focus and this may be null iff no key has been typed in
+ * the text input.
+ */
+var ac_focusedInput = null;
+/**
+ * null or the autocomplete store used to complete ac_focusedInput.
+ * @private
+ */
+var ac_store = null;
+/** store handler from ac_focusedInput. @private */
+var ac_oldBlurHandler = null;
+/**
+ * true iff user has indicated completions are unwanted (via ESC key)
+ * @private
+ */
+var ac_suppressCompletions = false;
+/**
+ * chunk of completable text seen last keystroke.
+ * Used to generate ac_completions.
+ * @private
+ */
+let ac_lastCompletable = null;
+/** an array of _AC_Completions.  @private */
+var ac_completions = null;
+/** -1 or in [0, _AC_Completions.length).  @private */
+var ac_selected = -1;
+
+/** Maximum number of options displayed in menu. @private */
+const ac_max_options = 100;
+
+/** Don't offer these values because they are already used. @private */
+let ac_avoidValues = [];
+
+/**
+ * handles all the key strokes, updating the completion list, tracking selected
+ * element, performing substitutions, etc.
+ * @private
+ */
+function ac_handleKey_(key, isDown, isShiftKey) {
+  // check completions
+  ac_checkCompletions();
+  let show = true;
+  const numCompletions = ac_completions ? ac_completions.length : 0;
+  // handle enter and tab on key press and the rest on key down
+  if (ac_store.isCompletionKey(key, isDown, isShiftKey)) {
+    if (ac_selected < 0 && numCompletions >= 1 &&
+        ac_store.autoselectFirstRow()) {
+      ac_selected = 0;
+    }
+    if (ac_selected >= 0) {
+      const backupInput = ac_focusedInput;
+      const completeValue = ac_completions[ac_selected].value;
+      ac_complete();
+      if (ac_store.oncomplete) {
+        ac_store.oncomplete(true, key, backupInput, completeValue);
+      }
+    }
+  } else {
+    switch (key) {
+      case ESC_KEYNAME: // escape
+      // JER?? ac_suppressCompletions = true;
+        ac_selected = -1;
+        show = false;
+        break;
+      case UP_KEYNAME: // up
+        if (isDown) {
+        // firefox fires arrow events on both down and press, but IE only fires
+        // then on press.
+          ac_selected = Math.max(numCompletions >= 0 ? 0 : -1, ac_selected - 1);
+        }
+        break;
+      case DOWN_KEYNAME: // down
+        if (isDown) {
+          ac_selected = Math.min(
+              ac_max_options - 1, Math.min(numCompletions - 1, ac_selected + 1));
+        }
+        break;
+    }
+
+    if (isDown) {
+      switch (key) {
+        case ESC_KEYNAME:
+        case ENTER_KEYNAME:
+        case UP_KEYNAME:
+        case DOWN_KEYNAME:
+        case RIGHT_KEYNAME:
+        case LEFT_KEYNAME:
+        case TAB_KEYNAME:
+        case SHIFT_KEYNAME:
+        case BACKSPACE_KEYNAME:
+        case DELETE_KEYNAME:
+          break;
+        default: // User typed some new characters.
+          ac_everTyped = true;
+      }
+    }
+  }
+
+  if (ac_focusedInput) {
+    ac_updateCompletionList(show);
+  }
+}
+
+/**
+ * called when an option is clicked on to select that option.
+ */
+function _ac_select(optionIndex) {
+  ac_selected = optionIndex;
+  ac_complete();
+  if (ac_store.oncomplete) {
+    ac_store.oncomplete(true, null, ac_focusedInput, ac_focusedInput.value);
+  }
+
+  // check completions
+  ac_checkCompletions();
+  ac_updateCompletionList(true);
+}
+
+function _ac_mouseover(optionIndex) {
+  ac_selected = optionIndex;
+  ac_updateCompletionList(true);
+}
+
+/** perform the substitution of the currently selected item. */
+function ac_complete() {
+  const caret = ac_getCaretPosition_(ac_focusedInput);
+  const completion = ac_completions[ac_selected];
+
+  ac_focusedInput.value = ac_store.substitute(
+      ac_focusedInput.value, caret,
+      ac_lastCompletable, completion);
+  // When the prefix starts with '*' we want to return the complete set of all
+  // possible completions. We treat the ac_lastCompletable value as empty so
+  // that the caret is correctly calculated (i.e. the caret should not consider
+  // placeholder values like '*member').
+  let new_caret = caret + completion.value.length;
+  if (!ac_lastCompletable.startsWith('*')) {
+    // Only consider the ac_lastCompletable length if it does not start with '*'
+    new_caret = new_caret - ac_lastCompletable.length;
+  }
+  // If we inserted something ending in two quotation marks, position
+  // the cursor between the quotation marks. If we inserted a complete term,
+  // skip over the trailing space so that the user is ready to enter the next
+  // term.  If we inserted just a search operator, leave the cursor immediately
+  // after the colon or equals and don't skip over the space.
+  if (completion.value.substring(completion.value.length - 2) == '""') {
+    new_caret--;
+  } else if (completion.value.substring(completion.value.length - 1) != ':' &&
+             completion.value.substring(completion.value.length - 1) != '=') {
+    new_caret++; // To account for the comma.
+    new_caret++; // To account for the space after the comma.
+  }
+  ac_selected = -1;
+  ac_completions = null;
+  ac_lastCompletable = null;
+  ac_everTyped = false;
+  SetCursorPos(window, ac_focusedInput, new_caret);
+}
+
+/**
+ * True if the user has ever typed any actual characters in the currently
+ * focused text field.  False if they have only clicked, backspaced, and
+ * used the arrow keys.
+ */
+var ac_everTyped = false;
+
+/**
+ * maintains ac_completions, ac_selected, ac_lastCompletable.
+ * @private
+ */
+function ac_checkCompletions() {
+  if (ac_focusedInput && !ac_suppressCompletions) {
+    const caret = ac_getCaretPosition_(ac_focusedInput);
+    const completable = ac_store.completable(ac_focusedInput.value, caret);
+
+    // If we already have completed, then our work here is done.
+    if (completable == ac_lastCompletable) {
+      return;
+    }
+
+    ac_completions = null;
+    ac_selected = -1;
+
+    const oldSelected =
+      ((ac_selected >= 0 && ac_selected < ac_completions.length) ?
+        ac_completions[ac_selected].value : null);
+    ac_completions = ac_store.completions(completable);
+    // Don't offer options for values that the user has already used
+    // in another part of the current form.
+    ac_completions = ac_completions.filter((comp) =>
+      FindInArray(ac_avoidValues, comp.value.toLowerCase()) === -1);
+
+    ac_selected = oldSelected ? 0 : -1;
+    ac_lastCompletable = completable;
+    return;
+  }
+  ac_lastCompletable = null;
+  ac_completions = null;
+  ac_selected = -1;
+}
+
+/**
+ * maintains the completion list GUI.
+ * @private
+ */
+function ac_updateCompletionList(show) {
+  let clist = document.getElementById('ac-list');
+  const input = ac_focusedInput;
+  if (input) {
+    input.setAttribute('aria-activedescendant', 'ac-status-row-none');
+  }
+  let tableEl;
+  let tableBody;
+  if (show && ac_completions && ac_completions.length) {
+    if (!clist) {
+      clist = document.createElement('DIV');
+      clist.id = 'ac-list';
+      clist.style.position = 'absolute';
+      clist.style.display = 'none';
+      // with 'listbox' and 'option' roles, screenreader narrates total
+      // number of options eg. 'New = issue has not .... 1 of 9'
+      document.body.appendChild(clist);
+      tableEl = document.createElement('table');
+      tableEl.setAttribute('cellpadding', 0);
+      tableEl.setAttribute('cellspacing', 0);
+      tableEl.id = 'ac-table';
+      tableEl.setAttribute('role', 'presentation');
+      tableBody = document.createElement('tbody');
+      tableBody.id = 'ac-table-body';
+      tableEl.appendChild(tableBody);
+      tableBody.setAttribute('role', 'listbox');
+      clist.appendChild(tableEl);
+      input.setAttribute('aria-controls', 'ac-table');
+      input.setAttribute('aria-haspopup', 'grid');
+    } else {
+      tableEl = document.getElementById('ac-table');
+      tableBody = document.getElementById('ac-table-body');
+      while (tableBody.childNodes.length) {
+        tableBody.removeChild(tableBody.childNodes[0]);
+      }
+    }
+
+    // If no choice is selected, then select the first item, if desired.
+    if (ac_selected < 0 && ac_store && ac_store.autoselectFirstRow()) {
+      ac_selected = 0;
+    }
+
+    let headerCount= 0;
+    for (let i = 0; i < Math.min(ac_max_options, ac_completions.length); ++i) {
+      if (ac_completions[i].heading) {
+        var rowEl = document.createElement('tr');
+        tableBody.appendChild(rowEl);
+        const cellEl = document.createElement('th');
+        rowEl.appendChild(cellEl);
+        cellEl.setAttribute('colspan', 2);
+        if (headerCount) {
+          cellEl.appendChild(document.createElement('br'));
+        }
+        cellEl.appendChild(
+            document.createTextNode(ac_completions[i].heading));
+        headerCount++;
+      } else {
+        var rowEl = document.createElement('tr');
+        tableBody.appendChild(rowEl);
+        if (i == ac_selected) {
+          rowEl.className = 'selected';
+        }
+        rowEl.id = `ac-status-row-${i}`;
+        rowEl.setAttribute('data-index', i);
+        rowEl.setAttribute('role', 'option');
+        rowEl.addEventListener('mousedown', function(event) {
+          event.preventDefault();
+        });
+        rowEl.addEventListener('mouseup', function(event) {
+          let target = event.target;
+          while (target && target.tagName != 'TR') {
+            target = target.parentNode;
+          }
+          const idx = Number(target.getAttribute('data-index'));
+          try {
+            _ac_select(idx);
+          } finally {
+            return false;
+          }
+        });
+        rowEl.addEventListener('mouseover', function(event) {
+          let target = event.target;
+          while (target && target.tagName != 'TR') {
+            target = target.parentNode;
+          }
+          const idx = Number(target.getAttribute('data-index'));
+          _ac_mouseover(idx);
+        });
+        const valCellEl = document.createElement('td');
+        rowEl.appendChild(valCellEl);
+        if (ac_completions[i].compSpan) {
+          valCellEl.appendChild(ac_completions[i].compSpan);
+        }
+        const docCellEl = document.createElement('td');
+        rowEl.appendChild(docCellEl);
+        if (ac_completions[i].docSpan &&
+            ac_completions[i].docSpan.textContent) {
+          docCellEl.appendChild(document.createTextNode(' = '));
+          docCellEl.appendChild(ac_completions[i].docSpan);
+        }
+      }
+    }
+
+    // position
+    const inputBounds = nodeBounds(ac_focusedInput);
+    clist.style.left = inputBounds.x + 'px';
+    clist.style.top = (inputBounds.y + inputBounds.h) + 'px';
+
+    window.setTimeout(ac_autoscroll, 100);
+    input.setAttribute('aria-activedescendant', `ac-status-row-${ac_selected}`);
+    // Note - we use '' instead of 'block', since 'block' has odd effects on
+    // the screen in IE, and causes scrollbars to resize
+    clist.style.display = '';
+  } else {
+    tableBody = document.getElementById('ac-table-body');
+    if (clist && tableBody) {
+      clist.style.display = 'none';
+      while (tableBody.childNodes.length) {
+        tableBody.removeChild(tableBody.childNodes[0]);
+      }
+    }
+  }
+}
+
+// TODO(jrobbins): make arrow keys and mouse not conflict if they are
+// used at the same time.
+
+
+/** Scroll the autocomplete menu to show the currently selected row. */
+function ac_autoscroll() {
+  const acList = document.getElementById('ac-list');
+  const acSelRow = acList.getElementsByClassName('selected')[0];
+  const acSelRowTop = acSelRow ? acSelRow.offsetTop : 0;
+  const acSelRowHeight = acSelRow ? acSelRow.offsetHeight : 0;
+
+
+  const EXTRA = 8; // Go an extra few pixels so the next row is partly exposed.
+
+  if (!acList || !acSelRow) return;
+
+  // Autoscroll upward if the selected item is above the visible area,
+  // else autoscroll downward if the selected item is below the visible area.
+  if (acSelRowTop < acList.scrollTop) {
+    acList.scrollTop = acSelRowTop - EXTRA;
+  } else if (acSelRowTop + acSelRowHeight + EXTRA >
+             acList.scrollTop + acList.offsetHeight) {
+    acList.scrollTop = (acSelRowTop + acSelRowHeight -
+                        acList.offsetHeight + EXTRA);
+  }
+}
+
+
+/** the position of the text caret in the given text field.
+ *
+ * @param textField an INPUT node with type=text or a TEXTAREA node
+ * @return an index in [0, textField.value.length]
+ */
+function ac_getCaretPosition_(textField) {
+  if ('INPUT' == textField.tagName) {
+    let caret = textField.value.length;
+
+    // chrome/firefox
+    if (undefined != textField.selectionStart) {
+      caret = textField.selectionEnd;
+
+      // JER: Special treatment for issue status field that makes all
+      // options show up more often
+      if (textField.id.startsWith('status')) {
+        caret = textField.selectionStart;
+      }
+      // ie
+    } else if (document.selection) {
+      // get an empty selection range
+      const range = document.selection.createRange();
+      const origSelectionLength = range.text.length;
+      // Force selection start to 0 position
+      range.moveStart('character', -caret);
+      // the caret end position is the new selection length
+      caret = range.text.length;
+
+      // JER: Special treatment for issue status field that makes all
+      // options show up more often
+      if (textField.id.startsWith('status')) {
+        // The amount that the selection grew when we forced start to
+        // position 0 is == the original start position.
+        caret = range.text.length - origSelectionLength;
+      }
+    }
+
+    return caret;
+  } else {
+    // a textarea
+
+    return GetCursorPos(window, textField);
+  }
+}
+
+function getTargetFromEvent(event) {
+  let targ = event.target || event.srcElement;
+  if (targ.shadowRoot) {
+    // Find the element within the shadowDOM.
+    const path = event.path || event.composedPath();
+    targ = path[0];
+  }
+  return targ;
+}
diff --git a/static/js/tracker/ac_test.js b/static/js/tracker/ac_test.js
new file mode 100644
index 0000000..30eedc5
--- /dev/null
+++ b/static/js/tracker/ac_test.js
@@ -0,0 +1,40 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+var firstCharMap;
+
+function setUp() {
+  firstCharMap = new Object();
+}
+
+function testAddItemToFirstCharMap_OneWordLabel() {
+  _AC_AddItemToFirstCharMap(firstCharMap, 'h', 'Hot');
+  let hArray = firstCharMap['h'];
+  assertEquals(1, hArray.length);
+  assertEquals('Hot', hArray[0].value);
+
+  _AC_AddItemToFirstCharMap(firstCharMap, '-', '-Hot');
+  _AC_AddItemToFirstCharMap(firstCharMap, 'h', '-Hot');
+  let minusArray = firstCharMap['-'];
+  assertEquals(1, minusArray.length);
+  assertEquals('-Hot', minusArray[0].value);
+  hArray = firstCharMap['h'];
+  assertEquals(2, hArray.length);
+  assertEquals('Hot', hArray[0].value);
+  assertEquals('-Hot', hArray[1].value);
+}
+
+function testAddItemToFirstCharMap_KeyValueLabels() {
+  _AC_AddItemToFirstCharMap(firstCharMap, 'p', 'Priority-High');
+  _AC_AddItemToFirstCharMap(firstCharMap, 'h', 'Priority-High');
+  let pArray = firstCharMap['p'];
+  assertEquals(1, pArray.length);
+  assertEquals('Priority-High', pArray[0].value);
+  let hArray = firstCharMap['h'];
+  assertEquals(1, hArray.length);
+  assertEquals('Priority-High', hArray[0].value);
+}
diff --git a/static/js/tracker/externs.js b/static/js/tracker/externs.js
new file mode 100644
index 0000000..2a92f58
--- /dev/null
+++ b/static/js/tracker/externs.js
@@ -0,0 +1,115 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+
+// Defined in framework/js:core_scripts
+var _hideID;
+var _showID;
+var _hideEl;
+var _showEl;
+var _showInstead;
+var _toggleHidden;
+
+var _selectAllIssues;
+var _selectNoneIssues;
+
+var _toggleRows;
+var _toggleColumn;
+var _toggleColumnUpdate;
+var _addGroupBy;
+var _addcol;
+var _checkRangeSelect;
+var _setRowLinks;
+var _makeIssueLink;
+
+var _onload;
+
+var _handleListActions;
+var _handleDetailActions;
+
+var _loadStatusSelect;
+var _fetchOptions;
+var _setACOptions;
+var _openIssueUpdateForm;
+var _addAttachmentFields;
+var _ignoreWidgetIfOpIsClear;
+
+var _formatContextQueryArgs;
+var _ctxArgs;
+var _ctxCan;
+var _ctxQuery;
+var _ctxSortspec;
+var _ctxGroupBy;
+var _ctxDefaultColspec;
+var _ctxStart;
+var _ctxNum;
+var _ctxResultsPerPage;
+
+var _filterTo;
+var _sortUp;
+var _sortDown;
+
+var _closeAllPopups;
+var _closeSubmenus;
+var _showRight;
+var _showBelow;
+var _highlightRow;
+var _highlightRowCallback;
+var _allColumnNames;
+
+var _setFieldIDs;
+var _selectTemplate;
+var _saveTemplate;
+var _newTemplate;
+var _deleteTemplate;
+var _switchTemplate;
+var _templateNames;
+
+var _confirmNovelStatus;
+var _confirmNovelLabel;
+var _lfidprefix;
+var _allOrigLabels;
+var _vallab;
+var _exposeExistingLabelFields;
+var _confirmDiscardEntry;
+var _confirmDiscardUpdate;
+var _checkPlusOne;
+var _checkUnrestrict;
+
+var _clearOnFirstEvent;
+var _forceProperTableWidth;
+
+var _acof;
+var _acmo;
+var _acse;
+var _acstore;
+var _acreg;
+var _accomp;
+var _acrob;
+
+var _d;
+
+var _getColspec;
+
+var issueRefs;
+
+var kibbles;
+var _setupKibblesOnEntryPage;
+var _setupKibblesOnListPage;
+var _setupKibblesOnDetailPage;
+
+var CS_env;
+
+var _checkFieldNameOnServer;
+var _checkLeafName;
+
+var _addMultiFieldValueWidget;
+var _removeMultiFieldValueWidget;
+var console;
+var _trimCommas;
+
+var _initDragAndDrop;
diff --git a/static/js/tracker/render-hotlist-table.js b/static/js/tracker/render-hotlist-table.js
new file mode 100644
index 0000000..5004296
--- /dev/null
+++ b/static/js/tracker/render-hotlist-table.js
@@ -0,0 +1,436 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions used in rendering a hotlistissues table
+ */
+
+
+/**
+ * Helper function to set several attributes of an element at once.
+ * @param {Element} el element that is getting the attributes
+ * @param {dict} attrs Dictionary of {attrName: attrValue, ..}
+ */
+function setAttributes(el, attrs) {
+  for (let key in attrs) {
+    el.setAttribute(key, attrs[key]);
+  }
+}
+
+// TODO(jojwang): readOnly is currently empty string, figure out what it should be
+// ('True'/'False' 'yes'/'no'?).
+
+/**
+ * Helper function for creating a <td> element that contains the widgets of the row.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {} readOnly.
+ * @param {boolean} userLoggedIn is the current user logged in.
+ * @return an element containing the widget elements
+ */
+function createWidgets(tableRow, readOnly, userLoggedIn) {
+  let widgets = document.createElement('td');
+  widgets.setAttribute('class', 'rowwidgets nowrap');
+
+  let gripper = document.createElement('i');
+  gripper.setAttribute('class', 'material-icons gripper');
+  gripper.setAttribute('title', 'Drag issue');
+  gripper.textContent = 'drag_indicator';
+  widgets.appendChild(gripper);
+
+  if (!readOnly) {
+    if (userLoggedIn) {
+      // TODO(jojwang): for bulk edit, only show a checkbox next to an issue that
+      // the user has permission to edit.
+      let checkbox = document.createElement('input');
+      setAttributes(checkbox, {'class': 'checkRangeSelect',
+        'id': 'cb_' + tableRow['issueRef'],
+        'type': 'checkbox'});
+      widgets.appendChild(checkbox);
+      widgets.appendChild(document.createTextNode(' '));
+
+      let star = document.createElement('a');
+      let starColor = tableRow['isStarred'] ? 'cornflowerblue' : 'gray';
+      let starred = tableRow['isStarred'] ? 'Un-s' : 'S';
+      setAttributes(star, {'class': 'star',
+        'id': 'star-' + tableRow['projectName'] + tableRow['localID'],
+        'style': 'color:' + starColor,
+        'title': starred + 'tar this issue',
+        'data-project-name': tableRow['projectName'],
+        'data-local-id': tableRow['localID']});
+      star.textContent = (tableRow['isStarred'] ? '\u2605' : '\u2606');
+      widgets.appendChild(star);
+    }
+  }
+  return widgets;
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an ID cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {boolean} isCrossProject are issues in the table from more than one project.
+*/
+function createIDCell(td, tableRow, isCrossProject) {
+  td.classList.add('id');
+  let aLink = document.createElement('a');
+  aLink.setAttribute('href', tableRow['issueCleanURL']);
+  aLink.setAttribute('class', 'computehref');
+  let aLinkContent = (isCrossProject ? (tableRow['projectName'] + ':') : '' ) + tableRow['localID'];
+  aLink.textContent = aLinkContent;
+  td.appendChild(aLink);
+}
+
+function createProjectCell(td, tableRow) {
+  td.classList.add('project');
+  let aLink = document.createElement('a');
+  aLink.setAttribute('href', tableRow['projectURL']);
+  aLink.textContent = tableRow['projectName'];
+  td.appendChild(aLink);
+}
+
+function createEditableNoteCell(td, cell, projectName, localID, hotlistID) {
+  let textBox = document.createElement('textarea');
+  setAttributes(textBox, {
+    'id': `itemnote_${projectName}_${localID}`,
+    'placeholder': '---',
+    'class': 'itemnote rowwidgets',
+    'projectname': projectName,
+    'localid': localID,
+    'style': 'height:15px',
+  });
+  if (cell['values'].length > 0) {
+    textBox.value = cell['values'][0]['item'];
+  }
+  textBox.addEventListener('blur', function(e) {
+    saveNote(e.target, hotlistID);
+  });
+  debouncedKeyHandler = debounce(function(e) {
+    saveNote(e.target, hotlistID);
+  });
+  textBox.addEventListener('keyup', debouncedKeyHandler, false);
+  td.appendChild(textBox);
+}
+
+function enter_detector(e) {
+  if (e.which==13||e.keyCode==13) {
+    this.blur();
+  }
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an Summary cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} cell dictionary {'values': [], .. } of relevant cell info.
+ * @param {string=} projectName The name of the project the summary references.
+*/
+function createSummaryCell(td, cell, projectName) {
+  // TODO(jojwang): detect when links are present and make clicking on cell go
+  // to link, not issue details page
+  td.setAttribute('style', 'width:100%');
+  fillValues(td, cell['values']);
+  fillNonColumnLabels(td, cell['nonColLabels'], projectName);
+}
+
+
+/**
+ * Helper function to set attributes and add Nodes for an Attribute or Unfilterable cell.
+ * @param {Element} td element to be added to current row in table.
+ * @param {dict} cell dictionary {'type': 'Summary', .. } of relevant cell info.
+*/
+function createAttrAndUnfiltCell(td, cell) {
+  if (cell['noWrap'] == 'yes') {
+    td.className += ' nowrapspan';
+  }
+  if (cell['align']) {
+    td.setAttribute('align', cell['align']);
+  }
+  fillValues(td, cell['values']);
+}
+
+function createUrlCell(td, cell) {
+  td.classList.add('url');
+  cell.values.forEach((value) => {
+    let aLink = document.createElement('a');
+    aLink.href = value['item'];
+    aLink.target = '_blank';
+    aLink.rel = 'nofollow';
+    aLink.textContent = value['item'];
+    aLink.classList.add('fieldvalue_url');
+    td.appendChild(aLink);
+  });
+}
+
+function createIssuesCell(td, cell) {
+  td.classList.add('url');
+  if (cell.values.length > 0) {
+    cell.values.forEach( function(value, index, array) {
+      const span = document.createElement('span');
+      if (value['isDerived']) {
+        span.className = 'derived';
+      }
+      const a = document.createElement('a');
+      a.href = value['href'];
+      a.rel = 'nofollow"';
+      if (value['title']) {
+        a.title = value['title'];
+      }
+      if (value['closed']) {
+        a.style.textDecoration = 'line-through';
+      }
+      a.textContent = value['id'];
+      span.appendChild(a);
+      td.appendChild(span);
+      if (index != array.length-1) {
+        td.appendChild(document.createTextNode(', '));
+      }
+    });
+  } else {
+    td.textContent = '---';
+  }
+}
+
+/**
+ * Helper function to fill a td element with a cell's non-column labels.
+ * @param {Element} td element to be added to current row in table.
+ * @param {list} labels list of dictionaries with relevant (key, value) for
+ *   each label
+ * @param {string=} projectName The name of the project the labels reference.
+ */
+function fillNonColumnLabels(td, labels, projectName) {
+  labels.forEach( function(label) {
+    const aLabel = document.createElement('a');
+    setAttributes(aLabel,
+        {
+          'class': 'label',
+          'href': `/p/${projectName}/issues/list?q=label:${label['value']}`,
+        });
+    if (label['isDerived']) {
+      const i = document.createElement('i');
+      i.textContent = label['value'];
+      aLabel.appendChild(i);
+    } else {
+      aLabel.textContent = label['value'];
+    }
+    td.appendChild(document.createTextNode(' '));
+    td.appendChild(aLabel);
+  });
+}
+
+
+/**
+ * Helper function to fill a td element with a cell's value(s).
+ * @param {Element} td element to be added to current row in table.
+ * @param {list} values list of dictionaries with relevant (key, value) for each value
+ */
+function fillValues(td, values) {
+  if (values.length > 0) {
+    values.forEach( function(value, index, array) {
+      let span = document.createElement('span');
+      if (value['isDerived']) {
+        span.className = 'derived';
+      }
+      span.textContent = value['item'];
+      td.appendChild(span);
+      if (index != array.length-1) {
+        td.appendChild(document.createTextNode(', '));
+      }
+    });
+  } else {
+    td.textContent = '---';
+  }
+}
+
+
+/**
+ * Helper function to create a table row.
+ * @param {dict} tableRow dictionary {'projectName': 'name', .. } of relevant row info.
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user viewing the page.
+ */
+function renderHotlistRow(tableRow, pageSettings) {
+  let tr = document.createElement('tr');
+  if (pageSettings['cursor'] == tableRow['issueRef']) {
+    tr.setAttribute('class', 'ifOpened hoverTarget cursor_on drag_item');
+  } else {
+    tr.setAttribute('class', 'ifOpened hoverTarget cursor_off drag_item');
+  }
+
+  setAttributes(tr, {'data-idx': tableRow['idx'], 'data-id': tableRow['issueID'], 'issue-context-url': tableRow['issueContextURL']});
+  widgets = createWidgets(tableRow, pageSettings['readOnly'],
+    pageSettings['userLoggedIn']);
+  tr.appendChild(widgets);
+  tableRow['cells'].forEach(function(cell) {
+    let td = document.createElement('td');
+    td.setAttribute('class', 'col_' + cell['colIndex']);
+    if (cell['type'] == 'ID') {
+      createIDCell(td, tableRow, (pageSettings['isCrossProject'] == 'True'));
+    } else if (cell['type'] == 'summary') {
+      createSummaryCell(td, cell, tableRow['projectName']);
+    } else if (cell['type'] == 'note') {
+      if (pageSettings['ownerPerm'] || pageSettings['editorPerm']) {
+        createEditableNoteCell(
+          td, cell, tableRow['projectName'], tableRow['localID'],
+          pageSettings['hotlistID']);
+      } else {
+        createSummaryCell(td, cell, tableRow['projectName']);
+      }
+    } else if (cell['type'] == 'project') {
+      createProjectCell(td, tableRow);
+    } else if (cell['type'] == 'url') {
+      createUrlCell(td, cell);
+    } else if (cell['type'] == 'issues') {
+      createIssuesCell(td, cell);
+    } else {
+      createAttrAndUnfiltCell(td, cell);
+    }
+    tr.appendChild(td);
+  });
+  let directLinkURL = tableRow['issueCleanURL'];
+  let directLink = document.createElement('a');
+  directLink.setAttribute('class', 'directlink material-icons');
+  directLink.setAttribute('href', directLinkURL);
+  directLink.textContent = 'link'; // Renders as a link icon.
+  let lastCol = document.createElement('td');
+  lastCol.appendChild(directLink);
+  tr.appendChild(lastCol);
+  return tr;
+}
+
+
+/**
+ * Helper function to create the group header row
+ * @param {dict} group dict of relevant values for the current group
+ * @return a <tr> element to be added to the current <tbody>
+ */
+function renderGroupRow(group) {
+  let tr = document.createElement('tr');
+  tr.setAttribute('class', 'group_row');
+  let td = document.createElement('td');
+  setAttributes(td, {'colspan': '100', 'class': 'toggleHidden'});
+  let whenClosedImg = document.createElement('img');
+  setAttributes(whenClosedImg, {'class': 'ifClosed', 'src': '/static/images/plus.gif'});
+  td.appendChild(whenClosedImg);
+  let whenOpenImg = document.createElement('img');
+  setAttributes(whenOpenImg, {'class': 'ifOpened', 'src': '/static/images/minus.gif'});
+  td.appendChild(whenOpenImg);
+  tr.appendChild(td);
+
+  div = document.createElement('div');
+  div.textContent += group['rowsInGroup'];
+
+  div.textContent += (group['rowsInGroup'] == '1' ? ' issue:': ' issues:');
+
+  group['cells'].forEach(function(cell) {
+    let hasValue = false;
+    cell['values'].forEach(function(value) {
+      if (value['item'] !== 'None') {
+        hasValue = true;
+      }
+    });
+    if (hasValue) {
+      cell.values.forEach(function(value) {
+        div.textContent += (' ' + cell['groupName'] + '=' + value['item']);
+      });
+    } else {
+      div.textContent += (' -has:' + cell['groupName']);
+    }
+  });
+  td.appendChild(div);
+  return tr;
+}
+
+
+/**
+ * Builds the body of a hotlistissues table.
+ * @param {dict} tableData dict of relevant values from 'table_data'
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user viewing the page.
+ */
+function renderHotlistTable(tableData, pageSettings) {
+  let tbody;
+  let table = $('resultstable');
+
+  // TODO(jojwang): this would not work if grouping did not require a page refresh
+  // that wiped the table of all its children. This should be redone to be more
+  // robust.
+  // This loop only does anything when reranking is enabled.
+  for (i=0; i < table.childNodes.length; i++) {
+    if (table.childNodes[i].tagName == 'TBODY') {
+      table.removeChild(table.childNodes[i]);
+    }
+  }
+
+  tableData.forEach(function(tableRow) {
+    if (tableRow['group'] !== 'no') {
+      // add current tbody to table, need a new tbody with group row
+      if (typeof tbody !== 'undefined') {
+        table.appendChild(tbody);
+      }
+      tbody = document.createElement('tbody');
+      tbody.setAttribute('class', 'opened');
+      tbody.appendChild(renderGroupRow(tableRow['group']));
+    }
+    if (typeof tbody == 'undefined') {
+      tbody = document.createElement('tbody');
+    }
+    tbody.appendChild(renderHotlistRow(tableRow, pageSettings));
+  });
+  tbody.appendChild(document.createElement('tr'));
+  table.appendChild(tbody);
+
+  let stars = document.getElementsByClassName('star');
+  for (var i = 0; i < stars.length; ++i) {
+    let star = stars[i];
+    star.addEventListener('click', function(event) {
+      let projectName = event.target.getAttribute('data-project-name');
+      let localID = event.target.getAttribute('data-local-id');
+      _TKR_toggleStar(event.target, projectName, localID, null, null, null);
+    });
+  }
+}
+
+
+/**
+ * Activates the drag and drop functionality of the hotlistissues table.
+ * @param {dict} tableData dict of relevant values from the 'table_data' of
+ *  hotlistissues servlet. This is used when a drag and drop motion does not
+ *  result in any changes in the ordering of the issues.
+ * @param {dict} pageSettings dict of relevant settings for the hotlist and user
+ *  viewing the page.
+ * @param {str} hotlistID the number ID of the current hotlist
+*/
+function activateDragDrop(tableData, pageSettings, hotlistID) {
+  function onHotlistRerank(srcID, targetID, position) {
+    let data = {
+      target_id: targetID,
+      moved_ids: srcID,
+      split_above: position == 'above',
+      colspec: pageSettings['colSpec'],
+      can: pageSettings['can'],
+    };
+    CS_doPost(hotlistID + '/rerank.do', onHotlistResponse, data);
+  }
+
+  function onHotlistResponse(event) {
+    let xhr = event.target;
+    if (xhr.readyState != 4) {
+      return;
+    }
+    if (xhr.status != 200) {
+      window.console.error('200 page error');
+      // TODO(jojwang): fill this in more
+      return;
+    }
+    let response = CS_parseJSON(xhr);
+    renderHotlistTable(
+      (response['table_data'] == '' ? tableData : response['table_data']),
+      pageSettings);
+    // TODO(jojwang): pass pagination state to server
+    _initDragAndDrop($('resultstable'), onHotlistRerank, true);
+  }
+  _initDragAndDrop($('resultstable'), onHotlistRerank, true);
+}
diff --git a/static/js/tracker/tracker-ac.js b/static/js/tracker/tracker-ac.js
new file mode 100644
index 0000000..4d98ac1
--- /dev/null
+++ b/static/js/tracker/tracker-ac.js
@@ -0,0 +1,1285 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+/**
+ * This file contains the autocomplete configuration logic that is
+ * specific to the issue fields of Monorail.  It depends on ac.js, our
+ * modified version of the autocomplete library.
+ */
+
+/**
+ * This is an autocomplete store that holds the hotlists of the current user.
+ */
+let TKR_hotlistsStore;
+
+/**
+ * This is an autocomplete store that holds well-known issue label
+ * values for the current project.
+ */
+let TKR_labelStore;
+
+/**
+ * Like TKR_labelStore but stores only label prefixes.
+ */
+let TKR_labelPrefixStore;
+
+/**
+ * Like TKR_labelStore but adds a trailing comma instead of replacing.
+ */
+let TKR_labelMultiStore;
+
+/**
+ * This is an autocomplete store that holds issue components.
+ */
+let TKR_componentStore;
+
+/**
+ * Like TKR_componentStore but adds a trailing comma instead of replacing.
+ */
+let TKR_componentListStore;
+
+/**
+ * This is an autocomplete store that holds many different kinds of
+ * items that can be shown in the artifact search autocomplete.
+ */
+let TKR_searchStore;
+
+/**
+ * This is similar to TKR_searchStore, but does not include any suggestions
+ * to use the "me" keyword. Using "me" is not a good idea for project canned
+ * queries and filter rules.
+ */
+let TKR_projectQueryStore;
+
+/**
+ * This is an autocomplete store that holds items for the quick edit
+ * autocomplete.
+ */
+// TODO(jrobbins): add options for fields and components.
+let TKR_quickEditStore;
+
+/**
+ * This is a list of label prefixes that each issue should only use once.
+ * E.g., each issue should only have one Priority-* label.  We do not prevent
+ * the user from using multiple such labels, we just warn the user before
+ * they submit.
+ */
+let TKR_exclPrefixes = [];
+
+/**
+ * This is an autocomplete store that holds custom permission names that
+ * have already been used in this project.
+ */
+let TKR_customPermissionsStore;
+
+
+/**
+ * This is an autocomplete store that holds well-known issue status
+ * values for the current project.
+ */
+let TKR_statusStore;
+
+
+/**
+ * This is an autocomplete store that holds the usernames of all the
+ * members of the current project.  This is used for autocomplete in
+ * the cc-list of an issue, where many user names can entered with
+ * commas between them.
+ */
+let TKR_memberListStore;
+
+
+/**
+ * This is an autocomplete store that holds the projects that the current
+ * user is contributor/member/owner of.
+ */
+let TKR_projectStore;
+
+/**
+ * This is an autocomplete store that holds the usernames of possible
+ * issue owners in the current project.  The list of possible issue
+ * owners is the same as the list of project members, but the behavior
+ * of this autocompete store is different because the issue owner text
+ * field can only accept one value.
+ */
+let TKR_ownerStore;
+
+
+/**
+ * This is an autocomplete store that holds any list of string for choices.
+ */
+let TKR_autoCompleteStore;
+
+
+/**
+ * An array of autocomplete stores used for user-type custom fields.
+ */
+const TKR_userAutocompleteStores = [];
+
+
+/**
+ * This boolean controls whether odd-ball status and labels are treated as
+ * a warning or an error.  Normally, it is False.
+ */
+// TODO(jrobbins): split this into one option for statuses and one for labels.
+let TKR_restrict_to_known;
+
+/**
+ * This substitute function should be used for multi-valued autocomplete fields
+ * that are delimited by commas. When we insert an autocomplete value, replace
+ * an entire search term. Add a comma and a space after it if it is a complete
+ * search term.
+ */
+function TKR_acSubstituteWithComma(inputValue, caret, completable, completion) {
+  let nextTerm = caret;
+
+  // Subtract one in case the cursor is at the end of the input, before a comma.
+  let prevTerm = caret - 1;
+  while (nextTerm < inputValue.length - 1 && inputValue.charAt(nextTerm) !== ',') {
+    nextTerm++;
+  }
+  // Set this at the position after the found comma.
+  nextTerm++;
+
+  while (prevTerm > 0 && ![',', ' '].includes(inputValue.charAt(prevTerm))) {
+    prevTerm--;
+  }
+  if (prevTerm > 0) {
+    // Set this boundary after the found space/comma if it's not the beginning
+    // of the field.
+    prevTerm++;
+  }
+
+  return inputValue.substring(0, prevTerm) +
+         completion.value + ', ' + inputValue.substring(nextTerm);
+}
+
+/**
+ * When the prefix starts with '*', return the complete set of all
+ * possible completions.
+ * @param {string} prefix If this starts with '*', return all possible
+ * completions.  Otherwise return null.
+ * @param {Array} labelDefs The array of label names and docstrings.
+ * @return Array of new _AC_Completions for each possible completion, or null.
+ */
+function TKR_fullComplete(prefix, labelDefs) {
+  if (!prefix.startsWith('*')) return null;
+  const out = [];
+  for (let i = 0; i < labelDefs.length; i++) {
+    out.push(new _AC_Completion(labelDefs[i].name,
+        labelDefs[i].name,
+        labelDefs[i].doc));
+  }
+  return out;
+}
+
+
+/**
+ * Constucts a list of all completions for both open and closed
+ * statuses, with a header for each group.
+ * @param {string} prefix If starts with '*', return all possible completions,
+ * else return null.
+ * @param {Array} openStatusDefs The array of open status values and
+ * docstrings.
+ * @param {Array} closedStatusDefs The array of closed status values
+ * and docstrings.
+ * @return Array of new _AC_Completions for each possible completion, or null.
+ */
+function TKR_openClosedComplete(prefix, openStatusDefs, closedStatusDefs) {
+  if (!prefix.startsWith('*')) return null;
+  const out = [];
+  out.push({heading: 'Open Statuses:'}); // TODO: i18n
+  for (var i = 0; i < openStatusDefs.length; i++) {
+    out.push(new _AC_Completion(openStatusDefs[i].name,
+        openStatusDefs[i].name,
+        openStatusDefs[i].doc));
+  }
+  out.push({heading: 'Closed Statuses:'}); // TODO: i18n
+  for (var i = 0; i < closedStatusDefs.length; i++) {
+    out.push(new _AC_Completion(closedStatusDefs[i].name,
+        closedStatusDefs[i].name,
+        closedStatusDefs[i].doc));
+  }
+  return out;
+}
+
+
+function TKR_setUpHotlistsStore(hotlists) {
+  const docdict = {};
+  const ref_strs = [];
+
+  for (let i = 0; i < hotlists.length; i++) {
+    ref_strs.push(hotlists[i]['ref_str']);
+    docdict[hotlists[i]['ref_str']] = hotlists[i]['summary'];
+  }
+
+  TKR_hotlistsStore = new _AC_SimpleStore(ref_strs, docdict);
+  TKR_hotlistsStore.substitute = TKR_acSubstituteWithComma;
+}
+
+
+/**
+ * An array of definitions of all well-known issue statuses.  Each
+ * definition has the name of the status value, and a docstring that
+ * describes its meaning.
+ */
+let TKR_statusWords = [];
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known issue
+ * status values.  The store has some DIT-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} openStatusDefs An array of definitions of the
+ * well-known open status values.  Each definition has a name and
+ * docstring.
+ * @param {Array} closedStatusDefs An array of definitions of the
+ * well-known closed status values.  Each definition has a name and
+ * docstring.
+ */
+function TKR_setUpStatusStore(openStatusDefs, closedStatusDefs) {
+  const docdict = {};
+  TKR_statusWords = [];
+  for (var i = 0; i < openStatusDefs.length; i++) {
+    var status = openStatusDefs[i];
+    TKR_statusWords.push(status.name);
+    docdict[status.name] = status.doc;
+  }
+  for (var i = 0; i < closedStatusDefs.length; i++) {
+    var status = closedStatusDefs[i];
+    TKR_statusWords.push(status.name);
+    docdict[status.name] = status.doc;
+  }
+
+  TKR_statusStore = new _AC_SimpleStore(TKR_statusWords, docdict);
+
+  TKR_statusStore.commaCompletes = false;
+
+  TKR_statusStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  TKR_statusStore.completable = function(inputValue, cursor) {
+    if (!ac_everTyped) return '*status';
+    return inputValue;
+  };
+
+  TKR_statusStore.completions = function(prefix, tofilter) {
+    const fullList = TKR_openClosedComplete(prefix,
+        openStatusDefs,
+        closedStatusDefs);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+}
+
+
+/**
+ * Simple function to add a given item to the list of items used to construct
+ * an "autocomplete store", and also update the docstring that describes
+ * that item.  They are stored separately for backward compatability with
+ * autocomplete store logic that preceeded the introduction of descriptions.
+ */
+function TKR_addACItem(items, docDict, item, docStr) {
+  items.push(item);
+  docDict[item] = docStr;
+}
+
+/**
+ * Adds a group of three items related to a date field.
+ */
+function TKR_addACDateItems(items, docDict, fieldName, humanReadable) {
+  const today = new Date();
+  const todayStr = (today.getFullYear() + '-' + (today.getMonth() + 1) + '-' +
+    today.getDate());
+  TKR_addACItem(items, docDict, fieldName + '>today-1',
+      humanReadable + ' within the last N days');
+  TKR_addACItem(items, docDict, fieldName + '>' + todayStr,
+      humanReadable + ' after the specified date');
+  TKR_addACItem(items, docDict, fieldName + '<today-1',
+      humanReadable + ' more than N days ago');
+}
+
+/**
+ * Add several autocomplete items to a word list that will be used to construct
+ * an autocomplete store.  Also, keep track of description strings for each
+ * item.  A search operator is prepended to the name of each item.  The opt_old
+ * and opt_new parameters are used to transform Key-Value labels into Key=Value
+ * search terms.
+ */
+function TKR_addACItemList(
+    items, docDict, searchOp, acDefs, opt_old, opt_new) {
+  let item;
+  for (let i = 0; i < acDefs.length; i++) {
+    const nameAndDoc = acDefs[i];
+    item = searchOp + nameAndDoc.name;
+    if (opt_old) {
+      // Preserve any leading minus-sign.
+      item = item.slice(0, 1) + item.slice(1).replace(opt_old, opt_new);
+    }
+    TKR_addACItem(items, docDict, item, nameAndDoc.doc);
+  }
+}
+
+
+/**
+ * Use information from an options feed to populate the artifact search
+ * autocomplete menu.  The order of sections is: custom fields, labels,
+ * components, people, status, special, dates.  Within each section,
+ * options are ordered semantically where possible, or alphabetically
+ * if there is no semantic ordering.  Negated options all come after
+ * all normal options.
+ */
+function TKR_setUpSearchStore(
+    labelDefs, memberDefs, openDefs, closedDefs, componentDefs, fieldDefs,
+    indMemberDefs) {
+  let searchWords = [];
+  const searchWordsNeg = [];
+  const docDict = {};
+
+  // Treat Key-Value and OneWord labels separately.
+  const keyValueLabelDefs = [];
+  const oneWordLabelDefs = [];
+  for (var i = 0; i < labelDefs.length; i++) {
+    const nameAndDoc = labelDefs[i];
+    if (nameAndDoc.name.indexOf('-') == -1) {
+      oneWordLabelDefs.push(nameAndDoc);
+    } else {
+      keyValueLabelDefs.push(nameAndDoc);
+    }
+  }
+
+  // Autocomplete for custom fields.
+  for (i = 0; i < fieldDefs.length; i++) {
+    const fieldName = fieldDefs[i]['field_name'];
+    const fieldType = fieldDefs[i]['field_type'];
+    if (fieldType == 'ENUM_TYPE') {
+      const choices = fieldDefs[i]['choices'];
+      TKR_addACItemList(searchWords, docDict, fieldName + '=', choices);
+      TKR_addACItemList(searchWordsNeg, docDict, '-' + fieldName + '=', choices);
+    } else if (fieldType == 'STR_TYPE') {
+      TKR_addACItem(searchWords, docDict, fieldName + ':',
+          fieldDefs[i]['docstring']);
+    } else if (fieldType == 'DATE_TYPE') {
+      TKR_addACItem(searchWords, docDict, fieldName + ':',
+          fieldDefs[i]['docstring']);
+      TKR_addACDateItems(searchWords, docDict, fieldName, fieldName);
+    } else {
+      TKR_addACItem(searchWords, docDict, fieldName + '=',
+          fieldDefs[i]['docstring']);
+    }
+    TKR_addACItem(searchWords, docDict, 'has:' + fieldName,
+        'Issues with any ' + fieldName + ' value');
+    TKR_addACItem(searchWordsNeg, docDict, '-has:' + fieldName,
+        'Issues with no ' + fieldName + ' value');
+  }
+
+  // Add suggestions with "me" first, because otherwise they may be impossible
+  // to reach in a project that has a lot of members with emails starting with
+  // "me".
+  if (CS_env['loggedInUserEmail']) {
+    TKR_addACItem(searchWords, docDict, 'owner:me', 'Issues owned by me');
+    TKR_addACItem(searchWordsNeg, docDict, '-owner:me', 'Issues not owned by me');
+    TKR_addACItem(searchWords, docDict, 'cc:me', 'Issues that CC me');
+    TKR_addACItem(searchWordsNeg, docDict, '-cc:me', 'Issues that don\'t CC me');
+    TKR_addACItem(searchWords, docDict, 'reporter:me', 'Issues I reported');
+    TKR_addACItem(searchWordsNeg, docDict, '-reporter:me', 'Issues reported by others');
+    TKR_addACItem(searchWords, docDict, 'commentby:me',
+        'Issues that I commented on');
+    TKR_addACItem(searchWordsNeg, docDict, '-commentby:me',
+        'Issues that I didn\'t comment on');
+  }
+
+  TKR_addACItemList(searchWords, docDict, '', keyValueLabelDefs, '-', '=');
+  TKR_addACItemList(searchWordsNeg, docDict, '-', keyValueLabelDefs, '-', '=');
+  TKR_addACItemList(searchWords, docDict, 'label:', oneWordLabelDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-label:', oneWordLabelDefs);
+
+  TKR_addACItemList(searchWords, docDict, 'component:', componentDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-component:', componentDefs);
+  TKR_addACItem(searchWords, docDict, 'has:component',
+      'Issues with any components specified');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:component',
+      'Issues with no components specified');
+
+  TKR_addACItemList(searchWords, docDict, 'owner:', indMemberDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-owner:', indMemberDefs);
+  TKR_addACItemList(searchWords, docDict, 'cc:', memberDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-cc:', memberDefs);
+  TKR_addACItem(searchWords, docDict, 'has:cc',
+      'Issues with any cc\'d users');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:cc',
+      'Issues with no cc\'d users');
+  TKR_addACItemList(searchWords, docDict, 'reporter:', memberDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-reporter:', memberDefs);
+  TKR_addACItemList(searchWords, docDict, 'status:', openDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-status:', openDefs);
+  TKR_addACItemList(searchWords, docDict, 'status:', closedDefs);
+  TKR_addACItemList(searchWordsNeg, docDict, '-status:', closedDefs);
+  TKR_addACItem(searchWords, docDict, 'has:status',
+      'Issues with any status');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:status',
+      'Issues with no status');
+
+  TKR_addACItem(searchWords, docDict, 'is:blocked',
+      'Issues that are blocked');
+  TKR_addACItem(searchWordsNeg, docDict, '-is:blocked',
+      'Issues that are not blocked');
+  TKR_addACItem(searchWords, docDict, 'has:blockedon',
+      'Issues that are blocked');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:blockedon',
+      'Issues that are not blocked');
+  TKR_addACItem(searchWords, docDict, 'has:blocking',
+      'Issues that are blocking other issues');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:blocking',
+      'Issues that are not blocking other issues');
+  TKR_addACItem(searchWords, docDict, 'has:mergedinto',
+      'Issues that were merged into other issues');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:mergedinto',
+      'Issues that were not merged into other issues');
+
+  TKR_addACItem(searchWords, docDict, 'is:starred',
+      'Starred by me');
+  TKR_addACItem(searchWordsNeg, docDict, '-is:starred',
+      'Not starred by me');
+  TKR_addACItem(searchWords, docDict, 'stars>10',
+      'More than 10 stars');
+  TKR_addACItem(searchWords, docDict, 'stars>100',
+      'More than 100 stars');
+  TKR_addACItem(searchWords, docDict, 'summary:',
+      'Search within the summary field');
+
+  TKR_addACItemList(searchWords, docDict, 'commentby:', memberDefs);
+  TKR_addACItem(searchWords, docDict, 'attachment:',
+      'Search within attachment names');
+  TKR_addACItem(searchWords, docDict, 'attachments>5',
+      'Has more than 5 attachments');
+  TKR_addACItem(searchWords, docDict, 'is:open', 'Issues that are open');
+  TKR_addACItem(searchWordsNeg, docDict, '-is:open', 'Issues that are closed');
+  TKR_addACItem(searchWords, docDict, 'has:owner',
+      'Issues with some owner');
+  TKR_addACItem(searchWordsNeg, docDict, '-has:owner',
+      'Issues with no owner');
+  TKR_addACItem(searchWords, docDict, 'has:attachments',
+      'Issues with some attachments');
+  TKR_addACItem(searchWords, docDict, 'id:1,2,3',
+      'Match only the specified issues');
+  TKR_addACItem(searchWords, docDict, 'id<100000',
+      'Issues with IDs under 100,000');
+  TKR_addACItem(searchWords, docDict, 'blockedon:1',
+      'Blocked on the specified issues');
+  TKR_addACItem(searchWords, docDict, 'blocking:1',
+      'Blocking the specified issues');
+  TKR_addACItem(searchWords, docDict, 'mergedinto:1',
+      'Merged into the specified issues');
+  TKR_addACItem(searchWords, docDict, 'is:ownerbouncing',
+      'Issues with owners we cannot contact');
+  TKR_addACItem(searchWords, docDict, 'is:spam', 'Issues classified as spam');
+  // We do not suggest -is:spam because it is implicit.
+
+  TKR_addACDateItems(searchWords, docDict, 'opened', 'Opened');
+  TKR_addACDateItems(searchWords, docDict, 'modified', 'Modified');
+  TKR_addACDateItems(searchWords, docDict, 'closed', 'Closed');
+  TKR_addACDateItems(searchWords, docDict, 'ownermodified', 'Owner field modified');
+  TKR_addACDateItems(searchWords, docDict, 'ownerlastvisit', 'Owner last visit');
+  TKR_addACDateItems(searchWords, docDict, 'statusmodified', 'Status field modified');
+  TKR_addACDateItems(
+      searchWords, docDict, 'componentmodified', 'Component field modified');
+
+  TKR_projectQueryStore = new _AC_SimpleStore(searchWords, docDict);
+
+  searchWords = searchWords.concat(searchWordsNeg);
+
+  TKR_searchStore = new _AC_SimpleStore(searchWords, docDict);
+
+  // When we insert an autocomplete value, replace an entire search term.
+  // Add just a space after it (not a comma) if it is a complete search term,
+  // or leave the caret immediately after the completion if we are just helping
+  // the user with the search operator.
+  TKR_searchStore.substitute =
+      function(inputValue, caret, completable, completion) {
+        let nextTerm = caret;
+        while (inputValue.charAt(nextTerm) != ' ' &&
+               nextTerm < inputValue.length) {
+          nextTerm++;
+        }
+        while (inputValue.charAt(nextTerm) == ' ' &&
+               nextTerm < inputValue.length) {
+          nextTerm++;
+        }
+        return inputValue.substring(0, caret - completable.length) +
+               completion.value + ' ' + inputValue.substring(nextTerm);
+      };
+  TKR_searchStore.autoselectFirstRow =
+      function() {
+        return false;
+      };
+
+  TKR_projectQueryStore.substitute = TKR_searchStore.substitute;
+  TKR_projectQueryStore.autoselectFirstRow = TKR_searchStore.autoselectFirstRow;
+}
+
+
+/**
+ * Use information from an options feed to populate the issue quick edit
+ * autocomplete menu.
+ */
+function TKR_setUpQuickEditStore(
+    labelDefs, memberDefs, openDefs, closedDefs, indMemberDefs) {
+  const qeWords = [];
+  const docDict = {};
+
+  // Treat Key-Value and OneWord labels separately.
+  const keyValueLabelDefs = [];
+  const oneWordLabelDefs = [];
+  for (let i = 0; i < labelDefs.length; i++) {
+    const nameAndDoc = labelDefs[i];
+    if (nameAndDoc.name.indexOf('-') == -1) {
+      oneWordLabelDefs.push(nameAndDoc);
+    } else {
+      keyValueLabelDefs.push(nameAndDoc);
+    }
+  }
+  TKR_addACItemList(qeWords, docDict, '', keyValueLabelDefs, '-', '=');
+  TKR_addACItemList(qeWords, docDict, '-', keyValueLabelDefs, '-', '=');
+  TKR_addACItemList(qeWords, docDict, '', oneWordLabelDefs);
+  TKR_addACItemList(qeWords, docDict, '-', oneWordLabelDefs);
+
+  TKR_addACItem(qeWords, docDict, 'owner=me', 'Make me the owner');
+  TKR_addACItem(qeWords, docDict, 'owner=----', 'Clear the owner field');
+  TKR_addACItem(qeWords, docDict, 'cc=me', 'CC me on this issue');
+  TKR_addACItem(qeWords, docDict, 'cc=-me', 'Remove me from CC list');
+  TKR_addACItemList(qeWords, docDict, 'owner=', indMemberDefs);
+  TKR_addACItemList(qeWords, docDict, 'cc=', memberDefs);
+  TKR_addACItemList(qeWords, docDict, 'cc=-', memberDefs);
+  TKR_addACItemList(qeWords, docDict, 'status=', openDefs);
+  TKR_addACItemList(qeWords, docDict, 'status=', closedDefs);
+  TKR_addACItem(qeWords, docDict, 'summary=""', 'Set the summary field');
+
+  TKR_quickEditStore = new _AC_SimpleStore(qeWords, docDict);
+
+  // When we insert an autocomplete value, replace an entire command part.
+  // Add just a space after it (not a comma) if it is a complete part,
+  // or leave the caret immediately after the completion if we are just helping
+  // the user with the command operator.
+  TKR_quickEditStore.substitute =
+      function(inputValue, caret, completable, completion) {
+        let nextTerm = caret;
+        while (inputValue.charAt(nextTerm) != ' ' &&
+               nextTerm < inputValue.length) {
+          nextTerm++;
+        }
+        while (inputValue.charAt(nextTerm) == ' ' &&
+               nextTerm < inputValue.length) {
+          nextTerm++;
+        }
+        return inputValue.substring(0, caret - completable.length) +
+               completion.value + ' ' + inputValue.substring(nextTerm);
+      };
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the project
+ * custom permissions.
+ * @param {Array} customPermissions An array of custom permission names.
+ */
+function TKR_setUpCustomPermissionsStore(customPermissions) {
+  customPermissions = customPermissions || [];
+  const permWords = ['View', 'EditIssue', 'AddIssueComment', 'DeleteIssue'];
+  const docdict = {
+    'View': '', 'EditIssue': '', 'AddIssueComment': '', 'DeleteIssue': ''};
+  for (let i = 0; i < customPermissions.length; i++) {
+    permWords.push(customPermissions[i]);
+    docdict[customPermissions[i]] = '';
+  }
+
+  TKR_customPermissionsStore = new _AC_SimpleStore(permWords, docdict);
+
+  TKR_customPermissionsStore.commaCompletes = false;
+
+  TKR_customPermissionsStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known project
+ * member user names and real names.  The store has some
+ * monorail-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} memberDefs an array of member objects.
+ * @param {Array} nonGroupMemberDefs an array of member objects who are not groups.
+ */
+function TKR_setUpMemberStore(memberDefs, nonGroupMemberDefs) {
+  const memberWords = [];
+  const indMemberWords = [];
+  const docdict = {};
+
+  memberDefs.forEach((memberDef) => {
+    memberWords.push(memberDef.name);
+    docdict[memberDef.name] = null;
+  });
+  nonGroupMemberDefs.forEach((memberDef) => {
+    indMemberWords.push(memberDef.name);
+  });
+
+  TKR_memberListStore = new _AC_SimpleStore(memberWords, docdict);
+
+  TKR_memberListStore.completions = function(prefix, tofilter) {
+    const fullList = TKR_fullComplete(prefix, memberDefs);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+
+  TKR_memberListStore.completable = function(inputValue, cursor) {
+    if (inputValue == '') return '*member';
+    return _AC_SimpleStore.prototype.completable.call(this, inputValue, cursor);
+  };
+
+  TKR_memberListStore.substitute = TKR_acSubstituteWithComma;
+
+  TKR_ownerStore = new _AC_SimpleStore(indMemberWords, docdict);
+
+  TKR_ownerStore.commaCompletes = false;
+
+  TKR_ownerStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  TKR_ownerStore.completions = function(prefix, tofilter) {
+    const fullList = TKR_fullComplete(prefix, nonGroupMemberDefs);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+
+  TKR_ownerStore.completable = function(inputValue, cursor) {
+    if (!ac_everTyped) return '*owner';
+    return inputValue;
+  };
+}
+
+
+/**
+ * Constuct one new autocomplete store for each user-valued custom
+ * field that has a needs_perm validation requirement, and thus a
+ * list of allowed user indexes.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} fieldDefs An array of field definitions, only some
+ * of which have a 'user_indexes' entry.
+ */
+function TKR_setUpUserAutocompleteStores(fieldDefs) {
+  fieldDefs.forEach((fieldDef) => {
+    if (fieldDef.qualifiedMembers) {
+      const us = makeOneUserAutocompleteStore(fieldDef);
+      TKR_userAutocompleteStores['custom_' + fieldDef['field_id']] = us;
+    }
+  });
+}
+
+function makeOneUserAutocompleteStore(fieldDef) {
+  const memberWords = [];
+  const docdict = {};
+  for (const member of fieldDef.qualifiedMembers) {
+    memberWords.push(member.name);
+    docdict[member.name] = member.doc;
+  }
+
+  const userStore = new _AC_SimpleStore(memberWords, docdict);
+  userStore.commaCompletes = false;
+
+  userStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  userStore.completions = function(prefix, tofilter) {
+    const fullList = TKR_fullComplete(prefix, fieldDef.qualifiedMembers);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+
+  userStore.completable = function(inputValue, cursor) {
+    if (!ac_everTyped) return '*custom';
+    return inputValue;
+  };
+
+  return userStore;
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the components.
+ * The store has some monorail-specific methods.
+ * @param {Array} componentDefs An array of definitions of components.
+ */
+function TKR_setUpComponentStore(componentDefs) {
+  const componentWords = [];
+  const docdict = {};
+  for (let i = 0; i < componentDefs.length; i++) {
+    const component = componentDefs[i];
+    componentWords.push(component.name);
+    docdict[component.name] = component.doc;
+  }
+
+  const completions = function(prefix, tofilter) {
+    const fullList = TKR_fullComplete(prefix, componentDefs);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+  const completable = function(inputValue, cursor) {
+    if (inputValue == '') return '*component';
+    return _AC_SimpleStore.prototype.completable.call(this, inputValue, cursor);
+  };
+
+  TKR_componentStore = new _AC_SimpleStore(componentWords, docdict);
+  TKR_componentStore.commaCompletes = false;
+  TKR_componentStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+  TKR_componentStore.completions = completions;
+  TKR_componentStore.completable = completable;
+
+  TKR_componentListStore = new _AC_SimpleStore(componentWords, docdict);
+  TKR_componentListStore.commaCompletes = false;
+  TKR_componentListStore.substitute = TKR_acSubstituteWithComma;
+  TKR_componentListStore.completions = completions;
+  TKR_componentListStore.completable = completable;
+}
+
+
+/**
+ * An array of definitions of all well-known issue labels.  Each
+ * definition has the name of the label, and a docstring that
+ * describes its meaning.
+ */
+let TKR_labelWords = [];
+
+
+/**
+ * Constuct a new autocomplete store with all the well-known issue
+ * labels for the current project.  The store has some DIT-specific methods.
+ * TODO(jrobbins): would it be easier to define my own class to use
+ * instead of _AC_Simple_Store?
+ * @param {Array} labelDefs An array of definitions of the project
+ * members.  Each definition has a name and docstring.
+ */
+function TKR_setUpLabelStore(labelDefs) {
+  TKR_labelWords = [];
+  const TKR_labelPrefixes = [];
+  const labelPrefs = new Set();
+  const docdict = {};
+  for (let i = 0; i < labelDefs.length; i++) {
+    const label = labelDefs[i];
+    TKR_labelWords.push(label.name);
+    TKR_labelPrefixes.push(label.name.split('-')[0]);
+    docdict[label.name] = label.doc;
+    labelPrefs.add(label.name.split('-')[0]);
+  }
+  const labelPrefArray = Array.from(labelPrefs);
+  const labelPrefDefs = labelPrefArray.map((s) => ({name: s, doc: ''}));
+
+  TKR_labelStore = new _AC_SimpleStore(TKR_labelWords, docdict);
+
+  TKR_labelStore.commaCompletes = false;
+  TKR_labelStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  TKR_labelPrefixStore = new _AC_SimpleStore(TKR_labelPrefixes);
+
+  TKR_labelPrefixStore.commaCompletes = false;
+  TKR_labelPrefixStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  TKR_labelMultiStore = new _AC_SimpleStore(TKR_labelWords, docdict);
+
+  TKR_labelMultiStore.substitute = TKR_acSubstituteWithComma;
+
+  const completable = function(inputValue, cursor) {
+    if (cursor === 0) {
+      return '*label'; // Show every well-known label that is not redundant.
+    }
+    let start = 0;
+    for (let i = cursor; --i >= 0;) {
+      const c = inputValue.charAt(i);
+      if (c === ' ' || c === ',') {
+        start = i + 1;
+        break;
+      }
+    }
+    const questionPos = inputValue.indexOf('?');
+    if (questionPos >= 0) {
+      // Ignore any "?" character and anything after it.
+      inputValue = inputValue.substring(start, questionPos);
+    }
+    let result = inputValue.substring(start, cursor);
+    if (inputValue.lastIndexOf('-') > 0 && !ac_everTyped) {
+      // Act like a menu: offer all alternative values for the same prefix.
+      result = inputValue.substring(
+          start, Math.min(cursor, inputValue.lastIndexOf('-')));
+    }
+    if (inputValue.startsWith('Restrict-') && !ac_everTyped) {
+      // If user is in the middle of 2nd part, use that to narrow the choices.
+      result = inputValue;
+      // If they completed 2nd part, give all choices matching 2-part prefix.
+      if (inputValue.lastIndexOf('-') > 8) {
+        result = inputValue.substring(
+            start, Math.min(cursor, inputValue.lastIndexOf('-') + 1));
+      }
+    }
+
+    return result;
+  };
+
+  const computeAvoid = function() {
+    const labelTextFields = Array.from(
+        document.querySelectorAll('.labelinput'));
+    const otherTextFields = labelTextFields.filter(
+        (tf) => (tf !== ac_focusedInput && tf.value));
+    return otherTextFields.map((tf) => tf.value);
+  };
+
+
+  const completions = function(labeldic) {
+    return function(prefix, tofilter) {
+      let comps = TKR_fullComplete(prefix, labeldic);
+      if (comps === null) {
+        comps = _AC_SimpleStore.prototype.completions.call(
+            this, prefix, tofilter);
+      }
+
+      const filteredComps = [];
+      for (const completion of comps) {
+        const completionLower = completion.value.toLowerCase();
+        const labelPrefix = completionLower.split('-')[0];
+        let alreadyUsed = false;
+        const isExclusive = FindInArray(TKR_exclPrefixes, labelPrefix) !== -1;
+        if (isExclusive) {
+          for (const usedLabel of ac_avoidValues) {
+            if (usedLabel.startsWith(labelPrefix + '-')) {
+              alreadyUsed = true;
+              break;
+            }
+          }
+        }
+        if (!alreadyUsed) {
+          filteredComps.push(completion);
+        }
+      }
+
+      return filteredComps;
+    };
+  };
+
+  TKR_labelStore.computeAvoid = computeAvoid;
+  TKR_labelStore.completable = completable;
+  TKR_labelStore.completions = completions(labelDefs);
+
+  TKR_labelPrefixStore.completable = completable;
+  TKR_labelPrefixStore.completions = completions(labelPrefDefs);
+
+  TKR_labelMultiStore.completable = completable;
+  TKR_labelMultiStore.completions = completions(labelDefs);
+}
+
+
+/**
+ * Constuct a new autocomplete store with the given strings as choices.
+ * @param {Array} choices An array of autocomplete choices.
+ */
+function TKR_setUpAutoCompleteStore(choices) {
+  TKR_autoCompleteStore = new _AC_SimpleStore(choices);
+  const choicesDefs = [];
+  for (let i = 0; i < choices.length; ++i) {
+    choicesDefs.push({'name': choices[i], 'doc': ''});
+  }
+
+  /**
+   * Override the default completions() function to return a list of
+   * available choices.  It proactively shows all choices when the user has
+   * not yet typed anything.  It stops offering choices if the text field
+   * has a pretty long string in it already.  It does not offer choices that
+   * have already been chosen.
+   */
+  TKR_autoCompleteStore.completions = function(prefix, tofilter) {
+    if (prefix.length > 18) {
+      return [];
+    }
+    let comps = TKR_fullComplete(prefix, choicesDefs);
+    if (comps == null) {
+      comps = _AC_SimpleStore.prototype.completions.call(
+          this, prefix, tofilter);
+    }
+
+    const usedComps = {};
+    const textFields = document.getElementsByTagName('input');
+    for (var i = 0; i < textFields.length; ++i) {
+      if (textFields[i].classList.contains('autocomplete')) {
+        usedComps[textFields[i].value] = true;
+      }
+    }
+    const unusedComps = [];
+    for (i = 0; i < comps.length; ++i) {
+      if (!usedComps[comps[i].value]) {
+        unusedComps.push(comps[i]);
+      }
+    }
+
+    return unusedComps;
+  };
+
+  /**
+   * Override the default completable() function with one that gives a
+   * special value when the user has not yet typed anything.  This
+   * causes TKR_fullComplete() to show all choices.  Also, always consider
+   * the whole textfield value as an input to completion matching.  Otherwise,
+   * it would only consider the part after the last comma (which makes sense
+   * for gmail To: and Cc: address fields).
+   */
+  TKR_autoCompleteStore.completable = function(inputValue, cursor) {
+    if (inputValue == '') {
+      return '*ac';
+    }
+    return inputValue;
+  };
+
+  /**
+   * Override the default substitute() function to completely replace the
+   * contents of the text field when the user selects a completion. Otherwise,
+   * it would append, much like the Gmail To: and Cc: fields append autocomplete
+   * selections.
+   */
+  TKR_autoCompleteStore.substitute =
+  function(inputValue, cursor, completable, completion) {
+    return completion.value;
+  };
+
+  /**
+   * We consider the whole textfield to be one value, not a comma separated
+   * list.  So, typing a ',' should not trigger an autocomplete selection.
+   */
+  TKR_autoCompleteStore.commaCompletes = false;
+}
+
+
+/**
+ * XMLHTTP object used to fetch autocomplete options from the server.
+ */
+const TKR_optionsXmlHttp = undefined;
+
+/**
+ * Contact the server to fetch the set of autocomplete options for the
+ * projects the user is contributor/member/owner of.
+ * @param {multiValue} boolean If set to true, the projectStore is configured to
+ * have support for multi-values (useful for example for saved queries where
+ * a query can apply to multiple projects).
+ */
+function TKR_fetchUserProjects(multiValue) {
+  // Set a request token to prevent XSRF leaking of user project lists.
+  const userRefs = [{displayName: window.CS_env.loggedInUserEmail}];
+  const userProjectsPromise = window.prpcClient.call(
+      'monorail.Users', 'GetUsersProjects', {userRefs});
+  userProjectsPromise.then((response) => {
+    const userProjects = response.usersProjects[0];
+    const projects = (userProjects.ownerOf || [])
+        .concat(userProjects.memberOf || [])
+        .concat(userProjects.contributorTo || []);
+    projects.sort();
+    if (projects) {
+      TKR_setUpProjectStore(projects, multiValue);
+    }
+  });
+}
+
+
+/**
+ * Constuct a new autocomplete store with all the projects that the
+ * current user has visibility into. The store has some monorail-specific
+ * methods.
+ * @param {Array} projects An array of project names.
+ * @param {boolean} multiValue Determines whether the store should support
+ *                  multiple values.
+ */
+function TKR_setUpProjectStore(projects, multiValue) {
+  const projectsDefs = [];
+  const docdict = {};
+  for (let i = 0; i < projects.length; ++i) {
+    projectsDefs.push({'name': projects[i], 'doc': ''});
+    docdict[projects[i]] = '';
+  }
+
+  TKR_projectStore = new _AC_SimpleStore(projects, docdict);
+  TKR_projectStore.commaCompletes = !multiValue;
+
+  if (multiValue) {
+    TKR_projectStore.substitute = TKR_acSubstituteWithComma;
+  } else {
+    TKR_projectStore.substitute =
+      function(inputValue, cursor, completable, completion) {
+        return completion.value;
+      };
+  }
+
+  TKR_projectStore.completions = function(prefix, tofilter) {
+    const fullList = TKR_fullComplete(prefix, projectsDefs);
+    if (fullList) return fullList;
+    return _AC_SimpleStore.prototype.completions.call(this, prefix, tofilter);
+  };
+
+  TKR_projectStore.completable = function(inputValue, cursor) {
+    if (inputValue == '') return '*project';
+    if (multiValue) {
+      return _AC_SimpleStore.prototype.completable.call(
+          this, inputValue, cursor);
+    } else {
+      return inputValue;
+    }
+  };
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListStatuses to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} statusesResponse A pRPC ListStatusesResponse object.
+ */
+function TKR_convertStatuses(statusesResponse) {
+  const statusDefs = statusesResponse.statusDefs || [];
+  const jsonData = {};
+
+  // Split statusDefs into open and closed name-doc objects.
+  jsonData.open = [];
+  jsonData.closed = [];
+  for (const s of statusDefs) {
+    if (!s.deprecated) {
+      const item = {
+        name: s.status,
+        doc: s.docstring,
+      };
+      if (s.meansOpen) {
+        jsonData.open.push(item);
+      } else {
+        jsonData.closed.push(item);
+      }
+    }
+  }
+
+  jsonData.strict = statusesResponse.restrictToKnown;
+
+  return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListComponents to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} componentsResponse A pRPC ListComponentsResponse object.
+ */
+function TKR_convertComponents(componentsResponse) {
+  const componentDefs = (componentsResponse.componentDefs || []);
+  const jsonData = {};
+
+  // Filter out deprecated components and normalize to name-doc object.
+  jsonData.components = [];
+  for (const c of componentDefs) {
+    if (!c.deprecated) {
+      jsonData.components.push({
+        name: c.path,
+        doc: c.docstring,
+      });
+    }
+  }
+
+  return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects GetLabelOptions
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {object} labelsResponse A pRPC GetLabelOptionsResponse.
+ * @param {Array<FieldDef>=} fieldDefs FieldDefs from a project config, used to
+ *   mask labels that are used to implement custom enum fields.
+ */
+function TKR_convertLabels(labelsResponse, fieldDefs = []) {
+  const labelDefs = (labelsResponse.labelDefs || []);
+  const exclusiveLabelPrefixes = (labelsResponse.exclusiveLabelPrefixes || []);
+  const jsonData = {};
+
+  const maskedLabels = new Set();
+  fieldDefs.forEach((fd) => {
+    if (fd.enumChoices) {
+      fd.enumChoices.forEach(({label}) => {
+        maskedLabels.add(`${fd.fieldRef.fieldName}-${label}`);
+      });
+    }
+  });
+
+  jsonData.labels = labelDefs.filter(({label}) => !maskedLabels.has(label)).map(
+      (label) => ({name: label.label, doc: label.docstring}));
+
+  jsonData.excl_prefixes = exclusiveLabelPrefixes.map(
+      (prefix) => prefix.toLowerCase());
+
+  return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects GetVisibleMembers
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {object?} visibleMembersResponse A pRPC GetVisibleMembersResponse.
+ * @return {{memberEmails: {name: string}, nonGroupEmails: {name: string}}}
+ */
+function TKR_convertVisibleMembers(visibleMembersResponse) {
+  if (!visibleMembersResponse) {
+    visibleMembersResponse = {};
+  }
+  const groupRefs = (visibleMembersResponse.groupRefs || []);
+  const userRefs = (visibleMembersResponse.userRefs || []);
+  const jsonData = {};
+
+  const groupEmails = new Set(groupRefs.map(
+      (groupRef) => groupRef.displayName));
+
+  jsonData.memberEmails = userRefs.map(
+      (userRef) => ({name: userRef.displayName}));
+  jsonData.nonGroupEmails = jsonData.memberEmails.filter(
+      (memberEmail) => !groupEmails.has(memberEmail));
+
+  return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Projects ListFields to
+ * the format expected by TKR_populateAutocomplete.
+ * @param {object} fieldsResponse A pRPC ListFieldsResponse object.
+ */
+function TKR_convertFields(fieldsResponse) {
+  const fieldDefs = (fieldsResponse.fieldDefs || []);
+  const jsonData = {};
+
+  jsonData.fields = fieldDefs.map((field) =>
+    ({
+      field_id: field.fieldRef.fieldId,
+      field_name: field.fieldRef.fieldName,
+      field_type: field.fieldRef.type,
+      docstring: field.docstring,
+      choices: (field.enumChoices || []).map(
+          (choice) => ({name: choice.label, doc: choice.docstring})),
+      qualifiedMembers: (field.userChoices || []).map(
+          (userRef) => ({name: userRef.displayName})),
+    }),
+  );
+
+  return jsonData;
+}
+
+
+/**
+ * Convert the object resulting of a monorail.Features ListHotlistsByUser
+ * call to the format expected by TKR_populateAutocomplete.
+ * @param {Array<HotlistV0>} hotlists A lists of hotlists
+ * @return {Array<{ref_str: string, summary: string}>}
+ */
+function TKR_convertHotlists(hotlists) {
+  if (hotlists === undefined) {
+    return [];
+  }
+
+  const seen = new Set();
+  const ambiguousNames = new Set();
+
+  hotlists.forEach((hotlist) => {
+    if (seen.has(hotlist.name)) {
+      ambiguousNames.add(hotlist.name);
+    }
+    seen.add(hotlist.name);
+  });
+
+  return hotlists.map((hotlist) => {
+    let ref_str = hotlist.name;
+    if (ambiguousNames.has(hotlist.name)) {
+      ref_str = hotlist.owner_ref.display_name + ':' + ref_str;
+    }
+    return {ref_str: ref_str, summary: hotlist.summary};
+  });
+}
+
+
+/**
+ * Initializes hotlists in autocomplete store.
+ * @param {Array<HotlistV0>} hotlists
+ */
+function TKR_populateHotlistAutocomplete(hotlists) {
+  TKR_setUpHotlistsStore(TKR_convertHotlists(hotlists));
+}
+
+
+/**
+ * Add project config data that's already been fetched to the legacy
+ * autocomplete.
+ * @param {Config} projectConfig Returned projectConfig data.
+ * @param {GetVisibleMembersResponse} visibleMembers
+ * @param {Array<string>} customPermissions
+ */
+function TKR_populateAutocomplete(projectConfig, visibleMembers,
+    customPermissions = []) {
+  const {statusDefs, componentDefs, labelDefs, fieldDefs,
+    exclusiveLabelPrefixes, projectName} = projectConfig;
+
+  const {memberEmails, nonGroupEmails} =
+    TKR_convertVisibleMembers(visibleMembers);
+  TKR_setUpMemberStore(memberEmails, nonGroupEmails);
+  TKR_prepOwnerField(memberEmails);
+
+  const {open, closed, strict} = TKR_convertStatuses({statusDefs});
+  TKR_setUpStatusStore(open, closed);
+  TKR_restrict_to_known = strict;
+
+  const {components} = TKR_convertComponents({componentDefs});
+  TKR_setUpComponentStore(components);
+
+  const {excl_prefixes, labels} = TKR_convertLabels(
+      {labelDefs, exclusiveLabelPrefixes}, fieldDefs);
+  TKR_exclPrefixes = excl_prefixes;
+  TKR_setUpLabelStore(labels);
+
+  const {fields} = TKR_convertFields({fieldDefs});
+  TKR_setUpUserAutocompleteStores(fields);
+
+  /* QuickEdit is not yet in Monorail. crbug.com/monorail/1926
+  TKR_setUpQuickEditStore(
+      jsonData.labels, jsonData.memberEmails, jsonData.open, jsonData.closed,
+      jsonData.nonGroupEmails);
+  */
+
+  // We need to wait until both exclusive prefixes (in configPromise) and
+  // labels (in labelsPromise) have been read.
+  TKR_prepLabelAC(TKR_labelFieldIDPrefix);
+
+  TKR_setUpSearchStore(
+      labels, memberEmails, open, closed,
+      components, fields, nonGroupEmails);
+
+  TKR_setUpCustomPermissionsStore(customPermissions);
+}
diff --git a/static/js/tracker/tracker-components.js b/static/js/tracker/tracker-components.js
new file mode 100644
index 0000000..633d70b
--- /dev/null
+++ b/static/js/tracker/tracker-components.js
@@ -0,0 +1,64 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS code for editing components and component definitions.
+ */
+
+var TKR_leafNameXmlHttp;
+
+var TKR_leafNameRE = /^[a-zA-Z]([-_]?[a-zA-Z0-9])+$/;
+var TKR_oldName = '';
+
+/**
+ * Function to validate the component leaf name..
+ * @param {string} projectName Current project name.
+ * @param {string} parentPath Path to this component's parent.
+ * @param {string} originalName Original leaf name, keeping that is always OK.
+ * @param {string} token security token.
+ */
+function TKR_checkLeafName(projectName, parentPath, originalName, token) {
+  var name = $('leaf_name').value;
+  var feedback = $('leafnamefeedback');
+  if (name == originalName) {
+    $('submit_btn').disabled = '';
+    feedback.textContent = '';
+  } else if (name != TKR_oldName) {
+    $('submit_btn').disabled = 'disabled';
+    if (name == '') {
+      feedback.textContent = 'Please choose a name';
+    } else if (!TKR_leafNameRE.test(name)) {
+      feedback.textContent = 'Invalid component name';
+    } else if (name.length > 30) {
+      feedback.textContent = 'Name is too long';
+    } else {
+      TKR_checkLeafNameOnServer(projectName, parentPath, name, token);
+    }
+  }
+  TKR_oldName = name;
+}
+
+
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName Current project name.
+ * @param {string} leafName The proposed leaf name.
+ * @param {string} token security token.
+ */
+async function TKR_checkLeafNameOnServer(projectName, parentPath, leafName) {
+  const message = {
+    project_name: projectName,
+    parent_path: parentPath,
+    component_name: leafName
+  };
+  const response = await window.prpcClient.call(
+      'monorail.Projects', 'CheckComponentName', message);
+
+  $('leafnamefeedback').textContent = response.error || '';
+  $('submit_btn').disabled = response.error ? 'disabled' : '';
+}
diff --git a/static/js/tracker/tracker-dd.js b/static/js/tracker/tracker-dd.js
new file mode 100644
index 0000000..e7b4c1e
--- /dev/null
+++ b/static/js/tracker/tracker-dd.js
@@ -0,0 +1,132 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by Monorail to control drag-and-drop re-orderable lists
+ *
+ */
+
+/**
+ * Initializes the drag-and-drop functionality on the elements of a
+ * container node.
+ * TODO(lukasperaza): allow bulk drag-and-drop
+ * @param {Element} container The HTML container element to turn into
+ *    a drag-and-drop list. The items of the list must have the
+ *    class 'drag_item'
+ */
+function TKR_initDragAndDrop(container, opt_onDrop, opt_preventMultiple) {
+  let dragSrc = null;
+  let dragLocation = null;
+  let dragItems = container.getElementsByClassName('drag_item');
+  let target = null;
+
+  opt_preventMultiple = opt_preventMultiple || false;
+  opt_onDrop = opt_onDrop || function() {};
+
+  function _handleMouseDown(event) {
+    target = event.target;
+  }
+
+  function _handleDragStart(event) {
+    let el = event.currentTarget;
+    let gripper = el.getElementsByClassName('gripper');
+    if (gripper.length && !gripper[0].contains(target)) {
+      event.preventDefault();
+      return;
+    }
+    el.style.opacity = 0.4;
+    event.dataTransfer.setData('text/html', el.outerHTML);
+    event.dataTransfer.dropEffect = 'move';
+    dragSrc = el;
+  }
+
+  function inRect(rect, x, y) {
+    if (x < rect.left || x > rect.right) {
+      return '';
+    } else if (rect.top <= y && y <= rect.top + rect.height / 2) {
+      return 'top';
+    } else {
+      return 'bottom';
+    }
+  }
+
+  function _handleDragOver(event) {
+    if (dragSrc == null) {
+      return true;
+    }
+    event.preventDefault();
+    let el = event.currentTarget;
+    let rect = el.getBoundingClientRect(),
+      classes = el.classList;
+    let section = inRect(rect, event.clientX, event.clientY);
+    if (section == 'top' && !classes.contains('top')) {
+      dragLocation = 'top';
+      classes.remove('bottom');
+      classes.add('top');
+    } else if (section == 'bottom' && !classes.contains('bottom')) {
+      dragLocation = 'bottom';
+      classes.remove('top');
+      classes.add('bottom');
+    }
+    return false;
+  }
+
+  function removeClasses(el) {
+    el.classList.remove('top');
+    el.classList.remove('bottom');
+  }
+
+  function _handleDragDrop(event) {
+    let el = event.currentTarget;
+    if (dragSrc == null || el == dragSrc) {
+      return true;
+    }
+
+    if (opt_preventMultiple) {
+      let dragItems = container.getElementsByClassName('drag_item');
+      for (let i = 0; i < dragItems.length; i++) {
+        dragItems[i].setAttribute('draggable', false);
+      }
+    }
+
+    let srcID = dragSrc.getAttribute('data-id');
+    let id = el.getAttribute('data-id');
+
+    if (dragLocation == 'top') {
+      el.parentNode.insertBefore(dragSrc, el);
+      opt_onDrop(srcID, id, 'above');
+    } else if (dragLocation == 'bottom') {
+      el.parentNode.insertBefore(dragSrc, el.nextSibling);
+      opt_onDrop(srcID, id, 'below');
+    }
+    dragSrc.style.opacity = 0.4;
+    dragSrc = null;
+  }
+
+  function _handleDragEnd(event) {
+    if (dragSrc) {
+      dragSrc.style.opacity = 1;
+      dragSrc = null;
+    }
+    for (let i = 0; i < dragItems.length; i++) {
+      removeClasses(dragItems[i]);
+    }
+  }
+
+  for (let i = 0; i < dragItems.length; i++) {
+    let el = dragItems[i];
+    el.setAttribute('draggable', true);
+    el.addEventListener('mousedown', _handleMouseDown);
+    el.addEventListener('dragstart', _handleDragStart);
+    el.addEventListener('dragover', _handleDragOver);
+    el.addEventListener('drop', _handleDragDrop);
+    el.addEventListener('dragend', _handleDragEnd);
+    el.addEventListener('dragleave', function(event) {
+      removeClasses(event.currentTarget);
+    });
+  }
+}
diff --git a/static/js/tracker/tracker-display.js b/static/js/tracker/tracker-display.js
new file mode 100644
index 0000000..23b9dcf
--- /dev/null
+++ b/static/js/tracker/tracker-display.js
@@ -0,0 +1,322 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * Functions used by Monorail to control the display of elements on
+ * the page, rollovers, and popup menus.
+ *
+ */
+
+
+/**
+ * Show a popup menu below a specified element. Optional x and y deltas can be
+ * used to fine-tune placement.
+ * @param {string} id The HTML id of the popup menu.
+ * @param {Element} el The HTML element that the popup should appear near.
+ * @param {number} opt_deltaX Optional X offset to finetune placement.
+ * @param {number} opt_deltaY Optional Y offset to finetune placement.
+ * @param {Element} opt_menuButton The HTML element for a menu button that
+ *    was pressed to open the menu.  When a button was used, we need to ignore
+ *    the first "click" event, otherwise the menu will immediately close.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showBelow(id, el, opt_deltaX, opt_deltaY, opt_menuButton) {
+  let popupDiv = $(id);
+  let elBounds = nodeBounds(el);
+  let startX = elBounds.x;
+  let startY = elBounds.y + elBounds.h;
+  if (BR_IsIE()) {
+    startX -= 1;
+    startY -= 2;
+  }
+  if (BR_IsSafari()) {
+    startX += 1;
+  }
+  popupDiv.style.display = 'block'; // needed so that offsetWidth != 0
+
+  popupDiv.style.left = '-2000px';
+  if (id == 'pop_dot' || id == 'redoMenu') {
+    startX = startX - popupDiv.offsetWidth + el.offsetWidth;
+  }
+  if (opt_deltaX) startX += opt_deltaX;
+  if (opt_deltaY) startY += opt_deltaY;
+  popupDiv.style.left = (startX)+'px';
+  popupDiv.style.top = (startY)+'px';
+  let popup = new TKR_MyPopup(popupDiv, opt_menuButton);
+  popup.show();
+  return false;
+}
+
+
+/**
+ * Show a popup menu to the right of a specified element. If there is not
+ * enough space to the right, then it will open to the left side instead.
+ * Optional x and y deltas can be used to fine-tune placement.
+ * TODO(jrobbins): reduce redundancy with function above.
+ * @param {string} id The HTML id of the popup menu.
+ * @param {Element} el The HTML element that the popup should appear near.
+ * @param {number} opt_deltaX Optional X offset to finetune placement.
+ * @param {number} opt_deltaY Optional Y offset to finetune placement.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showRight(id, el, opt_deltaX, opt_deltaY) {
+  let popupDiv = $(id);
+  let elBounds = nodeBounds(el);
+  let startX = elBounds.x + elBounds.w;
+  let startY = elBounds.y;
+
+  // Calculate pageSize.w and pageSize.h
+  let docElemWidth = document.documentElement.clientWidth;
+  let docElemHeight = document.documentElement.clientHeight;
+  let pageSize = {
+    w: (window.innerWidth || docElemWidth && docElemWidth > 0 ?
+      docElemWidth : document.body.clientWidth) || 1,
+    h: (window.innerHeight || docElemHeight && docElemHeight > 0 ?
+      docElemHeight : document.body.clientHeight) || 1,
+  };
+
+  // We need to make the popupDiv visible in order to capture its width
+  popupDiv.style.display = 'block';
+  let popupDivBounds = nodeBounds(popupDiv);
+
+  // Show popup to the left
+  if (startX + popupDivBounds.w > pageSize.w) {
+    startX = elBounds.x - popupDivBounds.w;
+    if (BR_IsIE()) {
+      startX -= 4;
+      startY -= 2;
+    }
+    if (BR_IsNav()) {
+      startX -= 2;
+    }
+    if (BR_IsSafari()) {
+      startX += -1;
+    }
+
+  // Show popup to the right
+  } else {
+    if (BR_IsIE()) {
+      startY -= 2;
+    }
+    if (BR_IsNav()) {
+      startX += 2;
+    }
+    if (BR_IsSafari()) {
+      startX += 3;
+    }
+  }
+
+  popupDiv.style.left = '-2000px';
+  popupDiv.style.position = 'absolute';
+  if (opt_deltaX) startX += opt_deltaX;
+  if (opt_deltaY) startY += opt_deltaY;
+  popupDiv.style.left = (startX)+'px';
+  popupDiv.style.top = (startY)+'px';
+  let popup = new TKR_MyPopup(popupDiv);
+  popup.show();
+  return false;
+}
+
+
+/**
+ * Close the specified popup menu and unregister it with the popup
+ * controller, otherwise old leftover popup instances can mess with
+ * the future display of menus.
+ * @param {string} id The HTML ID of the element to hide.
+ */
+function TKR_closePopup(id) {
+  let e = $(id);
+  if (e) {
+    for (let i = 0; i < gPopupController.activePopups_.length; ++i) {
+      if (e === gPopupController.activePopups_[i]._div) {
+        let popup = gPopupController.activePopups_[i];
+        popup.hide();
+        gPopupController.activePopups_.splice(i, 1);
+        return;
+      }
+    }
+  }
+}
+
+
+var TKR_allColumnNames = []; // Will be defined in HTML file.
+
+/**
+ * Close all popup menus.  Also, reset the hover state of the menu item that
+ * was selected. The list of popup menu names is computed from the list of
+ * columns specified in the HTML for the issue list page.
+ * @param menuItem {Element} The menu item that the user clicked.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_closeAllPopups(menuItem) {
+  for (let col_index = 0; col_index < TKR_allColumnNames.length; col_index++) {
+    TKR_closePopup('pop_' + col_index);
+    TKR_closePopup('filter_' + col_index);
+  }
+  TKR_closePopup('pop_dot');
+  TKR_closePopup('redoMenu');
+  menuItem.classList.remove('hover');
+  return false;
+}
+
+
+/**
+ * Close all the submenus (of which, one may be currently open).
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_closeSubmenus() {
+  for (let col_index = 0; col_index < TKR_allColumnNames.length; col_index++) {
+    TKR_closePopup('filter_' + col_index);
+  }
+  return false;
+}
+
+
+/**
+ * Find the enclosing HTML element that controls this section of the
+ * page and set it to use CSS class "opened".  That will make the
+ * section display in the opened state, regardless of what state is
+ * was in before.
+ * @param {Element} el The HTML element that the user clicked on.
+ * @return Always returns false to indicate that the browser should handle the
+ * event normally.
+ */
+function TKR_showHidden(el) {
+  while (el) {
+    if (el.classList.contains('closed')) {
+      el.classList.remove('closed');
+      el.classList.add('opened');
+      return false;
+    }
+    if (el.classList.contains('opened')) {
+      return false;
+    }
+    el = el.parentNode;
+  }
+}
+
+
+/**
+ * Toggle the display of a column in the issue list page.  That is
+ * done by adding or removing a CSS class of an enclosing HTML
+ * element, and by CSS rules that trigger based on that CSS class.
+ * @param {string} colName The name of the column to toggle,
+ * corresponds to a CSS class.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_toggleColumn(colName) {
+  let controlDiv = $('colcontrol');
+  if (controlDiv.classList.contains(colName)) {
+    controlDiv.classList.remove(colName);
+  } else {
+    controlDiv.classList.add(colName);
+  }
+  return false;
+}
+
+
+/**
+ * Toggle the display of a set of rows in the issue list page.  That is
+ * done by adding or removing a CSS class of an enclosing HTML
+ * element, and by CSS rules that trigger based on that CSS class.
+ * TODO(jrobbins): actually, this automatically hides the other groups.
+ * @param {string} rowClassName The name of the row group to toggle,
+ * corresponds to a CSS class.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_toggleRows(rowClassName) {
+  let controlDiv = $('colcontrol');
+  controlDiv.classList.add('hide_pri_groups');
+  controlDiv.classList.add('hide_mile_groups');
+  controlDiv.classList.add('hide_stat_groups');
+  TKR_toggleColumn(rowClassName);
+  return false;
+}
+
+
+/**
+ * A simple class that can manage the display of a popup menu.  Instances
+ * of this class are used by popup_controller.js.
+ * @param {Element} div The div that contains the popup menu.
+ * @param {Element} opt_launcherEl The button that launched the popup menu,
+ *     if any.
+ * @constructor
+ */
+function TKR_MyPopup(div, opt_launcherEl) {
+  this._div = div;
+  this._launcher = opt_launcherEl;
+  this._isVisible = false;
+}
+
+
+/**
+ * Show a popup menu.  This method registers the popup with popup_controller.
+ */
+TKR_MyPopup.prototype.show = function() {
+  this._div.style.display = 'block';
+  this._isVisible = true;
+  PC_addPopup(this);
+};
+
+
+/**
+ * Show a popup menu.  This method is called from the deactive method,
+ * which is called by popup_controller.
+ */
+TKR_MyPopup.prototype.hide = function() {
+  this._div.style.display = 'none';
+  this._isVisible = false;
+};
+
+
+/**
+ * When the popup_controller gets a user click, it calls deactive() on
+ * every active popup to check if the click should close that popup.
+ */
+TKR_MyPopup.prototype.deactivate = function(e) {
+  if (this._isVisible) {
+    let p = GetMousePosition(e);
+    if (nodeBounds(this._div).contains(p)) {
+      return false; // use clicked on popup, remain visible
+    } else if (this._launcher && nodeBounds(this._launcher).contains(p)) {
+      this._launcher = null;
+      return false; // mouseup element that launched menu, remain visible
+    } else {
+      this.hide();
+      return true; // clicked outside popup, make invisible
+    }
+  } else {
+    return true; // already deactivated, not visible
+  }
+};
+
+
+/**
+ * Highlight the issue row on the list page that contains the given
+ * checkbox.
+ * @param {Element} cb The checkbox that the user changed.
+ * @return Always returns false to indicate that the browser should
+ * handle the event normally.
+ */
+function TKR_highlightRow(el) {
+  let checked = el.checked;
+  while (el && el.tagName != 'TR') {
+    el = el.parentNode;
+  }
+  if (checked) {
+    el.classList.add('selected');
+  } else {
+    el.classList.remove('selected');
+  }
+  return false;
+}
diff --git a/static/js/tracker/tracker-editing.js b/static/js/tracker/tracker-editing.js
new file mode 100644
index 0000000..d53b515
--- /dev/null
+++ b/static/js/tracker/tracker-editing.js
@@ -0,0 +1,1823 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+/* eslint-disable prefer-const */
+
+/**
+ * This file contains JS functions that support various issue editing
+ * features of Monorail.  These editing features include: selecting
+ * issues on the issue list page, adding attachments, expanding and
+ * collapsing the issue editing form, and starring issues.
+ *
+ * Browser compatability: IE6, IE7, FF1.0+, Safari.
+ */
+
+
+/**
+ * Here are some string constants that are used repeatedly in the code.
+ */
+let TKR_SELECTED_CLASS = 'selected';
+let TKR_UNDEF_CLASS = 'undef';
+let TKR_NOVEL_CLASS = 'novel';
+let TKR_EXCL_CONFICT_CLASS = 'exclconflict';
+let TKR_QUESTION_MARK_CLASS = 'questionmark';
+let TKR_ATTACHPROMPT_ID = 'attachprompt';
+let TKR_ATTACHAFILE_ID = 'attachafile';
+let TKR_ATTACHMAXSIZE_ID = 'attachmaxsize';
+let TKR_CURRENT_TEMPLATE_INDEX_ID = 'current_template_index';
+let TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID = 'members_only_checkbox';
+let TKR_PROMPT_SUMMARY_EDITOR_ID = 'summary_editor';
+let TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID =
+    'summary_must_be_edited_checkbox';
+let TKR_PROMPT_CONTENT_EDITOR_ID = 'content_editor';
+let TKR_PROMPT_STATUS_EDITOR_ID = 'status_editor';
+let TKR_PROMPT_OWNER_EDITOR_ID = 'owner_editor';
+let TKR_PROMPT_ADMIN_NAMES_EDITOR_ID = 'admin_names_editor';
+let TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID =
+    'owner_defaults_to_member_checkbox';
+let TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID =
+    'owner_defaults_to_member_area';
+let TKR_COMPONENT_REQUIRED_CHECKBOX_ID =
+    'component_required_checkbox';
+let TKR_PROMPT_COMPONENTS_EDITOR_ID = 'components_editor';
+let TKR_FIELD_EDITOR_ID_PREFIX = 'tmpl_custom_';
+let TKR_PROMPT_LABELS_EDITOR_ID_PREFIX = 'label';
+let TKR_CONFIRMAREA_ID = 'confirmarea';
+let TKR_DISCARD_YOUR_CHANGES = 'Discard your changes?';
+// Note, users cannot enter '<'.
+let TKR_DELETED_PROMPT_NAME = '<DELETED>';
+// Display warning if labels contain the following prefixes.
+// The following list is the same as tracker_constants.RESERVED_PREFIXES except
+// for the 'hotlist' prefix. 'hostlist' will be added when it comes a full
+// feature and when projects that use 'Hostlist-*' labels are transitioned off.
+let TKR_LABEL_RESERVED_PREFIXES = [
+  'id', 'project', 'reporter', 'summary', 'status', 'owner', 'cc',
+  'attachments', 'attachment', 'component', 'opened', 'closed',
+  'modified', 'is', 'has', 'blockedon', 'blocking', 'blocked', 'mergedinto',
+  'stars', 'starredby', 'description', 'comment', 'commentby', 'label',
+  'rank', 'explicit_status', 'derived_status', 'explicit_owner',
+  'derived_owner', 'explicit_cc', 'derived_cc', 'explicit_label',
+  'derived_label', 'last_comment_by', 'exact_component',
+  'explicit_component', 'derived_component'];
+
+
+/**
+ * Appends a given child element to the DOM based on parameters.
+ * @param {HTMLElement} parentEl
+ * @param {string} tag
+ * @param {string} optClassName
+ * @param {string} optID
+ * @param {string} optText
+ * @param {string} optStyle
+*/
+function TKR_createChild(parentEl, tag, optClassName, optID, optText, optStyle) {
+  let el = document.createElement(tag);
+  if (optClassName) el.classList.add(optClassName);
+  if (optID) el.id = optID;
+  if (optText) el.textContent = optText;
+  if (optStyle) el.setAttribute('style', optStyle);
+  parentEl.appendChild(el);
+  return el;
+}
+
+/**
+ * Select all the issues on the issue list page.
+ */
+function TKR_selectAllIssues() {
+  TKR_selectIssues(true);
+}
+
+
+/**
+ * Function to deselect all the issues on the issue list page.
+ */
+function TKR_selectNoneIssues() {
+  TKR_selectIssues(false);
+}
+
+
+/**
+ * Function to select or deselect all the issues on the issue list page.
+ * @param {boolean} checked True means select issues, False means deselect.
+ */
+function TKR_selectIssues(checked) {
+  let table = $('resultstable');
+  for (let r = 0; r < table.rows.length; ++r) {
+    let row = table.rows[r];
+    let firstCell = row.cells[0];
+    if (firstCell.tagName == 'TD') {
+      for (let e = 0; e < firstCell.childNodes.length; ++e) {
+        let element = firstCell.childNodes[e];
+        if (element.tagName == 'INPUT' && element.type == 'checkbox') {
+          element.checked = checked ? 'checked' : '';
+          if (checked) {
+            row.classList.add(TKR_SELECTED_CLASS);
+          } else {
+            row.classList.remove(TKR_SELECTED_CLASS);
+          }
+        }
+      }
+    }
+  }
+}
+
+
+/**
+ * The ID number to append to the next dynamically created file upload field.
+ */
+let TKR_nextFileID = 1;
+
+
+/**
+ * Function to dynamically create a new attachment upload field add
+ * insert it into the page DOM.
+ * @param {string} id The id of the parent HTML element.
+ *
+ * TODO(lukasperaza): use different nextFileID for separate forms on same page,
+ *  e.g. issue update form and issue description update form
+ */
+function TKR_addAttachmentFields(id, attachprompt_id,
+    attachafile_id, attachmaxsize_id) {
+  if (TKR_nextFileID >= 16) {
+    return;
+  }
+  if (typeof attachprompt_id === 'undefined') {
+    attachprompt_id = TKR_ATTACHPROMPT_ID;
+  }
+  if (typeof attachafile_id === 'undefined') {
+    attachafile_id = TKR_ATTACHAFILE_ID;
+  }
+  if (typeof attachmaxsize_id === 'undefined') {
+    attachmaxsize_id = TKR_ATTACHMAXSIZE_ID;
+  }
+  let el = $(id);
+  el.style.marginTop = '4px';
+  let div = document.createElement('div');
+  var id = 'file' + TKR_nextFileID;
+  let label = TKR_createChild(div, 'label', null, null, 'Attach file:');
+  label.setAttribute('for', id);
+  let input = TKR_createChild(
+      div, 'input', null, id, null, 'width:auto;margin-left:17px');
+  input.setAttribute('type', 'file');
+  input.name = id;
+  let removeLink = TKR_createChild(
+      div, 'a', null, null, 'Remove', 'font-size:x-small');
+  removeLink.href = '#';
+  removeLink.addEventListener('click', function(event) {
+    let target = event.target;
+    $(attachafile_id).focus();
+    target.parentNode.parentNode.removeChild(target.parentNode);
+    event.preventDefault();
+  });
+  el.appendChild(div);
+  el.querySelector('input').focus();
+  ++TKR_nextFileID;
+  if (TKR_nextFileID < 16) {
+    $(attachafile_id).textContent = 'Attach another file';
+  } else {
+    $(attachprompt_id).style.display = 'none';
+  }
+  $(attachmaxsize_id).style.display = '';
+}
+
+
+/**
+ * Function to display the form so that the user can update an issue.
+ */
+function TKR_openIssueUpdateForm() {
+  TKR_showHidden($('makechangesarea'));
+  TKR_goToAnchor('makechanges');
+  TKR_forceProperTableWidth();
+  window.setTimeout(
+      function() {
+        document.getElementById('addCommentTextArea').focus();
+      },
+      100);
+}
+
+
+/**
+ * The index of the template that is currently selected for editing
+ * on the administration page for issues.
+ */
+let TKR_currentTemplateIndex = 0;
+
+
+/**
+ * Array of field IDs that are defined in the current project, set by call to setFieldIDs().
+ */
+let TKR_fieldIDs = [];
+
+
+function TKR_setFieldIDs(fieldIDs) {
+  TKR_fieldIDs = fieldIDs;
+}
+
+
+/**
+ * This function displays the appropriate template text in a text field.
+ * It is called after the user has selected one template to view/edit.
+ * @param {Element} widget The list widget containing the list of templates.
+ */
+function TKR_selectTemplate(widget) {
+  TKR_showHidden($('edit_panel'));
+  TKR_currentTemplateIndex = widget.value;
+  $(TKR_CURRENT_TEMPLATE_INDEX_ID).value = TKR_currentTemplateIndex;
+
+  let content_editor = $(TKR_PROMPT_CONTENT_EDITOR_ID);
+  TKR_makeDefined(content_editor);
+
+  let can_edit = $('can_edit_' + TKR_currentTemplateIndex).value == 'yes';
+  let disabled = can_edit ? '' : 'disabled';
+
+  $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).disabled = disabled;
+  $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).checked = $(
+      'members_only_' + TKR_currentTemplateIndex).value == 'yes';
+  $(TKR_PROMPT_SUMMARY_EDITOR_ID).disabled = disabled;
+  $(TKR_PROMPT_SUMMARY_EDITOR_ID).value = $(
+      'summary_' + TKR_currentTemplateIndex).value;
+  $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).disabled = disabled;
+  $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).checked = $(
+      'summary_must_be_edited_' + TKR_currentTemplateIndex).value == 'yes';
+  content_editor.disabled = disabled;
+  content_editor.value = $('content_' + TKR_currentTemplateIndex).value;
+  $(TKR_PROMPT_STATUS_EDITOR_ID).disabled = disabled;
+  $(TKR_PROMPT_STATUS_EDITOR_ID).value = $(
+      'status_' + TKR_currentTemplateIndex).value;
+  $(TKR_PROMPT_OWNER_EDITOR_ID).disabled = disabled;
+  $(TKR_PROMPT_OWNER_EDITOR_ID).value = $(
+      'owner_' + TKR_currentTemplateIndex).value;
+  $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).disabled = disabled;
+  $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).checked = $(
+      'owner_defaults_to_member_' + TKR_currentTemplateIndex).value == 'yes';
+  $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).disabled = disabled;
+  $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).checked = $(
+      'component_required_' + TKR_currentTemplateIndex).value == 'yes';
+  $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).disabled = disabled;
+  $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).style.display =
+      $(TKR_PROMPT_OWNER_EDITOR_ID).value ? 'none' : '';
+  $(TKR_PROMPT_COMPONENTS_EDITOR_ID).disabled = disabled;
+  $(TKR_PROMPT_COMPONENTS_EDITOR_ID).value = $(
+      'components_' + TKR_currentTemplateIndex).value;
+
+  // Blank out all custom field editors first, then fill them in during the next loop.
+  for (var i = 0; i < TKR_fieldIDs.length; i++) {
+    let fieldEditor = $(TKR_FIELD_EDITOR_ID_PREFIX + TKR_fieldIDs[i]);
+    let holder = $('field_value_' + TKR_currentTemplateIndex + '_' + TKR_fieldIDs[i]);
+    if (fieldEditor) {
+      fieldEditor.disabled = disabled;
+      fieldEditor.value = holder ? holder.value : '';
+    }
+  }
+
+  var i = 0;
+  while ($(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i)) {
+    $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).disabled = disabled;
+    $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).value =
+        $('label_' + TKR_currentTemplateIndex + '_' + i).value;
+    i++;
+  }
+
+  $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).disabled = disabled;
+  $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).value = $(
+      'admin_names_' + TKR_currentTemplateIndex).value;
+
+  let numNonDeletedTemplates = 0;
+  for (var i = 0; i < TKR_templateNames.length; i++) {
+    if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+      numNonDeletedTemplates++;
+    }
+  }
+  if ($('delbtn')) {
+    if (numNonDeletedTemplates > 1) {
+      $('delbtn').disabled='';
+    } else { // Don't allow the last template to be deleted.
+      $('delbtn').disabled='disabled';
+    }
+  }
+}
+
+
+var TKR_templateNames = []; // Exported in tracker-onload.js
+
+
+/**
+ * Create a new issue template and add the needed form fields to the DOM.
+ */
+function TKR_newTemplate() {
+  let newIndex = TKR_templateNames.length;
+  let templateName = prompt('Name of new template?', '');
+  templateName = templateName.replace(
+      /[&<>"]/g, '', // " help emacs highlighting
+  );
+  if (!templateName) return;
+
+  for (let i = 0; i < TKR_templateNames.length; i++) {
+    if (templateName == TKR_templateNames[i]) {
+      alert('Please choose a unique name.');
+      return;
+    }
+  }
+
+  TKR_addTemplateHiddenFields(newIndex, templateName);
+  TKR_templateNames.push(templateName);
+
+  let templateOption = TKR_createChild(
+      $('template_menu'), 'option', null, null, templateName);
+  templateOption.value = newIndex;
+  templateOption.selected = 'selected';
+
+  let developerOption = TKR_createChild(
+      $('default_template_for_developers'), 'option', null, null, templateName);
+  developerOption.value = templateName;
+
+  let userOption = TKR_createChild(
+      $('default_template_for_users'), 'option', null, null, templateName);
+  userOption.value = templateName;
+
+  TKR_selectTemplate($('template_menu'));
+}
+
+
+/**
+ * Private function to append HTML for new hidden form fields
+ * for a new issue template to the issue admin form.
+ */
+function TKR_addTemplateHiddenFields(templateIndex, templateName) {
+  let parentEl = $('adminTemplates');
+  TKR_appendHiddenField(
+      parentEl, 'template_id_' + templateIndex, 'template_id_' + templateIndex, '0');
+  TKR_appendHiddenField(parentEl, 'name_' + templateIndex,
+      'name_' + templateIndex, templateName);
+  TKR_appendHiddenField(parentEl, 'members_only_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'summary_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'summary_must_be_edited_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'content_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'status_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'owner_' + templateIndex);
+  TKR_appendHiddenField(
+      parentEl, 'owner_defaults_to_member_' + templateIndex,
+      'owner_defaults_to_member_' + templateIndex, 'yes');
+  TKR_appendHiddenField(parentEl, 'component_required_' + templateIndex);
+  TKR_appendHiddenField(parentEl, 'components_' + templateIndex);
+
+  var i = 0;
+  while ($('label_0_' + i)) {
+    TKR_appendHiddenField(parentEl, 'label_' + templateIndex,
+        'label_' + templateIndex + '_' + i);
+    i++;
+  }
+
+  for (var i = 0; i < TKR_fieldIDs.length; i++) {
+    let fieldId = 'field_value_' + templateIndex + '_' + TKR_fieldIDs[i];
+    TKR_appendHiddenField(parentEl, fieldId, fieldId);
+  }
+
+  TKR_appendHiddenField(parentEl, 'admin_names_' + templateIndex);
+  TKR_appendHiddenField(
+      parentEl, 'can_edit_' + templateIndex, 'can_edit_' + templateIndex,
+      'yes');
+}
+
+
+/**
+ * Utility function to append string parts for one hidden field
+ * to the given array.
+ */
+function TKR_appendHiddenField(parentEl, name, opt_id, opt_value) {
+  let input = TKR_createChild(parentEl, 'input', null, opt_id || name);
+  input.setAttribute('type', 'hidden');
+  input.name = name;
+  input.value = opt_value || '';
+}
+
+
+/**
+ * Delete the currently selected issue template, and mark its hidden
+ * form field as deleted so that they will be ignored when submitted.
+ */
+function TKR_deleteTemplate() {
+  // Mark the current template name as deleted.
+  TKR_templateNames.splice(
+      TKR_currentTemplateIndex, 1, TKR_DELETED_PROMPT_NAME);
+  $('name_' + TKR_currentTemplateIndex).value = TKR_DELETED_PROMPT_NAME;
+  _toggleHidden($('edit_panel'));
+  $('delbtn').disabled = 'disabled';
+  TKR_rebuildTemplateMenu();
+  TKR_rebuildDefaultTemplateMenu('default_template_for_developers');
+  TKR_rebuildDefaultTemplateMenu('default_template_for_users');
+}
+
+/**
+ * Utility function to rebuild the template menu on the issue admin page.
+ */
+function TKR_rebuildTemplateMenu() {
+  let parentEl = $('template_menu');
+  while (parentEl.childNodes.length) {
+    parentEl.removeChild(parentEl.childNodes[0]);
+  }
+  for (let i = 0; i < TKR_templateNames.length; i++) {
+    if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+      let option = TKR_createChild(
+          parentEl, 'option', null, null, TKR_templateNames[i]);
+      option.value = i;
+    }
+  }
+}
+
+
+/**
+ * Utility function to rebuild a default template drop-down.
+ */
+function TKR_rebuildDefaultTemplateMenu(menuID) {
+  let defaultTemplateName = $(menuID).value;
+  let parentEl = $(menuID);
+  while (parentEl.childNodes.length) {
+    parentEl.removeChild(parentEl.childNodes[0]);
+  }
+  for (let i = 0; i < TKR_templateNames.length; i++) {
+    if (TKR_templateNames[i] != TKR_DELETED_PROMPT_NAME) {
+      let option = TKR_createChild(
+          parentEl, 'option', null, null, TKR_templateNames[i]);
+      option.values = TKR_templateNames[i];
+      if (defaultTemplateName == TKR_templateNames[i]) {
+        option.setAttribute('selected', 'selected');
+      }
+    }
+  }
+}
+
+
+/**
+ * Change the issue template to the specified one.
+ * TODO(jrobbins): move to an AJAX implementation that would not reload page.
+ *
+ * @param {string} projectName The name of the current project.
+ * @param {string} templateName The name of the template to switch to.
+ */
+function TKR_switchTemplate(projectName, templateName) {
+  let ok = true;
+  if (TKR_isDirty()) {
+    ok = confirm('Switching to a different template will lose the text you entered.');
+  }
+  if (ok) {
+    TKR_initialFormValues = TKR_currentFormValues();
+    window.location = '/p/' + projectName +
+      '/issues/entry?template=' + templateName;
+  }
+}
+
+/**
+ * Function to remove a CSS class and initial tip from a text widget.
+ * Some text fields or text areas display gray textual tips to help the user
+ * make use of those widgets.  When the user focuses on the field, the tip
+ * disappears and is made ready for user input (in the normal text color).
+ * @param {Element} el The form field that had the gray text tip.
+ */
+function TKR_makeDefined(el) {
+  if (el.classList.contains(TKR_UNDEF_CLASS)) {
+    el.classList.remove(TKR_UNDEF_CLASS);
+    el.value = '';
+  }
+}
+
+
+/**
+ * Save the contents of the visible issue template text area into a hidden
+ * text field for later submission.
+ * Called when the user has edited the text of a issue template.
+ */
+function TKR_saveTemplate() {
+  if (TKR_currentTemplateIndex) {
+    $('members_only_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_MEMBERS_ONLY_CHECKBOX_ID).checked ? 'yes' : '';
+    $('summary_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_SUMMARY_EDITOR_ID).value;
+    $('summary_must_be_edited_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_SUMMARY_MUST_BE_EDITED_CHECKBOX_ID).checked ? 'yes' : '';
+    $('content_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_CONTENT_EDITOR_ID).value;
+    $('status_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_STATUS_EDITOR_ID).value;
+    $('owner_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_OWNER_EDITOR_ID).value;
+    $('owner_defaults_to_member_' + TKR_currentTemplateIndex).value =
+        $(TKR_OWNER_DEFAULTS_TO_MEMBER_CHECKBOX_ID).checked ? 'yes' : '';
+    $('component_required_' + TKR_currentTemplateIndex).value =
+        $(TKR_COMPONENT_REQUIRED_CHECKBOX_ID).checked ? 'yes' : '';
+    $('components_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_COMPONENTS_EDITOR_ID).value;
+    $(TKR_OWNER_DEFAULTS_TO_MEMBER_AREA_ID).style.display =
+        $(TKR_PROMPT_OWNER_EDITOR_ID).value ? 'none' : '';
+
+    for (var i = 0; i < TKR_fieldIDs.length; i++) {
+      let fieldID = TKR_fieldIDs[i];
+      let fieldEditor = $(TKR_FIELD_EDITOR_ID_PREFIX + fieldID);
+      if (fieldEditor) {
+        _saveFieldValue(fieldID, fieldEditor.value);
+      }
+    }
+
+    var i = 0;
+    while ($('label_' + TKR_currentTemplateIndex + '_' + i)) {
+      $('label_' + TKR_currentTemplateIndex + '_' + i).value =
+         $(TKR_PROMPT_LABELS_EDITOR_ID_PREFIX + i).value;
+      i++;
+    }
+
+    $('admin_names_' + TKR_currentTemplateIndex).value =
+        $(TKR_PROMPT_ADMIN_NAMES_EDITOR_ID).value;
+  }
+}
+
+
+function _saveFieldValue(fieldID, val) {
+  let fieldValId = 'field_value_' + TKR_currentTemplateIndex + '_' + fieldID;
+  $(fieldValId).value = val;
+}
+
+
+/**
+ * This is a json string encoding of an array of form values after the initial
+ * page load. It is used for comparison on page unload to prompt the user
+ * before abandoning changes. It is initialized in TKR_onload().
+*/
+let TKR_initialFormValues;
+
+
+/**
+ * Returns a json string encoding of an array of all the values from user
+ * input fields of interest (omits search box, e.g.)
+ */
+function TKR_currentFormValues() {
+  let inputs = document.querySelectorAll('input, textarea, select, checkbox');
+  let values = [];
+
+  for (i = 0; i < inputs.length; i++) {
+    // Don't include blank inputs. This prevents a popup if the user
+    // clicks "add a row" for new labels but doesn't actually enter any
+    // text into them. Also ignore search box contents.
+    if (inputs[i].value && !inputs[i].hasAttribute('ignore-dirty') &&
+        inputs[i].name != 'token') {
+      values.push(inputs[i].value);
+    }
+  }
+
+  return JSON.stringify(values);
+}
+
+
+/**
+ * This function returns true if the user has made any edits to fields of
+ * interest.
+ */
+function TKR_isDirty() {
+  return TKR_initialFormValues != TKR_currentFormValues();
+}
+
+
+/**
+ * The user has clicked the 'Discard' button on the issue update form.
+ * If the form has been edited, ask if they are sure about discarding
+ * before then navigating to the given URL.  This can go up to some
+ * other page, or reload the current page with a fresh form.
+ * @param {string} nextUrl The page to show after discarding.
+ */
+function TKR_confirmDiscardUpdate(nextUrl) {
+  if (!TKR_isDirty() || confirm(TKR_DISCARD_YOUR_CHANGES)) {
+    document.location = nextUrl;
+  }
+}
+
+
+/**
+ * The user has clicked the 'Discard' button on the issue entry form.
+ * If the form has been edited, this function asks if they are sure about
+ * discarding before doing it.
+ * @param {Element} discardButton The 'Discard' button.
+ */
+function TKR_confirmDiscardEntry(discardButton) {
+  if (!TKR_isDirty() || confirm(TKR_DISCARD_YOUR_CHANGES)) {
+    TKR_go('list');
+  }
+}
+
+
+/**
+ * Normally, we show 2 rows of label editing fields when updating an issue.
+ * However, if the issue has more than that many labels already, we make sure to
+ * show them all.
+ */
+function TKR_exposeExistingLabelFields() {
+  if ($('label3').value ||
+      $('label4').value ||
+      $('label5').value) {
+    if ($('addrow1')) {
+      _showID('LF_row2');
+      _hideID('addrow1');
+    }
+  }
+  if ($('label6').value ||
+      $('label7').value ||
+      $('label8').value) {
+    _showID('LF_row3');
+    _hideID('addrow2');
+  }
+  if ($('label9').value ||
+      $('label10').value ||
+      $('label11').value) {
+    _showID('LF_row4');
+    _hideID('addrow3');
+  }
+  if ($('label12').value ||
+      $('label13').value ||
+      $('label14').value) {
+    _showID('LF_row5');
+    _hideID('addrow4');
+  }
+  if ($('label15').value ||
+      $('label16').value ||
+      $('label17').value) {
+    _showID('LF_row6');
+    _hideID('addrow5');
+  }
+  if ($('label18').value ||
+      $('label19').value ||
+      $('label20').value) {
+    _showID('LF_row7');
+    _hideID('addrow6');
+  }
+  if ($('label21').value ||
+      $('label22').value ||
+      $('label23').value) {
+    _showID('LF_row8');
+    _hideID('addrow7');
+  }
+}
+
+
+/**
+ * Flag to indicate when the user has not yet caused any input events.
+ * We use this to clear the placeholder in the new issue summary field
+ * exactly once.
+ */
+let TKR_firstEvent = true;
+
+
+/**
+ * This is called in response to almost any user input event on the
+ * issue entry page.  If the placeholder in the new issue sumary field has
+ * not yet been cleared, then this function clears it.
+ */
+function TKR_clearOnFirstEvent(initialSummary) {
+  if (TKR_firstEvent && $('summary').value == initialSummary) {
+    TKR_firstEvent = false;
+    $('summary').value = TKR_keepJustSummaryPrefixes($('summary').value);
+  }
+}
+
+/**
+ * Clear the summary, except for any prefixes of the form "[bracketed text]"
+ * or "keyword:".  If there were any, add a trailing space.  This is useful
+ * to people who like to encode issue classification info in the summary line.
+ */
+function TKR_keepJustSummaryPrefixes(s) {
+  let matches = s.match(/^(\[[^\]]+\])+|^(\S+:\s*)+/);
+  if (matches == null) {
+    return '';
+  }
+
+  let prefix = matches[0];
+  if (prefix.substr(prefix.length - 1) != ' ') {
+    prefix += ' ';
+  }
+  return prefix;
+}
+
+/**
+ * An array of label <input>s that start with reserved prefixes.
+ */
+let TKR_labelsWithReservedPrefixes = [];
+
+/**
+ * An array of label <input>s that are equal to reserved words.
+ */
+let TKR_labelsConflictingWithReserved = [];
+
+/**
+ * An array of novel issue status values entered by the user on the
+ * current page. 'Novel' means that they are not well known and are
+ * likely to be typos.  Note that this list will always have zero or
+ * one element, but a list is used for consistency with the list of
+ * novel labels.
+ */
+let TKR_novelStatuses = [];
+
+/**
+ * An array of novel issue label values entered by the user on the
+ * current page. 'Novel' means that they are not well known and are
+ * likely to be typos.
+ */
+let TKR_novelLabels = [];
+
+/**
+ * A boolean that indicates whether the entered owner value is valid or not.
+ */
+let TKR_invalidOwner = false;
+
+/**
+ * The user has changed the issue status text field.  This function
+ * checks whether it is a well-known status value.  If not, highlight it
+ * as a potential typo.
+ * @param {Element} textField The issue status text field.
+ * @return Always returns true to indicate that the browser should
+ * continue to process the user input event normally.
+ */
+function TKR_confirmNovelStatus(textField) {
+  let v = textField.value.trim().toLowerCase();
+  let isNovel = (v !== '');
+  let wellKnown = TKR_statusWords;
+  for (let i = 0; i < wellKnown.length && isNovel; ++i) {
+    let wk = wellKnown[i];
+    if (v == wk.toLowerCase()) {
+      isNovel = false;
+    }
+  }
+  if (isNovel) {
+    if (TKR_novelStatuses.indexOf(textField) == -1) {
+      TKR_novelStatuses.push(textField);
+    }
+    textField.classList.add(TKR_NOVEL_CLASS);
+  } else {
+    if (TKR_novelStatuses.indexOf(textField) != -1) {
+      TKR_novelStatuses.splice(TKR_novelStatuses.indexOf(textField), 1);
+    }
+    textField.classList.remove(TKR_NOVEL_CLASS);
+  }
+  TKR_updateConfirmBeforeSubmit();
+  return true;
+}
+
+
+/**
+ * The user has changed a issue label text field.  This function checks
+ * whether it is a well-known label value.  If not, highlight it as a
+ * potential typo.
+ * @param {Element} textField An issue label text field.
+ * @return Always returns true to indicate that the browser should
+ * continue to process the user input event normally.
+ *
+ * TODO(jrobbins): code duplication with function above.
+ */
+function TKR_confirmNovelLabel(textField) {
+  let v = textField.value.trim().toLowerCase();
+  if (v.search('-') == 0) {
+    v = v.substr(1);
+  }
+  let isNovel = (v !== '');
+  if (v.indexOf('?') > -1) {
+    isNovel = false; // We don't count labels that the user must edit anyway.
+  }
+  let wellKnown = TKR_labelWords;
+  for (var i = 0; i < wellKnown.length && isNovel; ++i) {
+    let wk = wellKnown[i];
+    if (v == wk.toLowerCase()) {
+      isNovel = false;
+    }
+  }
+
+  let containsReservedPrefix = false;
+  var textFieldWarningDisplayed = TKR_labelsWithReservedPrefixes.indexOf(textField) != -1;
+  for (var i = 0; i < TKR_LABEL_RESERVED_PREFIXES.length; ++i) {
+    if (v.startsWith(TKR_LABEL_RESERVED_PREFIXES[i] + '-')) {
+      if (!textFieldWarningDisplayed) {
+        TKR_labelsWithReservedPrefixes.push(textField);
+      }
+      containsReservedPrefix = true;
+      break;
+    }
+  }
+  if (!containsReservedPrefix && textFieldWarningDisplayed) {
+    TKR_labelsWithReservedPrefixes.splice(
+        TKR_labelsWithReservedPrefixes.indexOf(textField), 1);
+  }
+
+  let conflictsWithReserved = false;
+  var textFieldWarningDisplayed =
+      TKR_labelsConflictingWithReserved.indexOf(textField) != -1;
+  for (var i = 0; i < TKR_LABEL_RESERVED_PREFIXES.length; ++i) {
+    if (v == TKR_LABEL_RESERVED_PREFIXES[i]) {
+      if (!textFieldWarningDisplayed) {
+        TKR_labelsConflictingWithReserved.push(textField);
+      }
+      conflictsWithReserved = true;
+      break;
+    }
+  }
+  if (!conflictsWithReserved && textFieldWarningDisplayed) {
+    TKR_labelsConflictingWithReserved.splice(
+        TKR_labelsConflictingWithReserved.indexOf(textField), 1);
+  }
+
+  if (isNovel) {
+    if (TKR_novelLabels.indexOf(textField) == -1) {
+      TKR_novelLabels.push(textField);
+    }
+    textField.classList.add(TKR_NOVEL_CLASS);
+  } else {
+    if (TKR_novelLabels.indexOf(textField) != -1) {
+      TKR_novelLabels.splice(TKR_novelLabels.indexOf(textField), 1);
+    }
+    textField.classList.remove(TKR_NOVEL_CLASS);
+  }
+  TKR_updateConfirmBeforeSubmit();
+  return true;
+}
+
+/**
+ * Dictionary { prefix:[textField,...], ...} for all the prefixes of any
+ * text that has been entered into any label field.  This is used to find
+ * duplicate labels and multiple labels that share an single exclusive
+ * prefix (e.g., Priority).
+ */
+let TKR_usedPrefixes = {};
+
+/**
+ * This is a prefix to the HTML ids of each label editing field.
+ * It varied by page, so it is set in the HTML page.  Needed to initialize
+ * our validation across label input text fields.
+ */
+let TKR_labelFieldIDPrefix = '';
+
+/**
+ * Initialize the set of all used labels on forms that allow users to
+ * enter issue labels.  Some labels are supplied in the HTML page
+ * itself, and we do not want to offer duplicates of those.
+ */
+function TKR_prepLabelAC() {
+  let i = 0;
+  while ($('label'+i)) {
+    TKR_validateLabel($('label'+i));
+    i++;
+  }
+}
+
+/**
+ * Reads the owner field and determines if the current value is a valid member.
+ */
+function TKR_prepOwnerField(validOwners) {
+  if ($('owneredit')) {
+    currentOwner = $('owneredit').value;
+    if (currentOwner == '') {
+      // Empty owner field is not an invalid owner.
+      invalidOwner = false;
+      return;
+    }
+    invalidOwner = true;
+    for (let i = 0; i < validOwners.length; i++) {
+      let owner = validOwners[i].name;
+      if (currentOwner == owner) {
+        invalidOwner = false;
+        break;
+      }
+    }
+    TKR_invalidOwner = invalidOwner;
+  }
+}
+
+/**
+ * Keep track of which label prefixes have been used so that
+ * we can not offer the same label twice and so that we can highlight
+ * multiple labels that share an exclusive prefix.
+ */
+function TKR_updateUsedPrefixes(textField) {
+  if (textField.oldPrefix != undefined) {
+    DeleteArrayElement(TKR_usedPrefixes[textField.oldPrefix], textField);
+  }
+
+  let prefix = textField.value.split('-')[0].toLowerCase();
+  if (TKR_usedPrefixes[prefix] == undefined) {
+    TKR_usedPrefixes[prefix] = [textField];
+  } else {
+    TKR_usedPrefixes[prefix].push(textField);
+  }
+  textField.oldPrefix = prefix;
+}
+
+/**
+ * Go through all the label entry fields in our prefix-oriented
+ * data structure and highlight any that are part of a conflict
+ * (multiple labels with the same exclusive prefix).  Unhighlight
+ * any label text entry fields that are not in conflict.  And, display
+ * a warning message to encourage the user to correct the conflict.
+ */
+function TKR_highlightExclusiveLabelPrefixConflicts() {
+  let conflicts = [];
+  for (let prefix in TKR_usedPrefixes) {
+    let textFields = TKR_usedPrefixes[prefix];
+    if (textFields == undefined || textFields.length == 0) {
+      delete TKR_usedPrefixes[prefix];
+    } else if (textFields.length > 1 &&
+        FindInArray(TKR_exclPrefixes, prefix) != -1) {
+      conflicts.push(prefix);
+      for (var i = 0; i < textFields.length; i++) {
+        var tf = textFields[i];
+        tf.classList.add(TKR_EXCL_CONFICT_CLASS);
+      }
+    } else {
+      for (var i = 0; i < textFields.length; i++) {
+        var tf = textFields[i];
+        tf.classList.remove(TKR_EXCL_CONFICT_CLASS);
+      }
+    }
+  }
+  if (conflicts.length > 0) {
+    let severity = TKR_restrict_to_known ? 'Error' : 'Warning';
+    let confirm_area = $(TKR_CONFIRMAREA_ID);
+    if (confirm_area) {
+      $('confirmmsg').textContent = (severity +
+          ': Multiple values for: ' + conflicts.join(', '));
+      confirm_area.className = TKR_EXCL_CONFICT_CLASS;
+      confirm_area.style.display = '';
+    }
+  }
+}
+
+/**
+ * Keeps track of any label text fields that have a value that
+ * is bad enough to prevent submission of the form.  When this
+ * list is non-empty, the submit button gets disabled.
+ */
+let TKR_labelsBlockingSubmit = [];
+
+/**
+ * Look for any "?" characters in the label and, if found,
+ * make the label text red, prevent form submission, and
+ * display on-page help to tell the user to edit those labels.
+ * @param {Element} textField An issue label text field.
+ */
+function TKR_highlightQuestionMarks(textField) {
+  let tfIndex = TKR_labelsBlockingSubmit.indexOf(textField);
+  if (textField.value.indexOf('?') > -1 && tfIndex == -1) {
+    TKR_labelsBlockingSubmit.push(textField);
+    textField.classList.add(TKR_QUESTION_MARK_CLASS);
+  } else if (textField.value.indexOf('?') == -1 && tfIndex > -1) {
+    TKR_labelsBlockingSubmit.splice(tfIndex, 1);
+    textField.classList.remove(TKR_QUESTION_MARK_CLASS);
+  }
+
+  let block_submit_msg = $('blocksubmitmsg');
+  if (block_submit_msg) {
+    if (TKR_labelsBlockingSubmit.length > 0) {
+      block_submit_msg.textContent = 'You must edit labels that contain "?".';
+    } else {
+      block_submit_msg.textContent = '';
+    }
+  }
+}
+
+/**
+ * The user has edited a label.  Display a warning if the label is
+ * not a well known label, or if there are multiple labels that
+ * share an exclusive prefix.
+ * @param {Element} textField An issue label text field.
+ */
+function TKR_validateLabel(textField) {
+  if (textField == undefined) return;
+  TKR_confirmNovelLabel(textField);
+  TKR_updateUsedPrefixes(textField);
+  TKR_highlightExclusiveLabelPrefixConflicts();
+  TKR_highlightQuestionMarks(textField);
+}
+
+// TODO(jrobbins): what about typos in owner and cc list?
+
+/**
+ * If there are any novel status or label values, we display a message
+ * that explains that to the user so that they can catch any typos before
+ * submitting them.  If the project is restricting input to only the
+ * well-known statuses and labels, then show these as an error instead.
+ * In that case, on-page JS will prevent submission.
+ */
+function TKR_updateConfirmBeforeSubmit() {
+  let severity = TKR_restrict_to_known ? 'Error' : 'Note';
+  let novelWord = TKR_restrict_to_known ? 'undefined' : 'uncommon';
+  let msg = '';
+  let labels = TKR_novelLabels.map(function(item) {
+    return item.value;
+  });
+  if (TKR_novelStatuses.length > 0 && TKR_novelLabels.length > 0) {
+    msg = severity + ': You are using an ' + novelWord + ' status and ' + novelWord + ' label(s): ' + labels.join(', ') + '.'; // TODO: i18n
+  } else if (TKR_novelStatuses.length > 0) {
+    msg = severity + ': You are using an ' + novelWord + ' status value.';
+  } else if (TKR_novelLabels.length > 0) {
+    msg = severity + ': You are using ' + novelWord + ' label(s): ' + labels.join(', ') + '.';
+  }
+
+  for (var i = 0; i < TKR_labelsWithReservedPrefixes.length; ++i) {
+    msg += '\nNote: The label ' + TKR_labelsWithReservedPrefixes[i].value +
+           ' starts with a reserved word. This is not recommended.';
+  }
+  for (var i = 0; i < TKR_labelsConflictingWithReserved.length; ++i) {
+    msg += '\nNote: The label ' + TKR_labelsConflictingWithReserved[i].value +
+           ' conflicts with a reserved word. This is not recommended.';
+  }
+  // Display the owner is no longer a member note only if an owner error is not
+  // already shown on the page.
+  if (TKR_invalidOwner && !$('ownererror')) {
+    msg += '\nNote: Current owner is no longer a project member.';
+  }
+
+  let confirm_area = $(TKR_CONFIRMAREA_ID);
+  if (confirm_area) {
+    $('confirmmsg').textContent = msg;
+    if (msg != '') {
+      confirm_area.className = TKR_NOVEL_CLASS;
+      confirm_area.style.display = '';
+    } else {
+      confirm_area.style.display = 'none';
+    }
+  }
+}
+
+
+/**
+ * The user has selected a command from the 'Actions...' menu
+ * on the issue list.  This function checks the selected value and carry
+ * out the requested action.
+ * @param {Element} actionsMenu The 'Actions...' <select> form element.
+ */
+function TKR_handleListActions(actionsMenu) {
+  switch (actionsMenu.value) {
+    case 'bulk':
+      TKR_HandleBulkEdit();
+      break;
+    case 'colspec':
+      TKR_closeAllPopups(actionsMenu);
+      _showID('columnspec');
+      _hideID('addissuesspec');
+      break;
+    case 'flagspam':
+      TKR_flagSpam(true);
+      break;
+    case 'unflagspam':
+      TKR_flagSpam(false);
+      break;
+    case 'addtohotlist':
+      TKR_addToHotlist();
+      break;
+    case 'addissues':
+      _showID('addissuesspec');
+      _hideID('columnspec');
+      setCurrentColSpec();
+      break;
+    case 'removeissues':
+      HTL_removeIssues();
+      break;
+    case 'issuesperpage':
+      break;
+  }
+  actionsMenu.value = 'moreactions';
+}
+
+
+async function TKR_handleDetailActions(localId) {
+  let moreActions = $('more_actions');
+
+  if (moreActions.value == 'delete') {
+    $('copy_issue_form_fragment').style.display = 'none';
+    $('move_issue_form_fragment').style.display = 'none';
+    let ok = confirm(
+        'Normally, you should just close issues by setting their status ' +
+      'to a closed value.\n' +
+      'Are you sure you want to delete this issue?');
+    if (ok) {
+      await window.prpcClient.call('monorail.Issues', 'DeleteIssue', {
+        issueRef: {
+          projectName: window.CS_env.projectName,
+          localId: localId,
+        },
+        delete: true,
+      });
+      location.reload(true);
+      return;
+    }
+  }
+
+  if (moreActions.value == 'move') {
+    $('move_issue_form_fragment').style.display = '';
+    $('copy_issue_form_fragment').style.display = 'none';
+    return;
+  }
+  if (moreActions.value == 'copy') {
+    $('copy_issue_form_fragment').style.display = '';
+    $('move_issue_form_fragment').style.display = 'none';
+    return;
+  }
+
+  // If no action was taken, reset the dropdown to the 'More actions...' item.
+  moreActions.value = '0';
+}
+
+/**
+ * The user has selected the "Flag as spam..." menu item.
+ */
+async function TKR_flagSpam(isSpam) {
+  const selectedIssueRefs = [];
+  issueRefs.forEach((issueRef) => {
+    const checkbox = $('cb_' + issueRef.id);
+    if (checkbox && checkbox.checked) {
+      selectedIssueRefs.push({
+        projectName: issueRef.project_name,
+        localId: issueRef.id,
+      });
+    }
+  });
+  if (selectedIssueRefs.length > 0) {
+    if (!confirm((isSpam ? 'Flag' : 'Un-flag') +
+        ' all selected issues as spam?')) {
+      return;
+    }
+    await window.prpcClient.call('monorail.Issues', 'FlagIssues', {
+      issueRefs: selectedIssueRefs,
+      flag: isSpam,
+    });
+    location.reload(true);
+  } else {
+    alert('Please select some issues to flag as spam');
+  }
+}
+
+function TKR_addToHotlist() {
+  const selectedIssueRefs = GetSelectedIssuesRefs();
+  if (selectedIssueRefs.length > 0) {
+    window.__hotlists_dialog.ShowUpdateHotlistDialog();
+  } else {
+    alert('Please select some issues to add to a hotlist');
+  }
+}
+
+
+function GetSelectedIssuesRefs() {
+  let selectedIssueRefs = [];
+  for (let i = 0; i < issueRefs.length; i++) {
+    let checkbox = document.getElementById('cb_' + issueRefs[i]['id']);
+    if (checkbox == null) {
+      checkbox = document.getElementById(
+          'cb_' + issueRefs[i]['project_name'] + ':' + issueRefs[i]['id']);
+    }
+    if (checkbox && checkbox.checked) {
+      selectedIssueRefs.push(issueRefs[i]);
+    }
+  }
+  return selectedIssueRefs;
+}
+
+function onResponseUpdateUI(modifiedHotlists, remainingHotlists) {
+  const list = $('user-hotlists-list');
+  while (list.firstChild) {
+    list.removeChild(list.firstChild);
+  }
+  remainingHotlists.forEach((hotlist) => {
+    const name = hotlist[0];
+    const userId = hotlist[1];
+    const url = `/u/${userId}/hotlists/${name}`;
+    const hotlistLink = document.createElement('a');
+    hotlistLink.setAttribute('href', url);
+    hotlistLink.textContent = name;
+    list.appendChild(hotlistLink);
+    list.appendChild(document.createElement('br'));
+  });
+  $('user-hotlists').style.display = 'block';
+  onAddIssuesResponse(modifiedHotlists);
+}
+
+function onAddIssuesResponse(modifiedHotlists) {
+  const hotlistNames = modifiedHotlists.map((hotlist) => hotlist[0]).join(', ');
+  $('notice').textContent = 'Successfully updated ' + hotlistNames;
+  $('update-issues-hotlists').style.display = 'none';
+  $('alert-table').style.display = 'table';
+}
+
+function onAddIssuesFailure(reason) {
+  $('notice').textContent =
+      'Some hotlists were not updated: ' + reason.description;
+  $('update-issues-hotlists').style.display = 'none';
+  $('alert-table').style.display = 'table';
+}
+
+/**
+ * The user has selected the "Bulk Edit..." menu item.  Go to a page that
+ * offers the ability to edit all selected issues.
+ */
+// TODO(jrobbins): cross-project bulk edit
+function TKR_HandleBulkEdit() {
+  let selectedIssueRefs = GetSelectedIssuesRefs();
+  let selectedLocalIDs = [];
+  for (let i = 0; i < selectedIssueRefs.length; i++) {
+    selectedLocalIDs.push(selectedIssueRefs[i]['id']);
+  }
+  if (selectedLocalIDs.length > 0) {
+    let selectedLocalIDString = selectedLocalIDs.join(',');
+    let url = 'bulkedit?ids=' + selectedLocalIDString;
+    TKR_go(url + _ctxArgs);
+  } else {
+    alert('Please select some issues to edit');
+  }
+}
+
+/**
+ * Clears the selected status value when the 'clear' operator is chosen.
+ */
+function TKR_ignoreWidgetIfOpIsClear(selectEl, inputID) {
+  if (selectEl.value == 'clear') {
+    document.getElementById(inputID).value = '';
+  }
+}
+
+/**
+ * Array of original labels on the served page, so that we can notice
+ * when the used submits a form that has any Restrict-* labels removed.
+ */
+let TKR_allOrigLabels = [];
+
+
+/**
+ * Prevent users from easily entering "+1" comments.
+ */
+function TKR_checkPlusOne() {
+  let c = $('addCommentTextArea').value;
+  let instructions = (
+    '\nPlease use the star icon instead.\n' +
+      'Stars show your interest without annoying other users.');
+  if (new RegExp('^\\s*[-+]+[0-9]+\\s*.{0,30}$', 'm').test(c) &&
+      c.length < 150) {
+    alert('This looks like a "+1" comment.' + instructions);
+    return false;
+  }
+  if (new RegExp('^\\s*me too.{0,30}$', 'i').test(c)) {
+    alert('This looks like a "me too" comment.' + instructions);
+    return false;
+  }
+  return true;
+}
+
+
+/**
+ * If the user removes Restrict-* labels, ask them if they are sure.
+ */
+function TKR_checkUnrestrict(prevent_restriction_removal) {
+  let removedRestrictions = [];
+
+  for (let i = 0; i < TKR_allOrigLabels.length; ++i) {
+    let origLabel = TKR_allOrigLabels[i];
+    if (origLabel.indexOf('Restrict-') == 0) {
+      let found = false;
+      let j = 0;
+      while ($('label' + j)) {
+        let newLabel = $('label' + j).value;
+        if (newLabel == origLabel) {
+          found = true;
+          break;
+        }
+        j++;
+      }
+      if (!found) {
+        removedRestrictions.push(origLabel);
+      }
+    }
+  }
+
+  if (removedRestrictions.length == 0) {
+    return true;
+  }
+
+  if (prevent_restriction_removal) {
+    let msg = 'You may not remove restriction labels.';
+    alert(msg);
+    return false;
+  }
+
+  let instructions = (
+    'You are removing these restrictions:\n   ' +
+      removedRestrictions.join('\n   ') +
+      '\nThis may allow more people to access this issue.' +
+      '\nAre you sure?');
+  return confirm(instructions);
+}
+
+
+/**
+ * Add a column to a list view by updating the colspec form element and
+ * submiting an invisible <form> to load a new page that includes the column.
+ * @param {string} colname The name of the column to start showing.
+ */
+function TKR_addColumn(colname) {
+  let colspec = TKR_getColspecElement();
+  colspec.value = colspec.value + ' ' + colname;
+  $('colspecform').submit();
+}
+
+
+/**
+ * Allow members to shift-click to select multiple issues.  This keeps
+ * track of the last row that the user clicked a checkbox on.
+ */
+let TKR_lastSelectedRow = undefined;
+
+
+/**
+ * Return true if an event had the shift-key pressed.
+ * @param {Event} evt The mouse click event.
+ */
+function TKR_hasShiftKey(evt) {
+  evt = (evt) ? evt : (window.event) ? window.event : '';
+  if (evt) {
+    if (evt.modifiers) {
+      return evt.modifiers & Event.SHIFT_MASK;
+    } else {
+      return evt.shiftKey;
+    }
+  }
+  return false;
+}
+
+
+/**
+ * Select one row: check the checkbox and use highlight color.
+ * @param {Element} row the row containing the checkbox that the user clicked.
+ * @param {boolean} checked True if the user checked the box.
+ */
+function TKR_rangeSelectRow(row, checked) {
+  if (!row) {
+    return;
+  }
+  if (checked) {
+    row.classList.add('selected');
+  } else {
+    row.classList.remove('selected');
+  }
+
+  let td = row.firstChild;
+  while (td && td.tagName != 'TD') {
+    td = td.nextSibling;
+  }
+  if (!td) {
+    return;
+  }
+
+  let checkbox = td.firstChild;
+  while (checkbox && checkbox.tagName != 'INPUT') {
+    checkbox = checkbox.nextSibling;
+  }
+  if (!checkbox) {
+    return;
+  }
+
+  checkbox.checked = checked;
+}
+
+
+/**
+ * If the user shift-clicked a checkbox, (un)select a range.
+ * @param {Event} evt The mouse click event.
+ * @param {Element} el The checkbox that was clicked.
+ */
+function TKR_checkRangeSelect(evt, el) {
+  let clicked_row = el.parentNode.parentNode.rowIndex;
+  if (clicked_row == TKR_lastSelectedRow) {
+    return;
+  }
+  if (TKR_hasShiftKey(evt) && TKR_lastSelectedRow != undefined) {
+    let results_table = $('resultstable');
+    let delta = (clicked_row > TKR_lastSelectedRow) ? 1 : -1;
+    for (let i = TKR_lastSelectedRow; i != clicked_row; i += delta) {
+      TKR_rangeSelectRow(results_table.rows[i], el.checked);
+    }
+  }
+  TKR_lastSelectedRow = clicked_row;
+}
+
+
+/**
+ * Make a link to a given issue that includes context parameters that allow
+ * the user to see the same list columns, sorting, query, and pagination state
+ * if they ever navigate up to the list again.
+ * @param {{issue_url: string}} issueRef The dict with info about an issue,
+ *     including a url to the issue detail page.
+ */
+function TKR_makeIssueLink(issueRef) {
+  return '/p/' + issueRef['project_name'] + '/issues/detail?id=' + issueRef['id'] + _ctxArgs;
+}
+
+
+/**
+ * Hide or show a list column in the case where we already have the
+ * data for that column on the page.
+ * @param {number} colIndex index of the column that is being shown or hidden.
+ */
+function TKR_toggleColumnUpdate(colIndex) {
+  let shownCols = TKR_getColspecElement().value.split(' ');
+  let filteredCols = [];
+  for (let i=0; i< shownCols.length; i++) {
+    if (_allColumnNames[colIndex] != shownCols[i].toLowerCase()) {
+      filteredCols.push(shownCols[i]);
+    }
+  }
+
+  TKR_getColspecElement().value = filteredCols.join(' ');
+  TKR_toggleColumn('hide_col_' + colIndex);
+  _ctxArgs = _formatContextQueryArgs();
+  window.history.replaceState({}, '', '?' + _ctxArgs);
+}
+
+
+/**
+ * Convert a column into a groupby clause by removing it from the column spec
+ * and adding it to the groupby spec, then reloading the page.
+ * @param {number} colIndex index of the column that is being shown or hidden.
+ */
+function TKR_addGroupBy(colIndex) {
+  let colName = _allColumnNames[colIndex];
+  let shownCols = TKR_getColspecElement().value.split(' ');
+  let filteredCols = [];
+  for (var i=0; i < shownCols.length; i++) {
+    if (shownCols[i] && colName != shownCols[i].toLowerCase()) {
+      filteredCols.push(shownCols[i]);
+    }
+  }
+
+  TKR_getColspecElement().value = filteredCols.join(' ');
+
+  let groupSpec = $('groupbyspec');
+  let shownGroupings = groupSpec.value.split(' ');
+  let filteredGroupings = [];
+  for (i=0; i < shownGroupings.length; i++) {
+    if (shownGroupings[i] && colName != shownGroupings[i].toLowerCase()) {
+      filteredGroupings.push(shownGroupings[i]);
+    }
+  }
+  filteredGroupings.push(colName);
+  groupSpec.value = filteredGroupings.join(' ');
+  $('colspecform').submit();
+}
+
+
+/**
+ * Add a multi-valued custom field editing widget.
+ */
+function TKR_addMultiFieldValueWidget(
+    el, field_id, field_type, opt_validate_1, opt_validate_2, field_phase_name) {
+  let widget = document.createElement('INPUT');
+  widget.name = (field_phase_name && (
+    field_phase_name != '')) ? `custom_${field_id}_${field_phase_name}` :
+    `custom_${field_id}`;
+  if (field_type == 'str' || field_type =='url') {
+    widget.size = 90;
+  }
+  if (field_type == 'user') {
+    widget.style = 'width:12em';
+    widget.classList.add('userautocomplete');
+    widget.classList.add('customfield');
+    widget.classList.add('multivalued');
+    widget.addEventListener('focus', function(event) {
+      _acrob(null);
+      _acof(event);
+    });
+  }
+  if (field_type == 'int' || field_type == 'date') {
+    widget.style.textAlign = 'right';
+    widget.style.width = '12em';
+    widget.min = opt_validate_1;
+    widget.max = opt_validate_2;
+  }
+  if (field_type == 'int') {
+    widget.type = 'number';
+  } else if (field_type == 'date') {
+    widget.type = 'date';
+  }
+
+  el.parentNode.insertBefore(widget, el);
+
+  let del_button = document.createElement('U');
+  del_button.onclick = function(event) {
+    _removeMultiFieldValueWidget(event.target);
+  };
+  del_button.textContent = 'X';
+  el.parentNode.insertBefore(del_button, el);
+}
+
+
+function TKR_removeMultiFieldValueWidget(el) {
+  let target = el.previousSibling;
+  while (target && target.tagName != 'INPUT') {
+    target = target.previousSibling;
+  }
+  if (target) {
+    el.parentNode.removeChild(target);
+  }
+  el.parentNode.removeChild(el); // the X itself
+}
+
+
+/**
+ * Trim trailing commas and spaces off <INPUT type="email" multiple> fields
+ * before submitting the form.
+ */
+function TKR_trimCommas() {
+  let ccField = $('memberccedit');
+  if (ccField) {
+    ccField.value = ccField.value.replace(/,\s*$/, '');
+  }
+  ccField = $('memberenter');
+  if (ccField) {
+    ccField.value = ccField.value.replace(/,\s*$/, '');
+  }
+}
+
+
+/**
+ * Identify which issues have been checkedboxed for removal from hotlist.
+ */
+function HTL_removeIssues() {
+  let selectedLocalIDs = [];
+  for (let i = 0; i < issueRefs.length; i++) {
+    issueRef = issueRefs[i]['project_name']+':'+issueRefs[i]['id'];
+    let checkbox = document.getElementById('cb_' + issueRef);
+    if (checkbox && checkbox.checked) {
+      selectedLocalIDs.push(issueRef);
+    }
+  }
+
+  if (selectedLocalIDs.length > 0) {
+    if (!confirm('Remove all selected issues?')) {
+      return;
+    }
+    let selectedLocalIDString = selectedLocalIDs.join(',');
+    $('bulk_remove_local_ids').value = selectedLocalIDString;
+    $('bulk_remove_value').value = 'true';
+    setCurrentColSpec();
+
+    let form = $('bulkremoveissues');
+    form.submit();
+  } else {
+    alert('Please select some issues to remove');
+  }
+}
+
+function setCurrentColSpec() {
+  $('current_col_spec').value = TKR_getColspecElement().value;
+}
+
+
+async function saveNote(textBox, hotlistID) {
+  const projectName = textBox.getAttribute('projectname');
+  const localId = textBox.getAttribute('localid');
+  await window.prpcClient.call(
+      'monorail.Features', 'UpdateHotlistIssueNote', {
+        hotlistRef: {
+          hotlistId: hotlistID,
+        },
+        issueRef: {
+          projectName: textBox.getAttribute('projectname'),
+          localId: textBox.getAttribute('localid'),
+        },
+        note: textBox.value,
+      });
+  $(`itemnote_${projectName}_${localId}`).value = textBox.value;
+}
+
+// TODO(jojwang): monorail:4291, integrate this into autocomplete process
+// to prevent calling ListStatuses twice.
+/**
+ * Load the status select element with possible project statuses.
+ */
+function TKR_loadStatusSelect(projectName, selectId, selected, isBulkEdit=false) {
+  const projectRequestMessage = {
+    project_name: projectName};
+  const statusesPromise = window.prpcClient.call(
+      'monorail.Projects', 'ListStatuses', projectRequestMessage);
+  statusesPromise.then((statusesResponse) => {
+    const jsonData = TKR_convertStatuses(statusesResponse);
+    const statusSelect = document.getElementById(selectId);
+    // An initial option with value='selected' had to be added in HTML
+    // to prevent TKR_isDirty() from registering a change in the select input
+    // even when the user has not selected a different value.
+    // That option needs to be removed otherwise, screenreaders will announce
+    // its existence.
+    while (statusSelect.firstChild) {
+      statusSelect.removeChild(statusSelect.firstChild);
+    }
+    // Add unrecognized status (can be empty status) to open statuses.
+    let selectedFound = false;
+    jsonData.open.concat(jsonData.closed).forEach((status) => {
+      if (status.name === selected) {
+        selectedFound = true;
+      }
+    });
+    if (!selectedFound) {
+      jsonData.open.unshift({name: selected});
+    }
+    // Add open statuses.
+    if (jsonData.open.length > 0) {
+      const openGroup =
+          statusSelect.appendChild(createStatusGroup('Open', jsonData.open, selected, isBulkEdit));
+    }
+    if (jsonData.closed.length > 0) {
+      statusSelect.appendChild(createStatusGroup('Closed', jsonData.closed, selected));
+    }
+  });
+}
+
+function createStatusGroup(groupName, options, selected, isBulkEdit=false) {
+  const groupElement = document.createElement('optgroup');
+  groupElement.label = groupName;
+  options.forEach((option) => {
+    const opt = document.createElement('option');
+    opt.value = option.name;
+    opt.selected = (selected === option.name) ? true : false;
+    // Special case for when opt represents an empty status.
+    if (opt.value === '') {
+      if (isBulkEdit) {
+        opt.textContent = '--- (no change)';
+        opt.setAttribute('aria-label', 'no change');
+      } else {
+        opt.textContent = '--- (empty status)';
+        opt.setAttribute('aria-label', 'empty status');
+      }
+    } else {
+      opt.textContent = option.doc ? `${option.name} = ${option.doc}` : option.name;
+    }
+    groupElement.appendChild(opt);
+  });
+  return groupElement;
+}
+
+/**
+ * Generate DOM for a filter rules preview section.
+ */
+function renderFilterRulesSection(section_id, heading, value_why_list) {
+  let section = $(section_id);
+  while (section.firstChild) {
+    section.removeChild(section.firstChild);
+  }
+  if (value_why_list.length == 0) return false;
+
+  section.appendChild(document.createTextNode(heading + ': '));
+  for (let i = 0; i < value_why_list.length; ++i) {
+    if (i > 0) {
+      section.appendChild(document.createTextNode(', '));
+    }
+    let value = value_why_list[i].value;
+    let why = value_why_list[i].why;
+    let span = section.appendChild(
+        document.createElement('span'));
+    span.textContent = value;
+    if (why) span.setAttribute('title', why);
+  }
+  return true;
+}
+
+
+/**
+ * Generate DOM for a filter rules preview section bullet list.
+ */
+function renderFilterRulesListSection(section_id, heading, value_why_list) {
+  let section = $(section_id);
+  while (section.firstChild) {
+    section.removeChild(section.firstChild);
+  }
+  if (value_why_list.length == 0) return false;
+
+  section.appendChild(document.createTextNode(heading + ': '));
+  let bulletList = document.createElement('ul');
+  section.appendChild(bulletList);
+  for (let i = 0; i < value_why_list.length; ++i) {
+    let listItem = document.createElement('li');
+    bulletList.appendChild(listItem);
+    let value = value_why_list[i].value;
+    let why = value_why_list[i].why;
+    let span = listItem.appendChild(
+        document.createElement('span'));
+    span.textContent = value;
+    if (why) span.setAttribute('title', why);
+  }
+  return true;
+}
+
+
+/**
+ * Ask server to do a presubmit check and then display and warnings
+ * as the user edits an issue.
+ */
+function TKR_presubmit() {
+  const issue_form = (
+    document.forms.create_issue_form || document.forms.issue_update_form);
+  if (!issue_form) {
+    return;
+  }
+
+  const inputs = issue_form.querySelectorAll(
+      'input:not([type="file"]), textarea, select');
+  if (!inputs) {
+    return;
+  }
+
+  const valuesByName = new Map();
+  for (const key in inputs) {
+    if (!inputs.hasOwnProperty(key)) {
+      continue;
+    }
+    const input = inputs[key];
+    if (input.type === 'checkbox' && !input.checked) {
+      continue;
+    }
+    if (!valuesByName.has(input.name)) {
+      valuesByName.set(input.name, []);
+    }
+    valuesByName.get(input.name).push(input.value);
+  }
+
+  const issueDelta = TKR_buildIssueDelta(valuesByName);
+  const issueRef = {project_name: window.CS_env.projectName};
+  if (valuesByName.has('id')) {
+    issueRef.local_id = valuesByName.get('id')[0];
+  }
+
+  const presubmitMessage = {
+    issue_ref: issueRef,
+    issue_delta: issueDelta,
+  };
+  const presubmitPromise = window.prpcClient.call(
+      'monorail.Issues', 'PresubmitIssue', presubmitMessage);
+
+  presubmitPromise.then((response) => {
+    $('owner_avail_state').style.display = (
+      response.ownerAvailabilityState ? '' : 'none');
+    $('owner_avail_state').className = (
+      'availability_' + response.ownerAvailabilityState);
+    $('owner_availability').textContent = response.ownerAvailability;
+
+    let derived_labels;
+    if (response.derivedLabels) {
+      derived_labels = renderFilterRulesSection(
+          'preview_filterrules_labels', 'Labels', response.derivedLabels);
+    }
+    let derived_owner_email;
+    if (response.derivedOwners) {
+      derived_owner_email = renderFilterRulesSection(
+          'preview_filterrules_owner', 'Owner', response.derivedOwners[0]);
+    }
+    let derived_cc_emails;
+    if (response.derivedCcs) {
+      derived_cc_emails = renderFilterRulesSection(
+          'preview_filterrules_ccs', 'Cc', response.derivedCcs);
+    }
+    let warnings;
+    if (response.warnings) {
+      warnings = renderFilterRulesListSection(
+          'preview_filterrules_warnings', 'Warnings', response.warnings);
+    }
+    let errors;
+    if (response.errors) {
+      errors = renderFilterRulesListSection(
+          'preview_filterrules_errors', 'Errors', response.errors);
+    }
+
+    if (derived_labels || derived_owner_email || derived_cc_emails ||
+        warnings || errors) {
+      $('preview_filterrules_area').style.display = '';
+    } else {
+      $('preview_filterrules_area').style.display = 'none';
+    }
+  });
+}
+
+function HTL_deleteHotlist(form) {
+  if (confirm('Are you sure you want to delete this hotlist? This cannot be undone.')) {
+    $('delete').value = 'true';
+    form.submit();
+  }
+}
+
+function HTL_toggleIssuesShown(toggleIssuesButton) {
+  const can = toggleIssuesButton.value;
+  const hotlist_name = $('hotlist_name').value;
+  let url = `${hotlist_name}?can=${can}`;
+  const hidden_cols = $('colcontrol').classList.value;
+  if (window.location.href.includes('&colspec') || hidden_cols) {
+    const colSpecElement =
+        TKR_getColspecElement(); // eslint-disable-line new-cap
+    let sort = '';
+    if ($('sort')) {
+      sort = $('sort').value.split(' ').join('+');
+      url += `&sort=${sort}`;
+    }
+    url += colSpecElement ? `&colspec=${colSpecElement.value}` : '';
+  }
+  TKR_go(url);
+}
diff --git a/static/js/tracker/tracker-fields.js b/static/js/tracker/tracker-fields.js
new file mode 100644
index 0000000..d84f11d
--- /dev/null
+++ b/static/js/tracker/tracker-fields.js
@@ -0,0 +1,75 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS code for editing fields and field definitions.
+ */
+
+var TKR_fieldNameXmlHttp;
+
+
+/**
+ * Function that communicates with the server.
+ * @param {string} projectName Current project name.
+ * @param {string} fieldName The proposed field name.
+ */
+async function TKR_checkFieldNameOnServer(projectName, fieldName) {
+  fieldName = fieldName.toLowerCase();
+
+  const fieldNameMessage = {
+    project_name: projectName,
+    field_name: fieldName,
+  };
+  const labelOptionsMessage = {
+    project_name: projectName,
+  };
+  const responses = await Promise.all([
+      window.prpcClient.call(
+          'monorail.Projects', 'CheckFieldName', fieldNameMessage),
+      window.prpcClient.call(
+          'monorail.Projects', 'GetLabelOptions', labelOptionsMessage),
+  ]);
+
+  const fieldNameResponse = responses[0];
+  const labelsResponse = responses[1];
+
+  $('fieldnamefeedback').textContent = fieldNameResponse.error || '';
+  $('submit_btn').disabled = fieldNameResponse.error ? 'disabled' : '';
+
+  const maskedLabels = (labelsResponse.labelOptions || []).filter(
+      label_def => label_def.label.toLowerCase().startsWith(fieldName + '-'));
+
+  if (maskedLabels.length === 0) {
+    enableOtherTypeOptions(false);
+  } else {
+    const prefixLength = fieldName.length + 1;
+    const padLength = Math.max.apply(null, maskedLabels.map(
+        label_def => label_def.label.length - prefixLength));
+    const choicesLines = maskedLabels.map(label_def => {
+      // Strip the field name from the label.
+      const choice = label_def.label.substr(prefixLength);
+      return choice.padEnd(padLength) + ' = ' + label_def.docstring;
+    });
+    $('choices').textContent = choicesLines.join('\n');
+    $('field_type').value = 'enum_type';
+    $('choices_row').style.display = '';
+    enableOtherTypeOptions(true);
+  }
+}
+
+
+function enableOtherTypeOptions(disabled) {
+  let type_option_el = $('field_type').firstChild;
+  while (type_option_el) {
+    if (type_option_el.tagName == 'OPTION') {
+      if (type_option_el.value != 'enum_type') {
+        type_option_el.disabled = disabled ? 'disabled' : '';
+      }
+    }
+    type_option_el = type_option_el.nextSibling;
+  }
+}
diff --git a/static/js/tracker/tracker-install-ac.js b/static/js/tracker/tracker-install-ac.js
new file mode 100644
index 0000000..2fe1dcd
--- /dev/null
+++ b/static/js/tracker/tracker-install-ac.js
@@ -0,0 +1,53 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+/**
+  * Sets up the legacy autocomplete editing widget on DOM elements that are
+  * set to use it.
+  */
+function TKR_install_ac() {
+  _ac_install();
+
+  _ac_register(function(input, event) {
+    if (input.id.startsWith('hotlists')) return TKR_hotlistsStore;
+    if (input.id.startsWith('search')) return TKR_searchStore;
+    if (input.id.startsWith('query_') || input.id.startsWith('predicate_')) {
+      return TKR_projectQueryStore;
+    }
+    if (input.id.startsWith('cmd')) return TKR_quickEditStore;
+    if (input.id.startsWith('labelPrefix')) return TKR_labelPrefixStore;
+    if (input.id.startsWith('label') && input.id != 'labelsInput') return TKR_labelStore;
+    if (input.dataset.acType === 'label' && input.id != 'labelsInput') return TKR_labelMultiStore;
+    if ((input.id.startsWith('component') || input.dataset.acType === 'component')
+      && input.id != 'componentsInput') return TKR_componentListStore;
+    if (input.id.startsWith('status')) return TKR_statusStore;
+    if (input.id.startsWith('member') || input.dataset.acType === 'member') return TKR_memberListStore;
+
+    if (input.id == 'admin_names_editor') return TKR_memberListStore;
+    if (input.id.startsWith('owner') && input.id != 'ownerInput') return TKR_ownerStore;
+    if (input.name == 'needs_perm' || input.name == 'grants_perm') {
+      return TKR_customPermissionsStore;
+    }
+    if (input.id == 'owner_editor' || input.dataset.acType === 'owner') return TKR_ownerStore;
+    if (input.className.indexOf('userautocomplete') != -1) {
+      const customFieldIDStr = input.name;
+      const uac = TKR_userAutocompleteStores[customFieldIDStr];
+      if (uac) return uac;
+      return TKR_ownerStore;
+    }
+    if (input.className.indexOf('autocomplete') != -1) {
+      return TKR_autoCompleteStore;
+    }
+    if (input.id.startsWith('copy_to') || input.id.startsWith('move_to') ||
+       input.id.startsWith('new_savedquery_projects') ||
+       input.id.startsWith('savedquery_projects')) {
+      return TKR_projectStore;
+    }
+  });
+};
diff --git a/static/js/tracker/tracker-keystrokes.js b/static/js/tracker/tracker-keystrokes.js
new file mode 100644
index 0000000..9a75971
--- /dev/null
+++ b/static/js/tracker/tracker-keystrokes.js
@@ -0,0 +1,232 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that implement keystroke accelerators
+ * for Monorail.
+ */
+
+/**
+ * Array of HTML elements where the kibbles cursor can be.  E.g.,
+ * the TR elements of an issue list, or the TR's for comments on an issue.
+ */
+let TKR_cursorStops;
+
+/**
+ * Integer index into TKR_cursorStops of the currently selected cursor
+ * stop, or undefined if nothing has been selected yet.
+ */
+let TKR_selected = undefined;
+
+/**
+ * Register keystrokes that apply to all pages in the current component.
+ * E.g., keystrokes that should work on every page under the "Issues" tab.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ * @param {string} currentPageType One of 'list', 'entry', or 'detail'.
+ */
+function TKR_setupKibblesComponentKeys(listUrl, entryUrl, currentPageType) {
+  if (currentPageType != 'list') {
+    kibbles.keys.addKeyPressListener(
+        'u', function() {
+          TKR_go(listUrl);
+        });
+  }
+}
+
+
+/**
+ * On the artifact list page, go to the artifact at the kibbles cursor.
+ * @param {number} linkCellIndex row child that is expected to hold a link.
+ */
+function TKR_openArtifactAtCursor(linkCellIndex, newWindow) {
+  if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+    window._goIssue(TKR_selected, newWindow);
+  }
+}
+
+
+/**
+ * On the artifact list page, toggle the checkbox for the artifact at
+ * the kibbles cursor.
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox.
+ */
+function TKR_selectArtifactAtCursor(cbCellIndex) {
+  if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+    const cell = TKR_cursorStops[TKR_selected].children[cbCellIndex];
+    let cb = cell.firstChild;
+    while (cb && cb.tagName != 'INPUT') {
+      cb = cb.nextSibling;
+    }
+    if (cb) {
+      cb.checked = cb.checked ? '' : 'checked';
+      TKR_highlightRow(cb);
+    }
+  }
+}
+
+/**
+ * On the artifact list page, toggle the star for the artifact at
+ * the kibbles cursor.
+ * @param {number} cbCellIndex row child that is expected to hold a checkbox
+ *     and star widget.
+ */
+function TKR_toggleStarArtifactAtCursor(cbCellIndex) {
+  if (TKR_selected >= 0 && TKR_selected < TKR_cursorStops.length) {
+    const cell = TKR_cursorStops[TKR_selected].children[cbCellIndex];
+    let starIcon = cell.firstChild;
+    while (starIcon && starIcon.tagName != 'A') {
+      starIcon = starIcon.nextSibling;
+    }
+    if (starIcon) {
+      _TKR_toggleStar(
+          starIcon, issueRefs[TKR_selected]['project_name'],
+          issueRefs[TKR_selected]['id'], null, null);
+    }
+  }
+}
+
+/**
+ * Updates the style on new stop and clears the style on the former stop.
+ * @param {Object} newStop the cursor stop that the user is selecting now.
+ * @param {Object} formerStop the old cursor stop, if any.
+ */
+function TKR_updateCursor(newStop, formerStop) {
+  TKR_selected = undefined;
+  if (formerStop) {
+    formerStop.element.classList.remove('cursor_on');
+    formerStop.element.classList.add('cursor_off');
+  }
+  if (newStop && newStop.element) {
+    newStop.element.classList.remove('cursor_off');
+    newStop.element.classList.add('cursor_on');
+    TKR_selected = newStop.index;
+  }
+}
+
+
+/**
+ * Walk part of the page DOM to find elements that should be kibbles
+ * cursor stops.  E.g., the rows of the issue list results table.
+ * @return {Array} an array of html elements.
+ */
+function TKR_findCursorRows() {
+  const rows = [];
+  const cursorarea = document.getElementById('cursorarea');
+  TKR_accumulateCursorRows(cursorarea, rows);
+  return rows;
+}
+
+
+/**
+ * Recusrively walk part of the page DOM to find elements that should
+ * be kibbles cursor stops.  E.g., the rows of the issue list results
+ * table.  The cursor stops are appended to the given rows array.
+ * @param {Element} parent html element to start on.
+ * @param {Array} rows  array of html TR or DIV elements, each cursor stop will
+ *    be added to this array.
+ */
+function TKR_accumulateCursorRows(parent, rows) {
+  for (let i = 0; i < parent.childNodes.length; i++) {
+    const elem = parent.childNodes[i];
+    const name = elem.tagName;
+    if (name && (name == 'TR' || name == 'DIV')) {
+      if (elem.className.indexOf('cursor') >= 0) {
+        elem.cursorIndex = rows.length;
+        rows.push(elem);
+      }
+    }
+    TKR_accumulateCursorRows(elem, rows);
+  }
+}
+
+
+/**
+ * Initialize kibbles cursors stops for the current page.
+ * @param {boolean} selectFirstStop True if the first stop should be
+ *   selected before the user presses any keys.
+ */
+function TKR_setupKibblesCursorStops(selectFirstStop) {
+  kibbles.skipper.addStopListener(
+      kibbles.skipper.LISTENER_TYPE.PRE, TKR_updateCursor);
+
+  // Set the 'offset' option to return the middle of the client area
+  // an option can be a static value, or a callback
+  kibbles.skipper.setOption('padding_top', 50);
+
+  // Set the 'offset' option to return the middle of the client area
+  // an option can be a static value, or a callback
+  kibbles.skipper.setOption('padding_bottom', 50);
+
+  // register our stops with skipper
+  TKR_cursorStops = TKR_findCursorRows();
+  for (let i = 0; i < TKR_cursorStops.length; i++) {
+    const element = TKR_cursorStops[i];
+    kibbles.skipper.append(element);
+
+    if (element.className.indexOf('cursor_on') >= 0) {
+      kibbles.skipper.setCurrentStop(i);
+    }
+  }
+}
+
+
+/**
+ * Initialize kibbles keystrokes for an artifact entry page.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ */
+function TKR_setupKibblesOnEntryPage(listUrl, entryUrl) {
+  TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'entry');
+}
+
+
+/**
+ * Initialize kibbles keystrokes for an artifact list page.
+ * @param {string} listUrl Rooted URL of the artifact list.
+ * @param {string} entryUrl Rooted URL of the artifact entry page.
+ * @param {string} projectName Name of the current project.
+ * @param {number} linkCellIndex table column that is expected to
+ *   link to individual artifacts.
+ * @param {number} opt_checkboxCellIndex table column that is expected
+ *   to contain a selection checkbox.
+ */
+function TKR_setupKibblesOnListPage(
+    listUrl, entryUrl, projectName, linkCellIndex,
+    opt_checkboxCellIndex) {
+  TKR_setupKibblesCursorStops(true);
+
+  kibbles.skipper.addFwdKey('j');
+  kibbles.skipper.addRevKey('k');
+
+  if (opt_checkboxCellIndex != undefined) {
+    const cbCellIndex = opt_checkboxCellIndex;
+    kibbles.keys.addKeyPressListener(
+        'x', function() {
+          TKR_selectArtifactAtCursor(cbCellIndex);
+        });
+    kibbles.keys.addKeyPressListener(
+        's',
+        function() {
+          TKR_toggleStarArtifactAtCursor(cbCellIndex);
+        });
+  }
+  kibbles.keys.addKeyPressListener(
+      'o', function() {
+        TKR_openArtifactAtCursor(linkCellIndex, false);
+      });
+  kibbles.keys.addKeyPressListener(
+      'O', function() {
+        TKR_openArtifactAtCursor(linkCellIndex, true);
+      });
+  kibbles.keys.addKeyPressListener(
+      'enter', function() {
+        TKR_openArtifactAtCursor(linkCellIndex);
+      });
+
+  TKR_setupKibblesComponentKeys(listUrl, entryUrl, 'list');
+}
diff --git a/static/js/tracker/tracker-nav.js b/static/js/tracker/tracker-nav.js
new file mode 100644
index 0000000..4458a51
--- /dev/null
+++ b/static/js/tracker/tracker-nav.js
@@ -0,0 +1,182 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable no-var */
+
+/**
+ * This file contains JS functions that implement various navigation
+ * features of Monorail.
+ */
+
+
+/**
+ * Navigate the browser to the given URL.
+ * @param {string} url The URL of the page to browse.
+ * @param {boolean} newWindow Open a new tab or window.
+ */
+function TKR_go(url, newWindow) {
+  if (newWindow) {
+    window.open(url, '_blank');
+  } else {
+    document.location = url;
+  }
+}
+
+
+/**
+ * Tell the browser to scroll to the given anchor on the current page.
+ * @param {string} anchor Name of the <a name="xxx"> anchor on the page.
+ */
+function TKR_goToAnchor(anchor) {
+  document.location.hash = anchor;
+}
+
+
+/**
+ * Get the user-editable colspec form field.  This text field is normally
+ * display:none, but it is shown when the user chooses "Edit columns...".
+ * We need a function to get this element because there are multiple form
+ * fields on the page with name="colspec", and an IE misfeature sets their
+ * id attributes as well, which makes document.getElementById() fail.
+ * @return {Element} user editable colspec form field.
+ */
+function TKR_getColspecElement() {
+  const elem = document.getElementById('colspec_field');
+  return elem && elem.firstChild;
+}
+
+
+/**
+ * Get the artifact search form field.  This is a visible text field where
+ * the user enters a query for issues. This function
+ * is needed because there is also the project search field on the each page,
+ * and it has name="q".  An IE misfeature confuses name="..." with id="...".
+ * @return {Element} artifact query form field, or undefined.
+ */
+function TKR_getArtifactSearchField() {
+  const element = _getSearchBarComponent();
+  if (!element) return $('searchq');
+
+  return element.shadowRoot.querySelector('#searchq');
+}
+
+
+/**
+ * Get the can selector. This function
+ * @return {Element} can input element.
+ */
+function TKR_getArtifactCanField() {
+  const element = _getSearchBarComponent();
+  if (!element) return $('can');
+
+  return element.shadowRoot.querySelector('#can');
+}
+
+
+function _getSearchBarComponent() {
+  const element = document.querySelector('mr-header');
+  if (!element) return;
+
+  return element.shadowRoot.querySelector('mr-search-bar');
+}
+
+
+/**
+ * Build a query string for all the common contextual values that we use.
+ */
+function TKR_formatContextQueryArgs() {
+  let args = '';
+  let colspec = _ctxDefaultColspec;
+  const colSpecElem = TKR_getColspecElement();
+  if (colSpecElem) {
+    colspec = colSpecElem.value;
+  }
+
+  if (_ctxHotlistID != '') args += '&hotlist_id=' + _ctxHotlistID;
+  if (_ctxCan != 2) args += '&can=' + _ctxCan;
+  args += '&q=' + encodeURIComponent(_ctxQuery);
+  if (_ctxSortspec != '') args += '&sort=' + _ctxSortspec;
+  if (_ctxGroupBy != '') args += '&groupby=' + _ctxGroupBy;
+  if (colspec != _ctxDefaultColspec) args += '&colspec=' + colspec;
+  if (_ctxStart != 0) args += '&start=' + _ctxStart;
+  if (_ctxNum != _ctxResultsPerPage) args += '&num=' + _ctxNum;
+  if (!colSpecElem) args += '&mode=grid';
+  return args;
+}
+
+// Fields that should use ":" when filtering.
+const _PRETOKENIZED_FIELDS = [
+  'owner', 'reporter', 'cc', 'commentby', 'component'];
+
+/**
+ * The user wants to narrow their search results by adding a search term
+ * for the given prefix and value. Reload the issue list page with that
+ * additional search term.
+ * @param {string} prefix Field or label prefix, e.g., "Priority".
+ * @param {string} suffix Field or label value, e.g., "High".
+ */
+function TKR_filterTo(prefix, suffix) {
+  let newQuery = TKR_getArtifactSearchField().value;
+  if (newQuery != '') newQuery += ' ';
+
+  let op = '=';
+  for (let i = 0; i < _PRETOKENIZED_FIELDS.length; i++) {
+    if (prefix == _PRETOKENIZED_FIELDS[i]) {
+      op = ':';
+      break;
+    }
+  }
+
+  newQuery += prefix + op + suffix;
+  let url = 'list?can=' + TKR_getArtifactCanField().value + '&q=' + newQuery;
+  if ($('sort') && $('sort').value) url += '&sort=' + $('sort').value;
+  url += '&colspec=' + TKR_getColspecElement().value;
+  TKR_go(url);
+}
+
+
+/**
+ * The user wants to sort their search results by adding a sort spec
+ * for the given column. Reload the issue list page with that
+ * additional sort spec.
+ * @param {string} colname Field or label prefix, e.g., "Priority".
+ * @param {boolean} descending True if the values should be reversed.
+ */
+function TKR_addSort(colname, descending) {
+  let existingSortSpec = '';
+  if ($('sort')) {
+    existingSortSpec = $('sort').value;
+  }
+  const oldSpecs = existingSortSpec.split(/ +/);
+  let sortDirective = colname;
+  if (descending) sortDirective = '-' + colname;
+  const specs = [sortDirective];
+  for (let i = 0; i < oldSpecs.length; i++) {
+    if (oldSpecs[i] != '' && oldSpecs[i] != colname &&
+        oldSpecs[i] != '-' + colname) {
+      specs.push(oldSpecs[i]);
+    }
+  }
+
+  const isHotlist = window.location.href.includes('/hotlists/');
+  let url = isHotlist ? ($('hotlist_name').value + '?') : ('list?');
+  url += ('can='+ TKR_getArtifactCanField().value + '&q=' +
+      TKR_getArtifactSearchField().value);
+  url += '&sort=' + specs.join('+');
+  url += '&colspec=' + TKR_getColspecElement().value;
+  TKR_go(url);
+}
+
+/** Convenience function for sorting in ascending order. */
+function TKR_sortUp(colname) {
+  TKR_addSort(colname, false);
+}
+
+/** Convenience function for sorting in descending order. */
+function TKR_sortDown(colname) {
+  TKR_addSort(colname, true);
+}
+
diff --git a/static/js/tracker/tracker-onload.js b/static/js/tracker/tracker-onload.js
new file mode 100644
index 0000000..051c86d
--- /dev/null
+++ b/static/js/tracker/tracker-onload.js
@@ -0,0 +1,136 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+/* eslint-disable camelcase */
+/* eslint-disable no-unused-vars */
+
+
+/**
+ * This file contains the Monorail onload() function that is called
+ * when each EZT page loads.
+ */
+
+
+/**
+ * This code is run on every DIT page load.  It registers a handler
+ * for autocomplete on four different types of text fields based on the
+ * name of that text field.
+ */
+function TKR_onload() {
+  TKR_install_ac();
+  _PC_Install();
+  TKR_allColumnNames = _allColumnNames;
+  TKR_labelFieldIDPrefix = _lfidprefix;
+  TKR_allOrigLabels = _allOrigLabels;
+  TKR_initialFormValues = TKR_currentFormValues();
+}
+
+// External names for functions that are called directly from HTML.
+// JSCompiler does not rename functions that begin with an underscore.
+// They are not defined with "var" because we want them to be global.
+
+// TODO(jrobbins): the underscore names could be shortened by a
+// cross-file search-and-replace script in our build process.
+
+_selectAllIssues = TKR_selectAllIssues;
+_selectNoneIssues = TKR_selectNoneIssues;
+
+_toggleRows = TKR_toggleRows;
+_toggleColumn = TKR_toggleColumn;
+_toggleColumnUpdate = TKR_toggleColumnUpdate;
+_addGroupBy = TKR_addGroupBy;
+_addcol = TKR_addColumn;
+_checkRangeSelect = TKR_checkRangeSelect;
+_makeIssueLink = TKR_makeIssueLink;
+
+_onload = TKR_onload;
+
+_handleListActions = TKR_handleListActions;
+_handleDetailActions = TKR_handleDetailActions;
+
+_loadStatusSelect = TKR_loadStatusSelect;
+_fetchUserProjects = TKR_fetchUserProjects;
+_setACOptions = TKR_setUpAutoCompleteStore;
+_openIssueUpdateForm = TKR_openIssueUpdateForm;
+_addAttachmentFields = TKR_addAttachmentFields;
+_ignoreWidgetIfOpIsClear = TKR_ignoreWidgetIfOpIsClear;
+
+_acstore = _AC_SimpleStore;
+_accomp = _AC_Completion;
+_acreg = _ac_register;
+
+_formatContextQueryArgs = TKR_formatContextQueryArgs;
+_ctxArgs = '';
+_ctxCan = undefined;
+_ctxQuery = undefined;
+_ctxSortspec = undefined;
+_ctxGroupBy = undefined;
+_ctxDefaultColspec = undefined;
+_ctxStart = undefined;
+_ctxNum = undefined;
+_ctxResultsPerPage = undefined;
+
+_filterTo = TKR_filterTo;
+_sortUp = TKR_sortUp;
+_sortDown = TKR_sortDown;
+
+_closeAllPopups = TKR_closeAllPopups;
+_closeSubmenus = TKR_closeSubmenus;
+_showRight = TKR_showRight;
+_showBelow = TKR_showBelow;
+_highlightRow = TKR_highlightRow;
+
+_setFieldIDs = TKR_setFieldIDs;
+_selectTemplate = TKR_selectTemplate;
+_saveTemplate = TKR_saveTemplate;
+_newTemplate = TKR_newTemplate;
+_deleteTemplate = TKR_deleteTemplate;
+_switchTemplate = TKR_switchTemplate;
+_templateNames = TKR_templateNames;
+
+_confirmNovelStatus = TKR_confirmNovelStatus;
+_confirmNovelLabel = TKR_confirmNovelLabel;
+_vallab = TKR_validateLabel;
+_exposeExistingLabelFields = TKR_exposeExistingLabelFields;
+_confirmDiscardEntry = TKR_confirmDiscardEntry;
+_confirmDiscardUpdate = TKR_confirmDiscardUpdate;
+_lfidprefix = undefined;
+_allOrigLabels = undefined;
+_checkPlusOne = TKR_checkPlusOne;
+_checkUnrestrict = TKR_checkUnrestrict;
+
+_clearOnFirstEvent = TKR_clearOnFirstEvent;
+_forceProperTableWidth = TKR_forceProperTableWidth;
+
+_initialFormValues = TKR_initialFormValues;
+_currentFormValues = TKR_currentFormValues;
+
+_acof = _ac_onfocus;
+_acmo = _ac_mouseover;
+_acse = _ac_select;
+_acrob = _ac_ob;
+
+// Variables that are given values in the HTML file.
+_allColumnNames = [];
+
+_go = TKR_go;
+_getColspec = TKR_getColspecElement;
+
+// Make the document actually listen for click events, otherwise the
+// event handlers above would never get called.
+if (document.captureEvents) document.captureEvents(Event.CLICK);
+
+_setupKibblesOnEntryPage = TKR_setupKibblesOnEntryPage;
+_setupKibblesOnListPage = TKR_setupKibblesOnListPage;
+
+_checkFieldNameOnServer = TKR_checkFieldNameOnServer;
+_checkLeafName = TKR_checkLeafName;
+
+_addMultiFieldValueWidget = TKR_addMultiFieldValueWidget;
+_removeMultiFieldValueWidget = TKR_removeMultiFieldValueWidget;
+_trimCommas = TKR_trimCommas;
+
+_initDragAndDrop = TKR_initDragAndDrop;
diff --git a/static/js/tracker/tracker-update-issues-hotlists.js b/static/js/tracker/tracker-update-issues-hotlists.js
new file mode 100644
index 0000000..04a85bf
--- /dev/null
+++ b/static/js/tracker/tracker-update-issues-hotlists.js
@@ -0,0 +1,320 @@
+/* Copyright 2018 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS functions that support a dialog for adding and removing
+ * issues from hotlists in Monorail.
+ */
+
+(function() {
+  window.__hotlists_dialog = window.__hotlists_dialog || {};
+
+  // An optional IssueRef.
+  // If set, we will not check for selected issues, and only add/remove issueRef
+  // instead.
+  window.__hotlists_dialog.issueRef = null;
+  // A function to be called with the modified hotlists. If issueRef is set, the
+  // hotlists for which the user is owner and the issue is part of will be
+  // passed as well.
+  window.__hotlists_dialog.onResponse = () => {};
+  // A function to be called if there was an error updating the hotlists.
+  window.__hotlists_dialog.onFailure = () => {};
+
+  /**
+   * A function to show the hotlist dialog.
+   * It is the only function exported by this module.
+   */
+  function ShowUpdateHotlistDialog() {
+    _FetchHotlists().then(_BuildDialog);
+  }
+
+  async function _CreateNewHotlistWithIssues() {
+    let selectedIssueRefs;
+    if (window.__hotlists_dialog.issueRef) {
+      selectedIssueRefs = [window.__hotlists_dialog.issueRef];
+    } else {
+      selectedIssueRefs = _GetSelectedIssueRefs();
+    }
+
+    const name = await _CheckNewHotlistName();
+    if (!name) {
+      return;
+    }
+
+    const message = {
+      name: name,
+      summary: 'Hotlist of bulk added issues',
+      issueRefs: selectedIssueRefs,
+    };
+    try {
+      await window.prpcClient.call(
+          'monorail.Features', 'CreateHotlist', message);
+    } catch (error) {
+      window.__hotlists_dialog.onFailure(error);
+      return;
+    }
+
+    const newHotlist = [name, window.CS_env.loggedInUserEmail];
+    const newIssueHotlists = [];
+    window.__hotlists_dialog._issueHotlists.forEach(
+        hotlist => newIssueHotlists.push(hotlist.split('_')));
+    newIssueHotlists.push(newHotlist);
+    window.__hotlists_dialog.onResponse([newHotlist], newIssueHotlists);
+  }
+
+  async function _UpdateIssuesInHotlists() {
+    const hotlistRefsAdd = _GetSelectedHotlists(
+        window.__hotlists_dialog._userHotlists);
+    const hotlistRefsRemove = _GetSelectedHotlists(
+        window.__hotlists_dialog._issueHotlists);
+    if (hotlistRefsAdd.length === 0 && hotlistRefsRemove.length === 0) {
+      alert('Please select/un-select some hotlists');
+      return;
+    }
+
+    let selectedIssueRefs;
+    if (window.__hotlists_dialog.issueRef) {
+      selectedIssueRefs = [window.__hotlists_dialog.issueRef];
+    } else {
+      selectedIssueRefs = _GetSelectedIssueRefs();
+    }
+
+    if (hotlistRefsAdd.length > 0) {
+      const message = {
+        hotlistRefs: hotlistRefsAdd,
+        issueRefs: selectedIssueRefs,
+      };
+      try {
+        await window.prpcClient.call(
+            'monorail.Features', 'AddIssuesToHotlists', message);
+      } catch (error) {
+        window.__hotlists_dialog.onFailure(error);
+        return;
+      }
+      hotlistRefsAdd.forEach(hotlist => {
+        window.__hotlists_dialog._issueHotlists.add(
+            hotlist.name + '_' + hotlist.owner.user_id);
+      });
+    }
+
+    if (hotlistRefsRemove.length > 0) {
+      const message = {
+        hotlistRefs: hotlistRefsRemove,
+        issueRefs: selectedIssueRefs,
+      };
+      try {
+        await window.prpcClient.call(
+            'monorail.Features', 'RemoveIssuesFromHotlists', message);
+      } catch (error) {
+        window.__hotlists_dialog.onFailure(error);
+        return;
+      }
+      hotlistRefsRemove.forEach(hotlist => {
+        window.__hotlists_dialog._issueHotlists.delete(
+            hotlist.name + '_' + hotlist.owner.user_id);
+      });
+    }
+
+    const modifiedHotlists = hotlistRefsAdd.concat(hotlistRefsRemove).map(
+        hotlist => [hotlist.name, hotlist.owner.user_id]);
+    const newIssueHotlists = [];
+    window.__hotlists_dialog._issueHotlists.forEach(
+        hotlist => newIssueHotlists.push(hotlist.split('_')));
+
+    window.__hotlists_dialog.onResponse(modifiedHotlists, newIssueHotlists);
+  }
+
+  async function _FetchHotlists() {
+    const userHotlistsMessage = {
+      user: {
+        display_name: window.CS_env.loggedInUserEmail,
+      }
+    };
+    const userHotlistsResponse = await window.prpcClient.call(
+        'monorail.Features', 'ListHotlistsByUser', userHotlistsMessage);
+
+    // Here we have the list of all hotlists owned by the user. We filter out
+    // the hotlists that already contain issueRef in the next paragraph of code.
+    window.__hotlists_dialog._userHotlists = new Set();
+    (userHotlistsResponse.hotlists || []).forEach(hotlist => {
+      window.__hotlists_dialog._userHotlists.add(
+          hotlist.name + '_' + hotlist.ownerRef.userId);
+    });
+
+    // Here we filter out the hotlists that are owned by the user, and that
+    // contain issueRef from _userHotlists and save them into _issueHotlists.
+    window.__hotlists_dialog._issueHotlists = new Set();
+    if (window.__hotlists_dialog.issueRef) {
+      const issueHotlistsMessage = {
+        issue: window.__hotlists_dialog.issueRef,
+      };
+      const issueHotlistsResponse = await window.prpcClient.call(
+          'monorail.Features', 'ListHotlistsByIssue', issueHotlistsMessage);
+      (issueHotlistsResponse.hotlists || []).forEach(hotlist => {
+        const hotlistRef = hotlist.name + '_' + hotlist.ownerRef.userId;
+        if (window.__hotlists_dialog._userHotlists.has(hotlistRef)) {
+          window.__hotlists_dialog._userHotlists.delete(hotlistRef);
+          window.__hotlists_dialog._issueHotlists.add(hotlistRef);
+        }
+      });
+    }
+  }
+
+  function _BuildDialog() {
+    const table = $('js-hotlists-table');
+
+    while (table.firstChild) {
+      table.removeChild(table.firstChild);
+    }
+
+    if (window.__hotlists_dialog._issueHotlists.size > 0) {
+      _UpdateRows(
+          table, 'Remove issues from:',
+          window.__hotlists_dialog._issueHotlists);
+    }
+    _UpdateRows(table, 'Add issues to:',
+        window.__hotlists_dialog._userHotlists);
+    _BuildCreateNewHotlist(table);
+
+    $('update-issues-hotlists').style.display = 'block';
+    $('save-issues-hotlists').addEventListener(
+        'click', _UpdateIssuesInHotlists);
+    $('cancel-update-hotlists').addEventListener('click', function() {
+      $('update-issues-hotlists').style.display = 'none';
+    });
+
+  }
+
+  function _BuildCreateNewHotlist(table) {
+    const inputTr = document.createElement('tr');
+    inputTr.classList.add('hotlist_rows');
+
+    const inputCell = document.createElement('td');
+    const input = document.createElement('input');
+    input.setAttribute('id', 'text_new_hotlist_name');
+    input.setAttribute('placeholder', 'New hotlist name');
+    // Hotlist changes are automatic and should be ignored by
+    // TKR_currentFormValues() and TKR_isDirty()
+    input.setAttribute('ignore-dirty', true);
+    input.addEventListener('input', _CheckNewHotlistName);
+    inputCell.appendChild(input);
+    inputTr.appendChild(inputCell);
+
+    const buttonCell = document.createElement('td');
+    const button = document.createElement('button');
+    button.setAttribute('id', 'create-new-hotlist');
+    button.addEventListener('click', _CreateNewHotlistWithIssues);
+    button.textContent = 'Create New Hotlist';
+    button.disabled = true;
+    buttonCell.appendChild(button);
+    inputTr.appendChild(buttonCell);
+
+    table.appendChild(inputTr);
+
+    const feedbackTr = document.createElement('tr');
+    feedbackTr.classList.add('hotlist_rows');
+
+    const feedbackCell = document.createElement('td');
+    feedbackCell.setAttribute('colspan', '2');
+    const feedback = document.createElement('span');
+    feedback.classList.add('fielderror');
+    feedback.setAttribute('id', 'hotlistnamefeedback');
+    feedbackCell.appendChild(feedback);
+    feedbackTr.appendChild(feedbackCell);
+
+    table.appendChild(feedbackTr);
+  }
+
+  function _UpdateRows(table, title, hotlists) {
+    const tr = document.createElement('tr');
+    tr.classList.add('hotlist_rows');
+    const addCell = document.createElement('td');
+    const add = document.createElement('b');
+    add.textContent = title;
+    addCell.appendChild(add);
+    tr.appendChild(addCell);
+    table.appendChild(tr);
+
+    hotlists.forEach(hotlist => {
+      const hotlistParts = hotlist.split('_');
+      const name = hotlistParts[0];
+
+      const tr = document.createElement('tr');
+      tr.classList.add('hotlist_rows');
+
+      const cbCell = document.createElement('td');
+      const cb = document.createElement('input');
+      cb.classList.add('checkRangeSelect');
+      cb.setAttribute('id', 'cb_hotlist_' + hotlist);
+      cb.setAttribute('type', 'checkbox');
+      // Hotlist changes are automatic and should be ignored by
+      // TKR_currentFormValues() and TKR_isDirty()
+      cb.setAttribute('ignore-dirty', true);
+      cbCell.appendChild(cb);
+
+      const nameCell = document.createElement('td');
+      const label = document.createElement('label');
+      label.htmlFor = cb.id;
+      label.textContent = name;
+      nameCell.appendChild(label);
+
+      tr.appendChild(cbCell);
+      tr.appendChild(nameCell);
+      table.appendChild(tr);
+    });
+  }
+
+  async function _CheckNewHotlistName() {
+    const name = $('text_new_hotlist_name').value;
+    const checkNameResponse = await window.prpcClient.call(
+        'monorail.Features', 'CheckHotlistName', {name});
+
+    if (checkNameResponse.error) {
+      $('hotlistnamefeedback').textContent = checkNameResponse.error;
+      $('create-new-hotlist').disabled = true;
+      return null;
+    }
+
+    $('hotlistnamefeedback').textContent = '';
+    $('create-new-hotlist').disabled = false;
+    return name;
+  }
+
+  /**
+  * Call GetSelectedIssuesRefs from tracker-editing.js and convert to an Array
+  * of IssueRef PBs.
+  */
+  function _GetSelectedIssueRefs() {
+    return GetSelectedIssuesRefs().map(issueRef => ({
+      project_name: issueRef['project_name'],
+      local_id: issueRef['id'],
+    }));
+  }
+
+  /**
+   * Get HotlistRef PBs for the hotlists that the user wants to add/remove the
+   * selected issues to.
+   */
+  function _GetSelectedHotlists(hotlists) {
+    const selectedHotlistRefs = [];
+    hotlists.forEach(hotlist => {
+      const checkbox = $('cb_hotlist_' + hotlist);
+      const hotlistParts = hotlist.split('_');
+      if (checkbox && checkbox.checked) {
+        selectedHotlistRefs.push({
+          name: hotlistParts[0],
+          owner: {
+            user_id: hotlistParts[1],
+          }
+        });
+      }
+    });
+    return selectedHotlistRefs;
+  }
+
+  Object.assign(window.__hotlists_dialog, {ShowUpdateHotlistDialog});
+})();
diff --git a/static/js/tracker/tracker-util.js b/static/js/tracker/tracker-util.js
new file mode 100644
index 0000000..040f8c1
--- /dev/null
+++ b/static/js/tracker/tracker-util.js
@@ -0,0 +1,166 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+/**
+ * This file contains JS utilities used by other JS files in Monorail.
+ */
+
+
+/**
+ * Add an indexOf method to all arrays, if this brower's JS implementation
+ * does not already have it.
+ * @param {Object} item The item to find
+ * @return {number} The index of the given item, or -1 if not found.
+ */
+if (Array.prototype.indexOf == undefined) {
+  Array.prototype.indexOf = function(item) {
+    for (let i = 0; i < this.length; ++i) {
+      if (this[i] == item) return i;
+    }
+    return -1;
+  };
+}
+
+
+/**
+ * This function works around a FF HTML layout problem.  The table
+ * width is somehow rendered at 100% when the table contains a
+ * display:none element, later, when that element is displayed, the
+ * table renders at the correct width.  The work-around is to have the
+ * element initiallye displayed so that the table renders properly,
+ * but then immediately hide the element until it is needed.
+ *
+ * TODO(jrobbins): Find HTML markup that FF can render more
+ * consistently.  After that, I can remove this hack.
+ */
+function TKR_forceProperTableWidth() {
+  let e = $('confirmarea');
+  if (e) e.style.display='none';
+}
+
+
+function TKR_parseIssueRef(issueRef) {
+  issueRef = issueRef.trim();
+  if (!issueRef) {
+    return null;
+  }
+
+  let projectName = window.CS_env.projectName;
+  let localId = issueRef;
+  if (issueRef.includes(':')) {
+    const parts = issueRef.split(':', 2);
+    projectName = parts[0];
+    localId = parts[1];
+  }
+
+  return {
+    project_name: projectName,
+    local_id: localId};
+}
+
+
+function _buildFieldsForIssueDelta(issueDelta, valuesByName) {
+  issueDelta.field_vals_add = [];
+  issueDelta.field_vals_remove = [];
+  issueDelta.fields_clear = [];
+
+  valuesByName.forEach((values, key, map) => {
+    if (key.startsWith('op_custom_') && values == 'clear') {
+      const field_id = key.substring('op_custom_'.length);
+      issueDelta.fields_clear.push({field_id: field_id});
+    } else if (key.startsWith('custom_')) {
+      const field_id = key.substring('custom_'.length);
+      values = values.filter(Boolean);
+      if (valuesByName.get('op_' + key) === 'remove') {
+        values.forEach((value) => {
+          issueDelta.field_vals_remove.push({
+            field_ref: {field_id: field_id},
+            value: value});
+        });
+      } else {
+        values.forEach((value) => {
+          issueDelta.field_vals_add.push({
+            field_ref: {field_id: field_id},
+            value: value});
+        });
+      }
+    }
+  });
+}
+
+
+function _classifyPlusMinusItems(values) {
+  let result = {
+    add: [],
+    remove: []};
+  values = new Set(values);
+  values.forEach((value) => {
+    if (!value.startsWith('-') && value) {
+      result.add.push(value);
+    } else if (value.startsWith('-') && value.substring(1)) {
+      result.remove.push(value);
+    }
+  });
+  return result;
+}
+
+
+function TKR_buildIssueDelta(valuesByName) {
+  let issueDelta = {};
+
+  if (valuesByName.has('status')) {
+    issueDelta.status = valuesByName.get('status')[0];
+  }
+  if (valuesByName.has('owner')) {
+    issueDelta.owner_ref = {
+      display_name: valuesByName.get('owner')[0].trim().toLowerCase()};
+  }
+  if (valuesByName.has('cc')) {
+    const cc_usernames = _classifyPlusMinusItems(
+      valuesByName.get('cc')[0].toLowerCase().split(/[,;\s]+/));
+    issueDelta.cc_refs_add = cc_usernames.add.map(
+      (email) => ({display_name: email}));
+    issueDelta.cc_refs_remove = cc_usernames.remove.map(
+      (email) => ({display_name: email}));
+  }
+  if (valuesByName.has('components')) {
+    const components = _classifyPlusMinusItems(
+      valuesByName.get('components')[0].split(/[,;\s]/));
+    issueDelta.comp_refs_add = components.add.map(
+      (path) => ({path: path}));
+    issueDelta.comp_refs_remove = components.remove.map(
+      (path) => ({path: path}));
+  }
+  if (valuesByName.has('label')) {
+    const labels = _classifyPlusMinusItems(valuesByName.get('label'));
+    issueDelta.label_refs_add = labels.add.map(
+      (label) => ({label: label}));
+    issueDelta.label_refs_remove = labels.remove.map(
+      (label) => ({label: label}));
+  }
+  if (valuesByName.has('blocked_on')) {
+    const blockedOn = _classifyPlusMinusItems(valuesByName.get('blocked_on'));
+    issueDelta.blocked_on_refs_add = blockedOn.add.map(TKR_parseIssueRef);
+    issueDelta.blocked_on_refs_add = blockedOn.remove.map(TKR_parseIssueRef);
+  }
+  if (valuesByName.has('blocking')) {
+    const blocking = _classifyPlusMinusItems(valuesByName.get('blocking'));
+    issueDelta.blocking_refs_add = blocking.add.map(TKR_parseIssueRef);
+    issueDelta.blocking_refs_add = blocking.remove.map(TKR_parseIssueRef);
+  }
+  if (valuesByName.has('merge_into')) {
+    issueDelta.merged_into_ref = TKR_parseIssueRef(
+      valuesByName.get('merge_into')[0]);
+  }
+  if (valuesByName.has('summary')) {
+    issueDelta.summary = valuesByName.get('summary')[0];
+  }
+
+  _buildFieldsForIssueDelta(issueDelta, valuesByName);
+
+  return issueDelta;
+}
diff --git a/static/js/tracker/trackerac_test.js b/static/js/tracker/trackerac_test.js
new file mode 100644
index 0000000..583fb01
--- /dev/null
+++ b/static/js/tracker/trackerac_test.js
@@ -0,0 +1,132 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+const feedData = {
+  'open': [{name: 'New', doc: 'Newly reported'},
+    {name: 'Started', doc: 'Work has begun'}],
+  'closed': [{name: 'Fixed', doc: 'Problem was fixed'},
+    {name: 'Invalid', doc: 'Bad issue report'}],
+  'labels': [{name: 'Type-Defect', doc: 'Something is broken'},
+    {name: 'Type-Enhancement', doc: 'It could be better'},
+    {name: 'Priority-High', doc: 'Urgent'},
+    {name: 'Priority-Low', doc: 'Not so urgent'},
+    {name: 'Hot', doc: ''},
+    {name: 'Cold', doc: ''}],
+  'members': [{name: 'jrobbins', doc: ''},
+    {name: 'jrobbins@chromium.org', doc: ''}],
+  'excl_prefixes': [],
+  'strict': false,
+};
+
+function setUp() {
+  TKR_autoCompleteFeedName = 'issueOptions';
+}
+
+/**
+ * The assertEquals method cannot do element-by-element comparisons.
+ * A search of how other teams write JS unit tests turned up this
+ * way to compare arrays.
+ */
+function assertElementsEqual(arrayA, arrayB) {
+  assertEquals(arrayA.join(' ;; '), arrayB.join(' ;; '));
+}
+
+function completionsEqual(strings, completions) {
+  if (strings.length != completions.length) {
+    return false;
+  }
+  for (let i = 0; i < strings.length; i++) {
+    if (strings[i] != completions[i].value) {
+      return false;
+    }
+  }
+  return true;
+}
+
+function assertHasCompletion(s, acStore) {
+  const ch = s.charAt(0).toLowerCase();
+  const firstCharMapArray = acStore.firstCharMap_[ch];
+  assertNotNull(!firstCharMapArray);
+  for (let i = 0; i < firstCharMapArray.length; i++) {
+    if (s == firstCharMapArray[i].value) return;
+  }
+  fail('completion ' + s + ' not found in acStore[' +
+       acStoreToString(acStore) + ']');
+}
+
+function assertHasAllCompletions(stringArray, acStore) {
+  for (let i = 0; i < stringArray.length; i++) {
+    assertHasCompletion(stringArray[i], acStore);
+  }
+}
+
+function acStoreToString(acStore) {
+  const allCompletions = [];
+  for (const ch in acStore.firstCharMap_) {
+    if (acStore.firstCharMap_.hasOwnProperty(ch)) {
+      const firstCharArray = acStore.firstCharMap_[ch];
+      for (let i = 0; i < firstCharArray.length; i++) {
+        allCompletions[firstCharArray[i].value] = true;
+      }
+    }
+  }
+  const parts = [];
+  for (const comp in allCompletions) {
+    if (allCompletions.hasOwnProperty(comp)) {
+      parts.push(comp);
+    }
+  }
+  return parts.join(', ');
+}
+
+function testSetUpStatusStore() {
+  TKR_setUpStatusStore(feedData.open, feedData.closed);
+  assertElementsEqual(
+      ['New', 'Started', 'Fixed', 'Invalid'],
+      TKR_statusWords);
+  assertHasAllCompletions(
+      ['New', 'Started', 'Fixed', 'Invalid'],
+      TKR_statusStore);
+}
+
+function testSetUpSearchStore() {
+  TKR_setUpSearchStore(
+      feedData.labels, feedData.members, feedData.open, feedData.closed);
+  assertHasAllCompletions(
+      ['status:New', 'status:Started', 'status:Fixed', 'status:Invalid',
+        '-status:New', '-status:Started', '-status:Fixed', '-status:Invalid',
+        'Type=Defect', '-Type=Defect', 'Type=Enhancement', '-Type=Enhancement',
+        'label:Hot', 'label:Cold', '-label:Hot', '-label:Cold',
+        'owner:jrobbins', 'cc:jrobbins', '-owner:jrobbins', '-cc:jrobbins',
+        'summary:', 'opened-after:today-1', 'commentby:me', 'reporter:me'],
+      TKR_searchStore);
+}
+
+function testSetUpQuickEditStore() {
+  TKR_setUpQuickEditStore(
+      feedData.labels, feedData.members, feedData.open, feedData.closed);
+  assertHasAllCompletions(
+      ['status=New', 'status=Started', 'status=Fixed', 'status=Invalid',
+        'Type=Defect', 'Type=Enhancement', 'Hot', 'Cold', '-Hot', '-Cold',
+        'owner=jrobbins', 'owner=me', 'cc=jrobbins', 'cc=me', 'cc=-jrobbins',
+        'cc=-me', 'summary=""', 'owner=----'],
+      TKR_quickEditStore);
+}
+
+function testSetUpLabelStore() {
+  TKR_setUpLabelStore(feedData.labels);
+  assertHasAllCompletions(
+      ['Type-Defect', 'Type-Enhancement', 'Hot', 'Cold'],
+      TKR_labelStore);
+}
+
+function testSetUpMembersStore() {
+  TKR_setUpMemberStore(feedData.members);
+  assertHasAllCompletions(
+      ['jrobbins', 'jrobbins@chromium.org'],
+      TKR_memberListStore);
+}
diff --git a/static/js/tracker/trackerediting_test.js b/static/js/tracker/trackerediting_test.js
new file mode 100644
index 0000000..27d45bf
--- /dev/null
+++ b/static/js/tracker/trackerediting_test.js
@@ -0,0 +1,69 @@
+/* Copyright 2016 The Chromium Authors. All Rights Reserved.
+ *
+ * Use of this source code is governed by a BSD-style
+ * license that can be found in the LICENSE file or at
+ * https://developers.google.com/open-source/licenses/bsd
+ */
+
+
+function testKeepJustSummaryPrefixes_NoPrefixes() {
+  assertEquals(
+      '',
+      TKR_keepJustSummaryPrefixes(''));
+
+  assertEquals(
+      '',
+      TKR_keepJustSummaryPrefixes('Enter one line summary'));
+
+  assertEquals(
+      '',
+      TKR_keepJustSummaryPrefixes('Translation problem [en]'));
+
+  assertEquals(
+      '',
+      TKR_keepJustSummaryPrefixes('Crash at HH:MM'));
+}
+
+function testKeepJustSummaryPrefixes_WithColons() {
+  assertEquals(
+      'Security: ',
+      TKR_keepJustSummaryPrefixes('Security:'));
+
+  assertEquals(
+      'Exploit: ',
+      TKR_keepJustSummaryPrefixes('Exploit: remote exploit'));
+
+  assertEquals(
+      'XSS:Security: ',
+      TKR_keepJustSummaryPrefixes('XSS:Security: rest of summary'));
+
+  assertEquals(
+      'XSS: Security: ',
+      TKR_keepJustSummaryPrefixes('XSS: Security: rest of summary'));
+
+  assertEquals(
+      'XSS-Security: ',
+      TKR_keepJustSummaryPrefixes('XSS-Security: rest of summary'));
+
+  assertEquals(
+      'XSS: Security: ',
+      TKR_keepJustSummaryPrefixes('XSS: Security: rest [of] su:mmary'));
+
+  assertEquals(
+      'XSS-Security: ',
+      TKR_keepJustSummaryPrefixes('XSS-Security: rest [of] su:mmary'));
+}
+
+function testKeepJustSummaryPrefixes_WithBrackets() {
+  assertEquals(
+      '[Printing] ',
+      TKR_keepJustSummaryPrefixes('[Printing] problem with page'));
+
+  assertEquals(
+      '[Printing] ',
+      TKR_keepJustSummaryPrefixes('[Printing]   problem with page'));
+
+  assertEquals(
+      '[l10n][en] ',
+      TKR_keepJustSummaryPrefixes('[l10n][en]Translation problem'));
+}