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 239994 35144551829c6ae3d8784885c0db2ba5554da614
parent 239993 814e685b77e19a8782667d5168df20052af14752
child 239995 2c8cdce4fed4dadf98f1ef32ced6f6518a400478
push id7472
push userraliiev@mozilla.com
push dateMon, 12 Jan 2015 20:36:27 +0000
treeherdermozilla-aurora@300ca104f8fb [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins, vporof
bugs1105825
milestone37.0a1
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": {