Bug 1105825 - 4 - Adds a simple animation inspector panel to the inspector sidebar; r=bgrins r=vporof
authorPatrick Brosset <pbrosset@mozilla.com>
Sat, 10 Jan 2015 19:51:46 +0100
changeset 249082 35144551829c6ae3d8784885c0db2ba5554da614
parent 249081 814e685b77e19a8782667d5168df20052af14752
child 249083 2c8cdce4fed4dadf98f1ef32ced6f6518a400478
push id4489
push userraliiev@mozilla.com
push dateMon, 23 Feb 2015 15:17:55 +0000
treeherdermozilla-beta@fd7c3dc24146 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins, vporof
bugs1105825
milestone37.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1105825 - 4 - Adds a simple animation inspector panel to the inspector sidebar; r=bgrins r=vporof
browser/devtools/animationinspector/animation-controller.js
browser/devtools/animationinspector/animation-inspector.xhtml
browser/devtools/animationinspector/animation-panel.js
browser/devtools/animationinspector/moz.build
browser/devtools/animationinspector/test/browser.ini
browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js
browser/devtools/animationinspector/test/browser_animation_panel_exists.js
browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js
browser/devtools/animationinspector/test/browser_animation_play_pause_button.js
browser/devtools/animationinspector/test/browser_animation_playerFronts_are_refreshed.js
browser/devtools/animationinspector/test/browser_animation_playerWidgets_destroy.js
browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js
browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js
browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js
browser/devtools/animationinspector/test/browser_animation_timeline_animates.js
browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_changes.js
browser/devtools/animationinspector/test/doc_frame_script.js
browser/devtools/animationinspector/test/doc_simple_animation.html
browser/devtools/animationinspector/test/head.js
browser/devtools/inspector/inspector-panel.js
browser/devtools/jar.mn
browser/devtools/moz.build
browser/devtools/shared/telemetry.js
browser/devtools/shared/test/browser_telemetry_sidebar.js
browser/locales/en-US/chrome/browser/devtools/animationinspector.dtd
browser/locales/en-US/chrome/browser/devtools/animationinspector.properties
browser/locales/jar.mn
browser/themes/linux/jar.mn
browser/themes/osx/jar.mn
browser/themes/shared/devtools/animationinspector.css
browser/themes/shared/devtools/toolbars.inc.css
browser/themes/windows/jar.mn
toolkit/components/telemetry/Histograms.json
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/animation-controller.js
@@ -0,0 +1,223 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/devtools/Loader.jsm");
+Cu.import("resource://gre/modules/devtools/Console.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+
+devtools.lazyRequireGetter(this, "promise");
+devtools.lazyRequireGetter(this, "EventEmitter",
+                                 "devtools/toolkit/event-emitter");
+devtools.lazyRequireGetter(this, "AnimationsFront",
+                                 "devtools/server/actors/animation", true);
+
+const require = devtools.require;
+
+const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
+const L10N = new ViewHelpers.L10N(STRINGS_URI);
+
+// Global toolbox/inspector, set when startup is called.
+let gToolbox, gInspector;
+
+/**
+ * Startup the animationinspector controller and view, called by the sidebar
+ * widget when loading/unloading the iframe into the tab.
+ */
+let startup = Task.async(function*(inspector) {
+  gInspector = inspector;
+  gToolbox = inspector.toolbox;
+
+  // Don't assume that AnimationsPanel is defined here, it's in another file.
+  if (!typeof AnimationsPanel === "undefined") {
+    throw new Error("AnimationsPanel was not loaded in the animationinspector window");
+  }
+
+  yield promise.all([
+    AnimationsController.initialize(),
+    AnimationsPanel.initialize()
+  ]).then(null, Cu.reportError);
+});
+
+/**
+ * Shutdown the animationinspector controller and view, called by the sidebar
+ * widget when loading/unloading the iframe into the tab.
+ */
+let shutdown = Task.async(function*() {
+  yield promise.all([
+    AnimationsController.destroy(),
+    // Don't assume that AnimationsPanel is defined here, it's in another file.
+    typeof AnimationsPanel !== "undefined"
+      ? AnimationsPanel.destroy()
+      : promise.resolve()
+  ]).then(() => {
+    gToolbox = gInspector = null;
+  }, Cu.reportError);
+});
+
+// This is what makes the sidebar widget able to load/unload the panel.
+function setPanel(panel) {
+  return startup(panel);
+}
+function destroy() {
+  return shutdown();
+}
+
+/**
+ * The animationinspector controller's job is to retrieve AnimationPlayerFronts
+ * from the server. It is also responsible for keeping the list of players up to
+ * date when the node selection changes in the inspector, as well as making sure
+ * no updates are done when the animationinspector sidebar panel is not visible.
+ *
+ * AnimationPlayerFronts are available in AnimationsController.animationPlayers.
+ *
+ * Note also that all AnimationPlayerFronts handled by the controller are set to
+ * auto-refresh (except when the sidebar panel is not visible).
+ *
+ * Usage example:
+ *
+ * AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT, onPlayers);
+ * function onPlayers() {
+ *   for (let player of AnimationsController.animationPlayers) {
+ *     // do something with player
+ *   }
+ * }
+ */
+let AnimationsController = {
+  PLAYERS_UPDATED_EVENT: "players-updated",
+
+  initialize: Task.async(function*() {
+    if (this.initialized) {
+      return this.initialized.promise;
+    }
+    this.initialized = promise.defer();
+
+    let target = gToolbox.target;
+    this.animationsFront = new AnimationsFront(target.client, target.form);
+
+    this.onPanelVisibilityChange = this.onPanelVisibilityChange.bind(this);
+    this.onNewNodeFront = this.onNewNodeFront.bind(this);
+
+    this.startListeners();
+
+    yield this.onNewNodeFront();
+
+    this.initialized.resolve();
+  }),
+
+  destroy: Task.async(function*() {
+    if (!this.initialized) {
+      return;
+    }
+
+    if (this.destroyed) {
+      return this.destroyed.promise;
+    }
+    this.destroyed = promise.defer();
+
+    this.stopListeners();
+    yield this.destroyAnimationPlayers();
+    this.nodeFront = null;
+
+    if (this.animationsFront) {
+      this.animationsFront.destroy();
+      this.animationsFront = null;
+    }
+
+    this.destroyed.resolve();
+  }),
+
+  startListeners: function() {
+    // Re-create the list of players when a new node is selected, except if the
+    // sidebar isn't visible. And set the players to auto-refresh when needed.
+    gInspector.selection.on("new-node-front", this.onNewNodeFront);
+    gInspector.sidebar.on("select", this.onPanelVisibilityChange);
+    gToolbox.on("select", this.onPanelVisibilityChange);
+  },
+
+  stopListeners: function() {
+    gInspector.selection.off("new-node-front", this.onNewNodeFront);
+    gInspector.sidebar.off("select", this.onPanelVisibilityChange);
+    gToolbox.off("select", this.onPanelVisibilityChange);
+  },
+
+  isPanelVisible: function() {
+    return gToolbox.currentToolId === "inspector" &&
+           gInspector.sidebar &&
+           gInspector.sidebar.getCurrentTabID() == "animationinspector";
+  },
+
+  onPanelVisibilityChange: Task.async(function*(e, id) {
+    if (this.isPanelVisible()) {
+      this.onNewNodeFront();
+      this.startAllAutoRefresh();
+    } else {
+      this.stopAllAutoRefresh();
+    }
+  }),
+
+  onNewNodeFront: Task.async(function*() {
+    // Ignore if the panel isn't visible or the node selection hasn't changed.
+    if (!this.isPanelVisible() || this.nodeFront === gInspector.selection.nodeFront) {
+      return;
+    }
+
+    let done = gInspector.updating("animationscontroller");
+
+    if(!gInspector.selection.isConnected() ||
+       !gInspector.selection.isElementNode()) {
+      yield this.destroyAnimationPlayers();
+      this.emit(this.PLAYERS_UPDATED_EVENT);
+      done();
+      return;
+    }
+
+    this.nodeFront = gInspector.selection.nodeFront;
+    yield this.refreshAnimationPlayers(this.nodeFront);
+    this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers);
+
+    done();
+  }),
+
+  // AnimationPlayerFront objects are managed by this controller. They are
+  // retrieved when refreshAnimationPlayers is called, stored in the
+  // animationPlayers array, and destroyed when refreshAnimationPlayers is
+  // called again.
+  animationPlayers: [],
+
+  refreshAnimationPlayers: Task.async(function*(nodeFront) {
+    yield this.destroyAnimationPlayers();
+
+    this.animationPlayers = yield this.animationsFront.getAnimationPlayersForNode(nodeFront);
+    this.startAllAutoRefresh();
+  }),
+
+  startAllAutoRefresh: function() {
+    for (let front of this.animationPlayers) {
+      front.startAutoRefresh();
+    }
+  },
+
+  stopAllAutoRefresh: function() {
+    for (let front of this.animationPlayers) {
+      front.stopAutoRefresh();
+    }
+  },
+
+  destroyAnimationPlayers: Task.async(function*() {
+    this.stopAllAutoRefresh();
+    for (let front of this.animationPlayers) {
+      yield front.release();
+    }
+    this.animationPlayers = [];
+  })
+};
+
+EventEmitter.decorate(AnimationsController);
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/animation-inspector.xhtml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html [
+<!ENTITY % animationinspectorDTD SYSTEM "chrome://browser/locale/devtools/animationinspector.dtd" >
+ %animationinspectorDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml">
+  <head>
+    <title>&title;</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
+    <link rel="stylesheet" href="chrome://browser/skin/devtools/common.css" type="text/css"/>
+    <link rel="stylesheet" href="chrome://browser/skin/devtools/animationinspector.css" type="text/css"/>
+    <script type="application/javascript;version=1.8" src="chrome://browser/content/devtools/theme-switching.js"/>
+  </head>
+  <body class="theme-sidebar devtools-monospace" role="application">
+    <div id="players" class="theme-toolbar"></div>
+    <div id="error-message">
+      <p>&invalidElement;</p>
+      <p>&selectElement;</p>
+      <button id="element-picker" standalone="true" class="devtools-button"></button>
+    </div>
+    <script type="application/javascript;version=1.8" src="animation-controller.js"></script>
+    <script type="application/javascript;version=1.8" src="animation-panel.js"></script>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/animation-panel.js
@@ -0,0 +1,432 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * The main animations panel UI.
+ */
+let AnimationsPanel = {
+  UI_UPDATED_EVENT: "ui-updated",
+
+  initialize: Task.async(function*() {
+    if (this.initialized) {
+      return this.initialized.promise;
+    }
+    this.initialized = promise.defer();
+
+    this.playersEl = document.querySelector("#players");
+    this.errorMessageEl = document.querySelector("#error-message");
+    this.pickerButtonEl = document.querySelector("#element-picker");
+
+    let hUtils = gToolbox.highlighterUtils;
+    this.togglePicker = hUtils.togglePicker.bind(hUtils);
+    this.onPickerStarted = this.onPickerStarted.bind(this);
+    this.onPickerStopped = this.onPickerStopped.bind(this);
+    this.createPlayerWidgets = this.createPlayerWidgets.bind(this);
+
+    this.startListeners();
+
+    this.initialized.resolve();
+  }),
+
+  destroy: Task.async(function*() {
+    if (!this.initialized) {
+      return;
+    }
+
+    if (this.destroyed) {
+      return this.destroyed.promise;
+    }
+    this.destroyed = promise.defer();
+
+    this.stopListeners();
+    yield this.destroyPlayerWidgets();
+
+    this.playersEl = this.errorMessageEl = null;
+
+    this.destroyed.resolve();
+  }),
+
+  startListeners: function() {
+    AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT,
+      this.createPlayerWidgets);
+    this.pickerButtonEl.addEventListener("click", this.togglePicker, false);
+    gToolbox.on("picker-started", this.onPickerStarted);
+    gToolbox.on("picker-stopped", this.onPickerStopped);
+  },
+
+  stopListeners: function() {
+    AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT,
+      this.createPlayerWidgets);
+    this.pickerButtonEl.removeEventListener("click", this.togglePicker, false);
+    gToolbox.off("picker-started", this.onPickerStarted);
+    gToolbox.off("picker-stopped", this.onPickerStopped);
+  },
+
+  displayErrorMessage: function() {
+    this.errorMessageEl.style.display = "block";
+  },
+
+  hideErrorMessage: function() {
+    this.errorMessageEl.style.display = "none";
+  },
+
+  onPickerStarted: function() {
+    this.pickerButtonEl.setAttribute("checked", "true");
+  },
+
+  onPickerStopped: function() {
+    this.pickerButtonEl.removeAttribute("checked");
+  },
+
+  createPlayerWidgets: Task.async(function*() {
+    let done = gInspector.updating("animationspanel");
+
+    // Empty the whole panel first.
+    this.hideErrorMessage();
+    yield this.destroyPlayerWidgets();
+
+    // If there are no players to show, show the error message instead and return.
+    if (!AnimationsController.animationPlayers.length) {
+      this.displayErrorMessage();
+      this.emit(this.UI_UPDATED_EVENT);
+      done();
+      return;
+    }
+
+    // Otherwise, create player widgets.
+    this.playerWidgets = [];
+    let initPromises = [];
+
+    for (let player of AnimationsController.animationPlayers) {
+      let widget = new PlayerWidget(player, this.playersEl);
+      initPromises.push(widget.initialize());
+      this.playerWidgets.push(widget);
+    }
+
+    yield initPromises;
+    this.emit(this.UI_UPDATED_EVENT);
+    done();
+  }),
+
+  destroyPlayerWidgets: Task.async(function*() {
+    if (!this.playerWidgets) {
+      return;
+    }
+
+    let destroyers = this.playerWidgets.map(widget => widget.destroy());
+    yield promise.all(destroyers);
+    this.playerWidgets = null;
+    this.playersEl.innerHTML = "";
+  })
+};
+
+EventEmitter.decorate(AnimationsPanel);
+
+/**
+ * An AnimationPlayer UI widget
+ */
+function PlayerWidget(player, containerEl) {
+  EventEmitter.decorate(this);
+
+  this.player = player;
+  this.containerEl = containerEl;
+
+  this.onStateChanged = this.onStateChanged.bind(this);
+  this.onPlayPauseBtnClick = this.onPlayPauseBtnClick.bind(this);
+}
+
+PlayerWidget.prototype = {
+  initialize: Task.async(function*() {
+    if (this.initialized) {
+      return;
+    }
+    this.initialized = true;
+
+    this.createMarkup();
+    this.startListeners();
+  }),
+
+  destroy: Task.async(function*() {
+    if (this.destroyed) {
+      return;
+    }
+    this.destroyed = true;
+
+    this.stopTimelineAnimation();
+    this.stopListeners();
+
+    this.el.remove();
+    this.playPauseBtnEl = this.currentTimeEl = this.timeDisplayEl = null;
+    this.containerEl = this.el = this.player = null;
+  }),
+
+  startListeners: function() {
+    this.player.on(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
+    this.playPauseBtnEl.addEventListener("click", this.onPlayPauseBtnClick);
+  },
+
+  stopListeners: function() {
+    this.player.off(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
+    this.playPauseBtnEl.removeEventListener("click", this.onPlayPauseBtnClick);
+  },
+
+  createMarkup: function() {
+    let state = this.player.state;
+
+    this.el = createNode({
+      attributes: {
+        "class": "player-widget " + state.playState
+      }
+    });
+
+    // Animation header
+    let titleEl = createNode({
+      parent: this.el,
+      attributes: {
+        "class": "animation-title"
+      }
+    });
+    let titleHTML = "";
+
+    // Name
+    if (state.name) {
+      // Css animations have names
+      titleHTML += L10N.getStr("player.animationNameLabel");
+      titleHTML += "<strong>" + state.name + "</strong>";
+    } else {
+      // Css transitions don't
+      titleHTML += L10N.getStr("player.transitionNameLabel");
+    }
+
+    // Duration and iteration count
+    titleHTML += "<span class='meta-data'>";
+    titleHTML += L10N.getStr("player.animationDurationLabel");
+    titleHTML += "<strong>" + L10N.getFormatStr("player.timeLabel",
+      this.getFormattedTime(state.duration)) + "</strong>";
+    titleHTML += L10N.getStr("player.animationIterationCountLabel");
+    let count = state.iterationCount || L10N.getStr("player.infiniteIterationCount");
+    titleHTML += "<strong>" + count + "</strong>";
+    titleHTML += "</span>"
+
+    titleEl.innerHTML = titleHTML;
+
+    // Timeline widget
+    let timelineEl = createNode({
+      parent: this.el,
+      attributes: {
+        "class": "timeline"
+      }
+    });
+
+    // Playback control buttons container
+    let playbackControlsEl = createNode({
+      parent: timelineEl,
+      attributes: {
+        "class": "playback-controls"
+      }
+    });
+
+    // Control buttons (when currentTime becomes settable, rewind and
+    // fast-forward can be added here).
+    this.playPauseBtnEl = createNode({
+      parent: playbackControlsEl,
+      nodeType: "button",
+      attributes: {
+        "class": "toggle devtools-button"
+      }
+    });
+
+    // Sliders container
+    let slidersContainerEl = createNode({
+      parent: timelineEl,
+      attributes: {
+        "class": "sliders-container",
+      }
+    });
+
+    let max = state.duration; // Infinite iterations
+    if (state.iterationCount) {
+      // Finite iterations
+      max = state.iterationCount * state.duration;
+    }
+
+    // For now, keyframes aren't exposed by the actor. So the only range <input>
+    // displayed in the container is the currentTime. When keyframes are
+    // available, one input per keyframe can be added here.
+    this.currentTimeEl = createNode({
+      nodeType: "input",
+      parent: slidersContainerEl,
+      attributes: {
+        "type": "range",
+        "class": "current-time",
+        "min": "0",
+        "max": max,
+        "step": "10",
+        // The currentTime isn't settable yet, so disable the timeline slider
+        "disabled": "true"
+      }
+    });
+
+    // Time display
+    this.timeDisplayEl = createNode({
+      parent: timelineEl,
+      attributes: {
+        "class": "time-display"
+      }
+    });
+    this.timeDisplayEl.textContent = L10N.getFormatStr("player.timeLabel",
+      this.getFormattedTime());
+
+    this.containerEl.appendChild(this.el);
+  },
+
+  /**
+   * Format time as a string.
+   * @param {Number} time Defaults to the player's currentTime.
+   * @return {String} The formatted time, e.g. "10.55"
+   */
+  getFormattedTime: function(time=this.player.state.currentTime) {
+    let str = time/1000 + "";
+    str = str.split(".");
+    if (str.length === 1) {
+      return str[0] + ".00";
+    } else {
+      return str[0] + "." + str[1].substring(0, 2);
+    }
+  },
+
+  /**
+   * Executed when the playPause button is clicked.
+   * Note that tests may want to call this callback directly rather than
+   * simulating a click on the button since it returns the promise returned by
+   * play and paused.
+   * @return {Promise}
+   */
+  onPlayPauseBtnClick: function() {
+    if (this.player.state.playState === "running") {
+      return this.pause();
+    } else {
+      return this.play();
+    }
+  },
+
+  /**
+   * Whenever a player state update is received.
+   */
+  onStateChanged: function() {
+    let state = this.player.state;
+    this.updatePlayPauseButton(state.playState);
+
+    switch (state.playState) {
+      case "finished":
+        this.destroy();
+        break;
+      case "running":
+        this.startTimelineAnimation();
+        break;
+      case "paused":
+        this.stopTimelineAnimation();
+        this.displayTime(this.player.state.currentTime);
+        break;
+    }
+  },
+
+  /**
+   * Pause the animation player via this widget.
+   * @return {Promise} Resolves when the player is paused, the button is
+   * switched to the right state, and the timeline animation is stopped.
+   */
+  pause: function() {
+    // Switch to the right className on the element right away to avoid waiting
+    // for the next state update to change the playPause icon.
+    this.updatePlayPauseButton("paused");
+    return this.player.pause().then(() => {
+      this.stopTimelineAnimation();
+    });
+  },
+
+  /**
+   * Play the animation player via this widget.
+   * @return {Promise} Resolves when the player is playing, the button is
+   * switched to the right state, and the timeline animation is started.
+   */
+  play: function() {
+    // Switch to the right className on the element right away to avoid waiting
+    // for the next state update to change the playPause icon.
+    this.updatePlayPauseButton("running");
+    this.startTimelineAnimation();
+    return this.player.play();
+  },
+
+  updatePlayPauseButton: function(playState) {
+    this.el.className = "player-widget " + playState;
+  },
+
+  /**
+   * Make the timeline progress smoothly, even though the currentTime is only
+   * updated at some intervals. This uses a local animation loop.
+   */
+  startTimelineAnimation: function() {
+    this.stopTimelineAnimation();
+
+    let start = performance.now();
+    let loop = () => {
+      this.rafID = requestAnimationFrame(loop);
+      let now = this.player.state.currentTime + performance.now() - start;
+      this.displayTime(now);
+    };
+
+    loop();
+  },
+
+  /**
+   * Display the time in the timeDisplayEl and in the currentTimeEl slider.
+   */
+  displayTime: function(time) {
+    let state = this.player.state;
+
+    this.timeDisplayEl.textContent = L10N.getFormatStr("player.timeLabel",
+      this.getFormattedTime(time));
+    if (!state.iterationCount && time !== state.duration) {
+      this.currentTimeEl.value = time % state.duration;
+    } else {
+      this.currentTimeEl.value = time;
+    }
+  },
+
+  /**
+   * Stop the animation loop that makes the timeline progress.
+   */
+  stopTimelineAnimation: function() {
+    if (this.rafID) {
+      cancelAnimationFrame(this.rafID);
+      this.rafID = null;
+    }
+  }
+};
+
+/**
+ * DOM node creation helper function.
+ * @param {Object} Options to customize the node to be created.
+ * @return {DOMNode} The newly created node.
+ */
+function createNode(options) {
+  let type = options.nodeType || "div";
+  let node = document.createElement(type);
+
+  for (let name in options.attributes || {}) {
+    let value = options.attributes[name];
+    node.setAttribute(name, value);
+  }
+
+  if (options.parent) {
+    options.parent.appendChild(node);
+  }
+
+  return node;
+}
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser.ini
@@ -0,0 +1,18 @@
+[DEFAULT]
+subsuite = devtools
+support-files =
+  doc_frame_script.js
+  doc_simple_animation.html
+  head.js
+
+[browser_animation_empty_on_invalid_nodes.js]
+[browser_animation_panel_exists.js]
+[browser_animation_participate_in_inspector_update.js]
+[browser_animation_play_pause_button.js]
+[browser_animation_playerFronts_are_refreshed.js]
+[browser_animation_playerWidgets_destroy.js]
+[browser_animation_refresh_when_active.js]
+[browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
+[browser_animation_shows_player_on_valid_node.js]
+[browser_animation_timeline_animates.js]
+[browser_animation_ui_updates_when_animation_changes.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js
@@ -0,0 +1,24 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the panel shows no animation data for invalid or not animated nodes
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Select node .still and check that the panel is empty");
+  let stillNode = yield getNodeFront(".still", inspector);
+  yield selectNode(stillNode, inspector);
+  ok(!panel.playerWidgets || !panel.playerWidgets.length,
+    "No player widgets displayed for a still node");
+
+  info("Select the comment text node and check that the panel is empty");
+  let commentNode = yield inspector.walker.previousSibling(stillNode);
+  yield selectNode(commentNode, inspector);
+  ok(!panel.playerWidgets || !panel.playerWidgets.length,
+    "No player widgets displayed for a text node");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_panel_exists.js
@@ -0,0 +1,18 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the animation panel sidebar exists
+
+add_task(function*() {
+  yield addTab("data:text/html;charset=utf-8,welcome to the animation panel");
+  let {panel, controller} = yield openAnimationInspector();
+
+  ok(controller, "The animation controller exists");
+  ok(controller.animationsFront, "The animation controller has been initialized");
+
+  ok(panel, "The animation panel exists");
+  ok(panel.playersEl, "The animation panel has been initialized");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js
@@ -0,0 +1,39 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the update of the animation panel participate in the
+// inspector-updated event. This means that the test verifies that the
+// inspector-updated event is emitted *after* the animation panel is ready.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel, controller} = yield openAnimationInspector();
+
+  info("Listen for the players-updated, ui-updated and inspector-updated events");
+  let receivedEvents = [];
+  controller.once(controller.PLAYERS_UPDATED_EVENT, () => {
+    receivedEvents.push(controller.PLAYERS_UPDATED_EVENT);
+  });
+  panel.once(panel.UI_UPDATED_EVENT, () => {
+    receivedEvents.push(panel.UI_UPDATED_EVENT);
+  })
+  inspector.once("inspector-updated", () => {
+    receivedEvents.push("inspector-updated");
+  });
+
+  info("Selecting an animated node");
+  let node = yield getNodeFront(".animated", inspector);
+  yield selectNode(node, inspector);
+
+  info("Check that all events were received, and in the right order");
+  is(receivedEvents.length, 3, "3 events were received");
+  is(receivedEvents[0], controller.PLAYERS_UPDATED_EVENT,
+    "The first event received was the players-updated event");
+  is(receivedEvents[1], panel.UI_UPDATED_EVENT,
+    "The second event received was the ui-updated event");
+  is(receivedEvents[2], "inspector-updated",
+    "The third event received was the inspector-updated event");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_play_pause_button.js
@@ -0,0 +1,32 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the play/pause button actually plays and pauses the player.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel, controller} = yield openAnimationInspector();
+
+  info("Selecting an animated node");
+  yield selectNode(".animated", inspector);
+
+  let player = controller.animationPlayers[0];
+  let widget = panel.playerWidgets[0];
+
+  info("Click the pause button");
+  yield togglePlayPauseButton(widget);
+
+  is(player.state.playState, "paused", "The AnimationPlayerFront is paused");
+  ok(widget.el.classList.contains("paused"), "The button's state has changed");
+  ok(!widget.rafID, "The smooth timeline animation has been stopped");
+
+  info("Click on the play button");
+  yield togglePlayPauseButton(widget);
+
+  is(player.state.playState, "running", "The AnimationPlayerFront is running");
+  ok(widget.el.classList.contains("running"), "The button's state has changed");
+  ok(widget.rafID, "The smooth timeline animation has been started");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_playerFronts_are_refreshed.js
@@ -0,0 +1,58 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the AnimationPlayerFront objects lifecycle is managed by the
+// AnimationController.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {controller, inspector} = yield openAnimationInspector();
+
+  is(controller.animationPlayers.length, 0,
+    "There are no AnimationPlayerFront objects at first");
+
+  info("Selecting an animated node");
+  // selectNode waits for the inspector-updated event before resolving, which
+  // means the controller.PLAYERS_UPDATED_EVENT event has been emitted before
+  // and players are ready.
+  yield selectNode(".animated", inspector);
+
+  is(controller.animationPlayers.length, 1,
+    "One AnimationPlayerFront has been created");
+  ok(controller.animationPlayers[0].autoRefreshTimer,
+    "The AnimationPlayerFront has been set to auto-refresh");
+
+  info("Selecting a node with mutliple animations");
+  yield selectNode(".multi", inspector);
+
+  is(controller.animationPlayers.length, 2,
+    "2 AnimationPlayerFronts have been created");
+  ok(controller.animationPlayers[0].autoRefreshTimer &&
+     controller.animationPlayers[1].autoRefreshTimer,
+    "The AnimationPlayerFronts have been set to auto-refresh");
+
+  // Hold on to one of the AnimationPlayerFront objects and mock its release
+  // method to test that it is released correctly and that its auto-refresh is
+  // stopped.
+  let retainedFront = controller.animationPlayers[0];
+  let oldRelease = retainedFront.release;
+  let releaseCalled = false;
+  retainedFront.release = () => {
+    releaseCalled = true;
+  };
+
+  info("Selecting a node with no animations");
+  yield selectNode(".still", inspector);
+
+  is(controller.animationPlayers.length, 0,
+    "There are no more AnimationPlayerFront objects");
+
+  info("Checking the destroyed AnimationPlayerFront object");
+  ok(releaseCalled, "The AnimationPlayerFront has been released");
+  ok(!retainedFront.autoRefreshTimer,
+    "The released AnimationPlayerFront's auto-refresh mode has been turned off");
+  yield oldRelease.call(retainedFront);
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_destroy.js
@@ -0,0 +1,23 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that player widgets are destroyed correctly when needed.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Select an animated node");
+  yield selectNode(".multi", inspector);
+
+  info("Hold on to one of the player widget instances to test it after destroy");
+  let widget = panel.playerWidgets[0];
+
+  info("Select another node to get the previous widgets destroyed");
+  yield selectNode(".animated", inspector);
+
+  ok(widget.destroyed, "The widget's destroyed flag is true");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js
@@ -0,0 +1,47 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the panel only refreshes when it is visible in the sidebar.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {toolbox, inspector, panel} = yield openAnimationInspector();
+
+  info("Select a non animated node");
+  yield selectNode(".still", inspector);
+
+  info("Switch to the rule-view panel");
+  inspector.sidebar.select("ruleview");
+
+  info("Select the animated node now");
+  yield selectNode(".animated", inspector);
+
+  ok(!panel.playerWidgets || !panel.playerWidgets.length,
+    "The panel doesn't show the animation data while inactive");
+
+  info("Switch to the animation panel");
+  inspector.sidebar.select("animationinspector");
+  yield panel.once(panel.UI_UPDATED_EVENT);
+
+  is(panel.playerWidgets.length, 1,
+    "The panel shows the animation data after selecting it");
+
+  info("Switch again to the rule-view");
+  inspector.sidebar.select("ruleview");
+
+  info("Select the non animated node again");
+  yield selectNode(".still", inspector);
+
+  is(panel.playerWidgets.length, 1,
+    "The panel still shows the previous animation data since it is inactive");
+
+  info("Switch to the animation panel again");
+  inspector.sidebar.select("animationinspector");
+  yield panel.once(panel.UI_UPDATED_EVENT);
+
+  ok(!panel.playerWidgets || !panel.playerWidgets.length,
+    "The panel is now empty after refreshing");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js
@@ -0,0 +1,25 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that when playerFronts are updated, the same number of playerWidgets
+// are created in the panel.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel, controller} = yield openAnimationInspector();
+
+  info("Selecting the test animated node");
+  yield selectNode(".multi", inspector);
+
+  is(controller.animationPlayers.length, panel.playerWidgets.length,
+    "As many playerWidgets were created as there are playerFronts");
+
+  for (let widget of panel.playerWidgets) {
+    ok(widget.initialized, "The player widget is initialized");
+    is(widget.el.parentNode, panel.playersEl,
+      "The player widget has been appended to the panel");
+  }
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js
@@ -0,0 +1,20 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the panel shows an animation player when an animated node is
+// selected.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Select node .animated and check that the panel is not empty");
+  let node = yield getNodeFront(".animated", inspector);
+  yield selectNode(node, inspector);
+
+  is(panel.playerWidgets.length, 1,
+    "Exactly 1 player widget is shown for animated node");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_timeline_animates.js
@@ -0,0 +1,28 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the currentTime timeline widget actually progresses with the
+// animation itself.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
+
+  info("Select the animated node");
+  yield selectNode(".animated", inspector);
+
+  info("Get the player widget's timeline element and its current position");
+  let widget = panel.playerWidgets[0];
+  let timeline = widget.currentTimeEl;
+
+  yield widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+  ok(widget.rafID, "The widget is updating the timeline with a rAF loop");
+
+  info("Pause the animation");
+  yield togglePlayPauseButton(widget);
+
+  ok(!widget.rafID, "The rAF loop has been stopped after the animation was paused");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_changes.js
@@ -0,0 +1,49 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Verify that if the animation object changes in content, then the widget
+// reflects that change.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {panel, inspector} = yield openAnimationInspector();
+
+  info("Select the test node");
+  yield selectNode(".animated", inspector);
+
+  info("Get the player widget");
+  let widget = panel.playerWidgets[0];
+
+  info("Pause the animation via the content DOM");
+  yield executeInContent("Test:ToggleAnimationPlayer", {
+    animationIndex: 0,
+    pause: true
+  }, {
+    node: getNode(".animated")
+  });
+
+  info("Wait for the next state update");
+  yield widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+
+  is(widget.player.state.playState, "paused", "The AnimationPlayerFront is paused");
+  ok(widget.el.classList.contains("paused"), "The button's state has changed");
+  ok(!widget.rafID, "The smooth timeline animation has been stopped");
+
+  info("Play the animation via the content DOM");
+  yield executeInContent("Test:ToggleAnimationPlayer", {
+    animationIndex: 0,
+    pause: false
+  }, {
+    node: getNode(".animated")
+  });
+
+  info("Wait for the next state update");
+  yield widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+
+  is(widget.player.state.playState, "running", "The AnimationPlayerFront is running");
+  ok(widget.el.classList.contains("running"), "The button's state has changed");
+  ok(widget.rafID, "The smooth timeline animation has been started");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/doc_frame_script.js
@@ -0,0 +1,28 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// A helper frame-script for brower/devtools/animationinspector tests.
+
+/**
+ * Toggle (play or pause) one of the animation players of a given node.
+ * @param {Object} data
+ * - {Number} animationIndex The index of the node's animationPlayers to play or pause
+ * @param {Object} objects
+ * - {DOMNode} node The node to use
+ */
+addMessageListener("Test:ToggleAnimationPlayer", function(msg) {
+  let {animationIndex, pause} = msg.data;
+  let {node} = msg.objects;
+
+  let player = node.getAnimationPlayers()[animationIndex];
+  if (pause) {
+    player.pause();
+  } else {
+    player.play();
+  }
+
+  sendAsyncMessage("Test:ToggleAnimationPlayer");
+});
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/doc_simple_animation.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <style>
+    .ball {
+      width: 100px;
+      height: 100px;
+      border-radius: 50%;
+      background: #f06;
+
+      position: absolute;
+    }
+
+    .still {
+      top: 50px;
+      left: 50px;
+    }
+
+    .animated {
+      top: 200px;
+      left: 200px;
+
+      animation: simple-animation 2s infinite alternate;
+    }
+
+    .multi {
+      top: 100px;
+      left: 400px;
+
+      animation: simple-animation 2s infinite alternate,
+                 other-animation 5s infinite alternate;
+    }
+
+    @keyframes simple-animation {
+      100% {
+        transform: translateX(300px);
+      }
+    }
+
+    @keyframes other-animation {
+      100% {
+        background: blue;
+      }
+    }
+  </style>
+</head>
+<body>
+  <!-- Comment node -->
+  <div class="ball still"></div>
+  <div class="ball animated"></div>
+  <div class="ball multi"></div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/devtools/animationinspector/test/head.js
@@ -0,0 +1,274 @@
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const Cu = Components.utils;
+let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
+let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
+let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
+let TargetFactory = devtools.TargetFactory;
+let {console} = Components.utils.import("resource://gre/modules/devtools/Console.jsm", {});
+
+// All tests are asynchronous
+waitForExplicitFinish();
+
+const TEST_URL_ROOT = "http://example.com/browser/browser/devtools/animationinspector/test/";
+const ROOT_TEST_DIR = getRootDirectory(gTestPath);
+const FRAME_SCRIPT_URL = ROOT_TEST_DIR + "doc_frame_script.js";
+
+// Auto clean-up when a test ends
+registerCleanupFunction(function*() {
+  let target = TargetFactory.forTab(gBrowser.selectedTab);
+  yield gDevTools.closeToolbox(target);
+
+  while (gBrowser.tabs.length > 1) {
+    gBrowser.removeCurrentTab();
+  }
+});
+
+// Uncomment this pref to dump all devtools emitted events to the console.
+// Services.prefs.setBoolPref("devtools.dump.emit", true);
+
+// Uncomment this pref to dump all devtools protocol traffic
+// Services.prefs.setBoolPref("devtools.debugger.log", true);
+
+// Set the testing flag on gDevTools and reset it when the test ends
+gDevTools.testing = true;
+registerCleanupFunction(() => gDevTools.testing = false);
+
+// Clean-up all prefs that might have been changed during a test run
+// (safer here because if the test fails, then the pref is never reverted)
+registerCleanupFunction(() => {
+  Services.prefs.clearUserPref("devtools.dump.emit");
+  Services.prefs.clearUserPref("devtools.debugger.log");
+});
+
+/**
+ * Add a new test tab in the browser and load the given url.
+ * @param {String} url The url to be loaded in the new tab
+ * @return a promise that resolves to the tab object when the url is loaded
+ */
+function addTab(url) {
+  info("Adding a new tab with URL: '" + url + "'");
+  let def = promise.defer();
+
+  window.focus();
+
+  let tab = window.gBrowser.selectedTab = window.gBrowser.addTab(url);
+  let browser = tab.linkedBrowser;
+
+  info("Loading the helper frame script " + FRAME_SCRIPT_URL);
+  browser.messageManager.loadFrameScript(FRAME_SCRIPT_URL, false);
+
+  browser.addEventListener("load", function onload() {
+    browser.removeEventListener("load", onload, true);
+    info("URL '" + url + "' loading complete");
+
+    def.resolve(tab);
+  }, true);
+
+  return def.promise;
+}
+
+/**
+ * Simple DOM node accesor function that takes either a node or a string css
+ * selector as argument and returns the corresponding node
+ * @param {String|DOMNode} nodeOrSelector
+ * @return {DOMNode|CPOW} Note that in e10s mode a CPOW object is returned which
+ * doesn't implement *all* of the DOMNode's properties
+ */
+function getNode(nodeOrSelector) {
+  info("Getting the node for '" + nodeOrSelector + "'");
+  return typeof nodeOrSelector === "string" ?
+    content.document.querySelector(nodeOrSelector) :
+    nodeOrSelector;
+}
+
+/**
+ * Get the NodeFront for a given css selector, via the protocol
+ * @param {String} selector
+ * @param {InspectorPanel} inspector The instance of InspectorPanel currently
+ * loaded in the toolbox
+ * @return {Promise} Resolves to the NodeFront instance
+ */
+function getNodeFront(selector, {walker}) {
+  return walker.querySelector(walker.rootNode, selector);
+}
+
+/*
+ * Set the inspector's current selection to a node or to the first match of the
+ * given css selector.
+ * @param {String|NodeFront}
+ *        data The node to select
+ * @param {InspectorPanel} inspector
+ *        The instance of InspectorPanel currently
+ * loaded in the toolbox
+ * @param {String} reason
+ *        Defaults to "test" which instructs the inspector not
+ *        to highlight the node upon selection
+ * @return {Promise} Resolves when the inspector is updated with the new node
+ */
+let selectNode = Task.async(function*(data, inspector, reason="test") {
+  info("Selecting the node for '" + data + "'");
+  let nodeFront = data;
+  if (!data._form) {
+    nodeFront = yield getNodeFront(data, inspector);
+  }
+  let updated = inspector.once("inspector-updated");
+  inspector.selection.setNodeFront(nodeFront, reason);
+  yield updated;
+});
+
+/**
+ * Open the toolbox, with the inspector tool visible and the animationinspector
+ * sidebar selected.
+ * @return a promise that resolves when the inspector is ready
+ */
+let openAnimationInspector = Task.async(function*() {
+  let target = TargetFactory.forTab(gBrowser.selectedTab);
+
+  info("Opening the toolbox with the inspector selected");
+  let toolbox = yield gDevTools.showToolbox(target, "inspector");
+  yield waitForToolboxFrameFocus(toolbox);
+
+  info("Switching to the animationinspector");
+  let inspector = toolbox.getPanel("inspector");
+  let initPromises = [
+    inspector.once("inspector-updated"),
+    inspector.sidebar.once("animationinspector-ready")
+  ];
+  inspector.sidebar.select("animationinspector");
+
+  info("Waiting for the inspector and sidebar to be ready");
+  yield promise.all(initPromises);
+
+  let win = inspector.sidebar.getWindowForTab("animationinspector");
+  let {AnimationsController, AnimationsPanel} = win;
+
+  yield promise.all([
+    AnimationsController.initialized,
+    AnimationsPanel.initialized
+  ]);
+
+  return {
+    toolbox: toolbox,
+    inspector: inspector,
+    controller: AnimationsController,
+    panel: AnimationsPanel,
+    window: win
+  };
+});
+
+/**
+ * Wait for the toolbox frame to receive focus after it loads
+ * @param {Toolbox} toolbox
+ * @return a promise that resolves when focus has been received
+ */
+function waitForToolboxFrameFocus(toolbox) {
+  info("Making sure that the toolbox's frame is focused");
+  let def = promise.defer();
+  let win = toolbox.frame.contentWindow;
+  waitForFocus(def.resolve, win);
+  return def.promise;
+}
+
+/**
+ * Checks whether the inspector's sidebar corresponding to the given id already
+ * exists
+ * @param {InspectorPanel}
+ * @param {String}
+ * @return {Boolean}
+ */
+function hasSideBarTab(inspector, id) {
+  return !!inspector.sidebar.getWindowForTab(id);
+}
+
+/**
+ * Wait for eventName on target.
+ * @param {Object} target An observable object that either supports on/off or
+ * addEventListener/removeEventListener
+ * @param {String} eventName
+ * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener
+ * @return A promise that resolves when the event has been handled
+ */
+function once(target, eventName, useCapture=false) {
+  info("Waiting for event: '" + eventName + "' on " + target + ".");
+
+  let deferred = promise.defer();
+
+  for (let [add, remove] of [
+    ["addEventListener", "removeEventListener"],
+    ["addListener", "removeListener"],
+    ["on", "off"]
+  ]) {
+    if ((add in target) && (remove in target)) {
+      target[add](eventName, function onEvent(...aArgs) {
+        target[remove](eventName, onEvent, useCapture);
+        deferred.resolve.apply(deferred, aArgs);
+      }, useCapture);
+      break;
+    }
+  }
+
+  return deferred.promise;
+}
+
+/**
+ * Wait for a content -> chrome message on the message manager (the window
+ * messagemanager is used).
+ * @param {String} name The message name
+ * @return {Promise} A promise that resolves to the response data when the
+ * message has been received
+ */
+function waitForContentMessage(name) {
+  info("Expecting message " + name + " from content");
+
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  let def = promise.defer();
+  mm.addMessageListener(name, function onMessage(msg) {
+    mm.removeMessageListener(name, onMessage);
+    def.resolve(msg.data);
+  });
+  return def.promise;
+}
+
+/**
+ * Send an async message to the frame script (chrome -> content) and wait for a
+ * response message with the same name (content -> chrome).
+ * @param {String} name The message name. Should be one of the messages defined
+ * in doc_frame_script.js
+ * @param {Object} data Optional data to send along
+ * @param {Object} objects Optional CPOW objects to send along
+ * @param {Boolean} expectResponse If set to false, don't wait for a response
+ * with the same name from the content script. Defaults to true.
+ * @return {Promise} Resolves to the response data if a response is expected,
+ * immediately resolves otherwise
+ */
+function executeInContent(name, data={}, objects={}, expectResponse=true) {
+  info("Sending message " + name + " to content");
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  mm.sendAsyncMessage(name, data, objects);
+  if (expectResponse) {
+    return waitForContentMessage(name);
+  } else {
+    return promise.resolve();
+  }
+}
+
+/**
+ * Simulate a click on the playPause button of a playerWidget.
+ */
+let togglePlayPauseButton = Task.async(function*(widget) {
+  // Note that instead of simulating a real event here, the callback is just
+  // called. This is better because the callback returns a promise, so we know
+  // when the player is paused, and we don't really care to test that simulating
+  // a DOM event actually works.
+  yield widget.onPlayPauseBtnClick();
+
+  // Wait for the next sate change event to make sure the state is updated
+  yield widget.player.once(widget.player.AUTO_REFRESH_EVENT);
+});
--- a/browser/devtools/inspector/inspector-panel.js
+++ b/browser/devtools/inspector/inspector-panel.js
@@ -336,16 +336,22 @@ InspectorPanel.prototype = {
                           "chrome://browser/content/devtools/fontinspector/font-inspector.xhtml",
                           "fontinspector" == defaultTab);
     }
 
     this.sidebar.addTab("layoutview",
                         "chrome://browser/content/devtools/layoutview/view.xhtml",
                         "layoutview" == defaultTab);
 
+    if (this.target.form.animationsActor) {
+      this.sidebar.addTab("animationinspector",
+                          "chrome://browser/content/devtools/animationinspector/animation-inspector.xhtml",
+                          "animationinspector" == defaultTab);
+    }
+
     let ruleViewTab = this.sidebar.getTab("ruleview");
 
     this.sidebar.show();
   },
 
   /**
    * Reset the inspector on new root mutation.
    */
--- a/browser/devtools/jar.mn
+++ b/browser/devtools/jar.mn
@@ -31,16 +31,19 @@ browser.jar:
     content/browser/devtools/cssruleview.xhtml                         (styleinspector/cssruleview.xhtml)
     content/browser/devtools/ruleview.css                              (styleinspector/ruleview.css)
     content/browser/devtools/layoutview/view.js                        (layoutview/view.js)
     content/browser/devtools/layoutview/view.xhtml                     (layoutview/view.xhtml)
     content/browser/devtools/layoutview/view.css                       (layoutview/view.css)
     content/browser/devtools/fontinspector/font-inspector.js           (fontinspector/font-inspector.js)
     content/browser/devtools/fontinspector/font-inspector.xhtml        (fontinspector/font-inspector.xhtml)
     content/browser/devtools/fontinspector/font-inspector.css          (fontinspector/font-inspector.css)
+    content/browser/devtools/animationinspector/animation-controller.js (animationinspector/animation-controller.js)
+    content/browser/devtools/animationinspector/animation-panel.js     (animationinspector/animation-panel.js)
+    content/browser/devtools/animationinspector/animation-inspector.xhtml (animationinspector/animation-inspector.xhtml)
     content/browser/devtools/codemirror/codemirror.js                  (sourceeditor/codemirror/codemirror.js)
     content/browser/devtools/codemirror/codemirror.css                 (sourceeditor/codemirror/codemirror.css)
     content/browser/devtools/codemirror/javascript.js                  (sourceeditor/codemirror/mode/javascript.js)
     content/browser/devtools/codemirror/xml.js                         (sourceeditor/codemirror/mode/xml.js)
     content/browser/devtools/codemirror/css.js                         (sourceeditor/codemirror/mode/css.js)
     content/browser/devtools/codemirror/htmlmixed.js                   (sourceeditor/codemirror/mode/htmlmixed.js)
     content/browser/devtools/codemirror/clike.js                       (sourceeditor/codemirror/mode/clike.js)
     content/browser/devtools/codemirror/activeline.js                  (sourceeditor/codemirror/selection/active-line.js)
--- a/browser/devtools/moz.build
+++ b/browser/devtools/moz.build
@@ -1,15 +1,16 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
+    'animationinspector',
     'app-manager',
     'canvasdebugger',
     'commandline',
     'debugger',
     'eyedropper',
     'fontinspector',
     'framework',
     'inspector',
--- a/browser/devtools/shared/telemetry.js
+++ b/browser/devtools/shared/telemetry.js
@@ -100,16 +100,21 @@ Telemetry.prototype = {
       userHistogram: "DEVTOOLS_LAYOUTVIEW_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_LAYOUTVIEW_TIME_ACTIVE_SECONDS"
     },
     fontinspector: {
       histogram: "DEVTOOLS_FONTINSPECTOR_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_FONTINSPECTOR_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_FONTINSPECTOR_TIME_ACTIVE_SECONDS"
     },
+    animationinspector: {
+      histogram: "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_BOOLEAN",
+      userHistogram: "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_PER_USER_FLAG",
+      timerHistogram: "DEVTOOLS_ANIMATIONINSPECTOR_TIME_ACTIVE_SECONDS"
+    },
     jsdebugger: {
       histogram: "DEVTOOLS_JSDEBUGGER_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_JSDEBUGGER_OPENED_PER_USER_FLAG",
       timerHistogram: "DEVTOOLS_JSDEBUGGER_TIME_ACTIVE_SECONDS"
     },
     jsbrowserdebugger: {
       histogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_BOOLEAN",
       userHistogram: "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_PER_USER_FLAG",
--- a/browser/devtools/shared/test/browser_telemetry_sidebar.js
+++ b/browser/devtools/shared/test/browser_telemetry_sidebar.js
@@ -35,17 +35,18 @@ function init() {
 
 function testSidebar() {
   info("Testing sidebar");
 
   let target = TargetFactory.forTab(gBrowser.selectedTab);
 
   gDevTools.showToolbox(target, "inspector").then(function(toolbox) {
     let inspector = toolbox.getCurrentPanel();
-    let sidebarTools = ["ruleview", "computedview", "fontinspector", "layoutview"];
+    let sidebarTools = ["ruleview", "computedview", "fontinspector",
+                        "layoutview", "animationinspector"];
 
     // Concatenate the array with itself so that we can open each tool twice.
     sidebarTools.push.apply(sidebarTools, sidebarTools);
 
     // See TOOL_DELAY for why we need setTimeout here
     setTimeout(function selectSidebarTab() {
       let tool = sidebarTools.pop();
       if (tool) {
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/animationinspector.dtd
@@ -0,0 +1,24 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!-- LOCALIZATION NOTE : FILE This file contains the Animations panel strings.
+  - The Animations panel is part of the Inspector sidebar -->
+
+<!-- LOCALIZATION NOTE : FILE The correct localization of this file might be to
+  - keep it in English, or another language commonly spoken among web developers.
+  - You want to make that choice consistent across the developer tools.
+  - A good criteria is the language in which you'd find the best
+  - documentation on web development on the web. -->
+
+<!-- LOCALIZATION NOTE (title): This is the label shown in the sidebar tab -->
+<!ENTITY title                  "Animations">
+
+<!-- LOCALIZATION NOTE (invalidElement): This is the label shown in the panel
+  - when an invalid node is currently selected in the inspector. -->
+<!ENTITY invalidElement         "No animations were found for the current element.">
+
+<!-- LOCALIZATION NOTE (selectElement): This is the label shown in the panel
+  - when an invalid node is currently selected in the inspector, to invite the
+  - user to select a new node by clicking on the element-picker icon. -->
+<!ENTITY selectElement         "Pick another element from the page.">
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties
@@ -0,0 +1,43 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE These strings are used inside the Animation inspector
+# which is available as a sidebar panel in the Inspector.
+# The correct localization of this file might be to keep it in
+# English, or another language commonly spoken among web developers.
+# You want to make that choice consistent across the developer tools.
+# A good criteria is the language in which you'd find the best
+# documentation on web development on the web.
+
+# LOCALIZATION NOTE (player.animationNameLabel):
+# This string is displayed in each animation player widget. It is the label
+# displayed before the animation name.
+player.animationNameLabel=Animation:
+
+# LOCALIZATION NOTE (player.transitionNameLabel):
+# This string is displayed in each animation player widget. It is the label
+# displayed in the header, when the element is animated by mean of a css
+# transition
+player.transitionNameLabel=Transition
+
+# LOCALIZATION NOTE (player.animationDurationLabel):
+# This string is displayed in each animation player widget. It is the label
+# displayed before the animation duration.
+player.animationDurationLabel=Duration:
+
+# LOCALIZATION NOTE (player.animationIterationCountLabel):
+# This string is displayed in each animation player widget. It is the label
+# displayed before the number of times the animation is set to repeat.
+player.animationIterationCountLabel=Repeats:
+
+# LOCALIZATION NOTE (player.infiniteIterationCount):
+# In case the animation repeats infinitely, this string is displayed next to the
+# player.animationIterationCountLabel string, instead of a number.
+player.infiniteIterationCount=&#8734;
+
+# LOCALIZATION NOTE (player.timeLabel):
+# This string is displayed in each animation player widget, to indicate either
+# how long (in seconds) the animation lasts, or what is the animation's current
+# time (in seconds too);
+player.timeLabel=%Ss
--- a/browser/locales/jar.mn
+++ b/browser/locales/jar.mn
@@ -22,16 +22,18 @@
     locale/browser/syncProgress.dtd                (%chrome/browser/syncProgress.dtd)
     locale/browser/syncCustomize.dtd               (%chrome/browser/syncCustomize.dtd)
     locale/browser/aboutSyncTabs.dtd               (%chrome/browser/aboutSyncTabs.dtd)
 #endif
     locale/browser/browser.dtd                     (%chrome/browser/browser.dtd)
     locale/browser/baseMenuOverlay.dtd             (%chrome/browser/baseMenuOverlay.dtd)
     locale/browser/browser.properties              (%chrome/browser/browser.properties)
     locale/browser/customizableui/customizableWidgets.properties (%chrome/browser/customizableui/customizableWidgets.properties)
+    locale/browser/devtools/animationinspector.dtd    (%chrome/browser/devtools/animationinspector.dtd)
+    locale/browser/devtools/animationinspector.properties (%chrome/browser/devtools/animationinspector.properties)
     locale/browser/devtools/appcacheutils.properties  (%chrome/browser/devtools/appcacheutils.properties)
     locale/browser/devtools/debugger.dtd              (%chrome/browser/devtools/debugger.dtd)
     locale/browser/devtools/debugger.properties       (%chrome/browser/devtools/debugger.properties)
     locale/browser/devtools/netmonitor.dtd            (%chrome/browser/devtools/netmonitor.dtd)
     locale/browser/devtools/netmonitor.properties     (%chrome/browser/devtools/netmonitor.properties)
     locale/browser/devtools/shadereditor.dtd          (%chrome/browser/devtools/shadereditor.dtd)
     locale/browser/devtools/shadereditor.properties   (%chrome/browser/devtools/shadereditor.properties)
     locale/browser/devtools/canvasdebugger.dtd        (%chrome/browser/devtools/canvasdebugger.dtd)
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -263,16 +263,17 @@ browser.jar:
   skin/classic/browser/devtools/markup-view.css       (../shared/devtools/markup-view.css)
   skin/classic/browser/devtools/editor-error.png       (../shared/devtools/images/editor-error.png)
   skin/classic/browser/devtools/editor-breakpoint.png  (../shared/devtools/images/editor-breakpoint.png)
   skin/classic/browser/devtools/editor-debug-location.png (../shared/devtools/images/editor-debug-location.png)
   skin/classic/browser/devtools/editor-debug-location@2x.png (../shared/devtools/images/editor-debug-location@2x.png)
   skin/classic/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
+  skin/classic/browser/devtools/animationinspector.css          (../shared/devtools/animationinspector.css)
 * skin/classic/browser/devtools/canvasdebugger.css    (devtools/canvasdebugger.css)
 * skin/classic/browser/devtools/debugger.css          (devtools/debugger.css)
   skin/classic/browser/devtools/eyedropper.css        (../shared/devtools/eyedropper.css)
 * skin/classic/browser/devtools/netmonitor.css        (devtools/netmonitor.css)
 * skin/classic/browser/devtools/profiler.css          (devtools/profiler.css)
 * skin/classic/browser/devtools/performance.css       (devtools/performance.css)
 * skin/classic/browser/devtools/timeline.css          (devtools/timeline.css)
   skin/classic/browser/devtools/timeline-filter.svg   (../shared/devtools/images/timeline-filter.svg)
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -394,16 +394,17 @@ browser.jar:
   skin/classic/browser/devtools/editor-debug-location@2x.png    (../shared/devtools/images/editor-debug-location@2x.png)
 * skin/classic/browser/devtools/webconsole.css                  (devtools/webconsole.css)
   skin/classic/browser/devtools/webconsole_networkpanel.css     (devtools/webconsole_networkpanel.css)
   skin/classic/browser/devtools/webconsole.png                  (../shared/devtools/images/webconsole.png)
   skin/classic/browser/devtools/webconsole@2x.png               (../shared/devtools/images/webconsole@2x.png)
   skin/classic/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
   skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
+  skin/classic/browser/devtools/animationinspector.css          (../shared/devtools/animationinspector.css)
 * skin/classic/browser/devtools/canvasdebugger.css          (devtools/canvasdebugger.css)
 * skin/classic/browser/devtools/debugger.css                (devtools/debugger.css)
   skin/classic/browser/devtools/eyedropper.css              (../shared/devtools/eyedropper.css)
 * skin/classic/browser/devtools/netmonitor.css              (devtools/netmonitor.css)
 * skin/classic/browser/devtools/profiler.css                (devtools/profiler.css)
 * skin/classic/browser/devtools/performance.css             (devtools/performance.css)
 * skin/classic/browser/devtools/timeline.css                (devtools/timeline.css)
   skin/classic/browser/devtools/timeline-filter.svg         (../shared/devtools/images/timeline-filter.svg)
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/devtools/animationinspector.css
@@ -0,0 +1,149 @@
+body {
+  margin: 0;
+  padding: 0;
+}
+
+/* The error message, shown when an invalid/unanimated element is selected */
+
+#error-message {
+  margin-top: 10%;
+  text-align: center;
+
+  /* The error message is hidden by default */
+  display: none;
+}
+
+/* Element picker button */
+
+#element-picker {
+  position: relative;
+}
+
+#element-picker::before {
+  content: "";
+  display: block;
+  width: 16px;
+  height: 16px;
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  margin: -8px 0 0 -8px;
+  background-image: url("chrome://browser/skin/devtools/command-pick.png");
+}
+
+#element-picker[checked]::before {
+  background-position: -48px 0;
+  filter: none; /* Icon is blue when checked, don't invert for light theme */
+}
+
+@media (min-resolution: 2dppx) {
+  #element-picker::before {
+    background-image: url("chrome://browser/skin/devtools/command-pick@2x.png");
+    background-size: 64px;
+  }
+}
+
+/* Animation title gutter, contains the name, duration, iteration */
+
+.animation-title {
+  background-color: var(--theme-toolbar-background);
+  color: var(--theme-content-color3);
+  border-bottom: 1px solid var(--theme-splitter-color);
+  padding: 1px 4px;
+  word-wrap: break-word;
+  overflow: auto;
+}
+
+.animation-title .meta-data {
+  float: right;
+}
+
+.animation-title strong {
+  margin: 0 .5em;
+}
+
+/* Timeline wiget */
+
+.timeline {
+  height: 20px;
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+  border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.timeline .playback-controls {
+  width: 50px;
+  display: flex;
+  flex-direction: row;
+}
+
+/* Playback control buttons */
+
+.timeline .playback-controls button {
+  flex-grow: 1;
+  border-width: 0 1px 0 0;
+}
+
+/* Play/pause button */
+
+.timeline .toggle::before {
+  background-image: url(debugger-pause.png);
+}
+
+.paused .timeline .toggle::before {
+  background-image: url(debugger-play.png);
+}
+
+@media (min-resolution: 2dppx) {
+  .timeline .toggle::before {
+    background-image: url(debugger-pause@2x.png);
+  }
+
+  .paused .timeline .toggle::before {
+    background-image: url(debugger-play@2x.png);
+  }
+}
+
+/* Slider (input type range) container */
+
+.timeline .sliders-container {
+  flex-grow: 1;
+  height: 100%;
+  position: relative;
+  border-width: 1px 0;
+}
+
+.timeline .sliders-container .current-time {
+  position: absolute;
+  padding: 0;
+  margin: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.timeline .sliders-container .current-time::-moz-range-thumb {
+  height: 100%;
+  width: 4px;
+  border-radius: 0;
+  border: none;
+  background: var(--theme-highlight-blue);
+}
+
+.timeline .sliders-container .current-time::-moz-range-track {
+  width: 100%;
+  height: 50px;
+  background: transparent;
+}
+
+/* Current time label */
+
+.timeline .time-display {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 50px;
+  border-left: 1px solid var(--theme-splitter-color);
+  background: var(--theme-toolbar-background);
+}
--- a/browser/themes/shared/devtools/toolbars.inc.css
+++ b/browser/themes/shared/devtools/toolbars.inc.css
@@ -239,16 +239,77 @@
 .devtools-toolbarbutton-group + .devtools-toolbarbutton {
   -moz-margin-start: 3px;
 }
 
 .devtools-separator + .devtools-toolbarbutton {
   -moz-margin-start: 1px;
 }
 
+/* HTML buttons, similar to toolbar buttons, but work in HTML documents */
+
+.devtools-button {
+  border: 0 solid var(--theme-splitter-color);
+  background: var(--theme-toolbar-background);
+  margin: 0;
+  padding: 0;
+  min-width: 32px;
+  min-height: 18px;
+  /* The icon is absolutely positioned in the button using ::before */
+  position: relative;
+}
+
+.devtools-button[standalone] {
+  min-height: 32px;
+  border-width: 1px;
+}
+
+/* Button States */
+.theme-dark .devtools-button:not([disabled]):hover {
+  background: rgba(0, 0, 0, .3); /* Splitters */
+}
+.theme-light .devtools-button:not([disabled]):hover {
+  background: rgba(170, 170, 170, .3); /* Splitters */
+}
+
+.theme-dark .devtools-button:not([disabled]):hover:active {
+  background: rgba(0, 0, 0, .4); /* Splitters */
+}
+.theme-light .devtools-button:not([disabled]):hover:active {
+  background: rgba(170, 170, 170, .4); /* Splitters */
+}
+
+/* Menu type buttons and checked states */
+.theme-dark .devtools-button[checked] {
+  background: rgba(29, 79, 115, .7) !important; /* Select highlight blue */
+  color: var(--theme-selection-color);
+}
+
+.theme-light .devtools-button[checked] {
+  background: rgba(76, 158, 217, .2) !important; /* Select highlight blue */
+}
+
+.devtools-button::before {
+  content: "";
+  display: block;
+  width: 16px;
+  height: 16px;
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  margin: -8px 0 0 -8px;
+  background-repeat: no-repeat;
+}
+
+@media (min-resolution: 2dppx) {
+  .devtools-button::before {
+    background-size: 32px;
+  }
+}
+
 /* Text input */
 
 .devtools-textinput,
 .devtools-searchinput {
   -moz-appearance: none;
   margin: 0 3px;
   border: 1px solid;
 %ifdef XP_MACOSX
@@ -817,17 +878,18 @@
 .theme-light #breadcrumb-separator-normal,
 .theme-light .scrollbutton-up > .toolbarbutton-icon,
 .theme-light .scrollbutton-down > .toolbarbutton-icon,
 .theme-light #black-boxed-message-button .button-icon,
 .theme-light #profiling-notice-button .button-icon,
 .theme-light #canvas-debugging-empty-notice-button .button-icon,
 .theme-light #requests-menu-perf-notice-button .button-icon,
 .theme-light #requests-menu-network-summary-button .button-icon,
-.theme-light .event-tooltip-debugger-icon {
+.theme-light .event-tooltip-debugger-icon,
+.theme-light .devtools-button::before {
   filter: url(filters.svg#invert);
 }
 
 /* Since selected backgrounds are blue, we want to use the normal
  * (light) icons. */
 .theme-light .command-button-invertable[checked=true]:not(:active) > image,
 .theme-light .devtools-tab[icon-invertable][selected] > image,
 .theme-light .devtools-tab[icon-invertable][highlighted] > image,
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -301,16 +301,17 @@ browser.jar:
         skin/classic/browser/devtools/editor-debug-location@2x.png     (../shared/devtools/images/editor-debug-location@2x.png)
 *       skin/classic/browser/devtools/webconsole.css                (devtools/webconsole.css)
         skin/classic/browser/devtools/webconsole_networkpanel.css   (devtools/webconsole_networkpanel.css)
         skin/classic/browser/devtools/webconsole.png                (../shared/devtools/images/webconsole.png)
         skin/classic/browser/devtools/webconsole@2x.png             (../shared/devtools/images/webconsole@2x.png)
         skin/classic/browser/devtools/breadcrumbs-divider@2x.png    (../shared/devtools/images/breadcrumbs-divider@2x.png)
         skin/classic/browser/devtools/breadcrumbs-scrollbutton.png  (../shared/devtools/images/breadcrumbs-scrollbutton.png)
         skin/classic/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
+        skin/classic/browser/devtools/animationinspector.css        (../shared/devtools/animationinspector.css)
         skin/classic/browser/devtools/eyedropper.css                (../shared/devtools/eyedropper.css)
 *       skin/classic/browser/devtools/canvasdebugger.css            (devtools/canvasdebugger.css)
 *       skin/classic/browser/devtools/debugger.css                  (devtools/debugger.css)
 *       skin/classic/browser/devtools/netmonitor.css                (devtools/netmonitor.css)
 *       skin/classic/browser/devtools/profiler.css                  (devtools/profiler.css)
 *       skin/classic/browser/devtools/performance.css               (devtools/performance.css)
 *       skin/classic/browser/devtools/timeline.css                  (devtools/timeline.css)
         skin/classic/browser/devtools/timeline-filter.svg           (../shared/devtools/images/timeline-filter.svg)
@@ -762,16 +763,17 @@ browser.jar:
         skin/classic/aero/browser/devtools/editor-debug-location@2x.png  (../shared/devtools/images/editor-debug-location@2x.png)
 *       skin/classic/aero/browser/devtools/webconsole.css                  (devtools/webconsole.css)
         skin/classic/aero/browser/devtools/webconsole_networkpanel.css     (devtools/webconsole_networkpanel.css)
         skin/classic/aero/browser/devtools/webconsole.png                  (../shared/devtools/images/webconsole.png)
         skin/classic/aero/browser/devtools/webconsole@2x.png                  (../shared/devtools/images/webconsole@2x.png)
         skin/classic/aero/browser/devtools/breadcrumbs-divider@2x.png      (../shared/devtools/images/breadcrumbs-divider@2x.png)
         skin/classic/aero/browser/devtools/breadcrumbs-scrollbutton.png    (../shared/devtools/images/breadcrumbs-scrollbutton.png)
         skin/classic/aero/browser/devtools/breadcrumbs-scrollbutton@2x.png (../shared/devtools/images/breadcrumbs-scrollbutton@2x.png)
+        skin/classic/aero/browser/devtools/animationinspector.css    (../shared/devtools/animationinspector.css)
 *       skin/classic/aero/browser/devtools/canvasdebugger.css        (devtools/canvasdebugger.css)
 *       skin/classic/aero/browser/devtools/debugger.css              (devtools/debugger.css)
         skin/classic/aero/browser/devtools/eyedropper.css            (../shared/devtools/eyedropper.css)
 *       skin/classic/aero/browser/devtools/netmonitor.css            (devtools/netmonitor.css)
 *       skin/classic/aero/browser/devtools/profiler.css              (devtools/profiler.css)
 *       skin/classic/aero/browser/devtools/performance.css           (devtools/performance.css)
 *       skin/classic/aero/browser/devtools/timeline.css              (devtools/timeline.css)
         skin/classic/aero/browser/devtools/timeline-filter.svg       (../shared/devtools/images/timeline-filter.svg)
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -5891,16 +5891,21 @@
     "kind": "boolean",
     "description": "How many times has the devtool's Layout View been opened?"
   },
   "DEVTOOLS_FONTINSPECTOR_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has the devtool's Font Inspector been opened?"
   },
+  "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_BOOLEAN": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "description": "How many times has the devtool's Animation Inspector been opened?"
+  },
   "DEVTOOLS_JSDEBUGGER_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
     "description": "How many times has the devtool's Debugger been opened?"
   },
   "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_BOOLEAN": {
     "expires_in_version": "never",
     "kind": "boolean",
@@ -6031,16 +6036,21 @@
     "kind": "flag",
     "description": "How many users have opened the devtool's Layout View?"
   },
   "DEVTOOLS_FONTINSPECTOR_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened the devtool's Font Inspector?"
   },
+  "DEVTOOLS_ANIMATIONINSPECTOR_OPENED_PER_USER_FLAG": {
+    "expires_in_version": "never",
+    "kind": "flag",
+    "description": "How many users have opened the devtool's Animation Inspector?"
+  },
   "DEVTOOLS_JSDEBUGGER_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
     "description": "How many users have opened the devtool's Debugger?"
   },
   "DEVTOOLS_JSBROWSERDEBUGGER_OPENED_PER_USER_FLAG": {
     "expires_in_version": "never",
     "kind": "flag",
@@ -6189,16 +6199,23 @@
   },
   "DEVTOOLS_FONTINSPECTOR_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has the font inspector been active (seconds)"
   },
+  "DEVTOOLS_ANIMATIONINSPECTOR_TIME_ACTIVE_SECONDS": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": "10000000",
+    "n_buckets": 100,
+    "description": "How long has the animation inspector been active (seconds)"
+  },
   "DEVTOOLS_JSDEBUGGER_TIME_ACTIVE_SECONDS": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000000",
     "n_buckets": 100,
     "description": "How long has the JS debugger been active (seconds)"
   },
   "DEVTOOLS_JSBROWSERDEBUGGER_TIME_ACTIVE_SECONDS": {