diff --git a/panels_ipe/css/panels_ipe-rtl.css b/panels_ipe/css/panels_ipe-rtl.css
deleted file mode 100644
index ef78afb..0000000
--- a/panels_ipe/css/panels_ipe-rtl.css
+++ /dev/null
@@ -1,67 +0,0 @@
-
-div.panels-ipe-handlebar-wrapper ul {
-  float: right;
-  text-align: left;
-}
-
-div.panels-ipe-handlebar-wrapper li {
-  margin: 0 0 0 .5em;
-  float: right;
-}
-
-div.panels-ipe-draghandle span.panels-ipe-draghandle-icon {
-  float: left;
-}
-
-div.panels-ipe-placeholder {
-  text-align: right;
-}
-
-div.panels-ipe-newblock {
-  left: 30px;
-  right: auto;
-}
-
-div.panels-ipe-handlebar-wrapper li a span,
-div.panels-ipe-newblock a span {
-  text-align: right;
-}
-
-div.panels-ipe-newblock a.style {
-  margin-left: .5em;
-  margin-right: auto;
-}
-
-.panels-ipe-editing .panels-ipe-region {
-  float: right;
-}
-
-/** ============================================================================
- * Controller form markup
- */
-
-.ipe-throbber {
-  right: 49%;
-  right: auto;
-}
-
-div.panels-ipe-control .form-submit {
-  padding: 0 34px 2px 0.8em;
-}
-
-input#panels-ipe-save,
-input#panels-ipe-cancel {
-  background-position: 86% 0;
-}
-
-div.panels-ipe-pseudobutton-container a.panels-ipe-startedit {
-  padding-right: 34px;
-  padding-left: 10px;
-  background-position: 93% 9px;
-}
-
-div.panels-ipe-pseudobutton-container a.panels-ipe-change-layout {
-  padding-right: 34px;
-  padding-left: 10px;
-  background-position: 93% 9px;
-}
diff --git a/panels_ipe/css/panels_ipe.css b/panels_ipe/css/panels_ipe.css
index ec372c6..679c7c1 100644
--- a/panels_ipe/css/panels_ipe.css
+++ b/panels_ipe/css/panels_ipe.css
@@ -1,559 +1,111 @@
-body.panels-ipe {
-  margin-bottom: 60px !important;
-}
-
-/* Hide the IPE toolbar on print output. */
-@media print {
-  #panels-ipe-control-container {
-    display: none !important;
-  }
-  body.panels-ipe {
-    margin-top: 0 !important;
-  }
-}
-
-/* Hide the control container when the overlay is open. */
-html.overlay-open #panels-ipe-control-container {
-  display: none !important;
-}
-html.overlay-open body.panels-ipe {
-  margin-top: 0 !important;
-}
-
-div.panels-ipe-handlebar-wrapper {
-  border-bottom: 1px solid #CCC;
-}
+/**
+ * @file
+ * Contains all CSS for the Panels In-Place Editor.
+ */
 
-.panels-ipe-editing div.panels-ipe-portlet-wrapper {
-  margin-top: 1em;
-  border: 1px solid #CCC;
+/* Fix the output of the AppView to the bottom of the screen. */
+#panels-ipe-tray {
+  position: fixed;
   width: 100%;
+  bottom: 0;
+  left: 0;
+  text-align: center;
 }
 
-/* Hide empty panes when not editing them. */
-.panels-ipe-empty-pane {
-  display: none;
-}
-
-.panels-ipe-editing .panels-ipe-empty-pane {
-  display: block;
-}
-
-.panels-ipe-editing div.panels-ipe-portlet-wrapper:hover {
-  border: 1px dashed #CCC;
-}
-
-.panels-ipe-editing .panels-ipe-sort-container {
-  min-height: 40px;
-}
-
-.panels-ipe-editing .panels-ipe-sort-container .ui-sortable-helper {
-  background: white;
-}
-
-.panels-ipe-editing div.panel-pane div.admin-links {
-  display: none !important;
-}
-
-.panels-ipe-editing .panels-ipe-sort-container .ui-sortable-placeholder {
-  border: 2px dashed #999;
-  background-color: #FFFF99;
-  margin: 1em 0;
-  -moz-border-radius: 0;
-	-khtml-border-radius: 0;
-	-webkit-border-radius: 0;
-	border-radius: 0;
-}
-
-div.panels-ipe-handlebar-wrapper ul {
-  float: left;
+/* Force text to render as a sans-serif web-safe font. */
+#panels-ipe-tray, #panels-ipe-tray a, #panels-ipe-tray p {
+  font-family: Arial, Helvetica, sans-serif;
   margin: 0;
-  padding: 0;
-  text-align: right;
-}
-
-div.panels-ipe-handlebar-wrapper li {
-  background: none;
-  list-style-type: none;
-  list-style-image: none;
-  margin: 0 .5em 0 0;
-  padding: 0;
-  float: left;
 }
 
-div.panels-ipe-draghandle,
-div.panels-ipe-nodraghandle {
-  background: #E9E9E9;
-  background-image: linear-gradient(bottom, #D5D5D5 0%, #FCFCFC 100%);
-  background-image: -o-linear-gradient(bottom, #D5D5D5 0%, #FCFCFC 100%);
-  background-image: -moz-linear-gradient(bottom, #D5D5D5 0%, #FCFCFC 100%);
-  background-image: -webkit-linear-gradient(bottom, #D5D5D5 0%, #FCFCFC 100%);
-  background-image: -ms-linear-gradient(bottom, #D5D5D5 0%, #FCFCFC 100%);
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #D5D5D5),
-    color-stop(1, #FCFCFC)
-  );
-
-  padding: 8px 7px;
-}
-
-div.panels-ipe-draghandle span.panels-ipe-draghandle-icon {
-  display: block;
-  float: right;
-  cursor: move;
-  width: 34px;
-  height: 34px;
-  padding: 0 !important; /* override button defaults */
-}
-
-span.panels-ipe-draghandle-icon-inner {
-  display: block;
-  width: 34px;
-  height: 34px;
-  background: url(../images/icon-draggable.png) no-repeat 3px 3px;
-}
-
-div.panels-ipe-placeholder {
-  border: 2px dashed #999;
-  padding: .5em;
-  position: relative;
-  margin-top: .5em;
-  background-color: #ECFAFF;
-  color: #999;
-  font: 15px/1.3em "Open Sans", "Lucida Grande", Tahoma, Verdana, sans-serif;
-  text-transform: none;
-  letter-spacing: 0;
-  text-align: left;
-  word-spacing: 0;
-}
-
-div.panels-ipe-placeholder h3 {
-  font-weight: normal;
-  font-size: 15px;
-  width: 75px; /* In order to prevent the region title from running into the button, set a width. Initital width only--this will be changed by jQuery */
-  margin: 1.154em 0;
-}
-
-/* Hide editor-state-on elements initially */
-.panels-ipe-on {
-  display: none;
-}
-
-.panels-ipe-editing .panels-ipe-on {
-  display: block;
-}
-
-/* Show editor-state-off elements initially */
-.panels-ipe-off {
-  display: block;
-}
-
-div.panels-ipe-newblock {
-  -webkit-box-shadow: 0px 0 5px 5px #ECFAFF;
-  -moz-box-shadow: 0px 0 5px 5px #ECFAFF;
-  box-shadow: 0px 0 5px 5px #ECFAFF;
-  position: absolute;
-  right: 10px;
-  top: 50%;
-  margin-top: -18px; /* some initial guesses to help center the add button
-    panels_ipe.js will evaluate the width and get this pixel-perfect */
-  margin-left: -30px;
-  z-index: 99;
-}
-
-div.panels-ipe-newblock li {
+/* Remove list styling from the output of the TabsView. */
+.ipe-tabs {
+  list-style: none;
+  margin: 0;
   padding: 0;
 }
 
-div.panels-ipe-handlebar-wrapper li a,
-div.panels-ipe-dragtitle span,
-div.panels-ipe-newblock a,
-span.panels-ipe-draghandle-icon {
+/* Display tabs inline and slightly on top of .ipe-tabs-content. */
+.ipe-tab {
   display: inline-block;
-  border: 1px solid #ccc;
-	padding: 0 8px 1px;
-	font: bold 12px/32px 'Open Sans', 'Lucida Sans', 'Lucida Grande', verdana sans-serif;
-	text-decoration: none;
-	height: 33px;
-	color: #666;
-	cursor: pointer;
-	outline: none;
-	-moz-border-radius: 3px;
-	-khtml-border-radius: 3px;
-	-webkit-border-radius: 3px;
-	border-radius: 3px;
-
-	background: #FAFAFA;
-  background-image: linear-gradient(bottom, #E9EAEC 0%, #FAFAFA 100%);
-  background-image: -o-linear-gradient(bottom, #E9EAEC 0%, #FAFAFA 100%);
-  background-image: -moz-linear-gradient(bottom, #E9EAEC 0%, #FAFAFA 100%);
-  background-image: -webkit-linear-gradient(bottom, #E9EAEC 0%, #FAFAFA 100%);
-  background-image: -ms-linear-gradient(bottom, #E9EAEC 0%, #FAFAFA 100%);
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #E9EAEC),
-    color-stop(1, #FAFAFA)
-  );
-
-  -webkit-box-shadow: 0px 3px 3px 0px #d2d2d2;
-  -moz-box-shadow: 0px 3px 3px 0px #d2d2d2;
-  box-shadow: 0px 3px 3px 0px #d2d2d2;
-}
-
-div.panels-ipe-handlebar-wrapper li a span,
-div.panels-ipe-newblock a span {
-  display: block;
-  height: 32px;
-  width: 18px;
-  background-position: center center;
-  background-repeat: no-repeat;
-  text-align: left;
-  text-indent: -9999em;
-}
-
-div.panels-ipe-handlebar-wrapper li.edit a span {
-  background-image: url(../images/icon-settings.png);
+  padding: 10px;
+  background-color: white;
+  border-top: 1px solid darkgray;
+  margin-bottom: -1px;
 }
 
-div.panels-ipe-handlebar-wrapper li.style a span,
-div.panels-ipe-newblock a.style span {
-  background-image: url(../images/icon-style.png);
+.ipe-tab:first-child {
+  border-left: 1px solid darkgray;
+  border-top-left-radius: 5px;
 }
 
-div.panels-ipe-newblock a.style {
-  margin-right: .5em;
+.ipe-tab:last-child {
+  border-right: 1px solid darkgray;
+  border-top-right-radius: 5px;
 }
 
-div.panels-ipe-newblock a.add span {
-  background-image: url(../images/icon-add.png);
+.ipe-tab.active a {
+  color: darkgray;
 }
 
-div.panels-ipe-handlebar-wrapper li.delete a span {
-  background-image: url(../images/icon-delete.png);
-}
-
-
-div.panels-ipe-handlebar-wrapper li a:hover,
-div.panels-ipe-dragtitle span:hover,
-div.panels-ipe-newblock a:hover,
-span.panels-ipe-draghandle-icon:hover {
-  background: #E6E6E6;
-  background-image: linear-gradient(bottom, #C5C5C5 0%, #FAFAFA 100%);
-  background-image: -o-linear-gradient(bottom, #C5C5C5 0%, #FAFAFA 100%);
-  background-image: -moz-linear-gradient(bottom, #C5C5C5 0%, #FAFAFA 100%);
-  background-image: -webkit-linear-gradient(bottom, #C5C5C5 0%, #FAFAFA 100%);
-  background-image: -ms-linear-gradient(bottom, #C5C5C5 0%, #FAFAFA 100%);
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #C5C5C5),
-    color-stop(1, #FAFAFA)
-  );
-}
-
-div.panels-ipe-handlebar-wrapper li a:active,
-div.panels-ipe-dragtitle span:active,
-div.panels-ipe-newblock a:active,
-span.panels-ipe-draghandle-icon:active {
-  outline: none;
-  background-image: linear-gradient(bottom, #FFFFFF 0%, #E9EAEC 100%);
-  background-image: -o-linear-gradient(bottom, #FFFFFF 0%, #E9EAEC 100%);
-  background-image: -moz-linear-gradient(bottom, #FFFFFF 0%, #E9EAEC 100%);
-  background-image: -webkit-linear-gradient(bottom, #FFFFFF 0%, #E9EAEC 100%);
-  background-image: -ms-linear-gradient(bottom, #FFFFFF 0%, #E9EAEC 100%);
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #FFFFFF),
-    color-stop(1, #E9EAEC)
-  );
-
-  -webkit-box-shadow: 0px 0px 0px 0px #fff;
-  -moz-box-shadow: 0px 0px 0px 0px #fff;
-  box-shadow: 0px 0px 0px 0px #fff;
-}
-
-.panels-ipe-editing .panels-ipe-portlet-content {
-  margin: 10px 3px;
-  overflow: hidden;
-}
-
-.panels-ipe-editing .panels-ipe-region {
-  border: transparent dotted 1px;
-  float: left;
-  width: 100%;
-  margin-bottom: 5px;
-}
-
-div.panels-ipe-draghandle {
+.ipe-tab a {
+  color: black;
+  text-transform: uppercase;
   border: none;
+  cursor: pointer;
 }
 
-.ui-sortable-placeholder {
-  margin: 1em;
-  border: 1px dotted black;
-  visibility: visible !important;
-  height: 50px !important;
-}
-.ui-sortable-placeholder * {
-  visibility: hidden;
+.ipe-tab a:hover {
+  color: darkgray;
+  border: none;
 }
 
-/** ============================================================================
- * Controller form markup
- */
-
-div#panels-ipe-control-container {
-  z-index: 99999;
-  position: fixed;
-  bottom: 0;
+/* Provide default styles and a minimum height for tab content. */
+.ipe-tab-content {
   display: none;
-  background-color: #000;
-  padding: 0.5em 0;
-  width: 100%;
-  overflow: hidden;
-  -moz-box-shadow: 0 3px 20px #000;
-  -webkit-box-shadow: 0 3px 20px #000;
-  box-shadow: 0 3px 20px #000;
-}
-
-.ipe-throbber {
-  background-color: #232323;
-  background-image: url("../images/loading-small.gif");
-  background-position: center center;
-  background-repeat: no-repeat;
-  -moz-border-radius: 7px;
-  -webkit-border-radius: 7px;
-  border-radius: 7px;
-  height: 24px;
-  opacity: .9;
-  padding: 4px;
-  width: 24px;
-  /* Can't do center:50% middle: 50%, so approximate it for a typical window size. */
-  left: 49%;
-  position: fixed;
-  top: 48.5%;
-  z-index: 1001;
+  min-height: 100px;
+  padding: 25px;
+  background-color: white;
+  border-top: 1px solid darkgray;
 }
 
-/* Hide the drupal system throbber image */
-.ipe-throbber .throbber {
-  display: none;
+.ipe-tab-content.active {
+  display: block;
 }
 
-div.panels-ipe-pseudobutton-container,
-div.panels-ipe-control .form-submit {
-  cursor: pointer;
-  background: #666666;
-  background-image: linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: -o-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: -moz-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: -webkit-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: -ms-linear-gradient(bottom, #383838 0%, #666666 100%);
-
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #383838),
-    color-stop(1, #666666)
-  );
-  border: 0;
-  -moz-border-radius: 3px;
-  -webkit-border-radius: 3px;
-  border-radius: 3px;
-  color: #CCC;
+/* Styles for the Layout selector. */
+.ipe-current-layout, .ipe-all-layouts {
   display: inline-block;
-  font: bold 12px/33px "Open Sans", "Lucida Grande", Tahoma, Verdana, sans-serif;
-  height: 33px;
-  margin: 0 10px;
+  height: 50px;
 }
 
-div.panels-ipe-control .form-submit {
-  padding: 0 0.8em 2px 34px;
+.ipe-current-layout {
+  margin-right: 50px;
 }
 
-div.panels-ipe-control input.panels-ipe-save, div.panels-ipe-control input.panels-ipe-cancel,
-div.panels-ipe-control input.panels-ipe-save:hover, div.panels-ipe-control input.panels-ipe-cancel:hover,
-div.panels-ipe-control input.panels-ipe-save:active, div.panels-ipe-control input.panels-ipe-cancel:active {
-  background-repeat: no-repeat;
+/* Remove <ul> list styling. */
+.ipe-layouts {
+  list-style: none;
+  margin: 0;
+  padding: 0;
 }
 
-div.panels-ipe-pseudobutton-container a {
-  height: 33px;
-  padding: 0 0.8em;
+/* Show layouts as clickable things. */
+.ipe-layout {
+  cursor: pointer;
   display: inline-block;
-  color: #CCC;
-  text-decoration: none;
-}
-
-div.panels-ipe-control input.panels-ipe-save {
-  background-image: url(../images/icon-save.png);
-  background-image: url(../images/icon-save.png), linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-save.png), -o-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-save.png), -moz-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-save.png), -webkit-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-save.png), -ms-linear-gradient(bottom, #383838 0%, #666666 100%);
-
-  background-image: url(../images/icon-save.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #383838),
-    color-stop(1, #666666)
-  );
-}
-
-div.panels-ipe-control input.panels-ipe-cancel {
-  background-image: url(../images/icon-close.png);
-  background-image: url(../images/icon-close.png), linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-close.png), -o-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-close.png), -moz-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-close.png), -webkit-linear-gradient(bottom, #383838 0%, #666666 100%);
-  background-image: url(../images/icon-close.png), -ms-linear-gradient(bottom, #383838 0%, #666666 100%);
-
-  background-image: url(../images/icon-close.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #383838),
-    color-stop(1, #666666)
-  );
-}
-
-div.panels-ipe-pseudobutton-container:hover,
-div.panels-ipe-control .form-submit:hover {
-  background: #999999;
-  background-image: linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: -o-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: -moz-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: -webkit-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: -ms-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #3D3D3D),
-    color-stop(1, #999999)
-  );
-
-  color: #FFF;
-}
-
-div.panels-ipe-pseudobutton-container a:hover {
-  color: #FFF;
-}
-
-div.panels-ipe-control input.panels-ipe-cancel:hover {
-  background-image: url(../images/icon-close.png), linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-close.png), -o-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-close.png), -moz-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-close.png), -webkit-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-close.png), -ms-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-
-  background-image: url(../images/icon-close.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #3D3D3D),
-    color-stop(1, #999999)
-  );
-}
-
-div.panels-ipe-control input.panels-ipe-save:hover {
-  background-image: url(../images/icon-save.png), linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-save.png), -o-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-save.png), -moz-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-save.png), -webkit-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-  background-image: url(../images/icon-save.png), -ms-linear-gradient(bottom, #3D3D3D 0%, #999999 100%);
-
-  background-image: url(../images/icon-save.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #3D3D3D),
-    color-stop(1, #999999)
-  );
 }
 
-div.panels-ipe-pseudobutton-container:active,
-div.panels-ipe-control .form-submit:active {
-  background: #333;
-  background-image: linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: -o-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: -moz-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: -webkit-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: -ms-linear-gradient(bottom, #616161 0%, #333333 100%);
-
-  background-image: -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #616161),
-    color-stop(1, #333333)
-  );
-
-  color: #CCC;
-}
-
-div.panels-ipe-pseudobutton-container a:active {
-  color: #CCC;
-}
-
-div.panels-ipe-control input.panels-ipe-cancel:active {
-  background-image: url(../images/icon-close.png), linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-close.png), -o-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-close.png), -moz-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-close.png), -webkit-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-close.png), -ms-linear-gradient(bottom, #616161 0%, #333333 100%);
-
-  background-image: url(../images/icon-close.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #616161),
-    color-stop(1, #333333)
-  );
-}
-
-div.panels-ipe-control input.panels-ipe-save:active {
-  background-image: url(../images/icon-save.png), linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-save.png), -o-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-save.png), -moz-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-save.png), -webkit-linear-gradient(bottom, #616161 0%, #333333 100%);
-  background-image: url(../images/icon-save.png), -ms-linear-gradient(bottom, #616161 0%, #333333 100%);
-
-  background-image: url(../images/icon-save.png), -webkit-gradient(
-    linear,
-    left bottom,
-    left top,
-    color-stop(0, #616161),
-    color-stop(1, #333333)
-  );
-}
-
-div.panels-ipe-pseudobutton-container a.panels-ipe-startedit {
-  padding-left: 34px;
-  background: url(../images/icon-configure.png) no-repeat 10px 9px;
+.ipe-layout-title {
+  font-weight: bold;
+  text-transform: uppercase;
 }
 
-div.panels-ipe-pseudobutton-container a.panels-ipe-change-layout {
-  padding-left: 34px;
-  background: url(../images/icon-change-layout.png) no-repeat 10px 9px;
-}
+.ipe-layout-title a {
 
-div.panels-ipe-button-container {
-  margin: 0.3em 0.5em;
-  text-align: center;
 }
 
-form#panels-ipe-edit-control-form {
-  text-align: center;
+/* Hide the tray for the edit and save tabs. */
+[data-tab-content-id="edit"].active, [data-tab-content-id="save"].active {
+  display: none;
 }
diff --git a/panels_ipe/images/dragger.png b/panels_ipe/images/dragger.png
deleted file mode 100644
index bb3b57b..0000000
Binary files a/panels_ipe/images/dragger.png and /dev/null differ
diff --git a/panels_ipe/images/icon-add.png b/panels_ipe/images/icon-add.png
deleted file mode 100644
index 521bec0..0000000
Binary files a/panels_ipe/images/icon-add.png and /dev/null differ
diff --git a/panels_ipe/images/icon-change-layout.png b/panels_ipe/images/icon-change-layout.png
deleted file mode 100644
index 49fa5f2..0000000
Binary files a/panels_ipe/images/icon-change-layout.png and /dev/null differ
diff --git a/panels_ipe/images/icon-close.png b/panels_ipe/images/icon-close.png
deleted file mode 100644
index 7e3f481..0000000
Binary files a/panels_ipe/images/icon-close.png and /dev/null differ
diff --git a/panels_ipe/images/icon-configure.png b/panels_ipe/images/icon-configure.png
deleted file mode 100644
index 348a8f9..0000000
Binary files a/panels_ipe/images/icon-configure.png and /dev/null differ
diff --git a/panels_ipe/images/icon-delete.png b/panels_ipe/images/icon-delete.png
deleted file mode 100644
index 4b3bc18..0000000
Binary files a/panels_ipe/images/icon-delete.png and /dev/null differ
diff --git a/panels_ipe/images/icon-draggable.png b/panels_ipe/images/icon-draggable.png
deleted file mode 100644
index 1a4e7de..0000000
Binary files a/panels_ipe/images/icon-draggable.png and /dev/null differ
diff --git a/panels_ipe/images/icon-save.png b/panels_ipe/images/icon-save.png
deleted file mode 100644
index b456813..0000000
Binary files a/panels_ipe/images/icon-save.png and /dev/null differ
diff --git a/panels_ipe/images/icon-settings.png b/panels_ipe/images/icon-settings.png
deleted file mode 100644
index 19ee4dd..0000000
Binary files a/panels_ipe/images/icon-settings.png and /dev/null differ
diff --git a/panels_ipe/images/icon-style.png b/panels_ipe/images/icon-style.png
deleted file mode 100644
index 7f376e6..0000000
Binary files a/panels_ipe/images/icon-style.png and /dev/null differ
diff --git a/panels_ipe/images/loading-small.gif b/panels_ipe/images/loading-small.gif
deleted file mode 100644
index 5cbf6e7..0000000
Binary files a/panels_ipe/images/loading-small.gif and /dev/null differ
diff --git a/panels_ipe/includes/panels_ipe.pipelines.inc b/panels_ipe/includes/panels_ipe.pipelines.inc
deleted file mode 100644
index 5bc3b03..0000000
--- a/panels_ipe/includes/panels_ipe.pipelines.inc
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-/**
- * @file
- * Bulk export of panels_layouts objects generated by Bulk export module.
- */
-
-/**
- * Implements hook_default_panels_renderer_pipeline().
- */
-function panels_ipe_default_panels_renderer_pipeline() {
-  $pipelines = array();
-
-  $pipeline = new stdClass;
-  $pipeline->disabled = FALSE; /* Edit this to true to make a default pipeline disabled initially */
-  $pipeline->api_version = 1;
-  $pipeline->name = 'ipe';
-  $pipeline->admin_title = t('In-Place Editor');
-  $pipeline->admin_description = t('Allows privileged users to update and rearrange the content while viewing this panel.');
-  $pipeline->weight = 0;
-  $pipeline->settings = array(
-    'renderers' => array(
-      0 => array(
-        'access' => array(
-          'plugins' => array(
-            0 => array(
-              'name' => 'perm',
-              'settings' => array(
-                'perm' => 'use panels in place editing',
-              ),
-              'context' => 'logged-in-user',
-            ),
-          ),
-          'logic' => 'and',
-        ),
-        'renderer' => 'ipe',
-        'options' => array(),
-      ),
-    ),
-  );
-  $pipelines[$pipeline->name] = $pipeline;
-
-  return $pipelines;
-}
diff --git a/panels_ipe/js/models/AppModel.js b/panels_ipe/js/models/AppModel.js
new file mode 100644
index 0000000..8910fdf
--- /dev/null
+++ b/panels_ipe/js/models/AppModel.js
@@ -0,0 +1,67 @@
+/**
+ * @file
+ * The primary Backbone model for Panels IPE.
+ *
+ * @see Drupal.panels_ipe.AppView
+ */
+
+(function (Backbone, Drupal) {
+
+  'use strict';
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Model
+   */
+  Drupal.panels_ipe.AppModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.AppModel# */{
+
+    /**
+     * @type {object}
+     *
+     * @prop {bool} active
+     * @prop {Drupal.panels_ipe.TabModel} activeTab
+     * @prop {Drupal.panels_ipe.BlockModel} activeBlock
+     * @prop {Drupal.panels_ipe.RegionModel} activeRegion
+     */
+    defaults: /** @lends Drupal.panels_ipe.AppModel# */{
+
+      /**
+       * Whether or not the editing part of the application is active.
+       *
+       * @type {bool}
+       */
+      active: false,
+
+      /**
+       * The current Layout.
+       *
+       * @type {Drupal.panels_ipe.LayoutModel}
+       */
+      layout: null,
+
+      /**
+       * A collection of all tabs on screen.
+       *
+       * @type {Drupal.panels_ipe.TabCollection}
+       */
+      tabCollection: null,
+
+      /**
+       * The "Edit" tab.
+       *
+       * @type {Drupal.panels_ipe.TabModel}
+       */
+      editTab: null,
+
+      /**
+       * The "Save" tab.
+       *
+       * @type {Drupal.panels_ipe.TabModel}
+       */
+      saveTab: null
+    }
+
+  });
+
+}(Backbone, Drupal));
diff --git a/panels_ipe/js/models/BlockModel.js b/panels_ipe/js/models/BlockModel.js
new file mode 100644
index 0000000..65a5c98
--- /dev/null
+++ b/panels_ipe/js/models/BlockModel.js
@@ -0,0 +1,78 @@
+/**
+ * @file
+ * Base Backbone model for a Block.
+ */
+
+(function (_, $, Backbone, Drupal, drupalSettings) {
+
+  'use strict';
+
+  Drupal.panels_ipe.BlockModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.BlockModel# */{
+
+    /**
+     * @type {object}
+     */
+    defaults: /** @lends Drupal.panels_ipe.BlockModel# */{
+
+      /**
+       * The block state.
+       *
+       * @type {bool}
+       */
+      active: false,
+
+      /**
+       * The ID of the block.
+       *
+       * @type {string}
+       */
+      id: null,
+
+      /**
+       * The unique ID of the block.
+       *
+       * @type {string}
+       */
+      uuid: null,
+
+      /**
+       * The label of the block.
+       *
+       * @type {string}
+       */
+      label: null,
+
+      /**
+       * The HTML content of the block. This is stored in the model as the
+       * IPE doesn't actually care what the block's content is, the functional
+       * elements of the model are the metadata. The BlockView renders this
+       * wrapped inside IPE elements.
+       *
+       * @type {string}
+       */
+      html: null
+    },
+
+    /**
+     * @type {function}
+     */
+    url: function() {
+      return Drupal.panels_ipe.urlRoot(drupalSettings) + '/block/' + this.get('uuid');
+    }
+
+  });
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Collection
+   */
+  Drupal.panels_ipe.BlockCollection = Backbone.Collection.extend(/** @lends Drupal.panels_ipe.BlockCollection# */{
+
+    /**
+     * @type {Drupal.panels_ipe.BlockModel}
+     */
+    model: Drupal.panels_ipe.BlockModel
+  });
+
+}(_, jQuery, Backbone, Drupal, drupalSettings));
diff --git a/panels_ipe/js/models/LayoutModel.js b/panels_ipe/js/models/LayoutModel.js
new file mode 100644
index 0000000..f0c926b
--- /dev/null
+++ b/panels_ipe/js/models/LayoutModel.js
@@ -0,0 +1,96 @@
+/**
+ * @file
+ * Base Backbone model for a Layout.
+ */
+
+(function (_, $, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.LayoutModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.LayoutModel# */{
+
+    /**
+     * @type {object}
+     */
+    defaults: /** @lends Drupal.panels_ipe.LayoutModel# */{
+
+      /**
+       * The layout machine name.
+       *
+       * @type {string}
+       */
+      id: null,
+
+      /**
+       * The layout label.
+       *
+       * @type {string}
+       */
+      label: null,
+
+      /**
+       * Whether or not this is the current layout.
+       *
+       * @type {bool}
+       */
+      current: false,
+
+      /**
+       * The wrapping HTML for this layout. Only used for initial rendering.
+       *
+       * @type {string}
+       */
+      html: null,
+
+      /**
+       * A collection of regions contained in this Layout.
+       *
+       * @type {Drupal.panels_ipe.RegionCollection}
+       */
+      regionCollection: null
+
+    },
+
+    /**
+     * Overrides the parse method to set our regionCollection dynamically.
+     *
+     * @param {Object} resp
+     * @param {Object} options
+     */
+    parse: function(resp, options) {
+      // If possible, initialize our region collection.
+      if (typeof resp.regions != 'undefined') {
+        resp.regionCollection = new Drupal.panels_ipe.RegionCollection();
+        for (var i in resp.regions) {
+          var region = new Drupal.panels_ipe.RegionModel(resp.regions[i]);
+          region.set({'blockCollection': new Drupal.panels_ipe.BlockCollection()});
+          resp.regionCollection.add(region);
+        }
+      }
+      return resp;
+    }
+
+  });
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Collection
+   */
+  Drupal.panels_ipe.LayoutCollection = Backbone.Collection.extend(/** @lends Drupal.panels_ipe.LayoutCollection# */{
+
+    /**
+     * @type {Drupal.panels_ipe.LayoutModel}
+     */
+    model: Drupal.panels_ipe.LayoutModel,
+
+    /**
+     * @type {function}
+     */
+    url: function() {
+      return Drupal.panels_ipe.urlRoot(drupalSettings) + '/layouts'
+    }
+
+  });
+
+}(_, jQuery, Backbone, Drupal));
diff --git a/panels_ipe/js/models/RegionModel.js b/panels_ipe/js/models/RegionModel.js
new file mode 100644
index 0000000..6a4669b
--- /dev/null
+++ b/panels_ipe/js/models/RegionModel.js
@@ -0,0 +1,59 @@
+/**
+ * @file
+ * Base Backbone model for a Region.
+ *
+ * @todo Support sync operations to refresh a region, even if we don't have
+ * a use case for that yet.
+ */
+
+(function (_, $, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.RegionModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.RegionModel# */{
+
+    /**
+     * @type {object}
+     */
+    defaults: /** @lends Drupal.panels_ipe.RegionModel# */{
+
+      /**
+       * The machine name of the region.
+       *
+       * @type {string}
+       */
+      name: null,
+
+      /**
+       * The label of the region.
+       *
+       * @type {string}
+       */
+      label: null,
+
+      /**
+       * A BlockCollection for all blocks in this region.
+       *
+       * @type {Drupal.panels_ipe.BlockCollection}
+       *
+       * @see Drupal.panels_ipe.BlockCollection
+       */
+      blockCollection: null
+    }
+
+  });
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Collection
+   */
+  Drupal.panels_ipe.RegionCollection = Backbone.Collection.extend(/** @lends Drupal.panels_ipe.RegionCollection# */{
+
+    /**
+     * @type {Drupal.panels_ipe.RegionModel}
+     */
+    model: Drupal.panels_ipe.RegionModel
+  });
+
+}(_, jQuery, Backbone, Drupal));
diff --git a/panels_ipe/js/models/TabModel.js b/panels_ipe/js/models/TabModel.js
new file mode 100644
index 0000000..c4c417c
--- /dev/null
+++ b/panels_ipe/js/models/TabModel.js
@@ -0,0 +1,64 @@
+/**
+ * @file
+ * A .
+ *
+ * @see Drupal.panels_ipe.TabView
+ */
+
+(function (Backbone, Drupal) {
+
+  'use strict';
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Model
+   */
+  Drupal.panels_ipe.TabModel = Backbone.Model.extend(/** @lends Drupal.panels_ipe.TabModel# */{
+
+    /**
+     * @type {object}
+     *
+     * @prop {bool} active
+     * @prop {string} title
+     */
+    defaults: /** @lends Drupal.panels_ipe.TabModel# */{
+
+      /**
+       * The ID of the tab.
+       *
+       * @type {int}
+       */
+      id: null,
+
+      /**
+       * Whether or not the tab is active.
+       *
+       * @type {bool}
+       */
+      active: false,
+
+      /**
+       * The title of the tab.
+       *
+       * @type {string}
+       */
+      title: null
+    }
+
+  });
+
+  /**
+   * @constructor
+   *
+   * @augments Backbone.Collection
+   */
+  Drupal.panels_ipe.TabCollection = Backbone.Collection.extend(/** @lends Drupal.panels_ipe.TabCollection# */{
+
+    /**
+     * @type {Drupal.panels_ipe.TabModel}
+     */
+    model: Drupal.panels_ipe.TabModel
+  });
+
+}(Backbone, Drupal));
diff --git a/panels_ipe/js/panels_ipe.js b/panels_ipe/js/panels_ipe.js
index aad076d..9261bf0 100644
--- a/panels_ipe/js/panels_ipe.js
+++ b/panels_ipe/js/panels_ipe.js
@@ -1,480 +1,107 @@
-
-// Ensure the $ alias is owned by jQuery.
-(function($) {
-
-// randomly lock a pane.
-// @debug only
-Drupal.settings.Panels = Drupal.settings.Panels || {};
-Drupal.settings.Panels.RegionLock = {
-  10: { 'top': false, 'left': true, 'middle': true }
-}
-
-Drupal.PanelsIPE = {
-  editors: {},
-  bindClickDelete: function(context) {
-    $('a.pane-delete:not(.pane-delete-processed)', context)
-      .addClass('pane-delete-processed')
-      .click(function() {
-        if (confirm(Drupal.t('Remove this pane?'))) {
-          $(this).parents('div.panels-ipe-portlet-wrapper').fadeOut('medium', function() {
-            var $sortable = $(this).closest('.ui-sortable');
-            $(this).empty().remove();
-            $sortable.trigger('sortremove');
-          });
-          $(this).parents('div.panels-ipe-display-container').addClass('changed');
-        }
-        return false;
-      });
-  }
-}
-
-
-Drupal.behaviors.PanelsIPE = {
-  attach: function(context) {
-    for (var i in Drupal.settings.PanelsIPECacheKeys) {
-      var key = Drupal.settings.PanelsIPECacheKeys[i];
-      $('div#panels-ipe-display-' + key + ':not(.panels-ipe-processed)')
-        .addClass('panels-ipe-processed')
-        .each(function() {
-          // If we're replacing an old IPE, clean it up a little.
-          if (Drupal.PanelsIPE.editors[key]) {
-            Drupal.PanelsIPE.editors[key].editing = false;
-          }
-          Drupal.PanelsIPE.editors[key] = new DrupalPanelsIPE(key);
-          Drupal.PanelsIPE.editors[key].showContainer();
-        });
-    }
-    $('.panels-ipe-hide-bar').once('panels-ipe-hide-bar-processed').click(function() {
-      Drupal.PanelsIPE.editors[key].hideContainer();
-    });
-    Drupal.PanelsIPE.bindClickDelete(context);
-  }
-};
-
 /**
- * Base object (class) definition for the Panels In-Place Editor.
+ * @file
+ * Attaches behavior for the Panels IPE module.
  *
- * A new instance of this object is instanciated for every unique IPE on a given
- * page.
- *
- * Note that this form is provisional, and we hope to replace it with a more
- * flexible, loosely-coupled model that utilizes separate controllers for the
- * discrete IPE elements. This will result in greater IPE flexibility.
  */
-function DrupalPanelsIPE(cache_key, cfg) {
-  cfg = cfg || {};
-  var ipe = this;
-  this.key = cache_key;
-  this.lockPath = null;
-  this.state = {};
-  this.container = $('#panels-ipe-control-container');
-  this.control = $('div#panels-ipe-control-' + cache_key);
-  this.initButton = $('div.panels-ipe-startedit', this.control);
-  this.cfg = cfg;
-  this.changed = false;
-  this.sortableOptions = $.extend({
-    opacity: 0.75, // opacity of sortable while sorting
-    items: 'div.panels-ipe-portlet-wrapper',
-    handle: 'div.panels-ipe-draghandle',
-    cancel: '.panels-ipe-nodrag',
-    dropOnEmpty: true
-  }, cfg.sortableOptions || {});
-
-  this.regions = [];
-  this.sortables = {};
-
-  $(document).bind('CToolsDetachBehaviors', function() {
-    // If the IPE is off and the container is not visible, then we need
-    // to reshow the container on modal close.
-    if (!$('.panels-ipe-form-container', ipe.control).html() && !ipe.container.is(':visible')) {
-      ipe.showContainer();
-      ipe.cancelLock();
-    }
-
-    // If the IPE is on and we've hidden the bar for a modal, we need to
-    // re-display it.
-    if (ipe.topParent && ipe.topParent.hasClass('panels-ipe-editing') && ipe.container.is(':not(visible)')) {
-      ipe.showContainer();
-    }
-  });
 
+(function ($, _, Backbone, Drupal) {
 
-  // If a user navigates away from a locked IPE, cancel the lock in the background.
-  $(window).bind('beforeunload', function() {
-    if (!ipe.editing) {
-      return;
-    }
-
-    if (ipe.topParent && ipe.topParent.hasClass('changed')) {
-      ipe.changed = true;
-    }
-
-    if (ipe.changed) {
-      return Drupal.t('This will discard all unsaved changes. Are you sure?');
-    }
-  });
-
-  // If a user navigates away from a locked IPE, cancel the lock in the background.
-  $(window).bind('unload', function() {
-    ipe.cancelLock(true);
-  });
+  'use strict';
 
   /**
-   * If something caused us to abort what we were doing, send a background
-   * cancel lock request to the server so that we do not leave stale locks
-   * hanging around.
+   * Contains initial Backbone initialization for the IPE.
+   *
+   * @type {Drupal~behavior}
    */
-  this.cancelLock = function(sync) {
-    // If there's a lockpath and an ajax available, inform server to clear lock.
-    // We borrow the ajax options from the customize this page link.
-    if (ipe.lockPath && Drupal.ajax['panels-ipe-customize-page']) {
-      var ajaxOptions = {
-        type: 'POST',
-        url: ipe.lockPath
-      }
-
-      if (sync) {
-        ajaxOptions.async = false;
-      }
-
-      // Make sure we don't somehow get another one:
-      ipe.lockPath = null;
+  Drupal.behaviors.panels_ipe = {
+    attach: function (context, settings) {
+      // Perform initial setup of our app.
+      $('body').once('panels-ipe-init').each(Drupal.panels_ipe.init, [settings]);
+    },
+    detach: function (context, settings) {
 
-      // Send the request. This is synchronous to prevent being cancelled.
-      $.ajax(ajaxOptions);
-    }
-  }
-
-  this.activateSortable = function(event, ui) {
-    if (!Drupal.settings.Panels || !Drupal.settings.Panels.RegionLock) {
-      // don't bother if there are no region locks in play.
-      return;
-    }
-
-    var region = event.data.region;
-    var paneId = ui.item.attr('id').replace('panels-ipe-paneid-', '');
-
-    var disabledRegions = false;
-
-    // Determined if this pane is locked out of this region.
-    if (!Drupal.settings.Panels.RegionLock[paneId] || Drupal.settings.Panels.RegionLock[paneId][region]) {
-      ipe.sortables[region].sortable('enable');
-      ipe.sortables[region].sortable('refresh');
-    }
-    else {
-      disabledRegions = true;
-      ipe.sortables[region].sortable('disable');
-      ipe.sortables[region].sortable('refresh');
     }
-
-    // If we disabled regions, we need to
-    if (disabledRegions) {
-      $(event.srcElement).bind('dragstop', function(event, ui) {
-        // Go through
-      });
-    }
-  };
-
-  // When dragging is stopped, we need to ensure all sortable regions are enabled.
-  this.enableRegions = function(event, ui) {
-    for (var i in ipe.regions) {
-      ipe.sortables[ipe.regions[i]].sortable('enable');
-      ipe.sortables[ipe.regions[i]].sortable('refresh');
-    }
-  }
-
-  this.initSorting = function() {
-    var $region = $(this).parents('.panels-ipe-region');
-    var region = $region.attr('id').replace('panels-ipe-regionid-', '');
-    ipe.sortables[region] = $(this).sortable(ipe.sortableOptions);
-    ipe.regions.push(region);
-    $(this).bind('sortactivate', {region: region}, ipe.activateSortable);
-  };
-
-  this.initEditing = function(formdata) {
-    ipe.editing = true;
-    ipe.topParent = $('div#panels-ipe-display-' + cache_key);
-    ipe.backup = this.topParent.clone();
-
-    // See http://jqueryui.com/demos/sortable/ for details on the configuration
-    // parameters used here.
-    ipe.changed = false;
-
-    $('div.panels-ipe-sort-container', ipe.topParent).each(ipe.initSorting);
-
-    // Since the connectWith option only does a one-way hookup, iterate over
-    // all sortable regions to connect them with one another.
-    $('div.panels-ipe-sort-container', ipe.topParent)
-      .sortable('option', 'connectWith', ['div.panels-ipe-sort-container']);
-
-    $('div.panels-ipe-sort-container', ipe.topParent).bind('sortupdate', function() {
-      ipe.changed = true;
-    });
-
-    $('div.panels-ipe-sort-container', ipe.topParent).bind('sortstop', this.enableRegions);
-
-    $('.panels-ipe-form-container', ipe.control).append(formdata);
-
-    $('input:submit:not(.ajax-processed)', ipe.control).addClass('ajax-processed').each(function() {
-      var element_settings = {};
-
-      element_settings.url = $(this.form).attr('action');
-      element_settings.setClick = true;
-      element_settings.event = 'click';
-      element_settings.progress = { 'type': 'throbber' };
-      element_settings.ipe_cache_key = cache_key;
-
-      var base = $(this).attr('id');
-      Drupal.ajax[ipe.base] = new Drupal.ajax(base, this, element_settings);
-    });
-
-    // Perform visual effects in a particular sequence.
-    // .show() + .hide() cannot have speeds associated with them, otherwise
-    // it clears out inline styles.
-    $('.panels-ipe-on').show();
-    ipe.showForm();
-    ipe.topParent.addClass('panels-ipe-editing');
-
-  };
-
-  this.hideContainer = function() {
-    ipe.container.slideUp('fast');
   };
 
-  this.showContainer = function() {
-    ipe.container.slideDown('normal');
-  };
-
-  this.showButtons = function() {
-    $('.panels-ipe-form-container').hide();
-    $('.panels-ipe-button-container').show();
-    ipe.showContainer();
-  };
-
-  this.showForm = function() {
-    $('.panels-ipe-button-container').hide();
-    $('.panels-ipe-form-container').show();
-    ipe.showContainer();
-  };
-
-  this.endEditing = function() {
-    ipe.editing = false;
-    ipe.lockPath = null;
-    $('.panels-ipe-form-container').empty();
-    // Re-show all the IPE non-editing meta-elements
-    $('div.panels-ipe-off').show('fast');
-
-    ipe.showButtons();
-    // Re-hide all the IPE meta-elements
-    $('div.panels-ipe-on').hide();
-
-    $('.panels-ipe-editing').removeClass('panels-ipe-editing');
-    $('div.panels-ipe-sort-container.ui-sortable', ipe.topParent).sortable("destroy");
-  };
+  /**
+   * @namespace
+   */
+  Drupal.panels_ipe = {};
 
-  this.saveEditing = function() {
-    $('div.panels-ipe-region', ipe.topParent).each(function() {
-      var val = '';
-      var region = $(this).attr('id').split('panels-ipe-regionid-')[1];
-      $(this).find('div.panels-ipe-portlet-wrapper').each(function() {
-        var id = $(this).attr('id').split('panels-ipe-paneid-')[1];
-        if (id) {
-          if (val) {
-            val += ',';
-          }
-          val += id;
-        }
-      });
-      $('input[name="panel[pane][' +  region + ']"]', ipe.control).val(val);
+  /**
+   * Setups up our initial Collection and Views based on the current settings.
+   */
+  Drupal.panels_ipe.init = function(settings) {
+    // Set up our initial tabs.
+    var tab_collection = new Drupal.panels_ipe.TabCollection();
+    tab_collection.add({title: 'Change Layout', id: 'change_layout'});
+    tab_collection.add({title: 'Manage Content', id: 'manage_content'});
+
+    // The edit and save tabs are special, and are tracked by our app.
+    var edit_tab = new Drupal.panels_ipe.TabModel({title: 'Edit', id: 'edit'});
+    var save_tab = new Drupal.panels_ipe.TabModel({title: 'Save', id: 'save'});
+    tab_collection.add(edit_tab);
+    tab_collection.add(save_tab);
+
+    // Create a global(ish) AppModel.
+    Drupal.panels_ipe.app = new Drupal.panels_ipe.AppModel({
+      'tabCollection': tab_collection,
+      'editTab': edit_tab,
+      'saveTab': save_tab
     });
-  }
 
-  this.cancelIPE = function() {
-    ipe.hideContainer();
-    ipe.topParent.fadeOut('medium', function() {
-      ipe.topParent.replaceWith(ipe.backup.clone());
-      ipe.topParent = $('div#panels-ipe-display-' + ipe.key);
+    // Set up our initial tab views.
+    var tab_views = {
+      'change_layout': new Drupal.panels_ipe.LayoutPicker()
+    };
 
-      // Processing of these things got lost in the cloning, but the classes remained behind.
-      // @todo this isn't ideal but I can't seem to figure out how to keep an unprocessed backup
-      // that will later get processed.
-      $('.ctools-use-modal-processed', ipe.topParent).removeClass('ctools-use-modal-processed');
-      $('.pane-delete-processed', ipe.topParent).removeClass('pane-delete-processed');
-      ipe.topParent.fadeIn('medium');
-      Drupal.attachBehaviors();
+    // Create an AppView instance.
+    var app_view = new Drupal.panels_ipe.AppView({
+      model: Drupal.panels_ipe.app,
+      'el': '#panels-ipe-tray',
+      'tabContentViews': tab_views
     });
-  };
+    $('body').append(app_view.render().$el);
+
+    // Assemble the initial region and block collections.
+    // This logic is a little messy, as traditionally we would never initialize
+    // Backbone with existing HTML content.
+    var region_collection = new Drupal.panels_ipe.RegionCollection();
+    for (var i in settings.panels_ipe.regions) {
+      var region = new Drupal.panels_ipe.RegionModel();
+      region.set(settings.panels_ipe.regions[i]);
+
+      var block_collection = new Drupal.panels_ipe.BlockCollection();
+      for (var j in settings.panels_ipe.regions[i].blocks) {
+        // Add a new block model.
+        var block = new Drupal.panels_ipe.BlockModel();
+        block.set(settings.panels_ipe.regions[i].blocks[j]);
+        block_collection.add(block);
+      }
 
-  this.cancelEditing = function() {
-    if (ipe.topParent.hasClass('changed')) {
-      ipe.changed = true;
-    }
+      region.set({'blockCollection': block_collection});
 
-    if (!ipe.changed || confirm(Drupal.t('This will discard all unsaved changes. Are you sure?'))) {
-      this.cancelIPE();
-      return true;
+      region_collection.add(region);
     }
-    else {
-      // Cancel the submission.
-      return false;
-    }
-  };
 
-  this.createSortContainers = function() {
-    $('div.panels-ipe-region', this.topParent).each(function() {
-      $(this).children('div.panels-ipe-portlet-marker').parent()
-        .wrapInner('<div class="panels-ipe-sort-container" />');
-
-      // Move our gadgets outside of the sort container so that sortables
-      // cannot be placed after them.
-      $('div.panels-ipe-portlet-static', this).each(function() {
-        $(this).prependTo($(this).parent().parent());
-      });
+    // Create the Layout model/view.
+    var layout = new Drupal.panels_ipe.LayoutModel(settings.panels_ipe.layout);
+    layout.set({'regionCollection': region_collection});
+    var layout_view = new Drupal.panels_ipe.LayoutView({
+      'model': layout,
+      'el': "#panels-ipe-content"
     });
-  }
-
-  this.createSortContainers();
-
-};
-
-$(function() {
-  Drupal.ajax.prototype.commands.initIPE = function(ajax, data, status) {
-    if (Drupal.PanelsIPE.editors[data.key]) {
-      Drupal.PanelsIPE.editors[data.key].initEditing(data.data);
-      Drupal.PanelsIPE.editors[data.key].lockPath = data.lockPath;
-    }
-    Drupal.attachBehaviors();
-
-  };
-
-  Drupal.ajax.prototype.commands.IPEsetLockState = function(ajax, data, status) {
-    if (Drupal.PanelsIPE.editors[data.key]) {
-      Drupal.PanelsIPE.editors[data.key].lockPath = data.lockPath;
-    }
-  };
-
-  Drupal.ajax.prototype.commands.addNewPane = function(ajax, data, status) {
-    if (Drupal.PanelsIPE.editors[data.key]) {
-      Drupal.PanelsIPE.editors[data.key].changed = true;
-    }
-  };
-
-  Drupal.ajax.prototype.commands.cancelIPE = function(ajax, data, status) {
-    if (Drupal.PanelsIPE.editors[data.key]) {
-      Drupal.PanelsIPE.editors[data.key].cancelIPE();
-      Drupal.PanelsIPE.editors[data.key].endEditing();
-    }
-  };
-
-  Drupal.ajax.prototype.commands.unlockIPE = function(ajax, data, status) {
-    if (confirm(data.message)) {
-      var ajaxOptions = ajax.options;
-      ajaxOptions.url = data.break_path;
-      $.ajax(ajaxOptions);
-    }
-    else {
-      Drupal.PanelsIPE.editors[data.key].endEditing();
-    }
-  };
-
-  Drupal.ajax.prototype.commands.endIPE = function(ajax, data, status) {
-    if (Drupal.PanelsIPE.editors[data.key]) {
-      Drupal.PanelsIPE.editors[data.key].endEditing();
-    }
-  };
-
-  Drupal.ajax.prototype.commands.insertNewPane = function(ajax, data, status) {
-    IPEContainerSelector = '#panels-ipe-regionid-' + data.regionId + ' div.panels-ipe-sort-container';
-    firstPaneSelector = IPEContainerSelector + ' div.panels-ipe-portlet-wrapper:first';
-    // Insert the new pane before the first existing pane in the region, if
-    // any.
-    if ($(firstPaneSelector).length) {
-      insertData = {
-        'method': 'before',
-        'selector': firstPaneSelector,
-        'data': data.renderedPane,
-        'settings': null
-      }
-      Drupal.ajax.prototype.commands.insert(ajax, insertData, status);
-    }
-    // Else, insert it as a first child of the container. Doing so might fall
-    // outside of the wrapping markup for the style, but it's the best we can
-    // do.
-    else {
-      insertData = {
-        'method': 'prepend',
-        'selector': IPEContainerSelector,
-        'data': data.renderedPane,
-        'settings': null
-      }
-      Drupal.ajax.prototype.commands.insert(ajax, insertData, status);
-    }
-  };
-
-  /**
-   * Override the eventResponse on ajax.js so we can add a little extra
-   * behavior.
-   */
-  Drupal.ajax.prototype.ipeReplacedEventResponse = Drupal.ajax.prototype.eventResponse;
-  Drupal.ajax.prototype.eventResponse = function (element, event) {
-    if (element.ipeCancelThis) {
-      element.ipeCancelThis = null;
-      return false;
-    }
+    layout_view.render();
 
-    if ($(this.element).attr('id') == 'panels-ipe-cancel') {
-      if (!Drupal.PanelsIPE.editors[this.element_settings.ipe_cache_key].cancelEditing()) {
-        return false;
-      }
-    }
-
-    var retval = this.ipeReplacedEventResponse(element, event);
-    if (this.ajaxing && this.element_settings.ipe_cache_key) {
-      // Move the throbber so that it appears outside our container.
-      if (this.progress.element) {
-        $(this.progress.element).addClass('ipe-throbber').appendTo($('body'));
-      }
-      Drupal.PanelsIPE.editors[this.element_settings.ipe_cache_key].hideContainer();
-    }
-    // @TODO $('#panels-ipe-throbber-backdrop').remove();
-    return retval;
+    Drupal.panels_ipe.app.set({'layout': layout});
+    app_view.layoutView = layout_view;
   };
 
   /**
-   * Override the eventResponse on ajax.js so we can add a little extra
-   * behavior.
+   * Returns the urlRoot for all callbacks
    */
-  Drupal.ajax.prototype.ipeReplacedError = Drupal.ajax.prototype.error;
-  Drupal.ajax.prototype.error = function (response, uri) {
-    var retval = this.ipeReplacedError(response, uri);
-    if (this.element_settings.ipe_cache_key) {
-      Drupal.PanelsIPE.editors[this.element_settings.ipe_cache_key].showContainer();
-    }
+  Drupal.panels_ipe.urlRoot = function(settings) {
+    return '/admin/panels_ipe/variant/' + settings.panels_ipe.display_variant.id;
   };
 
-  Drupal.ajax.prototype.ipeReplacedBeforeSerialize = Drupal.ajax.prototype.beforeSerialize;
-  Drupal.ajax.prototype.beforeSerialize = function (element_settings, options) {
-    if ($(this.element).hasClass('panels-ipe-save')) {
-      Drupal.PanelsIPE.editors[this.element_settings.ipe_cache_key].saveEditing();
-    };
-    return this.ipeReplacedBeforeSerialize(element_settings, options);
-  };
-
-});
-
-/**
- * Apply margin to bottom of the page.
- *
- * Note that directly applying marginBottom does not work in IE. To prevent
- * flickering/jumping page content with client-side caching, this is a regular
- * Drupal behavior.
- *
- * @see admin_menu.js via https://drupal.org/project/admin_menu
- */
-Drupal.behaviors.panelsIpeMarginBottom = {
-  attach: function () {
-    $('body:not(.panels-ipe)').addClass('panels-ipe');
-  }
-};
-
-})(jQuery);
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/AppView.js b/panels_ipe/js/views/AppView.js
new file mode 100644
index 0000000..3b97cf9
--- /dev/null
+++ b/panels_ipe/js/views/AppView.js
@@ -0,0 +1,165 @@
+/**
+ * @file
+ * The primary Backbone view for Panels IPE. For now this only controls the
+ * bottom tray, but in the future could have a larger scope.
+ *
+ * see Drupal.panels_ipe.AppModel
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.AppView = Backbone.View.extend(/** @lends Drupal.panels_ipe.AppView# */{
+
+    /**
+     * @type {function}
+     */
+    template: _.template('<div class="ipe-tab-wrapper"></div>'),
+
+    /**
+     * @type {Drupal.panels_ipe.TabsView}
+     */
+    tabsView: null,
+
+    /**
+     * @type {Drupal.panels_ipe.LayoutView}
+     */
+    layoutView: null,
+
+    /**
+     * @type {Drupal.panels_ipe.AppModel}
+     */
+    model: null,
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.View
+     *
+     * @param {object} options
+     *   An object with the following keys:
+     * @param {Drupal.panels_ipe.AppModel} options.model
+     *   The application state model.
+     * @param {Object} options.tabContentViews
+     *   An object mapping TabModel ids to arbitrary Backbone views.
+     */
+    initialize: function (options) {
+      this.model = options.model;
+
+      // Create a TabsView instance.
+      this.tabsView = new Drupal.panels_ipe.TabsView({
+        'collection': this.model.get('tabCollection'),
+        'tabViews': options.tabContentViews
+      });
+
+      // Listen to important events throughout the app.
+      this.listenTo(this.model, "changeLayout", this.changeLayout);
+      this.listenTo(this.model.get('editTab'), "change:active", this.clickEditTab);
+    },
+
+    /**
+     * Appends the IPE tray to the bottom of the screen.
+     */
+    render: function() {
+      // Empty our list.
+      this.$el.html(this.template(this.model.toJSON()));
+      // Add our tab collection to the App.
+      this.tabsView.setElement(this.$('.ipe-tab-wrapper')).render();
+      // Re-render our layout.
+      if (this.layoutView) {
+        this.layoutView.render();
+      }
+      return this;
+    },
+
+    /**
+     * Actives all regions and blocks for editing.
+     */
+    openIPE: function() {
+      var active = this.model.get('active');
+      if (active) {
+        return;
+      }
+
+      // Set our active state correctly.
+      this.model.set({'active': true});
+
+      // Set the layout's active state correctly.
+      this.model.get('layout').set({'active': true});
+
+      this.$el.addClass('active');
+    },
+
+    /**
+     * Deactivate all regions and blocks for editing.
+     */
+    closeIPE: function() {
+      var active = this.model.get('active');
+      if (!active) {
+        return;
+      }
+
+      // Set our active state correctly.
+      this.model.set({'active': false});
+
+      // Set the layout's active state correctly.
+      this.model.get('layout').set({'active': false});
+
+      this.$el.removeClass('active');
+    },
+
+    /**
+     * Event callback for when a new layout has been selected.
+     */
+    changeLayout: function (args) {
+      // Grab the layout from the argument list.
+      var layout = args[0];
+
+      // Sync the layout from Drupal.
+      var self = this;
+      layout.fetch().done(function() {
+        // Grab all the blocks from the current layout.
+        var regions = self.model.get('layout').get('regionCollection');
+        var block_collection = new Drupal.panels_ipe.BlockCollection();
+        regions.each(function(region) {
+          block_collection.add(region.get('blockCollection').toJSON());
+        });
+
+        // Get the first region in the layout.
+        // @todo Be smarter about re-adding blocks.
+        var first_region = layout.get('regionCollection').at(0);
+
+        // Append all blocks from previous layout.
+        first_region.set({'blockCollection': block_collection});
+
+        // Change the default layout in our AppModel.
+        self.model.set({'layout': layout});
+
+        // Create a new LayoutView.
+        self.layoutView = new Drupal.panels_ipe.LayoutView({
+          'model': layout,
+          'el': "#panels-ipe-content"
+        });
+
+        // Re-render the app.
+        self.render();
+      });
+    },
+
+    /**
+     * Sets the IPE active state based on the "Edit" TabModel.
+     */
+    clickEditTab: function(){
+      var active = this.model.get('editTab').get('active');
+      if (active) {
+        this.openIPE();
+      }
+      else {
+        this.closeIPE();
+      }
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/BlockView.js b/panels_ipe/js/views/BlockView.js
new file mode 100644
index 0000000..2fab8f8
--- /dev/null
+++ b/panels_ipe/js/views/BlockView.js
@@ -0,0 +1,61 @@
+/**
+ * @file
+ * The primary Backbone view for a Block.
+ *
+ * see Drupal.panels_ipe.BlockModel
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.BlockView = Backbone.View.extend(/** @lends Drupal.panels_ipe.BlockView# */{
+
+    /**
+     * @type {function}
+     */
+    template: _.template('<div class="panels-ipe-header"><h5>Block: <%= label %></h5></div>'),
+
+    /**
+     * @type {Drupal.panels_ipe.BlockModel}
+     */
+    model: null,
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.View
+     *
+     * @param {object} options
+     *   An object with the following keys:
+     * @param {Drupal.panels_ipe.BlockModel} options.model
+     *   The block state model.
+     * @param {string} options.el
+     *   An optional selector if an existing element is already on screen.
+     */
+    initialize: function (options) {
+      this.model = options.model;
+      if (options.el && !this.model.get('html')) {
+        this.model.set({'html': this.$el.prop('outerHTML')});
+      }
+      this.listenTo(this.model, 'reset', this.render);
+    },
+
+    /**
+     * Renders the wrapping elements and refreshes a block model.
+     */
+    render: function() {
+      // Replace our current HTML.
+      this.$el.replaceWith(this.model.get('html'));
+      this.setElement("[data-block-id='" + this.model.get('uuid') + "']");
+
+      // Check our active state.
+      if (this.model.get('active')) {
+        this.$el.prepend(this.template(this.model.toJSON()));
+      }
+      return this;
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/LayoutPicker.js b/panels_ipe/js/views/LayoutPicker.js
new file mode 100644
index 0000000..9656069
--- /dev/null
+++ b/panels_ipe/js/views/LayoutPicker.js
@@ -0,0 +1,99 @@
+/**
+ * @file
+ * Renders a collection of Layouts for selection.
+ *
+ * see Drupal.panels_ipe.LayoutCollection
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.LayoutPicker = Backbone.View.extend(/** @lends Drupal.panels_ipe.LayoutPicker# */{
+
+    /**
+     * @type {function}
+     */
+    template_layout: _.template('<li class="ipe-layout" data-layout-id="<%= id %>"><h5 class="ipe-layout-title"><a><%= label %></a></h5></li>'),
+
+    /**
+     * @type {function}
+     */
+    template_current: _.template('<p>Current Layout: </p><h5 class="ipe-layout-title"><%= label %></h5>'),
+
+    /**
+     * @type {function}
+     */
+    template: _.template(
+      '<div class="ipe-current-layout"></div><div class="ipe-all-layouts"><p>Available Layouts:</p><ul class="ipe-layouts"></ul></div>'
+    ),
+
+    /**
+     * @type {Drupal.panels_ipe.LayoutCollection}
+     */
+    collection: null,
+
+    /**
+     * @type {object}
+     */
+    events: {
+      'click .ipe-layout': 'selectLayout'
+    },
+
+    /**
+     * Renders the selection menu for picking Layouts.
+     */
+    render: function() {
+      // If we don't have layouts yet, pull some from the server.
+      if (!this.collection) {
+        this.collection = new Drupal.panels_ipe.LayoutCollection;
+        var self = this;
+        this.collection.fetch().done(function(){
+          self.render();
+        });
+      }
+      // Render our LayoutCollection.
+      else {
+        this.$el.empty();
+
+        // Setup the empty list.
+        this.$el.html(this.template());
+
+        // Append each layout option.
+        this.collection.each(function(layout) {
+          if (!layout.get('current')) {
+            this.$('.ipe-layouts').append(this.template_layout(layout.toJSON()));
+          }
+          else {
+            this.$('.ipe-current-layout').append(this.template_current(layout.toJSON()));
+          }
+        }, this);
+      }
+    },
+
+    /**
+     * Fires a global Backbone event that the App watches to switch layouts.
+     */
+    selectLayout: function(e) {
+      e.preventDefault();
+      var id = $(e.currentTarget).data('layout-id');
+
+      // Unset the current tab.
+      this.collection.each(function(layout) {
+        if (id == layout.id) {
+          layout.set('current', true);
+          // @todo Investigate using non-global events.
+          Drupal.panels_ipe.app.trigger('changeLayout', [layout]);
+        }
+        else {
+          layout.set('current', false);
+        }
+      });
+
+      // Trigger a re-render.
+      this.render();
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/LayoutView.js b/panels_ipe/js/views/LayoutView.js
new file mode 100644
index 0000000..7eb8d58
--- /dev/null
+++ b/panels_ipe/js/views/LayoutView.js
@@ -0,0 +1,108 @@
+/**
+ * @file
+ * The primary Backbone view for a Layout.
+ *
+ * see Drupal.panels_ipe.LayoutModel
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  'use strict';
+
+  Drupal.panels_ipe.LayoutView = Backbone.View.extend(/** @lends Drupal.panels_ipe.LayoutView# */{
+
+    /**
+     * @type {Drupal.panels_ipe.LayoutModel}
+     */
+    model: null,
+
+    /**
+     * @type {Array}
+     *   An array of child Drupal.panels_ipe.BlockView objects.
+     */
+    blockViews: [],
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.View
+     *
+     * @param {object} options
+     *   An object with the following keys:
+     * @param {Drupal.panels_ipe.LayoutModel} options.model
+     *   The layout state model.
+     * @param {string} options.el
+     *   An optional selector if an existing element is already on screen.
+     */
+    initialize: function (options) {
+      this.model = options.model;
+      // Initialiaze our html, this never changes.
+      if (this.model.get('html')) {
+        this.$el.html(this.model.get('html'));
+      }
+      // Initialize our Block Views, if HTML is already provided to us.
+      if (this.el) {
+        this.initBlockViews();
+      }
+      this.listenTo(this.model, 'change:active', this.changeState);
+    },
+
+    /**
+     * Re-renders our blocks, we have no HTML to be re-rendered.
+     */
+    render: function() {
+      // Re-render all of our blocks.
+      for (var i in this.blockViews) {
+        this.blockViews[i].render();
+      }
+      return this;
+    },
+
+    changeState: function(model, value, options) {
+      // Set all of our blocks active as well.
+      this.model.get('regionCollection').each(function (region) {
+        region.get('blockCollection').each(function (block) {
+            block.set({'active': value});
+        });
+      });
+      // Re-render all blocks.
+      this.render();
+    },
+
+    /**
+     * Initializes our blockViews property if HTML is provided to us.
+     *
+     * If anything in our BlockCollection isn't already on screen, this
+     * function will also fetch new HTML from the server and render that.
+     */
+    initBlockViews: function() {
+      this.model.get('regionCollection').each(function (region) {
+        region.get('blockCollection').each(function (block) {
+
+          // If the target element doesn't exist, append an empty one.
+          // The "empty_elem" variable will be later used to trigger a
+          // BlockModel.fetch() call, which will re-render and remove our
+          // placeholder.
+          if (this.$("[data-block-id='" + block.get('uuid') + "']").length == 0) {
+            var empty_elem = $('<div data-block-id="' + block.get('uuid') + '">');
+            this.$('[data-region-name="' + region.get('name') + '"]').append(empty_elem);
+          }
+
+          // Attach a View to this empty element.
+          this.blockViews.push(new Drupal.panels_ipe.BlockView({
+            'model': block,
+            'el': "[data-block-id='" + block.get('uuid') + "']"
+          }));
+
+          // Fetch the block content from the server
+          if (typeof empty_elem != 'undefined') {
+            block.fetch();
+          }
+
+        }, this);
+      }, this);
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/js/views/TabsView.js b/panels_ipe/js/views/TabsView.js
new file mode 100644
index 0000000..bd446fb
--- /dev/null
+++ b/panels_ipe/js/views/TabsView.js
@@ -0,0 +1,111 @@
+/**
+ * @file
+ * The primary Backbone view for a tab collection.
+ *
+ * see Drupal.panels_ipe.TabCollection
+ */
+
+(function ($, _, Backbone, Drupal) {
+
+  Drupal.panels_ipe.TabsView = Backbone.View.extend(/** @lends Drupal.panels_ipe.TabsView# */{
+
+    /**
+     * @type {function}
+     */
+    template_tab: _.template('<li class="ipe-tab<% if (active) { %> active<% } %>" data-tab-id="<%= id %>"><a><%= title %></a></li>'),
+
+    /**
+     * @type {function}
+     */
+    template_content: _.template('<div class="ipe-tab-content<% if (active) { %> active<% } %>" data-tab-content-id="<%= id %>"></div>'),
+
+    /**
+     * @type {object}
+     */
+    events: {
+      'click .ipe-tab > a': 'switchTab'
+    },
+
+    /**
+     * @type {Drupal.panels_ipe.TabCollection}
+     */
+    collection: null,
+
+    /**
+     * @type {Object}
+     *
+     * An object mapping tab IDs to Backbone views.
+     */
+    tabViews: {},
+
+    /**
+     * @constructs
+     *
+     * @augments Backbone.TabsView
+     *
+     * @param {object} options
+     *   An object with the following keys:
+     * @param {object} options.tabViews
+     *   An object mapping tab IDs to Backbone views.
+     */
+    initialize: function (options) {
+      this.tabViews = options.tabViews;
+    },
+
+    /**
+     * Renders our tab collection.
+     */
+    render: function() {
+      // Empty our list.
+      this.$el.empty();
+
+      // Setup the initial wrapping elements.
+      this.$el.append('<ul class="ipe-tabs"></ul>');
+      this.$el.append('<div class="ipe-tabs-content"></div>');
+
+      // Append each of our tabs and their tab content view.
+      this.collection.each(function(tab) {
+        // Append the tab.
+        var id = tab.get('id');
+        this.$('.ipe-tabs').append(this.template_tab(tab.toJSON()));
+
+        // Render the tab content.
+        this.$('.ipe-tabs-content').append(this.template_content(tab.toJSON()));
+        // Check to see if this tab has content.
+        if (tab.get('active') && this.tabViews[id]) {
+          this.tabViews[id].setElement('[data-tab-content-id="' + id + '"]').render();
+        }
+      }, this);
+      return this;
+    },
+
+    /**
+     * Switches the current tab.
+     */
+    switchTab: function(e) {
+      e.preventDefault();
+      var id = $(e.currentTarget).parent().data('tab-id');
+
+      // Disable all existing tabs.
+      this.collection.each(function(tab) {
+        // If the user is clicking the same tab twice, close it.
+        if (tab.get('id') == id && tab.get('active') == true) {
+          tab.set('active', false);
+        }
+        // If this is the first click, open the tab.
+        else if (tab.get('id') == id) {
+          tab.set('active', true);
+        }
+        // The tab wasn't clicked, make sure it's closed.
+        else {
+          tab.set('active', false);
+        }
+      });
+
+      // Trigger a re-render.
+      this.render();
+    }
+
+  });
+
+}(jQuery, _, Backbone, Drupal));
diff --git a/panels_ipe/panels_ipe.info b/panels_ipe/panels_ipe.info
deleted file mode 100644
index b0172a7..0000000
--- a/panels_ipe/panels_ipe.info
+++ /dev/null
@@ -1,7 +0,0 @@
-name = Panels In-Place Editor
-description = Provide a UI for managing some Panels directly on the frontend, instead of having to use the backend.
-package = "Panels"
-dependencies[] = panels
-core = 7.x
-configure = admin/structure/panels
-files[] = panels_ipe.module
diff --git a/panels_ipe/panels_ipe.info.yml b/panels_ipe/panels_ipe.info.yml
new file mode 100644
index 0000000..9786c02
--- /dev/null
+++ b/panels_ipe/panels_ipe.info.yml
@@ -0,0 +1,8 @@
+name: Panels IPE
+type: module
+description: Panels In-place editor.
+core: 8.x
+package: Panels
+dependencies:
+  - panels
+  - page_manager
\ No newline at end of file
diff --git a/panels_ipe/panels_ipe.libraries.yml b/panels_ipe/panels_ipe.libraries.yml
new file mode 100644
index 0000000..6f38e6e
--- /dev/null
+++ b/panels_ipe/panels_ipe.libraries.yml
@@ -0,0 +1,35 @@
+panels_ipe:
+  version: VERSION
+  js:
+    # Core.
+    js/panels_ipe.js: {}
+    # Models.
+    js/models/AppModel.js: {}
+    js/models/BlockModel.js: {}
+    js/models/RegionModel.js: {}
+    js/models/TabModel.js: {}
+    js/models/LayoutModel.js: {}
+    # Views.
+    js/views/AppView.js: {}
+    js/views/BlockView.js: {}
+    js/views/LayoutPicker.js: {}
+    js/views/LayoutView.js: {}
+    js/views/TabsView.js: {}
+  css:
+    component:
+      css/panels_ipe.css: {}
+  dependencies:
+    - core/jquery
+    - core/jquery.once
+    - core/jquery.ui
+    - core/jquery.ui.draggable
+    - core/jquery.form
+    - core/underscore
+    - core/backbone
+    - core/drupal
+    - core/drupal.displace
+    - core/drupal.form
+    - core/drupal.ajax
+    - core/drupal.debounce
+    - core/drupalSettings
+    - core/drupal.dialog
diff --git a/panels_ipe/panels_ipe.module b/panels_ipe/panels_ipe.module
index c309684..18d7d94 100644
--- a/panels_ipe/panels_ipe.module
+++ b/panels_ipe/panels_ipe.module
@@ -1,266 +1,26 @@
 <?php
 
 /**
- * Implementation of hook_ctools_plugin_directory().
+ * @file
+ * Contains panels_ipe.module.
  */
-function panels_ipe_ctools_plugin_directory($module, $plugin) {
-  if ($module == 'panels' && $plugin == 'display_renderers') {
-    return 'plugins/' . $plugin;
-  }
-}
-
-/**
- * Implementation of hook_ctools_plugin_api().
- *
- * Inform CTools about version information for various plugins implemented by
- * Panels.
- *
- * @param string $owner
- *   The system name of the module owning the API about which information is
- *   being requested.
- * @param string $api
- *   The name of the API about which information is being requested.
- */
-function panels_ipe_ctools_plugin_api($owner, $api) {
-  if ($owner == 'panels' && $api == 'pipelines') {
-    return array(
-      'version' => 1,
-      'path' => drupal_get_path('module', 'panels_ipe') . '/includes',
-    );
-  }
-}
 
-/**
- * Implementation of hook_theme().
- */
-function panels_ipe_theme() {
-  return array(
-    'panels_ipe_pane_wrapper' => array(
-      'variables' => array('output' => NULL, 'pane' => NULL, 'display' => NULL, 'renderer' => NULL),
-    ),
-    'panels_ipe_region_wrapper' => array(
-      'variables' => array('output' => NULL, 'region_id' => NULL, 'display' => NULL, 'controls' => NULL, 'renderer' => NULL),
-    ),
-    'panels_ipe_add_pane_button' => array(
-      'variables' => array('region_id' => NULL, 'display' => NULL, 'renderer' => NULL),
-    ),
-    'panels_ipe_placeholder_pane' => array(
-      'variables' => array('region_id' => NULL, 'region_title' => NULL),
-    ),
-    'panels_ipe_dnd_form_container' => array(
-      'variables' => array('link' => NULL, 'cache_key' => NULL, 'display' => NULL),
-    ),
-    'panels_ipe_toolbar' => array(
-      'variables' => array('buttons' => NULL),
-    ),
-  );
-}
+use Drupal\Core\Routing\RouteMatchInterface;
 
 /**
- * Theme the 'placeholder' pane, which is shown on an active IPE when no panes
- * live in that region.
- *
- * @param string $region_id
- * @param string $region_title
+ * Implements hook_help().
  */
-function theme_panels_ipe_placeholder_pane($vars) {
-  $region_id = $vars['region_id'];
-  $region_title = $vars['region_title'];
-
-  $output = '<div class="panels-ipe-placeholder-content">';
-  $output .= "<h3>$region_title</h3>";
-  $output .= '</div>';
-  return $output;
-}
-
-function template_preprocess_panels_ipe_pane_wrapper(&$vars) {
-  $pane = $vars['pane'];
-  $display = $vars['display'];
-  $renderer = $vars['renderer'];
-
-  $content_type = ctools_get_content_type($pane->type);
-  $subtype = ctools_content_get_subtype($content_type, $pane->subtype);
-  $vars['links'] = array();
-
-  if (ctools_content_editable($content_type, $subtype, $pane->configuration)) {
-    $vars['links']['edit'] = array(
-      'title' => isset($content_type['edit text']) ? '<span>' . $content_type['edit text'] . '</span>' : '<span>' . t('Settings') . '</span>',
-      'href' => $renderer->get_url('edit-pane', $pane->pid),
-      'html' => TRUE,
-      'attributes' => array(
-        'class' => array('ctools-use-modal', 'panels-ipe-hide-bar'),
-        'title' => isset($content_type['edit text']) ? $content_type['edit text'] : t('Settings'),
-        // 'id' => "pane-edit-panel-pane-$pane->pid",
-      ),
-    );
-  }
+function panels_ipe_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    // Main module help for the panels_ipe module.
+    case 'help.page.panels_ipe':
+      $output = '';
+      $output .= '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('Panels In-place editor.') . '</p>';
+      // @todo: Add useful help text for Panels In-place editor.
+      return $output;
 
-  // Add option to configure style in IPE
-  if (user_access('administer panels styles')) {
-    $vars['links']['style'] = array(
-      'title' => '<span>' . t('Style') . '</span>',
-      'href' => $renderer->get_url('style-type', 'pane', $pane->pid),
-      'html' => TRUE,
-      'attributes' => array(
-        'class' => array('ctools-use-modal', 'panels-ipe-hide-bar'),
-        'title' => t('Style'),
-      ),
-    );
+    default:
   }
-
-  // Deleting is managed entirely in the js; this is just an attachment point
-  // for it
-  $vars['links']['delete'] = array(
-    'title' => '<span>' . t('Delete') . '</span>',
-    'href' => '#',
-    'html' => TRUE,
-    'attributes' => array(
-      'class' => 'pane-delete',
-      'id' => "pane-delete-panel-pane-$pane->pid",
-      'title' => t('Delete'),
-    ),
-  );
-
-  $context = array(
-    'pane' => $pane,
-    'display' => $display,
-    'renderer' => $renderer
-  );
-  drupal_alter('panels_ipe_pane_links', $vars['links'], $context);
-
-}
-
-function theme_panels_ipe_pane_wrapper($vars) {
-  $output = $vars['output'];
-  $pane = $vars['pane'];
-
-  $attributes = array(
-    'class' => 'panels-ipe-linkbar',
-  );
-
-  $links = theme('links', array('links' => $vars['links'], 'attributes' => $attributes));
-
-  if (!empty($pane->locks['type']) && $pane->locks['type'] == 'immovable') {
-    $links = '<div class="panels-ipe-dragbar panels-ipe-nodraghandle clearfix">' . $links . '</div>';
-  }
-  else {
-    $links = '<div class="panels-ipe-dragbar panels-ipe-draghandle clearfix">' . $links . '<span class="panels-ipe-draghandle-icon"><span class="panels-ipe-draghandle-icon-inner"></span></span></div>';
-  }
-
-  $handlebar = '<div class="panels-ipe-handlebar-wrapper panels-ipe-on">' . $links . '</div>';
-
-  return $handlebar . $output;
 }
 
-function theme_panels_ipe_region_wrapper($vars) {
-  return $vars['controls'] . $vars['output'];
-}
-
-function template_preprocess_panels_ipe_add_pane_button(&$vars) {
-  $region_id = $vars['region_id'];
-  $display = $vars['display'];
-  $renderer = $vars['renderer'];
-  $vars['links'] = '';
-
-  // Add option to configure style in IPE
-  if (user_access('administer panels styles')) {
-    $vars['links']['style'] = array(
-      'title' => '<span>' . t('Region style') . '</span>',
-      'href' => $renderer->get_url('style-type', 'region', $region_id),
-      'html' => TRUE,
-      'attributes' => array(
-        'class' => array('ctools-use-modal', 'panels-ipe-hide-bar', 'style'),
-        'title' => t('Region style'),
-      ),
-    );
-  }
-
-  // Add option to add items in the IPE
-  $vars['links']['add-pane'] = array(
-    'title' => '<span>' . t('Add new pane') . '</span>',
-    'href' => $renderer->get_url('select-content', $region_id),
-    'attributes' => array(
-      'class' => array('ctools-use-modal', 'add', 'panels-ipe-hide-bar'),
-      'title' => t('Add new pane'),
-    ),
-    'html' => TRUE,
-  );
-
-  $context = array(
-    'region_id' => $region_id,
-    'display' => $display,
-    'renderer' => $renderer,
-  );
-  drupal_alter('panels_ipe_region_links', $vars['links'], $context);
-
-}
-
-function theme_panels_ipe_add_pane_button($vars) {
-  $attributes = array(
-    'class' => array('panels-ipe-linkbar', 'inline'),
-  );
-
-  $links = theme('links', array('links' => $vars['links'], 'attributes' => $attributes));
-
-  return '<div class="panels-ipe-newblock panels-ipe-on">' . $links . '</div>';
-}
-
-/**
- * @deprecated
- */
-function panels_ipe_get_cache_key($key = NULL) {
-  return array();
-}
-
-/**
- * Add a button to the IPE toolbar.
- */
-function panels_ipe_toolbar_add_button($cache_key, $id, $button) {
-  $buttons = &drupal_static('panels_ipe_toolbar_buttons', array());
-  $buttons[$cache_key][$id] = $button;
-}
-
-/**
- * Implementation of hook_footer()
- *
- * Adds the IPE control container.
- *
- * @param unknown_type $main
- */
-function panels_ipe_page_alter(&$page) {
-  $buttons = &drupal_static('panels_ipe_toolbar_buttons', array());
-  if (empty($buttons)) {
-    return;
-  }
-
-  $output = theme('panels_ipe_toolbar', array('buttons' => $buttons));
-
-  $page['page_bottom']['panels_ipe'] = array(
-    '#markup' => $output,
-  );
-}
-
-function theme_panels_ipe_toolbar($vars) {
-  $buttons = $vars['buttons'];
-
-  $output = "<div id='panels-ipe-control-container' class='clearfix'>";
-  foreach ($buttons as $key => $ipe_buttons) {
-    $output .= "<div id='panels-ipe-control-$key' class='panels-ipe-control'>";
-
-    // Controls in this container will appear when the IPE is not on.
-    $output .= '<div class="panels-ipe-button-container clearfix">';
-    foreach ($ipe_buttons as $button) {
-      $output .= is_string($button) ? $button : drupal_render($button);
-    }
-    $output .= '</div>';
-
-    // Controls in this container will appear when the IPE is on. It is usually
-    // filled via AJAX.
-    $output .= '<div class="panels-ipe-form-container clearfix"></div>';
-    $output .= '</div>';
-  }
-
-  $output .= "</div>";
-
-  return $output;
-}
diff --git a/panels_ipe/panels_ipe.permissions.yml b/panels_ipe/panels_ipe.permissions.yml
new file mode 100644
index 0000000..6350b38
--- /dev/null
+++ b/panels_ipe/panels_ipe.permissions.yml
@@ -0,0 +1,2 @@
+access panels in-place editing:
+  title: 'Access panels in-place editing'
\ No newline at end of file
diff --git a/panels_ipe/panels_ipe.routing.yml b/panels_ipe/panels_ipe.routing.yml
new file mode 100644
index 0000000..a769bd2
--- /dev/null
+++ b/panels_ipe/panels_ipe.routing.yml
@@ -0,0 +1,20 @@
+panels_ipe.block:
+  path: '/admin/panels_ipe/variant/{variant_id}/block/{block_id}'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getBlock'
+  requirements:
+    _permission: 'access panels in-place editing'
+
+panels_ipe.layouts:
+  path: '/admin/panels_ipe/variant/{variant_id}/layouts'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getLayouts'
+  requirements:
+    _permission: 'access panels in-place editing'
+
+panels_ipe.layout:
+  path: '/admin/panels_ipe/variant/{variant_id}/layouts/{layout_id}'
+  defaults:
+      _controller: '\Drupal\panels_ipe\Controller\PanelsIPEPageController::getLayout'
+  requirements:
+    _permission: 'access panels in-place editing'
\ No newline at end of file
diff --git a/panels_ipe/plugins/display_renderers/ipe.inc b/panels_ipe/plugins/display_renderers/ipe.inc
deleted file mode 100644
index ab63f9e..0000000
--- a/panels_ipe/plugins/display_renderers/ipe.inc
+++ /dev/null
@@ -1,5 +0,0 @@
-<?php
-
-$plugin = array(
-  'renderer' => 'panels_renderer_ipe',
-);
diff --git a/panels_ipe/plugins/display_renderers/panels_renderer_ipe.class.php b/panels_ipe/plugins/display_renderers/panels_renderer_ipe.class.php
deleted file mode 100644
index f1b32c5..0000000
--- a/panels_ipe/plugins/display_renderers/panels_renderer_ipe.class.php
+++ /dev/null
@@ -1,466 +0,0 @@
-<?php
-
-/**
- * Renderer class for all In-Place Editor (IPE) behavior.
- */
-class panels_renderer_ipe extends panels_renderer_editor {
-  // The IPE operates in normal render mode, not admin mode.
-  var $admin = FALSE;
-
-  function render() {
-    $output = parent::render();
-    return "<div id='panels-ipe-display-{$this->clean_key}' class='panels-ipe-display-container'>$output</div>";
-  }
-
-  function add_meta() {
-    ctools_include('display-edit', 'panels');
-    ctools_include('content');
-
-    if (empty($this->display->cache_key)) {
-      $this->cache = panels_edit_cache_get_default($this->display);
-    }
-    // @todo we may need an else to load the cache, but I am not sure we
-    // actually need to load it if we already have our cache key, and doing
-    // so is a waste of resources.
-
-    ctools_include('cleanstring');
-    $this->clean_key = ctools_cleanstring($this->display->cache_key);
-    $button = array(
-      '#type' => 'link',
-      '#title' => t('Customize this page'),
-      '#href' => $this->get_url('save_form'),
-      '#id' => 'panels-ipe-customize-page',
-      '#attributes' => array(
-        'class' => array('panels-ipe-startedit', 'panels-ipe-pseudobutton'),
-      ),
-      '#ajax' => array(
-        'progress' => 'throbber',
-        'ipe_cache_key' => $this->clean_key,
-      ),
-      '#prefix' => '<div class="panels-ipe-pseudobutton-container">',
-      '#suffix' => '</div>',
-    );
-
-    panels_ipe_toolbar_add_button($this->clean_key, 'panels-ipe-startedit', $button);
-
-    // @todo this actually should be an IPE setting instead.
-    if (user_access('change layouts in place editing')) {
-      $button = array(
-        '#type' => 'link',
-        '#title' => t('Change layout'),
-        '#href' => $this->get_url('change_layout'),
-        '#attributes' => array(
-          'class' => array('panels-ipe-change-layout', 'panels-ipe-pseudobutton', 'ctools-modal-layout'),
-        ),
-        '#ajax' => array(
-          'progress' => 'throbber',
-          'ipe_cache_key' => $this->clean_key,
-        ),
-
-      '#prefix' => '<div class="panels-ipe-pseudobutton-container">',
-      '#suffix' => '</div>',
-      );
-
-      panels_ipe_toolbar_add_button($this->clean_key, 'panels-ipe-change-layout', $button);
-    }
-
-    ctools_include('ajax');
-    ctools_include('modal');
-    ctools_modal_add_js();
-
-    ctools_add_css('panels_dnd', 'panels');
-    ctools_add_css('panels_admin', 'panels');
-    ctools_add_js('panels_ipe', 'panels_ipe');
-    ctools_add_css('panels_ipe', 'panels_ipe');
-
-    drupal_add_js(array('PanelsIPECacheKeys' => array($this->clean_key)), 'setting');
-
-    drupal_add_library('system', 'ui.draggable');
-    drupal_add_library('system', 'ui.droppable');
-    drupal_add_library('system', 'ui.sortable');
-
-    parent::add_meta();
-  }
-
-  /**
-   * Override & call the parent, then pass output through to the dnd wrapper
-   * theme function.
-   *
-   * @param $pane
-   */
-  function render_pane(&$pane) {
-    $output = parent::render_pane($pane);
-    if (empty($output)) {
-      return;
-    }
-
-    // If there are region locks, add them.
-    if (!empty($pane->locks['type']) && $pane->locks['type'] == 'regions') {
-      static $key = NULL;
-      $javascript = &drupal_static('drupal_add_js', array());
-
-      // drupal_add_js breaks as we add these, but we can't just lump them
-      // together because panes can be rendered independently. So game the system:
-      if (empty($key)) {
-        $settings['Panels']['RegionLock'][$pane->pid] = $pane->locks['regions'];
-        drupal_add_js($settings, 'setting');
-
-        // These are just added via [] so we have to grab the last one
-        // and reference it.
-        $keys = array_keys($javascript['settings']['data']);
-        $key = end($keys);
-      }
-      else {
-        $javascript['settings']['data'][$key]['Panels']['RegionLock'][$pane->pid] = $pane->locks['regions'];
-      }
-
-    }
-
-    if (empty($pane->IPE_empty)) {
-      // Add an inner layer wrapper to the pane content before placing it into
-      // draggable portlet
-      $output = "<div class=\"panels-ipe-portlet-content\">$output</div>";
-    }
-    else {
-      $output = "<div class=\"panels-ipe-portlet-content panels-ipe-empty-pane\">$output</div>";
-    }
-    // Hand it off to the plugin/theme for placing draggers/buttons
-    $output = theme('panels_ipe_pane_wrapper', array('output' => $output, 'pane' => $pane, 'display' => $this->display, 'renderer' => $this));
-
-    if (!empty($pane->locks['type']) && $pane->locks['type'] == 'immovable') {
-      return "<div id=\"panels-ipe-paneid-{$pane->pid}\" class=\"panels-ipe-nodrag panels-ipe-portlet-wrapper panels-ipe-portlet-marker\">" . $output . "</div>";
-    }
-
-    return "<div id=\"panels-ipe-paneid-{$pane->pid}\" class=\"panels-ipe-portlet-wrapper panels-ipe-portlet-marker\">" . $output . "</div>";
-  }
-
-  function prepare_panes($panes) {
-    // Set to admin mode just for this to ensure all panes are represented.
-    $this->admin = TRUE;
-    $panes = parent::prepare_panes($panes);
-    $this->admin = FALSE;
-  }
-
-  function render_pane_content(&$pane) {
-    if (!empty($pane->shown) && panels_pane_access($pane, $this->display)) {
-      $content = parent::render_pane_content($pane);
-    }
-    // Ensure that empty panes have some content.
-    if (empty($content) || empty($content->content)) {
-      if (empty($content)) {
-        $content = new stdClass();
-      }
-
-      // Get the administrative title.
-      $content_type = ctools_get_content_type($pane->type);
-      $title = ctools_content_admin_title($content_type, $pane->subtype, $pane->configuration, $this->display->context);
-
-      $content->content = t('Placeholder for empty or inaccessible "@title"', array('@title' => html_entity_decode($title, ENT_QUOTES)));
-      // Add these to prevent notices.
-      $content->type = 'panels_ipe';
-      $content->subtype = 'panels_ipe';
-      $pane->IPE_empty = TRUE;
-    }
-
-    return $content;
-  }
-
-  /**
-   * Add an 'empty' pane placeholder above all the normal panes.
-   *
-   * @param $region_id
-   * @param $panes
-   */
-  function render_region($region_id, $panes) {
-    // Generate this region's 'empty' placeholder pane from the IPE plugin.
-    $empty_ph = theme('panels_ipe_placeholder_pane', array('region_id' => $region_id, 'region_title' => $this->plugins['layout']['regions'][$region_id]));
-
-    // Wrap the placeholder in some guaranteed markup.
-    $control = '<div class="panels-ipe-placeholder panels-ipe-on panels-ipe-portlet-marker panels-ipe-portlet-static">' . $empty_ph . theme('panels_ipe_add_pane_button', array('region_id' => $region_id, 'display' => $this->display, 'renderer' => $this)) . "</div>";
-
-    $output = parent::render_region($region_id, $panes);
-    $output = theme('panels_ipe_region_wrapper', array('output' => $output, 'region_id' => $region_id, 'display' => $this->display, 'controls' => $control, 'renderer' => $this));
-    $classes = 'panels-ipe-region';
-
-    return "<div id='panels-ipe-regionid-$region_id' class='panels-ipe-region'>$output</div>";
-  }
-
-  /**
-   * This is a generic lock test.
-   */
-  function ipe_test_lock($url, $break) {
-    if (!empty($this->cache->locked)) {
-      if ($break != 'break') {
-        $account  = user_load($this->cache->locked->uid);
-        $name     = format_username($account);
-        $lock_age = format_interval(time() - $this->cache->locked->updated);
-
-        $message = t("This panel is being edited by user !user, and is therefore locked from editing by others. This lock is !age old.\n\nClick OK to break this lock and discard any changes made by !user.", array('!user' => $name, '!age' => $lock_age));
-
-        $this->commands[] = array(
-          'command' => 'unlockIPE',
-          'message' => $message,
-          'break_path' => url($this->get_url($url, 'break')),
-          'key' => $this->clean_key,
-        );
-        return TRUE;
-      }
-
-      // Break the lock.
-      panels_edit_cache_break_lock($this->cache);
-    }
-  }
-
-  /**
-   * AJAX callback to unlock the IPE.
-   *
-   * This is called whenever something server side determines that editing
-   * has stopped and cleans up no longer needed locks.
-   *
-   * It has no visible return value as this is considered a background task
-   * and the client side has already given all indications that things are
-   * now in a 'normal' state.
-   */
-  function ajax_unlock_ipe() {
-    panels_edit_cache_clear($this->cache);
-    $this->commands[] = array();
-  }
-
-  /**
-   * AJAX entry point to create the controller form for an IPE.
-   */
-  function ajax_save_form($break = NULL) {
-    if ($this->ipe_test_lock('save-form', $break)) {
-      return;
-    }
-
-    // Reset the $_POST['ajax_html_ids'] values to preserve
-    // proper IDs on form elements when they are rebuilt
-    // by the Panels IPE without refreshing the page
-    $_POST['ajax_html_ids'] = array();
-
-    $form_state = array(
-      'renderer' => $this,
-      'display' => &$this->display,
-      'content_types' => $this->cache->content_types,
-      'rerender' => FALSE,
-      'no_redirect' => TRUE,
-      // Panels needs this to make sure that the layout gets callbacks
-      'layout' => $this->plugins['layout'],
-    );
-
-    $output = drupal_build_form('panels_ipe_edit_control_form', $form_state);
-    if (empty($form_state['executed'])) {
-      // At this point, we want to save the cache to ensure that we have a lock.
-      $this->cache->ipe_locked = TRUE;
-      panels_edit_cache_set($this->cache);
-      $this->commands[] = array(
-        'command' => 'initIPE',
-        'key' => $this->clean_key,
-        'data' => drupal_render($output),
-        'lockPath' => url($this->get_url('unlock_ipe')),
-      );
-      return;
-    }
-
-    // Check to see if we have a lock that was broken. If so we need to
-    // inform the user and abort.
-    if (empty($this->cache->ipe_locked)) {
-      $this->commands[] = ajax_command_alert(t('A lock you had has been externally broken, and all your changes have been reverted.'));
-      $this->commands[] = array(
-        'command' => 'cancelIPE',
-        'key' => $this->clean_key,
-      );
-      return;
-    }
-
-    // Otherwise it was submitted.
-    if (!empty($form_state['clicked_button']['#save-display'])) {
-      // Saved. Save the cache.
-      panels_edit_cache_save($this->cache);
-      // A rerender should fix IDs on added panes as well as ensure style changes are
-      // rendered.
-      $this->meta_location = 'inline';
-      $this->commands[] = ajax_command_replace("#panels-ipe-display-{$this->clean_key}", panels_render_display($this->display, $this));
-    }
-    else {
-      // Cancelled. Clear the cache.
-      panels_edit_cache_clear($this->cache);
-    }
-
-    $this->commands[] = array(
-      'command' => 'endIPE',
-      'key' => $this->clean_key,
-    );
-  }
-
-  /**
-   * AJAX entry point to create the controller form for an IPE.
-   */
-  function ajax_change_layout($break = NULL) {
-    if ($this->ipe_test_lock('change_layout', $break)) {
-      return;
-    }
-
-    // At this point, we want to save the cache to ensure that we have a lock.
-    $this->cache->ipe_locked = TRUE;
-    panels_edit_cache_set($this->cache);
-
-    ctools_include('plugins', 'panels');
-    ctools_include('common', 'panels');
-
-    // @todo figure out a solution for this, it's critical
-    if (isset($this->display->allowed_layouts)) {
-      $layouts = $this->display->allowed_layouts;
-    }
-    else {
-      $layouts = panels_common_get_allowed_layouts('panels_page');
-    }
-
-    // Filter out builders
-    $layouts = array_filter($layouts, '_panels_builder_filter');
-
-    // Define the current layout
-    $current_layout = $this->plugins['layout']['name'];
-
-    $output = panels_common_print_layout_links($layouts, $this->get_url('set_layout'), array('attributes' => array('class' => array('use-ajax'))), $current_layout);
-
-    $this->commands[] = ctools_modal_command_display(t('Change layout'), $output);
-    $this->commands[] = array(
-      'command' => 'IPEsetLockState',
-      'key' => $this->clean_key,
-      'lockPath' => url($this->get_url('unlock_ipe')),
-    );
-  }
-
-  function ajax_set_layout($layout) {
-    ctools_include('context');
-    ctools_include('display-layout', 'panels');
-    $form_state = array(
-      'layout' => $layout,
-      'display' => $this->display,
-      'finish' => t('Save'),
-      'no_redirect' => TRUE,
-    );
-
-    // Reset the $_POST['ajax_html_ids'] values to preserve
-    // proper IDs on form elements when they are rebuilt
-    // by the Panels IPE without refreshing the page
-    $_POST['ajax_html_ids'] = array();
-
-    $output = drupal_build_form('panels_change_layout', $form_state);
-    $output = drupal_render($output);
-    if (!empty($form_state['executed'])) {
-      if (isset($form_state['back'])) {
-        return $this->ajax_change_layout();
-      }
-
-      if (!empty($form_state['clicked_button']['#save-display'])) {
-        // Saved. Save the cache.
-        panels_edit_cache_save($this->cache);
-        $this->display->skip_cache = TRUE;
-
-        // Since the layout changed, we have to update these things in the
-        // renderer in order to get the right settings.
-        $layout = panels_get_layout($this->display->layout);
-        $this->plugins['layout'] = $layout;
-        if (!isset($layout['regions'])) {
-          $this->plugins['layout']['regions'] = panels_get_regions($layout, $this->display);
-        }
-
-        $this->meta_location = 'inline';
-
-        $this->commands[] = ajax_command_replace("#panels-ipe-display-{$this->clean_key}", panels_render_display($this->display, $this));
-        $this->commands[] = ctools_modal_command_dismiss();
-        return;
-      }
-    }
-
-    $this->commands[] = ctools_modal_command_display(t('Change layout'), $output);
-  }
-
-  /**
-   * Create a command array to redraw a pane.
-   */
-  function command_update_pane($pid) {
-    if (is_object($pid)) {
-      $pane = $pid;
-    }
-    else {
-      $pane = $this->display->content[$pid];
-    }
-
-    $this->commands[] = ajax_command_replace("#panels-ipe-paneid-$pane->pid", $this->render_pane($pane));
-    $this->commands[] = ajax_command_changed("#panels-ipe-display-{$this->clean_key}");
-  }
-
-  /**
-   * Create a command array to add a new pane.
-   */
-  function command_add_pane($pid) {
-    if (is_object($pid)) {
-      $pane = $pid;
-    }
-    else {
-      $pane = $this->display->content[$pid];
-    }
-
-    $this->commands[] = array(
-      'command' => 'insertNewPane',
-      'regionId' => $pane->panel,
-      'renderedPane' => $this->render_pane($pane),
-    );
-    $this->commands[] = ajax_command_changed("#panels-ipe-display-{$this->clean_key}");
-    $this->commands[] = array(
-      'command' => 'addNewPane',
-      'key' => $this->clean_key,
-    );
-  }
-}
-
-/**
- * FAPI callback to create the Save/Cancel form for the IPE.
- */
-function panels_ipe_edit_control_form($form, &$form_state) {
-  $display = &$form_state['display'];
-  // @todo -- this should be unnecessary as we ensure cache_key is set in add_meta()
-//  $display->cache_key = isset($display->cache_key) ? $display->cache_key : $display->did;
-
-  // Annoyingly, theme doesn't have access to form_state so we have to do this.
-  $form['#display'] = $display;
-
-  $layout = panels_get_layout($display->layout);
-  $layout_panels = panels_get_regions($layout, $display);
-
-  $form['panel'] = array('#tree' => TRUE);
-  $form['panel']['pane'] = array('#tree' => TRUE);
-
-  foreach ($layout_panels as $panel_id => $title) {
-    // Make sure we at least have an empty array for all possible locations.
-    if (!isset($display->panels[$panel_id])) {
-      $display->panels[$panel_id] = array();
-    }
-
-    $form['panel']['pane'][$panel_id] = array(
-      // Use 'hidden' instead of 'value' so the js can access it.
-      '#type' => 'hidden',
-      '#default_value' => implode(',', (array) $display->panels[$panel_id]),
-    );
-  }
-
-  $form['buttons']['submit'] = array(
-    '#type' => 'submit',
-    '#value' => t('Save'),
-    '#id' => 'panels-ipe-save',
-    '#attributes' => array('class' => array('panels-ipe-save')),
-    '#submit' => array('panels_edit_display_form_submit'),
-    '#save-display' => TRUE,
-  );
-  $form['buttons']['cancel'] = array(
-    '#type' => 'submit',
-    '#id' => 'panels-ipe-cancel',
-    '#attributes' => array('class' => array('panels-ipe-cancel')),
-    '#value' => t('Cancel'),
-  );
-  return $form;
-}
diff --git a/panels_ipe/src/Controller/PanelsIPEPageController.php b/panels_ipe/src/Controller/PanelsIPEPageController.php
new file mode 100644
index 0000000..047fd34
--- /dev/null
+++ b/panels_ipe/src/Controller/PanelsIPEPageController.php
@@ -0,0 +1,222 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\panels_ipe\Controller\PanelsIPEPageController.
+ */
+
+namespace Drupal\panels_ipe\Controller;
+
+use Drupal\Core\Block\BlockManagerInterface;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Render\Element;
+use Drupal\layout_plugin\Layout;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\page_manager\Entity\PageVariant;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Zend\Diactoros\Response\JsonResponse;
+
+/**
+ * Contains all JSON endpoints required for Panels IPE + Page Manager.
+ */
+class PanelsIPEPageController extends ControllerBase {
+
+  /**
+   * @var \Drupal\Core\Block\BlockManagerInterface
+   */
+  protected $blockManager;
+
+  /**
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Constructs a new PanelsIPEController.
+   *
+   * @param \Drupal\Core\Block\BlockManagerInterface $block_manager
+   *   The block manager.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   */
+  public function __construct(BlockManagerInterface $block_manager, RendererInterface $renderer) {
+    $this->blockManager = $block_manager;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.block'),
+      $container->get('renderer')
+    );
+  }
+
+  /**
+   * Gets a block's metadata and rendered HTML.
+   *
+   * @param string $variant_id
+   *   The machine name of the current display variant.
+   * @param string $block_id
+   *   The UUID of the requested block.
+   *
+   * @return JsonResponse
+   *
+   * @throws AccessDeniedHttpException|NotFoundHttpException
+   */
+  public function getBlock($variant_id, $block_id) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    /** @var \Drupal\panels\Plugin\DisplayVariant\PanelsDisplayVariant $variant_plugin */
+    $variant_plugin = $variant->getVariantPlugin();
+
+    // Check if the block exists.
+    if (!$block = $variant_plugin->getBlock($block_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    // Check entity access before continuing.
+    $user = $this->currentUser();
+    if (!$block->access($user)) {
+      throw new AccessDeniedHttpException();
+    }
+
+    // Assemble a render array for the block.
+    // @todo Support contexts and revisions for all entities.
+    $configuration = $block->getConfiguration();
+    $elements = [
+      '#theme' => 'block',
+      '#attributes' => [
+        'data-block-id' => $block->getConfiguration()['uuid']
+      ],
+      '#configuration' => $configuration,
+      '#plugin_id' => $block->getPluginId(),
+      '#base_plugin_id' => $block->getBaseId(),
+      '#derivative_plugin_id' => $block->getDerivativeId(),
+      'content' => $block->build()
+    ];
+
+    // Return a structured JSON response for our Backbone App.
+    $data = [
+      'html' => $this->renderer->render($elements),
+      'uuid' => $configuration['uuid'],
+      'label' => $block->label(),
+      'id' => $block->getPluginId()
+    ];
+
+    return new JsonResponse($data);
+  }
+
+  /**
+   * Gets a list of available Layouts, without wrapping HTML.
+   *
+   * @param string $variant_id
+   *   The machine name of the current display variant.
+   *
+   * @return JsonResponse
+   *
+   * @throws AccessDeniedHttpException|NotFoundHttpException
+   */
+  public function getLayouts($variant_id) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    /** @var \Drupal\panels\Plugin\DisplayVariant\PanelsDisplayVariant $variant_plugin */
+    $variant_plugin = $variant->getVariantPlugin();
+
+    // Get the current layout.
+    $layout = $variant_plugin->getConfiguration()['layout'];
+
+    // Get a list of all available layouts.
+    $layouts = Layout::getLayoutOptions();
+    $data = [];
+    foreach ($layouts as $id => $label) {
+      $data[] = [
+        'id' => $id,
+        'label' => $label,
+        'current' => $layout == $id
+      ];
+    }
+
+    // Return a structured JSON response for our Backbone App.
+    return new JsonResponse($data);
+  }
+
+  /**
+   * Gets a given layout with empty regions and relevant metadata.
+   *
+   * @param string $variant_id
+   *   The machine name of the current display variant.
+   * @param string $layout_id
+   *   The machine name of the requested layout.
+   *
+   * @return JsonResponse
+   *
+   * @throws AccessDeniedHttpException|NotFoundHttpException
+   */
+  public function getLayout($variant_id, $layout_id) {
+    // Check if the variant exists.
+    /** @var \Drupal\page_manager\PageVariantInterface $variant */
+    if (!$variant = PageVariant::load($variant_id)) {
+      throw new NotFoundHttpException();
+    }
+
+    /** @var \Drupal\panels\Plugin\DisplayVariant\PanelsDisplayVariant $variant_plugin */
+    $variant_plugin = $variant->getVariantPlugin();
+
+    // Build the requested layout.
+    $configuration = $variant_plugin->getConfiguration();
+    $configuration['layout'] = $layout_id;
+    $variant_plugin->setConfiguration($configuration);
+    $build = $variant_plugin->build();
+
+    // Remove all blocks from the build.
+    $regions = $variant_plugin->getRegionNames();
+    $region_data = [];
+    foreach ($regions as $id => $label) {
+      // Get all block/random keys.
+      $children = Element::getVisibleChildren($build[$id]);
+      // Unset those keys, retaining the theme variables for the region.
+      $build[$id] = array_diff_key($build[$id], array_flip($children));
+
+      // Format region metadata.
+      $region_data[] = [
+        'name' => $id,
+        'label' => $label
+      ];
+    }
+
+    // Remove the wrapping elements, which our builder adds to every build.
+    unset($build['#suffix']);
+    unset($build['#prefix']);
+
+    // Get the current layout.
+    $current_layout = $variant_plugin->getConfiguration()['layout'];
+
+    // Get a list of all available layouts.
+    $layouts = Layout::getLayoutOptions();
+
+    $data = [
+      'id' => $layout_id,
+      'label' => $layouts[$layout_id],
+      'current' => $current_layout == $layout_id,
+      'html' => $this->renderer->render($build),
+      'regions' => $region_data
+    ];
+
+    // Return a structured JSON response for our Backbone App.
+    return new JsonResponse($data);
+  }
+
+}
diff --git a/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php b/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php
new file mode 100644
index 0000000..e4c09aa
--- /dev/null
+++ b/panels_ipe/src/Plugin/DisplayBuilder/InPlaceEditorDisplayBuilder.php
@@ -0,0 +1,118 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\panels_ipe\Plugin\DisplayBuilder\InPlaceEditorDisplayBuilder.
+ */
+
+namespace Drupal\panels_ipe\Plugin\DisplayBuilder;
+
+use Drupal\Component\Utility\Html;
+use Drupal\layout_plugin\Plugin\Layout\LayoutInterface;
+use Drupal\panels\Plugin\DisplayBuilder\StandardDisplayBuilder;
+
+/**
+ * The In-place editor display builder for viewing and editing a 
+ * PanelsDisplayVariant in the same place.
+ *
+ * @DisplayBuilder(
+ *   id = "in_place_editor",
+ *   label = @Translation("In-place editor")
+ * )
+ */
+class InPlaceEditorDisplayBuilder extends StandardDisplayBuilder {
+
+  /**
+   * Compiles settings needed for the IPE to function.
+   *
+   * @param array $regions
+   *   The render array representing regions.
+   *
+   * @return array
+   *   An associative array representing the contents of drupalSettings.
+   */
+  protected function getDrupalSettings(array $regions, array $contexts, LayoutInterface $layout = NULL) {
+    $settings = [
+      'regions' => [],
+    ];
+
+    // Add current block IDs to settings sorted by region.
+    foreach ($regions as $region => $blocks) {
+      $settings['regions'][$region]  = [
+        'name' => $region,
+        'label' => '',
+        'blocks' => []
+      ];
+
+      if (!$blocks) {
+        continue;
+      }
+
+      /** @var \Drupal\Core\Block\BlockPluginInterface[] $blocks */
+      foreach ($blocks as $block_uuid => $block) {
+        $settings['regions'][$region]['blocks'][$block_uuid] = [
+          'uuid' => $block_uuid,
+          'label' => $block->label(),
+          'id' => $block->getPluginId()
+        ];
+      }
+    }
+
+    // Add the layout information.
+    if ($layout) {
+      $layout_definition = $layout->getPluginDefinition();
+      $settings['layout'] = [
+        'id' => $layout->getPluginId(),
+        'label' => $layout_definition['label']
+      ];
+    }
+
+    // Explicitly support Page Manger, as we need to have a reference for where
+    // to save the display.
+    $variant = \Drupal::request()->attributes->get('page_manager_page_variant');
+    if ($variant) {
+      // Add the display variant's config.
+      $settings['display_variant'] = [
+        'label' => $variant->label(),
+        'id' => $variant->id(),
+        'uuid' => $variant->uuid(),
+      ];
+    }
+
+    return ['panels_ipe' => $settings];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build(array $regions, array $contexts, LayoutInterface $layout = NULL) {
+    $build = parent::build($regions, $contexts, $layout);
+    // Attach the Panels In-place editor library based on permissions.
+    if ($this->account->hasPermission('access panels in-place editing')) {
+      foreach ($regions as $region => $blocks) {
+        // Wrap each region with a unique class and data attribute.
+        $region_name = Html::getClass("block-region-$region");
+        $build[$region]['#prefix'] = '<div class="' . $region_name . '" data-region-name="' . $region . '">';
+        $build[$region]['#suffix'] = '</div>';
+
+        if ($blocks) {
+          foreach ($blocks as $block_id => $block) {
+            $build[$region][$block_id]['#attributes']['data-block-id'] = $block_id;
+          }
+        }
+      }
+
+      // Attach the required settings and IPE.
+      $build['#attached'] = [
+        'library' => ['panels_ipe/panels_ipe'],
+        'drupalSettings' => $this->getDrupalSettings($regions, $contexts, $layout)
+      ];
+
+      // Add our custom elements to the build.
+      $build['#prefix'] = '<div id="panels-ipe-content">';
+      $build['#suffix'] = '</div><div id="panels-ipe-tray"></div>';
+    }
+    return $build;
+  }
+
+}
