[gold] Port dots-sk to TypeScript.

Bug: skia:10246
Change-Id: I3c022c8847c1cdcbb743c42001d9dc8e93a1bf70
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/397220
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/modules/dots-sk/constants.js b/golden/modules/dots-sk/constants.ts
similarity index 92%
rename from golden/modules/dots-sk/constants.js
rename to golden/modules/dots-sk/constants.ts
index 5fa6916..e995a49 100644
--- a/golden/modules/dots-sk/constants.js
+++ b/golden/modules/dots-sk/constants.ts
@@ -5,8 +5,8 @@
 export const DOT_OFFSET_Y = 10;
 
 // Functions that go from dot space to canvas coordinates.
-export const dotToCanvasX = (x) => x * DOT_SCALE_X + DOT_OFFSET_X;
-export const dotToCanvasY = (y) => y * DOT_SCALE_Y + DOT_OFFSET_Y;
+export const dotToCanvasX = (x: number) => x * DOT_SCALE_X + DOT_OFFSET_X;
+export const dotToCanvasY = (y: number) => y * DOT_SCALE_Y + DOT_OFFSET_Y;
 
 // Maximum number of unique digests to display. If the number of unique digests
 // exceeds this, they will be grouped together with the last color.
diff --git a/golden/modules/dots-sk/demo_data.js b/golden/modules/dots-sk/demo_data.ts
similarity index 75%
rename from golden/modules/dots-sk/demo_data.js
rename to golden/modules/dots-sk/demo_data.ts
index f1bd629..9601f01 100644
--- a/golden/modules/dots-sk/demo_data.js
+++ b/golden/modules/dots-sk/demo_data.ts
@@ -1,18 +1,22 @@
-// The trace below is based on a subset of a real trace found in Skia Gold.
-// This is what the dots diagram should look like (trace length = 20):
-//
-//   +--------------------+
-//   |**765334433332211100|
-//   |   11-1-1100--0000  |
-//   |      22221111110000|
-//   +--------------------+
-//
-// Where the numbers represent different colors, and a star (*) represents the
-// special color used for unique digests in excess of MAX_UNIQUE_DIGESTS.
-//
-// Additionally, The numbers above correspond to traces.traces[i].data[j].
-// -1 means "missing digest".
-export const traces = {
+import { Commit, TraceGroup } from '../rpc_types';
+
+/**
+ * The trace below is based on a subset of a real trace found in Skia Gold.
+ * This is what the dots diagram should look like (trace length = 20):
+ *
+ *   +--------------------+
+ *   |**765334433332211100|
+ *   |   11-1-1100--0000  |
+ *   |      22221111110000|
+ *   +--------------------+
+ *
+ * Where the numbers represent different colors, and a star (*) represents the
+ * special color used for unique digests in excess of MAX_UNIQUE_DIGESTS.
+ *
+ * Additionally, The numbers above correspond to traces.traces[i].data[j].
+ * -1 means "missing digest".
+ */
+export const traces: TraceGroup = {
   tileSize: 20,
   traces: [{
     // Note: the backend tops out at 8, but we should handle values greater than
@@ -24,6 +28,7 @@
       beta: 'hello',
       gamma: 'world',
     },
+    comment_indices: null,
   }, {
     data: [-1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 0, 0, -1, -1, 0, 0, 0, 0, -1, -1],
     label: ',alpha=second-trace,beta=foo,gamma=bar,',
@@ -32,6 +37,7 @@
       beta: 'foo',
       gamma: 'bar',
     },
+    comment_indices: null,
   }, {
     data: [-1, -1, -1, -1, -1, -1, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
     label: ',alpha=third-trace,beta=baz,gamma=qux,',
@@ -40,6 +46,7 @@
       beta: 'baz',
       gamma: 'qux',
     },
+    comment_indices: null,
   }],
   digests: [{
     digest: 'ce0a9d2b546b25e00e39a33860cb72b6',
@@ -72,86 +79,127 @@
     digest: '8ad66f50b755d82cd1c08b22e984bbef',
     status: 'untriaged',
   }],
+  total_digests: 10,
 };
 
-export const commits = [{
+export const commits: Commit[] = [{
   commit_time: 1576186931,
   hash: '46a331b93f54d8b3bce88792dd8679beef11a751',
   author: 'Alpha (alpha@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576186932,
   hash: '1521e6b24c19f30eda383bb00b26862894ae9182',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576189965,
   hash: 'f46d5ca49221113497d41e8f2a3c0c59151f4010',
   author: 'Gamma (gamma@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576190315,
   hash: 'dcd8e9389d8aa79a389aebad570d340ec012f367',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576191335,
   hash: '2fc9fa6d08df3b12c764d88f4458d28d4352de9b',
   author: 'Epsilon (epsilon@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576192005,
   hash: '4d3b4a1bf31afb9d50ac84221a5852fea29a30df',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576197935,
   hash: '0678df30b5a56375ff6a4c21e6f0ecadb3493b7c',
   author: 'Delta (delta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576200535,
   hash: '39cdc37bdd0fd63556357a86b43f83fa4211ce0f',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576211413,
   hash: '252a03454d382b387c8f42aa75cfd63756816713',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576213625,
   hash: '415bce89a49abb6f53b2d3634159f6d304c8c8b5',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576213773,
   hash: '7fb7134e7d946d80741f779cfdb10cfd40a1f7a3',
   author: 'Beta (beta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576246423,
   hash: 'd0840ecf583171e55025d2808dba017910b7a54f',
   author: 'Zeta (zeta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576255153,
   hash: '81b98978bced13406df91c2f5917cc2b82772f1e',
   author: 'Eta (Eta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576258473,
   hash: 'a6069a154d66b2620bea1907b0eebf5d1afd02e7',
   author: 'Theta (Theta@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576260733,
   hash: '1c5be7b19707c54ff859aa9f834a92e14d6ab5b9',
   author: 'Epsilon (epsilon@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576260973,
   hash: 'ab51c2ce0884a2bb1693d0f15d9eb674800e18ba',
   author: 'Iota (iota@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576262533,
   hash: 'c9b4d279d235c8db48875f0d0854bfe25c631ff6',
   author: 'Kappa (kappa@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576264923,
   hash: '1cc767bd0d915bd0f3f5b40dcb282367c9fd9271',
   author: 'Gamma (gamma@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576265403,
   hash: 'a072b7b2758d644fbd5483a9716f581d270c7560',
   author: 'Lambda (lambda@example.com)',
+  message: '',
+  cl_url: '',
 }, {
   commit_time: 1576265853,
   hash: '17e7dfa37734347215f0b6bacb72c06ec85dbfdc',
   author: 'Epsilon (epsilon@example.com)',
+  message: '',
+  cl_url: '',
 }];
diff --git a/golden/modules/dots-sk/dots-sk-demo.js b/golden/modules/dots-sk/dots-sk-demo.js
deleted file mode 100644
index 857b308..0000000
--- a/golden/modules/dots-sk/dots-sk-demo.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import './index';
-import { $$ } from 'common-sk/modules/dom';
-import { isPuppeteerTest } from '../demo_util';
-import { traces, commits } from './demo_data';
-
-const logEventDetail = (e) => {
-  const log = $$('#event-log');
-  const entry = `Timestamp:    ${new Date().toISOString()}\n`
-      + `Event type:   ${e.type}\n`
-      + `Event detail: ${JSON.stringify(e.detail)}\n\n`;
-  log.value = entry + log.value;
-};
-
-const dots = document.createElement('dots-sk');
-dots.value = traces;
-dots.commits = commits;
-dots.addEventListener('show-commits', logEventDetail);
-dots.addEventListener('hover', logEventDetail);
-$$('#container').appendChild(dots);
-
-// Hide event log if we're within a Puppeteer test. We don't need the event log
-// to appear in any screenshots uploaded to Gold.
-if (isPuppeteerTest()) {
-  $$('#event-log-container').style.display = 'none';
-}
diff --git a/golden/modules/dots-sk/dots-sk-demo.ts b/golden/modules/dots-sk/dots-sk-demo.ts
new file mode 100644
index 0000000..8122e7b
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk-demo.ts
@@ -0,0 +1,27 @@
+import './index';
+import { $$ } from 'common-sk/modules/dom';
+import { isPuppeteerTest } from '../demo_util';
+import { traces, commits } from './demo_data';
+import {DotsSk} from './dots-sk';
+
+const logEventDetail = (e: Event) => {
+  const detail = (e as CustomEvent).detail;
+  const log = $$<HTMLTextAreaElement>('#event-log')!;
+  const entry = `Timestamp:    ${new Date().toISOString()}\n`
+      + `Event type:   ${e.type}\n`
+      + `Event detail: ${JSON.stringify(detail)}\n\n`;
+  log.value = entry + log.value;
+};
+
+const dotsSk = new DotsSk();
+dotsSk.value = traces;
+dotsSk.commits = commits;
+dotsSk.addEventListener('showblamelist', logEventDetail);
+dotsSk.addEventListener('hover', logEventDetail);
+$$('#container')!.appendChild(dotsSk);
+
+// Hide event log if we're within a Puppeteer test. We don't need the event log
+// to appear in any screenshots uploaded to Gold.
+if (isPuppeteerTest()) {
+  $$<HTMLDivElement>('#event-log-container')!.style.display = 'none';
+}
diff --git a/golden/modules/dots-sk/dots-sk.js b/golden/modules/dots-sk/dots-sk.js
deleted file mode 100644
index 58ca291..0000000
--- a/golden/modules/dots-sk/dots-sk.js
+++ /dev/null
@@ -1,350 +0,0 @@
-/**
- * @module modules/dots-sk
- * @description <h2><code>dots-sk</code></h2>
- *
- * A custom element for displaying a dot chart of digests by trace, such as:
- *
- *   ooo-o-o-oo
- *
- * @evt showblamelist - Event generated when a dot is clicked. e.detail contains
- *   the blamelist (an array of commits that could have made up that dot).
- *
- * @evt hover - Event generated when the mouse hovers over a trace. e.detail is
- *   the trace id.
- */
-
-import { define } from 'elements-sk/define';
-import { html } from 'lit-html';
-import { $$ } from 'common-sk/modules/dom';
-import { ElementSk } from '../../../infra-sk/modules/ElementSk';
-import {
-  dotToCanvasX,
-  dotToCanvasY,
-  DOT_FILL_COLORS,
-  DOT_FILL_COLORS_HIGHLIGHTED,
-  DOT_OFFSET_X,
-  DOT_OFFSET_Y,
-  DOT_RADIUS,
-  DOT_SCALE_X,
-  DOT_SCALE_Y,
-  DOT_STROKE_COLORS,
-  MISSING_DOT,
-  STROKE_WIDTH,
-  TRACE_LINE_COLOR,
-} from './constants';
-
-// Array of dots-sk component instances. A dots-sk instance is present if it has
-// a pending mousemove update.
-const dotsSkInstancesWithPendingMouseMoveUpdates = [];
-
-// Periodically process all pending mousemoves. We do not want to do any work on
-// a mouse move event as that can very easily degrade browser performance, e.g.
-// as the user drags the mouse pointer over the element. Processing mouse events
-// in batches remedies this.
-setInterval(() => {
-  while (dotsSkInstancesWithPendingMouseMoveUpdates.length > 0) {
-    const dotsSk = dotsSkInstancesWithPendingMouseMoveUpdates.pop();
-    dotsSk._updatePendingMouseMove();
-  }
-}, 40);
-
-const template = () => html`<canvas></canvas>`;
-
-define('dots-sk', class extends ElementSk {
-  constructor() {
-    super(template);
-    this._commits = [];
-    this._value = { tileSize: 0, traces: [] };
-    this._id = `id${Math.random()}`;
-
-    // The index of the trace that should be highlighted.
-    this._hoverIndex = -1;
-
-    // For capturing the last mousemove event, which is later processed in a
-    // timer.
-    this._lastMouseMove = null;
-
-    this._hasScrolledOnce = false;
-
-    // Explicitly bind event handler methods to this.
-    this._onMouseMove = this._onMouseMove.bind(this);
-    this._onMouseLeave = this._onMouseLeave.bind(this);
-    this._onClick = this._onClick.bind(this);
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._render();
-    this._canvas = $$('canvas', this);
-    this._canvas.addEventListener('mousemove', this._onMouseMove);
-    this._canvas.addEventListener('mouseleave', this._onMouseLeave);
-    this._canvas.addEventListener('click', this._onClick);
-    this._ctx = this._canvas.getContext('2d');
-    this._draw();
-  }
-
-  disconnectedCallback() {
-    this._canvas.removeEventListener('mousemove', this._onMouseMove);
-    this._canvas.removeEventListener('mouseleave', this._onMouseLeave);
-    this._canvas.removeEventListener('click', this._onClick);
-    this._hasScrolledOnce = false;
-  }
-
-  /**
-   * @prop value {Object} An object of the form:
-   *
-   *   {
-   *     tileSize: 50,
-   *     traces: [
-   *       {
-   *         label: "some:trace:id",
-   *         data: [0, 1, 2, 2, 1, -1, -1, 2, ...],
-   *       },
-   *       ...
-   *     ]
-   *   }
-   *
-   * Where the content of the data array are color codes; 0 is the target digest, while 1-6 indicate
-   * unique digests that are different from the target digest. A code of -1 means no data for the
-   * corresponding commit. A code of 7 means that there are 8 or more unique digests in the trace
-   * and all digests after the first 8 unique digests are represented by this code. The highest
-   * index of data is the most recent data point.
-   */
-  get value() { return this._value; }
-
-  set value(value) {
-    if (!value || (value.tileSize === 0)) {
-      return;
-    }
-    this._value = value;
-    if (this._connected) {
-      this._draw();
-    }
-  }
-
-  /**
-   * Scrolls the traces all the way to the right, showing the newest first. It will only do this
-   * on the first call, so as to avoid undoing the user manually scrolling left to see older
-   * history.
-   */
-  autoscroll() {
-    if (!this._hasScrolledOnce) {
-      this._hasScrolledOnce = true;
-      this.scroll(this.scrollWidth, 0);
-    }
-  }
-
-  // Draws the entire canvas.
-  _draw() {
-    const w = (this._value.tileSize - 1) * DOT_SCALE_X + 2 * DOT_OFFSET_X;
-    const h = (this._value.traces.length - 1) * DOT_SCALE_Y + 2 * DOT_OFFSET_Y;
-    this._canvas.setAttribute('width', `${w}px`);
-    this._canvas.setAttribute('height', `${h}px`);
-
-    // First clear the canvas.
-    this._ctx.lineWidth = STROKE_WIDTH;
-    this._ctx.fillStyle = '#FFFFFF';
-    this._ctx.fillRect(0, 0, w, h);
-
-    // Draw lines and dots.
-    this._value.traces.forEach((trace, traceIndex) => {
-      this._ctx.strokeStyle = TRACE_LINE_COLOR;
-      this._ctx.beginPath();
-      const firstNonMissingDot = trace.data.findIndex((dot) => dot !== MISSING_DOT);
-      let lastNonMissingDot = -1;
-      for (let i = trace.data.length - 1; i >= 0; i--) {
-        if (trace.data[i] !== MISSING_DOT) {
-          lastNonMissingDot = i;
-          break;
-        }
-      }
-      if (firstNonMissingDot < 0 || lastNonMissingDot < 0) {
-        // Trace was all missing data, so nothing to draw. This should never happen, such a trace
-        // would not be included in search results.
-        console.warn(`trace with id ${trace.label} was unexpectedly empty`);
-        return;
-      }
-      this._ctx.moveTo(
-        dotToCanvasX(firstNonMissingDot),
-        dotToCanvasY(traceIndex),
-      );
-      this._ctx.lineTo(
-        dotToCanvasX(lastNonMissingDot),
-        dotToCanvasY(traceIndex),
-      );
-      this._ctx.stroke();
-      this._drawTraceDots(trace.data, traceIndex);
-    });
-  }
-
-  // Draws the circles for a single trace.
-  _drawTraceDots(colors, y) {
-    colors.forEach((c, x) => {
-      // We don't draw a dot when it is missing.
-      if (c === MISSING_DOT) {
-        return;
-      }
-      this._ctx.beginPath();
-      this._ctx.strokeStyle = this._getColorSafe(DOT_STROKE_COLORS, c);
-      this._ctx.fillStyle = (this._hoverIndex === y)
-        ? this._getColorSafe(DOT_FILL_COLORS_HIGHLIGHTED, c)
-        : this._getColorSafe(DOT_FILL_COLORS, c);
-      this._ctx.arc(
-        dotToCanvasX(x), dotToCanvasY(y), DOT_RADIUS, 0, Math.PI * 2,
-      );
-      this._ctx.fill();
-      this._ctx.stroke();
-    });
-  }
-
-  // Used to index into the dot color arrays (DOT_STROKE_COLORS, etc.). Returns
-  // the last color in the array if the given unique digest index exceeds
-  // MAX_UNIQUE_DIGESTS.
-  //
-  // This assumes that the color array is of length MAX_UNIQUE_DIGESTS + 1.
-  _getColorSafe(colorArray, uniqueDigestIndex) {
-    return colorArray[Math.min(colorArray.length - 1, uniqueDigestIndex)];
-  }
-
-  // Redraws just the circles for a single trace.
-  _redrawTraceDots(traceIndex) {
-    const trace = this._value.traces[traceIndex];
-    if (!trace) {
-      return;
-    }
-    this._drawTraceDots(trace.data, traceIndex);
-  }
-
-  /**
-   * @prop commits {Object} An array of commits, such as:
-   *
-   *   [
-   *     {
-   *       author: "committer@example.org"
-   *       commit_time: 1428445634
-   *       hash: "c654e9016a15985ebeb24f94f819d113ad48a251"
-   *     },
-   *    ...
-   *   ]
-   */
-  get commits() { return this._commits; }
-
-  set commits(commits) { this._commits = commits; }
-
-  _onMouseLeave() {
-    const oldHoverIndex = this._hoverIndex;
-    this._hoverIndex = -1;
-    this._redrawTraceDots(oldHoverIndex);
-    this._lastMouseMove = null;
-  }
-
-  _onMouseMove(e) {
-    this._lastMouseMove = {
-      clientX: e.clientX,
-      clientY: e.clientY,
-    };
-    dotsSkInstancesWithPendingMouseMoveUpdates.push(this);
-  }
-
-  // Gets the coordinates of the mouse event in dot coordinates.
-  _mouseEventToDotSpace(e) {
-    const rect = this._canvas.getBoundingClientRect();
-    const x = (e.clientX - rect.left - DOT_OFFSET_X + STROKE_WIDTH + DOT_RADIUS)
-            / DOT_SCALE_X;
-    const y = (e.clientY - rect.top - DOT_OFFSET_Y + STROKE_WIDTH + DOT_RADIUS)
-            / DOT_SCALE_Y;
-    return { x: Math.floor(x), y: Math.floor(y) };
-  }
-
-  // We look at the mousemove event, if one occurred, to determine which trace
-  // to highlight.
-  _updatePendingMouseMove() {
-    if (!this._lastMouseMove) {
-      return;
-    }
-    const dotCoords = this._mouseEventToDotSpace(this._lastMouseMove);
-    this._lastMouseMove = null;
-    // If the focus has moved to a different trace then draw the two changing
-    // traces.
-    if (this._hoverIndex !== dotCoords.y) {
-      const oldIndex = this._hoverIndex;
-      this._hoverIndex = dotCoords.y;
-      if (this._hoverIndex >= 0
-          && this._hoverIndex < this._value.traces.length) {
-        this.dispatchEvent(new CustomEvent('hover', {
-          bubbles: true,
-          detail: this._value.traces[this._hoverIndex].label,
-        }));
-      }
-      // Just update the dots of the traces that have changed.
-      this._redrawTraceDots(oldIndex);
-      this._redrawTraceDots(this._hoverIndex);
-    }
-
-    // Set the cursor to a pointer if you are hovering over a dot.
-    let found = false;
-    const trace = this._value.traces[dotCoords.y];
-    if (trace) {
-      for (let i = trace.data.length - 1; i >= 0; i--) {
-        if (trace.data[i].x === dotCoords.x) {
-          found = true;
-          break;
-        }
-      }
-    }
-    this.style.cursor = (found) ? 'pointer' : 'auto';
-  }
-
-  // When a dot is clicked on, produce the showblamelist event with the
-  // blamelist; that is, all the commits that are included up to and including
-  // that dot.
-  _onClick(e) {
-    const dotCoords = this._mouseEventToDotSpace(e);
-    const trace = this._value.traces[dotCoords.y];
-    if (!trace) {
-      return; // Misclick, likely.
-    }
-    const blamelist = this._computeBlamelist(trace, dotCoords.x);
-    if (!blamelist) {
-      return; // No blamelist if there's no dot at that X coord, i.e. misclick.
-    }
-    this.dispatchEvent(new CustomEvent('showblamelist', {
-      bubbles: true,
-      detail: blamelist,
-    }));
-  }
-
-  // Takes a trace and the X coordinate of a dot in that trace and returns the
-  // blamelist for that dot. The blamelist includes the commit corresponding to
-  // the dot, and if the dot is preceded by any missing dots, then their
-  // corresponding commits will be included as well.
-  _computeBlamelist(trace, x) {
-    if (trace.data[x] === MISSING_DOT) {
-      // Can happen if there's no dot at that X coord, e.g. misclick.
-      return null;
-    }
-    // Look backwards in the trace for the previous commit with data. If none,
-    // 0 is a fine index to compute the blamelist from.
-    let lastNonMissingIndex = 0;
-    for (let i = x - 1; i >= 0; i--) {
-      if (trace.data[i] !== MISSING_DOT) {
-        // We include the last non-missing data in our slice because the slice of commits that
-        // Gold returns is not the complete history - Gold elides commits that have no data.
-        // This is potentially a problem in the following scenario:
-        //   - commit 1 has correct data
-        //   - commit 2 has correct data
-        //   - commit 3 has no data, but introduced a bug (and would have produced incorrect data)
-        //   - commit 4 has incorrect data
-        // In this case, we need to make sure we can create a blamelist that starts on the first
-        // real commit after commit 2. Therefore, we include commit 2 in the list, which GitHub
-        // and googlesource will automatically elide when we ask for a range of history (in
-        // blamelist-panel-sk). If there were no preceding missing digests, this will equal x-1.
-        lastNonMissingIndex = i;
-        break;
-      }
-    }
-    const blamelist = this._commits.slice(lastNonMissingIndex, x + 1);
-    blamelist.reverse();
-    return blamelist;
-  }
-});
diff --git a/golden/modules/dots-sk/dots-sk.ts b/golden/modules/dots-sk/dots-sk.ts
new file mode 100644
index 0000000..0bcc952
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk.ts
@@ -0,0 +1,347 @@
+/**
+ * @module modules/dots-sk
+ * @description <h2><code>dots-sk</code></h2>
+ *
+ * A custom element for displaying a dot chart of digests by trace, such as:
+ *
+ *   ooo-o-o-oo
+ *
+ * @evt showblamelist - Event generated when a dot is clicked. e.detail contains
+ *   the blamelist (an array of commits that could have made up that dot).
+ *
+ * @evt hover - Event generated when the mouse hovers over a trace. e.detail is
+ *   the trace id.
+ */
+
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+import { $$ } from 'common-sk/modules/dom';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import {
+  dotToCanvasX,
+  dotToCanvasY,
+  DOT_FILL_COLORS,
+  DOT_FILL_COLORS_HIGHLIGHTED,
+  DOT_OFFSET_X,
+  DOT_OFFSET_Y,
+  DOT_RADIUS,
+  DOT_SCALE_X,
+  DOT_SCALE_Y,
+  DOT_STROKE_COLORS,
+  MISSING_DOT,
+  STROKE_WIDTH,
+  TRACE_LINE_COLOR,
+} from './constants';
+import { Commit, Trace, TraceGroup } from '../rpc_types';
+
+// Array of dots-sk component instances. A dots-sk instance is present if it has a pending
+// mousemove update.
+const dotsSkInstancesWithPendingMouseMoveUpdates: DotsSk[] = [];
+
+// Periodically process all pending mousemoves. We do not want to do any work on a mouse move event
+// as that can very easily degrade browser performance, e.g. as the user drags the mouse pointer
+// over the element. Processing mouse events in batches remedies this.
+setInterval(() => {
+  while (dotsSkInstancesWithPendingMouseMoveUpdates.length > 0) {
+    const dotsSk = dotsSkInstancesWithPendingMouseMoveUpdates.pop()!;
+    dotsSk.updatePendingMouseMove();
+  }
+}, 40);
+
+export class DotsSk extends ElementSk {
+  private static template = () => html`<canvas></canvas>`;
+
+  private canvas: HTMLCanvasElement | null = null;
+  private ctx: CanvasRenderingContext2D | null = null;
+
+  private _commits: Commit[] = [];
+  private _value: TraceGroup = {tileSize: 0, traces: [], digests: [], total_digests: 0};
+
+  // The index of the trace that should be highlighted.
+  private hoverIndex = -1;
+  private hasScrolledOnce = false;
+
+  // For capturing the last mousemove event, which is later processed in a timer.
+  private lastMouseMove: MouseEvent | null = null;
+
+  constructor() {
+    super(DotsSk.template);
+
+    // Explicitly bind event handler methods to this.
+    this.onMouseMove = this.onMouseMove.bind(this);
+    this.onMouseLeave = this.onMouseLeave.bind(this);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+    this.canvas = $$('canvas', this);
+    this.canvas!.addEventListener('mousemove', this.onMouseMove);
+    this.canvas!.addEventListener('mouseleave', this.onMouseLeave);
+    this.canvas!.addEventListener('click', this.onClick);
+    this.ctx = this.canvas!.getContext('2d');
+    this.draw();
+  }
+
+  disconnectedCallback() {
+    this.canvas!.removeEventListener('mousemove', this.onMouseMove);
+    this.canvas!.removeEventListener('mouseleave', this.onMouseLeave);
+    this.canvas!.removeEventListener('click', this.onClick);
+    this.hasScrolledOnce = false;
+  }
+
+  /**
+   * The TraceGroup to display, e.g.:
+   *
+   *   {
+   *     tileSize: 50,
+   *     traces: [
+   *       {
+   *         label: "some:trace:id",
+   *         data: [0, 1, 2, 2, 1, -1, -1, 2, ...],
+   *       },
+   *       ...
+   *     ]
+   *   }
+   *
+   * Where the content of the data array are color codes; 0 is the target digest, while 1-6
+   * indicate unique digests that are different from the target digest. A code of -1 means no data
+   * for the corresponding commit. A code of 7 means that there are 8 or more unique digests in the
+   * trace and all digests after the first 8 unique digests are represented by this code. The
+   * highest index of data is the most recent data point.
+   */
+  get value(): TraceGroup { return this._value; }
+
+  set value(value: TraceGroup) {
+    if (!value || (value.tileSize === 0)) {
+      return;
+    }
+    this._value = value;
+    if (this._connected) {
+      this.draw();
+    }
+  }
+
+  /** An array of Commits associated with the TraceGroup to display. */
+  get commits(): Commit[] { return this._commits; }
+
+  set commits(commits: Commit[]) { this._commits = commits; }
+
+  /**
+   * Scrolls the traces all the way to the right, showing the newest first. It will only do this
+   * on the first call, so as to avoid undoing the user manually scrolling left to see older
+   * history.
+   */
+  autoscroll() {
+    if (!this.hasScrolledOnce) {
+      this.hasScrolledOnce = true;
+      this.scroll(this.scrollWidth, 0);
+    }
+  }
+
+  /** Draws the entire canvas. */
+  private draw() {
+    const w = (this._value.tileSize - 1) * DOT_SCALE_X + 2 * DOT_OFFSET_X;
+    const h = (this._value.traces!.length - 1) * DOT_SCALE_Y + 2 * DOT_OFFSET_Y;
+    this.canvas!.setAttribute('width', `${w}px`);
+    this.canvas!.setAttribute('height', `${h}px`);
+
+    // First clear the canvas.
+    this.ctx!.lineWidth = STROKE_WIDTH;
+    this.ctx!.fillStyle = '#FFFFFF';
+    this.ctx!.fillRect(0, 0, w, h);
+
+    // Draw lines and dots.
+    this._value.traces!.forEach((trace, traceIndex) => {
+      this.ctx!.strokeStyle = TRACE_LINE_COLOR;
+      this.ctx!.beginPath();
+      const firstNonMissingDot = trace.data!.findIndex((dot) => dot !== MISSING_DOT);
+      let lastNonMissingDot = -1;
+      for (let i = trace.data!.length - 1; i >= 0; i--) {
+        if (trace.data![i] !== MISSING_DOT) {
+          lastNonMissingDot = i;
+          break;
+        }
+      }
+      if (firstNonMissingDot < 0 || lastNonMissingDot < 0) {
+        // Trace was all missing data, so nothing to draw. This should never happen, such a trace
+        // would not be included in search results.
+        console.warn(`trace with id ${trace.label} was unexpectedly empty`);
+        return;
+      }
+      this.ctx!.moveTo(
+        dotToCanvasX(firstNonMissingDot),
+        dotToCanvasY(traceIndex),
+      );
+      this.ctx!.lineTo(
+        dotToCanvasX(lastNonMissingDot),
+        dotToCanvasY(traceIndex),
+      );
+      this.ctx!.stroke();
+      this.drawTraceDots(trace.data!, traceIndex);
+    });
+  }
+
+  /** Draws the circles for a single trace. */
+  private drawTraceDots(colors: number[], y: number) {
+    colors.forEach((c, x) => {
+      // We don't draw a dot when it is missing.
+      if (c === MISSING_DOT) {
+        return;
+      }
+      this.ctx!.beginPath();
+      this.ctx!.strokeStyle = this.getColorSafe(DOT_STROKE_COLORS, c);
+      this.ctx!.fillStyle = (this.hoverIndex === y)
+        ? this.getColorSafe(DOT_FILL_COLORS_HIGHLIGHTED, c)
+        : this.getColorSafe(DOT_FILL_COLORS, c);
+      this.ctx!.arc(
+        dotToCanvasX(x), dotToCanvasY(y), DOT_RADIUS, 0, Math.PI * 2,
+      );
+      this.ctx!.fill();
+      this.ctx!.stroke();
+    });
+  }
+
+  /**
+   * Used to index into the dot color arrays (DOT_STROKE_COLORS, etc.). Returns
+   * the last color in the array if the given unique digest index exceeds
+   * MAX_UNIQUE_DIGESTS.
+   *
+   * This assumes that the color array is of length MAX_UNIQUE_DIGESTS + 1.
+   */
+  private getColorSafe(colorArray: string[], uniqueDigestIndex: number): string {
+    return colorArray[Math.min(colorArray.length - 1, uniqueDigestIndex)];
+  }
+
+  /** Redraws just the circles for a single trace. */
+  private redrawTraceDots(traceIndex: number) {
+    const trace = this._value.traces![traceIndex];
+    if (!trace) {
+      return;
+    }
+    this.drawTraceDots(trace.data!, traceIndex);
+  }
+
+  private onMouseLeave() {
+    const oldHoverIndex = this.hoverIndex;
+    this.hoverIndex = -1;
+    this.redrawTraceDots(oldHoverIndex);
+    this.lastMouseMove = null;
+  }
+
+  private onMouseMove(e: MouseEvent) {
+    this.lastMouseMove = e;
+    dotsSkInstancesWithPendingMouseMoveUpdates.push(this);
+  }
+
+  /** Gets the coordinates of the mouse event in dot coordinates. */
+  private mouseEventToDotSpace(e: MouseEvent) {
+    const rect = this.canvas!.getBoundingClientRect();
+    const x = (e.clientX - rect.left - DOT_OFFSET_X + STROKE_WIDTH + DOT_RADIUS)
+            / DOT_SCALE_X;
+    const y = (e.clientY - rect.top - DOT_OFFSET_Y + STROKE_WIDTH + DOT_RADIUS)
+            / DOT_SCALE_Y;
+    return { x: Math.floor(x), y: Math.floor(y) };
+  }
+
+  /**
+   * We look at the mousemove event, if one occurred, to determine which trace to highlight.
+   *
+   * Not part of the public API.
+   */
+  updatePendingMouseMove() {
+    if (!this.lastMouseMove) {
+      return;
+    }
+    const dotCoords = this.mouseEventToDotSpace(this.lastMouseMove);
+    this.lastMouseMove = null;
+    // If the focus has moved to a different trace then draw the two changing
+    // traces.
+    if (this.hoverIndex !== dotCoords.y) {
+      const oldIndex = this.hoverIndex;
+      this.hoverIndex = dotCoords.y;
+      if (this.hoverIndex >= 0
+          && this.hoverIndex < this._value.traces!.length) {
+        this.dispatchEvent(new CustomEvent('hover', {
+          bubbles: true,
+          detail: this._value.traces![this.hoverIndex].label,
+        }));
+      }
+      // Just update the dots of the traces that have changed.
+      this.redrawTraceDots(oldIndex);
+      this.redrawTraceDots(this.hoverIndex);
+    }
+
+    // Set the cursor to a pointer if you are hovering over a dot.
+    let found = false;
+    const trace = this._value.traces![dotCoords.y];
+    if (trace) {
+      for (let i = trace.data!.length - 1; i >= 0; i--) {
+        const dot = trace.data![dotCoords.x]
+        if (dot !== undefined && dot !== MISSING_DOT ) {
+          found = true;
+          break;
+        }
+      }
+    }
+    this.style.cursor = (found) ? 'pointer' : 'auto';
+  }
+
+  /**
+   * When a dot is clicked on, produce the showblamelist event with the blamelist; that is, all the
+   * commits that are included up to and including  that dot.
+   */
+  private onClick(e: MouseEvent) {
+    const dotCoords = this.mouseEventToDotSpace(e);
+    const trace = this._value.traces![dotCoords.y];
+    if (!trace) {
+      return; // Misclick, likely.
+    }
+    const blamelist = this.computeBlamelist(trace, dotCoords.x);
+    if (!blamelist) {
+      return; // No blamelist if there's no dot at that X coord, i.e. misclick.
+    }
+    this.dispatchEvent(new CustomEvent('showblamelist', {
+      bubbles: true,
+      detail: blamelist,
+    }));
+  }
+
+  /**
+   * Takes a trace and the X coordinate of a dot in that trace and returns the blamelist for that
+   * dot. The blamelist includes the commit corresponding to the dot, and if the dot is preceded by
+   * any missing dots, then their corresponding commits will be included as well.
+   */
+  private computeBlamelist(trace: Trace, x: number) {
+    if (trace.data![x] === MISSING_DOT) {
+      // Can happen if there's no dot at that X coord, e.g. misclick.
+      return null;
+    }
+    // Look backwards in the trace for the previous commit with data. If none,
+    // 0 is a fine index to compute the blamelist from.
+    let lastNonMissingIndex = 0;
+    for (let i = x - 1; i >= 0; i--) {
+      if (trace.data![i] !== MISSING_DOT) {
+        // We include the last non-missing data in our slice because the slice of commits that
+        // Gold returns is not the complete history - Gold elides commits that have no data.
+        // This is potentially a problem in the following scenario:
+        //   - commit 1 has correct data
+        //   - commit 2 has correct data
+        //   - commit 3 has no data, but introduced a bug (and would have produced incorrect data)
+        //   - commit 4 has incorrect data
+        // In this case, we need to make sure we can create a blamelist that starts on the first
+        // real commit after commit 2. Therefore, we include commit 2 in the list, which GitHub
+        // and googlesource will automatically elide when we ask for a range of history (in
+        // blamelist-panel-sk). If there were no preceding missing digests, this will equal x-1.
+        lastNonMissingIndex = i;
+        break;
+      }
+    }
+    const blamelist = this._commits.slice(lastNonMissingIndex, x + 1);
+    blamelist.reverse();
+    return blamelist;
+  }
+}
+
+define('dots-sk', DotsSk);
diff --git a/golden/modules/dots-sk/dots-sk_test.js b/golden/modules/dots-sk/dots-sk_test.js
deleted file mode 100644
index 49cbba0..0000000
--- a/golden/modules/dots-sk/dots-sk_test.js
+++ /dev/null
@@ -1,307 +0,0 @@
-/* eslint-env browser, mocha */
-/* eslint arrow-body-style: ["off", "as-needed"] */
-import './index';
-import { commits, traces } from './demo_data';
-import {
-  dotToCanvasX,
-  dotToCanvasY,
-  DOT_FILL_COLORS,
-  DOT_FILL_COLORS_HIGHLIGHTED,
-  DOT_RADIUS,
-  DOT_STROKE_COLORS,
-  MAX_UNIQUE_DIGESTS,
-  TRACE_LINE_COLOR,
-} from './constants';
-import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
-
-describe('dots-sk constants', () => {
-  it('DOT_FILL_COLORS has the expected number of entries', () => {
-    expect(DOT_FILL_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
-  });
-
-  it('DOT_FILL_COLORS_HIGHLIGHTED has the expected number of entries', () => {
-    expect(DOT_FILL_COLORS_HIGHLIGHTED).to.have.length(MAX_UNIQUE_DIGESTS);
-  });
-
-  it('DOT_STROKE_COLORS has the expected number of entries', () => {
-    expect(DOT_STROKE_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
-  });
-});
-
-describe('dots-sk', () => {
-  const newInstance = setUpElementUnderTest('dots-sk');
-
-  let dotsSk;
-  beforeEach(() => {
-    dotsSk = newInstance((el) => {
-      // All test cases use the same set of traces and commits.
-      el.value = traces;
-      el.commits = commits;
-    });
-  });
-
-  it('renders correctly', () => {
-    expect(dotsSk._canvas.clientWidth).to.equal(210);
-    expect(dotsSk._canvas.clientHeight).to.equal(40);
-    // We specify the traces as an array and then join them instead of using a string literal
-    // to avoid having invisible (but important to the test) trailing spaces.
-    expect(canvasToAscii(dotsSk)).to.equal([
-      'iihgfddeeddddccbbbaa',
-      '   bb-b-bbaa--aaaa  ',
-      '      ccccbbbbbbaaaa',
-    ].join('\n'));
-  });
-
-  it('highlights traces when hovering', async () => {
-    // Hover over first trace. (X coordinate does not matter.)
-    await hoverOverDot(dotsSk, 0, 0);
-    expect(canvasToAscii(dotsSk)).to.equal([
-      'IIHGFDDEEDDDDCCBBBAA',
-      '   bb-b-bbaa--aaaa  ',
-      '      ccccbbbbbbaaaa',
-    ].join('\n'));
-
-    // Hover over second trace.
-    await hoverOverDot(dotsSk, 15, 1);
-    expect(canvasToAscii(dotsSk)).to.equal([
-      'iihgfddeeddddccbbbaa',
-      '   BB-B-BBAA--AAAA  ',
-      '      ccccbbbbbbaaaa',
-    ].join('\n'));
-
-    // Hover over third trace.
-    await hoverOverDot(dotsSk, 10, 2);
-    expect(canvasToAscii(dotsSk)).to.equal([
-      'iihgfddeeddddccbbbaa',
-      '   bb-b-bbaa--aaaa  ',
-      '      CCCCBBBBBBAAAA',
-    ].join('\n'));
-  });
-
-  it('emits "hover" event when a trace is hovered', async () => {
-    // Hover over first trace. (X coordinate does not matter.)
-    let event = await hoverOverDotAndCatchHoverEvent(dotsSk, 0, 0);
-    expect(event.detail).to.equal(',alpha=first-trace,beta=hello,gamma=world,');
-
-    // Hover over second trace.
-    event = await hoverOverDotAndCatchHoverEvent(dotsSk, 15, 1);
-    expect(event.detail).to.equal(',alpha=second-trace,beta=foo,gamma=bar,');
-
-    // Hover over third trace.
-    event = await hoverOverDotAndCatchHoverEvent(dotsSk, 10, 2);
-    expect(event.detail).to.equal(',alpha=third-trace,beta=baz,gamma=qux,');
-  });
-
-  it('emits "showblamelist" event when a dot is clicked', async () => {
-    // First trace, most recent commit.
-    let event = await clickDotAndCatchShowCommitsEvent(dotsSk, 19, 0);
-    expect(event.detail).to.deep.equal([commits[19], commits[18]]);
-
-    // First trace, middle-of-the-tile commit.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 10, 0);
-    expect(event.detail).to.deep.equal([commits[10], commits[9]]);
-
-    // First trace, oldest commit.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 0, 0);
-    expect(event.detail).to.deep.equal([commits[0]]);
-
-    // Second trace, most recent commit with data
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 17, 1);
-    expect(event.detail).to.deep.equal([commits[17], commits[16]]);
-
-    // Second trace, middle-of-the-tile dot preceded by two missing dots.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 14, 1);
-    expect(event.detail).to.deep.equal([commits[14], commits[13], commits[12], commits[11]]);
-
-    // Second trace, oldest commit with data preceded by three missing dots.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 3, 1);
-    expect(event.detail).to.deep.equal(
-      [commits[3], commits[2], commits[1], commits[0]],
-    );
-
-    // Third trace, most recent commit.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 19, 2);
-    expect(event.detail).to.deep.equal([commits[19], commits[18]]);
-
-    // Third trace, middle-of-the-tile commit.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 10, 2);
-    expect(event.detail).to.deep.equal([commits[10], commits[9]]);
-
-    // Third trace, oldest commit.
-    event = await clickDotAndCatchShowCommitsEvent(dotsSk, 6, 2);
-    expect(event.detail).to.deep.equal([
-      commits[6],
-      commits[5],
-      commits[4],
-      commits[3],
-      commits[2],
-      commits[1],
-      commits[0],
-    ]);
-  });
-});
-
-// Returns an ASCII-art representation of the canvas based on function
-// dotToAscii.
-function canvasToAscii(dotsSk) {
-  const ascii = [];
-  for (let y = 0; y < traces.traces.length; y++) {
-    const trace = [];
-    for (let x = 0; x < traces.tileSize; x++) {
-      trace.push(dotToAscii(dotsSk, x, y));
-    }
-    ascii.push(trace.join(''));
-  }
-  return ascii.join('\n');
-}
-
-// Returns a character representing the dot at (x, y) in dotspace.
-//   - A trace line is represented with '-'.
-//   - A non-highlighted dot is represented with a character in {'a', 'b', ...},
-//     where 'a' represents the dot color for the most recent commit.
-//   - A highlighted dot is represented with a character in {'A', 'B', ...}.
-//   - A blank position is represented with ' '.
-function dotToAscii(dotsSk, x, y) {
-  const canvasX = dotToCanvasX(x);
-  const canvasY = dotToCanvasY(y);
-
-  // Sample a few pixels (north, east, south, west, center) from the bounding
-  // box for the potential dot at (x, y). We'll use these to determine whether
-  // there's a dot or a trace line at (x, y), what the color of the dot is,
-  // whether or not it's highlighted, etc.
-  const n = pixelAt(dotsSk, canvasX, canvasY - DOT_RADIUS);
-  const e = pixelAt(dotsSk, canvasX + DOT_RADIUS, canvasY);
-  const s = pixelAt(dotsSk, canvasX, canvasY + DOT_RADIUS);
-  const w = pixelAt(dotsSk, canvasX - DOT_RADIUS, canvasY);
-  const c = pixelAt(dotsSk, canvasX, canvasY);
-
-  // Determines whether the sampled pixels match the given expected colors.
-  const exactColorMatch = (en, ee, es, ew, ec) => {
-    return [n, e, s, w, c].toString() === [en, ee, es, ew, ec].toString();
-  };
-
-  // Is it empty?
-  const white = '#FFFFFF';
-  if (exactColorMatch(white, white, white, white, white)) {
-    return ' ';
-  }
-
-  // Is it a trace line?
-  if (exactColorMatch(white, TRACE_LINE_COLOR, white, TRACE_LINE_COLOR, TRACE_LINE_COLOR)) {
-    return '-';
-  }
-
-  // Iterate over all possible dot colors.
-  for (let i = 0; i <= MAX_UNIQUE_DIGESTS; i++) {
-    // Is it a dot of the i-th color? Let's look at the pixels in the potential
-    // circumference of the dot. Do they match the current color?
-    // Note: we look for the closest match instead of an exact match due to
-    // canvas anti-aliasing.
-    if (closestColor(n, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
-        && closestColor(e, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
-        && closestColor(s, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
-        && closestColor(w, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]) {
-      // Is it a non-highlighted dot? (In other words, is it filled with the
-      // corresponding non-highlighted color?)
-      if (c === DOT_FILL_COLORS[i]) {
-        return 'abcdefghijklmnopqrstuvwxyz'[i];
-      }
-
-      // Is it a highlighted dot? (In other words, is it filled with the
-      // corresponding highlighted color?)
-      if (c === DOT_FILL_COLORS_HIGHLIGHTED[i]) {
-        return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i];
-      }
-    }
-  }
-
-  throw `unrecognized dot at (${x}, ${y})`;
-}
-
-// Returns the color for the pixel at (x, y) in the canvas, represented as a hex
-// string, e.g. "#AABBCC".
-function pixelAt(dotsSk, x, y) {
-  const pixel = dotsSk._ctx.getImageData(x, y, 1, 1).data;
-  const r = pixel[0].toString(16).padStart(2, '0');
-  const g = pixel[1].toString(16).padStart(2, '0');
-  const b = pixel[2].toString(16).padStart(2, '0');
-  return `#${r}${g}${b}`.toUpperCase();
-}
-
-// Finds the color in the haystack with the minimum Euclidean distance to the
-// needle. This is necessary for pixels in the circumference of a dot due to
-// canvas anti-aliasing. All colors are hex strings, e.g. "#AABBCC".
-function closestColor(needle, haystack) {
-  return haystack
-    .map((color) => ({ color: color, dist: euclideanDistanceSq(needle, color) }))
-    .reduce((acc, cur) => ((acc.dist < cur.dist) ? acc : cur))
-    .color;
-}
-
-// Takes two colors represented as hex strings (e.g. "#AABBCC") and computes the
-// squared Euclidean distance between them.
-function euclideanDistanceSq(color1, color2) {
-  const rgb1 = hexToRgb(color1);
-  const rgb2 = hexToRgb(color2);
-  return (rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2;
-}
-
-// Takes e.g. "#FF8000" and returns [256, 128, 0].
-function hexToRgb(hex) {
-  // Borrowed from https://stackoverflow.com/a/5624139.
-  const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
-  return [
-    parseInt(res[1], 16),
-    parseInt(res[2], 16),
-    parseInt(res[3], 16),
-  ];
-}
-
-// Returns a promise that will resolve when the given dots-sk instance emits the
-// given event. The promise resolves to the caught event object.
-function dotsSkEventPromise(dotsSk, event) {
-  let resolve;
-  const promise = new Promise((_resolve) => { resolve = _resolve; });
-  const handler = (e) => {
-    dotsSk.removeEventListener(event, handler);
-    resolve(e);
-  };
-  dotsSk.addEventListener(event, handler);
-  return promise;
-}
-
-// Simulate hovering over a dot.
-async function hoverOverDot(dotsSk, x, y) {
-  dotsSk._canvas.dispatchEvent(new MouseEvent('mousemove', {
-    clientX: dotsSk._canvas.getBoundingClientRect().left + dotToCanvasX(x),
-    clientY: dotsSk._canvas.getBoundingClientRect().top + dotToCanvasY(y),
-  }));
-
-  // Give mousemove event a chance to be processed. Necessary due to how
-  // mousemove events are processed in batches by dots-sk every 40 ms.
-  await new Promise((resolve) => setTimeout(resolve, 50));
-}
-
-// Simulate hovering over a dot, and return the "hover" CustomEvent emitted by
-// the dots-sk instance.
-async function hoverOverDotAndCatchHoverEvent(dotsSk, x, y) {
-  const eventPromise = dotsSkEventPromise(dotsSk, 'hover');
-  await hoverOverDot(dotsSk, x, y);
-  return eventPromise;
-}
-
-// Simulate clicking on a dot.
-function clickDot(dotsSk, x, y) {
-  dotsSk._canvas.dispatchEvent(new MouseEvent('click', {
-    clientX: dotsSk._canvas.getBoundingClientRect().left + dotToCanvasX(x),
-    clientY: dotsSk._canvas.getBoundingClientRect().top + dotToCanvasY(y),
-  }));
-}
-
-// Simulate clicking on a dot, and return the "showblamelist" CustomElement
-// emitted by the dots-sk instance.
-async function clickDotAndCatchShowCommitsEvent(dotsSk, x, y) {
-  const eventPromise = dotsSkEventPromise(dotsSk, 'showblamelist');
-  clickDot(dotsSk, x, y);
-  return eventPromise;
-}
diff --git a/golden/modules/dots-sk/dots-sk_test.ts b/golden/modules/dots-sk/dots-sk_test.ts
new file mode 100644
index 0000000..a730d7d
--- /dev/null
+++ b/golden/modules/dots-sk/dots-sk_test.ts
@@ -0,0 +1,301 @@
+import './index';
+import { DotsSk } from './dots-sk';
+import { commits, traces } from './demo_data';
+import {
+  dotToCanvasX,
+  dotToCanvasY,
+  DOT_FILL_COLORS,
+  DOT_FILL_COLORS_HIGHLIGHTED,
+  DOT_RADIUS,
+  DOT_STROKE_COLORS,
+  MAX_UNIQUE_DIGESTS,
+  TRACE_LINE_COLOR,
+} from './constants';
+import { eventPromise, setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
+import { expect } from 'chai';
+import { Commit } from '../rpc_types';
+
+describe('dots-sk constants', () => {
+  it('DOT_FILL_COLORS has the expected number of entries', () => {
+    expect(DOT_FILL_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
+  });
+
+  it('DOT_FILL_COLORS_HIGHLIGHTED has the expected number of entries', () => {
+    expect(DOT_FILL_COLORS_HIGHLIGHTED).to.have.length(MAX_UNIQUE_DIGESTS);
+  });
+
+  it('DOT_STROKE_COLORS has the expected number of entries', () => {
+    expect(DOT_STROKE_COLORS).to.have.length(MAX_UNIQUE_DIGESTS);
+  });
+});
+
+describe('dots-sk', () => {
+  const newInstance = setUpElementUnderTest<DotsSk>('dots-sk');
+
+  let dotsSk: DotsSk;
+  let dotsSkCanvas: HTMLCanvasElement;
+  let dotsSkCanvasCtx: CanvasRenderingContext2D;
+
+  beforeEach(() => {
+    dotsSk = newInstance((el) => {
+      // All test cases use the same set of traces and commits.
+      el.value = traces;
+      el.commits = commits;
+    });
+    dotsSkCanvas = dotsSk.querySelector('canvas')!;
+    dotsSkCanvasCtx = dotsSkCanvas.getContext('2d')!;
+  });
+
+  it('renders correctly', () => {
+    expect(dotsSkCanvas.clientWidth).to.equal(210);
+    expect(dotsSkCanvas.clientHeight).to.equal(40);
+    // We specify the traces as an array and then join them instead of using a string literal
+    // to avoid having invisible (but important to the test) trailing spaces.
+    expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+      'iihgfddeeddddccbbbaa',
+      '   bb-b-bbaa--aaaa  ',
+      '      ccccbbbbbbaaaa',
+    ].join('\n'));
+  });
+
+  it('highlights traces when hovering', async () => {
+    // Hover over first trace. (X coordinate does not matter.)
+    await hoverOverDot(dotsSkCanvas, 0, 0);
+    expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+      'IIHGFDDEEDDDDCCBBBAA',
+      '   bb-b-bbaa--aaaa  ',
+      '      ccccbbbbbbaaaa',
+    ].join('\n'));
+
+    // Hover over second trace.
+    await hoverOverDot(dotsSkCanvas, 15, 1);
+    expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+      'iihgfddeeddddccbbbaa',
+      '   BB-B-BBAA--AAAA  ',
+      '      ccccbbbbbbaaaa',
+    ].join('\n'));
+
+    // Hover over third trace.
+    await hoverOverDot(dotsSkCanvas, 10, 2);
+    expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([
+      'iihgfddeeddddccbbbaa',
+      '   bb-b-bbaa--aaaa  ',
+      '      CCCCBBBBBBAAAA',
+    ].join('\n'));
+  });
+
+  it('emits "hover" event when a trace is hovered', async () => {
+    // Hover over first trace. (X coordinate does not matter.)
+    let traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 0, 0);
+    expect(traceLabel).to.equal(',alpha=first-trace,beta=hello,gamma=world,');
+
+    // Hover over second trace.
+    traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 15, 1);
+    expect(traceLabel).to.equal(',alpha=second-trace,beta=foo,gamma=bar,');
+
+    // Hover over third trace.
+    traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 10, 2);
+    expect(traceLabel).to.equal(',alpha=third-trace,beta=baz,gamma=qux,');
+  });
+
+  it('emits "showblamelist" event when a dot is clicked', async () => {
+    // First trace, most recent commit.
+    let dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 0);
+    expect(dotCommits).to.deep.equal([commits[19], commits[18]]);
+
+    // First trace, middle-of-the-tile commit.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 0);
+    expect(dotCommits).to.deep.equal([commits[10], commits[9]]);
+
+    // First trace, oldest commit.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 0, 0);
+    expect(dotCommits).to.deep.equal([commits[0]]);
+
+    // Second trace, most recent commit with data
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 17, 1);
+    expect(dotCommits).to.deep.equal([commits[17], commits[16]]);
+
+    // Second trace, middle-of-the-tile dot preceded by two missing dots.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 14, 1);
+    expect(dotCommits).to.deep.equal([commits[14], commits[13], commits[12], commits[11]]);
+
+    // Second trace, oldest commit with data preceded by three missing dots.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 3, 1);
+    expect(dotCommits).to.deep.equal(
+      [commits[3], commits[2], commits[1], commits[0]],
+    );
+
+    // Third trace, most recent commit.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 2);
+    expect(dotCommits).to.deep.equal([commits[19], commits[18]]);
+
+    // Third trace, middle-of-the-tile commit.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 2);
+    expect(dotCommits).to.deep.equal([commits[10], commits[9]]);
+
+    // Third trace, oldest commit.
+    dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 6, 2);
+    expect(dotCommits).to.deep.equal([
+      commits[6],
+      commits[5],
+      commits[4],
+      commits[3],
+      commits[2],
+      commits[1],
+      commits[0],
+    ]);
+  });
+});
+
+// Returns an ASCII-art representation of the canvas based on function
+// dotToAscii.
+function canvasToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D): string {
+  const ascii = [];
+  for (let y = 0; y < traces.traces!.length; y++) {
+    const trace = [];
+    for (let x = 0; x < traces.tileSize; x++) {
+      trace.push(dotToAscii(dotsSkCanvasCtx, x, y));
+    }
+    ascii.push(trace.join(''));
+  }
+  return ascii.join('\n');
+}
+
+// Returns a character representing the dot at (x, y) in dotspace.
+//   - A trace line is represented with '-'.
+//   - A non-highlighted dot is represented with a character in {'a', 'b', ...},
+//     where 'a' represents the dot color for the most recent commit.
+//   - A highlighted dot is represented with a character in {'A', 'B', ...}.
+//   - A blank position is represented with ' '.
+function dotToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string {
+  const canvasX = dotToCanvasX(x);
+  const canvasY = dotToCanvasY(y);
+
+  // Sample a few pixels (north, east, south, west, center) from the bounding
+  // box for the potential dot at (x, y). We'll use these to determine whether
+  // there's a dot or a trace line at (x, y), what the color of the dot is,
+  // whether or not it's highlighted, etc.
+  const n = pixelAt(dotsSkCanvasCtx, canvasX, canvasY - DOT_RADIUS);
+  const e = pixelAt(dotsSkCanvasCtx, canvasX + DOT_RADIUS, canvasY);
+  const s = pixelAt(dotsSkCanvasCtx, canvasX, canvasY + DOT_RADIUS);
+  const w = pixelAt(dotsSkCanvasCtx, canvasX - DOT_RADIUS, canvasY);
+  const c = pixelAt(dotsSkCanvasCtx, canvasX, canvasY);
+
+  // Determines whether the sampled pixels match the given expected colors.
+  const exactColorMatch = (en: string, ee: string, es: string, ew: string, ec: string) => {
+    return [n, e, s, w, c].toString() === [en, ee, es, ew, ec].toString();
+  };
+
+  // Is it empty?
+  const white = '#FFFFFF';
+  if (exactColorMatch(white, white, white, white, white)) {
+    return ' ';
+  }
+
+  // Is it a trace line?
+  if (exactColorMatch(white, TRACE_LINE_COLOR, white, TRACE_LINE_COLOR, TRACE_LINE_COLOR)) {
+    return '-';
+  }
+
+  // Iterate over all possible dot colors.
+  for (let i = 0; i <= MAX_UNIQUE_DIGESTS; i++) {
+    // Is it a dot of the i-th color? Let's look at the pixels in the potential
+    // circumference of the dot. Do they match the current color?
+    // Note: we look for the closest match instead of an exact match due to
+    // canvas anti-aliasing.
+    if (closestColor(n, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+        && closestColor(e, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+        && closestColor(s, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]
+        && closestColor(w, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]) {
+      // Is it a non-highlighted dot? (In other words, is it filled with the
+      // corresponding non-highlighted color?)
+      if (c === DOT_FILL_COLORS[i]) {
+        return 'abcdefghijklmnopqrstuvwxyz'[i];
+      }
+
+      // Is it a highlighted dot? (In other words, is it filled with the
+      // corresponding highlighted color?)
+      if (c === DOT_FILL_COLORS_HIGHLIGHTED[i]) {
+        return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i];
+      }
+    }
+  }
+
+  throw `unrecognized dot at (${x}, ${y})`;
+}
+
+// Returns the color for the pixel at (x, y) in the canvas, represented as a hex
+// string, e.g. "#AABBCC".
+function pixelAt(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string {
+  const pixel = dotsSkCanvasCtx.getImageData(x, y, 1, 1).data;
+  const r = pixel[0].toString(16).padStart(2, '0');
+  const g = pixel[1].toString(16).padStart(2, '0');
+  const b = pixel[2].toString(16).padStart(2, '0');
+  return `#${r}${g}${b}`.toUpperCase();
+}
+
+// Finds the color in the haystack with the minimum Euclidean distance to the
+// needle. This is necessary for pixels in the circumference of a dot due to
+// canvas anti-aliasing. All colors are hex strings, e.g. "#AABBCC".
+function closestColor(needle: string, haystack: string[]): string {
+  return haystack
+    .map((color) => ({ color: color, dist: euclideanDistanceSq(needle, color) }))
+    .reduce((acc, cur) => ((acc.dist < cur.dist) ? acc : cur))
+    .color;
+}
+
+// Takes two colors represented as hex strings (e.g. "#AABBCC") and computes the
+// squared Euclidean distance between them.
+function euclideanDistanceSq(color1: string, color2: string): number {
+  const rgb1 = hexToRgb(color1);
+  const rgb2 = hexToRgb(color2);
+  return (rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2;
+}
+
+// Takes e.g. "#FF8000" and returns [256, 128, 0].
+function hexToRgb(hex: string): [number, number, number] {
+  // Borrowed from https://stackoverflow.com/a/5624139.
+  const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+  return [
+    parseInt(res[1], 16),
+    parseInt(res[2], 16),
+    parseInt(res[3], 16),
+  ];
+}
+
+// Simulate hovering over a dot.
+async function hoverOverDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) {
+  dotsSkCanvas.dispatchEvent(new MouseEvent('mousemove', {
+    clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x),
+    clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y),
+  }));
+
+  // Give mousemove event a chance to be processed. Necessary due to how
+  // mousemove events are processed in batches by dots-sk every 40 ms.
+  await new Promise((resolve) => setTimeout(resolve, 50));
+}
+
+// Simulate hovering over a dot, and return the trace label in the "hover" event details.
+async function hoverOverDotAndCatchHoverEvent(
+    dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<string> {
+  // const eventPromise = dotsSkEventPromise(dotsSk, 'hover');
+  const event = eventPromise<CustomEvent<string>>('hover');
+  await hoverOverDot(dotsSkCanvas, x, y);
+  return (await event).detail;
+}
+
+// Simulate clicking on a dot.
+function clickDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) {
+  dotsSkCanvas.dispatchEvent(new MouseEvent('click', {
+    clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x),
+    clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y),
+  }));
+}
+
+// Simulate clicking on a dot, and return the list of commits in the "showblamelist" event details.
+async function clickDotAndCatchShowBlamelistEvent(
+    dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<Commit[]> {
+  const event = eventPromise<CustomEvent<Commit[]>>('showblamelist');
+  clickDot(dotsSkCanvas, x, y);
+  return (await event).detail;
+}
diff --git a/golden/modules/dots-sk/index.js b/golden/modules/dots-sk/index.ts
similarity index 100%
rename from golden/modules/dots-sk/index.js
rename to golden/modules/dots-sk/index.ts